diff --git a/CI.json b/CI.json new file mode 100644 index 00000000..a2b87c40 --- /dev/null +++ b/CI.json @@ -0,0 +1,80 @@ +{ + "schemaVersion": 1, + "kind": "ci-artifact-catalog", + "purpose": "CI artifact catalog only. This file describes build inputs and image artifact naming; it does not describe runtime topology and does not replace deploy.json.", + "summaryContract": { + "requiredOnSuccess": [ + "serviceId", + "sourceCommit", + "sourceRepo", + "dockerfile", + "imageRef", + "tag", + "digest", + "digestRef" + ], + "fieldSemantics": { + "serviceId": "Stable UniDesk service id for the artifact.", + "sourceCommit": "Full 40-character Git commit used as the source and tag.", + "sourceRepo": "Git repository URL used to materialize the source.", + "dockerfile": "Repo-relative Dockerfile path used by CI.", + "imageRef": "Commit-tagged image reference pushed by CI.", + "tag": "Commit-pinned image tag; mutable tags such as latest are not valid.", + "digest": "Registry manifest digest for the pushed image.", + "digestRef": "Immutable image reference in repository@digest form." + } + }, + "defaults": { + "producer": "D601 Tekton CI", + "registry": "127.0.0.1:5000", + "tag": "{{sourceCommit}}", + "mutableTagsAllowed": false, + "runtimeFieldsForbidden": [ + "providerId", + "namespace", + "ports", + "composeService", + "kubernetesService", + "healthPath", + "replicas", + "env", + "volumes" + ] + }, + "artifacts": [ + { + "serviceId": "baidu-netdisk", + "sourceRepo": "https://github.com/pikasTech/unidesk", + "dockerfile": "src/components/microservices/baidu-netdisk/Dockerfile", + "imageRepository": "unidesk/baidu-netdisk", + "imageRef": "127.0.0.1:5000/unidesk/baidu-netdisk:{{sourceCommit}}", + "digestRef": "127.0.0.1:5000/unidesk/baidu-netdisk@{{digest}}" + }, + { + "serviceId": "decision-center", + "sourceRepo": "https://github.com/pikasTech/unidesk", + "dockerfile": "src/components/microservices/decision-center/Dockerfile", + "imageRepository": "unidesk/decision-center", + "imageRef": "127.0.0.1:5000/unidesk/decision-center:{{sourceCommit}}", + "digestRef": "127.0.0.1:5000/unidesk/decision-center@{{digest}}", + "publishCommand": "bun scripts/cli.ts ci publish-user-service --service decision-center --commit " + }, + { + "serviceId": "frontend", + "sourceRepo": "https://github.com/pikasTech/unidesk", + "dockerfile": "src/components/frontend/Dockerfile", + "imageRepository": "unidesk/frontend", + "imageRef": "127.0.0.1:5000/unidesk/frontend:{{sourceCommit}}", + "digestRef": "127.0.0.1:5000/unidesk/frontend@{{digest}}" + }, + { + "serviceId": "backend-core", + "sourceRepo": "https://github.com/pikasTech/unidesk", + "dockerfile": "src/components/backend-core/Dockerfile", + "imageRepository": "unidesk/backend-core", + "imageRef": "127.0.0.1:5000/unidesk/backend-core:{{sourceCommit}}", + "digestRef": "127.0.0.1:5000/unidesk/backend-core@{{digest}}", + "publishCommand": "bun scripts/cli.ts ci publish-backend-core --commit " + } + ] +} diff --git a/docs/reference/artifact-registry.md b/docs/reference/artifact-registry.md index 9be6ef8b..88d8f6b1 100644 --- a/docs/reference/artifact-registry.md +++ b/docs/reference/artifact-registry.md @@ -6,6 +6,8 @@ backend-core 和 reviewed user services 的长期分工是:CI 在 D601 构建 Production CI/CD runtime pinning and release-line boundaries follow `docs/reference/release-governance.md` and [GitHub issue #6](https://github.com/pikasTech/unidesk/issues/6). The registry may cache commit-pinned artifacts, but it must not become a floating replacement for `deploy.json`, `release/v1`, or `master` source history. +The CI-side artifact catalog is root `CI.json`. That file describes only artifact producer inputs and naming; registry consumers still verify the real image labels, manifest digest and live runtime separately. The producer summary contract is owned by `docs/reference/ci.md` and includes `serviceId`, `sourceCommit`, `sourceRepo`, `dockerfile`, `imageRef`, `tag`, `digest` and `digestRef`. + ## Architecture registry 运行在 D601 host/WSL OS 上,由 systemd 管理 Docker Compose 项目: @@ -111,7 +113,7 @@ docker compose -p unidesk-artifact-registry -f /home/ubuntu/.unidesk/artifact-re 1. D601 artifact registry 已安装并通过 `health`。 2. D601 CI 从 pushed Git checkout 构建 `unidesk/:`。 -3. CI 将镜像 push 到 `127.0.0.1:5000/unidesk/:`,并记录 image ref 与 digest。 +3. CI 将镜像 push 到 `127.0.0.1:5000/unidesk/:`,并记录完整 artifact summary,包括 service id、source repo、source commit、Dockerfile、image ref、tag、digest 和 digest ref。 4. CD 在运行目标上确认目标 commit/tag/digest 存在。 5. Compose runtime 通过 provider-gateway Host SSH 从 D601 registry 流式读取 commit-pinned 镜像 tar,不开放 registry 端口,也不使用第三方镜像托管。 6. Compose runtime retag 为 Compose 使用的镜像名,并执行 `docker compose up -d --no-build --no-deps --force-recreate `。 diff --git a/docs/reference/ci.md b/docs/reference/ci.md index 96294ea6..fa24ab6d 100644 --- a/docs/reference/ci.md +++ b/docs/reference/ci.md @@ -8,6 +8,7 @@ UniDesk CI is hosted on the D601 native k3s cluster with Tekton Pipelines and Te - Tekton Triggers: `v0.34.0`. - UniDesk CI namespace: `unidesk-ci`. - Manifests: `src/components/microservices/k3sctl-adapter/k3s/ci/`. +- Artifact catalog: root `CI.json`, which is CI artifact catalog only. It describes build inputs, image naming and summary fields; runtime topology, rollout target, ports, namespaces and desired service commits remain in `config.json`, service manifests and `deploy.json`. - CLI entry: `bun scripts/cli.ts ci install|status|run|publish-backend-core|publish-user-service|run-dev-e2e|logs`. - Dev namespace e2e runner: `bun scripts/cli.ts ci run-dev-e2e`; authoritative runner path, manifest contract and safety boundary are in `docs/reference/dev-ci-runner.md`. - Rust backend-core check/build boundary: CI may run `UNIDESK_D601_RUST_CHECK=1 bun scripts/cli.ts check --full --rust` on D601; the master server must not compile Rust for backend-core iteration. The authoritative dev environment rule is `docs/reference/dev-environment.md`. @@ -32,6 +33,23 @@ Git clone and dependency downloads inside the repo check task use `d601-provider Private repository source authentication is part of the CI contract and follows `docs/reference/devops-hygiene.md`. If the repo-check task fails at `git clone` because credentials are unavailable, treat it as a CI infrastructure/auth gap, not as an application test result. +## Artifact Catalog And Summary Contract + +`CI.json` is the reusable CI artifact catalog. It must remain artifact-only: `serviceId`, `sourceRepo`, `dockerfile`, registry repository naming, tag policy and summary-field semantics are allowed; provider ids, runtime namespaces, ports, compose services, Kubernetes Services, health paths, env, volumes and desired deploy commits are not allowed. `deploy.json` remains the version intent for deployments and must not be replaced by `CI.json`. + +Every successful image-producing CI task must expose a common `artifactSummary` contract: + +- `serviceId`: stable UniDesk service id. +- `sourceCommit`: full 40-character Git commit used as source and tag. +- `sourceRepo`: Git repository URL used to materialize the source. +- `dockerfile`: repo-relative Dockerfile path. +- `imageRef`: commit-tagged image reference pushed by CI. +- `tag`: commit-pinned image tag; mutable `latest` is invalid. +- `digest`: registry manifest digest for the pushed image. +- `digestRef`: immutable `repository@digest` image reference. + +Tekton artifact tasks write these values as TaskRun results and also print the legacy `*_artifact_*` log lines for operator diagnostics. The CLI must read TaskRun results first, fall back to pod logs only for older runs, derive `imageRef`/`digestRef` from repository, tag and digest where possible, and report exact missing fields such as `digest` or `digestRef`. It must not turn a succeeded PipelineRun into a generic `incomplete` failure. + ## CI/CD Runtime Governance CI/CD server and control-plane runtime is production-like infrastructure. Its service version must be pinned by `deploy.json` and verified through runtime commit metadata; it must not float with the latest `master` just because the operator's CLI is newer. @@ -72,7 +90,7 @@ The CI artifact task must follow these rules: - The image is tagged with the source commit, for example `unidesk/backend-core:`, and pushed to the D601 artifact registry as `127.0.0.1:5000/unidesk/backend-core:`. - The image must carry at least `unidesk.ai/service-id=backend-core`, `unidesk.ai/source-repo`, `unidesk.ai/source-commit` and `unidesk.ai/dockerfile=src/components/backend-core/Dockerfile`. - Publication must fail if the D601 artifact registry is not healthy. It must not fall back to a third-party registry or a mutable `latest` tag. -- CI may output the image ref and digest as deployment input, but it must not restart production Compose services, call production `deploy apply`, mutate production namespaces, or change `deploy.json`. +- CI output must include the common `artifactSummary` fields defined above. `artifactSummary.imageRef` and `artifactSummary.digestRef` are deployment inputs for later CD, but CI must not restart production Compose services, call production `deploy apply`, mutate production namespaces, or change `deploy.json`. The artifact registry contract and CD consumption path are defined in `docs/reference/artifact-registry.md`. CI is the producer of the backend-core image artifact; CD is only the consumer. @@ -86,7 +104,7 @@ The CI user-service artifact task must follow these rules: - D601 prepares a commit-pinned source export under `/home/ubuntu/.unidesk/ci/user-service-artifacts//` using the existing GitHub SSH deploy identity and node-local provider-gateway WS egress proxy. Tekton consumes that export through a read-only hostPath. - The image is tagged only with the source commit and pushed to the D601 registry as `127.0.0.1:5000/unidesk/:`. The producer must reject third-party registries and must not publish or consume a mutable `latest` tag. - The image must carry `unidesk.ai/service-id`, `unidesk.ai/source-repo`, `unidesk.ai/source-commit` and `unidesk.ai/dockerfile` labels. -- The command output must include the image ref, tag, digest, source commit and service id. The digest ref is suitable as immutable input for later dev/prod deployment work. +- The command output must include the common `artifactSummary` fields: `serviceId`, `sourceCommit`, `sourceRepo`, `dockerfile`, `imageRef`, `tag`, `digest` and `digestRef`. The digest ref is suitable as immutable input for later dev/prod deployment work. - CI is an artifact producer only. It must not restart production services, call production `deploy apply`, mutate the production namespace, or change `deploy.json`. Publish a Baidu Netdisk artifact: diff --git a/docs/reference/cli.md b/docs/reference/cli.md index ff7ddb0d..14c9103c 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -29,7 +29,7 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI - `dev-env validate [--manifest path] [--kubectl-dry-run]` 离线校验 D601 `unidesk-dev` namespace、dev PostgreSQL 底座和 dev workload manifest。默认检查 `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-foundation.k8s.yaml`;也可显式校验 `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-core.k8s.yaml` 或 `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-code-queue.k8s.yaml`。所有 namespaced 对象必须只落到 `unidesk-dev`,foundation manifest 必须包含 `postgres-dev` StatefulSet/Service、dev secret/config、迁移 Job 和 DB URL guard,core manifest 必须包含 `backend-core-dev`/`frontend-dev` Deployment/Service,Code Queue dev manifest 必须包含 `code-queue-scheduler-dev`、`code-queue-read-dev`、`code-queue-write-dev` 和 dev provider egress proxy。加 `--kubectl-dry-run` 时额外执行 `kubectl apply --dry-run=client --validate=false -f `,仍不 apply 资源。 - `dev-env prewarm-images [--image image] [--provider-id D601] [--no-pull] [--proxy-url URL] [--pull-timeout-ms N] [--dry-run]` 创建异步 job,通过 UniDesk SSH 维护桥在 D601 上把开发底座依赖镜像从 Docker 缓存导入原生 k3s containerd。默认镜像是 `postgres:16-alpine` 和 `rancher/mirrored-library-busybox:1.36.1`,用于避免 `postgres-dev` 与 local-path helper pod 卡在外部 registry 拉取。该命令固定验证 `/etc/rancher/k3s/k3s.yaml` 指向的 native k3s 上下文,并输出 `dev_env_containerd_image_ready=...` 作为成功判据;它不 apply manifest、不修改生产 `unidesk` namespace。 - `artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service` 管理 D601 host-managed CNCF Distribution registry 的声明、安装、只读检查和 pull-only artifact CD。该 registry 固定为 D601 loopback `127.0.0.1:5000`,由 systemd + Docker Compose 管理,位于 native k3s 故障域外;`deploy-backend-core` 和 `deploy-service` 只拉取 CI 已发布的 commit-pinned 镜像、retag/recreate 或导入 native k3s,并做 live commit 验证,不构建 runtime source。长期规则见 `docs/reference/artifact-registry.md`。 -- `ci install|status|run|publish-backend-core|publish-user-service|run-dev-e2e|logs` 管理 D601 原生 k3s 上的 Tekton CI。`run` 手动创建每 commit 检查和 Code Queue 只读性能门禁;`publish-backend-core` 与 `publish-user-service` 从 pushed Git commit 构建并发布 `127.0.0.1:5000/unidesk/:`,但不部署生产;`run-dev-e2e` 的 Git 控制 runner、短 launcher、host fetch 边界、临时 smoke namespace 和 no-CD 规则只在 `docs/reference/dev-ci-runner.md` 定义;Tekton CI 通用规则见 `docs/reference/ci.md`。 +- `ci install|status|run|publish-backend-core|publish-user-service|run-dev-e2e|logs` 管理 D601 原生 k3s 上的 Tekton CI。`run` 手动创建每 commit 检查和 Code Queue 只读性能门禁;`publish-backend-core` 与 `publish-user-service` 从 pushed Git commit 构建并发布 `127.0.0.1:5000/unidesk/:` commit-pinned artifacts,输出 `artifactSummary`(含 `serviceId`、`sourceCommit`、`sourceRepo`、`dockerfile`、`imageRef`、`tag`、`digest`、`digestRef`),但不部署生产;`run-dev-e2e` 的 Git 控制 runner、短 launcher、host fetch 边界、临时 smoke namespace 和 no-CD 规则只在 `docs/reference/dev-ci-runner.md` 定义;Tekton CI 通用规则见 `docs/reference/ci.md`。 - `codex deploy ` 是旧 Code Queue 兼容部署入口,已禁用以防止维护通道直连 D601 部署 Code Queue;当前 dev 自动化只做 `ci run-dev-e2e` smoke,不提供 Code Queue CD,详细规则见 `docs/reference/codex-deploy.md`。 - `codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue queueId] [--provider-id id] [--cwd path] [--model model] [--reasoning-effort effort] [--execution-mode mode] [--max-attempts N] [--reference-task-id id] [--dry-run]` 通过 backend-core 私有代理向稳定 `code-queue` 用户服务路径提交任务;prompt 必须且只能来自位置参数、文件或 stdin 之一,`--dry-run` 只返回结构化请求且不实际入队。提交确认和 dry-run 必须返回完整 prompt、字符数和 `truncated=false`,不能套用任务详情的预览截断策略,否则长任务 prompt 无法被人工验收。backend-core 默认把提交、队列 CRUD、已读状态、历史摘要和轻量 Trace 读取分流到主 server `code-queue-mgr`,由它写入主 PostgreSQL;D601 scheduler 只轮询并执行已入库任务。 - `codex task ` 通过 Code Queue 私有代理按任务 ID 查询结构化执行摘要;默认只返回有界 prompt/response 预览、执行 Provider、工作目录、最后 assistant message、最近工具调用摘要、attempt、judge、错误、耗时和 trace 翻页提示,适合在新队列任务中引用历史 session 且避免噪声爆炸。该摘要读取默认由主 server `code-queue-mgr` 从 PostgreSQL 返回,不依赖 D601 `code-queue-read` Service 可用。 diff --git a/docs/reference/user-service-delivery.md b/docs/reference/user-service-delivery.md index d65a82a7..983b4a90 100644 --- a/docs/reference/user-service-delivery.md +++ b/docs/reference/user-service-delivery.md @@ -32,7 +32,8 @@ The default release flow for a user-service change is: - No user-service artifact may rely on a third-party registry as source of truth. - No production deploy may rebuild the source from a dirty worktree. - Commit-pinned image tags are the deployment truth; mutable `latest` tags are not. -- The standard CI artifact producer is `bun scripts/cli.ts ci publish-user-service --service --commit `. It accepts only a pushed Git commit and a registered service id, and reports image ref, tag, digest, source commit and service id. +- Root `CI.json` is an artifact catalog only. It can list user-service CI build inputs such as `serviceId`, `sourceRepo`, `dockerfile`, image repository naming and the required artifact summary fields; it must not carry runtime topology or replace `deploy.json`. +- The standard CI artifact producer is `bun scripts/cli.ts ci publish-user-service --service --commit `. It accepts only a pushed Git commit and a registered service id, and reports `serviceId`, `sourceCommit`, `sourceRepo`, `dockerfile`, `imageRef`, `tag`, `digest` and `digestRef`. - The CI artifact producer is not a deploy executor. It must not mutate the production namespace, restart production services, or update `deploy.json`. - Every production release must finish with a manual acceptance step after the automated checks pass. diff --git a/scripts/src/ci.ts b/scripts/src/ci.ts index cadc7499..5eafb22b 100644 --- a/scripts/src/ci.ts +++ b/scripts/src/ci.ts @@ -41,6 +41,7 @@ interface CiPublishBackendCoreOptions { commit: string; waitMs: number; sourceHostPath: string; + dryRun: boolean; } interface CiPublishUserServiceArtifactOptions { @@ -116,6 +117,13 @@ interface ArtifactSummary { digestRef: string | null; } +interface ArtifactSummaryContext { + serviceId: string; + commit: string; + repoUrl: string; + dockerfile: string; +} + function stringOption(args: string[], name: string): string | null { const index = args.indexOf(name); if (index === -1) return null; @@ -863,34 +871,112 @@ function pipelineRunWaitSucceeded(wait: DispatchResult | null, condition: Pipeli return wait.ok || wait.exitCode === 0 || wait.stdout.includes("condition=True\tSucceeded\t"); } -function parseArtifactSummaryFromOutput(output: string, serviceId: string, commit: string, repoUrl: string, dockerfile: string): ArtifactSummary { - const fields = new Map(); - for (const line of output.split(/\r?\n/u)) { - const match = /^(user_service_artifact_[a-z_]+|backend_core_artifact_[a-z_]+)=(.*)$/u.exec(line.trim()); - if (match !== null) fields.set(match[1], match[2]); - } - const digest = fields.get("user_service_artifact_digest") ?? fields.get("backend_core_artifact_digest") ?? null; - const imageRef = fields.get("user_service_artifact_image") ?? fields.get("backend_core_artifact_image") ?? `127.0.0.1:5000/unidesk/${serviceId}:${commit}`; - const repository = fields.get("user_service_artifact_repository") ?? `127.0.0.1:5000/unidesk/${serviceId}`; - const digestRef = fields.get("user_service_artifact_digest_ref") ?? fields.get("backend_core_artifact_digest_ref") ?? (digest === null || digest.length === 0 ? null : `${repository}@${digest}`); +function artifactSummaryDefaults(context: ArtifactSummaryContext): ArtifactSummary { + const registry = "127.0.0.1:5000"; + const repository = `${registry}/unidesk/${context.serviceId}`; return { - serviceId: fields.get("user_service_artifact_service_id") ?? serviceId, - sourceCommit: fields.get("user_service_artifact_source_commit") ?? fields.get("backend_core_artifact_source_commit") ?? commit, - sourceRepo: fields.get("user_service_artifact_source_repo") ?? repoUrl, - dockerfile: fields.get("user_service_artifact_dockerfile") ?? dockerfile, - registry: fields.get("user_service_artifact_registry") ?? "127.0.0.1:5000", + serviceId: context.serviceId, + sourceCommit: context.commit, + sourceRepo: context.repoUrl, + dockerfile: context.dockerfile, + registry, repository, - tag: fields.get("user_service_artifact_tag") ?? commit, + tag: context.commit, + imageRef: `${repository}:${context.commit}`, + digest: null, + digestRef: null, + }; +} + +function artifactSummaryField(fields: Map, suffix: string): string | null { + const value = fields.get(`user_service_artifact_${suffix}`) ?? fields.get(`backend_core_artifact_${suffix}`) ?? null; + return value === null || value.length === 0 ? null : value; +} + +function parseArtifactSummaryFromFields(fields: Map, context: ArtifactSummaryContext): ArtifactSummary { + const planned = artifactSummaryDefaults(context); + const registry = artifactSummaryField(fields, "registry") ?? planned.registry; + const repository = artifactSummaryField(fields, "repository") ?? `${registry}/unidesk/${context.serviceId}`; + const tag = artifactSummaryField(fields, "tag") ?? planned.tag; + const imageRef = artifactSummaryField(fields, "image") ?? (repository.length > 0 && tag.length > 0 ? `${repository}:${tag}` : planned.imageRef); + const digest = artifactSummaryField(fields, "digest"); + const digestRef = artifactSummaryField(fields, "digest_ref") ?? (digest === null || digest.length === 0 || repository.length === 0 ? null : `${repository}@${digest}`); + return { + serviceId: artifactSummaryField(fields, "service_id") ?? planned.serviceId, + sourceCommit: artifactSummaryField(fields, "source_commit") ?? planned.sourceCommit, + sourceRepo: artifactSummaryField(fields, "source_repo") ?? planned.sourceRepo, + dockerfile: artifactSummaryField(fields, "dockerfile") ?? planned.dockerfile, + registry, + repository, + tag, imageRef, digest, digestRef, }; } -function assertArtifactSummaryComplete(artifact: ArtifactSummary, pipelineRun: string): void { - if (artifact.serviceId.length === 0 || artifact.sourceCommit.length !== 40 || artifact.imageRef.length === 0 || artifact.tag.length === 0 || artifact.digest === null || artifact.digest.length === 0 || artifact.digestRef === null || artifact.digestRef.length === 0) { - throw new Error(`artifact summary for ${pipelineRun} is incomplete`); +function parseArtifactSummaryFromOutput(output: string, context: ArtifactSummaryContext): ArtifactSummary { + const fields = new Map(); + for (const line of output.split(/\r?\n/u)) { + const match = /^(user_service_artifact_[a-z_]+|backend_core_artifact_[a-z_]+)=(.*)$/u.exec(line.trim()); + if (match !== null) fields.set(match[1], match[2]); } + return parseArtifactSummaryFromFields(fields, context); +} + +function missingArtifactSummaryFields(artifact: ArtifactSummary): string[] { + const missing: string[] = []; + if (artifact.serviceId.length === 0) missing.push("serviceId"); + if (!/^[0-9a-f]{40}$/u.test(artifact.sourceCommit)) missing.push("sourceCommit"); + if (artifact.sourceRepo.length === 0) missing.push("sourceRepo"); + if (artifact.dockerfile.length === 0) missing.push("dockerfile"); + if (artifact.imageRef.length === 0) missing.push("imageRef"); + if (artifact.tag.length === 0) missing.push("tag"); + if (artifact.digest === null || artifact.digest.length === 0) missing.push("digest"); + if (artifact.digestRef === null || artifact.digestRef.length === 0) missing.push("digestRef"); + return missing; +} + +function assertArtifactSummaryComplete(artifact: ArtifactSummary, pipelineRun: string): void { + const missing = missingArtifactSummaryFields(artifact); + if (missing.length > 0) { + throw new Error(`artifact summary for ${pipelineRun} is missing required field(s): ${missing.join(", ")}`); + } +} + +async function readArtifactSummaryFromPipelineRun(name: string, context: ArtifactSummaryContext): Promise { + const result = await runRemoteKubectlRaw([ + "set -euo pipefail", + `kubectl get taskrun -n unidesk-ci -l tekton.dev/pipelineRun=${shellQuote(name)} -o json`, + ].join("\n"), 60_000, 45_000); + if (result.ok && result.stdout.trim().length > 0) { + try { + const parsed = JSON.parse(result.stdout) as unknown; + const fields = new Map(); + const list = asRecord(parsed); + const items = Array.isArray(list?.items) ? list.items : []; + for (const item of items) { + const taskRun = asRecord(item); + const status = asRecord(taskRun?.status); + const results = Array.isArray(status?.results) ? status.results : []; + for (const rawResult of results) { + const taskResult = asRecord(rawResult); + const nameValue = asString(taskResult?.name); + const value = asString(taskResult?.value); + if (/^(user_service_artifact_[a-z_]+|backend_core_artifact_[a-z_]+)$/u.test(nameValue) && value.length > 0) { + fields.set(nameValue, value); + } + } + } + if (fields.size > 0) { + const fromResults = parseArtifactSummaryFromFields(fields, context); + if (missingArtifactSummaryFields(fromResults).length === 0) return fromResults; + } + } catch { + // Fall back to pod logs below; a malformed diagnostic line must not mask a succeeded PipelineRun. + } + } + return parseArtifactSummaryFromOutput(await readPipelineRunLogText(name), context); } async function readPipelineRunLogText(name: string): Promise { @@ -931,25 +1017,47 @@ async function run(options: CiOptions): Promise> { } async function publishBackendCoreArtifact(config: UniDeskConfig, options: CiPublishBackendCoreOptions): Promise> { - const plannedArtifact: ArtifactSummary = { + const summaryContext: ArtifactSummaryContext = { serviceId: "backend-core", - sourceCommit: options.commit, - sourceRepo: options.repoUrl, dockerfile: "src/components/backend-core/Dockerfile", - registry: "127.0.0.1:5000", - repository: "127.0.0.1:5000/unidesk/backend-core", - tag: options.commit, - imageRef: `127.0.0.1:5000/unidesk/backend-core:${options.commit}`, - digest: null, - digestRef: null, + commit: options.commit, + repoUrl: options.repoUrl, }; + const plannedArtifact = artifactSummaryDefaults(summaryContext); + if (options.dryRun) { + return { + ok: true, + mode: "dry-run", + pipeline: "unidesk-backend-core-artifact-publish", + namespace: "unidesk-ci", + repoUrl: options.repoUrl, + commit: options.commit, + sourceHostPath: options.sourceHostPath, + source: { + ok: true, + mode: "planned-only", + providerId: d601ProviderId, + repoUrl: options.repoUrl, + repoFetchUrl: repoSshUrl(options.repoUrl), + commit: options.commit, + dockerfile: "src/components/backend-core/Dockerfile", + sourceHostPath: options.sourceHostPath, + }, + artifact: plannedArtifact.imageRef, + artifactSummary: plannedArtifact, + boundary: "dry-run only; no D601 source export, no Tekton submission, no production mutation", + next: [ + `bun scripts/cli.ts ci publish-backend-core --commit ${options.commit} --wait-ms 1200000`, + ], + }; + } const source = await prepareBackendCoreArtifactSource(config, options); const name = await remoteCreatePipelineRun(backendCoreArtifactPipelineRunManifest(options)); const wait = await waitForPipelineRun(name, options.waitMs); const condition = wait === null ? null : await readPipelineRunCondition(name); const waitSucceeded = pipelineRunWaitSucceeded(wait, condition); const artifact = waitSucceeded && wait !== null - ? parseArtifactSummaryFromOutput(await readPipelineRunLogText(name), "backend-core", options.commit, options.repoUrl, "src/components/backend-core/Dockerfile") + ? await readArtifactSummaryFromPipelineRun(name, summaryContext) : plannedArtifact; if (waitSucceeded && wait !== null) assertArtifactSummaryComplete(artifact, name); return { @@ -979,18 +1087,13 @@ async function publishBackendCoreArtifact(config: UniDeskConfig, options: CiPubl } async function publishUserServiceArtifact(config: UniDeskConfig, options: CiPublishUserServiceArtifactOptions): Promise> { - const plannedArtifact: ArtifactSummary = { + const summaryContext: ArtifactSummaryContext = { serviceId: options.serviceId, - sourceCommit: options.commit, - sourceRepo: options.repoUrl, dockerfile: options.dockerfile, - registry: "127.0.0.1:5000", - repository: `127.0.0.1:5000/unidesk/${options.serviceId}`, - tag: options.commit, - imageRef: `127.0.0.1:5000/unidesk/${options.serviceId}:${options.commit}`, - digest: null, - digestRef: null, - } satisfies ArtifactSummary; + commit: options.commit, + repoUrl: options.repoUrl, + }; + const plannedArtifact = artifactSummaryDefaults(summaryContext); if (options.dryRun) { return { ok: true, @@ -1026,7 +1129,7 @@ async function publishUserServiceArtifact(config: UniDeskConfig, options: CiPubl const condition = wait === null ? null : await readPipelineRunCondition(name); const waitSucceeded = pipelineRunWaitSucceeded(wait, condition); const artifact = waitSucceeded && wait !== null - ? parseArtifactSummaryFromOutput(await readPipelineRunLogText(name), options.serviceId, options.commit, options.repoUrl, options.dockerfile) + ? await readArtifactSummaryFromPipelineRun(name, summaryContext) : plannedArtifact; if (waitSucceeded && wait !== null) assertArtifactSummaryComplete(artifact, name); return { @@ -1334,7 +1437,7 @@ export function ciHelp(): Record { command: "bun scripts/cli.ts ci publish-user-service --service --commit ", initiallySupportedServices: ["baidu-netdisk", "decision-center"], registry: "127.0.0.1:5000/unidesk/:", - outputFields: ["imageRef", "tag", "digest", "sourceCommit", "serviceId"], + outputFields: ["serviceId", "sourceCommit", "sourceRepo", "dockerfile", "imageRef", "tag", "digest", "digestRef"], boundary: "artifact producer only; no prod deploy and no production namespace mutation", }, runDevE2E: { @@ -1367,7 +1470,8 @@ export async function runCiCommand(config: UniDeskConfig, args: string[]): Promi const repoUrl = stringOption(args, "--repo") ?? stringOption(args, "--repo-url") ?? "https://github.com/pikasTech/unidesk"; const commit = requireFullCommit(stringOption(args, "--commit") ?? stringOption(args, "--revision")); const waitMs = numberOption(args, "--wait-ms", 0); - return publishBackendCoreArtifact(config, { repoUrl, commit, waitMs, sourceHostPath: backendCoreArtifactSourceHostPath(commit) }); + const dryRun = boolFlag(args, "--dry-run"); + return publishBackendCoreArtifact(config, { repoUrl, commit, waitMs, sourceHostPath: backendCoreArtifactSourceHostPath(commit), dryRun }); } if (action === "publish-user-service") { const serviceId = requireServiceId(stringOption(args, "--service") ?? stringOption(args, "--service-id")); diff --git a/src/components/microservices/k3sctl-adapter/k3s/ci/unidesk-ci.pipeline.yaml b/src/components/microservices/k3sctl-adapter/k3s/ci/unidesk-ci.pipeline.yaml index c7495ccf..cd795275 100644 --- a/src/components/microservices/k3sctl-adapter/k3s/ci/unidesk-ci.pipeline.yaml +++ b/src/components/microservices/k3sctl-adapter/k3s/ci/unidesk-ci.pipeline.yaml @@ -328,6 +328,27 @@ spec: default: 127.0.0.1:5000 - name: source-host-path type: string + results: + - name: backend_core_artifact_service_id + description: CI artifact summary service id. + - name: backend_core_artifact_source_commit + description: Full Git source commit used to build the artifact. + - name: backend_core_artifact_source_repo + description: Source repository URL used to build the artifact. + - name: backend_core_artifact_dockerfile + description: Repo-relative Dockerfile path used to build the artifact. + - name: backend_core_artifact_registry + description: D601 loopback registry host. + - name: backend_core_artifact_repository + description: Registry repository without tag or digest. + - name: backend_core_artifact_image + description: Commit-tagged image reference. + - name: backend_core_artifact_tag + description: Commit-pinned image tag. + - name: backend_core_artifact_digest + description: Registry manifest digest. + - name: backend_core_artifact_digest_ref + description: Immutable image digest reference. workspaces: - name: source volumes: @@ -428,7 +449,18 @@ spec: repo_digests="$(docker image inspect "$registry_image" --format '{{json .RepoDigests}}')" digest="$(docker image inspect "$registry_image" --format '{{range .RepoDigests}}{{println .}}{{end}}' | awk -F@ -v repo="$repository" '$1 == repo { print $2; exit }')" test -n "$digest" - printf 'backend_core_artifact_service_id=backend-core\nbackend_core_artifact_image=%s\nbackend_core_artifact_repository=%s\nbackend_core_artifact_tag=%s\nbackend_core_artifact_digest=%s\nbackend_core_artifact_digest_ref=%s@%s\nbackend_core_artifact_source_commit=%s\nbackend_core_artifact_source_repo=%s\nbackend_core_artifact_dockerfile=src/components/backend-core/Dockerfile\nbackend_core_artifact_repo_digests=%s\n' "$registry_image" "$repository" "$commit" "$digest" "$repository" "$digest" "$commit" "$(params.repo-url)" "$repo_digests" + digest_ref="$repository@$digest" + printf '%s' "backend-core" > "$(results.backend_core_artifact_service_id.path)" + printf '%s' "$commit" > "$(results.backend_core_artifact_source_commit.path)" + printf '%s' "$(params.repo-url)" > "$(results.backend_core_artifact_source_repo.path)" + printf '%s' "src/components/backend-core/Dockerfile" > "$(results.backend_core_artifact_dockerfile.path)" + printf '%s' "$registry" > "$(results.backend_core_artifact_registry.path)" + printf '%s' "$repository" > "$(results.backend_core_artifact_repository.path)" + printf '%s' "$registry_image" > "$(results.backend_core_artifact_image.path)" + printf '%s' "$commit" > "$(results.backend_core_artifact_tag.path)" + printf '%s' "$digest" > "$(results.backend_core_artifact_digest.path)" + printf '%s' "$digest_ref" > "$(results.backend_core_artifact_digest_ref.path)" + printf 'backend_core_artifact_service_id=backend-core\nbackend_core_artifact_image=%s\nbackend_core_artifact_repository=%s\nbackend_core_artifact_tag=%s\nbackend_core_artifact_digest=%s\nbackend_core_artifact_digest_ref=%s\nbackend_core_artifact_source_commit=%s\nbackend_core_artifact_source_repo=%s\nbackend_core_artifact_dockerfile=src/components/backend-core/Dockerfile\nbackend_core_artifact_registry=%s\nbackend_core_artifact_repo_digests=%s\n' "$registry_image" "$repository" "$commit" "$digest" "$digest_ref" "$commit" "$(params.repo-url)" "$registry" "$repo_digests" --- apiVersion: tekton.dev/v1 kind: Pipeline @@ -501,6 +533,27 @@ spec: default: 127.0.0.1:5000 - name: source-host-path type: string + results: + - name: user_service_artifact_service_id + description: CI artifact summary service id. + - name: user_service_artifact_source_commit + description: Full Git source commit used to build the artifact. + - name: user_service_artifact_source_repo + description: Source repository URL used to build the artifact. + - name: user_service_artifact_dockerfile + description: Repo-relative Dockerfile path used to build the artifact. + - name: user_service_artifact_registry + description: D601 loopback registry host. + - name: user_service_artifact_repository + description: Registry repository without tag or digest. + - name: user_service_artifact_image + description: Commit-tagged image reference. + - name: user_service_artifact_tag + description: Commit-pinned image tag. + - name: user_service_artifact_digest + description: Registry manifest digest. + - name: user_service_artifact_digest_ref + description: Immutable image digest reference. workspaces: - name: source volumes: @@ -625,7 +678,18 @@ spec: repo_digests="$(docker image inspect "$registry_image" --format '{{json .RepoDigests}}')" digest="$(docker image inspect "$registry_image" --format '{{range .RepoDigests}}{{println .}}{{end}}' | awk -F@ -v repo="$repository" '$1 == repo { print $2; exit }')" test -n "$digest" - printf 'user_service_artifact_service_id=%s\nuser_service_artifact_image=%s\nuser_service_artifact_repository=%s\nuser_service_artifact_tag=%s\nuser_service_artifact_digest=%s\nuser_service_artifact_digest_ref=%s@%s\nuser_service_artifact_source_commit=%s\nuser_service_artifact_source_repo=%s\nuser_service_artifact_dockerfile=%s\nuser_service_artifact_registry=%s\nuser_service_artifact_repo_digests=%s\n' "$service_id" "$registry_image" "$repository" "$commit" "$digest" "$repository" "$digest" "$commit" "$(params.repo-url)" "$dockerfile" "$registry" "$repo_digests" + digest_ref="$repository@$digest" + printf '%s' "$service_id" > "$(results.user_service_artifact_service_id.path)" + printf '%s' "$commit" > "$(results.user_service_artifact_source_commit.path)" + printf '%s' "$(params.repo-url)" > "$(results.user_service_artifact_source_repo.path)" + printf '%s' "$dockerfile" > "$(results.user_service_artifact_dockerfile.path)" + printf '%s' "$registry" > "$(results.user_service_artifact_registry.path)" + printf '%s' "$repository" > "$(results.user_service_artifact_repository.path)" + printf '%s' "$registry_image" > "$(results.user_service_artifact_image.path)" + printf '%s' "$commit" > "$(results.user_service_artifact_tag.path)" + printf '%s' "$digest" > "$(results.user_service_artifact_digest.path)" + printf '%s' "$digest_ref" > "$(results.user_service_artifact_digest_ref.path)" + printf 'user_service_artifact_service_id=%s\nuser_service_artifact_image=%s\nuser_service_artifact_repository=%s\nuser_service_artifact_tag=%s\nuser_service_artifact_digest=%s\nuser_service_artifact_digest_ref=%s\nuser_service_artifact_source_commit=%s\nuser_service_artifact_source_repo=%s\nuser_service_artifact_dockerfile=%s\nuser_service_artifact_registry=%s\nuser_service_artifact_repo_digests=%s\n' "$service_id" "$registry_image" "$repository" "$commit" "$digest" "$digest_ref" "$commit" "$(params.repo-url)" "$dockerfile" "$registry" "$repo_digests" --- apiVersion: tekton.dev/v1 kind: Pipeline