Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds WithPlatform constructor option for initialization/manifest list querying values #91

Merged
merged 11 commits into from
Mar 12, 2021

Conversation

micahyoung
Copy link
Member

@micahyoung micahyoung commented Feb 26, 2021

The changes in this PR are to introduce a WithPlatform() option for NewImage() to make it easier to override the default Architecture/OS/OSVersion with alternate values in new images and querying manifest lists with FromBaseImage or WithPreviousImage. Imgutil NewImage() currently creates new images with a default of linux/amd64 for remote and daemon OS/amd64 for local, and when using remote.FromBaseImage() or remote.WithPreviousImage(), it queries manifest lists preferring linux/amd64 as inherited from go-containerregistry.

The new implementation attempts to be additive and optional, preserving existing behavior (with an exception on Windows¹) but enabling some new scenarios:

  • Initialize a new empty image with Architecture/OS/OSVersion:
    remote

    img, err := remote.NewImage(
        "gcr.io/my-new-linux-arm-image:latest",
        authn.DefaultKeychain,
        remote.WithPlatform(imgutil.Platform{
    	    Architecture: "arm",
    	    OS:           "linux",
        }),
    )
    ...
    img.Architecture() // => "arm"
    img.OS()           // => "linux"

    local with a windows/amd64 daemon

    img, err := local.NewImage(
        "gcr.io/my-new-windows-arm-image:latest",
        dockerClient,
        local.WithPlatform(imgutil.Platform{
    	    Architecture: "arm",
    	    OS:           "windows",
    	    OSVersion:    "10.0.999",
        }),
    )
    ...
    img.Architecture() // => "arm"
    img.OS()           // => "windows"
    img.OSVersion()    // => "10.0.999"
  • Initialize a new remote image from a base image in multi-arch/os manifest list:

    img, err := remote.NewImage(
        "gcr.io/my-new-linux-arm-image:latest",
        authn.DefaultKeychain,
        remote.FromBaseImage("golang:1.15"), // golang manifest list (contains matching linux/arm image)
        remote.WithPlatform(imgutil.Platform{
    	    Architecture: "arm",
    	    OS:           "linux",
        }),
    )
    ...
    img.Architecture() // => "arm"
    img.OS()           // => "linux"
  • Initialize a new empty image when manifest list doesn't contain image manifest for requested platform:

    img, err := remote.NewImage(
        "gcr.io/my-new-windows-arm-image:latest",
        authn.DefaultKeychain,
        remote.FromBaseImage("golang:1.15"),  // golang manifest list (has no windows/arm image)
        remote.WithPlatform(imgutil.Platform{
    	    Architecture: "arm",
    	    OS:           "windows",
        }),
    )
    ...
    img.Architecture() => "arm"
    img.OS() => "windows"

