feat(ql3): fence cluster deployment transitions

This commit is contained in:
whyour
2026-08-17 01:41:10 +08:00
parent abf2d25d63
commit 2e848ef7c0
18 changed files with 1337 additions and 129 deletions
+23
View File
@@ -11,6 +11,29 @@
最新增量证据(2026-08-16):
- D-342/ADR-0434(已接受;资源退休 ceremony 与真实公开 catalog 运行仍待后续 Gate):Cluster deployment command、preflight 与
receipt 在尚未发布前直接升为 v2,并以目标 namespace 中唯一 `qinglong3-deployment-head` ConfigMap 建立部署顺序 authority。每次
`install|upgrade|rollback` 都必须绑定 exact expected Head generation/deployment/lock/state digestpreflight 读取当前 Headapply
再用 API Server 返回的 opaque `resourceVersion` 执行 create/replace CAS,先取得 `applying`,完成五步 server-side apply/convergence
后再提交 `committed`。相同意图可从 applying 或已提交但丢失本地 receipt 的窗口确定性恢复,陈旧 preflight、并发不同意图、Head
漂移与本地 receipt 对已前进 Head 的重放全部失败关闭。locked manifest 必须完整覆盖 `control|control-ai|admin|worker`,自动推导并
排序唯一 resource inventoryupgrade 不能省略现存对象,rollback 必须与上一 deployment 使用相同 inventory,避免把普通 apply 不会
删除的残留对象冒充成功。删除仍保留给后续绑定 UID/resourceVersion 的独立 retirement ceremony,不采用 alpha prune。Head 只增加一个
小 ConfigMap 和 preflight 一次 GET、apply 两次 CAS;没有 controller、CRD、webhook、Pod、listener、timer、数据库、migration、Pool、
新 package 或第三方依赖,Local/Edge/Standalone 零导入、零制品增量。Service Bridge 审计同时发现其仅为错误类型而加载完整 foundation
contract;已把同一 class identity 下沉到既有 `local-owner-cli/deployment/foundation/error` 内部模块,各 importer 改用叶子入口,未拆出
浅 package。定向 deployment ceremony 14/14、Local Owner 171 项为 166 pass/5 条件 skip、完整 backend 1,331 项为 1,329 pass/2 条件
skip/0 fail、18-package clean build/test 退出 0。10 项架构/部署审计与 14 档 Local artifact 全部 compatiblepackage boundary 仍为
18 packages、`singleSourcePackages=[]``shallowSourcePackages=[]`Local Owner 的 107 个源码中 106 个位于领域目录。默认
Edge/Standalone 保持 2,589,890/2,589,968 bytesMCP 保持 7,315,930/7,316,038 bytesCluster Admin dry-run pack 保持 250 files、
271,238-byte tarball、1,690,196-byte unpacked。隔离三节点 K3s `v1.34.3+k3s1`/arm64 真实运行 6 个 release resource、4 个零副本
Deployment 和 generation 1 committed Headdeployment/preflight/receipt digest 分别为
`sha256:f911cda19195735d00fcc6317db2c22794855e5eb5795696b19a4239fb68a263`
`sha256:574f80dde330c5b4fe41c681906a67ffd6e4b608dedc6d955574771b2bd5c711`
`sha256:0f2edd2037a4df7ae6eb28616aa38a280498660443d0f091e1fac5c1ff532258`,资源清理完成。经允许重跑 PostgreSQL 18.6
arm64 physical HA142/142、timeline `1→2`,报告 SHA-256
`8ba0b130100b48e19c602546b63844903e567d6997630f8c950b51cf41ee70ea`,独立证据审计通过且容器、卷、网络零残留。live 使用 synthetic
lock,只证明部署状态机和目标 API CAS,不冒充尚未产生的公开 GHCR catalog。
- D-341/ADR-0433(已接受;真实公开 catalog 运行待实际 release tag):Cluster 最后一跳不再交给裸
`kubectl apply -f locked.yaml`。新增工作站级 `cluster.deployment.preflight|apply|receipt.audit`,从 owner-private canonical
command 出发独立复验 Kubernetes v2 lock/report、locked manifest、release-set/catalog/workflow/image/annotation 闭包,并固定
@@ -0,0 +1,80 @@
# ADR-0434Resource-inventory-closed Kubernetes Deployment Head
- 状态:Accepted
- 日期:2026-08-16
- 关联 RFCQL-RFC-0001 D-03、D-14、D-341、D-342
- 关联 ADRADR-0431、ADR-0433
- 修正:ADR-0433 第 7、8 项的无目标状态重放与人工 roll-forward 边界
## 上下文
ADR-0433 把 catalog-bound lock、目标 cluster 与 pinned kubectl 绑定到最后一跳,但固定 field manager 的 server-side apply 只解决字段
ownership,不提供部署意图的先后顺序 CAS。两个运维者从同一旧状态取得 preflight 后,后执行者仍可能覆盖先执行者;manifest 中移除的
对象也不会被普通 apply 删除,若只检查新 manifest,旧对象可残留而 receipt 仍显示成功。
Kubernetes API 的 server-side apply 文档明确说明,它不适合依赖 current value 的条件更新;这种 lost-update 防护应使用包含当前
`resourceVersion` 的 update/replace。`kubectl apply --prune` 的 allowlist 仍标记为 alpha,也不能替代显式资源退休协议。因此部署
ceremony 需要一个很小的目标侧 durable head,而不是把并发或删除语义藏进 field ownership。
## 决策
1. 尚未发布的 deployment command/preflight/receipt 直接升级为 v2,不保留可绕过 Head 的 v1 双轨。每个 preflight/apply 必须显式提供
`transitionKind=install|upgrade|rollback` 和 exact `expectedHead={generation,deploymentDigest,lockDigest,stateDigest}`
2. Cluster 生产转换只接受 `control,control-ai,admin,worker` 完整 role surface。locked manifest 推导唯一 control namespace,并生成按
`apiVersion/kind/namespace/name` 排序、去重的完整 resource inventory;调用方不能另选 Head namespace 或提交手写 inventory。
3. 目标 namespace 中固定使用一个 `qinglong3-deployment-head` ConfigMap,唯一数据键为 `head.json`。它保存 schema、phase、generation、
exact transition、当前/前一 lock 与 inventory 摘要、target/tool authority、五项 workload step transcript digest、deployment digest 和
self digest;不保存 manifest 正文、token、credential、kubeconfig 或命令输出正文。
4. preflight 在 identity read 后读取 Head,要求它为空或 `committed`,并比较 exact expected Head。第一次 `install` 只接受空 Head
`upgrade` 只接受严格递增 SemVer`rollback` 只接受 Head 中精确的上一 deployment。
5. apply 重读 Head 后,以 ConfigMap create 或带 GET 所得 opaque `resourceVersion` 的 replace 原子取得 `phase=applying`。相同
command/mutation/preflight 可以恢复同一 applying intent;任何其他意图、陈旧 preflight、resourceVersion conflict 或 Head 漂移都在
workload mutation 前失败关闭。
6. server-side apply、convergence read 与末次 cluster identity 全部成功后,ceremony 再以 applying ConfigMap 的 resourceVersion replace
`phase=committed`,然后才 no-replace 发布 receipt。若 committed response 已返回而本地 receipt 丢失,相同 command 可从 Head
确定性重建;已有本地 receipt 的重放也必须联网确认 Head 尚未前进。
7. `upgrade` 要求 active inventory 是 target inventory 的子集;省略现存对象立即失败,不能隐式 prune。`rollback` 额外要求 current、
target 与 previous inventory 完全相同。需要删除对象时等待独立的 UID/resourceVersion delete-precondition retirement ceremony;本
ADR 不把“对象仍残留”冒充成功回退。
8. receipt 继续声明 Kubernetes 多资源 mutation 非事务,但新增 Head generation/deployment/state digest、完整 inventory、
`deploymentHeadCas=true``resourceInventoryClosed=true`
`recovery=resume_exact_transition_from_target_head`。offline receipt audit 不访问 API Server,也不宣称 Head 仍是当前状态。
## 部署与资源影响
- Local/Edge/Standalone 路径零导入、零制品增量;路由设备不创建 Head、不加载 Kubernetes/YAML/semver 或 Cluster role 代码。
- Cluster 每个目标 namespace 只增加一个小型 ConfigMap;无 controller、CRD、webhook、ServiceAccount、Pod、listener、timer、watcher、
数据库、migration、SQL 或 Pool。preflight 比 ADR-0433 多一次 Head GET;首次 apply 多一次 create,后续 apply 为 acquire replace 和
commit replace。
- 逻辑继续内聚在根级工作站 ceremony,不新增浅 workspace package。Head 是发布排序证据,不是通用 desired-state controller。
- namespace 必须先由受控 bootstrap 创建;否则 Head create 与 namespaced workload dry-run 都自然失败关闭。
## 被拒绝的替代方案
### 仅依赖相同 server-side apply field manager
拒绝。field manager 管理字段 ownership,不比较调用者观察到的上一部署 generation,陈旧 writer 仍可成为最后写入者。
### 使用 `kubectl apply --prune`
拒绝。当前 prune allowlist/ApplySet 仍有 alpha 边界,而且删除必须绑定已观察对象 UID/resourceVersion 与独立 receipt;隐式集合删除无法满足
这一证明要求。
### 在 PostgreSQL 或新 controller 中保存部署锁
拒绝。最后一跳的并发事实应与目标 API Server 同故障域;额外数据库或常驻 reconciler 会扩大低资源、凭据、升级与可用性边界。
## 验证
- 定向 Node 契约 14/14,通过空 Head install、create/replace CAS、committed receipt、同意图 applying 恢复、陈旧 preflight 阻断、
resource omission fail-closed、错误/漂移不发布 receipt、closed command 与低敏 CLI
- 隔离三节点 K3s `v1.34.3+k3s1`/Linux arm64 真实运行 6 个 release resource、4 个零副本 Deployment 和一个固定 Head ConfigMap
Head 从 absent 经 applying 到 generation 1 committeddeployment digest 为
`sha256:f911cda19195735d00fcc6317db2c22794855e5eb5795696b19a4239fb68a263`resource inventory 为 6receipt audit 通过,临时
container/network 清理完成;
- 完整 backend、18-package、边界、制品与 PostgreSQL HA 结果记录在 QL-RFC-0001 D-342。
## 规范依据
- [Kubernetes API conceptsresourceVersion 与 conditional update](https://kubernetes.io/docs/reference/using-api/api-concepts/)
- [Kubernetes declarative object managementprune/ApplySet 状态](https://kubernetes.io/docs/tasks/manage-kubernetes-objects/declarative-config/)
+1
View File
@@ -437,6 +437,7 @@
| [ADR-0431](./ADR-0431-catalog-bound-deployment-lock-chain.md) | Catalog-bound Deployment Lock 证据链 | Accepted(真实公开 catalog 运行待实际 release tag |
| [ADR-0432](./ADR-0432-target-side-catalog-bound-local-compose-revisions.md) | 目标侧 Catalog-bound Local Compose 修订 | Accepted |
| [ADR-0433](./ADR-0433-catalog-bound-kubernetes-target-deployment-ceremony.md) | Catalog-bound Kubernetes 目标部署 Ceremony | Accepted |
| [ADR-0434](./ADR-0434-resource-inventory-closed-kubernetes-deployment-head.md) | Resource-inventory-closed Kubernetes Deployment Head | Accepted |
## 规则
+36 -11
View File
@@ -194,13 +194,16 @@ kubectl --kubeconfig="${kubeconfig}" --context="${context}" \
ceremony 目录必须为当前 UID 的 canonical `0700` 目录;command、locked manifest、lock report、kubeconfig、preflight 与 receipt
都必须是单链接 `0600` 文件。kubectl 使用 realpath 后的 absolute executable,并记录其 SHA-256。kubeconfig 禁止 `exec`
`auth-provider`。每个 kubectl 调用使用独立临时 HOME/XDG cache/TMPDIR,结束即清理,不读取 ambient HOME 或在当前目录创建
`.kube/cache`以下是 preflight command 的逻辑结构;实际文件必须用 `JSON.stringify(value) + "\n"` 写成单行 canonical JSON
并以 `0600` no-replace 创建:
`.kube/cache`目标 Namespace 还承载固定名称 `qinglong3-deployment-head` 的小型 ConfigMap。它不属于应用 manifest,也不由
server-side apply 接管;ceremony 只通过 API Server 返回的 opaque `resourceVersion` 执行 create/replace CAS。第一次安装使用
`install` 和空 Head;后续 `upgrade`/`rollback` 必须从上一份 receipt 的 `deploymentHead` 复制 generation、deployment/lock/state
digest,不能自行推测。以下是第一次安装的 preflight command 逻辑结构;实际文件必须用 `JSON.stringify(value) + "\n"` 写成单行
canonical JSON,并以 `0600` no-replace 创建:
```json
{
"schemaVersion": 1,
"schema": "qinglong/kubernetes-deployment-command@v1",
"schema": "qinglong/kubernetes-deployment-command@v2",
"operation": "cluster.deployment.preflight",
"request": {
"preflightId": "<new UUID>",
@@ -222,6 +225,13 @@ ceremony 目录必须为当前 UID 的 canonical `0700` 目录;command、locke
},
"context": "<explicit context>",
"expectedClusterUid": "<reviewed kube-system UID>",
"transitionKind": "install",
"expectedHead": {
"generation": 0,
"deploymentDigest": null,
"lockDigest": null,
"stateDigest": null
},
"output": "<unused canonical absolute preflight.json>"
}
}
@@ -233,13 +243,14 @@ ceremony 目录必须为当前 UID 的 canonical `0700` 目录;command、locke
pnpm cluster-deployment:ql3 -- --command-file="${preflight_command}"
```
成功后,人工核对返回的 `preflightDigest`、lock/catalog digest、cluster UID 和
`kubernetesMutation:false`。apply 必须使用新的 mutation UUID,并精确复用所有 target/input authority
成功后,人工核对返回的 `preflightDigest`、lock/catalog digest、cluster UID、完整 resource inventory、`deploymentHead`
`kubernetesMutation:false`。apply 必须使用新的 mutation UUID,并精确复用 transition、expected Head 与所有 target/input
authority
```json
{
"schemaVersion": 1,
"schema": "qinglong/kubernetes-deployment-command@v1",
"schema": "qinglong/kubernetes-deployment-command@v2",
"operation": "cluster.deployment.apply",
"request": {
"mutationId": "<new UUID>",
@@ -265,22 +276,36 @@ pnpm cluster-deployment:ql3 -- --command-file="${preflight_command}"
},
"context": "<same context>",
"expectedClusterUid": "<same cluster UID>",
"transitionKind": "install",
"expectedHead": {
"generation": 0,
"deploymentDigest": null,
"lockDigest": null,
"stateDigest": null
},
"output": "<unused canonical absolute receipt.json>"
}
}
```
同样通过 `cluster-deployment:ql3` 执行。apply 会重新完成 lock 检查、cluster identity 和 server-side dry-run,然后以固定
`qinglong3-catalog-lock` field manager 执行 server-side apply,不使用 `--force-conflicts`;最后读取 live objects,验证
同样通过 `cluster-deployment:ql3` 执行。apply 先重读 Head 并以 create/resourceVersion replace 把唯一意图置为 `applying`,再重新
完成 lock 检查、cluster identity 和 server-side dry-run,然后以固定 `qinglong3-catalog-lock` field manager 执行 server-side
apply,不使用 `--force-conflicts`;最后读取 live objects,验证
UID/resourceVersion、完整期望字段、四类 immutable image/catalog annotations 和受影响资源的 managed-field ownership,再次确认
cluster UID 后才发布 receipt。
cluster UID,最后以同一 ConfigMap 的新 resourceVersion 提交 `committed` Head 后才发布 receipt。Head 保存当前与前一 deployment
摘要、完整有序 resource inventory、五个 workload step digest 和 self digest;它不保存 credential、token 或 manifest 正文。
`upgrade` 只接受严格递增 SemVer,且 active inventory 必须是目标 inventory 的子集;遗漏对象不会借助隐式 prune 删除,而是在
mutation 前失败关闭。`rollback` 只接受 Head 中精确的上一部署 lock,且当前/目标 inventory 必须完全相同;涉及资源退休时须等待
独立的 UID/resourceVersion-precondition retirement ceremony,不能把遗留对象伪装成回滚成功。
离线审计使用 `cluster.deployment.receipt.audit` command,其中 `applyCommand.expectedDigest` 是 apply command 文件完整字节的
SHA-256`receipt.expectedDigest` 是 receipt 内的 `receiptDigest`。审计不会访问 Kubernetes API,结果必须保持
`externalResultsReplayed:false``kubernetesMutation:false`
多资源 apply 不是事务,也不提供自动删除式 rollback。失败或 receipt 响应丢失时保留原文件:同 command/field manager 可安全
重放并重新做 live convergence;需要回退时,以上一份 catalog-bound lock 创建新的显式 preflight/apply,而不是修改旧 receipt
多资源 apply 不是事务,也不提供自动删除式 rollback。失败或 receipt 响应丢失时保留原文件:只有相同 command/mutation/preflight
意图能从 `applying` Head 重做 live convergence;不同意图和陈旧 preflight 都失败关闭。若 Head 已 `committed` 但本地 receipt 丢失,
同一 command 可从目标 Head 确定性重建 receipt。已有本地 receipt 的重放也会联网确认 Head 尚未前进,不能离线冒充当前部署。
## 准入检查
@@ -8,6 +8,9 @@ import {
type LocalComposeReleaseSelectionInput,
type ResolvedLocalComposeReleaseSelection,
} from '../compose/releaseSelection';
import { LocalDeploymentConfigurationError } from './error';
export { LocalDeploymentConfigurationError } from './error';
const MAX_PATH_BYTES = 4_096;
const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/;
@@ -364,15 +367,6 @@ export interface LocalDeploymentComposeEvidenceCollectionResult {
}>;
}
export class LocalDeploymentConfigurationError extends TypeError {
readonly code = 'QL3_LOCAL_DEPLOYMENT_CONFIGURATION_INVALID';
constructor(message: string, options?: ErrorOptions) {
super(`Local deployment configuration is invalid: ${message}`, options);
this.name = 'LocalDeploymentConfigurationError';
}
}
function object(value: unknown, label: string): Record<string, unknown> {
if (
!value ||
@@ -0,0 +1,8 @@
export class LocalDeploymentConfigurationError extends TypeError {
readonly code = 'QL3_LOCAL_DEPLOYMENT_CONFIGURATION_INVALID';
constructor(message: string, options?: ErrorOptions) {
super(`Local deployment configuration is invalid: ${message}`, options);
this.name = 'LocalDeploymentConfigurationError';
}
}
@@ -5,7 +5,7 @@ import { spawnSync } from 'node:child_process';
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
import { LocalDeploymentConfigurationError } from '../foundation/contract';
import { LocalDeploymentConfigurationError } from '../foundation/error';
import {
normalizeLocalServiceBridgeCommand,
normalizeLocalServiceManagerIntent,
@@ -1,7 +1,7 @@
import crypto from 'node:crypto';
import path from 'node:path';
import { LocalDeploymentConfigurationError } from '../foundation/contract';
import { LocalDeploymentConfigurationError } from '../foundation/error';
const MAX_PATH_BYTES = 4_096;
const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/;
@@ -1,7 +1,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { LocalDeploymentConfigurationError } from '../foundation/contract';
import { LocalDeploymentConfigurationError } from '../foundation/error';
const MAX_FILE_BYTES = 1024 * 1024;
@@ -8,10 +8,8 @@ import {
readPrivateLocalJsonFile,
} from '@qinglong/local-command-file';
import {
currentIdentity,
LocalDeploymentConfigurationError,
} from '../foundation/contract';
import { currentIdentity } from '../foundation/contract';
import { LocalDeploymentConfigurationError } from '../foundation/error';
import {
advanceLocalCutoverInstanceHead,
readLocalCutoverInstanceHead,
@@ -3,7 +3,7 @@ import path from 'node:path';
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
import { LocalDeploymentConfigurationError } from '../foundation/contract';
import { LocalDeploymentConfigurationError } from '../foundation/error';
import { preflightPublishedFile, publishExactFile } from '../foundation/files';
import { cutoverDigest } from '../cutover/targetEvidence';
import type {
@@ -7,10 +7,8 @@ import {
readPrivateLocalJsonFile,
} from '@qinglong/local-command-file';
import {
currentIdentity,
LocalDeploymentConfigurationError,
} from '../foundation/contract';
import { currentIdentity } from '../foundation/contract';
import { LocalDeploymentConfigurationError } from '../foundation/error';
import {
assertLocalCutoverTargetHead,
localCutoverInstanceHeadPath,
@@ -1,6 +1,6 @@
import crypto from 'node:crypto';
import { LocalDeploymentConfigurationError } from '../foundation/contract';
import { LocalDeploymentConfigurationError } from '../foundation/error';
import type {
LocalServiceManagerAction,
LocalServiceManagerKind,
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,8 @@ const path = require('node:path');
const {
COMMAND_SCHEMA,
FIELD_MANAGER,
HEAD_DATA_KEY,
HEAD_NAME,
canonicalJson,
executeCommand,
} = require('./lib/ql3-kubernetes-deployment-ceremony.cjs');
@@ -74,9 +76,9 @@ function references() {
}
function lockedArtifacts() {
const releaseSetDigest = digest('d341-live-release-set');
const catalogManifestDigest = digest('d341-live-catalog-manifest');
const catalogReportDigest = digest('d341-live-catalog-report');
const releaseSetDigest = digest('d342-live-release-set');
const catalogManifestDigest = digest('d342-live-catalog-manifest');
const catalogReportDigest = digest('d342-live-catalog-report');
const imageReferences = references();
const annotations = {
'qinglong.io/release-set-digest': releaseSetDigest,
@@ -162,7 +164,7 @@ function lockedArtifacts() {
count: role === 'admin' ? 2 : 1,
})),
manifest: {
inputDigest: digest('d341-live-source-render'),
inputDigest: digest('d342-live-source-render'),
outputDigest: digest(manifest),
resources: resources.length,
changedResources: 5,
@@ -256,6 +258,13 @@ async function main() {
},
context: CONTEXT,
expectedClusterUid: clusterUid,
transitionKind: 'install',
expectedHead: {
generation: 0,
deploymentDigest: null,
lockDigest: null,
stateDigest: null,
},
};
const preflightPath = path.join(ceremonyDirectory, 'preflight.json');
const preflightCommand = writeCommand(
@@ -298,6 +307,25 @@ async function main() {
},
);
const audit = executeCommand(auditCommand);
const deploymentHeadConfigMap = fixture.kubectlJson([
'get',
'configmap',
HEAD_NAME,
'-n',
NAMESPACE,
]);
const deploymentHead = JSON.parse(
deploymentHeadConfigMap.data?.[HEAD_DATA_KEY],
);
if (
deploymentHead.phase !== 'committed' ||
deploymentHead.generation !== 1 ||
deploymentHead.stateDigest !== receipt.deploymentHead.stateDigest ||
deploymentHead.deployment?.deploymentDigest !==
receipt.deploymentHead.deploymentDigest
) {
fail('committed deployment head is invalid');
}
const deployments = fixture.kubectlJson([
'get',
'deployments',
@@ -345,6 +373,11 @@ async function main() {
replicas: 0,
fieldManager: FIELD_MANAGER,
immutableImages: true,
headName: HEAD_NAME,
headPhase: deploymentHead.phase,
headGeneration: deploymentHead.generation,
deploymentDigest: deploymentHead.deployment.deploymentDigest,
resourceInventoryCount: deploymentHead.deployment.resources.length,
},
preflightDigest: preflight.preflightDigest,
receiptDigest: receipt.receiptDigest,
@@ -352,6 +385,8 @@ async function main() {
serverSideDryRun: preflight.verification.serverSideDryRun,
serverSideApply: receipt.verification.serverSideApply,
convergenceRead: receipt.verification.convergenceRead,
deploymentHeadCas: receipt.verification.deploymentHeadCas,
resourceInventoryClosed: receipt.verification.resourceInventoryClosed,
crossResourceAtomicity: receipt.verification.crossResourceAtomicity,
});
} finally {
@@ -49,6 +49,8 @@ function main() {
file.startsWith(
'packages/ql3-local-owner-cli/dist/deployment/service-manager/',
) ||
file ===
'packages/ql3-local-owner-cli/dist/deployment/foundation/error.js' ||
file ===
'packages/ql3-local-owner-cli/dist/deployment/foundation/contract.js' ||
file.startsWith('packages/ql3-local-command-file/dist/');
@@ -11,6 +11,8 @@ const yaml = require('js-yaml');
const {
COMMAND_SCHEMA,
FIELD_MANAGER,
HEAD_DATA_KEY,
HEAD_NAME,
PREFLIGHT_SCHEMA,
RECEIPT_SCHEMA,
canonicalJson,
@@ -44,21 +46,21 @@ function image(repository, digit) {
return `ghcr.io/qinglong-release/${repository}@sha256:${digit.repeat(64)}`;
}
function annotations() {
function annotations(version = VERSION) {
return {
'qinglong.io/release-set-digest': RELEASE_SET_DIGEST,
'qinglong.io/release-catalog-manifest-digest': CATALOG_MANIFEST_DIGEST,
'qinglong.io/release-catalog-report-digest': CATALOG_REPORT_DIGEST,
'qinglong.io/release-source-revision': SOURCE_REVISION,
'qinglong.io/release-version': VERSION,
'qinglong.io/release-version': version,
};
}
function manifest() {
function manifest({ version = VERSION, extraResource = false } = {}) {
const metadata = (name) => ({
name,
namespace: 'qinglong-system',
annotations: annotations(),
annotations: annotations(version),
});
const deployment = (name, containerName, reference) => ({
apiVersion: 'apps/v1',
@@ -67,7 +69,10 @@ function manifest() {
spec: {
selector: { matchLabels: { app: name } },
template: {
metadata: { labels: { app: name }, annotations: annotations() },
metadata: {
labels: { app: name },
annotations: annotations(version),
},
spec: { containers: [{ name: containerName, image: reference }] },
},
},
@@ -81,7 +86,7 @@ function manifest() {
metadata: metadata('ql3-admin'),
spec: {
template: {
metadata: { annotations: annotations() },
metadata: { annotations: annotations(version) },
spec: {
restartPolicy: 'Never',
containers: [{ name: 'admin', image: REFERENCES.admin }],
@@ -97,26 +102,36 @@ function manifest() {
data: { image: REFERENCES.admin },
},
];
if (extraResource) {
resources.push({
apiVersion: 'v1',
kind: 'ConfigMap',
metadata: metadata('ql3-extra-release-resource'),
data: { purpose: 'resource-inventory-closure-test' },
});
}
return `${resources
.map((resource) => JSON.stringify(resource))
.join('\n---\n')}\n`;
}
function lockReport(manifestContents = manifest()) {
function lockReport(manifestContents = manifest(), version = VERSION) {
const resources = [];
yaml.loadAll(manifestContents, (resource) => resources.push(resource));
const unsigned = {
schemaVersion: 1,
schema: 'qinglong/kubernetes-deployment-lock@v2',
release: {
version: VERSION,
version,
sourceRevision: SOURCE_REVISION,
sourceRef: `refs/tags/v${VERSION}`,
sourceRef: `refs/tags/v${version}`,
scope: 'cluster',
},
releaseSetDigest: RELEASE_SET_DIGEST,
catalog: {
schema: 'qinglong/release-catalog-consumption-ceremony@v1',
sourceRepository: 'qinglong-release/qinglong',
workflowIdentity: `https://github.com/qinglong-release/qinglong/.github/workflows/ql3-image-release.yml@refs/tags/v${VERSION}`,
workflowIdentity: `https://github.com/qinglong-release/qinglong/.github/workflows/ql3-image-release.yml@refs/tags/v${version}`,
immutableReference: `ghcr.io/qinglong-release/qinglong3-release-catalog@${CATALOG_MANIFEST_DIGEST}`,
manifestDigest: CATALOG_MANIFEST_DIGEST,
consumptionReportDigest: CATALOG_REPORT_DIGEST,
@@ -138,7 +153,7 @@ function lockReport(manifestContents = manifest()) {
manifest: {
inputDigest: digest('source-render'),
outputDigest: digest(manifestContents),
resources: 5,
resources: resources.length,
changedResources: 5,
admissionAuthorityCount: 1,
},
@@ -159,14 +174,19 @@ function lockReport(manifestContents = manifest()) {
};
}
function fixture(t) {
function fixture(t, options = {}) {
const directory = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-kubernetes-deployment-')),
);
fs.chmodSync(directory, 0o700);
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const lockedManifest = privateFile(directory, 'locked.yaml', manifest());
const report = lockReport();
const manifestContents = manifest(options);
const lockedManifest = privateFile(
directory,
'locked.yaml',
manifestContents,
);
const report = lockReport(manifestContents, options.version ?? VERSION);
const lockReportPath = privateFile(
directory,
'lock.json',
@@ -207,6 +227,7 @@ users:
kubectl,
kubeconfig,
report,
manifestContents,
};
}
@@ -250,28 +271,72 @@ function commonRequest(fixtureValue) {
},
context: CONTEXT,
expectedClusterUid: CLUSTER_UID,
transitionKind: 'install',
expectedHead: {
generation: 0,
deploymentDigest: null,
lockDigest: null,
stateDigest: null,
},
};
}
function successfulRunner(calls) {
return (_executable, args, input) => {
function successfulRunner(calls, server = {}, manifestContents = manifest()) {
server.head ??= null;
server.resourceVersion ??= 0;
const runner = (_executable, args, input) => {
calls.push({ args: [...args], input });
if (args.includes('get') && args.includes('-f=-')) {
return { status: 0, stdout: convergenceList(), stderr: '' };
return {
status: 0,
stdout: convergenceList(manifestContents),
stderr: '',
};
}
if (args.includes('get')) {
if (args.includes('get') && args.includes(HEAD_NAME)) {
return {
status: 0,
stdout: server.head === null ? '' : JSON.stringify(server.head),
stderr: '',
};
}
if (args.includes('get') && args.includes('kube-system')) {
return { status: 0, stdout: CLUSTER_UID, stderr: '' };
}
if (args.includes('--dry-run=server')) {
return { status: 0, stdout: 'deployment.apps/ql3-control\n', stderr: '' };
}
if (args.includes('create') || args.includes('replace')) {
const configMap = JSON.parse(input);
if (
(args.includes('create') && server.head !== null) ||
(args.includes('replace') &&
(server.head === null ||
configMap.metadata.resourceVersion !==
server.head.metadata.resourceVersion))
) {
return { status: 1, stdout: '', stderr: 'conflict' };
}
server.resourceVersion += 1;
server.head = {
...configMap,
metadata: {
...configMap.metadata,
resourceVersion: String(server.resourceVersion),
uid: '123e4567-e89b-42d3-a456-426614174099',
},
};
return { status: 0, stdout: JSON.stringify(server.head), stderr: '' };
}
return { status: 0, stdout: 'deployment.apps/ql3-control\n', stderr: '' };
};
runner.server = server;
return runner;
}
function convergenceList() {
function convergenceList(manifestContents = manifest()) {
const resources = [];
yaml.loadAll(manifest(), (resource) => resources.push(resource));
yaml.loadAll(manifestContents, (resource) => resources.push(resource));
return JSON.stringify({
apiVersion: 'v1',
kind: 'List',
@@ -310,8 +375,9 @@ function prepare(t) {
},
);
const calls = [];
const runner = successfulRunner(calls, {}, value.manifestContents);
const report = executeCommand(command, {
runProcess: successfulRunner(calls),
runProcess: runner,
});
return {
...value,
@@ -319,6 +385,63 @@ function prepare(t) {
preflightPath: output,
preflight: report,
calls,
runner,
};
}
function expectedFromReceipt(receipt) {
return {
generation: receipt.deploymentHead.generation,
deploymentDigest: receipt.deploymentHead.deploymentDigest,
lockDigest: receipt.deploymentHead.lockDigest,
stateDigest: receipt.deploymentHead.stateDigest,
};
}
function performTransition(
value,
runner,
transitionKind,
expectedHead,
suffix,
) {
const preflightPath = path.join(value.directory, `${suffix}-preflight.json`);
const preflightCommand = writeCommand(
value.directory,
`${suffix}-preflight-command.json`,
'cluster.deployment.preflight',
{
preflightId: crypto.randomUUID(),
...commonRequest(value),
transitionKind,
expectedHead,
output: preflightPath,
},
);
const preflight = executeCommand(preflightCommand, { runProcess: runner });
const receiptPath = path.join(value.directory, `${suffix}-receipt.json`);
const applyCommand = writeCommand(
value.directory,
`${suffix}-apply-command.json`,
'cluster.deployment.apply',
{
mutationId: crypto.randomUUID(),
preflight: {
path: preflightPath,
expectedDigest: preflight.preflightDigest,
},
...commonRequest(value),
transitionKind,
expectedHead,
output: receiptPath,
},
);
return {
preflight,
preflightPath,
applyCommand,
receiptPath,
receipt: executeCommand(applyCommand, { runProcess: runner }),
};
}
@@ -336,17 +459,18 @@ test('preflight binds one catalog lock to one Kubernetes target without mutation
assert.equal(value.preflight.verification.kubernetesMutation, false);
assert.deepEqual(
value.preflight.steps.map(({ name }) => name),
['cluster_identity_before', 'server_side_dry_run'],
['cluster_identity_before', 'deployment_head_read', 'server_side_dry_run'],
);
assert.equal(fs.statSync(value.preflightPath).mode & 0o777, 0o600);
assert.equal(value.calls.length, 2);
assert.equal(value.calls.length, 3);
assert.equal(value.calls[0].args.includes('get'), true);
assert.equal(value.calls[1].args.includes('--dry-run=server'), true);
assert.equal(value.calls[1].args.includes(HEAD_NAME), true);
assert.equal(value.calls[2].args.includes('--dry-run=server'), true);
assert.equal(
value.calls[1].args.includes(`--field-manager=${FIELD_MANAGER}`),
value.calls[2].args.includes(`--field-manager=${FIELD_MANAGER}`),
true,
);
assert.equal(value.calls[1].input, manifest());
assert.equal(value.calls[2].input, manifest());
});
test('preflight rejects lock, manifest and annotation drift before network access', (t) => {
@@ -427,10 +551,11 @@ test('preflight rejects the wrong cluster and rejected server dry-run', (t) => {
output,
},
);
const base = successfulRunner([]);
assert.throws(() =>
executeCommand(command, {
runProcess(_executable, args) {
if (args.includes('get')) {
runProcess(executable, args, input) {
if (args.includes('get') && args.includes('kube-system')) {
return {
status: 0,
stdout:
@@ -438,7 +563,14 @@ test('preflight rejects the wrong cluster and rejected server dry-run', (t) => {
stderr: '',
};
}
return { status: 1, stdout: '', stderr: 'redacted admission error' };
if (failure === 'dry-run' && args.includes('--dry-run=server')) {
return {
status: 1,
stdout: '',
stderr: 'redacted admission error',
};
}
return base(executable, args, input);
},
}),
);
@@ -508,8 +640,12 @@ test('apply revalidates preflight, mutates explicitly and proves convergence', (
},
);
const calls = [];
value.calls.length = 0;
const receipt = executeCommand(applyCommand, {
runProcess: successfulRunner(calls),
runProcess(executable, args, input) {
calls.push({ args: [...args], input });
return value.runner(executable, args, input);
},
});
assert.equal(receipt.schema, RECEIPT_SCHEMA);
assert.equal(receipt.preflightDigest, value.preflight.preflightDigest);
@@ -517,7 +653,7 @@ test('apply revalidates preflight, mutates explicitly and proves convergence', (
assert.equal(receipt.verification.crossResourceAtomicity, false);
assert.equal(
receipt.verification.recovery,
'reapply_exact_lock_with_same_field_manager',
'resume_exact_transition_from_target_head',
);
assert.deepEqual(
receipt.steps.map(({ name }) => name),
@@ -529,7 +665,7 @@ test('apply revalidates preflight, mutates explicitly and proves convergence', (
'cluster_identity_after',
],
);
assert.equal(calls.length, 5);
assert.equal(calls.length, 8);
assert.equal(fs.statSync(receiptPath).mode & 0o777, 0o600);
const auditCommand = writeCommand(
@@ -550,8 +686,13 @@ test('apply revalidates preflight, mutates explicitly and proves convergence', (
mutationId: receipt.mutationId,
receiptDigest: receipt.receiptDigest,
preflightDigest: receipt.preflightDigest,
transitionKind: 'install',
lockDigest: value.report.lockDigest,
manifestDigest: value.report.manifest.outputDigest,
deploymentGeneration: 1,
deploymentDigest: receipt.deploymentHead.deploymentDigest,
deploymentHeadStateDigest: receipt.deploymentHead.stateDigest,
resourceCount: 5,
clusterUid: CLUSTER_UID,
externalResultsReplayed: false,
kubernetesMutation: false,
@@ -560,14 +701,14 @@ test('apply revalidates preflight, mutates explicitly and proves convergence', (
let replayCalls = 0;
assert.equal(
executeCommand(applyCommand, {
runProcess() {
runProcess(executable, args, input) {
replayCalls += 1;
throw new Error('must not run');
return value.runner(executable, args, input);
},
}).receiptDigest,
receipt.receiptDigest,
);
assert.equal(replayCalls, 0);
assert.equal(replayCalls, 1);
});
test('apply failure or post-apply drift never publishes a success receipt', (t) => {
@@ -594,9 +735,10 @@ test('apply failure or post-apply drift never publishes a success receipt', (t)
},
);
let identityCount = 0;
const base = value.runner;
assert.throws(() =>
executeCommand(applyCommand, {
runProcess(_executable, args) {
runProcess(executable, args, input) {
if (args.includes('get') && args.includes('-f=-')) {
return {
status: failure === 'convergence' ? 1 : 0,
@@ -605,7 +747,7 @@ test('apply failure or post-apply drift never publishes a success receipt', (t)
failure === 'convergence' ? 'redacted convergence error' : '',
};
}
if (args.includes('get')) {
if (args.includes('get') && args.includes('kube-system')) {
identityCount += 1;
return {
status: 0,
@@ -619,11 +761,14 @@ test('apply failure or post-apply drift never publishes a success receipt', (t)
if (args.includes('--dry-run=server')) {
return { status: 0, stdout: 'dry-run', stderr: '' };
}
return {
status: failure === 'apply' ? 1 : 0,
stdout: '',
stderr: failure === 'apply' ? 'redacted apply error' : '',
};
if (failure === 'apply' && args.includes('apply')) {
return {
status: 1,
stdout: '',
stderr: 'redacted apply error',
};
}
return base(executable, args, input);
},
}),
);
@@ -631,6 +776,208 @@ test('apply failure or post-apply drift never publishes a success receipt', (t)
}
});
test('apply resumes the exact applying intent from the target head', (t) => {
const value = prepare(t);
const receiptPath = path.join(value.directory, 'resumed-receipt.json');
const applyCommand = writeCommand(
value.directory,
'resumed-apply-command.json',
'cluster.deployment.apply',
{
mutationId: '123e4567-e89b-42d3-a456-426614174043',
preflight: {
path: value.preflightPath,
expectedDigest: value.preflight.preflightDigest,
},
...commonRequest(value),
output: receiptPath,
},
);
let rejected = false;
assert.throws(() =>
executeCommand(applyCommand, {
runProcess(executable, args, input) {
if (
!rejected &&
args.includes('apply') &&
!args.includes('--dry-run=server')
) {
rejected = true;
return { status: 1, stdout: '', stderr: 'transient rejection' };
}
return value.runner(executable, args, input);
},
}),
);
assert.equal(fs.existsSync(receiptPath), false);
assert.equal(
JSON.parse(value.runner.server.head.data[HEAD_DATA_KEY]).phase,
'applying',
);
const receipt = executeCommand(applyCommand, {
runProcess: value.runner,
});
assert.equal(receipt.deploymentHead.generation, 1);
assert.equal(
JSON.parse(value.runner.server.head.data[HEAD_DATA_KEY]).phase,
'committed',
);
});
test('a stale preflight cannot overtake a committed deployment intent', (t) => {
const value = prepare(t);
const competingPreflightPath = path.join(
value.directory,
'competing-preflight.json',
);
const competingPreflightCommand = writeCommand(
value.directory,
'competing-preflight-command.json',
'cluster.deployment.preflight',
{
preflightId: '123e4567-e89b-42d3-a456-426614174044',
...commonRequest(value),
output: competingPreflightPath,
},
);
const competingPreflight = executeCommand(competingPreflightCommand, {
runProcess: value.runner,
});
const winner = performTransition(
value,
value.runner,
'install',
commonRequest(value).expectedHead,
'winner',
);
assert.equal(winner.receipt.deploymentHead.generation, 1);
const staleReceipt = path.join(value.directory, 'stale-receipt.json');
const staleApply = writeCommand(
value.directory,
'stale-apply-command.json',
'cluster.deployment.apply',
{
mutationId: '123e4567-e89b-42d3-a456-426614174045',
preflight: {
path: competingPreflightPath,
expectedDigest: competingPreflight.preflightDigest,
},
...commonRequest(value),
output: staleReceipt,
},
);
assert.throws(() => executeCommand(staleApply, { runProcess: value.runner }));
assert.equal(fs.existsSync(staleReceipt), false);
});
test('upgrade fails closed when its target omits an active resource', (t) => {
const server = {};
const installValue = fixture(t, { version: '3.0.0-alpha.0' });
const installRunner = successfulRunner(
[],
server,
installValue.manifestContents,
);
const installed = performTransition(
installValue,
installRunner,
'install',
commonRequest(installValue).expectedHead,
'inventory-install',
);
const expandedValue = fixture(t, {
version: '3.0.0-alpha.1',
extraResource: true,
});
const expandedRunner = successfulRunner(
[],
server,
expandedValue.manifestContents,
);
const expanded = performTransition(
expandedValue,
expandedRunner,
'upgrade',
expectedFromReceipt(installed.receipt),
'inventory-expand',
);
assert.equal(expanded.receipt.resourceInventory.length, 6);
const reducedValue = fixture(t, { version: '3.0.0-alpha.2' });
const reducedRunner = successfulRunner(
[],
server,
reducedValue.manifestContents,
);
const reducedPreflightPath = path.join(
reducedValue.directory,
'inventory-reduce-preflight.json',
);
const reducedPreflight = writeCommand(
reducedValue.directory,
'inventory-reduce-preflight-command.json',
'cluster.deployment.preflight',
{
preflightId: crypto.randomUUID(),
...commonRequest(reducedValue),
transitionKind: 'upgrade',
expectedHead: expectedFromReceipt(expanded.receipt),
output: reducedPreflightPath,
},
);
assert.throws(() =>
executeCommand(reducedPreflight, { runProcess: reducedRunner }),
);
assert.equal(fs.existsSync(reducedPreflightPath), false);
});
test('rollback restores only the exact previous lock with unchanged inventory', (t) => {
const server = {};
const installValue = fixture(t, { version: '3.0.0-alpha.0' });
const installRunner = successfulRunner(
[],
server,
installValue.manifestContents,
);
const installed = performTransition(
installValue,
installRunner,
'install',
commonRequest(installValue).expectedHead,
'rollback-install',
);
const upgradeValue = fixture(t, { version: '3.0.0-alpha.1' });
const upgradeRunner = successfulRunner(
[],
server,
upgradeValue.manifestContents,
);
const upgraded = performTransition(
upgradeValue,
upgradeRunner,
'upgrade',
expectedFromReceipt(installed.receipt),
'rollback-upgrade',
);
const rolledBack = performTransition(
installValue,
installRunner,
'rollback',
expectedFromReceipt(upgraded.receipt),
'rollback-restore',
);
assert.equal(rolledBack.receipt.transitionKind, 'rollback');
assert.equal(rolledBack.receipt.deploymentHead.generation, 3);
assert.equal(
rolledBack.receipt.lock.lockDigest,
installed.receipt.lock.lockDigest,
);
assert.deepEqual(
rolledBack.receipt.resourceInventory,
installed.receipt.resourceInventory,
);
});
test('receipt audit rejects a different command or recomputed receipt', (t) => {
const value = prepare(t);
const receiptPath = path.join(value.directory, 'receipt.json');
@@ -731,6 +1078,9 @@ const args = process.argv.slice(2);
if (args.includes('get') && args.includes('-f=-')) process.stdout.write(${JSON.stringify(
convergenceList(),
)});
else if (args.includes('get') && args.includes(${JSON.stringify(
HEAD_NAME,
)})) process.stdout.write('');
else if (args.includes('get')) process.stdout.write(${JSON.stringify(
CLUSTER_UID,
)});
+2 -2
View File
@@ -207,10 +207,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
},
{
sourceFiles: 106,
sourceFiles: 107,
rootSourceFiles: 1,
rootSourceLines: 50,
nestedSourceFiles: 105,
nestedSourceFiles: 106,
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
},
);