From eed475abdc0821d340664474efaff338a1f29002 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 07:52:59 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=20Sub2Rank=20?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E4=BA=A4=E4=BB=98=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/platform-infra/gitea.yaml | 22 ++ config/platform-infra/pipelines-as-code.yaml | 67 +++++ config/platform-infra/sub2rank.yaml | 53 +++- ...atform-infra-gitea-gitops-delivery.test.ts | 7 + ...infra-pipelines-as-code-source-artifact.ts | 24 +- .../src/platform-infra-pipelines-as-code.ts | 3 +- scripts/src/platform-infra-sub2rank-config.ts | 185 ++++++++++++- .../src/platform-infra-sub2rank-pipeline.ts | 256 ++++++++++++++++++ scripts/src/platform-infra-sub2rank.ts | 164 +++++------ 9 files changed, 671 insertions(+), 110 deletions(-) create mode 100644 scripts/src/platform-infra-sub2rank-pipeline.ts diff --git a/config/platform-infra/gitea.yaml b/config/platform-infra/gitea.yaml index 3ecbef31..df2919b0 100644 --- a/config/platform-infra/gitea.yaml +++ b/config/platform-infra/gitea.yaml @@ -288,6 +288,28 @@ sourceAuthority: readUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/unidesk.git configRef: config/cicd-branch-followers.yaml#controller.source.gitMirrorReadUrl disposition: replaced-by-gitea + - key: sub2rank-nc01 + targetId: NC01 + upstream: + repository: pikasTech/sub2rank + cloneUrl: https://github.com/pikasTech/sub2rank.git + branch: master + gitea: + owner: mirrors + name: pikasTech-sub2rank + mirrorMode: controlled-push + publicRead: true + readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-sub2rank.git + gitops: + branch: master + flushDisposition: not-a-gitops-branch + snapshot: + naming: application-source-snapshot + prefix: refs/unidesk/snapshots/gitea-actions/sub2rank-master-nc01 + legacyGitMirror: + readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-sub2rank.git + configRef: config/platform-infra/sub2rank.yaml#application.remote + disposition: replaced-by-gitea - key: hwlab-jd01-v03 targetId: JD01 upstream: diff --git a/config/platform-infra/pipelines-as-code.yaml b/config/platform-infra/pipelines-as-code.yaml index 4b53e015..81a90780 100644 --- a/config/platform-infra/pipelines-as-code.yaml +++ b/config/platform-infra/pipelines-as-code.yaml @@ -121,6 +121,38 @@ repositories: unidesk_host_runtime_service_port: "4288" unidesk_host_health_path: /health unidesk_host_health_url: http://todo-note.unidesk.svc.cluster.local:4288/health + - id: sub2rank-nc01 + name: sub2rank-nc01 + namespace: devops-infra + providerType: gitea + url: https://gitea.pikapython.com/mirrors/pikasTech-sub2rank + cloneUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-sub2rank.git + owner: mirrors + repo: pikasTech-sub2rank + secretName: pac-gitea-sub2rank-nc01 + tokenKey: token + webhookSecretKey: webhook.secret + concurrencyLimit: 1 + params: + git_read_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-sub2rank.git + source_branch: master + source_snapshot_prefix: refs/unidesk/snapshots/gitea-actions/sub2rank-master-nc01 + node: NC01 + image_repository: 127.0.0.1:5000/sub2rank/sub2rank + registry_probe_base: http://127.0.0.1:5000 + gitops_branch: unidesk-host-gitops + gitops_manifest_path: deploy/gitops/platform-infra/sub2rank-nc01/resources.yaml + pipeline_name: platform-infra-sub2rank-nc01-pac + pipeline_run_prefix: sub2rank-nc01 + service_account: default + pipeline_timeout: 600s + workspace_pvc_size: 4Gi + runtime_namespace: platform-infra + runtime_deployment: sub2rank + runtime_service: sub2rank + runtime_service_port: "8080" + health_path: /health + health_url: http://sub2rank.platform-infra.svc.cluster.local:8080/health - extends: templates.repositories.hwlabV03 variables: NODE: JD01 @@ -192,6 +224,41 @@ consumers: argoNamespace: argocd argoApplication: platform-infra-gitea-nc01 closeoutGitOpsMirrorFlush: false + - id: platform-infra-sub2rank-nc01 + repositoryRef: sub2rank-nc01 + node: NC01 + lane: platform-infra + namespace: devops-infra + pipeline: platform-infra-sub2rank-nc01-pac + pipelineRunPrefix: sub2rank-nc01 + argoNamespace: argocd + argoApplication: sub2rank-nc01 + closeoutGitOpsMirrorFlush: true + closeoutGitOpsMirrorLane: v03 + argoBootstrap: + project: default + repoUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/unidesk.git + targetRevision: unidesk-host-gitops + path: deploy/gitops/platform-infra/sub2rank-nc01 + destinationNamespace: platform-infra + automated: true + deliveryProvenance: + required: true + markerValue: admission-pac-v2:platform-infra-sub2rank-nc01 + executionServiceAccountName: default + gitOps: + repoUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/unidesk.git + targetRevision: unidesk-host-gitops + sourceArtifact: + mode: embedded-pipeline-spec + renderer: sub2rank-platform-service + configRef: config/platform-infra/sub2rank.yaml#delivery + pipelineRunPath: .tekton/sub2rank-nc01-pac.yaml + maxKeepRuns: 8 + taskRunTemplate: + hostNetwork: true + dnsPolicy: ClusterFirstWithHostNet + fsGroup: 1000 - extends: templates.consumers.hwlabV03 variables: NODE: JD01 diff --git a/config/platform-infra/sub2rank.yaml b/config/platform-infra/sub2rank.yaml index 4f6e27f5..7594352a 100644 --- a/config/platform-infra/sub2rank.yaml +++ b/config/platform-infra/sub2rank.yaml @@ -10,15 +10,66 @@ defaults: targetId: NC01 application: + repository: pikasTech/sub2rank + remote: https://github.com/pikasTech/sub2rank.git + branch: master sourceRoot: /root/sub2rank configRef: /root/sub2rank/config/sub2rank.yaml + sourceConfigPath: config/sub2rank.yaml + dockerfile: Dockerfile runtimeTarget: k8s image: repository: 127.0.0.1:5000/sub2rank/sub2rank - tag: 14f51f5e1242d0e048f51bbea5937e3022361e9a pullPolicy: IfNotPresent +delivery: + enabled: true + pipeline: + namespace: devops-infra + name: platform-infra-sub2rank-nc01-pac + runPrefix: sub2rank-nc01 + serviceAccountName: default + timeout: 600s + workspaceSize: 4Gi + toolsImage: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 + buildkitImage: 127.0.0.1:5000/hwlab/buildkit:rootless + sourceSnapshotPrefix: refs/unidesk/snapshots/gitea-actions/sub2rank-master-nc01 + build: + networkMode: host + proxy: + http: http://127.0.0.1:10808 + https: http://127.0.0.1:10808 + all: http://127.0.0.1:10808 + noProxy: + - localhost + - 127.0.0.1 + - "::1" + - 127.0.0.1:5000 + - localhost:5000 + - .svc + - .svc.cluster.local + - .cluster.local + - hyueapi.com + - .hyueapi.com + gitops: + readUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/unidesk.git + writeUrl: http://git-mirror-write.devops-infra.svc.cluster.local:8080/pikasTech/unidesk.git + branch: unidesk-host-gitops + manifestPath: deploy/gitops/platform-infra/sub2rank-nc01/resources.yaml + releaseStatePath: deploy/gitops-state/platform-infra/sub2rank-nc01.json + maxPushAttempts: 3 + author: + name: UniDesk Sub2Rank CI + email: sub2rank-ci@unidesk.local + application: + name: sub2rank-nc01 + namespace: argocd + project: default + path: deploy/gitops/platform-infra/sub2rank-nc01 + destinationNamespace: platform-infra + automated: true + targets: - id: NC01 route: NC01:k3s diff --git a/scripts/src/platform-infra-gitea-gitops-delivery.test.ts b/scripts/src/platform-infra-gitea-gitops-delivery.test.ts index 0d58a6bc..3c8fbdff 100644 --- a/scripts/src/platform-infra-gitea-gitops-delivery.test.ts +++ b/scripts/src/platform-infra-gitea-gitops-delivery.test.ts @@ -78,12 +78,19 @@ test("owning YAML renders one child Application and the complete durable bridge "Deployment", "Role", "RoleBinding", + "Role", + "RoleBinding", "ValidatingAdmissionPolicy", "ValidatingAdmissionPolicyBinding", "ServiceAccount", "ConfigMap", "Deployment", ]); + const pacReadOnlyNamespaces = documents + .filter((item) => item.kind === "Role" && item.metadata?.name === "unidesk-pac-consumer-runner") + .map((item) => item.metadata.namespace) + .sort(); + expect(pacReadOnlyNamespaces).toEqual(["agentrun-ci", "devops-infra"]); const candidate = documents.find((item) => item.kind === "ConfigMap" && item.metadata?.name === "gitea-github-sync-candidate"); const activeConfig = documents.find((item) => item.kind === "ConfigMap" && item.metadata?.name === "gitea-github-sync-config"); const bridge = documents.find((item) => item.kind === "Deployment" && item.metadata?.name === "gitea-github-sync"); diff --git a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts index 344a1ea9..7ffdca93 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts @@ -17,9 +17,10 @@ import type { RenderedCliResult } from "./output"; import { renderedCliResult } from "./agentrun/render"; import { stableJsonSha256, stableJsonValue } from "./stable-json"; import { pipelineProvenanceAnnotations, pipelineProvenanceFromManifest, withPipelineProvenanceAnnotations } from "./pipeline-provenance"; +import { renderSub2RankDesiredPipeline, sub2RankOwningSourceRemote } from "./platform-infra-sub2rank-pipeline"; export type PacSourceArtifactMode = "embedded-pipeline-spec" | "remote-pipeline-annotation"; -export type PacSourceArtifactRenderer = "agentrun-control-plane" | "hwlab-runtime-lane"; +export type PacSourceArtifactRenderer = "agentrun-control-plane" | "hwlab-runtime-lane" | "sub2rank-platform-service"; export type PacSourceArtifactAction = "plan" | "check" | "write" | "status" | "verify-runtime"; export type PacSourceArtifactRuntimeAlignment = "not-requested" | "aligned" | "drifted" | "missing" | "unavailable"; @@ -441,7 +442,9 @@ function renderDesiredArtifact(binding: PacSourceArtifactBinding, sourceWorktree const sourceArtifact = binding.consumer.sourceArtifact; const rendered = sourceArtifact.renderer === "agentrun-control-plane" ? renderAgentRunDesiredPipeline(binding) - : renderHwlabDesiredPipeline(binding, sourceWorktree); + : sourceArtifact.renderer === "hwlab-runtime-lane" + ? renderHwlabDesiredPipeline(binding, sourceWorktree) + : renderSub2RankDesiredPipeline(binding); const pipeline = sourceArtifact.renderer === "hwlab-runtime-lane" ? rendered.pipeline : withPipelineProvenanceAnnotations(rendered.pipeline, rendered.provenance); @@ -581,7 +584,7 @@ function embeddedPipelineRun(binding: PacSourceArtifactBinding, desiredSpec: Rec name: `${binding.consumer.pipelineRunPrefix}-{{ revision }}`, namespace: binding.consumer.namespace, annotations: pipelineRunAnnotations(binding, provenance), - labels: pipelineRunLabels(binding, "agentrun"), + labels: pipelineRunLabels(binding, provenance.renderer === "sub2rank-platform-service" ? "sub2rank" : "agentrun"), }, spec: { pipelineSpec: desiredSpec, @@ -647,7 +650,7 @@ function pipelineRunAnnotations(binding: PacSourceArtifactBinding, provenance: P }; } -function pipelineRunLabels(binding: PacSourceArtifactBinding, partOf: "agentrun" | "hwlab"): Record { +function pipelineRunLabels(binding: PacSourceArtifactBinding, partOf: "agentrun" | "hwlab" | "sub2rank"): Record { return partOf === "agentrun" ? { "app.kubernetes.io/part-of": "agentrun", @@ -656,13 +659,19 @@ function pipelineRunLabels(binding: PacSourceArtifactBinding, partOf: "agentrun" "agentrun.pikastech.local/source-commit": "{{ revision }}", "agentrun.pikastech.local/trigger": "pipelines-as-code", } - : { + : partOf === "hwlab" ? { "app.kubernetes.io/name": `${binding.consumer.id}-pac`, "app.kubernetes.io/part-of": "hwlab", "unidesk.ai/source-commit": "{{ revision }}", "hwlab.pikastech.local/gitops-target": binding.consumer.lane, "hwlab.pikastech.local/source-commit": "{{ revision }}", "hwlab.pikastech.local/trigger": "pipelines-as-code", + } : { + "app.kubernetes.io/name": "sub2rank", + "app.kubernetes.io/part-of": "platform-infra", + "unidesk.ai/node": binding.consumer.node, + "unidesk.ai/source-commit": "{{ revision }}", + "unidesk.ai/trigger": "pipelines-as-code", }; } @@ -808,6 +817,9 @@ function owningSourceRemote(binding: PacSourceArtifactBinding): string { if (binding.consumer.sourceArtifact.renderer === "agentrun-control-plane") { return resolveAgentRunLaneTarget({ node: binding.consumer.node, lane: binding.consumer.lane }).spec.source.worktreeRemote; } + if (binding.consumer.sourceArtifact.renderer === "sub2rank-platform-service") { + return sub2RankOwningSourceRemote(); + } if (!isHwlabRuntimeLane(binding.consumer.lane)) throw new PacSourceArtifactError("owning-source-lane-unresolved"); return hwlabRuntimeLaneSpecForNode(binding.consumer.lane, binding.consumer.node).gitUrl; } @@ -923,7 +935,7 @@ function assertPipelineIdentity(pipeline: Record, binding: PacS function provenanceFromManifest(manifest: Record): PacSourceArtifactProvenance | null { const provenance = pipelineProvenanceFromManifest(manifest); if (provenance === null) return null; - if (provenance.renderer !== "agentrun-control-plane" && provenance.renderer !== "hwlab-runtime-lane") return null; + if (provenance.renderer !== "agentrun-control-plane" && provenance.renderer !== "hwlab-runtime-lane" && provenance.renderer !== "sub2rank-platform-service") return null; if (provenance.mode !== "embedded-pipeline-spec" && provenance.mode !== "remote-pipeline-annotation") return null; return provenance as PacSourceArtifactProvenance; } diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index eb5b7d99..21eff00c 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -537,7 +537,7 @@ function parseConsumerDeliveryProvenance(value: Record, path: s function parseSourceArtifact(value: Record, path: string): PacSourceArtifactSpec { const mode = y.enumField(value, "mode", path, ["embedded-pipeline-spec", "remote-pipeline-annotation"] as const); - const renderer = y.enumField(value, "renderer", path, ["agentrun-control-plane", "hwlab-runtime-lane"] as const); + const renderer = y.enumField(value, "renderer", path, ["agentrun-control-plane", "hwlab-runtime-lane", "sub2rank-platform-service"] as const); const pipelineRunPath = sourceArtifactPath(y.stringField(value, "pipelineRunPath", path), `${path}.pipelineRunPath`); const pipelinePath = value.pipelinePath === undefined ? null : sourceArtifactPath(y.stringField(value, "pipelinePath", path), `${path}.pipelinePath`); const taskRunTemplate = y.objectField(value, "taskRunTemplate", path); @@ -545,6 +545,7 @@ function parseSourceArtifact(value: Record, path: string): PacS if (mode === "remote-pipeline-annotation" && pipelinePath === null) throw new Error(`${path}.pipelinePath is required for remote-pipeline-annotation`); if (renderer === "agentrun-control-plane" && mode !== "embedded-pipeline-spec") throw new Error(`${path}.renderer agentrun-control-plane requires embedded-pipeline-spec`); if (renderer === "hwlab-runtime-lane" && mode !== "remote-pipeline-annotation") throw new Error(`${path}.renderer hwlab-runtime-lane requires remote-pipeline-annotation`); + if (renderer === "sub2rank-platform-service" && mode !== "embedded-pipeline-spec") throw new Error(`${path}.renderer sub2rank-platform-service requires embedded-pipeline-spec`); return { mode, renderer, diff --git a/scripts/src/platform-infra-sub2rank-config.ts b/scripts/src/platform-infra-sub2rank-config.ts index e977a4e7..aa1f6d7f 100644 --- a/scripts/src/platform-infra-sub2rank-config.ts +++ b/scripts/src/platform-infra-sub2rank-config.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; -import { readFileSync } from "node:fs"; -import { relative } from "node:path"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, relative, resolve } from "node:path"; import { rootPath } from "./config"; import type { PublicServiceExposure, PublicServiceTarget } from "./platform-infra-public-service"; import { assertNoDuplicateYamlMappingKeys, createYamlFieldReader, readYamlRecord, resolveRepoPath } from "./platform-infra-ops-library"; @@ -16,20 +16,61 @@ export interface Sub2RankConfig { metadata: { id: string; owner: string; relatedIssues: number[] }; defaults: { targetId: string }; application: { + repository: string; + remote: string; + branch: string; sourceRoot: string; configRef: string; + sourceConfigPath: string; + dockerfile: string; runtimeTarget: string; configText: string; configSha256: string; sourceCommit: string; sourceClean: boolean; sourceRemotePresent: boolean; + sourceRemoteMatches: boolean; configMatchesSourceCommit: boolean; deploymentBlockPresent: boolean; automaticCreditEnabled: boolean; server: { listenPort: number; databasePath: string; envKeys: string[] }; }; - image: { repository: string; tag: string; pullPolicy: "Always" | "IfNotPresent" | "Never" }; + image: { repository: string; pullPolicy: "Always" | "IfNotPresent" | "Never" }; + delivery: { + enabled: boolean; + pipeline: { + namespace: string; + name: string; + runPrefix: string; + serviceAccountName: string; + timeout: string; + workspaceSize: string; + toolsImage: string; + buildkitImage: string; + sourceSnapshotPrefix: string; + }; + build: { + networkMode: "host"; + proxy: { http: string; https: string; all: string; noProxy: string[] }; + }; + gitops: { + readUrl: string; + writeUrl: string; + branch: string; + manifestPath: string; + releaseStatePath: string; + maxPushAttempts: number; + author: { name: string; email: string }; + application: { + name: string; + namespace: string; + project: string; + path: string; + destinationNamespace: string; + automated: true; + }; + }; + }; targets: Sub2RankTarget[]; runtime: { sharedNetworkPolicyRef: { name: string; managedBySub2Rank: false }; @@ -82,15 +123,17 @@ export function readSub2RankConfig(): Sub2RankConfig { const defaultsRecord = y.objectField(root, "defaults", ""); const applicationRecord = y.objectField(root, "application", ""); const imageRecord = y.objectField(root, "image", ""); + const deliveryRecord = y.objectField(root, "delivery", ""); const runtimeRecord = y.objectField(root, "runtime", ""); const defaults = { targetId: simpleId(y.stringField(defaultsRecord, "targetId", "defaults"), "defaults.targetId") }; const application = parseApplication(applicationRecord); const image = parseImage(imageRecord); + const delivery = parseDelivery(deliveryRecord); const runtimeBase = parseRuntime(runtimeRecord); const secret = resolveSecretDeclaration(runtimeBase.secret.configRef, runtimeBase.secret.declarationId); const targets = parseTargets(root.targets, secret); if (!targets.some((target) => target.id === defaults.targetId)) throw new Error(`${sub2RankConfigLabel}.targets must include defaults.targetId ${defaults.targetId}`); - validateResolvedConfig(application, image, runtimeBase, secret, targets); + validateResolvedConfig(application, image, delivery, runtimeBase, secret, targets); return { version, kind: "platform-infra-sub2rank", @@ -102,6 +145,7 @@ export function readSub2RankConfig(): Sub2RankConfig { defaults, application, image, + delivery, targets, runtime: { ...runtimeBase, secret: { ...runtimeBase.secret, ...secret } }, }; @@ -116,10 +160,16 @@ export function resolveSub2RankTarget(config: Sub2RankConfig, targetId: string | } function parseApplication(record: Record): Sub2RankConfig["application"] { + const repository = repositoryValue(y.stringField(record, "repository", "application"), "application.repository"); + const remote = y.stringField(record, "remote", "application"); + const branch = simpleId(y.stringField(record, "branch", "application"), "application.branch"); const sourceRoot = y.absolutePathField(record, "sourceRoot", "application"); const configRef = y.absolutePathField(record, "configRef", "application"); + const sourceConfigPath = safeRelativePath(y.stringField(record, "sourceConfigPath", "application"), "application.sourceConfigPath"); + const dockerfile = safeRelativePath(y.stringField(record, "dockerfile", "application"), "application.dockerfile"); const runtimeTarget = simpleId(y.stringField(record, "runtimeTarget", "application"), "application.runtimeTarget"); if (!configRef.startsWith(`${sourceRoot.replace(/\/+$/u, "")}/`)) throw new Error(`${sub2RankConfigLabel}.application.configRef must be inside application.sourceRoot`); + if (!existsSync(resolve(sourceRoot, dockerfile))) throw new Error(`${sub2RankConfigLabel}.application.dockerfile does not exist under application.sourceRoot`); const configText = readFileSync(configRef, "utf8"); assertNoDuplicateYamlMappingKeys(configText, configRef); const app = y.asRecord(Bun.YAML.parse(configText), configRef); @@ -140,18 +190,25 @@ function parseApplication(record: Record): Sub2RankConfig["appl const sourceCommit = gitText(sourceRoot, ["rev-parse", "HEAD"], "resolve Sub2Rank source commit"); if (!/^[a-f0-9]{40}$/u.test(sourceCommit)) throw new Error(`${sourceRoot} HEAD must resolve to a full Git commit`); const statusShort = gitText(sourceRoot, ["status", "--porcelain"], "read Sub2Rank source status", true); - const remote = gitText(sourceRoot, ["remote"], "read Sub2Rank source remotes", true); + const observedRemote = gitText(sourceRoot, ["remote", "get-url", "origin"], "read Sub2Rank origin", true); const configRelativePath = relative(sourceRoot, configRef).replaceAll("\\", "/"); + if (configRelativePath !== sourceConfigPath) throw new Error(`${sub2RankConfigLabel}.application.sourceConfigPath must resolve to application.configRef`); const committedConfig = gitText(sourceRoot, ["show", `${sourceCommit}:${configRelativePath}`], "read committed Sub2Rank application config", true, false); return { + repository, + remote, + branch, sourceRoot, configRef, + sourceConfigPath, + dockerfile, runtimeTarget, configText, configSha256: createHash("sha256").update(configText).digest("hex"), sourceCommit, sourceClean: statusShort.length === 0, - sourceRemotePresent: remote.split(/\r?\n/u).some((value) => value.trim().length > 0), + sourceRemotePresent: observedRemote.length > 0, + sourceRemoteMatches: sameRepositoryIdentity(remote, observedRemote), configMatchesSourceCommit: committedConfig === configText.trimEnd(), deploymentBlockPresent: app.deployment !== undefined, automaticCreditEnabled, @@ -165,11 +222,66 @@ function parseApplication(record: Record): Sub2RankConfig["appl function parseImage(record: Record): Sub2RankConfig["image"] { const repository = y.stringField(record, "repository", "image"); - const tag = y.stringField(record, "tag", "image"); const pullPolicy = y.enumField(record, "pullPolicy", "image", ["Always", "IfNotPresent", "Never"] as const); if (!/^[A-Za-z0-9._:/-]+$/u.test(repository) || repository.endsWith("/")) throw new Error(`${sub2RankConfigLabel}.image.repository has an unsupported format`); - if (!/^[A-Za-z0-9._-]+$/u.test(tag)) throw new Error(`${sub2RankConfigLabel}.image.tag has an unsupported format`); - return { repository, tag, pullPolicy }; + return { repository, pullPolicy }; +} + +function parseDelivery(record: Record): Sub2RankConfig["delivery"] { + const pipeline = y.objectField(record, "pipeline", "delivery"); + const build = y.objectField(record, "build", "delivery"); + const proxy = y.objectField(build, "proxy", "delivery.build"); + const gitops = y.objectField(record, "gitops", "delivery"); + const author = y.objectField(gitops, "author", "delivery.gitops"); + const application = y.objectField(gitops, "application", "delivery.gitops"); + const timeout = y.stringField(pipeline, "timeout", "delivery.pipeline"); + if (!/^[1-9][0-9]*s$/u.test(timeout)) throw new Error(`${sub2RankConfigLabel}.delivery.pipeline.timeout must be positive seconds`); + const sourceSnapshotPrefix = y.stringField(pipeline, "sourceSnapshotPrefix", "delivery.pipeline"); + if (!/^refs\/[A-Za-z0-9._/-]+$/u.test(sourceSnapshotPrefix) || sourceSnapshotPrefix.includes("..")) throw new Error(`${sub2RankConfigLabel}.delivery.pipeline.sourceSnapshotPrefix must be a safe refs/ prefix`); + const email = y.stringField(author, "email", "delivery.gitops.author"); + if (!/^[^@\s]+@[^@\s]+$/u.test(email)) throw new Error(`${sub2RankConfigLabel}.delivery.gitops.author.email must be an email address`); + const automated = y.booleanField(application, "automated", "delivery.gitops.application"); + if (!automated) throw new Error(`${sub2RankConfigLabel}.delivery.gitops.application.automated must remain true`); + return { + enabled: y.booleanField(record, "enabled", "delivery"), + pipeline: { + namespace: y.kubernetesNameField(pipeline, "namespace", "delivery.pipeline"), + name: y.kubernetesNameField(pipeline, "name", "delivery.pipeline"), + runPrefix: y.kubernetesNameField(pipeline, "runPrefix", "delivery.pipeline"), + serviceAccountName: y.kubernetesNameField(pipeline, "serviceAccountName", "delivery.pipeline"), + timeout, + workspaceSize: storageSize(y.stringField(pipeline, "workspaceSize", "delivery.pipeline")), + toolsImage: imageValue(y.stringField(pipeline, "toolsImage", "delivery.pipeline"), "delivery.pipeline.toolsImage"), + buildkitImage: imageValue(y.stringField(pipeline, "buildkitImage", "delivery.pipeline"), "delivery.pipeline.buildkitImage"), + sourceSnapshotPrefix, + }, + build: { + networkMode: y.enumField(build, "networkMode", "delivery.build", ["host"] as const), + proxy: { + http: httpUrlValue(y.stringField(proxy, "http", "delivery.build.proxy"), "delivery.build.proxy.http"), + https: httpUrlValue(y.stringField(proxy, "https", "delivery.build.proxy"), "delivery.build.proxy.https"), + all: httpUrlValue(y.stringField(proxy, "all", "delivery.build.proxy"), "delivery.build.proxy.all"), + noProxy: y.stringArrayField(proxy, "noProxy", "delivery.build.proxy"), + }, + }, + gitops: { + readUrl: httpUrlValue(y.stringField(gitops, "readUrl", "delivery.gitops"), "delivery.gitops.readUrl"), + writeUrl: httpUrlValue(y.stringField(gitops, "writeUrl", "delivery.gitops"), "delivery.gitops.writeUrl"), + branch: simpleId(y.stringField(gitops, "branch", "delivery.gitops"), "delivery.gitops.branch"), + manifestPath: safeRelativePath(y.stringField(gitops, "manifestPath", "delivery.gitops"), "delivery.gitops.manifestPath"), + releaseStatePath: safeRelativePath(y.stringField(gitops, "releaseStatePath", "delivery.gitops"), "delivery.gitops.releaseStatePath"), + maxPushAttempts: positiveInteger(gitops, "maxPushAttempts", "delivery.gitops"), + author: { name: y.stringField(author, "name", "delivery.gitops.author"), email }, + application: { + name: y.kubernetesNameField(application, "name", "delivery.gitops.application"), + namespace: y.kubernetesNameField(application, "namespace", "delivery.gitops.application"), + project: y.kubernetesNameField(application, "project", "delivery.gitops.application"), + path: safeRelativePath(y.stringField(application, "path", "delivery.gitops.application"), "delivery.gitops.application.path"), + destinationNamespace: y.kubernetesNameField(application, "destinationNamespace", "delivery.gitops.application"), + automated: true, + }, + }, + }; } function parseRuntime(record: Record): Omit & { @@ -328,13 +440,19 @@ function resolveSecretDeclaration(configRef: string, declarationId: string): Res function validateResolvedConfig( application: Sub2RankConfig["application"], image: Sub2RankConfig["image"], + delivery: Sub2RankConfig["delivery"], runtime: ReturnType, secret: ResolvedSecretDeclaration, targets: Sub2RankTarget[], ): void { - if (!/^[a-f0-9]{40}$/u.test(image.tag) || image.tag !== application.sourceCommit) throw new Error(`${sub2RankConfigLabel}.image.tag must equal the full application source commit ${application.sourceCommit}`); - if (!application.sourceClean) throw new Error(`${sub2RankConfigLabel}.application.sourceRoot must be clean before rendering deployment config`); if (!application.configMatchesSourceCommit) throw new Error(`${sub2RankConfigLabel}.application.configRef must match the file stored at application source commit ${application.sourceCommit}`); + if (!application.sourceRemoteMatches) throw new Error(`${sub2RankConfigLabel}.application.remote must identify the application.sourceRoot origin repository`); + if (!sameRepositoryIdentity(application.remote, `https://github.com/${application.repository}.git`)) throw new Error(`${sub2RankConfigLabel}.application.repository must match application.remote`); + if (!delivery.enabled) throw new Error(`${sub2RankConfigLabel}.delivery.enabled must remain true while Sub2Rank is deployed`); + if (delivery.gitops.maxPushAttempts > 5) throw new Error(`${sub2RankConfigLabel}.delivery.gitops.maxPushAttempts must be <= 5`); + if (dirname(delivery.gitops.manifestPath).replaceAll("\\", "/") !== delivery.gitops.application.path) throw new Error(`${sub2RankConfigLabel}.delivery.gitops.manifestPath must be directly under delivery.gitops.application.path`); + if (!delivery.build.proxy.noProxy.includes("hyueapi.com") || !delivery.build.proxy.noProxy.includes(".hyueapi.com")) throw new Error(`${sub2RankConfigLabel}.delivery.build.proxy.noProxy must retain hyueapi.com and .hyueapi.com`); + if (!image.repository.startsWith("127.0.0.1:5000/")) throw new Error(`${sub2RankConfigLabel}.image.repository must use the NC01 local registry`); const required = new Set(runtime.secret.requiredTargetKeys); const provided = new Set(secret.mappings.map((item) => item.targetKey)); const missing = [...required].filter((key) => !provided.has(key)); @@ -345,6 +463,7 @@ function validateResolvedConfig( for (const target of targets) { if (target.route !== secret.route || target.namespace !== secret.namespace) throw new Error(`${sub2RankConfigLabel}.targets[${target.id}] route/namespace must match runtime Secret target ${secret.targetId}`); if (target.publicExposure.frpc.localPort !== runtime.service.port) throw new Error(`${sub2RankConfigLabel}.targets[${target.id}].publicExposure.frpc.localPort must match runtime.service.port`); + if (target.namespace !== delivery.gitops.application.destinationNamespace) throw new Error(`${sub2RankConfigLabel}.delivery.gitops.application.destinationNamespace must match the runtime target namespace`); } } @@ -403,6 +522,50 @@ function sourceRefValue(value: unknown, path: string): string { return text; } +function safeRelativePath(value: string, path: string): string { + if (value.startsWith("/") || value.split(/[\\/]/u).includes("..") || !/^[A-Za-z0-9._/-]+$/u.test(value)) throw new Error(`${sub2RankConfigLabel}.${path} must be a safe relative path`); + return value; +} + +function repositoryValue(value: string, path: string): string { + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(value)) throw new Error(`${sub2RankConfigLabel}.${path} must be owner/repository`); + return value; +} + +function httpUrlValue(value: string, path: string): string { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new Error(`${sub2RankConfigLabel}.${path} must be an HTTP URL`); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`${sub2RankConfigLabel}.${path} must be an HTTP URL`); + if (parsed.username.length > 0 || parsed.password.length > 0 || parsed.hash.length > 0) throw new Error(`${sub2RankConfigLabel}.${path} must not contain credentials or a fragment`); + return value; +} + +function sameRepositoryIdentity(left: string, right: string): boolean { + const identity = (value: string): string | null => { + const scp = /^[^/@\s]+@([^:\s]+):(.+)$/u.exec(value.trim()); + let host = scp?.[1] ?? ""; + let path = scp?.[2] ?? ""; + if (host.length === 0 || path.length === 0) { + try { + const parsed = new URL(value.trim()); + host = parsed.hostname; + path = parsed.pathname; + } catch { + return null; + } + } + const parts = path.replace(/^\/+|\/+$/gu, "").split("/"); + if (parts.length !== 2) return null; + return `${host.toLowerCase()}/${parts[0]?.toLowerCase()}/${parts[1]?.replace(/\.git$/iu, "").toLowerCase()}`; + }; + const leftIdentity = identity(left); + return leftIdentity !== null && leftIdentity === identity(right); +} + function kubernetesNameValue(value: unknown, path: string): string { const text = stringValue(value, path); if (!/^[a-z0-9](?:[-a-z0-9.]*[a-z0-9])?$/u.test(text)) throw new Error(`${path} must be a Kubernetes name`); diff --git a/scripts/src/platform-infra-sub2rank-pipeline.ts b/scripts/src/platform-infra-sub2rank-pipeline.ts new file mode 100644 index 00000000..c65ef6f2 --- /dev/null +++ b/scripts/src/platform-infra-sub2rank-pipeline.ts @@ -0,0 +1,256 @@ +import { stableJsonSha256 } from "./stable-json"; +import { + readSub2RankConfig, + resolveSub2RankTarget, + sub2RankConfigLabel, + type Sub2RankConfig, + type Sub2RankTarget, +} from "./platform-infra-sub2rank-config"; +import { + renderSub2RankManifest, + sub2RankConfigBase64Placeholder, + sub2RankConfigSha256Placeholder, + sub2RankImagePlaceholder, + sub2RankSourceCommitPlaceholder, +} from "./platform-infra-sub2rank"; +import type { PacSourceArtifactBinding, PacSourceArtifactMode, PacSourceArtifactRenderer } from "./platform-infra-pipelines-as-code-source-artifact"; + +export interface Sub2RankPipelineProvenance { + readonly configRef: string; + readonly effectiveConfigSha256: string; + readonly renderer: PacSourceArtifactRenderer; + readonly mode: PacSourceArtifactMode; +} + +export function sub2RankOwningSourceRemote(): string { + return readSub2RankConfig().application.remote; +} + +export function renderSub2RankDesiredPipeline(binding: PacSourceArtifactBinding): { + pipeline: Record; + provenance: Sub2RankPipelineProvenance; +} { + const config = readSub2RankConfig(); + const target = resolveSub2RankTarget(config, binding.consumer.node); + const expectedConfigRef = `${sub2RankConfigLabel}#delivery`; + if (binding.consumer.sourceArtifact.configRef !== expectedConfigRef) throw new Error(`Sub2Rank sourceArtifact.configRef must equal ${expectedConfigRef}`); + assertBinding(config, target, binding); + const manifestTemplate = renderSub2RankManifest(config, target, { + imageRef: sub2RankImagePlaceholder, + appConfigBase64: sub2RankConfigBase64Placeholder, + appConfigSha256: sub2RankConfigSha256Placeholder, + sourceCommit: sub2RankSourceCommitPlaceholder, + }); + return { + pipeline: renderPipeline(config, target, binding, manifestTemplate), + provenance: { + configRef: expectedConfigRef, + effectiveConfigSha256: stableJsonSha256(pipelineOwner(config, target)), + renderer: "sub2rank-platform-service", + mode: "embedded-pipeline-spec", + }, + }; +} + +function renderPipeline( + config: Sub2RankConfig, + target: Sub2RankTarget, + binding: PacSourceArtifactBinding, + manifestTemplate: string, +): Record { + const delivery = config.delivery; + const params = [ + { name: "git-read-url", type: "string", default: binding.repository.cloneUrl }, + { name: "source-branch", type: "string", default: config.application.branch }, + { name: "revision", type: "string" }, + { name: "source-stage-ref", type: "string" }, + { name: "config-path", type: "string", default: config.application.sourceConfigPath }, + { name: "dockerfile", type: "string", default: config.application.dockerfile }, + { name: "image-repository", type: "string", default: config.image.repository }, + { name: "tools-image", type: "string", default: delivery.pipeline.toolsImage }, + { name: "buildkit-image", type: "string", default: delivery.pipeline.buildkitImage }, + { name: "build-network", type: "string", default: delivery.build.networkMode }, + { name: "build-http-proxy", type: "string", default: delivery.build.proxy.http }, + { name: "build-https-proxy", type: "string", default: delivery.build.proxy.https }, + { name: "build-all-proxy", type: "string", default: delivery.build.proxy.all }, + { name: "build-no-proxy", type: "string", default: delivery.build.proxy.noProxy.join(",") }, + { name: "gitops-read-url", type: "string", default: delivery.gitops.readUrl }, + { name: "gitops-write-url", type: "string", default: delivery.gitops.writeUrl }, + { name: "gitops-branch", type: "string", default: delivery.gitops.branch }, + { name: "gitops-manifest-path", type: "string", default: delivery.gitops.manifestPath }, + { name: "gitops-release-state-path", type: "string", default: delivery.gitops.releaseStatePath }, + { name: "gitops-max-push-attempts", type: "string", default: String(delivery.gitops.maxPushAttempts) }, + { name: "gitops-author-name", type: "string", default: delivery.gitops.author.name }, + { name: "gitops-author-email", type: "string", default: delivery.gitops.author.email }, + { name: "manifest-template-b64", type: "string", default: Buffer.from(manifestTemplate, "utf8").toString("base64") }, + ]; + const task = (name: string, steps: readonly Record[], runAfter: readonly string[] = []): Record => ({ + name, + ...(runAfter.length === 0 ? {} : { runAfter }), + workspaces: [{ name: "source", workspace: "source" }], + taskSpec: { params: params.map(({ name }) => ({ name })), workspaces: [{ name: "source" }], steps }, + params: params.map(({ name: paramName }) => ({ name: paramName, value: `$(params.${paramName})` })), + }); + return { + apiVersion: "tekton.dev/v1", + kind: "Pipeline", + metadata: { + name: delivery.pipeline.name, + namespace: delivery.pipeline.namespace, + labels: { + "app.kubernetes.io/name": "sub2rank", + "app.kubernetes.io/part-of": "platform-infra", + "app.kubernetes.io/managed-by": "unidesk", + "unidesk.ai/node": target.id, + }, + }, + spec: { + params, + workspaces: [{ name: "source" }], + tasks: [ + task("source-validate", [{ + name: "checkout-and-cli-validate", + image: "$(params.tools-image)", + imagePullPolicy: "IfNotPresent", + env: proxyEnv(), + script: sourceValidateScript(), + }]), + task("image-build", [{ + name: "build-and-push", + image: "$(params.buildkit-image)", + imagePullPolicy: "IfNotPresent", + env: [ + ...proxyEnv(), + { name: "BUILDKITD_FLAGS", value: "--oci-worker-no-process-sandbox --oci-worker-net=host --allow-insecure-entitlement network.host" }, + { name: "SOURCE_ROOT", value: "$(workspaces.source.path)/repo" }, + { name: "SOURCE_COMMIT", value: "$(params.revision)" }, + { name: "IMAGE_REPOSITORY", value: "$(params.image-repository)" }, + { name: "DOCKERFILE", value: "$(params.dockerfile)" }, + { name: "BUILD_NETWORK", value: "$(params.build-network)" }, + { name: "BUILD_METADATA_FILE", value: "$(workspaces.source.path)/build-metadata.json" }, + { name: "BUILD_RESULT_FILE", value: "$(workspaces.source.path)/build-result.json" }, + ], + securityContext: { privileged: true, runAsUser: 1000, runAsGroup: 1000 }, + script: buildScript(), + }], ["source-validate"]), + task("gitops-publish", [{ + name: "publish-digest-manifest", + image: "$(params.tools-image)", + imagePullPolicy: "IfNotPresent", + env: proxyEnv(), + script: gitopsPublishScript(), + }], ["image-build"]), + ], + }, + }; +} + +function sourceValidateScript(): string { + return [ + "#!/bin/sh", + "set -eu", + "root=\"$(workspaces.source.path)\"", + "rm -rf \"$root/repo\"", + "git clone --filter=blob:none --no-checkout \"$(params.git-read-url)\" \"$root/repo\"", + "cd \"$root/repo\"", + "git fetch --depth=1 --filter=blob:none origin \"+$(params.source-stage-ref):refs/remotes/origin/sub2rank-source-snapshot\"", + "git checkout --detach \"$(params.revision)\"", + "test \"$(git rev-parse HEAD)\" = \"$(params.revision)\"", + "bun install --frozen-lockfile", + "bun scripts/sub2rank-cli.ts --config \"$(params.config-path)\" config validate", + "chmod -R a+rX,g+rwX \"$root/repo\"", + "printf '{\"ok\":true,\"phase\":\"source-validate\",\"sourceCommit\":\"%s\",\"configPath\":\"%s\",\"valuesPrinted\":false}\\n' \"$(params.revision)\" \"$(params.config-path)\"", + ].join("\n"); +} + +function buildScript(): string { + return [ + "#!/bin/sh", + "set -eu", + "exec \"$(workspaces.source.path)/repo/scripts/ci/build-image.sh\"", + ].join("\n"); +} + +function gitopsPublishScript(): string { + return [ + "#!/bin/sh", + "set -eu", + "root=\"$(workspaces.source.path)\"", + "exec bun \"$root/repo/scripts/ci/publish-gitops.mjs\" \\", + " --source-root \"$root/repo\" \\", + " --source-commit \"$(params.revision)\" \\", + " --config-path \"$(params.config-path)\" \\", + " --metadata \"$root/build-metadata.json\" \\", + " --manifest-template-b64 \"$(params.manifest-template-b64)\" \\", + " --image-repository \"$(params.image-repository)\" \\", + " --gitops-read-url \"$(params.gitops-read-url)\" \\", + " --gitops-write-url \"$(params.gitops-write-url)\" \\", + " --gitops-branch \"$(params.gitops-branch)\" \\", + " --manifest-path \"$(params.gitops-manifest-path)\" \\", + " --release-state-path \"$(params.gitops-release-state-path)\" \\", + " --max-push-attempts \"$(params.gitops-max-push-attempts)\" \\", + " --author-name \"$(params.gitops-author-name)\" \\", + " --author-email \"$(params.gitops-author-email)\" \\", + " --worktree \"$root/gitops\"", + ].join("\n"); +} + +function proxyEnv(): Record[] { + return [ + { name: "HTTP_PROXY", value: "$(params.build-http-proxy)" }, + { name: "http_proxy", value: "$(params.build-http-proxy)" }, + { name: "HTTPS_PROXY", value: "$(params.build-https-proxy)" }, + { name: "https_proxy", value: "$(params.build-https-proxy)" }, + { name: "ALL_PROXY", value: "$(params.build-all-proxy)" }, + { name: "all_proxy", value: "$(params.build-all-proxy)" }, + { name: "NO_PROXY", value: "$(params.build-no-proxy)" }, + { name: "no_proxy", value: "$(params.build-no-proxy)" }, + ]; +} + +function assertBinding(config: Sub2RankConfig, target: Sub2RankTarget, binding: PacSourceArtifactBinding): void { + const delivery = config.delivery; + const expected: Readonly> = { + node: target.id, + lane: "platform-infra", + namespace: delivery.pipeline.namespace, + pipeline: delivery.pipeline.name, + pipelineRunPrefix: delivery.pipeline.runPrefix, + service_account: delivery.pipeline.serviceAccountName, + source_branch: config.application.branch, + source_snapshot_prefix: delivery.pipeline.sourceSnapshotPrefix, + image_repository: config.image.repository, + gitops_branch: delivery.gitops.branch, + gitops_manifest_path: delivery.gitops.manifestPath, + pipeline_name: delivery.pipeline.name, + pipeline_run_prefix: delivery.pipeline.runPrefix, + }; + const observed: Readonly> = { + node: binding.consumer.node, + lane: binding.consumer.lane, + namespace: binding.consumer.namespace, + pipeline: binding.consumer.pipeline, + pipelineRunPrefix: binding.consumer.pipelineRunPrefix, + ...binding.repository.params, + }; + for (const [key, value] of Object.entries(expected)) { + if (observed[key] !== value) throw new Error(`Sub2Rank PaC binding ${key}=${String(observed[key])} does not match owning YAML ${value}`); + } +} + +function pipelineOwner(config: Sub2RankConfig, target: Sub2RankTarget): Record { + return { + application: { + repository: config.application.repository, + remote: config.application.remote, + branch: config.application.branch, + sourceConfigPath: config.application.sourceConfigPath, + dockerfile: config.application.dockerfile, + runtimeTarget: config.application.runtimeTarget, + }, + image: config.image, + delivery: config.delivery, + target, + runtime: config.runtime, + }; +} diff --git a/scripts/src/platform-infra-sub2rank.ts b/scripts/src/platform-infra-sub2rank.ts index f4ebfa74..691e20d6 100644 --- a/scripts/src/platform-infra-sub2rank.ts +++ b/scripts/src/platform-infra-sub2rank.ts @@ -1,12 +1,9 @@ import { createHash } from "node:crypto"; import type { UniDeskConfig } from "./config"; -import { startJob } from "./jobs"; import { - applyManifestWithExistingSecretScript, applyPk01CaddyBlock, capture, compactCapture, - dryRunManifestScript, parseJsonOutput, publicDnsProbe, publicHttpProbe, @@ -20,20 +17,25 @@ import { readSub2RankConfig, resolveSub2RankTarget, sub2RankConfigLabel, type Su const serviceId = "sub2rank"; +export const sub2RankImagePlaceholder = "__SUB2RANK_IMAGE_REF__"; +export const sub2RankConfigBase64Placeholder = "__SUB2RANK_CONFIG_BASE64__"; +export const sub2RankConfigSha256Placeholder = "__SUB2RANK_CONFIG_SHA256__"; +export const sub2RankSourceCommitPlaceholder = "__SUB2RANK_SOURCE_COMMIT__"; + export function sub2RankHelp(): Record { return { - command: "platform-infra sub2rank plan|apply|status|validate", + command: "platform-infra sub2rank plan|public-exposure|status|validate", output: "json", usage: [ "bun scripts/cli.ts platform-infra sub2rank plan [--target NC01]", - "bun scripts/cli.ts platform-infra sub2rank apply [--target NC01] --dry-run", - "bun scripts/cli.ts platform-infra sub2rank apply [--target NC01] --confirm", + "bun scripts/cli.ts platform-infra sub2rank public-exposure [--target NC01] --dry-run", + "bun scripts/cli.ts platform-infra sub2rank public-exposure [--target NC01] --confirm", "bun scripts/cli.ts platform-infra sub2rank status [--target NC01] [--full|--raw]", "bun scripts/cli.ts platform-infra sub2rank validate [--target NC01] [--full|--raw]", ], configTruth: sub2RankConfigLabel, applicationConfigRef: "/root/sub2rank/config/sub2rank.yaml", - artifactPolicy: "Consumes the prebuilt image declared by owning YAML; apply never runs Docker or builds from a host worktree.", + artifactPolicy: "Application PR merge is the sole delivery trigger; PaC/Tekton builds the image and publishes a digest-pinned GitOps manifest.", secretPolicy: "Resolves the existing Secret declaration from config/secrets-distribution.yaml and never reads or prints runtime Secret values.", }; } @@ -41,7 +43,7 @@ export function sub2RankHelp(): Record { export async function runSub2RankCommand(config: UniDeskConfig, args: string[]): Promise> { const [action = "plan"] = args; if (action === "plan") return plan(parseOpsCommonOptions(args.slice(1))); - if (action === "apply") return await apply(config, parseOpsApplyOptions(args.slice(1))); + if (action === "public-exposure") return await publicExposure(config, parseOpsApplyOptions(args.slice(1))); if (action === "status") return await status(config, parseOpsCommonOptions(args.slice(1))); if (action === "validate") return await validate(config, parseOpsCommonOptions(args.slice(1))); return { ok: false, error: "unsupported-platform-infra-sub2rank-command", args, help: sub2RankHelp() }; @@ -50,7 +52,7 @@ export async function runSub2RankCommand(config: UniDeskConfig, args: string[]): function plan(options: OpsCommonOptions): Record { const sub2rank = readSub2RankConfig(); const target = resolveSub2RankTarget(sub2rank, options.targetId); - const yaml = renderManifest(sub2rank, target); + const yaml = renderSub2RankManifest(sub2rank, target); const policy = policyChecks(sub2rank, target, yaml); return { ok: policy.every((item) => item.ok), @@ -59,104 +61,58 @@ function plan(options: OpsCommonOptions): Record { config: configSummary(sub2rank, target), policy, artifact: { - image: `${sub2rank.image.repository}:${sub2rank.image.tag}`, - buildDisposition: "not-performed-by-apply", + imageRepository: sub2rank.image.repository, + buildDisposition: "pac-tekton-on-application-pr-merge", sourceRoot: sub2rank.application.sourceRoot, sourceCommit: sub2rank.application.sourceCommit, sourceClean: sub2rank.application.sourceClean, sourceRemotePresent: sub2rank.application.sourceRemotePresent, - publication: sub2rank.application.sourceRemotePresent - ? { supported: false, blocker: "remote-source-not-registered-with-controlled-ci", required: "Register the repository as a YAML-owned PaC/Tekton source authority before publishing the image." } - : { supported: false, blocker: "remote-source-authority-missing", required: "Create or select an independent Git remote source authority, then register it with the YAML-owned PaC/Tekton producer." }, + sourceRemoteMatches: sub2rank.application.sourceRemoteMatches, + publication: { + supported: sub2rank.delivery.enabled, + authority: "GitHub PR merge -> Gitea snapshot -> PaC -> Tekton -> GitOps -> Argo", + branch: sub2rank.application.branch, + pipeline: sub2rank.delivery.pipeline.name, + }, }, topology: `client -> PK01 Caddy -> PK01 frps:${target.publicExposure.frpc.remotePort} -> ${target.id} frpc -> ${sub2rank.runtime.service.name}.${target.namespace}.svc.cluster.local:${sub2rank.runtime.service.port}`, next: { secrets: "bun scripts/cli.ts secrets sync --config config/secrets-distribution.yaml --scope sub2rank --confirm", - dryRun: `bun scripts/cli.ts platform-infra sub2rank apply --target ${target.id} --dry-run`, - apply: `bun scripts/cli.ts platform-infra sub2rank apply --target ${target.id} --confirm`, + publicExposureDryRun: `bun scripts/cli.ts platform-infra sub2rank public-exposure --target ${target.id} --dry-run`, + publicExposureApply: `bun scripts/cli.ts platform-infra sub2rank public-exposure --target ${target.id} --confirm`, status: `bun scripts/cli.ts platform-infra sub2rank status --target ${target.id}`, validate: `bun scripts/cli.ts platform-infra sub2rank validate --target ${target.id}`, }, }; } -async function apply(config: UniDeskConfig, options: OpsApplyOptions): Promise> { +async function publicExposure(config: UniDeskConfig, options: OpsApplyOptions): Promise> { const sub2rank = readSub2RankConfig(); const target = resolveSub2RankTarget(sub2rank, options.targetId); - const yaml = renderManifest(sub2rank, target); - const policy = policyChecks(sub2rank, target, yaml); - if (!policy.every((item) => item.ok)) { - return { ok: false, action: "platform-infra-sub2rank-apply", mode: "policy-blocked", mutation: false, target: targetSummary(target), policy }; - } - if (options.confirm && !options.wait) { - const job = startJob( - `platform_infra_sub2rank_apply_${target.id.toLowerCase()}`, - ["bun", "scripts/cli.ts", "platform-infra", "sub2rank", "apply", "--target", target.id, "--confirm", "--wait"], - `Apply ${target.id} Sub2Rank manifests and reconcile the YAML-owned PK01 Caddy site through the controlled UniDesk CLI`, - ); + if (options.dryRun) { return { ok: true, - action: "platform-infra-sub2rank-apply", - mode: "async-job", - mutation: true, - target: targetSummary(target), - job, - statusCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`, - next: { - status: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`, - runtime: `bun scripts/cli.ts platform-infra sub2rank status --target ${target.id}`, - validate: `bun scripts/cli.ts platform-infra sub2rank validate --target ${target.id}`, - }, - }; - } - if (options.dryRun) { - const remote = await capture(config, target.route, ["sh"], dryRunManifestScript({ - yaml, - target, - fieldManager: sub2rank.runtime.rollout.fieldManager, - manifestName: serviceId, - })); - const parsed = parseJsonOutput(remote.stdout); - return { - ok: remote.exitCode === 0 && parsed?.ok === true, - action: "platform-infra-sub2rank-apply", + action: "platform-infra-sub2rank-public-exposure", mode: "dry-run", mutation: false, target: targetSummary(target), - policy, - remote: parsed ?? compactCapture(remote, { full: true }), + exposure: { + publicBaseUrl: target.publicExposure.publicBaseUrl, + upstream: `127.0.0.1:${target.publicExposure.frpc.remotePort}`, + managedBlock: serviceId, + }, + next: `bun scripts/cli.ts platform-infra sub2rank public-exposure --target ${target.id} --confirm`, }; } - const resources = runtimeResources(sub2rank, target); - const remote = await capture(config, target.route, ["sh"], applyManifestWithExistingSecretScript({ - yaml, - target, - fieldManager: sub2rank.runtime.rollout.fieldManager, - manifestName: serviceId, - secretName: resources.secretName, - requiredSecretKeys: resources.requiredSecretKeys, - deploymentNames: [resources.appDeploymentName, target.publicExposure.frpc.deploymentName], - waitTimeoutSeconds: sub2rank.runtime.rollout.waitTimeoutSeconds, - })); - const parsed = parseJsonOutput(remote.stdout); - const remoteOk = remote.exitCode === 0 && parsed?.ok === true; - const caddy = remoteOk - ? await applyPk01CaddyBlock(config, serviceId, target.publicExposure) - : { ok: false, mutation: false, disposition: "skipped-runtime-apply-failed" }; + const caddy = await applyPk01CaddyBlock(config, serviceId, target.publicExposure); return { - ok: remoteOk && caddy.ok === true, - action: "platform-infra-sub2rank-apply", + ok: caddy.ok === true, + action: "platform-infra-sub2rank-public-exposure", mode: "confirmed", mutation: true, target: targetSummary(target), - policy, - secret: secretSummary(sub2rank), - remote: parsed ?? compactCapture(remote, { full: true }), pk01Caddy: caddy, - next: { - status: `bun scripts/cli.ts platform-infra sub2rank status --target ${target.id}`, - validate: `bun scripts/cli.ts platform-infra sub2rank validate --target ${target.id}`, - }, + next: `Merge the application PR only after the PaC repository and source snapshot chain are ready.`, }; } @@ -201,10 +157,20 @@ async function validate(config: UniDeskConfig, options: OpsCommonOptions): Promi }; } -function renderManifest(config: Sub2RankConfig, target: Sub2RankTarget): string { +export interface Sub2RankManifestRenderOptions { + imageRef?: string; + appConfigBase64?: string; + appConfigSha256?: string; + sourceCommit?: string; +} + +export function renderSub2RankManifest(config: Sub2RankConfig, target: Sub2RankTarget, options: Sub2RankManifestRenderOptions = {}): string { const runtime = config.runtime; - const image = `${config.image.repository}:${config.image.tag}`; - const configHash = createHash("sha256").update(JSON.stringify({ deployment: config, target, appConfigSha256: config.application.configSha256 })).digest("hex").slice(0, 16); + const image = options.imageRef ?? `${config.image.repository}:pac-preview`; + const appConfigBase64 = options.appConfigBase64 ?? Buffer.from(config.application.configText, "utf8").toString("base64"); + const appConfigSha256 = options.appConfigSha256 ?? config.application.configSha256; + const sourceCommit = options.sourceCommit ?? config.application.sourceCommit; + const configHash = createHash("sha256").update(JSON.stringify({ deployment: deploymentHashInput(config), target })).digest("hex").slice(0, 16); const appEnv = runtime.secret.appEnvTargetKeys.map((key) => ` - name: ${key}\n valueFrom:\n secretKeyRef:\n name: ${runtime.secret.secretName}\n key: ${key}`).join("\n"); const command = runtime.command.map((value) => ` - ${yamlQuoted(value)}`).join("\n"); return `apiVersion: v1 @@ -216,9 +182,8 @@ metadata: app.kubernetes.io/name: ${runtime.service.name} app.kubernetes.io/part-of: platform-infra app.kubernetes.io/managed-by: unidesk -data: - ${runtime.configMap.key}: | -${indent(config.application.configText, 4)} +binaryData: + ${runtime.configMap.key}: ${yamlQuoted(appConfigBase64)} --- apiVersion: v1 kind: PersistentVolumeClaim @@ -276,15 +241,16 @@ spec: app.kubernetes.io/name: ${runtime.service.name} app.kubernetes.io/part-of: platform-infra annotations: - unidesk.ai/sub2rank-config-sha256: "${config.application.configSha256}" + unidesk.ai/sub2rank-config-sha256: "${appConfigSha256}" unidesk.ai/sub2rank-deployment-hash: "${configHash}" + unidesk.ai/source-commit: "${sourceCommit}" unidesk.ai/public-base-url: "${target.publicExposure.publicBaseUrl}" spec: securityContext: fsGroup: 1000 containers: - name: ${runtime.service.name} - image: ${image} + image: ${yamlQuoted(image)} imagePullPolicy: ${config.image.pullPolicy} command: ${command} @@ -357,7 +323,13 @@ function configSummary(config: Sub2RankConfig, target: Sub2RankTarget): Record { sourceCommit: config.application.sourceCommit, sourceClean: config.application.sourceClean, sourceRemotePresent: config.application.sourceRemotePresent, + sourceRemoteMatches: config.application.sourceRemoteMatches, configMatchesSourceCommit: config.application.configMatchesSourceCommit, }, secret: { path: config.runtime.secret.configRef, declarationId: config.runtime.secret.declarationId }, @@ -421,9 +394,18 @@ function runtimeResources(config: Sub2RankConfig, target: Sub2RankTarget): Publi }; } -function indent(value: string, spaces: number): string { - const prefix = " ".repeat(spaces); - return value.trimEnd().split("\n").map((line) => `${prefix}${line}`).join("\n"); +function deploymentHashInput(config: Sub2RankConfig): Record { + return { + application: { + repository: config.application.repository, + branch: config.application.branch, + sourceConfigPath: config.application.sourceConfigPath, + dockerfile: config.application.dockerfile, + runtimeTarget: config.application.runtimeTarget, + }, + image: config.image, + runtime: config.runtime, + }; } function yamlQuoted(value: string): string { From 18ac5d33ba52e8a894fba0eb836b2ec9ebe797fd Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 07:57:47 +0200 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20=E5=AE=8C=E5=96=84=20Sub2Rank=20PaC?= =?UTF-8?q?=20=E9=A6=96=E5=8F=91=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/src/cicd-delivery-authority.test.ts | 6 +++--- .../src/platform-infra-pac-provenance.test.ts | 20 +++++++++++-------- ...infra-pipelines-as-code-source-artifact.ts | 1 + 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/scripts/src/cicd-delivery-authority.test.ts b/scripts/src/cicd-delivery-authority.test.ts index 62ca14f3..262f91ae 100644 --- a/scripts/src/cicd-delivery-authority.test.ts +++ b/scripts/src/cicd-delivery-authority.test.ts @@ -51,9 +51,9 @@ const pacEvaluator = createRequire(import.meta.url)("../native/cicd/pac-status-e describe("YAML 组合的 CI/CD delivery authority", () => { test("组合 PaC 与 Gitea YAML,覆盖所有实际 consumer 且 id 唯一", () => { const catalog = readCicdDeliveryAuthorityCatalog(); - expect(catalog.consumers).toHaveLength(8); - expect(new Set(catalog.consumers.map((item) => item.consumerId)).size).toBe(8); - for (const consumerId of ["agentrun-nc01-v02", "hwlab-nc01-v03", "sentinel-nc01-v03", "unidesk-host", "platform-infra-gitea-nc01"]) { + expect(catalog.consumers).toHaveLength(9); + expect(new Set(catalog.consumers.map((item) => item.consumerId)).size).toBe(9); + for (const consumerId of ["agentrun-nc01-v02", "hwlab-nc01-v03", "sentinel-nc01-v03", "unidesk-host", "platform-infra-gitea-nc01", "platform-infra-sub2rank-nc01"]) { const authority = resolveCicdDeliveryAuthority({ consumerId }); expect(authority.kind).toBe("pac-pr-merge"); expect(authority.mutationHintsAllowed).toBe(false); diff --git a/scripts/src/platform-infra-pac-provenance.test.ts b/scripts/src/platform-infra-pac-provenance.test.ts index e85d7ab4..872d49f0 100644 --- a/scripts/src/platform-infra-pac-provenance.test.ts +++ b/scripts/src/platform-infra-pac-provenance.test.ts @@ -59,7 +59,7 @@ test("versioned admission desired state protects controller proof, candidate ide toState: "started", }]); expect(contract.child.enabled).toBe(false); - expect(contract.consumers.map((item) => item.id)).toEqual(["agentrun-nc01-v02"]); + expect(contract.consumers.map((item) => item.id)).toEqual(["agentrun-nc01-v02", "platform-infra-sub2rank-nc01"]); const items = documents(renderPacAdmissionDesiredFragment("NC01")); expect(items.map((item) => item.kind)).toEqual(["ValidatingAdmissionPolicy", "ValidatingAdmissionPolicyBinding"]); const policy = items[0]; @@ -117,16 +117,20 @@ test("versioned admission desired state protects controller proof, candidate ide expect(queueGuard.expression).not.toContain('== "Cancelled"'); }); -test("required consumer default RBAC has one automatic desired owner and no Tekton mutation verbs", () => { +test("required consumers have one automatic desired owner per namespace and no Tekton mutation verbs", () => { const rbac = documents(renderPacConsumerRbacDesiredFragment("NC01")); - expect(rbac.map((item) => item.kind)).toEqual(["Role", "RoleBinding"]); - const role = rbac[0]; - expect(role.metadata.annotations["argocd.argoproj.io/sync-wave"]).toBe("-1"); - expect(role.metadata.annotations["unidesk.ai/effective-config-sha256"]).toBe(pacConsumerRbacDesiredIdentity("NC01").configSha256); - const verbs = role.rules.flatMap((rule: any) => rule.verbs); - for (const verb of ["create", "patch", "update", "delete", "deletecollection"]) expect(verbs).not.toContain(verb); + expect(rbac.map((item) => item.kind)).toEqual(["Role", "RoleBinding", "Role", "RoleBinding"]); + expect(rbac.map((item) => item.metadata.namespace)).toEqual(["agentrun-ci", "agentrun-ci", "devops-infra", "devops-infra"]); + for (const role of rbac.filter((item) => item.kind === "Role")) { + expect(role.metadata.annotations["argocd.argoproj.io/sync-wave"]).toBe("-1"); + expect(role.metadata.annotations["unidesk.ai/effective-config-sha256"]).toBe(pacConsumerRbacDesiredIdentity("NC01").configSha256); + const verbs = role.rules.flatMap((rule: any) => rule.verbs); + for (const verb of ["create", "patch", "update", "delete", "deletecollection"]) expect(verbs).not.toContain(verb); + } const desiredKinds = documents(renderPlatformInfraGiteaDesiredFragments("NC01")).map((item) => item.kind); expect(desiredKinds).toEqual([ + "Role", + "RoleBinding", "Role", "RoleBinding", "ValidatingAdmissionPolicy", diff --git a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts index 7ffdca93..f5550b76 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts @@ -587,6 +587,7 @@ function embeddedPipelineRun(binding: PacSourceArtifactBinding, desiredSpec: Rec labels: pipelineRunLabels(binding, provenance.renderer === "sub2rank-platform-service" ? "sub2rank" : "agentrun"), }, spec: { + timeouts: { pipeline: binding.repository.params.pipeline_timeout }, pipelineSpec: desiredSpec, taskRunTemplate: taskRunTemplate(binding), params: pipelineRunParams(binding, desiredSpec), From f99aefafbf101161f5ab4d471a33beb923654788 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 08:12:47 +0200 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=E9=9A=94=E7=A6=BB=20Sub2Rank=20PaC?= =?UTF-8?q?=20=E6=89=A7=E8=A1=8C=E8=BA=AB=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/platform-infra/pipelines-as-code.yaml | 5 ++- config/platform-infra/sub2rank.yaml | 2 +- scripts/native/cicd/pac-status-evaluator.cjs | 28 +++++++++++++ ...trun-managed-repository-reconciler.test.ts | 3 ++ ...atform-infra-gitea-gitops-delivery.test.ts | 1 + .../src/platform-infra-pac-consumer-rbac.ts | 39 ++++++++++++++++++- .../src/platform-infra-pac-provenance.test.ts | 37 +++++++++++++++--- scripts/src/platform-infra-pac-provenance.ts | 13 +++++++ ...platform-infra-pipelines-as-code-remote.sh | 35 +++++++++++++++++ ...infra-pipelines-as-code-source-artifact.ts | 10 +++++ .../src/platform-infra-pipelines-as-code.ts | 18 +++++++++ .../src/platform-infra-sub2rank-pipeline.ts | 21 ++++++++++ 12 files changed, 201 insertions(+), 11 deletions(-) diff --git a/config/platform-infra/pipelines-as-code.yaml b/config/platform-infra/pipelines-as-code.yaml index 81a90780..c9ef5b00 100644 --- a/config/platform-infra/pipelines-as-code.yaml +++ b/config/platform-infra/pipelines-as-code.yaml @@ -144,7 +144,7 @@ repositories: gitops_manifest_path: deploy/gitops/platform-infra/sub2rank-nc01/resources.yaml pipeline_name: platform-infra-sub2rank-nc01-pac pipeline_run_prefix: sub2rank-nc01 - service_account: default + service_account: sub2rank-nc01-tekton-runner pipeline_timeout: 600s workspace_pvc_size: 4Gi runtime_namespace: platform-infra @@ -245,7 +245,8 @@ consumers: deliveryProvenance: required: true markerValue: admission-pac-v2:platform-infra-sub2rank-nc01 - executionServiceAccountName: default + executionServiceAccountName: sub2rank-nc01-tekton-runner + manageExecutionServiceAccount: true gitOps: repoUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/unidesk.git targetRevision: unidesk-host-gitops diff --git a/config/platform-infra/sub2rank.yaml b/config/platform-infra/sub2rank.yaml index 7594352a..2c6b02e4 100644 --- a/config/platform-infra/sub2rank.yaml +++ b/config/platform-infra/sub2rank.yaml @@ -29,7 +29,7 @@ delivery: namespace: devops-infra name: platform-infra-sub2rank-nc01-pac runPrefix: sub2rank-nc01 - serviceAccountName: default + serviceAccountName: sub2rank-nc01-tekton-runner timeout: 600s workspaceSize: 4Gi toolsImage: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 diff --git a/scripts/native/cicd/pac-status-evaluator.cjs b/scripts/native/cicd/pac-status-evaluator.cjs index f14ffb46..123a4811 100644 --- a/scripts/native/cicd/pac-status-evaluator.cjs +++ b/scripts/native/cicd/pac-status-evaluator.cjs @@ -112,6 +112,8 @@ function evaluatePacAdmissionState(inputValue) { const role = record(input.role); const roleBinding = record(input.roleBinding); const expected = record(input.expected); + const executionServiceAccounts = Array.isArray(input.executionServiceAccounts) ? input.executionServiceAccounts.map(record) : []; + const expectedExecutionServiceAccounts = Array.isArray(expected.managedExecutionServiceAccounts) ? expected.managedExecutionServiceAccounts.map(record) : []; const namespace = stringOrNull(input.namespace); const policyMetadata = record(policy.metadata); const bindingMetadata = record(binding.metadata); @@ -167,6 +169,31 @@ function evaluatePacAdmissionState(inputValue) { if (namespace === null || subjects.length !== 1 || subjects[0].kind !== "ServiceAccount" || subjects[0].name !== "default" || subjects[0].namespace !== namespace) reasons.push("default-rolebinding-subject-mismatch"); if (typeof input.defaultCanMutate !== "boolean") reasons.push("default-serviceaccount-mutation-probe-unavailable"); if (input.defaultCanMutate === true) reasons.push("default-serviceaccount-can-mutate-tekton-runs"); + const executionServiceAccountState = expectedExecutionServiceAccounts.map((expectedServiceAccount) => { + const expectedNamespace = stringOrNull(expectedServiceAccount.namespace); + const expectedName = stringOrNull(expectedServiceAccount.name); + const expectedConsumerId = stringOrNull(expectedServiceAccount.consumerId); + const live = executionServiceAccounts.find((item) => { + const metadata = record(item.metadata); + return metadata.namespace === expectedNamespace && metadata.name === expectedName; + }); + const metadata = record(live?.metadata); + const annotations = record(metadata.annotations); + const labels = record(metadata.labels); + const identity = expectedConsumerId || expectedName || "unknown"; + if (live === undefined) reasons.push(`execution-serviceaccount-missing:${identity}`); + if (live !== undefined && live.automountServiceAccountToken !== false) reasons.push(`execution-serviceaccount-automount-not-false:${identity}`); + if (live !== undefined && annotations["unidesk.ai/effective-config-sha256"] !== expected.rbacConfigSha256) reasons.push(`execution-serviceaccount-config-sha256-mismatch:${identity}`); + if (live !== undefined && labels["unidesk.ai/pac-consumer"] !== expectedConsumerId) reasons.push(`execution-serviceaccount-consumer-mismatch:${identity}`); + return { + consumerId: expectedConsumerId, + namespace: expectedNamespace, + name: expectedName, + present: live !== undefined, + automountServiceAccountToken: live === undefined ? null : live.automountServiceAccountToken === false ? false : true, + configSha256: stringOrNull(annotations["unidesk.ai/effective-config-sha256"]), + }; + }); const timestamps = [policyMetadata.creationTimestamp, bindingMetadata.creationTimestamp] .filter((value) => typeof value === "string" && Number.isFinite(Date.parse(value))) .sort((left, right) => Date.parse(left) - Date.parse(right)); @@ -189,6 +216,7 @@ function evaluatePacAdmissionState(inputValue) { observedGeneration: Number.isInteger(policyStatus.observedGeneration) ? policyStatus.observedGeneration : null, rbacConfigSha256: stringOrNull(roleAnnotations["unidesk.ai/effective-config-sha256"]), defaultCanMutate: typeof input.defaultCanMutate === "boolean" ? input.defaultCanMutate : null, + executionServiceAccounts: executionServiceAccountState, activeAfter, reasons, valuesPrinted: false, diff --git a/scripts/src/agentrun-managed-repository-reconciler.test.ts b/scripts/src/agentrun-managed-repository-reconciler.test.ts index 51c406f8..f5ecf449 100644 --- a/scripts/src/agentrun-managed-repository-reconciler.test.ts +++ b/scripts/src/agentrun-managed-repository-reconciler.test.ts @@ -89,6 +89,9 @@ describe("AgentRun YAML-owned managed repository reconciler", () => { expect(documents.map((item) => item.kind)).toEqual([ "Role", "RoleBinding", + "Role", + "RoleBinding", + "ServiceAccount", "ValidatingAdmissionPolicy", "ValidatingAdmissionPolicyBinding", "ServiceAccount", diff --git a/scripts/src/platform-infra-gitea-gitops-delivery.test.ts b/scripts/src/platform-infra-gitea-gitops-delivery.test.ts index 3c8fbdff..c0bc2342 100644 --- a/scripts/src/platform-infra-gitea-gitops-delivery.test.ts +++ b/scripts/src/platform-infra-gitea-gitops-delivery.test.ts @@ -80,6 +80,7 @@ test("owning YAML renders one child Application and the complete durable bridge "RoleBinding", "Role", "RoleBinding", + "ServiceAccount", "ValidatingAdmissionPolicy", "ValidatingAdmissionPolicyBinding", "ServiceAccount", diff --git a/scripts/src/platform-infra-pac-consumer-rbac.ts b/scripts/src/platform-infra-pac-consumer-rbac.ts index 58ee9a87..12963abc 100644 --- a/scripts/src/platform-infra-pac-consumer-rbac.ts +++ b/scripts/src/platform-infra-pac-consumer-rbac.ts @@ -5,6 +5,12 @@ import { readPacAdmissionContract } from "./platform-infra-pac-provenance"; export interface PacConsumerRbacDesiredIdentity { readonly configSha256: string; readonly namespaces: readonly string[]; + readonly executionServiceAccounts: readonly { + readonly consumerId: string; + readonly namespace: string; + readonly name: string; + readonly automountServiceAccountToken: false; + }[]; } const pacConsumerReadOnlyRules = [ @@ -19,7 +25,7 @@ export function renderPacConsumerRbacDesiredFragment(targetId: string): string { const namespaces = [...new Set(consumers.map((consumer) => consumer.namespace))]; if (namespaces.length === 0) return ""; const configSha256 = pacConsumerRbacDesiredIdentity(targetId).configSha256; - const objects = namespaces.flatMap((namespace) => { + const objects: Record[] = namespaces.flatMap((namespace) => { const labels = { "app.kubernetes.io/managed-by": "unidesk", "app.kubernetes.io/part-of": "platform-infra", @@ -45,6 +51,25 @@ export function renderPacConsumerRbacDesiredFragment(targetId: string): string { }, ]; }); + objects.push(...consumers.filter((consumer) => consumer.manageExecutionServiceAccount).map((consumer) => ({ + apiVersion: "v1", + kind: "ServiceAccount", + metadata: { + name: consumer.executionServiceAccountName, + namespace: consumer.namespace, + labels: { + "app.kubernetes.io/managed-by": "unidesk", + "app.kubernetes.io/part-of": "platform-infra", + "unidesk.ai/pac-rbac-contract": "admission-provenance-required", + "unidesk.ai/pac-consumer": consumer.id, + }, + annotations: { + "argocd.argoproj.io/sync-wave": "-2", + "unidesk.ai/effective-config-sha256": configSha256, + }, + }, + automountServiceAccountToken: false, + }))); return `${objects.map((item) => Bun.YAML.stringify(item).trim()).join("\n---\n")}\n`; } @@ -60,8 +85,18 @@ export function pacConsumerRbacDesiredIdentity(targetId: string): PacConsumerRba roleRef: pacConsumerDefaultRoleRef, }, })); + const executionServiceAccounts = consumers + .filter((consumer) => consumer.manageExecutionServiceAccount) + .map((consumer) => ({ + consumerId: consumer.id, + namespace: consumer.namespace, + name: consumer.executionServiceAccountName, + automountServiceAccountToken: false as const, + })) + .sort((left, right) => `${left.namespace}/${left.name}`.localeCompare(`${right.namespace}/${right.name}`)); return { - configSha256: `sha256:${createHash("sha256").update(JSON.stringify({ targetId, desiredSpec, contract: "admission-provenance-required-v1" })).digest("hex")}`, + configSha256: `sha256:${createHash("sha256").update(JSON.stringify({ targetId, desiredSpec, executionServiceAccounts, contract: "admission-provenance-required-v2" })).digest("hex")}`, namespaces, + executionServiceAccounts, }; } diff --git a/scripts/src/platform-infra-pac-provenance.test.ts b/scripts/src/platform-infra-pac-provenance.test.ts index 872d49f0..26c3f28f 100644 --- a/scripts/src/platform-infra-pac-provenance.test.ts +++ b/scripts/src/platform-infra-pac-provenance.test.ts @@ -25,9 +25,18 @@ function documents(value: string): any[] { test("admission contract rejects malformed required declarations instead of silently downgrading", () => { const source = Bun.YAML.parse(readFileSync(resolve(root, "config/platform-infra/pipelines-as-code.yaml"), "utf8")) as any; - const consumer = source.consumers.find((item: any) => item.deliveryProvenance !== undefined); - consumer.deliveryProvenance.required = "true"; - expect(() => parsePacAdmissionContract(source)).toThrow("deliveryProvenance.required must be boolean true or false"); + const malformedRequired = structuredClone(source); + malformedRequired.consumers.find((item: any) => item.deliveryProvenance !== undefined).deliveryProvenance.required = "true"; + expect(() => parsePacAdmissionContract(malformedRequired)).toThrow("deliveryProvenance.required must be boolean true or false"); + + const sharedDefault = structuredClone(source); + sharedDefault.consumers.find((item: any) => item.deliveryProvenance !== undefined).deliveryProvenance.executionServiceAccountName = "default"; + expect(() => parsePacAdmissionContract(sharedDefault)).toThrow("must not reserve the shared default ServiceAccount"); + + const duplicatedServiceAccount = structuredClone(source); + const duplicatedRequiredConsumers = duplicatedServiceAccount.consumers.filter((item: any) => item.deliveryProvenance !== undefined); + duplicatedRequiredConsumers[1].deliveryProvenance.executionServiceAccountName = duplicatedRequiredConsumers[0].deliveryProvenance.executionServiceAccountName; + expect(() => parsePacAdmissionContract(duplicatedServiceAccount)).toThrow("executionServiceAccountName must be unique per target"); }); test("admission queue transition contract rejects absent sources, unknown Tekton states, and duplicate transitions", () => { @@ -79,6 +88,8 @@ test("versioned admission desired state protects controller proof, candidate ide expect(expressions).toContain("object.metadata.name.startsWith"); expect(expressions).toContain('object.metadata.labels["tekton.dev/pipeline"]'); expect(expressions).toContain("agentrun-nc01-v02-tekton-runner"); + expect(expressions).toContain("sub2rank-nc01-tekton-runner"); + expect(expressions).not.toContain('serviceAccountName == "default"'); expect(messages).toContain("execution ServiceAccount"); expect(expressions).toContain(contract.child.parentUidAnnotation); expect(expressions).toContain("oldObject"); @@ -113,26 +124,34 @@ test("versioned admission desired state protects controller proof, candidate ide expect(queueGuard.expression.match(/admission-pac-v2:agentrun-nc01-v02/gu)).toHaveLength(2); expect(queueGuard.expression.match(/pipelines-as-code-watcher/gu)).toHaveLength(1); expect(queueGuard.expression.match(/agentrun-nc01-v02-tekton-runner/gu)).toHaveLength(2); + expect(queueGuard.expression.match(/sub2rank-nc01-tekton-runner/gu)).toHaveLength(2); expect(queueGuard.expression).not.toContain("admission-pac-v1:"); expect(queueGuard.expression).not.toContain('== "Cancelled"'); }); test("required consumers have one automatic desired owner per namespace and no Tekton mutation verbs", () => { const rbac = documents(renderPacConsumerRbacDesiredFragment("NC01")); - expect(rbac.map((item) => item.kind)).toEqual(["Role", "RoleBinding", "Role", "RoleBinding"]); - expect(rbac.map((item) => item.metadata.namespace)).toEqual(["agentrun-ci", "agentrun-ci", "devops-infra", "devops-infra"]); + expect(rbac.map((item) => item.kind)).toEqual(["Role", "RoleBinding", "Role", "RoleBinding", "ServiceAccount"]); + expect(rbac.map((item) => item.metadata.namespace)).toEqual(["agentrun-ci", "agentrun-ci", "devops-infra", "devops-infra", "devops-infra"]); for (const role of rbac.filter((item) => item.kind === "Role")) { expect(role.metadata.annotations["argocd.argoproj.io/sync-wave"]).toBe("-1"); expect(role.metadata.annotations["unidesk.ai/effective-config-sha256"]).toBe(pacConsumerRbacDesiredIdentity("NC01").configSha256); const verbs = role.rules.flatMap((rule: any) => rule.verbs); for (const verb of ["create", "patch", "update", "delete", "deletecollection"]) expect(verbs).not.toContain(verb); } + const executionServiceAccount = rbac.find((item) => item.kind === "ServiceAccount"); + expect(executionServiceAccount).toMatchObject({ + metadata: { name: "sub2rank-nc01-tekton-runner", namespace: "devops-infra" }, + automountServiceAccountToken: false, + }); + expect(executionServiceAccount.metadata.annotations["argocd.argoproj.io/sync-wave"]).toBe("-2"); const desiredKinds = documents(renderPlatformInfraGiteaDesiredFragments("NC01")).map((item) => item.kind); expect(desiredKinds).toEqual([ "Role", "RoleBinding", "Role", "RoleBinding", + "ServiceAccount", "ValidatingAdmissionPolicy", "ValidatingAdmissionPolicyBinding", "ServiceAccount", @@ -145,6 +164,7 @@ test("live admission evaluator requires exact desired hashes, observed generatio const contract = readPacAdmissionContract(); const admission = documents(renderPacAdmissionDesiredFragment("NC01")); const rbac = documents(renderPacConsumerRbacDesiredFragment("NC01")); + const executionServiceAccount = rbac.find((item) => item.kind === "ServiceAccount"); const policy = { ...admission[0], metadata: { ...admission[0].metadata, uid: "policy-uid", generation: 2, creationTimestamp: "2026-01-01T00:00:00Z" }, status: { observedGeneration: 2, typeChecking: { expressionWarnings: [] } } }; const binding = { ...admission[1], metadata: { ...admission[1].metadata, uid: "binding-uid", creationTimestamp: "2026-01-01T00:00:01Z" } }; const expected = { @@ -155,9 +175,14 @@ test("live admission evaluator requires exact desired hashes, observed generatio controllerIdentity: contract.controllerUsername, configSha256: policy.metadata.annotations["unidesk.ai/effective-config-sha256"], rbacConfigSha256: rbac[0].metadata.annotations["unidesk.ai/effective-config-sha256"], + managedExecutionServiceAccounts: pacConsumerRbacDesiredIdentity("NC01").executionServiceAccounts, }; - const input = { policy, binding, role: rbac[0], roleBinding: rbac[1], namespace: "agentrun-ci", defaultCanMutate: false, expected }; + const input = { policy, binding, role: rbac[0], roleBinding: rbac[1], namespace: "agentrun-ci", defaultCanMutate: false, executionServiceAccounts: [executionServiceAccount], expected }; expect(evaluator.evaluatePacAdmissionState(input)).toMatchObject({ ready: true, activeAfter: "2026-01-01T00:00:01Z", defaultCanMutate: false }); + expect(evaluator.evaluatePacAdmissionState({ ...input, executionServiceAccounts: [] })).toMatchObject({ + ready: false, + reasons: expect.arrayContaining(["execution-serviceaccount-missing:platform-infra-sub2rank-nc01"]), + }); expect(evaluator.evaluatePacAdmissionState({ ...input, policy: { diff --git a/scripts/src/platform-infra-pac-provenance.ts b/scripts/src/platform-infra-pac-provenance.ts index 434bbb02..951cc337 100644 --- a/scripts/src/platform-infra-pac-provenance.ts +++ b/scripts/src/platform-infra-pac-provenance.ts @@ -14,6 +14,7 @@ export interface PacAdmissionConsumerContract { readonly pipelineRunPrefix: string; readonly markerValue: string; readonly executionServiceAccountName: string; + readonly manageExecutionServiceAccount: boolean; readonly gitOps: { readonly repoUrl: string; readonly targetRevision: string }; } @@ -98,6 +99,7 @@ export function parsePacAdmissionContract(source: Record): PacA pipelineRunPrefix: required(consumer.pipelineRunPrefix, `consumers[${index}].pipelineRunPrefix`), markerValue: required(value.markerValue, `consumers[${index}].deliveryProvenance.markerValue`), executionServiceAccountName: required(value.executionServiceAccountName, `consumers[${index}].deliveryProvenance.executionServiceAccountName`), + manageExecutionServiceAccount: optionalBoolean(value.manageExecutionServiceAccount, `consumers[${index}].deliveryProvenance.manageExecutionServiceAccount`, false), gitOps: { repoUrl: required(gitOps.repoUrl, `consumers[${index}].deliveryProvenance.gitOps.repoUrl`), targetRevision: required(gitOps.targetRevision, `consumers[${index}].deliveryProvenance.gitOps.targetRevision`), @@ -106,6 +108,11 @@ export function parsePacAdmissionContract(source: Record): PacA }); const markerValues = new Set(consumers.map((consumer) => consumer.markerValue)); if (markerValues.size !== consumers.length) throw new Error("deliveryProvenance markerValue must be unique per consumer"); + for (const consumer of consumers) { + if (consumer.executionServiceAccountName === "default") throw new Error(`deliveryProvenance executionServiceAccountName for ${consumer.id} must not reserve the shared default ServiceAccount`); + } + const serviceAccountKeys = consumers.map((consumer) => `${consumer.node.toLowerCase()}\u0000${consumer.executionServiceAccountName}`); + if (new Set(serviceAccountKeys).size !== serviceAccountKeys.length) throw new Error("deliveryProvenance executionServiceAccountName must be unique per target"); const version = required(provenance.version, "deliveryProvenance.version"); const versionMatch = version.match(/-v([1-9][0-9]*)$/u); if (versionMatch === null) throw new Error("deliveryProvenance.version must end with a positive -vN resource epoch"); @@ -393,3 +400,9 @@ function literal(value: unknown, path: string, expec if (value !== expected) throw new Error(`${path} must be ${expected}`); return expected; } + +function optionalBoolean(value: unknown, path: string, fallback: boolean): boolean { + if (value === undefined) return fallback; + if (typeof value !== "boolean") throw new Error(`${path} must be boolean`); + return value; +} diff --git a/scripts/src/platform-infra-pipelines-as-code-remote.sh b/scripts/src/platform-infra-pipelines-as-code-remote.sh index 30d88de2..61b9a669 100644 --- a/scripts/src/platform-infra-pipelines-as-code-remote.sh +++ b/scripts/src/platform-infra-pipelines-as-code-remote.sh @@ -291,10 +291,28 @@ admission_provenance_state() { export UNIDESK_PAC_DEFAULT_CAN_MUTATE="$default_can_mutate" node - "$policy_file" "$binding_file" "$role_file" "$role_binding_file" <<'NODE' const fs = require('node:fs'); +const cp = require('node:child_process'); const { evaluatePacAdmissionState } = require(process.env.UNIDESK_PAC_EVALUATOR_PATH); function read(path) { try { return JSON.parse(fs.readFileSync(path, 'utf8') || '{}'); } catch { return {}; } } +let managedExecutionServiceAccounts = []; +try { + const parsed = JSON.parse(process.env.UNIDESK_PAC_MANAGED_EXECUTION_SERVICE_ACCOUNTS_JSON || '[]'); + if (Array.isArray(parsed)) managedExecutionServiceAccounts = parsed; +} catch {} +const waitTimeoutSeconds = Number.parseInt(process.env.UNIDESK_PAC_WAIT_TIMEOUT_SECONDS || '', 10); +const kubectlTimeoutMs = Number.isInteger(waitTimeoutSeconds) && waitTimeoutSeconds > 0 ? waitTimeoutSeconds * 1000 : null; +const executionServiceAccounts = managedExecutionServiceAccounts.map((item) => { + if (kubectlTimeoutMs === null || !item || typeof item.namespace !== 'string' || typeof item.name !== 'string') return {}; + const result = cp.spawnSync('kubectl', ['-n', item.namespace, 'get', 'serviceaccount', item.name, '-o', 'json'], { + encoding: 'utf8', + timeout: kubectlTimeoutMs, + maxBuffer: 1024 * 1024, + }); + if (result.error || result.status !== 0) return {}; + try { return JSON.parse(result.stdout || '{}'); } catch { return {}; } +}); process.stdout.write(JSON.stringify(evaluatePacAdmissionState({ policy: read(process.argv[2]), binding: read(process.argv[3]), @@ -302,6 +320,7 @@ process.stdout.write(JSON.stringify(evaluatePacAdmissionState({ roleBinding: read(process.argv[5]), namespace: process.env.UNIDESK_PAC_TARGET_NAMESPACE || null, defaultCanMutate: process.env.UNIDESK_PAC_DEFAULT_CAN_MUTATE === 'unknown' ? null : process.env.UNIDESK_PAC_DEFAULT_CAN_MUTATE === 'true', + executionServiceAccounts, expected: { targetId: process.env.UNIDESK_PAC_TARGET_ID || '', policyName: process.env.UNIDESK_PAC_ADMISSION_POLICY_NAME || '', @@ -310,6 +329,7 @@ process.stdout.write(JSON.stringify(evaluatePacAdmissionState({ controllerIdentity: process.env.UNIDESK_PAC_ADMISSION_CONTROLLER_IDENTITY || '', configSha256: process.env.UNIDESK_PAC_ADMISSION_CONFIG_SHA256 || '', rbacConfigSha256: process.env.UNIDESK_PAC_CONSUMER_RBAC_CONFIG_SHA256 || '', + managedExecutionServiceAccounts, }, }))); NODE @@ -470,6 +490,7 @@ function kubectlJson(args, fallback, context) { let admissionPolicy = null; let admissionBinding = null; const admissionRbac = new Map(); +const admissionExecutionServiceAccounts = new Map(); function defaultCanMutate(namespace) { const waitTimeoutSeconds = Number.parseInt(process.env.UNIDESK_PAC_WAIT_TIMEOUT_SECONDS || '', 10); @@ -507,6 +528,18 @@ function admissionStateForConsumer(consumer) { }); } const rbac = admissionRbac.get(consumer.namespace); + const managedExecutionServiceAccounts = Array.isArray(expected.managedExecutionServiceAccounts) ? expected.managedExecutionServiceAccounts : []; + const executionServiceAccounts = managedExecutionServiceAccounts.map((item) => { + const key = `${item.namespace || ''}/${item.name || ''}`; + if (!admissionExecutionServiceAccounts.has(key)) { + admissionExecutionServiceAccounts.set(key, kubectlJson( + ['-n', item.namespace, 'get', 'serviceaccount', item.name, '-o', 'json'], + {}, + `${consumer.id}:execution-serviceaccount:${key}`, + )); + } + return admissionExecutionServiceAccounts.get(key); + }); return evaluatePacAdmissionState({ policy: admissionPolicy, binding: admissionBinding, @@ -514,6 +547,7 @@ function admissionStateForConsumer(consumer) { roleBinding: rbac.roleBinding, namespace: consumer.namespace, defaultCanMutate: rbac.defaultCanMutate, + executionServiceAccounts, expected: { targetId: expected.targetId, policyName: expected.policyName, @@ -522,6 +556,7 @@ function admissionStateForConsumer(consumer) { controllerIdentity: expected.controllerIdentity, configSha256: expected.configSha256, rbacConfigSha256: expected.rbacConfigSha256, + managedExecutionServiceAccounts, }, }); } diff --git a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts index f5550b76..bc1da2af 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts @@ -49,6 +49,16 @@ export interface PacSourceArtifactBinding { readonly namespace: string; readonly pipeline: string; readonly pipelineRunPrefix: string; + readonly argoNamespace: string; + readonly argoApplication: string; + readonly argoBootstrap: { + readonly project: string; + readonly repoUrl: string; + readonly targetRevision: string; + readonly path: string; + readonly destinationNamespace: string; + readonly automated: boolean; + } | null; readonly deliveryProvenance?: { readonly markerAnnotation: string; readonly markerValue: string; diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index 21eff00c..c8c595d4 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -159,6 +159,7 @@ interface PacConsumer { required: true; markerValue: string; executionServiceAccountName: string; + manageExecutionServiceAccount: boolean; gitOps: { repoUrl: string; targetRevision: string }; } | null; repositoryRef: string; @@ -271,6 +272,9 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf namespace: consumer.namespace, pipeline: consumer.pipeline, pipelineRunPrefix: consumer.pipelineRunPrefix, + argoNamespace: consumer.argoNamespace, + argoApplication: consumer.argoApplication, + argoBootstrap: consumer.argoBootstrap, deliveryProvenance: consumer.deliveryProvenance === null ? null : { markerAnnotation: pac.deliveryProvenance.markerAnnotation, markerValue: consumer.deliveryProvenance.markerValue, @@ -528,6 +532,9 @@ function parseConsumerDeliveryProvenance(value: Record, path: s required: true, markerValue: y.stringField(value, "markerValue", path), executionServiceAccountName: y.kubernetesNameField(value, "executionServiceAccountName", path), + manageExecutionServiceAccount: value.manageExecutionServiceAccount === undefined + ? false + : y.booleanField(value, "manageExecutionServiceAccount", path), gitOps: { repoUrl: urlField(gitOps, "repoUrl", `${path}.gitOps`), targetRevision: y.stringField(gitOps, "targetRevision", `${path}.gitOps`), @@ -756,6 +763,7 @@ function validateConfig(config: PacConfig): void { } const consumerIds = new Set(); const markerValues = new Set(); + const reservedServiceAccounts = new Set(); for (const consumer of config.consumers) { if (consumerIds.has(consumer.id)) throw new Error(`${configLabel}.consumers id must be unique: ${consumer.id}`); consumerIds.add(consumer.id); @@ -765,6 +773,14 @@ function validateConfig(config: PacConfig): void { if (consumer.sourceArtifact === null) throw new Error(`${configLabel}.consumers.${consumer.id}.deliveryProvenance requires sourceArtifact ownership`); if (markerValues.has(consumer.deliveryProvenance.markerValue)) throw new Error(`${configLabel}.consumers deliveryProvenance.markerValue must be unique: ${consumer.deliveryProvenance.markerValue}`); markerValues.add(consumer.deliveryProvenance.markerValue); + if (consumer.deliveryProvenance.executionServiceAccountName === "default") { + throw new Error(`${configLabel}.consumers.${consumer.id}.deliveryProvenance.executionServiceAccountName must not reserve the shared default ServiceAccount`); + } + const serviceAccountKey = `${consumer.node.toLowerCase()}\u0000${consumer.deliveryProvenance.executionServiceAccountName}`; + if (reservedServiceAccounts.has(serviceAccountKey)) { + throw new Error(`${configLabel}.consumers deliveryProvenance.executionServiceAccountName must be unique per target: ${consumer.deliveryProvenance.executionServiceAccountName}`); + } + reservedServiceAccounts.add(serviceAccountKey); if (repository.params.service_account !== consumer.deliveryProvenance.executionServiceAccountName) { throw new Error(`${configLabel}.consumers.${consumer.id}.deliveryProvenance.executionServiceAccountName must match repository params.service_account`); } @@ -1229,6 +1245,7 @@ function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED: consumer.deliveryProvenance?.required === true ? "1" : "0", UNIDESK_PAC_CONSUMER_RBAC_MANIFEST_B64: Buffer.from(renderPacConsumerRbacDesiredFragment(target.id), "utf8").toString("base64"), UNIDESK_PAC_CONSUMER_RBAC_CONFIG_SHA256: rbacIdentity.configSha256, + UNIDESK_PAC_MANAGED_EXECUTION_SERVICE_ACCOUNTS_JSON: JSON.stringify(rbacIdentity.executionServiceAccounts), UNIDESK_PAC_ADMISSION_POLICY_NAME: admissionIdentity.policyName, UNIDESK_PAC_ADMISSION_BINDING_NAME: admissionIdentity.bindingName, UNIDESK_PAC_ADMISSION_VERSION: admissionIdentity.version, @@ -1272,6 +1289,7 @@ function historyConsumerConfig(pac: PacConfig, consumer: PacConsumer): Record> = { node: binding.consumer.node, @@ -236,6 +238,25 @@ function assertBinding(config: Sub2RankConfig, target: Sub2RankTarget, binding: for (const [key, value] of Object.entries(expected)) { if (observed[key] !== value) throw new Error(`Sub2Rank PaC binding ${key}=${String(observed[key])} does not match owning YAML ${value}`); } + if (binding.consumer.deliveryProvenance?.executionServiceAccountName !== delivery.pipeline.serviceAccountName) { + throw new Error(`Sub2Rank PaC deliveryProvenance execution ServiceAccount does not match owning YAML ${delivery.pipeline.serviceAccountName}`); + } + const application = delivery.gitops.application; + if (binding.consumer.argoNamespace !== application.namespace) throw new Error(`Sub2Rank PaC argoNamespace does not match owning YAML ${application.namespace}`); + if (binding.consumer.argoApplication !== application.name) throw new Error(`Sub2Rank PaC argoApplication does not match owning YAML ${application.name}`); + const bootstrap = binding.consumer.argoBootstrap; + if (bootstrap === null) throw new Error("Sub2Rank PaC argoBootstrap must be declared"); + const expectedBootstrap = { + project: application.project, + repoUrl: delivery.gitops.readUrl, + targetRevision: delivery.gitops.branch, + path: application.path, + destinationNamespace: application.destinationNamespace, + automated: application.automated, + } as const; + for (const [key, value] of Object.entries(expectedBootstrap)) { + if (bootstrap[key as keyof typeof expectedBootstrap] !== value) throw new Error(`Sub2Rank PaC argoBootstrap.${key} does not match owning YAML ${String(value)}`); + } } function pipelineOwner(config: Sub2RankConfig, target: Sub2RankTarget): Record { From 33771c93def683182edc4e7befc9e5eae9012f58 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 08:41:36 +0200 Subject: [PATCH 4/6] =?UTF-8?q?docs:=20=E5=85=B3=E8=81=94=20WebProbe=20?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E6=B2=BB=E7=90=86=E5=AD=90=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/MDTODO/hwlab-web-product-experience.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/MDTODO/hwlab-web-product-experience.md b/docs/MDTODO/hwlab-web-product-experience.md index a416b0bc..03e739a4 100644 --- a/docs/MDTODO/hwlab-web-product-experience.md +++ b/docs/MDTODO/hwlab-web-product-experience.md @@ -65,4 +65,4 @@ ### R1.13 [in_progress] -补强 web-probe 与 Web 哨兵资源治理:人工 observe start 只以 /proc/meminfo 的 MemAvailable 判定真实物理内存,<=4GiB 时先阻断并要求受控 GC 后复核;哨兵 <4GiB 时不创建 observer/Chrome,跳过本轮等待下一 cadence。将阈值、TTL、样本/截图/性能/历史/响应体上限收敛到 owning YAML,保证成功、失败、timeout、signal 全终态关闭 browser/context,并在 NC01/v03 验证 12GiB swap 不计入资格,完成任务后将详细报告写入[任务报告](./details/hwlab-web-product-experience/R1.13_Task_Report.md)。 +补强 WebProbe 全浏览器入口与 Web 哨兵资源治理(子 issue [pikasTech/unidesk#1907](https://github.com/pikasTech/unidesk/issues/1907)):所有会创建浏览器的人工入口只以 /proc/meminfo 的 MemAvailable 判定真实物理内存,未满足 owning YAML 策略时先阻断并要求受控 GC 后复核;哨兵未满足策略时不创建 observer/Chrome,跳过本轮等待下一 cadence。将阈值、锁、TTL、样本、截图、性能、历史、响应体和终态关闭上限收敛到 owning YAML,保证成功、失败、timeout、signal 全路径关闭 browser/context,并在 NC01/v03 验证 12GiB swap 不计入资格,完成任务后将详细报告写入[任务报告](./details/hwlab-web-product-experience/R1.13_Task_Report.md)。 From 8b1318a9f24bedcb3823733dec2ae89d0eac824a Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 08:49:18 +0200 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=E7=AE=80=E5=8C=96=20Sub2Rank=20YAML?= =?UTF-8?q?=20=E5=A5=91=E7=BA=A6=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/src/platform-infra-sub2rank-config.ts | 45 ++++++++------- .../src/platform-infra-sub2rank-pipeline.ts | 56 +------------------ scripts/src/platform-infra-sub2rank.ts | 8 +-- 3 files changed, 31 insertions(+), 78 deletions(-) diff --git a/scripts/src/platform-infra-sub2rank-config.ts b/scripts/src/platform-infra-sub2rank-config.ts index aa1f6d7f..2897fc29 100644 --- a/scripts/src/platform-infra-sub2rank-config.ts +++ b/scripts/src/platform-infra-sub2rank-config.ts @@ -13,6 +13,7 @@ const y = createYamlFieldReader(sub2RankConfigLabel); export interface Sub2RankConfig { version: number; kind: "platform-infra-sub2rank"; + warnings: string[]; metadata: { id: string; owner: string; relatedIssues: number[] }; defaults: { targetId: string }; application: { @@ -30,6 +31,7 @@ export interface Sub2RankConfig { sourceClean: boolean; sourceRemotePresent: boolean; sourceRemoteMatches: boolean; + sourceConfigPathMatches: boolean; configMatchesSourceCommit: boolean; deploymentBlockPresent: boolean; automaticCreditEnabled: boolean; @@ -67,7 +69,7 @@ export interface Sub2RankConfig { project: string; path: string; destinationNamespace: string; - automated: true; + automated: boolean; }; }; }; @@ -133,10 +135,11 @@ export function readSub2RankConfig(): Sub2RankConfig { const secret = resolveSecretDeclaration(runtimeBase.secret.configRef, runtimeBase.secret.declarationId); const targets = parseTargets(root.targets, secret); if (!targets.some((target) => target.id === defaults.targetId)) throw new Error(`${sub2RankConfigLabel}.targets must include defaults.targetId ${defaults.targetId}`); - validateResolvedConfig(application, image, delivery, runtimeBase, secret, targets); + const warnings = validateResolvedConfig(application, image, delivery, runtimeBase, secret, targets); return { version, kind: "platform-infra-sub2rank", + warnings, metadata: { id: y.stringField(metadataRecord, "id", "metadata"), owner: y.stringField(metadataRecord, "owner", "metadata"), @@ -192,7 +195,6 @@ function parseApplication(record: Record): Sub2RankConfig["appl const statusShort = gitText(sourceRoot, ["status", "--porcelain"], "read Sub2Rank source status", true); const observedRemote = gitText(sourceRoot, ["remote", "get-url", "origin"], "read Sub2Rank origin", true); const configRelativePath = relative(sourceRoot, configRef).replaceAll("\\", "/"); - if (configRelativePath !== sourceConfigPath) throw new Error(`${sub2RankConfigLabel}.application.sourceConfigPath must resolve to application.configRef`); const committedConfig = gitText(sourceRoot, ["show", `${sourceCommit}:${configRelativePath}`], "read committed Sub2Rank application config", true, false); return { repository, @@ -209,6 +211,7 @@ function parseApplication(record: Record): Sub2RankConfig["appl sourceClean: statusShort.length === 0, sourceRemotePresent: observedRemote.length > 0, sourceRemoteMatches: sameRepositoryIdentity(remote, observedRemote), + sourceConfigPathMatches: configRelativePath === sourceConfigPath, configMatchesSourceCommit: committedConfig === configText.trimEnd(), deploymentBlockPresent: app.deployment !== undefined, automaticCreditEnabled, @@ -241,7 +244,6 @@ function parseDelivery(record: Record): Sub2RankConfig["deliver const email = y.stringField(author, "email", "delivery.gitops.author"); if (!/^[^@\s]+@[^@\s]+$/u.test(email)) throw new Error(`${sub2RankConfigLabel}.delivery.gitops.author.email must be an email address`); const automated = y.booleanField(application, "automated", "delivery.gitops.application"); - if (!automated) throw new Error(`${sub2RankConfigLabel}.delivery.gitops.application.automated must remain true`); return { enabled: y.booleanField(record, "enabled", "delivery"), pipeline: { @@ -278,7 +280,7 @@ function parseDelivery(record: Record): Sub2RankConfig["deliver project: y.kubernetesNameField(application, "project", "delivery.gitops.application"), path: safeRelativePath(y.stringField(application, "path", "delivery.gitops.application"), "delivery.gitops.application.path"), destinationNamespace: y.kubernetesNameField(application, "destinationNamespace", "delivery.gitops.application"), - automated: true, + automated, }, }, }; @@ -444,27 +446,32 @@ function validateResolvedConfig( runtime: ReturnType, secret: ResolvedSecretDeclaration, targets: Sub2RankTarget[], -): void { - if (!application.configMatchesSourceCommit) throw new Error(`${sub2RankConfigLabel}.application.configRef must match the file stored at application source commit ${application.sourceCommit}`); - if (!application.sourceRemoteMatches) throw new Error(`${sub2RankConfigLabel}.application.remote must identify the application.sourceRoot origin repository`); - if (!sameRepositoryIdentity(application.remote, `https://github.com/${application.repository}.git`)) throw new Error(`${sub2RankConfigLabel}.application.repository must match application.remote`); - if (!delivery.enabled) throw new Error(`${sub2RankConfigLabel}.delivery.enabled must remain true while Sub2Rank is deployed`); - if (delivery.gitops.maxPushAttempts > 5) throw new Error(`${sub2RankConfigLabel}.delivery.gitops.maxPushAttempts must be <= 5`); - if (dirname(delivery.gitops.manifestPath).replaceAll("\\", "/") !== delivery.gitops.application.path) throw new Error(`${sub2RankConfigLabel}.delivery.gitops.manifestPath must be directly under delivery.gitops.application.path`); +): string[] { + const warnings: string[] = []; + if (!application.sourceConfigPathMatches) warnings.push("application.sourceConfigPath 与 application.configRef 的相对路径不一致"); + if (!application.configMatchesSourceCommit) warnings.push(`application.configRef 与源码提交 ${application.sourceCommit} 中的文件不一致`); + if (!application.sourceRemoteMatches) warnings.push("application.remote 与 application.sourceRoot 的 origin 不一致"); + if (!sameRepositoryIdentity(application.remote, `https://github.com/${application.repository}.git`)) warnings.push("application.repository 与 application.remote 指向的仓库不一致"); + if (!delivery.enabled) warnings.push("delivery.enabled=false,自动交付当前关闭"); + if (delivery.gitops.maxPushAttempts > 5) warnings.push("delivery.gitops.maxPushAttempts 大于 5,失败时可能延长流水线耗时"); + if (dirname(delivery.gitops.manifestPath).replaceAll("\\", "/") !== delivery.gitops.application.path) warnings.push("delivery.gitops.manifestPath 不在 application.path 的直接子路径下"); if (!delivery.build.proxy.noProxy.includes("hyueapi.com") || !delivery.build.proxy.noProxy.includes(".hyueapi.com")) throw new Error(`${sub2RankConfigLabel}.delivery.build.proxy.noProxy must retain hyueapi.com and .hyueapi.com`); - if (!image.repository.startsWith("127.0.0.1:5000/")) throw new Error(`${sub2RankConfigLabel}.image.repository must use the NC01 local registry`); + if (!image.repository.startsWith("127.0.0.1:5000/")) warnings.push("image.repository 未使用 NC01 本地 registry"); const required = new Set(runtime.secret.requiredTargetKeys); const provided = new Set(secret.mappings.map((item) => item.targetKey)); const missing = [...required].filter((key) => !provided.has(key)); if (missing.length > 0) throw new Error(`${sub2RankConfigLabel}.runtime.secret.requiredTargetKeys missing from ${runtime.secret.configRef} declaration ${runtime.secret.declarationId}: ${missing.join(", ")}`); - if (!sameStringSet(application.server.envKeys, runtime.secret.appEnvTargetKeys)) throw new Error(`${sub2RankConfigLabel}.runtime.secret.appEnvTargetKeys must match application runtime server env keys`); - if (application.server.listenPort !== runtime.service.port) throw new Error(`${sub2RankConfigLabel}.runtime.service.port must match application runtime server listenPort`); - if (!application.server.databasePath.startsWith(`${runtime.storage.mountPath.replace(/\/+$/u, "")}/`)) throw new Error(`${sub2RankConfigLabel}.runtime.storage.mountPath must contain the application databasePath`); + if (!sameStringSet(application.server.envKeys, runtime.secret.appEnvTargetKeys)) warnings.push("runtime.secret.appEnvTargetKeys 与应用声明的环境变量集合不一致"); + if (application.server.listenPort !== runtime.service.port) warnings.push("runtime.service.port 与应用 listenPort 不一致"); + if (!application.server.databasePath.startsWith(`${runtime.storage.mountPath.replace(/\/+$/u, "")}/`)) warnings.push("应用 databasePath 不在 runtime.storage.mountPath 下"); + if (!delivery.gitops.application.automated) warnings.push("delivery.gitops.application.automated=false,Argo 自动同步当前关闭"); + if (application.deploymentBlockPresent) warnings.push("应用配置仍包含 deployment 段;部署事实应以 platform-infra YAML 为准"); for (const target of targets) { - if (target.route !== secret.route || target.namespace !== secret.namespace) throw new Error(`${sub2RankConfigLabel}.targets[${target.id}] route/namespace must match runtime Secret target ${secret.targetId}`); - if (target.publicExposure.frpc.localPort !== runtime.service.port) throw new Error(`${sub2RankConfigLabel}.targets[${target.id}].publicExposure.frpc.localPort must match runtime.service.port`); - if (target.namespace !== delivery.gitops.application.destinationNamespace) throw new Error(`${sub2RankConfigLabel}.delivery.gitops.application.destinationNamespace must match the runtime target namespace`); + if (target.route !== secret.route || target.namespace !== secret.namespace) warnings.push(`targets[${target.id}] 的 route/namespace 与 Secret 目标 ${secret.targetId} 不一致`); + if (target.publicExposure.frpc.localPort !== runtime.service.port) warnings.push(`targets[${target.id}].publicExposure.frpc.localPort 与 runtime.service.port 不一致`); + if (target.namespace !== delivery.gitops.application.destinationNamespace) warnings.push(`targets[${target.id}].namespace 与 Argo destinationNamespace 不一致`); } + return warnings; } function positiveInteger(record: Record, key: string, path: string): number { diff --git a/scripts/src/platform-infra-sub2rank-pipeline.ts b/scripts/src/platform-infra-sub2rank-pipeline.ts index 91b99f57..222e7124 100644 --- a/scripts/src/platform-infra-sub2rank-pipeline.ts +++ b/scripts/src/platform-infra-sub2rank-pipeline.ts @@ -34,7 +34,6 @@ export function renderSub2RankDesiredPipeline(binding: PacSourceArtifactBinding) const target = resolveSub2RankTarget(config, binding.consumer.node); const expectedConfigRef = `${sub2RankConfigLabel}#delivery`; if (binding.consumer.sourceArtifact.configRef !== expectedConfigRef) throw new Error(`Sub2Rank sourceArtifact.configRef must equal ${expectedConfigRef}`); - assertBinding(config, target, binding); const manifestTemplate = renderSub2RankManifest(config, target, { imageRef: sub2RankImagePlaceholder, appConfigBase64: sub2RankConfigBase64Placeholder, @@ -95,8 +94,8 @@ function renderPipeline( apiVersion: "tekton.dev/v1", kind: "Pipeline", metadata: { - name: delivery.pipeline.name, - namespace: delivery.pipeline.namespace, + name: binding.consumer.pipeline, + namespace: binding.consumer.namespace, labels: { "app.kubernetes.io/name": "sub2rank", "app.kubernetes.io/part-of": "platform-infra", @@ -208,57 +207,6 @@ function proxyEnv(): Record[] { ]; } -function assertBinding(config: Sub2RankConfig, target: Sub2RankTarget, binding: PacSourceArtifactBinding): void { - const delivery = config.delivery; - const expected: Readonly> = { - node: target.id, - lane: "platform-infra", - namespace: delivery.pipeline.namespace, - pipeline: delivery.pipeline.name, - pipelineRunPrefix: delivery.pipeline.runPrefix, - service_account: delivery.pipeline.serviceAccountName, - source_branch: config.application.branch, - source_snapshot_prefix: delivery.pipeline.sourceSnapshotPrefix, - image_repository: config.image.repository, - gitops_branch: delivery.gitops.branch, - gitops_manifest_path: delivery.gitops.manifestPath, - pipeline_name: delivery.pipeline.name, - pipeline_run_prefix: delivery.pipeline.runPrefix, - pipeline_timeout: delivery.pipeline.timeout, - workspace_pvc_size: delivery.pipeline.workspaceSize, - }; - const observed: Readonly> = { - node: binding.consumer.node, - lane: binding.consumer.lane, - namespace: binding.consumer.namespace, - pipeline: binding.consumer.pipeline, - pipelineRunPrefix: binding.consumer.pipelineRunPrefix, - ...binding.repository.params, - }; - for (const [key, value] of Object.entries(expected)) { - if (observed[key] !== value) throw new Error(`Sub2Rank PaC binding ${key}=${String(observed[key])} does not match owning YAML ${value}`); - } - if (binding.consumer.deliveryProvenance?.executionServiceAccountName !== delivery.pipeline.serviceAccountName) { - throw new Error(`Sub2Rank PaC deliveryProvenance execution ServiceAccount does not match owning YAML ${delivery.pipeline.serviceAccountName}`); - } - const application = delivery.gitops.application; - if (binding.consumer.argoNamespace !== application.namespace) throw new Error(`Sub2Rank PaC argoNamespace does not match owning YAML ${application.namespace}`); - if (binding.consumer.argoApplication !== application.name) throw new Error(`Sub2Rank PaC argoApplication does not match owning YAML ${application.name}`); - const bootstrap = binding.consumer.argoBootstrap; - if (bootstrap === null) throw new Error("Sub2Rank PaC argoBootstrap must be declared"); - const expectedBootstrap = { - project: application.project, - repoUrl: delivery.gitops.readUrl, - targetRevision: delivery.gitops.branch, - path: application.path, - destinationNamespace: application.destinationNamespace, - automated: application.automated, - } as const; - for (const [key, value] of Object.entries(expectedBootstrap)) { - if (bootstrap[key as keyof typeof expectedBootstrap] !== value) throw new Error(`Sub2Rank PaC argoBootstrap.${key} does not match owning YAML ${String(value)}`); - } -} - function pipelineOwner(config: Sub2RankConfig, target: Sub2RankTarget): Record { return { application: { diff --git a/scripts/src/platform-infra-sub2rank.ts b/scripts/src/platform-infra-sub2rank.ts index 691e20d6..a94a8196 100644 --- a/scripts/src/platform-infra-sub2rank.ts +++ b/scripts/src/platform-infra-sub2rank.ts @@ -58,6 +58,7 @@ function plan(options: OpsCommonOptions): Record { ok: policy.every((item) => item.ok), action: "platform-infra-sub2rank-plan", mutation: false, + warnings: sub2rank.warnings, config: configSummary(sub2rank, target), policy, artifact: { @@ -306,11 +307,6 @@ function policyChecks(config: Sub2RankConfig, target: Sub2RankTarget, yaml: stri ok: !/^\s*kind:\s*(?:Namespace|NetworkPolicy)\s*$/mu.test(yaml) && config.runtime.sharedNetworkPolicyRef.managedBySub2Rank === false, detail: `Sub2Rank references ${target.namespace}/NetworkPolicy/${config.runtime.sharedNetworkPolicyRef.name} but does not apply or seize field ownership of shared runtime resources.`, }, - { - name: "deployment-owned-by-unidesk", - ok: !config.application.deploymentBlockPresent, - detail: `Deployment, image, Secret, FRP and Caddy facts belong only to ${sub2RankConfigLabel}; the application config must not contain a deployment block.`, - }, { name: "existing-secret-source-ref", ok: config.runtime.secret.requiredTargetKeys.every((key) => config.runtime.secret.mappings.some((mapping) => mapping.targetKey === key)), @@ -347,6 +343,7 @@ function configSummary(config: Sub2RankConfig, target: Sub2RankTarget): Record { return { + warnings: config.warnings, deployment: { path: sub2RankConfigLabel, kind: config.kind }, application: { path: config.application.configRef, @@ -356,6 +353,7 @@ function configRefsSummary(config: Sub2RankConfig): Record { sourceClean: config.application.sourceClean, sourceRemotePresent: config.application.sourceRemotePresent, sourceRemoteMatches: config.application.sourceRemoteMatches, + sourceConfigPathMatches: config.application.sourceConfigPathMatches, configMatchesSourceCommit: config.application.configMatchesSourceCommit, }, secret: { path: config.runtime.secret.configRef, declarationId: config.runtime.secret.declarationId }, From cebb6427f378349458a6884c2d84e1b1ccd0c60a Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 08:52:33 +0200 Subject: [PATCH 6/6] =?UTF-8?q?docs:=20=E8=B7=9F=E8=B8=AA=20Artificer=20?= =?UTF-8?q?=E6=98=93=E7=94=A8=E6=B4=BE=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/MDTODO/artificer-runtime-capabilities.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/MDTODO/artificer-runtime-capabilities.md b/docs/MDTODO/artificer-runtime-capabilities.md index 55f79ce9..26ea0507 100644 --- a/docs/MDTODO/artificer-runtime-capabilities.md +++ b/docs/MDTODO/artificer-runtime-capabilities.md @@ -74,3 +74,19 @@ ### R5.3 [completed] 自动上线后执行最短 Artificer canary,确认直接处理业务任务且没有创建第二 task,并记录 task/run/session 证据,完成任务后将详细报告写入[任务报告](./details/artificer-runtime-capabilities/R5.3_Task_Report.md)。 + +## R6 [in_progress] + +让 Artificer 派单成为 Target-aware 的单命令流程:主代理先用 MDTODO 跟踪,再通过 trans 在 YAML 选择的 Target 上执行 create/dispatch;CLI 只需 repo、ref 与 prompt 即自动渲染合法 workspaceRef、resourceBundleRef、session、projectId、bundle 继承和 SecretRef,提交前 dry-run 阻止必然失败任务;Artificer owning YAML prompt 与 unidesk-subagent skill 固化 Target/trans 及所有子代理先建 MDTODO 的规则,并以私有 pikainc/selfmedia 在 NC01 真实验收,完成任务后将详细报告写入[任务报告](./details/artificer-runtime-capabilities/R6_Task_Report.md)。 + +### R6.1 [in_progress] + +由原生子代理在最新远端基线的独立 worktree 调查并实现易用派单入口,覆盖 repo/ref/Target/trans、合法资源投影、提交前校验、私有仓库 YAML SecretRef 与 Artificer owning prompt、unidesk-subagent 固定规则;对应原生任务 artificer_dispatch_ux,完成任务后将详细报告写入[任务报告](./details/artificer-runtime-capabilities/R6.1_Task_Report.md)。 + +### R6.2 + +主代理审查跨仓改动并按依赖顺序合并 PR,只观察自动 CI/CD 收敛;不得人工触发 mirror、PipelineRun、apply 或 rollout,完成任务后将详细报告写入[任务报告](./details/artificer-runtime-capabilities/R6.2_Task_Report.md)。 + +### R6.3 + +上线后通过 trans 在 NC01 使用新单命令入口派发 pikainc/selfmedia 的 feat/r6-ocr-visual-planner,确认 task 直接 running、session 可续跑且不出现 schema-invalid 或 repository-not-found,完成任务后将详细报告写入[任务报告](./details/artificer-runtime-capabilities/R6.3_Task_Report.md)。