Merge remote-tracking branch 'origin/master' into fix/langbot-cli-n8n-binding
# Conflicts: # AGENTS.md
This commit is contained in:
@@ -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`);
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+88
-16
@@ -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
@@ -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);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 "$@"
|
||||
|
||||
Reference in New Issue
Block a user