Merge remote-tracking branch 'origin/master' into fix/langbot-cli-n8n-binding

# Conflicts:
#	AGENTS.md
This commit is contained in:
Codex
2026-06-13 06:38:06 +00:00
25 changed files with 4243 additions and 972 deletions
+694
View File
@@ -0,0 +1,694 @@
import { readFileSync } from "node:fs";
import { rootPath } from "./config";
export const AGENTRUN_CONFIG_PATH = "config/agentrun.yaml";
export interface AgentRunGitMirrorRepositorySpec {
readonly key: string;
readonly repository: string;
readonly sourceBranch: string;
readonly gitopsBranch?: string;
}
export interface AgentRunSecretRef {
readonly namespace: string;
readonly name: string;
readonly key: string;
}
export interface AgentRunLaneSecretSpec {
readonly id: string;
readonly sourceRef: string;
readonly sourceKey: string;
readonly targetRef: AgentRunSecretRef;
}
export interface AgentRunLaneSpec {
readonly lane: string;
readonly nodeId: string;
readonly nodeRoute: string;
readonly nodeKubeRoute: string;
readonly version: string;
readonly source: {
readonly repository: string;
readonly branch: string;
readonly bootstrapFromBranch: string | null;
readonly bootstrapTimeoutSeconds: number;
readonly bootstrapPollSeconds: number;
readonly remote: string;
readonly workspace: string;
};
readonly runtime: {
readonly namespace: string;
readonly managerDeployment: string;
readonly managerService: string;
readonly managerPort: number;
readonly internalBaseUrl: string;
};
readonly ci: {
readonly namespace: string;
readonly pipeline: string;
readonly pipelineRunPrefix: string;
readonly serviceAccountName: string;
readonly registryPrefix: string;
readonly toolsImage: string;
};
readonly gitops: {
readonly branch: string;
readonly path: string;
readonly argoNamespace: string;
readonly argoApplication: string;
readonly repoURL: string;
};
readonly deployment: {
readonly format: "unidesk-yaml-only";
readonly gitopsRoot: string;
readonly runtimeRenderDir: string;
readonly artifactCatalogPath: string;
readonly argocd: {
readonly project: string;
readonly applicationFile: string;
};
readonly manager: {
readonly serviceAccount: string;
readonly apiKeySecretRef: { readonly name: string; readonly key: string };
readonly unideskSshEndpointEnv: { readonly name: string; readonly value: string } | null;
readonly bootRepoUrl: string;
readonly imageBuild: AgentRunImageBuildSpec;
readonly resources: AgentRunContainerResources;
};
readonly runner: {
readonly serviceAccount: string;
readonly jobNamePrefix: string;
readonly apiKeySecretRef: { readonly name: string; readonly key: string };
};
readonly localPostgres: {
readonly enabled: boolean;
readonly serviceName: string | null;
readonly image: string | null;
readonly storage: string | null;
readonly port: number | null;
};
};
readonly gitMirror: {
readonly namespace: string;
readonly readService: string;
readonly readDeployment: string;
readonly writeService: string;
readonly writeDeployment: string;
readonly readUrl: string;
readonly writeUrl: string;
readonly cachePvc: string;
readonly cacheHostPath: string | null;
readonly sshSecretName: string;
readonly githubProxy: {
readonly host: string;
readonly port: number;
};
readonly toolsImage: string;
readonly syncJobPrefix: string;
readonly flushJobPrefix: string;
readonly repositories: readonly AgentRunGitMirrorRepositorySpec[];
};
readonly database: {
readonly mode: "local-postgres" | "external-postgres";
readonly provider: string | null;
readonly configRef: string | null;
readonly database: string | null;
readonly user: string | null;
readonly sslmode: "require" | null;
readonly secretSourceRef: string | null;
readonly secretRef: { readonly name: string; readonly key: string };
readonly localPostgresExpectedAbsent: boolean;
};
readonly secrets: readonly AgentRunLaneSecretSpec[];
}
export interface AgentRunContainerResources {
readonly requests: {
readonly cpu: string;
readonly memory: string;
};
readonly limits: {
readonly cpu: string;
readonly memory: string;
};
}
export interface AgentRunImageBuildSpec {
readonly context: string;
readonly containerfile: string;
readonly repository: string;
readonly network: string;
readonly buildArgs: Readonly<Record<string, string>>;
readonly httpProxy: string | null;
readonly httpsProxy: string | null;
readonly noProxy: readonly string[];
readonly envIdentityFiles: readonly string[];
readonly timeoutSeconds: number;
readonly pollSeconds: number;
}
export interface AgentRunLaneTarget {
readonly configPath: string;
readonly spec: AgentRunLaneSpec;
}
interface AgentRunNodeSpec {
readonly id: string;
readonly route: string;
readonly kubeRoute: string;
}
interface AgentRunControlPlaneConfig {
readonly sourcePath: string;
readonly defaultTarget: {
readonly node: string;
readonly lane: string;
};
readonly nodes: Record<string, AgentRunNodeSpec>;
readonly lanes: Record<string, AgentRunLaneSpec>;
}
export function resolveAgentRunLaneTarget(options: { node?: string | null; lane?: string | null }, env: NodeJS.ProcessEnv = process.env): AgentRunLaneTarget {
const config = readAgentRunControlPlaneConfig(env);
const requestedNode = options.node ?? null;
const requestedLane = options.lane ?? null;
if (requestedLane !== null) {
const spec = config.lanes[requestedLane];
if (spec === undefined) throw new Error(`${config.sourcePath}: controlPlane.lanes.${requestedLane} is not declared`);
if (requestedNode !== null && requestedNode !== spec.nodeId) throw new Error(`--node ${requestedNode} does not match controlPlane.lanes.${requestedLane}.node=${spec.nodeId}`);
return { configPath: config.sourcePath, spec };
}
if (requestedNode !== null) {
const candidates = Object.values(config.lanes).filter((lane) => lane.nodeId === requestedNode);
if (candidates.length === 0) throw new Error(`${config.sourcePath}: no AgentRun lane is declared for node ${requestedNode}`);
if (candidates.length > 1) throw new Error(`--lane is required because node ${requestedNode} has multiple AgentRun lanes: ${candidates.map((lane) => lane.lane).join(", ")}`);
return { configPath: config.sourcePath, spec: candidates[0] };
}
const spec = config.lanes[config.defaultTarget.lane];
if (spec === undefined) throw new Error(`${config.sourcePath}: default controlPlane lane ${config.defaultTarget.lane} is not declared`);
if (spec.nodeId !== config.defaultTarget.node) throw new Error(`${config.sourcePath}: controlPlane.default.node does not match default lane node`);
return { configPath: config.sourcePath, spec };
}
export function agentRunLaneSummary(spec: AgentRunLaneSpec): Record<string, unknown> {
return {
node: {
id: spec.nodeId,
route: spec.nodeRoute,
kubeRoute: spec.nodeKubeRoute,
},
lane: spec.lane,
version: spec.version,
source: {
repository: spec.source.repository,
branch: spec.source.branch,
bootstrapFromBranch: spec.source.bootstrapFromBranch,
bootstrapTimeoutSeconds: spec.source.bootstrapTimeoutSeconds,
bootstrapPollSeconds: spec.source.bootstrapPollSeconds,
workspace: spec.source.workspace,
},
runtime: {
namespace: spec.runtime.namespace,
managerDeployment: spec.runtime.managerDeployment,
managerService: spec.runtime.managerService,
managerPort: spec.runtime.managerPort,
internalBaseUrl: spec.runtime.internalBaseUrl,
},
ci: {
namespace: spec.ci.namespace,
pipeline: spec.ci.pipeline,
pipelineRunPrefix: spec.ci.pipelineRunPrefix,
serviceAccountName: spec.ci.serviceAccountName,
registryPrefix: spec.ci.registryPrefix,
},
gitops: {
branch: spec.gitops.branch,
path: spec.gitops.path,
argoNamespace: spec.gitops.argoNamespace,
argoApplication: spec.gitops.argoApplication,
repoURL: spec.gitops.repoURL,
},
deployment: {
format: spec.deployment.format,
gitopsRoot: spec.deployment.gitopsRoot,
runtimeRenderDir: spec.deployment.runtimeRenderDir,
artifactCatalogPath: spec.deployment.artifactCatalogPath,
argocd: spec.deployment.argocd,
manager: {
serviceAccount: spec.deployment.manager.serviceAccount,
apiKeySecretRef: spec.deployment.manager.apiKeySecretRef,
unideskSshEndpointEnv: spec.deployment.manager.unideskSshEndpointEnv === null
? null
: { name: spec.deployment.manager.unideskSshEndpointEnv.name, valuesPrinted: false },
bootRepoUrl: spec.deployment.manager.bootRepoUrl,
imageBuild: {
context: spec.deployment.manager.imageBuild.context,
containerfile: spec.deployment.manager.imageBuild.containerfile,
repository: spec.deployment.manager.imageBuild.repository,
network: spec.deployment.manager.imageBuild.network,
buildArgNames: Object.keys(spec.deployment.manager.imageBuild.buildArgs).sort(),
proxyConfigured: spec.deployment.manager.imageBuild.httpProxy !== null || spec.deployment.manager.imageBuild.httpsProxy !== null,
noProxyCount: spec.deployment.manager.imageBuild.noProxy.length,
envIdentityFileCount: spec.deployment.manager.imageBuild.envIdentityFiles.length,
timeoutSeconds: spec.deployment.manager.imageBuild.timeoutSeconds,
pollSeconds: spec.deployment.manager.imageBuild.pollSeconds,
},
resources: spec.deployment.manager.resources,
},
runner: {
serviceAccount: spec.deployment.runner.serviceAccount,
jobNamePrefix: spec.deployment.runner.jobNamePrefix,
apiKeySecretRef: spec.deployment.runner.apiKeySecretRef,
},
localPostgres: spec.deployment.localPostgres,
},
gitMirror: {
namespace: spec.gitMirror.namespace,
readService: spec.gitMirror.readService,
readDeployment: spec.gitMirror.readDeployment,
writeService: spec.gitMirror.writeService,
writeDeployment: spec.gitMirror.writeDeployment,
readUrl: spec.gitMirror.readUrl,
writeUrl: spec.gitMirror.writeUrl,
cachePvc: spec.gitMirror.cachePvc,
cacheHostPath: spec.gitMirror.cacheHostPath,
sshSecretName: spec.gitMirror.sshSecretName,
githubProxy: { host: spec.gitMirror.githubProxy.host, port: spec.gitMirror.githubProxy.port },
repositories: spec.gitMirror.repositories.map((repo) => ({
key: repo.key,
repository: repo.repository,
sourceBranch: repo.sourceBranch,
gitopsBranch: repo.gitopsBranch ?? null,
})),
},
database: {
mode: spec.database.mode,
provider: spec.database.provider,
configRef: spec.database.configRef,
database: spec.database.database,
user: spec.database.user,
sslmode: spec.database.sslmode,
secretSourceRef: spec.database.secretSourceRef,
secretRef: spec.database.secretRef,
localPostgresExpectedAbsent: spec.database.localPostgresExpectedAbsent,
valuesPrinted: false,
},
secrets: spec.secrets.map((secret) => ({
id: secret.id,
sourceRef: secret.sourceRef.startsWith("/") ? secret.sourceRef : `.state/secrets/${secret.sourceRef}`,
sourceKey: secret.sourceKey,
targetRef: secret.targetRef,
valuesPrinted: false,
})),
};
}
export function agentRunPipelineRunName(spec: AgentRunLaneSpec, sourceCommit: string): string {
return `${spec.ci.pipelineRunPrefix}-${sourceCommit.slice(0, 12)}`;
}
function readAgentRunControlPlaneConfig(env: NodeJS.ProcessEnv): AgentRunControlPlaneConfig {
const configPath = env.AGENTRUN_CONTROL_PLANE_CONFIG ?? rootPath(AGENTRUN_CONFIG_PATH);
const root = asRecord(Bun.YAML.parse(readFileSync(configPath, "utf8")) as unknown, configPath);
const controlPlane = recordField(root, "controlPlane", configPath);
const defaultTarget = recordField(controlPlane, "default", `${configPath}.controlPlane`);
const nodes = parseNodes(recordField(controlPlane, "nodes", `${configPath}.controlPlane`), configPath);
const lanes = parseLanes(recordField(controlPlane, "lanes", `${configPath}.controlPlane`), nodes, configPath);
return {
sourcePath: configPath,
defaultTarget: {
node: stringField(defaultTarget, "node", `${configPath}.controlPlane.default`),
lane: stringField(defaultTarget, "lane", `${configPath}.controlPlane.default`),
},
nodes,
lanes,
};
}
function parseNodes(input: Record<string, unknown>, configPath: string): Record<string, AgentRunNodeSpec> {
const result: Record<string, AgentRunNodeSpec> = {};
for (const [id, raw] of Object.entries(input)) {
validateSimpleId(id, `${configPath}.controlPlane.nodes`);
const node = asRecord(raw, `${configPath}.controlPlane.nodes.${id}`);
result[id] = {
id,
route: stringField(node, "route", `${configPath}.controlPlane.nodes.${id}`),
kubeRoute: stringField(node, "kubeRoute", `${configPath}.controlPlane.nodes.${id}`),
};
}
if (Object.keys(result).length === 0) throw new Error(`${configPath}.controlPlane.nodes must declare at least one node`);
return result;
}
function parseLanes(input: Record<string, unknown>, nodes: Record<string, AgentRunNodeSpec>, configPath: string): Record<string, AgentRunLaneSpec> {
const result: Record<string, AgentRunLaneSpec> = {};
for (const [lane, raw] of Object.entries(input)) {
validateSimpleId(lane, `${configPath}.controlPlane.lanes`);
const item = asRecord(raw, `${configPath}.controlPlane.lanes.${lane}`);
const nodeId = stringField(item, "node", `${configPath}.controlPlane.lanes.${lane}`);
const node = nodes[nodeId];
if (node === undefined) throw new Error(`${configPath}.controlPlane.lanes.${lane}.node references undeclared node ${nodeId}`);
result[lane] = parseLane(lane, node, item, configPath);
}
if (Object.keys(result).length === 0) throw new Error(`${configPath}.controlPlane.lanes must declare at least one lane`);
return result;
}
function parseLane(lane: string, node: AgentRunNodeSpec, input: Record<string, unknown>, configPath: string): AgentRunLaneSpec {
const path = `${configPath}.controlPlane.lanes.${lane}`;
const source = recordField(input, "source", path);
const runtime = recordField(input, "runtime", path);
const ci = recordField(input, "ci", path);
const gitops = recordField(input, "gitops", path);
const deployment = recordField(input, "deployment", path);
const gitMirror = recordField(input, "gitMirror", path);
const database = recordField(input, "database", path);
return {
lane,
nodeId: node.id,
nodeRoute: node.route,
nodeKubeRoute: node.kubeRoute,
version: stringField(input, "version", path),
source: {
repository: stringField(source, "repository", `${path}.source`),
branch: stringField(source, "branch", `${path}.source`),
bootstrapFromBranch: optionalStringField(source, "bootstrapFromBranch", `${path}.source`) ?? null,
bootstrapTimeoutSeconds: integerField(source, "bootstrapTimeoutSeconds", `${path}.source`),
bootstrapPollSeconds: integerField(source, "bootstrapPollSeconds", `${path}.source`),
remote: stringField(source, "remote", `${path}.source`),
workspace: absolutePathField(source, "workspace", `${path}.source`),
},
runtime: {
namespace: stringField(runtime, "namespace", `${path}.runtime`),
managerDeployment: stringField(runtime, "managerDeployment", `${path}.runtime`),
managerService: stringField(runtime, "managerService", `${path}.runtime`),
managerPort: integerField(runtime, "managerPort", `${path}.runtime`),
internalBaseUrl: urlField(runtime, "internalBaseUrl", `${path}.runtime`),
},
ci: {
namespace: stringField(ci, "namespace", `${path}.ci`),
pipeline: stringField(ci, "pipeline", `${path}.ci`),
pipelineRunPrefix: stringField(ci, "pipelineRunPrefix", `${path}.ci`),
serviceAccountName: stringField(ci, "serviceAccountName", `${path}.ci`),
registryPrefix: stringField(ci, "registryPrefix", `${path}.ci`),
toolsImage: stringField(ci, "toolsImage", `${path}.ci`),
},
gitops: {
branch: stringField(gitops, "branch", `${path}.gitops`),
path: relativePathField(gitops, "path", `${path}.gitops`),
argoNamespace: stringField(gitops, "argoNamespace", `${path}.gitops`),
argoApplication: stringField(gitops, "argoApplication", `${path}.gitops`),
repoURL: urlField(gitops, "repoURL", `${path}.gitops`),
},
deployment: parseDeployment(deployment, `${path}.deployment`),
gitMirror: {
namespace: stringField(gitMirror, "namespace", `${path}.gitMirror`),
readService: stringField(gitMirror, "readService", `${path}.gitMirror`),
readDeployment: stringField(gitMirror, "readDeployment", `${path}.gitMirror`),
writeService: stringField(gitMirror, "writeService", `${path}.gitMirror`),
writeDeployment: stringField(gitMirror, "writeDeployment", `${path}.gitMirror`),
readUrl: urlField(gitMirror, "readUrl", `${path}.gitMirror`),
writeUrl: urlField(gitMirror, "writeUrl", `${path}.gitMirror`),
cachePvc: stringField(gitMirror, "cachePvc", `${path}.gitMirror`),
cacheHostPath: optionalAbsolutePathField(gitMirror, "cacheHostPath", `${path}.gitMirror`) ?? null,
sshSecretName: stringField(gitMirror, "sshSecretName", `${path}.gitMirror`),
githubProxy: parseGithubProxy(recordField(gitMirror, "githubProxy", `${path}.gitMirror`), `${path}.gitMirror.githubProxy`),
toolsImage: stringField(gitMirror, "toolsImage", `${path}.gitMirror`),
syncJobPrefix: stringField(gitMirror, "syncJobPrefix", `${path}.gitMirror`),
flushJobPrefix: stringField(gitMirror, "flushJobPrefix", `${path}.gitMirror`),
repositories: arrayField(gitMirror, "repositories", `${path}.gitMirror`).map((repo, index) => parseGitMirrorRepository(repo, `${path}.gitMirror.repositories[${index}]`)),
},
database: parseDatabase(database, `${path}.database`),
secrets: arrayField(input, "secrets", path).map((secret, index) => parseLaneSecret(secret, `${path}.secrets[${index}]`)),
};
}
function parseDeployment(input: Record<string, unknown>, path: string): AgentRunLaneSpec["deployment"] {
const argocd = recordField(input, "argocd", path);
const manager = recordField(input, "manager", path);
const runner = recordField(input, "runner", path);
const localPostgres = recordField(input, "localPostgres", path);
return {
format: enumField(input, "format", path, ["unidesk-yaml-only"]),
gitopsRoot: relativePathField(input, "gitopsRoot", path),
runtimeRenderDir: relativePathField(input, "runtimeRenderDir", path),
artifactCatalogPath: relativePathField(input, "artifactCatalogPath", path),
argocd: {
project: stringField(argocd, "project", `${path}.argocd`),
applicationFile: relativePathField(argocd, "applicationFile", `${path}.argocd`),
},
manager: {
serviceAccount: stringField(manager, "serviceAccount", `${path}.manager`),
apiKeySecretRef: parseSecretRef(recordField(manager, "apiKeySecretRef", `${path}.manager`), `${path}.manager.apiKeySecretRef`),
unideskSshEndpointEnv: optionalEnvPair(manager, "unideskSshEndpointEnv", `${path}.manager`),
bootRepoUrl: urlField(manager, "bootRepoUrl", `${path}.manager`),
imageBuild: parseImageBuild(recordField(manager, "imageBuild", `${path}.manager`), `${path}.manager.imageBuild`),
resources: parseContainerResources(recordField(manager, "resources", `${path}.manager`), `${path}.manager.resources`),
},
runner: {
serviceAccount: stringField(runner, "serviceAccount", `${path}.runner`),
jobNamePrefix: stringField(runner, "jobNamePrefix", `${path}.runner`),
apiKeySecretRef: parseSecretRef(recordField(runner, "apiKeySecretRef", `${path}.runner`), `${path}.runner.apiKeySecretRef`),
},
localPostgres: parseLocalPostgres(localPostgres, `${path}.localPostgres`),
};
}
function parseLocalPostgres(input: Record<string, unknown>, path: string): AgentRunLaneSpec["deployment"]["localPostgres"] {
const enabled = booleanField(input, "enabled", path);
if (!enabled) {
return {
enabled: false,
serviceName: optionalStringField(input, "serviceName", path) ?? null,
image: optionalStringField(input, "image", path) ?? null,
storage: optionalStringField(input, "storage", path) ?? null,
port: optionalIntegerField(input, "port", path) ?? null,
};
}
return {
enabled: true,
serviceName: stringField(input, "serviceName", path),
image: stringField(input, "image", path),
storage: stringField(input, "storage", path),
port: integerField(input, "port", path),
};
}
function parseContainerResources(input: Record<string, unknown>, path: string): AgentRunContainerResources {
const requests = recordField(input, "requests", path);
const limits = recordField(input, "limits", path);
return {
requests: {
cpu: stringField(requests, "cpu", `${path}.requests`),
memory: stringField(requests, "memory", `${path}.requests`),
},
limits: {
cpu: stringField(limits, "cpu", `${path}.limits`),
memory: stringField(limits, "memory", `${path}.limits`),
},
};
}
function parseImageBuild(input: Record<string, unknown>, path: string): AgentRunImageBuildSpec {
return {
context: relativePathField(input, "context", path),
containerfile: relativePathField(input, "containerfile", path),
repository: stringField(input, "repository", path),
network: stringField(input, "network", path),
buildArgs: stringRecordField(recordField(input, "buildArgs", path), `${path}.buildArgs`),
httpProxy: optionalStringField(input, "httpProxy", path) ?? null,
httpsProxy: optionalStringField(input, "httpsProxy", path) ?? null,
noProxy: stringArrayField(input, "noProxy", path),
envIdentityFiles: stringArrayField(input, "envIdentityFiles", path).map((item, index) => {
if (item.startsWith("/") || item.includes("..")) throw new Error(`${path}.envIdentityFiles[${index}] must be a relative path without ..`);
return item;
}),
timeoutSeconds: integerField(input, "timeoutSeconds", path),
pollSeconds: integerField(input, "pollSeconds", path),
};
}
function parseLaneSecret(input: Record<string, unknown>, path: string): AgentRunLaneSecretSpec {
return {
id: stringField(input, "id", path),
sourceRef: secretSourceRefField(input, "sourceRef", path),
sourceKey: stringField(input, "sourceKey", path),
targetRef: parseNamespacedSecretRef(recordField(input, "targetRef", path), `${path}.targetRef`),
};
}
function parseGitMirrorRepository(input: Record<string, unknown>, path: string): AgentRunGitMirrorRepositorySpec {
const gitopsBranch = optionalStringField(input, "gitopsBranch", path);
return {
key: stringField(input, "key", path),
repository: stringField(input, "repository", path),
sourceBranch: stringField(input, "sourceBranch", path),
...(gitopsBranch === undefined ? {} : { gitopsBranch }),
};
}
function parseDatabase(input: Record<string, unknown>, path: string): AgentRunLaneSpec["database"] {
const mode = enumField(input, "mode", path, ["local-postgres", "external-postgres"]);
const sslmode = optionalStringField(input, "sslmode", path);
if (sslmode !== undefined && sslmode !== "require") throw new Error(`${path}.sslmode must be require when set`);
return {
mode,
provider: optionalStringField(input, "provider", path) ?? null,
configRef: optionalStringField(input, "configRef", path) ?? null,
database: optionalStringField(input, "database", path) ?? null,
user: optionalStringField(input, "user", path) ?? null,
sslmode: sslmode === undefined ? null : "require",
secretSourceRef: optionalStringField(input, "secretSourceRef", path) ?? null,
secretRef: parseSecretRef(recordField(input, "secretRef", path), `${path}.secretRef`),
localPostgresExpectedAbsent: booleanField(input, "localPostgresExpectedAbsent", path),
};
}
function parseSecretRef(input: Record<string, unknown>, path: string): { name: string; key: string } {
return {
name: stringField(input, "name", path),
key: stringField(input, "key", path),
};
}
function parseNamespacedSecretRef(input: Record<string, unknown>, path: string): AgentRunSecretRef {
return {
namespace: stringField(input, "namespace", path),
name: stringField(input, "name", path),
key: stringField(input, "key", path),
};
}
function parseGithubProxy(input: Record<string, unknown>, path: string): { host: string; port: number } {
return {
host: stringField(input, "host", path),
port: integerField(input, "port", path),
};
}
function optionalEnvPair(obj: Record<string, unknown>, key: string, path: string): { name: string; value: string } | null {
const value = obj[key];
if (value === undefined || value === null) return null;
const record = asRecord(value, `${path}.${key}`);
return {
name: stringField(record, "name", `${path}.${key}`),
value: stringField(record, "value", `${path}.${key}`),
};
}
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 recordField(obj: Record<string, unknown>, key: string, path: string): Record<string, unknown> {
return asRecord(obj[key], `${path}.${key}`);
}
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 optionalStringField(obj: Record<string, unknown>, key: string, path: string): string | undefined {
const value = obj[key];
if (value === undefined || value === null) return undefined;
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path}.${key} must be a non-empty string when set`);
return value.trim();
}
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;
}
function integerField(obj: Record<string, unknown>, key: string, path: string): number {
const value = obj[key];
if (!Number.isInteger(value)) throw new Error(`${path}.${key} must be an integer`);
return Number(value);
}
function optionalIntegerField(obj: Record<string, unknown>, key: string, path: string): number | undefined {
const value = obj[key];
if (value === undefined || value === null) return undefined;
if (!Number.isInteger(value)) throw new Error(`${path}.${key} must be an integer when set`);
return Number(value);
}
function arrayField(obj: Record<string, unknown>, key: string, path: string): Record<string, unknown>[] {
const value = obj[key];
if (!Array.isArray(value)) throw new Error(`${path}.${key} must be a YAML array`);
return value.map((item, index) => asRecord(item, `${path}.${key}[${index}]`));
}
function stringArrayField(obj: Record<string, unknown>, key: string, path: string): string[] {
const value = obj[key];
if (!Array.isArray(value)) throw new Error(`${path}.${key} must be a YAML array`);
return value.map((item, index) => {
if (typeof item !== "string" || item.trim().length === 0) throw new Error(`${path}.${key}[${index}] must be a non-empty string`);
return item.trim();
});
}
function stringRecordField(obj: Record<string, unknown>, path: string): Readonly<Record<string, string>> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(obj)) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) throw new Error(`${path}.${key} must be a valid build arg name`);
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
result[key] = value.trim();
}
return result;
}
function enumField<T extends string>(obj: Record<string, unknown>, key: string, path: string, values: readonly T[]): T {
const value = stringField(obj, key, path);
if (!values.includes(value as T)) throw new Error(`${path}.${key} must be one of ${values.join(", ")}`);
return value as T;
}
function absolutePathField(obj: Record<string, unknown>, key: string, path: string): string {
const value = stringField(obj, key, path);
if (!value.startsWith("/") || value.includes("..")) throw new Error(`${path}.${key} must be an absolute path without ..`);
return value;
}
function optionalAbsolutePathField(obj: Record<string, unknown>, key: string, path: string): string | undefined {
const value = obj[key];
if (value === undefined || value === null) return undefined;
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path}.${key} must be a non-empty string when set`);
const trimmed = value.trim();
if (!trimmed.startsWith("/") || trimmed.includes("..")) throw new Error(`${path}.${key} must be an absolute path without ..`);
return trimmed;
}
function relativePathField(obj: Record<string, unknown>, key: string, path: string): string {
const value = stringField(obj, key, path);
if (value.startsWith("/") || value.includes("..")) throw new Error(`${path}.${key} must be a relative path without ..`);
return value;
}
function secretSourceRefField(obj: Record<string, unknown>, key: string, path: string): string {
const value = stringField(obj, key, path);
if (value.includes("..")) throw new Error(`${path}.${key} must not contain ..`);
if (!value.startsWith("/") && value.startsWith(".")) throw new Error(`${path}.${key} must be absolute or relative without a leading dot`);
return value;
}
function urlField(obj: Record<string, unknown>, key: string, path: string): string {
const value = stringField(obj, key, path);
try {
const parsed = new URL(value);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("unsupported protocol");
} catch {
throw new Error(`${path}.${key} must be an http(s) URL`);
}
return value;
}
function validateSimpleId(value: string, path: string): void {
if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${path}.${value} must use a simple id`);
}
+489
View File
@@ -0,0 +1,489 @@
import { createHash } from "node:crypto";
import type { AgentRunLaneSpec } from "./agentrun-lanes";
export interface AgentRunArtifactService {
readonly serviceId: string;
readonly image: string;
readonly digest: string;
readonly repositoryDigest: string;
readonly imageTag: string;
readonly artifactKind: string;
readonly status: string;
readonly envIdentity: string;
readonly envImage: string;
readonly envDigest: string;
readonly envRepositoryDigest: string;
readonly bootCommit: string;
readonly bootScript: string;
readonly provenance: Record<string, unknown>;
}
export interface AgentRunArtifactCatalog {
readonly lane: string;
readonly sourceBranch: string;
readonly gitopsBranch: string;
readonly sourceCommitId: string;
readonly summary: string;
readonly services: readonly AgentRunArtifactService[];
}
export interface AgentRunGitopsRenderInput {
readonly sourceCommit: string;
readonly image: AgentRunArtifactService;
}
export interface AgentRunRenderedFile {
readonly path: string;
readonly content: string;
}
export function renderAgentRunControlPlaneManifests(spec: AgentRunLaneSpec): readonly Record<string, unknown>[] {
return [
{ apiVersion: "v1", kind: "Namespace", metadata: { name: spec.ci.namespace } },
{
apiVersion: "v1",
kind: "ServiceAccount",
metadata: {
name: spec.ci.serviceAccountName,
namespace: spec.ci.namespace,
labels: agentRunLabels(spec),
},
},
{
apiVersion: "rbac.authorization.k8s.io/v1",
kind: "Role",
metadata: {
name: spec.ci.serviceAccountName,
namespace: spec.ci.namespace,
labels: agentRunLabels(spec),
},
rules: [
{ apiGroups: ["tekton.dev"], resources: ["pipelineruns", "taskruns"], verbs: ["get", "list", "watch", "create", "patch", "update"] },
{ apiGroups: [""], resources: ["pods", "pods/log", "secrets", "configmaps", "persistentvolumeclaims"], verbs: ["get", "list", "watch", "create", "patch", "update", "delete"] },
],
},
{
apiVersion: "rbac.authorization.k8s.io/v1",
kind: "RoleBinding",
metadata: {
name: spec.ci.serviceAccountName,
namespace: spec.ci.namespace,
labels: agentRunLabels(spec),
},
subjects: [{ kind: "ServiceAccount", name: spec.ci.serviceAccountName }],
roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: spec.ci.serviceAccountName },
},
agentRunPipelineManifest(spec),
agentRunArgoProjectManifest(spec),
agentRunArgoApplicationManifest(spec),
];
}
export function renderAgentRunGitopsFiles(spec: AgentRunLaneSpec, input: AgentRunGitopsRenderInput): readonly AgentRunRenderedFile[] {
const catalog = agentRunArtifactCatalog(spec, input.sourceCommit, input.image);
const source = {
lane: spec.version,
sourceCommit: input.sourceCommit,
generatedBy: "unidesk config/agentrun.yaml",
configSource: "config/agentrun.yaml",
};
return [
{ path: "source.json", content: `${JSON.stringify(source, null, 2)}\n` },
{ path: spec.deployment.artifactCatalogPath, content: `${JSON.stringify(catalog, null, 2)}\n` },
{ path: `${spec.deployment.gitopsRoot}/argocd/project.yaml`, content: yaml(agentRunArgoProjectManifest(spec)) },
{ path: `${spec.deployment.gitopsRoot}/argocd/${spec.deployment.argocd.applicationFile}`, content: yaml(agentRunArgoApplicationManifest(spec)) },
{ path: `${spec.deployment.gitopsRoot}/${spec.deployment.runtimeRenderDir}/kustomization.yaml`, content: yaml(agentRunKustomizationManifest(spec)) },
{ path: `${spec.deployment.gitopsRoot}/${spec.deployment.runtimeRenderDir}/namespace.yaml`, content: yaml(agentRunRuntimeNamespaceManifest(spec)) },
...(spec.deployment.localPostgres.enabled ? [{ path: `${spec.deployment.gitopsRoot}/${spec.deployment.runtimeRenderDir}/postgres.yaml`, content: yaml(agentRunPostgresManifest(spec)) }] : []),
{ path: `${spec.deployment.gitopsRoot}/${spec.deployment.runtimeRenderDir}/mgr.yaml`, content: yamlAll(agentRunManagerManifests(spec, input.sourceCommit, input.image)) },
{ path: `${spec.deployment.gitopsRoot}/${spec.deployment.runtimeRenderDir}/runner-rbac.yaml`, content: yamlAll(agentRunRunnerRbacManifests(spec)) },
];
}
export function placeholderAgentRunImage(spec: AgentRunLaneSpec, sourceCommit: string): AgentRunArtifactService {
const digest = `sha256:${"0".repeat(64)}`;
const image = `${spec.ci.registryPrefix}/agentrun-mgr-env:${sourceCommit}`;
return {
serviceId: "agentrun-mgr",
artifactKind: "env-reuse",
status: "placeholder",
image,
digest,
repositoryDigest: `${spec.ci.registryPrefix}/agentrun-mgr-env@${digest}`,
imageTag: sourceCommit,
envIdentity: sourceCommit,
envImage: image,
envDigest: digest,
envRepositoryDigest: `${spec.ci.registryPrefix}/agentrun-mgr-env@${digest}`,
bootCommit: sourceCommit,
bootScript: "deploy/runtime/boot/agentrun-boot.sh",
provenance: {
sourceCommitId: sourceCommit,
source: "placeholder",
valuesPrinted: false,
},
};
}
export function agentRunImageArtifact(spec: AgentRunLaneSpec, input: {
sourceCommit: string;
envIdentity: string;
digest: string;
status: string;
}): AgentRunArtifactService {
const image = `${spec.ci.registryPrefix}/${spec.deployment.manager.imageBuild.repository}:${input.envIdentity}`;
return {
serviceId: "agentrun-mgr",
artifactKind: "env-reuse",
status: input.status,
image,
digest: input.digest,
repositoryDigest: `${spec.ci.registryPrefix}/${spec.deployment.manager.imageBuild.repository}@${input.digest}`,
imageTag: input.envIdentity,
envIdentity: input.envIdentity,
envImage: image,
envDigest: input.digest,
envRepositoryDigest: `${spec.ci.registryPrefix}/${spec.deployment.manager.imageBuild.repository}@${input.digest}`,
bootCommit: input.sourceCommit,
bootScript: "deploy/runtime/boot/agentrun-boot.sh",
provenance: {
sourceCommitId: input.sourceCommit,
source: "unidesk-yaml-only",
configSource: "config/agentrun.yaml",
valuesPrinted: false,
},
};
}
export function renderedFilesDigest(files: readonly AgentRunRenderedFile[]): string {
const hash = createHash("sha256");
for (const file of [...files].sort((left, right) => left.path.localeCompare(right.path))) {
hash.update(file.path);
hash.update("\0");
hash.update(file.content);
hash.update("\0");
}
return `sha256:${hash.digest("hex")}`;
}
export function renderedObjectsDigest(objects: readonly Record<string, unknown>[]): string {
return `sha256:${createHash("sha256").update(yamlAll(objects)).digest("hex")}`;
}
function agentRunPipelineManifest(spec: AgentRunLaneSpec): Record<string, unknown> {
return {
apiVersion: "tekton.dev/v1",
kind: "Pipeline",
metadata: {
name: spec.ci.pipeline,
namespace: spec.ci.namespace,
labels: agentRunLabels(spec),
},
spec: {
params: [
{ name: "git-url", type: "string", default: spec.source.remote },
{ name: "git-read-url", type: "string", default: spec.gitMirror.readUrl },
{ name: "git-write-url", type: "string", default: spec.gitMirror.writeUrl },
{ name: "source-branch", type: "string", default: spec.source.branch },
{ name: "gitops-branch", type: "string", default: spec.gitops.branch },
{ name: "revision", type: "string" },
{ name: "registry-prefix", type: "string", default: spec.ci.registryPrefix },
{ name: "tools-image", type: "string", default: spec.ci.toolsImage },
],
workspaces: [{ name: "source" }, { name: "git-ssh" }],
tasks: [
gitopsSmokeTask(spec),
],
},
};
}
function gitopsSmokeTask(spec: AgentRunLaneSpec): Record<string, unknown> {
return {
name: "render-smoke",
workspaces: [{ name: "source", workspace: "source" }],
taskSpec: {
params: [{ name: "revision" }, { name: "tools-image" }],
workspaces: [{ name: "source" }],
steps: [
{
name: "render-smoke",
image: "$(params.tools-image)",
script: [
"#!/bin/sh",
"set -eu",
"echo '{\"event\":\"agentrun-ci-render-smoke\",\"status\":\"placeholder\",\"reason\":\"unidesk-yaml-only-control-plane\",\"valuesPrinted\":false}'",
].join("\n"),
},
],
},
params: [
{ name: "revision", value: "$(params.revision)" },
{ name: "tools-image", value: "$(params.tools-image)" },
],
when: [{ input: spec.deployment.format, operator: "in", values: ["unidesk-yaml-only"] }],
};
}
function agentRunArgoProjectManifest(spec: AgentRunLaneSpec): Record<string, unknown> {
return {
apiVersion: "argoproj.io/v1alpha1",
kind: "AppProject",
metadata: {
name: spec.deployment.argocd.project,
namespace: spec.gitops.argoNamespace,
labels: agentRunLabels(spec),
},
spec: {
description: `AgentRun ${spec.version} GitOps lane`,
sourceRepos: [spec.gitops.repoURL, spec.source.remote],
destinations: [{ server: "https://kubernetes.default.svc", namespace: spec.runtime.namespace }],
clusterResourceWhitelist: [{ group: "", kind: "Namespace" }],
namespaceResourceWhitelist: [{ group: "*", kind: "*" }],
},
};
}
function agentRunArgoApplicationManifest(spec: AgentRunLaneSpec): Record<string, unknown> {
return {
apiVersion: "argoproj.io/v1alpha1",
kind: "Application",
metadata: {
name: spec.gitops.argoApplication,
namespace: spec.gitops.argoNamespace,
labels: agentRunLabels(spec),
},
spec: {
project: spec.deployment.argocd.project,
source: {
repoURL: spec.gitops.repoURL,
targetRevision: spec.gitops.branch,
path: spec.gitops.path,
},
destination: {
server: "https://kubernetes.default.svc",
namespace: spec.runtime.namespace,
},
syncPolicy: {
automated: { prune: false, selfHeal: true },
syncOptions: ["CreateNamespace=true", "ApplyOutOfSyncOnly=true"],
},
},
};
}
function agentRunKustomizationManifest(spec: AgentRunLaneSpec): Record<string, unknown> {
return {
apiVersion: "kustomize.config.k8s.io/v1beta1",
kind: "Kustomization",
resources: [
"namespace.yaml",
...(spec.deployment.localPostgres.enabled ? ["postgres.yaml"] : []),
"mgr.yaml",
"runner-rbac.yaml",
],
};
}
function agentRunRuntimeNamespaceManifest(spec: AgentRunLaneSpec): Record<string, unknown> {
return {
apiVersion: "v1",
kind: "Namespace",
metadata: {
name: spec.runtime.namespace,
labels: agentRunLabels(spec),
},
};
}
function agentRunPostgresManifest(spec: AgentRunLaneSpec): Record<string, unknown> {
const localPostgres = spec.deployment.localPostgres;
if (!localPostgres.enabled || localPostgres.serviceName === null || localPostgres.image === null || localPostgres.storage === null || localPostgres.port === null) {
throw new Error(`localPostgres is enabled for ${spec.version} without renderable YAML fields`);
}
const name = localPostgres.serviceName;
return {
apiVersion: "v1",
kind: "List",
items: [
{
apiVersion: "v1",
kind: "Service",
metadata: { name, namespace: spec.runtime.namespace, labels: agentRunLabels(spec) },
spec: { selector: { "app.kubernetes.io/name": name }, ports: [{ name: "postgres", port: localPostgres.port, targetPort: "postgres" }] },
},
{
apiVersion: "apps/v1",
kind: "StatefulSet",
metadata: { name, namespace: spec.runtime.namespace, labels: agentRunLabels(spec) },
spec: {
serviceName: name,
replicas: 1,
selector: { matchLabels: { "app.kubernetes.io/name": name } },
template: {
metadata: { labels: { ...agentRunLabels(spec), "app.kubernetes.io/name": name } },
spec: {
containers: [
{
name: "postgres",
image: localPostgres.image,
ports: [{ name: "postgres", containerPort: localPostgres.port }],
},
],
},
},
volumeClaimTemplates: [
{
metadata: { name: "data" },
spec: { accessModes: ["ReadWriteOnce"], resources: { requests: { storage: localPostgres.storage } } },
},
],
},
},
],
};
}
function agentRunManagerManifests(spec: AgentRunLaneSpec, sourceCommit: string, image: AgentRunArtifactService): readonly Record<string, unknown>[] {
const imageRef = image.envRepositoryDigest || image.repositoryDigest;
return [
{ apiVersion: "v1", kind: "ServiceAccount", metadata: { name: spec.deployment.manager.serviceAccount, namespace: spec.runtime.namespace, labels: agentRunLabels(spec) } },
{
apiVersion: "v1",
kind: "Service",
metadata: { name: spec.runtime.managerService, namespace: spec.runtime.namespace, labels: agentRunLabels(spec) },
spec: {
selector: { "app.kubernetes.io/name": spec.runtime.managerDeployment },
ports: [{ name: "http", port: spec.runtime.managerPort, targetPort: "http" }],
},
},
{
apiVersion: "apps/v1",
kind: "Deployment",
metadata: { name: spec.runtime.managerDeployment, namespace: spec.runtime.namespace, labels: agentRunLabels(spec) },
spec: {
replicas: 1,
selector: { matchLabels: { "app.kubernetes.io/name": spec.runtime.managerDeployment } },
template: {
metadata: {
labels: { ...agentRunLabels(spec), "app.kubernetes.io/name": spec.runtime.managerDeployment },
annotations: {
"agentrun.pikastech.local/lane": spec.version,
"agentrun.pikastech.local/source-commit": sourceCommit,
"agentrun.pikastech.local/env-identity": image.envIdentity,
},
},
spec: {
serviceAccountName: spec.deployment.manager.serviceAccount,
containers: [
{
name: "mgr",
image: imageRef,
imagePullPolicy: "IfNotPresent",
ports: [{ name: "http", containerPort: 8080 }],
env: managerEnv(spec, sourceCommit, imageRef, image.envIdentity),
readinessProbe: { httpGet: { path: "/health/readiness", port: "http" } },
livenessProbe: { httpGet: { path: "/health/live", port: "http" } },
resources: spec.deployment.manager.resources,
},
],
},
},
},
},
{
apiVersion: "rbac.authorization.k8s.io/v1",
kind: "Role",
metadata: { name: `${spec.deployment.manager.serviceAccount}-runner-job-controller`, namespace: spec.runtime.namespace, labels: agentRunLabels(spec) },
rules: [
{ apiGroups: ["batch"], resources: ["jobs"], verbs: ["create", "get", "list", "watch"] },
{ apiGroups: [""], resources: ["pods"], verbs: ["get", "list", "watch"] },
{ apiGroups: [""], resources: ["persistentvolumeclaims"], verbs: ["create", "get", "list", "watch", "delete"] },
],
},
{
apiVersion: "rbac.authorization.k8s.io/v1",
kind: "RoleBinding",
metadata: { name: `${spec.deployment.manager.serviceAccount}-runner-job-controller`, namespace: spec.runtime.namespace, labels: agentRunLabels(spec) },
subjects: [{ kind: "ServiceAccount", name: spec.deployment.manager.serviceAccount }],
roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: `${spec.deployment.manager.serviceAccount}-runner-job-controller` },
},
{
apiVersion: "rbac.authorization.k8s.io/v1",
kind: "Role",
metadata: { name: `${spec.deployment.manager.serviceAccount}-provider-secret-manager`, namespace: spec.runtime.namespace, labels: agentRunLabels(spec) },
rules: [{ apiGroups: [""], resources: ["secrets"], verbs: ["create", "delete", "get", "list", "patch", "update"] }],
},
{
apiVersion: "rbac.authorization.k8s.io/v1",
kind: "RoleBinding",
metadata: { name: `${spec.deployment.manager.serviceAccount}-provider-secret-manager`, namespace: spec.runtime.namespace, labels: agentRunLabels(spec) },
subjects: [{ kind: "ServiceAccount", name: spec.deployment.manager.serviceAccount }],
roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: `${spec.deployment.manager.serviceAccount}-provider-secret-manager` },
},
];
}
function managerEnv(spec: AgentRunLaneSpec, sourceCommit: string, imageRef: string, envIdentity: string): readonly Record<string, unknown>[] {
return [
{ name: "AGENTRUN_LANE", value: spec.version },
{ name: "DATABASE_URL", valueFrom: { secretKeyRef: spec.database.secretRef } },
{ name: "AGENTRUN_SOURCE_COMMIT", value: sourceCommit },
{ name: "AGENTRUN_BOOT_COMMIT", value: sourceCommit },
{ name: "AGENTRUN_BOOT_MODE", value: "mgr" },
{ name: "AGENTRUN_BOOT_REPO_URL", value: spec.deployment.manager.bootRepoUrl },
{ name: "AGENTRUN_ENV_IDENTITY", value: envIdentity },
{ name: "AGENTRUN_RUNTIME_NAMESPACE", value: spec.runtime.namespace },
{ name: "AGENTRUN_INTERNAL_MGR_URL", value: spec.runtime.internalBaseUrl },
{ name: "AGENTRUN_RUNNER_IMAGE", value: imageRef },
{ name: "AGENTRUN_RUNNER_SERVICE_ACCOUNT", value: spec.deployment.runner.serviceAccount },
{ name: "AGENTRUN_API_KEY", valueFrom: { secretKeyRef: spec.deployment.manager.apiKeySecretRef } },
...(spec.deployment.manager.unideskSshEndpointEnv === null ? [] : [{ name: spec.deployment.manager.unideskSshEndpointEnv.name, value: spec.deployment.manager.unideskSshEndpointEnv.value }]),
];
}
function agentRunRunnerRbacManifests(spec: AgentRunLaneSpec): readonly Record<string, unknown>[] {
return [
{ apiVersion: "v1", kind: "ServiceAccount", metadata: { name: spec.deployment.runner.serviceAccount, namespace: spec.runtime.namespace, labels: agentRunLabels(spec) } },
{
apiVersion: "rbac.authorization.k8s.io/v1",
kind: "Role",
metadata: { name: `${spec.deployment.runner.serviceAccount}-secret-reader`, namespace: spec.runtime.namespace, labels: agentRunLabels(spec) },
rules: [{ apiGroups: [""], resources: ["secrets"], verbs: ["get"] }],
},
{
apiVersion: "rbac.authorization.k8s.io/v1",
kind: "RoleBinding",
metadata: { name: `${spec.deployment.runner.serviceAccount}-secret-reader`, namespace: spec.runtime.namespace, labels: agentRunLabels(spec) },
subjects: [{ kind: "ServiceAccount", name: spec.deployment.runner.serviceAccount }],
roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: `${spec.deployment.runner.serviceAccount}-secret-reader` },
},
];
}
function agentRunArtifactCatalog(spec: AgentRunLaneSpec, sourceCommit: string, image: AgentRunArtifactService): AgentRunArtifactCatalog {
return {
lane: spec.version,
sourceBranch: spec.source.branch,
gitopsBranch: spec.gitops.branch,
sourceCommitId: sourceCommit,
summary: image.status === "placeholder" ? "build=0 reuse=0 placeholder=1" : "build=1 reuse=0 placeholder=0",
services: [image],
};
}
function agentRunLabels(spec: AgentRunLaneSpec): Record<string, string> {
return {
"app.kubernetes.io/part-of": "agentrun",
"agentrun.pikastech.local/lane": spec.version,
"agentrun.pikastech.local/node": spec.nodeId,
};
}
function yaml(value: unknown): string {
return `${Bun.YAML.stringify(value).trim()}\n`;
}
function yamlAll(values: readonly unknown[]): string {
return `${values.map((value) => Bun.YAML.stringify(value).trim()).join("\n---\n")}\n`;
}
+1503 -5
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+88 -16
View File
@@ -47,7 +47,11 @@ interface RuntimeSecretSpec {
lane: string;
namespace: string;
platformDb: boolean;
runtimeLaneSpec?: HwlabRuntimeLaneSpec;
externalPostgres?: NonNullable<HwlabRuntimeLaneSpec["externalPostgres"]>;
platformPostgresService: string;
platformPostgresEndpointAddress?: string;
platformPostgresEndpointSlice: string;
postgresSecret: string;
postgresStatefulSet: string;
postgresAdminUser: string;
@@ -2959,7 +2963,7 @@ function parseSecretOptions(args: string[]): NodeSecretOptions {
}
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) {
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 {
@@ -2980,7 +2984,7 @@ function parseSecretOptions(args: string[]): NodeSecretOptions {
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) {
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 {
@@ -2998,32 +3002,43 @@ function parseSecretOptions(args: string[]): NodeSecretOptions {
function runtimeSecretSpec(input: { node: string; lane: string }): RuntimeSecretSpec {
const namespace = `hwlab-${input.lane}`;
const platformDb = /^v0*[3-9]\d*$/.test(input.lane);
const platformPostgresService = "g14-platform-postgres";
const runtimeLaneSpec = isHwlabRuntimeLane(input.lane) ? hwlabRuntimeLaneSpecForNode(input.lane, input.node) : undefined;
const externalPostgres = runtimeLaneSpec?.externalPostgres;
const platformDb = externalPostgres !== undefined || /^v0*[3-9]\d*$/.test(input.lane);
const platformPostgresService = externalPostgres?.serviceName ?? "g14-platform-postgres";
const legacyPostgresHost = `${namespace}-postgres.${namespace}.svc.cluster.local`;
const platformPostgresHost = `${platformPostgresService}.${namespace}.svc.cluster.local`;
const platformPostgresEndpointSlice = `${platformPostgresService}-host`;
const openFgaDbName = externalPostgres?.database ?? (platformDb ? `openfga_${input.lane}` : "hwlab_openfga");
const openFgaDbUser = externalPostgres?.openfga.role ?? (platformDb ? `openfga_${input.lane}_app` : "hwlab_openfga");
const cloudApiDbName = externalPostgres?.database ?? `hwlab_${input.lane}`;
const cloudApiDbUser = externalPostgres?.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: externalPostgres?.endpointAddress,
platformPostgresEndpointSlice,
postgresSecret: `${namespace}-postgres`,
postgresStatefulSet: `${namespace}-postgres`,
postgresAdminUser: `hwlab_${input.lane}`,
openFgaSecret: `${namespace}-openfga`,
openFgaDbName: platformDb ? `openfga_${input.lane}` : "hwlab_openfga",
openFgaDbUser: platformDb ? `openfga_${input.lane}_app` : "hwlab_openfga",
openFgaSecret: externalPostgres?.openfga.secretName ?? `${namespace}-openfga`,
openFgaDbName,
openFgaDbUser,
openFgaDbHost: platformDb ? platformPostgresHost : legacyPostgresHost,
masterAdminApiKeySecret: `${namespace}-master-server-admin-api-key`,
bootstrapAdminSecret: `${namespace}-bootstrap-admin`,
bootstrapAdminPasswordHashKey: BOOTSTRAP_ADMIN_PASSWORD_HASH_KEY,
bootstrapAdminSourceNamespace: BOOTSTRAP_ADMIN_SOURCE_NAMESPACE,
bootstrapAdminSourceSecret: BOOTSTRAP_ADMIN_SOURCE_SECRET,
cloudApiDbSecret: `hwlab-cloud-api-${input.lane}-db`,
cloudApiDbKey: CLOUD_API_DB_KEY,
cloudApiDbName: `hwlab_${input.lane}`,
cloudApiDbUser: platformDb ? `hwlab_${input.lane}_app` : `hwlab_${input.lane}`,
cloudApiDbSecret: externalPostgres?.cloudApi.secretName ?? `hwlab-cloud-api-${input.lane}-db`,
cloudApiDbKey: externalPostgres?.cloudApi.secretKey ?? CLOUD_API_DB_KEY,
cloudApiDbName,
cloudApiDbUser,
cloudApiDbHost: platformDb ? platformPostgresHost : legacyPostgresHost,
cloudApiDeployment: "hwlab-cloud-api",
obsoleteHwpodDbSecret: `hwpod-${input.lane}-db`,
@@ -3039,6 +3054,9 @@ function runtimeSecretSpec(input: { node: string; lane: string }): RuntimeSecret
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 input = options.preset === "master-server-admin-api-key" && options.action === "ensure" && !options.dryRun
? readMasterAdminApiKey().key
: "";
@@ -3084,6 +3102,9 @@ function nextSecretCommand(options: NodeSecretOptions, spec: RuntimeSecretSpec):
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}` : ""}`,
@@ -3093,6 +3114,50 @@ function nextSecretCommand(options: NodeSecretOptions, spec: RuntimeSecretSpec):
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` };
}
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),
};
}
function publicExposureSummary(exposure: HwlabRuntimePublicExposureSpec): Record<string, unknown> {
return {
mode: exposure.mode,
@@ -3921,7 +3986,7 @@ function endpointBridgeScript(options: { lane: HwlabRuntimeLane; dryRun: boolean
function ownedPostgresCleanupScript(options: NodeSecretOptions, spec: RuntimeSecretSpec): string {
const pvc = `data-${spec.postgresSecret}-0`;
const platformService = "g14-platform-postgres";
const platformService = spec.platformPostgresService;
const postgresService = spec.postgresSecret;
const postgresConfigMap = `${spec.postgresSecret}-init`;
return [
@@ -4176,12 +4241,14 @@ function obsoletePlatformDbCleanupScript(options: NodeSecretOptions, spec: Runti
function platformDbSecretStatusScript(options: NodeSecretOptions, spec: RuntimeSecretSpec): string {
const isOpenFga = options.preset === "openfga";
const platformEndpointSlice = `${spec.platformPostgresService}-host`;
const platformEndpointSlice = spec.platformPostgresEndpointSlice;
const expectedUriHost = spec.platformPostgresEndpointAddress ?? (isOpenFga ? spec.openFgaDbHost : spec.cloudApiDbHost);
const databaseUrlKey = isOpenFga ? spec.externalPostgres?.openfga.secretKey ?? OPENFGA_DATASTORE_URI_KEY : spec.cloudApiDbKey;
return [
"set +e",
`namespace=${shellQuote(spec.namespace)}`,
`name=${shellQuote(isOpenFga ? spec.openFgaSecret : spec.cloudApiDbSecret)}`,
`database_url_key=${shellQuote(isOpenFga ? OPENFGA_DATASTORE_URI_KEY : spec.cloudApiDbKey)}`,
`database_url_key=${shellQuote(databaseUrlKey)}`,
`authn_key=${shellQuote(OPENFGA_AUTHN_KEY)}`,
`postgres_password_key=${shellQuote(OPENFGA_POSTGRES_PASSWORD_KEY)}`,
`legacy_postgres_secret=${shellQuote(spec.postgresSecret)}`,
@@ -4189,9 +4256,10 @@ function platformDbSecretStatusScript(options: NodeSecretOptions, spec: RuntimeS
`platform_endpointslice=${shellQuote(platformEndpointSlice)}`,
`platform_host=${shellQuote(spec.platformPostgresService)}`,
`platform_host_fqdn=${shellQuote(spec.openFgaDbHost)}`,
`platform_endpoint_address=${shellQuote(spec.platformPostgresEndpointAddress ?? "")}`,
`db_name=${shellQuote(isOpenFga ? spec.openFgaDbName : spec.cloudApiDbName)}`,
`db_user=${shellQuote(isOpenFga ? spec.openFgaDbUser : spec.cloudApiDbUser)}`,
`db_host=${shellQuote(isOpenFga ? spec.openFgaDbHost : spec.cloudApiDbHost)}`,
`db_host=${shellQuote(expectedUriHost)}`,
`selected_key=${shellQuote(options.key ?? "")}`,
`preset=${shellQuote(options.preset)}`,
"dry_run=true",
@@ -4209,7 +4277,10 @@ function platformDbSecretStatusScript(options: NodeSecretOptions, spec: RuntimeS
" uri_has_platform_host=no",
" uri_has_db_name=no",
" uri_has_db_user=no",
" case \"$uri\" in *\"@$platform_host:\"*|*\"@$platform_host/\"*|*\"@$platform_host_fqdn:\"*|*\"@$platform_host_fqdn/\"*) uri_has_platform_host=yes ;; esac",
" case \"$uri\" in *\"@$platform_host:\"*|*\"@$platform_host/\"*|*\"@$platform_host_fqdn:\"*|*\"@$platform_host_fqdn/\"*|*\"@$db_host:\"*|*\"@$db_host/\"*) uri_has_platform_host=yes ;; esac",
" if [ -n \"$platform_endpoint_address\" ]; then",
" case \"$uri\" in *\"@$platform_endpoint_address:\"*|*\"@$platform_endpoint_address/\"*) uri_has_platform_host=yes ;; esac",
" fi",
" case \"$uri\" in *\"/$db_name\"|*\"/$db_name?\"*|*\"/$db_name?\"*) uri_has_db_name=yes ;; esac",
" case \"$uri\" in postgres://$db_user:*|postgresql://$db_user:*) uri_has_db_user=yes ;; esac",
"}",
@@ -4254,6 +4325,7 @@ function platformDbSecretStatusScript(options: NodeSecretOptions, spec: RuntimeS
"printf 'platformEndpointsExists\\t%s\\n' \"$platform_endpoints_exists\"",
"printf 'platformEndpointSlice\\t%s\\n' \"$platform_endpointslice\"",
"printf 'platformEndpointSliceExists\\t%s\\n' \"$platform_endpointslice_exists\"",
"printf 'platformEndpointAddress\\t%s\\n' \"$platform_endpoint_address\"",
"printf 'dbName\\t%s\\n' \"$db_name\"",
"printf 'dbUser\\t%s\\n' \"$db_user\"",
"printf 'dbHost\\t%s\\n' \"$db_host\"",
+1 -9
View File
@@ -418,8 +418,6 @@ function summarizeRuntimeLaneTriggerJobProgress(job: JobRecord, stdoutTail: stri
const stageStatus = stringField(lastEvent.status);
const sourceCommit = stringField(lastEvent.sourceCommit) ?? firstMatch(stdoutTail, /"sourceCommit"\s*:\s*"([0-9a-f]{40})"/iu);
const pipelineRun = stringField(lastEvent.pipelineRun) ?? firstMatch(stdoutTail, /"pipelineRun"\s*:\s*"([^"]+)"/u);
const node = stringField(lastEvent.node) ?? commandOption(job.command, "--node") ?? "G14";
const lane = stringField(lastEvent.lane) ?? commandOption(job.command, "--lane") ?? "v03";
const pipelineCreated = /pipelinerun\.tekton\.dev\/[^ \n]+ created/u.test(stdoutTail)
? true
: stage === "create-pipelinerun" && stageStatus === "failed"
@@ -467,19 +465,13 @@ function summarizeRuntimeLaneTriggerJobProgress(job: JobRecord, stdoutTail: stri
slow ? "visibility-warning" : null,
].filter(Boolean).join(" "),
nextCommand: pipelineRun
? `bun scripts/cli.ts hwlab nodes control-plane status --node ${node} --lane ${lane} --pipeline-run ${pipelineRun}`
? `bun scripts/cli.ts hwlab nodes control-plane status --node ${stringField(lastEvent.node) ?? "G14"} --lane ${stringField(lastEvent.lane) ?? "v03"} --pipeline-run ${pipelineRun}`
: job.status === "running"
? `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`
: null,
};
}
function commandOption(command: readonly string[], name: string): string | null {
const index = command.indexOf(name);
const value = index >= 0 ? command[index + 1] : undefined;
return typeof value === "string" && value.trim().length > 0 && !value.startsWith("--") ? value.trim() : null;
}
function genericJobProgress(job: JobRecord, stderrTailOverride?: string): JobProgressSummary {
const nowMs = Date.now();
const stderrTail = stderrTailOverride ?? tailFile(job.stderrFile, 96_000);
+1 -6
View File
@@ -878,7 +878,7 @@ function connectionStringExport(item: Record<string, unknown>, path: string): Co
consumers: arrayOfRecords(item.consumers, `${path}.consumers`).map((consumer, index) => ({
scope: stringField(consumer, "scope", `${path}.consumers[${index}]`),
secret: stringField(consumer, "secret", `${path}.consumers[${index}]`),
key: kubernetesSecretKey(stringField(consumer, "key", `${path}.consumers[${index}]`), `${path}.consumers[${index}].key`),
key: envKey(stringField(consumer, "key", `${path}.consumers[${index}]`), `${path}.consumers[${index}].key`),
})),
};
}
@@ -909,11 +909,6 @@ function envKey(value: string, path: string): string {
return value;
}
function kubernetesSecretKey(value: string, path: string): string {
if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${path} must be a Kubernetes Secret key`);
return value;
}
function configSummary(pg: PostgresHostConfig): Record<string, unknown> {
return {
path: pg.configPath,
@@ -73,6 +73,15 @@ export interface CodexPoolSentinelProfileSecret {
apiKey: string;
upstreamUserAgent: string | null;
trustUpstream: boolean;
sentinelProtect: CodexPoolSentinelProtectPolicy;
}
export interface CodexPoolSentinelProtectPolicy {
enabled: boolean;
consecutiveFailures: number;
initialRetryDelaySeconds: number;
maxRetryDelaySeconds: number;
backoffMultiplier: number;
}
export interface CodexPoolSentinelManifestOptions {
@@ -1148,6 +1157,85 @@ def probe_account(profile, config, purpose):
"requestShape": resp.get("requestShape"),
}
def protect_policy(profile):
policy = profile.get("sentinelProtect") if isinstance(profile.get("sentinelProtect"), dict) else {}
return policy if policy.get("enabled") is True else None
def protect_failure_threshold(policy):
try:
return max(1, int(policy.get("consecutiveFailures") or 1))
except Exception:
return 1
def protect_retry_delay_seconds(policy, retry_index):
try:
initial = max(1, int(policy.get("initialRetryDelaySeconds") or 2))
except Exception:
initial = 2
try:
maximum = max(initial, int(policy.get("maxRetryDelaySeconds") or initial))
except Exception:
maximum = initial
try:
multiplier = max(1, int(policy.get("backoffMultiplier") or 2))
except Exception:
multiplier = 2
if retry_index <= 0:
return 0
return min(maximum, initial * (multiplier ** (retry_index - 1)))
def probe_account_with_protection(profile, config, purpose):
policy = protect_policy(profile)
if policy is None:
return probe_account(profile, config, purpose)
threshold = protect_failure_threshold(policy)
attempts = []
last_result = None
for index in range(threshold):
delay = protect_retry_delay_seconds(policy, index)
if delay > 0:
time.sleep(delay)
attempt_purpose = purpose if index == 0 else purpose + "-protect-retry"
result = probe_account(profile, config, attempt_purpose)
attempt = {
"attempt": index + 1,
"delaySeconds": delay,
"ok": result.get("ok"),
"markerMatched": result.get("markerMatched"),
"httpStatus": result.get("httpStatus"),
"durationMs": result.get("durationMs"),
"failureKind": result.get("failureKind"),
"outputHash": result.get("outputHash"),
"responseBodyHash": result.get("responseBodyHash"),
"errorDetails": result.get("errorDetails"),
}
attempts.append(attempt)
last_result = result
if result.get("markerMatched") is True:
result["sentinelProtect"] = {
"enabled": True,
"threshold": threshold,
"attempts": attempts,
"failureCount": index,
"protected": index > 0,
"decision": "pass",
}
if index > 0:
result["purpose"] = purpose + "-protect-recovered"
return result
if last_result is None:
last_result = probe_account(profile, config, purpose)
last_result["sentinelProtect"] = {
"enabled": True,
"threshold": threshold,
"attempts": attempts,
"failureCount": len(attempts),
"protected": False,
"decision": "fail",
}
last_result["purpose"] = purpose + "-protect-exhausted"
return last_result
def ledger_for(state, now):
day = day_key(now)
ledger = state.setdefault("ledger", {}).setdefault(day, {"inputTokens": 0, "outputTokens": 0, "totalTokens": 0, "estimatedCostUsd": 0, "requestCount": 0})
@@ -1306,6 +1394,7 @@ def apply_result(result, state, config, now, admin, profile):
"ok": result.get("ok"),
"purpose": result.get("purpose"),
"trustUpstream": result.get("trustUpstream"),
"sentinelProtect": result.get("sentinelProtect"),
"successMaxIntervalMinutes": success_max_interval(profile, config),
"httpStatus": result.get("httpStatus"),
"durationMs": result.get("durationMs"),
@@ -1479,6 +1568,68 @@ def next_gateway_freeze_interval(account_state, config):
def apply_gateway_failure(account_name, failures, state, config, now, admin, profile):
latest = failures[-1]
account_state = state.setdefault("accounts", {}).setdefault(account_name, {})
policy = protect_policy(profile)
protected_probe = None
if policy is not None:
protected_probe = probe_account_with_protection(profile, config, "gateway-failure-confirm")
protected_probe["sourceGatewayFailure"] = {
"requestId": latest.get("requestId"),
"clientRequestId": latest.get("clientRequestId"),
"failureKind": latest.get("failureKind"),
"path": latest.get("path"),
"countInRun": len(failures),
}
account_state["lastProbeAt"] = iso(now)
account_state["lastProbe"] = {
"ok": protected_probe.get("ok"),
"purpose": protected_probe.get("purpose"),
"trustUpstream": protected_probe.get("trustUpstream"),
"sentinelProtect": protected_probe.get("sentinelProtect"),
"successMaxIntervalMinutes": success_max_interval(profile, config),
"httpStatus": protected_probe.get("httpStatus"),
"durationMs": protected_probe.get("durationMs"),
"markerMatched": protected_probe.get("markerMatched"),
"transportOk": protected_probe.get("transportOk"),
"outputHash": protected_probe.get("outputHash"),
"outputPreview": protected_probe.get("outputPreview"),
"responseBodyHash": protected_probe.get("responseBodyHash"),
"responseBodyPreview": protected_probe.get("responseBodyPreview"),
"error": protected_probe.get("error"),
"errorDetails": protected_probe.get("errorDetails"),
"usage": protected_probe.get("usage"),
"failureKind": protected_probe.get("failureKind"),
"sdk": protected_probe.get("sdk"),
"requestShape": protected_probe.get("requestShape"),
"action": {"taken": False, "type": "protect-confirm-pass" if protected_probe.get("markerMatched") is True else "protect-confirm-fail"},
"sourceGatewayFailure": protected_probe.get("sourceGatewayFailure"),
}
add_usage(state, account_state, now, protected_probe.get("usage") or {})
account_state["sentinelProtect"] = protected_probe.get("sentinelProtect")
account_state["trustUpstream"] = profile.get("trustUpstream") is True
account_state["successMaxIntervalMinutes"] = success_max_interval(profile, config)
if protected_probe.get("markerMatched") is True:
interval = next_success_interval(account_state, config, profile)
account_state["successStreak"] = int(account_state.get("successStreak") or 0) + 1
account_state["successIntervalMinutes"] = interval
account_state["nextProbeAfter"] = iso(add_minutes(now, interval, int(config["cadence"]["jitterPercent"])))
account_state["lastOkAt"] = iso(now)
account_state["lastStatus"] = "gateway-failure-protect-confirmed-ok"
account_state["lastGatewayFailureAt"] = iso(now)
account_state["lastGatewayFailure"] = {
"accountName": account_name,
"accountId": latest.get("accountId"),
"requestId": latest.get("requestId"),
"clientRequestId": latest.get("clientRequestId"),
"failureKind": latest.get("failureKind"),
"path": latest.get("path"),
"errorPreview": latest.get("errorPreview"),
"countInRun": len(failures),
"firstAt": failures[0].get("at"),
"lastAt": latest.get("at"),
"action": {"taken": False, "type": "protect-confirm-pass"},
"sentinelProtect": protected_probe.get("sentinelProtect"),
}
return {"taken": False, "type": "protect-confirm-pass", "sentinelProtect": protected_probe.get("sentinelProtect")}
interval = next_gateway_freeze_interval(account_state, config)
until = add_minutes(now, interval, int(config["freeze"]["jitterPercent"]))
actions_enabled = bool(config["actions"]["enabled"])
@@ -1511,6 +1662,7 @@ def apply_gateway_failure(account_name, failures, state, config, now, admin, pro
"countInRun": len(failures),
},
"lastBadAt": iso(now),
"sentinelProtect": protected_probe.get("sentinelProtect") if isinstance(protected_probe, dict) else None,
}
account_state["nextProbeAfter"] = iso(until)
account_state["successStreak"] = 0
@@ -1538,6 +1690,7 @@ def apply_gateway_failure(account_name, failures, state, config, now, admin, pro
"intervalMinutes": interval,
"freezeUntil": iso(until),
"action": action,
"sentinelProtect": protected_probe.get("sentinelProtect") if isinstance(protected_probe, dict) else None,
}
return action
@@ -1721,7 +1874,7 @@ def main():
actions = []
if (config["monitor"]["enabled"] or forced_names) and due:
with ThreadPoolExecutor(max_workers=max(1, len(due))) as executor:
futures = {executor.submit(probe_account, item["profile"], config, item["purpose"]): item["profile"] for item in due}
futures = {executor.submit(probe_account_with_protection, item["profile"], config, item["purpose"]): item["profile"] for item in due}
for future in as_completed(futures):
result = future.result()
results.append(result)
@@ -1760,6 +1913,7 @@ def main():
"accountName": item.get("accountName"),
"purpose": item.get("purpose"),
"trustUpstream": item.get("trustUpstream"),
"sentinelProtect": item.get("sentinelProtect"),
"ok": item.get("ok"),
"markerMatched": item.get("markerMatched"),
"httpStatus": item.get("httpStatus"),
+266 -24
View File
@@ -17,7 +17,8 @@ import {
import { runSshCommandCapture, type SshCaptureResult } from "./ssh";
const g14K3sRoute = "G14:k3s";
const defaultTargetId = "G14";
const defaultTargetId = "D601";
const defaultTargetRoute = "D601:k3s";
const namespace = "platform-infra";
const serviceName = "sub2api";
const serviceDns = `${serviceName}.${namespace}.svc.cluster.local:8080`;
@@ -79,6 +80,7 @@ interface CodexPoolRuntimeTarget {
namespace: string;
serviceName: string;
serviceDns: string;
publicBaseUrl: string | null;
appSecretName: string;
egressProxy: {
enabled: boolean;
@@ -103,6 +105,7 @@ interface CodexProfile {
openaiResponsesWebSocketsV2Mode: OpenAIResponsesWebSocketsV2Mode | null;
upstreamUserAgent: string | null;
trustUpstream: boolean;
sentinelProtect: CodexSentinelProtectPolicy;
priority: number;
capacity: number;
loadFactor: number;
@@ -154,12 +157,21 @@ interface CodexPoolProfileConfig {
openaiResponsesWebSocketsV2Mode: OpenAIResponsesWebSocketsV2Mode | null;
upstreamUserAgent: string | null;
trustUpstream: boolean;
sentinelProtect: CodexSentinelProtectPolicy;
priority: number;
capacity: number | null;
loadFactor: number | null;
tempUnschedulable: CodexTempUnschedulablePolicy;
}
export interface CodexSentinelProtectPolicy {
enabled: boolean;
consecutiveFailures: number;
initialRetryDelaySeconds: number;
maxRetryDelaySeconds: number;
backoffMultiplier: number;
}
interface CodexPoolPublicExposureConfig {
enabled: boolean;
proxyName: string;
@@ -549,7 +561,7 @@ function codexPoolRuntimeTarget(targetId: string = defaultTargetId): CodexPoolRu
const raw = parsed.targets.find((item) => isRecord(item) && String(item.id ?? "").toLowerCase() === targetId.toLowerCase());
if (!isRecord(raw)) throw new Error(`${sub2apiConfigPath}.targets does not contain target ${targetId}`);
const id = stringValue(raw.id) ?? targetId;
const route = stringValue(raw.route) ?? (id === defaultTargetId ? g14K3sRoute : "");
const route = stringValue(raw.route) ?? (id === defaultTargetId ? defaultTargetRoute : "");
const targetNamespace = stringValue(raw.namespace) ?? namespace;
if (route.length === 0) throw new Error(`${sub2apiConfigPath}.targets[${id}].route is required`);
validateKubernetesName(targetNamespace, `${sub2apiConfigPath}.targets[${id}].namespace`, true);
@@ -571,12 +583,19 @@ function codexPoolRuntimeTarget(targetId: string = defaultTargetId): CodexPoolRu
};
}
let publicBaseUrl: string | null = null;
if (isRecord(raw.publicExposure) && raw.publicExposure.enabled === true) {
publicBaseUrl = normalizeBaseUrl(stringValue(raw.publicExposure.publicBaseUrl));
if (publicBaseUrl === null) throw new Error(`${sub2apiConfigPath}.targets[${id}].publicExposure.publicBaseUrl must be a valid http(s) URL when target public exposure is enabled`);
}
return {
id,
route,
namespace: targetNamespace,
serviceName,
serviceDns: `${serviceName}.${targetNamespace}.svc.cluster.local:8080`,
publicBaseUrl,
appSecretName,
egressProxy,
};
@@ -591,6 +610,7 @@ function codexPoolPlan(options: DisclosureOptions = { full: false, raw: false, t
const runtimeTarget = codexPoolRuntimeTarget(options.targetId);
const profiles = collectCodexProfiles();
const ok = profiles.length > 0 && profiles.every((profile) => profile.ok);
const consumerBaseUrl = codexConsumerBaseUrl(pool, runtimeTarget);
return {
ok,
action: "platform-infra-sub2api-codex-pool-plan",
@@ -614,8 +634,10 @@ function codexPoolPlan(options: DisclosureOptions = { full: false, raw: false, t
? `Account sentinel is enabled as k8s CronJob ${runtimeTarget.namespace}/${pool.sentinel.cronJobName}; actions.enabled=${pool.sentinel.actions.enabled}.`
: "Account sentinel monitoring is disabled by YAML.",
publicExposure: pool.publicExposure.enabled
? `Default Codex consumers use ${codexConsumerBaseUrl(pool)}; bounded master-local probes may use ${pool.publicExposure.masterBaseUrl}. FRP proxy ${pool.publicExposure.proxyName} maps public ${pool.publicExposure.publicBaseUrl} to ${pool.publicExposure.localIP}:${pool.publicExposure.localPort}.`
: "Public FRP exposure is disabled by YAML.",
? `Default Codex consumers use ${consumerBaseUrl}; bounded master-local probes may use ${pool.publicExposure.masterBaseUrl}. Legacy Codex-pool FRP proxy ${pool.publicExposure.proxyName} maps public ${pool.publicExposure.publicBaseUrl} to ${pool.publicExposure.localIP}:${pool.publicExposure.localPort}.`
: runtimeTarget.publicBaseUrl === null
? "Public FRP exposure is disabled by YAML."
: `Legacy Codex-pool FRP exposure is disabled by YAML; Codex consumers for target ${runtimeTarget.id} use target-level public exposure ${consumerBaseUrl}.`,
idempotency: "sync reuses the group, account names, and k3s Secret when they already exist; credentials are updated from the current local Codex files; managed accounts missing from YAML are preserved unless --prune-removed is explicitly provided.",
configPolicy: "UniDesk-owned durable configuration remains YAML-first; local ~/.codex files and runtime Secrets are not committed.",
},
@@ -702,10 +724,12 @@ async function codexPoolSync(config: UniDeskConfig, options: SyncOptions): Promi
upstreamUserAgent: profile.upstreamUserAgent,
openaiResponsesWebSocketsV2Mode: profile.openaiResponsesWebSocketsV2Mode,
trustUpstream: profile.trustUpstream,
sentinelProtect: profile.sentinelProtect,
}),
openaiResponsesWebSocketsV2Mode: profile.openaiResponsesWebSocketsV2Mode,
upstreamUserAgent: profile.upstreamUserAgent,
trustUpstream: profile.trustUpstream,
sentinelProtect: profile.sentinelProtect,
priority: profile.priority,
capacity: profile.capacity,
loadFactor: profile.loadFactor,
@@ -994,6 +1018,8 @@ async function codexPoolExpose(config: UniDeskConfig, options: ConfirmOptions):
async function codexPoolConfigureLocal(config: UniDeskConfig, options: ConfirmOptions): Promise<Record<string, unknown>> {
const pool = readCodexPoolConfig();
const runtimeTarget = codexPoolRuntimeTarget(options.targetId);
const consumerExposureAvailable = pool.publicExposure.enabled || runtimeTarget.publicBaseUrl !== null;
const codexDir = join(homedir(), ".codex");
const configPath = join(codexDir, "config.toml");
const authPath = join(codexDir, "auth.json");
@@ -1010,7 +1036,7 @@ async function codexPoolConfigureLocal(config: UniDeskConfig, options: ConfirmOp
authPath,
backupConfigPath,
backupAuthPath,
baseUrl: codexConsumerBaseUrl(pool),
baseUrl: consumerExposureAvailable ? codexConsumerBaseUrl(pool, runtimeTarget) : null,
providerName: pool.localCodex.providerName,
wireApi: pool.localCodex.wireApi,
modelContextWindow: pool.localCodex.modelContextWindow,
@@ -1021,12 +1047,12 @@ async function codexPoolConfigureLocal(config: UniDeskConfig, options: ConfirmOp
valuesPrinted: false,
},
next: {
confirm: "bun scripts/cli.ts platform-infra sub2api codex-pool configure-local --confirm",
confirm: `bun scripts/cli.ts platform-infra sub2api codex-pool configure-local${targetFlag(runtimeTarget)} --confirm`,
},
};
}
if (!pool.publicExposure.enabled) throw new Error(`${codexPoolConfigPath}.publicExposure.enabled must be true before configure-local`);
const keyResult = await fetchPoolApiKey(config, pool);
if (!consumerExposureAvailable) throw new Error(`${codexPoolConfigPath}.publicExposure.enabled is false and ${sub2apiConfigPath}.targets[${runtimeTarget.id}].publicExposure.enabled is not true; configure-local needs one consumer URL`);
const keyResult = await fetchPoolApiKey(config, pool, runtimeTarget);
if (keyResult.apiKey === null) {
return {
ok: false,
@@ -1035,8 +1061,8 @@ async function codexPoolConfigureLocal(config: UniDeskConfig, options: ConfirmOp
valuesPrinted: false,
};
}
const writeResult = writeLocalCodexConfig(pool, keyResult.apiKey);
const validateResult = await validatePublicGatewayWithKey(pool, keyResult.apiKey);
const writeResult = writeLocalCodexConfig(pool, keyResult.apiKey, runtimeTarget);
const validateResult = await validatePublicGatewayWithKey(pool, keyResult.apiKey, runtimeTarget);
return {
ok: writeResult.ok && validateResult.ok,
action: "platform-infra-sub2api-codex-pool-configure-local",
@@ -1044,7 +1070,7 @@ async function codexPoolConfigureLocal(config: UniDeskConfig, options: ConfirmOp
local: writeResult,
validation: validateResult,
apiKey: {
secret: `${namespace}/${pool.apiKeySecretName}.${pool.apiKeySecretKey}`,
secret: `${runtimeTarget.namespace}/${pool.apiKeySecretName}.${pool.apiKeySecretKey}`,
keyPreview: apiKeyPreview(keyResult.apiKey),
apiKeyFingerprint: fingerprint(keyResult.apiKey),
valuesPrinted: false,
@@ -1084,6 +1110,7 @@ function collectCodexProfiles(): CodexProfile[] {
openaiResponsesWebSocketsV2Mode: entry.openaiResponsesWebSocketsV2Mode,
upstreamUserAgent: entry.upstreamUserAgent,
trustUpstream: entry.trustUpstream,
sentinelProtect: entry.sentinelProtect,
priority: entry.priority,
capacity: entry.capacity ?? pool.defaultAccountCapacity,
loadFactor: entry.loadFactor ?? pool.defaultAccountLoadFactor,
@@ -1157,6 +1184,7 @@ function discoverCodexProfileConfigs(
openaiResponsesWebSocketsV2Mode: null,
upstreamUserAgent: null,
trustUpstream: false,
sentinelProtect: defaultCodexSentinelProtectPolicy(),
priority: defaultPriority,
capacity: null,
loadFactor: null,
@@ -1326,6 +1354,7 @@ function readProfileConfig(
const openaiResponsesWebSocketsV2Mode = readOpenAIResponsesWebSocketsV2Mode(entry.openaiResponsesWebSocketsV2Mode, `profiles.entries[${index}].openaiResponsesWebSocketsV2Mode`);
const upstreamUserAgent = readUpstreamUserAgent(entry.upstreamUserAgent, `profiles.entries[${index}].upstreamUserAgent`);
const trustUpstream = readTrustUpstream(entry.trustUpstream, `profiles.entries[${index}].trustUpstream`);
const sentinelProtect = readSentinelProtectPolicy(entry.sentinelProtect, `profiles.entries[${index}].sentinelProtect`);
const priority = readAccountPriority(entry.priority, `profiles.entries[${index}].priority`, defaultPriority);
const capacity = entry.capacity === undefined || entry.capacity === null ? null : readAccountCapacity(entry.capacity, `profiles.entries[${index}].capacity`);
const loadFactor = entry.loadFactor === undefined || entry.loadFactor === null ? null : readAccountLoadFactor(entry.loadFactor, `profiles.entries[${index}].loadFactor`);
@@ -1340,6 +1369,7 @@ function readProfileConfig(
openaiResponsesWebSocketsV2Mode,
upstreamUserAgent,
trustUpstream,
sentinelProtect,
priority,
capacity,
loadFactor,
@@ -1382,6 +1412,52 @@ function readTrustUpstream(value: unknown, key: string): boolean {
return parsed;
}
export function defaultCodexSentinelProtectPolicy(): CodexSentinelProtectPolicy {
return {
enabled: false,
consecutiveFailures: 1,
initialRetryDelaySeconds: 2,
maxRetryDelaySeconds: 60,
backoffMultiplier: 2,
};
}
function readSentinelProtectPolicy(value: unknown, key: string): CodexSentinelProtectPolicy {
const fallback = defaultCodexSentinelProtectPolicy();
if (value === undefined || value === null) return fallback;
if (!isRecord(value)) throw new Error(`${codexPoolConfigPath}.${key} must be a YAML object`);
const enabled = readBooleanConfig(value.enabled, `${key}.enabled`, fallback.enabled);
if (!enabled) return { ...fallback, enabled: false };
const required = ["consecutiveFailures", "initialRetryDelaySeconds", "maxRetryDelaySeconds", "backoffMultiplier"];
for (const field of required) {
if (value[field] === undefined || value[field] === null) {
throw new Error(`${codexPoolConfigPath}.${key}.${field} is required when enabled=true`);
}
}
const consecutiveFailures = readBoundedInteger(value.consecutiveFailures, `${key}.consecutiveFailures`, 1, 20);
const initialRetryDelaySeconds = readBoundedInteger(value.initialRetryDelaySeconds, `${key}.initialRetryDelaySeconds`, 1, 3600);
const maxRetryDelaySeconds = readBoundedInteger(value.maxRetryDelaySeconds, `${key}.maxRetryDelaySeconds`, 1, 3600);
const backoffMultiplier = readBoundedInteger(value.backoffMultiplier, `${key}.backoffMultiplier`, 1, 10);
if (maxRetryDelaySeconds < initialRetryDelaySeconds) {
throw new Error(`${codexPoolConfigPath}.${key}.maxRetryDelaySeconds must be >= initialRetryDelaySeconds`);
}
return {
enabled: true,
consecutiveFailures,
initialRetryDelaySeconds,
maxRetryDelaySeconds,
backoffMultiplier,
};
}
function readBoundedInteger(value: unknown, key: string, min: number, max: number): number {
const parsed = numberValue(value);
if (parsed === null || !Number.isInteger(parsed) || parsed < min || parsed > max) {
throw new Error(`${codexPoolConfigPath}.${key} must be an integer from ${min} to ${max}`);
}
return parsed;
}
function readAccountPriority(value: unknown, key: string, fallback = defaultAccountPriority): number {
if (value === undefined || value === null) return fallback;
const priority = numberValue(value);
@@ -1709,6 +1785,7 @@ function redactProfile(profile: CodexProfile): Record<string, unknown> {
openaiResponsesWebSocketsV2Mode: profile.openaiResponsesWebSocketsV2Mode,
upstreamUserAgent: profile.upstreamUserAgent,
trustUpstream: profile.trustUpstream,
sentinelProtect: profile.sentinelProtect,
priority: profile.priority,
capacity: profile.capacity,
loadFactor: profile.loadFactor,
@@ -1731,6 +1808,8 @@ function compactProfile(profile: CodexProfile): Record<string, unknown> {
model: profile.model,
priority: profile.priority,
trustUpstream: profile.trustUpstream,
sentinelProtectEnabled: profile.sentinelProtect.enabled,
sentinelProtectConsecutiveFailures: profile.sentinelProtect.enabled ? profile.sentinelProtect.consecutiveFailures : undefined,
capacity: profile.capacity,
loadFactor: profile.loadFactor,
tempUnschedulableEnabled: profile.tempUnschedulable.enabled,
@@ -1994,6 +2073,8 @@ function compactGatewayResponsesRecent(block: unknown): unknown {
slowFinalErrorCount: block.slowFinalErrorCount,
contextCanceledCount: block.contextCanceledCount,
ignoredProbeNoiseCount: block.ignoredProbeNoiseCount,
failoverBudgetExhaustedCount: Array.isArray(block.failoverBudgetExhausted) ? block.failoverBudgetExhausted.length : undefined,
failoverBudgetExhausted: Array.isArray(block.failoverBudgetExhausted) ? block.failoverBudgetExhausted.slice(-3).reverse() : undefined,
recentFailovers: Array.isArray(block.recentFailovers) ? block.recentFailovers.slice(-4).reverse() : undefined,
recentForwardFailures: Array.isArray(block.recentForwardFailures) ? block.recentForwardFailures.slice(-4).reverse().map((item) => ({
...item,
@@ -2045,6 +2126,7 @@ function compactSentinelErrorDetails(value: unknown): Record<string, unknown> |
function compactSentinelQuarantine(item: unknown): Record<string, unknown> {
if (!isRecord(item)) return {};
const protect = isRecord(item.sentinelProtect) ? item.sentinelProtect : {};
return {
accountName: item.accountName,
until: item.until,
@@ -2052,6 +2134,12 @@ function compactSentinelQuarantine(item: unknown): Record<string, unknown> {
reason: item.reason,
failureKind: item.failureKind,
intervalMinutes: item.intervalMinutes,
sentinelProtect: Object.keys(protect).length > 0 ? {
enabled: protect.enabled,
decision: protect.decision,
failureCount: protect.failureCount,
threshold: protect.threshold,
} : undefined,
error: compactSentinelErrorDetails(item.errorDetails),
};
}
@@ -2060,6 +2148,7 @@ function compactSentinelRecentAccount(item: unknown): Record<string, unknown> {
if (!isRecord(item)) return {};
const action = isRecord(item.action) ? item.action : {};
const error = compactSentinelErrorDetails(item.errorDetails);
const protect = isRecord(item.sentinelProtect) ? item.sentinelProtect : {};
return {
accountName: item.accountName,
lastProbeAt: item.lastProbeAt,
@@ -2070,6 +2159,13 @@ function compactSentinelRecentAccount(item: unknown): Record<string, unknown> {
httpStatus: item.httpStatus,
durationMs: item.durationMs,
markerMatched: item.markerMatched,
sentinelProtect: Object.keys(protect).length > 0 ? {
enabled: protect.enabled,
decision: protect.decision,
protected: protect.protected,
failureCount: protect.failureCount,
threshold: protect.threshold,
} : undefined,
failureKind: item.failureKind,
requestShape: item.requestShape,
action: Object.keys(action).length > 0 ? {
@@ -2222,6 +2318,7 @@ function compactSentinelProbeResult(parsed: Record<string, unknown> | null): Rec
"purpose",
"ok",
"markerMatched",
"sentinelProtect",
"httpStatus",
"durationMs",
"usage",
@@ -2291,12 +2388,14 @@ function renderSentinelReport(
lines.push("");
lines.push("ACCOUNTS");
lines.push(renderTable([
["ACCOUNT", "STATE", "Q", "T", "F_MIN", "S_MIN", "S_MAX", "PROBES", "LAST", "HTTP", "M", "KIND", "ACTION", "NEXT", "OBS_MIN"],
["ACCOUNT", "STATE", "Q", "T", "PROT", "P_FAIL", "F_MIN", "S_MIN", "S_MAX", "PROBES", "LAST", "HTTP", "M", "KIND", "ACTION", "NEXT", "OBS_MIN"],
...accounts.map((account) => [
stringValue(account.account) ?? "-",
stringValue(account.status) ?? "-",
account.quarantineActive === true ? "Y" : "-",
account.trustUpstream === true ? "Y" : account.trustUpstream === false ? "N" : "-",
account.sentinelProtectEnabled === true ? textValue(account.sentinelProtectThreshold) : "-",
account.sentinelProtectDecision ? `${textValue(account.sentinelProtectFailureCount)}/${textValue(account.sentinelProtectThreshold)}` : "-",
textValue(account.freezeIntervalMin),
textValue(account.successIntervalMin),
textValue(account.successMaxIntervalMin),
@@ -2330,7 +2429,7 @@ function renderSentinelReport(
]));
}
lines.push("");
lines.push("LEGEND Q=quarantined T=trusted upstream M=marker matched F_MIN=freeze interval S_MIN=success interval S_MAX=success max interval OBS_MIN=last event to next probe minutes TF=transport failures GF=gateway failures GACT=gateway freeze actions");
lines.push("LEGEND Q=quarantined T=trusted upstream PROT=sentinel protect consecutive-failure threshold P_FAIL=last protect failures/threshold M=marker matched F_MIN=freeze interval S_MIN=success interval S_MAX=success max interval OBS_MIN=last event to next probe minutes TF=transport failures GF=gateway failures GACT=gateway freeze actions");
lines.push("Raw: bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-report --raw");
return lines.join("\n");
}
@@ -2633,6 +2732,8 @@ function poolTarget(pool = readCodexPoolConfig(), target = codexPoolRuntimeTarge
namespace: target.namespace,
service: serviceName,
serviceDns: target.serviceDns,
publicBaseUrl: target.publicBaseUrl,
consumerBaseUrl: pool.publicExposure.enabled || target.publicBaseUrl !== null ? codexConsumerBaseUrl(pool, target) : null,
configPath: codexPoolConfigPath,
groupName: pool.groupName,
apiKeyName: pool.apiKeyName,
@@ -2669,6 +2770,7 @@ function sentinelProfileSecrets(profiles: CodexProfile[]): CodexPoolSentinelProf
apiKey: profile.apiKey ?? "",
upstreamUserAgent: profile.upstreamUserAgent,
trustUpstream: profile.trustUpstream,
sentinelProtect: profile.sentinelProtect,
}));
}
@@ -3070,7 +3172,7 @@ kubectl -n ${target.namespace} get secret ${pool.apiKeySecretName} -o json
}
}
function writeLocalCodexConfig(pool: CodexPoolConfig, apiKey: string): Record<string, unknown> {
function writeLocalCodexConfig(pool: CodexPoolConfig, apiKey: string, target = codexPoolRuntimeTarget(defaultTargetId)): Record<string, unknown> {
const codexDir = join(homedir(), ".codex");
mkdirSync(codexDir, { recursive: true, mode: 0o700 });
const configPath = join(codexDir, "config.toml");
@@ -3082,7 +3184,7 @@ function writeLocalCodexConfig(pool: CodexPoolConfig, apiKey: string): Record<st
const configBackupAction = copyIfMissing(configPath, backupConfigPath);
const authBackupAction = copyIfMissing(authPath, backupAuthPath);
const currentToml = readFileSync(configPath, "utf8");
const nextToml = updateCodexConfigToml(currentToml, pool);
const nextToml = updateCodexConfigToml(currentToml, pool, target);
writeFileSync(configPath, nextToml, { encoding: "utf8", mode: 0o600 });
chmodSync(configPath, 0o600);
writeFileSync(authPath, `${JSON.stringify({ OPENAI_API_KEY: apiKey }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
@@ -3110,7 +3212,7 @@ function writeLocalCodexConfig(pool: CodexPoolConfig, apiKey: string): Record<st
},
provider: {
name: pool.localCodex.providerName,
baseUrl: codexConsumerBaseUrl(pool),
baseUrl: codexConsumerBaseUrl(pool, target),
wireApi: pool.localCodex.wireApi,
modelContextWindow: pool.localCodex.modelContextWindow,
modelAutoCompactTokenLimit: pool.localCodex.modelAutoCompactTokenLimit,
@@ -3128,10 +3230,10 @@ function copyIfMissing(source: string, target: string): "created" | "kept-existi
return "created";
}
function updateCodexConfigToml(current: string, pool: CodexPoolConfig): string {
function updateCodexConfigToml(current: string, pool: CodexPoolConfig, target = codexPoolRuntimeTarget(defaultTargetId)): string {
return renderCodexLocalConsumerToml(current, {
providerName: pool.localCodex.providerName,
baseUrl: codexConsumerBaseUrl(pool),
baseUrl: codexConsumerBaseUrl(pool, target),
wireApi: pool.localCodex.wireApi,
modelContextWindow: pool.localCodex.modelContextWindow,
modelAutoCompactTokenLimit: pool.localCodex.modelAutoCompactTokenLimit,
@@ -3140,8 +3242,9 @@ function updateCodexConfigToml(current: string, pool: CodexPoolConfig): string {
});
}
function codexConsumerBaseUrl(pool: CodexPoolConfig): string {
return `${pool.publicExposure.publicBaseUrl.replace(/\/+$/u, "")}/`;
function codexConsumerBaseUrl(pool: CodexPoolConfig, target = codexPoolRuntimeTarget(defaultTargetId)): string {
const baseUrl = target.publicBaseUrl ?? pool.publicExposure.publicBaseUrl;
return `${baseUrl.replace(/\/+$/u, "")}/`;
}
export function renderCodexLocalConsumerToml(current: string, options: CodexLocalConsumerTomlOptions): string {
@@ -3237,8 +3340,9 @@ function upsertTomlSectionBoolean(text: string, sectionName: string, key: string
return [...lines.slice(0, start + 1), assignment, ...lines.slice(start + 1)].join("\n");
}
async function validatePublicGatewayWithKey(pool: CodexPoolConfig, apiKey: string): Promise<Record<string, unknown>> {
const probe = await probePublicModels(pool, "with-api-key", apiKey, "public");
async function validatePublicGatewayWithKey(pool: CodexPoolConfig, apiKey: string, target = codexPoolRuntimeTarget(defaultTargetId)): Promise<Record<string, unknown>> {
const probeBase = target.publicBaseUrl === null ? "public" : "target-public";
const probe = await probePublicModels(pool, "with-api-key", apiKey, probeBase, target);
return {
...probe,
ok: probe.ok === true,
@@ -3248,8 +3352,12 @@ async function validatePublicGatewayWithKey(pool: CodexPoolConfig, apiKey: strin
};
}
async function probePublicModels(pool: CodexPoolConfig, mode: "with-api-key" | "without-api-key", apiKey?: string, base: "master" | "public" = "master"): Promise<Record<string, unknown>> {
const baseUrl = base === "public" ? pool.publicExposure.publicBaseUrl : pool.publicExposure.masterBaseUrl;
async function probePublicModels(pool: CodexPoolConfig, mode: "with-api-key" | "without-api-key", apiKey?: string, base: "master" | "public" | "target-public" = "master", target = codexPoolRuntimeTarget(defaultTargetId)): Promise<Record<string, unknown>> {
const baseUrl = base === "target-public"
? (target.publicBaseUrl ?? pool.publicExposure.publicBaseUrl)
: base === "public"
? pool.publicExposure.publicBaseUrl
: pool.publicExposure.masterBaseUrl;
const url = `${baseUrl.replace(/\/+$/u, "")}/v1/models`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
@@ -3365,6 +3473,7 @@ export function codexPoolSentinelProbeConfigFingerprint(input: {
upstreamUserAgent: string | null;
openaiResponsesWebSocketsV2Mode: string | null;
trustUpstream: boolean;
sentinelProtect: CodexSentinelProtectPolicy;
}): string {
return fingerprint(JSON.stringify({
accountName: input.accountName,
@@ -3374,6 +3483,7 @@ export function codexPoolSentinelProbeConfigFingerprint(input: {
upstreamUserAgent: input.upstreamUserAgent,
openaiResponsesWebSocketsV2Mode: input.openaiResponsesWebSocketsV2Mode,
trustUpstream: input.trustUpstream,
sentinelProtect: input.sentinelProtect,
}));
}
@@ -3493,6 +3603,24 @@ def error_code(probe):
return openai_error.get("code") or openai_error.get("type") or ""
return details.get("kind") or ""
def sentinel_protect_summary(account_state, probe):
probe_protect = probe.get("sentinelProtect") if isinstance(probe.get("sentinelProtect"), dict) else {}
config_protect = account_state.get("sentinelProtectConfig") if isinstance(account_state.get("sentinelProtectConfig"), dict) else {}
source = probe_protect if probe_protect else config_protect
if not isinstance(source, dict) or source.get("enabled") is not True:
return {
"enabled": False,
"threshold": None,
"decision": None,
"failureCount": None,
}
return {
"enabled": True,
"threshold": source.get("threshold") or source.get("consecutiveFailures"),
"decision": probe_protect.get("decision"),
"failureCount": probe_protect.get("failureCount"),
}
def report():
cronjob, cron_error = kube_json(["-n", NAMESPACE, "get", "cronjob", CRONJOB_NAME])
state_cm, state_error = kube_json(["-n", NAMESPACE, "get", "configmap", STATE_NAME])
@@ -3520,6 +3648,7 @@ def report():
if gateway_failure and (not account_state.get("lastProbeAt") or str(account_state.get("lastGatewayFailureAt") or "") >= str(account_state.get("lastProbeAt") or "")):
last_action = action_type(gateway_failure)
ledger = account_ledger(account_state)
protect = sentinel_protect_summary(account_state, probe)
account_rows.append({
"account": name,
"status": account_state.get("lastStatus"),
@@ -3542,6 +3671,10 @@ def report():
"lastPurpose": probe.get("purpose"),
"lastHttp": probe.get("httpStatus"),
"lastMarker": probe.get("markerMatched"),
"sentinelProtectEnabled": protect.get("enabled"),
"sentinelProtectDecision": protect.get("decision"),
"sentinelProtectFailureCount": protect.get("failureCount"),
"sentinelProtectThreshold": protect.get("threshold"),
"lastFailureKind": last_failure_kind,
"lastErrorCode": error_code(probe),
"lastAction": last_action,
@@ -3963,6 +4096,8 @@ def sentinel_probe_change_reasons(current, profile):
runtime_ws_mode = empty_to_none(extra.get("openai_apikey_responses_websockets_v2_mode"))
expected_trust_upstream = profile.get("trustUpstream") is True
runtime_trust_upstream = extra.get("unidesk_trust_upstream") is True
expected_protect = profile.get("sentinelProtect") if isinstance(profile.get("sentinelProtect"), dict) else {"enabled": False}
runtime_protect = extra.get("unidesk_sentinel_protect") if isinstance(extra.get("unidesk_sentinel_protect"), dict) else {"enabled": False}
reasons = []
if empty_to_none(extra.get("unidesk_codex_profile")) != profile.get("profile"):
reasons.append("profile")
@@ -3976,6 +4111,8 @@ def sentinel_probe_change_reasons(current, profile):
reasons.append("responses-websockets-v2-mode")
if runtime_trust_upstream != expected_trust_upstream:
reasons.append("trust-upstream")
if runtime_protect != expected_protect:
reasons.append("sentinel-protect")
return reasons
def curl_api(method, path, bearer=None, payload=None):
@@ -4190,6 +4327,7 @@ def account_payload(profile, group_id):
"unidesk_codex_profile": profile["profile"],
"unidesk_managed": True,
"unidesk_trust_upstream": profile.get("trustUpstream") is True,
"unidesk_sentinel_protect": profile.get("sentinelProtect") if isinstance(profile.get("sentinelProtect"), dict) else {"enabled": False},
}
ws_mode = profile.get("openaiResponsesWebSocketsV2Mode")
if ws_mode:
@@ -4240,6 +4378,7 @@ def planned_sentinel_account_results(profiles, existing_accounts):
"profileConfig": {
"accountName": profile["accountName"],
"trustUpstream": profile.get("trustUpstream") is True,
"sentinelProtect": profile.get("sentinelProtect") if isinstance(profile.get("sentinelProtect"), dict) else {"enabled": False},
},
"sentinelProbeConfigFingerprint": profile.get("sentinelProbeConfigFingerprint"),
"sentinelProbeRequired": quality_gate_required,
@@ -4284,6 +4423,7 @@ def ensure_accounts(token, profiles, group_id, prune_removed=False, protected_fr
"profileConfig": {
"accountName": profile["accountName"],
"trustUpstream": profile.get("trustUpstream") is True,
"sentinelProtect": profile.get("sentinelProtect") if isinstance(profile.get("sentinelProtect"), dict) else {"enabled": False},
},
"accountId": data.get("id") if isinstance(data, dict) else None,
"action": action,
@@ -4443,6 +4583,7 @@ def sentinel_runtime_status():
"failureKind": quarantine.get("failureKind"),
"errorDetails": quarantine.get("errorDetails"),
"intervalMinutes": quarantine.get("intervalMinutes"),
"sentinelProtect": quarantine.get("sentinelProtect"),
})
last_probe = account_state.get("lastProbe")
if isinstance(last_probe, dict):
@@ -4456,6 +4597,7 @@ def sentinel_runtime_status():
"httpStatus": last_probe.get("httpStatus"),
"durationMs": last_probe.get("durationMs"),
"markerMatched": last_probe.get("markerMatched"),
"sentinelProtect": last_probe.get("sentinelProtect") or account_state.get("sentinelProtectConfig"),
"outputHash": last_probe.get("outputHash"),
"outputPreview": last_probe.get("outputPreview"),
"responseBodyHash": last_probe.get("responseBodyHash"),
@@ -4628,6 +4770,7 @@ def clamp_sentinel_success_cadence_for_config(state, profiles, now):
quarantine = account_state.get("quarantine")
if isinstance(quarantine, dict) and quarantine.get("active") is True:
account_state["trustUpstream"] = profile.get("trustUpstream") is True
account_state["sentinelProtectConfig"] = profile.get("sentinelProtect") if isinstance(profile.get("sentinelProtect"), dict) else {"enabled": False}
account_state["successMaxIntervalMinutes"] = profile_success_max_interval(profile)
continue
try:
@@ -4637,6 +4780,7 @@ def clamp_sentinel_success_cadence_for_config(state, profiles, now):
next_epoch = parse_iso_epoch(account_state.get("nextProbeAfter"))
max_interval = profile_success_max_interval(profile)
account_state["trustUpstream"] = profile.get("trustUpstream") is True
account_state["sentinelProtectConfig"] = profile.get("sentinelProtect") if isinstance(profile.get("sentinelProtect"), dict) else {"enabled": False}
account_state["successMaxIntervalMinutes"] = max_interval
if interval <= max_interval and (next_epoch is None or next_epoch <= now_epoch + max_interval * 60):
continue
@@ -4727,6 +4871,7 @@ def ensure_sentinel_state_for_sync(account_results, pending_only=False):
account_state["successIntervalMinutes"] = 0
profile_config = item.get("profileConfig") if isinstance(item.get("profileConfig"), dict) else {}
account_state["trustUpstream"] = profile_config.get("trustUpstream") is True
account_state["sentinelProtectConfig"] = profile_config.get("sentinelProtect") if isinstance(profile_config.get("sentinelProtect"), dict) else {"enabled": False}
account_state["successMaxIntervalMinutes"] = profile_success_max_interval(profile_config)
account_state["lastStatus"] = "pending-sentinel-quality-gate"
account_state["qualityGate"] = {
@@ -4787,6 +4932,7 @@ def sentinel_state_summary():
"failureKind": quarantine.get("failureKind"),
"errorDetails": quarantine.get("errorDetails"),
"intervalMinutes": quarantine.get("intervalMinutes"),
"sentinelProtect": quarantine.get("sentinelProtect"),
})
last_probe = account_state.get("lastProbe")
if isinstance(last_probe, dict):
@@ -4800,6 +4946,7 @@ def sentinel_state_summary():
"httpStatus": last_probe.get("httpStatus"),
"durationMs": last_probe.get("durationMs"),
"markerMatched": last_probe.get("markerMatched"),
"sentinelProtect": last_probe.get("sentinelProtect") or account_state.get("sentinelProtectConfig"),
"usage": last_probe.get("usage"),
"responseBodyHash": last_probe.get("responseBodyHash"),
"responseBodyPreview": last_probe.get("responseBodyPreview"),
@@ -5112,6 +5259,51 @@ def ignored_probe_noise(items, section):
result.append(probe)
return result
def group_by_request_id(items):
grouped = {}
for item in items:
request_id = item.get("requestId")
if not isinstance(request_id, str) or not request_id:
continue
grouped.setdefault(request_id, []).append(item)
return grouped
def failover_budget_exhausted_evidence(failovers, final_errors):
final_by_request = {}
for item in final_errors:
request_id = item.get("requestId")
if isinstance(request_id, str) and request_id:
final_by_request[request_id] = item
exhausted = []
for request_id, request_failovers in group_by_request_id(failovers).items():
final = final_by_request.get(request_id)
if not final:
continue
last = request_failovers[-1]
switch_count = last.get("switchCount")
max_switches = last.get("maxSwitches")
final_status = final.get("statusCode")
if (
isinstance(switch_count, int)
and isinstance(max_switches, int)
and max_switches > 0
and switch_count >= max_switches
and isinstance(final_status, int)
and final_status >= 500
):
exhausted.append({
"requestId": request_id,
"clientRequestId": final.get("clientRequestId") or last.get("clientRequestId"),
"path": final.get("path") or last.get("path"),
"finalAccountId": final.get("accountId"),
"finalStatusCode": final_status,
"switchCount": switch_count,
"maxSwitches": max_switches,
"lastFailoverAccountId": last.get("accountId"),
"lastUpstreamStatus": last.get("upstreamStatus"),
})
return exhausted
def recent_responses_gateway_evidence():
proc = kubectl(["-n", NAMESPACE, "logs", "deployment/sub2api", "--since=6h", "--tail=2500"])
stdout = proc.stdout.decode("utf-8", errors="replace")
@@ -5167,6 +5359,7 @@ def recent_responses_gateway_evidence():
visible_final_errors = filter_ignored_probe_noise(final_errors)
visible_slow_final_errors = filter_ignored_probe_noise(slow_final_errors)
visible_context_canceled = filter_ignored_probe_noise(context_canceled)
failover_budget_exhausted = failover_budget_exhausted_evidence(visible_failovers, visible_final_errors)
probe_noise = (
ignored_probe_noise(failovers, "failovers")
+ ignored_probe_noise(forward_failures, "forwardFailures")
@@ -5185,6 +5378,7 @@ def recent_responses_gateway_evidence():
"slowFinalErrorCount": len(visible_slow_final_errors),
"contextCanceledCount": len(visible_context_canceled),
"ignoredProbeNoiseCount": len(probe_noise),
"failoverBudgetExhausted": failover_budget_exhausted[-8:],
"rawCounts": {
"failoverCount": len(failovers),
"forwardFailureCount": len(forward_failures),
@@ -5968,6 +6162,8 @@ def trace_reason(events, final_event):
failovers = [item for item in events if item.get("type") == "failover"]
select_failures = [item for item in events if item.get("type") == "select-failed"]
upstream_errors = [item for item in events if item.get("type") == "upstream-error"]
if failover_budget_exhausted(failovers, final_event):
return "failover-budget-exhausted"
if failovers and select_failures:
return "failover-attempted-no-candidate"
if failovers:
@@ -5982,6 +6178,47 @@ def trace_reason(events, final_event):
return "completed"
return "unknown"
def failover_budget_exhausted(failovers, final_event):
if not failovers or not isinstance(final_event, dict):
return False
last = failovers[-1]
switch_count = last.get("switchCount")
max_switches = last.get("maxSwitches")
final_status = final_event.get("statusCode")
return (
isinstance(switch_count, int)
and isinstance(max_switches, int)
and max_switches > 0
and switch_count >= max_switches
and isinstance(final_status, int)
and final_status >= 500
)
def trace_untried_schedulable_accounts(failovers, final_event, account_snapshot):
if not failover_budget_exhausted(failovers, final_event):
return []
tried = set()
for item in failovers:
account_id = item.get("accountId")
if isinstance(account_id, int):
tried.add(account_id)
final_account = final_event.get("accountId") if isinstance(final_event, dict) else None
if isinstance(final_account, int):
tried.add(final_account)
result = []
for item in account_snapshot:
account_id = item.get("accountId")
if not isinstance(account_id, int) or account_id in tried:
continue
if item.get("schedulable") is True and item.get("status") == "active" and item.get("tempUnschedulableSet") is not True:
result.append({
"accountId": account_id,
"accountName": item.get("accountName"),
"priority": item.get("priority"),
"concurrency": item.get("concurrency"),
})
return result
def run_trace():
payload = json.loads(base64.b64decode(PAYLOAD_B64).decode("utf-8")) if PAYLOAD_B64 else {}
request_id = payload.get("requestId")
@@ -6041,6 +6278,7 @@ def run_trace():
final_errors = [item for item in window_events if item.get("type") == "final" and isinstance(item.get("statusCode"), int) and item.get("statusCode") >= 400]
window_failovers = [item for item in window_events if item.get("type") == "failover"]
window_select_failures = [item for item in window_events if item.get("type") == "select-failed"]
untried_schedulable_accounts = trace_untried_schedulable_accounts(failovers, final_event or {}, account_snapshot)
reason = trace_reason(events, final_event)
if not matched:
outcome = "not-found"
@@ -6090,6 +6328,10 @@ def run_trace():
"tempUnschedulableCount": len(temp_unsched),
"adminSchedulableCount": len(admin_sched),
},
"diagnostics": {
"failoverBudgetExhausted": failover_budget_exhausted(failovers, final_event or {}),
"untriedSchedulableAccounts": untried_schedulable_accounts,
},
"accountSnapshot": account_snapshot,
"accountSnapshotError": account_snapshot_error,
"rawLines": [{"line": item.get("_line")} for item in matched[-30:]] if show_lines else [],
+257 -19
View File
@@ -7,12 +7,13 @@ import { startJob } from "./jobs";
import type { RenderedCliResult } from "./output";
import { runSshCommandCapture, type SshCaptureResult } from "./ssh";
const defaultTargetId = "G14";
const defaultTargetId = "D601";
const namespace = "platform-infra";
const serviceName = "sub2api";
const fieldManager = "unidesk-platform-infra";
const manifestPath = rootPath("src", "components", "platform-infra", "sub2api", "sub2api.k8s.yaml");
const configPath = rootPath("config", "platform-infra", "sub2api.yaml");
const codexPoolConfigPath = rootPath("config", "platform-infra", "sub2api-codex-pool.yaml");
const repoRoot = rootPath();
const secretName = "sub2api-secrets";
const requiredSecretKeys = ["POSTGRES_PASSWORD", "ADMIN_PASSWORD", "JWT_SECRET", "TOTP_ENCRYPTION_KEY"] as const;
@@ -189,6 +190,33 @@ interface EgressProxySecretMaterial {
valuesPrinted: false;
}
interface ManagedResourceCleanupPlan {
externalDbState: boolean;
redisPersistentState: boolean;
publicExposure: {
enabled: boolean;
deploymentName: string;
configMapName: string;
secretName: string;
};
egressProxy: {
enabled: boolean;
deploymentName: string;
serviceName: string;
secretName: string;
};
sentinel: {
enabled: boolean;
cronJobName: string;
configMapName: string;
credentialsSecretName: string;
stateConfigMapName: string;
serviceAccountName: string;
roleName: string;
roleBindingName: string;
};
}
export function platformInfraHelp(): unknown {
return {
command: "platform-infra sub2api|langbot|n8n|wechat-archive ...",
@@ -898,6 +926,68 @@ function configHashFor(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): str
.slice(0, 16);
}
function targetHasSentinel(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): boolean {
return sub2api.runtime.sentinel.enabledOnTargets.some((id) => id.toLowerCase() === target.id.toLowerCase());
}
function managedResourceCleanupPlan(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): ManagedResourceCleanupPlan {
const sentinel = codexPoolSentinelResourceNames();
return {
externalDbState: isExternalTarget(target),
redisPersistentState: target.redisMode === "local-ephemeral",
publicExposure: {
enabled: target.publicExposure?.enabled === true,
deploymentName: target.publicExposure?.frpc.deploymentName ?? "sub2api-frpc",
configMapName: "sub2api-frpc-config",
secretName: target.publicExposure?.frpc.secretName ?? "sub2api-frpc-secrets",
},
egressProxy: {
enabled: target.egressProxy?.enabled === true,
deploymentName: target.egressProxy?.deploymentName ?? "sub2api-egress-proxy",
serviceName: target.egressProxy?.serviceName ?? "sub2api-egress-proxy",
secretName: target.egressProxy?.secretName ?? "sub2api-egress-proxy-config",
},
sentinel: {
...sentinel,
enabled: targetHasSentinel(sub2api, target),
},
};
}
function codexPoolSentinelResourceNames(): ManagedResourceCleanupPlan["sentinel"] {
const defaults: ManagedResourceCleanupPlan["sentinel"] = {
enabled: false,
cronJobName: "sub2api-account-sentinel",
configMapName: "sub2api-account-sentinel-config",
credentialsSecretName: "sub2api-account-sentinel-profiles",
stateConfigMapName: "sub2api-account-sentinel-state",
serviceAccountName: "sub2api-account-sentinel",
roleName: "sub2api-account-sentinel",
roleBindingName: "sub2api-account-sentinel",
};
if (!existsSync(codexPoolConfigPath)) return defaults;
const parsed = Bun.YAML.parse(readFileSync(codexPoolConfigPath, "utf8")) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return defaults;
const sentinel = (parsed as Record<string, unknown>).sentinel;
if (typeof sentinel !== "object" || sentinel === null || Array.isArray(sentinel)) return defaults;
const record = sentinel as Record<string, unknown>;
const text = (key: string, fallback: string): string => {
const value = record[key];
return typeof value === "string" && value.length > 0 ? value : fallback;
};
const serviceAccountName = text("serviceAccountName", defaults.serviceAccountName);
return {
enabled: false,
cronJobName: text("cronJobName", defaults.cronJobName),
configMapName: text("configMapName", defaults.configMapName),
credentialsSecretName: text("credentialsSecretName", defaults.credentialsSecretName),
stateConfigMapName: text("stateConfigMapName", defaults.stateConfigMapName),
serviceAccountName,
roleName: serviceAccountName,
roleBindingName: serviceAccountName,
};
}
function externalPendingManifest(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
const urlAllowlist = sub2api.security.urlAllowlist;
const database = sub2api.runtime.database;
@@ -1473,13 +1563,13 @@ function plan(options: TargetOptions): Record<string, unknown> {
decision: {
owner: "UniDesk",
namespace: target.namespace,
reason: target.id === "G14"
? "Sub2API remains the active G14 platform-infra deployment."
reason: target.databaseMode === "external-pending"
? `${target.id} is a standby Sub2API platform-infra target prepared through YAML; it stays scaled to zero until this target is promoted in YAML.`
: target.databaseMode === "external-active"
? "D601 is activated as a platform-infra Sub2API target against the external PK01 PostgreSQL runtime while keeping app state outside the k3s node."
: "D601 is a standby Sub2API platform-infra target prepared through YAML; it does not run until the external Pika01/PK01 database secret is ready.",
? `${target.id} is activated as a platform-infra Sub2API target against the external PK01 PostgreSQL runtime while keeping app state outside the k3s node.`
: `${target.id} is a bundled active Sub2API platform-infra target.`,
exposure: target.publicExposure?.enabled
? `Public HTTPS ${target.publicExposure.publicBaseUrl} through PK01 Caddy and D601 frpc; no master server forwarding and no Kubernetes Ingress/NodePort/LoadBalancer.`
? `Public HTTPS ${target.publicExposure.publicBaseUrl} through PK01 Caddy and ${target.id} frpc; no master server forwarding and no Kubernetes Ingress/NodePort/LoadBalancer.`
: "ClusterIP only; no public ingress or node-level exposure.",
resourcePolicy: "No Kubernetes CPU/memory requests or limits, matching issue #220.",
imageVersionControl: "Sub2API image repository/tag/pullPolicy are controlled by config/platform-infra/sub2api.yaml in the UniDesk repository.",
@@ -1488,7 +1578,7 @@ function plan(options: TargetOptions): Record<string, unknown> {
publicExposure: target.publicExposure?.enabled
? {
mode: "pk01-caddy-frp-direct",
dataPath: "client -> PK01 Caddy -> PK01 frps remotePort -> D601 frpc -> Sub2API",
dataPath: `client -> PK01 Caddy -> PK01 frps remotePort -> ${target.id} frpc -> Sub2API`,
pikanodeRole: "pikapython.com upstream only; api.pikapython.com does not pass through pikanode Express",
publicBaseUrl: target.publicExposure.publicBaseUrl,
hostname: target.publicExposure.dns.hostname,
@@ -1497,7 +1587,7 @@ function plan(options: TargetOptions): Record<string, unknown> {
: null,
egressProxy: target.egressProxy?.enabled
? {
mode: "D601 in-cluster HTTP proxy client to the master VPN subscription",
mode: `${target.id} in-cluster HTTP proxy client to the master VPN subscription`,
service: `${target.egressProxy.serviceName}.${target.namespace}.svc.cluster.local:${target.egressProxy.listenPort}`,
sourceRef: target.egressProxy.sourceRef,
applyToSub2Api: target.egressProxy.applyToSub2Api,
@@ -1508,7 +1598,7 @@ function plan(options: TargetOptions): Record<string, unknown> {
dataStores: isExternalTarget(target)
? [
target.databaseMode === "external-active" ? "External PostgreSQL active from platform-db/PK01" : "External PostgreSQL pending from platform-db/Pika01",
target.redisReplicas === 0 ? "D601 local Redis 8 ephemeral cache, scaled to zero until activation" : "D601 local Redis 8 ephemeral cache",
target.redisReplicas === 0 ? `${target.id} local Redis 8 ephemeral cache, scaled to zero until activation` : `${target.id} local Redis 8 ephemeral cache`,
]
: ["PostgreSQL 18", "Redis 8"],
appPoolCaps: {
@@ -1517,6 +1607,16 @@ function plan(options: TargetOptions): Record<string, unknown> {
redisPoolSize: 32,
redisMinIdleConns: 2,
},
standbyActivation: target.databaseMode === "external-pending"
? {
target: target.id,
currentMode: "predeployed-standby",
enableByYaml: `change target ${target.id} databaseMode to external-active and set appReplicas/redisReplicas to 1, then apply --target ${target.id} --confirm`,
sharedExternalDb: `${sub2api.runtime.database.host}:${sub2api.runtime.database.port}/${sub2api.runtime.database.dbName}`,
sentinelEnabled: targetHasSentinel(sub2api, target),
}
: null,
cleanup: managedResourceCleanupPlan(sub2api, target),
},
policy,
next: {
@@ -1587,7 +1687,7 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco
const secretMaterial = target.databaseMode === "external-active" ? prepareExternalActiveSecret(sub2api) : null;
const publicExposureSecretMaterial = preparePublicExposureSecret(sub2api, target);
const egressProxySecretMaterial = prepareEgressProxySecret(sub2api, target);
const result = await capture(config, target.route, ["script"], applyScript(yaml, target, secretMaterial, publicExposureSecretMaterial, egressProxySecretMaterial));
const result = await capture(config, target.route, ["script"], applyScript(sub2api, yaml, target, secretMaterial, publicExposureSecretMaterial, egressProxySecretMaterial));
const parsed = parseJsonOutput(result.stdout);
const pk01Exposure = publicExposureSecretMaterial === null ? null : await applyPk01PublicExposure(config, target);
return {
@@ -1755,7 +1855,7 @@ function policyChecks(yaml: string, target: Sub2ApiTargetConfig): PolicyCheck[]
checks.push({
name: "local-redis-ephemeral",
ok: !/\bsub2api-redis-data\b/u.test(yaml) && /^\s*emptyDir:\s*\{\}\s*$/mu.test(yaml),
detail: "D601 standby Redis is a local ephemeral cache and must not allocate persistent Redis storage.",
detail: "Local Redis is an ephemeral cache and must not allocate persistent Redis storage.",
});
}
@@ -1763,7 +1863,7 @@ function policyChecks(yaml: string, target: Sub2ApiTargetConfig): PolicyCheck[]
checks.push({
name: "egress-proxy-yaml-controlled",
ok: hasDeploymentReplicas(yaml, target.egressProxy.deploymentName, 1) && new RegExp(`^\\s*name:\\s*${escapeRegExp(target.egressProxy.serviceName)}\\s*$`, "mu").test(yaml),
detail: "D601 egress HTTP proxy client must be rendered as a YAML-controlled platform-infra Deployment and ClusterIP Service.",
detail: "Egress HTTP proxy client must be rendered as a YAML-controlled platform-infra Deployment and ClusterIP Service.",
});
}
@@ -2656,6 +2756,7 @@ PY
}
function applyScript(
sub2api: Sub2ApiConfig,
yaml: string,
target: Sub2ApiTargetConfig,
secretMaterial: ExternalActiveSecretMaterial | null,
@@ -2663,6 +2764,7 @@ function applyScript(
egressProxySecretMaterial: EgressProxySecretMaterial | null,
): string {
const encoded = Buffer.from(yaml, "utf8").toString("base64");
const cleanupPlan = managedResourceCleanupPlan(sub2api, target);
if (target.databaseMode === "external-active" && secretMaterial === null) throw new Error("external-active apply requires secret material");
const externalSecretFiles = secretMaterial === null
? ""
@@ -2727,6 +2829,8 @@ egress_secret_out="$tmp/egress-secret.out"
egress_secret_err="$tmp/egress-secret.err"
apply_out="$tmp/apply.out"
apply_err="$tmp/apply.err"
cleanup_out="$tmp/cleanup.out"
cleanup_err="$tmp/cleanup.err"
kubectl create namespace ${target.namespace} --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f - >"$ns_out" 2>"$ns_err"
ns_rc=$?
secret_action="unknown"
@@ -2803,7 +2907,98 @@ else
: >"$apply_out"
printf '%s\\n' 'skipped because namespace, app secret, public exposure secret, or egress proxy secret step failed' >"$apply_err"
fi
python3 - "$ns_rc" "$secret_rc" "$exposure_secret_rc" "$egress_secret_rc" "$apply_rc" "$secret_action" "$exposure_secret_action" "$egress_secret_action" "$ns_out" "$ns_err" "$secret_out" "$secret_err" "$exposure_secret_out" "$exposure_secret_err" "$egress_secret_out" "$egress_secret_err" "$apply_out" "$apply_err" <<'PY'
cleanup_rc=0
if [ "$apply_rc" -eq 0 ]; then
python3 - "$cleanup_out" "$cleanup_err" <<'PY'
import json
import subprocess
import sys
import time
out_path, err_path = sys.argv[1:3]
namespace = ${JSON.stringify(target.namespace)}
plan = json.loads(${JSON.stringify(JSON.stringify(cleanupPlan))})
def run(args):
proc = subprocess.run(["kubectl", "-n", namespace, *args], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
return {
"args": ["kubectl", "-n", namespace, *args],
"exitCode": proc.returncode,
"stdout": proc.stdout.decode("utf-8", errors="replace")[-2000:],
"stderr": proc.stderr.decode("utf-8", errors="replace")[-2000:],
}
def delete(kind, name):
return run(["delete", kind, name, "--ignore-not-found=true", "--wait=false"])
items = []
if plan["externalDbState"]:
items.append({"name": "legacy-postgres-statefulset", **delete("statefulset", "sub2api-postgres")})
items.append({"name": "legacy-postgres-service", **delete("service", "sub2api-postgres")})
items.append({"name": "legacy-postgres-pvc", **delete("pvc", "postgres-data-sub2api-postgres-0")})
items.append({"name": "legacy-app-data-pvc", **delete("pvc", "sub2api-data")})
if plan["redisPersistentState"]:
items.append({"name": "legacy-redis-pvc", **delete("pvc", "sub2api-redis-data")})
if not plan["publicExposure"]["enabled"]:
items.append({"name": "disabled-public-frpc-deployment", **delete("deployment", plan["publicExposure"]["deploymentName"])})
items.append({"name": "disabled-public-frpc-configmap", **delete("configmap", plan["publicExposure"]["configMapName"])})
items.append({"name": "disabled-public-frpc-secret", **delete("secret", plan["publicExposure"]["secretName"])})
if not plan["egressProxy"]["enabled"]:
items.append({"name": "disabled-egress-proxy-deployment", **delete("deployment", plan["egressProxy"]["deploymentName"])})
items.append({"name": "disabled-egress-proxy-service", **delete("service", plan["egressProxy"]["serviceName"])})
items.append({"name": "disabled-egress-proxy-secret", **delete("secret", plan["egressProxy"]["secretName"])})
if not plan["sentinel"]["enabled"]:
items.append({"name": "disabled-sentinel-cronjob", **delete("cronjob", plan["sentinel"]["cronJobName"])})
items.append({"name": "disabled-sentinel-jobs", **run(["delete", "job", "-l", f"app.kubernetes.io/name={plan['sentinel']['cronJobName']}", "--ignore-not-found=true", "--wait=false"])})
items.append({"name": "disabled-sentinel-configmap", **delete("configmap", plan["sentinel"]["configMapName"])})
items.append({"name": "disabled-sentinel-credentials-secret", **delete("secret", plan["sentinel"]["credentialsSecretName"])})
items.append({"name": "disabled-sentinel-serviceaccount", **delete("serviceaccount", plan["sentinel"]["serviceAccountName"])})
items.append({"name": "disabled-sentinel-role", **delete("role", plan["sentinel"]["roleName"])})
items.append({"name": "disabled-sentinel-rolebinding", **delete("rolebinding", plan["sentinel"]["roleBindingName"])})
watch = []
if plan["externalDbState"]:
watch.extend([("statefulset", "sub2api-postgres"), ("service", "sub2api-postgres"), ("pvc", "postgres-data-sub2api-postgres-0"), ("pvc", "sub2api-data")])
if plan["redisPersistentState"]:
watch.append(("pvc", "sub2api-redis-data"))
if not plan["publicExposure"]["enabled"]:
watch.append(("deployment", plan["publicExposure"]["deploymentName"]))
if not plan["egressProxy"]["enabled"]:
watch.append(("deployment", plan["egressProxy"]["deploymentName"]))
watch.append(("service", plan["egressProxy"]["serviceName"]))
if not plan["sentinel"]["enabled"]:
watch.append(("cronjob", plan["sentinel"]["cronJobName"]))
deadline = time.time() + 90
remaining = []
while True:
remaining = []
for kind, name in watch:
result = run(["get", kind, name])
if result["exitCode"] == 0:
remaining.append({"kind": kind, "name": name})
if not remaining or time.time() >= deadline:
break
time.sleep(3)
payload = {
"ok": all(item["exitCode"] == 0 for item in items) and not remaining,
"plan": plan,
"items": items,
"remainingAfterWait": remaining,
"valuesPrinted": False,
}
open(out_path, "w", encoding="utf-8").write(json.dumps(payload, ensure_ascii=False, indent=2))
open(err_path, "w", encoding="utf-8").write("")
sys.exit(0 if payload["ok"] else 1)
PY
cleanup_rc=$?
else
: >"$cleanup_out"
printf '%s\\n' 'skipped because apply failed' >"$cleanup_err"
cleanup_rc=1
fi
python3 - "$ns_rc" "$secret_rc" "$exposure_secret_rc" "$egress_secret_rc" "$apply_rc" "$cleanup_rc" "$secret_action" "$exposure_secret_action" "$egress_secret_action" "$ns_out" "$ns_err" "$secret_out" "$secret_err" "$exposure_secret_out" "$exposure_secret_err" "$egress_secret_out" "$egress_secret_err" "$apply_out" "$apply_err" "$cleanup_out" "$cleanup_err" <<'PY'
import json
import sys
ns_rc = int(sys.argv[1])
@@ -2811,17 +3006,23 @@ secret_rc = int(sys.argv[2])
exposure_secret_rc = int(sys.argv[3])
egress_secret_rc = int(sys.argv[4])
apply_rc = int(sys.argv[5])
secret_action = sys.argv[6]
exposure_secret_action = sys.argv[7]
egress_secret_action = sys.argv[8]
paths = sys.argv[9:]
cleanup_rc = int(sys.argv[6])
secret_action = sys.argv[7]
exposure_secret_action = sys.argv[8]
egress_secret_action = sys.argv[9]
paths = sys.argv[10:]
def text(path):
try:
return open(path, encoding="utf-8").read()
except FileNotFoundError:
return ""
def parsed(path):
try:
return json.load(open(path, encoding="utf-8"))
except Exception:
return None
payload = {
"ok": ns_rc == 0 and secret_rc == 0 and exposure_secret_rc == 0 and egress_secret_rc == 0 and apply_rc == 0,
"ok": ns_rc == 0 and secret_rc == 0 and exposure_secret_rc == 0 and egress_secret_rc == 0 and apply_rc == 0 and cleanup_rc == 0,
"target": "${target.id}",
"namespace": "${target.namespace}",
"databaseMode": "${target.databaseMode}",
@@ -2843,6 +3044,7 @@ payload = {
"publicExposureSecret": {"exitCode": exposure_secret_rc, "action": exposure_secret_action, "stdout": text(paths[4])[-4000:], "stderr": text(paths[5])[-4000:]},
"egressProxySecret": {"exitCode": egress_secret_rc, "action": egress_secret_action, "stdout": text(paths[6])[-4000:], "stderr": text(paths[7])[-4000:]},
"apply": {"exitCode": apply_rc, "stdout": text(paths[8])[-8000:], "stderr": text(paths[9])[-4000:]},
"cleanup": {"exitCode": cleanup_rc, "summary": parsed(paths[10]), "stdout": text(paths[10])[-8000:], "stderr": text(paths[11])[-4000:]},
},
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
@@ -2878,6 +3080,7 @@ capture_json pvc kubectl -n ${target.namespace} get pvc -l app.kubernetes.io/par
capture_json secrets kubectl -n ${target.namespace} get secret ${secretName}
capture_json configmap kubectl -n ${target.namespace} get configmap sub2api-config
capture_json networkpolicies kubectl -n ${target.namespace} get networkpolicy
capture_json cronjobs kubectl -n ${target.namespace} get cronjob -l app.kubernetes.io/part-of=platform-infra
capture_json ingresses kubectl -n ${target.namespace} get ingress
capture_json quotas kubectl -n ${target.namespace} get resourcequota
capture_json limitranges kubectl -n ${target.namespace} get limitrange
@@ -3074,6 +3277,7 @@ services = items("services")
pods = items("pods")
pvcs = items("pvc")
networkpolicies = items("networkpolicies")
cronjobs = items("cronjobs")
secret = load("secrets")
configmap = load("configmap")
configmap_data = (configmap or {}).get("data") or {}
@@ -3169,6 +3373,14 @@ statefulset_summaries = [statefulset_summary(item) for item in statefulsets]
workload_ready = all(d["ready"] for d in deployment_summaries) and all(s["ready"] for s in statefulset_summaries)
local_postgres_present = any(item.get("metadata", {}).get("name") == "sub2api-postgres" for item in statefulsets + services + pvcs)
redis_pvc_present = any(item.get("metadata", {}).get("name") == "sub2api-redis-data" for item in pvcs)
public_exposure_deployment_present = any(item.get("metadata", {}).get("name") == "sub2api-frpc" for item in deployments)
egress_proxy_deployment_present = any(item.get("metadata", {}).get("name") == "sub2api-egress-proxy" for item in deployments)
sentinel_cronjob_present = any(item.get("metadata", {}).get("name") == "${codexPoolSentinelResourceNames().cronJobName}" for item in cronjobs)
standby_disabled_resources_ok = not external_pending or (
not public_exposure_deployment_present
and not egress_proxy_deployment_present
and not sentinel_cronjob_present
)
secret_ready = len(missing_secret_keys) == 0
secret_ok = secret_ready or external_pending
if external_pending:
@@ -3179,6 +3391,7 @@ if external_pending:
and redis_desired_aligned
and expected_app_replicas == 0
and expected_redis_replicas == 0
and standby_disabled_resources_ok
)
elif external_active:
state_model_ok = (
@@ -3223,6 +3436,10 @@ payload = {
"redisPvcPresent": redis_pvc_present,
"sub2apiDesiredReplicasAligned": sub2api_desired_aligned,
"redisDesiredReplicasAligned": redis_desired_aligned,
"standbyDisabledResourcesOk": standby_disabled_resources_ok,
"publicExposureDeploymentPresent": public_exposure_deployment_present,
"egressProxyDeploymentPresent": egress_proxy_deployment_present,
"sentinelCronJobPresent": sentinel_cronjob_present,
"ok": state_model_ok,
},
"imageControl": {
@@ -3702,7 +3919,9 @@ capture_json() {
capture_json ns kubectl get namespace ${target.namespace}
capture_json app kubectl -n ${target.namespace} get deployment ${serviceName}
capture_json redis kubectl -n ${target.namespace} get deployment ${redisService}
capture_json deployments kubectl -n ${target.namespace} get deployments
capture_json services kubectl -n ${target.namespace} get service
capture_json cronjobs kubectl -n ${target.namespace} get cronjob
capture_json statefulsets kubectl -n ${target.namespace} get statefulsets
capture_json pvc kubectl -n ${target.namespace} get pvc
capture_json configmap kubectl -n ${target.namespace} get configmap sub2api-config
@@ -3772,6 +3991,8 @@ def deployment_summary(item):
}
services = items("services")
deployments = items("deployments")
cronjobs = items("cronjobs")
statefulsets = items("statefulsets")
pvcs = items("pvc")
configmap = load("configmap")
@@ -3785,6 +4006,16 @@ network_policy_ok = rc("networkpolicy") == 0 and is_allow_all_network_policy(net
service_names = sorted(item.get("metadata", {}).get("name") for item in services)
local_postgres_present = any(item.get("metadata", {}).get("name") == "sub2api-postgres" for item in services + statefulsets + pvcs)
redis_pvc_present = any(item.get("metadata", {}).get("name") == "sub2api-redis-data" for item in pvcs)
public_exposure_deployment_present = any(item.get("metadata", {}).get("name") == "sub2api-frpc" for item in deployments)
egress_proxy_deployment_present = any(item.get("metadata", {}).get("name") == "sub2api-egress-proxy" for item in deployments)
egress_proxy_service_present = "sub2api-egress-proxy" in service_names
sentinel_cronjob_present = any(item.get("metadata", {}).get("name") == "${codexPoolSentinelResourceNames().cronJobName}" for item in cronjobs)
standby_disabled_ok = (
not public_exposure_deployment_present
and not egress_proxy_deployment_present
and not egress_proxy_service_present
and not sentinel_cronjob_present
)
redis_ephemeral = any(volume.get("emptyDir") == {} for volume in redis_summary["volumes"])
configmap_aligned = (
configmap_data.get("DATABASE_HOST") == "${database.host}"
@@ -3798,7 +4029,7 @@ app_scaled_to_zero = app_summary["exists"] and app_summary["desired"] == ${targe
image_aligned = "${expectedImage}" in app_summary["images"]
redis_ready = redis_summary["exists"] and redis_summary["desired"] == ${target.redisReplicas} and redis_ephemeral and (redis_summary["ready"] or ${target.redisReplicas} == 0)
payload = {
"ok": rc("ns") == 0 and network_policy_ok and app_scaled_to_zero and image_aligned and redis_ready and configmap_aligned and not local_postgres_present and not redis_pvc_present and "${serviceName}" in service_names and "${redisService}" in service_names,
"ok": rc("ns") == 0 and network_policy_ok and app_scaled_to_zero and image_aligned and redis_ready and configmap_aligned and not local_postgres_present and not redis_pvc_present and standby_disabled_ok and "${serviceName}" in service_names and "${redisService}" in service_names,
"target": "${target.id}",
"namespace": "${target.namespace}",
"status": "pending-external-db",
@@ -3825,6 +4056,13 @@ payload = {
"redisReadyEphemeral": {"ok": redis_ready, "summary": redis_summary, "redisPvcPresent": redis_pvc_present, "readinessRequired": ${target.redisReplicas} > 0},
"serviceBoundary": {"serviceNames": service_names, "servicePresent": "${serviceName}" in service_names, "redisServicePresent": "${redisService}" in service_names},
"externalDbOnly": {"ok": not local_postgres_present, "localPostgresPresent": local_postgres_present},
"standbyDisabled": {
"ok": standby_disabled_ok,
"publicExposureDeploymentPresent": public_exposure_deployment_present,
"egressProxyDeploymentPresent": egress_proxy_deployment_present,
"egressProxyServicePresent": egress_proxy_service_present,
"sentinelCronJobPresent": sentinel_cronjob_present,
},
"configMapAligned": {"ok": configmap_aligned, "databaseHost": configmap_data.get("DATABASE_HOST"), "redisHost": configmap_data.get("REDIS_HOST")},
},
"next": {
+4 -5
View File
@@ -1,11 +1,10 @@
#!/bin/sh
set -eu
self_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
self_repo=$(CDPATH= cd -- "$self_dir/.." && pwd)
repo=${UNIDESK_TRAN_REPO_ROOT:-$self_repo}
if [ ! -f "$repo/scripts/cli.ts" ] && [ -f /root/unidesk/scripts/cli.ts ]; then
repo=/root/unidesk
repo=${UNIDESK_TRAN_REPO_ROOT:-/root/unidesk}
if [ ! -f "$repo/scripts/cli.ts" ]; then
self_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
repo=$(CDPATH= cd -- "$self_dir/.." && pwd)
fi
tran_timeout_seconds() {
+4 -5
View File
@@ -1,11 +1,10 @@
#!/bin/sh
set -eu
self_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
self_repo=$(CDPATH= cd -- "$self_dir/.." && pwd)
repo=${UNIDESK_TRANS_REPO_ROOT:-$self_repo}
if [ ! -f "$repo/scripts/cli.ts" ] && [ -f /root/unidesk/scripts/cli.ts ]; then
repo=/root/unidesk
repo=${UNIDESK_TRANS_REPO_ROOT:-/root/unidesk}
if [ ! -f "$repo/scripts/cli.ts" ]; then
self_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
repo=$(CDPATH= cd -- "$self_dir/.." && pwd)
fi
exec bun "$repo/scripts/cli.ts" ssh "$@"