diff --git a/Dockerfile.rhtpa-operator.rh b/Dockerfile.rhtpa-operator.rh index 5ec2c0ba..61a2aa46 100644 --- a/Dockerfile.rhtpa-operator.rh +++ b/Dockerfile.rhtpa-operator.rh @@ -51,7 +51,7 @@ LABEL features.operators.openshift.io/proxy-aware="false" LABEL features.operators.openshift.io/cnf="false" LABEL features.operators.openshift.io/csi="false" LABEL features.operators.openshift.io/tls-profiles="false" -LABEL features.operators.openshift.io/token-auth-aws="false" +LABEL features.operators.openshift.io/token-auth-aws="true" LABEL features.operators.openshift.io/token-auth-azure="false" LABEL features.operators.openshift.io/token-auth-gcp="false" diff --git a/bundle.Dockerfile b/bundle.Dockerfile index 1b2a6946..79016b36 100644 --- a/bundle.Dockerfile +++ b/bundle.Dockerfile @@ -23,9 +23,9 @@ LABEL features.operators.openshift.io/proxy-aware="false" LABEL features.operators.openshift.io/cnf="false" LABEL features.operators.openshift.io/csi="false" LABEL features.operators.openshift.io/tls-profiles="false" -LABEL features.operators.openshift.io/token-auth-aws="false" +LABEL features.operators.openshift.io/token-auth-aws="true" LABEL features.operators.openshift.io/token-auth-azure="false" -LABEL features.operators.openshift.io/token-auth-gcp="false" +LABEL features.operators.openshift.io/token-auth-gcp="true" # Core bundle labels. LABEL operators.operatorframework.io.bundle.mediatype.v1=registry+v1 LABEL operators.operatorframework.io.bundle.manifests.v1=manifests/ diff --git a/bundle/manifests/rhtpa-operator.clusterserviceversion.yaml b/bundle/manifests/rhtpa-operator.clusterserviceversion.yaml index 13d81daa..35d999c2 100644 --- a/bundle/manifests/rhtpa-operator.clusterserviceversion.yaml +++ b/bundle/manifests/rhtpa-operator.clusterserviceversion.yaml @@ -103,9 +103,9 @@ metadata: features.operators.openshift.io/fips-compliant: "false" features.operators.openshift.io/proxy-aware: "false" features.operators.openshift.io/tls-profiles: "false" - features.operators.openshift.io/token-auth-aws: "false" + features.operators.openshift.io/token-auth-aws: "true" features.operators.openshift.io/token-auth-azure: "false" - features.operators.openshift.io/token-auth-gcp: "false" + features.operators.openshift.io/token-auth-gcp: "true" operators.openshift.io/valid-subscription: '["Red Hat Trusted Profile Analyzer"]' operators.operatorframework.io/builder: operator-sdk-v1.42.0 operators.operatorframework.io/project_layout: helm.sdk.operatorframework.io/v1 diff --git a/bundle/metadata/annotations.yaml b/bundle/metadata/annotations.yaml index 0e1d916e..4e9ec9b8 100644 --- a/bundle/metadata/annotations.yaml +++ b/bundle/metadata/annotations.yaml @@ -22,6 +22,6 @@ annotations: features.operators.openshift.io/cnf: "false" features.operators.openshift.io/csi: "false" features.operators.openshift.io/tls-profiles: "false" - features.operators.openshift.io/token-auth-aws: "false" + features.operators.openshift.io/token-auth-aws: "true" features.operators.openshift.io/token-auth-azure: "false" - features.operators.openshift.io/token-auth-gcp: "false" + features.operators.openshift.io/token-auth-gcp: "true" diff --git a/config/manifests/bases/rhtpa-operator.clusterserviceversion.yaml b/config/manifests/bases/rhtpa-operator.clusterserviceversion.yaml index c6f115c0..f9de2ef9 100644 --- a/config/manifests/bases/rhtpa-operator.clusterserviceversion.yaml +++ b/config/manifests/bases/rhtpa-operator.clusterserviceversion.yaml @@ -11,9 +11,9 @@ metadata: features.operators.openshift.io/fips-compliant: "false" features.operators.openshift.io/proxy-aware: "false" features.operators.openshift.io/tls-profiles: "false" - features.operators.openshift.io/token-auth-aws: "false" + features.operators.openshift.io/token-auth-aws: "true" features.operators.openshift.io/token-auth-azure: "false" - features.operators.openshift.io/token-auth-gcp: "false" + features.operators.openshift.io/token-auth-gcp: "true" operators.openshift.io/valid-subscription: '["Red Hat Trusted Profile Analyzer"]' name: rhtpa-operator.v0.0.0 namespace: placeholder diff --git a/config/rbac/clusterrole.yaml b/config/rbac/clusterrole.yaml new file mode 100644 index 00000000..e3b8134b --- /dev/null +++ b/config/rbac/clusterrole.yaml @@ -0,0 +1,8 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: cco-credentialsrequest-access +rules: + - apiGroups: ["cloudcredential.openshift.io"] + resources: ["credentialsrequests"] + verbs: ["create", "delete", "get", "list", "patch", "update", "watch"] diff --git a/config/rbac/clusterrolebinding_cco.yaml b/config/rbac/clusterrolebinding_cco.yaml new file mode 100644 index 00000000..82d13e0a --- /dev/null +++ b/config/rbac/clusterrolebinding_cco.yaml @@ -0,0 +1,15 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + app.kubernetes.io/name: rhtpa-operator + app.kubernetes.io/managed-by: kustomize + name: cco-credentialsrequest-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cco-credentialsrequest-access +subjects: + - kind: ServiceAccount + name: rhtpa-operator-controller-manager + namespace: placeholder diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index 162b99f4..71823f14 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -15,6 +15,8 @@ resources: - rolebinding_job.yaml - role_cluster_ingress.yaml - role_cluster_ingress_binding.yaml +- clusterrole.yaml +- clusterrolebinding_cco.yaml # The following RBAC configurations are used to protect # the metrics endpoint with authn/authz. These configurations # ensure that only authorized users and service accounts diff --git a/devel/cco/credentialRequest.yaml b/devel/cco/credentialRequest.yaml new file mode 100644 index 00000000..4f349f08 --- /dev/null +++ b/devel/cco/credentialRequest.yaml @@ -0,0 +1,24 @@ +apiVersion: cloudcredential.openshift.io/v1 +kind: CredentialsRequest +metadata: + name: my-operator-credentials + namespace: openshift-cloud-credential-operator +spec: + secretRef: + name: my-cloud-creds + namespace: my-operator-namespace + providerSpec: + apiVersion: cloudcredential.openshift.io/v1 + kind: AWSProviderSpec # AWS + statementEntries: + - effect: Allow + action: + - "s3:GetObject" + - "s3:PutObject" + - "s3:DeleteObject" + - "s3:ListBucket" + - "s3:GetBucketLocation" + - "s3:ListBucketMultipartUploads" + - "s3:AbortMultipartUpload" + - "s3:ListMultipartUploadParts" + resource: "*" diff --git a/extensions/README.md b/extensions/README.md new file mode 100644 index 00000000..5b7f57c9 --- /dev/null +++ b/extensions/README.md @@ -0,0 +1,100 @@ +# Tokenize with Cloud Credentials Operator + +Cloud Credentials Operator (CCO) is installed by default on OCP. +To check the CCO status + +```console +oc get clusteroperator cloud-credential +``` +it shows something like + +```console +NAME VERSION AVAILABLE PROGRESSING DEGRADED SINCE +cloud-credential 4.x.x True False False ... +``` +Pod status +```console +oc get pods -n openshift-cloud-credential-operator +``` + +Credential requests checks +```console +oc get credentialsrequests -n openshift-cloud-credential-operator +``` + +CCO details +```console +oc describe clusteroperator cloud-credential +``` + +CCO Modality +```console +oc get cloudcredential cluster -o yaml +``` +On spec.credentialsMode will be the configured setting (Mint, +Passthrough, Manual, or empty for default). + +## How the Operator interacts with the CCO + +1. Operator declares permissions needed in a CredentialsRequest CR in the namespace openshift-cloud-credential-operator + +```console + apiVersion: cloudcredential.openshift.io/v1 + kind: CredentialsRequest + metadata: + name: my-operator-credentials + namespace: openshift-cloud-credential-operator + spec: + secretRef: + name: my-cloud-creds + namespace: my-operator-namespace + providerSpec: + apiVersion: cloudcredential.openshift.io/v1 + kind: AWSProviderSpec # esempio per AWS + statementEntries: + - effect: Allow + action: + - "s3:GetObject" + - "s3:PutObject" + resource: "*" +``` + +2. CCO processes the CR and create a Kubernetes Secret with the cloud credentails + in the namespace specified in spec.secretRef. + +3. Operator reads the Secret and uses the credentials to interact with the cloud API. + The Operator must tolerate the non-immediate availability of the Secret because it takes time to create. + +## How to integrate the CCO with the Helm Chart Operator + +1. Define Credential Request in the chart +2. +2. Configure Deployment to use the secret created by the CCO + +3. Handling the delay of the creation of the secret + first approach: init container +```console + initContainers: + - name: wait-for-creds + image: registry.redhat.io/openshift4/ose-cli + command: + - /bin/bash + - -c + - | + until oc get secret {{ .Release.Name }}-cloud-creds -n {{ + .Release.Namespace }} 2>/dev/null; do + echo "Waiting for cloud credentials..." + sleep 5 + done +``` + second approach Retry in the Operator's code, but this is available on a full Go Operator + +4. Support different cloud providers with values configurations + +5. RBAC needed to create the CredentialsRequest +6. Support Manual Mode (STS/WIF): if the cluster uses STS mode, CCO doesn’t create the secret automatically. + In STS mode the user must : + 1. Extract CredentialsRequest from chart + 2. Use ccoctl tool to generate the credentials + 3. Create the secrets manually before installing the chart. + diff --git a/helm-charts/redhat-trusted-profile-analyzer/templates/credentialrequest.yaml b/helm-charts/redhat-trusted-profile-analyzer/templates/credentialrequest.yaml new file mode 100644 index 00000000..3a418326 --- /dev/null +++ b/helm-charts/redhat-trusted-profile-analyzer/templates/credentialrequest.yaml @@ -0,0 +1,33 @@ +{{- if .Values.cloudProvider }} +apiVersion: cloudcredential.openshift.io/v1 +kind: CredentialsRequest +metadata: + name: {{ .Release.Name }}-cloud-creds + namespace: openshift-cloud-credential-operator +spec: + secretRef: + name: {{ .Release.Name }}-cloud-creds + namespace: {{ .Release.Namespace }} + {{- if eq (toString .Values.ccoMode) "manual" }} + cloudTokenPath: /var/run/secrets/openshift/serviceaccount/token + {{- end }} + {{- if eq .Values.cloudProvider "aws" }} + providerSpec: + apiVersion: cloudcredential.openshift.io/v1 + kind: AWSProviderSpec + statementEntries: + {{- toYaml .Values.cloudCredentials.aws.statementEntries | nindent 6 }} + {{- if eq (toString .Values.ccoMode) "manual" }} + stsIAMRoleARN: {{ .Values.cloudCredentials.aws.stsIAMRoleARN | default "" | quote }} + {{- end }} + {{- else if eq .Values.cloudProvider "gcp" }} + providerSpec: + apiVersion: cloudcredential.openshift.io/v1 + kind: GCPProviderSpec + predefinedRoles: + {{- toYaml .Values.cloudCredentials.gcp.permissions | nindent 6 }} + {{- if eq (toString .Values.ccoMode) "manual" }} + serviceAccountEmail: {{ .Values.cloudCredentials.gcp.serviceAccountEmail | default "" | quote }} + {{- end }} + {{- end }} +{{- end }} diff --git a/helm-charts/redhat-trusted-profile-analyzer/templates/helpers/_cco.tpl b/helm-charts/redhat-trusted-profile-analyzer/templates/helpers/_cco.tpl new file mode 100644 index 00000000..8a51392d --- /dev/null +++ b/helm-charts/redhat-trusted-profile-analyzer/templates/helpers/_cco.tpl @@ -0,0 +1,78 @@ +{{/* +Cloud Credentials Operator (CCO) helpers for manual/STS/WIF mode. + +These templates emit volumes, volume mounts, and environment variables +needed when ccoMode is "manual". For mint/passthrough/default modes +they emit nothing — credentials are handled via _storage.tpl. + +Arguments (dict): + * root - . +*/}} + +{{/* +Returns "true" when CCO manual mode is active (cloudProvider set AND ccoMode is "manual"). +*/}} +{{- define "trustification.cco.isManualMode" -}} +{{- if and .root.Values.cloudProvider (eq (toString .root.Values.ccoMode) "manual") -}}true{{- end -}} +{{- end -}} + +{{/* +Returns the CCO secret name. +*/}} +{{- define "trustification.cco.secretName" -}} +{{ .root.Release.Name }}-cloud-creds +{{- end -}} + +{{/* +Volumes for CCO manual mode: CCO credentials secret + projected SA token. +*/}} +{{- define "trustification.cco.volumes" -}} +{{- if eq (include "trustification.cco.isManualMode" .) "true" }} +- name: cloud-credentials + secret: + secretName: {{ include "trustification.cco.secretName" . }} +- name: bound-sa-token + projected: + sources: + - serviceAccountToken: + audience: openshift + expirationSeconds: 3600 + path: token +{{- end }} +{{- end -}} + +{{/* +Volume mounts for CCO manual mode. +*/}} +{{- define "trustification.cco.volumeMounts" -}} +{{- if eq (include "trustification.cco.isManualMode" .) "true" }} +- name: cloud-credentials + mountPath: /var/run/secrets/cloud + readOnly: true +- name: bound-sa-token + mountPath: /var/run/secrets/openshift/serviceaccount + readOnly: true +{{- end }} +{{- end -}} + +{{/* +Environment variables for CCO manual mode, branched by cloud provider. +For mint/passthrough/default modes this emits nothing. +*/}} +{{- define "trustification.cco.envVars" -}} +{{- if eq (include "trustification.cco.isManualMode" .) "true" }} +{{- if eq .root.Values.cloudProvider "aws" }} +- name: AWS_SHARED_CREDENTIALS_FILE + value: /var/run/secrets/cloud/credentials +- name: AWS_WEB_IDENTITY_TOKEN_FILE + value: /var/run/secrets/openshift/serviceaccount/token +{{- with .root.Values.cloudCredentials.aws.stsIAMRoleARN }} +- name: AWS_ROLE_ARN + value: {{ . | quote }} +{{- end }} +{{- else if eq .root.Values.cloudProvider "gcp" }} +- name: GOOGLE_APPLICATION_CREDENTIALS + value: /var/run/secrets/cloud/service_account.json +{{- end }} +{{- end }} +{{- end -}} diff --git a/helm-charts/redhat-trusted-profile-analyzer/templates/helpers/_storage.tpl b/helm-charts/redhat-trusted-profile-analyzer/templates/helpers/_storage.tpl index e86f9753..a499b36e 100644 --- a/helm-charts/redhat-trusted-profile-analyzer/templates/helpers/_storage.tpl +++ b/helm-charts/redhat-trusted-profile-analyzer/templates/helpers/_storage.tpl @@ -50,10 +50,26 @@ Arguments (dict): - name: TRUSTD_STORAGE_STRATEGY value: s3 +{{- if not (eq (include "trustification.cco.isManualMode" .) "true") }} - name: TRUSTD_S3_ACCESS_KEY + {{- if and .root.Values.cloudProvider (not .storage.accessKey) }} + valueFrom: + secretKeyRef: + name: {{ .root.Release.Name }}-cloud-creds + key: aws_access_key_id + {{- else }} {{- include "trustification.common.envVarValue" .storage.accessKey | nindent 2 }} + {{- end }} - name: TRUSTD_S3_SECRET_KEY + {{- if and .root.Values.cloudProvider (not .storage.secretKey) }} + valueFrom: + secretKeyRef: + name: {{ .root.Release.Name }}-cloud-creds + key: aws_secret_access_key + {{- else }} {{- include "trustification.common.envVarValue" .storage.secretKey | nindent 2 }} + {{- end }} +{{- end }} - name: TRUSTD_S3_REGION {{- include "trustification.common.envVarValue" .storage.region | nindent 2 }} - name: TRUSTD_S3_BUCKET diff --git a/helm-charts/redhat-trusted-profile-analyzer/templates/services/importer/030-Deployment.yaml b/helm-charts/redhat-trusted-profile-analyzer/templates/services/importer/030-Deployment.yaml index cc2be05f..42dbf73b 100644 --- a/helm-charts/redhat-trusted-profile-analyzer/templates/services/importer/030-Deployment.yaml +++ b/helm-charts/redhat-trusted-profile-analyzer/templates/services/importer/030-Deployment.yaml @@ -49,6 +49,7 @@ spec: {{- include "trustification.application.infrastructure.envVars" $mod | nindent 12 }} {{- include "trustification.postgres.envVars" (dict "root" . "database" .Values.database) | nindent 12 }} {{- include "trustification.storage.envVars" $mod | nindent 12 }} + {{- include "trustification.cco.envVars" $mod | nindent 12 }} ports: {{- include "trustification.application.infrastructure.podPorts" $mod | nindent 12 }} @@ -57,6 +58,7 @@ spec: - name: workdir mountPath: /data/workdir {{- include "trustification.storage.volumeMount" $mod | nindent 12 }} + {{- include "trustification.cco.volumeMounts" $mod | nindent 12 }} {{- include "trustification.application.extraVolumeMounts" $mod | nindent 12 }} volumes: @@ -75,6 +77,7 @@ spec: requests: storage: {{ $mod.module.workingDirectory.size | quote }} {{- include "trustification.storage.volume" $mod | nindent 8 }} + {{- include "trustification.cco.volumes" $mod | nindent 8 }} {{- include "trustification.application.extraVolumes" $mod | nindent 8 }} {{ end }} diff --git a/helm-charts/redhat-trusted-profile-analyzer/templates/services/server/030-Deployment.yaml b/helm-charts/redhat-trusted-profile-analyzer/templates/services/server/030-Deployment.yaml index db9bd8fd..a0f726ff 100644 --- a/helm-charts/redhat-trusted-profile-analyzer/templates/services/server/030-Deployment.yaml +++ b/helm-charts/redhat-trusted-profile-analyzer/templates/services/server/030-Deployment.yaml @@ -51,6 +51,7 @@ spec: {{- include "trustification.application.httpServer.envVars" $mod | nindent 12 }} {{- include "trustification.postgres.envVars" (dict "root" . "database" .Values.database) | nindent 12 }} {{- include "trustification.storage.envVars" $mod | nindent 12 }} + {{- include "trustification.cco.envVars" $mod | nindent 12 }} {{- include "trustification.oidc.swaggerUi" $mod | nindent 12 }} - name: UI_ISSUER_URL @@ -93,12 +94,14 @@ spec: {{- include "trustification.application.httpServerVolumesMounts" $mod | nindent 12 }} {{- include "trustification.authenticator.volumeMount" $mod | nindent 12 }} {{- include "trustification.storage.volumeMount" $mod | nindent 12 }} + {{- include "trustification.cco.volumeMounts" $mod | nindent 12 }} {{- include "trustification.application.extraVolumeMounts" $mod | nindent 12 }} volumes: {{- include "trustification.application.httpServerVolumes" $mod | nindent 8 }} {{- include "trustification.authenticator.volume" $mod | nindent 8 }} {{- include "trustification.storage.volume" $mod | nindent 8 }} + {{- include "trustification.cco.volumes" $mod | nindent 8 }} {{- include "trustification.application.extraVolumes" $mod | nindent 8 }} {{ end }} diff --git a/helm-charts/redhat-trusted-profile-analyzer/values.schema.json b/helm-charts/redhat-trusted-profile-analyzer/values.schema.json index efbfb1f8..b5c692cc 100644 --- a/helm-charts/redhat-trusted-profile-analyzer/values.schema.json +++ b/helm-charts/redhat-trusted-profile-analyzer/values.schema.json @@ -385,6 +385,74 @@ }, "prometheus": { "$ref": "#/definitions/Feature" + }, + "ccoMode": { + "type": "string", + "enum": [ + "default", + "mint", + "passthrough", + "manual" + ], + "description": "Cloud Credential Operator mode. Controls how cloud credentials are provisioned.\n- default: CCO auto-determines the provisioning method\n- mint: CCO creates new IAM credentials from permissions in CredentialsRequest\n- passthrough: CCO copies admin credentials to the target namespace\n- manual: Credentials are pre-provisioned via ccoctl (STS/WIF). Pods are automatically configured with projected SA token volumes and SDK env vars.\n" + }, + "cloudProvider": { + "type": "string", + "enum": [ + "aws", + "gcp" + ], + "description": "Set the cloud provider to enable Cloud Credentials Operator (CCO) integration.\nWhen set, a CredentialsRequest will be created and the resulting secret will be\nused for S3 storage credentials (accessKey and secretKey become optional).\n" + }, + "cloudCredentials": { + "type": "object", + "description": "Cloud-specific credential configuration for the Cloud Credentials Operator.\n", + "properties": { + "aws": { + "type": "object", + "properties": { + "statementEntries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "effect": { + "type": "string" + }, + "action": { + "type": "array", + "items": { + "type": "string" + } + }, + "resource": { + "type": "string" + } + } + } + }, + "stsIAMRoleARN": { + "type": "string", + "description": "The ARN of the IAM role for STS-based authentication (manual mode only).\n" + } + } + }, + "gcp": { + "type": "object", + "properties": { + "permissions": { + "type": "array", + "items": { + "type": "string" + } + }, + "serviceAccountEmail": { + "type": "string", + "description": "The GCP service account email for Workload Identity Federation (manual mode only).\n" + } + } + } + } } }, "allOf": [ @@ -971,12 +1039,10 @@ } }, "S3StorageConfig": { - "description": "Configure an S3 compatible object storage.\n", + "description": "Configure an S3 compatible object storage.\nWhen cloudProvider is set, accessKey and secretKey are automatically populated\nfrom the CCO-provisioned secret and do not need to be specified.\n", "type": "object", "required": [ "type", - "accessKey", - "secretKey", "bucket", "region" ], diff --git a/helm-charts/redhat-trusted-profile-analyzer/values.schema.yaml b/helm-charts/redhat-trusted-profile-analyzer/values.schema.yaml index 5c4f74ba..74c82d16 100644 --- a/helm-charts/redhat-trusted-profile-analyzer/values.schema.yaml +++ b/helm-charts/redhat-trusted-profile-analyzer/values.schema.yaml @@ -64,6 +64,69 @@ properties: description: | Control the usage of the OpenShift service CA. + ccoMode: + type: string + enum: + - default + - mint + - passthrough + - manual + description: | + Cloud Credential Operator mode. Controls how cloud credentials are provisioned. + - default: CCO auto-determines the provisioning method + - mint: CCO creates new IAM credentials from permissions in CredentialsRequest + - passthrough: CCO copies admin credentials to the target namespace + - manual: Credentials are pre-provisioned via ccoctl (STS/WIF). Pods are + automatically configured with projected SA token volumes and SDK env vars. + + cloudProvider: + type: string + enum: + - aws + - gcp + description: | + Set the cloud provider to enable Cloud Credentials Operator (CCO) integration. + When set, a CredentialsRequest will be created and the resulting secret will be + used for S3 storage credentials (accessKey and secretKey become optional). + + cloudCredentials: + type: object + description: | + Cloud-specific credential configuration for the Cloud Credentials Operator. + properties: + aws: + type: object + properties: + statementEntries: + type: array + items: + type: object + properties: + effect: + type: string + enum: [Allow, Deny] + action: + type: array + items: + type: string + resource: + type: string + stsIAMRoleARN: + type: string + description: | + The ARN of the IAM role for STS-based authentication (manual mode only). + gcp: + type: object + properties: + permissions: + type: array + items: + type: string + serviceAccountEmail: + type: string + description: | + The GCP service account email for Workload Identity Federation (manual mode only). + oidc: $ref: "#/definitions/Oidc" @@ -742,11 +805,11 @@ definitions: S3StorageConfig: description: | Configure an S3 compatible object storage. + When cloudProvider is set, accessKey and secretKey are automatically populated + from the CCO-provisioned secret and do not need to be specified. type: object required: - type - - accessKey - - secretKey - bucket - region additionalProperties: false @@ -757,11 +820,13 @@ definitions: - s3 accessKey: description: | - The access key/username to the storage resource + The access key/username to the storage resource. + Optional when cloudProvider is set (auto-populated from CCO secret). $ref: "#/definitions/ValueOrRef" secretKey: description: | - The secret key/password to the storage resource + The secret key/password to the storage resource. + Optional when cloudProvider is set (auto-populated from CCO secret). $ref: "#/definitions/ValueOrRef" bucket: type: string diff --git a/helm-charts/redhat-trusted-profile-analyzer/values.yaml b/helm-charts/redhat-trusted-profile-analyzer/values.yaml index 169c8710..c9b09c25 100644 --- a/helm-charts/redhat-trusted-profile-analyzer/values.yaml +++ b/helm-charts/redhat-trusted-profile-analyzer/values.yaml @@ -17,6 +17,21 @@ storage: {} database: {} +# ccoMode: "" # default | mint | passthrough | manual + +# cloudProvider: aws # aws | gcp — set to enable CCO-managed credentials for S3 storage +cloudCredentials: {} + #aws: + # statementEntries: + # - effect: Allow + # action: [ "s3:*" ] + # resource: "*" + # stsIAMRoleARN: "" # Required for manual mode (STS) + #gcp: + # permissions: + # - storage.objects.get + # serviceAccountEmail: "" # Required for manual mode (WIF) + openshift: useServiceCa: true diff --git a/test/e2e/cco_helm_rendering_test.go b/test/e2e/cco_helm_rendering_test.go new file mode 100644 index 00000000..044e809a --- /dev/null +++ b/test/e2e/cco_helm_rendering_test.go @@ -0,0 +1,642 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +const ( + fieldCloudProvider = "cloudProvider" + fieldCloudCredentials = "cloudCredentials" + fieldCcoMode = "ccoMode" + kindCredentialsReq = "CredentialsRequest" + ccoSecretName = "test-release-cloud-creds" + ccoNamespace = "openshift-cloud-credential-operator" + testBucket = "trustify-storage" + testRegionUSEast1 = "us-east-1" + testSTSRoleARN = "arn:aws:iam::123456789012:role/trustify-s3-role" + testGCPServiceAccountEmail = "trustify-sa@my-project.iam.gserviceaccount.com" + cloudProviderAWS = "aws" + cloudProviderGCP = "gcp" +) + +func testDatabaseValues() map[string]interface{} { + return map[string]interface{}{ + "host": "postgres.test.svc", + fieldName: "testdb", + "username": "testuser", + "password": "testpass", + } +} + +func awsValues() map[string]interface{} { + return map[string]interface{}{ + fieldAppDomain: testAppDomain, + fieldCloudProvider: cloudProviderAWS, + fieldCloudCredentials: map[string]interface{}{ + cloudProviderAWS: map[string]interface{}{ + "statementEntries": []interface{}{ + map[string]interface{}{ + "effect": "Allow", + "action": []interface{}{"s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListBucket"}, + "resource": "*", + }, + }, + }, + }, + fieldStorage: map[string]interface{}{ + fieldType: "s3", + fieldBucket: testBucket, + fieldRegion: testRegionUSEast1, + }, + fieldDatabase: testDatabaseValues(), + fieldMetrics: map[string]interface{}{fieldEnabled: false}, + fieldTracing: map[string]interface{}{fieldEnabled: false}, + } +} + +func gcpValues() map[string]interface{} { + return map[string]interface{}{ + fieldAppDomain: testAppDomain, + fieldCloudProvider: cloudProviderGCP, + fieldCloudCredentials: map[string]interface{}{ + cloudProviderGCP: map[string]interface{}{ + "permissions": []interface{}{ + "storage.objects.get", + "storage.objects.create", + "storage.objects.delete", + "storage.buckets.get", + }, + }, + }, + fieldStorage: map[string]interface{}{ + fieldType: "s3", + fieldBucket: testBucket, + fieldRegion: "us-central1", + }, + fieldDatabase: testDatabaseValues(), + fieldMetrics: map[string]interface{}{fieldEnabled: false}, + fieldTracing: map[string]interface{}{fieldEnabled: false}, + } +} + +func gcpManualValues() map[string]interface{} { + return map[string]interface{}{ + fieldAppDomain: testAppDomain, + fieldCloudProvider: cloudProviderGCP, + fieldCcoMode: "manual", + fieldCloudCredentials: map[string]interface{}{ + cloudProviderGCP: map[string]interface{}{ + "permissions": []interface{}{ + "storage.objects.get", + "storage.objects.create", + "storage.objects.delete", + "storage.buckets.get", + }, + "serviceAccountEmail": testGCPServiceAccountEmail, + }, + }, + fieldStorage: map[string]interface{}{ + fieldType: "s3", + fieldBucket: testBucket, + fieldRegion: "us-central1", + }, + fieldDatabase: testDatabaseValues(), + fieldMetrics: map[string]interface{}{fieldEnabled: false}, + fieldTracing: map[string]interface{}{fieldEnabled: false}, + } +} + +func awsManualValues() map[string]interface{} { + return map[string]interface{}{ + fieldAppDomain: testAppDomain, + fieldCloudProvider: cloudProviderAWS, + fieldCcoMode: "manual", + fieldCloudCredentials: map[string]interface{}{ + cloudProviderAWS: map[string]interface{}{ + "statementEntries": []interface{}{ + map[string]interface{}{ + "effect": "Allow", + "action": []interface{}{"s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListBucket"}, + "resource": "*", + }, + }, + "stsIAMRoleARN": testSTSRoleARN, + }, + }, + fieldStorage: map[string]interface{}{ + fieldType: "s3", + fieldBucket: testBucket, + fieldRegion: testRegionUSEast1, + }, + fieldDatabase: testDatabaseValues(), + fieldMetrics: map[string]interface{}{fieldEnabled: false}, + fieldTracing: map[string]interface{}{fieldEnabled: false}, + } +} + +func awsPassthroughValues() map[string]interface{} { + v := awsValues() + v[fieldCcoMode] = "passthrough" + return v +} + +func awsDefaultValues() map[string]interface{} { + v := awsValues() + v[fieldCcoMode] = "default" + return v +} + +// parseYAMLDoc parses a YAML document via JSON round-trip so that numeric +// types are float64 (compatible with k8s unstructured helpers). +func parseYAMLDoc(doc string) (map[string]interface{}, error) { + var intermediate interface{} + if err := yaml.Unmarshal([]byte(doc), &intermediate); err != nil { + return nil, err + } + jsonBytes, err := json.Marshal(intermediate) + if err != nil { + return nil, err + } + var result map[string]interface{} + if err := json.Unmarshal(jsonBytes, &result); err != nil { + return nil, err + } + return result, nil +} + +func findCredentialsRequest(t *testing.T, docs []string) map[string]interface{} { + t.Helper() + for _, doc := range docs { + obj, err := parseYAMLDoc(doc) + if err != nil { + continue + } + if obj["kind"] == kindCredentialsReq { + return obj + } + } + return nil +} + +func nestedString(obj map[string]interface{}, keys ...string) string { + current := obj + for i, key := range keys { + if i == len(keys)-1 { + v, _ := current[key].(string) + return v + } + next, ok := current[key].(map[string]interface{}) + if !ok { + return "" + } + current = next + } + return "" +} + +func TestHelmRenderAWSCredentialsRequest(t *testing.T) { + if testing.Short() { + t.Skip(skipE2ETest) + } + + chartPath := getChartPath(t) + rendered := renderHelmChart(t, chartPath, awsValues()) + docs := splitYAMLDocs(rendered) + + cr := findCredentialsRequest(t, docs) + require.NotNil(t, cr, "CredentialsRequest should be rendered for AWS") + + metadata, _ := cr[fieldMetadata].(map[string]interface{}) + require.NotNil(t, metadata, "metadata should exist") + assert.Equal(t, ccoSecretName, metadata[fieldName], "CredentialsRequest name should match release") + assert.Equal(t, ccoNamespace, metadata[fieldNamespace], "CredentialsRequest should be in CCO namespace") + + spec, _ := cr[fieldSpec].(map[string]interface{}) + require.NotNil(t, spec, "spec should exist") + + assert.Equal(t, ccoSecretName, nestedString(spec, "secretRef", fieldName), "secretRef.name should match") + + providerSpec, _ := spec["providerSpec"].(map[string]interface{}) + require.NotNil(t, providerSpec, "providerSpec should exist") + assert.Equal(t, "AWSProviderSpec", providerSpec["kind"], "providerSpec should be AWSProviderSpec") + assert.Equal(t, "cloudcredential.openshift.io/v1", + providerSpec[fieldAPIVersion], "providerSpec apiVersion should match") + + entries, ok := providerSpec["statementEntries"].([]interface{}) + assert.True(t, ok, "statementEntries should be a list") + assert.NotEmpty(t, entries, "statementEntries should not be empty") + + assert.NotContains(t, rendered, "GCPProviderSpec", "AWS config should not contain GCP provider") + assert.NotContains(t, rendered, "predefinedRoles", "AWS config should not contain predefinedRoles") +} + +func TestHelmRenderGCPCredentialsRequest(t *testing.T) { + if testing.Short() { + t.Skip(skipE2ETest) + } + + chartPath := getChartPath(t) + rendered := renderHelmChart(t, chartPath, gcpValues()) + docs := splitYAMLDocs(rendered) + + cr := findCredentialsRequest(t, docs) + require.NotNil(t, cr, "CredentialsRequest should be rendered for GCP") + + metadata, _ := cr[fieldMetadata].(map[string]interface{}) + require.NotNil(t, metadata, "metadata should exist") + assert.Equal(t, ccoSecretName, metadata[fieldName], "CredentialsRequest name should match release") + assert.Equal(t, ccoNamespace, metadata[fieldNamespace], "CredentialsRequest should be in CCO namespace") + + spec, _ := cr[fieldSpec].(map[string]interface{}) + require.NotNil(t, spec, "spec should exist") + + providerSpec, _ := spec["providerSpec"].(map[string]interface{}) + require.NotNil(t, providerSpec, "providerSpec should exist") + assert.Equal(t, "GCPProviderSpec", providerSpec["kind"], "providerSpec should be GCPProviderSpec") + + roles, ok := providerSpec["predefinedRoles"].([]interface{}) + assert.True(t, ok, "predefinedRoles should be a list") + assert.NotEmpty(t, roles, "predefinedRoles should not be empty") + + assert.NotContains(t, rendered, "AWSProviderSpec", "GCP config should not contain AWS provider") + assert.NotContains(t, rendered, "statementEntries", "GCP config should not contain statementEntries") +} + +func TestHelmRenderNoCredentialsRequestWithoutCloudProvider(t *testing.T) { + if testing.Short() { + t.Skip(skipE2ETest) + } + + chartPath := getChartPath(t) + values := map[string]interface{}{ + fieldAppDomain: testAppDomain, + fieldStorage: map[string]interface{}{ + fieldType: "s3", + fieldBucket: "test-bucket", + fieldRegion: testRegionUSEast1, + "accessKey": "test-key", + "secretKey": "test-secret", + }, + fieldDatabase: testDatabaseValues(), + fieldMetrics: map[string]interface{}{fieldEnabled: false}, + fieldTracing: map[string]interface{}{fieldEnabled: false}, + } + + rendered := renderHelmChart(t, chartPath, values) + assert.NotEmpty(t, rendered, "chart should render without cloudProvider") + + docs := splitYAMLDocs(rendered) + cr := findCredentialsRequest(t, docs) + assert.Nil(t, cr, "CredentialsRequest should NOT be rendered without cloudProvider") +} + +func TestHelmRenderStorageEnvVarsWithCCO(t *testing.T) { + if testing.Short() { + t.Skip(skipE2ETest) + } + + chartPath := getChartPath(t) + rendered := renderHelmChart(t, chartPath, awsValues()) + + assert.Contains(t, rendered, ccoSecretName, + "rendered output should reference CCO secret") + assert.Contains(t, rendered, "aws_access_key_id", + "rendered output should reference aws_access_key_id key") + assert.Contains(t, rendered, "aws_secret_access_key", + "rendered output should reference aws_secret_access_key key") + + docs := splitYAMLDocs(rendered) + for _, doc := range docs { + obj, err := parseYAMLDoc(doc) + if err != nil { + continue + } + if obj["kind"] != "Deployment" { + continue + } + metadata, _ := obj[fieldMetadata].(map[string]interface{}) + if metadata == nil || !strings.Contains(metadata[fieldName].(string), fieldServer) { + continue + } + + spec, _ := obj[fieldSpec].(map[string]interface{}) + template, _ := spec["template"].(map[string]interface{}) + templateSpec, _ := template[fieldSpec].(map[string]interface{}) + containers, _ := templateSpec["containers"].([]interface{}) + require.NotEmpty(t, containers, "server deployment should have containers") + + container, _ := containers[0].(map[string]interface{}) + envList, _ := container["env"].([]interface{}) + require.NotEmpty(t, envList, "container should have env vars") + + accessKeyFound := false + secretKeyFound := false + for _, e := range envList { + env, ok := e.(map[string]interface{}) + if !ok { + continue + } + envName, _ := env[fieldName].(string) + if envName == "TRUSTD_S3_ACCESS_KEY" { + refName := nestedString(env, "valueFrom", "secretKeyRef", fieldName) + refKey := nestedString(env, "valueFrom", "secretKeyRef", "key") + assert.Equal(t, ccoSecretName, refName, "ACCESS_KEY should reference CCO secret") + assert.Equal(t, "aws_access_key_id", refKey, "ACCESS_KEY should use correct key") + accessKeyFound = true + } + if envName == "TRUSTD_S3_SECRET_KEY" { + refName := nestedString(env, "valueFrom", "secretKeyRef", fieldName) + refKey := nestedString(env, "valueFrom", "secretKeyRef", "key") + assert.Equal(t, ccoSecretName, refName, "SECRET_KEY should reference CCO secret") + assert.Equal(t, "aws_secret_access_key", refKey, "SECRET_KEY should use correct key") + secretKeyFound = true + } + } + assert.True(t, accessKeyFound, "TRUSTD_S3_ACCESS_KEY env var should be present") + assert.True(t, secretKeyFound, "TRUSTD_S3_SECRET_KEY env var should be present") + return + } + t.Fatal("server deployment not found in rendered output") +} + +func TestHelmRenderStorageEnvVarsWithoutCCO(t *testing.T) { + if testing.Short() { + t.Skip(skipE2ETest) + } + + chartPath := getChartPath(t) + values := map[string]interface{}{ + fieldAppDomain: testAppDomain, + fieldStorage: map[string]interface{}{ + fieldType: "s3", + fieldBucket: testBucket, + fieldRegion: testRegionUSEast1, + "accessKey": "explicit-access-key", + "secretKey": "explicit-secret-key", + }, + fieldDatabase: testDatabaseValues(), + fieldMetrics: map[string]interface{}{fieldEnabled: false}, + fieldTracing: map[string]interface{}{fieldEnabled: false}, + } + + rendered := renderHelmChart(t, chartPath, values) + + assert.NotContains(t, rendered, "cloud-creds", + "rendered output should NOT reference CCO secret when cloudProvider is not set") + assert.Contains(t, rendered, "explicit-access-key", + "rendered output should contain explicit access key") + assert.Contains(t, rendered, "explicit-secret-key", + "rendered output should contain explicit secret key") +} + +func TestHelmRenderAWSManualCredentialsRequest(t *testing.T) { + if testing.Short() { + t.Skip(skipE2ETest) + } + + chartPath := getChartPath(t) + rendered := renderHelmChart(t, chartPath, awsManualValues()) + docs := splitYAMLDocs(rendered) + + cr := findCredentialsRequest(t, docs) + require.NotNil(t, cr, "CredentialsRequest should be rendered for AWS manual mode") + + spec, _ := cr[fieldSpec].(map[string]interface{}) + require.NotNil(t, spec, "spec should exist") + + assert.Equal(t, "/var/run/secrets/openshift/serviceaccount/token", + spec["cloudTokenPath"], "cloudTokenPath should be set for manual mode") + + providerSpec, _ := spec["providerSpec"].(map[string]interface{}) + require.NotNil(t, providerSpec, "providerSpec should exist") + assert.Equal(t, "AWSProviderSpec", providerSpec["kind"]) + assert.Equal(t, testSTSRoleARN, providerSpec["stsIAMRoleARN"], + "stsIAMRoleARN should be set for manual mode") +} + +func TestHelmRenderAWSManualDeploymentVolumes(t *testing.T) { + if testing.Short() { + t.Skip(skipE2ETest) + } + + chartPath := getChartPath(t) + rendered := renderHelmChart(t, chartPath, awsManualValues()) + + assert.Contains(t, rendered, "cloud-credentials", + "manual mode should include cloud-credentials volume") + assert.Contains(t, rendered, "bound-sa-token", + "manual mode should include bound-sa-token volume") + assert.Contains(t, rendered, "/var/run/secrets/cloud", + "manual mode should mount cloud credentials") + assert.Contains(t, rendered, "/var/run/secrets/openshift/serviceaccount", + "manual mode should mount projected SA token") +} + +func TestHelmRenderAWSManualEnvVars(t *testing.T) { + if testing.Short() { + t.Skip(skipE2ETest) + } + + chartPath := getChartPath(t) + rendered := renderHelmChart(t, chartPath, awsManualValues()) + + assert.Contains(t, rendered, "AWS_SHARED_CREDENTIALS_FILE", + "manual mode should set AWS_SHARED_CREDENTIALS_FILE") + assert.Contains(t, rendered, "AWS_WEB_IDENTITY_TOKEN_FILE", + "manual mode should set AWS_WEB_IDENTITY_TOKEN_FILE") + assert.Contains(t, rendered, "AWS_ROLE_ARN", + "manual mode should set AWS_ROLE_ARN") + assert.Contains(t, rendered, testSTSRoleARN, + "AWS_ROLE_ARN should contain the STS role ARN value") +} + +func TestHelmRenderManualModeNoS3Keys(t *testing.T) { + if testing.Short() { + t.Skip(skipE2ETest) + } + + chartPath := getChartPath(t) + rendered := renderHelmChart(t, chartPath, awsManualValues()) + + assert.NotContains(t, rendered, "TRUSTD_S3_ACCESS_KEY", + "manual mode should NOT set TRUSTD_S3_ACCESS_KEY") + assert.NotContains(t, rendered, "TRUSTD_S3_SECRET_KEY", + "manual mode should NOT set TRUSTD_S3_SECRET_KEY") + assert.NotContains(t, rendered, "aws_access_key_id", + "manual mode should NOT reference aws_access_key_id secret key") +} + +func TestHelmRenderMintModeHasS3Keys(t *testing.T) { + if testing.Short() { + t.Skip(skipE2ETest) + } + + chartPath := getChartPath(t) + rendered := renderHelmChart(t, chartPath, awsValues()) + + assert.Contains(t, rendered, "TRUSTD_S3_ACCESS_KEY", + "mint mode should set TRUSTD_S3_ACCESS_KEY") + assert.Contains(t, rendered, "TRUSTD_S3_SECRET_KEY", + "mint mode should set TRUSTD_S3_SECRET_KEY") + assert.Contains(t, rendered, "aws_access_key_id", + "mint mode should reference aws_access_key_id from CCO secret") + + assert.NotContains(t, rendered, "AWS_WEB_IDENTITY_TOKEN_FILE", + "mint mode should NOT set AWS_WEB_IDENTITY_TOKEN_FILE") + assert.NotContains(t, rendered, "bound-sa-token", + "mint mode should NOT include bound-sa-token volume") +} + +func TestHelmRenderAWSPassthroughCredentialsRequest(t *testing.T) { + if testing.Short() { + t.Skip(skipE2ETest) + } + + chartPath := getChartPath(t) + rendered := renderHelmChart(t, chartPath, awsPassthroughValues()) + docs := splitYAMLDocs(rendered) + + cr := findCredentialsRequest(t, docs) + require.NotNil(t, cr, "CredentialsRequest should be rendered for passthrough mode") + + spec, _ := cr[fieldSpec].(map[string]interface{}) + require.NotNil(t, spec, "spec should exist") + + assert.Nil(t, spec["cloudTokenPath"], + "passthrough mode should NOT set cloudTokenPath") + + providerSpec, _ := spec["providerSpec"].(map[string]interface{}) + require.NotNil(t, providerSpec, "providerSpec should exist") + assert.Equal(t, "AWSProviderSpec", providerSpec["kind"]) + assert.Empty(t, providerSpec["stsIAMRoleARN"], + "passthrough mode should NOT set stsIAMRoleARN") + + assert.Contains(t, rendered, "TRUSTD_S3_ACCESS_KEY", + "passthrough mode should set TRUSTD_S3_ACCESS_KEY from CCO secret") + assert.Contains(t, rendered, "aws_access_key_id", + "passthrough mode should reference aws_access_key_id from CCO secret") +} + +func TestHelmRenderAWSDefaultCredentialsRequest(t *testing.T) { + if testing.Short() { + t.Skip(skipE2ETest) + } + + chartPath := getChartPath(t) + rendered := renderHelmChart(t, chartPath, awsDefaultValues()) + docs := splitYAMLDocs(rendered) + + cr := findCredentialsRequest(t, docs) + require.NotNil(t, cr, "CredentialsRequest should be rendered for default mode") + + spec, _ := cr[fieldSpec].(map[string]interface{}) + require.NotNil(t, spec, "spec should exist") + + assert.Nil(t, spec["cloudTokenPath"], + "default mode should NOT set cloudTokenPath") + + assert.Contains(t, rendered, "TRUSTD_S3_ACCESS_KEY", + "default mode should set TRUSTD_S3_ACCESS_KEY from CCO secret") +} + +func TestHelmRenderGCPManualCredentialsRequest(t *testing.T) { + if testing.Short() { + t.Skip(skipE2ETest) + } + + chartPath := getChartPath(t) + rendered := renderHelmChart(t, chartPath, gcpManualValues()) + docs := splitYAMLDocs(rendered) + + cr := findCredentialsRequest(t, docs) + require.NotNil(t, cr, "CredentialsRequest should be rendered for GCP manual mode") + + spec, _ := cr[fieldSpec].(map[string]interface{}) + require.NotNil(t, spec, "spec should exist") + + assert.Equal(t, "/var/run/secrets/openshift/serviceaccount/token", + spec["cloudTokenPath"], "cloudTokenPath should be set for GCP manual mode") + + providerSpec, _ := spec["providerSpec"].(map[string]interface{}) + require.NotNil(t, providerSpec, "providerSpec should exist") + assert.Equal(t, "GCPProviderSpec", providerSpec["kind"]) + assert.Equal(t, testGCPServiceAccountEmail, providerSpec["serviceAccountEmail"], + "serviceAccountEmail should be set for GCP manual mode") + + roles, ok := providerSpec["predefinedRoles"].([]interface{}) + assert.True(t, ok, "predefinedRoles should be a list") + assert.NotEmpty(t, roles, "predefinedRoles should not be empty") +} + +func TestHelmRenderGCPManualDeploymentVolumes(t *testing.T) { + if testing.Short() { + t.Skip(skipE2ETest) + } + + chartPath := getChartPath(t) + rendered := renderHelmChart(t, chartPath, gcpManualValues()) + + assert.Contains(t, rendered, "cloud-credentials", + "GCP manual mode should include cloud-credentials volume") + assert.Contains(t, rendered, "bound-sa-token", + "GCP manual mode should include bound-sa-token volume") + assert.Contains(t, rendered, "/var/run/secrets/cloud", + "GCP manual mode should mount cloud credentials") + assert.Contains(t, rendered, "/var/run/secrets/openshift/serviceaccount", + "GCP manual mode should mount projected SA token") +} + +func TestHelmRenderGCPManualEnvVars(t *testing.T) { + if testing.Short() { + t.Skip(skipE2ETest) + } + + chartPath := getChartPath(t) + rendered := renderHelmChart(t, chartPath, gcpManualValues()) + + assert.Contains(t, rendered, "GOOGLE_APPLICATION_CREDENTIALS", + "GCP manual mode should set GOOGLE_APPLICATION_CREDENTIALS") + assert.Contains(t, rendered, "/var/run/secrets/cloud/service_account.json", + "GOOGLE_APPLICATION_CREDENTIALS should point to the credentials file") + + assert.NotContains(t, rendered, "AWS_SHARED_CREDENTIALS_FILE", + "GCP manual mode should NOT set AWS env vars") + assert.NotContains(t, rendered, "AWS_WEB_IDENTITY_TOKEN_FILE", + "GCP manual mode should NOT set AWS env vars") +} + +func TestHelmRenderGCPManualModeNoS3Keys(t *testing.T) { + if testing.Short() { + t.Skip(skipE2ETest) + } + + chartPath := getChartPath(t) + rendered := renderHelmChart(t, chartPath, gcpManualValues()) + + assert.NotContains(t, rendered, "TRUSTD_S3_ACCESS_KEY", + "GCP manual mode should NOT set TRUSTD_S3_ACCESS_KEY") + assert.NotContains(t, rendered, "TRUSTD_S3_SECRET_KEY", + "GCP manual mode should NOT set TRUSTD_S3_SECRET_KEY") +} diff --git a/test/e2e/helpers_test.go b/test/e2e/helpers_test.go index 41006237..945cd9c9 100644 --- a/test/e2e/helpers_test.go +++ b/test/e2e/helpers_test.go @@ -61,6 +61,13 @@ const ( fieldImporter = "importer" fieldIngress = "ingress" fieldHelm = "helm" + fieldStorage = "storage" + fieldDatabase = "database" + fieldMetrics = "metrics" + fieldTracing = "tracing" + fieldType = "type" + fieldBucket = "bucket" + fieldRegion = "region" // Common test skip messages. skipE2ETest = "skipping e2e test in short mode" diff --git a/test/e2e/operator_health_test.go b/test/e2e/operator_health_test.go index 57d5146e..f861e040 100644 --- a/test/e2e/operator_health_test.go +++ b/test/e2e/operator_health_test.go @@ -275,7 +275,7 @@ func TestOperatorMetricsEndpoint(t *testing.T) { assert.NotEmpty(t, service.Spec.Ports, "metrics service should have ports") for _, port := range service.Spec.Ports { - if port.Name == "https" || port.Name == "metrics" { + if port.Name == "https" || port.Name == fieldMetrics { t.Logf("Metrics port: %s -> %d", port.Name, port.Port) } } diff --git a/test/fixtures/aws_cco_cr.yaml b/test/fixtures/aws_cco_cr.yaml new file mode 100644 index 00000000..b64d79cd --- /dev/null +++ b/test/fixtures/aws_cco_cr.yaml @@ -0,0 +1,54 @@ +apiVersion: rhtpa.io/v1 +kind: TrustedProfileAnalyzer +metadata: + name: aws-cco-instance + namespace: test-namespace +spec: + appDomain: test.example.com + replicas: 1 + cloudProvider: aws + ccoMode: mint + cloudCredentials: + aws: + statementEntries: + - effect: Allow + action: + - "s3:GetObject" + - "s3:PutObject" + - "s3:DeleteObject" + - "s3:ListBucket" + resource: "*" + storage: + type: s3 + bucket: trustify-storage + region: us-east-1 + modules: + server: + enabled: true + replicas: 1 + resources: + requests: + cpu: 100m + memory: 128Mi + importer: + enabled: true + replicas: 1 + resources: + requests: + cpu: 100m + memory: 128Mi + oidc: + clients: + frontend: + clientId: test-frontend + cli: + clientSecret: test-secret + database: + host: postgres.test.svc + name: testdb + username: testuser + password: testpass + metrics: + enabled: false + tracing: + enabled: false diff --git a/test/fixtures/aws_cco_default_cr.yaml b/test/fixtures/aws_cco_default_cr.yaml new file mode 100644 index 00000000..a809aa04 --- /dev/null +++ b/test/fixtures/aws_cco_default_cr.yaml @@ -0,0 +1,54 @@ +apiVersion: rhtpa.io/v1 +kind: TrustedProfileAnalyzer +metadata: + name: aws-cco-default-instance + namespace: test-namespace +spec: + appDomain: test.example.com + replicas: 1 + cloudProvider: aws + ccoMode: default + cloudCredentials: + aws: + statementEntries: + - effect: Allow + action: + - "s3:GetObject" + - "s3:PutObject" + - "s3:DeleteObject" + - "s3:ListBucket" + resource: "*" + storage: + type: s3 + bucket: trustify-storage + region: us-east-1 + modules: + server: + enabled: true + replicas: 1 + resources: + requests: + cpu: 100m + memory: 128Mi + importer: + enabled: true + replicas: 1 + resources: + requests: + cpu: 100m + memory: 128Mi + oidc: + clients: + frontend: + clientId: test-frontend + cli: + clientSecret: test-secret + database: + host: postgres.test.svc + name: testdb + username: testuser + password: testpass + metrics: + enabled: false + tracing: + enabled: false diff --git a/test/fixtures/aws_cco_manual_cr.yaml b/test/fixtures/aws_cco_manual_cr.yaml new file mode 100644 index 00000000..6d2fbbd8 --- /dev/null +++ b/test/fixtures/aws_cco_manual_cr.yaml @@ -0,0 +1,55 @@ +apiVersion: rhtpa.io/v1 +kind: TrustedProfileAnalyzer +metadata: + name: aws-cco-manual-instance + namespace: test-namespace +spec: + appDomain: test.example.com + replicas: 1 + cloudProvider: aws + ccoMode: manual + cloudCredentials: + aws: + statementEntries: + - effect: Allow + action: + - "s3:GetObject" + - "s3:PutObject" + - "s3:DeleteObject" + - "s3:ListBucket" + resource: "*" + stsIAMRoleARN: "arn:aws:iam::123456789012:role/trustify-s3-role" + storage: + type: s3 + bucket: trustify-storage + region: us-east-1 + modules: + server: + enabled: true + replicas: 1 + resources: + requests: + cpu: 100m + memory: 128Mi + importer: + enabled: true + replicas: 1 + resources: + requests: + cpu: 100m + memory: 128Mi + oidc: + clients: + frontend: + clientId: test-frontend + cli: + clientSecret: test-secret + database: + host: postgres.test.svc + name: testdb + username: testuser + password: testpass + metrics: + enabled: false + tracing: + enabled: false diff --git a/test/fixtures/aws_cco_passthrough_cr.yaml b/test/fixtures/aws_cco_passthrough_cr.yaml new file mode 100644 index 00000000..6ee1b428 --- /dev/null +++ b/test/fixtures/aws_cco_passthrough_cr.yaml @@ -0,0 +1,54 @@ +apiVersion: rhtpa.io/v1 +kind: TrustedProfileAnalyzer +metadata: + name: aws-cco-passthrough-instance + namespace: test-namespace +spec: + appDomain: test.example.com + replicas: 1 + cloudProvider: aws + ccoMode: passthrough + cloudCredentials: + aws: + statementEntries: + - effect: Allow + action: + - "s3:GetObject" + - "s3:PutObject" + - "s3:DeleteObject" + - "s3:ListBucket" + resource: "*" + storage: + type: s3 + bucket: trustify-storage + region: us-east-1 + modules: + server: + enabled: true + replicas: 1 + resources: + requests: + cpu: 100m + memory: 128Mi + importer: + enabled: true + replicas: 1 + resources: + requests: + cpu: 100m + memory: 128Mi + oidc: + clients: + frontend: + clientId: test-frontend + cli: + clientSecret: test-secret + database: + host: postgres.test.svc + name: testdb + username: testuser + password: testpass + metrics: + enabled: false + tracing: + enabled: false diff --git a/test/fixtures/gcp_cco_cr.yaml b/test/fixtures/gcp_cco_cr.yaml new file mode 100644 index 00000000..089ffd59 --- /dev/null +++ b/test/fixtures/gcp_cco_cr.yaml @@ -0,0 +1,51 @@ +apiVersion: rhtpa.io/v1 +kind: TrustedProfileAnalyzer +metadata: + name: gcp-cco-instance + namespace: test-namespace +spec: + appDomain: test.example.com + replicas: 1 + cloudProvider: gcp + ccoMode: mint + cloudCredentials: + gcp: + permissions: + - storage.objects.get + - storage.objects.create + - storage.objects.delete + - storage.buckets.get + storage: + type: s3 + bucket: trustify-storage + region: us-central1 + modules: + server: + enabled: true + replicas: 1 + resources: + requests: + cpu: 100m + memory: 128Mi + importer: + enabled: true + replicas: 1 + resources: + requests: + cpu: 100m + memory: 128Mi + oidc: + clients: + frontend: + clientId: test-frontend + cli: + clientSecret: test-secret + database: + host: postgres.test.svc + name: testdb + username: testuser + password: testpass + metrics: + enabled: false + tracing: + enabled: false diff --git a/test/fixtures/gcp_cco_manual_cr.yaml b/test/fixtures/gcp_cco_manual_cr.yaml new file mode 100644 index 00000000..9d7cb093 --- /dev/null +++ b/test/fixtures/gcp_cco_manual_cr.yaml @@ -0,0 +1,52 @@ +apiVersion: rhtpa.io/v1 +kind: TrustedProfileAnalyzer +metadata: + name: gcp-cco-manual-instance + namespace: test-namespace +spec: + appDomain: test.example.com + replicas: 1 + cloudProvider: gcp + ccoMode: manual + cloudCredentials: + gcp: + permissions: + - storage.objects.get + - storage.objects.create + - storage.objects.delete + - storage.buckets.get + serviceAccountEmail: "trustify-sa@my-project.iam.gserviceaccount.com" + storage: + type: s3 + bucket: trustify-storage + region: us-central1 + modules: + server: + enabled: true + replicas: 1 + resources: + requests: + cpu: 100m + memory: 128Mi + importer: + enabled: true + replicas: 1 + resources: + requests: + cpu: 100m + memory: 128Mi + oidc: + clients: + frontend: + clientId: test-frontend + cli: + clientSecret: test-secret + database: + host: postgres.test.svc + name: testdb + username: testuser + password: testpass + metrics: + enabled: false + tracing: + enabled: false diff --git a/test/integration/cco_rbac_test.go b/test/integration/cco_rbac_test.go new file mode 100644 index 00000000..a713d22b --- /dev/null +++ b/test/integration/cco_rbac_test.go @@ -0,0 +1,140 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package integration + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "gopkg.in/yaml.v3" +) + +type ClusterRoleBinding struct { + APIVersion string `yaml:"apiVersion"` + Kind string `yaml:"kind"` + Metadata struct { + Name string `yaml:"name"` + Labels map[string]string `yaml:"labels"` + } `yaml:"metadata"` + RoleRef struct { + APIGroup string `yaml:"apiGroup"` + Kind string `yaml:"kind"` + Name string `yaml:"name"` + } `yaml:"roleRef"` + Subjects []struct { + Kind string `yaml:"kind"` + Name string `yaml:"name"` + Namespace string `yaml:"namespace"` + } `yaml:"subjects"` +} + +func TestCCOClusterRoleExists(t *testing.T) { + rolePath := filepath.Join("config", "rbac", "clusterrole.yaml") + + data, err := os.ReadFile(rolePath) + require.NoError(t, err, "CCO ClusterRole file should exist and be readable") + + var role Role + err = yaml.Unmarshal(data, &role) + require.NoError(t, err, "CCO ClusterRole should be valid YAML") + + assert.Equal(t, "rbac.authorization.k8s.io/v1", role.APIVersion, "should use correct API version") + assert.Equal(t, "ClusterRole", role.Kind, "should be a ClusterRole") + assert.Equal(t, "cco-credentialsrequest-access", role.Metadata.Name, "name should match") +} + +func TestCCOClusterRolePermissions(t *testing.T) { + rolePath := filepath.Join("config", "rbac", "clusterrole.yaml") + + data, err := os.ReadFile(rolePath) + require.NoError(t, err, "CCO ClusterRole should be readable") + + var role Role + err = yaml.Unmarshal(data, &role) + require.NoError(t, err, "CCO ClusterRole should be valid YAML") + + foundCCORule := false + for _, rule := range role.Rules { + if contains(rule.APIGroups, "cloudcredential.openshift.io") && + contains(rule.Resources, "credentialsrequests") { + foundCCORule = true + requiredVerbs := []string{"create", "get", "list", "watch", "update", "delete"} + for _, verb := range requiredVerbs { + assert.Contains(t, rule.Verbs, verb, + "CCO ClusterRole should have '%s' verb on credentialsrequests", verb) + } + } + } + + assert.True(t, foundCCORule, + "CCO ClusterRole should have a rule for cloudcredential.openshift.io/credentialsrequests") +} + +func TestCCOClusterRoleBindingExists(t *testing.T) { + bindingPath := filepath.Join("config", "rbac", "clusterrolebinding_cco.yaml") + + data, err := os.ReadFile(bindingPath) + require.NoError(t, err, "CCO ClusterRoleBinding file should exist and be readable") + + var binding ClusterRoleBinding + err = yaml.Unmarshal(data, &binding) + require.NoError(t, err, "CCO ClusterRoleBinding should be valid YAML") + + assert.Equal(t, "rbac.authorization.k8s.io/v1", binding.APIVersion, "should use correct API version") + assert.Equal(t, "ClusterRoleBinding", binding.Kind, "should be a ClusterRoleBinding") + assert.Equal(t, "cco-credentialsrequest-rolebinding", binding.Metadata.Name, "name should match") + + assert.Equal(t, "rbac.authorization.k8s.io", binding.RoleRef.APIGroup, "roleRef apiGroup should match") + assert.Equal(t, "ClusterRole", binding.RoleRef.Kind, "roleRef kind should be ClusterRole") + assert.Equal(t, "cco-credentialsrequest-access", binding.RoleRef.Name, "roleRef name should match the CCO ClusterRole") +} + +func TestCCOClusterRoleBindingSubjects(t *testing.T) { + bindingPath := filepath.Join("config", "rbac", "clusterrolebinding_cco.yaml") + + data, err := os.ReadFile(bindingPath) + require.NoError(t, err, "CCO ClusterRoleBinding should be readable") + + var binding ClusterRoleBinding + err = yaml.Unmarshal(data, &binding) + require.NoError(t, err, "CCO ClusterRoleBinding should be valid YAML") + + require.NotEmpty(t, binding.Subjects, "ClusterRoleBinding should have at least one subject") + + assert.Equal(t, "ServiceAccount", binding.Subjects[0].Kind, + "subject should be a ServiceAccount") + assert.Equal(t, "rhtpa-operator-controller-manager", binding.Subjects[0].Name, + "subject should reference the operator's service account") +} + +func TestCCOResourcesInKustomization(t *testing.T) { + kustomizationPath := filepath.Join("config", "rbac", "kustomization.yaml") + + data, err := os.ReadFile(kustomizationPath) + require.NoError(t, err, "kustomization.yaml should be readable") + + content := string(data) + assert.True(t, strings.Contains(content, "clusterrole.yaml"), + "kustomization.yaml should include clusterrole.yaml") + assert.True(t, strings.Contains(content, "clusterrolebinding_cco.yaml"), + "kustomization.yaml should include clusterrolebinding_cco.yaml") +} diff --git a/test/integration/helm_chart_test.go b/test/integration/helm_chart_test.go index e55d316d..1dad7574 100644 --- a/test/integration/helm_chart_test.go +++ b/test/integration/helm_chart_test.go @@ -178,3 +178,60 @@ func TestHelmIgnoreExists(t *testing.T) { _, err := os.Stat(helmignorePath) assert.NoError(t, err, ".helmignore should exist") } + +func TestValuesSchemaContainsCCOFields(t *testing.T) { + schemaPath := filepath.Join("helm-charts", "redhat-trusted-profile-analyzer", "values.schema.json") + + data, err := os.ReadFile(schemaPath) + require.NoError(t, err, "values.schema.json should be readable") + + var schema map[string]interface{} + err = yaml.Unmarshal(data, &schema) + require.NoError(t, err, "values.schema.json should be valid JSON") + + properties, ok := schema["properties"].(map[string]interface{}) + require.True(t, ok, "schema should have properties") + + assert.Contains(t, properties, "cloudProvider", "schema should define cloudProvider") + assert.Contains(t, properties, "ccoMode", "schema should define ccoMode") + assert.Contains(t, properties, "cloudCredentials", "schema should define cloudCredentials") + + cloudProvider, ok := properties["cloudProvider"].(map[string]interface{}) + require.True(t, ok, "cloudProvider should be an object") + assert.Equal(t, "string", cloudProvider["type"], "cloudProvider should be a string type") + + cpEnum, ok := cloudProvider["enum"].([]interface{}) + require.True(t, ok, "cloudProvider should have enum values") + assert.Contains(t, cpEnum, "aws", "cloudProvider enum should include aws") + assert.Contains(t, cpEnum, "gcp", "cloudProvider enum should include gcp") + + ccoMode, ok := properties["ccoMode"].(map[string]interface{}) + require.True(t, ok, "ccoMode should be an object") + assert.Equal(t, "string", ccoMode["type"], "ccoMode should be a string type") + + modeEnum, ok := ccoMode["enum"].([]interface{}) + require.True(t, ok, "ccoMode should have enum values") + assert.Contains(t, modeEnum, "default", "ccoMode enum should include default") + assert.Contains(t, modeEnum, "mint", "ccoMode enum should include mint") + assert.Contains(t, modeEnum, "passthrough", "ccoMode enum should include passthrough") + assert.Contains(t, modeEnum, "manual", "ccoMode enum should include manual") + + cloudCreds, ok := properties["cloudCredentials"].(map[string]interface{}) + require.True(t, ok, "cloudCredentials should be an object") + ccProps, ok := cloudCreds["properties"].(map[string]interface{}) + require.True(t, ok, "cloudCredentials should have properties") + assert.Contains(t, ccProps, "aws", "cloudCredentials should have aws") + assert.Contains(t, ccProps, "gcp", "cloudCredentials should have gcp") + + awsCreds, ok := ccProps["aws"].(map[string]interface{}) + require.True(t, ok, "aws credentials should be an object") + awsProps, ok := awsCreds["properties"].(map[string]interface{}) + require.True(t, ok, "aws should have properties") + assert.Contains(t, awsProps, "stsIAMRoleARN", "aws should have stsIAMRoleARN property") + + gcpCreds, ok := ccProps["gcp"].(map[string]interface{}) + require.True(t, ok, "gcp credentials should be an object") + gcpProps, ok := gcpCreds["properties"].(map[string]interface{}) + require.True(t, ok, "gcp should have properties") + assert.Contains(t, gcpProps, "serviceAccountEmail", "gcp should have serviceAccountEmail property") +}