From 687310d83c2d44d6c77f392af60df089b885ba5e Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 13 Jun 2026 04:21:57 +0000 Subject: [PATCH] feat: add yaml-first AgentRun lane ops --- config/agentrun.yaml | 130 +++++++++ config/platform-db/postgres-pk01.yaml | 76 +++++ scripts/src/agentrun-lanes.ts | 393 ++++++++++++++++++++++++++ scripts/src/agentrun.ts | 333 +++++++++++++++++++++- 4 files changed, 928 insertions(+), 4 deletions(-) create mode 100644 scripts/src/agentrun-lanes.ts diff --git a/config/agentrun.yaml b/config/agentrun.yaml index a1990499..b910350b 100644 --- a/config/agentrun.yaml +++ b/config/agentrun.yaml @@ -28,3 +28,133 @@ auth: client: role: render-only transport: direct-http + +controlPlane: + default: + node: G14 + lane: v01 + + nodes: + G14: + route: G14 + kubeRoute: G14:k3s + D601: + route: D601 + kubeRoute: D601:k3s + + lanes: + v01: + node: G14 + version: v0.1 + source: + repository: pikasTech/agentrun + branch: v0.1 + remote: git@github.com:pikasTech/agentrun.git + workspace: /root/agentrun-v01 + runtime: + namespace: agentrun-v01 + managerDeployment: agentrun-mgr + managerService: agentrun-mgr + managerPort: 8080 + internalBaseUrl: http://agentrun-mgr.agentrun-v01.svc.cluster.local:8080 + ci: + namespace: agentrun-ci + pipeline: agentrun-v01-ci-image-publish + pipelineRunPrefix: agentrun-v01-ci + serviceAccountName: agentrun-v01-tekton-runner + registryPrefix: 127.0.0.1:5000/agentrun + toolsImage: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 + gitops: + branch: v0.1-gitops + path: deploy/gitops/g14/runtime-v01 + argoNamespace: argocd + argoApplication: agentrun-g14-v01 + repoURL: http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/agentrun.git + gitMirror: + namespace: devops-infra + readService: git-mirror-http + writeService: git-mirror-write + readUrl: http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/agentrun.git + writeUrl: http://git-mirror-write.devops-infra.svc.cluster.local/pikasTech/agentrun.git + cachePvc: git-mirror-cache + toolsImage: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 + syncJobPrefix: git-mirror-agentrun-sync-manual + flushJobPrefix: git-mirror-agentrun-flush-manual + repositories: + - key: agentrun + repository: pikasTech/agentrun + sourceBranch: v0.1 + gitopsBranch: v0.1-gitops + - key: unidesk + repository: pikasTech/unidesk + sourceBranch: master + - key: agent_skills + repository: pikasTech/agent_skills + sourceBranch: master + database: + mode: local-postgres + secretRef: + name: agentrun-v01-mgr-db + key: DATABASE_URL + localPostgresExpectedAbsent: false + + v02: + node: D601 + version: v0.2 + source: + repository: pikasTech/agentrun + branch: v0.2 + remote: git@github.com:pikasTech/agentrun.git + workspace: /home/ubuntu/workspace/agentrun-v02 + runtime: + namespace: agentrun-v02 + managerDeployment: agentrun-mgr + managerService: agentrun-mgr + managerPort: 8080 + internalBaseUrl: http://agentrun-mgr.agentrun-v02.svc.cluster.local:8080 + ci: + namespace: agentrun-ci + pipeline: agentrun-v02-ci-image-publish + pipelineRunPrefix: agentrun-v02-ci + serviceAccountName: agentrun-v02-tekton-runner + registryPrefix: 127.0.0.1:5000/agentrun + toolsImage: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 + gitops: + branch: v0.2-gitops + path: deploy/gitops/node/d601/runtime-v02 + argoNamespace: argocd + argoApplication: agentrun-d601-v02 + repoURL: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/agentrun.git + gitMirror: + namespace: devops-infra + readService: git-mirror-http + writeService: git-mirror-write + readUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/agentrun.git + writeUrl: http://git-mirror-write.devops-infra.svc.cluster.local:8080/pikasTech/agentrun.git + cachePvc: hwlab-git-mirror-cache + toolsImage: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 + syncJobPrefix: git-mirror-agentrun-d601-v02-sync-manual + flushJobPrefix: git-mirror-agentrun-d601-v02-flush-manual + repositories: + - key: agentrun + repository: pikasTech/agentrun + sourceBranch: v0.2 + gitopsBranch: v0.2-gitops + - key: unidesk + repository: pikasTech/unidesk + sourceBranch: master + - key: agent_skills + repository: pikasTech/agent_skills + sourceBranch: master + database: + mode: external-postgres + provider: PK01 + configRef: config/platform-db/postgres-pk01.yaml + database: agentrun_v02 + user: agentrun_v02 + sslmode: require + secretSourceRef: agentrun/d601-v02-mgr-db.env + secretRef: + name: agentrun-v02-mgr-db + key: DATABASE_URL + localPostgresExpectedAbsent: true diff --git a/config/platform-db/postgres-pk01.yaml b/config/platform-db/postgres-pk01.yaml index 8d22b3cc..5434c07f 100644 --- a/config/platform-db/postgres-pk01.yaml +++ b/config/platform-db/postgres-pk01.yaml @@ -200,6 +200,36 @@ postgres: user: n8n address: 202.98.17.68/32 method: scram-sha-256 + - type: hostssl + database: agentrun_v02 + user: agentrun_v02 + address: 10.0.8.0/22 + method: scram-sha-256 + - type: hostssl + database: postgres + user: agentrun_v02 + address: 10.0.8.0/22 + method: scram-sha-256 + - type: hostssl + database: agentrun_v02 + user: agentrun_v02 + address: 36.49.29.73/32 + method: scram-sha-256 + - type: hostssl + database: postgres + user: agentrun_v02 + address: 36.49.29.73/32 + method: scram-sha-256 + - type: hostssl + database: agentrun_v02 + user: agentrun_v02 + address: 74.48.78.17/32 + method: scram-sha-256 + - type: hostssl + database: postgres + user: agentrun_v02 + address: 74.48.78.17/32 + method: scram-sha-256 secrets: source: master-local @@ -247,6 +277,20 @@ secrets: N8N_DB_NAME: n8n randomHex: N8N_DB_PASSWORD: 32 + - name: agentrun-v02-db-credentials + sourceRef: platform-db/agentrun-v02-db.env + type: env + requiredKeys: + - AGENTRUN_V02_DB_USER + - AGENTRUN_V02_DB_PASSWORD + - AGENTRUN_V02_DB_NAME + createIfMissing: + enabled: true + values: + AGENTRUN_V02_DB_USER: agentrun_v02 + AGENTRUN_V02_DB_NAME: agentrun_v02 + randomHex: + AGENTRUN_V02_DB_PASSWORD: 32 objects: roles: @@ -277,6 +321,15 @@ objects: createdb: false createrole: false superuser: false + - name: agentrun_v02 + passwordRef: + sourceRef: platform-db/agentrun-v02-db.env + key: AGENTRUN_V02_DB_PASSWORD + login: true + attributes: + createdb: false + createrole: false + superuser: false databases: - name: sub2api owner: sub2api @@ -293,6 +346,11 @@ objects: encoding: UTF8 locale: C.UTF-8 extensions: [] + - name: agentrun_v02 + owner: agentrun_v02 + encoding: UTF8 + locale: C.UTF-8 + extensions: [] exports: connectionStrings: @@ -341,6 +399,21 @@ exports: - scope: platform-infra secret: n8n-secrets key: DATABASE_URL + - name: agentrun-v02-database-url + sourceSecretRef: platform-db/agentrun-v02-db.env + render: + envKey: DATABASE_URL + format: postgresql://$(AGENTRUN_V02_DB_USER):$(AGENTRUN_V02_DB_PASSWORD)@$(PGHOST):5432/$(AGENTRUN_V02_DB_NAME)?sslmode=require + variables: + PGHOST: 82.156.23.220 + writeToSecretSource: + sourceRef: agentrun/d601-v02-mgr-db.env + key: DATABASE_URL + mode: update-or-insert + consumers: + - scope: agentrun-v02 + secret: agentrun-v02-mgr-db + key: DATABASE_URL backup: phase: minimum-restoreable @@ -377,6 +450,9 @@ observability: - kind: psql-app-role database: n8n user: n8n + - kind: psql-app-role + database: agentrun_v02 + user: agentrun_v02 - kind: disk-free path: /var/lib/postgresql/16/main minFreeGiB: 10 diff --git a/scripts/src/agentrun-lanes.ts b/scripts/src/agentrun-lanes.ts new file mode 100644 index 00000000..fd2ebfb4 --- /dev/null +++ b/scripts/src/agentrun-lanes.ts @@ -0,0 +1,393 @@ +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 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 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 gitMirror: { + readonly namespace: string; + readonly readService: string; + readonly writeService: string; + readonly readUrl: string; + readonly writeUrl: string; + readonly cachePvc: string; + 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; + }; +} + +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; + readonly lanes: Record; +} + +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 { + 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, + 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, + }, + gitMirror: { + namespace: spec.gitMirror.namespace, + readService: spec.gitMirror.readService, + writeService: spec.gitMirror.writeService, + readUrl: spec.gitMirror.readUrl, + writeUrl: spec.gitMirror.writeUrl, + cachePvc: spec.gitMirror.cachePvc, + 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, + }, + }; +} + +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, configPath: string): Record { + const result: Record = {}; + 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, nodes: Record, configPath: string): Record { + const result: Record = {}; + 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, 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 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`), + 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`), + }, + gitMirror: { + namespace: stringField(gitMirror, "namespace", `${path}.gitMirror`), + readService: stringField(gitMirror, "readService", `${path}.gitMirror`), + writeService: stringField(gitMirror, "writeService", `${path}.gitMirror`), + readUrl: urlField(gitMirror, "readUrl", `${path}.gitMirror`), + writeUrl: urlField(gitMirror, "writeUrl", `${path}.gitMirror`), + cachePvc: stringField(gitMirror, "cachePvc", `${path}.gitMirror`), + 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`), + }; +} + +function parseGitMirrorRepository(input: Record, 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, 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, path: string): { name: string; key: string } { + return { + name: stringField(input, "name", path), + key: stringField(input, "key", path), + }; +} + +function asRecord(value: unknown, path: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be a YAML object`); + return value as Record; +} + +function recordField(obj: Record, key: string, path: string): Record { + return asRecord(obj[key], `${path}.${key}`); +} + +function stringField(obj: Record, 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, 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, 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, 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 arrayField(obj: Record, key: string, path: string): Record[] { + 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 enumField(obj: Record, 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, 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 relativePathField(obj: Record, 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 urlField(obj: Record, 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`); +} diff --git a/scripts/src/agentrun.ts b/scripts/src/agentrun.ts index b2db4a0e..d3bc5909 100644 --- a/scripts/src/agentrun.ts +++ b/scripts/src/agentrun.ts @@ -5,6 +5,13 @@ import type { RenderedCliResult } from "./output"; import { runSshCommandCapture, type SshCaptureResult } from "./ssh"; import { runRemoteSshCommandCapture } from "./remote"; import { startJob } from "./jobs"; +import { + AGENTRUN_CONFIG_PATH, + agentRunLaneSummary, + agentRunPipelineRunName, + resolveAgentRunLaneTarget, + type AgentRunLaneSpec, +} from "./agentrun-lanes"; const g14SourceRoute = "G14:/root/agentrun-v01"; const g14K3sRoute = "G14:k3s"; @@ -56,6 +63,8 @@ export function agentRunHelp(): unknown { "bun scripts/cli.ts agentrun apply -f - --dry-run", "bun scripts/cli.ts agentrun send session/ --aipod Artificer --prompt-stdin", "bun scripts/cli.ts agentrun explain task", + "bun scripts/cli.ts agentrun control-plane plan --node D601 --lane v02", + "bun scripts/cli.ts agentrun control-plane status --node D601 --lane v02", "bun scripts/cli.ts agentrun control-plane status", "bun scripts/cli.ts agentrun control-plane status --full", "bun scripts/cli.ts agentrun control-plane status --pipeline-run agentrun-v01-ci-", @@ -92,6 +101,7 @@ export async function runAgentRunCommand(config: UniDeskConfig | null, args: str } if (config === null) throw new Error("agentrun control-plane and git-mirror commands require UniDesk config"); if (group === "control-plane") { + if (action === "plan") return await controlPlanePlan(config, parseStatusOptions(actionArgs)); if (action === "status") return await status(config, parseStatusOptions(actionArgs)); if (action === "expose") return await exposeAgentRun(config, parseConfirmOptions(actionArgs)); if (action === "trigger-current") return await triggerCurrent(config, parseTriggerOptions(actionArgs)); @@ -217,8 +227,10 @@ function agentRunHelpText(args: string[]): string { return [ "Usage: bun scripts/cli.ts agentrun control-plane [options]", "", - "Actions: status, expose, trigger-current, refresh, cleanup-runs, cleanup-released-pvs", + "Actions: plan, status, expose, trigger-current, refresh, cleanup-runs, cleanup-released-pvs", "Examples:", + " bun scripts/cli.ts agentrun control-plane plan --node D601 --lane v02", + " bun scripts/cli.ts agentrun control-plane status --node D601 --lane v02", " bun scripts/cli.ts agentrun control-plane status", " bun scripts/cli.ts agentrun control-plane status --pipeline-run agentrun-v01-ci-", " bun scripts/cli.ts agentrun control-plane expose --dry-run", @@ -1645,6 +1657,8 @@ interface DisclosureOptions { } interface StatusOptions extends DisclosureOptions { + node: string | null; + lane: string | null; sourceCommit: string | null; pipelineRun: string | null; targetMode: "latest-source-head" | "source-commit" | "pipeline-run"; @@ -1656,7 +1670,14 @@ interface TimedValue { } function parseDisclosureOptions(args: string[]): DisclosureOptions { - for (const arg of args) { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--node" || arg === "--lane") { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value`); + index += 1; + continue; + } if (arg !== "--full" && arg !== "--raw") throw new Error(`unsupported status option: ${arg}`); } const raw = args.includes("--raw"); @@ -1664,6 +1685,8 @@ function parseDisclosureOptions(args: string[]): DisclosureOptions { } function parseStatusOptions(args: string[]): StatusOptions { + let node: string | null = null; + let lane: string | null = null; let sourceCommit: string | null = null; let pipelineRun: string | null = null; let full = false; @@ -1679,6 +1702,20 @@ function parseStatusOptions(args: string[]): StatusOptions { full = true; continue; } + if (arg === "--node") { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error("--node requires a value"); + node = value; + index += 1; + continue; + } + if (arg === "--lane") { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error("--lane requires a value"); + lane = value; + index += 1; + continue; + } if (arg === "--source-commit") { const value = args[index + 1]; if (value === undefined || value.startsWith("--")) throw new Error("--source-commit requires a value"); @@ -1690,7 +1727,7 @@ function parseStatusOptions(args: string[]): StatusOptions { if (arg === "--pipeline-run") { const value = args[index + 1]; if (value === undefined || value.startsWith("--")) throw new Error("--pipeline-run requires a value"); - if (!isAgentRunPipelineRunName(value)) throw new Error("--pipeline-run must be an agentrun-v01-ci-<12+ hex> PipelineRun name"); + if (!isAgentRunPipelineRunName(value)) throw new Error("--pipeline-run must be an agentrun-vNN-ci-<12+ hex> PipelineRun name"); pipelineRun = value; index += 1; continue; @@ -1701,6 +1738,8 @@ function parseStatusOptions(args: string[]): StatusOptions { return { full, raw, + node, + lane, sourceCommit, pipelineRun, targetMode: pipelineRun !== null ? "pipeline-run" : sourceCommit !== null ? "source-commit" : "latest-source-head", @@ -1779,7 +1818,42 @@ function positiveIntegerOption(args: string[], name: string, defaultValue: numbe return Math.min(value, maxValue); } +async function controlPlanePlan(_config: UniDeskConfig, options: StatusOptions): Promise> { + const target = resolveAgentRunLaneTarget(options); + const spec = target.spec; + return { + ok: true, + command: "agentrun control-plane plan", + mode: "yaml-declared-node-lane", + configPath: target.configPath, + target: agentRunLaneSummary(spec), + plannedChecks: [ + "source-branch-exists", + "source-worktree-exists-and-clean", + "git-mirror-services-ready", + "ci-namespace-pipeline-serviceaccount", + "argo-application-alignment", + "runtime-namespace-manager-service", + "database-secretref-present", + "local-postgres-absent-when-external", + ], + deploymentBoundary: { + mutation: false, + note: "plan/status are read-only. Long writes must use controlled AgentRun/Platform DB commands and this YAML target.", + }, + next: { + status: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`, + postgresStatus: spec.database.configRef ? `bun scripts/cli.ts platform-db postgres status --config ${spec.database.configRef}` : null, + postgresApply: spec.database.configRef ? `bun scripts/cli.ts platform-db postgres apply --config ${spec.database.configRef} --confirm` : null, + }, + valuesPrinted: false, + }; +} + async function status(config: UniDeskConfig, options: StatusOptions): Promise> { + if (options.node !== null || options.lane !== null) { + return await statusYamlLane(config, options, resolveAgentRunLaneTarget(options)); + } const sourceProbe = await timedStatusStage("source", () => capture(config, g14SourceRoute, ["script", "--", [ "cd /root/agentrun-v01", "git fetch origin v0.1 >/dev/null 2>&1 || true", @@ -1930,6 +2004,103 @@ async function status(config: UniDeskConfig, options: StatusOptions): Promise> { + const spec = target.spec; + const sourceProbe = await timedStatusStage("source", () => capture(config, `${spec.nodeRoute}:${spec.source.workspace}`, ["script", "--", yamlLaneSourceStatusScript(spec)])); + const sourcePayload = captureJsonPayload(sourceProbe.value); + const sourceCommit = options.sourceCommit + ?? (options.pipelineRun !== null ? null : stringOrNull(sourcePayload.remoteBranchCommit) ?? stringOrNull(sourcePayload.localHead)); + const pipelineRun = options.pipelineRun ?? (sourceCommit ? agentRunPipelineRunName(spec, sourceCommit) : null); + const [runtimeProbe, mirrorProbe] = await Promise.all([ + timedStatusStage("runtime", () => capture(config, spec.nodeKubeRoute, ["script", "--", yamlLaneRuntimeStatusScript(spec, pipelineRun)])), + timedStatusStage("git-mirror", () => capture(config, spec.nodeKubeRoute, ["script", "--", yamlLaneGitMirrorStatusScript(spec)])), + ]); + const runtimePayload = captureJsonPayload(runtimeProbe.value); + const mirrorPayload = captureJsonPayload(mirrorProbe.value); + const pipeline = record(runtimePayload.pipeline); + const argo = record(runtimePayload.argo); + const manager = record(runtimePayload.manager); + const database = record(runtimePayload.database); + const localPostgres = record(runtimePayload.localPostgres); + const blockers = [ + ...(sourcePayload.workspaceExists === true ? [] : ["source-worktree-missing"]), + ...(sourcePayload.remoteBranchExists === true ? [] : ["source-branch-missing"]), + ...(sourcePayload.workspaceClean === true || sourcePayload.workspaceExists !== true ? [] : ["source-worktree-dirty"]), + ...(mirrorPayload.readReady === true ? [] : ["git-mirror-read-not-ready"]), + ...(mirrorPayload.writeReady === true ? [] : ["git-mirror-write-not-ready"]), + ...(mirrorPayload.cachePvcExists === true ? [] : ["git-mirror-cache-pvc-missing"]), + ...(runtimePayload.ciNamespaceExists === true ? [] : ["ci-namespace-missing"]), + ...(pipeline.exists === true ? [] : ["pipeline-missing"]), + ...(runtimePayload.serviceAccountExists === true ? [] : ["ci-serviceaccount-missing"]), + ...(argo.exists === true ? [] : ["argo-application-missing"]), + ...(runtimePayload.runtimeNamespaceExists === true ? [] : ["runtime-namespace-missing"]), + ...(manager.deploymentExists === true ? [] : ["manager-deployment-missing"]), + ...(manager.serviceExists === true ? [] : ["manager-service-missing"]), + ...(spec.database.mode === "external-postgres" && database.secretPresent !== true ? ["database-secret-missing"] : []), + ...(spec.database.localPostgresExpectedAbsent && localPostgres.absent !== true ? ["local-postgres-present"] : []), + ]; + return { + ok: sourceProbe.value.exitCode === 0 && runtimeProbe.value.exitCode === 0 && mirrorProbe.value.exitCode === 0 && blockers.length === 0, + command: "agentrun control-plane status", + mode: "yaml-declared-node-lane", + configPath: target.configPath, + target: agentRunLaneSummary(spec), + summary: { + aligned: blockers.length === 0, + blockers, + sourceCommit, + expectedPipelineRun: pipelineRun, + source: { + workspaceExists: sourcePayload.workspaceExists ?? false, + workspaceClean: sourcePayload.workspaceClean ?? null, + localHead: sourcePayload.localHead ?? null, + remoteBranchExists: sourcePayload.remoteBranchExists ?? false, + remoteBranchCommit: sourcePayload.remoteBranchCommit ?? null, + }, + gitMirror: { + readReady: mirrorPayload.readReady ?? false, + writeReady: mirrorPayload.writeReady ?? false, + cachePvcExists: mirrorPayload.cachePvcExists ?? false, + repositoryCount: Array.isArray(mirrorPayload.repositories) ? mirrorPayload.repositories.length : null, + }, + ci: { + namespaceExists: runtimePayload.ciNamespaceExists ?? false, + serviceAccountExists: runtimePayload.serviceAccountExists ?? false, + pipeline, + pipelineRun: record(runtimePayload.pipelineRun), + }, + argo, + runtime: { + namespaceExists: runtimePayload.runtimeNamespaceExists ?? false, + manager, + database, + localPostgres, + }, + }, + timings: { + sourceMs: sourceProbe.elapsedMs, + runtimeMs: runtimeProbe.elapsedMs, + gitMirrorMs: mirrorProbe.elapsedMs, + totalMs: sourceProbe.elapsedMs + Math.max(runtimeProbe.elapsedMs, mirrorProbe.elapsedMs), + }, + source: sourcePayload, + runtime: runtimePayload, + gitMirror: mirrorPayload, + captures: { + source: compactCapture(sourceProbe.value, { full: options.full || options.raw || sourceProbe.value.exitCode !== 0 }), + runtime: compactCapture(runtimeProbe.value, { full: options.full || options.raw || runtimeProbe.value.exitCode !== 0 }), + gitMirror: compactCapture(mirrorProbe.value, { full: options.full || options.raw || mirrorProbe.value.exitCode !== 0 }), + }, + next: { + plan: `bun scripts/cli.ts agentrun control-plane plan --node ${spec.nodeId} --lane ${spec.lane}`, + postgresStatus: spec.database.configRef ? `bun scripts/cli.ts platform-db postgres status --config ${spec.database.configRef}` : null, + postgresApply: spec.database.configRef ? `bun scripts/cli.ts platform-db postgres apply --config ${spec.database.configRef} --confirm` : null, + statusFull: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane} --full`, + }, + valuesPrinted: false, + }; +} + async function exposeAgentRun(_config: UniDeskConfig, options: ConfirmOptions): Promise> { const clientConfig = readAgentRunClientConfig(); const exposure = clientConfig.publicExposure; @@ -2406,6 +2577,160 @@ function cleanupReleasedPvsScript(options: CleanupReleasedPvOptions): string { ].join("\n"); } +function yamlLaneSourceStatusScript(spec: AgentRunLaneSpec): string { + return [ + "set +e", + `expected_workspace=${shQuote(spec.source.workspace)}`, + `source_branch=${shQuote(spec.source.branch)}`, + "workspace_exists=false", + "workspace_clean=null", + "local_head=null", + "branch=null", + "remote_url=null", + "remote_branch_exists=false", + "remote_branch_commit=null", + "status_short=''", + "if [ -d .git ] || git rev-parse --show-toplevel >/dev/null 2>&1; then", + " actual_workspace=$(pwd)", + " workspace_exists=true", + " branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)", + " remote_url=$(git remote get-url origin 2>/dev/null || true)", + " local_head=$(git rev-parse HEAD 2>/dev/null || true)", + " status_short=$(git status --short 2>/dev/null || true)", + " if [ -z \"$status_short\" ]; then workspace_clean=true; else workspace_clean=false; fi", + " git fetch origin \"$source_branch\" >/dev/null 2>&1 || true", + " remote_branch_commit=$(git rev-parse \"refs/remotes/origin/$source_branch\" 2>/dev/null || true)", + " if [ -n \"$remote_branch_commit\" ]; then remote_branch_exists=true; fi", + "else", + " actual_workspace=$(pwd)", + "fi", + "export expected_workspace source_branch workspace_exists workspace_clean local_head branch remote_url remote_branch_exists remote_branch_commit status_short actual_workspace", + "node <<'NODE'", + "function nullable(value) { return value && value !== 'null' ? value : null; }", + "function booleanValue(value) { if (value === 'true') return true; if (value === 'false') return false; return null; }", + "const env = process.env;", + "console.log(JSON.stringify({", + " ok: env.workspace_exists === 'true',", + " expectedWorkspace: env.expected_workspace,", + " actualWorkspace: env.actual_workspace || null,", + " workspaceExists: env.workspace_exists === 'true',", + " workspaceClean: booleanValue(env.workspace_clean),", + " branch: nullable(env.branch),", + " remoteUrl: nullable(env.remote_url),", + " localHead: nullable(env.local_head),", + " remoteBranch: env.source_branch,", + " remoteBranchExists: env.remote_branch_exists === 'true',", + " remoteBranchCommit: nullable(env.remote_branch_commit),", + " statusShort: nullable(env.status_short),", + " valuesPrinted: false", + "}));", + "NODE", + ].join("\n"); +} + +function yamlLaneRuntimeStatusScript(spec: AgentRunLaneSpec, pipelineRun: string | null): string { + return [ + "set +e", + `runtime_namespace=${shQuote(spec.runtime.namespace)}`, + `ci_namespace=${shQuote(spec.ci.namespace)}`, + `pipeline_name=${shQuote(spec.ci.pipeline)}`, + `pipeline_run=${pipelineRun === null ? "''" : shQuote(pipelineRun)}`, + `service_account=${shQuote(spec.ci.serviceAccountName)}`, + `argo_namespace=${shQuote(spec.gitops.argoNamespace)}`, + `argo_application=${shQuote(spec.gitops.argoApplication)}`, + `manager_deployment=${shQuote(spec.runtime.managerDeployment)}`, + `manager_service=${shQuote(spec.runtime.managerService)}`, + `database_secret=${shQuote(spec.database.secretRef.name)}`, + `database_key=${shQuote(spec.database.secretRef.key)}`, + "export runtime_namespace ci_namespace pipeline_name pipeline_run service_account argo_namespace argo_application manager_deployment manager_service database_secret database_key", + "tmp_dir=$(mktemp -d)", + "trap 'rm -rf \"$tmp_dir\"' EXIT", + "kubectl get ns \"$runtime_namespace\" -o json > \"$tmp_dir/runtime-ns.json\" 2>/dev/null", + "runtime_ns_exit=$?", + "kubectl get ns \"$ci_namespace\" -o json > \"$tmp_dir/ci-ns.json\" 2>/dev/null", + "ci_ns_exit=$?", + "kubectl -n \"$ci_namespace\" get pipeline \"$pipeline_name\" -o json > \"$tmp_dir/pipeline.json\" 2>/dev/null", + "pipeline_exit=$?", + "kubectl -n \"$ci_namespace\" get serviceaccount \"$service_account\" -o json > \"$tmp_dir/serviceaccount.json\" 2>/dev/null", + "sa_exit=$?", + "if [ -n \"$pipeline_run\" ]; then kubectl -n \"$ci_namespace\" get pipelinerun \"$pipeline_run\" -o json > \"$tmp_dir/pipelinerun.json\" 2>/dev/null; pr_exit=$?; else pr_exit=2; fi", + "kubectl -n \"$argo_namespace\" get application \"$argo_application\" -o json > \"$tmp_dir/argo.json\" 2>/dev/null", + "argo_exit=$?", + "kubectl -n \"$runtime_namespace\" get deploy \"$manager_deployment\" -o json > \"$tmp_dir/manager-deploy.json\" 2>/dev/null", + "manager_deploy_exit=$?", + "kubectl -n \"$runtime_namespace\" get svc \"$manager_service\" -o json > \"$tmp_dir/manager-svc.json\" 2>/dev/null", + "manager_svc_exit=$?", + "kubectl -n \"$runtime_namespace\" get secret \"$database_secret\" -o json > \"$tmp_dir/db-secret.json\" 2>/dev/null", + "db_secret_exit=$?", + "kubectl -n \"$runtime_namespace\" get deploy,sts,svc,secret -o name > \"$tmp_dir/runtime-names.txt\" 2>/dev/null", + "NODE_TMP=\"$tmp_dir\" RUNTIME_NS_EXIT=\"$runtime_ns_exit\" CI_NS_EXIT=\"$ci_ns_exit\" PIPELINE_EXIT=\"$pipeline_exit\" SERVICE_ACCOUNT_EXIT=\"$sa_exit\" PIPELINERUN_EXIT=\"$pr_exit\" ARGO_EXIT=\"$argo_exit\" MANAGER_DEPLOY_EXIT=\"$manager_deploy_exit\" MANAGER_SVC_EXIT=\"$manager_svc_exit\" DB_SECRET_EXIT=\"$db_secret_exit\" node <<'NODE'", + "const fs = require('node:fs');", + "const path = require('node:path');", + "const dir = process.env.NODE_TMP;", + "function readJson(name) { try { return JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8')); } catch { return null; } }", + "function exists(exitName) { return process.env[exitName] === '0'; }", + "function condition(obj) { return obj?.status?.conditions?.[0] || null; }", + "function envValue(deploy, name) { return deploy?.spec?.template?.spec?.containers?.[0]?.env?.find((entry) => entry.name === name)?.value || null; }", + "const pipelineRun = readJson('pipelinerun.json');", + "const argo = readJson('argo.json');", + "const managerDeploy = readJson('manager-deploy.json');", + "const managerSvc = readJson('manager-svc.json');", + "const dbSecret = readJson('db-secret.json');", + "let names = ''; try { names = fs.readFileSync(path.join(dir, 'runtime-names.txt'), 'utf8'); } catch {}", + "const c = condition(pipelineRun);", + "console.log(JSON.stringify({", + " ok: exists('RUNTIME_NS_EXIT') && exists('CI_NS_EXIT'),", + " runtimeNamespaceExists: exists('RUNTIME_NS_EXIT'),", + " ciNamespaceExists: exists('CI_NS_EXIT'),", + " serviceAccountExists: exists('SERVICE_ACCOUNT_EXIT'),", + " pipeline: { exists: exists('PIPELINE_EXIT'), name: process.env.pipeline_name },", + " pipelineRun: { exists: exists('PIPELINERUN_EXIT'), name: process.env.pipeline_run || null, status: c?.status || null, reason: c?.reason || null, startTime: pipelineRun?.status?.startTime || null, completionTime: pipelineRun?.status?.completionTime || null },", + " argo: { exists: exists('ARGO_EXIT'), namespace: process.env.argo_namespace, application: process.env.argo_application, revision: argo?.status?.sync?.revision || null, syncStatus: argo?.status?.sync?.status || null, healthStatus: argo?.status?.health?.status || null },", + " manager: { deploymentExists: exists('MANAGER_DEPLOY_EXIT'), serviceExists: exists('MANAGER_SVC_EXIT'), deployment: process.env.manager_deployment, service: process.env.manager_service, image: managerDeploy?.spec?.template?.spec?.containers?.[0]?.image || null, sourceCommit: envValue(managerDeploy, 'AGENTRUN_SOURCE_COMMIT'), servicePorts: Array.isArray(managerSvc?.spec?.ports) ? managerSvc.spec.ports.map((port) => ({ name: port.name || null, port: port.port || null, targetPort: port.targetPort || null })) : [] },", + " database: { secretPresent: exists('DB_SECRET_EXIT'), secretName: process.env.database_secret, key: process.env.database_key, keyPresent: Boolean(dbSecret?.data && Object.prototype.hasOwnProperty.call(dbSecret.data, process.env.database_key || '')) , valuesPrinted: false },", + " localPostgres: { absent: !/postgres/i.test(names), matchingObjects: names.split(/\\r?\\n/).filter((line) => /postgres/i.test(line)).slice(0, 20) },", + " valuesPrinted: false", + "}));", + "NODE", + ].join("\n"); +} + +function yamlLaneGitMirrorStatusScript(spec: AgentRunLaneSpec): string { + return [ + "set +e", + `namespace=${shQuote(spec.gitMirror.namespace)}`, + `read_service=${shQuote(spec.gitMirror.readService)}`, + `write_service=${shQuote(spec.gitMirror.writeService)}`, + `cache_pvc=${shQuote(spec.gitMirror.cachePvc)}`, + `repositories_json=${shQuote(JSON.stringify(spec.gitMirror.repositories))}`, + "kubectl -n \"$namespace\" get svc \"$read_service\" -o json >/tmp/agentrun-gitmirror-read.json 2>/dev/null", + "read_exit=$?", + "kubectl -n \"$namespace\" get svc \"$write_service\" -o json >/tmp/agentrun-gitmirror-write.json 2>/dev/null", + "write_exit=$?", + "kubectl -n \"$namespace\" get pvc \"$cache_pvc\" -o json >/tmp/agentrun-gitmirror-cache.json 2>/dev/null", + "cache_exit=$?", + "kubectl -n \"$namespace\" get deploy,svc,pvc -o name > /tmp/agentrun-gitmirror-names.txt 2>/dev/null", + "NAMESPACE=\"$namespace\" READ_EXIT=\"$read_exit\" WRITE_EXIT=\"$write_exit\" CACHE_EXIT=\"$cache_exit\" REPOSITORIES_JSON=\"$repositories_json\" node <<'NODE'", + "const fs = require('node:fs');", + "const repositories = JSON.parse(process.env.REPOSITORIES_JSON || '[]');", + "let names = ''; try { names = fs.readFileSync('/tmp/agentrun-gitmirror-names.txt', 'utf8'); } catch {}", + "const readReady = process.env.READ_EXIT === '0';", + "const writeReady = process.env.WRITE_EXIT === '0';", + "const cachePvcExists = process.env.CACHE_EXIT === '0';", + "console.log(JSON.stringify({", + " ok: readReady && writeReady && cachePvcExists,", + " namespace: process.env.NAMESPACE,", + " readReady,", + " writeReady,", + " cachePvcExists,", + " resources: names.split(/\\r?\\n/).filter(Boolean).slice(0, 40),", + " repositories,", + " valuesPrinted: false", + "}));", + "NODE", + ].join("\n"); +} + function cleanupRunsPlanNodeScript(): string { return String.raw` const fs = require("node:fs"); @@ -4429,7 +4754,7 @@ function pipelineRunName(sourceCommit: string): string { } function isAgentRunPipelineRunName(value: string): boolean { - return /^agentrun-v01-ci-[0-9a-f]{12,40}(?:-[a-z0-9-]+)?$/u.test(value); + return /^agentrun-v[0-9]+-ci-[0-9a-f]{12,40}(?:-[a-z0-9-]+)?$/u.test(value); } function statusTargetArg(options: StatusOptions, target: Record): string {