diff --git a/ct.yaml b/ct.yaml index 1f0580a..720ea2a 100644 --- a/ct.yaml +++ b/ct.yaml @@ -6,4 +6,5 @@ chart-repos: - opentelemetry=https://open-telemetry.github.io/opentelemetry-helm-charts - grafana=https://grafana.github.io/helm-charts - prometheus-community=https://prometheus-community.github.io/helm-charts + - external-secrets=https://charts.external-secrets.io/ helm-dependency-extra-args: "--skip-refresh" diff --git a/deploy-templates/Chart.yaml b/deploy-templates/Chart.yaml index 0a8b825..8301f10 100644 --- a/deploy-templates/Chart.yaml +++ b/deploy-templates/Chart.yaml @@ -37,3 +37,7 @@ dependencies: version: 10.5.15 repository: https://grafana.github.io/helm-charts condition: grafana.enabled + - name: external-secrets + version: 2.7.0 + repository: https://charts.external-secrets.io/ + condition: external-secrets.enabled diff --git a/deploy-templates/README.md b/deploy-templates/README.md index be23e6f..3ec2dd3 100644 --- a/deploy-templates/README.md +++ b/deploy-templates/README.md @@ -84,6 +84,7 @@ and `config/grafana/dashboards/`). See the repository root `README.md` for the f | Repository | Name | Version | |------------|------|---------| +| https://charts.external-secrets.io/ | external-secrets | 2.7.0 | | https://grafana.github.io/helm-charts | grafana | 10.5.15 | | https://grafana.github.io/helm-charts | loki | 7.0.0 | | https://open-telemetry.github.io/opentelemetry-helm-charts | opentelemetry-collector | 0.164.1 | @@ -101,7 +102,36 @@ and `config/grafana/dashboards/`). See the repository root `README.md` for the f | dashboards.folders.operational | string | `"Operational"` | | | dashboards.label | string | `"grafana_dashboard"` | | | dashboards.labelValue | string | `"1"` | | +| eso.aws | object | `{"region":"eu-central-1","roleArn":"arn:aws:iam::012345678910:role/AWSIRSA_Shared_ExternalSecretOperatorAccess"}` | AWS configuration (if provider is `aws`). | +| eso.aws.region | string | `"eu-central-1"` | AWS region. | +| eso.aws.roleArn | string | `"arn:aws:iam::012345678910:role/AWSIRSA_Shared_ExternalSecretOperatorAccess"` | AWS role ARN for the ExternalSecretOperator to assume. | +| eso.enabled | bool | `false` | Render the SecretStore/ExternalSecret resources. | +| eso.generic.secretStore.providerConfig | object | `{}` | Defines SecretStore provider configuration. | +| eso.provider | string | `"generic"` | Defines provider type. One of `aws`, `generic`, or `vault`. | +| eso.secretPath | string | `"/infra/claude-code-telemetry"` | Defines the path to the secret in the provider. If provider is `vault`, the path must be prefixed with `secret/`. | +| eso.vault | object | `{"mountPath":"core","role":"claude-code-telemetry","server":"http://vault.vault:8200"}` | Vault configuration (if provider is `vault`). | +| eso.vault.mountPath | string | `"core"` | Mount path for the Kubernetes authentication method. | +| eso.vault.role | string | `"claude-code-telemetry"` | Vault role for the Kubernetes authentication method. | +| eso.vault.server | string | `"http://vault.vault:8200"` | Vault server URL. | +| external-secrets.enabled | bool | `false` | | +| external-secrets.installCRDs | bool | `true` | | +| external-secrets.scopedNamespace | string | `"claude-code-telemetry"` | | +| external-secrets.scopedRBAC | bool | `true` | | | fullnameOverride | string | `"claude-code-telemetry"` | | +| grafana."grafana.ini"."auth.generic_oauth".allow_sign_up | bool | `true` | | +| grafana."grafana.ini"."auth.generic_oauth".api_url | string | `"https://keycloak.example.com/realms/main/protocol/openid-connect/userinfo"` | | +| grafana."grafana.ini"."auth.generic_oauth".auth_url | string | `"https://keycloak.example.com/realms/main/protocol/openid-connect/auth"` | | +| grafana."grafana.ini"."auth.generic_oauth".auto_login | bool | `false` | | +| grafana."grafana.ini"."auth.generic_oauth".client_id | string | `"grafana-claude-code-telemetry"` | | +| grafana."grafana.ini"."auth.generic_oauth".email_attribute_path | string | `"email"` | | +| grafana."grafana.ini"."auth.generic_oauth".enabled | bool | `false` | | +| grafana."grafana.ini"."auth.generic_oauth".groups_attribute_path | string | `"groups"` | | +| grafana."grafana.ini"."auth.generic_oauth".name | string | `"SSO"` | | +| grafana."grafana.ini"."auth.generic_oauth".role_attribute_path | string | `"contains(roles[*], 'administrator') && 'Admin' || contains(roles[*], 'developer') && 'Editor' || 'Viewer'"` | | +| grafana."grafana.ini"."auth.generic_oauth".scopes | string | `"openid profile email roles groups"` | | +| grafana."grafana.ini"."auth.generic_oauth".token_url | string | `"https://keycloak.example.com/realms/main/protocol/openid-connect/token"` | | +| grafana."grafana.ini".analytics.check_for_updates | bool | `false` | | +| grafana."grafana.ini".server.root_url | string | `"https://grafana.example.com"` | | | grafana.datasources."datasources.yaml".apiVersion | int | `1` | | | grafana.datasources."datasources.yaml".datasources[0].access | string | `"proxy"` | | | grafana.datasources."datasources.yaml".datasources[0].isDefault | bool | `true` | | @@ -116,6 +146,12 @@ and `config/grafana/dashboards/`). See the repository root `README.md` for the f | grafana.datasources."datasources.yaml".datasources[1].url | string | `"http://loki:3100"` | | | grafana.enabled | bool | `true` | | | grafana.fullnameOverride | string | `"grafana"` | | +| grafana.ingress.annotations | object | `{}` | | +| grafana.ingress.enabled | bool | `false` | | +| grafana.ingress.hosts[0] | string | `"grafana.example.com"` | | +| grafana.ingress.path | string | `"/"` | | +| grafana.ingress.pathType | string | `"Prefix"` | | +| grafana.ingress.tls | list | `[]` | | | grafana.rbac.namespaced | bool | `true` | | | grafana.sidecar.dashboards.enabled | bool | `true` | | | grafana.sidecar.dashboards.folderAnnotation | string | `"grafana_folder"` | | @@ -131,6 +167,10 @@ and `config/grafana/dashboards/`). See the repository root `README.md` for the f | ingress.http.enabled | bool | `false` | | | ingress.http.host | string | `"otel-http.example.com"` | | | ingress.http.tls | list | `[]` | | +| keycloakClient.clientId | string | `"grafana"` | OAuth client ID; keep in sync with grafana.grafana\.ini.auth\.generic_oauth.client_id. | +| keycloakClient.create | bool | `false` | Render a KeycloakClient CR for Grafana SSO via the KubeRocketCI keycloak-operator. | +| keycloakClient.grafanaUrl | string | `"https://grafana.example.com"` | Grafana external URL (OAuth redirect base); keep in sync with grafana ingress host and server.root_url. | +| keycloakClient.realmRef | object | `{"kind":"ClusterKeycloakRealm","name":"main"}` | Keycloak realm the client is created in. | | loki.backend.replicas | int | `0` | | | loki.chunksCache.enabled | bool | `false` | | | loki.deploymentMode | string | `"SingleBinary"` | | @@ -231,5 +271,5 @@ and `config/grafana/dashboards/`). See the repository root `README.md` for the f | prometheus.scrapeConfigs.prometheus.enabled | bool | `false` | | | prometheus.server.fullnameOverride | string | `"prometheus-server"` | | | prometheus.server.persistentVolume.enabled | bool | `true` | | -| prometheus.server.persistentVolume.size | string | `"10Gi"` | | -| prometheus.server.retention | string | `"30d"` | | +| prometheus.server.persistentVolume.size | string | `"20Gi"` | | +| prometheus.server.retention | string | `"365d"` | | diff --git a/deploy-templates/templates/external-secrets/grafana-sso.yaml b/deploy-templates/templates/external-secrets/grafana-sso.yaml new file mode 100644 index 0000000..3b8d0a6 --- /dev/null +++ b/deploy-templates/templates/external-secrets/grafana-sso.yaml @@ -0,0 +1,41 @@ +{{- /* +The single SSO secret for this bundle: Grafana admin credentials + OAuth client secret. +Grafana consumes `username`/`password` (admin.existingSecret) and `clientSecret` +(envValueFrom -> GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET); the KeycloakClient CR references +`$grafana-sso:clientSecret`. Without ESO, create the same Secret manually: + kubectl create secret generic grafana-sso \ + --from-literal=username= --from-literal=password= \ + --from-literal=clientSecret= +*/ -}} +{{- if .Values.eso.enabled }} +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: grafana-sso + labels: + {{- include "claude-code-telemetry.labels" . | nindent 4 }} +spec: + refreshInterval: 1h + secretStoreRef: + kind: SecretStore + name: {{ .Release.Name }} + data: + - secretKey: username + remoteRef: + conversionStrategy: Default + decodingStrategy: None + key: {{ .Values.eso.secretPath }} + property: username + - secretKey: password + remoteRef: + conversionStrategy: Default + decodingStrategy: None + key: {{ .Values.eso.secretPath }} + property: password + - secretKey: clientSecret + remoteRef: + conversionStrategy: Default + decodingStrategy: None + key: {{ .Values.eso.secretPath }} + property: clientSecret +{{- end }} diff --git a/deploy-templates/templates/external-secrets/secretstore-aws.yaml b/deploy-templates/templates/external-secrets/secretstore-aws.yaml new file mode 100644 index 0000000..3fa9c0a --- /dev/null +++ b/deploy-templates/templates/external-secrets/secretstore-aws.yaml @@ -0,0 +1,17 @@ +{{- if and .Values.eso.enabled (eq .Values.eso.provider "aws") }} +apiVersion: external-secrets.io/v1 +kind: SecretStore +metadata: + name: {{ .Release.Name }} + labels: + {{- include "claude-code-telemetry.labels" . | nindent 4 }} +spec: + provider: + aws: + service: ParameterStore + region: {{ .Values.eso.aws.region }} + auth: + jwt: + serviceAccountRef: + name: externalsecrets-{{ .Release.Name }}-{{ .Values.eso.provider }} +{{- end }} diff --git a/deploy-templates/templates/external-secrets/secretstore-generic.yaml b/deploy-templates/templates/external-secrets/secretstore-generic.yaml new file mode 100644 index 0000000..20cadec --- /dev/null +++ b/deploy-templates/templates/external-secrets/secretstore-generic.yaml @@ -0,0 +1,13 @@ +{{- if and .Values.eso.enabled (eq .Values.eso.provider "generic") }} +apiVersion: external-secrets.io/v1 +kind: SecretStore +metadata: + name: {{ .Release.Name }} + labels: + {{- include "claude-code-telemetry.labels" . | nindent 4 }} +spec: + provider: + {{- with .Values.eso.generic.secretStore.providerConfig }} + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/deploy-templates/templates/external-secrets/secretstore-vault.yaml b/deploy-templates/templates/external-secrets/secretstore-vault.yaml new file mode 100644 index 0000000..525ed49 --- /dev/null +++ b/deploy-templates/templates/external-secrets/secretstore-vault.yaml @@ -0,0 +1,20 @@ +{{- if and .Values.eso.enabled (eq .Values.eso.provider "vault") }} +apiVersion: external-secrets.io/v1 +kind: SecretStore +metadata: + name: {{ .Release.Name }} + labels: + {{- include "claude-code-telemetry.labels" . | nindent 4 }} +spec: + provider: + vault: + auth: + kubernetes: + mountPath: {{ .Values.eso.vault.mountPath }} + role: {{ .Values.eso.vault.role }} + serviceAccountRef: + name: externalsecrets-{{ .Release.Name }}-{{ .Values.eso.provider }} + path: secret + server: {{ .Values.eso.vault.server }} + version: v2 +{{- end }} diff --git a/deploy-templates/templates/external-secrets/serviceaccount.yaml b/deploy-templates/templates/external-secrets/serviceaccount.yaml new file mode 100644 index 0000000..af7d64f --- /dev/null +++ b/deploy-templates/templates/external-secrets/serviceaccount.yaml @@ -0,0 +1,12 @@ +{{- if and .Values.eso.enabled (ne .Values.eso.provider "generic") }} +apiVersion: v1 +kind: ServiceAccount +metadata: +{{- if eq .Values.eso.provider "aws" }} + annotations: + eks.amazonaws.com/role-arn: {{ .Values.eso.aws.roleArn }} +{{- end }} + name: externalsecrets-{{ .Release.Name }}-{{ .Values.eso.provider }} + labels: + {{- include "claude-code-telemetry.labels" . | nindent 4 }} +{{- end }} diff --git a/deploy-templates/templates/keycloak/keycloakclient-grafana.yaml b/deploy-templates/templates/keycloak/keycloakclient-grafana.yaml new file mode 100644 index 0000000..c3c9f27 --- /dev/null +++ b/deploy-templates/templates/keycloak/keycloakclient-grafana.yaml @@ -0,0 +1,42 @@ +{{- if .Values.keycloakClient.create }} +apiVersion: v1.edp.epam.com/v1 +kind: KeycloakClient +metadata: + name: {{ .Values.keycloakClient.clientId }} + labels: + {{- include "claude-code-telemetry.labels" . | nindent 4 }} +spec: + advancedProtocolMappers: true + clientId: {{ .Values.keycloakClient.clientId }} + directAccess: true + # Client secret consumed from the grafana-sso Secret (kubectl- or ESO-created; see values.yaml). + secret: $grafana-sso:clientSecret + defaultClientScopes: + - profile + - email + - roles + realmRef: + kind: {{ .Values.keycloakClient.realmRef.kind }} + name: {{ .Values.keycloakClient.realmRef.name }} + webUrl: {{ .Values.keycloakClient.grafanaUrl | quote }} + protocolMappers: + - name: roles + protocol: openid-connect + protocolMapper: "oidc-usermodel-realm-role-mapper" + config: + "access.token.claim": "true" + "claim.name": "roles" + "id.token.claim": "true" + "userinfo.token.claim": "true" + "multivalued": "true" + # Expose group membership so Grafana can gate access (allowed_groups) and map org roles. + - name: groups + protocol: openid-connect + protocolMapper: "oidc-group-membership-mapper" + config: + "access.token.claim": "true" + "claim.name": "groups" + "id.token.claim": "true" + "userinfo.token.claim": "true" + "full.path": "false" +{{- end }} diff --git a/deploy-templates/values.yaml b/deploy-templates/values.yaml index bebaf4c..3385fba 100644 --- a/deploy-templates/values.yaml +++ b/deploy-templates/values.yaml @@ -237,10 +237,10 @@ prometheus: # prepends the release name. Pin it so the Grafana datasource URL # (http://prometheus-server) resolves regardless of the release name. fullnameOverride: prometheus-server - retention: 30d + retention: 365d persistentVolume: enabled: true - size: 10Gi + size: 20Gi # Disable ALL of the chart's default scrape jobs (kubernetes-* cluster-wide # discovery, self-scrape, pushgateway) — this Prometheus monitors ONLY the # bundle's collector via two static targets: the Claude Code data metrics @@ -291,6 +291,70 @@ grafana: namespaced: true testFramework: enabled: false + + # --- Single sign-on (OIDC via Grafana generic OAuth — Keycloak or any OIDC provider) --- + # Everything SSO hangs off ONE Kubernetes Secret — `grafana-sso` — with three keys: + # username / password -> Grafana admin login (admin.existingSecret below) + # clientSecret -> OAuth client secret (envValueFrom below + KeycloakClient ref) + # Create it either way (this chart never renders Secrets itself): + # kubectl: kubectl create secret generic grafana-sso \ + # --from-literal=username= --from-literal=password= \ + # --from-literal=clientSecret= + # ESO: eso.enabled=true — materialized from your secret manager (same three keys) + # To expose Grafana under SSO: + # 1. create the grafana-sso Secret (kubectl or ESO), uncomment envValueFrom and admin, + # 2. flip auth.generic_oauth.enabled=true and point the URLs at your provider, + # 3. enable the ingress and set server.root_url to the same host, + # 4. register the OIDC client at your provider — either by hand, or set + # keycloakClient.create=true to render a KeycloakClient CR through the + # keycloak-operator add-on (consuming $grafana-sso:clientSecret). + + # envValueFrom: + # GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET: + # secretKeyRef: + # name: grafana-sso + # key: clientSecret + + grafana.ini: + analytics: + check_for_updates: false + server: + root_url: https://grafana.example.com + auth.generic_oauth: + enabled: false + name: SSO + auto_login: false + allow_sign_up: true + client_id: grafana-claude-code-telemetry + # Client secret comes from the GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET env var (envValueFrom above) — never inline it here. + scopes: openid profile email roles groups + email_attribute_path: email + # Keycloak endpoints (any OIDC provider exposes equivalents): + auth_url: https://keycloak.example.com/realms/main/protocol/openid-connect/auth + token_url: https://keycloak.example.com/realms/main/protocol/openid-connect/token + api_url: https://keycloak.example.com/realms/main/protocol/openid-connect/userinfo + # Map provider roles/groups to Grafana org roles (JMESPath over the userinfo/token claims). + role_attribute_path: contains(roles[*], 'administrator') && 'Admin' || contains(roles[*], 'developer') && 'Editor' || 'Viewer' + # Restrict sign-in to members of specific groups (requires the `groups` claim): + groups_attribute_path: groups + # allowed_groups: /grafana-users + + # Grafana admin credentials from the same grafana-sso Secret. + # admin: + # existingSecret: grafana-sso + # userKey: username + # passwordKey: password + + ingress: + enabled: false + annotations: {} + hosts: + - grafana.example.com + path: / + pathType: Prefix + tls: [] + # - secretName: grafana-tls + # hosts: [grafana.example.com] sidecar: dashboards: enabled: true @@ -316,3 +380,83 @@ grafana: uid: loki access: proxy url: http://loki:3100 + +# This section enables configuration for using External Secrets Operator (ESO) as the +# secret manager: it renders a SecretStore plus ONE ExternalSecret that materializes the +# `grafana-sso` Secret (fixed name, fixed keys: username, password, clientSecret). +# Store the secret in your provider at `secretPath` as a JSON object with exactly +# those three properties: +# {"username": "", "password": "", "clientSecret": ""} +# Without ESO, create the identical Secret manually instead: +# kubectl -n create secret generic grafana-sso \ +# --from-literal=username= \ +# --from-literal=password= \ +# --from-literal=clientSecret= +# Prerequisite: ESO controller available — either pre-installed in the cluster or via +# the optional `external-secrets` subchart at the bottom of this file. +# https://docs.kuberocketci.io/docs/operator-guide/secrets-management/external-secrets-operator-integration +eso: + # -- Render the SecretStore/ExternalSecret resources. + enabled: false + # -- Defines provider type. One of `aws`, `generic`, or `vault`. + provider: "generic" + # -- Defines the path to the secret in the provider. If provider is `vault`, the path must be prefixed with `secret/`. + secretPath: "/infra/claude-code-telemetry" + # -- Vault configuration (if provider is `vault`). + vault: + # -- Vault server URL. + server: "http://vault.vault:8200" + # -- Mount path for the Kubernetes authentication method. + mountPath: "core" + # -- Vault role for the Kubernetes authentication method. + role: claude-code-telemetry + # -- AWS configuration (if provider is `aws`). + aws: + # -- AWS region. + region: "eu-central-1" + # -- AWS role ARN for the ExternalSecretOperator to assume. + roleArn: "arn:aws:iam::012345678910:role/AWSIRSA_Shared_ExternalSecretOperatorAccess" + generic: + secretStore: + # -- Defines SecretStore provider configuration. + providerConfig: {} + # gcpsm: + # projectID: "alphabet-123" + +# Grafana SSO Keycloak client. When enabled, the chart renders a KeycloakClient CR +# (roles + groups protocol mappers) managed by the KubeRocketCI keycloak-operator — a +# convenience for clusters that run it as an add-on. The chart does NOT install the +# operator; prerequisite: +# https://github.com/epam/edp-cluster-add-ons/tree/main/clusters/core/addons/keycloak-operator +# Leave `create: false` for a manual OIDC setup — register the client at your provider +# (Keycloak or any other) yourself. Grafana SSO works either way: it depends only on the +# `grafana-sso` Secret (key `clientSecret`, created with kubectl or ESO — see the `eso` +# section above) and the grafana `generic_oauth` config, NOT on this block. The chart never +# renders a Kubernetes Secret; the KeycloakClient CR reads `$grafana-sso:clientSecret` to +# set the client secret in Keycloak. +keycloakClient: + # -- Render a KeycloakClient CR for Grafana SSO via the KubeRocketCI keycloak-operator. + create: false + # -- OAuth client ID; keep in sync with grafana.grafana\.ini.auth\.generic_oauth.client_id. + clientId: grafana + # -- Grafana external URL (OAuth redirect base); keep in sync with grafana ingress host and server.root_url. + grafanaUrl: https://grafana.example.com + # -- Keycloak realm the client is created in. + realmRef: + kind: ClusterKeycloakRealm + name: main + +# Optional External Secrets Operator subchart (external-secrets/external-secrets). +# Disabled by default — the bundle stays free of cluster-wide/CRD dependencies unless +# you opt in. If ESO already runs in the cluster (e.g. via the edp-cluster-add-ons +# external-secrets add-on), leave this off and only enable the `eso` block above. +# NOTE: when enabling this subchart AND `eso` together for the first time, install in +# two steps — first with only `external-secrets.enabled=true` (CRDs + validating webhook +# must be up), then upgrade with `eso.enabled=true` — otherwise the ExternalSecret CRs +# fail admission ("failed calling webhook ... connection refused"). +external-secrets: + enabled: false + installCRDs: true + # To keep the operator namespace-scoped (no cluster-wide RBAC) + scopedNamespace: claude-code-telemetry + scopedRBAC: true