diff --git a/CHANGELOG.md b/CHANGELOG.md index c1fed86..300c577 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add Crossplane AWS support for automated S3 bucket provisioning for CNPG backups. + ### Changed - migrated upstream helm chart to grafana-community diff --git a/helm/grafana/templates/cnpg/crossplane/_helpers.tpl b/helm/grafana/templates/cnpg/crossplane/_helpers.tpl new file mode 100644 index 0000000..58282c7 --- /dev/null +++ b/helm/grafana/templates/cnpg/crossplane/_helpers.tpl @@ -0,0 +1,52 @@ +{{/* +Crossplane enabled check +*/}} +{{- define "grafana.crossplane.enabled" -}} +{{- if and .Values.postgresqlCluster.crossplane.enabled .Values.postgresqlCluster.crossplane.clusterName -}} +true +{{- else -}} +false +{{- end -}} +{{- end -}} + +{{/* +Crossplane is AWS/CAPA +*/}} +{{- define "grafana.crossplane.isAWS" -}} +{{- if eq .Values.postgresqlCluster.crossplane.provider "aws" -}} +true +{{- else -}} +false +{{- end -}} +{{- end -}} + +{{/* +Merge tags from cluster CR with user-provided tags +Returns tags as a map: {foo: "bar"} +*/}} +{{- define "grafana.crossplane.tags" -}} +{{- $clusterName := .Values.postgresqlCluster.crossplane.clusterName -}} +{{- $clusterNamespace := .Values.postgresqlCluster.crossplane.clusterNamespace -}} +{{- $provider := .Values.postgresqlCluster.crossplane.provider -}} +{{- $tags := dict -}} +{{- if eq $provider "aws" -}} + {{- $awsCluster := lookup "infrastructure.cluster.x-k8s.io/v1beta2" "AWSCluster" $clusterNamespace $clusterName -}} + {{- if $awsCluster -}} + {{- if $awsCluster.spec.additionalTags -}} + {{- range $key, $value := $awsCluster.spec.additionalTags -}} + {{- $_ := set $tags $key $value -}} + {{- end -}} + {{- end -}} + {{- end -}} +{{- end -}} +{{- $defaultTags := dict + "app" "grafana-postgresql" + "managed-by" "crossplane" +-}} +{{- $tags = merge $tags $defaultTags -}} +{{- $userTags := .Values.postgresqlCluster.crossplane.tags | default list -}} +{{- range $tag := $userTags -}} + {{- $_ := set $tags (index $tag "key") (index $tag "value") -}} +{{- end -}} +{{- $tags | toYaml -}} +{{- end -}} diff --git a/helm/grafana/templates/cnpg/crossplane/aws/_helpers.tpl b/helm/grafana/templates/cnpg/crossplane/aws/_helpers.tpl new file mode 100644 index 0000000..e939eeb --- /dev/null +++ b/helm/grafana/templates/cnpg/crossplane/aws/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Get AWS Account ID from AWSCluster identity +Supports both AWSClusterRoleIdentity and AWSClusterControllerIdentity +*/}} +{{- define "grafana.crossplane.aws.accountId" -}} +{{- $clusterName := .Values.postgresqlCluster.crossplane.clusterName -}} +{{- $clusterNamespace := .Values.postgresqlCluster.crossplane.clusterNamespace -}} +{{- $accountId := "" -}} +{{- $awsCluster := lookup "infrastructure.cluster.x-k8s.io/v1beta2" "AWSCluster" $clusterNamespace $clusterName -}} +{{- if $awsCluster -}} + {{- if $awsCluster.spec.identityRef -}} + {{- $identityName := $awsCluster.spec.identityRef.name -}} + {{- $identityKind := $awsCluster.spec.identityRef.kind | default "AWSClusterControllerIdentity" -}} + {{- if eq $identityKind "AWSClusterRoleIdentity" -}} + {{- $identity := lookup "infrastructure.cluster.x-k8s.io/v1beta2" "AWSClusterRoleIdentity" "" $identityName -}} + {{- if $identity -}} + {{- /* Extract account ID from roleARN like arn:aws:iam::758407694730:role/... */ -}} + {{- $roleARN := $identity.spec.roleARN -}} + {{- $parts := regexSplit "::" $roleARN -1 -}} + {{- if gt (len $parts) 1 -}} + {{- $accountId = index (regexSplit ":" (index $parts 1) -1) 0 -}} + {{- end -}} + {{- end -}} + {{- else -}} + {{- $identity := lookup "infrastructure.cluster.x-k8s.io/v1beta2" "AWSClusterControllerIdentity" "" $identityName -}} + {{- if $identity -}} + {{- $accountId = $identity.spec.awsAccountID -}} + {{- end -}} + {{- end -}} + {{- end -}} +{{- end -}} +{{- $accountId -}} +{{- end -}} + +{{/* +Get OIDC Provider URL from cluster +First tries annotation aws.giantswarm.io/irsa-trust-domains, then falls back to identity +*/}} +{{- define "grafana.crossplane.aws.oidcProvider" -}} +{{- $clusterName := .Values.postgresqlCluster.crossplane.clusterName -}} +{{- $clusterNamespace := .Values.postgresqlCluster.crossplane.clusterNamespace -}} +{{- $oidcProvider := "" -}} +{{- $awsCluster := lookup "infrastructure.cluster.x-k8s.io/v1beta2" "AWSCluster" $clusterNamespace $clusterName -}} +{{- if $awsCluster -}} + {{- /* First try to get from annotation (Giant Swarm specific) */ -}} + {{- if $awsCluster.metadata.annotations -}} + {{- $oidcProvider = index $awsCluster.metadata.annotations "aws.giantswarm.io/irsa-trust-domains" | default "" -}} + {{- end -}} + {{- /* If not found in annotation, try identity ref */ -}} + {{- if and (not $oidcProvider) $awsCluster.spec.identityRef -}} + {{- $identityName := $awsCluster.spec.identityRef.name -}} + {{- $identityKind := $awsCluster.spec.identityRef.kind | default "AWSClusterControllerIdentity" -}} + {{- if eq $identityKind "AWSClusterControllerIdentity" -}} + {{- $identity := lookup "infrastructure.cluster.x-k8s.io/v1beta2" "AWSClusterControllerIdentity" "" $identityName -}} + {{- if and $identity $identity.spec.oidc -}} + {{- $oidcProvider = $identity.spec.oidc.issuerURL | trimPrefix "https://" -}} + {{- end -}} + {{- end -}} + {{- end -}} +{{- end -}} +{{- $oidcProvider -}} +{{- end -}} diff --git a/helm/grafana/templates/cnpg/crossplane/aws/bucket-lifecycle.yaml b/helm/grafana/templates/cnpg/crossplane/aws/bucket-lifecycle.yaml new file mode 100644 index 0000000..7a4e259 --- /dev/null +++ b/helm/grafana/templates/cnpg/crossplane/aws/bucket-lifecycle.yaml @@ -0,0 +1,34 @@ +{{- if and .Values.postgresqlCluster.enabled .Values.postgresqlCluster.backup.enabled .Values.postgresqlCluster.crossplane.enabled (include "grafana.crossplane.isAWS" .) .Values.postgresqlCluster.crossplane.aws.enabled }} +{{- $bucketName := .Values.postgresqlCluster.crossplane.aws.bucket.name }} +--- +# Configures lifecycle policies for the PostgreSQL backup S3 bucket +# to automatically expire old backups after a configured number of days +apiVersion: s3.aws.upbound.io/v1beta1 +kind: BucketLifecycleConfiguration +metadata: + name: {{ $bucketName }} + namespace: {{ .Release.Namespace }} + annotations: + crossplane.io/external-name: {{ $bucketName }} + labels: + {{- include "grafana.labels" . | nindent 4 }} + app.kubernetes.io/component: storage +spec: + managementPolicies: + {{- if .Values.postgresqlCluster.crossplane.observeOnly }} + - Observe + {{- else }} + - "*" + {{- end }} + forProvider: + bucketRef: + name: {{ $bucketName }} + region: {{ .Values.postgresqlCluster.crossplane.region }} + rule: + - id: Expiration + status: Enabled + expiration: + - days: {{ .Values.postgresqlCluster.crossplane.aws.bucket.lifecycleDays }} + providerConfigRef: + name: {{ .Values.postgresqlCluster.crossplane.providerConfigRef }} +{{- end }} diff --git a/helm/grafana/templates/cnpg/crossplane/aws/bucket-policy.yaml b/helm/grafana/templates/cnpg/crossplane/aws/bucket-policy.yaml new file mode 100644 index 0000000..0eabc32 --- /dev/null +++ b/helm/grafana/templates/cnpg/crossplane/aws/bucket-policy.yaml @@ -0,0 +1,51 @@ +{{- if and .Values.postgresqlCluster.enabled .Values.postgresqlCluster.backup.enabled .Values.postgresqlCluster.crossplane.enabled (include "grafana.crossplane.isAWS" .) .Values.postgresqlCluster.crossplane.aws.enabled }} +{{- $bucketName := .Values.postgresqlCluster.crossplane.aws.bucket.name }} +{{- $isChinaRegion := hasPrefix "cn-" .Values.postgresqlCluster.crossplane.region }} +--- +# Defines the S3 bucket policy for the PostgreSQL backup bucket +# to enforce secure transport (TLS/SSL) for all access +apiVersion: s3.aws.upbound.io/v1beta1 +kind: BucketPolicy +metadata: + name: {{ $bucketName }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "grafana.labels" . | nindent 4 }} + app.kubernetes.io/component: storage + annotations: + crossplane.io/external-name: {{ $bucketName }} +spec: + managementPolicies: + {{- if .Values.postgresqlCluster.crossplane.observeOnly }} + - Observe + {{- else }} + - "*" + {{- end }} + forProvider: + bucketRef: + name: {{ $bucketName }} + policy: | + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "EnforceSSLOnly", + "Effect": "Deny", + "Principal": "*", + "Action": ["s3:*"], + "Resource": [ + "arn:aws{{- if $isChinaRegion }}-cn{{- end }}:s3:::{{ $bucketName }}", + "arn:aws{{- if $isChinaRegion }}-cn{{- end }}:s3:::{{ $bucketName }}/*" + ], + "Condition": { + "Bool": { + "aws:SecureTransport": "false" + } + } + } + ] + } + region: {{ .Values.postgresqlCluster.crossplane.region }} + providerConfigRef: + name: {{ .Values.postgresqlCluster.crossplane.providerConfigRef }} +{{- end }} diff --git a/helm/grafana/templates/cnpg/crossplane/aws/bucket-public-access-block.yaml b/helm/grafana/templates/cnpg/crossplane/aws/bucket-public-access-block.yaml new file mode 100644 index 0000000..3f02766 --- /dev/null +++ b/helm/grafana/templates/cnpg/crossplane/aws/bucket-public-access-block.yaml @@ -0,0 +1,34 @@ +{{- if and .Values.postgresqlCluster.enabled .Values.postgresqlCluster.backup.enabled .Values.postgresqlCluster.crossplane.enabled (include "grafana.crossplane.isAWS" .) .Values.postgresqlCluster.crossplane.aws.enabled }} +{{- $bucketName := .Values.postgresqlCluster.crossplane.aws.bucket.name }} +--- +# Blocks all public access to the PostgreSQL backup S3 bucket +# as access is restricted to authorized services via VPC endpoints +apiVersion: s3.aws.upbound.io/v1beta1 +kind: BucketPublicAccessBlock +metadata: + name: {{ $bucketName }} + namespace: {{ .Release.Namespace }} + annotations: + crossplane.io/external-name: {{ $bucketName }} + labels: + {{- include "grafana.labels" . | nindent 4 }} + app.kubernetes.io/component: storage +spec: + managementPolicies: + {{- if .Values.postgresqlCluster.crossplane.observeOnly }} + - Observe + {{- else }} + - "*" + {{- end }} + forProvider: + bucketRef: + name: {{ $bucketName }} + region: {{ .Values.postgresqlCluster.crossplane.region }} + blockPublicAcls: true + blockPublicPolicy: true + ignorePublicAcls: true + # Prevent public access to the bucket as we use VPC endpoints + restrictPublicBuckets: true + providerConfigRef: + name: {{ .Values.postgresqlCluster.crossplane.providerConfigRef }} +{{- end }} diff --git a/helm/grafana/templates/cnpg/crossplane/aws/bucket.yaml b/helm/grafana/templates/cnpg/crossplane/aws/bucket.yaml new file mode 100644 index 0000000..e5ab85e --- /dev/null +++ b/helm/grafana/templates/cnpg/crossplane/aws/bucket.yaml @@ -0,0 +1,36 @@ +{{- if and .Values.postgresqlCluster.enabled .Values.postgresqlCluster.backup.enabled .Values.postgresqlCluster.crossplane.enabled (include "grafana.crossplane.isAWS" .) .Values.postgresqlCluster.crossplane.aws.enabled }} +{{- $tags := include "grafana.crossplane.tags" . | fromYaml }} +{{- $bucketName := .Values.postgresqlCluster.crossplane.aws.bucket.name }} +--- +# Creates the S3 bucket for storing PostgreSQL backups +# managed by CloudNativePG with barman-cloud +apiVersion: s3.aws.upbound.io/v1beta2 +kind: Bucket +metadata: + name: {{ $bucketName }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "grafana.labels" . | nindent 4 }} + app.kubernetes.io/component: storage + annotations: + crossplane.io/external-name: {{ $bucketName }} +spec: + managementPolicies: + {{- if .Values.postgresqlCluster.crossplane.observeOnly }} + - Observe + {{- else }} + - "*" + {{- end }} + forProvider: + forceDestroy: false + objectLockEnabled: false + region: {{ .Values.postgresqlCluster.crossplane.region }} + {{- if $tags }} + tags: + {{- range $key, $value := $tags }} + {{ $key }}: {{ $value | quote }} + {{- end }} + {{- end }} + providerConfigRef: + name: {{ .Values.postgresqlCluster.crossplane.providerConfigRef }} +{{- end }} diff --git a/helm/grafana/templates/cnpg/crossplane/aws/iam.yaml b/helm/grafana/templates/cnpg/crossplane/aws/iam.yaml new file mode 100644 index 0000000..5b25372 --- /dev/null +++ b/helm/grafana/templates/cnpg/crossplane/aws/iam.yaml @@ -0,0 +1,112 @@ +{{- if and .Values.postgresqlCluster.enabled .Values.postgresqlCluster.backup.enabled .Values.postgresqlCluster.crossplane.enabled (include "grafana.crossplane.isAWS" .) .Values.postgresqlCluster.crossplane.aws.enabled .Values.postgresqlCluster.crossplane.aws.iam.enabled }} +{{- $bucketName := .Values.postgresqlCluster.crossplane.aws.bucket.name }} +{{- $namespace := .Release.Namespace }} +{{- $tags := include "grafana.crossplane.tags" . | fromYaml }} +{{- $oidcProvider := include "grafana.crossplane.aws.oidcProvider" . }} +{{- $isChinaRegion := hasPrefix "cn-" .Values.postgresqlCluster.crossplane.region }} +{{- $serviceAccountName := .Values.postgresqlCluster.name }} +--- +# Creates an IAM role for PostgreSQL service accounts to access the backup S3 bucket +# using IRSA (IAM Roles for Service Accounts) with OIDC authentication +apiVersion: iam.aws.upbound.io/v1beta1 +kind: Role +metadata: + name: {{ $bucketName }} + namespace: {{ $namespace }} + labels: + {{- include "grafana.labels" . | nindent 4 }} + app.kubernetes.io/component: iam + annotations: + crossplane.io/external-name: {{ $bucketName }} +spec: + managementPolicies: + {{- if .Values.postgresqlCluster.crossplane.observeOnly }} + - Observe + {{- else }} + - "*" + {{- end }} + forProvider: + assumeRolePolicy: | + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Federated": "arn:aws{{- if $isChinaRegion }}-cn{{- end }}:iam::{{ include "grafana.crossplane.aws.accountId" . }}:oidc-provider/{{ $oidcProvider }}" + }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "{{ $oidcProvider }}:sub": "system:serviceaccount:{{ $namespace }}:{{ $serviceAccountName }}", + "{{ $oidcProvider }}:aud": "sts.amazonaws.com{{- if $isChinaRegion }}.cn{{- end }}" + } + } + }, + { + "Effect": "Allow", + "Principal": { + "Federated": "arn:aws{{- if $isChinaRegion }}-cn{{- end }}:iam::{{ include "grafana.crossplane.aws.accountId" . }}:oidc-provider/{{ $oidcProvider }}" + }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "{{ $oidcProvider }}:sub": "system:serviceaccount:{{ $namespace }}:grafana-postgresql-recovery-test", + "{{ $oidcProvider }}:aud": "sts.amazonaws.com{{- if $isChinaRegion }}.cn{{- end }}" + } + } + }, + { + "Effect": "Allow", + "Principal": { + "Federated": "arn:aws{{- if $isChinaRegion }}-cn{{- end }}:iam::{{ include "grafana.crossplane.aws.accountId" . }}:oidc-provider/{{ $oidcProvider }}" + }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "{{ $oidcProvider }}:sub": "system:serviceaccount:{{ $namespace }}:plugin-barman-cloud", + "{{ $oidcProvider }}:aud": "sts.amazonaws.com{{- if $isChinaRegion }}.cn{{- end }}" + } + } + } + ] + } + inlinePolicy: + - name: {{ $bucketName }} + policy: | + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:ListBucket", + "s3:PutObject", + "s3:GetObject", + "s3:DeleteObject" + ], + "Resource": [ + "arn:aws{{- if $isChinaRegion }}-cn{{- end }}:s3:::{{ $bucketName }}", + "arn:aws{{- if $isChinaRegion }}-cn{{- end }}:s3:::{{ $bucketName }}/*" + ] + }, + { + "Effect": "Allow", + "Action": [ + "s3:GetAccessPoint", + "s3:GetAccountPublicAccessBlock", + "s3:ListAccessPoints" + ], + "Resource": "*" + } + ] + } + {{- if $tags }} + tags: + {{- range $key, $value := $tags }} + {{ $key }}: {{ $value | quote }} + {{- end }} + {{- end }} + providerConfigRef: + name: {{ .Values.postgresqlCluster.crossplane.providerConfigRef }} +{{- end }} diff --git a/helm/grafana/values.schema.json b/helm/grafana/values.schema.json index 60d2d41..a889c8e 100644 --- a/helm/grafana/values.schema.json +++ b/helm/grafana/values.schema.json @@ -1040,6 +1040,74 @@ } } }, + "crossplane": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "provider": { + "type": "string" + }, + "region": { + "type": "string" + }, + "clusterName": { + "type": "string" + }, + "clusterNamespace": { + "type": "string" + }, + "providerConfigRef": { + "type": "string" + }, + "observeOnly": { + "type": "boolean" + }, + "tags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": ["key", "value"] + } + }, + "aws": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "bucket": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "lifecycleDays": { + "type": "integer" + } + } + }, + "iam": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + } + } + } + } + } + }, "enabled": { "type": "boolean" }, diff --git a/helm/grafana/values.yaml b/helm/grafana/values.yaml index a46bf77..9039269 100644 --- a/helm/grafana/values.yaml +++ b/helm/grafana/values.yaml @@ -86,6 +86,40 @@ postgresqlCluster: destinationPath: "" #-- If one wants to recover a backup from a specific point in time recoveryBackupTime: "" # e.g. "2024-01-01T12:00:00Z" + #-- Crossplane configuration for automated infrastructure provisioning + crossplane: + #-- Enable Crossplane resources + enabled: false + #-- Crossplane provider (e.g., aws, azure) + provider: aws + #-- Crossplane ProviderConfig name + providerConfigRef: "default" + #-- Cloud region for resources + region: "" + #-- Cluster name (used for cluster discovery) + clusterName: "" + #-- Cluster namespace (used for cluster discovery) + clusterNamespace: "org-giantswarm" + #-- Enable observe-only mode for safe migration + observeOnly: false + #-- Additional tags to apply to all storage resources + # Tags from cluster CR will be merged automatically + tags: [] + # - key: team + # value: atlas + aws: + #-- Enable AWS Crossplane resources + enabled: false + #-- Bucket configuration for Grafana PostgreSQL backups + bucket: + #-- Bucket name for Grafana PostgreSQL backups + name: "" + #-- Lifecycle expiration in days + lifecycleDays: 45 + #-- IAM Role configuration for IRSA + iam: + #-- Enable IAM role creation via Crossplane + enabled: true grafana: rbac: