feat: idempotent secret script and updated auth docs

This commit is contained in:
Moritz Graf 2026-01-06 13:43:43 +01:00
parent 2c250f601b
commit 6c1dbe9681
2 changed files with 167 additions and 82 deletions

View File

@ -1,18 +1,55 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import subprocess import subprocess
import argparse
import sys import sys
import json import json
import os
# Configuration # --- Configuration ---
PROJECT_ID = "haumdaucher" # Could fetch from gcloud config, but hardcoding for project context PROJECT_ID = "haumdaucher"
SECRETS = { SECRETS = {
"haumdaucher-oauth-client-id": "Google OAuth Client ID", "haumdaucher-oauth-client-id": {
"haumdaucher-oauth-client-secret": "Google OAuth Client Secret" "desc": "OAuth 2.0 Client ID",
"instructions": (
"1. Go to https://console.cloud.google.com/apis/credentials?project=haumdaucher\n"
"2. Look for 'OAuth 2.0 Client IDs' -> 'Haumdaucher Web' (or create one if missing).\n"
"3. Copy the 'Client ID' (looks like: 12345...apps.googleusercontent.com)"
),
"validation_hint": "Must end with .apps.googleusercontent.com"
},
"haumdaucher-oauth-client-secret": {
"desc": "OAuth 2.0 Client Secret",
"instructions": (
"1. On the same credentials page, click the edit icon (pencil) or the name of the client.\n"
"2. On the right side, find 'Client secret'.\n"
"3. Copy the string (it is hidden by default)."
),
"validation_hint": "Usually a random string of characters."
}
} }
# --- Helpers ---
class Colors:
HEADER = '\033[95m'
OKBLUE = '\033[94m'
OKGREEN = '\033[92m'
WARNING = '\033[93m'
FAIL = '\033[91m'
ENDC = '\033[0m'
BOLD = '\033[1m'
def print_header(msg):
print(f"\n{Colors.HEADER}{Colors.BOLD}=== {msg} ==={Colors.ENDC}")
def print_step(msg):
print(f"\n{Colors.OKBLUE}👉 {msg}{Colors.ENDC}")
def print_success(msg):
print(f"{Colors.OKGREEN}{msg}{Colors.ENDC}")
def print_error(msg):
print(f"{Colors.FAIL}{msg}{Colors.ENDC}")
def run_command(command, check=True): def run_command(command, check=True):
"""Runs a shell command and returns the output."""
try: try:
result = subprocess.run( result = subprocess.run(
command, command,
@ -24,28 +61,54 @@ def run_command(command, check=True):
) )
return result.stdout.strip() return result.stdout.strip()
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
print(f"❌ Error running command: {command}")
print(f" Stderr: {e.stderr}")
if check: if check:
print_error(f"Command failed: {command}")
print(e.stderr)
sys.exit(1) sys.exit(1)
return None return None
def check_secret_exists(secret_name): def verify_gcloud_login():
"""Checks if a secret exists.""" """Ensures user is logged in to gcloud."""
cmd = f"gcloud secrets describe {secret_name} --project={PROJECT_ID} --format=json" account = run_command("gcloud config get-value account", check=False)
result = run_command(cmd, check=False) if not account or account == "(unset)":
return result is not None print_error("You are not logged in to gcloud.")
print(" Run: gcloud auth login")
sys.exit(1)
project = run_command("gcloud config get-value project", check=False)
if project != PROJECT_ID:
print(f"{Colors.WARNING}⚠️ Current gcloud project is '{project}', but this script targets '{PROJECT_ID}'.{Colors.ENDC}")
confirm = input(" Continue anyway? (y/n): ")
if confirm.lower() != 'y':
sys.exit(1)
def create_secret(secret_name): def enable_api():
"""Creates a new secret.""" """Ensures Secret Manager API is enabled."""
print(f" Creating secret '{secret_name}'...") print("🔍 Checking API status...")
cmd = f"gcloud secrets create {secret_name} --replication-policy=automatic --project={PROJECT_ID}" run_command(f"gcloud services enable secretmanager.googleapis.com --project={PROJECT_ID}", check=False)
run_command(cmd)
def add_secret_version(secret_name, payload): def get_secret_status(secret_id):
"""Adds a new version to the secret.""" """Returns 'MISSING', 'EMPTY' (no versions), or 'READY'."""
print(f" Adding new version to '{secret_name}'...") exists_cmd = f"gcloud secrets describe {secret_id} --project={PROJECT_ID} --format=json"
# Passing payload via stdin to avoid shell history logging meta = run_command(exists_cmd, check=False)
if not meta:
return "MISSING"
# Check for versions
versions_cmd = f"gcloud secrets versions list {secret_id} --project={PROJECT_ID} --limit=1 --filter='state=ENABLED' --format=json"
versions = run_command(versions_cmd, check=False)
if not versions or versions == "[]":
return "EMPTY"
return "READY"
def create_secret_resource(secret_id):
print(f" creating secret container '{secret_id}'...")
run_command(f"gcloud secrets create {secret_id} --replication-policy=automatic --project={PROJECT_ID}")
def add_secret_version(secret_name, value):
process = subprocess.Popen( process = subprocess.Popen(
f"gcloud secrets versions add {secret_name} --data-file=- --project={PROJECT_ID}", f"gcloud secrets versions add {secret_name} --data-file=- --project={PROJECT_ID}",
shell=True, shell=True,
@ -54,84 +117,101 @@ def add_secret_version(secret_name, payload):
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
text=True text=True
) )
stdout, stderr = process.communicate(input=payload) stdout, stderr = process.communicate(input=value)
if process.returncode != 0: if process.returncode != 0:
print(f"Failed to add version: {stderr}") print_error(f"Failed to add version: {stderr}")
sys.exit(1) sys.exit(1)
print(" ✅ Version added.")
def cleanup_old_versions(secret_name): def cleanup_old_versions(secret_name):
"""Destroys old versions, keeping only the latest enabled one.""" cmd = f"gcloud secrets versions list {secret_name} --project={PROJECT_ID} --limit=10 --filter='state!=DESTROYED' --format=json"
print(f" Cleaning up old versions for '{secret_name}'...") output = run_command(cmd, check=False)
cmd = f"gcloud secrets versions list {secret_name} --project={PROJECT_ID} --limit=10 --format=json"
output = run_command(cmd)
if not output: if not output:
return return
versions = json.loads(output) versions = json.loads(output)
# Sort by createTime desc (latest first)
# Filter for ENABLED versions usually, but we want to destroy everything except latest.
# We keep the one with state ENABLED that is most recent?
# Usually the just-added one is ENABLED.
if len(versions) <= 1: if len(versions) <= 1:
print(" No old versions to cleanup.")
return return
# Keep the latest one (index 0) # Keep latest enabled
latest = versions[0] # The API returns sorted list usually, but let's be safe: assume index 0 is latest
to_destroy = versions[1:] # Actually, we should just keep the one we just made.
for v in to_destroy: print(f" 🧹 Cleaning up old versions for clean state...")
state = v.get('state') # Skip the first one
if state == 'DESTROYED': for v in versions[1:]:
continue
version_id = v['name'].split('/')[-1] version_id = v['name'].split('/')[-1]
print(f" destroying old version {version_id} ({state})...") run_command(f"gcloud secrets versions destroy {version_id} --secret={secret_name} --project={PROJECT_ID} --quiet", check=False)
destroy_cmd = f"gcloud secrets versions destroy {version_id} --secret={secret_name} --project={PROJECT_ID} --quiet"
run_command(destroy_cmd)
# --- Main Logic ---
def main(): def main():
print(f"🔐 Haumdaucher Secret Manager Tool") print_header("Haumdaucher Secret Manager Setup")
print(f" Project: {PROJECT_ID}") verify_gcloud_login()
enable_api()
# Ensure API enabled
print("🔍 Checking Secret Manager API...")
run_command(f"gcloud services enable secretmanager.googleapis.com --project={PROJECT_ID}", check=False)
for secret_id, description in SECRETS.items(): # Main Loop ensuring state
print(f"\n👉 Secret: {secret_id} ({description})") while True:
all_ready = True
# Check existence # 1. Audit State
if not check_secret_exists(secret_id): print_header("Current Status Audit")
create_secret(secret_id) status_map = {}
for secret_id in SECRETS.keys():
# Prompt for value status = get_secret_status(secret_id)
if secret_id == "haumdaucher-oauth-client-id": status_map[secret_id] = status
print(f" Go to GCP Console > Credentials > OAuth 2.0 Client IDs")
print(f" Copy the 'Client ID' (ends in .apps.googleusercontent.com)")
elif secret_id == "haumdaucher-oauth-client-secret":
print(f" Copy the 'Client Secret' (shorter string, hidden in console)")
print(f" Enter value for {description} (Input hidden): ")
# Use getpass logic via manual read to support all shells or just input() but masked?
# getpass in python is better.
import getpass
value = getpass.getpass(prompt=" > ")
if not value:
print(" ⚠️ Skipping update (empty input).")
continue
# Add version icon = "" if status == "READY" else ""
add_secret_version(secret_id, value) msg = f"{secret_id}: {status}"
if status == "READY":
print(f"{Colors.OKGREEN}{icon} {msg}{Colors.ENDC}")
else:
print(f"{Colors.FAIL}{icon} {msg}{Colors.ENDC}")
all_ready = False
# Lifecycle: Destroy old if all_ready:
cleanup_old_versions(secret_id) print_success("All secrets are configured correctly!")
break
# 2. Fix Missing Items
print_header("Action Required")
for secret_id, config in SECRETS.items():
status = status_map[secret_id]
if status == "READY":
continue
print_step(f"Configuring {config['desc']} ({secret_id})")
if status == "MISSING":
create_secret_resource(secret_id)
print(f"{Colors.WARNING}INSTRUCTIONS:{Colors.ENDC}")
print(config['instructions'])
print("")
import getpass
value = getpass.getpass(prompt=f"{Colors.BOLD}Paste {config['desc']} here (hidden): {Colors.ENDC}")
if not value.strip():
print_error("Empty value provided. Retrying audit...")
continue
add_secret_version(secret_id, value)
cleanup_old_versions(secret_id)
print_success("Secret updated.")
print("\n🔄 Re-verifying state...")
# Loop continues to verify
print("\n✅ All secrets updated successfully.") print_header("Next Steps")
print("1. Infrastructure is ready.")
print("2. Run Terraform to apply the changes:")
print(f"\n {Colors.OKBLUE}cd terraform && terraform apply{Colors.ENDC}\n")
if __name__ == "__main__": if __name__ == "__main__":
main() try:
main()
except KeyboardInterrupt:
print("\n\n⚠️ Aborted by user.")
sys.exit(130)

View File

@ -20,6 +20,11 @@ When you enable Firebase Authentication, Google creates an **OAuth 2.0 Client ID
1. At the moment of creation. 1. At the moment of creation.
2. In the GCP Console UI (where you can "download JSON"). 2. In the GCP Console UI (where you can "download JSON").
> [!IMPORTANT]
> **"Browser key" vs "OAuth Client"**:
> You might see a "Browser key (auto created by Firebase)" in the console. This is an **API Key**, which is public and safe to share.
> **However**, for the Identity Platform backend configuration (`google_identity_platform_default_supported_idp_config`), we strictly need the **OAuth 2.0 Client Secret**, which acts as a password for the server. Do not confuse the two!
Because Terraform interacts via the API, it **cannot** fetch this secret on its own. If we tried to create a *new* Client ID via Terraform, we would hit another blocker: the **OAuth Consent Screen** requires manual configuration in the Console. Because Terraform interacts via the API, it **cannot** fetch this secret on its own. If we tried to create a *new* Client ID via Terraform, we would hit another blocker: the **OAuth Consent Screen** requires manual configuration in the Console.
### 2. The Solution: "Bootstrap" Script ### 2. The Solution: "Bootstrap" Script