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

Ignore username/password changes in registryAuth config #1327

Open
wants to merge 7 commits into
base: master
Choose a base branch
from

Conversation

flostadler
Copy link

This PR modifies how the Docker provider handles changes to registry authentication credentials. Previously, any changes to the registryAuth configuration would trigger resource updates. Now, changes to usernames and passwords are ignored while changes to registry addresses still trigger updates. This aligns the native docker.Image resource (which already ignored username/password changes) with the rest of the bridged resources.
To handle nested json-encoded configuration (related to pulumi/pulumi#17667) the provider is leveraging the UnfoldProperties method from the bridge. This way it can transparently operate on the config without having to worry about the json encoding.

Key changes:

Fixes #952

Changes to registry authentication credentials (username/password) should not
trigger resource updates, while changes to registry addresses should. This
applies to both raw config and JSON-encoded nested configuration.

Key changes:
- Added logic to ignore username/password updates in registryAuth arrays
- Added handling for JSON-encoded nested configuration
- Added tests to verify credential changes don't trigger diffs
- Maintained address changes as a trigger for updates
@flostadler flostadler requested review from blampe and a team January 3, 2025 12:06
@flostadler flostadler self-assigned this Jan 3, 2025
Copy link

github-actions bot commented Jan 3, 2025

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.

Copy link

codecov bot commented Jan 3, 2025

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 0.00%. Comparing base (a738187) to head (de3def7).

Additional details and impacted files
@@          Coverage Diff           @@
##           master   #1327   +/-   ##
======================================
  Coverage    0.00%   0.00%           
======================================
  Files           3       3           
  Lines         147     147           
======================================
  Misses        147     147           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@flostadler flostadler marked this pull request as draft January 3, 2025 12:23
@flostadler flostadler marked this pull request as ready for review January 3, 2025 12:44
@flostadler flostadler requested a review from t0yv0 January 6, 2025 14:03
@flostadler flostadler requested a review from corymhall January 7, 2025 13:43
Copy link

@corymhall corymhall left a comment

Choose a reason for hiding this comment

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

LGTM!

return nil, fmt.Errorf("error unwrapping new config: %w", err)
}

return dp.nativeProvider.DiffConfig(ctx, request)
Copy link
Member

Choose a reason for hiding this comment

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

Isn't this going to make it a no-op?

// DiffConfig diffs the configuration for this provider.
func (p *dockerNativeProvider) DiffConfig(context.Context, *rpc.DiffRequest) (*rpc.DiffResponse, error) {
	return &rpc.DiffResponse{}, nil
}

func (p *dockerNativeProvider) DiffConfig(context.Context, *rpc.DiffRequest) (*rpc.DiffResponse, error) {
return &rpc.DiffResponse{}, nil
func (p *dockerNativeProvider) DiffConfig(ctx context.Context, req *rpc.DiffRequest) (*rpc.DiffResponse, error) {
return p.Diff(ctx, req)
Copy link
Member

Choose a reason for hiding this comment

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

Oh.

@t0yv0
Copy link
Member

t0yv0 commented Jan 8, 2025

So to clarify.

Prior to this change, provider configuration was handled by the bridge. The bridge default logic caused unwanted behavior: changes to usernames and passwords triggered cascading updates. To address the problem, given difficulty in customizing the bridge behavior to what is wanted, DiffConfig method is re-implemented entirely to use a custom method.

@t0yv0
Copy link
Member

t0yv0 commented Jan 8, 2025

I'd like to double-check that the bridge could not be reconfigured to give the desired behavior here quickly.

Presumably this code was tripping up a force replace?

https://github.com/pulumi/pulumi-terraform-bridge/blob/e2b32971aeb4b9ff1483d27f46dd09a9d515bff3/pkg/tfbridge/provider.go#L824

}),
},
want: &rpc.DiffResponse{
Changes: rpc.DiffResponse_DIFF_NONE,
Copy link
Member

Choose a reason for hiding this comment

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

Ah so to clarify you want to completely hide the changes? Displaying the changes without causing a cascading replacement is undesirable? Why? Does not the user expect to see changes reflected after they are made?

Copy link
Author

Choose a reason for hiding this comment

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

Those are settings that are likely to change on every run (e.g. for ECR). We’re already doing this for the Image resource. This change aligns the bridged resources with that.

without this, drift detection wouldn’t work for most major clouds

Copy link
Member

Choose a reason for hiding this comment

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

Gotcha so we want this as default behavior.

@flostadler
Copy link
Author

I'd like to double-check that the bridge could not be reconfigured to give the desired behavior here quickly.

Presumably this code was tripping up a force replace?

https://github.com/pulumi/pulumi-terraform-bridge/blob/e2b32971aeb4b9ff1483d27f46dd09a9d515bff3/pkg/tfbridge/provider.go#L824

The issue is that the password and in turn the auth config block are marked as secret and that shows up as a replacement in the preview no matter what. We cannot un-secret the password because it’s sensitive.

@t0yv0
Copy link
Member

t0yv0 commented Jan 8, 2025

To be pedantic it seems to be an update and not a replacement:

Previewing update (dev)

View Live: https://app.pulumi.com/anton-pulumi-corp/docker-952/dev/previews/df71e2d0-8cd4-4f56-ac19-93588dd00b5f

  pulumi:pulumi:Stack: (same)
    [urn=urn:pulumi:dev::docker-952::pulumi:pulumi:Stack::docker-952-dev]
    ~ pulumi:providers:docker: (update)
        [id=b633b4d6-1415-4599-b0f9-77b28f170d6f]
        [urn=urn:pulumi:dev::docker-952::pulumi:providers:docker::docker-provider]
        host        : "unix:///Users/anton/.colima/docker.sock"
        registryAuth: [secret]
        version     : "4.5.8"
Resources:
    ~ 1 to update
    1 unchanged

@t0yv0
Copy link
Member

t0yv0 commented Jan 8, 2025

All right given we want the new behavior, let's take the change, just let's talk a little bit about our options to keep the code a bit cleaner.

Would it be possible to not reuse the Diff method for Docker Image as the DiffConfig implementation for the provider? These have different schemas and different purposes and are just accidentally similar. I think sharing this code reduces duplication but creates dense code that's harder to understand. Could we have some extra code instead that's simpler?

If going down the path of implementing a custom diff method, one interesting hint can be seen here:

d := oldInputs.Diff(news)

func (props PropertyMap) Diff(other PropertyMap, ignoreKeys ...IgnoreKeyFunc) *ObjectDiff {

Note the IgnoreKeyFunc perhaps it can take care of the ignored keys elegantly instead of post-processing ObjectDiff.

Another possibility is to keep using the bridge implementation but do pre-processing or ask it to ignore keys e.g.

func (dp dockerHybridProvider) DiffConfig(ctx context.Context, request *rpc.DiffRequest) (*rpc.DiffResponse, error) {
	urn := resource.URN(request.GetUrn())
	label := fmt.Sprintf("%s.DiffConfig(%s)", dp.name, urn)
	logging.V(9).Infof("%s executing", label)

	var err error
	request.Olds, err = dp.unwrapJSONConfig(label, request.GetOlds())
	if err != nil {
		return nil, fmt.Errorf("error unwrapping old config: %w", err)
	}
	request.News, err = dp.unwrapJSONConfig(label, request.GetNews())
	if err != nil {
		return nil, fmt.Errorf("error unwrapping new config: %w", err)
	}

	request.IgnoreChanges = []string{"registryAuth"}
	return dp.bridgedProvider.DiffConfig(ctx, request)
}

This might not work but may be worth a quick try.

There is also this https://github.com/pulumi/pulumi-terraform-bridge/blob/e2b32971aeb4b9ff1483d27f46dd09a9d515bff3/unstable/propertyvalue/ignore_changes.go#L24 that can pre-process the maps.

@flostadler
Copy link
Author

To be pedantic it seems to be an update and not a replacement:

Previewing update (dev)

View Live: https://app.pulumi.com/anton-pulumi-corp/docker-952/dev/previews/df71e2d0-8cd4-4f56-ac19-93588dd00b5f

  pulumi:pulumi:Stack: (same)
    [urn=urn:pulumi:dev::docker-952::pulumi:pulumi:Stack::docker-952-dev]
    ~ pulumi:providers:docker: (update)
        [id=b633b4d6-1415-4599-b0f9-77b28f170d6f]
        [urn=urn:pulumi:dev::docker-952::pulumi:providers:docker::docker-provider]
        host        : "unix:///Users/anton/.colima/docker.sock"
        registryAuth: [secret]
        version     : "4.5.8"
Resources:
    ~ 1 to update
    1 unchanged

Sorry, yeah what I wanted to say (and the PR description has it correct) is that it shows as an update for the provider and updates for all dependent resources.

@@ -339,20 +339,47 @@ func (p *dockerNativeProvider) Diff(_ context.Context, req *rpc.DiffRequest) (*r
func diffUpdates(updates map[resource.PropertyKey]resource.ValueDiff) map[string]*rpc.PropertyDiff {
updateDiff := map[string]*rpc.PropertyDiff{}
for key, valueDiff := range updates {
update := true
// Include all the same updates by default.
Copy link
Member

Choose a reason for hiding this comment

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

Sharing the diffUpdates function for cross-purposes is the most dense bit in this PR I think - it might be correct but this is pretty non-obivous code. I'd love if we had a separate version of this function for provider config specifically or even better if we didn't have to use this function to post-process updates but got the updates to compute as we want them in the first place.

@flostadler
Copy link
Author

All right given we want the new behavior, let's take the change, just let's talk a little bit about our options to keep the code a bit cleaner.

Would it be possible to not reuse the Diff method for Docker Image as the DiffConfig implementation for the provider? These have different schemas and different purposes and are just accidentally similar. I think sharing this code reduces duplication but creates dense code that's harder to understand. Could we have some extra code instead that's simpler?

If going down the path of implementing a custom diff method, one interesting hint can be seen here:

d := oldInputs.Diff(news)

func (props PropertyMap) Diff(other PropertyMap, ignoreKeys ...IgnoreKeyFunc) *ObjectDiff {

Note the IgnoreKeyFunc perhaps it can take care of the ignored keys elegantly instead of post-processing ObjectDiff.

Another possibility is to keep using the bridge implementation but do pre-processing or ask it to ignore keys e.g.

func (dp dockerHybridProvider) DiffConfig(ctx context.Context, request *rpc.DiffRequest) (*rpc.DiffResponse, error) {
	urn := resource.URN(request.GetUrn())
	label := fmt.Sprintf("%s.DiffConfig(%s)", dp.name, urn)
	logging.V(9).Infof("%s executing", label)

	var err error
	request.Olds, err = dp.unwrapJSONConfig(label, request.GetOlds())
	if err != nil {
		return nil, fmt.Errorf("error unwrapping old config: %w", err)
	}
	request.News, err = dp.unwrapJSONConfig(label, request.GetNews())
	if err != nil {
		return nil, fmt.Errorf("error unwrapping new config: %w", err)
	}

	request.IgnoreChanges = []string{"registryAuth"}
	return dp.bridgedProvider.DiffConfig(ctx, request)
}

This might not work but may be worth a quick try.

There is also this https://github.com/pulumi/pulumi-terraform-bridge/blob/e2b32971aeb4b9ff1483d27f46dd09a9d515bff3/unstable/propertyvalue/ignore_changes.go#L24 that can pre-process the maps.

Yeah, more than happy to give these other options a try. I’m mostly worried that we end up with two very similar functions that basically do the same thing. But let me quickly prototype so we can compare it side by side


got, err := p.Diff(context.TODO(), req)
if (err != nil) != tt.wantErr {
t.Errorf("dockerNativeProvider.Diff() error = %v, wantErr %v", err, tt.wantErr)
Copy link
Member

Choose a reason for hiding this comment

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

This is a very confusing test suite we are testing dockerNativeProvider.Diff; the only job of dockerNativeProvider.Diff is to compute diffs for the Image resource. However we're testing it with registryAuth field that is not even part of the Image resource schema. I think we can keep the test suite but target

func (dp dockerHybridProvider) DiffConfig(ctx context.Context, request *rpc.DiffRequest) (*rpc.DiffResponse, error) {

Copy link
Author

Choose a reason for hiding this comment

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

Yeah, more reason to split out the methods!

@t0yv0
Copy link
Member

t0yv0 commented Jan 8, 2025

I’m mostly worried that we end up with two very similar functions that basically do the same thing

I think that's good in my book. It's only accidentally similar. There's generic code for doing diffs, and there's specific distinct jobs for diffing provider config vs Image resource config, which have different concrete schemas. The schemas will evolve separately in the future. Slightly duplicative code seems the way to go.

@t0yv0
Copy link
Member

t0yv0 commented Jan 8, 2025

Capturing some slack convo that helped me understand the trade-offs here.

This workaround does not do what the user wants because it persists password from state. This will get stale in 1hr or so after the token expires.

import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
import * as docker from "@pulumi/docker";

const creds = aws.ecr.getAuthorizationTokenOutput({});

export const dockerProvider = new docker.Provider("docker-provider", {
  registryAuth: [
    {
      username: creds.apply((c) => c.userName),
      password: creds.apply((c) => c.password),
      address: creds.apply((c) => c.proxyEndpoint),
    },
  ],
}, {ignoreChanges: ["registryAuth"]});

Instead, what we get with this PR is that the password in Pulumi statefile will become obsolete, but the real password used by the provider will keep changing to be the latest issued token. This is because the changes scope ignoreChanges effect to DiffConfig but do not affect CheckConfig and Check lifecycle methods. This seems like an acceptable workaround.

@flostadler
Copy link
Author

Capturing some slack convo that helped me understand the trade-offs here.

This workaround does not do what the user wants because it persists password from state. This will get stale in 1hr or so after the token expires.

import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
import * as docker from "@pulumi/docker";

const creds = aws.ecr.getAuthorizationTokenOutput({});

export const dockerProvider = new docker.Provider("docker-provider", {
  registryAuth: [
    {
      username: creds.apply((c) => c.userName),
      password: creds.apply((c) => c.password),
      address: creds.apply((c) => c.proxyEndpoint),
    },
  ],
}, {ignoreChanges: ["registryAuth"]});

Instead, what we get with this PR is that the password in Pulumi statefile will become obsolete, but the real password used by the provider will keep changing to be the latest issued token. This is because the changes scope ignoreChanges effect to DiffConfig but do not affect CheckConfig and Check lifecycle methods. This seems like an acceptable workaround.

To clarify a bit more, the outputs of the provider in state will be frozen, but the input will still be updated. The input is what's passed to configure in the case of refresh/delete so this change doesn't break any of those workflows.

@EronWright
Copy link

Another approach to consider here, is to add support for configuring explicit providers using the stack configuration file. In this way, one could maintain the password as a stack configuration setting (which is updatable even for refresh/destroy), rather than as a Provider argument.

@flostadler
Copy link
Author

Another approach to consider here, is to add support for configuring explicit providers using the stack configuration file. In this way, one could maintain the password as a stack configuration setting (which is updatable even for refresh/destroy), rather than as a Provider argument.

I think this would generally be a great idea! Sadly it wouldn’t solve this particular problem. The issue here mostly surfaces in cases where the provider is configured with short-lived credentials retrieved by a function (e.g. Amazon ECR).

@flostadler
Copy link
Author

@t0yv0 it took a bit of experimentation, but I managed to get a version working that uses ignoreChanges and minimal post processing on the DiffConfig response!
This is minimally invasive and doesn't touch the native provider portion at all, PTAL :)

@flostadler flostadler requested review from corymhall and t0yv0 January 9, 2025 21:59
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.

Registry auth shows changes on every run
4 participants