diff --git a/CHANGELOG.md b/CHANGELOG.md index b2c027d7..cfee5e88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,118 @@ +# v1.4.0 + +## Features +- feat(k8s): Add support for GA ingress apiVersion in helm chart and operator (#287) + +## Improvements +- chore(docker): Update alpine images operator + halyard (#292) +- chore(release): Update deployment manifest with specific release tag (#282) +- docs(k8s): Kubernetes compatibility matrix (#285) +- chore(release): Manifests update + +# v1.3.1 + +## Improvements +- chore(docker): Update alpine images operator + halyard (#292) (#293) + +## Dependencies +- Updated halyard version to operator-a6ac1d4 + +# v1.3.0 + +## Features +- feat(lambda/validation): Validation regarding the AWS Lambda using the GO SDK to get the Lambda Functions using the AWS provider credentials +- feat(cloudfoundry/validation): Add a CloudFoundry validation for each account (#222) +- feat(validator/aws): Add AWS account validator (#195) +- feat(health-check): Increase timeout and validate ready replicas for status (#219) +- feat(halyard): Bump version (#269) + +## Bug Fixes +- fix(build): Kind unable to start control-plane (#279) +- fix(actions): Update metallb condition (#259) +- fix(it): Fix integration tests (#236) +- fix(expose): Override public service port (#210) +- fix(validation): Validate primary account for kubernetes provider (#209) +- fix(timeout): Avoid revalidation when patching Spinnaker Status (#192) +- fix(build): No push dev image to scan.connect.redhat.com (#184) +- Fix transforming k8s secrets (#262) + +## Improvements +- chore(ci): Upgrading actions (#284) +- chore(ci): Skipping integration tests (#283) +- chore(dependency): Update upstream oss halyard version (#280, #278) +- chore(build): Split actions for PRs and master (#273) +- chore(build): Make sure forks can run tests, but not create releases (#268) +- chore(halyard): Updated halyard version (#232, #213, #200, #197) +- chore(cve): Fix for CVE-2020-13757 (#193) +- update(kind): Update yml reference files (#258, #257) +- update(operator): Update files for the new API of k8s v1.22 (#252) (#256) +- doc(fix): Add Plugins section to README (#220) + +## Dependencies +- Updated halyard version to operator-b135799 +- Support for Kubernetes v1.22 API changes + +# v1.2.5 + +## Improvements +- chore(release): v1.2.5 (#233) + +## Dependencies +- Updated halyard version to operator-ccae06e + +# v1.2.4 + +## Features +- feat(health-check): Increase timeout and validate ready replicas for status (#219) (#221) + +# v1.2.3 + +## Improvements +- chore(release): v1.2.3 (#217) + +# v1.2.2 + +## Improvements +- chore(halyard): Updated halyard version (#200) (#201) + +## Dependencies +- Updated halyard version to operator-7162184 + +# v1.2.1 + +## Improvements +- chore(release): v1.2.1 (#198) + +## Dependencies +- Updated halyard version to operator-8e0406f + +# v1.2.0 + +## Features +- feat(health-check): Check spinnaker status (#168) +- feat(ubi): Add build for UBI images (#158) + +## Bug Fixes +- fix(build): No push dev image to scan.connect.redhat.com (#184) (#185) +- fix(ingress): Fix panic when overriding endpoint with ingress (#181) +- fix(test): Fix integration tests (#179, #171) +- fix(k8s-context): Use the context passed in SpinnakerService when validating Kubernetes accounts (#173) +- fix(ingress): Support ingress with load balancer IP (GCE/bare metal) (#170) +- fix(ubi): Fix UBI run issue (#169) +- fix(test): Added ingress permissions to role used in tests (#155) +- fix(expose/ingress): Solve issue for spinsvc status URL (#154) + +## Improvements +- chore(halyard): Update version (#183) +- chore(release): Updated halyard version (#174) +- chore(license): Update Copyright section (#162) +- chore(coverage): Add code coverage (#161) +- chore(mergify): Add mergify config (#157) +- chore(release): Update changelog + +## Dependencies +- Updated halyard version to operator-c1d641c + # Unreleased (1.1.2) - chore: Update halyard version. diff --git a/build-tools/Dockerfile b/build-tools/Dockerfile index 69989d8f..75d8d1e5 100644 --- a/build-tools/Dockerfile +++ b/build-tools/Dockerfile @@ -24,7 +24,7 @@ RUN wget -nv https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-c && mkdir -p /opt && cd /opt \ && tar -xzf /google-cloud-sdk-${GOOGLE_CLOUD_SDK_VERSION}-linux-x86_64.tar.gz \ && rm /google-cloud-sdk-${GOOGLE_CLOUD_SDK_VERSION}-linux-x86_64.tar.gz \ - && CLOUDSDK_PYTHON="python3" /opt/google-cloud-sdk/install.sh --usage-reporting=false --bash-completion=false --additional-components app-engine-java app-engine-go \ + && CLOUDSDK_PYTHON="python3" /opt/google-cloud-sdk/install.sh --usage-reporting=false --bash-completion=false --additional-components app-engine-java \ && rm -rf ~/.config/gcloud \ && gcloud components remove --quiet anthoscli \ && rm -rf /opt/google-cloud-sdk/.install/.backup diff --git a/pkg/deploy/spindeploy/transformer/secrets.go b/pkg/deploy/spindeploy/transformer/secrets.go index 34164193..4dc431e2 100644 --- a/pkg/deploy/spindeploy/transformer/secrets.go +++ b/pkg/deploy/spindeploy/transformer/secrets.go @@ -3,6 +3,9 @@ package transformer import ( "context" "fmt" + "path" + "strings" + secups "github.com/armory/go-yaml-tools/pkg/secrets" "github.com/armory/spinnaker-operator/pkg/apis/spinnaker/interfaces" "github.com/armory/spinnaker-operator/pkg/generated" @@ -15,9 +18,7 @@ import ( v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" - "path" "sigs.k8s.io/controller-runtime/pkg/client" - "strings" ) const ( @@ -32,6 +33,8 @@ const ( monitoringContainerName = "monitoring-daemon" ) +var clouddriverServices = []string{"clouddriver", "clouddriver-ro", "clouddriver-rw", "clouddriver-ro-deck", "clouddriver-caching"} + // secretsTransformer maps Kubernetes secrets onto the deployment of the service that requires it // Either as a mounted file (encryptedFile) or an environment variable (tokens, passwords...) type secretsTransformer struct { @@ -90,7 +93,22 @@ func (s *secretsTransformer) replaceK8sSecretsFromAwsKeys(spinCfg *interfaces.Sp return err } if artifactKeys != nil { - s.k8sSecrets.awsCredsByService["clouddriver"] = artifactKeys + // Merge artifact keys with existing provider keys instead of overwriting + var finalKeys *awsCredentials + if existingKeys, exists := s.k8sSecrets.awsCredsByService["clouddriver"]; exists { + finalKeys = s.mergeAwsCredentials(existingKeys, artifactKeys) + } else { + finalKeys = artifactKeys + } + // Apply the merged credentials to all clouddriver service variants + for _, svcName := range clouddriverServices { + s.k8sSecrets.awsCredsByService[svcName] = finalKeys + } + } else if providerKeys != nil { + // If only provider keys exist, apply them to all clouddriver service variants + for _, svcName := range clouddriverServices { + s.k8sSecrets.awsCredsByService[svcName] = providerKeys + } } can, ok := spinCfg.Config[awsCanary] if !ok { @@ -130,8 +148,20 @@ func (s *secretsTransformer) getAndReplace(svc, accessKeyProp, secretKeyProp str if err != nil { return nil, err } + var genAccessKey v1.EnvVar + // Check if access key is also a Kubernetes secret reference + if secups.IsEncryptedSecret(accessKeyRaw) { + accessKeySecretName, accessKeySecretKey, err := secrets.ParseKubernetesSecretParams(accessKeyRaw) + if err != nil { + return nil, err + } + genAccessKey = envVarFromSecretReference("AWS_ACCESS_KEY_ID", accessKeySecretName, accessKeySecretKey) + } else { + // Access key is plain text + genAccessKey = envVarFromRawString("AWS_ACCESS_KEY_ID", accessKeyRaw) + } return &awsCredentials{ - genAccessKey: envVarFromRawString("AWS_ACCESS_KEY_ID", accessKeyRaw), + genAccessKey: genAccessKey, genSecretKey: envVarFromSecretReference("AWS_SECRET_ACCESS_KEY", secretName, secretKey), svcSecretKeys: []v1.EnvVar{envVarFromSecretReference(envVarName, secretName, secretKey)}, }, nil @@ -164,7 +194,17 @@ func (s *secretsTransformer) getAndReplaceArray(svc, rootProp, accessKeyProp, se if !ok { return nil, fmt.Errorf("aws secret access key specified without access key under %s", root) } - genAccessKey = envVarFromRawString("AWS_ACCESS_KEY_ID", accessKey) + // Check if access key is also a Kubernetes secret reference + if secups.IsEncryptedSecret(accessKey) { + accessKeySecretName, accessKeySecretKey, err := secrets.ParseKubernetesSecretParams(accessKey) + if err != nil { + return nil, err + } + genAccessKey = envVarFromSecretReference("AWS_ACCESS_KEY_ID", accessKeySecretName, accessKeySecretKey) + } else { + // Access key is plain text + genAccessKey = envVarFromRawString("AWS_ACCESS_KEY_ID", accessKey) + } genSecretKey = envVarFromSecretReference("AWS_SECRET_ACCESS_KEY", secretName, secretKey) svcSecretKeys = append(svcSecretKeys, envVarFromSecretReference(envVarName, secretName, secretKey)) } @@ -188,6 +228,25 @@ func (s *secretsTransformer) getAndReplaceArray(svc, rootProp, accessKeyProp, se }, nil } +// mergeAwsCredentials merges two awsCredentials structs, preserving both sets of credentials +func (s *secretsTransformer) mergeAwsCredentials(existing, new *awsCredentials) *awsCredentials { + merged := &awsCredentials{ + // Use the new credentials for generic AWS keys (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) + // This follows the "last one wins" pattern from getAndReplaceArray + genAccessKey: new.genAccessKey, + genSecretKey: new.genSecretKey, + // Combine service-specific secret keys from both credentials + svcSecretKeys: make([]v1.EnvVar, 0, len(existing.svcSecretKeys)+len(new.svcSecretKeys)), + } + + // Add new service-specific keys first (artifact credentials) + merged.svcSecretKeys = append(merged.svcSecretKeys, new.svcSecretKeys...) + // Add existing service-specific keys after (provider credentials) + merged.svcSecretKeys = append(merged.svcSecretKeys, existing.svcSecretKeys...) + + return merged +} + func (s *secretsTransformer) sanitizeK8sSecret(object interface{}, ctx context.Context) (interface{}, error) { h := func(val string) (string, error) { if !secups.IsEncryptedSecret(val) { @@ -217,10 +276,10 @@ func (s *secretsTransformer) TransformManifests(ctx context.Context, gen *genera if ok && sec.Object["kind"] == "Secret" { var secret v1.Secret runtime.DefaultUnstructuredConverter.FromUnstructured(sec.Object, &secret) - err := kCollector.mapSecrets(&secret) - if err != nil { - return err - } + err := kCollector.mapSecrets(&secret) + if err != nil { + return err + } cfg.Resources[k] = &secret } } diff --git a/pkg/deploy/spindeploy/transformer/secrets_test.go b/pkg/deploy/spindeploy/transformer/secrets_test.go index 64624e54..40538fc2 100644 --- a/pkg/deploy/spindeploy/transformer/secrets_test.go +++ b/pkg/deploy/spindeploy/transformer/secrets_test.go @@ -3,6 +3,8 @@ package transformer import ( "context" "fmt" + "testing" + secups "github.com/armory/go-yaml-tools/pkg/secrets" "github.com/armory/spinnaker-operator/pkg/apis/spinnaker/interfaces" "github.com/armory/spinnaker-operator/pkg/secrets" @@ -12,7 +14,6 @@ import ( "github.com/stretchr/testify/assert" appsv1 "k8s.io/api/apps/v1" v1 "k8s.io/api/core/v1" - "testing" ) func TestEnvVarName(t *testing.T) { @@ -388,3 +389,645 @@ profiles: ` assert.Equal(t, expected, string(actual)) } + +func TestMergeAwsCredentials(t *testing.T) { + tr := &secretsTransformer{} + + t.Run("merge with both existing and new credentials", func(t *testing.T) { + existing := &awsCredentials{ + genAccessKey: v1.EnvVar{Name: "AWS_ACCESS_KEY_ID", Value: "existing-access-key"}, + genSecretKey: v1.EnvVar{Name: "AWS_SECRET_ACCESS_KEY", Value: "existing-secret-key"}, + svcSecretKeys: []v1.EnvVar{ + {Name: "CLOUDDRIVER_SECRET1_KEY1", Value: "existing-svc-key1"}, + {Name: "CLOUDDRIVER_SECRET1_KEY2", Value: "existing-svc-key2"}, + }, + } + + new := &awsCredentials{ + genAccessKey: v1.EnvVar{Name: "AWS_ACCESS_KEY_ID", Value: "new-access-key"}, + genSecretKey: v1.EnvVar{Name: "AWS_SECRET_ACCESS_KEY", Value: "new-secret-key"}, + svcSecretKeys: []v1.EnvVar{ + {Name: "CLOUDDRIVER_ARTIFACT_SECRET", Value: "new-svc-key1"}, + {Name: "CLOUDDRIVER_ARTIFACT_SECRET2", Value: "new-svc-key2"}, + }, + } + + merged := tr.mergeAwsCredentials(existing, new) + + // Generic keys should use "new" values (last one wins) + assert.Equal(t, "new-access-key", merged.genAccessKey.Value) + assert.Equal(t, "new-secret-key", merged.genSecretKey.Value) + + // Service-specific keys should be combined with new keys first, then existing + assert.Equal(t, 4, len(merged.svcSecretKeys)) + assert.Equal(t, "CLOUDDRIVER_ARTIFACT_SECRET", merged.svcSecretKeys[0].Name) + assert.Equal(t, "CLOUDDRIVER_ARTIFACT_SECRET2", merged.svcSecretKeys[1].Name) + assert.Equal(t, "CLOUDDRIVER_SECRET1_KEY1", merged.svcSecretKeys[2].Name) + assert.Equal(t, "CLOUDDRIVER_SECRET1_KEY2", merged.svcSecretKeys[3].Name) + }) + + t.Run("merge with existing having empty service keys", func(t *testing.T) { + existing := &awsCredentials{ + genAccessKey: v1.EnvVar{Name: "AWS_ACCESS_KEY_ID", Value: "existing-access-key"}, + genSecretKey: v1.EnvVar{Name: "AWS_SECRET_ACCESS_KEY", Value: "existing-secret-key"}, + svcSecretKeys: []v1.EnvVar{}, // empty slice + } + + new := &awsCredentials{ + genAccessKey: v1.EnvVar{Name: "AWS_ACCESS_KEY_ID", Value: "new-access-key"}, + genSecretKey: v1.EnvVar{Name: "AWS_SECRET_ACCESS_KEY", Value: "new-secret-key"}, + svcSecretKeys: []v1.EnvVar{ + {Name: "CLOUDDRIVER_ARTIFACT_SECRET", Value: "new-svc-key"}, + }, + } + + merged := tr.mergeAwsCredentials(existing, new) + + assert.Equal(t, "new-access-key", merged.genAccessKey.Value) + assert.Equal(t, "new-secret-key", merged.genSecretKey.Value) + assert.Equal(t, 1, len(merged.svcSecretKeys)) + assert.Equal(t, "CLOUDDRIVER_ARTIFACT_SECRET", merged.svcSecretKeys[0].Name) + }) + + t.Run("merge with new having empty service keys", func(t *testing.T) { + existing := &awsCredentials{ + genAccessKey: v1.EnvVar{Name: "AWS_ACCESS_KEY_ID", Value: "existing-access-key"}, + genSecretKey: v1.EnvVar{Name: "AWS_SECRET_ACCESS_KEY", Value: "existing-secret-key"}, + svcSecretKeys: []v1.EnvVar{ + {Name: "CLOUDDRIVER_PROVIDER_SECRET", Value: "existing-svc-key"}, + }, + } + + new := &awsCredentials{ + genAccessKey: v1.EnvVar{Name: "AWS_ACCESS_KEY_ID", Value: "new-access-key"}, + genSecretKey: v1.EnvVar{Name: "AWS_SECRET_ACCESS_KEY", Value: "new-secret-key"}, + svcSecretKeys: []v1.EnvVar{}, // empty slice + } + + merged := tr.mergeAwsCredentials(existing, new) + + assert.Equal(t, "new-access-key", merged.genAccessKey.Value) + assert.Equal(t, "new-secret-key", merged.genSecretKey.Value) + assert.Equal(t, 1, len(merged.svcSecretKeys)) + assert.Equal(t, "CLOUDDRIVER_PROVIDER_SECRET", merged.svcSecretKeys[0].Name) + }) + + t.Run("merge with both having empty service keys", func(t *testing.T) { + existing := &awsCredentials{ + genAccessKey: v1.EnvVar{Name: "AWS_ACCESS_KEY_ID", Value: "existing-access-key"}, + genSecretKey: v1.EnvVar{Name: "AWS_SECRET_ACCESS_KEY", Value: "existing-secret-key"}, + svcSecretKeys: []v1.EnvVar{}, + } + + new := &awsCredentials{ + genAccessKey: v1.EnvVar{Name: "AWS_ACCESS_KEY_ID", Value: "new-access-key"}, + genSecretKey: v1.EnvVar{Name: "AWS_SECRET_ACCESS_KEY", Value: "new-secret-key"}, + svcSecretKeys: []v1.EnvVar{}, + } + + merged := tr.mergeAwsCredentials(existing, new) + + assert.Equal(t, "new-access-key", merged.genAccessKey.Value) + assert.Equal(t, "new-secret-key", merged.genSecretKey.Value) + assert.Equal(t, 0, len(merged.svcSecretKeys)) + }) + + t.Run("merge with secret key references", func(t *testing.T) { + existing := &awsCredentials{ + genAccessKey: v1.EnvVar{ + Name: "AWS_ACCESS_KEY_ID", + ValueFrom: &v1.EnvVarSource{ + SecretKeyRef: &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{Name: "existing-secret"}, + Key: "access-key", + }, + }, + }, + genSecretKey: v1.EnvVar{ + Name: "AWS_SECRET_ACCESS_KEY", + ValueFrom: &v1.EnvVarSource{ + SecretKeyRef: &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{Name: "existing-secret"}, + Key: "secret-key", + }, + }, + }, + svcSecretKeys: []v1.EnvVar{ + { + Name: "CLOUDDRIVER_EXISTING_SECRET", + ValueFrom: &v1.EnvVarSource{ + SecretKeyRef: &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{Name: "existing-secret"}, + Key: "svc-key", + }, + }, + }, + }, + } + + new := &awsCredentials{ + genAccessKey: v1.EnvVar{ + Name: "AWS_ACCESS_KEY_ID", + ValueFrom: &v1.EnvVarSource{ + SecretKeyRef: &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{Name: "new-secret"}, + Key: "access-key", + }, + }, + }, + genSecretKey: v1.EnvVar{ + Name: "AWS_SECRET_ACCESS_KEY", + ValueFrom: &v1.EnvVarSource{ + SecretKeyRef: &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{Name: "new-secret"}, + Key: "secret-key", + }, + }, + }, + svcSecretKeys: []v1.EnvVar{ + { + Name: "CLOUDDRIVER_NEW_SECRET", + ValueFrom: &v1.EnvVarSource{ + SecretKeyRef: &v1.SecretKeySelector{ + LocalObjectReference: v1.LocalObjectReference{Name: "new-secret"}, + Key: "svc-key", + }, + }, + }, + }, + } + + merged := tr.mergeAwsCredentials(existing, new) + + // Generic keys should use "new" values + assert.Equal(t, "new-secret", merged.genAccessKey.ValueFrom.SecretKeyRef.Name) + assert.Equal(t, "new-secret", merged.genSecretKey.ValueFrom.SecretKeyRef.Name) + + // Service-specific keys should be combined + assert.Equal(t, 2, len(merged.svcSecretKeys)) + assert.Equal(t, "CLOUDDRIVER_NEW_SECRET", merged.svcSecretKeys[0].Name) + assert.Equal(t, "new-secret", merged.svcSecretKeys[0].ValueFrom.SecretKeyRef.Name) + assert.Equal(t, "CLOUDDRIVER_EXISTING_SECRET", merged.svcSecretKeys[1].Name) + assert.Equal(t, "existing-secret", merged.svcSecretKeys[1].ValueFrom.SecretKeyRef.Name) + }) + + t.Run("merge preserves slice capacity optimization", func(t *testing.T) { + existing := &awsCredentials{ + genAccessKey: v1.EnvVar{Name: "AWS_ACCESS_KEY_ID", Value: "existing-access-key"}, + genSecretKey: v1.EnvVar{Name: "AWS_SECRET_ACCESS_KEY", Value: "existing-secret-key"}, + svcSecretKeys: []v1.EnvVar{ + {Name: "KEY1", Value: "value1"}, + {Name: "KEY2", Value: "value2"}, + }, + } + + new := &awsCredentials{ + genAccessKey: v1.EnvVar{Name: "AWS_ACCESS_KEY_ID", Value: "new-access-key"}, + genSecretKey: v1.EnvVar{Name: "AWS_SECRET_ACCESS_KEY", Value: "new-secret-key"}, + svcSecretKeys: []v1.EnvVar{ + {Name: "KEY3", Value: "value3"}, + {Name: "KEY4", Value: "value4"}, + {Name: "KEY5", Value: "value5"}, + }, + } + + merged := tr.mergeAwsCredentials(existing, new) + + // Verify the slice has the correct capacity and length + assert.Equal(t, 5, len(merged.svcSecretKeys)) + assert.GreaterOrEqual(t, cap(merged.svcSecretKeys), 5) + + // Verify order: new keys first, then existing keys + assert.Equal(t, "KEY3", merged.svcSecretKeys[0].Name) + assert.Equal(t, "KEY4", merged.svcSecretKeys[1].Name) + assert.Equal(t, "KEY5", merged.svcSecretKeys[2].Name) + assert.Equal(t, "KEY1", merged.svcSecretKeys[3].Name) + assert.Equal(t, "KEY2", merged.svcSecretKeys[4].Name) + }) +} + +func TestGetAndReplaceWithEncryptedAccessKey(t *testing.T) { + // This tests the case where both access key and secret key are encrypted Kubernetes secret references + cfg := ` +config: + providers: + aws: + accessKeyId: encrypted:k8s!n:testsecret!k:accessKey + secretAccessKey: encrypted:k8s!n:testsecret!k:secretKey + enabled: true +` + spinCfg := &interfaces.SpinnakerConfig{} + assert.Nil(t, yaml.Unmarshal([]byte(cfg), spinCfg)) + + tr := &secretsTransformer{k8sSecrets: &k8sSecretHolder{awsCredsByService: map[string]*awsCredentials{}}} + + // Mock the k8s secret engine + secups.Engines["k8s"] = func(ctx context.Context, isFile bool, params string) (secups.Decrypter, error) { + _, k, err := secrets.ParseKubernetesSecretParams(params) + if err != nil { + return nil, err + } + return &test.DummyK8sSecretEngine{Secret: k}, nil + } + + // Test the getAndReplace function directly + creds, err := tr.getAndReplace("clouddriver", "providers.aws.accessKeyId", "providers.aws.secretAccessKey", spinCfg) + + assert.Nil(t, err) + assert.NotNil(t, creds) + + // Verify that the access key is configured as a secret reference (not plain text) + assert.Equal(t, "AWS_ACCESS_KEY_ID", creds.genAccessKey.Name) + assert.NotNil(t, creds.genAccessKey.ValueFrom) + assert.NotNil(t, creds.genAccessKey.ValueFrom.SecretKeyRef) + assert.Equal(t, "testsecret", creds.genAccessKey.ValueFrom.SecretKeyRef.Name) + assert.Equal(t, "accessKey", creds.genAccessKey.ValueFrom.SecretKeyRef.Key) + assert.Empty(t, creds.genAccessKey.Value) // Should be empty since it's a secret reference + + // Verify that the secret key is also configured as a secret reference + assert.Equal(t, "AWS_SECRET_ACCESS_KEY", creds.genSecretKey.Name) + assert.NotNil(t, creds.genSecretKey.ValueFrom) + assert.NotNil(t, creds.genSecretKey.ValueFrom.SecretKeyRef) + assert.Equal(t, "testsecret", creds.genSecretKey.ValueFrom.SecretKeyRef.Name) + assert.Equal(t, "secretKey", creds.genSecretKey.ValueFrom.SecretKeyRef.Key) + assert.Empty(t, creds.genSecretKey.Value) +} + +func TestGetAndReplaceWithPlainTextAccessKey(t *testing.T) { + // Test the case where access key is plain text but secret key is encrypted + cfg := ` +config: + providers: + aws: + accessKeyId: plainTextAccessKey + secretAccessKey: encrypted:k8s!n:testsecret!k:secretKey + enabled: true +` + spinCfg := &interfaces.SpinnakerConfig{} + assert.Nil(t, yaml.Unmarshal([]byte(cfg), spinCfg)) + + tr := &secretsTransformer{k8sSecrets: &k8sSecretHolder{awsCredsByService: map[string]*awsCredentials{}}} + + // Mock the k8s secret engine + secups.Engines["k8s"] = func(ctx context.Context, isFile bool, params string) (secups.Decrypter, error) { + _, k, err := secrets.ParseKubernetesSecretParams(params) + if err != nil { + return nil, err + } + return &test.DummyK8sSecretEngine{Secret: k}, nil + } + + // Test the getAndReplace function directly + creds, err := tr.getAndReplace("clouddriver", "providers.aws.accessKeyId", "providers.aws.secretAccessKey", spinCfg) + + assert.Nil(t, err) + assert.NotNil(t, creds) + + // Verify that the access key is configured as plain text (not a secret reference) + assert.Equal(t, "AWS_ACCESS_KEY_ID", creds.genAccessKey.Name) + assert.Nil(t, creds.genAccessKey.ValueFrom) // Should be nil since it's plain text + assert.Equal(t, "plainTextAccessKey", creds.genAccessKey.Value) + + // Verify that the secret key is configured as a secret reference + assert.Equal(t, "AWS_SECRET_ACCESS_KEY", creds.genSecretKey.Name) + assert.NotNil(t, creds.genSecretKey.ValueFrom) + assert.NotNil(t, creds.genSecretKey.ValueFrom.SecretKeyRef) + assert.Equal(t, "testsecret", creds.genSecretKey.ValueFrom.SecretKeyRef.Name) + assert.Equal(t, "secretKey", creds.genSecretKey.ValueFrom.SecretKeyRef.Key) + assert.Empty(t, creds.genSecretKey.Value) +} + +func TestGetAndReplaceWithMalformedEncryptedAccessKey(t *testing.T) { + // Test error handling for malformed encrypted access key references + cfg := ` +config: + providers: + aws: + accessKeyId: encrypted:k8s!malformed + secretAccessKey: encrypted:k8s!n:testsecret!k:secretKey + enabled: true +` + spinCfg := &interfaces.SpinnakerConfig{} + assert.Nil(t, yaml.Unmarshal([]byte(cfg), spinCfg)) + + tr := &secretsTransformer{k8sSecrets: &k8sSecretHolder{awsCredsByService: map[string]*awsCredentials{}}} + + // Mock the k8s secret engine + secups.Engines["k8s"] = func(ctx context.Context, isFile bool, params string) (secups.Decrypter, error) { + _, k, err := secrets.ParseKubernetesSecretParams(params) + if err != nil { + return nil, err + } + return &test.DummyK8sSecretEngine{Secret: k}, nil + } + + // Test the getAndReplace function directly - should return an error + creds, err := tr.getAndReplace("clouddriver", "providers.aws.accessKeyId", "providers.aws.secretAccessKey", spinCfg) + + assert.NotNil(t, err) + assert.Nil(t, creds) + assert.Contains(t, err.Error(), "malformed") // Error should mention malformed secret reference +} + +func TestEnvVarFromSecretReference(t *testing.T) { + // Direct unit test for the envVarFromSecretReference helper function + envVar := envVarFromSecretReference("TEST_VAR", "my-secret", "my-key") + + assert.Equal(t, "TEST_VAR", envVar.Name) + assert.Empty(t, envVar.Value) + assert.NotNil(t, envVar.ValueFrom) + assert.NotNil(t, envVar.ValueFrom.SecretKeyRef) + assert.Equal(t, "my-secret", envVar.ValueFrom.SecretKeyRef.Name) + assert.Equal(t, "my-key", envVar.ValueFrom.SecretKeyRef.Key) +} + +func TestEnvVarFromRawString(t *testing.T) { + // Direct unit test for the envVarFromRawString helper function + envVar := envVarFromRawString("TEST_VAR", "test-value") + + assert.Equal(t, "TEST_VAR", envVar.Name) + assert.Equal(t, "test-value", envVar.Value) + assert.Nil(t, envVar.ValueFrom) +} + +func TestEnvVarHelperFunctionsWithAwsCredentials(t *testing.T) { + // Test the helper functions with actual AWS credential environment variable names + + // Test secret reference for AWS_ACCESS_KEY_ID + accessKeyEnvVar := envVarFromSecretReference("AWS_ACCESS_KEY_ID", "aws-creds", "access-key") + assert.Equal(t, "AWS_ACCESS_KEY_ID", accessKeyEnvVar.Name) + assert.Equal(t, "aws-creds", accessKeyEnvVar.ValueFrom.SecretKeyRef.Name) + assert.Equal(t, "access-key", accessKeyEnvVar.ValueFrom.SecretKeyRef.Key) + + // Test secret reference for AWS_SECRET_ACCESS_KEY + secretKeyEnvVar := envVarFromSecretReference("AWS_SECRET_ACCESS_KEY", "aws-creds", "secret-key") + assert.Equal(t, "AWS_SECRET_ACCESS_KEY", secretKeyEnvVar.Name) + assert.Equal(t, "aws-creds", secretKeyEnvVar.ValueFrom.SecretKeyRef.Name) + assert.Equal(t, "secret-key", secretKeyEnvVar.ValueFrom.SecretKeyRef.Key) + + // Test plain text for AWS_ACCESS_KEY_ID + plainAccessKeyEnvVar := envVarFromRawString("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE") + assert.Equal(t, "AWS_ACCESS_KEY_ID", plainAccessKeyEnvVar.Name) + assert.Equal(t, "AKIAIOSFODNN7EXAMPLE", plainAccessKeyEnvVar.Value) + assert.Nil(t, plainAccessKeyEnvVar.ValueFrom) +} + +func TestClouddriverServiceVariantsCredentialDistribution(t *testing.T) { + // Test that AWS credentials are applied to all clouddriver service variants + cfg := ` +config: + artifacts: + s3: + accounts: + - awsAccessKeyId: artifactAccessKey + awsSecretAccessKey: encrypted:k8s!n:testsecret!k:artifactSecret + name: artifact-account + providers: + aws: + accessKeyId: providerAccessKey + secretAccessKey: encrypted:k8s!n:testsecret!k:providerSecret + enabled: true +` + spinCfg := &interfaces.SpinnakerConfig{} + assert.Nil(t, yaml.Unmarshal([]byte(cfg), spinCfg)) + + tr := &secretsTransformer{k8sSecrets: &k8sSecretHolder{awsCredsByService: map[string]*awsCredentials{}}} + + // Mock the k8s secret engine + secups.Engines["k8s"] = func(ctx context.Context, isFile bool, params string) (secups.Decrypter, error) { + _, k, err := secrets.ParseKubernetesSecretParams(params) + if err != nil { + return nil, err + } + return &test.DummyK8sSecretEngine{Secret: k}, nil + } + + ctx := secrets.NewContext(context.TODO(), nil, "") + assert.Nil(t, tr.replaceK8sSecretsFromAwsKeys(spinCfg, ctx)) + + // Verify credentials are applied to all clouddriver service variants + expectedServices := []string{"clouddriver", "clouddriver-ro", "clouddriver-rw", "clouddriver-ro-deck", "clouddriver-caching"} + + for _, svcName := range expectedServices { + creds, exists := tr.k8sSecrets.awsCredsByService[svcName] + assert.True(t, exists, "Credentials should exist for service %s", svcName) + assert.NotNil(t, creds, "Credentials should not be nil for service %s", svcName) + + // Verify the merged credentials contain both artifact and provider keys + // The artifact keys should be the generic keys (new credentials win in merge) + assert.Equal(t, "AWS_ACCESS_KEY_ID", creds.genAccessKey.Name) + assert.Equal(t, "artifactAccessKey", creds.genAccessKey.Value) + + // The artifact secret should also be the generic secret key (new credentials win in merge) + assert.Equal(t, "AWS_SECRET_ACCESS_KEY", creds.genSecretKey.Name) + assert.Equal(t, "artifactSecret", creds.genSecretKey.ValueFrom.SecretKeyRef.Key) + + // Should have service-specific secret keys from both artifact and provider + assert.GreaterOrEqual(t, len(creds.svcSecretKeys), 2, "Should have at least 2 service secret keys for %s", svcName) + } + + // Verify front50 credentials are separate and not affected + front50Creds, exists := tr.k8sSecrets.awsCredsByService["front50"] + assert.False(t, exists, "front50 should not have credentials in this test") + assert.Nil(t, front50Creds) +} + +func TestClouddriverProviderOnlyScenario(t *testing.T) { + // Test the provider-only fallback scenario (lines 107-112) + // This tests when only providers.aws exists but no artifacts.s3.accounts + cfg := ` +config: + providers: + aws: + accessKeyId: providerOnlyAccessKey + secretAccessKey: encrypted:k8s!n:testsecret!k:providerOnlySecret + enabled: true +` + spinCfg := &interfaces.SpinnakerConfig{} + assert.Nil(t, yaml.Unmarshal([]byte(cfg), spinCfg)) + + tr := &secretsTransformer{k8sSecrets: &k8sSecretHolder{awsCredsByService: map[string]*awsCredentials{}}} + + // Mock the k8s secret engine + secups.Engines["k8s"] = func(ctx context.Context, isFile bool, params string) (secups.Decrypter, error) { + _, k, err := secrets.ParseKubernetesSecretParams(params) + if err != nil { + return nil, err + } + return &test.DummyK8sSecretEngine{Secret: k}, nil + } + + ctx := secrets.NewContext(context.TODO(), nil, "") + assert.Nil(t, tr.replaceK8sSecretsFromAwsKeys(spinCfg, ctx)) + + // Verify provider credentials are applied to all clouddriver service variants + expectedServices := []string{"clouddriver", "clouddriver-ro", "clouddriver-rw", "clouddriver-ro-deck", "clouddriver-caching"} + + for _, svcName := range expectedServices { + creds, exists := tr.k8sSecrets.awsCredsByService[svcName] + assert.True(t, exists, "Provider credentials should exist for service %s", svcName) + assert.NotNil(t, creds, "Provider credentials should not be nil for service %s", svcName) + + // Verify the provider-only credentials + assert.Equal(t, "AWS_ACCESS_KEY_ID", creds.genAccessKey.Name) + assert.Equal(t, "providerOnlyAccessKey", creds.genAccessKey.Value) + + assert.Equal(t, "AWS_SECRET_ACCESS_KEY", creds.genSecretKey.Name) + assert.Equal(t, "providerOnlySecret", creds.genSecretKey.ValueFrom.SecretKeyRef.Key) + + // Should have exactly 1 service-specific secret key from provider + assert.Equal(t, 1, len(creds.svcSecretKeys), "Should have exactly 1 service secret key for %s", svcName) + } +} + +func TestClouddriverArtifactOnlyScenario(t *testing.T) { + // Test the artifact-only scenario (lines 95-106) + // This tests when only artifacts.s3.accounts exists but no providers.aws + cfg := ` +config: + artifacts: + s3: + accounts: + - awsAccessKeyId: artifactOnlyAccessKey + awsSecretAccessKey: encrypted:k8s!n:testsecret!k:artifactOnlySecret + name: artifact-only-account +` + spinCfg := &interfaces.SpinnakerConfig{} + assert.Nil(t, yaml.Unmarshal([]byte(cfg), spinCfg)) + + tr := &secretsTransformer{k8sSecrets: &k8sSecretHolder{awsCredsByService: map[string]*awsCredentials{}}} + + // Mock the k8s secret engine + secups.Engines["k8s"] = func(ctx context.Context, isFile bool, params string) (secups.Decrypter, error) { + _, k, err := secrets.ParseKubernetesSecretParams(params) + if err != nil { + return nil, err + } + return &test.DummyK8sSecretEngine{Secret: k}, nil + } + + ctx := secrets.NewContext(context.TODO(), nil, "") + assert.Nil(t, tr.replaceK8sSecretsFromAwsKeys(spinCfg, ctx)) + + // Verify artifact credentials are applied to all clouddriver service variants + expectedServices := []string{"clouddriver", "clouddriver-ro", "clouddriver-rw", "clouddriver-ro-deck", "clouddriver-caching"} + + for _, svcName := range expectedServices { + creds, exists := tr.k8sSecrets.awsCredsByService[svcName] + assert.True(t, exists, "Artifact credentials should exist for service %s", svcName) + assert.NotNil(t, creds, "Artifact credentials should not be nil for service %s", svcName) + + // Verify the artifact-only credentials + assert.Equal(t, "AWS_ACCESS_KEY_ID", creds.genAccessKey.Name) + assert.Equal(t, "artifactOnlyAccessKey", creds.genAccessKey.Value) + + assert.Equal(t, "AWS_SECRET_ACCESS_KEY", creds.genSecretKey.Name) + assert.Equal(t, "artifactOnlySecret", creds.genSecretKey.ValueFrom.SecretKeyRef.Key) + + // Should have exactly 1 service-specific secret key from artifact + assert.Equal(t, 1, len(creds.svcSecretKeys), "Should have exactly 1 service secret key for %s", svcName) + } +} + +func TestClouddriverMergingBehavior(t *testing.T) { + // Test the specific merging behavior when both provider and artifact keys exist + // This tests the merging logic in lines 98-99 + cfg := ` +config: + artifacts: + s3: + accounts: + - awsAccessKeyId: artifactAccessKey1 + awsSecretAccessKey: encrypted:k8s!n:testsecret!k:artifactSecret1 + name: artifact-account-1 + - awsAccessKeyId: artifactAccessKey2 + awsSecretAccessKey: encrypted:k8s!n:testsecret!k:artifactSecret2 + name: artifact-account-2 + providers: + aws: + accessKeyId: providerAccessKey + secretAccessKey: encrypted:k8s!n:testsecret!k:providerSecret + enabled: true +` + spinCfg := &interfaces.SpinnakerConfig{} + assert.Nil(t, yaml.Unmarshal([]byte(cfg), spinCfg)) + + tr := &secretsTransformer{k8sSecrets: &k8sSecretHolder{awsCredsByService: map[string]*awsCredentials{}}} + + // Mock the k8s secret engine + secups.Engines["k8s"] = func(ctx context.Context, isFile bool, params string) (secups.Decrypter, error) { + _, k, err := secrets.ParseKubernetesSecretParams(params) + if err != nil { + return nil, err + } + return &test.DummyK8sSecretEngine{Secret: k}, nil + } + + ctx := secrets.NewContext(context.TODO(), nil, "") + assert.Nil(t, tr.replaceK8sSecretsFromAwsKeys(spinCfg, ctx)) + + // Test one of the clouddriver service variants to verify merging behavior + creds, exists := tr.k8sSecrets.awsCredsByService["clouddriver-ro"] + assert.True(t, exists) + assert.NotNil(t, creds) + + // The generic keys should use artifact values (last one wins from getAndReplaceArray) + assert.Equal(t, "artifactAccessKey2", creds.genAccessKey.Value) + assert.Equal(t, "artifactSecret2", creds.genSecretKey.ValueFrom.SecretKeyRef.Key) + + // Should have service-specific keys from both artifact accounts AND provider + // Artifact keys come first (new), then provider keys (existing) + assert.Equal(t, 3, len(creds.svcSecretKeys), "Should have 3 service secret keys: 2 from artifacts + 1 from provider") + + // Verify all service variants have the same merged credentials + expectedServices := []string{"clouddriver", "clouddriver-rw", "clouddriver-ro-deck", "clouddriver-caching"} + for _, svcName := range expectedServices { + otherCreds, exists := tr.k8sSecrets.awsCredsByService[svcName] + assert.True(t, exists, "Credentials should exist for %s", svcName) + assert.Equal(t, creds.genAccessKey.Value, otherCreds.genAccessKey.Value, "Access key should match for %s", svcName) + assert.Equal(t, len(creds.svcSecretKeys), len(otherCreds.svcSecretKeys), "Service secret keys count should match for %s", svcName) + } +} + +func TestClouddriverNoAwsKeysScenario(t *testing.T) { + // Test when no AWS keys exist at all + // This should not create any clouddriver service entries + cfg := ` +config: + persistentStorage: + persistentStoreType: s3 + s3: + accessKeyId: persistenceAccessKey + secretAccessKey: encrypted:k8s!n:testsecret!k:persistenceSecret +` + spinCfg := &interfaces.SpinnakerConfig{} + assert.Nil(t, yaml.Unmarshal([]byte(cfg), spinCfg)) + + tr := &secretsTransformer{k8sSecrets: &k8sSecretHolder{awsCredsByService: map[string]*awsCredentials{}}} + + // Mock the k8s secret engine + secups.Engines["k8s"] = func(ctx context.Context, isFile bool, params string) (secups.Decrypter, error) { + _, k, err := secrets.ParseKubernetesSecretParams(params) + if err != nil { + return nil, err + } + return &test.DummyK8sSecretEngine{Secret: k}, nil + } + + ctx := secrets.NewContext(context.TODO(), nil, "") + assert.Nil(t, tr.replaceK8sSecretsFromAwsKeys(spinCfg, ctx)) + + // Verify no clouddriver service variants have credentials + clouddriverServices := []string{"clouddriver", "clouddriver-ro", "clouddriver-rw", "clouddriver-ro-deck", "clouddriver-caching"} + for _, svcName := range clouddriverServices { + creds, exists := tr.k8sSecrets.awsCredsByService[svcName] + assert.False(t, exists, "No credentials should exist for %s when no AWS keys are configured", svcName) + assert.Nil(t, creds, "Credentials should be nil for %s", svcName) + } + + // Verify front50 credentials exist (from persistence config) + front50Creds, exists := tr.k8sSecrets.awsCredsByService["front50"] + assert.True(t, exists, "front50 should have persistence credentials") + assert.NotNil(t, front50Creds) + assert.Equal(t, "persistenceAccessKey", front50Creds.genAccessKey.Value) +}