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
import subprocess
import argparse
import sys
import json
import os
# Configuration
PROJECT_ID = "haumdaucher" # Could fetch from gcloud config, but hardcoding for project context
# --- Configuration ---
PROJECT_ID = "haumdaucher"
SECRETS = {
"haumdaucher-oauth-client-id": "Google OAuth Client ID",
"haumdaucher-oauth-client-secret": "Google OAuth Client Secret"
"haumdaucher-oauth-client-id": {
"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):
"""Runs a shell command and returns the output."""
try:
result = subprocess.run(
command,
@ -24,28 +61,54 @@ def run_command(command, check=True):
)
return result.stdout.strip()
except subprocess.CalledProcessError as e:
print(f"❌ Error running command: {command}")
print(f" Stderr: {e.stderr}")
if check:
print_error(f"Command failed: {command}")
print(e.stderr)
sys.exit(1)
return None
def check_secret_exists(secret_name):
"""Checks if a secret exists."""
cmd = f"gcloud secrets describe {secret_name} --project={PROJECT_ID} --format=json"
result = run_command(cmd, check=False)
return result is not None
def verify_gcloud_login():
"""Ensures user is logged in to gcloud."""
account = run_command("gcloud config get-value account", check=False)
if not account or account == "(unset)":
print_error("You are not logged in to gcloud.")
print(" Run: gcloud auth login")
sys.exit(1)
def create_secret(secret_name):
"""Creates a new secret."""
print(f" Creating secret '{secret_name}'...")
cmd = f"gcloud secrets create {secret_name} --replication-policy=automatic --project={PROJECT_ID}"
run_command(cmd)
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 add_secret_version(secret_name, payload):
"""Adds a new version to the secret."""
print(f" Adding new version to '{secret_name}'...")
# Passing payload via stdin to avoid shell history logging
def enable_api():
"""Ensures Secret Manager API is enabled."""
print("🔍 Checking API status...")
run_command(f"gcloud services enable secretmanager.googleapis.com --project={PROJECT_ID}", check=False)
def get_secret_status(secret_id):
"""Returns 'MISSING', 'EMPTY' (no versions), or 'READY'."""
exists_cmd = f"gcloud secrets describe {secret_id} --project={PROJECT_ID} --format=json"
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(
f"gcloud secrets versions add {secret_name} --data-file=- --project={PROJECT_ID}",
shell=True,
@ -54,84 +117,101 @@ def add_secret_version(secret_name, payload):
stderr=subprocess.PIPE,
text=True
)
stdout, stderr = process.communicate(input=payload)
stdout, stderr = process.communicate(input=value)
if process.returncode != 0:
print(f"Failed to add version: {stderr}")
print_error(f"Failed to add version: {stderr}")
sys.exit(1)
print(" ✅ Version added.")
def cleanup_old_versions(secret_name):
"""Destroys old versions, keeping only the latest enabled one."""
print(f" Cleaning up old versions for '{secret_name}'...")
cmd = f"gcloud secrets versions list {secret_name} --project={PROJECT_ID} --limit=10 --format=json"
output = run_command(cmd)
cmd = f"gcloud secrets versions list {secret_name} --project={PROJECT_ID} --limit=10 --filter='state!=DESTROYED' --format=json"
output = run_command(cmd, check=False)
if not output:
return
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:
print(" No old versions to cleanup.")
return
# Keep the latest one (index 0)
latest = versions[0]
to_destroy = versions[1:]
for v in to_destroy:
state = v.get('state')
if state == 'DESTROYED':
continue
# Keep latest enabled
# The API returns sorted list usually, but let's be safe: assume index 0 is latest
# Actually, we should just keep the one we just made.
print(f" 🧹 Cleaning up old versions for clean state...")
# Skip the first one
for v in versions[1:]:
version_id = v['name'].split('/')[-1]
print(f" destroying old version {version_id} ({state})...")
destroy_cmd = f"gcloud secrets versions destroy {version_id} --secret={secret_name} --project={PROJECT_ID} --quiet"
run_command(destroy_cmd)
run_command(f"gcloud secrets versions destroy {version_id} --secret={secret_name} --project={PROJECT_ID} --quiet", check=False)
# --- Main Logic ---
def main():
print(f"🔐 Haumdaucher Secret Manager Tool")
print(f" Project: {PROJECT_ID}")
print_header("Haumdaucher Secret Manager Setup")
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)
# Main Loop ensuring state
while True:
all_ready = True
for secret_id, description in SECRETS.items():
print(f"\n👉 Secret: {secret_id} ({description})")
# 1. Audit State
print_header("Current Status Audit")
status_map = {}
for secret_id in SECRETS.keys():
status = get_secret_status(secret_id)
status_map[secret_id] = status
# Check existence
if not check_secret_exists(secret_id):
create_secret(secret_id)
icon = "" if status == "READY" else ""
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
# Prompt for value
if secret_id == "haumdaucher-oauth-client-id":
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)")
if all_ready:
print_success("All secrets are configured correctly!")
break
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=" > ")
# 2. Fix Missing Items
print_header("Action Required")
if not value:
print(" ⚠️ Skipping update (empty input).")
continue
for secret_id, config in SECRETS.items():
status = status_map[secret_id]
if status == "READY":
continue
# Add version
add_secret_version(secret_id, value)
print_step(f"Configuring {config['desc']} ({secret_id})")
# Lifecycle: Destroy old
cleanup_old_versions(secret_id)
if status == "MISSING":
create_secret_resource(secret_id)
print("\n✅ All secrets updated successfully.")
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_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__":
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.
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.
### 2. The Solution: "Bootstrap" Script