Skip to content

Commit

Permalink
Add the ability to ignore changes to volatile provider configuration
Browse files Browse the repository at this point in the history
The password and username of the registryAuth config can be volatile
for many cloud registries. For AWS ECR you need to exchange AWS
credentials for short lived tokens to authenticate to the registry.
This will lead to perma-diffs in the provider config.

This change adds the ability to ignore certain volatile fields
of the registryAuth configuration.
  • Loading branch information
flostadler committed Jun 17, 2024
1 parent 26c9c62 commit 94b8ac0
Show file tree
Hide file tree
Showing 8 changed files with 239 additions and 1 deletion.
17 changes: 17 additions & 0 deletions examples/examples_nodejs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,23 @@ func TestLocalRepoDigestNode(t *testing.T) {
integration.ProgramTest(t, &test)
}

func TestRegistryTokenAuth(t *testing.T) {
region := os.Getenv("AWS_REGION")
if region == "" {
t.Skipf("Skipping test due to missing AWS_REGION environment variable")
}
test := getJsOptions(t).
With(integration.ProgramTestOptions{
Dir: path.Join(getCwd(t), "registry-token-auth"),
Config: map[string]string{
"aws:region": region,
},
ExtraRuntimeValidation: assertHasRepoDigest,
})

integration.ProgramTest(t, &test)
}

func getJsOptions(t *testing.T) integration.ProgramTestOptions {
base := getBaseOptions()
baseJs := base.With(integration.ProgramTestOptions{
Expand Down
2 changes: 2 additions & 0 deletions examples/registry-token-auth/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/bin/
/node_modules/
3 changes: 3 additions & 0 deletions examples/registry-token-auth/Pulumi.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
name: registry-token-auth
runtime: nodejs
description: A minimal AWS TypeScript Pulumi program
3 changes: 3 additions & 0 deletions examples/registry-token-auth/app/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
FROM nginx
RUN echo "<h1>Hi from Pulumi!</h1>" > \
/usr/share/nginx/html/index.html
49 changes: 49 additions & 0 deletions examples/registry-token-auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import * as aws from "@pulumi/aws";
import * as docker from "@pulumi/docker";

// Create a private ECR registry.
const repo = new aws.ecr.Repository("my-repo", {
forceDelete: true,
});

// Get registry info (creds and endpoint) so we can build/publish to it.
const registryInfo = repo.registryId.apply(async id => {
const credentials = await aws.ecr.getCredentials({ registryId: id });
const decodedCredentials = Buffer.from(credentials.authorizationToken, "base64").toString();
const [username, password] = decodedCredentials.split(":");
if (!password || !username) {
throw new Error("Invalid credentials");
}
return {
address: credentials.proxyEndpoint,
username: username,
password: password,
};
});


// Build image to simulate a local image
const image = new docker.Image("my-image", {
build: {
context: "app",
},
imageName: repo.repositoryUrl,
skipPush: true
});

const ecrProvider = new docker.Provider("ecr-provider", {
registryAuth: [registryInfo],
},
);

// Publish the image to the registry
const registryImage = new docker.RegistryImage("my-registry-image",
{
name: repo.repositoryUrl,
},
{ provider: ecrProvider, dependsOn: [image] },
);

// Export the resulting image name
export const imageName = image.imageName;
export const repoDigest = image.repoDigest;
11 changes: 11 additions & 0 deletions examples/registry-token-auth/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "cbp-aws-ts",
"devDependencies": {
"@types/node": "^14.0.0"
},
"dependencies": {
"@pulumi/aws": "^6.10.0",
"@pulumi/pulumi": "^3.0.0",
"@pulumi/random": "^4.14.0"
}
}
18 changes: 18 additions & 0 deletions examples/registry-token-auth/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"strict": true,
"outDir": "bin",
"target": "es2016",
"module": "commonjs",
"moduleResolution": "node",
"sourceMap": true,
"experimentalDecorators": true,
"pretty": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"forceConsistentCasingInFileNames": true
},
"files": [
"index.ts"
]
}
137 changes: 136 additions & 1 deletion provider/hybrid.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ package provider

