Merge remote-tracking branch 'origin/master' into docs/pj2026-02-terminal-spec
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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: sub2rank-nc01-tekton-runner
|
||||
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
|
||||
@@ -227,6 +259,45 @@ 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: sub2rank-nc01-tekton-runner
|
||||
gitOps:
|
||||
repoUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/unidesk.git
|
||||
targetRevision: unidesk-host-gitops
|
||||
runnerServiceAccount:
|
||||
name: sub2rank-nc01-tekton-runner
|
||||
automountServiceAccountToken: false
|
||||
roleBindingName: sub2rank-nc01-tekton-runner
|
||||
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
|
||||
|
||||
@@ -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: sub2rank-nc01-tekton-runner
|
||||
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
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -87,6 +87,10 @@ describe("AgentRun YAML-owned managed repository reconciler", () => {
|
||||
.split(/^---\s*$/mu)
|
||||
.map((item) => Bun.YAML.parse(item) as any);
|
||||
expect(documents.map((item) => item.kind)).toEqual([
|
||||
"Role",
|
||||
"RoleBinding",
|
||||
"Role",
|
||||
"RoleBinding",
|
||||
"Role",
|
||||
"RoleBinding",
|
||||
"ValidatingAdmissionPolicy",
|
||||
|
||||
@@ -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(10);
|
||||
expect(new Set(catalog.consumers.map((item) => item.consumerId)).size).toBe(10);
|
||||
for (const consumerId of ["agentrun-nc01-v02", "hwlab-nc01-v03", "sentinel-nc01-v03", "unidesk-host", "platform-infra-gitea-nc01", "platform-infra-sub2rank-nc01", "selfmedia-nc01"]) {
|
||||
const authority = resolveCicdDeliveryAuthority({ consumerId });
|
||||
expect(authority.kind).toBe("pac-pr-merge");
|
||||
expect(authority.mutationHintsAllowed).toBe(false);
|
||||
|
||||
@@ -80,12 +80,19 @@ test("owning YAML renders one child Application and the complete durable bridge
|
||||
"RoleBinding",
|
||||
"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", "selfmedia-ci"]);
|
||||
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");
|
||||
|
||||
@@ -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", () => {
|
||||
@@ -59,7 +68,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", "selfmedia-nc01"]);
|
||||
expect(contract.consumers.map((item) => item.id)).toEqual(["agentrun-nc01-v02", "platform-infra-sub2rank-nc01", "selfmedia-nc01"]);
|
||||
const items = documents(renderPacAdmissionDesiredFragment("NC01"));
|
||||
expect(items.map((item) => item.kind)).toEqual(["ValidatingAdmissionPolicy", "ValidatingAdmissionPolicyBinding"]);
|
||||
const policy = items[0];
|
||||
@@ -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(expressions).toContain("selfmedia-nc01-tekton-runner");
|
||||
expect(messages).toContain("execution ServiceAccount");
|
||||
expect(expressions).toContain(contract.child.parentUidAnnotation);
|
||||
@@ -114,14 +125,16 @@ 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.match(/selfmedia-nc01-tekton-runner/gu)).toHaveLength(2);
|
||||
expect(queueGuard.expression).not.toContain("admission-pac-v1:");
|
||||
expect(queueGuard.expression).not.toContain('== "Cancelled"');
|
||||
});
|
||||
|
||||
test("required consumer default RBAC has 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.filter((item) => item.kind === "Role").map((item) => item.metadata.namespace)).toEqual(["agentrun-ci", "selfmedia-ci"]);
|
||||
expect(rbac.map((item) => item.kind)).toEqual(["Role", "RoleBinding", "Role", "RoleBinding", "Role", "RoleBinding"]);
|
||||
expect(rbac.filter((item) => item.kind === "Role").map((item) => item.metadata.namespace)).toEqual(["agentrun-ci", "devops-infra", "selfmedia-ci"]);
|
||||
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);
|
||||
@@ -134,6 +147,8 @@ test("required consumer default RBAC has one automatic desired owner per namespa
|
||||
"RoleBinding",
|
||||
"Role",
|
||||
"RoleBinding",
|
||||
"Role",
|
||||
"RoleBinding",
|
||||
"ValidatingAdmissionPolicy",
|
||||
"ValidatingAdmissionPolicyBinding",
|
||||
"ServiceAccount",
|
||||
|
||||
@@ -106,6 +106,11 @@ export function parsePacAdmissionContract(source: Record<string, unknown>): 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");
|
||||
|
||||
@@ -198,6 +198,10 @@ describe("PaC source artifact CLI contract", () => {
|
||||
const sourceArtifact = consumers.find((item) => item.extends === template && item.variables?.NODE === "NC01")?.sourceArtifact;
|
||||
expect(sourceArtifact?.taskRunTemplate).toEqual({ hostNetwork: true, dnsPolicy: "ClusterFirstWithHostNet", fsGroup: 1000 });
|
||||
}
|
||||
for (const id of ["platform-infra-sub2rank-nc01", "selfmedia-nc01"]) {
|
||||
const sourceArtifact = consumers.find((item) => item.id === id)?.sourceArtifact;
|
||||
expect(sourceArtifact?.taskRunTemplate).toEqual({ hostNetwork: true, dnsPolicy: "ClusterFirstWithHostNet", fsGroup: 1000 });
|
||||
}
|
||||
});
|
||||
|
||||
test("undeclared JD01 source artifact owner fails closed", () => {
|
||||
|
||||
@@ -17,10 +17,11 @@ 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";
|
||||
import { renderSelfMediaDesiredPipeline, selfMediaSourceWorktreeRemote } from "./selfmedia-delivery-renderer";
|
||||
|
||||
export type PacSourceArtifactMode = "embedded-pipeline-spec" | "remote-pipeline-annotation";
|
||||
export type PacSourceArtifactRenderer = "agentrun-control-plane" | "hwlab-runtime-lane" | "selfmedia-runtime";
|
||||
export type PacSourceArtifactRenderer = "agentrun-control-plane" | "hwlab-runtime-lane" | "sub2rank-platform-service" | "selfmedia-runtime";
|
||||
export type PacSourceArtifactAction = "plan" | "check" | "write" | "status" | "verify-runtime";
|
||||
export type PacSourceArtifactRuntimeAlignment = "not-requested" | "aligned" | "drifted" | "missing" | "unavailable";
|
||||
|
||||
@@ -49,6 +50,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;
|
||||
@@ -444,7 +455,9 @@ function renderDesiredArtifact(binding: PacSourceArtifactBinding, sourceWorktree
|
||||
? renderAgentRunDesiredPipeline(binding)
|
||||
: sourceArtifact.renderer === "hwlab-runtime-lane"
|
||||
? renderHwlabDesiredPipeline(binding, sourceWorktree)
|
||||
: renderSelfMediaPipeline(binding, sourceWorktree);
|
||||
: sourceArtifact.renderer === "sub2rank-platform-service"
|
||||
? renderSub2RankDesiredPipeline(binding)
|
||||
: renderSelfMediaPipeline(binding, sourceWorktree);
|
||||
const pipeline = sourceArtifact.renderer === "hwlab-runtime-lane"
|
||||
? rendered.pipeline
|
||||
: withPipelineProvenanceAnnotations(rendered.pipeline, rendered.provenance);
|
||||
@@ -597,7 +610,14 @@ function embeddedPipelineRun(binding: PacSourceArtifactBinding, desiredSpec: Rec
|
||||
name: `${binding.consumer.pipelineRunPrefix}-{{ revision }}`,
|
||||
namespace: binding.consumer.namespace,
|
||||
annotations: pipelineRunAnnotations(binding, provenance),
|
||||
labels: pipelineRunLabels(binding, provenance.renderer === "selfmedia-runtime" ? "selfmedia" : "agentrun"),
|
||||
labels: pipelineRunLabels(
|
||||
binding,
|
||||
provenance.renderer === "sub2rank-platform-service"
|
||||
? "sub2rank"
|
||||
: provenance.renderer === "selfmedia-runtime"
|
||||
? "selfmedia"
|
||||
: "agentrun",
|
||||
),
|
||||
},
|
||||
spec: {
|
||||
...(binding.repository.params.pipeline_timeout === undefined ? {} : { timeouts: { pipeline: binding.repository.params.pipeline_timeout } }),
|
||||
@@ -664,7 +684,7 @@ function pipelineRunAnnotations(binding: PacSourceArtifactBinding, provenance: P
|
||||
};
|
||||
}
|
||||
|
||||
function pipelineRunLabels(binding: PacSourceArtifactBinding, partOf: "agentrun" | "hwlab" | "selfmedia"): Record<string, string> {
|
||||
function pipelineRunLabels(binding: PacSourceArtifactBinding, partOf: "agentrun" | "hwlab" | "sub2rank" | "selfmedia"): Record<string, string> {
|
||||
if (partOf === "agentrun") {
|
||||
return {
|
||||
"app.kubernetes.io/part-of": "agentrun",
|
||||
@@ -683,6 +703,15 @@ function pipelineRunLabels(binding: PacSourceArtifactBinding, partOf: "agentrun"
|
||||
"selfmedia.pikapython.com/trigger": "pipelines-as-code",
|
||||
};
|
||||
}
|
||||
if (partOf === "sub2rank") {
|
||||
return {
|
||||
"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",
|
||||
};
|
||||
}
|
||||
return {
|
||||
"app.kubernetes.io/name": `${binding.consumer.id}-pac`,
|
||||
"app.kubernetes.io/part-of": "hwlab",
|
||||
@@ -690,7 +719,7 @@ function pipelineRunLabels(binding: PacSourceArtifactBinding, partOf: "agentrun"
|
||||
"hwlab.pikastech.local/gitops-target": binding.consumer.lane,
|
||||
"hwlab.pikastech.local/source-commit": "{{ revision }}",
|
||||
"hwlab.pikastech.local/trigger": "pipelines-as-code",
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function taskRunTemplate(binding: PacSourceArtifactBinding): Record<string, unknown> {
|
||||
@@ -835,6 +864,7 @@ 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 (binding.consumer.sourceArtifact.renderer === "selfmedia-runtime") return selfMediaSourceWorktreeRemote(binding.consumer.node);
|
||||
if (!isHwlabRuntimeLane(binding.consumer.lane)) throw new PacSourceArtifactError("owning-source-lane-unresolved");
|
||||
return hwlabRuntimeLaneSpecForNode(binding.consumer.lane, binding.consumer.node).gitUrl;
|
||||
@@ -951,7 +981,10 @@ function assertPipelineIdentity(pipeline: Record<string, unknown>, binding: PacS
|
||||
function provenanceFromManifest(manifest: Record<string, unknown>): PacSourceArtifactProvenance | null {
|
||||
const provenance = pipelineProvenanceFromManifest(manifest);
|
||||
if (provenance === null) return null;
|
||||
if (provenance.renderer !== "agentrun-control-plane" && provenance.renderer !== "hwlab-runtime-lane" && provenance.renderer !== "selfmedia-runtime") return null;
|
||||
if (provenance.renderer !== "agentrun-control-plane"
|
||||
&& provenance.renderer !== "hwlab-runtime-lane"
|
||||
&& provenance.renderer !== "sub2rank-platform-service"
|
||||
&& provenance.renderer !== "selfmedia-runtime") return null;
|
||||
if (provenance.mode !== "embedded-pipeline-spec" && provenance.mode !== "remote-pipeline-annotation") return null;
|
||||
return provenance as PacSourceArtifactProvenance;
|
||||
}
|
||||
|
||||
@@ -280,6 +280,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,
|
||||
@@ -564,7 +567,7 @@ function parseConsumerDeliveryProvenance(value: Record<string, unknown>, path: s
|
||||
|
||||
function parseSourceArtifact(value: Record<string, unknown>, 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", "selfmedia-runtime"] as const);
|
||||
const renderer = y.enumField(value, "renderer", path, ["agentrun-control-plane", "hwlab-runtime-lane", "sub2rank-platform-service", "selfmedia-runtime"] 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);
|
||||
@@ -572,6 +575,7 @@ function parseSourceArtifact(value: Record<string, unknown>, 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`);
|
||||
if (renderer === "selfmedia-runtime" && mode !== "embedded-pipeline-spec") throw new Error(`${path}.renderer selfmedia-runtime requires embedded-pipeline-spec`);
|
||||
return {
|
||||
mode,
|
||||
@@ -783,6 +787,7 @@ function validateConfig(config: PacConfig): void {
|
||||
}
|
||||
const consumerIds = new Set<string>();
|
||||
const markerValues = new Set<string>();
|
||||
const reservedServiceAccounts = new Set<string>();
|
||||
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);
|
||||
@@ -792,6 +797,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`);
|
||||
}
|
||||
@@ -804,6 +817,9 @@ function validateConfig(config: PacConfig): void {
|
||||
throw new Error(`${configLabel}.consumers.${consumer.id}.runnerServiceAccount.name must match deliveryProvenance.executionServiceAccountName`);
|
||||
}
|
||||
}
|
||||
if (consumer.sourceArtifact?.renderer === "sub2rank-platform-service" && consumer.runnerServiceAccount === null) {
|
||||
throw new Error(`${configLabel}.consumers.${consumer.id}.runnerServiceAccount is required for sub2rank-platform-service`);
|
||||
}
|
||||
if (consumer.sourceArtifact?.renderer === "selfmedia-runtime") {
|
||||
if (consumer.runnerServiceAccount === null) throw new Error(`${configLabel}.consumers.${consumer.id}.runnerServiceAccount is required for selfmedia-runtime`);
|
||||
if (consumer.argoBootstrap?.repositoryCredential === null || consumer.argoBootstrap?.repositoryCredential === undefined) {
|
||||
|
||||
@@ -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";
|
||||
@@ -13,23 +13,66 @@ 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: {
|
||||
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;
|
||||
sourceConfigPathMatches: 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: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
targets: Sub2RankTarget[];
|
||||
runtime: {
|
||||
sharedNetworkPolicyRef: { name: string; managedBySub2Rank: false };
|
||||
@@ -82,18 +125,21 @@ 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);
|
||||
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"),
|
||||
@@ -102,6 +148,7 @@ export function readSub2RankConfig(): Sub2RankConfig {
|
||||
defaults,
|
||||
application,
|
||||
image,
|
||||
delivery,
|
||||
targets,
|
||||
runtime: { ...runtimeBase, secret: { ...runtimeBase.secret, ...secret } },
|
||||
};
|
||||
@@ -116,10 +163,16 @@ export function resolveSub2RankTarget(config: Sub2RankConfig, targetId: string |
|
||||
}
|
||||
|
||||
function parseApplication(record: Record<string, unknown>): 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 +193,25 @@ function parseApplication(record: Record<string, unknown>): 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("\\", "/");
|
||||
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),
|
||||
sourceConfigPathMatches: configRelativePath === sourceConfigPath,
|
||||
configMatchesSourceCommit: committedConfig === configText.trimEnd(),
|
||||
deploymentBlockPresent: app.deployment !== undefined,
|
||||
automaticCreditEnabled,
|
||||
@@ -165,11 +225,65 @@ function parseApplication(record: Record<string, unknown>): Sub2RankConfig["appl
|
||||
|
||||
function parseImage(record: Record<string, unknown>): 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<string, unknown>): 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");
|
||||
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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseRuntime(record: Record<string, unknown>): Omit<Sub2RankConfig["runtime"], "secret"> & {
|
||||
@@ -328,24 +442,36 @@ function resolveSecretDeclaration(configRef: string, declarationId: string): Res
|
||||
function validateResolvedConfig(
|
||||
application: Sub2RankConfig["application"],
|
||||
image: Sub2RankConfig["image"],
|
||||
delivery: Sub2RankConfig["delivery"],
|
||||
runtime: ReturnType<typeof parseRuntime>,
|
||||
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}`);
|
||||
): 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/")) 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.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<string, unknown>, key: string, path: string): number {
|
||||
@@ -403,6 +529,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`);
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
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<string, unknown>;
|
||||
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}`);
|
||||
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<string, unknown> {
|
||||
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<string, unknown>[], runAfter: readonly string[] = []): Record<string, unknown> => ({
|
||||
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: binding.consumer.pipeline,
|
||||
namespace: binding.consumer.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<string, unknown>[] {
|
||||
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 pipelineOwner(config: Sub2RankConfig, target: Sub2RankTarget): Record<string, unknown> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
export async function runSub2RankCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown>> {
|
||||
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,113 +52,68 @@ export async function runSub2RankCommand(config: UniDeskConfig, args: string[]):
|
||||
function plan(options: OpsCommonOptions): Record<string, unknown> {
|
||||
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),
|
||||
action: "platform-infra-sub2rank-plan",
|
||||
mutation: false,
|
||||
warnings: sub2rank.warnings,
|
||||
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<Record<string, unknown>> {
|
||||
async function publicExposure(config: UniDeskConfig, options: OpsApplyOptions): Promise<Record<string, unknown>> {
|
||||
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 +158,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 +183,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 +242,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}
|
||||
@@ -340,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)),
|
||||
@@ -357,7 +319,13 @@ function configSummary(config: Sub2RankConfig, target: Sub2RankTarget): Record<s
|
||||
return {
|
||||
configRefs: configRefsSummary(config),
|
||||
target: targetSummary(target),
|
||||
image: `${config.image.repository}:${config.image.tag}`,
|
||||
image: { repository: config.image.repository, pullPolicy: config.image.pullPolicy, authority: "PaC digest pin" },
|
||||
delivery: {
|
||||
pipeline: config.delivery.pipeline.name,
|
||||
gitopsBranch: config.delivery.gitops.branch,
|
||||
gitopsManifestPath: config.delivery.gitops.manifestPath,
|
||||
argoApplication: config.delivery.gitops.application.name,
|
||||
},
|
||||
runtime: {
|
||||
service: `${config.runtime.service.name}.${target.namespace}.svc.cluster.local:${config.runtime.service.port}`,
|
||||
storage: { claimName: config.runtime.storage.claimName, size: config.runtime.storage.size, mountPath: config.runtime.storage.mountPath },
|
||||
@@ -375,6 +343,7 @@ function configSummary(config: Sub2RankConfig, target: Sub2RankTarget): Record<s
|
||||
|
||||
function configRefsSummary(config: Sub2RankConfig): Record<string, unknown> {
|
||||
return {
|
||||
warnings: config.warnings,
|
||||
deployment: { path: sub2RankConfigLabel, kind: config.kind },
|
||||
application: {
|
||||
path: config.application.configRef,
|
||||
@@ -383,6 +352,8 @@ function configRefsSummary(config: Sub2RankConfig): Record<string, unknown> {
|
||||
sourceCommit: config.application.sourceCommit,
|
||||
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 },
|
||||
@@ -421,9 +392,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<string, unknown> {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user