Merge remote-tracking branch 'origin/master' into fix/2784-temporal-pac
# Conflicts: # scripts/src/platform-infra-pipelines-as-code-remote.sh
This commit is contained in:
@@ -106,15 +106,7 @@ targets:
|
||||
- source: /root/user_uploads
|
||||
target: /root/user_uploads
|
||||
readOnly: false
|
||||
autoStartSessions:
|
||||
- id: term
|
||||
title: TERM
|
||||
cwd: /root/unidesk
|
||||
command: mycx resume 019f4eeb-bbb3-7e32-bb98-d10753561516
|
||||
cols: 100
|
||||
rows: 30
|
||||
resumePrompt: true
|
||||
fallbackShell: true
|
||||
autoStartSessions: []
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: "7681"
|
||||
@@ -187,15 +179,7 @@ targets:
|
||||
- source: /root/user_uploads
|
||||
target: /root/user_uploads
|
||||
readOnly: false
|
||||
autoStartSessions:
|
||||
- id: term
|
||||
title: TERM
|
||||
cwd: /root/unidesk
|
||||
command: mycx resume 019f4eeb-bbb3-7e32-bb98-d10753561516
|
||||
cols: 100
|
||||
rows: 30
|
||||
resumePrompt: true
|
||||
fallbackShell: true
|
||||
autoStartSessions: []
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: "7681"
|
||||
@@ -271,31 +255,7 @@ targets:
|
||||
downloadBytes: 16777216
|
||||
uploadBytes: 8388608
|
||||
maxDurationMs: 10000
|
||||
autoStartSessions:
|
||||
- id: decision-center
|
||||
title: 决策中心
|
||||
cwd: /root/unidesk
|
||||
command: mycx resume 019f4937-9820-7b31-9178-344ae93d5a4c
|
||||
cols: 100
|
||||
rows: 30
|
||||
resumePrompt: true
|
||||
fallbackShell: true
|
||||
- id: constar
|
||||
title: CONSTAR
|
||||
cwd: /root/unidesk
|
||||
command: mycx resume 019f4b5f-b5ce-76b3-8ccb-508790e9662d
|
||||
cols: 100
|
||||
rows: 30
|
||||
resumePrompt: true
|
||||
fallbackShell: true
|
||||
- id: workbench
|
||||
title: WORKBENCH
|
||||
cwd: /root/unidesk
|
||||
command: mycx resume 019f4a1a-fd46-7662-8dc2-bf628210739d
|
||||
cols: 100
|
||||
rows: 30
|
||||
resumePrompt: true
|
||||
fallbackShell: true
|
||||
autoStartSessions: []
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: "7681"
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
| 边界项 | 内容 |
|
||||
| --- | --- |
|
||||
| 外部使用者 | 平台管理员、发布操作人员、服务维护者和需要发布状态的业务模块。 |
|
||||
| 外部输入 | source commit、deploy 配置、lane 选择、发布计划请求、带计划指纹的手动触发请求、SecretRef presence 和验证请求。 |
|
||||
| 外部输入 | source commit、deploy 配置、lane 选择、发布计划请求、带 `plan identity` 的手动触发请求、SecretRef presence 和验证请求。 |
|
||||
| 受控资源 | Tekton PipelineRun、Argo Application、runtime namespace、workload、image、release status、health/readiness 和发布判定结果。 |
|
||||
| 外部输出 | PipelineRun 状态、image digest、promotion revision、Argo sync 状态、runtime readiness、发布候选结论和 redacted 失败信息。 |
|
||||
| 用户接口 | UniDesk CI/CD CLI、服务自有 CLI 发布/验证相关入口、服务 health/readiness、Tekton/Argo 受控状态查询入口。 |
|
||||
@@ -85,7 +85,7 @@
|
||||
| PJ2026-01060102 | 镜像Promotion | 本规格 6.2 | env image reuse、digest-pinned image、artifact promotion 和 image readiness | Source commit、Containerfile、registry | 需要运行镜像的服务 |
|
||||
| PJ2026-01060103 | 发布判定 | 本规格 6.3 | source/GitOps/runtime 一致性、health/readiness 和 migration/SecretRef presence | CI/CD、GitOps、YAML运维 | 平台管理员、业务模块 |
|
||||
| PJ2026-01060104 | 验证分层 | 本规格 6.4 | 自测试、综合联调、CLI/API 交互和真实运行面通过口径 | 目标 runtime、业务模块测试需求 | 发布决策 |
|
||||
| PJ2026-01060108 | 手动发布 | 本规格 6.5 | L2/L3 发布计划、范围审阅和带计划指纹的受控手动触发 | 源码同步、YAML运维、镜像Promotion | 全部 L2/L3 runtime 服务 |
|
||||
| PJ2026-01060108 | 手动发布 | 本规格 6.5 | L2/L3 发布计划、范围审阅和带 plan identity 的受控手动触发 | 源码同步、YAML运维、镜像Promotion | 全部 L2/L3 runtime 服务 |
|
||||
| PJ2026-01060105 | AgentRun发布 | [PJ2026-01060105 AgentRun发布Lane](PJ2026-01060105-agentrun-v01-release-lane.md) | AgentRun `v0.1` 的发布 lane、Pipeline、runtime namespace 和真实联调细则 | 源码同步、YAML运维、Agent编排 | AgentRun runtime |
|
||||
| PJ2026-01060106 | HWLAB双环境 | [PJ2026-01060106 HWLAB双环境](PJ2026-01060106-hwlab-dev-production-lanes.md) | HWLAB `v0.3` development 与 `release` production 的分支、流水线、公开入口和数据隔离 | 源码同步、YAML运维、Workbench实时权威 | HWLAB runtime |
|
||||
| PJ2026-01060107 | NC01 CI资源治理 | [PJ2026-01060107 NC01 CI资源治理](PJ2026-01060107-nc01-ci-resource-governance.md) | NC01 构建并发、排队、资源合同、稳定去重和核心运行面保护 | 发布流水、YAML运维、CI/CD目标治理 | NC01 CI 与核心服务 |
|
||||
@@ -152,6 +152,21 @@ L2 与 L3 发布必须使用 `plan -> 人工审阅 -> trigger` 的受控手动
|
||||
|
||||
`plan` 必须是只读操作,并针对精确 target、lane 和 source commit 输出:env identity 及 reuse/build 判定、待构建镜像数量与服务列表、待 rollout 服务、全部受影响服务、范围基线和相对基线新增项。`plan` 不得同步 mirror、创建 snapshot、修改 branch、创建 PipelineRun 或写入运行面。
|
||||
|
||||
- `plan identity` 必须由规范化的计划输入与执行范围确定:
|
||||
- 输入至少绑定 target、consumer、lane、source commit、base commit、catalog authority、catalog source、catalog digest、registry probe 模式和结果;
|
||||
- 执行范围至少绑定 build、rollout 和 affected 服务列表;
|
||||
- 列表与对象键必须规范排序,时间戳、临时路径和运行编号不得参与 identity;
|
||||
- 相同输入与范围必须产生相同 identity,任一绑定输入或范围变化必须产生不同 identity。
|
||||
- `trigger` 必须显式携带操作者刚审阅的 `plan identity`:
|
||||
- 发送 webhook 前必须用当前 source、base、catalog 和 registry 输入重新生成计划;
|
||||
- 重算 identity 与已审阅 identity 不一致时不得发送 webhook;
|
||||
- webhook 只携带规范化计划事实,不引入可变计划存储或第二 authority。
|
||||
- Pipeline 必须在 build 前用同一组输入重新生成计划:
|
||||
- 实际 identity 与 webhook 携带的已审阅 identity 不一致时立即失败;
|
||||
- build、rollout 或 affected 范围任一扩大时必须输出 expected、actual 和新增服务;
|
||||
- identity 校验通过前不得启动镜像构建,CLI 显示 `build=0` 时 Pipeline 不得构建镜像;
|
||||
- 不得通过补跑、提高 timeout、忽略 warning 或继续后续 Task 绕过该失败。
|
||||
|
||||
`plan` 恢复 artifact catalog 时必须满足:
|
||||
|
||||
- 读取 owning YAML 声明的 GitOps read URL 与 branch;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { resolve } from "node:path";
|
||||
import type { GiteaConfig, GiteaMirrorRepository } from "./platform-infra-gitea-config";
|
||||
import { renderMirrorBootstrap } from "./platform-infra-gitea-render";
|
||||
import { pacBootstrapYamlMatchFailure, projectPacBootstrapResult, renderPacBootstrap, resolvePacBootstrapMirrorRepository } from "./platform-infra-pipelines-as-code-bootstrap";
|
||||
import { manualReleaseNext, PacReleaseManifestRenderError, parsePacConfigDocument, readPacConfig, renderPacReleaseManifest, runPlatformInfraPipelinesAsCodeCommand, validatePacConfig, validPacConsumers } from "./platform-infra-pipelines-as-code";
|
||||
import { compactManualReleasePlan, manualReleaseNext, PacReleaseManifestRenderError, parsePacConfigDocument, readPacConfig, renderPacReleaseManifest, runPlatformInfraPipelinesAsCodeCommand, validatePacConfig, validPacConsumers } from "./platform-infra-pipelines-as-code";
|
||||
import { materializeYamlComposition } from "./yaml-composition";
|
||||
|
||||
const mirror: GiteaMirrorRepository = {
|
||||
@@ -311,6 +311,10 @@ test("L2/L3 release exposes plan first and trigger only sends the PaC webhook",
|
||||
"release", "trigger", "--target", "NC01", "--consumer", "hwlab-nc01-v03",
|
||||
"--source-commit", "a".repeat(40),
|
||||
])).rejects.toThrow("release trigger requires --confirm after reviewing release plan");
|
||||
await expect(runPlatformInfraPipelinesAsCodeCommand({} as never, [
|
||||
"release", "trigger", "--target", "NC01", "--consumer", "hwlab-nc01-v03",
|
||||
"--source-commit", "a".repeat(40), "--confirm",
|
||||
])).rejects.toThrow("release trigger requires --plan-identity from the reviewed release plan");
|
||||
const remote = readFileSync(resolve(import.meta.dir, "platform-infra-pipelines-as-code-remote.sh"), "utf8");
|
||||
const planStart = remote.indexOf("manual_release_plan_json()");
|
||||
const actionStart = remote.indexOf("manual_release_action()");
|
||||
@@ -329,6 +333,9 @@ test("L2/L3 release exposes plan first and trigger only sends the PaC webhook",
|
||||
expect(planAction).toContain("expandedServices");
|
||||
expect(planAction).toContain("buildServices");
|
||||
expect(planAction).toContain("rolloutServices");
|
||||
expect(planAction).toContain("--verify-reuse-registry");
|
||||
expect(planAction).toContain("--release-target");
|
||||
expect(planAction).toContain("planIdentity");
|
||||
expect(planAction).toContain('gitops_read_url=$(cluster_service_url "$UNIDESK_PAC_MANUAL_GITOPS_READ_URL")');
|
||||
expect(planAction).toContain('HWLAB_GIT_READ_URL="$gitops_read_url"');
|
||||
expect(planAction).not.toContain('HWLAB_GIT_READ_URL="$UNIDESK_PAC_REPOSITORY_URL"');
|
||||
@@ -337,8 +344,13 @@ test("L2/L3 release exposes plan first and trigger only sends the PaC webhook",
|
||||
.toBe("http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/HWLAB.git");
|
||||
expect(triggerAction).toContain("release-plan-scope-expanded");
|
||||
expect(triggerAction).toContain("release-plan-empty-scope");
|
||||
expect(triggerAction).toContain("release-plan-identity-changed");
|
||||
expect(triggerAction).toContain("unidesk-release-plan-v1.");
|
||||
expect(triggerAction).not.toContain("kubectl create");
|
||||
expect(triggerAction).not.toContain("PipelineRun");
|
||||
const sourceArtifactRenderer = readFileSync(resolve(import.meta.dir, "platform-infra-pipelines-as-code-source-artifact.ts"), "utf8");
|
||||
expect(sourceArtifactRenderer).toContain('if (name === "release-base-commit") return { name, value: "{{ body.before }}" }');
|
||||
expect(sourceArtifactRenderer).toContain('if (name === "reviewed-plan") return { name, value: "{{ body.head_commit.message }}" }');
|
||||
});
|
||||
|
||||
test("PaC release scoped help stays on the manual plan-before-trigger path", async () => {
|
||||
@@ -356,22 +368,25 @@ test("PaC release scoped help stays on the manual plan-before-trigger path", asy
|
||||
expect(JSON.stringify(plan)).toContain("计划非空");
|
||||
expect(JSON.stringify(trigger)).toContain("release trigger --target <NODE>");
|
||||
expect(JSON.stringify(trigger)).toContain("--confirm");
|
||||
expect(JSON.stringify(trigger)).toContain("--plan-identity");
|
||||
expect(JSON.stringify(trigger)).toContain("先执行 release plan");
|
||||
expect(JSON.stringify(trigger)).toContain("无需 trigger");
|
||||
});
|
||||
|
||||
test("PaC failed or expanded plan never points next to trigger", () => {
|
||||
const args = ["plan", "NC01", "hwlab-nc01-v03", "a".repeat(40), "b".repeat(40)] as const;
|
||||
const identity = `sha256:${"d".repeat(64)}`;
|
||||
const args = ["plan", "NC01", "hwlab-nc01-v03", "a".repeat(40), "b".repeat(40), identity] as const;
|
||||
expect(manualReleaseNext(...args, { planSucceeded: false, scopeExpanded: false, scopeEmpty: false, catalogBaseCommit: null, remoteError: false })).toContain("then rerun");
|
||||
const catalogBase = "c".repeat(40);
|
||||
const expanded = manualReleaseNext(...args, { planSucceeded: true, scopeExpanded: true, scopeEmpty: false, catalogBaseCommit: catalogBase, remoteError: false });
|
||||
expect(expanded).toContain(`--base-commit ${catalogBase}`);
|
||||
expect(expanded).not.toContain(`--base-commit ${args[4]}`);
|
||||
expect(manualReleaseNext(...args, { planSucceeded: true, scopeExpanded: false, scopeEmpty: false, catalogBaseCommit: null, remoteError: false })).toContain("release trigger");
|
||||
expect(manualReleaseNext(...args, { planSucceeded: true, scopeExpanded: false, scopeEmpty: false, catalogBaseCommit: null, remoteError: false })).toContain(`--plan-identity ${identity}`);
|
||||
const noOp = manualReleaseNext(...args, { planSucceeded: true, scopeExpanded: false, scopeEmpty: true, catalogBaseCommit: null, remoteError: false });
|
||||
expect(noOp).toContain("no release trigger required");
|
||||
expect(noOp).not.toContain("release trigger --target");
|
||||
const observed = manualReleaseNext("trigger", args[1], args[2], args[3], args[4], { planSucceeded: true, scopeExpanded: false, scopeEmpty: false, catalogBaseCommit: null, remoteError: false });
|
||||
const observed = manualReleaseNext("trigger", args[1], args[2], args[3], args[4], args[5], { planSucceeded: true, scopeExpanded: false, scopeEmpty: false, catalogBaseCommit: null, remoteError: false });
|
||||
expect(observed).toContain("delivery-observe");
|
||||
expect(observed).toContain("a".repeat(40));
|
||||
expect(observed).not.toContain("closeout");
|
||||
@@ -388,6 +403,8 @@ test("PaC default release plan projection keeps review fields without changed-pa
|
||||
const source = readFileSync(resolve(import.meta.dir, "platform-infra-pipelines-as-code.ts"), "utf8");
|
||||
const projector = source.slice(source.indexOf("function compactManualReleasePlan"), source.indexOf("export function manualReleaseNext"));
|
||||
expect(projector).toContain("requestedScope");
|
||||
expect(projector).toContain("planIdentity");
|
||||
expect(projector).toContain("registryProbe");
|
||||
expect(projector).toContain("envReuse");
|
||||
expect(projector).toContain("buildServices");
|
||||
expect(projector).toContain("rolloutServices");
|
||||
@@ -396,6 +413,28 @@ test("PaC default release plan projection keeps review fields without changed-pa
|
||||
expect(projector).not.toContain("changedPaths:");
|
||||
});
|
||||
|
||||
test("PaC compact release plan keeps bounded registry probe results", () => {
|
||||
const compact = compactManualReleasePlan({
|
||||
registryProbe: {
|
||||
enabled: true,
|
||||
services: [
|
||||
{ serviceId: "api", status: "present", image: "registry/api:large", digest: `sha256:${"a".repeat(64)}` },
|
||||
{ serviceId: "worker", status: "missing", image: "registry/worker:large", digest: null },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(compact.registryProbe).toEqual({
|
||||
enabled: true,
|
||||
serviceCount: 2,
|
||||
statusCounts: { present: 1, missing: 1 },
|
||||
exceptions: [
|
||||
{ serviceId: "worker", status: "missing" },
|
||||
],
|
||||
});
|
||||
expect(JSON.stringify(compact)).not.toContain("registry/api:large");
|
||||
expect(JSON.stringify(compact)).not.toContain("sha256:aaaaaaaa");
|
||||
});
|
||||
|
||||
test("PaC release manifest renders YAML-owned watcher startup backlog probes", () => {
|
||||
const pac = readPacConfig({ consumerId: "hwlab-nc01-v03", selectDefault: true });
|
||||
const source = readFileSync(resolve(import.meta.dir, "fixtures/pac/release-leading-comment-only.yaml"), "utf8");
|
||||
|
||||
@@ -289,7 +289,10 @@ NODE
|
||||
--artifact-catalog "$UNIDESK_PAC_MANUAL_ARTIFACT_CATALOG_PATH" \
|
||||
--services "$UNIDESK_PAC_MANUAL_SERVICE_IDS" \
|
||||
--registry-prefix "$UNIDESK_PAC_MANUAL_REGISTRY_PREFIX" \
|
||||
--base-image "$UNIDESK_PAC_MANUAL_BASE_IMAGE") >"$domain_plan_file"
|
||||
--base-image "$UNIDESK_PAC_MANUAL_BASE_IMAGE" \
|
||||
--release-target "$UNIDESK_PAC_MANUAL_RELEASE_TARGET" \
|
||||
--release-consumer "$UNIDESK_PAC_MANUAL_RELEASE_CONSUMER" \
|
||||
--verify-reuse-registry) >"$domain_plan_file"
|
||||
else
|
||||
if [ -z "$base_commit" ]; then
|
||||
base_commit=$(git -C "$tmp/source" rev-parse "$source_commit^")
|
||||
@@ -302,6 +305,7 @@ NODE
|
||||
fi
|
||||
SOURCE_COMMIT="$source_commit" BASE_COMMIT="$base_commit" BASE_SOURCE="$base_source" node - "$changed_paths_file" "$domain_plan_file" <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const crypto = require('node:crypto');
|
||||
const changedPaths = fs.readFileSync(process.argv[2], 'utf8').split('\n').filter(Boolean);
|
||||
const domain = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'));
|
||||
const materializationPaths = JSON.parse(process.env.UNIDESK_PAC_MANUAL_MATERIALIZATION_PATHS_JSON || '[]');
|
||||
@@ -329,14 +333,40 @@ const envReuse = domain ? {
|
||||
reusedServiceCount: Number(domain.serviceReusedCount || 0),
|
||||
buildSkippedServices: domain.imageBuildSkippedServices || [],
|
||||
} : { status: 'not-configured', groups: [], reusedServices: [], reusedServiceCount: 0, buildSkippedServices: [] };
|
||||
const stable = (value) => {
|
||||
if (Array.isArray(value)) return '[' + value.map(stable).join(',') + ']';
|
||||
if (value && typeof value === 'object') return '{' + Object.keys(value).sort().map((key) => JSON.stringify(key) + ':' + stable(value[key])).join(',') + '}';
|
||||
return JSON.stringify(value);
|
||||
};
|
||||
const identityScope = {
|
||||
buildServices: [...buildServices].sort(),
|
||||
rolloutServices: [...rolloutServices].sort(),
|
||||
affectedServices: [...affectedServices].sort(),
|
||||
};
|
||||
const genericIdentity = {
|
||||
version: 'v1',
|
||||
inputs: {
|
||||
target: process.env.UNIDESK_PAC_MANUAL_RELEASE_TARGET,
|
||||
consumer: process.env.UNIDESK_PAC_MANUAL_RELEASE_CONSUMER,
|
||||
lane: process.env.UNIDESK_PAC_CONSUMER_LANE,
|
||||
sourceCommitId: process.env.SOURCE_COMMIT,
|
||||
baseCommitId: process.env.BASE_COMMIT,
|
||||
catalog: domain?.artifactCatalog || null,
|
||||
registryProbe: domain?.planIdentity?.inputs?.registryProbe || { enabled: false, services: [] },
|
||||
},
|
||||
scope: identityScope,
|
||||
};
|
||||
const planIdentity = domain?.planIdentity?.value || `sha256:${crypto.createHash('sha256').update(stable(genericIdentity)).digest('hex')}`;
|
||||
const plan = {
|
||||
sourceCommit: process.env.SOURCE_COMMIT,
|
||||
baseCommit: process.env.BASE_COMMIT,
|
||||
sourceBranch: process.env.UNIDESK_PAC_MANUAL_SOURCE_BRANCH,
|
||||
changedPathCount: effectiveChangedPaths.length,
|
||||
changedPaths: effectiveChangedPaths,
|
||||
sourceChangedPathCount: changedPaths.length,
|
||||
materializationPaths,
|
||||
planIdentity,
|
||||
registryProbe: domain?.planIdentity?.inputs?.registryProbe || genericIdentity.inputs.registryProbe,
|
||||
changedPathCount: effectiveChangedPaths.length,
|
||||
changedPaths: effectiveChangedPaths,
|
||||
sourceChangedPathCount: changedPaths.length,
|
||||
materializationPaths,
|
||||
artifactCatalog: domain?.artifactCatalog || null,
|
||||
rangeBaseline: {
|
||||
mode: process.env.BASE_SOURCE,
|
||||
@@ -385,6 +415,7 @@ manual_release_action() {
|
||||
const fs = require('node:fs');
|
||||
const plan = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
|
||||
if (plan.scopeReview?.expanded === true) process.stdout.write('expanded');
|
||||
else if (!process.env.UNIDESK_PAC_MANUAL_PLAN_IDENTITY || process.env.UNIDESK_PAC_MANUAL_PLAN_IDENTITY !== plan.planIdentity) process.stdout.write('identity-changed');
|
||||
else if (Number(plan.imageBuildCount || 0) === 0 && Number(plan.rolloutServiceCount || 0) === 0 && Number(plan.affectedServiceCount || 0) === 0) process.stdout.write('empty');
|
||||
else process.stdout.write('ready');
|
||||
NODE
|
||||
@@ -394,6 +425,11 @@ NODE
|
||||
printf '{"ok":false,"mutation":false,"error":"release-plan-scope-expanded","plan":%s,"webhook":null,"valuesPrinted":false}\n' "$plan"
|
||||
return 1
|
||||
fi
|
||||
if [ "$plan_gate" = "identity-changed" ]; then
|
||||
rm -f "$plan_file"
|
||||
printf '{"ok":false,"mutation":false,"error":"release-plan-identity-changed","plan":%s,"webhook":null,"valuesPrinted":false}\n' "$plan"
|
||||
return 1
|
||||
fi
|
||||
if [ "$plan_gate" = "empty" ]; then
|
||||
rm -f "$plan_file"
|
||||
printf '{"ok":false,"mutation":false,"error":"release-plan-empty-scope","plan":%s,"webhook":null,"valuesPrinted":false}\n' "$plan"
|
||||
@@ -402,15 +438,26 @@ NODE
|
||||
payload_file=$(mktemp)
|
||||
node - "$plan_file" >"$payload_file" <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const crypto = require('node:crypto');
|
||||
const plan = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
|
||||
const byKind = { added: [], removed: [], modified: plan.changedPaths };
|
||||
const owner = process.env.UNIDESK_PAC_GITEA_OWNER;
|
||||
const repo = process.env.UNIDESK_PAC_GITEA_REPO;
|
||||
const url = process.env.UNIDESK_PAC_REPOSITORY_URL.replace(/\/+$/u, '');
|
||||
const commit = { id: plan.sourceCommit, message: 'manual L2/L3 release', url: `${url}/commit/${plan.sourceCommit}`, added: byKind.added, removed: byKind.removed, modified: byKind.modified, author: { name: 'unidesk-cli', email: '', username: 'unidesk-cli' }, committer: { name: 'unidesk-cli', email: '', username: 'unidesk-cli' } };
|
||||
const reviewedPlan = {
|
||||
version: 'v1',
|
||||
planIdentity: plan.planIdentity,
|
||||
target: process.env.UNIDESK_PAC_MANUAL_RELEASE_TARGET,
|
||||
consumer: process.env.UNIDESK_PAC_MANUAL_RELEASE_CONSUMER,
|
||||
sourceCommit: plan.sourceCommit,
|
||||
baseCommit: plan.baseCommit,
|
||||
buildServices: plan.buildServices,
|
||||
rolloutServices: plan.rolloutServices,
|
||||
affectedServices: plan.affectedServices,
|
||||
};
|
||||
const reviewedPlanEnvelope = `unidesk-release-plan-v1.${Buffer.from(JSON.stringify(reviewedPlan)).toString('base64url')}`;
|
||||
const commit = { id: plan.sourceCommit, message: reviewedPlanEnvelope, url: `${url}/commit/${plan.sourceCommit}`, added: byKind.added, removed: byKind.removed, modified: byKind.modified, author: { name: 'unidesk-cli', email: '', username: 'unidesk-cli' }, committer: { name: 'unidesk-cli', email: '', username: 'unidesk-cli' } };
|
||||
const user = { id: 0, login: 'unidesk-cli', full_name: 'UniDesk CLI', email: '', avatar_url: '', username: 'unidesk-cli' };
|
||||
process.stdout.write(JSON.stringify({ ref: `refs/heads/${plan.sourceBranch}`, before: plan.baseCommit, after: plan.sourceCommit, compare_url: `${url}/compare/${plan.baseCommit}...${plan.sourceCommit}`, commits: [commit], head_commit: commit, repository: { id: 0, owner: { ...user, login: owner, username: owner }, name: repo, full_name: `${owner}/${repo}`, private: true, html_url: url, clone_url: `${url}.git`, default_branch: plan.sourceBranch }, pusher: user, sender: user }));
|
||||
process.stdout.write(JSON.stringify({ ref: `refs/heads/${plan.sourceBranch}`, before: plan.baseCommit, after: plan.sourceCommit, compare_url: `${url}/compare/${plan.baseCommit}...${plan.sourceCommit}`, commits: [commit], total_commits: 1, head_commit: commit, repository: { id: 0, owner: { ...user, login: owner, username: owner }, name: repo, full_name: `${owner}/${repo}`, private: true, html_url: url, clone_url: `${url}.git`, default_branch: plan.sourceBranch }, pusher: user, sender: user }));
|
||||
NODE
|
||||
signature=$(node - "$payload_file" <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
|
||||
@@ -1075,6 +1075,12 @@ function pipelineRunParams(binding: PacSourceArtifactBinding, desiredSpec: Recor
|
||||
const params = arrayRecords(desiredSpec.params, "Pipeline.spec.params");
|
||||
return params.map((param) => {
|
||||
const name = requiredString(param.name, "Pipeline.spec.params[].name");
|
||||
if (binding.consumer.sourceArtifact.renderer === "hwlab-runtime-lane") {
|
||||
if (name === "release-base-commit") return { name, value: "{{ body.before }}" };
|
||||
if (name === "release-target") return { name, value: binding.consumer.node };
|
||||
if (name === "release-consumer") return { name, value: binding.consumer.id };
|
||||
if (name === "reviewed-plan") return { name, value: "{{ body.head_commit.message }}" };
|
||||
}
|
||||
if (name === "revision" || name === "source-commit") return { name, value: "{{ revision }}" };
|
||||
if (name === "source-stage-ref") {
|
||||
const prefix = Object.prototype.hasOwnProperty.call(binding.consumer.params, "source_snapshot_prefix")
|
||||
|
||||
@@ -317,6 +317,7 @@ interface ApplyOptions extends CommonOptions {
|
||||
interface ManualReleaseOptions extends CommonOptions {
|
||||
sourceCommit: string;
|
||||
baseCommit: string | null;
|
||||
planIdentity: string | null;
|
||||
confirm: boolean;
|
||||
}
|
||||
|
||||
@@ -366,11 +367,12 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf
|
||||
if (action === "release") {
|
||||
const releaseAction = args[1];
|
||||
if (releaseAction === undefined || releaseAction === "--help" || releaseAction === "-h") return help("release");
|
||||
if (releaseAction !== "plan" && releaseAction !== "trigger") return { ok: false, action: "platform-infra-pipelines-as-code-release", mutation: false, error: "unsupported-release-action", usage: "pipelines-as-code release plan|trigger --target <NODE> --consumer <consumer> --source-commit <sha> [--base-commit <sha>] [--confirm]", valuesPrinted: false };
|
||||
if (releaseAction !== "plan" && releaseAction !== "trigger") return { ok: false, action: "platform-infra-pipelines-as-code-release", mutation: false, error: "unsupported-release-action", usage: "pipelines-as-code release plan|trigger --target <NODE> --consumer <consumer> --source-commit <sha> [--base-commit <sha>] [--plan-identity <sha256:id>] [--confirm]", valuesPrinted: false };
|
||||
if (args.slice(2).includes("--help") || args.slice(2).includes("-h")) return help(`release-${releaseAction}`);
|
||||
const options = parseManualReleaseOptions(args.slice(2));
|
||||
if (releaseAction === "plan" && options.confirm) throw new Error("release plan does not accept --confirm");
|
||||
if (releaseAction === "trigger" && !options.confirm) throw new Error("release trigger requires --confirm after reviewing release plan");
|
||||
if (releaseAction === "trigger" && options.planIdentity === null) throw new Error("release trigger requires --plan-identity from the reviewed release plan");
|
||||
return await manualRelease(config, releaseAction, options);
|
||||
}
|
||||
if (action === "apply") {
|
||||
@@ -575,13 +577,13 @@ function help(scope: string | null): Record<string, unknown> {
|
||||
if (scope === "release" || scope === "release-plan" || scope === "release-trigger") {
|
||||
const action = scope === "release-plan" ? "plan" : scope === "release-trigger" ? "trigger" : null;
|
||||
const planUsage = "bun scripts/cli.ts platform-infra pipelines-as-code release plan --target <NODE> --consumer <consumer> --source-commit <40-sha> [--base-commit <40-sha>]";
|
||||
const triggerUsage = "bun scripts/cli.ts platform-infra pipelines-as-code release trigger --target <NODE> --consumer <consumer> --source-commit <40-sha> [--base-commit <40-sha>] --confirm";
|
||||
const triggerUsage = "bun scripts/cli.ts platform-infra pipelines-as-code release trigger --target <NODE> --consumer <consumer> --source-commit <40-sha> [--base-commit <40-sha>] --plan-identity <sha256:id> --confirm";
|
||||
return {
|
||||
command: `platform-infra pipelines-as-code release${action === null ? "" : ` ${action}`}`,
|
||||
configTruth: configLabel,
|
||||
usage: action === "plan" ? [planUsage] : action === "trigger" ? [triggerUsage] : [planUsage, triggerUsage],
|
||||
boundary: "PR merge 只更新 source authority,不创建 PipelineRun。必须先 plan;仅当 scopeReview.status=exact 且 imageBuildCount、rolloutServiceCount、affectedServiceCount 至少一项非零时才可 trigger。三项均为零是终态 no-op,不发送 webhook。trigger 只发送 PaC webhook,由 PaC 创建 PipelineRun。",
|
||||
review: ["rangeBaseline", "requestedScope", "effective", "envReuse", "imageBuildCount", "rolloutServiceCount", "affectedServiceCount", "scopeReview"],
|
||||
review: ["planIdentity", "registryProbe", "rangeBaseline", "requestedScope", "effective", "envReuse", "imageBuildCount", "rolloutServiceCount", "affectedServiceCount", "scopeReview"],
|
||||
next: action === "trigger"
|
||||
? "先执行 release plan,并确认 rangeBaseline.catalogAligned=true、scopeReview.status=exact 且计划非空;省略 --base-commit 时 HWLAB 使用 catalog source,范围扩大时按 next 重新 plan,三项范围计数均为零时无需 trigger。"
|
||||
: "先执行 release plan;HWLAB 默认以 catalog source 为范围基线,确认 rangeBaseline.catalogAligned=true、scopeReview.status=exact 且计划非空后才执行 release trigger --confirm,三项范围计数均为零时无需 trigger。",
|
||||
@@ -625,7 +627,7 @@ function help(scope: string | null): Record<string, unknown> {
|
||||
"bun scripts/cli.ts platform-infra pipelines-as-code plan --target JD01",
|
||||
"bun scripts/cli.ts platform-infra pipelines-as-code automatic-webhooks disable --target <NODE> [--confirm]",
|
||||
"bun scripts/cli.ts platform-infra pipelines-as-code release plan --target <NODE> --consumer <consumer> --source-commit <sha> [--base-commit <sha>]",
|
||||
"bun scripts/cli.ts platform-infra pipelines-as-code release trigger --target <NODE> --consumer <consumer> --source-commit <sha> [--base-commit <sha>] --confirm",
|
||||
"bun scripts/cli.ts platform-infra pipelines-as-code release trigger --target <NODE> --consumer <consumer> --source-commit <sha> [--base-commit <sha>] --plan-identity <sha256:id> --confirm",
|
||||
"bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 [--json|--full|--raw]",
|
||||
"bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 [--consumer hwlab-jd01-v03] [--limit 10]",
|
||||
"bun scripts/cli.ts platform-infra pipelines-as-code diagnose-regression --target NC01 --consumer agentrun-nc01-v02 [--limit 20] [--json|--full]",
|
||||
@@ -1631,6 +1633,7 @@ async function manualRelease(config: UniDeskConfig, action: "plan" | "trigger",
|
||||
const result = await capture(config, target.route, ["sh"], remoteScript(remoteAction, pac, target, repository, consumer, { ...options, dryRun: action === "plan", wait: false }, secrets, "", [consumer], false, [repository], {
|
||||
sourceCommit: options.sourceCommit,
|
||||
baseCommit: options.baseCommit,
|
||||
planIdentity: options.planIdentity,
|
||||
sourceBranch: params.source_branch ?? "",
|
||||
imageRepositories,
|
||||
runtimeService: params.runtime_deployment ?? consumer.id,
|
||||
@@ -1641,6 +1644,8 @@ async function manualRelease(config: UniDeskConfig, action: "plan" | "trigger",
|
||||
serviceIds: hwlabLane?.serviceIds ?? [],
|
||||
registryPrefix: hwlabLane?.registryPrefix ?? "",
|
||||
baseImage: hwlabLane?.baseImage ?? "",
|
||||
releaseTarget: target.id,
|
||||
releaseConsumer: consumer.id,
|
||||
}), { runtimeTimeoutMs: action === "plan" ? 120_000 : 150_000 });
|
||||
const remote = parseJsonOutput(result.stdout) ?? {};
|
||||
const fullReleasePlan = remote.plan && typeof remote.plan === "object" ? remote.plan as Record<string, any> : null;
|
||||
@@ -1665,7 +1670,7 @@ async function manualRelease(config: UniDeskConfig, action: "plan" | "trigger",
|
||||
releasePlan,
|
||||
webhook: action === "trigger" ? remote.webhook ?? null : null,
|
||||
remote: result.exitCode === 0 ? undefined : { exitCode: result.exitCode, stderrTail: result.stderr.slice(-1200) },
|
||||
next: manualReleaseNext(action, target.id, consumer.id, options.sourceCommit, typeof fullReleasePlan?.baseCommit === "string" ? fullReleasePlan.baseCommit : options.baseCommit, {
|
||||
next: manualReleaseNext(action, target.id, consumer.id, options.sourceCommit, typeof fullReleasePlan?.baseCommit === "string" ? fullReleasePlan.baseCommit : options.baseCommit, typeof fullReleasePlan?.planIdentity === "string" ? fullReleasePlan.planIdentity : options.planIdentity, {
|
||||
planSucceeded,
|
||||
scopeExpanded,
|
||||
scopeEmpty,
|
||||
@@ -1678,11 +1683,13 @@ async function manualRelease(config: UniDeskConfig, action: "plan" | "trigger",
|
||||
};
|
||||
}
|
||||
|
||||
function compactManualReleasePlan(plan: Record<string, any>): Record<string, unknown> {
|
||||
export function compactManualReleasePlan(plan: Record<string, any>): Record<string, unknown> {
|
||||
return {
|
||||
sourceCommit: plan.sourceCommit ?? null,
|
||||
baseCommit: plan.baseCommit ?? null,
|
||||
sourceBranch: plan.sourceBranch ?? null,
|
||||
planIdentity: plan.planIdentity ?? null,
|
||||
registryProbe: compactRegistryProbe(plan.registryProbe),
|
||||
changedPathCount: plan.changedPathCount ?? null,
|
||||
artifactCatalog: plan.artifactCatalog === null ? null : {
|
||||
status: plan.artifactCatalog?.status ?? null,
|
||||
@@ -1705,23 +1712,49 @@ function compactManualReleasePlan(plan: Record<string, any>): Record<string, unk
|
||||
};
|
||||
}
|
||||
|
||||
function compactRegistryProbe(value: unknown): Record<string, unknown> | null {
|
||||
if (value === null || typeof value !== "object") return null;
|
||||
const probe = value as Record<string, unknown>;
|
||||
const services = Array.isArray(probe.services)
|
||||
? probe.services.map((item) => {
|
||||
const service = item !== null && typeof item === "object" ? item as Record<string, unknown> : {};
|
||||
return {
|
||||
serviceId: typeof service.serviceId === "string" ? service.serviceId : null,
|
||||
status: typeof service.status === "string" ? service.status : null,
|
||||
};
|
||||
}).filter((item) => item.serviceId !== null)
|
||||
: [];
|
||||
const statusCounts = services.reduce<Record<string, number>>((counts, service) => {
|
||||
const status = service.status ?? "unknown";
|
||||
return { ...counts, [status]: (counts[status] ?? 0) + 1 };
|
||||
}, {});
|
||||
return {
|
||||
enabled: probe.enabled === true,
|
||||
serviceCount: services.length,
|
||||
statusCounts,
|
||||
exceptions: services.filter((service) => service.status !== "present"),
|
||||
};
|
||||
}
|
||||
|
||||
export function manualReleaseNext(
|
||||
action: "plan" | "trigger",
|
||||
targetId: string,
|
||||
consumerId: string,
|
||||
sourceCommit: string,
|
||||
baseCommit: string | null,
|
||||
planIdentity: string | null,
|
||||
outcome: { readonly planSucceeded: boolean; readonly scopeExpanded: boolean; readonly scopeEmpty: boolean; readonly catalogBaseCommit: string | null; readonly remoteError: boolean },
|
||||
): string {
|
||||
const nextBaseCommit = outcome.scopeExpanded && outcome.catalogBaseCommit !== null
|
||||
? outcome.catalogBaseCommit
|
||||
: baseCommit;
|
||||
const baseSuffix = nextBaseCommit === null ? "" : " --base-commit " + nextBaseCommit;
|
||||
const identitySuffix = planIdentity === null ? "" : " --plan-identity " + planIdentity;
|
||||
const plan = `bun scripts/cli.ts platform-infra pipelines-as-code release plan --target ${targetId} --consumer ${consumerId} --source-commit ${sourceCommit}${baseSuffix}`;
|
||||
if (outcome.scopeEmpty) return "no release trigger required: plan has zero build, rollout, and affected services";
|
||||
if (action === "plan") {
|
||||
return outcome.planSucceeded && !outcome.scopeExpanded
|
||||
? `bun scripts/cli.ts platform-infra pipelines-as-code release trigger --target ${targetId} --consumer ${consumerId} --source-commit ${sourceCommit}${baseSuffix} --confirm`
|
||||
? `bun scripts/cli.ts platform-infra pipelines-as-code release trigger --target ${targetId} --consumer ${consumerId} --source-commit ${sourceCommit}${baseSuffix}${identitySuffix} --confirm`
|
||||
: `fix the source range, planner, or owning YAML, then rerun: ${plan}`;
|
||||
}
|
||||
return outcome.remoteError
|
||||
@@ -2443,7 +2476,7 @@ export class PacReleaseManifestRenderError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function remoteScript(action: "apply" | "status" | "history" | "diagnose-regression" | "debug-step" | "disable-automatic-webhooks" | "manual-release-plan" | "manual-release-trigger", pac: PacConfig, target: PacTarget, repository: PacRepository, consumer: PacConsumer, options: ApplyOptions | HistoryOptions, secrets: SecretMaterial, releaseManifest: string, historyConsumers: PacConsumer[] = [consumer], collectArtifactLogs = false, webhookRepositories: PacRepository[] = [repository], manualRelease?: { sourceCommit: string; baseCommit: string | null; sourceBranch: string; imageRepositories: string[]; runtimeService: string; planningNodeModules: string; artifactCatalogPath: string; gitopsReadUrl: string; gitopsBranch: string; serviceIds: readonly string[]; registryPrefix: string; baseImage: string }): string {
|
||||
function remoteScript(action: "apply" | "status" | "history" | "diagnose-regression" | "debug-step" | "disable-automatic-webhooks" | "manual-release-plan" | "manual-release-trigger", pac: PacConfig, target: PacTarget, repository: PacRepository, consumer: PacConsumer, options: ApplyOptions | HistoryOptions, secrets: SecretMaterial, releaseManifest: string, historyConsumers: PacConsumer[] = [consumer], collectArtifactLogs = false, webhookRepositories: PacRepository[] = [repository], manualRelease?: { sourceCommit: string; baseCommit: string | null; planIdentity: string | null; sourceBranch: string; imageRepositories: string[]; runtimeService: string; planningNodeModules: string; artifactCatalogPath: string; gitopsReadUrl: string; gitopsBranch: string; serviceIds: readonly string[]; registryPrefix: string; baseImage: string; releaseTarget: string; releaseConsumer: 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 admissionIdentity = pacAdmissionDesiredIdentity(target.id);
|
||||
const rbacIdentity = pacConsumerRbacDesiredIdentity(target.id);
|
||||
@@ -2474,6 +2507,7 @@ function remoteScript(action: "apply" | "status" | "history" | "diagnose-regress
|
||||
UNIDESK_PAC_WEBHOOK_REPOSITORIES_JSON: JSON.stringify(webhookRepositories.map((item) => ({ id: item.id, owner: item.owner, repo: item.repo }))),
|
||||
UNIDESK_PAC_MANUAL_SOURCE_COMMIT: manualRelease?.sourceCommit ?? "",
|
||||
UNIDESK_PAC_MANUAL_BASE_COMMIT: manualRelease?.baseCommit ?? "",
|
||||
UNIDESK_PAC_MANUAL_PLAN_IDENTITY: manualRelease?.planIdentity ?? "",
|
||||
UNIDESK_PAC_MANUAL_SOURCE_BRANCH: manualRelease?.sourceBranch ?? "",
|
||||
UNIDESK_PAC_MANUAL_IMAGE_REPOSITORIES_JSON: JSON.stringify(manualRelease?.imageRepositories ?? []),
|
||||
UNIDESK_PAC_MANUAL_RUNTIME_SERVICE: manualRelease?.runtimeService ?? "",
|
||||
@@ -2486,6 +2520,8 @@ function remoteScript(action: "apply" | "status" | "history" | "diagnose-regress
|
||||
UNIDESK_PAC_MANUAL_SERVICE_IDS: (manualRelease?.serviceIds ?? []).join(","),
|
||||
UNIDESK_PAC_MANUAL_REGISTRY_PREFIX: manualRelease?.registryPrefix ?? "",
|
||||
UNIDESK_PAC_MANUAL_BASE_IMAGE: manualRelease?.baseImage ?? "",
|
||||
UNIDESK_PAC_MANUAL_RELEASE_TARGET: manualRelease?.releaseTarget ?? "",
|
||||
UNIDESK_PAC_MANUAL_RELEASE_CONSUMER: manualRelease?.releaseConsumer ?? "",
|
||||
UNIDESK_PAC_WEBHOOK_URL: webhookUrl,
|
||||
UNIDESK_PAC_WEBHOOK_SECRET: secrets.webhookSecret,
|
||||
UNIDESK_PAC_REPOSITORY_NAME: repository.name,
|
||||
@@ -3876,6 +3912,7 @@ function parseManualReleaseOptions(args: string[]): ManualReleaseOptions {
|
||||
let consumerId: string | null = null;
|
||||
let sourceCommit: string | null = null;
|
||||
let baseCommit: string | null = null;
|
||||
let planIdentity: string | null = null;
|
||||
let confirm = false;
|
||||
let full = false;
|
||||
let raw = false;
|
||||
@@ -3886,6 +3923,7 @@ function parseManualReleaseOptions(args: string[]): ManualReleaseOptions {
|
||||
else if (arg === "--consumer") consumerId = optionValue(args, ++index, arg);
|
||||
else if (arg === "--source-commit") sourceCommit = optionValue(args, ++index, arg);
|
||||
else if (arg === "--base-commit") baseCommit = optionValue(args, ++index, arg);
|
||||
else if (arg === "--plan-identity") planIdentity = optionValue(args, ++index, arg);
|
||||
else if (arg === "--confirm") confirm = true;
|
||||
else if (arg === "--full") full = true;
|
||||
else if (arg === "--raw") raw = true;
|
||||
@@ -3895,7 +3933,8 @@ function parseManualReleaseOptions(args: string[]): ManualReleaseOptions {
|
||||
if (consumerId === null) throw new Error("release requires --consumer <consumer>");
|
||||
if (sourceCommit === null || !/^[0-9a-f]{40}$/iu.test(sourceCommit)) throw new Error("release requires --source-commit <40-hex-sha>");
|
||||
if (baseCommit !== null && !/^[0-9a-f]{40}$/iu.test(baseCommit)) throw new Error("--base-commit must be a 40-hex SHA");
|
||||
return { targetId, consumerId, sourceCommit: sourceCommit.toLowerCase(), baseCommit: baseCommit?.toLowerCase() ?? null, confirm, full, raw, json };
|
||||
if (planIdentity !== null && !/^sha256:[0-9a-f]{64}$/iu.test(planIdentity)) throw new Error("--plan-identity must be sha256:<64-hex>");
|
||||
return { targetId, consumerId, sourceCommit: sourceCommit.toLowerCase(), baseCommit: baseCommit?.toLowerCase() ?? null, planIdentity: planIdentity?.toLowerCase() ?? null, confirm, full, raw, json };
|
||||
}
|
||||
|
||||
function optionValue(args: readonly string[], index: number, option: string): string {
|
||||
|
||||
Reference in New Issue
Block a user