feat: sync hwlab api key host env
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
Reference in New Issue
Block a user