|
|
|
@@ -0,0 +1,585 @@
|
|
|
|
|
import { createHash, randomBytes } from "node:crypto";
|
|
|
|
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
|
|
import { dirname, isAbsolute, join } from "node:path";
|
|
|
|
|
import postgres from "postgres";
|
|
|
|
|
import { repoRoot, rootPath } from "./config";
|
|
|
|
|
import { runCommand, type CommandResult } from "./command";
|
|
|
|
|
|
|
|
|
|
const defaultConfigPath = "config/hwlab-test-accounts.yaml";
|
|
|
|
|
const apiKeyTargetKey = "api-key";
|
|
|
|
|
|
|
|
|
|
type AccountTargetKind = "kubernetes-secret" | "user-billing-api-key";
|
|
|
|
|
|
|
|
|
|
interface Options {
|
|
|
|
|
action: "status" | "sync";
|
|
|
|
|
configPath: string;
|
|
|
|
|
node: string;
|
|
|
|
|
lane: string;
|
|
|
|
|
confirm: boolean;
|
|
|
|
|
createSources: boolean;
|
|
|
|
|
timeoutSeconds: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface SourceRef {
|
|
|
|
|
sourceRef: string;
|
|
|
|
|
sourceKey: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface Account extends SourceRef {
|
|
|
|
|
logicalId: string;
|
|
|
|
|
kind: string;
|
|
|
|
|
userId: string;
|
|
|
|
|
username: string;
|
|
|
|
|
email: string | null;
|
|
|
|
|
displayName: string;
|
|
|
|
|
role: "admin" | "user";
|
|
|
|
|
status: "active" | "disabled" | "pending";
|
|
|
|
|
planId: string;
|
|
|
|
|
initialCredits: number;
|
|
|
|
|
permissions: string[];
|
|
|
|
|
workbench: Record<string, unknown> | null;
|
|
|
|
|
createIfMissing: { enabled: boolean; randomBase64Url: { bytes: number; prefix: string } | null };
|
|
|
|
|
target: {
|
|
|
|
|
kind: AccountTargetKind;
|
|
|
|
|
namespace: string | null;
|
|
|
|
|
secretName: string | null;
|
|
|
|
|
serviceId: string | null;
|
|
|
|
|
keyId: string | null;
|
|
|
|
|
keyName: string;
|
|
|
|
|
targetKey: string;
|
|
|
|
|
rolloutDeployment: string | null;
|
|
|
|
|
scopes: string[];
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface Target {
|
|
|
|
|
id: string;
|
|
|
|
|
node: string;
|
|
|
|
|
lane: string;
|
|
|
|
|
namespace: string;
|
|
|
|
|
publicUrl: string;
|
|
|
|
|
userBilling: { serviceId: string; databaseUrlSource: SourceRef };
|
|
|
|
|
accounts: Account[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface LoadedConfig {
|
|
|
|
|
configPath: string;
|
|
|
|
|
version: number;
|
|
|
|
|
kind: "hwlab-test-accounts";
|
|
|
|
|
sourceRoot: string;
|
|
|
|
|
targets: Target[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface SourceMaterial {
|
|
|
|
|
ok: boolean;
|
|
|
|
|
sourceRef: string;
|
|
|
|
|
sourceKey: string;
|
|
|
|
|
sourcePath: string;
|
|
|
|
|
exists: boolean;
|
|
|
|
|
byteCount: number | null;
|
|
|
|
|
keyPrefix: string | null;
|
|
|
|
|
serviceKeyPrefix: string | null;
|
|
|
|
|
fingerprint: string | null;
|
|
|
|
|
sha256Hex: string | null;
|
|
|
|
|
value: string | null;
|
|
|
|
|
blocker: Record<string, unknown> | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function runHwlabTestAccountsCommand(args: string[]): Promise<Record<string, unknown>> {
|
|
|
|
|
if (args.length === 0 || args.includes("--help") || args.includes("-h") || args[0] === "help") return hwlabTestAccountsHelp();
|
|
|
|
|
const options = parseOptions(args);
|
|
|
|
|
const config = loadConfig(options.configPath);
|
|
|
|
|
const target = selectTarget(config, options);
|
|
|
|
|
if (options.action === "sync") return sync(config, target, options);
|
|
|
|
|
return status(config, target, options);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function hwlabTestAccountsHelp(): Record<string, unknown> {
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
command: "hwlab nodes test-accounts",
|
|
|
|
|
description: "Inspect or sync UniDesk YAML-declared HWLAB test/admin accounts without printing API keys or database URLs.",
|
|
|
|
|
configPath: defaultConfigPath,
|
|
|
|
|
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",
|
|
|
|
|
],
|
|
|
|
|
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]");
|
|
|
|
|
const options: Options = { action, configPath: defaultConfigPath, node: "", lane: "", confirm: false, createSources: true, timeoutSeconds: 30 };
|
|
|
|
|
for (let index = 1; 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);
|
|
|
|
|
else if (arg === "--lane") options.lane = requiredValue(args, index += 1, arg);
|
|
|
|
|
else if (arg === "--confirm") options.confirm = true;
|
|
|
|
|
else if (arg === "--no-create-sources") options.createSources = false;
|
|
|
|
|
else if (arg === "--timeout-seconds") options.timeoutSeconds = positiveInteger(requiredValue(args, index += 1, arg), arg);
|
|
|
|
|
else throw new Error(`unknown test-accounts option: ${arg}`);
|
|
|
|
|
}
|
|
|
|
|
if (!options.node) throw new Error("--node is required");
|
|
|
|
|
if (!options.lane) throw new Error("--lane is required");
|
|
|
|
|
return options;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
return report("status", config, target, options, accounts, database, { mutation: false });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function sync(config: LoadedConfig, target: Target, options: Options): Promise<Record<string, unknown>> {
|
|
|
|
|
const database = readSource(target.userBilling.databaseUrlSource, config.sourceRoot, false);
|
|
|
|
|
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<string, unknown>[];
|
|
|
|
|
if (sourceBlockers.length > 0 || !database.value) {
|
|
|
|
|
const accounts = await accountStatuses(config, target, options, database, sources);
|
|
|
|
|
return report("sync", config, target, options, accounts, database, { mutation: false, blockers: sourceBlockers });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const synced: Record<string, unknown>[] = [];
|
|
|
|
|
for (const account of target.accounts.filter((item) => item.target.kind === "kubernetes-secret")) {
|
|
|
|
|
const source = sources.find((item) => item.sourceRef === account.sourceRef && item.sourceKey === account.sourceKey);
|
|
|
|
|
if (!source?.value) throw new Error(`${account.logicalId} source material missing after preflight`);
|
|
|
|
|
synced.push(syncKubernetesSecret(target, account, source, options));
|
|
|
|
|
}
|
|
|
|
|
const userBillingAccounts = target.accounts.filter((item) => item.target.kind === "user-billing-api-key");
|
|
|
|
|
if (userBillingAccounts.length === 0) {
|
|
|
|
|
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.value, { max: 1 });
|
|
|
|
|
try {
|
|
|
|
|
await sql.begin(async (tx: any) => {
|
|
|
|
|
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`);
|
|
|
|
|
await syncUserBillingAccount(tx, account, source);
|
|
|
|
|
synced.push({ logicalId: account.logicalId, targetKind: account.target.kind, mutation: true, userId: account.userId, keyId: account.target.keyId, keyPrefix: source.serviceKeyPrefix, fingerprint: source.fingerprint, valuesRedacted: true });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
} finally {
|
|
|
|
|
await sql.end({ timeout: 5 });
|
|
|
|
|
}
|
|
|
|
|
const accounts = await accountStatuses(config, target, options, database);
|
|
|
|
|
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<Record<string, unknown>[]> {
|
|
|
|
|
const sql = database.value ? postgres(database.value, { max: 1 }) : null;
|
|
|
|
|
try {
|
|
|
|
|
const statuses: Record<string, unknown>[] = [];
|
|
|
|
|
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);
|
|
|
|
|
const blockers = [
|
|
|
|
|
...validateAccount(account, source),
|
|
|
|
|
...targetFingerprintBlockers(account, targetStatus),
|
|
|
|
|
];
|
|
|
|
|
statuses.push({
|
|
|
|
|
logicalId: account.logicalId,
|
|
|
|
|
account: publicAccount(account),
|
|
|
|
|
source: publicSource(source),
|
|
|
|
|
target: targetStatus,
|
|
|
|
|
blockers,
|
|
|
|
|
valuesRedacted: true,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return statuses;
|
|
|
|
|
} finally {
|
|
|
|
|
if (sql) await sql.end({ timeout: 5 });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function targetFingerprintBlockers(account: Account, targetStatus: Record<string, unknown>): Record<string, unknown>[] {
|
|
|
|
|
if (targetStatus.matchesSourceFingerprint === false) {
|
|
|
|
|
return [{ code: "target_fingerprint_mismatch", logicalId: account.logicalId, targetKind: account.target.kind }];
|
|
|
|
|
}
|
|
|
|
|
const apiKey = targetStatus.apiKey;
|
|
|
|
|
if (typeof apiKey === "object" && apiKey !== null && (apiKey as Record<string, unknown>).matchesSourceFingerprint === false) {
|
|
|
|
|
return [{ code: "target_fingerprint_mismatch", logicalId: account.logicalId, targetKind: account.target.kind }];
|
|
|
|
|
}
|
|
|
|
|
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);
|
|
|
|
|
if (!existsSync(sourcePath)) createSourceFile(sourcePath, account.sourceKey, account.createIfMissing.randomBase64Url);
|
|
|
|
|
}
|
|
|
|
|
return readSource(account, sourceRoot, false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readSource(source: SourceRef, sourceRoot: string, _allowCreate: boolean): SourceMaterial {
|
|
|
|
|
const sourcePath = resolveSourcePath(source.sourceRef, sourceRoot);
|
|
|
|
|
if (!existsSync(sourcePath)) {
|
|
|
|
|
return sourceBlocker(source, sourcePath, false, "source_ref_missing");
|
|
|
|
|
}
|
|
|
|
|
const value = parseEnvFile(readFileSync(sourcePath, "utf8")).get(source.sourceKey);
|
|
|
|
|
if (!value) return sourceBlocker(source, sourcePath, true, "source_key_missing");
|
|
|
|
|
const sha256Hex = sha256(value);
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
sourceRef: source.sourceRef,
|
|
|
|
|
sourceKey: source.sourceKey,
|
|
|
|
|
sourcePath: displaySourcePath(sourcePath),
|
|
|
|
|
exists: true,
|
|
|
|
|
byteCount: Buffer.byteLength(value, "utf8"),
|
|
|
|
|
keyPrefix: value.slice(0, Math.min(12, value.length)),
|
|
|
|
|
serviceKeyPrefix: value.slice(0, Math.min(16, value.length)),
|
|
|
|
|
fingerprint: `sha256:${sha256Hex.slice(0, 16)}`,
|
|
|
|
|
sha256Hex,
|
|
|
|
|
value,
|
|
|
|
|
blocker: null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sourceBlocker(source: SourceRef, sourcePath: string, exists: boolean, code: string): SourceMaterial {
|
|
|
|
|
return {
|
|
|
|
|
ok: false,
|
|
|
|
|
sourceRef: source.sourceRef,
|
|
|
|
|
sourceKey: source.sourceKey,
|
|
|
|
|
sourcePath: displaySourcePath(sourcePath),
|
|
|
|
|
exists,
|
|
|
|
|
byteCount: null,
|
|
|
|
|
keyPrefix: null,
|
|
|
|
|
serviceKeyPrefix: null,
|
|
|
|
|
fingerprint: null,
|
|
|
|
|
sha256Hex: null,
|
|
|
|
|
value: null,
|
|
|
|
|
blocker: { code, sourceRef: source.sourceRef, sourceKey: source.sourceKey },
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createSourceFile(sourcePath: string, sourceKey: string, spec: { bytes: number; prefix: string }): void {
|
|
|
|
|
mkdirSync(dirname(sourcePath), { recursive: true, mode: 0o700 });
|
|
|
|
|
const value = `${spec.prefix}${randomBytes(spec.bytes).toString("base64url")}`;
|
|
|
|
|
writeFileSync(sourcePath, `${sourceKey}=${value}\n`, { flag: "wx", mode: 0o600 });
|
|
|
|
|
chmodSync(sourcePath, 0o600);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function syncUserBillingAccount(tx: any, account: Account, source: SourceMaterial): Promise<void> {
|
|
|
|
|
const metadata = { source: "unidesk-hwlab-test-accounts", logicalId: account.logicalId, sourceRef: account.sourceRef };
|
|
|
|
|
const conflicts = await tx`SELECT id FROM hwlab_users WHERE id <> ${account.userId} AND (lower(username) = lower(${account.username}) OR lower(email) = lower(${account.email ?? ""})) LIMIT 1`;
|
|
|
|
|
if (conflicts.length > 0) throw new Error(`${account.logicalId} username/email is already owned by another user`);
|
|
|
|
|
const keyHashConflicts = await tx`SELECT id FROM hwlab_api_keys WHERE key_hash = ${source.sha256Hex ?? ""} AND id <> ${account.target.keyId ?? ""} LIMIT 1`;
|
|
|
|
|
if (keyHashConflicts.length > 0) throw new Error(`${account.logicalId} API key hash is already owned by another key`);
|
|
|
|
|
|
|
|
|
|
await tx`
|
|
|
|
|
INSERT INTO hwlab_users (id, email, username, display_name, password_hash, status, role, email_verified)
|
|
|
|
|
VALUES (${account.userId}, ${account.email ?? ""}, ${account.username}, ${account.displayName}, 'owner-only-api-key-account', ${account.status}, ${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()`;
|
|
|
|
|
await tx`
|
|
|
|
|
INSERT INTO hwlab_credit_accounts (user_id, balance_credits, reserved_credits, plan_id)
|
|
|
|
|
VALUES (${account.userId}, ${account.initialCredits}, 0, ${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()`;
|
|
|
|
|
if (account.initialCredits > 0) {
|
|
|
|
|
await tx`
|
|
|
|
|
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 (${`led_${account.logicalId.replaceAll("-", "_")}_initial`}, ${account.userId}, ${account.initialCredits}, 0, ${account.initialCredits}, 'admin_grant', 'test_account_initial_credit', 'test-account-sync', 'applied', ${`test-account-sync:${account.logicalId}:initial-credit`}, 'usr_v03_admin', 'admin', ${tx.json(metadata)})
|
|
|
|
|
ON CONFLICT DO NOTHING`;
|
|
|
|
|
}
|
|
|
|
|
await tx`
|
|
|
|
|
INSERT INTO hwlab_api_keys (id, user_id, name, key_prefix, key_hash, scopes_json, status, revoked_at)
|
|
|
|
|
VALUES (${account.target.keyId ?? ""}, ${account.userId}, ${account.target.keyName}, ${source.serviceKeyPrefix ?? ""}, ${source.sha256Hex ?? ""}, ${tx.json(account.target.scopes)}, '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`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function syncKubernetesSecret(target: Target, account: Account, source: SourceMaterial, options: Options): Record<string, unknown> {
|
|
|
|
|
const namespace = account.target.namespace ?? target.namespace;
|
|
|
|
|
const secretName = account.target.secretName ?? "";
|
|
|
|
|
const rolloutDeployment = account.target.rolloutDeployment;
|
|
|
|
|
const script = [
|
|
|
|
|
"set -eu",
|
|
|
|
|
"tmp=\"$(mktemp -d)\"",
|
|
|
|
|
"trap 'rm -rf \"$tmp\"' EXIT",
|
|
|
|
|
"key_file=\"$tmp/value\"",
|
|
|
|
|
"cat >\"$key_file\"",
|
|
|
|
|
`kubectl -n ${shellQuote(namespace)} create secret generic ${shellQuote(secretName)} --from-file=${shellQuote(`${account.target.targetKey}=`)}"$key_file" --dry-run=client -o yaml | kubectl apply -f - >/dev/null`,
|
|
|
|
|
...(rolloutDeployment
|
|
|
|
|
? [
|
|
|
|
|
`kubectl -n ${shellQuote(namespace)} rollout restart deploy/${shellQuote(rolloutDeployment)} >/dev/null`,
|
|
|
|
|
`kubectl -n ${shellQuote(namespace)} rollout status deploy/${shellQuote(rolloutDeployment)} --timeout=${shellQuote(String(options.timeoutSeconds))}s >/dev/null`,
|
|
|
|
|
]
|
|
|
|
|
: []),
|
|
|
|
|
`printf 'kind=%s namespace=%s secret=%s key=%s rolloutDeployment=%s mutation=true\\n' ${shellQuote(account.target.kind)} ${shellQuote(namespace)} ${shellQuote(secretName)} ${shellQuote(account.target.targetKey)} ${shellQuote(rolloutDeployment ?? "")}`,
|
|
|
|
|
].join("\n");
|
|
|
|
|
const result = runCommand([transPath(), `${target.node}:k3s`, "sh", "--", script], repoRoot, { input: source.value ?? "", timeoutMs: options.timeoutSeconds * 1000 + 15000 });
|
|
|
|
|
if (result.exitCode !== 0) throw new Error(`${account.logicalId} kubernetes secret sync failed: ${(result.stderr || result.stdout).slice(0, 500)}`);
|
|
|
|
|
return {
|
|
|
|
|
logicalId: account.logicalId,
|
|
|
|
|
targetKind: account.target.kind,
|
|
|
|
|
namespace,
|
|
|
|
|
secretName,
|
|
|
|
|
targetKey: account.target.targetKey,
|
|
|
|
|
rolloutDeployment,
|
|
|
|
|
mutation: true,
|
|
|
|
|
result: compactCommandResult(result),
|
|
|
|
|
fingerprint: source.fingerprint,
|
|
|
|
|
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 ?? "";
|
|
|
|
|
const result = runCommand([transPath(), `${target.node}:k3s`, "kubectl", "-n", namespace, "get", "secret", secretName, "-o", `jsonpath={.data.${account.target.targetKey.replaceAll(".", "\\.")}}`], repoRoot, { timeoutMs: options.timeoutSeconds * 1000 });
|
|
|
|
|
if (result.exitCode !== 0 || result.stdout.trim().length === 0) {
|
|
|
|
|
return { checked: true, kind: account.target.kind, namespace, secretName, targetKey: account.target.targetKey, exists: false, result: compactCommandResult(result), valuesRedacted: true };
|
|
|
|
|
}
|
|
|
|
|
const value = Buffer.from(result.stdout.trim(), "base64").toString("utf8");
|
|
|
|
|
const sha256Hex = sha256(value);
|
|
|
|
|
return {
|
|
|
|
|
checked: true,
|
|
|
|
|
kind: account.target.kind,
|
|
|
|
|
namespace,
|
|
|
|
|
secretName,
|
|
|
|
|
targetKey: account.target.targetKey,
|
|
|
|
|
exists: true,
|
|
|
|
|
byteCount: Buffer.byteLength(value, "utf8"),
|
|
|
|
|
keyPrefix: value.slice(0, Math.min(12, value.length)),
|
|
|
|
|
fingerprint: `sha256:${sha256Hex.slice(0, 16)}`,
|
|
|
|
|
matchesSourceFingerprint: source.ok && source.sha256Hex ? source.sha256Hex === sha256Hex : null,
|
|
|
|
|
result: compactCommandResult(result),
|
|
|
|
|
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`
|
|
|
|
|
SELECT u.id AS user_id, u.username, u.role, u.status AS user_status, COALESCE(a.balance_credits, 0) AS balance_credits, COALESCE(a.reserved_credits, 0) AS reserved_credits, COALESCE(a.plan_id, '') AS plan_id, k.id AS key_id, k.key_prefix, k.key_hash, k.scopes_json::text AS scopes_json, k.status AS key_status
|
|
|
|
|
FROM hwlab_users u
|
|
|
|
|
LEFT JOIN hwlab_credit_accounts a ON a.user_id = u.id
|
|
|
|
|
LEFT JOIN hwlab_api_keys k ON k.id = ${account.target.keyId ?? ""} AND k.user_id = u.id
|
|
|
|
|
WHERE u.id = ${account.userId}
|
|
|
|
|
LIMIT 1`;
|
|
|
|
|
const row = rows[0] as Record<string, unknown> | undefined;
|
|
|
|
|
if (!row) return { checked: true, kind: account.target.kind, exists: false, userId: account.userId, keyId: account.target.keyId, valuesRedacted: true };
|
|
|
|
|
return {
|
|
|
|
|
checked: true,
|
|
|
|
|
kind: account.target.kind,
|
|
|
|
|
exists: true,
|
|
|
|
|
user: { id: row.user_id, username: row.username, role: row.role, status: row.user_status, planId: row.plan_id, balanceCredits: Number(row.balance_credits ?? 0), reservedCredits: Number(row.reserved_credits ?? 0) },
|
|
|
|
|
apiKey: { exists: Boolean(row.key_id), keyId: account.target.keyId, keyPrefix: row.key_prefix || null, status: row.key_status || null, scopes: parseJsonArray(row.scopes_json), matchesSourceFingerprint: source.ok && source.sha256Hex && row.key_hash ? row.key_hash === source.sha256Hex : null },
|
|
|
|
|
valuesRedacted: true,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function report(action: string, config: LoadedConfig, target: Target, options: Options, accounts: Record<string, unknown>[], database: SourceMaterial, extra: Record<string, unknown> = {}): Record<string, unknown> {
|
|
|
|
|
const blockers = [
|
|
|
|
|
...(database.ok ? [] : [database.blocker]),
|
|
|
|
|
...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, userBillingServiceId: target.userBilling.serviceId },
|
|
|
|
|
sourceRoot: config.sourceRoot,
|
|
|
|
|
databaseUrlSource: publicSource(database),
|
|
|
|
|
accounts,
|
|
|
|
|
blockers,
|
|
|
|
|
next: blockers.length === 0 ? undefined : { sync: `bun scripts/cli.ts hwlab nodes test-accounts sync --node ${options.node} --lane ${options.lane} --confirm` },
|
|
|
|
|
...extra,
|
|
|
|
|
valuesRedacted: true,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateAccount(account: Account, source: SourceMaterial): Record<string, unknown>[] {
|
|
|
|
|
const blockers: Record<string, unknown>[] = [];
|
|
|
|
|
if (!source.ok && source.blocker) blockers.push(source.blocker);
|
|
|
|
|
if (account.target.targetKey !== apiKeyTargetKey) blockers.push({ code: "target_key_mismatch", logicalId: account.logicalId, expected: apiKeyTargetKey, actual: account.target.targetKey });
|
|
|
|
|
if (account.target.kind === "kubernetes-secret" && (!account.target.namespace || !account.target.secretName)) blockers.push({ code: "kubernetes_secret_target_incomplete", logicalId: account.logicalId });
|
|
|
|
|
if (account.target.kind === "user-billing-api-key" && (!account.email || !account.target.keyId)) blockers.push({ code: "user_billing_target_incomplete", logicalId: account.logicalId });
|
|
|
|
|
return blockers;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function loadConfig(configPath: string): LoadedConfig {
|
|
|
|
|
const raw = asRecord(Bun.YAML.parse(readFileSync(rootPath(configPath), "utf8")) as unknown, configPath);
|
|
|
|
|
const kind = stringField(raw, "kind", configPath);
|
|
|
|
|
if (kind !== "hwlab-test-accounts") throw new Error(`${configPath}.kind must be hwlab-test-accounts`);
|
|
|
|
|
return { configPath, version: numberField(raw, "version", configPath), kind, sourceRoot: stringField(raw, "sourceRoot", configPath), targets: arrayField(raw, "targets", configPath).map((item, index) => parseTarget(item, `${configPath}.targets[${index}]`)) };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseTarget(raw: Record<string, unknown>, path: string): Target {
|
|
|
|
|
const userBilling = asRecord(raw.userBilling, `${path}.userBilling`);
|
|
|
|
|
const databaseUrlSource = asRecord(userBilling.databaseUrlSource, `${path}.userBilling.databaseUrlSource`);
|
|
|
|
|
return {
|
|
|
|
|
id: stringField(raw, "id", path),
|
|
|
|
|
node: stringField(raw, "node", path),
|
|
|
|
|
lane: stringField(raw, "lane", path),
|
|
|
|
|
namespace: stringField(raw, "namespace", path),
|
|
|
|
|
publicUrl: stringField(raw, "publicUrl", path),
|
|
|
|
|
userBilling: { serviceId: stringField(userBilling, "serviceId", `${path}.userBilling`), databaseUrlSource: parseSourceRef(databaseUrlSource, `${path}.userBilling.databaseUrlSource`) },
|
|
|
|
|
accounts: arrayField(raw, "accounts", path).map((item, index) => parseAccount(item, `${path}.accounts[${index}]`)),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseAccount(raw: Record<string, unknown>, path: string): Account {
|
|
|
|
|
const role = stringField(raw, "role", path);
|
|
|
|
|
if (role !== "admin" && role !== "user") throw new Error(`${path}.role must be admin or user`);
|
|
|
|
|
const status = optionalStringField(raw, "status", path) ?? "active";
|
|
|
|
|
if (status !== "active" && status !== "disabled" && status !== "pending") throw new Error(`${path}.status is invalid`);
|
|
|
|
|
const target = asRecord(raw.target, `${path}.target`);
|
|
|
|
|
const targetKind = stringField(target, "kind", `${path}.target`);
|
|
|
|
|
if (targetKind !== "kubernetes-secret" && targetKind !== "user-billing-api-key") throw new Error(`${path}.target.kind is unsupported`);
|
|
|
|
|
const createIfMissing = raw.createIfMissing === undefined ? {} : asRecord(raw.createIfMissing, `${path}.createIfMissing`);
|
|
|
|
|
const randomBase64Url = createIfMissing.randomBase64Url === undefined ? null : asRecord(createIfMissing.randomBase64Url, `${path}.createIfMissing.randomBase64Url`);
|
|
|
|
|
return {
|
|
|
|
|
logicalId: stringField(raw, "logicalId", path),
|
|
|
|
|
kind: stringField(raw, "kind", path),
|
|
|
|
|
userId: stringField(raw, "userId", path),
|
|
|
|
|
username: stringField(raw, "username", path),
|
|
|
|
|
email: optionalStringField(raw, "email", path) ?? null,
|
|
|
|
|
displayName: optionalStringField(raw, "displayName", path) ?? stringField(raw, "username", path),
|
|
|
|
|
role,
|
|
|
|
|
status,
|
|
|
|
|
planId: optionalStringField(raw, "planId", path) ?? "default",
|
|
|
|
|
initialCredits: optionalNumberField(raw, "initialCredits", path) ?? 0,
|
|
|
|
|
permissions: optionalStringArray(raw.permissions, `${path}.permissions`),
|
|
|
|
|
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`) ?? "" } },
|
|
|
|
|
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"]) },
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseSourceRef(raw: Record<string, unknown>, path: string): SourceRef {
|
|
|
|
|
return { sourceRef: stringField(raw, "sourceRef", path), sourceKey: stringField(raw, "sourceKey", path) };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function publicSource(source: SourceMaterial): Record<string, unknown> {
|
|
|
|
|
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 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}`);
|
|
|
|
|
return target;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveSourcePath(sourceRef: string, sourceRoot: string): string {
|
|
|
|
|
return isAbsolute(sourceRef) ? sourceRef : join(sourceRoot, sourceRef);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function displaySourcePath(path: string): string {
|
|
|
|
|
return path.startsWith(`${repoRoot}/`) ? path.slice(repoRoot.length + 1) : path;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseEnvFile(text: string): Map<string, string> {
|
|
|
|
|
const values = new Map<string, string>();
|
|
|
|
|
for (const rawLine of text.split(/\r?\n/u)) {
|
|
|
|
|
const line = rawLine.trim();
|
|
|
|
|
if (!line || line.startsWith("#")) continue;
|
|
|
|
|
const clean = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
|
|
|
|
|
const match = clean.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u);
|
|
|
|
|
if (!match) continue;
|
|
|
|
|
values.set(match[1] ?? "", unquoteEnvValue((match[2] ?? "").trim()));
|
|
|
|
|
}
|
|
|
|
|
return values;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function unquoteEnvValue(value: string): string {
|
|
|
|
|
if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) return value.slice(1, -1);
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseJsonArray(value: unknown): string[] {
|
|
|
|
|
if (Array.isArray(value)) return value.map((item) => String(item));
|
|
|
|
|
if (typeof value !== "string" || value.length === 0) return [];
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(value) as unknown;
|
|
|
|
|
return Array.isArray(parsed) ? parsed.map((item) => String(item)) : [];
|
|
|
|
|
} catch {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sha256(value: string): string {
|
|
|
|
|
return createHash("sha256").update(value).digest("hex");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function transPath(): string {
|
|
|
|
|
return rootPath("scripts/trans");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function compactCommandResult(result: CommandResult): Record<string, unknown> {
|
|
|
|
|
return { command: result.command, exitCode: result.exitCode, stdoutBytes: Buffer.byteLength(result.stdout ?? "", "utf8"), stderr: (result.stderr ?? "").trim().slice(0, 2000), timedOut: result.timedOut };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function shellQuote(value: string): string {
|
|
|
|
|
return `'${value.replace(/'/gu, "'\\''")}'`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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`);
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function positiveInteger(raw: string, name: string): number {
|
|
|
|
|
const value = Number(raw);
|
|
|
|
|
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function asRecord(value: unknown, path: string): Record<string, unknown> {
|
|
|
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be a YAML object`);
|
|
|
|
|
return value as Record<string, unknown>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stringField(obj: Record<string, unknown>, key: string, path: string): string {
|
|
|
|
|
const value = obj[key];
|
|
|
|
|
if (typeof value !== "string" || value.length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function optionalStringField(obj: Record<string, unknown>, key: string, path: string): string | undefined {
|
|
|
|
|
const value = obj[key];
|
|
|
|
|
if (value === undefined) return undefined;
|
|
|
|
|
if (typeof value !== "string" || value.length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function numberField(obj: Record<string, unknown>, key: string, path: string): number {
|
|
|
|
|
const value = obj[key];
|
|
|
|
|
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${path}.${key} must be a number`);
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function optionalNumberField(obj: Record<string, unknown>, key: string, path: string): number | undefined {
|
|
|
|
|
const value = obj[key];
|
|
|
|
|
if (value === undefined) return undefined;
|
|
|
|
|
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error(`${path}.${key} must be a non-negative number`);
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function arrayField(obj: Record<string, unknown>, key: string, path: string): Record<string, unknown>[] {
|
|
|
|
|
const value = obj[key];
|
|
|
|
|
if (!Array.isArray(value)) throw new Error(`${path}.${key} must be an array`);
|
|
|
|
|
return value.map((item, index) => asRecord(item, `${path}.${key}[${index}]`));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function optionalStringArray(value: unknown, path: string, fallback: string[] = []): string[] {
|
|
|
|
|
if (value === undefined) return fallback;
|
|
|
|
|
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0)) throw new Error(`${path} must be an array of non-empty strings`);
|
|
|
|
|
return value as string[];
|
|
|
|
|
}
|