diff --git a/config/hwlab-test-accounts.yaml b/config/hwlab-test-accounts.yaml index 53609181..a2c74882 100644 --- a/config/hwlab-test-accounts.yaml +++ b/config/hwlab-test-accounts.yaml @@ -41,6 +41,10 @@ targets: randomBase64Url: bytes: 32 prefix: hwl_live_ + hostEnvTargets: + - path: /home/ubuntu/.config/hwlab-v03/master-server-admin-api-key.env + envKey: HWLAB_API_KEY + mode: "0600" target: kind: kubernetes-secret namespace: hwlab-v03 @@ -71,6 +75,10 @@ targets: randomBase64Url: bytes: 32 prefix: hwl_live_ + hostEnvTargets: + - path: /home/ubuntu/.config/hwlab-v03/inner-test-api-key.env + envKey: HWLAB_API_KEY + mode: "0600" target: kind: user-billing-api-key serviceId: hwlab-user-billing diff --git a/scripts/src/hwlab-test-accounts.ts b/scripts/src/hwlab-test-accounts.ts index 77837b06..88843861 100644 --- a/scripts/src/hwlab-test-accounts.ts +++ b/scripts/src/hwlab-test-accounts.ts @@ -9,9 +9,10 @@ const defaultConfigPath = "config/hwlab-test-accounts.yaml"; const apiKeyTargetKey = "api-key"; type AccountTargetKind = "kubernetes-secret" | "user-billing-api-key"; +type Action = "status" | "sync" | "host-env-status" | "host-env-sync"; interface Options { - action: "status" | "sync"; + action: Action; configPath: string; node: string; lane: string; @@ -25,6 +26,12 @@ interface SourceRef { sourceKey: string; } +interface HostEnvTarget { + path: string; + envKey: string; + mode: string; +} + interface Account extends SourceRef { logicalId: string; kind: string; @@ -39,6 +46,7 @@ interface Account extends SourceRef { permissions: string[]; workbench: Record | null; createIfMissing: { enabled: boolean; randomBase64Url: { bytes: number; prefix: string } | null }; + hostEnvTargets: HostEnvTarget[]; target: { kind: AccountTargetKind; namespace: string | null; @@ -90,6 +98,8 @@ export async function runHwlabTestAccountsCommand(args: string[]): Promise { examples: [ "bun scripts/cli.ts hwlab nodes test-accounts status --node D601 --lane v03", "bun scripts/cli.ts hwlab nodes test-accounts sync --node D601 --lane v03 --confirm", + "bun scripts/cli.ts hwlab nodes test-accounts host-env status --node D601 --lane v03", + "bun scripts/cli.ts hwlab nodes test-accounts host-env sync --node D601 --lane v03 --confirm", ], valuesRedacted: true, }; } function parseOptions(args: string[]): Options { - const action = args[0]; - if (action !== "status" && action !== "sync") throw new Error("test-accounts usage: status|sync --node NODE --lane vNN [--config PATH] [--confirm]"); + let action = args[0] as Action | "host-env" | undefined; + let optionStart = 1; + if (action === "host-env") { + const subaction = args[1]; + if (subaction !== "status" && subaction !== "sync") throw new Error("test-accounts host-env usage: host-env status|sync --node NODE --lane vNN [--config PATH] [--confirm]"); + action = subaction === "status" ? "host-env-status" : "host-env-sync"; + optionStart = 2; + } + if (action !== "status" && action !== "sync" && action !== "host-env-status" && action !== "host-env-sync") throw new Error("test-accounts usage: status|sync|host-env status|host-env sync --node NODE --lane vNN [--config PATH] [--confirm]"); const options: Options = { action, configPath: defaultConfigPath, node: "", lane: "", confirm: false, createSources: true, timeoutSeconds: 30 }; - for (let index = 1; index < args.length; index += 1) { + for (let index = optionStart; index < args.length; index += 1) { const arg = args[index] ?? ""; if (arg === "--config") options.configPath = requiredValue(args, index += 1, arg); else if (arg === "--node") options.node = requiredValue(args, index += 1, arg); @@ -127,6 +146,37 @@ function parseOptions(args: string[]): Options { return options; } +async function hostEnvStatus(config: LoadedConfig, target: Target, options: Options): Promise> { + const accounts = target.accounts.map((account) => { + const source = readAccountSource(account, config.sourceRoot, false); + return hostEnvAccountStatus(target, account, source, options); + }); + return hostEnvReport("host-env status", config, target, options, accounts, { mutation: false }); +} + +async function hostEnvSync(config: LoadedConfig, target: Target, options: Options): Promise> { + if (!options.confirm) { + return hostEnvReport("host-env sync", config, target, options, [], { mutation: false, blockers: [{ code: "confirm_required", message: "host-env sync requires --confirm" }] }); + } + const synced: Record[] = []; + const sourceBlockers: Record[] = []; + for (const account of target.accounts) { + const source = readAccountSource(account, config.sourceRoot, options.createSources); + if (!source.ok || !source.value) { + if (source.blocker) sourceBlockers.push(source.blocker); + continue; + } + for (const hostTarget of account.hostEnvTargets) { + synced.push(syncHostEnvTarget(target, account, hostTarget, source, options)); + } + } + const accounts = target.accounts.map((account) => { + const source = readAccountSource(account, config.sourceRoot, false); + return hostEnvAccountStatus(target, account, source, options); + }); + return hostEnvReport("host-env sync", config, target, options, accounts, { mutation: synced.some((item) => item.mutation === true), synced, blockers: sourceBlockers }); +} + async function status(config: LoadedConfig, target: Target, options: Options): Promise> { const database = readSource(target.userBilling.databaseUrlSource, config.sourceRoot, false); const accounts = await accountStatuses(config, target, options, database); @@ -375,6 +425,106 @@ function syncKubernetesSecret(target: Target, account: Account, source: SourceMa }; } +function hostEnvAccountStatus(target: Target, account: Account, source: SourceMaterial, options: Options): Record { + const targets = account.hostEnvTargets.map((hostTarget) => hostEnvTargetStatus(target, account, hostTarget, source, options)); + const blockers = [ + ...(source.ok ? [] : source.blocker ? [source.blocker] : []), + ...targets.flatMap((item) => { + if (item.exists === false) return [{ code: "host_env_target_missing", logicalId: account.logicalId, path: item.path, envKey: item.envKey }]; + if (item.keyPresent === false) return [{ code: "host_env_key_missing", logicalId: account.logicalId, path: item.path, envKey: item.envKey }]; + if (item.matchesSourceFingerprint === false) return [{ code: "host_env_fingerprint_mismatch", logicalId: account.logicalId, path: item.path, envKey: item.envKey }]; + return []; + }), + ]; + return { + logicalId: account.logicalId, + account: publicHostEnvAccount(account), + source: publicSource(source), + hostEnvTargets: targets, + blockers, + valuesRedacted: true, + }; +} + +function hostEnvTargetStatus(target: Target, _account: Account, hostTarget: HostEnvTarget, source: SourceMaterial, options: Options): Record { + const script = [ + "set -eu", + `target_path=${shellQuote(hostTarget.path)}`, + `env_key=${shellQuote(hostTarget.envKey)}`, + `expected_sha=${shellQuote(source.sha256Hex ?? "")}`, + "if [ ! -f \"$target_path\" ]; then printf 'exists=false keyPresent=false path=%s envKey=%s mode= byteCount=0 fingerprint= matchesSourceFingerprint=unknown\\n' \"$target_path\" \"$env_key\"; exit 0; fi", + "line=$(grep -E \"^(export[[:space:]]+)?${env_key}=\" \"$target_path\" | tail -n 1 || true)", + "mode=$(stat -c '%a' \"$target_path\" 2>/dev/null || printf unknown)", + "if [ -z \"$line\" ]; then printf 'exists=true keyPresent=false path=%s envKey=%s mode=%s byteCount=0 fingerprint= matchesSourceFingerprint=unknown\\n' \"$target_path\" \"$env_key\" \"$mode\"; exit 0; fi", + "value=${line#*=}", + "value=${value%$'\\r'}", + "sha=$(printf '%s' \"$value\" | sha256sum | awk '{print $1}')", + "bytes=$(printf '%s' \"$value\" | wc -c | tr -d ' ')", + "prefix=$(printf '%s' \"$value\" | cut -c1-12)", + "matches=unknown", + "if [ -n \"$expected_sha\" ]; then if [ \"$sha\" = \"$expected_sha\" ]; then matches=true; else matches=false; fi; fi", + "printf 'exists=true keyPresent=true path=%s envKey=%s mode=%s byteCount=%s keyPrefix=%s fingerprint=sha256:%s matchesSourceFingerprint=%s\\n' \"$target_path\" \"$env_key\" \"$mode\" \"$bytes\" \"$prefix\" \"$(printf '%s' \"$sha\" | cut -c1-16)\" \"$matches\"", + ].join("\n"); + const result = runCommand([transPath(), target.node, "sh", "--", script], repoRoot, { timeoutMs: options.timeoutSeconds * 1000 }); + const fields = keyValueFields(result.stdout); + return { + checked: true, + kind: "host-env", + path: fields.path || hostTarget.path, + envKey: fields.envKey || hostTarget.envKey, + exists: fields.exists === "true", + keyPresent: fields.keyPresent === "true", + mode: fields.mode || null, + byteCount: numericField(fields.byteCount), + keyPrefix: fields.keyPrefix || null, + fingerprint: fields.fingerprint || null, + matchesSourceFingerprint: fields.matchesSourceFingerprint === "true" ? true : fields.matchesSourceFingerprint === "false" ? false : null, + result: compactCommandResult(result), + valuesRedacted: true, + }; +} + +function syncHostEnvTarget(target: Target, account: Account, hostTarget: HostEnvTarget, source: SourceMaterial, options: Options): Record { + const before = hostEnvTargetStatus(target, account, hostTarget, source, options); + if (before.matchesSourceFingerprint === true && before.mode === hostTarget.mode) { + return { logicalId: account.logicalId, targetKind: "host-env", mutation: false, skipped: "already-matches-source", target: before, fingerprint: source.fingerprint, valuesRedacted: true }; + } + const script = [ + "set -eu", + `target_path=${shellQuote(hostTarget.path)}`, + `env_key=${shellQuote(hostTarget.envKey)}`, + `mode=${shellQuote(hostTarget.mode)}`, + "tmp=$(mktemp)", + "trap 'rm -f \"$tmp\"' EXIT", + "value=$(cat)", + "mkdir -p \"$(dirname \"$target_path\")\"", + "chmod 700 \"$(dirname \"$target_path\")\" 2>/dev/null || true", + "printf '%s=%s\\n' \"$env_key\" \"$value\" >\"$tmp\"", + "install -m \"$mode\" \"$tmp\" \"$target_path\"", + "sha=$(printf '%s' \"$value\" | sha256sum | awk '{print $1}')", + "bytes=$(printf '%s' \"$value\" | wc -c | tr -d ' ')", + "prefix=$(printf '%s' \"$value\" | cut -c1-12)", + "actual_mode=$(stat -c '%a' \"$target_path\" 2>/dev/null || printf unknown)", + "printf 'kind=host-env path=%s envKey=%s mode=%s byteCount=%s keyPrefix=%s fingerprint=sha256:%s mutation=true\\n' \"$target_path\" \"$env_key\" \"$actual_mode\" \"$bytes\" \"$prefix\" \"$(printf '%s' \"$sha\" | cut -c1-16)\"", + ].join("\n"); + const result = runCommand([transPath(), target.node, "sh", "--", script], repoRoot, { input: source.value ?? "", timeoutMs: options.timeoutSeconds * 1000 + 15000 }); + if (result.exitCode !== 0) throw new Error(`${account.logicalId} host env sync failed: ${(result.stderr || result.stdout).slice(0, 500)}`); + const fields = keyValueFields(result.stdout); + return { + logicalId: account.logicalId, + targetKind: "host-env", + path: fields.path || hostTarget.path, + envKey: fields.envKey || hostTarget.envKey, + mode: fields.mode || hostTarget.mode, + byteCount: numericField(fields.byteCount), + keyPrefix: fields.keyPrefix || null, + fingerprint: fields.fingerprint || source.fingerprint, + mutation: true, + result: compactCommandResult(result), + valuesRedacted: true, + }; +} + function runtimeSecretStatus(target: Target, account: Account, source: SourceMaterial, options: Options): Record { const namespace = account.target.namespace ?? target.namespace; const secretName = account.target.secretName ?? ""; @@ -400,6 +550,26 @@ function runtimeSecretStatus(target: Target, account: Account, source: SourceMat }; } +function hostEnvReport(action: string, config: LoadedConfig, target: Target, options: Options, accounts: Record[], extra: Record = {}): Record { + const blockers = [ + ...accounts.flatMap((account) => Array.isArray(account.blockers) ? account.blockers : []), + ...((extra.blockers as Record[] | undefined) ?? []), + ].filter(Boolean); + return { + ok: blockers.length === 0, + command: `hwlab nodes test-accounts ${action}`, + configPath: config.configPath, + version: config.version, + target: { id: target.id, node: target.node, lane: target.lane, namespace: target.namespace, publicUrl: target.publicUrl }, + sourceRoot: config.sourceRoot, + accounts, + blockers, + next: blockers.length === 0 ? undefined : { sync: `bun scripts/cli.ts hwlab nodes test-accounts host-env sync --node ${options.node} --lane ${options.lane} --confirm` }, + ...extra, + valuesRedacted: true, + }; +} + async function userBillingApiKeyStatus(sql: any, account: Account, source: SourceMaterial): Promise> { if (!sql) return { checked: false, kind: account.target.kind, reason: "database_url_source_missing", valuesRedacted: true }; const rows = await sql` @@ -527,6 +697,7 @@ function parseAccount(raw: Record, path: string): Account { workbench: raw.workbench === undefined ? null : asRecord(raw.workbench, `${path}.workbench`), ...parseSourceRef(raw, path), createIfMissing: { enabled: createIfMissing.enabled === true, randomBase64Url: randomBase64Url === null ? null : { bytes: numberField(randomBase64Url, "bytes", `${path}.createIfMissing.randomBase64Url`), prefix: optionalStringField(randomBase64Url, "prefix", `${path}.createIfMissing.randomBase64Url`) ?? "" } }, + hostEnvTargets: raw.hostEnvTargets === undefined ? [] : arrayField(raw, "hostEnvTargets", path).map((item, index) => parseHostEnvTarget(item, `${path}.hostEnvTargets[${index}]`)), target: { kind: targetKind, namespace: optionalStringField(target, "namespace", `${path}.target`) ?? null, secretName: optionalStringField(target, "secretName", `${path}.target`) ?? null, serviceId: optionalStringField(target, "serviceId", `${path}.target`) ?? null, keyId: optionalStringField(target, "keyId", `${path}.target`) ?? null, keyName: optionalStringField(target, "keyName", `${path}.target`) ?? "default", targetKey: stringField(target, "targetKey", `${path}.target`), rolloutDeployment: optionalStringField(target, "rolloutDeployment", `${path}.target`) ?? null, scopes: optionalStringArray(target.scopes, `${path}.target.scopes`, ["api"]) }, }; } @@ -535,8 +706,20 @@ function parseSourceRef(raw: Record, path: string): SourceRef { return { sourceRef: stringField(raw, "sourceRef", path), sourceKey: stringField(raw, "sourceKey", path) }; } +function parseHostEnvTarget(raw: Record, path: string): HostEnvTarget { + const mode = optionalStringField(raw, "mode", path) ?? "0600"; + if (!/^[0-7]{3,4}$/u.test(mode)) throw new Error(`${path}.mode must be an octal file mode string`); + const envKey = stringField(raw, "envKey", path); + if (!/^[A-Z_][A-Z0-9_]*$/u.test(envKey)) throw new Error(`${path}.envKey must be an uppercase env var name`); + return { path: stringField(raw, "path", path), envKey, mode }; +} + function publicAccount(account: Account): Record { - return { logicalId: account.logicalId, kind: account.kind, userId: account.userId, username: account.username, email: account.email, displayName: account.displayName, role: account.role, status: account.status, planId: account.planId, initialCredits: account.initialCredits, permissions: account.permissions, workbench: account.workbench, sourceRef: account.sourceRef, sourceKey: account.sourceKey, target: account.target }; + return { logicalId: account.logicalId, kind: account.kind, userId: account.userId, username: account.username, email: account.email, displayName: account.displayName, role: account.role, status: account.status, planId: account.planId, initialCredits: account.initialCredits, permissions: account.permissions, workbench: account.workbench, sourceRef: account.sourceRef, sourceKey: account.sourceKey, hostEnvTargets: account.hostEnvTargets, target: account.target }; +} + +function publicHostEnvAccount(account: Account): Record { + return { logicalId: account.logicalId, kind: account.kind, userId: account.userId, username: account.username, role: account.role, sourceRef: account.sourceRef, sourceKey: account.sourceKey, hostEnvTargets: account.hostEnvTargets }; } function publicSource(source: SourceMaterial): Record { @@ -575,6 +758,22 @@ function unquoteEnvValue(value: string): string { return value; } +function keyValueFields(text: string): Record { + const fields: Record = {}; + for (const token of text.trim().split(/\s+/u)) { + const index = token.indexOf("="); + if (index <= 0) continue; + fields[token.slice(0, index)] = token.slice(index + 1); + } + return fields; +} + +function numericField(raw: string | undefined): number | null { + if (raw === undefined || raw.length === 0) return null; + const value = Number(raw); + return Number.isFinite(value) ? value : null; +} + function parseJsonArray(value: unknown): string[] { if (Array.isArray(value)) return value.map((item) => String(item)); if (typeof value !== "string" || value.length === 0) return [];