deploy: configure NC01 HWLAB monitor exposure
This commit is contained in:
@@ -48,6 +48,7 @@ export interface AgentRunLaneSecretSpec {
|
||||
readonly sourceRef: string;
|
||||
readonly sourceMode: "env" | "file";
|
||||
readonly sourceKey: string | null;
|
||||
readonly sourceFormat: "env" | "raw-token" | null;
|
||||
readonly targetRef: AgentRunSecretRef;
|
||||
readonly providerCredentialProfile: string | null;
|
||||
}
|
||||
@@ -407,6 +408,7 @@ export function agentRunLaneSummary(spec: AgentRunLaneSpec): Record<string, unkn
|
||||
sourceRef: secret.sourceRef.startsWith("/") ? secret.sourceRef : `.state/secrets/${secret.sourceRef}`,
|
||||
sourceMode: secret.sourceMode,
|
||||
sourceKey: secret.sourceKey,
|
||||
sourceFormat: secret.sourceFormat,
|
||||
targetRef: secret.targetRef,
|
||||
providerCredential: secret.providerCredentialProfile === null ? null : { profile: secret.providerCredentialProfile },
|
||||
valuesPrinted: false,
|
||||
@@ -831,12 +833,16 @@ function parseLaneSecret(input: Record<string, unknown>, path: string): AgentRun
|
||||
if (sourceMode !== "env" && sourceMode !== "file") throw new Error(`${path}.sourceMode must be env or file`);
|
||||
const sourceKey = optionalStringField(input, "sourceKey", path) ?? null;
|
||||
if (sourceMode === "env" && sourceKey === null) throw new Error(`${path}.sourceKey is required when sourceMode is env`);
|
||||
const sourceFormat = optionalStringField(input, "sourceFormat", path) ?? null;
|
||||
if (sourceFormat !== null && sourceFormat !== "env" && sourceFormat !== "raw-token") throw new Error(`${path}.sourceFormat must be env or raw-token`);
|
||||
if (sourceFormat === "raw-token" && sourceMode !== "env") throw new Error(`${path}.sourceFormat raw-token requires sourceMode env`);
|
||||
const providerCredential = parseProviderCredentialBinding(input, `${path}.providerCredential`);
|
||||
return {
|
||||
id: stringField(input, "id", path),
|
||||
sourceRef: secretSourceRefField(input, "sourceRef", path),
|
||||
sourceMode,
|
||||
sourceKey,
|
||||
sourceFormat,
|
||||
targetRef: parseNamespacedSecretRef(recordField(input, "targetRef", path), `${path}.targetRef`),
|
||||
providerCredentialProfile: providerCredential,
|
||||
};
|
||||
|
||||
@@ -45,6 +45,7 @@ export function parseSecretSyncOptions(args: string[]): SecretSyncOptions {
|
||||
const base = parseConfirmOptions(args);
|
||||
let node: string | null = null;
|
||||
let lane: string | null = null;
|
||||
const secretIds: string[] = [];
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--confirm" || arg === "--dry-run") continue;
|
||||
@@ -62,9 +63,17 @@ export function parseSecretSyncOptions(args: string[]): SecretSyncOptions {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--secret-id") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) throw new Error("--secret-id requires a value");
|
||||
if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error("--secret-id must be a simple YAML secret id");
|
||||
if (!secretIds.includes(value)) secretIds.push(value);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`unsupported secret-sync option: ${arg}`);
|
||||
}
|
||||
return { ...base, node, lane };
|
||||
return { ...base, node, lane, secretIds };
|
||||
}
|
||||
|
||||
export function parseLaneConfirmOptions(args: string[]): LaneConfirmOptions {
|
||||
|
||||
@@ -71,6 +71,7 @@ export function agentRunHelp(): unknown {
|
||||
"bun scripts/cli.ts agentrun control-plane status --node D601 --lane v02",
|
||||
"bun scripts/cli.ts agentrun control-plane secret-sync --node D601 --lane v02 --dry-run",
|
||||
"bun scripts/cli.ts agentrun control-plane secret-sync --node D601 --lane v02 --confirm",
|
||||
"bun scripts/cli.ts agentrun control-plane secret-sync --node D601 --lane v02 --secret-id tool-github-pr-token --confirm",
|
||||
"bun scripts/cli.ts agentrun control-plane restart --node D601 --lane v02 --dry-run",
|
||||
"bun scripts/cli.ts agentrun control-plane restart --node D601 --lane v02 --confirm",
|
||||
"bun scripts/cli.ts agentrun control-plane trigger-current --node D601 --lane v02 --dry-run",
|
||||
|
||||
@@ -219,6 +219,7 @@ export interface ConfirmOptions {
|
||||
export interface SecretSyncOptions extends ConfirmOptions {
|
||||
node: string | null;
|
||||
lane: string | null;
|
||||
secretIds: string[];
|
||||
}
|
||||
|
||||
export interface LaneConfirmOptions extends ConfirmOptions {
|
||||
|
||||
@@ -350,6 +350,7 @@ export function yamlLanePipelineRunManifest(spec: AgentRunLaneSpec, sourceCommit
|
||||
{ name: "source-branch", value: spec.source.branch },
|
||||
{ name: "gitops-branch", value: spec.gitops.branch },
|
||||
{ name: "revision", value: sourceCommit },
|
||||
...(sourceStageRef === null ? [] : [{ name: "source-stage-ref", value: sourceStageRef }]),
|
||||
{ name: "registry-prefix", value: spec.ci.registryPrefix },
|
||||
{ name: "tools-image", value: spec.ci.toolsImage },
|
||||
],
|
||||
@@ -476,6 +477,7 @@ export function yamlLaneGitMirrorSshSetupShellLines(spec: AgentRunLaneSpec): str
|
||||
const proxyHost = spec.gitMirror.githubProxy.host;
|
||||
const proxyPort = spec.gitMirror.githubProxy.port;
|
||||
const proxyUrl = `http://${proxyHost}:${proxyPort}`;
|
||||
const noProxy = "localhost,127.0.0.1,::1,.svc,.svc.cluster.local,.cluster.local,10.0.0.0/8,10.42.0.0/16,10.43.0.0/16,hyueapi.com,.hyueapi.com";
|
||||
const proxySummary = `agentrun git-mirror-egress-proxy node=${spec.nodeId} lane=${spec.lane} host=${proxyHost} port=${proxyPort} ssh=GIT_SSH-wrapper source=yaml`;
|
||||
const proxyCommand = `ProxyCommand=node /tmp/agentrun-github-proxy-connect.cjs ${proxyHost} ${proxyPort} %h %p`;
|
||||
return [
|
||||
@@ -545,6 +547,8 @@ export function yamlLaneGitMirrorSshSetupShellLines(spec: AgentRunLaneSpec): str
|
||||
`export http_proxy=${shQuote(proxyUrl)}`,
|
||||
`export https_proxy=${shQuote(proxyUrl)}`,
|
||||
`export all_proxy=${shQuote(proxyUrl)}`,
|
||||
`export NO_PROXY=${shQuote(noProxy)}`,
|
||||
`export no_proxy=${shQuote(noProxy)}`,
|
||||
"export GIT_SSH=/tmp/agentrun-git-ssh-proxy.sh",
|
||||
"unset GIT_SSH_COMMAND",
|
||||
];
|
||||
@@ -558,8 +562,8 @@ export function yamlLaneGitMirrorSyncShell(spec: AgentRunLaneSpec): string {
|
||||
`source_branch=${shQuote(spec.source.branch)}`,
|
||||
`source_stage_ref_prefix=${shQuote(agentRunSourceSnapshotStageRefPrefix(spec))}`,
|
||||
`gitops_branch=${shQuote(spec.gitops.branch)}`,
|
||||
`remote=${shQuote(spec.source.remote)}`,
|
||||
"repo=\"/cache/${repository}.git\"",
|
||||
"remote=\"ssh://git@ssh.github.com:443/${repository}.git\"",
|
||||
"mkdir -p \"$(dirname \"$repo\")\"",
|
||||
"if [ -d \"$repo/objects\" ] && [ -f \"$repo/HEAD\" ]; then",
|
||||
" git --git-dir=\"$repo\" remote set-url origin \"$remote\" || git --git-dir=\"$repo\" remote add origin \"$remote\"",
|
||||
@@ -606,8 +610,8 @@ export function yamlLaneGitMirrorFlushShell(spec: AgentRunLaneSpec): string {
|
||||
...yamlLaneGitMirrorSshSetupShellLines(spec),
|
||||
`repository=${shQuote(spec.source.repository)}`,
|
||||
`gitops_branch=${shQuote(spec.gitops.branch)}`,
|
||||
`remote=${shQuote(spec.gitMirror.writeUrl)}`,
|
||||
"repo=\"/cache/${repository}.git\"",
|
||||
"remote=\"ssh://git@ssh.github.com:443/${repository}.git\"",
|
||||
"test -d \"$repo\"",
|
||||
"git --git-dir=\"$repo\" remote set-url origin \"$remote\" || git --git-dir=\"$repo\" remote add origin \"$remote\"",
|
||||
"local_gitops=$(git --git-dir=\"$repo\" rev-parse --verify \"refs/heads/${gitops_branch}^{commit}\" 2>/dev/null || true)",
|
||||
@@ -703,6 +707,7 @@ export type LaneSecretSource = {
|
||||
sourceRef: string;
|
||||
sourceMode: "env" | "file";
|
||||
sourceKey: string | null;
|
||||
sourceFormat?: "env" | "raw-token" | null;
|
||||
targetRef: { namespace: string; name: string; key: string };
|
||||
transform?: "local-postgres-database" | "local-postgres-user" | "local-postgres-database-url";
|
||||
};
|
||||
@@ -719,8 +724,8 @@ export function readSecretSourceValue(spec: AgentRunLaneSpec, source: LaneSecret
|
||||
value = readFileSync(sourcePath, "utf8");
|
||||
} else {
|
||||
if (source.sourceKey === null) throw new Error(`secret source ${sourceRef} is missing sourceKey`);
|
||||
const values = parseEnvFile(readFileSync(sourcePath, "utf8"));
|
||||
const envValue = values.get(source.sourceKey);
|
||||
const text = readFileSync(sourcePath, "utf8");
|
||||
const envValue = source.sourceFormat === "raw-token" ? text.trim() : parseEnvFile(text).get(source.sourceKey);
|
||||
if (envValue === undefined || envValue.length === 0) throw new Error(`secret source ${sourceRef} is missing required key ${source.sourceKey}`);
|
||||
value = envValue;
|
||||
}
|
||||
@@ -837,6 +842,7 @@ export function collectLaneSecretSources(spec: AgentRunLaneSpec): LaneSecretSour
|
||||
sourceRef: secret.sourceRef,
|
||||
sourceMode: secret.sourceMode,
|
||||
sourceKey: secret.sourceKey,
|
||||
sourceFormat: secret.sourceFormat,
|
||||
targetRef: secret.targetRef,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -178,15 +178,19 @@ export async function restartYamlLane(config: UniDeskConfig, options: LaneConfir
|
||||
|
||||
export async function secretSync(config: UniDeskConfig, options: SecretSyncOptions): Promise<Record<string, unknown>> {
|
||||
const { configPath, spec } = resolveAgentRunLaneTarget(options);
|
||||
const sources = collectLaneSecretSources(spec);
|
||||
const allSources = collectLaneSecretSources(spec);
|
||||
const selectedIds = new Set(options.secretIds);
|
||||
const sources = selectedIds.size === 0 ? allSources : allSources.filter((source) => selectedIds.has(source.id));
|
||||
if (sources.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
command: "agentrun control-plane secret-sync",
|
||||
mode: "yaml-declared-node-lane",
|
||||
configPath,
|
||||
target: agentRunLaneSummary(spec),
|
||||
target: compactAgentRunLaneStatusTarget(spec),
|
||||
degradedReason: "lane-secret-sources-not-declared",
|
||||
requestedSecretIds: options.secretIds,
|
||||
declaredSecretIds: allSources.map((source) => source.id),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
@@ -211,10 +215,11 @@ export async function secretSync(config: UniDeskConfig, options: SecretSyncOptio
|
||||
mode: "dry-run",
|
||||
mutation: false,
|
||||
configPath,
|
||||
target: agentRunLaneSummary(spec),
|
||||
target: compactAgentRunLaneStatusTarget(spec),
|
||||
filter: options.secretIds.length === 0 ? null : { secretIds: options.secretIds, selected: sources.map((source) => source.id) },
|
||||
plan: { secretCount: plan.length, items: plan, valuesPrinted: false },
|
||||
next: {
|
||||
confirm: `bun scripts/cli.ts agentrun control-plane secret-sync --node ${spec.nodeId} --lane ${spec.lane} --confirm`,
|
||||
confirm: `bun scripts/cli.ts agentrun control-plane secret-sync --node ${spec.nodeId} --lane ${spec.lane}${options.secretIds.map((id) => ` --secret-id ${id}`).join("")} --confirm`,
|
||||
status: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`,
|
||||
},
|
||||
valuesPrinted: false,
|
||||
@@ -228,7 +233,8 @@ export async function secretSync(config: UniDeskConfig, options: SecretSyncOptio
|
||||
mode: "confirmed-sync",
|
||||
mutation: true,
|
||||
configPath,
|
||||
target: agentRunLaneSummary(spec),
|
||||
target: compactAgentRunLaneStatusTarget(spec),
|
||||
filter: options.secretIds.length === 0 ? null : { secretIds: options.secretIds, selected: sources.map((source) => source.id) },
|
||||
plan: { secretCount: plan.length, items: plan, valuesPrinted: false },
|
||||
result: payload,
|
||||
capture: compactCapture(result, { full: result.exitCode !== 0, stdoutTailChars: 3000, stderrTailChars: 3000 }),
|
||||
|
||||
@@ -128,22 +128,9 @@ export type AgentRunBridgeCaptureResult = SshCaptureResult & { bridgeExecution?:
|
||||
export let localBackendCoreStatusCache: LocalBackendCoreStatus | null = null;
|
||||
|
||||
export async function capture(config: UniDeskConfig, target: string, args: string[]): Promise<AgentRunBridgeCaptureResult> {
|
||||
const result = await runSshCommandCapture(config, target, args);
|
||||
const plan = agentRunBridgeCapturePlan(config, target);
|
||||
if (plan.backend === "remote-frontend-websocket" && plan.remoteHost !== null) {
|
||||
return await captureRemote(config, plan, target, args);
|
||||
}
|
||||
const local = attachBridgeExecution(await runSshCommandCapture(config, target, args), plan);
|
||||
const fallbackHost = agentRunBridgeRemoteHost(config);
|
||||
if (local.exitCode !== 0 && fallbackHost !== null && bridgeExecutionFailureKind(local)?.degradedReason === "capture-backend-unavailable") {
|
||||
const fallbackPlan: AgentRunBridgeCapturePlan = {
|
||||
...plan,
|
||||
backend: "remote-frontend-websocket",
|
||||
remoteHost: fallbackHost,
|
||||
reason: "local-capture-backend-unavailable",
|
||||
};
|
||||
return await captureRemote(config, fallbackPlan, target, args);
|
||||
}
|
||||
return local;
|
||||
return attachBridgeExecution(result, { ...plan, reason: `unified-ssh-capture:${plan.reason}` });
|
||||
}
|
||||
|
||||
export async function captureRemote(config: UniDeskConfig, plan: AgentRunBridgeCapturePlan, target: string, args: string[]): Promise<AgentRunBridgeCaptureResult> {
|
||||
|
||||
@@ -1242,15 +1242,18 @@ function yamlLaneK3sBuildProxyEnv(spec: AgentRunLaneSpec): Array<{ name: string;
|
||||
}
|
||||
|
||||
function yamlLaneK3sBuildSourceShell(spec: AgentRunLaneSpec, sourceCommit: string): string {
|
||||
const useDirectSourceRemote = spec.source.remote.startsWith("http://gitea-http.") || spec.source.remote.startsWith("https://gitea.");
|
||||
const readUrl = useDirectSourceRemote ? spec.source.remote : spec.gitMirror.readUrl;
|
||||
return [
|
||||
"set -eu",
|
||||
`read_url=${shQuote(spec.gitMirror.readUrl)}`,
|
||||
`read_url=${shQuote(readUrl)}`,
|
||||
`source_commit=${shQuote(sourceCommit)}`,
|
||||
`source_stage_ref=${shQuote(agentRunSourceSnapshotRef(spec, sourceCommit))}`,
|
||||
`use_direct_source_remote=${useDirectSourceRemote ? "true" : "false"}`,
|
||||
"rm -rf /workspace/repo",
|
||||
"git clone --no-checkout \"$read_url\" /workspace/repo",
|
||||
"cd /workspace/repo",
|
||||
"git fetch origin \"+$source_stage_ref:refs/remotes/origin/unidesk-source-snapshot\"",
|
||||
"if [ \"$use_direct_source_remote\" != true ]; then git fetch origin \"+$source_stage_ref:refs/remotes/origin/unidesk-source-snapshot\"; fi",
|
||||
"git checkout --detach \"$source_commit\"",
|
||||
"actual=$(git rev-parse HEAD)",
|
||||
"test \"$actual\" = \"$source_commit\"",
|
||||
|
||||
@@ -3,10 +3,25 @@
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { isAbsolute, join, normalize, relative, resolve } from "node:path";
|
||||
import { rootPath } from "../config";
|
||||
import { parseEnvFile } from "../platform-infra-ops-library";
|
||||
import { readIssueCommentBody } from "./body-input";
|
||||
import { PREVIEW_CHARS } from "./types";
|
||||
import { PREVIEW_CHARS, UNIDESK_CLI_CONFIG_PATH } from "./types";
|
||||
import type { GitHubOptions, GitHubTokenProbe } from "./types";
|
||||
|
||||
const GITHUB_TOKEN_SOURCE_CONFIG_REF = `${UNIDESK_CLI_CONFIG_PATH}#github.auth.tokenSource`;
|
||||
|
||||
interface GitHubYamlTokenSourceConfig {
|
||||
enabled: boolean;
|
||||
priority: "before-env" | "after-env";
|
||||
root: string;
|
||||
sourceRef: string;
|
||||
sourceKey: string;
|
||||
format: "env" | "raw-token";
|
||||
}
|
||||
|
||||
export function readIssueLifecycleCommentBody(options: GitHubOptions, command: string): { body: string; bodySource: Record<string, unknown> } | null {
|
||||
if (options.comment === undefined && options.commentFile === undefined) return null;
|
||||
if (options.comment !== undefined) {
|
||||
@@ -34,6 +49,50 @@ export function tokenFromEnvironment(): GitHubTokenProbe {
|
||||
return { present: false, source: null, ghFallbackAttempted: false };
|
||||
}
|
||||
|
||||
export function tokenFromYamlSource(): { token: string | null; probe: GitHubTokenProbe } {
|
||||
const config = readGitHubYamlTokenSourceConfig();
|
||||
if (config === null || !config.enabled) {
|
||||
return {
|
||||
token: null,
|
||||
probe: {
|
||||
present: false,
|
||||
source: null,
|
||||
ghFallbackAttempted: false,
|
||||
yamlSourceAttempted: true,
|
||||
yamlSourceEnabled: config?.enabled ?? false,
|
||||
yamlSourcePriority: config?.priority,
|
||||
configRef: GITHUB_TOKEN_SOURCE_CONFIG_REF,
|
||||
valuesPrinted: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
const sourceRoot = resolveYamlSourceRoot(config.root);
|
||||
const sourcePath = resolveYamlSourcePath(sourceRoot, config.sourceRef);
|
||||
const sourceExists = existsSync(sourcePath);
|
||||
const values = sourceExists ? parseYamlTokenSource(readFileSync(sourcePath, "utf8"), config) : {};
|
||||
const token = values[config.sourceKey] ?? null;
|
||||
const present = token !== null && token.length > 0;
|
||||
return {
|
||||
token: present ? token : null,
|
||||
probe: {
|
||||
present,
|
||||
source: present ? "yaml-token-source" : null,
|
||||
ghFallbackAttempted: false,
|
||||
yamlSourceAttempted: true,
|
||||
yamlSourceEnabled: true,
|
||||
yamlSourcePriority: config.priority,
|
||||
configRef: GITHUB_TOKEN_SOURCE_CONFIG_REF,
|
||||
sourceRef: config.sourceRef,
|
||||
sourceKey: config.sourceKey,
|
||||
sourcePath: redactRepoPath(sourcePath),
|
||||
sourceExists,
|
||||
sourceKeyPresent: present,
|
||||
tokenFingerprint: present ? fingerprintToken(token) : null,
|
||||
valuesPrinted: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function ghBinaryPath(): string | null {
|
||||
try {
|
||||
const output = execFileSync("sh", ["-lc", "command -v gh"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
||||
@@ -53,17 +112,103 @@ export function ghAuthToken(): string | null {
|
||||
}
|
||||
|
||||
export function resolveToken(allowGhFallback: boolean): { token: string | null; probe: GitHubTokenProbe } {
|
||||
const yamlToken = tokenFromYamlSource();
|
||||
if (yamlToken.probe.yamlSourcePriority === "before-env" && yamlToken.probe.present) return yamlToken;
|
||||
const envProbe = tokenFromEnvironment();
|
||||
if (envProbe.present) {
|
||||
const token = envProbe.source === "GH_TOKEN" ? process.env.GH_TOKEN ?? null : process.env.GITHUB_TOKEN ?? null;
|
||||
return { token, probe: envProbe };
|
||||
}
|
||||
if (!allowGhFallback) return { token: null, probe: envProbe };
|
||||
if (yamlToken.probe.present) return yamlToken;
|
||||
if (!allowGhFallback) return { token: null, probe: yamlToken.probe };
|
||||
const ghPath = ghBinaryPath();
|
||||
if (ghPath === null) return { token: null, probe: { present: false, source: null, ghFallbackAttempted: true, ghBinaryFound: false, ghAuthTokenAvailable: null } };
|
||||
if (ghPath === null) return { token: null, probe: { ...yamlToken.probe, ghFallbackAttempted: true, ghBinaryFound: false, ghAuthTokenAvailable: null } };
|
||||
const token = ghAuthToken();
|
||||
if (token !== null) return { token, probe: { present: true, source: "gh-auth-token", ghFallbackAttempted: true, ghBinaryFound: true, ghAuthTokenAvailable: true } };
|
||||
return { token: null, probe: { present: false, source: null, ghFallbackAttempted: true, ghBinaryFound: true, ghAuthTokenAvailable: false } };
|
||||
return { token: null, probe: { ...yamlToken.probe, ghFallbackAttempted: true, ghBinaryFound: true, ghAuthTokenAvailable: false } };
|
||||
}
|
||||
|
||||
function readGitHubYamlTokenSourceConfig(): GitHubYamlTokenSourceConfig | null {
|
||||
const configPath = rootPath(UNIDESK_CLI_CONFIG_PATH);
|
||||
if (!existsSync(configPath)) return null;
|
||||
const parsed = asRecord(Bun.YAML.parse(readFileSync(configPath, "utf8")) as unknown, UNIDESK_CLI_CONFIG_PATH);
|
||||
const github = optionalRecord(parsed.github, `${UNIDESK_CLI_CONFIG_PATH}.github`);
|
||||
const auth = optionalRecord(github?.auth, `${UNIDESK_CLI_CONFIG_PATH}.github.auth`);
|
||||
const tokenSource = optionalRecord(auth?.tokenSource, `${GITHUB_TOKEN_SOURCE_CONFIG_REF}`);
|
||||
if (tokenSource === null) return null;
|
||||
return {
|
||||
enabled: booleanField(tokenSource, "enabled", GITHUB_TOKEN_SOURCE_CONFIG_REF),
|
||||
priority: tokenSourcePriorityField(tokenSource, "priority", GITHUB_TOKEN_SOURCE_CONFIG_REF),
|
||||
root: stringField(tokenSource, "root", GITHUB_TOKEN_SOURCE_CONFIG_REF),
|
||||
sourceRef: stringField(tokenSource, "sourceRef", GITHUB_TOKEN_SOURCE_CONFIG_REF),
|
||||
sourceKey: envKeyField(tokenSource, "sourceKey", GITHUB_TOKEN_SOURCE_CONFIG_REF),
|
||||
format: tokenSourceFormatField(tokenSource, "format", GITHUB_TOKEN_SOURCE_CONFIG_REF),
|
||||
};
|
||||
}
|
||||
|
||||
function parseYamlTokenSource(text: string, config: GitHubYamlTokenSourceConfig): Record<string, string> {
|
||||
if (config.format === "raw-token") return { [config.sourceKey]: text.trim() };
|
||||
return parseEnvFile(text);
|
||||
}
|
||||
|
||||
function resolveYamlSourceRoot(root: string): string {
|
||||
return isAbsolute(root) ? resolve(root) : rootPath(root);
|
||||
}
|
||||
|
||||
function resolveYamlSourcePath(root: string, sourceRef: string): string {
|
||||
if (isAbsolute(sourceRef) || sourceRef.includes("..")) throw new Error(`${GITHUB_TOKEN_SOURCE_CONFIG_REF}.sourceRef must be a relative path without ..`);
|
||||
const resolved = normalize(join(root, sourceRef));
|
||||
if (resolved !== root && !resolved.startsWith(`${root}/`)) throw new Error(`${GITHUB_TOKEN_SOURCE_CONFIG_REF}.sourceRef escapes configured root`);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function redactRepoPath(value: string): string {
|
||||
const root = rootPath();
|
||||
return value === root ? "." : value.startsWith(`${root}/`) ? relative(root, value) : value;
|
||||
}
|
||||
|
||||
function fingerprintToken(value: string): string {
|
||||
return `sha256:${createHash("sha256").update(value, "utf8").digest("hex")}`;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be a YAML object`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function optionalRecord(value: unknown, path: string): Record<string, unknown> | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
return asRecord(value, path);
|
||||
}
|
||||
|
||||
function stringField(obj: Record<string, unknown>, key: string, path: string): string {
|
||||
const value = obj[key];
|
||||
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function envKeyField(obj: Record<string, unknown>, key: string, path: string): string {
|
||||
const value = stringField(obj, key, path);
|
||||
if (!/^[A-Z_][A-Z0-9_]*$/u.test(value)) throw new Error(`${path}.${key} must be an env key`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function tokenSourcePriorityField(obj: Record<string, unknown>, key: string, path: string): "before-env" | "after-env" {
|
||||
const value = stringField(obj, key, path);
|
||||
if (value !== "before-env" && value !== "after-env") throw new Error(`${path}.${key} must be before-env or after-env`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function tokenSourceFormatField(obj: Record<string, unknown>, key: string, path: string): "env" | "raw-token" {
|
||||
const value = obj[key] === undefined ? "env" : stringField(obj, key, path);
|
||||
if (value !== "env" && value !== "raw-token") throw new Error(`${path}.${key} must be env or raw-token`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function booleanField(obj: Record<string, unknown>, key: string, path: string): boolean {
|
||||
const value = obj[key];
|
||||
if (typeof value !== "boolean") throw new Error(`${path}.${key} must be a boolean`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function repoParts(repo: string): { owner: string; name: string } {
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function authStatus(repo: string): Promise<GitHubCommandResult> {
|
||||
};
|
||||
}
|
||||
degraded.push("missing-token");
|
||||
return commandError("auth status", repo, errorPayload("missing-token", "GH_TOKEN or GITHUB_TOKEN is required", { details: probe }), {
|
||||
return commandError("auth status", repo, errorPayload("missing-token", "GH_TOKEN, GITHUB_TOKEN, or the YAML-declared GitHub token source is required", { details: probe }), {
|
||||
degraded,
|
||||
gh: { binaryFound: ghPath !== null, path: ghPath },
|
||||
token: probe,
|
||||
|
||||
@@ -335,12 +335,12 @@ export async function githubGraphqlRequest<T>(
|
||||
export function authRequired(repo: string, command: string, tokenProbe: GitHubTokenProbe): GitHubCommandResult | null {
|
||||
if (!tokenProbe.present) {
|
||||
if (tokenProbe.ghFallbackAttempted && tokenProbe.ghBinaryFound === false) {
|
||||
return commandError(command, repo, errorPayload("missing-binary", "gh binary is missing and no GH_TOKEN/GITHUB_TOKEN is available", { details: tokenProbe }));
|
||||
return commandError(command, repo, errorPayload("missing-binary", "gh binary is missing and no env or YAML GitHub token source is available", { details: tokenProbe }));
|
||||
}
|
||||
if (tokenProbe.ghFallbackAttempted && tokenProbe.ghBinaryFound === true && tokenProbe.ghAuthTokenAvailable === false) {
|
||||
return commandError(command, repo, errorPayload("auth-failed", "gh auth token failed and no GH_TOKEN/GITHUB_TOKEN is available", { details: tokenProbe }));
|
||||
return commandError(command, repo, errorPayload("auth-failed", "gh auth token failed and no env or YAML GitHub token source is available", { details: tokenProbe }));
|
||||
}
|
||||
return commandError(command, repo, errorPayload("missing-token", "GH_TOKEN or GITHUB_TOKEN is required", { details: tokenProbe }), {
|
||||
return commandError(command, repo, errorPayload("missing-token", "GH_TOKEN, GITHUB_TOKEN, or the YAML-declared GitHub token source is required", { details: tokenProbe }), {
|
||||
degraded: ["missing-token"],
|
||||
token: tokenProbe,
|
||||
});
|
||||
|
||||
@@ -377,8 +377,17 @@ function renderAuthStatus(result: GitHubCommandResult): string {
|
||||
const token = record(result.token);
|
||||
const gh = record(result.gh);
|
||||
const probes = record(result.probes);
|
||||
const tokenDetail = [
|
||||
`source=${ghText(token.source)}`,
|
||||
`yaml=${ghText(token.yamlSourceEnabled)}`,
|
||||
`priority=${ghText(token.yamlSourcePriority)}`,
|
||||
`sourceRef=${ghText(token.sourceRef)}`,
|
||||
`key=${ghText(token.sourceKey)}`,
|
||||
`fingerprint=${ghText(token.tokenFingerprint)}`,
|
||||
`ghFallback=${ghText(token.ghFallbackAttempted)}`,
|
||||
].filter((item) => !item.endsWith("=-") && !item.endsWith("=undefined")).join(" ");
|
||||
const rows = [
|
||||
["token", token.present === true ? "ok" : "missing", `source=${ghText(token.source)} ghFallback=${ghText(token.ghFallbackAttempted)}`],
|
||||
["token", token.present === true ? "ok" : "missing", tokenDetail],
|
||||
["gh-binary", gh.binaryFound === true ? "ok" : "missing", `path=${ghText(gh.path)}`],
|
||||
["rest-api", probeStatus(probes.restApi), ghText(probes.restApi)],
|
||||
["repo", probeStatus(probes.repo), probeDetail(probes.repo)],
|
||||
|
||||
+12
-1
@@ -410,10 +410,21 @@ export type GitHubCommandFailure = GitHubCommandResult & { ok: false };
|
||||
|
||||
export interface GitHubTokenProbe {
|
||||
present: boolean;
|
||||
source: "GH_TOKEN" | "GITHUB_TOKEN" | "gh-auth-token" | null;
|
||||
source: "GH_TOKEN" | "GITHUB_TOKEN" | "yaml-token-source" | "gh-auth-token" | null;
|
||||
ghFallbackAttempted: boolean;
|
||||
ghBinaryFound?: boolean;
|
||||
ghAuthTokenAvailable?: boolean | null;
|
||||
yamlSourceAttempted?: boolean;
|
||||
yamlSourceEnabled?: boolean;
|
||||
yamlSourcePriority?: "before-env" | "after-env";
|
||||
configRef?: string;
|
||||
sourceRef?: string;
|
||||
sourceKey?: string;
|
||||
sourcePath?: string;
|
||||
sourceExists?: boolean;
|
||||
sourceKeyPresent?: boolean;
|
||||
tokenFingerprint?: string | null;
|
||||
valuesPrinted?: false;
|
||||
}
|
||||
|
||||
export interface GitHubOptions {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { rootPath } from "./config";
|
||||
|
||||
const hostK8sConfigPath = "config/unidesk-host-k8s.yaml";
|
||||
|
||||
function asRecord(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stringField(obj: Record<string, unknown>, key: string, path: string): string {
|
||||
const value = obj[key];
|
||||
if (typeof value !== "string" || value.length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function readEnvFile(path: string): Record<string, string> {
|
||||
const values: Record<string, string> = {};
|
||||
const text = readFileSync(path, "utf8");
|
||||
for (const [index, rawLine] of text.split(/\r?\n/u).entries()) {
|
||||
const line = rawLine.trim();
|
||||
if (line.length === 0 || line.startsWith("#")) continue;
|
||||
const eq = line.indexOf("=");
|
||||
if (eq <= 0) throw new Error(`${path}:${index + 1} must be KEY=value`);
|
||||
values[line.slice(0, eq)] = line.slice(eq + 1);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function readHostK8sConfig(): Record<string, unknown> | null {
|
||||
const path = rootPath(hostK8sConfigPath);
|
||||
if (!existsSync(path)) return null;
|
||||
return asRecord(Bun.YAML.parse(readFileSync(path, "utf8")) as unknown, hostK8sConfigPath);
|
||||
}
|
||||
|
||||
export function readHostK8sPublicHost(): string | null {
|
||||
const config = readHostK8sConfig();
|
||||
if (config === null) return null;
|
||||
const runtime = asRecord(config.runtime, `${hostK8sConfigPath}.runtime`);
|
||||
return stringField(runtime, "publicHost", `${hostK8sConfigPath}.runtime`);
|
||||
}
|
||||
|
||||
export function readHostK8sSshClientToken(): string | null {
|
||||
const config = readHostK8sConfig();
|
||||
if (config === null) return null;
|
||||
const runtimeSecrets = asRecord(config.runtimeSecrets, `${hostK8sConfigPath}.runtimeSecrets`);
|
||||
const sourceRef = asRecord(runtimeSecrets.sourceRef, `${hostK8sConfigPath}.runtimeSecrets.sourceRef`);
|
||||
const target = asRecord(runtimeSecrets.target, `${hostK8sConfigPath}.runtimeSecrets.target`);
|
||||
const keys = asRecord(target.keys, `${hostK8sConfigPath}.runtimeSecrets.target.keys`);
|
||||
const sourcePathRaw = stringField(sourceRef, "path", `${hostK8sConfigPath}.runtimeSecrets.sourceRef`);
|
||||
const tokenKey = stringField(keys, "sshClientToken", `${hostK8sConfigPath}.runtimeSecrets.target.keys`);
|
||||
const sourcePath = isAbsolute(sourcePathRaw) ? sourcePathRaw : join(rootPath(), sourcePathRaw);
|
||||
if (!existsSync(sourcePath)) return null;
|
||||
const token = readEnvFile(sourcePath)[tokenKey]?.trim() ?? "";
|
||||
return token.length > 0 ? token : null;
|
||||
}
|
||||
@@ -1817,10 +1817,7 @@ function tektonGitWorkspaceSecretSpec(
|
||||
if (sourceRefFrom !== "gitMirror.githubTransport") {
|
||||
throw new Error(`${path}.sourceRefFrom must be gitMirror.githubTransport`);
|
||||
}
|
||||
if (githubTransport.mode !== "ssh") {
|
||||
throw new Error(`${path}.sourceRefFrom=gitMirror.githubTransport requires gitMirror.githubTransport.mode=ssh`);
|
||||
}
|
||||
if (githubTransport.knownHostsSecretKey === null) {
|
||||
if (githubTransport.mode === "ssh" && githubTransport.knownHostsSecretKey === null) {
|
||||
throw new Error(`${path}.sourceRefFrom=gitMirror.githubTransport requires gitMirror.githubTransport.knownHostsSecretKey`);
|
||||
}
|
||||
const name = stringField(raw, "name", path);
|
||||
@@ -2301,6 +2298,7 @@ function gitMirrorGithubHttpsToken(transport: Extract<ControlPlaneGitMirrorGithu
|
||||
const source = readEnvSourceFile({
|
||||
root: absolute ? "/" : rootPath("."),
|
||||
sourceRef: absolute ? transport.tokenSourceRef.slice(1) : transport.tokenSourceRef,
|
||||
rawKey: transport.tokenSourceKey,
|
||||
missingMessage: () => `gitMirror.githubTransport token source ${transport.tokenSourceRef} is missing; create the YAML-declared sourceRef with ${transport.tokenSourceKey} before applying the control plane`,
|
||||
});
|
||||
const value = requiredEnvValue(source.values, transport.tokenSourceKey, transport.tokenSourceRef);
|
||||
|
||||
@@ -476,6 +476,23 @@ export function nodeRuntimeStatusDegradedReason(input: {
|
||||
}
|
||||
|
||||
export function nodeRuntimePublicProbeStatus(spec: HwlabRuntimeLaneSpec): Record<string, unknown> {
|
||||
if (spec.publicExposure?.enabled !== true) {
|
||||
return {
|
||||
ready: true,
|
||||
skipped: true,
|
||||
reason: "public-exposure-not-configured",
|
||||
web: { kind: "web", url: spec.publicWebUrl, ok: true, skipped: true, httpStatus: null },
|
||||
apiHealth: { kind: "apiHealth", url: joinUrlPath(spec.publicApiUrl, "/health/live"), ok: true, skipped: true, httpStatus: null },
|
||||
targetHost: { ready: true, skipped: true, probeAvailable: false },
|
||||
diagnostic: {
|
||||
kind: "public-entry-probe-skipped",
|
||||
affectsUserEntry: false,
|
||||
targetHostReady: true,
|
||||
message: "publicExposure is not configured for this node/lane; public endpoint readiness is not a deployment gate.",
|
||||
nextAction: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
const web = publicHttpProbe("web", spec.publicWebUrl);
|
||||
const apiHealth = publicHttpProbe("apiHealth", joinUrlPath(spec.publicApiUrl, "/health/live"));
|
||||
const ready = web.ok === true && apiHealth.ok === true;
|
||||
@@ -1896,10 +1913,11 @@ export function nodeRuntimePipelinePostprocessScript(): string[] {
|
||||
" if (!fs.existsSync(file)) return false;",
|
||||
" const doc = readYaml(file) || {};",
|
||||
" const resources = Array.isArray(doc.resources) ? doc.resources : [];",
|
||||
" const publicExposureEnabled = overlay.publicExposure && overlay.publicExposure.enabled;",
|
||||
" const next = resources.filter((item) => {",
|
||||
" if (!(overlay.observability && overlay.observability.prometheusOperator === false)) return true;",
|
||||
" const resource = String(item);",
|
||||
" if (resource === 'observability.yaml') return false;",
|
||||
" if (!publicExposureEnabled && resource === 'node-frpc.yaml') return false;",
|
||||
" if (overlay.observability && overlay.observability.prometheusOperator === false && resource === 'observability.yaml') return false;",
|
||||
" return !(/\\.ya?ml$/u.test(resource) && !fs.existsSync(path.join(runtimePath, resource)));",
|
||||
" });",
|
||||
" let changed = false;",
|
||||
@@ -1918,7 +1936,6 @@ export function nodeRuntimePipelinePostprocessScript(): string[] {
|
||||
" const items = listItems(doc).filter(Boolean);",
|
||||
" let changed = false;",
|
||||
" const endpointIsIpv4 = /^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+$/.test(String(access.endpointAddress));",
|
||||
" const endpointSliceName = String(pg.serviceName) + '-host';",
|
||||
" for (const item of items) {",
|
||||
" if (!item || typeof item !== 'object') continue;",
|
||||
" item.metadata = item.metadata || {};",
|
||||
@@ -1946,11 +1963,14 @@ export function nodeRuntimePipelinePostprocessScript(): string[] {
|
||||
" }",
|
||||
" if (item.kind === 'EndpointSlice') {",
|
||||
" if (!endpointIsIpv4) { item.__delete = true; changed = true; continue; }",
|
||||
" item.metadata.name = endpointSliceName;",
|
||||
" item.apiVersion = 'v1';",
|
||||
" item.kind = 'Endpoints';",
|
||||
" item.metadata.name = pg.serviceName;",
|
||||
" item.metadata.labels['kubernetes.io/service-name'] = pg.serviceName;",
|
||||
" item.addressType = 'IPv4';",
|
||||
" item.ports = [{ name: 'postgres', port: access.port, protocol: 'TCP' }];",
|
||||
" item.endpoints = [{ addresses: [access.endpointAddress], conditions: { ready: true } }];",
|
||||
" delete item.addressType;",
|
||||
" delete item.ports;",
|
||||
" delete item.endpoints;",
|
||||
" item.subsets = [{ addresses: [{ ip: access.endpointAddress }], ports: [{ name: 'postgres', port: access.port, protocol: 'TCP' }] }];",
|
||||
" changed = true;",
|
||||
" }",
|
||||
" }",
|
||||
@@ -2015,8 +2035,12 @@ export function nodeRuntimePipelinePostprocessScript(): string[] {
|
||||
"}",
|
||||
"function patchPublicExposure() {",
|
||||
" const exposure = overlay.publicExposure;",
|
||||
" if (!exposure || !exposure.enabled) return { configured: false, changed: false };",
|
||||
" const file = path.join(runtimePath, 'node-frpc.yaml');",
|
||||
" if (!exposure || !exposure.enabled) {",
|
||||
" const changed = fs.existsSync(file);",
|
||||
" if (changed) fs.rmSync(file, { force: true });",
|
||||
" return { configured: false, changed };",
|
||||
" }",
|
||||
" if (!fs.existsSync(file)) {",
|
||||
" console.error(JSON.stringify({ event: 'unidesk-public-exposure-postprocess', ok: false, reason: 'node-frpc-yaml-missing', filePath: file, hostname: exposure.hostname }));",
|
||||
" process.exit(49);",
|
||||
@@ -2253,6 +2277,7 @@ export function nodeRuntimePipelinePostprocessScript(): string[] {
|
||||
" if (!fs.existsSync(file)) fail('external-postgres-missing');",
|
||||
" const items = listItems(readYaml(file)).filter(Boolean);",
|
||||
" const service = items.find((item) => item && item.kind === 'Service' && item.metadata && item.metadata.name === pg.serviceName);",
|
||||
" const endpoints = items.find((item) => item && item.kind === 'Endpoints' && item.metadata && item.metadata.name === String(pg.serviceName));",
|
||||
" const endpointSlice = items.find((item) => item && item.kind === 'EndpointSlice' && item.metadata && item.metadata.name === String(pg.serviceName) + '-host');",
|
||||
" if (!service) fail('external-postgres-service-missing', { expected: pg.serviceName });",
|
||||
" const endpointIsIpv4 = /^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+$/.test(String(access.endpointAddress));",
|
||||
@@ -2262,11 +2287,12 @@ export function nodeRuntimePipelinePostprocessScript(): string[] {
|
||||
" if (!service.spec || String(service.spec.externalName) !== String(access.endpointAddress)) fail('external-postgres-external-name-mismatch', { value: service.spec && service.spec.externalName, expected: access.endpointAddress });",
|
||||
" if (Number(servicePort) !== Number(access.port)) fail('external-postgres-port-mismatch', { servicePort, expectedPort: access.port });",
|
||||
" if (endpointSlice) fail('external-postgres-endpointslice-unexpected', { name: String(pg.serviceName) + '-host' });",
|
||||
" if (endpoints) fail('external-postgres-endpoints-unexpected', { name: String(pg.serviceName) });",
|
||||
" checks.push('external-postgres-bridge');",
|
||||
" } else {",
|
||||
" if (!endpointSlice) fail('external-postgres-endpointslice-missing', { expected: String(pg.serviceName) + '-host' });",
|
||||
" const endpointPort = Array.isArray(endpointSlice.ports) && endpointSlice.ports[0] ? endpointSlice.ports[0].port : null;",
|
||||
" const endpointAddress = Array.isArray(endpointSlice.endpoints) && endpointSlice.endpoints[0] && Array.isArray(endpointSlice.endpoints[0].addresses) ? endpointSlice.endpoints[0].addresses[0] : null;",
|
||||
" if (!endpoints && !endpointSlice) fail('external-postgres-endpoints-missing', { expected: String(pg.serviceName) });",
|
||||
" const endpointPort = endpoints && Array.isArray(endpoints.subsets) && endpoints.subsets[0] && Array.isArray(endpoints.subsets[0].ports) && endpoints.subsets[0].ports[0] ? endpoints.subsets[0].ports[0].port : endpointSlice && Array.isArray(endpointSlice.ports) && endpointSlice.ports[0] ? endpointSlice.ports[0].port : null;",
|
||||
" const endpointAddress = endpoints && Array.isArray(endpoints.subsets) && endpoints.subsets[0] && Array.isArray(endpoints.subsets[0].addresses) && endpoints.subsets[0].addresses[0] ? endpoints.subsets[0].addresses[0].ip : endpointSlice && Array.isArray(endpointSlice.endpoints) && endpointSlice.endpoints[0] && Array.isArray(endpointSlice.endpoints[0].addresses) ? endpointSlice.endpoints[0].addresses[0] : null;",
|
||||
" if (Number(servicePort) !== Number(access.port) || Number(endpointPort) !== Number(access.port)) fail('external-postgres-port-mismatch', { servicePort, endpointPort, expectedPort: access.port });",
|
||||
" if (String(endpointAddress) !== String(access.endpointAddress)) fail('external-postgres-address-mismatch', { endpointAddress, expectedEndpointAddress: access.endpointAddress });",
|
||||
" checks.push('external-postgres-bridge');",
|
||||
|
||||
@@ -62,7 +62,13 @@ function shellUrlEncodeFunction(): string[] {
|
||||
export function runNodeEndpointBridge(options: ReturnType<typeof parseNodeScopedDelegatedOptions>): Record<string, unknown> {
|
||||
if (options.dryRun && options.confirm) throw new Error("control-plane allow-endpoint-bridge accepts only one of --dry-run or --confirm");
|
||||
const dryRun = options.dryRun || !options.confirm;
|
||||
const result = runTransScript(options.node, endpointBridgeScript({ lane: options.lane, dryRun }), "", options.timeoutSeconds);
|
||||
const secretSpec = runtimeSecretSpec({ node: options.node, lane: options.lane });
|
||||
const result = runTransScript(options.node, endpointBridgeScript({
|
||||
lane: options.lane,
|
||||
dryRun,
|
||||
platformService: secretSpec.platformPostgresService,
|
||||
hostEndpointSlice: secretSpec.platformPostgresEndpointSlice,
|
||||
}), "", options.timeoutSeconds);
|
||||
const fields = keyValueLinesFromText(statusText(result));
|
||||
const beforeExcluded = fields.beforeEndpointResourcesExcluded === "yes";
|
||||
const beforeIgnored = fields.beforeEndpointsIgnoreUpdates === "yes" || fields.beforeEndpointSliceIgnoreUpdates === "yes";
|
||||
@@ -74,8 +80,8 @@ export function runNodeEndpointBridge(options: ReturnType<typeof parseNodeScoped
|
||||
const afterLegacyEndpoints = fields.afterLegacyEndpointsExists === "yes";
|
||||
const beforeHostEndpointSlice = fields.beforeHostEndpointSliceExists === "yes";
|
||||
const afterHostEndpointSlice = fields.afterHostEndpointSliceExists === "yes";
|
||||
const bridgeReady = !afterLegacyEndpoints && afterHostEndpointSlice && afterExtraEndpointSlices.length === 0;
|
||||
const ok = result.exitCode === 0 && !afterExcluded && !afterIgnored && bridgeReady;
|
||||
const bridgeReady = (afterLegacyEndpoints || afterHostEndpointSlice) && afterExtraEndpointSlices.length === 0;
|
||||
const ok = result.exitCode === 0 && !afterExcluded && !afterIgnored;
|
||||
return {
|
||||
ok: dryRun ? result.exitCode === 0 : ok,
|
||||
command: "hwlab nodes control-plane allow-endpoint-bridge",
|
||||
@@ -105,8 +111,8 @@ export function runNodeEndpointBridge(options: ReturnType<typeof parseNodeScoped
|
||||
extraEndpointSlices: afterExtraEndpointSlices,
|
||||
},
|
||||
runtimeNamespace: fields.runtimeNamespace || `hwlab-${options.lane}`,
|
||||
platformService: fields.platformService || "g14-platform-postgres",
|
||||
hostEndpointSlice: fields.hostEndpointSlice || "g14-platform-postgres-host",
|
||||
platformService: fields.platformService || secretSpec.platformPostgresService,
|
||||
hostEndpointSlice: fields.hostEndpointSlice || secretSpec.platformPostgresEndpointSlice,
|
||||
patchExitCode: numericField(fields.patchExitCode),
|
||||
rolloutRestartExitCode: numericField(fields.rolloutRestartExitCode),
|
||||
rolloutStatusExitCode: numericField(fields.rolloutStatusExitCode),
|
||||
@@ -115,15 +121,16 @@ export function runNodeEndpointBridge(options: ReturnType<typeof parseNodeScoped
|
||||
refreshExitCode: numericField(fields.refreshExitCode),
|
||||
exitCode: result.exitCode,
|
||||
stderr: result.exitCode === 0 ? "" : result.stderr.trim().slice(0, 2000),
|
||||
summary: !afterExcluded && !afterIgnored && bridgeReady
|
||||
? "Argo tracks HWLAB external Postgres EndpointSlice and no legacy Endpoints remain"
|
||||
: "Argo endpoint bridge is not in final Service plus EndpointSlice shape",
|
||||
bridgeReady,
|
||||
summary: !afterExcluded && !afterIgnored
|
||||
? "Argo tracks HWLAB external Postgres bridge resource"
|
||||
: "Argo endpoint bridge resources are still excluded or ignored",
|
||||
},
|
||||
result: compactCommandResult(result),
|
||||
};
|
||||
}
|
||||
|
||||
export function endpointBridgeScript(options: { lane: HwlabRuntimeLane; dryRun: boolean }): string {
|
||||
export function endpointBridgeScript(options: { lane: HwlabRuntimeLane; dryRun: boolean; platformService: string; hostEndpointSlice: string }): string {
|
||||
const application = `hwlab-node-${options.lane}`;
|
||||
const runtimeNamespace = `hwlab-${options.lane}`;
|
||||
return [
|
||||
@@ -133,8 +140,8 @@ export function endpointBridgeScript(options: { lane: HwlabRuntimeLane; dryRun:
|
||||
"configmap=argocd-cm",
|
||||
`application=${shellQuote(application)}`,
|
||||
`dry_run=${shellQuote(options.dryRun ? "true" : "false")}`,
|
||||
"platform_service=g14-platform-postgres",
|
||||
"host_endpointslice=g14-platform-postgres-host",
|
||||
`platform_service=${shellQuote(options.platformService)}`,
|
||||
`host_endpointslice=${shellQuote(options.hostEndpointSlice)}`,
|
||||
"preset=endpoint-bridge-resource-tracking",
|
||||
"cm_data() { kubectl -n \"$namespace\" get configmap \"$configmap\" -o \"go-template={{ index .data \\\"$1\\\" }}\" 2>/dev/null || true; }",
|
||||
"cm_has_key() { value=$(cm_data \"$1\"); [ -n \"$value\" ] && [ \"$value\" != \"<no value>\" ] && printf yes || printf no; }",
|
||||
@@ -146,7 +153,7 @@ export function endpointBridgeScript(options: { lane: HwlabRuntimeLane; dryRun:
|
||||
" current_legacy=$(resource_exists endpoints \"$platform_service\")",
|
||||
" current_extra=$(extra_endpoint_slices)",
|
||||
" current_host=$(resource_exists endpointslice \"$host_endpointslice\")",
|
||||
" if [ \"$current_legacy\" != yes ] && [ -z \"$current_extra\" ] && [ \"$current_host\" = yes ]; then return 0; fi",
|
||||
" if { [ \"$current_legacy\" = yes ] || [ \"$current_host\" = yes ]; } && [ -z \"$current_extra\" ]; then return 0; fi",
|
||||
" sleep 2",
|
||||
" done",
|
||||
" return 1",
|
||||
@@ -310,7 +317,6 @@ export function endpointBridgeScript(options: { lane: HwlabRuntimeLane; dryRun:
|
||||
"printf 'deleteExtraEndpointSlicesExitCode\\t%s\\n' \"$delete_extra_endpointslices_exit\"",
|
||||
"printf 'refreshExitCode\\t%s\\n' \"$refresh_exit\"",
|
||||
"if [ \"$dry_run\" != true ] && { [ \"$after_endpoint_resources_excluded\" = yes ] || [ \"$after_endpoints_ignore_updates\" = yes ] || [ \"$after_endpoint_slice_ignore_updates\" = yes ]; }; then exit 46; fi",
|
||||
"if [ \"$dry_run\" != true ] && { [ \"$after_legacy_endpoints_exists\" = yes ] || [ -n \"$after_extra_endpoint_slice_names\" ] || [ \"$after_host_endpointslice_exists\" != yes ]; }; then exit 47; fi",
|
||||
"if [ -n \"$patch_exit\" ] && [ \"$patch_exit\" != 0 ]; then exit \"$patch_exit\"; fi",
|
||||
"if [ -n \"$rollout_restart_exit\" ] && [ \"$rollout_restart_exit\" != 0 ]; then exit \"$rollout_restart_exit\"; fi",
|
||||
"if [ -n \"$rollout_status_exit\" ] && [ \"$rollout_status_exit\" != 0 ]; then exit \"$rollout_status_exit\"; fi",
|
||||
|
||||
@@ -1706,14 +1706,20 @@ export function externalPostgresBridgeStatus(spec: HwlabRuntimeLaneSpec, namespa
|
||||
service: compactRuntimeCommand(service),
|
||||
};
|
||||
}
|
||||
const endpointSlice = runNodeK3sArgs(spec, ["kubectl", "-n", spec.runtimeNamespace, "get", "endpointslice", `${pg.serviceName}-host`, "-o", "jsonpath={.addressType}{\"\\n\"}{.endpoints[0].addresses[0]}{\"\\n\"}{.ports[0].port}{\"\\n\"}"], 60);
|
||||
const [addressType = "", address = "", port = ""] = endpointSlice.stdout.split(/\r?\n/u);
|
||||
const endpoints = runNodeK3sArgs(spec, ["kubectl", "-n", spec.runtimeNamespace, "get", "endpoints", pg.serviceName, "-o", "jsonpath={.subsets[0].addresses[0].ip}{\"\\n\"}{.subsets[0].ports[0].port}{\"\\n\"}"], 60);
|
||||
const [endpointsAddress = "", endpointsPort = ""] = endpoints.stdout.split(/\r?\n/u);
|
||||
const endpointSlice = endpoints.exitCode === 0 && endpointsAddress.length > 0
|
||||
? { exitCode: 1, stdout: "", stderr: "", timedOut: false }
|
||||
: runNodeK3sArgs(spec, ["kubectl", "-n", spec.runtimeNamespace, "get", "endpointslice", `${pg.serviceName}-host`, "-o", "jsonpath={.addressType}{\"\\n\"}{.endpoints[0].addresses[0]}{\"\\n\"}{.ports[0].port}{\"\\n\"}"], 60);
|
||||
const [addressType = "", endpointSliceAddress = "", endpointSlicePort = ""] = endpointSlice.stdout.split(/\r?\n/u);
|
||||
const address = endpointsAddress || endpointSliceAddress;
|
||||
const port = endpointsPort || endpointSlicePort;
|
||||
return {
|
||||
required: true,
|
||||
ready: service.exitCode === 0 && endpointSlice.exitCode === 0 && serviceType !== "ExternalName" && address === runtimeAccess.endpointAddress && Number(port) === runtimeAccess.port,
|
||||
ready: service.exitCode === 0 && (endpoints.exitCode === 0 || endpointSlice.exitCode === 0) && serviceType !== "ExternalName" && address === runtimeAccess.endpointAddress && Number(port) === runtimeAccess.port,
|
||||
serviceName: pg.serviceName,
|
||||
serviceType: serviceType || null,
|
||||
addressType: addressType || null,
|
||||
addressType: endpoints.exitCode === 0 ? "IPv4" : addressType || null,
|
||||
endpointAddress: address || null,
|
||||
expectedEndpointAddress: runtimeAccess.endpointAddress,
|
||||
port: numericField(port),
|
||||
@@ -1722,6 +1728,7 @@ export function externalPostgresBridgeStatus(spec: HwlabRuntimeLaneSpec, namespa
|
||||
providerPort: pg.port,
|
||||
runtimeAccess: pg.runtimeAccess ?? null,
|
||||
service: compactRuntimeCommand(service),
|
||||
endpoints: compactRuntimeCommand(endpoints),
|
||||
endpointSlice: compactRuntimeCommand(endpointSlice),
|
||||
};
|
||||
}
|
||||
|
||||
+12
-4
@@ -49,8 +49,9 @@ process.stderr.on("error", (error) => {
|
||||
});
|
||||
|
||||
export function emitJson<T>(command: string, data: T, ok = true): void {
|
||||
const envelope: JsonEnvelope<T> = { ok, command, data };
|
||||
safeStdoutWrite(renderEnvelope(command, envelope));
|
||||
const safeCommand = redactSensitiveCommandArgs(command);
|
||||
const envelope: JsonEnvelope<T> = { ok, command: safeCommand, data };
|
||||
safeStdoutWrite(renderEnvelope(safeCommand, envelope));
|
||||
}
|
||||
|
||||
export function isRenderedCliResult(value: unknown): value is RenderedCliResult {
|
||||
@@ -67,8 +68,15 @@ export function emitText(text: string, command = "text"): void {
|
||||
|
||||
export function emitError(command: string, error: unknown): void {
|
||||
const payload = normalizeErrorPayload(command, error);
|
||||
const envelope: JsonEnvelope<never> = { ok: false, command, error: payload };
|
||||
safeStdoutWrite(renderEnvelope(command, envelope));
|
||||
const safeCommand = redactSensitiveCommandArgs(command);
|
||||
const envelope: JsonEnvelope<never> = { ok: false, command: safeCommand, error: payload };
|
||||
safeStdoutWrite(renderEnvelope(safeCommand, envelope));
|
||||
}
|
||||
|
||||
function redactSensitiveCommandArgs(command: string): string {
|
||||
return command
|
||||
.replace(/(--(?:provider-token|token|password|auth-password|session-secret|ssh-client-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/giu, "$1<redacted>")
|
||||
.replace(/\b((?:PROVIDER_TOKEN|UNIDESK_PROVIDER_TOKEN|UNIDESK_SSH_CLIENT_TOKEN|AUTH_PASSWORD|SESSION_SECRET)=)("[^"]*"|'[^']*'|\S+)/gu, "$1<redacted>");
|
||||
}
|
||||
|
||||
export function readCliOutputPolicy(): CliOutputPolicy {
|
||||
|
||||
@@ -30,6 +30,9 @@ capture_json() {
|
||||
run_apply() {
|
||||
manifest="$tmp/gitea.k8s.yaml"
|
||||
printf '%s' "$UNIDESK_GITEA_MANIFEST_B64" | base64 -d >"$manifest"
|
||||
if [ "$UNIDESK_GITEA_DRY_RUN" != "1" ]; then
|
||||
kubectl create namespace "$UNIDESK_GITEA_NAMESPACE" --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f - >"$tmp/namespace.out" 2>"$tmp/namespace.err"
|
||||
fi
|
||||
if [ "$UNIDESK_GITEA_FRPC_ENABLED" = "1" ] && [ "$UNIDESK_GITEA_DRY_RUN" != "1" ]; then
|
||||
printf '%s' "$UNIDESK_GITEA_FRPC_TOML_B64" | base64 -d >"$tmp/frpc.toml"
|
||||
kubectl -n "$UNIDESK_GITEA_NAMESPACE" create secret generic "$UNIDESK_GITEA_FRPC_SECRET_NAME" \
|
||||
@@ -63,11 +66,13 @@ run_apply() {
|
||||
fi
|
||||
fi
|
||||
dry_arg=""
|
||||
apply_args="--server-side --force-conflicts"
|
||||
if [ "$UNIDESK_GITEA_DRY_RUN" = "1" ]; then
|
||||
dry_arg="--dry-run=server"
|
||||
dry_arg="--dry-run=client"
|
||||
apply_args=""
|
||||
fi
|
||||
if [ "$frpc_rc" -eq 0 ] && [ "$webhook_secret_rc" -eq 0 ]; then
|
||||
timeout "$UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS" kubectl apply --server-side $dry_arg --force-conflicts --field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f "$manifest" >"$tmp/apply.out" 2>"$tmp/apply.err"
|
||||
timeout "$UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS" kubectl apply $apply_args $dry_arg --field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f "$manifest" >"$tmp/apply.out" 2>"$tmp/apply.err"
|
||||
apply_rc=$?
|
||||
else
|
||||
apply_rc=1
|
||||
|
||||
@@ -33,6 +33,7 @@ interface GiteaTarget {
|
||||
enabled: boolean;
|
||||
createNamespace: boolean;
|
||||
storageClassName: string;
|
||||
publicExposureEnabled: boolean;
|
||||
}
|
||||
|
||||
interface GiteaConfig {
|
||||
@@ -150,6 +151,7 @@ interface GiteaGithubCredential {
|
||||
transport: "https-token";
|
||||
sourceRef: string;
|
||||
sourceKey: string;
|
||||
format: "env" | "raw-token";
|
||||
requiredFor: string[];
|
||||
}
|
||||
|
||||
@@ -446,6 +448,7 @@ function parseGithubCredential(record: Record<string, unknown>, path: string): G
|
||||
transport: y.enumField(record, "transport", path, ["https-token"] as const),
|
||||
sourceRef: secretSourceRefField(record, "sourceRef", path),
|
||||
sourceKey: y.envKeyField(record, "sourceKey", path),
|
||||
format: optionalLiteralField(record, "format", path, ["env", "raw-token"] as const) ?? "env",
|
||||
requiredFor: y.stringArrayField(record, "requiredFor", path),
|
||||
};
|
||||
}
|
||||
@@ -569,6 +572,11 @@ function parseMirrorRepository(record: Record<string, unknown>, index: number):
|
||||
|
||||
function parseTarget(record: Record<string, unknown>, index: number): GiteaTarget {
|
||||
const path = `targets[${index}]`;
|
||||
const publicExposureRaw = record.publicExposure;
|
||||
if (publicExposureRaw !== undefined && (typeof publicExposureRaw !== "object" || publicExposureRaw === null || Array.isArray(publicExposureRaw))) {
|
||||
throw new Error(`${configLabel}.${path}.publicExposure must be an object`);
|
||||
}
|
||||
const publicExposure = publicExposureRaw as Record<string, unknown> | undefined;
|
||||
return {
|
||||
id: y.stringField(record, "id", path),
|
||||
route: y.stringField(record, "route", path),
|
||||
@@ -577,6 +585,7 @@ function parseTarget(record: Record<string, unknown>, index: number): GiteaTarge
|
||||
enabled: y.booleanField(record, "enabled", path),
|
||||
createNamespace: y.booleanField(record, "createNamespace", path),
|
||||
storageClassName: y.stringField(record, "storageClassName", path),
|
||||
publicExposureEnabled: publicExposure === undefined ? true : y.booleanField(publicExposure, "enabled", `${path}.publicExposure`),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -644,7 +653,7 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco
|
||||
const secrets = gitea.sourceAuthority.webhookSync.enabled && !options.dryRun ? ensureMirrorSecrets(gitea, true, true) : undefined;
|
||||
const result = await capture(config, target.route, ["sh"], remoteScript("apply", gitea, target, manifest, options, { frpcSecret, secrets }));
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
const caddy = !options.dryRun && result.exitCode === 0 && parsed?.ok === true && gitea.app.publicExposure.enabled
|
||||
const caddy = !options.dryRun && result.exitCode === 0 && parsed?.ok === true && publicExposureEnabled(gitea, target)
|
||||
? await applyGiteaCaddyBlock(config, gitea)
|
||||
: { ok: true, skipped: true, reason: options.dryRun ? "dry-run" : "apply-not-ready" };
|
||||
return {
|
||||
@@ -1265,7 +1274,7 @@ function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstra
|
||||
UNIDESK_GITEA_GITHUB_PROXY_ENABLED: gitea.sourceAuthority.githubProxy.enabled ? "1" : "0",
|
||||
UNIDESK_GITEA_GITHUB_PROXY_URL: gitea.sourceAuthority.githubProxy.url,
|
||||
UNIDESK_GITEA_NO_PROXY: gitea.sourceAuthority.githubProxy.noProxy.join(","),
|
||||
UNIDESK_GITEA_FRPC_ENABLED: gitea.app.publicExposure.enabled ? "1" : "0",
|
||||
UNIDESK_GITEA_FRPC_ENABLED: publicExposureEnabled(gitea, target) ? "1" : "0",
|
||||
UNIDESK_GITEA_FRPC_DEPLOYMENT_NAME: gitea.app.publicExposure.frpc.deploymentName,
|
||||
UNIDESK_GITEA_WEBHOOK_SYNC_ENABLED: gitea.sourceAuthority.webhookSync.enabled ? "1" : "0",
|
||||
UNIDESK_GITEA_WEBHOOK_PUBLIC_URL: githubWebhookPublicUrl(gitea),
|
||||
@@ -1337,6 +1346,7 @@ function targetSummary(target: GiteaTarget): Record<string, unknown> {
|
||||
role: target.role,
|
||||
createNamespace: target.createNamespace,
|
||||
storageClassName: target.storageClassName,
|
||||
publicExposureEnabled: target.publicExposureEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1363,11 +1373,20 @@ function publicServiceTarget(gitea: GiteaConfig, target: GiteaTarget): PublicSer
|
||||
route: target.route,
|
||||
namespace: target.namespace,
|
||||
replicas: 1,
|
||||
publicExposure: gitea.app.publicExposure,
|
||||
publicExposure: targetPublicExposure(gitea, target),
|
||||
};
|
||||
}
|
||||
|
||||
function prepareGiteaFrpcSecret(gitea: GiteaConfig, target: GiteaTarget): FrpcSecretMaterial {
|
||||
function publicExposureEnabled(gitea: GiteaConfig, target: GiteaTarget): boolean {
|
||||
return gitea.app.publicExposure.enabled && target.publicExposureEnabled;
|
||||
}
|
||||
|
||||
function targetPublicExposure(gitea: GiteaConfig, target: GiteaTarget): GiteaConfig["app"]["publicExposure"] {
|
||||
return publicExposureEnabled(gitea, target) ? gitea.app.publicExposure : { ...gitea.app.publicExposure, enabled: false };
|
||||
}
|
||||
|
||||
function prepareGiteaFrpcSecret(gitea: GiteaConfig, target: GiteaTarget): FrpcSecretMaterial | undefined {
|
||||
if (!publicExposureEnabled(gitea, target)) return undefined;
|
||||
const base = prepareFrpcSecret({
|
||||
secretRoot: gitea.app.publicExposure.secretRoot,
|
||||
exposure: gitea.app.publicExposure,
|
||||
@@ -1438,7 +1457,8 @@ function publicExposureSummary(gitea: GiteaConfig): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
function frpcSecretSummary(secret: FrpcSecretMaterial): Record<string, unknown> {
|
||||
function frpcSecretSummary(secret: FrpcSecretMaterial | undefined): Record<string, unknown> {
|
||||
if (secret === undefined) return { enabled: false, skipped: true, reason: "target-public-exposure-disabled", valuesPrinted: false };
|
||||
return {
|
||||
sourceRef: secret.sourceRef,
|
||||
sourcePath: secret.sourcePath,
|
||||
@@ -1568,9 +1588,10 @@ function webhookSyncSummary(gitea: GiteaConfig): Record<string, unknown> {
|
||||
|
||||
function credentialSummaries(gitea: GiteaConfig): Array<Record<string, unknown>> {
|
||||
const adminPath = credentialPath(gitea, gitea.sourceAuthority.credentials.admin.sourceRef);
|
||||
const githubPath = credentialPath(gitea, gitea.sourceAuthority.credentials.github.sourceRef);
|
||||
const github = gitea.sourceAuthority.credentials.github;
|
||||
const githubPath = credentialPath(gitea, github.sourceRef);
|
||||
const adminLinePair = existsSync(adminPath) ? parseLinePairCredential(adminPath, gitea.sourceAuthority.credentials.admin) : { username: null, password: null };
|
||||
const githubEnv = existsSync(githubPath) ? parseEnvFileSafe(githubPath) : {};
|
||||
const githubEnv = existsSync(githubPath) ? parseGithubCredentialFile(githubPath, github) : {};
|
||||
return [
|
||||
{
|
||||
id: "gitea-admin",
|
||||
@@ -1584,12 +1605,12 @@ function credentialSummaries(gitea: GiteaConfig): Array<Record<string, unknown>>
|
||||
},
|
||||
{
|
||||
id: "github-upstream",
|
||||
transport: gitea.sourceAuthority.credentials.github.transport,
|
||||
sourceRef: gitea.sourceAuthority.credentials.github.sourceRef,
|
||||
transport: github.transport,
|
||||
sourceRef: github.sourceRef,
|
||||
sourcePath: githubPath,
|
||||
present: existsSync(githubPath),
|
||||
requiredKeysPresent: Boolean(githubEnv[gitea.sourceAuthority.credentials.github.sourceKey]),
|
||||
fingerprint: fingerprintKeys(githubEnv, [gitea.sourceAuthority.credentials.github.sourceKey]),
|
||||
requiredKeysPresent: Boolean(githubEnv[github.sourceKey]),
|
||||
fingerprint: fingerprintKeys(githubEnv, [github.sourceKey]),
|
||||
valuesPrinted: false,
|
||||
},
|
||||
];
|
||||
@@ -1614,7 +1635,7 @@ function ensureMirrorSecrets(gitea: GiteaConfig, createAdmin: boolean, createWeb
|
||||
const github = gitea.sourceAuthority.credentials.github;
|
||||
const githubPath = credentialPath(gitea, github.sourceRef);
|
||||
if (!existsSync(githubPath)) throw new Error(`${github.sourceRef} is missing; cannot sync GitHub upstream`);
|
||||
const githubEnv = parseEnvFileSafe(githubPath);
|
||||
const githubEnv = parseGithubCredentialFile(githubPath, github);
|
||||
const webhookSecret = ensureWebhookSecret(gitea, createWebhookSecret);
|
||||
const adminUsername = adminLinePair.username;
|
||||
const adminPassword = adminLinePair.password;
|
||||
@@ -1667,6 +1688,27 @@ function parseEnvFileSafe(path: string): Record<string, string> {
|
||||
return values;
|
||||
}
|
||||
|
||||
function parseGithubCredentialFile(path: string, github: GiteaGithubCredential): Record<string, string> {
|
||||
const text = readFileSync(path, "utf8");
|
||||
if (github.format === "raw-token") return { [github.sourceKey]: text.trim() };
|
||||
return parseEnvTextSafe(text);
|
||||
}
|
||||
|
||||
function parseEnvTextSafe(text: string): Record<string, string> {
|
||||
const values: Record<string, string> = {};
|
||||
for (const rawLine of text.split(/\r?\n/u)) {
|
||||
let line = rawLine.trim();
|
||||
if (line.length === 0 || line.startsWith("#")) continue;
|
||||
if (line.startsWith("export ")) line = line.slice("export ".length).trim();
|
||||
const eq = line.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const key = line.slice(0, eq).trim();
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) continue;
|
||||
values[key] = unquote(line.slice(eq + 1).trim());
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function unquote(value: string): string {
|
||||
if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) return value.slice(1, -1);
|
||||
return value;
|
||||
@@ -2055,6 +2097,13 @@ function literalField<T extends string>(obj: Record<string, unknown>, key: strin
|
||||
return value as T;
|
||||
}
|
||||
|
||||
function optionalLiteralField<T extends string>(obj: Record<string, unknown>, key: string, path: string, allowed: readonly T[]): T | null {
|
||||
if (obj[key] === undefined || obj[key] === null) return null;
|
||||
const value = y.stringField(obj, key, path);
|
||||
if (!allowed.includes(value as T)) throw new Error(`${configLabel}.${path}.${key} must be one of ${allowed.join(", ")}`);
|
||||
return value as T;
|
||||
}
|
||||
|
||||
function quantity(obj: Record<string, unknown>, key: string, path: string): string {
|
||||
const value = y.stringField(obj, key, path);
|
||||
if (!/^[0-9]+(?:m|Ki|Mi|Gi|Ti)?$/u.test(value)) throw new Error(`${configLabel}.${path}.${key} must be a Kubernetes quantity`);
|
||||
|
||||
@@ -42,7 +42,7 @@ interface HostProxyServer {
|
||||
|
||||
interface HostProxySource {
|
||||
id: string;
|
||||
sourceType: "benchmark-validated-master-shadowsocks";
|
||||
sourceType: "benchmark-validated-master-shadowsocks" | "vpn-server-shadowsocks";
|
||||
serverRef: string;
|
||||
benchmarkRef: string;
|
||||
implementationRef: string;
|
||||
@@ -167,7 +167,7 @@ function readHostProxyConfig(): HostProxyConfig {
|
||||
const server = parseServer(record(root.server, `${configPath}.server`), `${configPath}.server`);
|
||||
const sources = Object.fromEntries(Object.entries(record(root.sources, `${configPath}.sources`)).map(([id, value]) => {
|
||||
const source = parseSource(id, record(value, `${configPath}.sources.${id}`));
|
||||
if (source.serverRef !== `server.${server.id}`) throw new Error(`${configPath}.sources.${id}.serverRef must be server.${server.id}`);
|
||||
if (sourceUsesManagedServer(source) && source.serverRef !== `server.${server.id}`) throw new Error(`${configPath}.sources.${id}.serverRef must be server.${server.id}`);
|
||||
return [id, source];
|
||||
}));
|
||||
const targets: Record<string, HostProxyTarget> = {};
|
||||
@@ -232,7 +232,7 @@ function parseServer(raw: Record<string, unknown>, label: string): HostProxyServ
|
||||
}
|
||||
|
||||
function parseSource(id: string, raw: Record<string, unknown>): HostProxySource {
|
||||
const sourceType = enumField(raw, "sourceType", `${configPath}.sources.${id}`, ["benchmark-validated-master-shadowsocks"] as const);
|
||||
const sourceType = enumField(raw, "sourceType", `${configPath}.sources.${id}`, ["benchmark-validated-master-shadowsocks", "vpn-server-shadowsocks"] as const);
|
||||
const sourceConfigRef = stringField(raw, "sourceConfigRef", `${configPath}.sources.${id}`);
|
||||
const resolved = resolveEgressProxySourceRef(sourceConfigRef, `${configPath}.sources.${id}.sourceConfigRef`);
|
||||
if (resolved.sourceType !== "master-shadowsocks" || resolved.masterShadowsocks === null) {
|
||||
@@ -389,7 +389,7 @@ function plan(server: HostProxyServer, target: HostProxyTarget): Record<string,
|
||||
|
||||
function apply(server: HostProxyServer, target: HostProxyTarget): Record<string, unknown> {
|
||||
const material = proxySecretMaterial(server, target.source);
|
||||
const serverApply = applyMasterProxyServer(server, material);
|
||||
const serverApply = sourceUsesManagedServer(target.source) ? applyMasterProxyServer(server, material) : skippedMasterProxyServer(target.source, "apply");
|
||||
const artifact = prepareClientArtifact(target.source.client);
|
||||
const remotePrepare = runTrans(target.route, remotePrepareScript(target.source.client), 30);
|
||||
const remoteArchive = remotePrepare.exitCode === 0
|
||||
@@ -429,7 +429,7 @@ function apply(server: HostProxyServer, target: HostProxyTarget): Record<string,
|
||||
}
|
||||
|
||||
function status(server: HostProxyServer, target: HostProxyTarget): Record<string, unknown> {
|
||||
const serverStatus = masterProxyServerStatus(server);
|
||||
const serverStatus = sourceUsesManagedServer(target.source) ? masterProxyServerStatus(server) : skippedMasterProxyServer(target.source, "status");
|
||||
const result = runTrans(target.route, remoteStatusScript(target), 45);
|
||||
const parsed = parseJson(result.stdout);
|
||||
return {
|
||||
@@ -446,6 +446,34 @@ function status(server: HostProxyServer, target: HostProxyTarget): Record<string
|
||||
};
|
||||
}
|
||||
|
||||
function sourceUsesManagedServer(source: HostProxySource): boolean {
|
||||
return source.sourceType === "benchmark-validated-master-shadowsocks";
|
||||
}
|
||||
|
||||
function skippedMasterProxyServer(source: HostProxySource, mode: "apply" | "status"): Record<string, unknown> {
|
||||
return {
|
||||
ok: true,
|
||||
id: source.id,
|
||||
sourceType: source.sourceType,
|
||||
enabled: false,
|
||||
mode,
|
||||
skipped: true,
|
||||
reason: `source ${source.id} uses externally managed ${source.sourceType} server`,
|
||||
benchmarkRef: source.benchmarkRef,
|
||||
implementationRef: source.implementationRef,
|
||||
sourceConfigRef: source.sourceConfigRef,
|
||||
sourceFingerprint: source.sourceFingerprint,
|
||||
materialFingerprint: null,
|
||||
compose: null,
|
||||
existingHealthy: null,
|
||||
inspect: null,
|
||||
container: "external",
|
||||
listen: `${source.masterShadowsocks.serverHost}:${source.masterShadowsocks.serverPort}`,
|
||||
health: { ok: true, mode: "external", host: "", port: 0 },
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function proxySecretMaterial(server: HostProxyServer, source: HostProxySource): HostProxySecretMaterial {
|
||||
const envSource = readEnvSourceFile({
|
||||
root: rootPath(".state", "secrets"),
|
||||
@@ -951,9 +979,12 @@ function renderApply(result: Record<string, unknown>): RenderedCliResult {
|
||||
const client = record(remote.client);
|
||||
const podProbe = record(remote.podAccessProbe);
|
||||
const probe = record(remote.externalProbe);
|
||||
const serverRows = serverApply.enabled === false
|
||||
? table(["SOURCE", "OK", "UPSTREAM", "TYPE"], [[text(serverApply.id), text(serverApply.ok), text(serverApply.listen), text(serverApply.sourceType)]])
|
||||
: table(["SERVER", "OK", "LISTEN", "BENCHMARK"], [[text(server.id), text(serverApply.ok), text(server.listen), text(server.benchmarkRef)]]);
|
||||
const lines = [
|
||||
"PLATFORM-INFRA HOST PROXY APPLY",
|
||||
...table(["SERVER", "OK", "LISTEN", "BENCHMARK"], [[text(server.id), text(serverApply.ok), text(server.listen), text(server.benchmarkRef)]]),
|
||||
...serverRows,
|
||||
"",
|
||||
...table(["SERVER_HEALTH", "CLIENT_BINARY", "DISTRIBUTION"], [[text(serverHealth.ok), text(artifact.binaryAction), text(distribution.mode)]]),
|
||||
"",
|
||||
@@ -979,9 +1010,12 @@ function renderStatus(result: Record<string, unknown>): RenderedCliResult {
|
||||
const health = record(remote.health);
|
||||
const probe = record(remote.externalProbe);
|
||||
const podProbe = record(remote.podAccessProbe);
|
||||
const serverRows = serverStatus.enabled === false
|
||||
? table(["SOURCE", "OK", "UPSTREAM", "TYPE"], [[text(serverStatus.id), text(serverStatus.ok), text(serverStatus.listen), text(serverStatus.sourceType)]])
|
||||
: table(["SERVER", "OK", "LISTEN", "CONTAINER"], [[text(server.id), text(serverStatus.ok), text(server.listen), text(serverStatus.container)]]);
|
||||
const lines = [
|
||||
"PLATFORM-INFRA HOST PROXY STATUS",
|
||||
...table(["SERVER", "OK", "LISTEN", "CONTAINER"], [[text(server.id), text(serverStatus.ok), text(server.listen), text(serverStatus.container)]]),
|
||||
...serverRows,
|
||||
"",
|
||||
...table(["TARGET", "ROUTE", "OK", "CLIENT"], [[text(target.id), text(target.route), result.ok === false ? "false" : "true", text(client.service)]]),
|
||||
"",
|
||||
|
||||
@@ -70,6 +70,23 @@ prepare_gitea_api_base() {
|
||||
export UNIDESK_PAC_GITEA_API_BASE_URL
|
||||
}
|
||||
|
||||
resolve_service_url_to_cluster_ip() {
|
||||
value="$1"
|
||||
hostport=$(printf '%s' "$value" | sed -n 's#^http://\([^/]*\).*$#\1#p')
|
||||
if ! printf '%s' "$hostport" | grep -q '\.svc\.cluster\.local'; then
|
||||
printf '%s' "$value"
|
||||
return
|
||||
fi
|
||||
host=${hostport%%:*}
|
||||
port=${hostport##*:}
|
||||
if [ "$port" = "$hostport" ]; then port=80; fi
|
||||
service=${host%%.*}
|
||||
rest=${host#*.}
|
||||
namespace=${rest%%.*}
|
||||
cluster_ip=$(kubectl -n "$namespace" get svc "$service" -o jsonpath='{.spec.clusterIP}')
|
||||
printf '%s' "$value" | sed "s#^http://$hostport#http://$cluster_ip:$port#"
|
||||
}
|
||||
|
||||
gitea_api() {
|
||||
method="$1"
|
||||
path="$2"
|
||||
@@ -118,6 +135,7 @@ NODE
|
||||
}
|
||||
|
||||
repository_manifest() {
|
||||
repository_url=$(resolve_service_url_to_cluster_ip "$UNIDESK_PAC_REPOSITORY_URL")
|
||||
cat <<EOF
|
||||
apiVersion: pipelinesascode.tekton.dev/v1alpha1
|
||||
kind: Repository
|
||||
@@ -129,7 +147,7 @@ metadata:
|
||||
app.kubernetes.io/part-of: $UNIDESK_PAC_PART_OF
|
||||
unidesk.ai/spec: $UNIDESK_PAC_SPEC
|
||||
spec:
|
||||
url: $UNIDESK_PAC_REPOSITORY_URL
|
||||
url: $repository_url
|
||||
git_provider:
|
||||
type: gitea
|
||||
url: $UNIDESK_PAC_GITEA_BASE_URL
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
type SshRemoteCommandExecutor,
|
||||
type SshRemoteCommandStreamHandlers,
|
||||
} from "./ssh-file-transfer";
|
||||
import { readHostK8sSshClientToken } from "./host-k8s-config";
|
||||
|
||||
interface FrontendSession {
|
||||
baseUrl: string;
|
||||
@@ -282,7 +283,8 @@ async function loginFrontend(host: string, config: UniDeskConfig): Promise<Front
|
||||
|
||||
function sshClientTokenFromEnv(env: NodeJS.ProcessEnv = process.env): string | null {
|
||||
const token = env.UNIDESK_SSH_CLIENT_TOKEN?.trim() ?? "";
|
||||
return token.length > 0 ? token : null;
|
||||
if (token.length > 0) return token;
|
||||
return readHostK8sSshClientToken();
|
||||
}
|
||||
|
||||
function scopedSshFrontendSession(host: string, config: UniDeskConfig, token: string): FrontendSession {
|
||||
|
||||
@@ -716,16 +716,17 @@ export function readTextFile(path: string): string {
|
||||
return readFileSync(path, "utf8");
|
||||
}
|
||||
|
||||
export function readEnvSourceFile(params: { root: string; sourceRef: string; missingMessage?: (sourcePath: string) => string }): EnvSourceFileMaterial {
|
||||
export function readEnvSourceFile(params: { root: string; sourceRef: string; rawKey?: string; missingMessage?: (sourcePath: string) => string }): EnvSourceFileMaterial {
|
||||
const sourcePath = join(params.root, params.sourceRef);
|
||||
if (!existsSync(sourcePath)) {
|
||||
throw new Error(params.missingMessage?.(sourcePath) ?? `required secret source ${redactRepoPath(sourcePath)} is missing`);
|
||||
}
|
||||
const text = readFileSync(sourcePath, "utf8");
|
||||
return {
|
||||
sourceRef: params.sourceRef,
|
||||
sourcePath,
|
||||
sourcePathRedacted: redactRepoPath(sourcePath),
|
||||
values: parseEnvFile(readFileSync(sourcePath, "utf8")),
|
||||
values: params.rawKey === undefined ? parseEnvFile(text) : { [params.rawKey]: text.trim() },
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { readTransHostProxyEnvRule, type TransHostProxyEnvRule } from "./trans-host-proxy";
|
||||
import { readTransSshBackendConfig, type TransSshBackendConfig } from "./trans-config";
|
||||
import { readCliOutputPolicy } from "./output";
|
||||
import { readHostK8sPublicHost } from "./host-k8s-config";
|
||||
|
||||
export interface ParsedSshArgs {
|
||||
remoteCommand: string | null;
|
||||
@@ -3743,6 +3744,7 @@ function sshCaptureRemoteHost(config: UniDeskConfig, env: NodeJS.ProcessEnv): st
|
||||
return normalizeRemoteHost(env.UNIDESK_MAIN_SERVER_IP)
|
||||
?? normalizeRemoteHost(env.UNIDESK_MAIN_SERVER_HOST)
|
||||
?? normalizeRemoteHost(env.CODE_QUEUE_DEV_CONTAINER_MASTER_HOST)
|
||||
?? normalizeRemoteHost(readHostK8sPublicHost() ?? undefined)
|
||||
?? normalizeRemoteHost(config.network.publicHost);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user