There is additional behavior that is intended to stay as close as possible to existing behavior for NewImage() and FromBaseImage(), but may nevertheless be surprising:

  • A single image manifest result for FromBaseImage() is always used, even when it doesn't match the WithPlatform() values

    img, err := remote.NewImage(
        "gcr.io/my-supposedly-windows-arm-image:latest",
        authn.DefaultKeychain,
        remote.FromBaseImage("amd64/golang:1.15"),  // linux/amd64 image manifest
        remote.WithPlatform(imgutil.Platform{
    	    Architecture: "arm",
    	    OS:           "windows",
        }),
    )
    ...
    img.Architecture() => "amd64"
    img.OS() => "linux"
    // with a windows/amd64 daemon
    img, err := local.NewImage(
        "gcr.io/my-supposedly-windows-arm-image:latest",
        authn.DefaultKeychain,
        remote.FromBaseImage("winamd64/golang:1.15"),  // locally pulled windows/amd64 image
        remote.WithPlatform(imgutil.Platform{
    	    Architecture: "arm",
    	    OS:           "windows",
        }),
    )
    ...
    img.Architecture() => "amd64"
    img.OS() => "windows"

    The reasoning for this behavior is that WithPlatform() should primarily be used to assign values to a new image when nothing is inherited - when either no FromBaseImage() is given or when the FromBaseImage() image is not found. It's secondary purpose is for remote's special edge-case when FromBaseImage() needs a manifest list tie-breaker. It shouldn't be treated as a constraint or validator. This approach also follows ggcr/remote.Image(), which currently only uses it's own ggcr/remote.WithPlatform() option when the ref is a manifest list, and disregards it for single image manifests even when the platform and image conflict.

    In addition to deviating from ggcr, the potential alternative approaches I looked into all had downsides:

    • If it returned the base image with the platform values clobbered, it would be difficult for a consumer to figure out what happened and message a user.
    • If it were to ignore the base image result and return an empty image based on the platform values, it would also be difficult to for consumers to deduce what happened since the image ref was otherwise valid.
    • If it were to return an error, we'd need to add custom filtering and error conditions. Not returning an image could potentially prevent consumers from implementing their own filtering/validation after NewImage is called. For consumers currently using NewImage() and accepting all image platforms, new errors could make backward compatibility challenging, if they only want WithPlatform for manifest list tie-breaking and new image defaults.

    With the PR approach, consumers can hopefully compare the returned image with their desired platform, and throw an appropriate error if needed. I'm sure there are other considerations though so I'm open for feedback on this.

  • ¹ New empty Windows images are always initialized with minimal Windows base layers (Adds support for creating Windows scratch-equivalent baselayers #64)

    // with a windows/amd64 daemon
    img, err := local.NewImage(
        "gcr.io/my-new-windows-arm-image:latest",
        dockerClient,
        local.WithPlatform(imgutil.Platform{
    	    Architecture: "arm",
    	    OS:           "windows",
    	    OSVersion:    "10.0.999",
        }),
    )
    ...
    img.Architecture() // => "arm"
    img.OS()           // => "windows"
    img.OSVersion()    // => "10.0.999"
    len(image.Layers()) // => 1

    A Windows image cannot be pulled or stored on a daemon without a valid base layer - either from a Microsoft-authored base image or one from layer.WindowsBaseLayer(). To simplify new image creation, NewImage() now adds one based on layer.WindowsBaseLayer(), when FromBaseImage() is not used or returns an empty image. This will allow Linux and Windows to behave much more consistently and hopefully hides away this implementation detail for most consumers. It could also allow us to deprecate the shim base layer if it becomes unnecessary.

    It is a breaking change for all Windows consumers that already externally add their own base layer with layer.WindowsBaseLayer() and AddLayer() since that will effectively add it twice. This should be acceptable since it's only known to be used in pack which has it behind an experimental flag. Additionally, this new functionality should likely have been included in the original implementation but was blocked on the missing WithPlatform support. It is included in the PR since it's the driver for this WithPlatform implementation (Fixes cache image with incorrect OS on windows lifecycle#532), though can be separated out if needed.

@micahyoung micahyoung changed the title Adds WithPlatform constructor option for better initialization/manifest list querying options Adds WithPlatform constructor option for initialization/manifest list querying values Feb 26, 2021
@micahyoung micahyoung marked this pull request as ready for review February 26, 2021 18:35
@micahyoung micahyoung requested a review from a team as a code owner February 26, 2021 18:35
Comment on lines 1406 to 1407
prevLayer1SHA = inspect.RootFS.Layers[len(inspect.RootFS.Layers)-2]
prevLayer2SHA = inspect.RootFS.Layers[len(inspect.RootFS.Layers)-1]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this to avoid having the assertion switch on the daemon OS?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, and I realize it's repeated in a couple other places - I'll toss in a helper to clarify it.

The reason it needed to be changed here is that NewImage() can no longer create Windows images with no layers at all (which technically could be saved to a daemon, but have no value that I could see).

If layerless Windows images are important, I can certainly take a shot at getting them back

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a new helper that works as follows:

from:

prevLayer1SHA = inspect.RootFS.Layers[len(inspect.RootFS.Layers)-2]

to:

// negative index (offset from len(inspect.RootFS.Layers))
prevLayer1SHA = h.StringElementAt(inspect.RootFS.Layers), -2)

// also normal index
prevLayer1SHA = h.StringElementAt(inspect.RootFS.Layers), 1)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason it needed to be changed here is that NewImage() can no longer create Windows images with no layers at all (which technically could be saved to a daemon, but have no value that I could see).

