feat: idempotent secret script and updated auth docs
This commit is contained in:
parent
2c250f601b
commit
6c1dbe9681
|
|
@ -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)
|
||||||
|
|
||||||
def create_secret(secret_name):
|
project = run_command("gcloud config get-value project", check=False)
|
||||||
"""Creates a new secret."""
|
if project != PROJECT_ID:
|
||||||
print(f" Creating secret '{secret_name}'...")
|
print(f"{Colors.WARNING}⚠️ Current gcloud project is '{project}', but this script targets '{PROJECT_ID}'.{Colors.ENDC}")
|
||||||
cmd = f"gcloud secrets create {secret_name} --replication-policy=automatic --project={PROJECT_ID}"
|
confirm = input(" Continue anyway? (y/n): ")
|
||||||
run_command(cmd)
|
if confirm.lower() != 'y':
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
def add_secret_version(secret_name, payload):
|
def enable_api():
|
||||||
"""Adds a new version to the secret."""
|
"""Ensures Secret Manager API is enabled."""
|
||||||
print(f" Adding new version to '{secret_name}'...")
|
print("🔍 Checking API status...")
|
||||||
# Passing payload via stdin to avoid shell history logging
|
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(
|
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:
|
|
||||||
state = v.get('state')
|
|
||||||
if state == 'DESTROYED':
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
print(f" 🧹 Cleaning up old versions for clean state...")
|
||||||
|
# Skip the first one
|
||||||
|
for v in versions[1:]:
|
||||||
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
|
# Main Loop ensuring state
|
||||||
print("🔍 Checking Secret Manager API...")
|
while True:
|
||||||
run_command(f"gcloud services enable secretmanager.googleapis.com --project={PROJECT_ID}", check=False)
|
all_ready = True
|
||||||
|
|
||||||
for secret_id, description in SECRETS.items():
|
# 1. Audit State
|
||||||
print(f"\n👉 Secret: {secret_id} ({description})")
|
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
|
icon = "✅" if status == "READY" else "❌"
|
||||||
if not check_secret_exists(secret_id):
|
msg = f"{secret_id}: {status}"
|
||||||
create_secret(secret_id)
|
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 all_ready:
|
||||||
if secret_id == "haumdaucher-oauth-client-id":
|
print_success("All secrets are configured correctly!")
|
||||||
print(f" ℹ️ Go to GCP Console > Credentials > OAuth 2.0 Client IDs")
|
break
|
||||||
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): ")
|
# 2. Fix Missing Items
|
||||||
# Use getpass logic via manual read to support all shells or just input() but masked?
|
print_header("Action Required")
|
||||||
# getpass in python is better.
|
|
||||||
import getpass
|
|
||||||
value = getpass.getpass(prompt=" > ")
|
|
||||||
|
|
||||||
if not value:
|
for secret_id, config in SECRETS.items():
|
||||||
print(" ⚠️ Skipping update (empty input).")
|
status = status_map[secret_id]
|
||||||
continue
|
if status == "READY":
|
||||||
|
continue
|
||||||
|
|
||||||
# Add version
|
print_step(f"Configuring {config['desc']} ({secret_id})")
|
||||||
add_secret_version(secret_id, value)
|
|
||||||
|
|
||||||
# Lifecycle: Destroy old
|
if status == "MISSING":
|
||||||
cleanup_old_versions(secret_id)
|
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__":
|
if __name__ == "__main__":
|
||||||
main()
|
try:
|
||||||
|
main()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\n⚠️ Aborted by user.")
|
||||||
|
sys.exit(130)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue