diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6d4f93b35..63efb6891 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,17 +1,29 @@ name: Test on: + # develop itself, so the state after a merge is tested push: branches: [ develop ] + # Pull requests targeting develop. This also covers every push to the branch of an open + # pull request, through the `synchronize` event, so branches without one are not tested. pull_request: branches: [ develop ] +# Supersede in-flight runs of the same branch or pull request +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: test: runs-on: ubuntu-latest steps: + # On a pull request, check out the branch as it was pushed. The default is the merge + # commit with the base branch, which tests a tree that exists nowhere else. - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: actions/setup-python@v5 with: diff --git a/applications/accounts/deploy/templates/_identity_providers.tpl b/applications/accounts/deploy/templates/_identity_providers.tpl index 72e797202..40c9da836 100644 --- a/applications/accounts/deploy/templates/_identity_providers.tpl +++ b/applications/accounts/deploy/templates/_identity_providers.tpl @@ -14,8 +14,8 @@ "firstBrokerLoginFlowAlias": "first broker login", "config": { "syncMode": "IMPORT", - "clientSecret": {{ .app.harness.secrets.github_clientSecret | default "" | quote }}, - "clientId": {{ .app.harness.secrets.github_clientId | default "" | quote }}, + "clientSecret": {{ include "deploy_utils.secretValue" (dict "spec" .app.harness.secrets.github_clientSecret) | default "" | quote }}, + "clientId": {{ include "deploy_utils.secretValue" (dict "spec" .app.harness.secrets.github_clientId) | default "" | quote }}, "useJwksUrl": "true" } } @@ -36,8 +36,8 @@ "firstBrokerLoginFlowAlias": "first broker login", "config": { "syncMode": "IMPORT", - "clientSecret": {{ .app.harness.secrets.google_clientSecret | default "" | quote }}, - "clientId": {{ .app.harness.secrets.google_clientId | default "" | quote }}, + "clientSecret": {{ include "deploy_utils.secretValue" (dict "spec" .app.harness.secrets.google_clientSecret) | default "" | quote }}, + "clientId": {{ include "deploy_utils.secretValue" (dict "spec" .app.harness.secrets.google_clientId) | default "" | quote }}, "useJwksUrl": "true" } } diff --git a/deployment-configuration/compose/templates/_secrets.yaml b/deployment-configuration/compose/templates/_secrets.yaml new file mode 100644 index 000000000..1c844ffe6 --- /dev/null +++ b/deployment-configuration/compose/templates/_secrets.yaml @@ -0,0 +1,51 @@ +{{/* +Secret definition helpers, shared with the helm chart. + +Secret managers do not exist in a local docker compose deployment: only the plain value of +a secret definition is resolved here. Keep in sync with the reference implementation in +`deployment-configuration/helm/templates/secrets/_secrets.tpl`, which also documents the rich form. +*/}} + +{{/* +Resolve the manager of a secret definition. +Outputs `cloudharness`, `unmanaged` or the manager name. +Usage: {{ include "deploy_utils.secretManager" (dict "spec" $secretDefinition) }} +*/}} +{{- define "deploy_utils.secretManager" -}} +{{- $manager := "cloudharness" -}} +{{- if kindIs "map" .spec -}} + {{- if hasKey .spec "manager" -}} + {{- $declared := get .spec "manager" -}} + {{- if kindIs "invalid" $declared -}} + {{- $manager = "unmanaged" -}} + {{- else -}} + {{- if eq (toString $declared) "" -}} + {{- $manager = "unmanaged" -}} + {{- else -}} + {{- $manager = toString $declared -}} + {{- end -}} + {{- end -}} + {{- end -}} +{{- end -}} +{{- $manager -}} +{{- end -}} + +{{/* +Resolve the value of a secret definition: the definition itself in the simple form, +the `default` entry in the rich form. Empty when not defined. +Usage: {{ include "deploy_utils.secretValue" (dict "spec" $secretDefinition) }} +*/}} +{{- define "deploy_utils.secretValue" -}} +{{- if kindIs "map" .spec -}} + {{- if hasKey .spec "default" -}} + {{- $default := get .spec "default" -}} + {{- if not (kindIs "invalid" $default) -}} + {{- $default -}} + {{- end -}} + {{- end -}} +{{- else -}} + {{- if not (kindIs "invalid" .spec) -}} + {{- .spec -}} + {{- end -}} +{{- end -}} +{{- end -}} diff --git a/deployment-configuration/compose/templates/auto-secrets.yaml b/deployment-configuration/compose/templates/auto-secrets.yaml index ed9345d2f..6799a7972 100644 --- a/deployment-configuration/compose/templates/auto-secrets.yaml +++ b/deployment-configuration/compose/templates/auto-secrets.yaml @@ -1,11 +1,17 @@ {{- define "deploy_utils.secret" }} {{- if .app.harness.secrets }} +{{/* Secret managers are not available locally: every secret is resolved to its literal + value, or to the `default` entry when defined in the rich form. */}} +{{- $resolved := dict }} +{{- range $k, $v := .app.harness.secrets }} + {{- $_ := set $resolved $k (include "deploy_utils.secretValue" (dict "spec" $v)) }} +{{- end }} {{- $secret_name := printf "%s" .app.harness.deployment.name }} {{- $secret := (lookup "v1" "Secret" .root.Values.namespace $secret_name) }} {{- if $secret }} # secret already exists - {{- if not (compact (values .app.harness.secrets)) }} + {{- if not (compact (values $resolved)) }} # secret values are null, copy from the existing secret {{- range $k, $v := $secret.data }} cloudharness-metadata: @@ -17,7 +23,7 @@ data: {{ $v }} {{- else }} # there are non default values in values.yaml, use these stringData: - {{- range $k, $v := .app.harness.secrets }} + {{- range $k, $v := $resolved }} cloudharness-metadata: path: resources/generated/auth/{{ $k }} @@ -28,7 +34,7 @@ data: {{ $v | default (randAlphaNum 20) }} {{- else }} # secret doesn't exist stringData: - {{- range $k, $v := .app.harness.secrets }} + {{- range $k, $v := $resolved }} cloudharness-metadata: path: resources/generated/auth/{{ $k }} data: {{ $v | default (randAlphaNum 20) }} @@ -46,4 +52,4 @@ data: {{ $v | default (randAlphaNum 20) }} {{- end }} {{- end }} {{- end }} -{{- end }} \ No newline at end of file +{{- end }} diff --git a/deployment-configuration/helm/templates/auto-deployments.yaml b/deployment-configuration/helm/templates/auto-deployments.yaml index 5f26effaf..ebab5fbfb 100644 --- a/deployment-configuration/helm/templates/auto-deployments.yaml +++ b/deployment-configuration/helm/templates/auto-deployments.yaml @@ -147,9 +147,7 @@ spec: {{- range $dep := concat .app.harness.dependencies.hard .app.harness.dependencies.soft }} {{- $depApp := index $root.Values.apps $dep }} {{- if $depApp.harness.secrets }} - - name: cloudharness-{{ $dep }} - secret: - secretName: {{ $dep }} + {{- include "deploy_utils.secretsVolume" (dict "root" $root "app" $depApp "name" (printf "cloudharness-%s" $dep) "secretName" $dep) | nindent 8 }} {{- end }} {{- end }} {{- if .app.harness.deployment.volume }} @@ -164,9 +162,7 @@ spec: name: "{{ $app.harness.deployment.name }}-{{ $resource.name }}" {{- end }} {{- if .app.harness.secrets }} - - name: secrets - secret: - secretName: {{ .app.harness.deployment.name }} + {{- include "deploy_utils.secretsVolume" (dict "root" .root "app" .app "name" "secrets" "secretName" .app.harness.deployment.name) | nindent 8 }} {{- end }} {{- if kindIs "map" .app.harness.database }} {{- if and (hasKey .app.harness.database "connect_string") .app.harness.database.connect_string }} diff --git a/deployment-configuration/helm/templates/secrets/_secrets.tpl b/deployment-configuration/helm/templates/secrets/_secrets.tpl new file mode 100644 index 000000000..9432431b9 --- /dev/null +++ b/deployment-configuration/helm/templates/secrets/_secrets.tpl @@ -0,0 +1,223 @@ +{{/* vim: set filetype=mustache: */}} + +{{/* +Secret managers framework. + +An application secret is defined either in the simple form + + harness: + secrets: + mySecret: "a value" + +or in the rich form + + harness: + secrets: + mySecret: + manager: onepassword + default: "a value" + # ... manager specific fields + +The manager is resolved as follows: + * no `manager` key, or `manager: cloudharness` -> `cloudharness` (the built-in behaviour: + the value is written to the application secret, random values are generated when needed) + * `manager:` (explicit null or empty) -> `unmanaged`: CloudHarness renders nothing, the + secret entry is expected to be created out of band (e.g. `kubectl edit secret `) + * any other value -> the named secret manager, which is responsible for materializing a + Kubernetes Secret holding the value. + +A secret manager named `X` is implemented by defining two templates: + + * `deploy_utils.secretmanager.X.resource`: renders the Kubernetes resources needed to + materialize the secret (e.g. a `OnePasswordItem` or an `ExternalSecret`). + * `deploy_utils.secretmanager.X.ref`: outputs `/`, telling + CloudHarness where the value ends up so that it can be mounted with the application secrets. + +Both are called with the context + + (dict "root" $ "app" $app "name" "spec" "resourceName" ) + +where `spec` is the secret definition, holding the manager specific settings, and +`resourceName` is a name safe to give to the resources materializing the secret. +`deploy_utils.secretManagerSetting` reads a setting from the definition, falling back to +the manager's deployment wide configuration under `secretmanagers.X` in the root values. + +The manager is looked up by name at render time, so an unknown manager fails with +`no template "deploy_utils.secretmanager.X.resource"`. + +One file per manager lives in `managers/`, each documenting its cluster prerequisites, its +settings and what it renders: + + * `managers/onepassword.tpl`: 1Password, through the 1Password Kubernetes Operator + * `managers/aws.tpl`: AWS Secrets Manager, through the External Secrets Operator + +Managers can equally be added by any application: templates under +`/deploy/templates` are collected into the same chart and therefore share the +same template namespace. +*/}} + +{{/* +Resolve the manager of a secret definition. +Outputs `cloudharness`, `unmanaged` or the manager name. +Usage: {{ include "deploy_utils.secretManager" (dict "spec" $secretDefinition) }} +*/}} +{{- define "deploy_utils.secretManager" -}} +{{- $manager := "cloudharness" -}} +{{- if kindIs "map" .spec -}} + {{- if hasKey .spec "manager" -}} + {{- $declared := get .spec "manager" -}} + {{- if kindIs "invalid" $declared -}} + {{- $manager = "unmanaged" -}} + {{- else -}} + {{- if eq (toString $declared) "" -}} + {{- $manager = "unmanaged" -}} + {{- else -}} + {{- $manager = toString $declared -}} + {{- end -}} + {{- end -}} + {{- end -}} +{{- end -}} +{{- $manager -}} +{{- end -}} + +{{/* +Tells whether a secret definition is handled by an external secret manager. +Outputs `true` or the empty string. +Usage: {{ if include "deploy_utils.secretIsExternal" (dict "spec" $secretDefinition) }} +*/}} +{{- define "deploy_utils.secretIsExternal" -}} +{{- if not (has (include "deploy_utils.secretManager" (dict "spec" .spec)) (list "cloudharness" "unmanaged")) -}} +true +{{- end -}} +{{- end -}} + +{{/* +Resolve the value of a secret definition: the definition itself in the simple form, +the `default` entry in the rich form. Empty when not defined. +Usage: {{ include "deploy_utils.secretValue" (dict "spec" $secretDefinition) }} +*/}} +{{- define "deploy_utils.secretValue" -}} +{{- if kindIs "map" .spec -}} + {{- if hasKey .spec "default" -}} + {{- $default := get .spec "default" -}} + {{- if not (kindIs "invalid" $default) -}} + {{- $default -}} + {{- end -}} + {{- end -}} +{{- else -}} + {{- if not (kindIs "invalid" .spec) -}} + {{- .spec -}} + {{- end -}} +{{- end -}} +{{- end -}} + +{{/* +Tells whether an application has secrets handled by CloudHarness itself, i.e. whether +CloudHarness creates the application secret. +Outputs `true` or the empty string. +Usage: {{ if include "deploy_utils.hasManagedSecrets" (dict "app" $app) }} +*/}} +{{- define "deploy_utils.hasManagedSecrets" -}} +{{- $managed := "" -}} +{{- range $name, $spec := .app.harness.secrets -}} + {{- if eq (include "deploy_utils.secretManager" (dict "spec" $spec)) "cloudharness" -}} + {{- $managed = "true" -}} + {{- end -}} +{{- end -}} +{{- $managed -}} +{{- end -}} + +{{/* +Name of the Kubernetes resource materializing an externally managed secret. +Usage: {{ include "deploy_utils.secretResourceName" (dict "app" $app "name" $secretName) }} +*/}} +{{- define "deploy_utils.secretResourceName" -}} +{{- printf "%s-%s" .app.harness.deployment.name .name | lower | replace "_" "-" | replace "." "-" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Look up a manager setting, first in the secret definition, then in the manager global +configuration, falling back to the given default. +Usage: {{ include "deploy_utils.secretManagerSetting" (dict "spec" $spec "conf" $conf "key" "store" "default" "") }} +*/}} +{{- define "deploy_utils.secretManagerSetting" -}} +{{- $value := .default -}} +{{- if kindIs "map" .conf -}} + {{- if hasKey .conf .key -}} + {{- $declared := index .conf .key -}} + {{- if not (kindIs "invalid" $declared) -}} + {{- $value = $declared -}} + {{- end -}} + {{- end -}} +{{- end -}} +{{- if kindIs "map" .spec -}} + {{- if hasKey .spec .key -}} + {{- $declared := index .spec .key -}} + {{- if not (kindIs "invalid" $declared) -}} + {{- $value = $declared -}} + {{- end -}} + {{- end -}} +{{- end -}} +{{- $value -}} +{{- end -}} + +{{/* +Render the volume exposing the secrets of an application as files. +All secrets, whatever their manager, are exposed in the same directory, so that +`cloudharness.utils.secrets.get_secret` finds them by name. +Usage: {{ include "deploy_utils.secretsVolume" (dict "root" $root "app" $app "name" "secrets" "secretName" $app.harness.deployment.name) }} +*/}} +{{- define "deploy_utils.secretsVolume" -}} +{{- $root := .root -}} +{{- $app := .app -}} +{{- $managed := include "deploy_utils.hasManagedSecrets" (dict "app" $app) -}} +{{- $external := list -}} +{{- range $name, $spec := $app.harness.secrets -}} + {{- if include "deploy_utils.secretIsExternal" (dict "spec" $spec) -}} + {{- $manager := include "deploy_utils.secretManager" (dict "spec" $spec) -}} + {{- $context := dict "root" $root "app" $app "name" $name "spec" $spec "resourceName" (include "deploy_utils.secretResourceName" (dict "app" $app "name" $name)) -}} + {{- $ref := splitList "/" (include (printf "deploy_utils.secretmanager.%s.ref" $manager) $context) -}} + {{- $external = append $external (dict "path" $name "secretName" (first $ref) "key" (last $ref)) -}} + {{- end -}} +{{- end -}} +- name: {{ .name }} +{{- if $external }} + projected: + sources: + - secret: + name: {{ .secretName }} + {{- if not $managed }} + optional: true + {{- end }} + {{- range $source := $external }} + - secret: + name: {{ $source.secretName }} + items: + - key: {{ $source.key }} + path: {{ $source.path }} + {{- end }} +{{- else }} + secret: + secretName: {{ .secretName }} + {{- if not $managed }} + optional: true + {{- end }} +{{- end }} +{{- end -}} + +{{/* +Render the resources materializing the externally managed secrets of an application. +Usage: {{ include "deploy_utils.secretManagerResources" (dict "root" $root "app" $app) }} +*/}} +{{- define "deploy_utils.secretManagerResources" -}} +{{- $root := .root -}} +{{- $app := .app -}} +{{- range $name, $spec := .app.harness.secrets }} + {{- if include "deploy_utils.secretIsExternal" (dict "spec" $spec) }} + {{- $manager := include "deploy_utils.secretManager" (dict "spec" $spec) }} + {{- $context := dict "root" $root "app" $app "name" $name "spec" $spec "resourceName" (include "deploy_utils.secretResourceName" (dict "app" $app "name" $name)) }} +--- +{{ include (printf "deploy_utils.secretmanager.%s.resource" $manager) $context }} + {{- end }} +{{- end }} +{{- end -}} diff --git a/deployment-configuration/helm/templates/auto-secrets.yaml b/deployment-configuration/helm/templates/secrets/auto-secrets.yaml similarity index 76% rename from deployment-configuration/helm/templates/auto-secrets.yaml rename to deployment-configuration/helm/templates/secrets/auto-secrets.yaml index ad17d1e68..f2c61eadf 100644 --- a/deployment-configuration/helm/templates/auto-secrets.yaml +++ b/deployment-configuration/helm/templates/secrets/auto-secrets.yaml @@ -1,5 +1,15 @@ {{- define "deploy_utils.secret" }} {{- $secret_name := printf "%s" .app.harness.deployment.name }} +{{/* Only the secrets handled by CloudHarness itself end up in the application secret. + Externally managed ones are materialized by their own manager, unmanaged ones are + expected to be created out of band. */}} +{{- $managed := dict }} +{{- range $k, $v := .app.harness.secrets }} + {{- if eq (include "deploy_utils.secretManager" (dict "spec" $v)) "cloudharness" }} + {{- $_ := set $managed $k (include "deploy_utils.secretValue" (dict "spec" $v)) }} + {{- end }} +{{- end }} +{{- if $managed }} apiVersion: v1 kind: Secret metadata: @@ -13,39 +23,35 @@ type: Opaque stringData: updated: {{ now | quote }} # Added because in case of update, if no field is updated, alla data is erased {{- if $secret }} - {{- range $k, $v := .app.harness.secrets }} + {{- range $k, $v := $managed }} {{- if $v }} - {{- if eq (typeOf $v) "string" }} - {{- if ne $v "?" }} + {{- if ne $v "?" }} # Update/set value to value in values.yaml if specified {{ $k }}: {{ $v | quote }} - {{- else }} + {{- else }} # Refresh at any deployment for ? (pure random) value {{ $k }}: {{ randAlphaNum 20 | quote }} - {{- end }} - {{- else }} - # Type not recognized: setting to a empty string" - {{ $k }}-formatnotrecognized: {{ $v }} - {{ $k }}: "" - {{- end }} + {{- end }} {{- else if eq (typeOf $secret.data) (typeOf dict) }} # Value empty or null in the values.yaml {{- if not (hasKey $secret.data $k) }} # Create a random secret value if not specified in values.yaml if it is not set and it is not already in the deployed secret (static random secret) */}} - {{ $k }}: {{ randAlphaNum 20 | quote }} + {{ $k }}: {{ randAlphaNum 20 | quote }} {{- else }} - # confirm previous value from the secret (static random secret already set, do nothing)} + # confirm previous value from the secret (static random secret already set, do nothing)} {{- end}} {{- end }} {{- end }} # range end {{- else }} # New secret - {{- range $k, $v := .app.harness.secrets }} + {{- range $k, $v := $managed }} {{ $k }}: {{ $v | default (randAlphaNum 20) | quote }} {{- end }} {{- end }} --- {{- end }} +{{- include "deploy_utils.secretManagerResources" (dict "root" .root "app" .app) }} +{{- end }} {{- range $app := .Values.apps }} {{- if $app.harness.secrets }}{{- if ne (len $app.harness.secrets) 0 }} {{- include "deploy_utils.secret" (dict "root" $ "app" $app) }} @@ -78,4 +84,4 @@ stringData: {{- include "deploy_utils.db_secret" (dict "root" $ "app" $app) }} {{- end }} {{- end }} -{{- end }} \ No newline at end of file +{{- end }} diff --git a/deployment-configuration/helm/templates/secrets/managers/aws.tpl b/deployment-configuration/helm/templates/secrets/managers/aws.tpl new file mode 100644 index 000000000..8d6149b1c --- /dev/null +++ b/deployment-configuration/helm/templates/secrets/managers/aws.tpl @@ -0,0 +1,117 @@ +{{/* vim: set filetype=mustache: */}} + +{{/* +================================================================================ +Secret manager: aws +================================================================================ + +Full setup guide: docs/applications/secrets/managers/aws.md + +Reads secrets from AWS Secrets Manager through the External Secrets Operator. + +Cluster prerequisites +--------------------- +The External Secrets Operator must be installed, see https://external-secrets.io/, along +with a `SecretStore` or `ClusterSecretStore` pointing at AWS Secrets Manager and holding +the credentials or the IRSA service account used to read it. CloudHarness only renders the +`ExternalSecret` resources: the operator is what talks to AWS and materializes the +Kubernetes Secrets. + +Usage +----- + harness: + secrets: + mySecret: + manager: aws + arn: arn:aws:secretsmanager:eu-west-1:123456789012:secret:my-secret + property: password + +Per secret settings +------------------- + arn required. ARN, or plain name, of the secret in AWS Secrets Manager. Becomes + the operator's `remoteRef.key`. + property optional. Key to extract when the AWS secret holds a JSON document. Without + it, the whole remote value is used. + store optional here, normally set deployment wide (see below). + +Deployment wide settings, under `secretmanagers.aws` in the root values +----------------------------------------------------------------------- + store required, the name of the store to read from. A store is shared by the + whole deployment, so it belongs here rather than on each secret. + storeKind optional, defaults to `ClusterSecretStore`. Use `SecretStore` for a + store defined in the release namespace. + refreshInterval optional, defaults to `1h`. How often the operator re-reads AWS. + apiVersion optional, defaults to `external-secrets.io/v1beta1`. Override for a + cluster running a different version of the operator's CRD. + + secretmanagers: + aws: + store: aws-secrets-manager + storeKind: ClusterSecretStore + refreshInterval: 1h + +These hold no credentials: the store does, and this section is exposed in the allvalues +config map. + +What is rendered +---------------- +One `ExternalSecret` per secret, named `-` (lowercased, with +`_` and `.` replaced by `-`), targeting a Kubernetes Secret of the same name with the value +under a fixed `value` key. CloudHarness mounts it under the secret's own name, next to the +other secrets of the application. + +Other AWS Secrets Manager secrets can be reached the same way by adding a manager with a +different store, which is why the store is a setting rather than being hardcoded. + +Errors +------ +Rendering fails when `arn` is missing, and when no store is configured. +*/}} + +{{- define "deploy_utils.secretmanager.aws.resource" -}} +{{- $conf := dict -}} +{{- if kindIs "map" .root.Values.secretmanagers -}} + {{- if kindIs "map" (index .root.Values.secretmanagers "aws") -}} + {{- $conf = index .root.Values.secretmanagers "aws" -}} + {{- end -}} +{{- end -}} +{{/* the arn identifies one specific secret: never a deployment wide setting */}} +{{- $arn := include "deploy_utils.secretManagerSetting" (dict "spec" .spec "conf" dict "key" "arn" "default" "") -}} +{{- if not $arn -}} + {{- fail (printf "Secret %s of application %s: the aws manager requires an 'arn'" .name .app.harness.name) -}} +{{- end -}} +{{- $store := include "deploy_utils.secretManagerSetting" (dict "spec" .spec "conf" $conf "key" "store" "default" "") -}} +{{- if not $store -}} + {{- fail (printf "Secret %s of application %s: the aws manager requires a 'store', set it in 'secretmanagers.aws.store'" .name .app.harness.name) -}} +{{- end -}} +{{- $property := include "deploy_utils.secretManagerSetting" (dict "spec" .spec "conf" dict "key" "property" "default" "") -}} +apiVersion: {{ include "deploy_utils.secretManagerSetting" (dict "spec" .spec "conf" $conf "key" "apiVersion" "default" "external-secrets.io/v1beta1") }} +kind: ExternalSecret +metadata: + name: {{ .resourceName }} + namespace: {{ .root.Values.namespace }} + labels: + app: {{ .app.harness.deployment.name }} +spec: + refreshInterval: {{ include "deploy_utils.secretManagerSetting" (dict "spec" .spec "conf" $conf "key" "refreshInterval" "default" "1h") | quote }} + secretStoreRef: + name: {{ $store }} + kind: {{ include "deploy_utils.secretManagerSetting" (dict "spec" .spec "conf" $conf "key" "storeKind" "default" "ClusterSecretStore") }} + target: + name: {{ .resourceName }} + creationPolicy: Owner + data: + - secretKey: value + remoteRef: + key: {{ $arn | quote }} + {{- if $property }} + property: {{ $property | quote }} + {{- end }} +{{- end -}} + +{{/* +The operator writes the value under the fixed `value` key of the target Secret. +*/}} +{{- define "deploy_utils.secretmanager.aws.ref" -}} +{{- printf "%s/value" .resourceName -}} +{{- end -}} diff --git a/deployment-configuration/helm/templates/secrets/managers/onepassword.tpl b/deployment-configuration/helm/templates/secrets/managers/onepassword.tpl new file mode 100644 index 000000000..cf3aa5c0a --- /dev/null +++ b/deployment-configuration/helm/templates/secrets/managers/onepassword.tpl @@ -0,0 +1,100 @@ +{{/* vim: set filetype=mustache: */}} + +{{/* +================================================================================ +Secret manager: onepassword +================================================================================ + +Full setup guide: docs/applications/secrets/managers/onepassword.md + +Reads secrets from 1Password through the 1Password Kubernetes Operator. + +Cluster prerequisites +--------------------- +The 1Password Kubernetes Operator must be installed in the cluster and connected to a +1Password Connect server, see https://developer.1password.com/docs/k8s/k8s-operator/. +CloudHarness only renders the `OnePasswordItem` custom resources: the operator is what +reaches 1Password and materializes the Kubernetes Secrets. + +Usage +----- + harness: + secrets: + mySecret: + manager: onepassword + path: vaults/my-vault/items/my-item + field: password + +Per secret settings +------------------- + path required. Path of the 1Password item, `vaults//items/`. The + vault part may be omitted, and the item name given alone, when a default + vault is configured globally (see below). + field optional, defaults to `password`. Field of the 1Password item holding the + value. The operator names the Secret keys after the item fields, so this + selects which one is exposed to the application. + apiVersion optional, defaults to `onepassword.com/v1`. Override for a cluster running + a different version of the operator's CRD. + +Deployment wide settings, under `secretmanagers.onepassword` in the root values +------------------------------------------------------------------------------- + vault default vault, so that secrets only need to name their item. + apiVersion as above, applied to every onepassword secret. + + secretmanagers: + onepassword: + vault: my-vault + +These hold no credentials: the operator authenticates on its own, and this section is +exposed in the allvalues config map. + +What is rendered +---------------- +One `OnePasswordItem` per secret, named `-` (lowercased, +with `_` and `.` replaced by `-`). The operator creates a Kubernetes Secret with the same +name, holding every field of the 1Password item. CloudHarness then mounts only `field` +from it, under the secret's own name, next to the other secrets of the application. + +Errors +------ +Rendering fails when `path` is missing, and when `path` is an item name alone while no +default vault is configured. +*/}} + +{{- define "deploy_utils.secretmanager.onepassword.resource" -}} +{{- $conf := dict -}} +{{- if kindIs "map" .root.Values.secretmanagers -}} + {{- if kindIs "map" (index .root.Values.secretmanagers "onepassword") -}} + {{- $conf = index .root.Values.secretmanagers "onepassword" -}} + {{- end -}} +{{- end -}} +{{/* the item path is never a deployment wide setting: pass an empty conf */}} +{{- $path := include "deploy_utils.secretManagerSetting" (dict "spec" .spec "conf" dict "key" "path" "default" "") -}} +{{- if not $path -}} + {{- fail (printf "Secret %s of application %s: the onepassword manager requires a 'path'" .name .app.harness.name) -}} +{{- end -}} +{{- if not (contains "/" $path) -}} + {{/* an item name alone is completed with the globally configured vault */}} + {{- $vault := include "deploy_utils.secretManagerSetting" (dict "spec" .spec "conf" $conf "key" "vault" "default" "") -}} + {{- if not $vault -}} + {{- fail (printf "Secret %s of application %s: the onepassword 'path' must be a full item path, or 'secretmanagers.onepassword.vault' must be set" .name .app.harness.name) -}} + {{- end -}} + {{- $path = printf "vaults/%s/items/%s" $vault $path -}} +{{- end -}} +apiVersion: {{ include "deploy_utils.secretManagerSetting" (dict "spec" .spec "conf" $conf "key" "apiVersion" "default" "onepassword.com/v1") }} +kind: OnePasswordItem +metadata: + name: {{ .resourceName }} + namespace: {{ .root.Values.namespace }} + labels: + app: {{ .app.harness.deployment.name }} +spec: + itemPath: {{ $path | quote }} +{{- end -}} + +{{/* +The operator names the Secret after the OnePasswordItem, and its keys after the item fields. +*/}} +{{- define "deploy_utils.secretmanager.onepassword.ref" -}} +{{- printf "%s/%s" .resourceName (include "deploy_utils.secretManagerSetting" (dict "spec" .spec "conf" dict "key" "field" "default" "password")) -}} +{{- end -}} diff --git a/deployment-configuration/helm/templates/secrets.yaml b/deployment-configuration/helm/templates/secrets/secrets.yaml similarity index 100% rename from deployment-configuration/helm/templates/secrets.yaml rename to deployment-configuration/helm/templates/secrets/secrets.yaml diff --git a/deployment-configuration/helm/templates/tls-secret.yaml b/deployment-configuration/helm/templates/secrets/tls-secret.yaml similarity index 100% rename from deployment-configuration/helm/templates/tls-secret.yaml rename to deployment-configuration/helm/templates/secrets/tls-secret.yaml diff --git a/deployment-configuration/helm/values.yaml b/deployment-configuration/helm/values.yaml index 1f91ba322..207bea565 100644 --- a/deployment-configuration/helm/values.yaml +++ b/deployment-configuration/helm/values.yaml @@ -93,6 +93,22 @@ ingress: path: "/" # -- The pathType for the Ingress path. Default is Prefix. For regex paths, set to ImplementationSpecific pathType: Prefix +# -- Deployment wide configuration of the secret managers referenced by `harness.secrets`. +# Each entry is keyed by manager name and holds the settings shared by all the secrets +# handled by that manager. Settings can be overridden secret by secret. This is plain +# configuration and is exposed in the allvalues config map: never put credentials here. +# @default -- Empty, no secret manager configured. +secretmanagers: {} + # onepassword: + # # -- Vault used when a secret only specifies the item name instead of the full item path. + # vault: my-vault + # aws: + # # -- Name of the External Secrets Operator store pointing to AWS Secrets Manager. + # store: aws-secrets-manager + # # -- SecretStore or ClusterSecretStore. + # storeKind: ClusterSecretStore + # # -- How often the value is refreshed from AWS. + # refreshInterval: 1h backup: # -- Flag to enable/disable backups. active: false diff --git a/deployment-configuration/value-template.yaml b/deployment-configuration/value-template.yaml index 74fd46460..62f3f328e 100644 --- a/deployment-configuration/value-template.yaml +++ b/deployment-configuration/value-template.yaml @@ -73,7 +73,10 @@ harness: name: # -- Service port. port: 80 - # -- Auto generated secrets key-value pairs. If no value is provided, a random hash is generated + # -- Auto generated secrets key-value pairs. If no value is provided, a random hash is generated. + # A secret can also be delegated to a secret manager with the rich form + # `mySecret: {manager: onepassword, default: a value, path: vaults/my-vault/items/my-item}`. + # Set `manager` explicitly to null to leave the secret unmanaged, i.e. created out of band. secrets: {} # -- Specify which services this application uses in the frontend to create proxy ingresses. e.g. - name: mnp-checkout use_services: [] diff --git a/docs/README.md b/docs/README.md index e159b56ee..3f669ef49 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,7 +12,9 @@ - [Override applications values and files](./applications/overridings.md) - [Configure a database](./applications/databases.md) - [Add configuration files to your application](./applications/resources.md) - - [Add and use secrets](./applications/secrets.md) + - [Add and use secrets](./applications/secrets) + - [Secret manager: 1Password](./applications/secrets/managers/onepassword.md) + - [Secret manager: AWS Secrets Manager](./applications/secrets/managers/aws.md) - [Application development](./applications/development) - [Backend development](./applications/development/backend-development.md) - [Run workflows](./applications/development/workflows-api.md) diff --git a/docs/applications/secrets.md b/docs/applications/secrets.md deleted file mode 100644 index feaa7f35b..000000000 --- a/docs/applications/secrets.md +++ /dev/null @@ -1,70 +0,0 @@ -# CloudHarness Secrets - -## What secrets are - -Kubernetes Secrets let you store and manage sensitive information, such as passwords, OAuth tokens, and ssh keys. Storing confidential information in a Secret is safer and more flexible than putting it verbatim in a Pod definition or in a container image. See [Secrets design document](https://github.com/kubernetes/design-proposals-archive/blob/main/auth/secrets.md) for more information. - -**CloudHarness has build-in support for application specific kubernetes secrets.** - -The CH secrets will be mounted as data volumes to be used by a container in a Pod and will be auto updated on change. This means that a pod doesn't need to be restarted to "see" the new value(s) - -remark: an application has only access to it's "own" secrets - -## Secret definition in CloudHarness - -Secrets are defined in the application values.yaml file in the `secrets` section under the `harness` section. -Example - -```yaml -harness: - secrets: - unsecureSecret: - secureSecret: - random-static-secret: "" - random-dynamic-secret: ? -``` - -Secret values are initialized in three different ways: -* Set the secret's value (as in `unsecureSecret`). Do that only if you aware of what you are doing as the value may be pushed in the git(hub) repository. -* Leave the secret's value `null` (as in `secureSecret`) to configure manually later in the ci/cd pipeline. -* Use the "" (empty string) value (as in `random-static-secret`) to let CloudHarness generate a random value for you. - This secret won't be updated after being set by any of the CloudHarness automations, so has to be managed through `kubectl` directly. -* Use the `?` value (as in `random-dynamic-secret`) to get a new random value for every deployment upgrade - -Secret editing/maintenance alternatives: -* CI/CD Codefresh support: all `null` and `` secrets will be added to the codefresh deployment file(s) and can be set/overwritten through the codefresh variable configuration -* Using Helm to set/overwrite the secret's value `helm ... --set apps..harness.secrets.=` -* Using kubernetes secret edit `kubectl edit secret ` - -## Secrets in Codefresh pipelines - -Secrets defined under `harness.secrets` are also exported as deployment variables in the automatically -generated Codefresh pipeline. When the deployment step is assembled, each secret name is transformed -before being referenced in the pipeline: - -- Any underscore (`_`) in the secret name is replaced by a double underscore (`__`). -- The resulting string is converted to upper case to form the environment variable name. - -For example a secret declared as `db_password` becomes the variable `DB__PASSWORD` in Codefresh and will -appear in the deployment step as: - -``` -custom_values: - - apps__harness_secrets_db__password=${{DB__PASSWORD}} -``` - -The same underscore replacement is applied to the application name in the `custom_values` entry. - -## Secret usage in Python backend apps - -The CloudHarness python library (`cloudharness-common`) provides easy access to the CH secrets, just import `get_secrets` from `cloudharness.utils.secrets`. - -Example: -```python -from cloudharness.utils.secrets import get_secret -secret1_value = get_secret("Secret1") -print(f"Secret1 = {secret1_value}") -``` - -Hint: make sure the secret's value is read on every use, remember that secrets can be changed "on the fly" - diff --git a/docs/applications/secrets/README.md b/docs/applications/secrets/README.md new file mode 100644 index 000000000..218d12fb6 --- /dev/null +++ b/docs/applications/secrets/README.md @@ -0,0 +1,175 @@ +# CloudHarness Secrets + +## What secrets are + +Kubernetes Secrets let you store and manage sensitive information, such as passwords, OAuth tokens, and ssh keys. Storing confidential information in a Secret is safer and more flexible than putting it verbatim in a Pod definition or in a container image. See [Secrets design document](https://github.com/kubernetes/design-proposals-archive/blob/main/auth/secrets.md) for more information. + +**CloudHarness has build-in support for application specific kubernetes secrets.** + +The CH secrets will be mounted as data volumes to be used by a container in a Pod and will be auto updated on change. This means that a pod doesn't need to be restarted to "see" the new value(s) + +remark: an application has only access to it's "own" secrets + +## Secret definition in CloudHarness + +Secrets are defined in the application values.yaml file in the `secrets` section under the `harness` section. +Example + +```yaml +harness: + secrets: + unsecureSecret: + secureSecret: + random-static-secret: "" + random-dynamic-secret: ? +``` + +Secret values are initialized in three different ways: +* Set the secret's value (as in `unsecureSecret`). Do that only if you aware of what you are doing as the value may be pushed in the git(hub) repository. +* Leave the secret's value `null` (as in `secureSecret`) to configure manually later in the ci/cd pipeline. +* Use the "" (empty string) value (as in `random-static-secret`) to let CloudHarness generate a random value for you. + This secret won't be updated after being set by any of the CloudHarness automations, so has to be managed through `kubectl` directly. +* Use the `?` value (as in `random-dynamic-secret`) to get a new random value for every deployment upgrade + +Secret editing/maintenance alternatives: +* CI/CD Codefresh support: all `null` and `` secrets will be added to the codefresh deployment file(s) and can be set/overwritten through the codefresh variable configuration +* Using Helm to set/overwrite the secret's value `helm ... --set apps..harness.secrets.=` +* Using kubernetes secret edit `kubectl edit secret ` + +## Secret managers + +The values above are managed by CloudHarness itself. A secret can instead be delegated to a +secret manager, by replacing the plain value with a definition object: + +```yaml +harness: + secrets: + mySecret: + manager: onepassword + default: "a value" + path: vaults/my-vault/items/my-item +``` + +* `manager` selects who provides the value. When the key is missing, or is set to + `cloudharness`, everything works exactly as described above, so the two forms are + interchangeable: `mySecret: 'a value'` and `mySecret: {default: 'a value'}` are equivalent. +* `manager` set explicitly to null means **unmanaged**: CloudHarness renders nothing at all + and assumes the secret entry already exists. Create it out of band, for instance with + `kubectl edit secret `. The application secret is mounted as optional in that + case, so a missing entry surfaces as a `SecretNotFound` at runtime rather than blocking + the pod from starting. +* `default` is the value used by the `cloudharness` manager, and the fallback used when the + secret manager is not available, as in local docker compose deployments. It follows the + same conventions as a plain value, including `""` and `?`. +* Any other entry is manager specific: 1Password needs the item `path`, AWS needs the + secret `arn`, and so on. + +Whatever the manager, all the secrets of an application are exposed as files in the same +directory, so `get_secret` keeps working unchanged. + +When moving an existing secret from `cloudharness` to another manager, delete the entry from +the application secret (`kubectl edit secret `) as part of the upgrade. CloudHarness +stops rendering the entry but does not remove the value already stored in the cluster, and +having it both in the application secret and in the one created by the manager makes the pod +fail to mount its secrets directory. + +Secrets handled by a manager other than `cloudharness` are never exported as Codefresh +pipeline variables: their value does not come from the pipeline. + +### Built-in managers + +Every manager relies on an operator running in the cluster: CloudHarness renders the custom +resources, the operator is what reaches the external service. Each manager has its own page +covering the cluster setup, its settings and what it renders. + +| Manager | Reads from | Needs | Page | +| --- | --- | --- | --- | +| `onepassword` | 1Password | [1Password Kubernetes Operator](https://developer.1password.com/docs/k8s/k8s-operator/) | [managers/onepassword.md](./managers/onepassword.md) | +| `aws` | AWS Secrets Manager | [External Secrets Operator](https://external-secrets.io/) | [managers/aws.md](./managers/aws.md) | + +```yaml +harness: + secrets: + fromOnePassword: + manager: onepassword + path: vaults/my-vault/items/my-item + fromAws: + manager: aws + arn: arn:aws:secretsmanager:eu-west-1:123456789012:secret:my-secret +``` + +Settings shared by all the secrets of a manager are configured once for the whole +deployment in the `secretmanagers` section of the root `values.yaml`, and can be overridden +secret by secret. This section is plain deployment configuration and ends up in the +`cloudharness-allvalues` config map: never put credentials in it, the manager authenticates +through its own operator configuration. + +```yaml +secretmanagers: + onepassword: + vault: my-vault + aws: + store: aws-secrets-manager + storeKind: ClusterSecretStore + refreshInterval: 1h +``` + +### Adding a secret manager + +A manager named `X` is defined by two Helm templates, which can be added by CloudHarness or +by any application in its `deploy/templates` folder: + +* `deploy_utils.secretmanager.X.resource` renders the Kubernetes resources materializing + the secret. +* `deploy_utils.secretmanager.X.ref` outputs `/`, telling + CloudHarness where the value ends up, so that it can be mounted with the other secrets of + the application. + +Both are called once per secret with the context +`(dict "root" $ "app" $app "name" "spec" "resourceName" )`, +where `spec` carries the manager specific settings and `resourceName` is a name safe to +give to the rendered resources. Use `deploy_utils.secretManagerSetting` to read a setting +from the secret, falling back to the manager's `secretmanagers.X` section. + +Each built-in manager lives in its own file under +`deployment-configuration/helm/templates/secrets/managers/`, documenting its cluster +prerequisites, its settings and what it renders — `onepassword.tpl` and `aws.tpl` are the +two to copy from, with [managers/onepassword.md](./managers/onepassword.md) and +[managers/aws.md](./managers/aws.md) as the matching pages. The framework itself is in +`deployment-configuration/helm/templates/secrets/_secrets.tpl`. + +## Secrets in Codefresh pipelines + +Secrets defined under `harness.secrets` and handled by the `cloudharness` manager are also exported as +deployment variables in the automatically generated Codefresh pipeline. When the deployment step is +assembled, each secret name is transformed before being referenced in the pipeline: + +- Any underscore (`_`) in the secret name is replaced by a double underscore (`__`). +- The resulting string is converted to upper case to form the environment variable name. + +For example a secret declared as `db_password` becomes the variable `DB__PASSWORD` in Codefresh and will +appear in the deployment step as: + +``` +custom_values: + - apps__harness_secrets_db__password=${{DB__PASSWORD}} +``` + +The same underscore replacement is applied to the application name in the `custom_values` entry. + +Secrets declared in the rich form have their value nested under `default`, so the entry becomes +`apps__harness_secrets_db__password_default=${{DB__PASSWORD}}`. + +## Secret usage in Python backend apps + +The CloudHarness python library (`cloudharness-common`) provides easy access to the CH secrets, just import `get_secrets` from `cloudharness.utils.secrets`. + +Example: +```python +from cloudharness.utils.secrets import get_secret +secret1_value = get_secret("Secret1") +print(f"Secret1 = {secret1_value}") +``` + +Hint: make sure the secret's value is read on every use, remember that secrets can be changed "on the fly" + diff --git a/docs/applications/secrets/managers/aws.md b/docs/applications/secrets/managers/aws.md new file mode 100644 index 000000000..4f0a1e716 --- /dev/null +++ b/docs/applications/secrets/managers/aws.md @@ -0,0 +1,202 @@ +# Secret manager: `aws` + +Reads application secrets from AWS Secrets Manager, through the +[External Secrets Operator](https://external-secrets.io/) (ESO). + +CloudHarness renders one `ExternalSecret` custom resource per secret. The operator is what +authenticates to AWS, reads the value and materializes the Kubernetes Secret that +CloudHarness then mounts with the other secrets of the application. + +## Cluster setup + +### 1. Install the operator + +```bash +helm repo add external-secrets https://charts.external-secrets.io + +helm install external-secrets external-secrets/external-secrets \ + -n external-secrets --create-namespace +``` + +See the [getting started guide](https://external-secrets.io/latest/introduction/getting-started/). +The chart installs the CRDs by default; pass `--set installCRDs=false` if you manage them +separately, in which case they must be applied with server-side apply as they exceed the +256KB annotation limit. + +### 2. Grant access to the secrets + +Attach an IAM policy scoped to the secrets the cluster may read, rather than all of them: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "secretsmanager:GetResourcePolicy", + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret", + "secretsmanager:ListSecretVersionIds" + ], + "Resource": [ + "arn:aws:secretsmanager:eu-west-1:123456789012:secret:myproject-*" + ] + } + ] +} +``` + +### 3. Create the store + +A store says which AWS account and region to read from, and how to authenticate. Use a +`ClusterSecretStore` when several namespaces share it, a `SecretStore` when it belongs to +one namespace. With static credentials: + +```bash +echo -n 'KEYID' > ./access-key +echo -n 'SECRETKEY' > ./secret-access-key +kubectl create secret generic awssm-secret \ + --from-file=./access-key --from-file=./secret-access-key +``` + +```yaml +apiVersion: external-secrets.io/v1 +kind: ClusterSecretStore +metadata: + name: aws-secrets-manager +spec: + provider: + aws: + service: SecretsManager + region: eu-west-1 + # optional, to assume a role scoped to the secrets above + role: arn:aws:iam::123456789012:role/external-secrets + auth: + secretRef: + accessKeyIDSecretRef: + name: awssm-secret + key: access-key + namespace: external-secrets + secretAccessKeySecretRef: + name: awssm-secret + key: secret-access-key + namespace: external-secrets +``` + +On EKS, prefer +[IRSA or the pod identity](https://external-secrets.io/latest/provider/aws-secrets-manager/) +over static keys and drop the `auth` block entirely. + +For a `ClusterSecretStore` the `namespace` of each secret reference is required, as the +store is not itself namespaced. + +### 4. Point CloudHarness at it + +```yaml +secretmanagers: + aws: + store: aws-secrets-manager +``` + +## Configuration + +### Per secret + +```yaml +harness: + secrets: + mySecret: + manager: aws + arn: arn:aws:secretsmanager:eu-west-1:123456789012:secret:my-secret + property: password +``` + +| Setting | Required | Default | Meaning | +| --- | --- | --- | --- | +| `arn` | yes | — | ARN, or plain name, of the secret in AWS Secrets Manager. Becomes the operator's `remoteRef.key`. | +| `property` | no | — | Key to extract when the AWS secret holds a JSON document. Without it the whole remote value is used. | +| `store` | no | from `secretmanagers.aws` | Overrides the deployment wide store for this one secret. | +| `default` | no | — | Value used for local docker compose deployments, where no operator exists. | + +### Deployment wide + +```yaml +secretmanagers: + aws: + store: aws-secrets-manager + storeKind: ClusterSecretStore + refreshInterval: 1h +``` + +| Setting | Required | Default | Meaning | +| --- | --- | --- | --- | +| `store` | yes | — | Name of the store to read from. Shared by the deployment, which is why it lives here rather than on each secret. | +| `storeKind` | no | `ClusterSecretStore` | Set to `SecretStore` for a store defined in the release namespace. | +| `refreshInterval` | no | `1h` | How often the operator re-reads AWS. CloudHarness mounts secrets as files, so a refreshed value reaches the application without a restart. | +| `apiVersion` | no | `external-secrets.io/v1beta1` | See the version note below. | + +This section holds no credentials — the store does — and it is exposed in the +`cloudharness-allvalues` config map, so never put an access key in it. + +## What gets rendered + +For a secret `mySecret` of an application deployed as `myapp`: + +```yaml +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: myapp-mysecret + namespace: + labels: + app: myapp +spec: + refreshInterval: "1h" + secretStoreRef: + name: aws-secrets-manager + kind: ClusterSecretStore + target: + name: myapp-mysecret + creationPolicy: Owner + data: + - secretKey: value + remoteRef: + key: "arn:aws:secretsmanager:eu-west-1:123456789012:secret:my-secret" + property: "password" +``` + +The operator creates a Kubernetes Secret named `myapp-mysecret` with the value under a +fixed `value` key. CloudHarness projects it into +`/opt/cloudharness/resources/secrets/myapp/mySecret`, so the application reads it with +`get_secret("mySecret")` like any other secret. + +## Gotchas + +* **API version.** The default is `external-secrets.io/v1beta1`, which recent operators + still serve but have deprecated in favour of `external-secrets.io/v1`. Set + `secretmanagers.aws.apiVersion: external-secrets.io/v1` on a cluster running ESO 0.17 or + later; keep the default for older installs. +* **The store must exist before the release.** An `ExternalSecret` pointing at a missing + store never produces its Secret, and the pod waits on the missing file. `kubectl describe + externalsecret myapp-mysecret` reports the reason. +* **Versioned secrets.** `remoteRef.key` resolves to the current version. Pin a stage or + version through the store or the ARN if you need a fixed one. +* **Moving an existing secret to this manager.** Delete the old entry from the application + secret (`kubectl edit secret myapp`) as part of the upgrade, otherwise the same file is + claimed twice and the pod fails to mount its secrets directory. + +## Other AWS-backed managers + +The `arn` and the store are settings rather than hardcoded values, so a second manager +reading from a different account or a different provider is a copy of +`managers/aws.tpl` with another store. See +[Adding a secret manager](../README.md#adding-a-secret-manager). + +## References + +* [External Secrets Operator](https://external-secrets.io/) +* [Getting started](https://external-secrets.io/latest/introduction/getting-started/) +* [AWS Secrets Manager provider](https://external-secrets.io/latest/provider/aws-secrets-manager/) +* [ExternalSecret API](https://external-secrets.io/latest/api/externalsecret/) +* Implementation: `deployment-configuration/helm/templates/secrets/managers/aws.tpl` diff --git a/docs/applications/secrets/managers/onepassword.md b/docs/applications/secrets/managers/onepassword.md new file mode 100644 index 000000000..042bf0925 --- /dev/null +++ b/docs/applications/secrets/managers/onepassword.md @@ -0,0 +1,142 @@ +# Secret manager: `onepassword` + +Reads application secrets from 1Password, through the +[1Password Kubernetes Operator](https://developer.1password.com/docs/k8s/k8s-operator/). + +CloudHarness renders one `OnePasswordItem` custom resource per secret. The operator is what +authenticates to 1Password, fetches the item and materializes the Kubernetes Secret that +CloudHarness then mounts with the other secrets of the application. + +## Cluster setup + +The operator reaches 1Password in one of two ways, and only one of them is needed. Both are +documented in the [operator usage guide](https://github.com/1Password/onepassword-operator/blob/main/USAGEGUIDE.md). + +### Option 1 — Connect server (recommended for a shared cluster) + +1Password Connect runs in the cluster and holds the credentials; the operator talks to it. +The [Helm chart](https://github.com/1Password/connect-helm-charts) installs both at once. + +1. Create a + [Connect server and credentials file](https://developer.1password.com/docs/connect/get-started/) + in your 1Password account. You end up with a `1password-credentials.json` file and a + Connect token. + +2. Install Connect together with the operator: + + ```bash + helm repo add 1password https://1password.github.io/connect-helm-charts + helm install connect 1password/connect \ + --set-file connect.credentials=1password-credentials.json \ + --set operator.create=true \ + --set operator.token.value= + ``` + +### Option 2 — Service account + +The operator authenticates directly with a +[1Password service account](https://developer.1password.com/docs/service-accounts) token, +with no Connect server to run. + +1. [Create a service account](https://developer.1password.com/docs/service-accounts/get-started#create-a-service-account) + and grant it read access to the vault holding the secrets. + +2. Store its token in the cluster: + + ```bash + kubectl create secret generic onepassword-service-account-token \ + --from-literal=token="$OP_SERVICE_ACCOUNT_TOKEN" + ``` + +3. Deploy the operator with `OP_SERVICE_ACCOUNT_TOKEN` set from that secret, and without + `OP_CONNECT_TOKEN` / `OP_CONNECT_HOST`. + +### Operator settings worth knowing + +| Variable | Default | Why it matters here | +| --- | --- | --- | +| `WATCH_NAMESPACE` | all namespaces | Must cover the namespace CloudHarness deploys to, otherwise the `OnePasswordItem` resources are ignored and the secrets never appear. | +| `POLLING_INTERVAL` | `600` (seconds) | How long a change in 1Password takes to reach the cluster. CloudHarness mounts secrets as files, which the kubelet refreshes in place, so a change propagates without restarting anything. | +| `AUTO_RESTART` | `false` | Leave it off unless an application caches secrets at startup: mounted files update on their own. | + +## Configuration + +### Per secret + +```yaml +harness: + secrets: + mySecret: + manager: onepassword + path: vaults/my-vault/items/my-item + field: password +``` + +| Setting | Required | Default | Meaning | +| --- | --- | --- | --- | +| `path` | yes | — | Path of the 1Password item, `vaults//items/`. Both parts accept an id or a title. The item name can be given alone when `secretmanagers.onepassword.vault` is set. | +| `field` | no | `password` | Field of the item holding the value. The operator turns every field of the item into a key of the Kubernetes Secret; this picks the one to expose. | +| `apiVersion` | no | `onepassword.com/v1` | For a cluster running a different version of the operator's CRD. | +| `default` | no | — | Value used for local docker compose deployments, where no operator exists. | + +### Deployment wide + +```yaml +secretmanagers: + onepassword: + vault: my-vault +``` + +| Setting | Default | Meaning | +| --- | --- | --- | +| `vault` | — | Default vault, so secrets can name their item alone instead of repeating the full path. | +| `apiVersion` | `onepassword.com/v1` | Applied to every `onepassword` secret. | + +This section holds no credentials — the operator has its own — and it is exposed in the +`cloudharness-allvalues` config map, so never put a token in it. + +## What gets rendered + +For a secret `mySecret` of an application deployed as `myapp`: + +```yaml +apiVersion: onepassword.com/v1 +kind: OnePasswordItem +metadata: + name: myapp-mysecret + namespace: + labels: + app: myapp +spec: + itemPath: "vaults/my-vault/items/my-item" +``` + +The operator creates a Kubernetes Secret named `myapp-mysecret` holding every field of the +1Password item. CloudHarness projects only `field` from it into +`/opt/cloudharness/resources/secrets/myapp/mySecret`, so the application reads it with +`get_secret("mySecret")` like any other secret. + +## Gotchas + +* **Field names are normalized.** The operator lowercases field names, strips invalid + leading and trailing characters and replaces inner whitespace with `-`, so a 1Password + field named `API Token` becomes the key `api-token`. `field` must match the normalized + form, not what you see in the 1Password UI. +* **Titles are ambiguous.** When several vaults or items share a title, the operator picks + the oldest one. Use ids in `path` when that is a risk. +* **File fields.** A field storing a file contributes the file contents as the value. If a + file field and another field share a name, the non-file one wins. +* **Freezing a value.** Adding the tag `operator.1password.io:ignore-secret` to the item in + 1Password stops the operator from propagating further updates. +* **Moving an existing secret to this manager.** Delete the old entry from the application + secret (`kubectl edit secret myapp`) as part of the upgrade, otherwise the same file is + claimed twice and the pod fails to mount its secrets directory. + +## References + +* [1Password Kubernetes Operator](https://developer.1password.com/docs/k8s/k8s-operator/) +* [Operator usage guide](https://github.com/1Password/onepassword-operator/blob/main/USAGEGUIDE.md) +* [Connect Helm charts](https://github.com/1Password/connect-helm-charts) +* [1Password Connect](https://developer.1password.com/docs/connect/) +* [1Password service accounts](https://developer.1password.com/docs/service-accounts) +* Implementation: `deployment-configuration/helm/templates/secrets/managers/onepassword.tpl` diff --git a/docs/model/ApplicationHarnessConfig.md b/docs/model/ApplicationHarnessConfig.md index f10c624f7..e55a3e893 100644 --- a/docs/model/ApplicationHarnessConfig.md +++ b/docs/model/ApplicationHarnessConfig.md @@ -14,7 +14,7 @@ Name | Type | Description | Notes **dependencies** | [**ApplicationDependenciesConfig**](ApplicationDependenciesConfig.md) | | [optional] **secured** | **object** | When true, the application is shielded with a getekeeper | [optional] **uri_role_mapping** | [**List[UriRoleMappingConfig]**](UriRoleMappingConfig.md) | Map uri/roles to secure with the Gatekeeper (if `secured: true`) | [optional] -**secrets** | **Dict[str, object]** | | [optional] +**secrets** | [**Dict[str, SecretDefinition]**](SecretDefinition.md) | Application secrets, by name | [optional] **use_services** | [**List[NamedObject]**](NamedObject.md) | Specify which services this application uses in the frontend to create proxy ingresses. e.g. ``` - name: samples ``` | [optional] **database** | [**DatabaseDeploymentConfig**](DatabaseDeploymentConfig.md) | | [optional] **resources** | [**List[FileResourcesConfig]**](FileResourcesConfig.md) | Application file resources. Maps from deploy/resources folder and mounts as configmaps | [optional] diff --git a/docs/model/GatekeeperConf.md b/docs/model/GatekeeperConf.md index dfc33fbe8..c412fe0cb 100644 --- a/docs/model/GatekeeperConf.md +++ b/docs/model/GatekeeperConf.md @@ -10,7 +10,7 @@ Name | Type | Description | Notes **replicas** | **int** | | [optional] **resources** | [**DeploymentResourcesConf**](DeploymentResourcesConf.md) | | [optional] **secret** | **str** | | [optional] -**configuration** | **Dict[str, Any]** | Native Gatekeeper proxy.yml settings, keyed by the kebab-case names from the Gatekeeper configuration reference. Application values override global values and CloudHarness-generated defaults. | [optional] +**configuration** | **Dict[str, object]** | Native Gatekeeper proxy.yml settings, keyed by the kebab-case names from the Gatekeeper configuration reference. Application values override global values and CloudHarness-generated defaults. | [optional] ## Example @@ -30,3 +30,5 @@ gatekeeper_conf_dict = gatekeeper_conf_instance.to_dict() gatekeeper_conf_from_dict = GatekeeperConf.from_dict(gatekeeper_conf_dict) ``` [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/docs/model/SecretConfig.md b/docs/model/SecretConfig.md new file mode 100644 index 000000000..671fdfb51 --- /dev/null +++ b/docs/model/SecretConfig.md @@ -0,0 +1,31 @@ +# SecretConfig + +Rich definition of an application secret, used in place of a plain value to delegate the secret to a secret manager. Manager specific settings (e.g. `path` for onepassword, `arn` for aws) are added next to the properties below. + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**manager** | **str** | Name of the secret manager handling the secret. Defaults to `cloudharness`, which creates the value in the application secret. Set explicitly to null to leave the secret unmanaged: nothing is created and the secret is assumed to exist already. | [optional] +**default** | **str** | Value used by the `cloudharness` manager and as a fallback when the secret manager is not available, as in local docker compose deployments. Follows the same conventions as a plain secret value: null or empty generates a random value once, `?` generates a new random value at every deployment. | [optional] + +## Example + +```python +from cloudharness_model.models.secret_config import SecretConfig + +# TODO update the JSON string below +json = "{}" +# create an instance of SecretConfig from a JSON string +secret_config_instance = SecretConfig.from_json(json) +# print the JSON string representation of the object +print(SecretConfig.to_json()) + +# convert the object into a dict +secret_config_dict = secret_config_instance.to_dict() +# create an instance of SecretConfig from a dict +secret_config_from_dict = SecretConfig.from_dict(secret_config_dict) +``` +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/docs/model/SecretDefinition.md b/docs/model/SecretDefinition.md new file mode 100644 index 000000000..f972258de --- /dev/null +++ b/docs/model/SecretDefinition.md @@ -0,0 +1,31 @@ +# SecretDefinition + +An application secret, defined either as a plain value or as a secret configuration object. A `string` (or null) is the secret value itself: empty or null generates a random value, `?` generates a new random value at every deployment. A `SecretConfig` object instead delegates the secret to a secret manager, and carries the settings that manager needs. + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**manager** | **str** | Name of the secret manager handling the secret. Defaults to `cloudharness`, which creates the value in the application secret. Set explicitly to null to leave the secret unmanaged: nothing is created and the secret is assumed to exist already. | [optional] +**default** | **str** | Value used by the `cloudharness` manager and as a fallback when the secret manager is not available, as in local docker compose deployments. Follows the same conventions as a plain secret value: null or empty generates a random value once, `?` generates a new random value at every deployment. | [optional] + +## Example + +```python +from cloudharness_model.models.secret_definition import SecretDefinition + +# TODO update the JSON string below +json = "{}" +# create an instance of SecretDefinition from a JSON string +secret_definition_instance = SecretDefinition.from_json(json) +# print the JSON string representation of the object +print(SecretDefinition.to_json()) + +# convert the object into a dict +secret_definition_dict = secret_definition_instance.to_dict() +# create an instance of SecretDefinition from a dict +secret_definition_from_dict = SecretDefinition.from_dict(secret_definition_dict) +``` +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/libraries/cloudharness-common/test-requirements.txt b/libraries/cloudharness-common/test-requirements.txt index 0fa41ffd1..2565a50d2 100644 --- a/libraries/cloudharness-common/test-requirements.txt +++ b/libraries/cloudharness-common/test-requirements.txt @@ -6,5 +6,10 @@ nose randomize pyparsing python-keycloak +# cloudharness.utils.server imports connexion. It is not a runtime dependency of the +# package, as only the flask applications use that module and they bring their own, but +# the test environment needs it to import the module under test. Kept aligned with +# infrastructure/common-images/cloudharness-flask/requirements.txt +connexion[swagger-ui,flask,uvicorn]>=3.0.0,<4.0.0 -e ../models -e . diff --git a/libraries/models/README.md b/libraries/models/README.md index 8f02d510e..12a27f39a 100644 --- a/libraries/models/README.md +++ b/libraries/models/README.md @@ -108,6 +108,8 @@ Class | Method | HTTP request | Description - [ProxyTimeoutConf](docs/ProxyTimeoutConf.md) - [RegistryConfig](docs/RegistryConfig.md) - [RegistrySecretConfig](docs/RegistrySecretConfig.md) + - [SecretConfig](docs/SecretConfig.md) + - [SecretDefinition](docs/SecretDefinition.md) - [ServiceAutoArtifactConfig](docs/ServiceAutoArtifactConfig.md) - [UnitTestsConfig](docs/UnitTestsConfig.md) - [UriRoleMappingConfig](docs/UriRoleMappingConfig.md) diff --git a/libraries/models/api/openapi.yaml b/libraries/models/api/openapi.yaml index b566680f0..7695cff91 100644 --- a/libraries/models/api/openapi.yaml +++ b/libraries/models/api/openapi.yaml @@ -487,6 +487,57 @@ components: description: '' type: object additionalProperties: true + SecretsMap: + description: 'Application secrets, by name' + type: object + additionalProperties: + $ref: '#/components/schemas/SecretDefinition' + example: + unsecureSecret: a value + randomSecret: '' + managedSecret: + manager: onepassword + path: vaults/my-vault/items/my-item + SecretDefinition: + description: >- + An application secret, defined either as a plain value or as a secret + configuration object. A `string` (or null) is the secret value itself: empty or + null generates a random value, `?` generates a new random value at every + deployment. A `SecretConfig` object instead delegates the secret to a secret + manager, and carries the settings that manager needs. + anyOf: + - type: string + - $ref: '#/components/schemas/SecretConfig' + SecretConfig: + title: Root Type for SecretConfig + description: >- + Rich definition of an application secret, used in place of a plain value to + delegate the secret to a secret manager. Manager specific settings (e.g. `path` + for onepassword, `arn` for aws) are added next to the properties below. + type: object + properties: + manager: + description: >- + Name of the secret manager handling the secret. Defaults to `cloudharness`, + which creates the value in the application secret. Set explicitly to null to + leave the secret unmanaged: nothing is created and the secret is assumed to + exist already. + type: string + nullable: true + example: onepassword + default: + description: >- + Value used by the `cloudharness` manager and as a fallback when the secret + manager is not available, as in local docker compose deployments. Follows the + same conventions as a plain secret value: null or empty generates a random + value once, `?` generates a new random value at every deployment. + type: string + nullable: true + additionalProperties: true + example: + manager: onepassword + default: a-value + path: vaults/my-vault/items/my-item Quota: description: '' type: object @@ -1092,7 +1143,7 @@ components: items: $ref: '#/components/schemas/UriRoleMappingConfig' secrets: - $ref: '#/components/schemas/SimpleMap' + $ref: '#/components/schemas/SecretsMap' description: |- Define secrets will be mounted in the deployment @@ -1104,7 +1155,19 @@ components: ``` - Values if left empty are randomly generated + Values if left empty are randomly generated. + + A secret can also be defined in the rich form, to delegate it to a secret + manager (see `SecretConfig`) + + ```yaml + secrets: + secret_name: + manager: onepassword + default: 'value' + path: vaults/my-vault/items/my-item + + ``` use_services: description: >- Specify which services this application uses in the frontend to create proxy diff --git a/libraries/models/cloudharness_model/base_model.py b/libraries/models/cloudharness_model/base_model.py index c228e33fa..16d5e04b9 100644 --- a/libraries/models/cloudharness_model/base_model.py +++ b/libraries/models/cloudharness_model/base_model.py @@ -7,6 +7,7 @@ """ from __future__ import annotations +import json import re from typing import Any, Dict, List, Union from pydantic import BaseModel @@ -463,6 +464,19 @@ def __contains__(self, key: str) -> bool: return False + @classmethod + def from_json(cls, json_str: str) -> Any: + """Create an instance from a JSON string. + + Union models (anyOf/oneOf) generated by the OpenAPI generator resolve their + actual instance by calling this on each candidate schema. + """ + return cls.from_dict(json.loads(json_str)) + + def to_json(self) -> str: + """Serialize to a JSON string, the counterpart of `from_json`.""" + return json.dumps(self.to_dict()) + def to_dict(self) -> Dict[str, Any]: """Enhanced to_dict that converts nested models to AttrDict for backward compatibility.""" result = super().model_dump(by_alias=True, exclude_none=True) diff --git a/libraries/models/cloudharness_model/models/__init__.py b/libraries/models/cloudharness_model/models/__init__.py index 013a351c7..0dcd4f522 100644 --- a/libraries/models/cloudharness_model/models/__init__.py +++ b/libraries/models/cloudharness_model/models/__init__.py @@ -55,6 +55,8 @@ from cloudharness_model.models.proxy_timeout_conf import ProxyTimeoutConf from cloudharness_model.models.registry_config import RegistryConfig from cloudharness_model.models.registry_secret_config import RegistrySecretConfig +from cloudharness_model.models.secret_config import SecretConfig +from cloudharness_model.models.secret_definition import SecretDefinition from cloudharness_model.models.service_auto_artifact_config import ServiceAutoArtifactConfig from cloudharness_model.models.unit_tests_config import UnitTestsConfig from cloudharness_model.models.uri_role_mapping_config import UriRoleMappingConfig diff --git a/libraries/models/cloudharness_model/models/application_harness_config.py b/libraries/models/cloudharness_model/models/application_harness_config.py index d886a593e..bfa003c65 100644 --- a/libraries/models/cloudharness_model/models/application_harness_config.py +++ b/libraries/models/cloudharness_model/models/application_harness_config.py @@ -38,6 +38,7 @@ from cloudharness_model.models.name_value import NameValue from cloudharness_model.models.named_object import NamedObject from cloudharness_model.models.proxy_conf import ProxyConf +from cloudharness_model.models.secret_definition import SecretDefinition from cloudharness_model.models.service_auto_artifact_config import ServiceAutoArtifactConfig from cloudharness_model.models.uri_role_mapping_config import UriRoleMappingConfig @@ -53,7 +54,7 @@ class ApplicationHarnessConfig(CloudHarnessBaseModel): dependencies: Optional[ApplicationDependenciesConfig] = None secured: Optional[Any] = Field(default=None, description="When true, the application is shielded with a getekeeper") uri_role_mapping: Optional[List[UriRoleMappingConfig]] = Field(default=None, description="Map uri/roles to secure with the Gatekeeper (if `secured: true`)") - secrets: Optional[Dict[str, Any]] = None + secrets: Optional[Dict[str, SecretDefinition]] = Field(default=None, description="Application secrets, by name") use_services: Optional[List[NamedObject]] = Field(default=None, description="Specify which services this application uses in the frontend to create proxy ingresses. e.g. ``` - name: samples ```") database: Optional[DatabaseDeploymentConfig] = None resources: Optional[List[FileResourcesConfig]] = Field(default=None, description="Application file resources. Maps from deploy/resources folder and mounts as configmaps") @@ -122,6 +123,13 @@ def to_dict(self) -> Dict[str, Any]: if _item_uri_role_mapping: _items.append(_item_uri_role_mapping.to_dict()) _dict['uri_role_mapping'] = _items + # override the default output from pydantic by calling `to_dict()` of each value in secrets (dict) + _field_dict = {} + if self.secrets: + for _key_secrets in self.secrets: + if self.secrets[_key_secrets]: + _field_dict[_key_secrets] = self.secrets[_key_secrets].to_dict() + _dict['secrets'] = _field_dict # override the default output from pydantic by calling `to_dict()` of each item in use_services (list) _items = [] if self.use_services: @@ -203,7 +211,12 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "dependencies": ApplicationDependenciesConfig.from_dict(obj["dependencies"]) if obj.get("dependencies") is not None else None, "secured": obj.get("secured"), "uri_role_mapping": [UriRoleMappingConfig.from_dict(_item) for _item in obj["uri_role_mapping"]] if obj.get("uri_role_mapping") is not None else None, - "secrets": obj.get("secrets"), + "secrets": dict( + (_k, SecretDefinition.from_dict(_v)) + for _k, _v in obj["secrets"].items() + ) + if obj.get("secrets") is not None + else None, "use_services": [NamedObject.from_dict(_item) for _item in obj["use_services"]] if obj.get("use_services") is not None else None, "database": DatabaseDeploymentConfig.from_dict(obj["database"]) if obj.get("database") is not None else None, "resources": [FileResourcesConfig.from_dict(_item) for _item in obj["resources"]] if obj.get("resources") is not None else None, diff --git a/libraries/models/cloudharness_model/models/gatekeeper_conf.py b/libraries/models/cloudharness_model/models/gatekeeper_conf.py index 1f10fc60d..db3717080 100644 --- a/libraries/models/cloudharness_model/models/gatekeeper_conf.py +++ b/libraries/models/cloudharness_model/models/gatekeeper_conf.py @@ -92,3 +92,4 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: return _obj + diff --git a/libraries/models/cloudharness_model/models/secret_config.py b/libraries/models/cloudharness_model/models/secret_config.py new file mode 100644 index 000000000..eeb230c86 --- /dev/null +++ b/libraries/models/cloudharness_model/models/secret_config.py @@ -0,0 +1,95 @@ +# coding: utf-8 + +""" + cloudharness + + No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from typing import Optional, Set +from typing_extensions import Self + + +from cloudharness_model.base_model import CloudHarnessBaseModel +from pydantic import BaseModel, Field, field_validator, StrictStr, StrictBool, StrictInt, StrictFloat +from typing import ClassVar, List, Dict, Any, Union, Optional, Annotated +import importlib + +class SecretConfig(CloudHarnessBaseModel): + """ + Rich definition of an application secret, used in place of a plain value to delegate the secret to a secret manager. Manager specific settings (e.g. `path` for onepassword, `arn` for aws) are added next to the properties below. + """ # noqa: E501 + manager: Optional[StrictStr] = Field(default=None, description="Name of the secret manager handling the secret. Defaults to `cloudharness`, which creates the value in the application secret. Set explicitly to null to leave the secret unmanaged: nothing is created and the secret is assumed to exist already.") + default: Optional[StrictStr] = Field(default=None, description="Value used by the `cloudharness` manager and as a fallback when the secret manager is not available, as in local docker compose deployments. Follows the same conventions as a plain secret value: null or empty generates a random value once, `?` generates a new random value at every deployment.") + additional_properties: Dict[str, Any] = {} + __properties: ClassVar[List[str]] = ["manager", "default"] + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + * Fields in `self.additional_properties` are added to the output dict. + """ + excluded_fields: Set[str] = set([ + "additional_properties", + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # puts key-value pairs in additional_properties in the top level + if self.additional_properties is not None: + for _key, _value in self.additional_properties.items(): + _dict[_key] = _value + + # set to None if manager (nullable) is None + # and model_fields_set contains the field + if self.manager is None and "manager" in self.model_fields_set: + _dict['manager'] = None + + # set to None if default (nullable) is None + # and model_fields_set contains the field + if self.default is None and "default" in self.model_fields_set: + _dict['default'] = None + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of SecretConfig from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "manager": obj.get("manager"), + "default": obj.get("default") + }) + # store additional fields in additional_properties + for _key in obj.keys(): + if _key not in cls.__properties: + _obj.additional_properties[_key] = obj.get(_key) + + return _obj + + diff --git a/libraries/models/cloudharness_model/models/secret_definition.py b/libraries/models/cloudharness_model/models/secret_definition.py new file mode 100644 index 000000000..ab32b5cfa --- /dev/null +++ b/libraries/models/cloudharness_model/models/secret_definition.py @@ -0,0 +1,136 @@ +# coding: utf-8 + +""" + cloudharness + + No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + + The version of the OpenAPI document: 1.0.0 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +from inspect import getfullargspec +import json +import pprint +import re # noqa: F401 +from pydantic import BaseModel, ConfigDict, Field, StrictStr, ValidationError, field_validator +from typing import Optional +from cloudharness_model.models.secret_config import SecretConfig +from typing import Union, Any, List, Set, TYPE_CHECKING, Optional, Dict +from typing_extensions import Literal, Self +from pydantic import Field + +SECRETDEFINITION_ANY_OF_SCHEMAS = ["SecretConfig", "str"] + +class SecretDefinition(BaseModel): + """ + An application secret, defined either as a plain value or as a secret configuration object. A `string` (or null) is the secret value itself: empty or null generates a random value, `?` generates a new random value at every deployment. A `SecretConfig` object instead delegates the secret to a secret manager, and carries the settings that manager needs. + """ + + # data type: str + anyof_schema_1_validator: Optional[StrictStr] = None + # data type: SecretConfig + anyof_schema_2_validator: Optional[SecretConfig] = None + if TYPE_CHECKING: + actual_instance: Optional[Union[SecretConfig, str]] = None + else: + actual_instance: Any = None + any_of_schemas: Set[str] = { "SecretConfig", "str" } + + model_config = { + "validate_assignment": True, + "protected_namespaces": (), + } + + def __init__(self, *args, **kwargs) -> None: + if args: + if len(args) > 1: + raise ValueError("If a position argument is used, only 1 is allowed to set `actual_instance`") + if kwargs: + raise ValueError("If a position argument is used, keyword arguments cannot be used.") + super().__init__(actual_instance=args[0]) + else: + super().__init__(**kwargs) + + @field_validator('actual_instance') + def actual_instance_must_validate_anyof(cls, v): + instance = SecretDefinition.model_construct() + error_messages = [] + # validate data type: str + try: + instance.anyof_schema_1_validator = v + return v + except (ValidationError, ValueError) as e: + error_messages.append(str(e)) + # validate data type: SecretConfig + if not isinstance(v, SecretConfig): + error_messages.append(f"Error! Input type `{type(v)}` is not `SecretConfig`") + else: + return v + + if error_messages: + # no match + raise ValueError("No match found when setting the actual_instance in SecretDefinition with anyOf schemas: SecretConfig, str. Details: " + ", ".join(error_messages)) + else: + return v + + @classmethod + def from_dict(cls, obj: Dict[str, Any]) -> Self: + return cls.from_json(json.dumps(obj)) + + @classmethod + def from_json(cls, json_str: str) -> Self: + """Returns the object represented by the json string""" + instance = cls.model_construct() + error_messages = [] + # deserialize data into str + try: + # validation + instance.anyof_schema_1_validator = json.loads(json_str) + # assign value to actual_instance + instance.actual_instance = instance.anyof_schema_1_validator + return instance + except (ValidationError, ValueError) as e: + error_messages.append(str(e)) + # anyof_schema_2_validator: Optional[SecretConfig] = None + try: + instance.actual_instance = SecretConfig.from_json(json_str) + return instance + except (ValidationError, ValueError) as e: + error_messages.append(str(e)) + + if error_messages: + # no match + raise ValueError("No match found when deserializing the JSON string into SecretDefinition with anyOf schemas: SecretConfig, str. Details: " + ", ".join(error_messages)) + else: + return instance + + def to_json(self) -> str: + """Returns the JSON representation of the actual instance""" + if self.actual_instance is None: + return "null" + + if hasattr(self.actual_instance, "to_json") and callable(self.actual_instance.to_json): + return self.actual_instance.to_json() + else: + return json.dumps(self.actual_instance) + + def to_dict(self) -> Optional[Union[Dict[str, Any], SecretConfig, str]]: + """Returns the dict representation of the actual instance""" + if self.actual_instance is None: + return None + + if hasattr(self.actual_instance, "to_dict") and callable(self.actual_instance.to_dict): + return self.actual_instance.to_dict() + else: + return self.actual_instance + + def to_str(self) -> str: + """Returns the string representation of the actual instance""" + return pprint.pformat(self.model_dump()) + + diff --git a/libraries/models/docs/ApplicationHarnessConfig.md b/libraries/models/docs/ApplicationHarnessConfig.md index f10c624f7..e55a3e893 100644 --- a/libraries/models/docs/ApplicationHarnessConfig.md +++ b/libraries/models/docs/ApplicationHarnessConfig.md @@ -14,7 +14,7 @@ Name | Type | Description | Notes **dependencies** | [**ApplicationDependenciesConfig**](ApplicationDependenciesConfig.md) | | [optional] **secured** | **object** | When true, the application is shielded with a getekeeper | [optional] **uri_role_mapping** | [**List[UriRoleMappingConfig]**](UriRoleMappingConfig.md) | Map uri/roles to secure with the Gatekeeper (if `secured: true`) | [optional] -**secrets** | **Dict[str, object]** | | [optional] +**secrets** | [**Dict[str, SecretDefinition]**](SecretDefinition.md) | Application secrets, by name | [optional] **use_services** | [**List[NamedObject]**](NamedObject.md) | Specify which services this application uses in the frontend to create proxy ingresses. e.g. ``` - name: samples ``` | [optional] **database** | [**DatabaseDeploymentConfig**](DatabaseDeploymentConfig.md) | | [optional] **resources** | [**List[FileResourcesConfig]**](FileResourcesConfig.md) | Application file resources. Maps from deploy/resources folder and mounts as configmaps | [optional] diff --git a/libraries/models/docs/GatekeeperConf.md b/libraries/models/docs/GatekeeperConf.md index dfc33fbe8..c412fe0cb 100644 --- a/libraries/models/docs/GatekeeperConf.md +++ b/libraries/models/docs/GatekeeperConf.md @@ -10,7 +10,7 @@ Name | Type | Description | Notes **replicas** | **int** | | [optional] **resources** | [**DeploymentResourcesConf**](DeploymentResourcesConf.md) | | [optional] **secret** | **str** | | [optional] -**configuration** | **Dict[str, Any]** | Native Gatekeeper proxy.yml settings, keyed by the kebab-case names from the Gatekeeper configuration reference. Application values override global values and CloudHarness-generated defaults. | [optional] +**configuration** | **Dict[str, object]** | Native Gatekeeper proxy.yml settings, keyed by the kebab-case names from the Gatekeeper configuration reference. Application values override global values and CloudHarness-generated defaults. | [optional] ## Example @@ -30,3 +30,5 @@ gatekeeper_conf_dict = gatekeeper_conf_instance.to_dict() gatekeeper_conf_from_dict = GatekeeperConf.from_dict(gatekeeper_conf_dict) ``` [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/libraries/models/docs/SecretConfig.md b/libraries/models/docs/SecretConfig.md new file mode 100644 index 000000000..671fdfb51 --- /dev/null +++ b/libraries/models/docs/SecretConfig.md @@ -0,0 +1,31 @@ +# SecretConfig + +Rich definition of an application secret, used in place of a plain value to delegate the secret to a secret manager. Manager specific settings (e.g. `path` for onepassword, `arn` for aws) are added next to the properties below. + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**manager** | **str** | Name of the secret manager handling the secret. Defaults to `cloudharness`, which creates the value in the application secret. Set explicitly to null to leave the secret unmanaged: nothing is created and the secret is assumed to exist already. | [optional] +**default** | **str** | Value used by the `cloudharness` manager and as a fallback when the secret manager is not available, as in local docker compose deployments. Follows the same conventions as a plain secret value: null or empty generates a random value once, `?` generates a new random value at every deployment. | [optional] + +## Example + +```python +from cloudharness_model.models.secret_config import SecretConfig + +# TODO update the JSON string below +json = "{}" +# create an instance of SecretConfig from a JSON string +secret_config_instance = SecretConfig.from_json(json) +# print the JSON string representation of the object +print(SecretConfig.to_json()) + +# convert the object into a dict +secret_config_dict = secret_config_instance.to_dict() +# create an instance of SecretConfig from a dict +secret_config_from_dict = SecretConfig.from_dict(secret_config_dict) +``` +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/libraries/models/docs/SecretDefinition.md b/libraries/models/docs/SecretDefinition.md new file mode 100644 index 000000000..f972258de --- /dev/null +++ b/libraries/models/docs/SecretDefinition.md @@ -0,0 +1,31 @@ +# SecretDefinition + +An application secret, defined either as a plain value or as a secret configuration object. A `string` (or null) is the secret value itself: empty or null generates a random value, `?` generates a new random value at every deployment. A `SecretConfig` object instead delegates the secret to a secret manager, and carries the settings that manager needs. + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**manager** | **str** | Name of the secret manager handling the secret. Defaults to `cloudharness`, which creates the value in the application secret. Set explicitly to null to leave the secret unmanaged: nothing is created and the secret is assumed to exist already. | [optional] +**default** | **str** | Value used by the `cloudharness` manager and as a fallback when the secret manager is not available, as in local docker compose deployments. Follows the same conventions as a plain secret value: null or empty generates a random value once, `?` generates a new random value at every deployment. | [optional] + +## Example + +```python +from cloudharness_model.models.secret_definition import SecretDefinition + +# TODO update the JSON string below +json = "{}" +# create an instance of SecretDefinition from a JSON string +secret_definition_instance = SecretDefinition.from_json(json) +# print the JSON string representation of the object +print(SecretDefinition.to_json()) + +# convert the object into a dict +secret_definition_dict = secret_definition_instance.to_dict() +# create an instance of SecretDefinition from a dict +secret_definition_from_dict = SecretDefinition.from_dict(secret_definition_dict) +``` +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/tools/deployment-cli-tools/ch_cli_tools/codefresh.py b/tools/deployment-cli-tools/ch_cli_tools/codefresh.py index cc9757376..9a4f25f9c 100644 --- a/tools/deployment-cli-tools/ch_cli_tools/codefresh.py +++ b/tools/deployment-cli-tools/ch_cli_tools/codefresh.py @@ -12,6 +12,7 @@ from .models import HarnessMainConfig, ApplicationTestConfig, ApplicationHarnessConfig from cloudharness_utils.constants import * from .configurationgenerator import KEY_APPS, KEY_TASK_IMAGES, KEY_TEST_IMAGES +from .secrets import is_cloudharness_managed, is_secret_config, secret_value from .utils import check_image_exists_in_registry, find_dockerfiles_paths, get_app_relative_to_base_path, guess_build_dependencies_from_dockerfile, \ get_template, dict_merge, app_name_from_path, clean_path, strip_registry_tag from cloudharness_utils.testing.api import get_api_filename, get_schemathesis_command, get_urls_from_api_file @@ -466,10 +467,17 @@ def adjust_build_steps(index): arguments["custom_values"] = [] for app_name, app in helm_values.apps.items(): if app.harness.secrets: - for secret in [secret[0] for secret in app.harness.secrets.items() if secret[1] != ""]: + # Secrets handled by a manager other than CloudHarness must not be overridden: + # their value doesn't come from the pipeline, and setting it would replace the + # manager definition with a plain string + for secret, definition in app.harness.secrets.items(): + if not is_cloudharness_managed(definition) or secret_value(definition) == "": + continue secret_name = secret.replace("_", "__") + # the rich form nests the value under `default` + value_path = f"{secret_name}_default" if is_secret_config(definition) else secret_name arguments["custom_values"].append( - 'apps_%s_harness_secrets_%s="${{%s}}"' % (app_name.replace("_", "__"), secret_name, secret_name.upper()) + 'apps_%s_harness_secrets_%s="${{%s}}"' % (app_name.replace("_", "__"), value_path, secret_name.upper()) ) for app_name, app in helm_values.apps.items(): if app.harness.database and app.harness.database.get("connect_string") == "": diff --git a/tools/deployment-cli-tools/ch_cli_tools/configurationgenerator.py b/tools/deployment-cli-tools/ch_cli_tools/configurationgenerator.py index e992ad17a..6ce2c1286 100644 --- a/tools/deployment-cli-tools/ch_cli_tools/configurationgenerator.py +++ b/tools/deployment-cli-tools/ch_cli_tools/configurationgenerator.py @@ -18,6 +18,7 @@ from .utils import get_cluster_ip, env_variable, get_sub_paths, guess_build_dependencies_from_dockerfile, image_name_from_dockerfile_path, \ get_template, merge_configuration_directories, dict_merge, app_name_from_path, \ find_dockerfiles_paths, get_git_commit_hash +from .secrets import secret_definition_error KEY_HARNESS = 'harness' @@ -643,6 +644,20 @@ class ValuesValidationException(Exception): def validate_helm_values(values): validate_dependencies(values) + validate_secrets(values) + + +def validate_secrets(values): + for app, app_values in values["apps"].items(): + secrets = app_values[KEY_HARNESS].get("secrets") or {} + if not isinstance(secrets, dict): + raise ValuesValidationException( + f"Bad secrets specified for application {app}: expected a map of secret definitions") + for name, definition in secrets.items(): + error = secret_definition_error(definition) + if error: + raise ValuesValidationException( + f"Bad definition for secret {name} of application {app}: {error}") def validate_dependencies(values): diff --git a/tools/deployment-cli-tools/ch_cli_tools/secrets.py b/tools/deployment-cli-tools/ch_cli_tools/secrets.py new file mode 100644 index 000000000..3db5af034 --- /dev/null +++ b/tools/deployment-cli-tools/ch_cli_tools/secrets.py @@ -0,0 +1,107 @@ +"""Helpers to read the `harness.secrets` definitions. + +A secret is defined either in the simple form + + secrets: + mySecret: "a value" + +or in the rich form, which selects a secret manager and carries the settings it needs + + secrets: + mySecret: + manager: onepassword + default: "a value" + path: vaults/my-vault/items/my-item + +See `deployment-configuration/helm/templates/_secrets.tpl` for the Helm side. +""" + +from typing import Any, Optional, TypedDict, Union + +CLOUDHARNESS_MANAGER = "cloudharness" +UNMANAGED = None + + +class SecretConfigDict(TypedDict, total=False): + """The rich form of a secret definition. Manager specific settings (`path` for + onepassword, `arn` for aws, ...) are added next to these entries.""" + manager: Optional[str] + default: Optional[str] + + +# mirrors the SecretsMap schema: a secret is a plain value or a secret configuration +SecretDefinition = Union[str, SecretConfigDict, None] + + +def _plain(definition: Any) -> Any: + """The plain form of a secret definition. + + Definitions reach the helpers either as raw values, the way they appear in + `values.yaml`, or wrapped in the generated `SecretDefinition` union model when they + come from a parsed `HarnessMainConfig`. Both are reduced to a value or a plain dict. + """ + definition = getattr(definition, "actual_instance", definition) + as_dict = getattr(definition, "to_dict", None) + return as_dict() if as_dict else definition + + +def is_secret_config(definition: SecretDefinition) -> bool: + """Whether the secret uses the rich form, which nests the value under `default`.""" + return isinstance(_plain(definition), dict) + + +def secret_manager(definition: SecretDefinition) -> Optional[str]: + """Name of the secret manager handling a secret definition. + + Returns `cloudharness` for the simple form and whenever no manager is specified, + `None` when the manager is explicitly null, meaning the secret is not managed by + CloudHarness at all. + """ + definition = _plain(definition) + if not isinstance(definition, dict): + return CLOUDHARNESS_MANAGER + if "manager" not in definition: + return CLOUDHARNESS_MANAGER + manager = definition["manager"] + if manager is None or manager == "": + return UNMANAGED + return str(manager) + + +def is_cloudharness_managed(definition: SecretDefinition) -> bool: + """Whether the secret value is handled by CloudHarness itself.""" + return secret_manager(definition) == CLOUDHARNESS_MANAGER + + +def secret_value(definition: SecretDefinition) -> Optional[str]: + """Value of a secret definition: the definition itself in the simple form, the + `default` entry in the rich form. + + `None` (configure later) and `""` (generate a static random value) keep their meaning + in both forms. + """ + definition = _plain(definition) + if not isinstance(definition, dict): + return definition + return definition.get("default") + + +def secret_definition_error(definition: Any) -> Optional[str]: + """Describe why a secret definition is malformed, `None` when it is well formed. + + Only the shape shared by every manager is checked: manager names are not validated + against a known list, as managers can be contributed by any application's helm + templates, which are never read here. + """ + definition = _plain(definition) + if definition is None or isinstance(definition, (str, int, float, bool)): + return None + if not isinstance(definition, dict): + return f"expected a secret value or a secret configuration, got {type(definition).__name__}" + manager = definition.get("manager") + if manager is not None and not isinstance(manager, str): + return "`manager` must be the name of a secret manager, or null for an unmanaged secret" + default = definition.get("default") + if isinstance(default, (dict, list)): + return "`default` must be a plain secret value" + return None diff --git a/tools/deployment-cli-tools/ch_cli_tools/templates/python/model.mustache b/tools/deployment-cli-tools/ch_cli_tools/templates/python/model.mustache index 9b795ddf3..37df5a12b 100644 --- a/tools/deployment-cli-tools/ch_cli_tools/templates/python/model.mustache +++ b/tools/deployment-cli-tools/ch_cli_tools/templates/python/model.mustache @@ -1,21 +1,7 @@ # coding: utf-8 {{>partial_header}} - -from __future__ import annotations -import pprint -import re # noqa: F401 -import json - -{{#vendorExtensions.x-py-other-imports}} -{{{.}}} -{{/vendorExtensions.x-py-other-imports}} -{{#vendorExtensions.x-py-model-imports}} -{{{.}}} -{{/vendorExtensions.x-py-model-imports}} -from typing import Optional, Set -from typing_extensions import Self - +{{! Imports belong to the body templates: model_oneof/model_anyof come from the generator and emit their own header, so one here would be duplicated (two `from __future__` lines is a syntax error). See model_generic.mustache. }} {{#hasChildren}} {{#discriminator}} {{! If this model is a super class, importlib is used. So import the necessary modules for the type here. }} diff --git a/tools/deployment-cli-tools/ch_cli_tools/templates/python/model_generic.mustache b/tools/deployment-cli-tools/ch_cli_tools/templates/python/model_generic.mustache index e05c01e78..070426659 100644 --- a/tools/deployment-cli-tools/ch_cli_tools/templates/python/model_generic.mustache +++ b/tools/deployment-cli-tools/ch_cli_tools/templates/python/model_generic.mustache @@ -1,3 +1,12 @@ +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from typing import Optional, Set +from typing_extensions import Self + + from cloudharness_model.base_model import CloudHarnessBaseModel from pydantic import BaseModel, Field, field_validator, StrictStr, StrictBool, StrictInt, StrictFloat from typing import ClassVar, List, Dict, Any, Union, Optional, Annotated diff --git a/tools/deployment-cli-tools/tests/resources/applications/myapp/deploy/values-secrets.yaml b/tools/deployment-cli-tools/tests/resources/applications/myapp/deploy/values-secrets.yaml new file mode 100644 index 000000000..1fe02e8d2 --- /dev/null +++ b/tools/deployment-cli-tools/tests/resources/applications/myapp/deploy/values-secrets.yaml @@ -0,0 +1,9 @@ +harness: + secrets: + plainSecret: a value + richSecret: + manager: onepassword + path: vaults/my-vault/items/my-item + default: a local value + unmanagedSecret: + manager: diff --git a/tools/deployment-cli-tools/tests/test_codefresh.py b/tools/deployment-cli-tools/tests/test_codefresh.py index 1259c001d..413a741f4 100644 --- a/tools/deployment-cli-tools/tests/test_codefresh.py +++ b/tools/deployment-cli-tools/tests/test_codefresh.py @@ -3,6 +3,7 @@ from ch_cli_tools.helm import * from ch_cli_tools.configurationgenerator import * from ch_cli_tools.codefresh import * +from ch_cli_tools.secrets import is_cloudharness_managed, is_secret_config, secret_manager, secret_value HERE = os.path.dirname(os.path.realpath(__file__)) RESOURCES = os.path.join(HERE, 'resources') @@ -951,3 +952,80 @@ def test_codefresh_working_directory_uses_cloned_cloud_harness(): finally: os.chdir(old_cwd) shutil.rmtree(BUILD_MERGE_DIR, ignore_errors=True) + + +def test_codefresh_secret_managers(): + """Only the secrets handled by CloudHarness are exported as pipeline variables""" + values = create_helm_chart( + [CLOUDHARNESS_ROOT, RESOURCES], + output_path=OUT, + include=['myapp'], + exclude=['events'], + domain="my.local", + namespace='test', + env='dev', + local=False, + tag=1, + registry='reg' + ) + try: + root_paths = preprocess_build_overrides( + root_paths=[CLOUDHARNESS_ROOT, RESOURCES], + helm_values=values, + merge_build_path=BUILD_MERGE_DIR + ) + + build_included = [app['harness']['name'] + for app in values['apps'].values() if 'harness' in app] + + values.apps["myapp"].harness.secrets = { + "plain_secret": None, + "static_random": "", + "rich_secret": {"default": None}, + "rich_static_random": {"manager": "cloudharness", "default": ""}, + "op_secret": {"manager": "onepassword", "path": "vaults/v/items/i"}, + "unmanaged_secret": {"manager": None}, + } + + cf = create_codefresh_deployment_scripts(root_paths, include=build_included, + envs=['dev'], + base_image_name=values['name'], + helm_values=values, save=False) + + custom_values = [value for value in cf['steps']['deployment']['arguments']['custom_values'] + if value.startswith("apps_myapp_harness_secrets_")] + assert custom_values == [ + 'apps_myapp_harness_secrets_plain__secret="${{PLAIN__SECRET}}"', + # the rich form nests the value under `default` + 'apps_myapp_harness_secrets_rich__secret_default="${{RICH__SECRET}}"', + ] + finally: + shutil.rmtree(BUILD_MERGE_DIR, ignore_errors=True) + + +def test_codefresh_secret_managers_from_parsed_values(): + """Secrets read back from a parsed configuration are wrapped in the generated + SecretDefinition union model: the helpers must see through it, or a manager delegated + secret would be exported to the pipeline and overwritten by its value""" + harness = ApplicationHarnessConfig.from_dict({ + 'name': 'myapp', + 'secrets': { + 'plain_secret': None, + 'static_random': '', + 'rich_secret': {'default': None}, + 'op_secret': {'manager': 'onepassword', 'path': 'vaults/v/items/i'}, + 'unmanaged_secret': {'manager': None}, + }, + }) + + definitions = harness.secrets + assert type(definitions['op_secret']).__name__ == 'SecretDefinition', \ + "the test must exercise the wrapped form, not raw values" + + assert secret_manager(definitions['op_secret']) == 'onepassword' + assert secret_manager(definitions['unmanaged_secret']) is None + assert secret_manager(definitions['plain_secret']) == 'cloudharness' + assert not is_cloudharness_managed(definitions['op_secret']) + assert is_secret_config(definitions['rich_secret']) + assert not is_secret_config(definitions['plain_secret']) + assert secret_value(definitions['static_random']) == '' diff --git a/tools/deployment-cli-tools/tests/test_dockercompose.py b/tools/deployment-cli-tools/tests/test_dockercompose.py index 993147297..d1516ae33 100644 --- a/tools/deployment-cli-tools/tests/test_dockercompose.py +++ b/tools/deployment-cli-tools/tests/test_dockercompose.py @@ -475,3 +475,18 @@ def create(): assert v1 != values.apps['myapp'].harness.deployment.image, "2 levels dependency: If a base image dependency is changed, the hash should change" finally: fname.unlink() + + +@pytest.mark.skipif(not HELM_IS_INSTALLED, reason="helm is not installed") +def test_compose_secrets_use_local_defaults(tmp_path): + """Secret managers are not available locally: compose falls back to the secret defaults""" + out_folder = tmp_path / 'test_compose_secrets_use_local_defaults' + create_docker_compose_configuration([CLOUDHARNESS_ROOT, RESOURCES], output_path=out_folder, + include=['myapp'], exclude=['events', 'legacy'], domain="my.local", + namespace='test', env='secrets', local=False, tag=1, registry='reg') + + generated = out_folder / COMPOSE_PATH / 'resources' / 'generated' / 'auth' + assert (generated / 'plainSecret').read_text() == 'a value' + assert (generated / 'richSecret').read_text() == 'a local value' + # nothing is known locally about an unmanaged secret: a random value is generated + assert len((generated / 'unmanagedSecret').read_text()) == 20 diff --git a/tools/deployment-cli-tools/tests/test_helm.py b/tools/deployment-cli-tools/tests/test_helm.py index 5833f90e9..6cc482815 100644 --- a/tools/deployment-cli-tools/tests/test_helm.py +++ b/tools/deployment-cli-tools/tests/test_helm.py @@ -637,3 +637,284 @@ def test_app_depends_on_task_only(tmp_path): assert "myapp-mytask" in values[KEY_TASK_IMAGES], "cross-app task image must be built" assert "cloudharness-flask" in values[KEY_TASK_IMAGES], "declared base-image build dep must be kept" assert "myapp" not in values[KEY_APPS], "owner app must be built but not deployed" + + +def find_manifests(manifests, kind, name=None): + return [manifest for manifest in manifests + if manifest.get("kind") == kind and + (name is None or manifest.get("metadata", {}).get("name") == name)] + + +def render_with_secrets(tmp_path, name, secrets, secretmanagers=None, app='myapp', include=None, patch=None): + """Generate the chart, override the application secrets and render it.""" + out_folder = tmp_path / name + create_helm_chart([CLOUDHARNESS_ROOT, RESOURCES], output_path=out_folder, domain="my.local", + env='', local=False, include=include or [app], exclude=["legacy"]) + + helm_path = out_folder / HELM_CHART_PATH + shutil.rmtree(helm_path / 'charts', ignore_errors=True) + values_path = helm_path / 'values.yaml' + with open(values_path) as values_file: + values = yaml.safe_load(values_file) + values['apps'][app]['harness']['secrets'] = secrets + # the test applications are not deployed by default, but we need the deployment to + # check how the secrets are mounted + values['apps'][app]['harness']['deployment']['auto'] = True + if secretmanagers is not None: + values['secretmanagers'] = secretmanagers + if patch: + patch(values) + with open(values_path, 'w') as values_file: + yaml.safe_dump(values, values_file) + + return render_helm_chart(helm_path) + + +def secrets_volume(manifests, app='myapp'): + deployment = find_manifest(manifests, 'Deployment', app) + volumes = [v for v in deployment['spec']['template']['spec']['volumes'] if v['name'] == 'secrets'] + assert volumes, "the secrets volume is not mounted" + return volumes[0] + + +def test_secrets_simple_definitions(tmp_path): + """The legacy plain value forms keep creating the application secret and a plain secret volume""" + manifests = render_with_secrets(tmp_path, 'test_secrets_simple_definitions', { + 'unsecureSecret': 'a value', + 'secureSecret': None, + 'random-static-secret': '', + 'random-dynamic-secret': '?', + }) + + secret = find_manifest(manifests, 'Secret', 'myapp') + assert secret['stringData']['unsecureSecret'] == 'a value' + # rendering happens without a cluster, so this is always a first install: + # empty and null values are randomly generated, ? is refreshed at every upgrade + assert len(secret['stringData']['secureSecret']) == 20 + assert len(secret['stringData']['random-static-secret']) == 20 + assert secret['stringData']['random-dynamic-secret'] == '?' + + assert secrets_volume(manifests) == {'name': 'secrets', 'secret': {'secretName': 'myapp'}} + + +def test_secrets_rich_definition_defaults_to_cloudharness(tmp_path): + """Without a manager, the rich form behaves exactly like the plain form""" + manifests = render_with_secrets(tmp_path, 'test_secrets_rich_definition_defaults_to_cloudharness', { + 'withDefault': {'default': 'a value'}, + 'explicitManager': {'manager': 'cloudharness', 'default': '?'}, + 'noDefault': {'manager': 'cloudharness'}, + }) + + secret = find_manifest(manifests, 'Secret', 'myapp') + assert secret['stringData']['withDefault'] == 'a value' + assert secret['stringData']['explicitManager'] == '?' + assert len(secret['stringData']['noDefault']) == 20 + # the rich form must never be rendered as an unrecognized value + assert not any('formatnotrecognized' in key for key in secret['stringData']) + + assert secrets_volume(manifests) == {'name': 'secrets', 'secret': {'secretName': 'myapp'}} + + +def test_secrets_unmanaged(tmp_path): + """An explicitly null manager creates nothing: the secret is expected to exist already""" + manifests = render_with_secrets(tmp_path, 'test_secrets_unmanaged', { + 'existing': {'manager': None}, + }) + + assert not find_manifests(manifests, 'Secret', 'myapp'), "no secret must be created for unmanaged secrets" + # mounted as optional: a missing secret must not block the pod from starting + assert secrets_volume(manifests) == {'name': 'secrets', 'secret': {'secretName': 'myapp', 'optional': True}} + + +def test_secrets_unmanaged_mixed_with_cloudharness(tmp_path): + """Unmanaged secrets are simply left out of the application secret""" + manifests = render_with_secrets(tmp_path, 'test_secrets_unmanaged_mixed_with_cloudharness', { + 'managed': 'a value', + 'existing': {'manager': None}, + }) + + secret = find_manifest(manifests, 'Secret', 'myapp') + assert secret['stringData']['managed'] == 'a value' + assert 'existing' not in secret['stringData'] + # the whole secret is mounted, so the out of band entry shows up as well + assert secrets_volume(manifests) == {'name': 'secrets', 'secret': {'secretName': 'myapp'}} + + +def test_secrets_onepassword_manager(tmp_path): + manifests = render_with_secrets(tmp_path, 'test_secrets_onepassword_manager', { + 'opSecret': {'manager': 'onepassword', 'path': 'vaults/my-vault/items/my-item'}, + 'opField': {'manager': 'onepassword', 'path': 'my-item', 'field': 'credential'}, + }, secretmanagers={'onepassword': {'vault': 'default-vault'}}) + + item = find_manifest(manifests, 'OnePasswordItem', 'myapp-opsecret') + assert item['apiVersion'] == 'onepassword.com/v1' + assert item['spec']['itemPath'] == 'vaults/my-vault/items/my-item' + + # the item name alone is completed with the globally configured vault + item = find_manifest(manifests, 'OnePasswordItem', 'myapp-opfield') + assert item['spec']['itemPath'] == 'vaults/default-vault/items/my-item' + + assert not find_manifests(manifests, 'Secret', 'myapp'), "externally managed secrets are not created by CloudHarness" + + assert secrets_volume(manifests) == { + 'name': 'secrets', + 'projected': { + 'sources': [ + {'secret': {'name': 'myapp', 'optional': True}}, + {'secret': {'name': 'myapp-opfield', 'items': [{'key': 'credential', 'path': 'opField'}]}}, + {'secret': {'name': 'myapp-opsecret', 'items': [{'key': 'password', 'path': 'opSecret'}]}}, + ] + } + } + + +def test_secrets_aws_manager(tmp_path): + manifests = render_with_secrets(tmp_path, 'test_secrets_aws_manager', { + 'awsSecret': {'manager': 'aws', 'arn': 'arn:aws:secretsmanager:eu-west-1:1:secret:mine', 'property': 'password'}, + }, secretmanagers={'aws': {'store': 'aws-store', 'refreshInterval': '30m'}}) + + external = find_manifest(manifests, 'ExternalSecret', 'myapp-awssecret') + assert external['apiVersion'] == 'external-secrets.io/v1beta1' + assert external['spec']['secretStoreRef'] == {'name': 'aws-store', 'kind': 'ClusterSecretStore'} + assert external['spec']['refreshInterval'] == '30m' + assert external['spec']['target']['name'] == 'myapp-awssecret' + assert external['spec']['data'] == [{ + 'secretKey': 'value', + 'remoteRef': {'key': 'arn:aws:secretsmanager:eu-west-1:1:secret:mine', 'property': 'password'}, + }] + + assert secrets_volume(manifests) == { + 'name': 'secrets', + 'projected': { + 'sources': [ + {'secret': {'name': 'myapp', 'optional': True}}, + {'secret': {'name': 'myapp-awssecret', 'items': [{'key': 'value', 'path': 'awsSecret'}]}}, + ] + } + } + + +def test_secrets_mixed_managers(tmp_path): + """CloudHarness and externally managed secrets are exposed in the same directory""" + manifests = render_with_secrets(tmp_path, 'test_secrets_mixed_managers', { + 'local': 'a value', + 'opSecret': {'manager': 'onepassword', 'path': 'vaults/my-vault/items/my-item', 'default': 'ignored locally'}, + }) + + secret = find_manifest(manifests, 'Secret', 'myapp') + assert secret['stringData']['local'] == 'a value' + assert 'opSecret' not in secret['stringData'], "the value of an externally managed secret is never written in the chart" + + assert find_manifests(manifests, 'OnePasswordItem', 'myapp-opsecret') + assert secrets_volume(manifests) == { + 'name': 'secrets', + 'projected': { + 'sources': [ + {'secret': {'name': 'myapp'}}, + {'secret': {'name': 'myapp-opsecret', 'items': [{'key': 'password', 'path': 'opSecret'}]}}, + ] + } + } + + +def test_secrets_unknown_manager_setting_fails(tmp_path): + with pytest.raises(subprocess.CalledProcessError) as error: + render_with_secrets(tmp_path, 'test_secrets_unknown_manager_setting_fails', { + 'opSecret': {'manager': 'onepassword'}, + }) + assert "requires a 'path'" in error.value.stderr + + +def test_secrets_of_a_dependency_are_mounted(tmp_path): + """Applications see the secrets of their dependencies, whatever the manager""" + def depend_on_myapp(values): + values['apps']['dependantapp']['harness']['deployment']['auto'] = True + values['apps']['dependantapp']['harness']['dependencies']['hard'] = ['myapp'] + + manifests = render_with_secrets(tmp_path, 'test_secrets_of_a_dependency_are_mounted', { + 'local': 'a value', + 'opSecret': {'manager': 'onepassword', 'path': 'vaults/my-vault/items/my-item'}, + }, include=['myapp', 'dependantapp'], patch=depend_on_myapp) + + deployment = find_manifest(manifests, 'Deployment', 'dependantapp') + volumes = [v for v in deployment['spec']['template']['spec']['volumes'] if v['name'] == 'cloudharness-myapp'] + assert volumes == [{ + 'name': 'cloudharness-myapp', + 'projected': { + 'sources': [ + {'secret': {'name': 'myapp'}}, + {'secret': {'name': 'myapp-opsecret', 'items': [{'key': 'password', 'path': 'opSecret'}]}}, + ] + } + }] + + mounts = [m for m in deployment['spec']['template']['spec']['containers'][0]['volumeMounts'] + if m['name'] == 'cloudharness-myapp'] + assert mounts[0]['mountPath'] == '/opt/cloudharness/resources/secrets/myapp' + + +def test_secrets_definitions_survive_the_values_generation(tmp_path): + """The rich form must reach the chart untouched, an explicitly null manager included: + losing it would silently turn an unmanaged secret into a CloudHarness managed one""" + out_folder = tmp_path / 'test_secrets_definitions_survive_the_values_generation' + create_helm_chart([CLOUDHARNESS_ROOT, RESOURCES], output_path=out_folder, domain="my.local", + env='secrets', local=False, include=["myapp"], exclude=["legacy"]) + + helm_path = out_folder / HELM_CHART_PATH + shutil.rmtree(helm_path / 'charts', ignore_errors=True) + values_path = helm_path / 'values.yaml' + with open(values_path) as values_file: + values = yaml.safe_load(values_file) + secrets = values['apps']['myapp']['harness']['secrets'] + assert 'manager' in secrets['unmanagedSecret'], "the explicitly null manager must be kept" + assert secrets['unmanagedSecret']['manager'] is None + assert secrets['richSecret'] == { + 'manager': 'onepassword', + 'path': 'vaults/my-vault/items/my-item', + 'default': 'a local value', + } + + values['apps']['myapp']['harness']['deployment']['auto'] = True + with open(values_path, 'w') as values_file: + yaml.safe_dump(values, values_file) + + manifests = render_helm_chart(helm_path) + secret = find_manifest(manifests, 'Secret', 'myapp') + assert secret['stringData']['plainSecret'] == 'a value' + assert 'unmanagedSecret' not in secret['stringData'], "unmanaged secrets are never created" + assert 'richSecret' not in secret['stringData'], "externally managed secrets are never created" + assert find_manifests(manifests, 'OnePasswordItem', 'myapp-richsecret') + + +def secret_values(secrets): + return {KEY_APPS: {'myapp': {KEY_HARNESS: {'secrets': secrets}}}} + + +def test_validate_secrets_accepts_both_definition_forms(): + validate_secrets(secret_values({ + 'plain': 'a value', + 'tobeset': None, + 'static': '', + 'dynamic': '?', + 'rich': {'default': 'a value'}, + 'managed': {'manager': 'onepassword', 'path': 'vaults/v/items/i'}, + 'unmanaged': {'manager': None}, + # unknown managers are valid: applications can contribute their own + 'custom': {'manager': 'my-own-manager', 'whatever': {'nested': True}}, + })) + validate_secrets(secret_values({})) + validate_secrets(secret_values(None)) + + +def test_validate_secrets_rejects_malformed_definitions(): + with pytest.raises(ValuesValidationException, match="secret alist of application myapp"): + validate_secrets(secret_values({'alist': ['a', 'b']})) + + with pytest.raises(ValuesValidationException, match="`manager` must be"): + validate_secrets(secret_values({'badmanager': {'manager': {'name': 'onepassword'}}})) + + with pytest.raises(ValuesValidationException, match="`default` must be"): + validate_secrets(secret_values({'baddefault': {'default': {'a': 'b'}}})) + + with pytest.raises(ValuesValidationException, match="expected a map of secret definitions"): + validate_secrets(secret_values(['a', 'b']))