diff --git a/.agents/skills/unidesk-cicd/references/branch-follower.md b/.agents/skills/unidesk-cicd/references/branch-follower.md index a0e357d2..69995d0b 100644 --- a/.agents/skills/unidesk-cicd/references/branch-follower.md +++ b/.agents/skills/unidesk-cicd/references/branch-follower.md @@ -107,9 +107,9 @@ Timeout, TTL, retry/backoff, reconcile interval and end-to-end budget values mus - `hwlab-jd01-v03`: follows `pikasTech/HWLAB@v0.3`, adapter `hwlab-node-runtime`, native trigger `Tekton PipelineRun -> Argo Application closeout -> runtime Deployment sourceCommit readiness`. - `agentrun-jd01-v02`: historical first follower only. This lane has migrated to Gitea webhook -> Pipelines-as-Code -> Tekton -> GitOps/Argo -> runtime readiness; do not re-enable branch-follower, act_runner or custom trigger fallback for it. Current operation lives in `config/platform-infra/gitea.yaml`, `config/platform-infra/pipelines-as-code.yaml` and [agentrun.md](agentrun.md). -- `web-probe-sentinel-master`: follows `pikasTech/unidesk@master`, adapter `web-probe-sentinel-cicd`, native trigger `Tekton PipelineRun -> Argo Application closeout -> runtime Deployment sourceCommit readiness`. +- `web-probe-sentinel-master`: historical follower only. This lane has migrated to Gitea webhook -> Pipelines-as-Code consumer `sentinel-jd01-v03` -> Tekton publish -> GitOps/Argo -> runtime readiness; do not re-enable branch-follower, Gitea Actions, act_runner or custom fallback for it. -These entries describe the initial production set and migration history. HWLAB still runs on JD01 through branch-follower unless YAML changes; AgentRun JD01 v0.2 now uses the PaC/Gitea path. +These entries describe the initial production set and migration history. HWLAB still runs on JD01 through branch-follower unless YAML changes; AgentRun JD01 v0.2 and Web 哨兵 JD01 now use the PaC/Gitea path. ## Reuse And Mirror Contract diff --git a/.agents/skills/unidesk-cicd/references/platform-ops.md b/.agents/skills/unidesk-cicd/references/platform-ops.md index 301b6378..55ee6885 100644 --- a/.agents/skills/unidesk-cicd/references/platform-ops.md +++ b/.agents/skills/unidesk-cicd/references/platform-ops.md @@ -41,12 +41,12 @@ bun scripts/cli.ts hwlab g14 observability status|apply|query|targets|boundary|c bun scripts/cli.ts platform-infra sub2api plan|apply|status|validate bun scripts/cli.ts platform-infra sub2api codex-pool plan|sync|validate|expose|configure-local bun scripts/cli.ts platform-infra gitea plan|apply|status|validate|mirror --target JD01 -bun scripts/cli.ts platform-infra pipelines-as-code plan|apply|status|webhook-test --target JD01 +bun scripts/cli.ts platform-infra pipelines-as-code plan|apply|status|webhook-test --target JD01 [--consumer ] bun scripts/cli.ts platform-infra wechat-archive plan|apply|status|validate|pull bun scripts/cli.ts platform-infra wechat-archive wcf-host-status|collector-plan|collector-apply|collector-status ``` -`platform-infra` 是 UniDesk 运维的平台基础设施控制面;新增平台服务优先进入该命名空间或对应 YAML 声明目标,旧 `devops-infra` 只作为渐进迁移来源。Sub2API 日常部署、Codex pool、FRP 暴露、master `~/.codex` 配置、验收和排障统一使用 `$unidesk-sub2api`。Gitea mirror 和 Pipelines-as-Code 是迁移后的 CI source/trigger 平台服务,source-of-truth 分别是 `config/platform-infra/gitea.yaml` 和 `config/platform-infra/pipelines-as-code.yaml`;PaC status 是 migrated lane closeout 入口,不用 Gitea Actions、act_runner、branch-follower 或自维护脚本兜底。WeChat archive 是 platform-infra 的 YAML-first 工作流入口;只读 collector 的副本、镜像、WCF host、端口和版本 pin 都以 YAML 为准。 +`platform-infra` 是 UniDesk 运维的平台基础设施控制面;新增平台服务优先进入该命名空间或对应 YAML 声明目标,旧 `devops-infra` 只作为渐进迁移来源。Sub2API 日常部署、Codex pool、FRP 暴露、master `~/.codex` 配置、验收和排障统一使用 `$unidesk-sub2api`。Gitea mirror 和 Pipelines-as-Code 是迁移后的 CI source/trigger 平台服务,source-of-truth 分别是 `config/platform-infra/gitea.yaml` 和 `config/platform-infra/pipelines-as-code.yaml`;PaC status 是 migrated lane closeout 入口,不用 Gitea Actions、act_runner、branch-follower 或自维护脚本兜底。`agentrun-jd01-v02` 是默认 consumer;哨兵使用 `--consumer sentinel-jd01-v03` 查看 PaC/Tekton/Argo/env reuse 证据。WeChat archive 是 platform-infra 的 YAML-first 工作流入口;只读 collector 的副本、镜像、WCF host、端口和版本 pin 都以 YAML 为准。 ## CI Tools Image diff --git a/.agents/skills/unidesk-monitor/references/web-sentinel.md b/.agents/skills/unidesk-monitor/references/web-sentinel.md index 92735fc7..2a4ae7ab 100644 --- a/.agents/skills/unidesk-monitor/references/web-sentinel.md +++ b/.agents/skills/unidesk-monitor/references/web-sentinel.md @@ -3,3 +3,5 @@ Web sentinel operations stay YAML-first. Node, lane, sentinel id, cadence, Deployment, Service, PVC, route prefix, dashboard URL and Secret sourceRefs come from config. Use controlled `web-probe sentinel ...` commands for status, report, control-plane, validate and dashboard operations. Direct `kubectl create job --from=cronjob` is diagnostic only, not acceptance evidence. + +JD01 `jd01-web-probe-sentinel` CI follows the migrated Gitea/Pipelines-as-Code path. Source truth is `config/platform-infra/gitea.yaml#sourceAuthority.repositories.unidesk-master` plus `config/platform-infra/pipelines-as-code.yaml#consumers.sentinel-jd01-v03`; status evidence is `bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 --consumer sentinel-jd01-v03`. Do not re-enable `web-probe-sentinel-master` branch-follower, Gitea Actions or act_runner fallback. diff --git a/.tekton/web-probe-sentinel-jd01-pac.yaml b/.tekton/web-probe-sentinel-jd01-pac.yaml new file mode 100644 index 00000000..3b426158 --- /dev/null +++ b/.tekton/web-probe-sentinel-jd01-pac.yaml @@ -0,0 +1,54 @@ +apiVersion: tekton.dev/v1 +kind: PipelineRun +metadata: + generateName: hwlab-web-probe-sentinel-jd01- + annotations: + pipelinesascode.tekton.dev/on-event: "[push]" + pipelinesascode.tekton.dev/on-target-branch: "[master]" + labels: + app.kubernetes.io/name: hwlab-web-probe-sentinel-jd01-pac + app.kubernetes.io/part-of: hwlab-web-probe-sentinel + unidesk.ai/source-commit: "{{revision}}" + hwlab.pikastech.local/source-commit: "{{revision}}" +spec: + timeouts: + pipeline: 120s + taskRunTemplate: + serviceAccountName: default + podTemplate: + hostNetwork: true + dnsPolicy: ClusterFirstWithHostNet + pipelineSpec: + tasks: + - name: publish + taskSpec: + steps: + - name: sentinel-publish + image: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 + imagePullPolicy: IfNotPresent + workingDir: /workspace/source + env: + - name: GIT_READ_URL + value: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-unidesk.git + - name: SOURCE_COMMIT + value: "{{revision}}" + - name: SOURCE_STAGE_REF + value: refs/unidesk/snapshots/gitea-actions/unidesk-master/{{revision}} + script: | + #!/bin/sh + set -eu + rm -rf /workspace/source + git clone --filter=blob:none --no-checkout "$GIT_READ_URL" /workspace/source + cd /workspace/source + git fetch --depth=1 --filter=blob:none origin "+$SOURCE_STAGE_REF:refs/remotes/origin/unidesk-source-snapshot" + git checkout --detach "$SOURCE_COMMIT" + bun scripts/cli.ts web-probe sentinel publish-current \ + --node JD01 \ + --lane v03 \ + --sentinel jd01-web-probe-sentinel \ + --confirm \ + --wait \ + --timeout-seconds 120 \ + --source-commit "$SOURCE_COMMIT" \ + --source-stage-ref "$SOURCE_STAGE_REF" \ + --source-authority gitea-snapshot diff --git a/config/cicd-branch-followers.yaml b/config/cicd-branch-followers.yaml index c9243831..06637ce9 100644 --- a/config/cicd-branch-followers.yaml +++ b/config/cicd-branch-followers.yaml @@ -212,9 +212,9 @@ followers: checks: ["sourceSnapshot", "pipelineRun", "gitops", "argo", "manager", "runtimeHealth"] - id: web-probe-sentinel-master - enabled: true + enabled: false adapter: web-probe-sentinel-cicd - description: Follow UniDesk master into the selected HWLAB web-probe sentinel runtime. + description: Historical follower retired by GH-1555. Active path is Gitea mirror -> Pipelines-as-Code -> Tekton -> ArgoCD; do not re-enable branch-follower/Gitea Actions/act_runner fallback. source: repository: pikasTech/unidesk branch: master diff --git a/config/hwlab-web-probe-sentinel/profiles.yaml b/config/hwlab-web-probe-sentinel/profiles.yaml index 731adf87..ea4b5117 100644 --- a/config/hwlab-web-probe-sentinel/profiles.yaml +++ b/config/hwlab-web-probe-sentinel/profiles.yaml @@ -180,10 +180,15 @@ nodes: controlPlaneConfigRef: config/hwlab-node-control-plane.yaml#targets[1] source: <<: *cicd-source + gitMirrorReadUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-unidesk.git sourceAuthority: <<: *cicd-source-authority + mode: giteaSnapshot + resolver: gitea-mirror sourceSnapshot: <<: *cicd-source-snapshot + stageRefPrefix: refs/unidesk/snapshots/gitea-actions/unidesk-master + refreshPolicy: gitea-controlled-snapshot argo: &jd01-argo namespace: argocd projectName: hwlab-jd01 @@ -193,6 +198,10 @@ nodes: <<: *maintenance monitorWeb: <<: *monitor-web + gitMirror: + source: source.gitMirrorReadUrl + preSync: not-required + postFlush: not-required confirmWait: <<: *confirm-wait publishCurrent: diff --git a/config/platform-infra/pipelines-as-code.yaml b/config/platform-infra/pipelines-as-code.yaml index 80742d27..d286ac36 100644 --- a/config/platform-infra/pipelines-as-code.yaml +++ b/config/platform-infra/pipelines-as-code.yaml @@ -2,14 +2,16 @@ version: 1 kind: platform-infra-pipelines-as-code metadata: - id: pac-gitea-agentrun + id: pac-gitea-consumers owner: unidesk - spec: GH-1552 + spec: GH-1552/GH-1555 relatedIssues: - 1552 + - 1555 defaults: targetId: JD01 + consumerId: agentrun-jd01-v02 release: version: v0.48.0 @@ -27,7 +29,7 @@ targets: enabled: true gitea: - configRef: config/platform-infra/gitea.yaml#sourceAuthority.repositories.agentrun-jd01-v02 + configRef: config/platform-infra/gitea.yaml#sourceAuthority.repositories internalBaseUrl: http://gitea-http.devops-infra.svc.cluster.local:3000 admin: sourceRoot: /root/unidesk @@ -43,34 +45,67 @@ gitea: - push branch: v0.2 -repository: - name: agentrun-jd01-v02 - namespace: agentrun-ci - providerType: gitea - url: https://gitea.pikapython.com/mirrors/pikasTech-agentrun - cloneUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-agentrun.git - owner: mirrors - repo: pikasTech-agentrun - secretName: pac-gitea-agentrun-jd01-v02 - tokenKey: token - webhookSecretKey: webhook.secret - concurrencyLimit: 1 - params: - git_read_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-agentrun.git - git_write_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-agentrun.git - source_branch: v0.2 - gitops_branch: jd01-v0.2-gitops - source_snapshot_prefix: refs/unidesk/snapshots/gitea-actions/agentrun-v0.2 - pipeline_name: agentrun-jd01-v02-ci-image-publish - pipeline_run_prefix: agentrun-jd01-v02-ci - service_account: agentrun-jd01-v02-tekton-runner - workspace_pvc_size: 2Gi +repositories: + - id: agentrun-jd01-v02 + name: agentrun-jd01-v02 + namespace: agentrun-ci + providerType: gitea + url: https://gitea.pikapython.com/mirrors/pikasTech-agentrun + cloneUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-agentrun.git + owner: mirrors + repo: pikasTech-agentrun + secretName: pac-gitea-agentrun-jd01-v02 + tokenKey: token + webhookSecretKey: webhook.secret + concurrencyLimit: 1 + params: + git_read_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-agentrun.git + git_write_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-agentrun.git + source_branch: v0.2 + gitops_branch: jd01-v0.2-gitops + source_snapshot_prefix: refs/unidesk/snapshots/gitea-actions/agentrun-v0.2 + pipeline_name: agentrun-jd01-v02-ci-image-publish + pipeline_run_prefix: agentrun-jd01-v02-ci + service_account: agentrun-jd01-v02-tekton-runner + workspace_pvc_size: 2Gi + - id: sentinel-jd01-v03 + name: sentinel-jd01-v03 + namespace: devops-infra + providerType: gitea + url: https://gitea.pikapython.com/mirrors/pikasTech-unidesk + cloneUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-unidesk.git + owner: mirrors + repo: pikasTech-unidesk + secretName: pac-gitea-sentinel-jd01-v03 + tokenKey: token + webhookSecretKey: webhook.secret + concurrencyLimit: 1 + params: + git_read_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-unidesk.git + source_branch: master + source_snapshot_prefix: refs/unidesk/snapshots/gitea-actions/unidesk-master + node: JD01 + lane: v03 + sentinel_id: jd01-web-probe-sentinel + pipeline_name: hwlab-web-probe-sentinel-jd01-pac + pipeline_run_prefix: hwlab-web-probe-sentinel-jd01 -consumer: - node: JD01 - lane: jd01-v02 - namespace: agentrun-ci - pipeline: agentrun-jd01-v02-ci-image-publish - pipelineRunPrefix: agentrun-jd01-v02-ci - argoNamespace: argocd - argoApplication: agentrun-jd01-v02 +consumers: + - id: agentrun-jd01-v02 + repositoryRef: agentrun-jd01-v02 + node: JD01 + lane: jd01-v02 + namespace: agentrun-ci + pipeline: agentrun-jd01-v02-ci-image-publish + pipelineRunPrefix: agentrun-jd01-v02-ci + argoNamespace: argocd + argoApplication: agentrun-jd01-v02 + - id: sentinel-jd01-v03 + repositoryRef: sentinel-jd01-v03 + node: JD01 + lane: v03 + namespace: devops-infra + pipeline: hwlab-web-probe-sentinel-jd01-pac + pipelineRunPrefix: hwlab-web-probe-sentinel-jd01 + argoNamespace: argocd + argoApplication: hwlab-web-probe-sentinel-jd01 diff --git a/docs/reference/platform-infra.md b/docs/reference/platform-infra.md index ca7ef04d..7fbc6805 100644 --- a/docs/reference/platform-infra.md +++ b/docs/reference/platform-infra.md @@ -15,6 +15,7 @@ - Gitea mirror and Pipelines-as-Code are platform-infra CI source/trigger services operated by UniDesk. Their durable configuration lives in `config/platform-infra/gitea.yaml` and `config/platform-infra/pipelines-as-code.yaml`; do not hide repo URLs, mirror repo names, webhook settings, public exposure, FRP/Caddy ports, token sourceRefs or PaC Repository params in helper constants. - The canonical Gitea entrypoints are `bun scripts/cli.ts platform-infra gitea plan|apply|status|validate|mirror --target ` and `bun scripts/cli.ts platform-infra gitea mirror plan|bootstrap|sync|status --target `. Mirror bootstrap/sync must repair declared repo/org visibility such as `publicRead: true`; create-time defaults alone are not enough for long-lived repos. - The canonical PaC entrypoints are `bun scripts/cli.ts platform-infra pipelines-as-code plan|apply|status|webhook-test --target `. PaC status is the operator-facing closeout surface for migrated CI lanes and must expose webhook count, latest PipelineRun/TaskRun duration, image status, env identity, digest, GitOps commit, Argo revision and runtime provenance without requiring raw `kubectl`, `tkn` or Gitea UI inspection. +- `config/platform-infra/pipelines-as-code.yaml` may declare multiple repositories and consumers. `agentrun-jd01-v02` is the default consumer; Web 哨兵 uses `--consumer sentinel-jd01-v03`. Consumer-scoped status must not mix PipelineRuns or env reuse evidence across repositories. - Public Gitea UI may use the YAML-declared HTTPS hostname, but k8s-internal consumers must use the ClusterIP service URL from YAML. Internal CI/Argo/runtime reads must not loop through public DNS/Caddy/FRP, and migrated lanes must not fall back to legacy git-mirror read URLs when the commit exists only in Gitea. - A PaC-migrated lane must keep a single trigger path: Gitea webhook -> Pipelines-as-Code -> Tekton -> GitOps/Argo -> k8s runtime. Do not add Gitea Actions, `act_runner`, branch-follower or custom script fallback unless a later issue explicitly changes the architecture. - k8s runtime remains Docker-free from the point it pulls already built images. CI build steps may use YAML-declared native build tooling, but Docker socket/daemon access must not become part of the runtime plane. diff --git a/scripts/native/cicd/submit-pipelinerun.mjs b/scripts/native/cicd/submit-pipelinerun.mjs index b3fe8f7b..1387a964 100644 --- a/scripts/native/cicd/submit-pipelinerun.mjs +++ b/scripts/native/cicd/submit-pipelinerun.mjs @@ -6,6 +6,8 @@ const pipelineRun = process.env.PIPELINERUN || ""; const shouldWait = process.env.WAIT === "true"; const timeoutSeconds = requiredPositiveNumber("TIMEOUT_SECONDS"); const pollIntervalSeconds = requiredPositiveNumber("POLL_INTERVAL_SECONDS"); +const logsTailLines = Number(process.env.LOGS_TAIL_LINES || "240"); +const maxLogBytes = Number(process.env.MAX_LOG_BYTES || "32000"); const host = process.env.KUBERNETES_SERVICE_HOST; const port = Number(process.env.KUBERNETES_SERVICE_PORT || "443"); const token = readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/token", "utf8").trim(); @@ -106,6 +108,7 @@ const condition = succeededCondition(latest.object); const completed = condition?.status === "True"; const failed = condition?.status === "False"; const terminal = completed || failed; +const logsTail = terminal ? await pipelineRunLogsTail() : ""; const output = { ok: !failed, submitted: true, @@ -120,6 +123,7 @@ const output = { timedOutWait: shouldWait && !terminal, elapsedMs: Date.now() - startedAt, pipelineRun: compact(latest.object), + logsTail, statusAuthority: "kubernetes-api-serviceaccount", parsedDownstreamCliOutput: false, valuesRedacted: true, @@ -132,3 +136,25 @@ function requiredPositiveNumber(name) { if (!Number.isFinite(value) || value <= 0) throw new Error(`${name} must be a positive number`); return value; } + +async function pipelineRunLogsTail() { + const selector = encodeURIComponent(`tekton.dev/pipelineRun=${pipelineRun}`); + const podsResult = await request("GET", `/api/v1/namespaces/${encodeURIComponent(namespace)}/pods?labelSelector=${selector}`); + if (podsResult.status < 200 || podsResult.status >= 300) return ""; + const pods = parseBody(podsResult); + const chunks = []; + for (const pod of Array.isArray(pods?.items) ? pods.items : []) { + const podName = pod?.metadata?.name; + const containers = [ + ...(Array.isArray(pod?.spec?.initContainers) ? pod.spec.initContainers : []), + ...(Array.isArray(pod?.spec?.containers) ? pod.spec.containers : []), + ]; + for (const container of containers) { + if (!podName || !container?.name) continue; + const path = `/api/v1/namespaces/${encodeURIComponent(namespace)}/pods/${encodeURIComponent(podName)}/log?container=${encodeURIComponent(container.name)}&tailLines=${Math.max(1, logsTailLines)}`; + const log = await request("GET", path); + if (log.status >= 200 && log.status < 300 && log.text) chunks.push(log.text); + } + } + return chunks.join("\n").slice(-Math.max(1, maxLogBytes)); +} diff --git a/scripts/src/hwlab-node-web-sentinel-cicd-jobs.ts b/scripts/src/hwlab-node-web-sentinel-cicd-jobs.ts index 53402d41..49049955 100644 --- a/scripts/src/hwlab-node-web-sentinel-cicd-jobs.ts +++ b/scripts/src/hwlab-node-web-sentinel-cicd-jobs.ts @@ -318,6 +318,9 @@ export function runSentinelPublishJob(state: SentinelCicdState, publishGitops: b const pipelineRunName = sentinelPipelineRunName(state, rerun); const manifest = sentinelPublishPipelineRunManifest(state, pipelineRunName, publishGitops); const namespace = stringAt(state.cicd, "builder.namespace"); + if (process.env.KUBERNETES_SERVICE_HOST && process.env.KUBERNETES_SERVICE_PORT) { + return runSentinelPublishPipelineRunInCluster(state, manifest, namespace, pipelineRunName, publishGitops, timeoutSeconds); + } sentinelProgressEvent("sentinel.publish.progress", { phase: "create-pipelinerun", status: "submitting", pipelineRun: pipelineRunName, publishGitops, sourceCommit: state.sourceHead.commit, node: state.spec.nodeId, lane: state.spec.lane }); const created = runCommand(["trans", stringAt(state.controlPlaneNode, "kubeRoute"), "sh", "--", createTektonPipelineRunScript(namespace, manifest)], repoRoot, { timeoutMs: Math.min(timeoutSeconds, 60) * 1000 }); if (created.exitCode !== 0) { @@ -367,6 +370,47 @@ export function runSentinelPublishJob(state: SentinelCicdState, publishGitops: b return withSentinelRemoteJobDiagnostics(state, { ok: false, phase: "pipelinerun-timeout", resourceKind: "PipelineRun", jobName: pipelineRunName, payload: { ok: false, status: "timeout", valuesRedacted: true }, polls, elapsedMs: Date.now() - startedAt, probe: lastProbe, valuesRedacted: true }, "publish"); } +function runSentinelPublishPipelineRunInCluster(state: SentinelCicdState, manifest: Record, namespace: string, pipelineRunName: string, publishGitops: boolean, timeoutSeconds: number): SentinelRemoteJobResult { + sentinelProgressEvent("sentinel.publish.progress", { phase: "native-submit-pipelinerun", status: "submitting", pipelineRun: pipelineRunName, publishGitops, sourceCommit: state.sourceHead.commit, node: state.spec.nodeId, lane: state.spec.lane }); + const manifestB64 = Buffer.from(JSON.stringify(manifest), "utf8").toString("base64"); + const result = runCommand(["node", "scripts/native/cicd/submit-pipelinerun.mjs"], repoRoot, { + input: manifestB64, + timeoutMs: Math.max(5_000, Math.min(timeoutSeconds * 1000, controlPlaneWaitWarningSeconds(state) * 1000)), + env: { + ...process.env, + NAMESPACE: namespace, + PIPELINERUN: pipelineRunName, + WAIT: "true", + TIMEOUT_SECONDS: String(Math.max(1, Math.min(timeoutSeconds, controlPlaneWaitWarningSeconds(state)))), + POLL_INTERVAL_SECONDS: "2", + LOGS_TAIL_LINES: "240", + MAX_LOG_BYTES: "48000", + }, + }); + const parsed = parseJsonObject(result.stdout) ?? {}; + const payload = sentinelPayloadFromLogs(String(parsed.logsTail ?? "")); + const pipelineRun = record(parsed.pipelineRun); + const failed = parsed.failed === true || record(pipelineRun).conditionStatus === "False"; + const completed = parsed.completed === true || record(pipelineRun).conditionStatus === "True"; + const phase = failed ? "pipelinerun-failed" : completed ? "pipelinerun-succeeded" : "pipelinerun-timeout"; + const ok = result.exitCode === 0 && completed && payload.ok === true; + return withSentinelRemoteJobDiagnostics(state, { + ok, + phase, + resourceKind: "PipelineRun", + jobName: pipelineRunName, + payload: Object.keys(payload).length === 0 ? { ok: false, status: completed ? "result-missing" : failed ? "failed" : "timeout", valuesRedacted: true } : payload, + polls: parsed.polls ?? null, + elapsedMs: parsed.elapsedMs ?? result.durationMs ?? null, + probe: { + nativeSubmit: parsed, + capture: compactCommand(result), + valuesRedacted: true, + }, + valuesRedacted: true, + }, "publish"); +} + export function sentinelPublishPipelineRunManifest(state: SentinelCicdState, pipelineRunName: string, publishGitops: boolean): Record { const namespace = stringAt(state.cicd, "builder.namespace"); const buildkitImage = requireSentinelBuildkitImage(state); @@ -808,11 +852,12 @@ function sentinelPublishShell(state: SentinelCicdState, jobName: string, publish "fi", "gitops_finished_ms=$(now_ms)", "finished_ms=$(now_ms)", - "node - \"$job_name\" \"$source_commit\" \"$source_stage_ref\" \"$mirror_commit\" \"$image_ref\" \"$digest_ref\" \"$gitops_commit\" \"$changed\" \"$file_count\" \"$started_ms\" \"$finished_ms\" \"$source_fetch_started_ms\" \"$source_fetch_finished_ms\" \"$monitor_web_verify_started_ms\" \"$monitor_web_verify_finished_ms\" \"$image_build_started_ms\" \"$image_build_finished_ms\" \"$gitops_started_ms\" \"$gitops_finished_ms\" \"$env_reuse_mode\" \"$env_reuse_node_deps_path\" \"$env_reuse_node_deps_present\" \"$env_reuse_node_deps_entries\" \"$env_reuse_linked_node_deps\" \"$image_build_cache_hits\" \"$image_build_step_lines\" \"$image_build_log_tail_b64\" \"$image_build_builder\" \"$image_build_package_mode\" \"$image_build_network_mode\" \"$image_build_proxy_source\" \"$image_build_http_proxy_present\" \"$image_build_https_proxy_present\" \"$image_build_all_proxy_present\" \"$image_build_no_proxy_present\" \"$context_ignore_entries\" <<'NODE'", - "const [jobName, sourceCommit, sourceStageRef, mirrorCommit, imageRef, digestRef, gitopsCommit, changed, fileCount, startedMs, finishedMs, sourceFetchStartedMs, sourceFetchFinishedMs, monitorWebVerifyStartedMs, monitorWebVerifyFinishedMs, imageBuildStartedMs, imageBuildFinishedMs, gitopsStartedMs, gitopsFinishedMs, envReuseMode, envReuseNodeDepsPath, envReuseNodeDepsPresent, envReuseNodeDepsEntries, envReuseLinkedNodeDeps, imageBuildCacheHits, imageBuildStepLines, imageBuildLogTailB64, imageBuildBuilder, imageBuildPackageMode, imageBuildNetworkMode, imageBuildProxySource, imageBuildHttpProxyPresent, imageBuildHttpsProxyPresent, imageBuildAllProxyPresent, imageBuildNoProxyPresent, contextIgnoreEntries] = process.argv.slice(2);", + `source_authority=${shellQuote(state.sourceHead.sourceAuthority)}`, + "node - \"$job_name\" \"$source_commit\" \"$source_stage_ref\" \"$mirror_commit\" \"$image_ref\" \"$digest_ref\" \"$gitops_commit\" \"$changed\" \"$file_count\" \"$started_ms\" \"$finished_ms\" \"$source_fetch_started_ms\" \"$source_fetch_finished_ms\" \"$monitor_web_verify_started_ms\" \"$monitor_web_verify_finished_ms\" \"$image_build_started_ms\" \"$image_build_finished_ms\" \"$gitops_started_ms\" \"$gitops_finished_ms\" \"$env_reuse_mode\" \"$env_reuse_node_deps_path\" \"$env_reuse_node_deps_present\" \"$env_reuse_node_deps_entries\" \"$env_reuse_linked_node_deps\" \"$image_build_cache_hits\" \"$image_build_step_lines\" \"$image_build_log_tail_b64\" \"$image_build_builder\" \"$image_build_package_mode\" \"$image_build_network_mode\" \"$image_build_proxy_source\" \"$image_build_http_proxy_present\" \"$image_build_https_proxy_present\" \"$image_build_all_proxy_present\" \"$image_build_no_proxy_present\" \"$context_ignore_entries\" \"$source_authority\" <<'NODE'", + "const [jobName, sourceCommit, sourceStageRef, mirrorCommit, imageRef, digestRef, gitopsCommit, changed, fileCount, startedMs, finishedMs, sourceFetchStartedMs, sourceFetchFinishedMs, monitorWebVerifyStartedMs, monitorWebVerifyFinishedMs, imageBuildStartedMs, imageBuildFinishedMs, gitopsStartedMs, gitopsFinishedMs, envReuseMode, envReuseNodeDepsPath, envReuseNodeDepsPresent, envReuseNodeDepsEntries, envReuseLinkedNodeDeps, imageBuildCacheHits, imageBuildStepLines, imageBuildLogTailB64, imageBuildBuilder, imageBuildPackageMode, imageBuildNetworkMode, imageBuildProxySource, imageBuildHttpProxyPresent, imageBuildHttpsProxyPresent, imageBuildAllProxyPresent, imageBuildNoProxyPresent, contextIgnoreEntries, sourceAuthority] = process.argv.slice(2);", "const elapsed = (start, finish) => Number(finish) - Number(start);", "const cacheHits = Number(imageBuildCacheHits || 0);", - "console.log(JSON.stringify({ ok:true, status:'succeeded', jobName, sourceCommit, sourceStageRef, sourceAuthority:'git-mirror-snapshot', mirrorCommit, imageRef, digestRef, gitopsCommit: gitopsCommit || null, changed: changed === 'true', fileCount: Number(fileCount || 0), elapsedMs: elapsed(startedMs, finishedMs), stageTimings: { sourceFetchMs: elapsed(sourceFetchStartedMs, sourceFetchFinishedMs), monitorWebVerifyMs: elapsed(monitorWebVerifyStartedMs, monitorWebVerifyFinishedMs), imageBuildMs: elapsed(imageBuildStartedMs, imageBuildFinishedMs), gitopsMs: elapsed(gitopsStartedMs, gitopsFinishedMs), totalMs: elapsed(startedMs, finishedMs), valuesRedacted:true }, envReuse: { mode: envReuseMode, nodeDepsPath: envReuseNodeDepsPath, nodeDepsPresent: envReuseNodeDepsPresent === 'true', nodeDepsEntries: Number(envReuseNodeDepsEntries || 0), linkedNodeDeps: Number(envReuseLinkedNodeDeps || 0), dependencyReuse: envReuseNodeDepsPresent === 'true' ? 'hit' : 'miss', valuesRedacted:true }, imageBuild: { builder: 'k8s-buildkit-rootless', builderImage: imageBuildBuilder, cacheHitLines: cacheHits, stepLines: Number(imageBuildStepLines || 0), layerCache: cacheHits > 0 ? 'hit' : 'unknown-or-miss', packageMode: imageBuildPackageMode, networkMode: imageBuildNetworkMode, proxySource: imageBuildProxySource, proxy: { httpProxyPresent: imageBuildHttpProxyPresent === 'true', httpsProxyPresent: imageBuildHttpsProxyPresent === 'true', allProxyPresent: imageBuildAllProxyPresent === 'true', noProxyPresent: imageBuildNoProxyPresent === 'true', valuesRedacted:true }, contextIgnoreEntries: Number(contextIgnoreEntries || 0), verifyLocation: 'pre-image-build', logTail: Buffer.from(imageBuildLogTailB64 || '', 'base64').toString('utf8'), valuesRedacted:true }, completedStages: ['source-fetch', 'monitor-web-verify', 'image-build', gitopsCommit ? 'gitops' : 'gitops-skipped'], valuesRedacted:true }));", + "console.log(JSON.stringify({ ok:true, status:'succeeded', jobName, sourceCommit, sourceStageRef, sourceAuthority, mirrorCommit, imageRef, digestRef, gitopsCommit: gitopsCommit || null, changed: changed === 'true', fileCount: Number(fileCount || 0), elapsedMs: elapsed(startedMs, finishedMs), stageTimings: { sourceFetchMs: elapsed(sourceFetchStartedMs, sourceFetchFinishedMs), monitorWebVerifyMs: elapsed(monitorWebVerifyStartedMs, monitorWebVerifyFinishedMs), imageBuildMs: elapsed(imageBuildStartedMs, imageBuildFinishedMs), gitopsMs: elapsed(gitopsStartedMs, gitopsFinishedMs), totalMs: elapsed(startedMs, finishedMs), valuesRedacted:true }, envReuse: { mode: envReuseMode, nodeDepsPath: envReuseNodeDepsPath, nodeDepsPresent: envReuseNodeDepsPresent === 'true', nodeDepsEntries: Number(envReuseNodeDepsEntries || 0), linkedNodeDeps: Number(envReuseLinkedNodeDeps || 0), dependencyReuse: envReuseNodeDepsPresent === 'true' ? 'hit' : 'miss', valuesRedacted:true }, imageBuild: { builder: 'k8s-buildkit-rootless', builderImage: imageBuildBuilder, cacheHitLines: cacheHits, stepLines: Number(imageBuildStepLines || 0), layerCache: cacheHits > 0 ? 'hit' : 'unknown-or-miss', packageMode: imageBuildPackageMode, networkMode: imageBuildNetworkMode, proxySource: imageBuildProxySource, proxy: { httpProxyPresent: imageBuildHttpProxyPresent === 'true', httpsProxyPresent: imageBuildHttpsProxyPresent === 'true', allProxyPresent: imageBuildAllProxyPresent === 'true', noProxyPresent: imageBuildNoProxyPresent === 'true', valuesRedacted:true }, contextIgnoreEntries: Number(contextIgnoreEntries || 0), verifyLocation: 'pre-image-build', logTail: Buffer.from(imageBuildLogTailB64 || '', 'base64').toString('utf8'), valuesRedacted:true }, completedStages: ['source-fetch', 'monitor-web-verify', 'image-build', gitopsCommit ? 'gitops' : 'gitops-skipped'], valuesRedacted:true }));", "NODE", "trap - EXIT", ].join("\n"); diff --git a/scripts/src/hwlab-node-web-sentinel-cicd-shared.ts b/scripts/src/hwlab-node-web-sentinel-cicd-shared.ts index 16bbbc11..654b3b6a 100644 --- a/scripts/src/hwlab-node-web-sentinel-cicd-shared.ts +++ b/scripts/src/hwlab-node-web-sentinel-cicd-shared.ts @@ -16,6 +16,14 @@ export type WebProbeSentinelPublishAction = "publish-current"; export type WebProbeSentinelMaintenanceAction = "status" | "start" | "stop"; export type WebProbeSentinelDashboardAction = "verify" | "screenshot" | "trigger"; export type WebProbeSentinelReportView = "summary" | "turn-summary" | "findings" | "trace-frame" | "auth-session-switch-summary"; +export type WebProbeSentinelSourceAuthority = "git-mirror-snapshot" | "gitea-snapshot"; + +export interface WebProbeSentinelSourceOverrideOptions { + readonly sourceCommit: string | null; + readonly sourceStageRef: string | null; + readonly sourceMirrorCommit: string | null; + readonly sourceAuthority: WebProbeSentinelSourceAuthority | null; +} export type WebProbeSentinelOptions = | { @@ -36,6 +44,7 @@ export type WebProbeSentinelOptions = readonly confirm: boolean; readonly wait: boolean; readonly timeoutSeconds: number; + readonly sourceOverride: WebProbeSentinelSourceOverrideOptions; } | { readonly kind: "control-plane"; @@ -48,6 +57,7 @@ export type WebProbeSentinelOptions = readonly wait: boolean; readonly timeoutSeconds: number; readonly rerun: boolean; + readonly sourceOverride: WebProbeSentinelSourceOverrideOptions; } | { readonly kind: "publish"; @@ -60,6 +70,7 @@ export type WebProbeSentinelOptions = readonly wait: boolean; readonly timeoutSeconds: number; readonly rerun: boolean; + readonly sourceOverride: WebProbeSentinelSourceOverrideOptions; } | { readonly kind: "maintenance"; @@ -146,7 +157,7 @@ export interface SourceHead { readonly commit: string | null; readonly stageRef: string | null; readonly mirrorCommit: string | null; - readonly sourceAuthority: "git-mirror-cache" | "git-mirror-snapshot"; + readonly sourceAuthority: "git-mirror-cache" | "git-mirror-snapshot" | "gitea-cache" | "gitea-snapshot"; readonly latestDrift: boolean; readonly result: CompactCommandResult; } diff --git a/scripts/src/hwlab-node-web-sentinel-cicd.ts b/scripts/src/hwlab-node-web-sentinel-cicd.ts index b288d0e6..2992b72a 100644 --- a/scripts/src/hwlab-node-web-sentinel-cicd.ts +++ b/scripts/src/hwlab-node-web-sentinel-cicd.ts @@ -82,6 +82,7 @@ import { type WebProbeSentinelDashboardAction, type WebProbeSentinelOptions, type WebProbeSentinelReportView, + type WebProbeSentinelSourceAuthority, } from "./hwlab-node-web-sentinel-cicd-shared"; import { applySentinelArgoApplication, @@ -108,6 +109,8 @@ export type { WebProbeSentinelDashboardAction, WebProbeSentinelOptions, WebProbeSentinelReportView, + WebProbeSentinelSourceAuthority, + WebProbeSentinelSourceOverrideOptions, } from "./hwlab-node-web-sentinel-cicd-shared"; export { arrayAt, @@ -143,13 +146,13 @@ export interface SentinelSourceOverride { readonly commit: string; readonly stageRef?: string | null; readonly mirrorCommit?: string | null; - readonly sourceAuthority: "git-mirror-snapshot"; + readonly sourceAuthority: WebProbeSentinelSourceAuthority; } export function runWebProbeSentinelCommand(spec: HwlabRuntimeLaneSpec, options: WebProbeSentinelOptions): RenderedCliResult { if (options.kind === "config") return withWebProbeSentinelConfigRendered(webProbeSentinelConfigPlan(spec, options.action, options.sentinelId)); requireSentinelIdForRegistry(spec, options.sentinelId, `web-probe sentinel ${options.kind}`); - const state = loadSentinelCicdState(spec, options.sentinelId, options.timeoutSeconds, sentinelSourceResolveMode(options)); + const state = loadSentinelCicdState(spec, options.sentinelId, options.timeoutSeconds, sentinelSourceResolveMode(options), sentinelSourceOverrideFromOptions(options)); if (options.kind === "image") return runSentinelImage(state, options); if (options.kind === "control-plane") return runSentinelControlPlane(state, options); if (options.kind === "publish") return runSentinelPublishCurrent(state, options); @@ -159,6 +162,19 @@ export function runWebProbeSentinelCommand(spec: HwlabRuntimeLaneSpec, options: return runSentinelReport(state, options); } +function sentinelSourceOverrideFromOptions(options: WebProbeSentinelOptions): SentinelSourceOverride | null { + if (options.kind !== "image" && options.kind !== "control-plane" && options.kind !== "publish") return null; + const override = options.sourceOverride; + if (override.sourceCommit === null && override.sourceStageRef === null && override.sourceMirrorCommit === null && override.sourceAuthority === null) return null; + if (override.sourceCommit === null) throw new Error("--source-commit is required when overriding web-probe sentinel source"); + return { + commit: override.sourceCommit, + stageRef: override.sourceStageRef, + mirrorCommit: override.sourceMirrorCommit, + sourceAuthority: override.sourceAuthority ?? "git-mirror-snapshot", + }; +} + function sentinelSourceResolveMode(options: WebProbeSentinelOptions): SourceResolveMode { if (options.kind === "image" && options.action === "build" && options.confirm && options.wait) return "sync"; if (options.kind === "control-plane" && options.action === "trigger-current" && options.confirm && options.wait) return "sync"; @@ -347,6 +363,51 @@ function runSentinelPublishCurrentConfirmedInner(state: SentinelCicdState, optio lane: state.spec.lane, sentinelId: state.sentinelId, }); + if (process.env.KUBERNETES_SERVICE_HOST && process.env.KUBERNETES_SERVICE_PORT) { + const publish = runSentinelPublishJob(state, true, Math.max(1, remainingBudgetSeconds()), options.rerun); + const elapsedMs = Date.now() - startedAt; + const ok = record(publish).ok === true; + const result = { + ok, + command, + node: state.spec.nodeId, + lane: state.spec.lane, + sentinelId: state.sentinelId, + mode: "pac-publish-only", + mutation: true, + specRef: SPEC_REF, + source: state.sourceHead, + image: state.image, + pipelineRun: record(publish).jobName ?? sentinelPipelineRunName(state, options.rerun), + controlPlane: { + ok, + mode: "pac-publish-only", + mutation: true, + source: state.sourceHead, + publish, + argo: { + namespace: stringAt(state.cicd, "argo.namespace"), + applicationName: stringAt(state.cicd, "argo.applicationName"), + }, + valuesRedacted: true, + }, + health: { ok: false, skipped: true, reason: "pac-publish-only-runtime-closeout-read-by-platform-infra-status", valuesRedacted: true }, + elapsedMs, + timings: publishCurrentStageTimings({ publish }, null, elapsedMs), + slowStages: publishCurrentSlowStages(state, publishCurrentStageTimings({ publish }, null, elapsedMs), budgetSeconds), + withinBudget: elapsedMs <= budgetSeconds * 1000, + warnings: [ + "PaC in-cluster publish-current submits the native sentinel publish PipelineRun and leaves Argo/runtime closeout to platform-infra pipelines-as-code status.", + ...sentinelCicdElapsedWarnings(record(publish).elapsedMs, "sentinel publish", controlPlaneWaitWarningSeconds(state)), + ...sentinelRemoteJobTimeoutWarnings(publish, "sentinel publish"), + ], + blocker: ok ? null : { code: "sentinel-pac-publish-not-ready", reason: "sentinel publish PipelineRun did not complete successfully" }, + recoveryNext: controlPlaneRecoveryNext(state, ok, publish, null, false), + next: publishCurrentNext(state), + valuesRedacted: true, + }; + return rendered(ok, command, renderPublishCurrentResult(result)); + } if (state.configReady && state.sourceHead.ok && remainingBudgetSeconds() >= 5) { interruptContext.phase = "already-current-registry-probe"; const registryProbe = probeImageRegistry(state, Math.max(1, Math.min(remainingBudgetSeconds(), 5))); @@ -377,6 +438,7 @@ function runSentinelPublishCurrentConfirmedInner(state: SentinelCicdState, optio wait: true, timeoutSeconds: Math.max(1, remainingBudgetSeconds()), rerun: options.rerun, + sourceOverride: options.sourceOverride, }); let health: Record; let healthElapsedMs: number | null = null; @@ -632,7 +694,7 @@ function sourceHeadFromOverride(cicd: Record, override: Sentine timedOut: false, stdoutBytes: 0, stderrBytes: 0, - stdoutPreview: "source supplied by cicd branch-follower k8s git-mirror snapshot", + stdoutPreview: `source supplied by ${override.sourceAuthority}`, stderrPreview: "", }, }; @@ -755,8 +817,8 @@ function validateSentinelSourceAuthority(cicd: Record): void { const allowHostGit = booleanAt(cicd, "sourceAuthority.allowHostGit"); const allowGithubDirectInPipeline = booleanAt(cicd, "sourceAuthority.allowGithubDirectInPipeline"); const missingObjectPolicy = stringAt(cicd, "sourceSnapshot.missingObjectPolicy"); - if (mode !== "gitMirrorSnapshot") throw new Error("sourceAuthority.mode must be gitMirrorSnapshot"); - if (resolver !== "k8s-git-mirror") throw new Error("sourceAuthority.resolver must be k8s-git-mirror"); + const supported = (mode === "gitMirrorSnapshot" && resolver === "k8s-git-mirror") || (mode === "giteaSnapshot" && resolver === "gitea-mirror"); + if (!supported) throw new Error("sourceAuthority must be gitMirrorSnapshot/k8s-git-mirror or giteaSnapshot/gitea-mirror"); if (allowHostGit !== false) throw new Error("sourceAuthority.allowHostGit must be false"); if (allowGithubDirectInPipeline !== false) throw new Error("sourceAuthority.allowGithubDirectInPipeline must be false"); if (missingObjectPolicy !== "fail-fast") throw new Error("sourceSnapshot.missingObjectPolicy must be fail-fast"); @@ -1597,8 +1659,13 @@ function sentinelControlPlaneConfirmedResult(state: SentinelCicdState, options: const deadline = startedAt + waitBudgetSeconds * 1000; const remainingCicdWaitSeconds = () => strictRemainingSeconds(deadline, waitBudgetSeconds); const remainingCommandSeconds = () => Math.max(1, remainingCicdWaitSeconds()); - const sourceMirrorProbe = applyOnly ? null : probeSourceMirror(state, Math.min(remainingCommandSeconds(), 20)); - const sourceMirrorSync = applyOnly ? null : record(sourceMirrorProbe).ok === true ? sentinelSourceMirrorAlreadyPresentResult(state, sourceMirrorProbe) : runSentinelSourceMirrorSyncJob(state, remainingCommandSeconds()); + const giteaSource = state.sourceHead.sourceAuthority === "gitea-snapshot" || state.sourceHead.sourceAuthority === "gitea-cache"; + const sourceMirrorProbe = applyOnly || giteaSource ? null : probeSourceMirror(state, Math.min(remainingCommandSeconds(), 20)); + const sourceMirrorSync = applyOnly + ? null + : giteaSource + ? sentinelSourceMirrorAlreadyPresentResult(state, { ok: true, mode: "gitea-controlled-mirror-snapshot", stageRef: state.sourceHead.stageRef, sourceCommit: state.sourceHead.commit, valuesRedacted: true }) + : record(sourceMirrorProbe).ok === true ? sentinelSourceMirrorAlreadyPresentResult(state, sourceMirrorProbe) : runSentinelSourceMirrorSyncJob(state, remainingCommandSeconds()); const sourceMirrorReady = applyOnly || record(sourceMirrorSync).ok === true; const publish = applyOnly ? null diff --git a/scripts/src/hwlab-node/web-probe-observe.ts b/scripts/src/hwlab-node/web-probe-observe.ts index 5cf01423..7e8a5a9f 100644 --- a/scripts/src/hwlab-node/web-probe-observe.ts +++ b/scripts/src/hwlab-node/web-probe-observe.ts @@ -23,7 +23,7 @@ import { parseNodeWebProbeObserveCollectView, type NodeWebProbeObserveCollectVie import { withWebObserveCommandRendered, withWebObserveStatusRendered } from "../hwlab-node-web-observe-render"; import { buildWebObserveWrapperForObserveOptions, webObserveWrapperStateDirFromStatus } from "../hwlab-node-web-observe-wrapper"; import { renderWebObserveWrapperContract } from "../hwlab-node-web-observe-wrapper-render"; -import { runWebProbeSentinelCommand, type WebProbeSentinelDashboardAction, type WebProbeSentinelOptions, type WebProbeSentinelReportView } from "../hwlab-node-web-sentinel-cicd"; +import { runWebProbeSentinelCommand, type WebProbeSentinelDashboardAction, type WebProbeSentinelOptions, type WebProbeSentinelReportView, type WebProbeSentinelSourceAuthority, type WebProbeSentinelSourceOverrideOptions } from "../hwlab-node-web-sentinel-cicd"; import { hwlabNodeHelp, hwlabNodeObservabilityHelp, hwlabNodeWebProbeHelp } from "../hwlab-node-help"; import { compactWebProbeResult, compactWebProbeScriptResult } from "../hwlab-node-web-probe-summary"; import { nodeObservabilityRecordingRuleExpression, nodeObservabilityRecordingRuleSummaries, nodeObservabilityWarningAlertExpression, nodeObservabilityWarningAlertSummaries } from "../hwlab-node-observability-promql"; @@ -77,6 +77,10 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe "--timeout-ms", "--wait-timeout-ms", "--command-timeout-seconds", + "--source-commit", + "--source-stage-ref", + "--source-mirror-commit", + "--source-authority", ]), new Set(["--dry-run", "--confirm", "--wait", "--rerun", "--quick-verify", "--raw", "--full", "--latest", "--full-page", "--no-full-page"])); const node = requiredOption(args, "--node"); assertNodeId(node); @@ -89,21 +93,22 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe const dryRun = args.includes("--dry-run"); if (confirm && dryRun) throw new Error("web-probe sentinel accepts only one of --confirm or --dry-run"); const timeoutSeconds = positiveIntegerOption(args, "--timeout-seconds", 900, 3600); + const sourceOverride = parseWebProbeSentinelSourceOverride(args); let sentinel: WebProbeSentinelOptions; if (sentinelActionRaw === "plan" || sentinelActionRaw === "status") { sentinel = { kind: "config", action: sentinelActionRaw, node, lane, sentinelId, dryRun }; } else if (sentinelActionRaw === "image") { const imageAction = args[1]; if (imageAction !== "status" && imageAction !== "build") throw new Error("web-probe sentinel image usage: image status|build --node NODE --lane vNN [--dry-run|--confirm]"); - sentinel = { kind: "image", action: imageAction, node, lane, sentinelId, dryRun: imageAction === "build" ? dryRun || !confirm : dryRun, confirm, wait: args.includes("--wait"), timeoutSeconds }; + sentinel = { kind: "image", action: imageAction, node, lane, sentinelId, dryRun: imageAction === "build" ? dryRun || !confirm : dryRun, confirm, wait: args.includes("--wait"), timeoutSeconds, sourceOverride }; } else if (sentinelActionRaw === "control-plane") { const controlPlaneAction = args[1]; if (controlPlaneAction !== "plan" && controlPlaneAction !== "apply" && controlPlaneAction !== "status" && controlPlaneAction !== "trigger-current") { throw new Error("web-probe sentinel control-plane usage: control-plane plan|apply|status|trigger-current --node NODE --lane vNN [--dry-run|--confirm]"); } - sentinel = { kind: "control-plane", action: controlPlaneAction, node, lane, sentinelId, dryRun: controlPlaneAction === "apply" || controlPlaneAction === "trigger-current" ? dryRun || !confirm : dryRun, confirm, wait: args.includes("--wait"), timeoutSeconds, rerun: args.includes("--rerun") }; + sentinel = { kind: "control-plane", action: controlPlaneAction, node, lane, sentinelId, dryRun: controlPlaneAction === "apply" || controlPlaneAction === "trigger-current" ? dryRun || !confirm : dryRun, confirm, wait: args.includes("--wait"), timeoutSeconds, rerun: args.includes("--rerun"), sourceOverride }; } else if (sentinelActionRaw === "publish-current") { - sentinel = { kind: "publish", action: "publish-current", node, lane, sentinelId, dryRun: dryRun || !confirm, confirm, wait: args.includes("--wait"), timeoutSeconds, rerun: args.includes("--rerun") }; + sentinel = { kind: "publish", action: "publish-current", node, lane, sentinelId, dryRun: dryRun || !confirm, confirm, wait: args.includes("--wait"), timeoutSeconds, rerun: args.includes("--rerun"), sourceOverride }; } else if (sentinelActionRaw === "maintenance") { const maintenanceAction = args[1]; if (maintenanceAction !== "status" && maintenanceAction !== "start" && maintenanceAction !== "stop") { @@ -180,6 +185,22 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe }; } +function parseWebProbeSentinelSourceOverride(args: string[]): WebProbeSentinelSourceOverrideOptions { + const sourceCommit = optionValue(args, "--source-commit") ?? null; + const sourceStageRef = optionValue(args, "--source-stage-ref") ?? null; + const sourceMirrorCommit = optionValue(args, "--source-mirror-commit") ?? null; + const sourceAuthorityRaw = optionValue(args, "--source-authority") ?? null; + if (sourceCommit !== null && !/^[0-9a-f]{40}$/iu.test(sourceCommit)) throw new Error(`--source-commit must be a full git sha, got ${sourceCommit}`); + if (sourceMirrorCommit !== null && !/^[0-9a-f]{40}$/iu.test(sourceMirrorCommit)) throw new Error(`--source-mirror-commit must be a full git sha, got ${sourceMirrorCommit}`); + if (sourceStageRef !== null && !sourceStageRef.startsWith("refs/")) throw new Error(`--source-stage-ref must be a git ref, got ${sourceStageRef}`); + let sourceAuthority: WebProbeSentinelSourceAuthority | null = null; + if (sourceAuthorityRaw !== null) { + if (sourceAuthorityRaw !== "git-mirror-snapshot" && sourceAuthorityRaw !== "gitea-snapshot") throw new Error("--source-authority must be git-mirror-snapshot or gitea-snapshot"); + sourceAuthority = sourceAuthorityRaw; + } + return { sourceCommit, sourceStageRef, sourceMirrorCommit, sourceAuthority }; +} + function parseWebProbeSentinelDashboardAction(value: string | undefined): WebProbeSentinelDashboardAction { if (value === "verify" || value === "screenshot" || value === "trigger") return value; throw new Error("web-probe sentinel dashboard usage: dashboard verify|screenshot|trigger --node NODE --lane vNN --sentinel "); diff --git a/scripts/src/platform-infra-pipelines-as-code-remote.sh b/scripts/src/platform-infra-pipelines-as-code-remote.sh index aa7a9705..8df140b0 100644 --- a/scripts/src/platform-infra-pipelines-as-code-remote.sh +++ b/scripts/src/platform-infra-pipelines-as-code-remote.sh @@ -109,8 +109,8 @@ metadata: namespace: $UNIDESK_PAC_TARGET_NAMESPACE labels: app.kubernetes.io/managed-by: unidesk - app.kubernetes.io/part-of: agentrun - unidesk.ai/spec: GH-1552 + app.kubernetes.io/part-of: $UNIDESK_PAC_PART_OF + unidesk.ai/spec: $UNIDESK_PAC_SPEC spec: url: $UNIDESK_PAC_REPOSITORY_URL git_provider: @@ -128,6 +128,45 @@ $(printf '%s' "$UNIDESK_PAC_PARAMS_JSON" | node -e 'const fs=require("fs"); cons EOF } +consumer_rbac_manifest() { + cat < "$tmp" @@ -156,6 +195,7 @@ apply_action() { fi apply_release kubectl create ns "$UNIDESK_PAC_TARGET_NAMESPACE" --dry-run=client -o yaml | kubectl apply -f - >/dev/null + consumer_rbac_manifest | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER" -f - >/dev/null token=$(ensure_token) test -n "$token" kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" create secret generic "$UNIDESK_PAC_SECRET_NAME" \ @@ -205,7 +245,7 @@ const rows = (data.items || []) startTime: item.status?.startTime || null, completionTime: item.status?.completionTime || null, durationSeconds: durationSeconds(item), - sourceCommit: item.metadata.labels?.['pipelinesascode.tekton.dev/sha'] || item.metadata.labels?.['agentrun.pikastech.local/source-commit'] || null, + sourceCommit: item.metadata.labels?.['pipelinesascode.tekton.dev/sha'] || item.metadata.labels?.['agentrun.pikastech.local/source-commit'] || item.metadata.labels?.['unidesk.ai/source-commit'] || item.metadata.labels?.['hwlab.pikastech.local/source-commit'] || null, }; }); process.stdout.write(JSON.stringify(rows)); @@ -262,10 +302,21 @@ for (const line of lines) { } const publish = [...records].reverse().find((item) => item.phase === 'gitops-publish' || item.gitopsCommit); const image = publish || [...records].reverse().find((item) => item.imageStatus || item.status === 'reused' || item.status === 'built'); +function digestOf(item) { + if (item.digest) return item.digest; + const ref = item.digestRef || ''; + const index = String(ref).indexOf('@'); + return index >= 0 ? String(ref).slice(index + 1) : null; +} +function envReuseOf(item) { + const env = item.envReuse || {}; + return env.dependencyReuse || env.mode || item.envReuseStatus || null; +} process.stdout.write(JSON.stringify(image ? { - imageStatus: image.imageStatus || image.status || null, + imageStatus: image.imageStatus || image.status || (image.digestRef ? 'built' : null), envIdentity: image.envIdentity || null, - digest: image.digest || null, + envReuse: envReuseOf(image), + digest: digestOf(image), gitopsCommit: image.gitopsCommit || null, sourceCommit: image.sourceCommit || null, valuesPrinted: false, diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index 14536f28..f864b7d3 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -39,6 +39,7 @@ interface PacConfig { }; defaults: { targetId: string; + consumerId: string; }; release: { version: string; @@ -67,7 +68,12 @@ interface PacConfig { branch: string; }; }; - repository: { + repositories: PacRepository[]; + consumers: PacConsumer[]; +} + +interface PacRepository { + id: string; name: string; namespace: string; providerType: "gitea"; @@ -80,8 +86,10 @@ interface PacConfig { webhookSecretKey: string; concurrencyLimit: number; params: Record; - }; - consumer: { +} + +interface PacConsumer { + id: string; node: string; lane: string; namespace: string; @@ -89,11 +97,12 @@ interface PacConfig { pipelineRunPrefix: string; argoNamespace: string; argoApplication: string; - }; + repositoryRef: string; } interface CommonOptions { targetId: string | null; + consumerId: string | null; full: boolean; raw: boolean; } @@ -164,8 +173,13 @@ function readPacConfig(): PacConfig { const gitea = y.objectField(root, "gitea", ""); const admin = y.objectField(gitea, "admin", "gitea"); const webhook = y.objectField(gitea, "webhook", "gitea"); - const repository = y.objectField(root, "repository", ""); - const consumer = y.objectField(root, "consumer", ""); + const repositories = root.repositories === undefined + ? [parseRepository(y.objectField(root, "repository", ""), "repository")] + : y.arrayOfRecords(root.repositories, "repositories").map((item, index) => parseRepository(item, `repositories[${index}]`)); + const consumers = root.consumers === undefined + ? [parseConsumer(y.objectField(root, "consumer", ""), "consumer", repositories[0]?.id ?? "default")] + : y.arrayOfRecords(root.consumers, "consumers").map((item, index) => parseConsumer(item, `consumers[${index}]`, repositories[0]?.id ?? "default")); + const defaults = y.objectField(root, "defaults", ""); const parsed: PacConfig = { version: y.integerField(root, "version", ""), kind: "platform-infra-pipelines-as-code", @@ -176,7 +190,8 @@ function readPacConfig(): PacConfig { relatedIssues: y.numberArrayField(y.objectField(root, "metadata", ""), "relatedIssues", "metadata"), }, defaults: { - targetId: y.stringField(y.objectField(root, "defaults", ""), "targetId", "defaults"), + targetId: y.stringField(defaults, "targetId", "defaults"), + consumerId: typeof defaults.consumerId === "string" && defaults.consumerId.length > 0 ? defaults.consumerId : consumers[0]?.id ?? "default", }, release: { version: y.stringField(release, "version", "release"), @@ -205,34 +220,47 @@ function readPacConfig(): PacConfig { branch: y.stringField(webhook, "branch", "gitea.webhook"), }, }, - repository: { - name: y.kubernetesNameField(repository, "name", "repository"), - namespace: y.kubernetesNameField(repository, "namespace", "repository"), - providerType: y.enumField(repository, "providerType", "repository", ["gitea"] as const), - url: urlField(repository, "url", "repository"), - cloneUrl: urlField(repository, "cloneUrl", "repository"), - owner: y.stringField(repository, "owner", "repository"), - repo: y.stringField(repository, "repo", "repository"), - secretName: y.kubernetesNameField(repository, "secretName", "repository"), - tokenKey: y.stringField(repository, "tokenKey", "repository"), - webhookSecretKey: y.stringField(repository, "webhookSecretKey", "repository"), - concurrencyLimit: positiveInteger(repository, "concurrencyLimit", "repository"), - params: stringRecord(y.objectField(repository, "params", "repository"), "repository.params"), - }, - consumer: { - node: y.stringField(consumer, "node", "consumer"), - lane: y.stringField(consumer, "lane", "consumer"), - namespace: y.kubernetesNameField(consumer, "namespace", "consumer"), - pipeline: y.kubernetesNameField(consumer, "pipeline", "consumer"), - pipelineRunPrefix: y.stringField(consumer, "pipelineRunPrefix", "consumer"), - argoNamespace: y.kubernetesNameField(consumer, "argoNamespace", "consumer"), - argoApplication: y.kubernetesNameField(consumer, "argoApplication", "consumer"), - }, + repositories, + consumers, }; validateConfig(parsed); return parsed; } +function parseRepository(repository: Record, path: string): PacRepository { + const name = y.kubernetesNameField(repository, "name", path); + return { + id: typeof repository.id === "string" && repository.id.length > 0 ? repository.id : name, + name, + namespace: y.kubernetesNameField(repository, "namespace", path), + providerType: y.enumField(repository, "providerType", path, ["gitea"] as const), + url: urlField(repository, "url", path), + cloneUrl: urlField(repository, "cloneUrl", path), + owner: y.stringField(repository, "owner", path), + repo: y.stringField(repository, "repo", path), + secretName: y.kubernetesNameField(repository, "secretName", path), + tokenKey: y.stringField(repository, "tokenKey", path), + webhookSecretKey: y.stringField(repository, "webhookSecretKey", path), + concurrencyLimit: positiveInteger(repository, "concurrencyLimit", path), + params: stringRecord(y.objectField(repository, "params", path), `${path}.params`), + }; +} + +function parseConsumer(consumer: Record, path: string, defaultRepositoryRef: string): PacConsumer { + const id = typeof consumer.id === "string" && consumer.id.length > 0 ? consumer.id : `${y.stringField(consumer, "node", path)}-${y.stringField(consumer, "lane", path)}`; + return { + id, + node: y.stringField(consumer, "node", path), + lane: y.stringField(consumer, "lane", path), + namespace: y.kubernetesNameField(consumer, "namespace", path), + pipeline: y.kubernetesNameField(consumer, "pipeline", path), + pipelineRunPrefix: y.stringField(consumer, "pipelineRunPrefix", path), + argoNamespace: y.kubernetesNameField(consumer, "argoNamespace", path), + argoApplication: y.kubernetesNameField(consumer, "argoApplication", path), + repositoryRef: typeof consumer.repositoryRef === "string" && consumer.repositoryRef.length > 0 ? consumer.repositoryRef : defaultRepositoryRef, + }; +} + function parseTarget(record: Record, index: number): PacTarget { const path = `targets[${index}]`; return { @@ -247,10 +275,22 @@ function parseTarget(record: Record, index: number): PacTarget function validateConfig(config: PacConfig): void { if (config.version !== 1) throw new Error(`${configLabel}.version must be 1`); resolveTarget(config, config.defaults.targetId); - if (config.repository.providerType !== "gitea") throw new Error(`${configLabel}.repository.providerType must be gitea`); if (config.release.waitTimeoutSeconds > 55) throw new Error(`${configLabel}.release.waitTimeoutSeconds must fit the 60s trans budget`); - if (config.repository.namespace !== config.consumer.namespace) throw new Error(`${configLabel}.repository.namespace must match consumer.namespace`); - if (new URL(config.repository.cloneUrl).origin !== new URL(config.gitea.internalBaseUrl).origin) throw new Error(`${configLabel}.repository.cloneUrl must use the configured internal Gitea base URL`); + resolveConsumer(config, config.defaults.consumerId); + const ids = new Set(); + for (const repository of config.repositories) { + if (ids.has(repository.id)) throw new Error(`${configLabel}.repositories id must be unique: ${repository.id}`); + ids.add(repository.id); + if (repository.providerType !== "gitea") throw new Error(`${configLabel}.repositories.${repository.id}.providerType must be gitea`); + if (new URL(repository.cloneUrl).origin !== new URL(config.gitea.internalBaseUrl).origin) throw new Error(`${configLabel}.repositories.${repository.id}.cloneUrl must use the configured internal Gitea base URL`); + } + const consumerIds = new Set(); + for (const consumer of config.consumers) { + if (consumerIds.has(consumer.id)) throw new Error(`${configLabel}.consumers id must be unique: ${consumer.id}`); + consumerIds.add(consumer.id); + const repository = resolveRepository(config, consumer.repositoryRef); + if (repository.namespace !== consumer.namespace) throw new Error(`${configLabel}.consumers.${consumer.id}.namespace must match repository namespace`); + } if (!config.gitea.webhook.events.includes("push")) throw new Error(`${configLabel}.gitea.webhook.events must include push`); } @@ -262,30 +302,48 @@ function resolveTarget(config: PacConfig, targetId: string | null): PacTarget { return target; } +function resolveConsumer(config: PacConfig, consumerId: string | null): PacConsumer { + const id = consumerId ?? config.defaults.consumerId; + const consumer = config.consumers.find((item) => item.id.toLowerCase() === id.toLowerCase()); + if (consumer === undefined) throw new Error(`unknown Pipelines-as-Code consumer ${id}; known consumers: ${config.consumers.map((item) => item.id).join(", ")}`); + return consumer; +} + +function resolveRepository(config: PacConfig, repositoryRef: string): PacRepository { + const repository = config.repositories.find((item) => item.id === repositoryRef || item.name === repositoryRef); + if (repository === undefined) throw new Error(`unknown Pipelines-as-Code repository ${repositoryRef}; known repositories: ${config.repositories.map((item) => item.id).join(", ")}`); + return repository; +} + function plan(options: CommonOptions): Record { const pac = readPacConfig(); const target = resolveTarget(pac, options.targetId); + const consumer = resolveConsumer(pac, options.consumerId); + const repository = resolveRepository(pac, consumer.repositoryRef); const secrets = secretSummaries(pac); return { ok: true, action: "platform-infra-pipelines-as-code-plan", mutation: false, target: targetSummary(target), - config: configSummary(pac), + config: configSummary(pac, consumer, repository), release: pac.release, - repository: repositorySummary(pac), + repository: repositorySummary(repository), + consumer, secrets, - policy: policyChecks(pac), - next: nextCommands(target.id), + policy: policyChecks(repository), + next: nextCommands(target.id, consumer.id, pac.defaults.consumerId), }; } async function apply(config: UniDeskConfig, options: ApplyOptions): Promise> { const pac = readPacConfig(); const target = resolveTarget(pac, options.targetId); + const consumer = resolveConsumer(pac, options.consumerId); + const repository = resolveRepository(pac, consumer.repositoryRef); const secrets = ensureSecrets(pac, !options.dryRun, options.dryRun); const releaseManifest = options.dryRun ? "" : await fetchReleaseManifest(pac); - const result = await capture(config, target.route, ["sh"], remoteScript("apply", pac, target, options, secrets, releaseManifest)); + const result = await capture(config, target.route, ["sh"], remoteScript("apply", pac, target, repository, consumer, options, secrets, releaseManifest)); const parsed = parseJsonOutput(result.stdout); return { ok: result.exitCode === 0 && parsed?.ok === true, @@ -293,20 +351,23 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise> { const pac = readPacConfig(); const target = resolveTarget(pac, options.targetId); + const consumer = resolveConsumer(pac, options.consumerId); + const repository = resolveRepository(pac, consumer.repositoryRef); const secrets = ensureSecrets(pac, false); - const result = await capture(config, target.route, ["sh"], remoteScript("status", pac, target, { ...options, confirm: false, dryRun: true, wait: false }, secrets, "")); + const result = await capture(config, target.route, ["sh"], remoteScript("status", pac, target, repository, consumer, { ...options, confirm: false, dryRun: true, wait: false }, secrets, "")); const parsed = parseJsonOutput(result.stdout); const summary = parsed === null ? null : statusSummary(parsed); return { @@ -314,27 +375,31 @@ async function status(config: UniDeskConfig, options: CommonOptions): Promise> { const pac = readPacConfig(); const target = resolveTarget(pac, options.targetId); + const consumer = resolveConsumer(pac, options.consumerId); + const repository = resolveRepository(pac, consumer.repositoryRef); if (!options.confirm) return { ok: false, action: "platform-infra-pipelines-as-code-webhook-test", mutation: false, mode: "missing-confirm", error: "webhook-test requires --confirm" }; const secrets = ensureSecrets(pac, false); - const result = await capture(config, target.route, ["sh"], remoteScript("webhook-test", pac, target, { ...options, dryRun: false, wait: false }, secrets, "")); + const result = await capture(config, target.route, ["sh"], remoteScript("webhook-test", pac, target, repository, consumer, { ...options, dryRun: false, wait: false }, secrets, "")); const parsed = parseJsonOutput(result.stdout); return { ok: result.exitCode === 0 && parsed?.ok === true, action: "platform-infra-pipelines-as-code-webhook-test", mutation: true, target: targetSummary(target), + consumer, remote: parsed ?? compactCapture(result, { full: true }), - next: nextCommands(target.id), + next: nextCommands(target.id, consumer.id, pac.defaults.consumerId), }; } @@ -346,13 +411,14 @@ async function fetchReleaseManifest(pac: PacConfig): Promise { return text; } -function remoteScript(action: "apply" | "status" | "webhook-test", pac: PacConfig, target: PacTarget, options: ApplyOptions | WebhookTestOptions, secrets: SecretMaterial, releaseManifest: string): string { +function remoteScript(action: "apply" | "status" | "webhook-test", pac: PacConfig, target: PacTarget, repository: PacRepository, consumer: PacConsumer, options: ApplyOptions | WebhookTestOptions, secrets: SecretMaterial, releaseManifest: string): string { const webhookUrl = `${pac.gitea.internalBaseUrl.replace(/\/+$/u, "").replace(/gitea-http\.[^.]+\.svc\.cluster\.local:3000/u, `${pac.release.controllerServiceName}.${pac.release.namespace}.svc.cluster.local:${pac.release.controllerServicePort}`)}`; const env: Record = { UNIDESK_PAC_ACTION: action, UNIDESK_PAC_FIELD_MANAGER: fieldManager, UNIDESK_PAC_TARGET_ID: target.id, - UNIDESK_PAC_TARGET_NAMESPACE: target.namespace, + UNIDESK_PAC_TARGET_NAMESPACE: consumer.namespace, + UNIDESK_PAC_CONSUMER_ID: consumer.id, UNIDESK_PAC_RELEASE_NAMESPACE: pac.release.namespace, UNIDESK_PAC_RELEASE_MANIFEST_B64: Buffer.from(releaseManifest, "utf8").toString("base64"), UNIDESK_PAC_CONTROLLER_SERVICE_NAME: pac.release.controllerServiceName, @@ -362,20 +428,22 @@ function remoteScript(action: "apply" | "status" | "webhook-test", pac: PacConfi UNIDESK_PAC_GITEA_ADMIN_USERNAME: secrets.adminUsername, UNIDESK_PAC_GITEA_ADMIN_PASSWORD: secrets.adminPassword, UNIDESK_PAC_GITEA_API_USERNAME: pac.gitea.admin.apiUsername, - UNIDESK_PAC_GITEA_OWNER: pac.repository.owner, - UNIDESK_PAC_GITEA_REPO: pac.repository.repo, + UNIDESK_PAC_GITEA_OWNER: repository.owner, + UNIDESK_PAC_GITEA_REPO: repository.repo, UNIDESK_PAC_WEBHOOK_URL: webhookUrl, UNIDESK_PAC_WEBHOOK_SECRET: secrets.webhookSecret, - UNIDESK_PAC_REPOSITORY_NAME: pac.repository.name, - UNIDESK_PAC_REPOSITORY_URL: pac.repository.url, - UNIDESK_PAC_SECRET_NAME: pac.repository.secretName, - UNIDESK_PAC_TOKEN_KEY: pac.repository.tokenKey, - UNIDESK_PAC_WEBHOOK_SECRET_KEY: pac.repository.webhookSecretKey, - UNIDESK_PAC_CONCURRENCY_LIMIT: String(pac.repository.concurrencyLimit), - UNIDESK_PAC_PARAMS_JSON: JSON.stringify(pac.repository.params), - UNIDESK_PAC_PIPELINE_RUN_PREFIX: pac.consumer.pipelineRunPrefix, - UNIDESK_PAC_ARGO_NAMESPACE: pac.consumer.argoNamespace, - UNIDESK_PAC_ARGO_APPLICATION: pac.consumer.argoApplication, + UNIDESK_PAC_REPOSITORY_NAME: repository.name, + UNIDESK_PAC_REPOSITORY_URL: repository.url, + UNIDESK_PAC_SECRET_NAME: repository.secretName, + UNIDESK_PAC_TOKEN_KEY: repository.tokenKey, + UNIDESK_PAC_WEBHOOK_SECRET_KEY: repository.webhookSecretKey, + UNIDESK_PAC_CONCURRENCY_LIMIT: String(repository.concurrencyLimit), + UNIDESK_PAC_PARAMS_JSON: JSON.stringify(repository.params), + UNIDESK_PAC_PIPELINE_RUN_PREFIX: consumer.pipelineRunPrefix, + UNIDESK_PAC_ARGO_NAMESPACE: consumer.argoNamespace, + UNIDESK_PAC_ARGO_APPLICATION: consumer.argoApplication, + UNIDESK_PAC_PART_OF: consumer.id.startsWith("sentinel") ? "hwlab-web-probe-sentinel" : "agentrun", + UNIDESK_PAC_SPEC: pac.metadata.relatedIssues.includes(1555) ? "GH-1552/GH-1555" : pac.metadata.spec, }; const exports = Object.entries(env).map(([key, value]) => `export ${key}=${shQuote(value)}`).join("\n"); return `${exports}\n${readFileSync(remoteScriptFile, "utf8")}`; @@ -470,11 +538,11 @@ function statusSummary(payload: Record): Record> { +function policyChecks(repository: PacRepository): Array> { return [ { name: "single-path", ok: true, detail: "Gitea webhook -> Pipelines-as-Code -> Tekton -> Argo/k8s runtime; no Gitea Actions/act_runner/branch-follower fallback." }, - { name: "yaml-source-of-truth", ok: true, detail: `${configLabel} owns PaC release, Repository CR, Gitea webhook and AgentRun JD01 v0.2 params.` }, - { name: "gitea-internal-source", ok: pac.repository.cloneUrl.includes(".svc.cluster.local"), detail: "Tekton source clone uses the internal k3s Gitea service URL." }, + { name: "yaml-source-of-truth", ok: true, detail: `${configLabel} owns PaC release, Repository CR, Gitea webhook and consumer params.` }, + { name: "gitea-internal-source", ok: repository.cloneUrl.includes(".svc.cluster.local"), detail: "Tekton source clone uses the internal k3s Gitea service URL." }, { name: "runtime-zero-docker", ok: true, detail: "Runtime starts at k8s pulling already-built images; CI build may use Tekton/BuildKit." }, ]; } @@ -483,51 +551,60 @@ function targetSummary(target: PacTarget): Record { return { id: target.id, route: target.route, namespace: target.namespace, role: target.role }; } -function configSummary(pac: PacConfig): Record { +function configSummary(pac: PacConfig, consumer: PacConsumer, repository: PacRepository): Record { return { path: configLabel, metadata: pac.metadata, release: pac.release, gitea: { configRef: pac.gitea.configRef, internalBaseUrl: pac.gitea.internalBaseUrl, webhookBranch: pac.gitea.webhook.branch }, - repository: repositorySummary(pac), - consumer: pac.consumer, + repositories: pac.repositories.map(repositorySummary), + consumers: pac.consumers, + repository: repositorySummary(repository), + consumer, valuesPrinted: false, }; } -function compactConfigSummary(pac: PacConfig): Record { +function compactConfigSummary(pac: PacConfig, consumer: PacConsumer, repository: PacRepository): Record { return { path: configLabel, releaseVersion: pac.release.version, - repository: pac.repository.name, - providerType: pac.repository.providerType, - sourceUrl: pac.repository.cloneUrl, - consumer: `${pac.consumer.node}/${pac.consumer.lane}`, - pipeline: pac.consumer.pipeline, + repository: repository.name, + providerType: repository.providerType, + sourceUrl: repository.cloneUrl, + consumer: `${consumer.node}/${consumer.lane}`, + consumerId: consumer.id, + pipeline: consumer.pipeline, valuesPrinted: false, }; } -function repositorySummary(pac: PacConfig): Record { +function repositorySummary(repository: PacRepository): Record { return { - name: pac.repository.name, - namespace: pac.repository.namespace, - providerType: pac.repository.providerType, - url: pac.repository.url, - cloneUrl: pac.repository.cloneUrl, - owner: pac.repository.owner, - repo: pac.repository.repo, - secretName: pac.repository.secretName, - concurrencyLimit: pac.repository.concurrencyLimit, - params: Object.keys(pac.repository.params).sort(), + id: repository.id, + name: repository.name, + namespace: repository.namespace, + providerType: repository.providerType, + url: repository.url, + cloneUrl: repository.cloneUrl, + owner: repository.owner, + repo: repository.repo, + secretName: repository.secretName, + concurrencyLimit: repository.concurrencyLimit, + params: Object.keys(repository.params).sort(), }; } -function nextCommands(targetId: string): Record { +function consumerSuffix(consumerId: string, defaultConsumerId: string): string { + return consumerId === defaultConsumerId ? "" : ` --consumer ${consumerId}`; +} + +function nextCommands(targetId: string, consumerId: string, defaultConsumerId: string): Record { + const suffix = consumerSuffix(consumerId, defaultConsumerId); return { - apply: `bun scripts/cli.ts platform-infra pipelines-as-code apply --target ${targetId} --confirm`, - status: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${targetId}`, - webhookTest: `bun scripts/cli.ts platform-infra pipelines-as-code webhook-test --target ${targetId} --confirm`, + apply: `bun scripts/cli.ts platform-infra pipelines-as-code apply --target ${targetId}${suffix} --confirm`, + status: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${targetId}${suffix}`, + webhookTest: `bun scripts/cli.ts platform-infra pipelines-as-code webhook-test --target ${targetId}${suffix} --confirm`, }; } @@ -535,18 +612,19 @@ function renderPlan(result: Record): RenderedCliResult { const target = record(result.target); const config = record(result.config); const repository = record(result.repository); + const consumer = record(result.consumer); const secrets = arrayRecords(result.secrets); const policy = arrayRecords(result.policy); const lines = [ "PLATFORM-INFRA PIPELINES-AS-CODE PLAN", - ...table(["TARGET", "NAMESPACE", "RELEASE", "REPOSITORY"], [[stringValue(target.id), stringValue(target.namespace), stringValue(record(config.release).version), stringValue(repository.name)]]), + ...table(["TARGET", "NAMESPACE", "RELEASE", "REPOSITORY", "CONSUMER"], [[stringValue(target.id), stringValue(repository.namespace), stringValue(record(config.release).version), stringValue(repository.name), stringValue(consumer.id)]]), "", "PATH", ...table(["STAGE", "AUTHORITY", "DETAIL"], [ ["source", "Gitea", stringValue(repository.cloneUrl)], ["trigger", "Pipelines-as-Code", "Gitea push webhook"], - ["ci", "Tekton", stringValue(record(config.consumer).pipeline)], - ["cd", "Argo", stringValue(record(config.consumer).argoApplication)], + ["ci", "Tekton", stringValue(consumer.pipeline)], + ["cd", "Argo", stringValue(consumer.argoApplication)], ]), "", "SECRETS", @@ -580,13 +658,14 @@ function renderApply(result: Record): RenderedCliResult { function renderStatus(result: Record): RenderedCliResult { const summary = record(result.summary); + const consumer = record(result.consumer); const latest = record(summary.latestPipelineRun); const taskRuns = arrayRecords(summary.taskRuns); const artifact = record(summary.artifact); const argo = record(summary.argo); const lines = [ "PLATFORM-INFRA PIPELINES-AS-CODE STATUS", - ...table(["READY", "CRD", "CONTROLLER", "WEBHOOKS", "REPOSITORY"], [[boolText(summary.ready), boolText(summary.crdPresent), stringValue(summary.controllerReady), stringValue(summary.webhookCount), compactLine(stringValue(summary.repositoryCondition))]]), + ...table(["CONSUMER", "READY", "CRD", "CONTROLLER", "WEBHOOKS", "REPOSITORY"], [[stringValue(consumer.id), boolText(summary.ready), boolText(summary.crdPresent), stringValue(summary.controllerReady), stringValue(summary.webhookCount), compactLine(stringValue(summary.repositoryCondition))]]), "", "LATEST PIPELINERUN", ...table(["NAME", "STATUS", "REASON", "DURATION_S", "SOURCE"], [[stringValue(latest.name), stringValue(latest.status), stringValue(latest.reason), stringValue(latest.durationSeconds), short(stringValue(latest.sourceCommit))]]), @@ -595,13 +674,13 @@ function renderStatus(result: Record): RenderedCliResult { ...(taskRuns.length === 0 ? ["-"] : table(["TASKRUN", "STATUS", "REASON", "DURATION_S"], taskRuns.map((item) => [short(stringValue(item.name), 56), stringValue(item.status), stringValue(item.reason), stringValue(item.durationSeconds)]))), "", "IMAGE / GITOPS", - ...table(["IMAGE_STATUS", "ENV_ID", "DIGEST", "GITOPS"], [[stringValue(artifact.imageStatus), stringValue(artifact.envIdentity), short(stringValue(artifact.digest), 18), short(stringValue(artifact.gitopsCommit))]]), + ...table(["IMAGE_STATUS", "ENV_REUSE", "ENV_ID", "DIGEST", "GITOPS"], [[stringValue(artifact.imageStatus), stringValue(artifact.envReuse), stringValue(artifact.envIdentity), short(stringValue(artifact.digest), 18), short(stringValue(artifact.gitopsCommit))]]), "", "ARGO", ...table(["SYNC", "HEALTH", "REVISION"], [[stringValue(argo.sync), stringValue(argo.health), short(stringValue(argo.revision))]]), "", "NEXT", - ` full: bun scripts/cli.ts platform-infra pipelines-as-code status --target ${stringValue(record(result.target).id)} --full`, + ` full: ${stringValue(record(result.next).status)} --full`, ]; return rendered(result, "platform-infra pipelines-as-code status", lines); } @@ -630,7 +709,7 @@ function parseApplyOptions(args: string[]): ApplyOptions { else if (arg === "--wait") wait = true; else { commonArgs.push(arg); - if (arg === "--target" || arg === "--node") { + if (arg === "--target" || arg === "--node" || arg === "--consumer") { commonArgs.push(args[index + 1] ?? ""); index += 1; } @@ -648,7 +727,7 @@ function parseWebhookTestOptions(args: string[]): WebhookTestOptions { if (arg === "--confirm") confirm = true; else { commonArgs.push(arg); - if (arg === "--target" || arg === "--node") { + if (arg === "--target" || arg === "--node" || arg === "--consumer") { commonArgs.push(args[index + 1] ?? ""); index += 1; } @@ -659,15 +738,17 @@ function parseWebhookTestOptions(args: string[]): WebhookTestOptions { function parseCommonOptions(args: string[]): CommonOptions { let targetId: string | null = null; + let consumerId: string | null = null; let full = false; let raw = false; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; - if (arg === "--target" || arg === "--node") { + if (arg === "--target" || arg === "--node" || arg === "--consumer") { const value = args[index + 1]; if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value`); - if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${arg} must be a simple target id`); - targetId = value; + if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${arg} must be a simple id`); + if (arg === "--consumer") consumerId = value; + else targetId = value; index += 1; } else if (arg === "--full") { full = true; @@ -678,7 +759,7 @@ function parseCommonOptions(args: string[]): CommonOptions { throw new Error(`unsupported pipelines-as-code option: ${arg}`); } } - return { targetId, full, raw }; + return { targetId, consumerId, full, raw }; } function positiveInteger(obj: Record, key: string, path: string): number {