1062 lines
54 KiB
TypeScript
1062 lines
54 KiB
TypeScript
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. public-exposure module for scripts/src/hwlab-node-impl.ts.
|
|
|
|
// Moved mechanically from scripts/src/hwlab-node-impl.ts:11201-12084 for #903.
|
|
|
|
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
|
|
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
|
|
// Responsibility: YAML-first node/lane operations, including Workbench observability control commands.
|
|
import { createHash, randomBytes } from "node:crypto";
|
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { repoRoot, rootPath, type Config } from "../config";
|
|
import { runCommand, type CommandResult } from "../command";
|
|
import { startJob } from "../jobs";
|
|
import { classifySshTcpPoolFailure } from "../ssh";
|
|
import { HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH, hwlabNodeControlPlaneInfraHelp, runHwlabNodeControlPlaneInfra } from "../hwlab-node-control-plane";
|
|
import { hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, hwlabRuntimeLaneSpecForNode, hwlabRuntimeNodeIds, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec, type HwlabRuntimeObservabilityRecordingRuleSpec, type HwlabRuntimeObservabilitySpec, type HwlabRuntimeObservabilityWarningAlertSpec, type HwlabRuntimePublicExposureSpec, type HwlabRuntimeWebProbeAlertThresholdsSpec, type HwlabRuntimeWebProbeProjectManagementSpec } from "../hwlab-node-lanes";
|
|
import { nodeWebProbeScriptRunnerSource } from "../hwlab-node-web-probe-runner-source";
|
|
import { nodeWebObserveAnalyzerSource } from "../hwlab-node-web-observe-analyzer-source";
|
|
import { nodeWebObserveRunnerSource } from "../hwlab-node-web-observe-runner-source";
|
|
import { nodeWebObserveCollectViewNodeScript, parseNodeWebProbeObserveCollectView, type NodeWebProbeObserveCollectView } from "../hwlab-node-web-observe-collect";
|
|
import { withWebObserveCollectRendered, withWebObserveCommandRendered, withWebObserveStatusRendered } from "../hwlab-node-web-observe-render";
|
|
import { buildWebObserveWrapperForObserveOptions, webObserveWrapperStateDirFromStatus } from "../hwlab-node-web-observe-wrapper";
|
|
import { renderWebObserveWrapperContract } from "../hwlab-node-web-observe-wrapper-render";
|
|
import { runWebProbeSentinelCommand, type WebProbeSentinelOptions } from "../hwlab-node-web-sentinel-cicd";
|
|
import { hwlabNodeHelp, hwlabNodeObservabilityHelp, hwlabNodeWebProbeHelp } from "../hwlab-node-help";
|
|
import { compactWebProbeResult, compactWebProbeScriptResult } from "../hwlab-node-web-probe-summary";
|
|
import { nodeObservabilityRecordingRuleExpression, nodeObservabilityRecordingRuleSummaries, nodeObservabilityWarningAlertExpression, nodeObservabilityWarningAlertSummaries } from "../hwlab-node-observability-promql";
|
|
import { runDelegatedHwlabNodeCommand, type DelegatedNodeDomain } from "../hwlab-node-transport";
|
|
import type { RenderedCliResult } from "../output";
|
|
|
|
import type { CodeAgentProviderSecretMaterial, NodePublicExposureOptions, NodeSecretOptions, RuntimeSecretSpec } from "./entry";
|
|
import { BOOTSTRAP_ADMIN_PASSWORD_HASH_KEY, BOOTSTRAP_ADMIN_SOURCE_NAMESPACE, BOOTSTRAP_ADMIN_SOURCE_SECRET, CLOUD_API_DB_KEY, CODE_AGENT_PROVIDER_OPENAI_KEY, CODE_AGENT_PROVIDER_OPENCODE_KEY, CODE_AGENT_PROVIDER_SOURCE_NAMESPACE, CODE_AGENT_PROVIDER_SOURCE_SECRET, MASTER_ADMIN_API_KEY_KEY, OPENFGA_AUTHN_KEY, OPENFGA_DATASTORE_URI_KEY, OPENFGA_POSTGRES_PASSWORD_KEY } from "./entry";
|
|
import { transPath } from "./runtime-common";
|
|
import { bootstrapAdminSecretScript, cloudApiDbSecretScript, codeAgentProviderSecretScript, masterAdminApiKeySecretScript, obsoletePlatformDbCleanupScript, obsoletePlatformDbStatusFromText, obsoleteSecretCleanupScript, openFgaSecretScript, ownedPostgresCleanupScript, platformDbSecretStatusScript, secretStatusFromText } from "./secret-scripts";
|
|
import { assertLane, assertNodeId, compactCommandResult, keyValueLinesFromText, numericField, optionValue, positiveIntegerOption, readMasterAdminApiKey, requiredOption, shellQuote, statusText } from "./utils";
|
|
import { displayRepoPath, localSecretSourcePaths, parseEnvFile, readBootstrapAdminSecretMaterial, shortSecretFingerprint, syncNodeExternalPostgresSecrets } from "./web-probe";
|
|
import { hwlabRuntimeActiveExternalPostgres } from "../hwlab-node-lanes";
|
|
|
|
export function isSafeWebProbeScriptRunDir(value: string | null): value is string {
|
|
return typeof value === "string"
|
|
&& value.length > 0
|
|
&& !value.includes("\0")
|
|
&& !value.includes("..")
|
|
&& /\.state\/web-probe-script\/run\.[A-Za-z0-9]+$/u.test(value);
|
|
}
|
|
|
|
export function isSafeWebProbeScriptReportPath(value: string | null): value is string {
|
|
return typeof value === "string"
|
|
&& isSafeWebProbeScriptArtifactPath(value)
|
|
&& value.endsWith("/web-probe-script-report.json");
|
|
}
|
|
|
|
export function isSafeWebProbeScriptArtifactPath(value: string): boolean {
|
|
return typeof value === "string"
|
|
&& value.length > 0
|
|
&& !value.includes("\0")
|
|
&& !value.includes("..")
|
|
&& /\.state\/web-probe-script\/run\.[A-Za-z0-9]+\/[^/]+[.](?:png|json)$/u.test(value);
|
|
}
|
|
|
|
export function parseSecretOptions(args: string[]): NodeSecretOptions {
|
|
const [actionRaw] = args;
|
|
if (actionRaw !== "status" && actionRaw !== "ensure" && actionRaw !== "cleanup-owned-postgres" && actionRaw !== "cleanup-obsolete") {
|
|
throw new Error("secret usage: status|ensure --node NODE --lane vNN --name hwlab-vNN-openfga|hwlab-vNN-master-server-admin-api-key|hwlab-vNN-bootstrap-admin|hwlab-cloud-api-vNN-db|hwlab-vNN-code-agent-provider [--dry-run|--confirm] | cleanup-owned-postgres --node NODE --lane vNN [--dry-run|--confirm] | cleanup-obsolete --node NODE --lane vNN --name hwpod-vNN-db [--dry-run|--confirm]");
|
|
}
|
|
const node = requiredOption(args, "--node");
|
|
assertNodeId(node);
|
|
const lane = requiredOption(args, "--lane");
|
|
assertLane(lane);
|
|
const spec = runtimeSecretSpec({ node, lane });
|
|
const name = optionValue(args, "--name") ?? spec.openFgaSecret;
|
|
const key = optionValue(args, "--key");
|
|
const confirm = args.includes("--confirm");
|
|
const explicitDryRun = args.includes("--dry-run");
|
|
if (confirm && explicitDryRun) throw new Error("secret accepts only one of --confirm or --dry-run");
|
|
if (actionRaw === "cleanup-owned-postgres") {
|
|
if (lane === "v02") throw new Error("secret cleanup-owned-postgres is only for v0.3+ lanes after migration to G14 platform Postgres");
|
|
if (key !== undefined) throw new Error("secret cleanup-owned-postgres does not accept --key");
|
|
if (name !== spec.openFgaSecret && name !== spec.postgresSecret) throw new Error(`secret cleanup-owned-postgres for --lane ${lane} targets ${spec.postgresSecret}; omit --name or pass --name ${spec.postgresSecret}`);
|
|
return {
|
|
action: actionRaw,
|
|
node,
|
|
lane,
|
|
name: spec.postgresSecret,
|
|
preset: "owned-postgres-cleanup",
|
|
confirm,
|
|
dryRun: explicitDryRun || !confirm,
|
|
timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", 180, 900),
|
|
};
|
|
}
|
|
if (actionRaw === "cleanup-obsolete") {
|
|
if (lane === "v02") throw new Error("secret cleanup-obsolete is only for v0.3+ lanes after deprecated v0.3 HWPOD DB SecretRef cleanup");
|
|
if (key !== undefined) throw new Error("secret cleanup-obsolete does not accept --key");
|
|
if (name !== spec.obsoleteHwpodDbSecret) throw new Error(`secret cleanup-obsolete for --lane ${lane} currently only targets obsolete ${spec.obsoleteHwpodDbSecret}`);
|
|
return {
|
|
action: actionRaw,
|
|
node,
|
|
lane,
|
|
name,
|
|
preset: "obsolete-secret-cleanup",
|
|
confirm,
|
|
dryRun: explicitDryRun || !confirm,
|
|
timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", 180, 900),
|
|
};
|
|
}
|
|
if (name === spec.masterAdminApiKeySecret) {
|
|
if (key !== undefined && key !== MASTER_ADMIN_API_KEY_KEY) throw new Error(`secret ${name} supports only key ${MASTER_ADMIN_API_KEY_KEY}`);
|
|
return {
|
|
action: actionRaw,
|
|
node,
|
|
lane,
|
|
name,
|
|
key: key ?? MASTER_ADMIN_API_KEY_KEY,
|
|
preset: "master-server-admin-api-key",
|
|
confirm,
|
|
dryRun: actionRaw === "status" ? true : explicitDryRun || !confirm,
|
|
timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", 180, 900),
|
|
};
|
|
}
|
|
if (name === spec.bootstrapAdminSecret) {
|
|
if (key !== undefined && key !== BOOTSTRAP_ADMIN_PASSWORD_HASH_KEY) throw new Error(`secret ${name} supports only key ${BOOTSTRAP_ADMIN_PASSWORD_HASH_KEY}`);
|
|
const force = args.includes("--force");
|
|
if (force && actionRaw !== "ensure") throw new Error("secret --force is only supported with ensure");
|
|
return {
|
|
action: actionRaw,
|
|
node,
|
|
lane,
|
|
name,
|
|
key: key ?? BOOTSTRAP_ADMIN_PASSWORD_HASH_KEY,
|
|
preset: "bootstrap-admin",
|
|
confirm,
|
|
force,
|
|
dryRun: actionRaw === "status" ? true : explicitDryRun || !confirm,
|
|
timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", 180, 900),
|
|
};
|
|
}
|
|
if (name === spec.codeAgentProviderSecret) {
|
|
if (key !== undefined && key !== CODE_AGENT_PROVIDER_OPENAI_KEY && key !== CODE_AGENT_PROVIDER_OPENCODE_KEY) {
|
|
throw new Error(`secret ${name} supports keys ${CODE_AGENT_PROVIDER_OPENAI_KEY} and ${CODE_AGENT_PROVIDER_OPENCODE_KEY}`);
|
|
}
|
|
return {
|
|
action: actionRaw,
|
|
node,
|
|
lane,
|
|
name,
|
|
key,
|
|
preset: "code-agent-provider",
|
|
confirm,
|
|
dryRun: actionRaw === "status" ? true : explicitDryRun || !confirm,
|
|
timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", 180, 900),
|
|
};
|
|
}
|
|
if (name === spec.cloudApiDbSecret) {
|
|
if (key !== undefined && key !== spec.cloudApiDbKey) throw new Error(`secret ${name} supports only key ${spec.cloudApiDbKey}`);
|
|
if (actionRaw === "ensure" && spec.platformDb && spec.externalPostgres === undefined) {
|
|
throw new Error(`secret ensure for ${name} on --lane ${lane} was removed after native platform DB migration; use status plus platform DB SecretRef rotation CLI when it exists`);
|
|
}
|
|
return {
|
|
action: actionRaw,
|
|
node,
|
|
lane,
|
|
name,
|
|
key: key ?? spec.cloudApiDbKey,
|
|
preset: "cloud-api-db",
|
|
confirm,
|
|
dryRun: actionRaw === "status" ? true : explicitDryRun || !confirm,
|
|
timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", 240, 900),
|
|
};
|
|
}
|
|
if (name !== spec.openFgaSecret) {
|
|
throw new Error(`secret status/ensure supports --name ${spec.openFgaSecret}, ${spec.masterAdminApiKeySecret}, ${spec.bootstrapAdminSecret}, ${spec.cloudApiDbSecret}, or ${spec.codeAgentProviderSecret} for --lane ${lane}; obsolete ${spec.obsoleteHwpodDbSecret} must use cleanup-obsolete`);
|
|
}
|
|
if (key !== undefined && key !== OPENFGA_AUTHN_KEY && key !== OPENFGA_DATASTORE_URI_KEY && key !== OPENFGA_POSTGRES_PASSWORD_KEY) {
|
|
throw new Error(`secret ${name} supports keys ${OPENFGA_AUTHN_KEY}, ${OPENFGA_DATASTORE_URI_KEY}, and ${OPENFGA_POSTGRES_PASSWORD_KEY}`);
|
|
}
|
|
if (actionRaw === "ensure" && spec.platformDb && spec.externalPostgres === undefined) {
|
|
throw new Error(`secret ensure for ${name} on --lane ${lane} was removed after native platform DB migration; use status plus platform DB SecretRef rotation CLI when it exists`);
|
|
}
|
|
return {
|
|
action: actionRaw,
|
|
node,
|
|
lane,
|
|
name,
|
|
key,
|
|
preset: "openfga",
|
|
confirm,
|
|
dryRun: actionRaw === "status" ? true : explicitDryRun || !confirm,
|
|
timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", 180, 900),
|
|
};
|
|
}
|
|
|
|
export function runtimeSecretSpec(input: { node: string; lane: string }): RuntimeSecretSpec {
|
|
const namespace = `hwlab-${input.lane}`;
|
|
const runtimeLaneSpec = isHwlabRuntimeLane(input.lane) ? hwlabRuntimeLaneSpecForNode(input.lane, input.node) : undefined;
|
|
const externalPostgres = runtimeLaneSpec === undefined ? undefined : hwlabRuntimeActiveExternalPostgres(runtimeLaneSpec);
|
|
const postgresStore = runtimeLaneSpec?.runtimeStore?.postgres;
|
|
const bootstrapAdmin = runtimeLaneSpec?.bootstrapAdmin;
|
|
const platformDb = postgresStore?.mode === "platform-service";
|
|
const localPostgresService = postgresStore?.serviceName ?? `${namespace}-postgres`;
|
|
const platformPostgresService = externalPostgres?.serviceName ?? postgresStore?.serviceName ?? "g14-platform-postgres";
|
|
const platformPostgresRuntimeAccess = externalPostgres?.runtimeAccess;
|
|
const legacyPostgresHost = `${localPostgresService}.${namespace}.svc.cluster.local`;
|
|
const platformPostgresHost = `${platformPostgresService}.${namespace}.svc.cluster.local`;
|
|
const platformPostgresEndpointSlice = `${platformPostgresService}-host`;
|
|
const openFgaDbName = externalPostgres?.database ?? postgresStore?.openfga?.database ?? (platformDb ? `openfga_${input.lane}` : "hwlab_openfga");
|
|
const openFgaDbUser = externalPostgres?.openfga.role ?? postgresStore?.openfga?.role ?? (platformDb ? `openfga_${input.lane}_app` : "hwlab_openfga");
|
|
const cloudApiDbName = externalPostgres?.database ?? postgresStore?.cloudApi?.database ?? `hwlab_${input.lane}`;
|
|
const cloudApiDbUser = externalPostgres?.cloudApi.role ?? postgresStore?.cloudApi?.role ?? (platformDb ? `hwlab_${input.lane}_app` : `hwlab_${input.lane}`);
|
|
return {
|
|
node: input.node,
|
|
lane: input.lane,
|
|
namespace,
|
|
platformDb,
|
|
...(runtimeLaneSpec === undefined ? {} : { runtimeLaneSpec }),
|
|
...(externalPostgres === undefined ? {} : { externalPostgres }),
|
|
platformPostgresService,
|
|
platformPostgresEndpointAddress: platformPostgresRuntimeAccess?.endpointAddress ?? externalPostgres?.endpointAddress,
|
|
platformPostgresEndpointSlice,
|
|
postgresSecret: postgresStore?.secretName ?? `${namespace}-postgres`,
|
|
postgresStatefulSet: postgresStore?.statefulSet ?? postgresStore?.secretName ?? `${namespace}-postgres`,
|
|
postgresAdminUser: postgresStore?.adminUser ?? `hwlab_${input.lane}`,
|
|
openFgaSecret: externalPostgres?.openfga.secretName ?? postgresStore?.openfga?.secretName ?? `${namespace}-openfga`,
|
|
openFgaDbName,
|
|
openFgaDbUser,
|
|
openFgaDbHost: platformDb ? platformPostgresHost : legacyPostgresHost,
|
|
masterAdminApiKeySecret: `${namespace}-master-server-admin-api-key`,
|
|
bootstrapAdminSecret: bootstrapAdmin?.secretName ?? `${namespace}-bootstrap-admin`,
|
|
bootstrapAdminPasswordHashKey: bootstrapAdmin?.secretKey ?? BOOTSTRAP_ADMIN_PASSWORD_HASH_KEY,
|
|
bootstrapAdminUsername: bootstrapAdmin?.username ?? "admin",
|
|
bootstrapAdminDisplayName: bootstrapAdmin?.displayName ?? `HWLAB ${input.lane} Admin`,
|
|
...(bootstrapAdmin?.usernameSourceRef === undefined ? {} : { bootstrapAdminUsernameSourceRef: bootstrapAdmin.usernameSourceRef }),
|
|
...(bootstrapAdmin?.usernameSourceKey === undefined ? {} : { bootstrapAdminUsernameSourceKey: bootstrapAdmin.usernameSourceKey }),
|
|
...(bootstrapAdmin?.usernameSourceLine === undefined ? {} : { bootstrapAdminUsernameSourceLine: bootstrapAdmin.usernameSourceLine }),
|
|
...(bootstrapAdmin?.passwordSourceRef === undefined ? {} : { bootstrapAdminPasswordSourceRef: bootstrapAdmin.passwordSourceRef }),
|
|
...(bootstrapAdmin?.passwordSourceKey === undefined ? {} : { bootstrapAdminPasswordSourceKey: bootstrapAdmin.passwordSourceKey }),
|
|
...(bootstrapAdmin?.passwordSourceLine === undefined ? {} : { bootstrapAdminPasswordSourceLine: bootstrapAdmin.passwordSourceLine }),
|
|
...(bootstrapAdmin?.passwordHashTransform === undefined ? {} : { bootstrapAdminPasswordHashTransform: bootstrapAdmin.passwordHashTransform }),
|
|
bootstrapAdminSourceNamespace: BOOTSTRAP_ADMIN_SOURCE_NAMESPACE,
|
|
bootstrapAdminSourceSecret: BOOTSTRAP_ADMIN_SOURCE_SECRET,
|
|
cloudApiDbSecret: externalPostgres?.cloudApi.secretName ?? postgresStore?.cloudApi?.secretName ?? `hwlab-cloud-api-${input.lane}-db`,
|
|
cloudApiDbKey: externalPostgres?.cloudApi.secretKey ?? postgresStore?.cloudApi?.secretKey ?? CLOUD_API_DB_KEY,
|
|
cloudApiDbName,
|
|
cloudApiDbUser,
|
|
cloudApiDbHost: platformDb ? platformPostgresHost : legacyPostgresHost,
|
|
cloudApiDeployment: "hwlab-cloud-api",
|
|
obsoleteHwpodDbSecret: `hwpod-${input.lane}-db`,
|
|
obsoleteHwpodDbName: `hwpod_${input.lane}`,
|
|
obsoleteHwpodDbUser: `hwpod_${input.lane}_app`,
|
|
codeAgentProviderSecret: runtimeLaneSpec?.codeAgentProvider?.secretName ?? `${namespace}-code-agent-provider`,
|
|
codeAgentProviderSourceNamespace: CODE_AGENT_PROVIDER_SOURCE_NAMESPACE,
|
|
codeAgentProviderSourceSecret: CODE_AGENT_PROVIDER_SOURCE_SECRET,
|
|
...(runtimeLaneSpec?.codeAgentProvider?.sourceRef === undefined ? {} : { codeAgentProviderSourceRef: runtimeLaneSpec.codeAgentProvider.sourceRef }),
|
|
...(runtimeLaneSpec?.codeAgentProvider?.openaiSourceKey === undefined ? {} : { codeAgentProviderOpenaiSourceKey: runtimeLaneSpec.codeAgentProvider.openaiSourceKey }),
|
|
...(runtimeLaneSpec?.codeAgentProvider?.opencodeSourceKey === undefined ? {} : { codeAgentProviderOpencodeSourceKey: runtimeLaneSpec.codeAgentProvider.opencodeSourceKey }),
|
|
fieldManager: `unidesk-hwlab-node-${input.lane}-secret`,
|
|
};
|
|
}
|
|
|
|
export function runNodeSecret(options: NodeSecretOptions): Record<string, unknown> {
|
|
const spec = runtimeSecretSpec(options);
|
|
if (options.preset === "obsolete-secret-cleanup") return runObsoleteSecretCleanup(options, spec);
|
|
if (spec.externalPostgres !== undefined && options.action === "ensure" && (options.preset === "cloud-api-db" || options.preset === "openfga")) {
|
|
return runExternalPostgresSecretEnsure(options, spec);
|
|
}
|
|
const bootstrapAdminMaterial = options.preset === "bootstrap-admin" ? readBootstrapAdminSecretMaterial(spec) : null;
|
|
const codeAgentProviderMaterial = options.preset === "code-agent-provider" && spec.codeAgentProviderSourceRef !== undefined
|
|
? readCodeAgentProviderSecretMaterial(spec)
|
|
: null;
|
|
const input = options.preset === "master-server-admin-api-key" && options.action === "ensure" && !options.dryRun
|
|
? readMasterAdminApiKey(spec).key
|
|
: options.preset === "bootstrap-admin" && options.action === "ensure" && !options.dryRun && bootstrapAdminMaterial?.ok === true
|
|
? bootstrapAdminMaterial.passwordHash ?? ""
|
|
: "";
|
|
const script = options.preset === "openfga"
|
|
? spec.platformDb ? platformDbSecretStatusScript(options, spec) : openFgaSecretScript(options, spec)
|
|
: options.preset === "master-server-admin-api-key"
|
|
? masterAdminApiKeySecretScript(options, spec)
|
|
: options.preset === "bootstrap-admin"
|
|
? bootstrapAdminSecretScript(options, spec, bootstrapAdminMaterial)
|
|
: options.preset === "cloud-api-db"
|
|
? spec.platformDb ? platformDbSecretStatusScript(options, spec) : cloudApiDbSecretScript(options, spec)
|
|
: options.preset === "owned-postgres-cleanup"
|
|
? ownedPostgresCleanupScript(options, spec)
|
|
: codeAgentProviderSecretScript(options, spec, codeAgentProviderMaterial);
|
|
const result = runTransScript(options.node, script, input, options.timeoutSeconds);
|
|
const status = secretStatusFromText(statusText(result), result.exitCode === 0, result.exitCode, result.stderr, spec);
|
|
const dryRunOk = options.action === "ensure" && options.dryRun && result.exitCode === 0;
|
|
const cleanupDryRunOk = options.action === "cleanup-owned-postgres" && options.dryRun && result.exitCode === 0;
|
|
const obsoleteCleanupDryRunOk = options.action === "cleanup-obsolete" && options.dryRun && status.ok === true;
|
|
const ok = dryRunOk || cleanupDryRunOk || obsoleteCleanupDryRunOk ? true : status.ok === true;
|
|
return {
|
|
ok,
|
|
command: `hwlab nodes secret ${options.action}`,
|
|
node: options.node,
|
|
lane: options.lane,
|
|
namespace: spec.namespace,
|
|
secret: options.name,
|
|
key: options.key ?? null,
|
|
preset: options.preset,
|
|
mode: options.action === "status" ? "status" : options.dryRun ? "dry-run" : options.action === "cleanup-owned-postgres" || options.action === "cleanup-obsolete" ? "confirmed-delete" : "confirmed-ensure",
|
|
status,
|
|
mutation: status.mutation === true,
|
|
result: compactCommandResult(result),
|
|
valuesRedacted: true,
|
|
next: ok && options.action === "status" ? undefined : nextSecretCommand(options, spec),
|
|
};
|
|
}
|
|
|
|
export function readCodeAgentProviderSecretMaterial(spec: RuntimeSecretSpec): CodeAgentProviderSecretMaterial {
|
|
const sourceRef = spec.codeAgentProviderSourceRef ?? null;
|
|
if (sourceRef === null) {
|
|
return {
|
|
ok: false,
|
|
sourceRef,
|
|
sourcePath: null,
|
|
sourcePresent: false,
|
|
openaiSourceKey: spec.codeAgentProviderOpenaiSourceKey ?? null,
|
|
opencodeSourceKey: spec.codeAgentProviderOpencodeSourceKey ?? null,
|
|
openaiValue: null,
|
|
opencodeValue: null,
|
|
openaiFingerprint: null,
|
|
opencodeFingerprint: null,
|
|
error: "code-agent-provider-source-ref-missing",
|
|
};
|
|
}
|
|
const paths = localSecretSourcePaths(sourceRef);
|
|
const sourcePath = paths.find((candidate) => existsSync(candidate)) ?? paths[0] ?? null;
|
|
const openaiSourceKey = spec.codeAgentProviderOpenaiSourceKey ?? null;
|
|
const opencodeSourceKey = spec.codeAgentProviderOpencodeSourceKey ?? null;
|
|
if (sourcePath === null) {
|
|
return {
|
|
ok: false,
|
|
sourceRef,
|
|
sourcePath,
|
|
sourcePresent: false,
|
|
openaiSourceKey,
|
|
opencodeSourceKey,
|
|
openaiValue: null,
|
|
opencodeValue: null,
|
|
openaiFingerprint: null,
|
|
opencodeFingerprint: null,
|
|
error: "code-agent-provider-source-path-unresolved",
|
|
};
|
|
}
|
|
if (!existsSync(sourcePath)) {
|
|
return {
|
|
ok: false,
|
|
sourceRef,
|
|
sourcePath: displayRepoPath(sourcePath),
|
|
sourcePresent: false,
|
|
openaiSourceKey,
|
|
opencodeSourceKey,
|
|
openaiValue: null,
|
|
opencodeValue: null,
|
|
openaiFingerprint: null,
|
|
opencodeFingerprint: null,
|
|
error: "code-agent-provider-source-missing",
|
|
};
|
|
}
|
|
const values = parseEnvFile(readFileSync(sourcePath, "utf8"));
|
|
const openaiValue = openaiSourceKey === null ? null : values[openaiSourceKey] ?? null;
|
|
const opencodeValue = opencodeSourceKey === null ? null : values[opencodeSourceKey] ?? null;
|
|
const ok = (openaiValue !== null && openaiValue.length > 0) || (opencodeValue !== null && opencodeValue.length > 0);
|
|
return {
|
|
ok,
|
|
sourceRef,
|
|
sourcePath: displayRepoPath(sourcePath),
|
|
sourcePresent: true,
|
|
openaiSourceKey,
|
|
opencodeSourceKey,
|
|
openaiValue,
|
|
opencodeValue,
|
|
openaiFingerprint: openaiValue === null || openaiValue.length === 0 ? null : shortSecretFingerprint(openaiValue),
|
|
opencodeFingerprint: opencodeValue === null || opencodeValue.length === 0 ? null : shortSecretFingerprint(opencodeValue),
|
|
error: ok ? null : "code-agent-provider-source-key-missing",
|
|
};
|
|
}
|
|
|
|
export function nextSecretCommand(options: NodeSecretOptions, spec: RuntimeSecretSpec): Record<string, string> {
|
|
if (options.action === "cleanup-owned-postgres") {
|
|
return { ensure: `bun scripts/cli.ts hwlab nodes secret cleanup-owned-postgres --node ${options.node} --lane ${options.lane} --confirm` };
|
|
}
|
|
if (options.action === "cleanup-obsolete") {
|
|
return { cleanup: `bun scripts/cli.ts hwlab nodes secret cleanup-obsolete --node ${options.node} --lane ${options.lane} --name ${options.name} --confirm` };
|
|
}
|
|
if (spec.externalPostgres !== undefined && (options.preset === "cloud-api-db" || options.preset === "openfga")) {
|
|
return { ensure: `bun scripts/cli.ts hwlab nodes secret ensure --node ${options.node} --lane ${options.lane} --name ${options.name}${options.key ? ` --key ${options.key}` : ""} --confirm` };
|
|
}
|
|
if (spec.platformDb && (options.preset === "cloud-api-db" || options.preset === "openfga")) {
|
|
return {
|
|
status: `bun scripts/cli.ts hwlab nodes secret status --node ${options.node} --lane ${options.lane} --name ${options.name}${options.key ? ` --key ${options.key}` : ""}`,
|
|
controlPlaneStatus: `bun scripts/cli.ts hwlab nodes control-plane status --node ${options.node} --lane ${options.lane}`,
|
|
};
|
|
}
|
|
return { ensure: `bun scripts/cli.ts hwlab nodes secret ensure --node ${options.node} --lane ${options.lane} --name ${options.name}${options.key ? ` --key ${options.key}` : ""} --confirm` };
|
|
}
|
|
|
|
export function runExternalPostgresSecretEnsure(options: NodeSecretOptions, spec: RuntimeSecretSpec): Record<string, unknown> {
|
|
const runtimeLaneSpec = spec.runtimeLaneSpec;
|
|
if (runtimeLaneSpec === undefined) {
|
|
return {
|
|
ok: false,
|
|
command: `hwlab nodes secret ${options.action}`,
|
|
node: options.node,
|
|
lane: options.lane,
|
|
namespace: spec.namespace,
|
|
secret: options.name,
|
|
key: options.key ?? null,
|
|
preset: options.preset,
|
|
mode: options.dryRun ? "dry-run" : "confirmed-ensure",
|
|
mutation: false,
|
|
degradedReason: "external-postgres-runtime-lane-spec-missing",
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
const sync = syncNodeExternalPostgresSecrets(runtimeLaneSpec, options.dryRun, options.timeoutSeconds);
|
|
const shouldReadStatus = sync !== null && sync.ok === true;
|
|
const statusResult = shouldReadStatus ? runTransScript(options.node, platformDbSecretStatusScript({ ...options, action: "status", dryRun: true }, spec), "", options.timeoutSeconds) : null;
|
|
const status = statusResult === null
|
|
? null
|
|
: secretStatusFromText(statusText(statusResult), statusResult.exitCode === 0, statusResult.exitCode, statusResult.stderr, spec);
|
|
const ok = sync !== null && sync.ok === true && (options.dryRun || status?.ok === true);
|
|
return {
|
|
ok,
|
|
command: `hwlab nodes secret ${options.action}`,
|
|
node: options.node,
|
|
lane: options.lane,
|
|
namespace: spec.namespace,
|
|
secret: options.name,
|
|
key: options.key ?? null,
|
|
preset: options.preset,
|
|
mode: options.dryRun ? "dry-run" : "confirmed-ensure",
|
|
status: status ?? sync,
|
|
externalPostgresSecretSync: sync,
|
|
mutation: sync?.mutation === true,
|
|
result: statusResult === null ? null : compactCommandResult(statusResult),
|
|
valuesRedacted: true,
|
|
next: ok ? undefined : nextSecretCommand(options, spec),
|
|
};
|
|
}
|
|
|
|
export function publicExposureSummary(exposure: HwlabRuntimePublicExposureSpec): Record<string, unknown> {
|
|
return {
|
|
mode: exposure.mode,
|
|
publicBaseUrl: exposure.publicBaseUrl,
|
|
hostname: exposure.hostname,
|
|
expectedA: exposure.expectedA,
|
|
frpc: {
|
|
serverAddr: exposure.serverAddr,
|
|
serverPort: exposure.serverPort,
|
|
tokenSourceRef: exposure.tokenSourceRef,
|
|
tokenSourceKey: exposure.tokenSourceKey,
|
|
secretName: exposure.secretName,
|
|
secretKey: exposure.secretKey,
|
|
tokenKey: exposure.tokenKey,
|
|
webProxy: exposure.webProxy,
|
|
apiProxy: exposure.apiProxy,
|
|
extraProxies: exposure.extraProxies,
|
|
},
|
|
caddy: {
|
|
route: exposure.caddyRoute,
|
|
configPath: exposure.caddyConfigPath,
|
|
serviceName: exposure.caddyServiceName,
|
|
tls: exposure.caddyTls,
|
|
responseHeaderTimeoutSeconds: exposure.responseHeaderTimeoutSeconds,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function runNodePublicExposure(options: NodePublicExposureOptions): Record<string, unknown> {
|
|
const exposure = options.spec.publicExposure;
|
|
if (exposure === null || !exposure.enabled) {
|
|
return {
|
|
ok: false,
|
|
command: "hwlab nodes control-plane public-exposure",
|
|
node: options.node,
|
|
lane: options.lane,
|
|
configPath: hwlabRuntimeLaneConfigPath(),
|
|
error: "publicExposure is not configured for this node/lane target",
|
|
mutation: false,
|
|
};
|
|
}
|
|
const source = readPublicExposureTokenSource(exposure);
|
|
if (!source.ok) {
|
|
return {
|
|
ok: false,
|
|
command: "hwlab nodes control-plane public-exposure",
|
|
node: options.node,
|
|
lane: options.lane,
|
|
mode: options.dryRun ? "dry-run" : "confirmed",
|
|
configPath: hwlabRuntimeLaneConfigPath(),
|
|
publicExposure: publicExposureSummary(exposure),
|
|
source,
|
|
mutation: false,
|
|
valuesRedacted: true,
|
|
next: { fixSecretSource: `create .state/secrets/${exposure.tokenSourceRef} with ${exposure.tokenSourceKey}=<redacted>` },
|
|
};
|
|
}
|
|
const caddyResult = runTransHostScript(exposure.caddyRoute, publicExposureCaddyScript(options, exposure), "", options.timeoutSeconds);
|
|
const caddyFields = keyValueLinesFromText(statusText(caddyResult));
|
|
const secretStatus = options.dryRun
|
|
? publicExposureSecretDryRunStatus(options, exposure, source.value?.length ?? 0)
|
|
: publicExposureSecretApplyStatus(options, exposure, source.value ?? "");
|
|
const caddyStatus = publicExposureCaddyStatus(caddyFields, caddyResult);
|
|
const ok = secretStatus.ok === true && caddyStatus.ok === true;
|
|
return {
|
|
ok,
|
|
command: "hwlab nodes control-plane public-exposure",
|
|
node: options.node,
|
|
lane: options.lane,
|
|
mode: options.dryRun ? "dry-run" : "confirmed",
|
|
mutation: !options.dryRun && (secretStatus.mutation === true || caddyFields.mutation === "true"),
|
|
configPath: hwlabRuntimeLaneConfigPath(),
|
|
publicExposure: publicExposureSummary(exposure),
|
|
source: {
|
|
ok: source.ok,
|
|
path: source.path,
|
|
key: exposure.tokenSourceKey,
|
|
bytes: source.value?.length ?? 0,
|
|
fingerprint: source.fingerprint,
|
|
valueRedacted: true,
|
|
},
|
|
secret: secretStatus,
|
|
caddy: caddyStatus,
|
|
valuesRedacted: true,
|
|
next: ok
|
|
? {
|
|
triggerCurrent: `bun scripts/cli.ts hwlab nodes control-plane trigger-current --node ${options.node} --lane ${options.lane} --confirm`,
|
|
status: `bun scripts/cli.ts hwlab nodes control-plane status --node ${options.node} --lane ${options.lane}`,
|
|
}
|
|
: { retry: `bun scripts/cli.ts hwlab nodes control-plane public-exposure --node ${options.node} --lane ${options.lane} --confirm` },
|
|
};
|
|
}
|
|
|
|
function publicExposureSecretDryRunStatus(options: NodePublicExposureOptions, exposure: HwlabRuntimePublicExposureSpec, tokenBytes: number): Record<string, unknown> {
|
|
return {
|
|
ok: true,
|
|
dryRun: true,
|
|
mutation: false,
|
|
planned: true,
|
|
namespace: options.spec.runtimeNamespace,
|
|
secret: exposure.secretName,
|
|
deployment: `${options.spec.runtimeNamespace}-frpc`,
|
|
beforeExists: null,
|
|
afterExists: null,
|
|
tokenBytes,
|
|
applyExitCode: null,
|
|
restartMode: null,
|
|
strategyPatchExitCode: null,
|
|
rolloutRestartExitCode: null,
|
|
scaleDownExitCode: null,
|
|
scaleDownWaitExitCode: null,
|
|
scaleDownWaitPodCount: null,
|
|
scaleUpExitCode: null,
|
|
rolloutStatusExitCode: null,
|
|
readyReplicas: null,
|
|
availableReplicas: null,
|
|
desiredReplicas: null,
|
|
strategyPatchErrorPreview: null,
|
|
restartErrorPreview: null,
|
|
scaleDownErrorPreview: null,
|
|
scaleUpErrorPreview: null,
|
|
readyErrorPreview: null,
|
|
exitCode: 0,
|
|
stderr: "",
|
|
valuesRedacted: true,
|
|
};
|
|
}
|
|
|
|
function publicExposureSecretApplyStatus(options: NodePublicExposureOptions, exposure: HwlabRuntimePublicExposureSpec, token: string): Record<string, unknown> {
|
|
const secretApplyResult = runTransScript(options.node, publicExposureSecretScript(options, exposure), token, options.timeoutSeconds);
|
|
let secretFields = keyValueLinesFromText(statusText(secretApplyResult));
|
|
let secretResult = secretApplyResult;
|
|
if (secretApplyResult.exitCode === 0 && secretFields.afterSecretExists === "yes") {
|
|
const restartResult = runPublicExposureFrpcRecreate(options);
|
|
secretFields = { ...secretFields, ...keyValueLinesFromText(statusText(restartResult)) };
|
|
secretResult = combinePublicExposureCommandResults(secretApplyResult, restartResult);
|
|
}
|
|
return publicExposureSecretStatus(secretFields, secretResult);
|
|
}
|
|
|
|
export function readPublicExposureTokenSource(exposure: HwlabRuntimePublicExposureSpec): { ok: boolean; path: string; checkedPaths: string[]; key: string; value: string | null; fingerprint: string | null; error?: string } {
|
|
const checkedPaths = publicExposureTokenSourcePaths(exposure);
|
|
const path = checkedPaths.find((candidate) => existsSync(candidate)) ?? checkedPaths[0] ?? join(repoRoot, ".state", "secrets", exposure.tokenSourceRef);
|
|
if (!existsSync(path)) return { ok: false, path, checkedPaths, key: exposure.tokenSourceKey, value: null, fingerprint: null, error: "secret-source-missing" };
|
|
const values = parseEnvFile(readFileSync(path, "utf8"));
|
|
const value = values[exposure.tokenSourceKey];
|
|
if (value === undefined || value.length === 0) return { ok: false, path, checkedPaths, key: exposure.tokenSourceKey, value: null, fingerprint: null, error: "secret-key-missing" };
|
|
return {
|
|
ok: true,
|
|
path,
|
|
checkedPaths,
|
|
key: exposure.tokenSourceKey,
|
|
value,
|
|
fingerprint: `sha256:${createHash("sha256").update(value).digest("hex").slice(0, 16)}`,
|
|
};
|
|
}
|
|
|
|
export function publicExposureTokenSourcePaths(exposure: HwlabRuntimePublicExposureSpec): string[] {
|
|
const paths = [join(repoRoot, ".state", "secrets", exposure.tokenSourceRef)];
|
|
const marker = "/.worktree/";
|
|
const index = repoRoot.indexOf(marker);
|
|
if (index >= 0) paths.push(join(repoRoot.slice(0, index), ".state", "secrets", exposure.tokenSourceRef));
|
|
return [...new Set(paths)];
|
|
}
|
|
|
|
export function publicExposureSecretStatus(fields: Record<string, string>, result: CommandResult): Record<string, unknown> {
|
|
const dryRun = fields.dryRun === "true";
|
|
return {
|
|
ok: result.exitCode === 0 && (dryRun || (
|
|
fields.afterSecretExists === "yes"
|
|
&& fields.strategyPatchExitCode === "0"
|
|
&& fields.rolloutRestartExitCode === "0"
|
|
&& fields.rolloutStatusExitCode === "0"
|
|
)),
|
|
dryRun,
|
|
mutation: fields.mutation === "true",
|
|
namespace: fields.namespace || null,
|
|
secret: fields.secret || null,
|
|
deployment: fields.deployment || null,
|
|
beforeExists: fields.beforeSecretExists === "yes",
|
|
afterExists: fields.afterSecretExists === "yes",
|
|
tokenBytes: numericField(fields.tokenBytes),
|
|
applyExitCode: numericField(fields.applyExitCode),
|
|
restartMode: fields.restartMode || null,
|
|
strategyPatchExitCode: numericField(fields.strategyPatchExitCode),
|
|
rolloutRestartExitCode: numericField(fields.rolloutRestartExitCode),
|
|
scaleDownExitCode: numericField(fields.scaleDownExitCode),
|
|
scaleDownWaitExitCode: numericField(fields.scaleDownWaitExitCode),
|
|
scaleDownWaitPodCount: numericField(fields.scaleDownWaitPodCount),
|
|
scaleUpExitCode: numericField(fields.scaleUpExitCode),
|
|
rolloutStatusExitCode: numericField(fields.rolloutStatusExitCode),
|
|
readyReplicas: numericField(fields.readyReplicas),
|
|
availableReplicas: numericField(fields.availableReplicas),
|
|
desiredReplicas: numericField(fields.desiredReplicas),
|
|
strategyPatchErrorPreview: fields.strategyPatchErrorPreview || null,
|
|
restartErrorPreview: fields.restartErrorPreview || null,
|
|
scaleDownErrorPreview: fields.scaleDownErrorPreview || null,
|
|
scaleUpErrorPreview: fields.scaleUpErrorPreview || null,
|
|
readyErrorPreview: fields.readyErrorPreview || null,
|
|
exitCode: result.exitCode,
|
|
stderr: result.exitCode === 0 ? "" : result.stderr.trim().slice(0, 2000),
|
|
};
|
|
}
|
|
|
|
export function publicExposureCaddyStatus(fields: Record<string, string>, result: CommandResult): Record<string, unknown> {
|
|
const dryRun = fields.dryRun === "true";
|
|
return {
|
|
ok: result.exitCode === 0
|
|
&& fields.afterBlockPresent === "yes"
|
|
&& fields.validateExitCode === "0"
|
|
&& (dryRun || (
|
|
fields.active === "active"
|
|
&& fields.localWebStatus === "200"
|
|
&& fields.localApiStatus === "200"
|
|
)),
|
|
dryRun,
|
|
mutation: fields.mutation === "true",
|
|
hostname: fields.hostname || null,
|
|
configPath: fields.configPath || null,
|
|
beforeBlockPresent: fields.beforeBlockPresent === "yes",
|
|
afterBlockPresent: fields.afterBlockPresent === "yes",
|
|
staleBlocksRemoved: numericField(fields.staleBlocksRemoved),
|
|
pythonExitCode: numericField(fields.pythonExitCode),
|
|
validateMode: fields.validateMode || null,
|
|
validateExitCode: numericField(fields.validateExitCode),
|
|
reloadExitCode: numericField(fields.reloadExitCode),
|
|
active: fields.active || null,
|
|
localWebStatus: fields.localWebStatus || null,
|
|
localApiStatus: fields.localApiStatus || null,
|
|
validateErrorPreview: fields.validateErrorPreview || null,
|
|
localWebErrorPreview: fields.localWebErrorPreview || null,
|
|
localApiErrorPreview: fields.localApiErrorPreview || null,
|
|
exitCode: result.exitCode,
|
|
stderr: result.exitCode === 0 ? "" : result.stderr.trim().slice(0, 2000),
|
|
};
|
|
}
|
|
|
|
export function publicExposureSecretScript(options: NodePublicExposureOptions, exposure: HwlabRuntimePublicExposureSpec): string {
|
|
const deployment = `${options.spec.runtimeNamespace}-frpc`;
|
|
return [
|
|
"set +e",
|
|
`namespace=${shellQuote(options.spec.runtimeNamespace)}`,
|
|
`secret=${shellQuote(exposure.secretName)}`,
|
|
`deployment=${shellQuote(deployment)}`,
|
|
`token_key=${shellQuote(exposure.tokenKey)}`,
|
|
`dry_run=${shellQuote(options.dryRun ? "true" : "false")}`,
|
|
"token=$(cat)",
|
|
"before_secret_exists=no",
|
|
"kubectl -n \"$namespace\" get secret \"$secret\" >/dev/null 2>&1 && before_secret_exists=yes",
|
|
"token_bytes=$(printf '%s' \"$token\" | wc -c | tr -d ' ')",
|
|
"apply_exit=",
|
|
"restart_mode=recreate-rollout-restart",
|
|
"strategy_patch_exit=",
|
|
"rollout_restart_exit=",
|
|
"scale_down_exit=",
|
|
"scale_down_wait_exit=",
|
|
"scale_down_wait_pod_count=",
|
|
"scale_up_exit=",
|
|
"rollout_status_exit=",
|
|
"ready_replicas=",
|
|
"available_replicas=",
|
|
"desired_replicas=",
|
|
"mutation=false",
|
|
"if [ \"$dry_run\" = false ]; then",
|
|
" tmp=$(mktemp /tmp/hwlab-public-frpc-secret.XXXXXX.yaml)",
|
|
" token_b64=$(printf '%s' \"$token\" | base64 | tr -d '\\n')",
|
|
" cat >\"$tmp\" <<EOF",
|
|
"apiVersion: v1",
|
|
"kind: Secret",
|
|
"metadata:",
|
|
" name: $secret",
|
|
" namespace: $namespace",
|
|
" labels:",
|
|
" app.kubernetes.io/part-of: hwlab",
|
|
" app.kubernetes.io/managed-by: unidesk",
|
|
"type: Opaque",
|
|
"data:",
|
|
" $token_key: $token_b64",
|
|
"EOF",
|
|
" kubectl apply --server-side --force-conflicts --field-manager=unidesk-hwlab-node-public-exposure -f \"$tmp\" >/tmp/hwlab-public-frpc-secret.apply.out 2>/tmp/hwlab-public-frpc-secret.apply.err",
|
|
" apply_exit=$?",
|
|
" rm -f \"$tmp\"",
|
|
" if [ \"$apply_exit\" = 0 ]; then mutation=true; fi",
|
|
"fi",
|
|
"after_secret_exists=no",
|
|
"kubectl -n \"$namespace\" get secret \"$secret\" >/dev/null 2>&1 && after_secret_exists=yes",
|
|
"printf 'namespace\\t%s\\n' \"$namespace\"",
|
|
"printf 'secret\\t%s\\n' \"$secret\"",
|
|
"printf 'deployment\\t%s\\n' \"$deployment\"",
|
|
"printf 'dryRun\\t%s\\n' \"$dry_run\"",
|
|
"printf 'mutation\\t%s\\n' \"$mutation\"",
|
|
"printf 'beforeSecretExists\\t%s\\n' \"$before_secret_exists\"",
|
|
"printf 'afterSecretExists\\t%s\\n' \"$after_secret_exists\"",
|
|
"printf 'tokenBytes\\t%s\\n' \"$token_bytes\"",
|
|
"printf 'applyExitCode\\t%s\\n' \"$apply_exit\"",
|
|
"printf 'restartMode\\t%s\\n' \"$restart_mode\"",
|
|
"printf 'strategyPatchExitCode\\t%s\\n' \"$strategy_patch_exit\"",
|
|
"printf 'rolloutRestartExitCode\\t%s\\n' \"$rollout_restart_exit\"",
|
|
"printf 'scaleDownExitCode\\t%s\\n' \"$scale_down_exit\"",
|
|
"printf 'scaleDownWaitExitCode\\t%s\\n' \"$scale_down_wait_exit\"",
|
|
"printf 'scaleDownWaitPodCount\\t%s\\n' \"$scale_down_wait_pod_count\"",
|
|
"printf 'scaleUpExitCode\\t%s\\n' \"$scale_up_exit\"",
|
|
"printf 'rolloutStatusExitCode\\t%s\\n' \"$rollout_status_exit\"",
|
|
"printf 'readyReplicas\\t%s\\n' \"$ready_replicas\"",
|
|
"printf 'availableReplicas\\t%s\\n' \"$available_replicas\"",
|
|
"printf 'desiredReplicas\\t%s\\n' \"$desired_replicas\"",
|
|
"if [ \"$dry_run\" = true ]; then exit 0; fi",
|
|
"[ \"$after_secret_exists\" = yes ] && [ \"$apply_exit\" = 0 ]",
|
|
].join("\n");
|
|
}
|
|
|
|
export function runPublicExposureFrpcRecreate(options: NodePublicExposureOptions): CommandResult {
|
|
const namespace = options.spec.runtimeNamespace;
|
|
const deployment = `${namespace}-frpc`;
|
|
const fields: Record<string, string> = {
|
|
deployment,
|
|
restartMode: "recreate-rollout-restart",
|
|
strategyPatchExitCode: "",
|
|
rolloutRestartExitCode: "",
|
|
scaleDownExitCode: "",
|
|
scaleDownWaitExitCode: "",
|
|
scaleDownWaitPodCount: "",
|
|
scaleUpExitCode: "",
|
|
rolloutStatusExitCode: "",
|
|
readyReplicas: "",
|
|
availableReplicas: "",
|
|
desiredReplicas: "",
|
|
strategyPatchErrorPreview: "",
|
|
restartErrorPreview: "",
|
|
scaleDownErrorPreview: "",
|
|
scaleUpErrorPreview: "",
|
|
readyErrorPreview: "",
|
|
};
|
|
const stdoutParts: string[] = [];
|
|
const stderrParts: string[] = [];
|
|
const addResult = (result: CommandResult): void => {
|
|
if (result.stdout.trim()) stdoutParts.push(result.stdout.trim());
|
|
if (result.stderr.trim()) stderrParts.push(result.stderr.trim());
|
|
};
|
|
const shortTimeout = Math.min(Math.max(20, options.timeoutSeconds), 55);
|
|
const strategyPatch = runTransScript(options.node, [
|
|
"set +e",
|
|
`namespace=${shellQuote(namespace)}`,
|
|
`deployment=${shellQuote(deployment)}`,
|
|
"kubectl -n \"$namespace\" patch deploy/\"$deployment\" --type=merge -p '{\"spec\":{\"strategy\":{\"type\":\"Recreate\",\"rollingUpdate\":null}}}' >/tmp/hwlab-public-frpc-strategy-patch.out 2>/tmp/hwlab-public-frpc-strategy-patch.err",
|
|
"code=$?",
|
|
"printf 'strategyPatchExitCode\\t%s\\n' \"$code\"",
|
|
"printf 'strategyPatchErrorPreview\\t%s\\n' \"$([ -f /tmp/hwlab-public-frpc-strategy-patch.err ] && tr '\\n' ';' </tmp/hwlab-public-frpc-strategy-patch.err | cut -c1-1000 || true)\"",
|
|
"exit \"$code\"",
|
|
].join("\n"), "", shortTimeout);
|
|
addResult(strategyPatch);
|
|
Object.assign(fields, keyValueLinesFromText(statusText(strategyPatch)));
|
|
|
|
if (strategyPatch.exitCode === 0) {
|
|
const restart = runTransScript(options.node, [
|
|
"set +e",
|
|
`namespace=${shellQuote(namespace)}`,
|
|
`deployment=${shellQuote(deployment)}`,
|
|
"kubectl -n \"$namespace\" rollout restart deploy/\"$deployment\" >/tmp/hwlab-public-frpc-restart.out 2>/tmp/hwlab-public-frpc-restart.err",
|
|
"code=$?",
|
|
"printf 'rolloutRestartExitCode\\t%s\\n' \"$code\"",
|
|
"printf 'restartErrorPreview\\t%s\\n' \"$([ -f /tmp/hwlab-public-frpc-restart.err ] && tr '\\n' ';' </tmp/hwlab-public-frpc-restart.err | cut -c1-1000 || true)\"",
|
|
"exit \"$code\"",
|
|
].join("\n"), "", shortTimeout);
|
|
addResult(restart);
|
|
Object.assign(fields, keyValueLinesFromText(statusText(restart)));
|
|
}
|
|
|
|
if (fields.rolloutRestartExitCode === "0") {
|
|
const deadline = Date.now() + 55_000;
|
|
while (Date.now() <= deadline) {
|
|
const probe = runTransScript(options.node, [
|
|
"set +e",
|
|
`namespace=${shellQuote(namespace)}`,
|
|
`deployment=${shellQuote(deployment)}`,
|
|
"kubectl -n \"$namespace\" get deploy \"$deployment\" -o jsonpath='{.spec.replicas}{\"\\t\"}{.status.readyReplicas}{\"\\t\"}{.status.availableReplicas}' >/tmp/hwlab-public-frpc-ready.out 2>/tmp/hwlab-public-frpc-ready.err",
|
|
"code=$?",
|
|
"values=$(cat /tmp/hwlab-public-frpc-ready.out 2>/dev/null || true)",
|
|
"desired=$(printf '%s' \"$values\" | cut -f1)",
|
|
"ready=$(printf '%s' \"$values\" | cut -f2)",
|
|
"available=$(printf '%s' \"$values\" | cut -f3)",
|
|
"printf 'desiredReplicas\\t%s\\n' \"$desired\"",
|
|
"printf 'readyReplicas\\t%s\\n' \"$ready\"",
|
|
"printf 'availableReplicas\\t%s\\n' \"$available\"",
|
|
"printf 'readyErrorPreview\\t%s\\n' \"$([ -f /tmp/hwlab-public-frpc-ready.err ] && tr '\\n' ';' </tmp/hwlab-public-frpc-ready.err | cut -c1-1000 || true)\"",
|
|
"[ \"$code\" = 0 ] && [ \"$desired\" = 1 ] && [ \"$ready\" = 1 ] && [ \"$available\" = 1 ]",
|
|
].join("\n"), "", shortTimeout);
|
|
addResult(probe);
|
|
Object.assign(fields, keyValueLinesFromText(statusText(probe)));
|
|
if (probe.exitCode === 0) {
|
|
fields.rolloutStatusExitCode = "0";
|
|
break;
|
|
}
|
|
runCommand(["sleep", "2"], repoRoot, { timeoutMs: 3_000 });
|
|
}
|
|
}
|
|
if (fields.rolloutStatusExitCode === "") fields.rolloutStatusExitCode = "1";
|
|
|
|
const ok = fields.strategyPatchExitCode === "0"
|
|
&& fields.rolloutRestartExitCode === "0"
|
|
&& fields.rolloutStatusExitCode === "0";
|
|
const stdout = Object.entries(fields).map(([key, value]) => `${key}\t${value}`).join("\n") + "\n";
|
|
return {
|
|
command: [transPath(), `${options.node}:k3s`, "sh", "--", "<public-exposure-frpc-recreate>"],
|
|
cwd: repoRoot,
|
|
exitCode: ok ? 0 : 1,
|
|
stdout: [stdout.trim(), ...stdoutParts].filter(Boolean).join("\n") + "\n",
|
|
stderr: stderrParts.join("\n"),
|
|
signal: null,
|
|
timedOut: false,
|
|
};
|
|
}
|
|
|
|
export function combinePublicExposureCommandResults(first: CommandResult, second: CommandResult): CommandResult {
|
|
return {
|
|
command: [...first.command, "&&", ...second.command],
|
|
cwd: repoRoot,
|
|
exitCode: first.exitCode === 0 && second.exitCode === 0 ? 0 : second.exitCode ?? first.exitCode,
|
|
stdout: [first.stdout.trim(), second.stdout.trim()].filter(Boolean).join("\n") + "\n",
|
|
stderr: [first.stderr.trim(), second.stderr.trim()].filter(Boolean).join("\n"),
|
|
signal: second.signal ?? first.signal,
|
|
timedOut: first.timedOut || second.timedOut,
|
|
};
|
|
}
|
|
|
|
export function publicExposureCaddyScript(options: NodePublicExposureOptions, exposure: HwlabRuntimePublicExposureSpec): string {
|
|
const blockB64 = Buffer.from(publicExposureCaddyBlock(exposure), "utf8").toString("base64");
|
|
const marker = `unidesk managed ${exposure.hostname}`;
|
|
return [
|
|
"set +e",
|
|
`dry_run=${shellQuote(options.dryRun ? "true" : "false")}`,
|
|
`hostname=${shellQuote(exposure.hostname)}`,
|
|
`config_path=${shellQuote(exposure.caddyConfigPath)}`,
|
|
`service=${shellQuote(exposure.caddyServiceName)}`,
|
|
`marker=${shellQuote(marker)}`,
|
|
`block_b64=${shellQuote(blockB64)}`,
|
|
`expected_a=${shellQuote(exposure.expectedA)}`,
|
|
`web_port=${shellQuote(String(exposure.webProxy.remotePort))}`,
|
|
`api_port=${shellQuote(String(exposure.apiProxy.remotePort))}`,
|
|
"rm -f /tmp/hwlab-public-caddy-validate.out /tmp/hwlab-public-caddy-validate.err /tmp/hwlab-public-caddy-python.err /tmp/hwlab-public-caddy-web.err /tmp/hwlab-public-caddy-api.err",
|
|
"tmp=$(mktemp -d)",
|
|
"block=\"$tmp/block\"",
|
|
"next=\"$tmp/Caddyfile\"",
|
|
"printf '%s' \"$block_b64\" | base64 -d >\"$block\"",
|
|
"before_present=no",
|
|
"if [ -f \"$config_path\" ] && grep -Fq \"# BEGIN $marker\" \"$config_path\"; then before_present=yes; fi",
|
|
"if [ -f \"$config_path\" ]; then cp \"$config_path\" \"$next\"; else : >\"$next\"; fi",
|
|
"stale_blocks_removed=$(python3 - \"$next\" \"$block\" \"$marker\" \"$web_port\" \"$api_port\" 2>/tmp/hwlab-public-caddy-python.err <<'PY'",
|
|
"import pathlib, re, sys",
|
|
"config = pathlib.Path(sys.argv[1])",
|
|
"block = pathlib.Path(sys.argv[2]).read_text(encoding='utf-8')",
|
|
"marker = sys.argv[3]",
|
|
"web_port = sys.argv[4]",
|
|
"api_port = sys.argv[5]",
|
|
"current_name = marker[len('unidesk managed '):] if marker.startswith('unidesk managed ') else marker",
|
|
"text = config.read_text(encoding='utf-8') if config.exists() else ''",
|
|
"begin = f'# BEGIN {marker}'",
|
|
"end = f'# END {marker}'",
|
|
"managed = f'{begin}\\n{block.rstrip()}\\n{end}\\n'",
|
|
"stale_removed = [0]",
|
|
"pattern = re.compile(r'(?ms)^# BEGIN unidesk managed (?P<name>[^\\n]+)\\n(?P<body>.*?)\\n# END unidesk managed (?P=name)\\n*')",
|
|
"def keep(match):",
|
|
" name = match.group('name')",
|
|
" body = match.group('body')",
|
|
" if name != current_name and (f'127.0.0.1:{web_port}' in body or f'127.0.0.1:{api_port}' in body):",
|
|
" stale_removed[0] += 1",
|
|
" return ''",
|
|
" return match.group(0)",
|
|
"text = pattern.sub(keep, text)",
|
|
"if begin in text and end in text:",
|
|
" start = text.index(begin)",
|
|
" stop = text.index(end, start) + len(end)",
|
|
" text = text[:start].rstrip() + '\\n\\n' + managed.rstrip() + '\\n' + text[stop:].lstrip('\\n')",
|
|
"else:",
|
|
" text = text.rstrip() + '\\n\\n' + managed",
|
|
"config.write_text(text, encoding='utf-8')",
|
|
"print(stale_removed[0])",
|
|
"PY",
|
|
")",
|
|
"python_exit=$?",
|
|
"validate_exit=1",
|
|
"validate_mode=validate",
|
|
"if [ \"$python_exit\" != 0 ]; then",
|
|
" validate_mode=python",
|
|
" validate_exit=$python_exit",
|
|
"else",
|
|
" if [ \"$dry_run\" = true ]; then",
|
|
" validate_mode=adapt",
|
|
" caddy adapt --config \"$next\" --adapter caddyfile >/tmp/hwlab-public-caddy-validate.out 2>/tmp/hwlab-public-caddy-validate.err",
|
|
" else",
|
|
" sudo caddy validate --config \"$next\" --adapter caddyfile >/tmp/hwlab-public-caddy-validate.out 2>/tmp/hwlab-public-caddy-validate.err",
|
|
" fi",
|
|
" validate_exit=$?",
|
|
"fi",
|
|
"reload_exit=",
|
|
"mutation=false",
|
|
"if [ \"$dry_run\" = false ] && [ \"$validate_exit\" = 0 ]; then",
|
|
" sudo install -m 0644 \"$next\" \"$config_path\" >/tmp/hwlab-public-caddy-install.out 2>/tmp/hwlab-public-caddy-install.err",
|
|
" install_exit=$?",
|
|
" if [ \"$install_exit\" = 0 ]; then",
|
|
" mutation=true",
|
|
" sudo systemctl reload \"$service\" >/tmp/hwlab-public-caddy-reload.out 2>/tmp/hwlab-public-caddy-reload.err || sudo systemctl restart \"$service\" >>/tmp/hwlab-public-caddy-reload.out 2>>/tmp/hwlab-public-caddy-reload.err",
|
|
" reload_exit=$?",
|
|
" else",
|
|
" reload_exit=$install_exit",
|
|
" fi",
|
|
"fi",
|
|
"active=$(systemctl is-active \"$service\" 2>/dev/null || true)",
|
|
"after_present=no",
|
|
"if [ \"$dry_run\" = true ]; then grep -Fq \"# BEGIN $marker\" \"$next\" && after_present=yes; else grep -Fq \"# BEGIN $marker\" \"$config_path\" && after_present=yes; fi",
|
|
"local_web_status=",
|
|
"local_api_status=",
|
|
"if [ \"$dry_run\" = false ]; then",
|
|
" local_web_status=$(curl -kfsS --max-time 15 --resolve \"$hostname:443:127.0.0.1\" \"https://$hostname/\" -o /dev/null -w '%{http_code}' 2>/tmp/hwlab-public-caddy-web.err || true)",
|
|
" local_api_status=$(curl -kfsS --max-time 15 --resolve \"$hostname:443:127.0.0.1\" \"https://$hostname/health/live\" -o /dev/null -w '%{http_code}' 2>/tmp/hwlab-public-caddy-api.err || true)",
|
|
"fi",
|
|
"validate_err_preview=$(cat /tmp/hwlab-public-caddy-python.err /tmp/hwlab-public-caddy-validate.err 2>/dev/null | tr '\\n' ';' | cut -c1-1000 || true)",
|
|
"local_web_err_preview=$([ -f /tmp/hwlab-public-caddy-web.err ] && tr '\\n' ';' </tmp/hwlab-public-caddy-web.err 2>/dev/null | cut -c1-1000 || true)",
|
|
"local_api_err_preview=$([ -f /tmp/hwlab-public-caddy-api.err ] && tr '\\n' ';' </tmp/hwlab-public-caddy-api.err 2>/dev/null | cut -c1-1000 || true)",
|
|
"printf 'hostname\\t%s\\n' \"$hostname\"",
|
|
"printf 'expectedA\\t%s\\n' \"$expected_a\"",
|
|
"printf 'configPath\\t%s\\n' \"$config_path\"",
|
|
"printf 'dryRun\\t%s\\n' \"$dry_run\"",
|
|
"printf 'mutation\\t%s\\n' \"$mutation\"",
|
|
"printf 'beforeBlockPresent\\t%s\\n' \"$before_present\"",
|
|
"printf 'afterBlockPresent\\t%s\\n' \"$after_present\"",
|
|
"printf 'staleBlocksRemoved\\t%s\\n' \"$stale_blocks_removed\"",
|
|
"printf 'pythonExitCode\\t%s\\n' \"$python_exit\"",
|
|
"printf 'validateMode\\t%s\\n' \"$validate_mode\"",
|
|
"printf 'validateExitCode\\t%s\\n' \"$validate_exit\"",
|
|
"printf 'reloadExitCode\\t%s\\n' \"$reload_exit\"",
|
|
"printf 'active\\t%s\\n' \"$active\"",
|
|
"printf 'localWebStatus\\t%s\\n' \"$local_web_status\"",
|
|
"printf 'localApiStatus\\t%s\\n' \"$local_api_status\"",
|
|
"printf 'validateErrorPreview\\t%s\\n' \"$validate_err_preview\"",
|
|
"printf 'localWebErrorPreview\\t%s\\n' \"$local_web_err_preview\"",
|
|
"printf 'localApiErrorPreview\\t%s\\n' \"$local_api_err_preview\"",
|
|
"rm -rf \"$tmp\"",
|
|
"[ \"$validate_exit\" = 0 ] && [ \"$after_present\" = yes ]",
|
|
].join("\n");
|
|
}
|
|
|
|
export function publicExposureCaddyBlock(exposure: HwlabRuntimePublicExposureSpec): string {
|
|
const tlsLines = exposure.caddyTls === "internal" ? " tls internal\n" : "";
|
|
const mainBlock = `${exposure.hostname} {
|
|
${tlsLines} @api path /health* /v1* /json-rpc* /openapi* /docs* /swagger*
|
|
reverse_proxy @api 127.0.0.1:${exposure.apiProxy.remotePort} {
|
|
transport http {
|
|
response_header_timeout ${exposure.responseHeaderTimeoutSeconds}s
|
|
}
|
|
}
|
|
reverse_proxy 127.0.0.1:${exposure.webProxy.remotePort} {
|
|
transport http {
|
|
response_header_timeout ${exposure.responseHeaderTimeoutSeconds}s
|
|
}
|
|
}
|
|
}`;
|
|
const extraBlocks = exposure.extraProxies
|
|
.filter((proxy) => proxy.hostname !== undefined)
|
|
.map((proxy) => `${proxy.hostname} {
|
|
${tlsLines} reverse_proxy 127.0.0.1:${proxy.remotePort} {
|
|
transport http {
|
|
response_header_timeout ${exposure.responseHeaderTimeoutSeconds}s
|
|
}
|
|
}
|
|
}`);
|
|
return [mainBlock, ...extraBlocks].join("\n\n");
|
|
}
|
|
|
|
export function runTransScript(node: string, script: string, input: string, timeoutSeconds: number): CommandResult {
|
|
return runCommand([transPath(), `${node}:k3s`, "sh", "--", script], repoRoot, { input, timeoutMs: timeoutSeconds * 1000 });
|
|
}
|
|
|
|
export function runTransHostScript(node: string, script: string, input: string, timeoutSeconds: number): CommandResult {
|
|
return runCommand([transPath(), node, "sh", "--", script], repoRoot, { input, timeoutMs: timeoutSeconds * 1000 });
|
|
}
|
|
|
|
export function runTransWorkspaceStdinScript(node: string, workspace: string, script: string, timeoutSeconds: number): CommandResult {
|
|
return runCommand([transPath(), `${node}:${workspace}`, "sh"], repoRoot, { input: script, timeoutMs: timeoutSeconds * 1000 });
|
|
}
|
|
|
|
export function runObsoleteSecretCleanup(options: NodeSecretOptions, spec: RuntimeSecretSpec): Record<string, unknown> {
|
|
const kubernetesResult = runTransScript(options.node, obsoleteSecretCleanupScript(options, spec), "", options.timeoutSeconds);
|
|
const kubernetesStatus = secretStatusFromText(statusText(kubernetesResult), kubernetesResult.exitCode === 0, kubernetesResult.exitCode, kubernetesResult.stderr, spec);
|
|
const hostOptions = { ...options, dryRun: options.dryRun || kubernetesStatus.ok !== true };
|
|
const platformDbResult = runTransHostScript(options.node, obsoletePlatformDbCleanupScript(hostOptions, spec), "", options.timeoutSeconds);
|
|
const platformDbStatus = obsoletePlatformDbStatusFromText(statusText(platformDbResult), platformDbResult.exitCode === 0, platformDbResult.exitCode, platformDbResult.stderr, spec);
|
|
const ok = kubernetesStatus.ok === true && platformDbStatus.ok === true && (options.dryRun || hostOptions.dryRun === false);
|
|
const mutation = kubernetesStatus.mutation === true || platformDbStatus.mutation === true;
|
|
return {
|
|
ok,
|
|
command: `hwlab nodes secret ${options.action}`,
|
|
node: options.node,
|
|
lane: options.lane,
|
|
namespace: spec.namespace,
|
|
secret: options.name,
|
|
key: null,
|
|
preset: options.preset,
|
|
mode: options.dryRun ? "dry-run" : "confirmed-delete",
|
|
status: {
|
|
ok,
|
|
preset: "obsolete-hwpod-db-cleanup",
|
|
dryRun: options.dryRun,
|
|
mutation,
|
|
kubernetesSecret: kubernetesStatus,
|
|
platformDatabase: platformDbStatus,
|
|
hostMutationSkipped: !options.dryRun && hostOptions.dryRun,
|
|
summary: ok
|
|
? `${spec.obsoleteHwpodDbSecret}, ${spec.obsoleteHwpodDbName}, and ${spec.obsoleteHwpodDbUser} are absent or ready to remove`
|
|
: `${spec.obsoleteHwpodDbSecret}, ${spec.obsoleteHwpodDbName}, or ${spec.obsoleteHwpodDbUser} still needs cleanup`,
|
|
},
|
|
mutation,
|
|
result: {
|
|
kubernetesSecret: compactCommandResult(kubernetesResult),
|
|
platformDatabase: compactCommandResult(platformDbResult),
|
|
},
|
|
valuesRedacted: true,
|
|
next: ok ? undefined : nextSecretCommand(options, spec),
|
|
};
|
|
}
|