This statement confuses me. I thought we were always adding a base layer on windows images because it is impossible to pull a windows image without one. Can you successfully docker load an empty image, even though you can't pull one?

local/local.go Outdated Show resolved Hide resolved
@natalieparellano
Copy link
Member

@micahyoung I think the flow you proposed makes sense. I know @ekcasey will want to review this one, but this looks good to me!

local/local.go Outdated Show resolved Hide resolved
}
when("#WithPlatform", func() {
when("base image and platform architecture/OS do not match", func() {
it("uses the base image architecture/OS, ignoring platform", func() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we just fail in this case? Given that we can't sensibly reconcile the two options?

Copy link
Member Author

@micahyoung micahyoung Mar 1, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this gets tricky because we have WithPlatform doing double-duty for remote (1. as a new image value setter, 2. as a tie-breaker for manifest lists). If an imgutil consumer wants to call NewImage in a way that ignores platform for image manifest refs, but has some tie-breaking for manifest list refs, then I think the only way to do this with a single call is to ignore the platform for image manifest results entirely (much like ggcr does). And because we're stuck with that behavior for remote, I think we need to stay consistent in local.

Maybe a different name for WithPlatform would clarify this behavior though, maybe WithPreferredPlatform or WithFallbackPlatform or something. Or we could split out the double-duty of remote.WithPlatform and add a platform param to remote.FromBaseImage and remote.WithPreviousImage and those could deal with manifest list platform disambiguation only.

I'd love to hear other thoughts though.

local/local_test.go Outdated Show resolved Hide resolved
remote/remote.go Outdated Show resolved Hide resolved
local/local.go Show resolved Hide resolved
remote/remote.go Outdated Show resolved Hide resolved
@micahyoung
Copy link
Member Author

micahyoung commented Mar 1, 2021

Thanks for the reviews! Here's my TODO list so far:

  • Add doc comments for each ImageOption
  • Rename to WithDefaultPlatform
  • Remove Variants, Features, OSFeatures from imgutil.Platform
  • Refactor ImageOption to not alter the image directly, restore ImageOption instead of interface{} in constructor
  • Sanity check state after WithPlatform + SetOS are used.
  • Add test helper for assertions on top-2-image layers
  • Add test case for WithPlatform() + WithPreviousImage()

local/local.go Outdated Show resolved Hide resolved
@micahyoung
Copy link
Member Author

After the above changes, plus some manual and newly enabled acceptance lifecycle testing on Windows, I'm feeling like this is in a good state and ready for another look, if you all wouldn't mind.

Copy link
Member

@ekcasey ekcasey left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good! Just a couple suggestions for cleanup.

local/local.go Outdated Show resolved Hide resolved
local/local.go Outdated Show resolved Hide resolved
local/local.go Outdated Show resolved Hide resolved
local/local.go Outdated Show resolved Hide resolved
local/local.go Outdated Show resolved Hide resolved
local/local.go Outdated Show resolved Hide resolved
Micah Young added 11 commits March 8, 2021 15:25
- For Windows images only, adds minimal Windows baselayer on new empty images with no base layers (breaking change)

Signed-off-by: Micah Young <[email protected]>
- Prioritize based on type instead of using struct values

Signed-off-by: Micah Young <[email protected]>
- Don't fill in fields for remote.WithPlatform, should limit ambiguous consumer calls and allow intentionally blank fields

Signed-off-by: Micah Young <[email protected]>
- Don't autofill OS field for local.WithPlatform, should limit ambiguous consumer calls and allow intentionally blank fields

Signed-off-by: Micah Young <[email protected]>
Signed-off-by: Micah Young <[email protected]>
- Guard options at constructor level
- Modify image by reference
- Return nil image for err cases

Signed-off-by: Micah Young <[email protected]>
@micahyoung
Copy link
Member Author

micahyoung commented Mar 8, 2021

I made changes based on the feedback, which did clarify the logic. I feel like I'll want to revisit this at some point after living with it a bit and maybe looking into some different option pattern examples - I feel there must be some tidier ways to move the logic back into the ImageOptions, but still enforce the order of execution that we need.

@ekcasey ekcasey merged commit 65c986a into buildpacks:main Mar 12, 2021
@micahyoung micahyoung deleted the empty-image-constructors branch March 13, 2021 12:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants