feat: sync hwlab api key host env

This commit is contained in:
Codex
2026-06-19 16:04:19 +00:00
parent 838591116f
commit 54cbc43c60
2 changed files with 212 additions and 5 deletions
+8
View File
@@ -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
+204 -5
View File
@@ -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<string, unknown> | 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<Recor
const options = parseOptions(args);
const config = loadConfig(options.configPath);
const target = selectTarget(config, options);
if (options.action === "host-env-status") return hostEnvStatus(config, target, options);
if (options.action === "host-env-sync") return hostEnvSync(config, target, options);
if (options.action === "sync") return sync(config, target, options);
return status(config, target, options);
}
@@ -103,16 +113,25 @@ export function hwlabTestAccountsHelp(): Record<string, unknown> {
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<Record<string, unknown>> {
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<Record<string, unknown>> {
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<string, unknown>[] = [];
const sourceBlockers: Record<string, unknown>[] = [];
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<Record<string, unknown>> {
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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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<string, unknown>[], extra: Record<string, unknown> = {}): Record<string, unknown> {
const blockers = [
...accounts.flatMap((account) => Array.isArray(account.blockers) ? account.blockers : []),
...((extra.blockers as Record<string, unknown>[] | 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<Record<string, unknown>> {
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<string, unknown>, 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<string, unknown>, path: string): SourceRef {
return { sourceRef: stringField(raw, "sourceRef", path), sourceKey: stringField(raw, "sourceKey", path) };
}
function parseHostEnvTarget(raw: Record<string, unknown>, 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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
@@ -575,6 +758,22 @@ function unquoteEnvValue(value: string): string {
return value;
}
function keyValueFields(text: string): Record<string, string> {
const fields: Record<string, string> = {};
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 [];