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

docker:Image - Add flag to enable build on preview #855

Merged
merged 32 commits into from
Dec 5, 2023

Conversation

guineveresaenger
Copy link
Contributor

@guineveresaenger guineveresaenger commented Nov 21, 2023

This pull request's intent is to allow a user to build their image during pulumi preview by using the new buildOnPreview resource field.

This field is added to the Image schema as a boolean and defaults to false.

The implementation specifics are as follows, with some things for the reviewer to weigh in on.

  1. As a prerequisite, supportPreview is enabled and all implemented RPD methods should handle Unknowns. This should also help address Message sooner about building for the host architecture #847 and Unable to use dynamic build property #620.
  2. ContainsUnknowns() checks are added to the marshaler and some of the Check() logic. I wasn't sure if ContainsUnknowns() or IsComputed()should be used here; the former contains a check for the latter.
  3. Unit tests verify the new marshaling behavior.
  4. When a Dockerfile is Unknown, we do not verify its location during Preview Check(), instead we apply other defaults and carry on. We will calculate the build hash on the update call once Unknowns are computed.
  5. When in preview mode, and buildOnPreview is false, we return all inputs as-is in Update() and Create().
  6. When an attempt is made to build on preview, but there are Unknowns in the inputs or news, we send an error instructing the user to set buildOnPreview to false.
  7. An integration test is added that verifies an image builds on preview if the buildOnPreview flag is set to true.
  8. An integration test is added that verifies an image fails to build on preview if there are Unknown inputs and the buildOnPreview flag is set to true

Fixes #540.

  • Handle unknowns in Build object
  • Handle unknowns in Check; skip dockerfile location finding.
  • Set SupportPreview to true
  • Add ContainsUnknowns() checks for build: target, stages, platform, Dockerfile, Context; and registry: username, password, server
  • Add tests for Unknowns, and tweak Unknown checks as a result of a bit of TDD
  • Add logic to imageBuild that allows for buildOnPreview
  • Use Command.stdout to test unknowns
  • Add a few more Unknown checks in Check()
  • Add an integration test for Build On Preview
  • Build SDKs

Copy link

Does the PR have any schema changes?

Does the PR have any schema changes?

Looking good! No breaking changes found.
No new resources/functions.

Maintainer note: consult the runbook for dealing with any breaking changes.

@guineveresaenger guineveresaenger marked this pull request as draft November 21, 2023 20:42
SkipUpdate: true, //only run Preview
SkipExportImport: true,
Verbose: true, //we need this to verify the build output logs
AllowEmptyPreviewChanges: true,
Copy link
Contributor

Choose a reason for hiding this comment

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

Wouldn't we expect to build an image in the preview?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

So I think this flag is poorly named. All it does is it tells the programtest to not pass the --expect-no-changes flag to pulumi update, as the test fails spuriously otherwise. The real check is a few lines below , in whether the preview output contains messages that point to a built image. This test only runs preview, not update.

examples/test-unknowns/yaml/Dockerfile Show resolved Hide resolved
provider/hybrid.go Show resolved Hide resolved
examples/test-unknowns/ts/index.ts Show resolved Hide resolved
Comment on lines +597 to +608
if !buildObject["context"].ContainsUnknowns() {
if buildObject["context"].IsNull() {
// set default
build.Context = "."
} else {
build.Context = buildObject["context"].StringValue()
}
}

// Dockerfile
if buildObject["dockerfile"].IsNull() {
// set default
build.Dockerfile = path.Join(build.Context, defaultDockerfile)
} else {
build.Dockerfile = buildObject["dockerfile"].StringValue()
if !buildObject["dockerfile"].ContainsUnknowns() {
if buildObject["dockerfile"].IsNull() {
// set default
build.Dockerfile = path.Join(build.Context, defaultDockerfile)
} else {
build.Dockerfile = buildObject["dockerfile"].StringValue()
}
}

Copy link
Contributor

Choose a reason for hiding this comment

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

So if I understand correctly, we'll only run a preview as long as all of our inputs have resolved. That seems reasonable. I think that matches how the bridge provider previously did these previews?

provider/provider.go Outdated Show resolved Hide resolved
@guineveresaenger guineveresaenger marked this pull request as ready for review November 22, 2023 20:27
@guineveresaenger guineveresaenger requested review from blampe and a team November 22, 2023 20:27
@@ -629,7 +635,7 @@ func marshalCachedImages(b resource.PropertyValue) ([]string, error) {
}
c := b.ObjectValue()["cacheFrom"]

if c.IsNull() {
if c.IsNull() || c.IsComputed() {
Copy link
Member

Choose a reason for hiding this comment

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

Unfortunately, resource.PropertyValue.IsComputed() is a footgun.1 Basically, resource.PropertyValue has two different ways to represent a value as "unknown": resource.Computed{ ... } and resource.Output{ Known: false, ...}. There can also be secret computed values (which this will not catch).

Suggested change
if c.IsNull() || c.IsComputed() {
if c.IsNull() || c.ContainsUnknowns() {

I can't think of a place where it would be correct to use c.IsComputed() directly in this provider.

Footnotes

  1. https://github.com/pulumi/pulumi/issues/14620

if !r.IsNull() {
if !r.ObjectValue()["server"].IsNull() {

if !r.ObjectValue()["server"].IsNull() && !r.ObjectValue()["server"].ContainsUnknowns() {
Copy link
Member

Choose a reason for hiding this comment

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

If r isn't an object value or null, this function will panic. I'd be very nervous here vis a vis if r is unknown or secret.

I would recommend changing the type signature of this function to operate on a resource.PropertyMap. I'm commenting on something not in this PR, so feel free to leave that for later.

Comment on lines +691 to +696
if !v.ContainsUnknowns() {
vStr := v.StringValue()
args[key] = &vStr
}
Copy link
Member

Choose a reason for hiding this comment

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

Nit: keeping things left indented

Suggested change
if !v.ContainsUnknowns() {
vStr := v.StringValue()
args[key] = &vStr
}
if v.ContainsUnknowns() {
continue
}
vStr := v.StringValue()
args[key] = &vStr

Comment on lines 394 to 396
resource.NewComputedProperty(
resource.Computed{Element: resource.NewStringProperty("looking-for-my-image")},
),
Copy link
Member

Choose a reason for hiding this comment

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

You can do this, but you don't need to.

Suggested change
resource.NewComputedProperty(
resource.Computed{Element: resource.NewStringProperty("looking-for-my-image")},
),
resource.MakeComputed(resource.NewStringProperty("looking-for-my-image")),

This applies in many test cases.

knownDockerfile = true
} else {
// We do not want to set these fields if their values are Unknown.
if !inputs["build"].ObjectValue()["dockerfile"].ContainsUnknowns() {
Copy link
Member

Choose a reason for hiding this comment

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

It looks like if inputs["build"] is computed, then this will panic. Am I missing a check somewhere?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I did think about this and was having a difficult time coming up with a practical example of a situation where a program would pass in the entire build object as Unknown.
It's not impossible, and definitely worth a safety check, but my thinking was, in most situations unknowns would be passed to the concrete fields, not the overall build object (and I haven't seen any bugs filed that showed this kind of pattern).

Copy link
Contributor

Choose a reason for hiding this comment

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

It's weird but definitely possible! I've done things like this:

build: thing.arn.apply((arn) => {
  return {
    dockerfile: "Dockerfile",
    args: {
      THE_ARN: arn
    }
  }
}) 

Sure you could have done that apply deeper in the object... but anything is possible :)

I added a small test to sanity check everything still works when build is computed and it caught a few panics... whew!

@@ -325,6 +334,18 @@ func (p *dockerNativeProvider) Create(ctx context.Context, req *rpc.CreateReques
return nil, errors.Wrapf(err, "malformed resource inputs")
}

// if we're in preview mode and buildOnPreview is set to false, we return the inputs
if req.GetPreview() && !inputs["buildOnPreview"].BoolValue() {
Copy link
Member

Choose a reason for hiding this comment

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

What if inputs["buildOnPreview"] is unknown?

  1. This code will panic.
  2. What should the provider do?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Again, thinking about this from a user perspective, I was having a difficult time coming up with a situation where anyone would make buildOnPreview unknown, but you're right that the world is a large and diverse place so we should add a check. My feeling is to warn and continue.

Comment on lines 338 to 408
if req.GetPreview() && !inputs["buildOnPreview"].BoolValue() {
return &rpc.CreateResponse{
Properties: req.GetProperties(),
}, nil
}
// buildOnPreview needs all inputs to be resolved. Return error if trying to build on preview and there are Unknowns.
if req.GetPreview() && inputs["buildOnPreview"].BoolValue() && inputs.ContainsUnknowns() {
return nil, errors.New("cannot build on preview with unresolved inputs. " +
"Set buildOnPreview to False, or ensure all inputs are resolved at preview.")
}
Copy link
Member

Choose a reason for hiding this comment

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

Code structure: Can we fold the req.GetPreview() call one level higher:

Suggested change
if req.GetPreview() && !inputs["buildOnPreview"].BoolValue() {
return &rpc.CreateResponse{
Properties: req.GetProperties(),
}, nil
}
// buildOnPreview needs all inputs to be resolved. Return error if trying to build on preview and there are Unknowns.
if req.GetPreview() && inputs["buildOnPreview"].BoolValue() && inputs.ContainsUnknowns() {
return nil, errors.New("cannot build on preview with unresolved inputs. " +
"Set buildOnPreview to False, or ensure all inputs are resolved at preview.")
}
if req.GetPreview() {
if !inputs["buildOnPreview"].BoolValue() {
return &rpc.CreateResponse{
Properties: req.GetProperties(),
}, nil
}
// buildOnPreview needs all inputs to be resolved. Return error if trying to build on preview and there are Unknowns.
if inputs.ContainsUnknowns() {
return nil, errors.New("cannot build on preview with unresolved inputs. " +
"Set buildOnPreview to False, or ensure all inputs are resolved at preview.")
}
}

}
// buildOnPreview needs all inputs to be resolved. Return error if trying to build on preview and there are Unknowns.
if req.GetPreview() && inputs["buildOnPreview"].BoolValue() && inputs.ContainsUnknowns() {
return nil, errors.New("cannot build on preview with unresolved inputs. " +
Copy link
Member

Choose a reason for hiding this comment

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

It feels like this should be a warning instead of an error. If we have agreed that this should error in the past, please ignore this comment.

// buildOnPreview needs all inputs to be resolved. Warn and continue without building the image
// TODO: there is room for some future granularity here - we should be able to build a local image without
// (TODO cont) knowing inputs for a registry, for example.
if inputs["buildOnPreview"].BoolValue() && inputs.ContainsUnknowns() {
Copy link
Member

Choose a reason for hiding this comment

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

We already know here that inputs["buildOnPreview"].BoolValue() is true, since if it wasn't we would have exited the function on L352.

Suggested change
if inputs["buildOnPreview"].BoolValue() && inputs.ContainsUnknowns() {
if inputs.ContainsUnknowns() {

Copy link
Member

@iwahbe iwahbe left a comment

Choose a reason for hiding this comment

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

Barring the comments I have already made, this looks good vis a vis resource.PropertyValue handling. I'll leave it to @blampe to check that the PR does what it is supposed to.

provider/hybrid.go Show resolved Hide resolved
@@ -639,38 +645,38 @@ func marshalCachedImages(b resource.PropertyValue) ([]string, error) {
if !ok {
return cacheImages, fmt.Errorf("cacheFrom requires an `images` field")
}
if images.IsNull() {
if images.IsNull() || images.IsComputed() {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
if images.IsNull() || images.IsComputed() {
if images.IsNull() || images.ContainsUnknowns() {


func marshalBuildOnPreview(inputs resource.PropertyMap) bool {
//set default if not set
if inputs["buildOnPreview"].IsNull() || inputs["buildOnPreview"].IsComputed() {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
if inputs["buildOnPreview"].IsNull() || inputs["buildOnPreview"].IsComputed() {
if inputs["buildOnPreview"].IsNull() || inputs["buildOnPreview"].ContainsUnknowns() {

Copy link
Contributor

@blampe blampe 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, only some minor nits and one high-level question that's not blocking.

Comment on lines +3175 to +3179
"buildOnPreview": {
"type": "boolean",
"description": "A flag to build an image on preview",
"default": false
},
Copy link
Contributor

Choose a reason for hiding this comment

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

I realize it's late in the game to be asking this, but I'm curious why this is a resource property and not some configuration on the provider.

With 3.x, it would build all images on preview. So, strictly speaking, for anyone that wants that behavior back it seems like it would be OK to set at the provider level.

I'm not saying that because I think we need to do it, only to make sure we've considered it. (If anything, doing it this way might make the buildx.Image resource slightly easier because we could defer or punt on this behavior altogether.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

What if someone wants to build some images on preview, but not others? I could easily imagine only wanting to check this conditionally. I opted for flexibility here, rather than "all or nothing", but I admit I did not consider inheriting this for other inflections of Image.

Comment on lines 653 to 658
if !images.ContainsUnknowns() {
return cacheImages, fmt.Errorf("the `images` field must be a list of strings")
} else {
return cacheImages, nil
}

Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
if !images.ContainsUnknowns() {
return cacheImages, fmt.Errorf("the `images` field must be a list of strings")
} else {
return cacheImages, nil
}
if !images.ContainsUnknowns() {
return cacheImages, fmt.Errorf("the `images` field must be a list of strings")
}
return cacheImages, nil

Comment on lines 152 to 172
} else {
// avoid panic if inputs["build"] is not an Object - we only want to set these fields if their values are Known.
if inputs["build"].IsObject() {
if !inputs["build"].ObjectValue()["dockerfile"].ContainsUnknowns() {
inputs["build"].ObjectValue()["dockerfile"] = resource.NewStringProperty(build.Dockerfile)
knownDockerfile = true
}
if !inputs["build"].ObjectValue()["context"].ContainsUnknowns() {
inputs["build"].ObjectValue()["context"] = resource.NewStringProperty(build.Context)
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
} else {
// avoid panic if inputs["build"] is not an Object - we only want to set these fields if their values are Known.
if inputs["build"].IsObject() {
if !inputs["build"].ObjectValue()["dockerfile"].ContainsUnknowns() {
inputs["build"].ObjectValue()["dockerfile"] = resource.NewStringProperty(build.Dockerfile)
knownDockerfile = true
}
if !inputs["build"].ObjectValue()["context"].ContainsUnknowns() {
inputs["build"].ObjectValue()["context"] = resource.NewStringProperty(build.Context)
}
}
}
} else if inputs["build"].IsObject() {
// avoid panic if inputs["build"] is not an Object - we only want to set these fields if their values are Known.
if !inputs["build"].ObjectValue()["dockerfile"].ContainsUnknowns() {
inputs["build"].ObjectValue()["dockerfile"] = resource.NewStringProperty(build.Dockerfile)
knownDockerfile = true
}
if !inputs["build"].ObjectValue()["context"].ContainsUnknowns() {
inputs["build"].ObjectValue()["context"] = resource.NewStringProperty(build.Context)
}
}


if _, statErr := os.Stat(build.Dockerfile); statErr != nil {
if filepath.IsAbs(build.Dockerfile) {
return nil, fmt.Errorf("could not open dockerfile at absolute path %s: %v", build.Dockerfile, statErr)
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: instead of "could not open..." a more helpful error might say something like "expected a relative path, got %q instead".

If build object contains unknowns, skip Dockerfile hashing and platform
logic in Check(). This is a blunt approach, as it doesn't allow us to
granularly apply verificaton logic the first time Chieck() is called.
…ckerfile, Context; and registry: username, password, server
It turns out the random provider does not in fact return computed
values, so this test never tested what it said to test.
Using Command.stdout fixes this.
…and Create when run in preview mode. Disambiguate test name.
…ne is trying to build on preview with unresolved inputs.
if inputs["buildOnPreview"].ContainsUnknowns() {
msg = "buildOnPreview is unresolved; cannot build on preview. Continuing without preview image build. " +
"To avoid this warning, set buildOnPreview explicitly, and ensure all inputs are resolved at preview."
returnWithoutBuild = true
Copy link
Contributor Author

Choose a reason for hiding this comment

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

could we return early here? then we don't need to do the double verification on L#755.
We could return msg instead of an error, and put the logging logic and error handling in the main function.

Copy link
Contributor

Choose a reason for hiding this comment

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

Great idea, fixed!

@@ -458,3 +458,93 @@ func TestCheck(t *testing.T) {
})
}
}

func TestCanPreview(t *testing.T) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

love this!

Copy link
Contributor

Choose a reason for hiding this comment

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

It's a little more work up front to crank out some unit tests but worth it for the peace of mind!

Copy link
Contributor

@blampe blampe left a comment

Choose a reason for hiding this comment

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

Looks great! Let's cut a prerelease and give this a spin internally! I think there's already a 4.6.0-alpha.0 if it matters.

Comment on lines 6 to +9
"dependencies": {
"@pulumi/aws": "^6.10.0",
"@pulumi/pulumi": "^3.0.0",
"@pulumi/aws": "latest",
"@pulumi/random": "latest"
"@pulumi/random": "^4.14.0"
Copy link
Contributor

Choose a reason for hiding this comment

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

Unrelated to your PR but shouldn't these include @pulumi/docker? I was a little confused when I tried running one locally and it didn't work.

})
actual, err := marshalCachedImages(buildInput)
assert.NoError(t, err)
assert.Equal(t, expected, actual)
})
}

// TODO: do we want to allow Builder to be Unknown? there's very little use case here
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we can skip this one, very few people are setting this in the wild so I'm not too concerned about it working in exotic ways.

@@ -366,6 +477,7 @@ func TestMarshalBuilder(t *testing.T) {
})
}

// TODO: do we want to allow SkipPush to be Unknown?
Copy link
Contributor

Choose a reason for hiding this comment

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

This one is more plausible and fortunately you've already handled it!

@@ -134,58 +133,86 @@ func (p *dockerNativeProvider) log(ctx context.Context, sev diag.Severity, urn r
// the provider inputs are using for detecting and rendering diffs.
func (p *dockerNativeProvider) Check(ctx context.Context, req *rpc.CheckRequest) (*rpc.CheckResponse, error) {
urn := resource.URN(req.GetUrn())
label := fmt.Sprintf("%s.Create(%s)", p.name, urn)
label := fmt.Sprintf("%s.Check(%s)", p.name, urn)
Copy link
Contributor

Choose a reason for hiding this comment

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

🙌

knownDockerfile = true
} else {
// We do not want to set these fields if their values are Unknown.
if !inputs["build"].ObjectValue()["dockerfile"].ContainsUnknowns() {
Copy link
Contributor

Choose a reason for hiding this comment

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

It's weird but definitely possible! I've done things like this:

build: thing.arn.apply((arn) => {
  return {
    dockerfile: "Dockerfile",
    args: {
      THE_ARN: arn
    }
  }
}) 

Sure you could have done that apply deeper in the object... but anything is possible :)

I added a small test to sanity check everything still works when build is computed and it caught a few panics... whew!

if inputs["buildOnPreview"].ContainsUnknowns() {
msg = "buildOnPreview is unresolved; cannot build on preview. Continuing without preview image build. " +
"To avoid this warning, set buildOnPreview explicitly, and ensure all inputs are resolved at preview."
returnWithoutBuild = true
Copy link
Contributor

Choose a reason for hiding this comment

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

Great idea, fixed!

@@ -458,3 +458,93 @@ func TestCheck(t *testing.T) {
})
}
}

func TestCanPreview(t *testing.T) {
Copy link
Contributor

Choose a reason for hiding this comment

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

It's a little more work up front to crank out some unit tests but worth it for the peace of mind!

@guineveresaenger guineveresaenger merged commit 11599a3 into master Dec 5, 2023
17 checks passed
@guineveresaenger guineveresaenger deleted the guin/build-on-preview branch December 5, 2023 23:46
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.

Option to re-enable docker build on preview
3 participants