diff --git a/scripts/src/hwlab-test-accounts.ts b/scripts/src/hwlab-test-accounts.ts index 88843861..1350c6b5 100644 --- a/scripts/src/hwlab-test-accounts.ts +++ b/scripts/src/hwlab-test-accounts.ts @@ -4,6 +4,8 @@ import { dirname, isAbsolute, join } from "node:path"; import postgres from "postgres"; import { repoRoot, rootPath } from "./config"; import { runCommand, type CommandResult } from "./command"; +import type { RuntimeSecretSpec } from "./hwlab-node/entry"; +import { runtimeSecretSpec } from "./hwlab-node/public-exposure"; const defaultConfigPath = "config/hwlab-test-accounts.yaml"; const apiKeyTargetKey = "api-key"; @@ -93,6 +95,13 @@ interface SourceMaterial { blocker: Record | null; } +interface DatabaseAccess { + kind: "platform-service" | "local-k3s"; + runtimeStoreMode: string | null; + databaseUrlSource: SourceMaterial; + runtimeSecretSpec: RuntimeSecretSpec | null; +} + export async function runHwlabTestAccountsCommand(args: string[]): Promise> { if (args.length === 0 || args.includes("--help") || args.includes("-h") || args[0] === "help") return hwlabTestAccountsHelp(); const options = parseOptions(args); @@ -178,19 +187,22 @@ async function hostEnvSync(config: LoadedConfig, target: Target, options: Option } async function status(config: LoadedConfig, target: Target, options: Options): Promise> { - const database = readSource(target.userBilling.databaseUrlSource, config.sourceRoot, false); + const database = databaseAccessForTarget(config, target); const accounts = await accountStatuses(config, target, options, database); return report("status", config, target, options, accounts, database, { mutation: false }); } async function sync(config: LoadedConfig, target: Target, options: Options): Promise> { - const database = readSource(target.userBilling.databaseUrlSource, config.sourceRoot, false); + const database = databaseAccessForTarget(config, target); if (!options.confirm) { return report("sync", config, target, options, [], database, { mutation: false, blockers: [{ code: "confirm_required", message: "sync requires --confirm" }] }); } const sources = target.accounts.map((account) => readAccountSource(account, config.sourceRoot, options.createSources)); - const sourceBlockers = [database.ok ? null : database.blocker, ...sources.map((source) => source.ok ? null : source.blocker)].filter(Boolean) as Record[]; - if (sourceBlockers.length > 0 || !database.value) { + const sourceBlockers = [ + database.kind === "platform-service" && !database.databaseUrlSource.ok ? database.databaseUrlSource.blocker : null, + ...sources.map((source) => source.ok ? null : source.blocker), + ].filter(Boolean) as Record[]; + if (sourceBlockers.length > 0 || (database.kind === "platform-service" && !database.databaseUrlSource.value)) { const accounts = await accountStatuses(config, target, options, database, sources); return report("sync", config, target, options, accounts, database, { mutation: false, blockers: sourceBlockers }); } @@ -207,7 +219,32 @@ async function sync(config: LoadedConfig, target: Target, options: Options): Pro return report("sync", config, target, options, accounts, database, { mutation: synced.some((item) => item.mutation === true), synced }); } - const sql = postgres(database.value, { max: 1 }); + if (database.kind === "local-k3s") { + const syncBlockers: Record[] = []; + for (const account of userBillingAccounts) { + const source = sources.find((item) => item.sourceRef === account.sourceRef && item.sourceKey === account.sourceKey); + if (!source?.value || !source.sha256Hex || !source.serviceKeyPrefix) throw new Error(`${account.logicalId} source material missing after preflight`); + const targetStatus = userBillingApiKeyStatusLocalK3s(database.runtimeSecretSpec, account, source, options); + const statusBlockers = targetStatusAvailabilityBlockers(account, targetStatus); + if (statusBlockers.length > 0) { + syncBlockers.push(...statusBlockers); + continue; + } + if (userBillingAccountMatches(account, targetStatus)) { + synced.push(skippedUserBillingAccountSync(account, source, targetStatus)); + continue; + } + synced.push(syncUserBillingAccountLocalK3s(database.runtimeSecretSpec, account, source, options)); + } + if (syncBlockers.length > 0) { + const accounts = await accountStatuses(config, target, options, database, sources); + return report("sync", config, target, options, accounts, database, { mutation: synced.some((item) => item.mutation === true), synced, blockers: syncBlockers }); + } + const accounts = await accountStatuses(config, target, options, database); + return report("sync", config, target, options, accounts, database, { mutation: synced.some((item) => item.mutation === true), synced }); + } + + const sql = postgres(database.databaseUrlSource.value ?? "", { max: 1 }); try { await sql.begin(async (tx: any) => { for (const account of userBillingAccounts) { @@ -229,17 +266,20 @@ async function sync(config: LoadedConfig, target: Target, options: Options): Pro return report("sync", config, target, options, accounts, database, { mutation: synced.some((item) => item.mutation === true), synced }); } -async function accountStatuses(config: LoadedConfig, target: Target, options: Options, database: SourceMaterial, preloadedSources?: SourceMaterial[]): Promise[]> { - const sql = database.value ? postgres(database.value, { max: 1 }) : null; +async function accountStatuses(config: LoadedConfig, target: Target, options: Options, database: DatabaseAccess, preloadedSources?: SourceMaterial[]): Promise[]> { + const sql = database.kind === "platform-service" && database.databaseUrlSource.value ? postgres(database.databaseUrlSource.value, { max: 1 }) : null; try { const statuses: Record[] = []; for (const account of target.accounts) { const source = preloadedSources?.find((item) => item.sourceRef === account.sourceRef && item.sourceKey === account.sourceKey) ?? readAccountSource(account, config.sourceRoot, false); const targetStatus = account.target.kind === "kubernetes-secret" ? runtimeSecretStatus(target, account, source, options) - : await userBillingApiKeyStatus(sql, account, source); + : database.kind === "local-k3s" + ? userBillingApiKeyStatusLocalK3s(database.runtimeSecretSpec, account, source, options) + : await userBillingApiKeyStatus(sql, account, source); const blockers = [ ...validateAccount(account, source), + ...targetStatusAvailabilityBlockers(account, targetStatus), ...targetFingerprintBlockers(account, targetStatus), ]; statuses.push({ @@ -268,6 +308,13 @@ function targetFingerprintBlockers(account: Account, targetStatus: Record): Record[] { + if (targetStatus.checked === false || typeof targetStatus.error === "string") { + return [{ code: "target_status_unavailable", logicalId: account.logicalId, targetKind: account.target.kind, reason: targetStatus.error ?? targetStatus.reason ?? "unknown" }]; + } + return []; +} + function readAccountSource(account: Account, sourceRoot: string, allowCreate: boolean): SourceMaterial { if (allowCreate && account.createIfMissing.enabled && account.createIfMissing.randomBase64Url !== null) { const sourcePath = resolveSourcePath(account.sourceRef, sourceRoot); @@ -390,6 +437,197 @@ function skippedUserBillingAccountSync(account: Account, source: SourceMaterial, }; } +function databaseAccessForTarget(config: LoadedConfig, target: Target): DatabaseAccess { + const spec = runtimeSecretSpec({ node: target.node, lane: target.lane }); + const runtimeStoreMode = spec.runtimeLaneSpec?.runtimeStore?.postgres?.mode ?? null; + if (runtimeStoreMode === "local-k3s") { + return { + kind: "local-k3s", + runtimeStoreMode, + databaseUrlSource: inactiveDatabaseSource(target.userBilling.databaseUrlSource, config.sourceRoot), + runtimeSecretSpec: spec, + }; + } + return { + kind: "platform-service", + runtimeStoreMode, + databaseUrlSource: readSource(target.userBilling.databaseUrlSource, config.sourceRoot, false), + runtimeSecretSpec: spec, + }; +} + +function inactiveDatabaseSource(source: SourceRef, sourceRoot: string): SourceMaterial { + const sourcePath = resolveSourcePath(source.sourceRef, sourceRoot); + return { + ok: true, + sourceRef: source.sourceRef, + sourceKey: source.sourceKey, + sourcePath: displaySourcePath(sourcePath), + exists: existsSync(sourcePath), + byteCount: null, + keyPrefix: null, + serviceKeyPrefix: null, + fingerprint: null, + sha256Hex: null, + value: null, + blocker: null, + }; +} + +function userBillingApiKeyStatusLocalK3s(spec: RuntimeSecretSpec | null, account: Account, source: SourceMaterial, options: Options): Record { + if (spec === null) return { checked: false, kind: account.target.kind, reason: "runtime_secret_spec_missing", valuesRedacted: true }; + const result = runLocalK3sUserBillingSql(spec, userBillingStatusSql(account, source), options); + const parsed = parseJsonObjectLine(result.stdout); + if (result.exitCode !== 0 || parsed === null) { + return { + checked: false, + kind: account.target.kind, + error: compactText(result.stderr || result.stdout || "local-k3s-user-billing-status-failed"), + result: compactCommandResult(result), + valuesRedacted: true, + }; + } + if (parsed.ok === false) { + return { checked: false, kind: account.target.kind, error: String(parsed.error ?? "local-k3s-user-billing-status-failed"), result: compactCommandResult(result), valuesRedacted: true }; + } + return { + checked: true, + kind: account.target.kind, + exists: parsed.exists === true, + userId: account.userId, + keyId: account.target.keyId, + user: parsed.user ?? null, + apiKey: normalizeApiKeyStatus(parsed.apiKey, account), + result: compactCommandResult(result), + valuesRedacted: true, + }; +} + +function syncUserBillingAccountLocalK3s(spec: RuntimeSecretSpec | null, account: Account, source: SourceMaterial, options: Options): Record { + if (spec === null) throw new Error(`${account.logicalId} local-k3s runtime secret spec missing`); + if (!source.sha256Hex || !source.serviceKeyPrefix) throw new Error(`${account.logicalId} source material missing hash after preflight`); + const result = runLocalK3sUserBillingSql(spec, syncUserBillingAccountSql(account, source), options); + const parsed = parseJsonObjectLine(result.stdout); + if (result.exitCode !== 0 || parsed === null || parsed.ok === false) { + throw new Error(`${account.logicalId} local-k3s user-billing sync failed: ${compactText(result.stderr || result.stdout || String(parsed?.error ?? "unknown"))}`); + } + return { + logicalId: account.logicalId, + targetKind: account.target.kind, + mutation: parsed.mutation === true, + userId: account.userId, + keyId: account.target.keyId, + keyPrefix: source.serviceKeyPrefix, + fingerprint: source.fingerprint, + result: compactCommandResult(result), + valuesRedacted: true, + }; +} + +function runLocalK3sUserBillingSql(spec: RuntimeSecretSpec, sql: string, options: Options): CommandResult { + const script = [ + "set -eu", + `namespace=${shellQuote(spec.namespace)}`, + `postgres_secret=${shellQuote(spec.postgresSecret)}`, + `postgres_statefulset=${shellQuote(spec.postgresStatefulSet)}`, + `postgres_admin_user=${shellQuote(spec.postgresAdminUser)}`, + `db_name=${shellQuote(spec.cloudApiDbName)}`, + "secret_b64_key() { kubectl -n \"$namespace\" get secret \"$1\" -o \"go-template={{ index .data \\\"$2\\\" }}\" 2>/dev/null || true; }", + "postgres_admin_b64=$(secret_b64_key \"$postgres_secret\" POSTGRES_PASSWORD)", + "if [ -z \"$postgres_admin_b64\" ]; then printf '{\"ok\":false,\"error\":\"postgres-admin-secret-missing\"}\\n'; exit 0; fi", + "postgres_admin_password=$(printf '%s' \"$postgres_admin_b64\" | base64 -d 2>/dev/null || true)", + "if [ -z \"$postgres_admin_password\" ]; then printf '{\"ok\":false,\"error\":\"postgres-admin-secret-decode-failed\"}\\n'; exit 0; fi", + "kubectl -n \"$namespace\" exec -i \"statefulset/$postgres_statefulset\" -c postgres -- env PGPASSWORD=\"$postgres_admin_password\" psql -X -q -t -A -v ON_ERROR_STOP=1 -U \"$postgres_admin_user\" -d \"$db_name\" <<'SQL'", + sql, + "SQL", + ].join("\n"); + return runCommand([transPath(), `${spec.node}:k3s`, "sh"], repoRoot, { input: script, timeoutMs: options.timeoutSeconds * 1000 }); +} + +function userBillingStatusSql(account: Account, source: SourceMaterial): string { + const expectedHash = source.ok && source.sha256Hex ? source.sha256Hex : ""; + return ` +SELECT jsonb_build_object( + 'ok', true, + 'checked', true, + 'kind', ${sqlLiteral(account.target.kind)}, + 'exists', u.id IS NOT NULL, + 'user', CASE WHEN u.id IS NULL THEN NULL ELSE jsonb_build_object( + 'id', u.id, + 'email', u.email, + 'username', u.username, + 'displayName', u.display_name, + 'role', u.role, + 'status', u.status, + 'planId', COALESCE(a.plan_id, ''), + 'balanceCredits', COALESCE(a.balance_credits, 0), + 'reservedCredits', COALESCE(a.reserved_credits, 0) + ) END, + 'apiKey', CASE WHEN k.id IS NULL THEN jsonb_build_object( + 'exists', false, + 'keyId', ${sqlLiteral(account.target.keyId ?? "")}, + 'keyPrefix', NULL, + 'status', NULL, + 'scopes', '[]'::jsonb, + 'matchesSourceFingerprint', NULL + ) ELSE jsonb_build_object( + 'exists', true, + 'keyId', ${sqlLiteral(account.target.keyId ?? "")}, + 'keyPrefix', k.key_prefix, + 'status', k.status, + 'scopes', COALESCE(k.scopes_json, '[]'::jsonb), + 'matchesSourceFingerprint', CASE WHEN ${sqlLiteral(expectedHash)} = '' OR k.key_hash IS NULL THEN NULL ELSE k.key_hash = ${sqlLiteral(expectedHash)} END + ) END +)::text +FROM (SELECT 1) seed +LEFT JOIN hwlab_users u ON u.id = ${sqlLiteral(account.userId)} +LEFT JOIN hwlab_credit_accounts a ON a.user_id = u.id +LEFT JOIN hwlab_api_keys k ON k.id = ${sqlLiteral(account.target.keyId ?? "")} AND k.user_id = u.id +LIMIT 1;`.trim(); +} + +function syncUserBillingAccountSql(account: Account, source: SourceMaterial): string { + const metadata = JSON.stringify({ source: "unidesk-hwlab-test-accounts", logicalId: account.logicalId, sourceRef: account.sourceRef }); + return ` +BEGIN; +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM hwlab_users WHERE id <> ${sqlLiteral(account.userId)} AND (lower(username) = lower(${sqlLiteral(account.username)}) OR lower(email) = lower(${sqlLiteral(account.email ?? "")})) LIMIT 1) THEN + RAISE EXCEPTION 'test account username/email is already owned by another user'; + END IF; + IF EXISTS (SELECT 1 FROM hwlab_api_keys WHERE key_hash = ${sqlLiteral(source.sha256Hex ?? "")} AND id <> ${sqlLiteral(account.target.keyId ?? "")} LIMIT 1) THEN + RAISE EXCEPTION 'test account API key hash is already owned by another key'; + END IF; +END $$; +INSERT INTO hwlab_users (id, email, username, display_name, password_hash, status, role, email_verified) +VALUES (${sqlLiteral(account.userId)}, ${sqlLiteral(account.email ?? "")}, ${sqlLiteral(account.username)}, ${sqlLiteral(account.displayName)}, 'owner-only-api-key-account', ${sqlLiteral(account.status)}, ${sqlLiteral(account.role)}, true) +ON CONFLICT (id) DO UPDATE SET email = EXCLUDED.email, username = EXCLUDED.username, display_name = EXCLUDED.display_name, status = EXCLUDED.status, role = EXCLUDED.role, email_verified = true, updated_at = now(); +INSERT INTO hwlab_credit_accounts (user_id, balance_credits, reserved_credits, plan_id) +VALUES (${sqlLiteral(account.userId)}, ${account.initialCredits}, 0, ${sqlLiteral(account.planId)}) +ON CONFLICT (user_id) DO UPDATE SET balance_credits = GREATEST(hwlab_credit_accounts.balance_credits, EXCLUDED.balance_credits), plan_id = EXCLUDED.plan_id, updated_at = now(); +${account.initialCredits > 0 ? `INSERT INTO hwlab_credit_ledger (id, user_id, delta_credits, balance_before, balance_after, kind, reason, source, status, idempotency_key, operator_user_id, operator_username, metadata) +VALUES (${sqlLiteral(`led_${account.logicalId.replaceAll("-", "_")}_initial`)}, ${sqlLiteral(account.userId)}, ${account.initialCredits}, 0, ${account.initialCredits}, 'admin_grant', 'test_account_initial_credit', 'test-account-sync', 'applied', ${sqlLiteral(`test-account-sync:${account.logicalId}:initial-credit`)}, 'usr_v03_admin', 'admin', ${sqlLiteral(metadata)}::jsonb) +ON CONFLICT DO NOTHING;` : ""} +INSERT INTO hwlab_api_keys (id, user_id, name, key_prefix, key_hash, scopes_json, status, revoked_at) +VALUES (${sqlLiteral(account.target.keyId ?? "")}, ${sqlLiteral(account.userId)}, ${sqlLiteral(account.target.keyName)}, ${sqlLiteral(source.serviceKeyPrefix ?? "")}, ${sqlLiteral(source.sha256Hex ?? "")}, ${sqlLiteral(JSON.stringify(account.target.scopes))}::jsonb, 'active', NULL) +ON CONFLICT (id) DO UPDATE SET user_id = EXCLUDED.user_id, name = EXCLUDED.name, key_prefix = EXCLUDED.key_prefix, key_hash = EXCLUDED.key_hash, scopes_json = EXCLUDED.scopes_json, status = 'active', revoked_at = NULL; +COMMIT; +SELECT jsonb_build_object('ok', true, 'mutation', true, 'userId', ${sqlLiteral(account.userId)}, 'keyId', ${sqlLiteral(account.target.keyId ?? "")}, 'keyPrefix', ${sqlLiteral(source.serviceKeyPrefix ?? "")}, 'valuesRedacted', true)::text;`.trim(); +} + +function normalizeApiKeyStatus(value: unknown, account: Account): Record { + const apiKey = plainObject(value); + if (apiKey === null) return { exists: false, keyId: account.target.keyId, keyPrefix: null, status: null, scopes: [], matchesSourceFingerprint: null }; + return { + exists: apiKey.exists === true, + keyId: apiKey.keyId ?? account.target.keyId, + keyPrefix: apiKey.keyPrefix ?? null, + status: apiKey.status ?? null, + scopes: parseJsonArray(apiKey.scopes), + matchesSourceFingerprint: apiKey.matchesSourceFingerprint === true ? true : apiKey.matchesSourceFingerprint === false ? false : null, + }; +} + function syncKubernetesSecret(target: Target, account: Account, source: SourceMaterial, options: Options): Record { const namespace = account.target.namespace ?? target.namespace; const secretName = account.target.secretName ?? ""; @@ -620,9 +858,9 @@ function compactTargetStatus(status: Record): Record[], database: SourceMaterial, extra: Record = {}): Record { +function report(action: string, config: LoadedConfig, target: Target, options: Options, accounts: Record[], database: DatabaseAccess, extra: Record = {}): Record { const blockers = [ - ...(database.ok ? [] : [database.blocker]), + ...(database.kind === "platform-service" && !database.databaseUrlSource.ok ? [database.databaseUrlSource.blocker] : []), ...accounts.flatMap((account) => Array.isArray(account.blockers) ? account.blockers : []), ...((extra.blockers as Record[] | undefined) ?? []), ].filter(Boolean); @@ -633,7 +871,8 @@ function report(action: string, config: LoadedConfig, target: Target, options: O version: config.version, target: { id: target.id, node: target.node, lane: target.lane, namespace: target.namespace, publicUrl: target.publicUrl, userBillingServiceId: target.userBilling.serviceId }, sourceRoot: config.sourceRoot, - databaseUrlSource: publicSource(database), + databaseAccess: publicDatabaseAccess(database), + databaseUrlSource: publicSource(database.databaseUrlSource), accounts, blockers, next: blockers.length === 0 ? undefined : { sync: `bun scripts/cli.ts hwlab nodes test-accounts sync --node ${options.node} --lane ${options.lane} --confirm` }, @@ -726,6 +965,27 @@ function publicSource(source: SourceMaterial): Record { return { sourceRef: source.sourceRef, sourceKey: source.sourceKey, sourcePath: source.sourcePath, exists: source.exists, byteCount: source.byteCount, keyPrefix: source.keyPrefix, fingerprint: source.fingerprint, valuesRedacted: true }; } +function publicDatabaseAccess(database: DatabaseAccess): Record { + const spec = database.runtimeSecretSpec; + return { + mode: database.kind, + runtimeStoreMode: database.runtimeStoreMode, + databaseUrlSource: { ...publicSource(database.databaseUrlSource), active: database.kind === "platform-service" }, + localK3s: database.kind === "local-k3s" && spec !== null + ? { + namespace: spec.namespace, + postgresStatefulSet: spec.postgresStatefulSet, + postgresSecret: spec.postgresSecret, + database: spec.cloudApiDbName, + user: spec.cloudApiDbUser, + cloudApiDbSecret: spec.cloudApiDbSecret, + cloudApiDbKey: spec.cloudApiDbKey, + } + : null, + valuesRedacted: true, + }; +} + function selectTarget(config: LoadedConfig, options: Options): Target { const target = config.targets.find((item) => item.node === options.node && item.lane === options.lane); if (!target) throw new Error(`${config.configPath} has no target for node=${options.node} lane=${options.lane}`); @@ -809,6 +1069,28 @@ function shellQuote(value: string): string { return `'${value.replace(/'/gu, "'\\''")}'`; } +function sqlLiteral(value: string): string { + return `'${value.replace(/'/gu, "''")}'`; +} + +function parseJsonObjectLine(text: string): Record | null { + for (const line of text.split(/\r?\n/u)) { + const trimmed = line.trim(); + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) continue; + try { + const parsed = JSON.parse(trimmed) as unknown; + return plainObject(parsed); + } catch { + continue; + } + } + return null; +} + +function compactText(value: string): string { + return value.replace(/\b([A-Za-z0-9_.-]*(?:TOKEN|PASSWORD|SECRET|API_KEY|DATABASE_URL)[A-Za-z0-9_.-]*)=([^\s"'`]+)/giu, "$1=").trim().slice(0, 1000); +} + function requiredValue(args: string[], index: number, name: string): string { const value = args[index]; if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`);