diff --git a/config/hwlab-test-accounts.yaml b/config/hwlab-test-accounts.yaml new file mode 100644 index 00000000..53609181 --- /dev/null +++ b/config/hwlab-test-accounts.yaml @@ -0,0 +1,81 @@ +version: 1 +kind: hwlab-test-accounts + +metadata: + id: hwlab-test-accounts + owner: unidesk + relatedIssues: + - 1238 + - 1237 + - 1236 + +sourceRoot: /root/unidesk/.state/secrets + +targets: + - id: d601-v03 + node: D601 + lane: v03 + namespace: hwlab-v03 + publicUrl: https://hwlab.pikapython.com + userBilling: + serviceId: hwlab-user-billing + databaseUrlSource: + sourceRef: hwlab/d601-v03-cloud-api-db.env + sourceKey: DATABASE_URL + accounts: + - logicalId: d601-v03-admin + kind: bootstrap-admin-api-key + userId: usr_v03_admin + username: admin + displayName: HWLAB v0.3 Admin + role: admin + status: active + permissions: + - admin + - system:hwlab + - "tool:*" + sourceRef: /root/.config/hwlab-v03/master-server-admin-api-key.env + sourceKey: HWLAB_API_KEY + createIfMissing: + enabled: true + randomBase64Url: + bytes: 32 + prefix: hwl_live_ + target: + kind: kubernetes-secret + namespace: hwlab-v03 + secretName: hwlab-v03-master-server-admin-api-key + targetKey: api-key + rolloutDeployment: hwlab-cloud-api + - logicalId: d601-v03-inner-test + kind: user-billing-api-key + userId: usr_d601_v03_inner_test + username: inner-test + email: inner-test@hwlab.local + displayName: HWLAB v0.3 T1 Test User + role: user + status: active + planId: default + initialCredits: 100 + permissions: + - code_agent + - hwpod + - aipod + workbench: + projectId: prj_hwpod_workbench + lane: v03 + sourceRef: hwlab/d601-v03-inner-test.env + sourceKey: HWLAB_API_KEY + createIfMissing: + enabled: true + randomBase64Url: + bytes: 32 + prefix: hwl_live_ + target: + kind: user-billing-api-key + serviceId: hwlab-user-billing + keyId: key_d601_v03_inner_test + keyName: D601 v0.3 inner test fixed key + targetKey: api-key + scopes: + - api diff --git a/docs/reference/hwlab.md b/docs/reference/hwlab.md index ceccd864..4cecc383 100644 --- a/docs/reference/hwlab.md +++ b/docs/reference/hwlab.md @@ -44,6 +44,12 @@ D601 node-scoped `v0.3` 已按 `pikasTech/HWLAB#1176` 收敛到 Sub2API-style 真实 payment provider、外部支付回调、订单结算和增长活动策略不是 #1176 完成态的一部分。只有当 `config/hwlab-node-lanes.yaml` 或 HWLAB 自有受控 YAML 明确声明 provider/sourceRef,并且 Secret sync、回调入口、ledger/order 状态机和 public end-to-end smoke 都齐备时,才可以把 D601 `v0.3` 从 provider-agnostic placeholder 扩展到真实支付;不得通过硬编码 provider、手写 k8s Secret、临时 SQL 或 Cloud API 平行账本绕过 user-billing authority。 +## HWLAB 测试账号 YAML 归属 + +HWLAB node/lane 测试账号、bootstrap admin API key 观测、普通测试用户固定 API key、workbench 绑定、user-billing DB sync 输入和 sourceRef/targetKey 映射属于 UniDesk 指挥侧运维真相,必须写入 UniDesk `config/hwlab-test-accounts.yaml`,并通过 `bun scripts/cli.ts hwlab nodes test-accounts status|sync --node --lane ` 受控读取和同步。HWLAB 仓库可以保留应用代码、user-billing schema/migration 和业务 API,但不能另建一份账号 YAML 真相,也不能用运行面 Secret、pod env、日志或 DB 结果反推 owner-only key source。 + +该入口的输出只能记录 logicalId、role、permissions、workbench、sourceRef、sourceKey、targetKey、对象 id、byte count、prefix、fingerprint、presence、matchesSourceFingerprint 和 mutation 摘要;不得打印完整 `HWLAB_API_KEY`、完整 `DATABASE_URL`、base64 payload 或可复制凭据。D601 `v0.3` T1/T2 类验证如果需要 admin/test 两套身份,先用此 UniDesk YAML/CLI 准备账号,再在目标 HWLAB workspace 使用原 `hwlab-cli` 切换 `HWLAB_API_KEY` 做真实入口复验。 + ## HWLAB FRP 维护 HWLAB 公网 FRP server 由 master server 上的 `hwlab-frps-dev` 容器承担,容器使用 host network,并把 `/opt/hwlab-frp/frps.dev.toml` 只读挂载到 `/etc/frp/frps.toml`。这个 server 侧 allowlist 是 UniDesk 指挥侧维护对象,不属于 G14 k3s GitOps desired state;G14 侧 `frpc` ConfigMap/Deployment 只负责各 runtime namespace 的客户端 tunnel。 diff --git a/docs/reference/yaml-first-ops.md b/docs/reference/yaml-first-ops.md index da031c25..d49279ad 100644 --- a/docs/reference/yaml-first-ops.md +++ b/docs/reference/yaml-first-ops.md @@ -73,6 +73,12 @@ The domain CLI resolves the target from YAML, calls shared helpers and prints st Runtime mutation goes through UniDesk CLI and `trans` route execution. Direct `kubectl`, raw SSH, hand-written Caddy edits, direct GitHub API calls or ad hoc shell scripts may be diagnostic or emergency recovery tools only. Repeated operational writes must be promoted into a controlled CLI command that reads YAML and reports redacted structured output. +## Cross-Repository Operational Truth + +When UniDesk prepares or supervises runtime data for another repository, the owning YAML still belongs in UniDesk if the data is part of node/lane operations rather than that repository's application source. Examples include HWLAB test/admin account inventories, operator-only API key source references, runtime DB sync inputs, public exposure targets and cross-node workbench preparation. The service repository may contain application code and app-native migrations, but it must not become a second desired-state truth for UniDesk-operated accounts, credentials, nodes, lanes, namespaces or sourceRef bindings. + +Cross-repository YAML must name the target repository/runtime explicitly and must route all writes through a UniDesk CLI entrypoint that prints only sourceRef, targetKey, presence, byte counts, fingerprints, object ids and mutation summaries. The CLI may read owner-only local source files or declared platform DB exports; it must not backfill those files from runtime Secrets, pod env, logs or database rows. + ## Common Block Rules Reusable blocks must describe operations in data, not in service-specific code branches. diff --git a/scripts/src/help.ts b/scripts/src/help.ts index 47551e02..7a8c160c 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -58,7 +58,7 @@ export function rootHelp(): unknown { { command: "auth-broker contract|health --dry-run|credential-request --dry-run|pr-preflight --dry-run", description: "Inspect the P0 Rust auth broker and CLI adapter contract without reading token values, writing GitHub, or starting services." }, { command: "gh preflight|auth|issue|pr", description: "Run safe GitHub issue and PR CRUD/lifecycle operations through REST with body-file update replace/append, issue/comment apply_patch body patching, comment delete, token diagnostics, PR closeout preflight, hard delete unsupported, and guarded PR merge." }, { command: "commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run", description: "Host Codex commander skeleton contract, no-daemon smoke plan, and dry-run approval preview without live bridges or message sends." }, - { command: "hwlab nodes control-plane|git-mirror|secret --node --lane ", description: "Manage HWLAB node/lane runtime prerequisites, including D601 YAML-declared infra/tools-image/Argo bootstrap and G14 v0.3+ runtime lanes, with the node identity passed as data." }, + { command: "hwlab nodes control-plane|git-mirror|secret|test-accounts --node --lane ", description: "Manage HWLAB node/lane runtime prerequisites, including D601 YAML-declared infra/tools-image/Argo bootstrap, redacted test-account preparation, and G14 v0.3+ runtime lanes, with the node identity passed as data." }, { command: "hwlab g14 monitor-prs | hwlab g14 control-plane status|apply|trigger-current|runtime-migration|cleanup-runs|cleanup-released-pvs | hwlab g14 git-mirror status|apply|sync|flush | hwlab g14 tools-image status|build", description: "Start the legacy G14 PR monitor, run bounded v0.2 Tekton/Argo control-plane, manual PipelineRun trigger, runtime migration, CI workspace retention, manual devops-infra git mirror/relay maintenance, or fixed HWLAB CI tools image actions; long confirmed trigger/sync/flush actions return async jobs by default." }, { command: "agentrun get|describe|events|logs|result|ack|cancel|dispatch|create|apply|send|control-plane|git-mirror", description: "Use AgentRun v0.1 resource primitives with low-noise human output by default; session follow-up uses send only and the server decides internal steer vs turn." }, { command: "platform-infra sub2api|langbot|n8n|wechat-archive ...", description: "Deploy platform-infra services such as Sub2API, LangBot and n8n, manage YAML-controlled public FRP/Caddy exposure and WeChat archive workflows, and inspect status/logs without printing secrets." }, @@ -643,7 +643,7 @@ function platformInfraHelpSummary(): unknown { function hwlabNodeHelpSummary(): unknown { return { - command: "hwlab nodes control-plane|git-mirror|secret --node --lane ", + command: "hwlab nodes control-plane|git-mirror|secret|test-accounts --node --lane ", output: "json", usage: [ "bun scripts/cli.ts hwlab nodes control-plane infra plan --node D601 --lane v03", @@ -653,8 +653,10 @@ function hwlabNodeHelpSummary(): unknown { "bun scripts/cli.ts hwlab nodes control-plane status --node G14 --lane v03", "bun scripts/cli.ts hwlab nodes git-mirror status --node G14 --lane v03", "bun scripts/cli.ts hwlab nodes secret status --node G14 --lane v03 --name ", + "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", ], - description: "Operate HWLAB node/lane runtime prerequisites with node and lane passed as data. The infra subcommand manages YAML-controlled node-local CI/CD, git-mirror, public Dockerfile tools image, and declarative Argo CD prerequisites for D601 v03 while keeping cross-node work semi-automatic.", + description: "Operate HWLAB node/lane runtime prerequisites with node and lane passed as data. The infra subcommand manages YAML-controlled node-local CI/CD, git-mirror, public Dockerfile tools image, and declarative Argo CD prerequisites for D601 v03 while keeping cross-node work semi-automatic; test-accounts prepares UniDesk YAML-declared admin/test account API keys with redacted sourceRef/fingerprint output.", }; } @@ -728,6 +730,9 @@ export async function staticNamespaceHelp(args: string[]): Promise (await import("./hwlab-node-control-plane")).hwlabNodeControlPlaneInfraHelp(), hwlabNodeHelpSummary()); } + if (top === "hwlab" && (sub === "node" || sub === "nodes") && args[2] === "test-accounts") { + return loadHelp(async () => (await import("./hwlab-test-accounts")).hwlabTestAccountsHelp(), hwlabNodeHelpSummary()); + } if (top === "hwlab" && (sub === "node" || sub === "nodes")) return loadHelp(async () => (await import("./hwlab-node")).hwlabNodeHelp(), hwlabNodeHelpSummary()); if (top === "hwlab" && sub === "g14") return loadHelp(async () => (await import("./hwlab-g14")).hwlabG14Help(), hwlabG14HelpSummary()); if (top === "hwlab") return loadHelp(async () => (await import("./hwlab-cd")).hwlabHelp(), hwlabHelpSummary()); diff --git a/scripts/src/hwlab-node.ts b/scripts/src/hwlab-node.ts index bfe02e10..8971435d 100644 --- a/scripts/src/hwlab-node.ts +++ b/scripts/src/hwlab-node.ts @@ -134,12 +134,16 @@ export async function runHwlabNodeCommand(_config: Config, args: string[]): Prom if (args.length === 2 || args.includes("--help") || args.includes("-h") || args[2] === "help") return hwlabNodeControlPlaneInfraHelp(); return runHwlabNodeControlPlaneInfra(args.slice(2)); } + if (domain === "test-accounts") { + const { runHwlabTestAccountsCommand } = await import("./hwlab-test-accounts"); + return runHwlabTestAccountsCommand(args.slice(1)); + } if (args.includes("--help") || args.includes("-h")) return hwlabNodeHelp(); if (domain === "control-plane" || domain === "git-mirror") { return runNodeDelegatedDomain(_config, domain, args.slice(1)); } if (domain !== "secret") { - return { ok: false, command: `hwlab nodes ${domain ?? ""}`.trim(), message: "supported commands: hwlab nodes control-plane, hwlab nodes git-mirror, hwlab nodes secret" }; + return { ok: false, command: `hwlab nodes ${domain ?? ""}`.trim(), message: "supported commands: hwlab nodes control-plane, hwlab nodes git-mirror, hwlab nodes secret, hwlab nodes test-accounts" }; } const options = parseSecretOptions(args.slice(1)); return runNodeSecret(options); @@ -183,6 +187,8 @@ export function hwlabNodeHelp(): Record { "bun scripts/cli.ts hwlab nodes secret cleanup-obsolete --node G14 --lane v03 --name hwpod-v03-db --confirm", "bun scripts/cli.ts hwlab nodes secret status --node G14 --lane v03 --name hwlab-v03-code-agent-provider", "bun scripts/cli.ts hwlab nodes secret ensure --node G14 --lane v03 --name hwlab-v03-code-agent-provider --confirm", + "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", ], }; } diff --git a/scripts/src/hwlab-test-accounts.ts b/scripts/src/hwlab-test-accounts.ts new file mode 100644 index 00000000..9d3f52a1 --- /dev/null +++ b/scripts/src/hwlab-test-accounts.ts @@ -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 | 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 | 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); + 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 { + 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> { + 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> { + 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[]; + 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[] = []; + 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[]> { + const sql = database.value ? postgres(database.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); + 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): Record[] { + 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).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 { + 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 { + 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 { + 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> { + 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 | 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[], database: SourceMaterial, extra: Record = {}): Record { + const blockers = [ + ...(database.ok ? [] : [database.blocker]), + ...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, 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[] { + const blockers: Record[] = []; + 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, 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, 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, path: string): SourceRef { + return { sourceRef: stringField(raw, "sourceRef", path), sourceKey: stringField(raw, "sourceKey", path) }; +} + +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 }; +} + +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 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 { + const values = new Map(); + 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 { + 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 { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be a YAML object`); + return value as Record; +} + +function stringField(obj: Record, 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, 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, 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, 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, key: string, path: string): Record[] { + 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[]; +}