From c79f07aadffedbc9246fe2f6c933bf2c9f25e55c Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 9 Aug 2026 02:04:32 +0200 Subject: [PATCH] Add coulomb-social-env secret create/patch script Builds SECRET_KEY, optional URL-encoded DATABASE_URL from app DB credentials, and USER_ENGINE_PROXY_SECRET from user-engine-runtime. Never prints values; Makefile dry-run and apply targets. --- Makefile | 9 +- docs/coulomb-social.md | 44 +++- tools/create-coulomb-social-env-secret.sh | 273 ++++++++++++++++++++++ 3 files changed, 321 insertions(+), 5 deletions(-) create mode 100755 tools/create-coulomb-social-env-secret.sh diff --git a/Makefile b/Makefile index 7b3a6fa..42ffd33 100644 --- a/Makefile +++ b/Makefile @@ -297,6 +297,13 @@ coulomb-social-status: check-production-kubeconfig ## Show coulomb-social pod/sv coulomb-social-logs: ## Tail coulomb-social app logs kubectl -n $(COULOMB_SOCIAL_NAMESPACE) logs -l app.kubernetes.io/name=coulomb-social -f --tail=200 + + +coulomb-social-env-secret: ## Create/patch coulomb-social-env (SECRET_KEY, DATABASE_URL, USER_ENGINE_PROXY_SECRET) + @bash tools/create-coulomb-social-env-secret.sh $(COULOMB_SOCIAL_ENV_SECRET_ARGS) + +coulomb-social-env-secret-dry-run: ## Plan coulomb-social-env keys without applying + @bash tools/create-coulomb-social-env-secret.sh --dry-run $(COULOMB_SOCIAL_ENV_SECRET_ARGS) ##@ Inter-Hub inter-hub-render-baseline: ## Render checked-in inter-hub values for chart validation only @@ -577,4 +584,4 @@ help: ## Show this help /^[a-zA-Z0-9_-]+:.*?##/ { printf " \033[36m%-20s\033[0m %s\n", $$1, $$2 } \ /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) }' $(MAKEFILE_LIST) -.PHONY: check-tools check-sops k8s-server-dry-run apps-pg-status apps-pg-restore-drill check-railiance01-kubeconfig check-inter-hub-image-tag check-inter-hub-image check-vergabe-image-tag check-vergabe-image vergabe-dry-run vergabe-deploy vergabe-ingress-deploy vergabe-status vergabe-migrate vergabe-seed vergabe-superuser vergabe-logs vergabe-db-url-secret check-coulomb-social-image-tag coulomb-social-dry-run coulomb-social-deploy coulomb-social-ingress-deploy coulomb-social-status coulomb-social-logs eso-deploy forgejo-openbao-eso-token-apply forgejo-openbao-store-deploy forgejo-mailer-es-deploy forgejo-mailer-es-status forgejo-dry-run forgejo-server-dry-run forgejo-deploy forgejo-ingress-deploy forgejo-ssh-nodeport-deploy forgejo-status forgejo-smoke forgejo-npm-smoke forgejo-verify forgejo-operator-bootstrap forgejo-runner-registration-sops-bootstrap forgejo-secrets-check forgejo-logs forgejo-runner-registration-deploy forgejo-runner-deploy forgejo-runner-status forgejo-runner-logs inter-hub-render-baseline inter-hub-dry-run inter-hub-server-dry-run inter-hub-deploy inter-hub-status inter-hub-release-info inter-hub-smoke inter-hub-logs reuse-dry-run reuse-deploy reuse-status reuse-smoke reuse-logs reuse-forgejo-webhook reuse-openbao-eso-token-apply reuse-openbao-store-deploy reuse-runtime-es-deploy reuse-runtime-es-status reuse-webhook-smoke help +.PHONY: check-tools check-sops k8s-server-dry-run apps-pg-status apps-pg-restore-drill check-railiance01-kubeconfig check-inter-hub-image-tag check-inter-hub-image check-vergabe-image-tag check-vergabe-image vergabe-dry-run vergabe-deploy vergabe-ingress-deploy vergabe-status vergabe-migrate vergabe-seed vergabe-superuser vergabe-logs vergabe-db-url-secret check-coulomb-social-image-tag coulomb-social-dry-run coulomb-social-deploy coulomb-social-ingress-deploy coulomb-social-status coulomb-social-logs coulomb-social-env-secret coulomb-social-env-secret-dry-run eso-deploy forgejo-openbao-eso-token-apply forgejo-openbao-store-deploy forgejo-mailer-es-deploy forgejo-mailer-es-status forgejo-dry-run forgejo-server-dry-run forgejo-deploy forgejo-ingress-deploy forgejo-ssh-nodeport-deploy forgejo-status forgejo-smoke forgejo-npm-smoke forgejo-verify forgejo-operator-bootstrap forgejo-runner-registration-sops-bootstrap forgejo-secrets-check forgejo-logs forgejo-runner-registration-deploy forgejo-runner-deploy forgejo-runner-status forgejo-runner-logs inter-hub-render-baseline inter-hub-dry-run inter-hub-server-dry-run inter-hub-deploy inter-hub-status inter-hub-release-info inter-hub-smoke inter-hub-logs reuse-dry-run reuse-deploy reuse-status reuse-smoke reuse-logs reuse-forgejo-webhook reuse-openbao-eso-token-apply reuse-openbao-store-deploy reuse-runtime-es-deploy reuse-runtime-es-status reuse-webhook-smoke help diff --git a/docs/coulomb-social.md b/docs/coulomb-social.md index d8451d0..19a8e95 100644 --- a/docs/coulomb-social.md +++ b/docs/coulomb-social.md @@ -9,15 +9,51 @@ Ingress: `manifests/coulomb-social-ingress.yaml` - Image published: `forgejo.coulomb.social/coulomb/coulomb-social:` - KeyCape client `coulomb-social` registered (see source repo `scripts/register-keycape-client.sh`) -- K8s Secret `coulomb-social-env` in namespace `coulomb-social` with at least: - - `SECRET_KEY` - - `DATABASE_URL` (or platform DB handoff) - - `USER_ENGINE_PROXY_SECRET` (from user-engine-runtime) +- K8s Secret `coulomb-social-env` (see **Env secret** below) +- Optional: DB role Secret `coulomb-social-app-credentials` in the app namespace + (cnpg mirror; same pattern as vergabe) before DATABASE_URL can be filled - DNS `coulomb.social` → cluster ingress IP +## Env secret + +Opaque Secret `coulomb-social-env` is mounted via `envFrom` on the Deployment. +**Never commit values.** Create/patch with: + +```bash +# plan only (key names, no values) +make coulomb-social-env-secret-dry-run + +# apply: SECRET_KEY (generate or keep), DATABASE_URL (if DB secret exists), +# USER_ENGINE_PROXY_SECRET (copied from user-engine/user-engine-runtime) +make coulomb-social-env-secret + +# options via COULOMB_SOCIAL_ENV_SECRET_ARGS: +make coulomb-social-env-secret COULOMB_SOCIAL_ENV_SECRET_ARGS='--rotate-secret-key' +make coulomb-social-env-secret COULOMB_SOCIAL_ENV_SECRET_ARGS='--skip-db' +``` + +Script: `tools/create-coulomb-social-env-secret.sh` +Source-repo wrapper: `coulomb-social/scripts/create-env-secret.sh` + +| Key | Source | +|-----|--------| +| `SECRET_KEY` | generated (or kept if present; `--rotate-secret-key` forces new) | +| `DATABASE_URL` | URL-encoded password from `coulomb-social-app-credentials` | +| `USER_ENGINE_PROXY_SECRET` | `user-engine/user-engine-runtime` key `proxy-secret` | + +Non-secret OIDC/issuer settings live in `helm/coulomb-social-values.yaml`, not +this Secret. + +After updating an existing deploy: + +```bash +kubectl -n coulomb-social rollout restart deploy/coulomb-social +``` + ## Deploy ```bash +make coulomb-social-env-secret COULOMB_SOCIAL_IMAGE_TAG= make coulomb-social-dry-run COULOMB_SOCIAL_IMAGE_TAG= make coulomb-social-deploy make coulomb-social-ingress-deploy diff --git a/tools/create-coulomb-social-env-secret.sh b/tools/create-coulomb-social-env-secret.sh new file mode 100755 index 0000000..b405c7d --- /dev/null +++ b/tools/create-coulomb-social-env-secret.sh @@ -0,0 +1,273 @@ +#!/usr/bin/env bash +# Create or patch the coulomb-social-env Opaque Secret used by the Helm chart +# (envFrom). Never prints secret values to stdout/stderr. +# +# Required keys for a full production deploy: +# SECRET_KEY +# DATABASE_URL +# USER_ENGINE_PROXY_SECRET +# +# Usage: +# make coulomb-social-env-secret +# ./tools/create-coulomb-social-env-secret.sh [options] +# +# Options: +# --dry-run Plan only (list keys, no apply) +# --rotate-secret-key Always generate a new Django SECRET_KEY +# --skip-db Do not set DATABASE_URL +# --skip-user-engine Do not copy USER_ENGINE_PROXY_SECRET +# +# Environment overrides (defaults match Makefile / chart): +# COULOMB_SOCIAL_NAMESPACE, COULOMB_SOCIAL_ENV_SECRET +# COULOMB_SOCIAL_DB_SECRET, COULOMB_SOCIAL_DB_USER, COULOMB_SOCIAL_DB_HOST, +# COULOMB_SOCIAL_DB_PORT, COULOMB_SOCIAL_DB_NAME, COULOMB_SOCIAL_DB_PASSWORD_KEY +# USER_ENGINE_NAMESPACE, USER_ENGINE_RUNTIME_SECRET, USER_ENGINE_PROXY_SECRET_KEY +set -euo pipefail + +usage() { + sed -n '2,28p' "$0" | sed 's/^# \?//' +} + +DRY_RUN=0 +ROTATE_SECRET_KEY=0 +SKIP_DB=0 +SKIP_USER_ENGINE=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) DRY_RUN=1; shift ;; + --rotate-secret-key) ROTATE_SECRET_KEY=1; shift ;; + --skip-db) SKIP_DB=1; shift ;; + --skip-user-engine) SKIP_USER_ENGINE=1; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; + esac +done + +NS="${COULOMB_SOCIAL_NAMESPACE:-coulomb-social}" +ENV_SECRET="${COULOMB_SOCIAL_ENV_SECRET:-coulomb-social-env}" + +DB_SECRET="${COULOMB_SOCIAL_DB_SECRET:-coulomb-social-app-credentials}" +DB_USER="${COULOMB_SOCIAL_DB_USER:-coulomb_social}" +DB_HOST="${COULOMB_SOCIAL_DB_HOST:-apps-pg-rw.databases}" +DB_PORT="${COULOMB_SOCIAL_DB_PORT:-5432}" +DB_NAME="${COULOMB_SOCIAL_DB_NAME:-coulomb_social_db}" +DB_PASSWORD_KEY="${COULOMB_SOCIAL_DB_PASSWORD_KEY:-password}" +DB_SCHEME="${COULOMB_SOCIAL_DB_SCHEME:-postgresql}" + +UE_NS="${USER_ENGINE_NAMESPACE:-user-engine}" +UE_RUNTIME_SECRET="${USER_ENGINE_RUNTIME_SECRET:-user-engine-runtime}" +UE_PROXY_KEY="${USER_ENGINE_PROXY_SECRET_KEY:-proxy-secret}" + +for cmd in kubectl python3 openssl base64 mktemp; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "ERROR: missing required command: $cmd" >&2 + exit 1 + fi +done + +secret_exists() { + kubectl get secret "$2" -n "$1" >/dev/null 2>&1 +} + +secret_has_key() { + local b64 + b64="$(kubectl get secret "$2" -n "$1" -o "jsonpath={.data.${3}}" 2>/dev/null || true)" + [[ -n "$b64" ]] +} + +read_secret_plain() { + local b64 + b64="$(kubectl get secret "$2" -n "$1" -o "jsonpath={.data.${3}}" 2>/dev/null || true)" + [[ -n "$b64" ]] || return 1 + printf '%s' "$b64" | base64 -d +} + +gen_secret_key() { + openssl rand -base64 48 | tr -d '\n' | tr '/+=' 'xyz' +} + +# Staging file: JSON object of stringData keys (mode 0600). +STAGE="$(mktemp)" +chmod 600 "$STAGE" +echo '{}' >"$STAGE" +trap 'rm -f "$STAGE"' EXIT + +stage_put() { + local key="$1" value="$2" + KEY="$key" VALUE="$value" STAGE="$STAGE" python3 - <<'PY' +import json, os +path = os.environ["STAGE"] +with open(path, encoding="utf-8") as f: + data = json.load(f) +data[os.environ["KEY"]] = os.environ["VALUE"] +with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, separators=(",", ":")) +PY +} + +PLANNED=() +SKIPPED=() +WARNINGS=() + +plan() { PLANNED+=("$1"); } +skip() { SKIPPED+=("$1"); } +warn() { WARNINGS+=("$1"); } + +# ── SECRET_KEY ────────────────────────────────────────────────────────────── +if [[ "$ROTATE_SECRET_KEY" -eq 1 ]]; then + stage_put SECRET_KEY "$(gen_secret_key)" + plan "SECRET_KEY (rotated)" +elif secret_exists "$NS" "$ENV_SECRET" && secret_has_key "$NS" "$ENV_SECRET" SECRET_KEY; then + skip "SECRET_KEY (kept existing)" +else + stage_put SECRET_KEY "$(gen_secret_key)" + plan "SECRET_KEY (generated)" +fi + +# ── DATABASE_URL ──────────────────────────────────────────────────────────── +if [[ "$SKIP_DB" -eq 1 ]]; then + skip "DATABASE_URL (--skip-db)" +elif ! secret_exists "$NS" "$DB_SECRET"; then + warn "DB secret $NS/$DB_SECRET missing — skip DATABASE_URL (create app DB credentials first)" +elif ! secret_has_key "$NS" "$DB_SECRET" "$DB_PASSWORD_KEY"; then + warn "DB secret key '$DB_PASSWORD_KEY' missing — skip DATABASE_URL" +else + raw_password="$(read_secret_plain "$NS" "$DB_SECRET" "$DB_PASSWORD_KEY")" + if [[ -z "${raw_password}" ]]; then + warn "DB password empty — skip DATABASE_URL" + else + encoded_password="$( + RAW_PASSWORD="$raw_password" python3 -c \ + 'import os, urllib.parse; print(urllib.parse.quote(os.environ["RAW_PASSWORD"], safe=""))' + )" + unset raw_password + stage_put DATABASE_URL \ + "${DB_SCHEME}://${DB_USER}:${encoded_password}@${DB_HOST}:${DB_PORT}/${DB_NAME}" + unset encoded_password + plan "DATABASE_URL (from $NS/$DB_SECRET)" + fi +fi + +# ── USER_ENGINE_PROXY_SECRET ──────────────────────────────────────────────── +if [[ "$SKIP_USER_ENGINE" -eq 1 ]]; then + skip "USER_ENGINE_PROXY_SECRET (--skip-user-engine)" +elif ! secret_exists "$UE_NS" "$UE_RUNTIME_SECRET"; then + warn "user-engine secret $UE_NS/$UE_RUNTIME_SECRET missing — skip USER_ENGINE_PROXY_SECRET" +elif ! secret_has_key "$UE_NS" "$UE_RUNTIME_SECRET" "$UE_PROXY_KEY"; then + warn "user-engine secret key '$UE_PROXY_KEY' missing — skip USER_ENGINE_PROXY_SECRET" +else + proxy="$(read_secret_plain "$UE_NS" "$UE_RUNTIME_SECRET" "$UE_PROXY_KEY")" + if [[ -z "${proxy}" ]]; then + warn "USER_ENGINE_PROXY_SECRET empty after decode — skip" + else + stage_put USER_ENGINE_PROXY_SECRET "$proxy" + unset proxy + plan "USER_ENGINE_PROXY_SECRET (from $UE_NS/$UE_RUNTIME_SECRET)" + fi +fi + +# ── report ────────────────────────────────────────────────────────────────── +echo "==> coulomb-social env secret plan" +echo " namespace: $NS" +echo " secret: $ENV_SECRET" +if [[ ${#PLANNED[@]} -gt 0 ]]; then + echo " set/update:" + for k in "${PLANNED[@]}"; do echo " + $k"; done +else + echo " set/update: (none)" +fi +if [[ ${#SKIPPED[@]} -gt 0 ]]; then + echo " skipped:" + for k in "${SKIPPED[@]}"; do echo " · $k"; done +fi +if [[ ${#WARNINGS[@]} -gt 0 ]]; then + echo " warnings:" + for k in "${WARNINGS[@]}"; do echo " ! $k"; done +fi + +KEY_COUNT="$(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))))' "$STAGE")" +if [[ "$KEY_COUNT" -eq 0 ]]; then + echo "Nothing to apply." + exit 0 +fi + +if [[ "$DRY_RUN" -eq 1 ]]; then + echo "Dry-run: no changes applied ($KEY_COUNT key(s) would be written)." + exit 0 +fi + +# ── apply ─────────────────────────────────────────────────────────────────── +kubectl create namespace "$NS" --dry-run=client -o yaml | kubectl apply -f - >/dev/null + +STAGE="$STAGE" NS="$NS" ENV_SECRET="$ENV_SECRET" python3 - <<'PY' +import json +import os +import subprocess +import tempfile + +stage = os.environ["STAGE"] +ns = os.environ["NS"] +name = os.environ["ENV_SECRET"] +with open(stage, encoding="utf-8") as f: + string_data = json.load(f) + +exists = ( + subprocess.call( + ["kubectl", "get", "secret", name, "-n", ns], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + == 0 +) + +if exists: + body = json.dumps({"stringData": string_data}) + with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as tf: + tf.write(body) + path = tf.name + try: + os.chmod(path, 0o600) + subprocess.check_call( + [ + "kubectl", + "patch", + "secret", + name, + "-n", + ns, + "--type=merge", + f"--patch-file={path}", + ] + ) + finally: + os.unlink(path) + print(f"Patched secret {ns}/{name} ({len(string_data)} key(s)).") +else: + args = ["kubectl", "create", "secret", "generic", name, "-n", ns] + files = [] + try: + for key, value in string_data.items(): + with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as tf: + tf.write(value) + path = tf.name + os.chmod(path, 0o600) + files.append(path) + args.append(f"--from-file={key}={path}") + subprocess.check_call(args) + print(f"Created secret {ns}/{name} ({len(string_data)} key(s)).") + finally: + for path in files: + try: + os.unlink(path) + except OSError: + pass +PY + +echo "Keys now present (names only):" +kubectl get secret "$ENV_SECRET" -n "$NS" -o json \ + | python3 -c 'import sys,json; d=json.load(sys.stdin).get("data") or {}; print(" ", ", ".join(sorted(d.keys())) or "(none)")' +echo +echo "Next:" +echo " COULOMB_SOCIAL_IMAGE_TAG= make coulomb-social-deploy" +echo " kubectl -n $NS rollout restart deploy/coulomb-social # if secret updated under a running deploy"