import (
"context"
"encoding/json"
"fmt"
"reflect"

"github.com/golang/protobuf/ptypes/empty"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"

"github.com/pulumi/pulumi/sdk/v3/go/common/resource"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource/plugin"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/logging"
rpc "github.com/pulumi/pulumi/sdk/v3/proto/go"
Expand Down Expand Up @@ -66,7 +69,139 @@ func (dp dockerHybridProvider) CheckConfig(ctx context.Context, request *rpc.Che

func (dp dockerHybridProvider) DiffConfig(ctx context.Context, request *rpc.DiffRequest) (*rpc.DiffResponse, error) {
// Delegate to the bridged provider, as native Provider does not implement it.
return dp.bridgedProvider.DiffConfig(ctx, request)

urn := resource.URN(request.GetUrn())
label := fmt.Sprintf("DiffConfig(%s)", urn)

olds, err := plugin.UnmarshalProperties(request.GetOlds(), plugin.MarshalOptions{
Label: fmt.Sprintf("%s.olds", label),
KeepUnknowns: true,
SkipNulls: true,
})
if err != nil {
return nil, err
}
news, err := plugin.UnmarshalProperties(request.GetNews(), plugin.MarshalOptions{
Label: fmt.Sprintf("%s.news", label),
KeepUnknowns: true,
SkipNulls: true,
})
if err != nil {
return nil, fmt.Errorf("DiffConfig failed because of malformed resource inputs: %w", err)
}

// forcing this to true for the prototype. This needs to be configurable
var ignoreRegistryAuthChanges bool = true

Check failure on line 94 in provider/hybrid.go

View workflow job for this annotation

GitHub Actions / lint / lint

var-declaration: should omit type bool from declaration of var ignoreRegistryAuthChanges; it will be inferred from the right-hand side (revive)
if v := news["ignoreRegistryAuthChanges"]; v.HasValue() && v.IsBool() {
ignoreRegistryAuthChanges = v.BoolValue()
}

resp, err := dp.bridgedProvider.DiffConfig(ctx, request)
if err != nil {
return nil, err
}

if hasDiff, err := hasVolatileRegistryAuthDiff(olds, news); err == nil && hasDiff && ignoreRegistryAuthChanges {
for idx, prop := range resp.Diffs {
if prop == "registryAuth" {
resp.Diffs = append(resp.Diffs[:idx], resp.Diffs[idx+1:]...)
break
}
}
delete(resp.DetailedDiff, "registryAuth")

// Set diff to none if there are no other diffs
if len(resp.DetailedDiff) == 0 && len(resp.Diffs) == 0 {
resp.Changes = rpc.DiffResponse_DIFF_NONE
}
} else if err != nil {
return nil, fmt.Errorf("DiffConfig failed because of malformed registryAuth inputs: %w", err)
}

return resp, err
}

// hasVolatileRegistryAuthDiff checks if the registryAuth field has a diff that is volatile and should be ignored.
func hasVolatileRegistryAuthDiff(olds, news resource.PropertyMap) (bool, error) {
if !olds.HasValue("registryAuth") || !news.HasValue("registryAuth") {
// if old or new registryAuth is missing then there is a diff, no need to clean
return false, nil
} else if !olds["registryAuth"].IsString() || !news["registryAuth"].IsString() {
// if old or new registryAuth is not a string then there is no diff to be cleaned
return false, nil
} else if olds["registryAuth"].StringValue() == news["registryAuth"].StringValue() {
// if the strings are equal then there is no diff to be cleaned
return false, nil
}

var oldAuthData []map[string]interface{}
var newAuthData []map[string]interface{}

err := json.Unmarshal([]byte(olds["registryAuth"].StringValue()), &oldAuthData)
if err != nil {
return false, err
}
err = json.Unmarshal([]byte(news["registryAuth"].StringValue()), &newAuthData)
if err != nil {
return false, err
}

if len(oldAuthData) != len(newAuthData) {
// an auth data was added/removed, no need to clean because there's an actual diff
return false, nil
}

newAuthMap, err := toAuthMapWithoutVolatileFields(newAuthData)
if err != nil {
return false, err
}
oldAuthMap, err := toAuthMapWithoutVolatileFields(oldAuthData)
if err != nil {
return false, err
}

if reflect.DeepEqual(newAuthMap, oldAuthMap) {
// if the maps are equal without the volatile then they need to be cleaned
return true, nil
}

// fall through, there is an actual diff. No need to clean
return false, nil
}

var volatileAuthFields = map[string]bool{
"password": true,
"username": true,
}

// toAuthMapWithoutVolatileFields converts a slice of auth data maps to a map of auth data maps without volatile fields.
func toAuthMapWithoutVolatileFields(authDataSlice []map[string]interface{}) (map[string]map[string]interface{}, error) {
authMap := make(map[string]map[string]interface{})
for _, authData := range authDataSlice {
address, found := authData["address"]
if !found {
return nil, fmt.Errorf("required address field not found in new registryAuth data")
}
addressStr, ok := address.(string)
if !ok {
return nil, fmt.Errorf("address field in new registryAuth data is not a string")
}

authMap[addressStr] = copyMapIgnoringKeys(authData, volatileAuthFields)
}

return authMap, nil
}

// copyMapIgnoringKeys creates a shallow copy of the given map without the specified keys that should be ignored.
func copyMapIgnoringKeys(original map[string]interface{}, ignoreKeys map[string]bool) map[string]interface{} {
copy := make(map[string]interface{}, len(original))
for key, value := range original {
if ignore, found := ignoreKeys[key]; !found || !ignore {
copy[key] = value
}
}
return copy
}

func (dp dockerHybridProvider) Configure(
Expand Down

0 comments on commit 94b8ac0

Please sign in to comment.