#!/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"