import { Command } from "jsr:@cliffy/command@1.0.0-rc.7"; import { Input, Secret, Select } from "jsr:@cliffy/prompt@1.0.0-rc.7"; import * as colors from "jsr:@std/fmt@0.225.2/colors"; import * as path from "jsr:@std/path@0.225.2"; const ENV_PATH = path.join("infra", ".env"); const COMPOSE_PATH = path.join("infra", "compose.yml"); const SPIRE_COMPOSE_PATH = path.join("infra", "compose.spire.yml"); export interface AuthSetupConfig { reg: string; ghcrReg: string; domainName: string; dbPassword: string; dbDataPath: string; spireDataPath: string; appSecret: string; } const DEFAULT_AUTH_CONFIG: AuthSetupConfig = { reg: "quay.atyg.org", ghcrReg: "ghcr.atyg.org", domainName: "auth.system.local", dbPassword: "", dbDataPath: "/volume1/docker/auth-yes/data", spireDataPath: "/volume1/docker/spire", appSecret: "", }; export async function readEnv(): Promise> { try { const text = await Deno.readTextFile(ENV_PATH); const config: Partial = {}; for (const line of text.split("\n")) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; const [key, ...rest] = trimmed.split("="); const val = rest.join("=").trim(); if (key === "REG") config.reg = val; if (key === "GHCR_REG") config.ghcrReg = val; if (key === "SYSTEM_DOMAIN") config.domainName = val; if (key === "POSTGRES_PASSWORD") config.dbPassword = val; if (key === "DB_DATA_PATH") config.dbDataPath = val; if (key === "SPIRE_DATA_PATH") config.spireDataPath = val; if (key === "APP_SECRET") config.appSecret = val; } return config; } catch (_e) { // Ignore and check next return {}; } } export function generateEnv(config: AuthSetupConfig): string { return `# --- Container Registry --- REG=${config.reg} GHCR_REG=${config.ghcrReg || "ghcr.atyg.org"} # --- Network & Routing --- SYSTEM_DOMAIN=${config.domainName} RP_ID=${config.domainName} ORIGIN=https://${config.domainName} # --- Identity Provider Internal App Secret --- APP_SECRET=${config.appSecret} # --- Database Configuration --- POSTGRES_HOST=auth-db POSTGRES_PORT=5432 POSTGRES_USER=postgres POSTGRES_DB=authdb POSTGRES_PASSWORD=${config.dbPassword} DB_DATA_PATH=${config.dbDataPath || "/volume1/docker/auth-yes/data"} # --- SPIRE Configuration --- SPIRE_DATA_PATH=${config.spireDataPath || "/volume1/docker/spire"} # --- Valkey Configuration --- VALKEY_HOST=auth-valkey VALKEY_PORT=6379 VALKEY_URL=redis://auth-valkey:6379 `; } export function generateDockerCompose(): string { return `version: "3.8" services: auth-api: image: \${REG}/library/auth-yes-api:latest env_file: .env labels: - "traefik.enable=true" - "traefik.docker.network=traefik-net" - "traefik.http.routers.auth-api.rule=Host(\`\${SYSTEM_DOMAIN}\`)" - "traefik.http.routers.auth-api.entrypoints=websecure" - "traefik.http.routers.auth-api.tls=true" - "traefik.http.services.auth-api.loadbalancer.server.port=8000" expose: - "8000" depends_on: - auth-db - auth-valkey networks: - default - traefik-net volumes: - spire-socket:/var/run/spire:ro auth-db: image: acr.atyg.org/library/postgres:18-alpine environment: - POSTGRES_USER=\${POSTGRES_USER} - POSTGRES_PASSWORD=\${POSTGRES_PASSWORD} - POSTGRES_DB=\${POSTGRES_DB} volumes: - auth-db-data:/var/lib/postgresql/data networks: - default auth-valkey: image: acr.atyg.org/valkey/valkey:8-alpine networks: - default volumes: auth-db-data: driver: local driver_opts: type: none device: \${DB_DATA_PATH} o: bind spire-socket: name: spire-socket networks: default: name: auth-internal-net traefik-net: external: true `; } export function generateSpireDockerCompose(): string { return `version: "3.8" services: spire-server: image: \${REG}/library/spire-server:latest container_name: spire-server hostname: spire-server networks: - auth-internal-net volumes: - spire-data:/opt/spire spire-agent: image: \${REG}/library/spire-agent:latest container_name: spire-agent hostname: spire-agent pid: host depends_on: - spire-server networks: - auth-internal-net volumes: - spire-data:/opt/spire - spire-socket:/var/run/spire - /var/run/docker.sock:/var/run/docker.sock:ro volumes: spire-data: driver: local driver_opts: type: none device: \${SPIRE_DATA_PATH} o: bind spire-socket: name: spire-socket networks: auth-internal-net: external: true `; } export function generateProtobufCompilationCommands(): string[] { return [ "deno", "run", "-A", "npm:@bufbuild/buf", "generate", "server/auth.proto", "--template", '{"version":"v1","plugins":[{"plugin":"buf.build/bufbuild/es:v1.10.0","out":"sdk/gen","opt":"target=ts,import_extension=.ts"},{"plugin":"buf.build/connectrpc/es:v1.4.0","out":"sdk/gen","opt":"target=ts,import_extension=.ts"}]}', ]; } // SIDE EFFECT: Runs the protoc compilation export async function executeProtobufCompilation(): Promise { const commands = generateProtobufCompilationCommands(); const cmd = new Deno.Command(commands[0], { args: commands.slice(1), stdout: "inherit", stderr: "inherit", }); const { code } = await cmd.output(); if (code !== 0) { throw new Error("Failed to compile protobuf definitions."); } } export async function downloadWorkloadProto(): Promise { console.log(colors.blue("\nDownloading workload.proto...")); const res = await fetch( "https://raw.githubusercontent.com/spiffe/go-spiffe/main/proto/spiffe/workload/workload.proto", ); if (!res.ok) { throw new Error(`Failed to download workload.proto: ${res.statusText}`); } const text = await res.text(); await Deno.mkdir("spire_ffi/proto", { recursive: true }); await Deno.writeTextFile("spire_ffi/proto/workload.proto", text); console.log(colors.green("✓ workload.proto downloaded successfully.")); } export function generateBuildCommands(reg: string): string[] { return [ `podman build -t ${reg}/library/auth-yes-api:latest -f Dockerfile .`, `podman push ${reg}/library/auth-yes-api:latest`, `podman build -t ${reg}/library/spire-server:latest -f spire/Dockerfile.server spire/`, `podman push ${reg}/library/spire-server:latest`, `podman build -t ${reg}/library/spire-agent:latest -f spire/Dockerfile.agent spire/`, `podman push ${reg}/library/spire-agent:latest`, ]; } // SIDE EFFECT: Executes docker build and push commands to the host system export async function executeBuildImage(commands: string[]): Promise { for (const cmd of commands) { console.log(colors.cyan(`\nExecuting: ${cmd}`)); const args = cmd.split(" "); const process = new Deno.Command(args[0], { args: args.slice(1), stdout: "inherit", stderr: "inherit", stdin: "inherit", }); const { code } = await process.output(); if (code !== 0) { throw new Error(`Command failed with exit code ${code}: ${cmd}`); } } } export async function generateAuthSetupFiles( config: AuthSetupConfig, ): Promise { const envContent = generateEnv(config); await Deno.writeTextFile(ENV_PATH, envContent); const composeContent = generateDockerCompose(); await Deno.writeTextFile(COMPOSE_PATH, composeContent); const spireComposeContent = generateSpireDockerCompose(); await Deno.writeTextFile(SPIRE_COMPOSE_PATH, spireComposeContent); console.log( colors.green( `\n✓ Successfully generated ${ENV_PATH}, ${COMPOSE_PATH}, and ${SPIRE_COMPOSE_PATH}!`, ), ); console.log( colors.green("Setup complete. You may now deploy your stack by running:\n"), ); console.log( colors.cyan( "podman-compose --project-name auth-yes --env-file infra/.env -f infra/compose.yml up -d\n", ), ); } export async function handleAuthSetup( currentConfig: AuthSetupConfig, ): Promise { console.log( colors.gray( "Please provide the following Auth Yes configuration details.\n", ), ); const reg = await Input.prompt({ message: "Enter the Primary Container Registry URL:", default: currentConfig.reg, }); const ghcrReg = await Input.prompt({ message: "Enter the GHCR Mirror Registry URL (for SPIRE):", default: currentConfig.ghcrReg || "ghcr.atyg.org", }); const domainName = await Input.prompt({ message: "Enter the Auth Domain Name:", hint: "E.g., auth.system.local", default: currentConfig.domainName, }); const dbDataPath = await Input.prompt({ message: "Enter the Database Path on the Host:", default: currentConfig.dbDataPath, }); const spireDataPath = await Input.prompt({ message: "Enter the SPIRE Path on the Host:", default: currentConfig.spireDataPath, }); const appSecret = await Secret.prompt({ message: "Enter the App Secret for the IDP:", default: currentConfig.appSecret, minLength: 16, }); const pwdMessage = currentConfig.dbPassword ? "Enter the PostgreSQL database password: (Leave blank to keep existing password)" : "Enter the PostgreSQL database password:"; const pwdInput = await Secret.prompt({ message: pwdMessage, minLength: currentConfig.dbPassword ? 0 : 1, }); const dbPassword = pwdInput === "" && currentConfig.dbPassword !== "" ? currentConfig.dbPassword : pwdInput; const newConfig: AuthSetupConfig = { reg, ghcrReg, domainName, dbPassword, dbDataPath, spireDataPath, appSecret, }; await generateAuthSetupFiles(newConfig); return newConfig; } export async function handleTestConnection(domainName: string): Promise { console.log( colors.bold( colors.blue(`\n=== Testing Auth Connection (https://${domainName}) ===`), ), ); try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); const res1 = await fetch(`https://${domainName}/`, { signal: controller.signal, }); clearTimeout(timeoutId); if (res1.status === 502 || res1.status === 503 || res1.status === 504) { throw new Error(`Gateway error: ${res1.status}`); } if (res1.body) { await res1.body.cancel(); } console.log( colors.green( `✓ Auth service is up and reachable at https://${domainName}/`, ), ); } catch (error) { console.log( colors.red( `✗ Error: Could not reach the auth service at https://${domainName}/`, ), ); if (error instanceof Error) { console.log(colors.red(` Reason: ${error.message}`)); } else { console.log(colors.red(` Reason: ${error}`)); } console.log( colors.red( " Please verify the container is running and DNS/Traefik is resolving.", ), ); } } async function handleReviewConfigs(): Promise { const envContent = await Deno.readTextFile(ENV_PATH).catch(() => null); const composeContent = await Deno.readTextFile(COMPOSE_PATH).catch(() => null ); const spireComposeContent = await Deno.readTextFile(SPIRE_COMPOSE_PATH).catch( () => null, ); if (!envContent && !composeContent && !spireComposeContent) { console.log( colors.red("\n✗ No generated configs found. Run the setup first.\n"), ); return; } while (true) { const action = await Select.prompt({ message: "Review Generated Configs", options: [ { name: "[Show .env]", value: "env" }, { name: "[Show compose.yml]", value: "compose" }, { name: "[Show compose.spire.yml]", value: "spire_compose" }, { name: "[Back to Main Menu]", value: "back" }, ], }); if (action === "env") { console.log(colors.bold(colors.blue(`\n=== ${ENV_PATH} ===\n`))); console.log(envContent || colors.yellow("File not found.")); console.log(); } else if (action === "compose") { console.log(colors.bold(colors.blue(`\n=== ${COMPOSE_PATH} ===\n`))); console.log(composeContent || colors.yellow("File not found.")); console.log(); } else if (action === "spire_compose") { console.log( colors.bold(colors.blue(`\n=== ${SPIRE_COMPOSE_PATH} ===\n`)), ); console.log(spireComposeContent || colors.yellow("File not found.")); console.log(); } else if (action === "back") { break; } } } // SIDE EFFECT: Runs the interactive CLI wizard. export async function runSetupWizard(): Promise { console.log(colors.bold(colors.blue("=== Auth Setup Wizard ===\n"))); const loadedEnv = await readEnv(); let currentConfig: AuthSetupConfig = { ...DEFAULT_AUTH_CONFIG, ...loadedEnv, }; if (Object.keys(loadedEnv).length > 0 && currentConfig.domainName) { await handleTestConnection(currentConfig.domainName); console.log(); } while (true) { const action = await Select.prompt({ message: "Main Menu", options: [ { name: "[Test Auth Connection]", value: "test" }, { name: "[Configure Auth Yes API]", value: "auth" }, { name: "[Compile Protobuf Definitions]", value: "compile_proto" }, { name: "[Review Generated Configs]", value: "review" }, { name: "[Build and Push Auth Image]", value: "build" }, { name: "[Exit]", value: "exit" }, ], }); if (action === "test") { await handleTestConnection(currentConfig.domainName); console.log(); } else if (action === "auth") { currentConfig = await handleAuthSetup(currentConfig); } else if (action === "compile_proto") { try { await downloadWorkloadProto(); await executeProtobufCompilation(); console.log(colors.green("\n✓ Successfully compiled protobufs!\n")); } catch (error) { if (error instanceof Error) { console.log( colors.red(`\n✗ Protobuf compilation failed: ${error.message}\n`), ); } else { console.log( colors.red(`\n✗ Protobuf compilation failed: ${error}\n`), ); } } } else if (action === "review") { await handleReviewConfigs(); } else if (action === "build") { try { const commands = generateBuildCommands(currentConfig.reg); await executeBuildImage(commands); console.log(colors.green("\n✓ Successfully built and pushed image!")); console.log( colors.green("You may now deploy your stack by running:\n"), ); console.log( colors.cyan( "podman-compose --project-name auth-yes --env-file infra/.env -f infra/compose.yml up -d\n", ), ); } catch (error) { if (error instanceof Error) { console.log( colors.red(`\n✗ Build process failed: ${error.message}\n`), ); } else { console.log(colors.red(`\n✗ Build process failed: ${error}\n`)); } } } else if (action === "exit") { console.log(colors.gray("Exiting...\n")); break; } } } if (import.meta.main) { if (!Deno.stdin.isTerminal() && Deno.args.length === 0) { console.error( colors.red( "Error: Non-interactive environment detected, but no explicit CLI subcommands or --auto flag were provided. Aborting to prevent hangs.", ), ); Deno.exit(1); } const cmd = new Command() .name("auth-setup") .description("Auth setup wizard and CLI") .action(() => { runSetupWizard(); }) .command("auth", "Configure Auth Yes API") .option("--auto, --headless", "Run in headless mode") .option("--registry ", "Container Registry URL") .option("--ghcr-registry ", "GHCR Mirror Registry URL") .option("--domain ", "Auth Domain Name") .option("--db-path ", "Database Path on the Host") .option("--spire-path ", "SPIRE Path on the Host") .action(async (options) => { const loadedEnv = await readEnv(); const currentConfig: AuthSetupConfig = { ...DEFAULT_AUTH_CONFIG, ...loadedEnv, }; if (options.auto) { if (!options.registry || !options.domain || !options.dbPath) { console.error( colors.red( "Error: --registry, --domain, and --db-path are required in headless mode.", ), ); Deno.exit(1); } const dbPassword = Deno.env.get("POSTGRES_PASSWORD") || currentConfig.dbPassword; const appSecret = Deno.env.get("APP_SECRET") || currentConfig.appSecret; if (!dbPassword) { console.error( colors.red( "Error: POSTGRES_PASSWORD environment variable is required in headless mode.", ), ); Deno.exit(1); } if (!appSecret) { console.error( colors.red( "Error: APP_SECRET environment variable is required in headless mode.", ), ); Deno.exit(1); } const newConfig: AuthSetupConfig = { reg: options.registry, ghcrReg: options.ghcrRegistry || currentConfig.ghcrReg || "ghcr.atyg.org", domainName: options.domain, dbPassword, dbDataPath: options.dbPath || currentConfig.dbDataPath || "/volume1/docker/auth-yes/data", spireDataPath: options.spirePath || currentConfig.spireDataPath || "/volume1/docker/spire", appSecret, }; await generateAuthSetupFiles(newConfig); } else { if (options.registry) currentConfig.reg = options.registry; if (options.ghcrRegistry) currentConfig.ghcrReg = options.ghcrRegistry; if (options.domain) currentConfig.domainName = options.domain; if (options.dbPath) currentConfig.dbDataPath = options.dbPath; if (options.spirePath) currentConfig.spireDataPath = options.spirePath; await handleAuthSetup(currentConfig); } }) .command("test", "Test Auth Connection") .action(async () => { const loadedEnv = await readEnv(); const currentConfig: AuthSetupConfig = { ...DEFAULT_AUTH_CONFIG, ...loadedEnv, }; await handleTestConnection(currentConfig.domainName); }) .command("compile_proto", "Compile Protobuf Definitions") .action(async () => { try { await downloadWorkloadProto(); await executeProtobufCompilation(); console.log(colors.green("\n✓ Successfully compiled protobufs!\n")); } catch (error) { if (error instanceof Error) { console.log( colors.red(`\n✗ Protobuf compilation failed: ${error.message}\n`), ); } else { console.log( colors.red(`\n✗ Protobuf compilation failed: ${error}\n`), ); } Deno.exit(1); } }) .command("build", "Build and Push Auth Image") .action(async () => { try { const loadedEnv = await readEnv(); const currentConfig: AuthSetupConfig = { ...DEFAULT_AUTH_CONFIG, ...loadedEnv, }; const commands = generateBuildCommands(currentConfig.reg); await executeBuildImage(commands); console.log( colors.green("\n✓ Successfully built and pushed auth image!"), ); console.log( colors.green("You may now deploy your stack by running:\n"), ); console.log( colors.cyan( "podman-compose --project-name auth-yes --env-file infra/.env -f infra/compose.yml up -d\n", ), ); } catch (error) { if (error instanceof Error) { console.log( colors.red(`\n✗ Build process failed: ${error.message}\n`), ); } else { console.log(colors.red(`\n✗ Build process failed: ${error}\n`)); } Deno.exit(1); } }) .command("dump_compose", "Output all generated Compose YAML configurations") .action(() => { console.log( colors.bold( colors.blue( "\n================================================================", ), ), ); console.log( colors.bold( colors.green(" STACK 1: Auth-Yes Stack (infra/compose.yml)"), ), ); console.log( colors.bold( colors.blue( "================================================================\n", ), ), ); console.log(generateDockerCompose()); console.log( colors.bold( colors.blue( "================================================================", ), ), ); console.log( colors.bold( colors.green( " STACK 2: Standalone SPIRE Stack (infra/compose.spire.yml)", ), ), ); console.log( colors.bold( colors.blue( "================================================================\n", ), ), ); console.log(generateSpireDockerCompose()); }) .command("dump_env", "Output current environment configuration (.env)") .action(async () => { const loadedEnv = await readEnv(); const currentConfig: AuthSetupConfig = { ...DEFAULT_AUTH_CONFIG, ...loadedEnv, }; console.log( colors.bold( colors.blue( "\n================================================================", ), ), ); console.log( colors.bold( colors.green(" ENVIRONMENT CONFIGURATION (infra/.env)"), ), ); console.log( colors.bold( colors.blue( "================================================================\n", ), ), ); console.log(generateEnv(currentConfig)); }) .command("secrets", "Inspect secrets status and persistence paths") .action(async () => { const loadedEnv = await readEnv(); const currentConfig: AuthSetupConfig = { ...DEFAULT_AUTH_CONFIG, ...loadedEnv, }; console.log( colors.bold( colors.blue( "\n=== Auth-Yes Secrets & Persistence Topology ===\n", ), ), ); console.log( `${ colors.cyan("Database Persistence Path:") } ${currentConfig.dbDataPath}`, ); console.log( `${ colors.cyan("SPIRE Persistence Path:") } ${currentConfig.spireDataPath}`, ); console.log( `${ colors.cyan("Local Artifact Files:") } ${ENV_PATH}, ${COMPOSE_PATH}, ${SPIRE_COMPOSE_PATH}`, ); console.log( `${colors.cyan("PostgreSQL Password:")} ${ currentConfig.dbPassword ? colors.green( "✓ Configured (len: " + currentConfig.dbPassword.length + ")", ) : colors.red("✗ Missing") }`, ); console.log( `${colors.cyan("Application Secret:")} ${ currentConfig.appSecret ? colors.green( "✓ Configured (len: " + currentConfig.appSecret.length + ")", ) : colors.red("✗ Missing") }`, ); console.log( colors.gray( "\nTip: To dump raw environment variables, run: deno run -A infra/setup.ts dump_env\n", ), ); }); await cmd.parse(Deno.args); }