mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 03:18:09 +08:00
feat(ql3): fence cluster deployment transitions
This commit is contained in:
@@ -11,6 +11,29 @@
|
|||||||
|
|
||||||
最新增量证据(2026-08-16):
|
最新增量证据(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 digest;preflight 读取当前 Head,apply
|
||||||
|
再用 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 inventory;upgrade 不能省略现存对象,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 全部 compatible,package boundary 仍为
|
||||||
|
18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`;Local Owner 的 107 个源码中 106 个位于领域目录。默认
|
||||||
|
Edge/Standalone 保持 2,589,890/2,589,968 bytes,MCP 保持 7,315,930/7,316,038 bytes;Cluster 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 Head,deployment/preflight/receipt digest 分别为
|
||||||
|
`sha256:f911cda19195735d00fcc6317db2c22794855e5eb5795696b19a4239fb68a263`、
|
||||||
|
`sha256:574f80dde330c5b4fe41c681906a67ffd6e4b608dedc6d955574771b2bd5c711`、
|
||||||
|
`sha256:0f2edd2037a4df7ae6eb28616aa38a280498660443d0f091e1fac5c1ff532258`,资源清理完成。经允许重跑 PostgreSQL 18.6
|
||||||
|
arm64 physical HA,142/142、timeline `1→2`,报告 SHA-256
|
||||||
|
`8ba0b130100b48e19c602546b63844903e567d6997630f8c950b51cf41ee70ea`,独立证据审计通过且容器、卷、网络零残留。live 使用 synthetic
|
||||||
|
lock,只证明部署状态机和目标 API CAS,不冒充尚未产生的公开 GHCR catalog。
|
||||||
- D-341/ADR-0433(已接受;真实公开 catalog 运行待实际 release tag):Cluster 最后一跳不再交给裸
|
- D-341/ADR-0433(已接受;真实公开 catalog 运行待实际 release tag):Cluster 最后一跳不再交给裸
|
||||||
`kubectl apply -f locked.yaml`。新增工作站级 `cluster.deployment.preflight|apply|receipt.audit`,从 owner-private canonical
|
`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 闭包,并固定
|
command 出发独立复验 Kubernetes v2 lock/report、locked manifest、release-set/catalog/workflow/image/annotation 闭包,并固定
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# ADR-0434:Resource-inventory-closed Kubernetes Deployment Head
|
||||||
|
|
||||||
|
- 状态:Accepted
|
||||||
|
- 日期:2026-08-16
|
||||||
|
- 关联 RFC:QL-RFC-0001 D-03、D-14、D-341、D-342
|
||||||
|
- 关联 ADR:ADR-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 committed,deployment digest 为
|
||||||
|
`sha256:f911cda19195735d00fcc6317db2c22794855e5eb5795696b19a4239fb68a263`,resource inventory 为 6,receipt audit 通过,临时
|
||||||
|
container/network 清理完成;
|
||||||
|
- 完整 backend、18-package、边界、制品与 PostgreSQL HA 结果记录在 QL-RFC-0001 D-342。
|
||||||
|
|
||||||
|
## 规范依据
|
||||||
|
|
||||||
|
- [Kubernetes API concepts:resourceVersion 与 conditional update](https://kubernetes.io/docs/reference/using-api/api-concepts/)
|
||||||
|
- [Kubernetes declarative object management:prune/ApplySet 状态](https://kubernetes.io/docs/tasks/manage-kubernetes-objects/declarative-config/)
|
||||||
@@ -437,6 +437,7 @@
|
|||||||
| [ADR-0431](./ADR-0431-catalog-bound-deployment-lock-chain.md) | Catalog-bound Deployment Lock 证据链 | Accepted(真实公开 catalog 运行待实际 release tag) |
|
| [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-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-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 |
|
||||||
|
|
||||||
## 规则
|
## 规则
|
||||||
|
|
||||||
|
|||||||
@@ -194,13 +194,16 @@ kubectl --kubeconfig="${kubeconfig}" --context="${context}" \
|
|||||||
ceremony 目录必须为当前 UID 的 canonical `0700` 目录;command、locked manifest、lock report、kubeconfig、preflight 与 receipt
|
ceremony 目录必须为当前 UID 的 canonical `0700` 目录;command、locked manifest、lock report、kubeconfig、preflight 与 receipt
|
||||||
都必须是单链接 `0600` 文件。kubectl 使用 realpath 后的 absolute executable,并记录其 SHA-256。kubeconfig 禁止 `exec` 与
|
都必须是单链接 `0600` 文件。kubectl 使用 realpath 后的 absolute executable,并记录其 SHA-256。kubeconfig 禁止 `exec` 与
|
||||||
`auth-provider`。每个 kubectl 调用使用独立临时 HOME/XDG cache/TMPDIR,结束即清理,不读取 ambient HOME 或在当前目录创建
|
`auth-provider`。每个 kubectl 调用使用独立临时 HOME/XDG cache/TMPDIR,结束即清理,不读取 ambient HOME 或在当前目录创建
|
||||||
`.kube/cache`。以下是 preflight command 的逻辑结构;实际文件必须用 `JSON.stringify(value) + "\n"` 写成单行 canonical JSON,
|
`.kube/cache`。目标 Namespace 还承载固定名称 `qinglong3-deployment-head` 的小型 ConfigMap。它不属于应用 manifest,也不由
|
||||||
并以 `0600` no-replace 创建:
|
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
|
```json
|
||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"schema": "qinglong/kubernetes-deployment-command@v1",
|
"schema": "qinglong/kubernetes-deployment-command@v2",
|
||||||
"operation": "cluster.deployment.preflight",
|
"operation": "cluster.deployment.preflight",
|
||||||
"request": {
|
"request": {
|
||||||
"preflightId": "<new UUID>",
|
"preflightId": "<new UUID>",
|
||||||
@@ -222,6 +225,13 @@ ceremony 目录必须为当前 UID 的 canonical `0700` 目录;command、locke
|
|||||||
},
|
},
|
||||||
"context": "<explicit context>",
|
"context": "<explicit context>",
|
||||||
"expectedClusterUid": "<reviewed kube-system UID>",
|
"expectedClusterUid": "<reviewed kube-system UID>",
|
||||||
|
"transitionKind": "install",
|
||||||
|
"expectedHead": {
|
||||||
|
"generation": 0,
|
||||||
|
"deploymentDigest": null,
|
||||||
|
"lockDigest": null,
|
||||||
|
"stateDigest": null
|
||||||
|
},
|
||||||
"output": "<unused canonical absolute preflight.json>"
|
"output": "<unused canonical absolute preflight.json>"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,13 +243,14 @@ ceremony 目录必须为当前 UID 的 canonical `0700` 目录;command、locke
|
|||||||
pnpm cluster-deployment:ql3 -- --command-file="${preflight_command}"
|
pnpm cluster-deployment:ql3 -- --command-file="${preflight_command}"
|
||||||
```
|
```
|
||||||
|
|
||||||
成功后,人工核对返回的 `preflightDigest`、lock/catalog digest、cluster UID 和
|
成功后,人工核对返回的 `preflightDigest`、lock/catalog digest、cluster UID、完整 resource inventory、`deploymentHead` 和
|
||||||
`kubernetesMutation:false`。apply 必须使用新的 mutation UUID,并精确复用所有 target/input authority:
|
`kubernetesMutation:false`。apply 必须使用新的 mutation UUID,并精确复用 transition、expected Head 与所有 target/input
|
||||||
|
authority:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"schema": "qinglong/kubernetes-deployment-command@v1",
|
"schema": "qinglong/kubernetes-deployment-command@v2",
|
||||||
"operation": "cluster.deployment.apply",
|
"operation": "cluster.deployment.apply",
|
||||||
"request": {
|
"request": {
|
||||||
"mutationId": "<new UUID>",
|
"mutationId": "<new UUID>",
|
||||||
@@ -265,22 +276,36 @@ pnpm cluster-deployment:ql3 -- --command-file="${preflight_command}"
|
|||||||
},
|
},
|
||||||
"context": "<same context>",
|
"context": "<same context>",
|
||||||
"expectedClusterUid": "<same cluster UID>",
|
"expectedClusterUid": "<same cluster UID>",
|
||||||
|
"transitionKind": "install",
|
||||||
|
"expectedHead": {
|
||||||
|
"generation": 0,
|
||||||
|
"deploymentDigest": null,
|
||||||
|
"lockDigest": null,
|
||||||
|
"stateDigest": null
|
||||||
|
},
|
||||||
"output": "<unused canonical absolute receipt.json>"
|
"output": "<unused canonical absolute receipt.json>"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
同样通过 `cluster-deployment:ql3` 执行。apply 会重新完成 lock 检查、cluster identity 和 server-side dry-run,然后以固定
|
同样通过 `cluster-deployment:ql3` 执行。apply 先重读 Head 并以 create/resourceVersion replace 把唯一意图置为 `applying`,再重新
|
||||||
`qinglong3-catalog-lock` field manager 执行 server-side apply,不使用 `--force-conflicts`;最后读取 live objects,验证
|
完成 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,再次确认
|
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 文件完整字节的
|
离线审计使用 `cluster.deployment.receipt.audit` command,其中 `applyCommand.expectedDigest` 是 apply command 文件完整字节的
|
||||||
SHA-256,`receipt.expectedDigest` 是 receipt 内的 `receiptDigest`。审计不会访问 Kubernetes API,结果必须保持
|
SHA-256,`receipt.expectedDigest` 是 receipt 内的 `receiptDigest`。审计不会访问 Kubernetes API,结果必须保持
|
||||||
`externalResultsReplayed:false`、`kubernetesMutation:false`。
|
`externalResultsReplayed:false`、`kubernetesMutation:false`。
|
||||||
|
|
||||||
多资源 apply 不是事务,也不提供自动删除式 rollback。失败或 receipt 响应丢失时保留原文件:同一 command/field manager 可安全
|
多资源 apply 不是事务,也不提供自动删除式 rollback。失败或 receipt 响应丢失时保留原文件:只有相同 command/mutation/preflight
|
||||||
重放并重新做 live convergence;需要回退时,以上一份 catalog-bound lock 创建新的显式 preflight/apply,而不是修改旧 receipt。
|
意图能从 `applying` Head 重做 live convergence;不同意图和陈旧 preflight 都失败关闭。若 Head 已 `committed` 但本地 receipt 丢失,
|
||||||
|
同一 command 可从目标 Head 确定性重建 receipt。已有本地 receipt 的重放也会联网确认 Head 尚未前进,不能离线冒充当前部署。
|
||||||
|
|
||||||
## 准入检查
|
## 准入检查
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import {
|
|||||||
type LocalComposeReleaseSelectionInput,
|
type LocalComposeReleaseSelectionInput,
|
||||||
type ResolvedLocalComposeReleaseSelection,
|
type ResolvedLocalComposeReleaseSelection,
|
||||||
} from '../compose/releaseSelection';
|
} from '../compose/releaseSelection';
|
||||||
|
import { LocalDeploymentConfigurationError } from './error';
|
||||||
|
|
||||||
|
export { LocalDeploymentConfigurationError } from './error';
|
||||||
|
|
||||||
const MAX_PATH_BYTES = 4_096;
|
const MAX_PATH_BYTES = 4_096;
|
||||||
const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/;
|
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> {
|
function object(value: unknown, label: string): Record<string, unknown> {
|
||||||
if (
|
if (
|
||||||
!value ||
|
!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 { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||||
|
|
||||||
import { LocalDeploymentConfigurationError } from '../foundation/contract';
|
import { LocalDeploymentConfigurationError } from '../foundation/error';
|
||||||
import {
|
import {
|
||||||
normalizeLocalServiceBridgeCommand,
|
normalizeLocalServiceBridgeCommand,
|
||||||
normalizeLocalServiceManagerIntent,
|
normalizeLocalServiceManagerIntent,
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { LocalDeploymentConfigurationError } from '../foundation/contract';
|
import { LocalDeploymentConfigurationError } from '../foundation/error';
|
||||||
|
|
||||||
const MAX_PATH_BYTES = 4_096;
|
const MAX_PATH_BYTES = 4_096;
|
||||||
const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/;
|
const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { LocalDeploymentConfigurationError } from '../foundation/contract';
|
import { LocalDeploymentConfigurationError } from '../foundation/error';
|
||||||
|
|
||||||
const MAX_FILE_BYTES = 1024 * 1024;
|
const MAX_FILE_BYTES = 1024 * 1024;
|
||||||
|
|
||||||
|
|||||||
+2
-4
@@ -8,10 +8,8 @@ import {
|
|||||||
readPrivateLocalJsonFile,
|
readPrivateLocalJsonFile,
|
||||||
} from '@qinglong/local-command-file';
|
} from '@qinglong/local-command-file';
|
||||||
|
|
||||||
import {
|
import { currentIdentity } from '../foundation/contract';
|
||||||
currentIdentity,
|
import { LocalDeploymentConfigurationError } from '../foundation/error';
|
||||||
LocalDeploymentConfigurationError,
|
|
||||||
} from '../foundation/contract';
|
|
||||||
import {
|
import {
|
||||||
advanceLocalCutoverInstanceHead,
|
advanceLocalCutoverInstanceHead,
|
||||||
readLocalCutoverInstanceHead,
|
readLocalCutoverInstanceHead,
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ import path from 'node:path';
|
|||||||
|
|
||||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||||
|
|
||||||
import { LocalDeploymentConfigurationError } from '../foundation/contract';
|
import { LocalDeploymentConfigurationError } from '../foundation/error';
|
||||||
import { preflightPublishedFile, publishExactFile } from '../foundation/files';
|
import { preflightPublishedFile, publishExactFile } from '../foundation/files';
|
||||||
import { cutoverDigest } from '../cutover/targetEvidence';
|
import { cutoverDigest } from '../cutover/targetEvidence';
|
||||||
import type {
|
import type {
|
||||||
|
|||||||
@@ -7,10 +7,8 @@ import {
|
|||||||
readPrivateLocalJsonFile,
|
readPrivateLocalJsonFile,
|
||||||
} from '@qinglong/local-command-file';
|
} from '@qinglong/local-command-file';
|
||||||
|
|
||||||
import {
|
import { currentIdentity } from '../foundation/contract';
|
||||||
currentIdentity,
|
import { LocalDeploymentConfigurationError } from '../foundation/error';
|
||||||
LocalDeploymentConfigurationError,
|
|
||||||
} from '../foundation/contract';
|
|
||||||
import {
|
import {
|
||||||
assertLocalCutoverTargetHead,
|
assertLocalCutoverTargetHead,
|
||||||
localCutoverInstanceHeadPath,
|
localCutoverInstanceHeadPath,
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
import { LocalDeploymentConfigurationError } from '../foundation/contract';
|
import { LocalDeploymentConfigurationError } from '../foundation/error';
|
||||||
import type {
|
import type {
|
||||||
LocalServiceManagerAction,
|
LocalServiceManagerAction,
|
||||||
LocalServiceManagerKind,
|
LocalServiceManagerKind,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,8 @@ const path = require('node:path');
|
|||||||
const {
|
const {
|
||||||
COMMAND_SCHEMA,
|
COMMAND_SCHEMA,
|
||||||
FIELD_MANAGER,
|
FIELD_MANAGER,
|
||||||
|
HEAD_DATA_KEY,
|
||||||
|
HEAD_NAME,
|
||||||
canonicalJson,
|
canonicalJson,
|
||||||
executeCommand,
|
executeCommand,
|
||||||
} = require('./lib/ql3-kubernetes-deployment-ceremony.cjs');
|
} = require('./lib/ql3-kubernetes-deployment-ceremony.cjs');
|
||||||
@@ -74,9 +76,9 @@ function references() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function lockedArtifacts() {
|
function lockedArtifacts() {
|
||||||
const releaseSetDigest = digest('d341-live-release-set');
|
const releaseSetDigest = digest('d342-live-release-set');
|
||||||
const catalogManifestDigest = digest('d341-live-catalog-manifest');
|
const catalogManifestDigest = digest('d342-live-catalog-manifest');
|
||||||
const catalogReportDigest = digest('d341-live-catalog-report');
|
const catalogReportDigest = digest('d342-live-catalog-report');
|
||||||
const imageReferences = references();
|
const imageReferences = references();
|
||||||
const annotations = {
|
const annotations = {
|
||||||
'qinglong.io/release-set-digest': releaseSetDigest,
|
'qinglong.io/release-set-digest': releaseSetDigest,
|
||||||
@@ -162,7 +164,7 @@ function lockedArtifacts() {
|
|||||||
count: role === 'admin' ? 2 : 1,
|
count: role === 'admin' ? 2 : 1,
|
||||||
})),
|
})),
|
||||||
manifest: {
|
manifest: {
|
||||||
inputDigest: digest('d341-live-source-render'),
|
inputDigest: digest('d342-live-source-render'),
|
||||||
outputDigest: digest(manifest),
|
outputDigest: digest(manifest),
|
||||||
resources: resources.length,
|
resources: resources.length,
|
||||||
changedResources: 5,
|
changedResources: 5,
|
||||||
@@ -256,6 +258,13 @@ async function main() {
|
|||||||
},
|
},
|
||||||
context: CONTEXT,
|
context: CONTEXT,
|
||||||
expectedClusterUid: clusterUid,
|
expectedClusterUid: clusterUid,
|
||||||
|
transitionKind: 'install',
|
||||||
|
expectedHead: {
|
||||||
|
generation: 0,
|
||||||
|
deploymentDigest: null,
|
||||||
|
lockDigest: null,
|
||||||
|
stateDigest: null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const preflightPath = path.join(ceremonyDirectory, 'preflight.json');
|
const preflightPath = path.join(ceremonyDirectory, 'preflight.json');
|
||||||
const preflightCommand = writeCommand(
|
const preflightCommand = writeCommand(
|
||||||
@@ -298,6 +307,25 @@ async function main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const audit = executeCommand(auditCommand);
|
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([
|
const deployments = fixture.kubectlJson([
|
||||||
'get',
|
'get',
|
||||||
'deployments',
|
'deployments',
|
||||||
@@ -345,6 +373,11 @@ async function main() {
|
|||||||
replicas: 0,
|
replicas: 0,
|
||||||
fieldManager: FIELD_MANAGER,
|
fieldManager: FIELD_MANAGER,
|
||||||
immutableImages: true,
|
immutableImages: true,
|
||||||
|
headName: HEAD_NAME,
|
||||||
|
headPhase: deploymentHead.phase,
|
||||||
|
headGeneration: deploymentHead.generation,
|
||||||
|
deploymentDigest: deploymentHead.deployment.deploymentDigest,
|
||||||
|
resourceInventoryCount: deploymentHead.deployment.resources.length,
|
||||||
},
|
},
|
||||||
preflightDigest: preflight.preflightDigest,
|
preflightDigest: preflight.preflightDigest,
|
||||||
receiptDigest: receipt.receiptDigest,
|
receiptDigest: receipt.receiptDigest,
|
||||||
@@ -352,6 +385,8 @@ async function main() {
|
|||||||
serverSideDryRun: preflight.verification.serverSideDryRun,
|
serverSideDryRun: preflight.verification.serverSideDryRun,
|
||||||
serverSideApply: receipt.verification.serverSideApply,
|
serverSideApply: receipt.verification.serverSideApply,
|
||||||
convergenceRead: receipt.verification.convergenceRead,
|
convergenceRead: receipt.verification.convergenceRead,
|
||||||
|
deploymentHeadCas: receipt.verification.deploymentHeadCas,
|
||||||
|
resourceInventoryClosed: receipt.verification.resourceInventoryClosed,
|
||||||
crossResourceAtomicity: receipt.verification.crossResourceAtomicity,
|
crossResourceAtomicity: receipt.verification.crossResourceAtomicity,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ function main() {
|
|||||||
file.startsWith(
|
file.startsWith(
|
||||||
'packages/ql3-local-owner-cli/dist/deployment/service-manager/',
|
'packages/ql3-local-owner-cli/dist/deployment/service-manager/',
|
||||||
) ||
|
) ||
|
||||||
|
file ===
|
||||||
|
'packages/ql3-local-owner-cli/dist/deployment/foundation/error.js' ||
|
||||||
file ===
|
file ===
|
||||||
'packages/ql3-local-owner-cli/dist/deployment/foundation/contract.js' ||
|
'packages/ql3-local-owner-cli/dist/deployment/foundation/contract.js' ||
|
||||||
file.startsWith('packages/ql3-local-command-file/dist/');
|
file.startsWith('packages/ql3-local-command-file/dist/');
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ const yaml = require('js-yaml');
|
|||||||
const {
|
const {
|
||||||
COMMAND_SCHEMA,
|
COMMAND_SCHEMA,
|
||||||
FIELD_MANAGER,
|
FIELD_MANAGER,
|
||||||
|
HEAD_DATA_KEY,
|
||||||
|
HEAD_NAME,
|
||||||
PREFLIGHT_SCHEMA,
|
PREFLIGHT_SCHEMA,
|
||||||
RECEIPT_SCHEMA,
|
RECEIPT_SCHEMA,
|
||||||
canonicalJson,
|
canonicalJson,
|
||||||
@@ -44,21 +46,21 @@ function image(repository, digit) {
|
|||||||
return `ghcr.io/qinglong-release/${repository}@sha256:${digit.repeat(64)}`;
|
return `ghcr.io/qinglong-release/${repository}@sha256:${digit.repeat(64)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function annotations() {
|
function annotations(version = VERSION) {
|
||||||
return {
|
return {
|
||||||
'qinglong.io/release-set-digest': RELEASE_SET_DIGEST,
|
'qinglong.io/release-set-digest': RELEASE_SET_DIGEST,
|
||||||
'qinglong.io/release-catalog-manifest-digest': CATALOG_MANIFEST_DIGEST,
|
'qinglong.io/release-catalog-manifest-digest': CATALOG_MANIFEST_DIGEST,
|
||||||
'qinglong.io/release-catalog-report-digest': CATALOG_REPORT_DIGEST,
|
'qinglong.io/release-catalog-report-digest': CATALOG_REPORT_DIGEST,
|
||||||
'qinglong.io/release-source-revision': SOURCE_REVISION,
|
'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) => ({
|
const metadata = (name) => ({
|
||||||
name,
|
name,
|
||||||
namespace: 'qinglong-system',
|
namespace: 'qinglong-system',
|
||||||
annotations: annotations(),
|
annotations: annotations(version),
|
||||||
});
|
});
|
||||||
const deployment = (name, containerName, reference) => ({
|
const deployment = (name, containerName, reference) => ({
|
||||||
apiVersion: 'apps/v1',
|
apiVersion: 'apps/v1',
|
||||||
@@ -67,7 +69,10 @@ function manifest() {
|
|||||||
spec: {
|
spec: {
|
||||||
selector: { matchLabels: { app: name } },
|
selector: { matchLabels: { app: name } },
|
||||||
template: {
|
template: {
|
||||||
metadata: { labels: { app: name }, annotations: annotations() },
|
metadata: {
|
||||||
|
labels: { app: name },
|
||||||
|
annotations: annotations(version),
|
||||||
|
},
|
||||||
spec: { containers: [{ name: containerName, image: reference }] },
|
spec: { containers: [{ name: containerName, image: reference }] },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -81,7 +86,7 @@ function manifest() {
|
|||||||
metadata: metadata('ql3-admin'),
|
metadata: metadata('ql3-admin'),
|
||||||
spec: {
|
spec: {
|
||||||
template: {
|
template: {
|
||||||
metadata: { annotations: annotations() },
|
metadata: { annotations: annotations(version) },
|
||||||
spec: {
|
spec: {
|
||||||
restartPolicy: 'Never',
|
restartPolicy: 'Never',
|
||||||
containers: [{ name: 'admin', image: REFERENCES.admin }],
|
containers: [{ name: 'admin', image: REFERENCES.admin }],
|
||||||
@@ -97,26 +102,36 @@ function manifest() {
|
|||||||
data: { image: REFERENCES.admin },
|
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
|
return `${resources
|
||||||
.map((resource) => JSON.stringify(resource))
|
.map((resource) => JSON.stringify(resource))
|
||||||
.join('\n---\n')}\n`;
|
.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 = {
|
const unsigned = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
schema: 'qinglong/kubernetes-deployment-lock@v2',
|
schema: 'qinglong/kubernetes-deployment-lock@v2',
|
||||||
release: {
|
release: {
|
||||||
version: VERSION,
|
version,
|
||||||
sourceRevision: SOURCE_REVISION,
|
sourceRevision: SOURCE_REVISION,
|
||||||
sourceRef: `refs/tags/v${VERSION}`,
|
sourceRef: `refs/tags/v${version}`,
|
||||||
scope: 'cluster',
|
scope: 'cluster',
|
||||||
},
|
},
|
||||||
releaseSetDigest: RELEASE_SET_DIGEST,
|
releaseSetDigest: RELEASE_SET_DIGEST,
|
||||||
catalog: {
|
catalog: {
|
||||||
schema: 'qinglong/release-catalog-consumption-ceremony@v1',
|
schema: 'qinglong/release-catalog-consumption-ceremony@v1',
|
||||||
sourceRepository: 'qinglong-release/qinglong',
|
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}`,
|
immutableReference: `ghcr.io/qinglong-release/qinglong3-release-catalog@${CATALOG_MANIFEST_DIGEST}`,
|
||||||
manifestDigest: CATALOG_MANIFEST_DIGEST,
|
manifestDigest: CATALOG_MANIFEST_DIGEST,
|
||||||
consumptionReportDigest: CATALOG_REPORT_DIGEST,
|
consumptionReportDigest: CATALOG_REPORT_DIGEST,
|
||||||
@@ -138,7 +153,7 @@ function lockReport(manifestContents = manifest()) {
|
|||||||
manifest: {
|
manifest: {
|
||||||
inputDigest: digest('source-render'),
|
inputDigest: digest('source-render'),
|
||||||
outputDigest: digest(manifestContents),
|
outputDigest: digest(manifestContents),
|
||||||
resources: 5,
|
resources: resources.length,
|
||||||
changedResources: 5,
|
changedResources: 5,
|
||||||
admissionAuthorityCount: 1,
|
admissionAuthorityCount: 1,
|
||||||
},
|
},
|
||||||
@@ -159,14 +174,19 @@ function lockReport(manifestContents = manifest()) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function fixture(t) {
|
function fixture(t, options = {}) {
|
||||||
const directory = fs.realpathSync(
|
const directory = fs.realpathSync(
|
||||||
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-kubernetes-deployment-')),
|
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-kubernetes-deployment-')),
|
||||||
);
|
);
|
||||||
fs.chmodSync(directory, 0o700);
|
fs.chmodSync(directory, 0o700);
|
||||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||||
const lockedManifest = privateFile(directory, 'locked.yaml', manifest());
|
const manifestContents = manifest(options);
|
||||||
const report = lockReport();
|
const lockedManifest = privateFile(
|
||||||
|
directory,
|
||||||
|
'locked.yaml',
|
||||||
|
manifestContents,
|
||||||
|
);
|
||||||
|
const report = lockReport(manifestContents, options.version ?? VERSION);
|
||||||
const lockReportPath = privateFile(
|
const lockReportPath = privateFile(
|
||||||
directory,
|
directory,
|
||||||
'lock.json',
|
'lock.json',
|
||||||
@@ -207,6 +227,7 @@ users:
|
|||||||
kubectl,
|
kubectl,
|
||||||
kubeconfig,
|
kubeconfig,
|
||||||
report,
|
report,
|
||||||
|
manifestContents,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,28 +271,72 @@ function commonRequest(fixtureValue) {
|
|||||||
},
|
},
|
||||||
context: CONTEXT,
|
context: CONTEXT,
|
||||||
expectedClusterUid: CLUSTER_UID,
|
expectedClusterUid: CLUSTER_UID,
|
||||||
|
transitionKind: 'install',
|
||||||
|
expectedHead: {
|
||||||
|
generation: 0,
|
||||||
|
deploymentDigest: null,
|
||||||
|
lockDigest: null,
|
||||||
|
stateDigest: null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function successfulRunner(calls) {
|
function successfulRunner(calls, server = {}, manifestContents = manifest()) {
|
||||||
return (_executable, args, input) => {
|
server.head ??= null;
|
||||||
|
server.resourceVersion ??= 0;
|
||||||
|
const runner = (_executable, args, input) => {
|
||||||
calls.push({ args: [...args], input });
|
calls.push({ args: [...args], input });
|
||||||
if (args.includes('get') && args.includes('-f=-')) {
|
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: '' };
|
return { status: 0, stdout: CLUSTER_UID, stderr: '' };
|
||||||
}
|
}
|
||||||
if (args.includes('--dry-run=server')) {
|
if (args.includes('--dry-run=server')) {
|
||||||
return { status: 0, stdout: 'deployment.apps/ql3-control\n', stderr: '' };
|
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: '' };
|
return { status: 0, stdout: 'deployment.apps/ql3-control\n', stderr: '' };
|
||||||
};
|
};
|
||||||
|
runner.server = server;
|
||||||
|
return runner;
|
||||||
}
|
}
|
||||||
|
|
||||||
function convergenceList() {
|
function convergenceList(manifestContents = manifest()) {
|
||||||
const resources = [];
|
const resources = [];
|
||||||
yaml.loadAll(manifest(), (resource) => resources.push(resource));
|
yaml.loadAll(manifestContents, (resource) => resources.push(resource));
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
apiVersion: 'v1',
|
apiVersion: 'v1',
|
||||||
kind: 'List',
|
kind: 'List',
|
||||||
@@ -310,8 +375,9 @@ function prepare(t) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const calls = [];
|
const calls = [];
|
||||||
|
const runner = successfulRunner(calls, {}, value.manifestContents);
|
||||||
const report = executeCommand(command, {
|
const report = executeCommand(command, {
|
||||||
runProcess: successfulRunner(calls),
|
runProcess: runner,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
...value,
|
...value,
|
||||||
@@ -319,6 +385,63 @@ function prepare(t) {
|
|||||||
preflightPath: output,
|
preflightPath: output,
|
||||||
preflight: report,
|
preflight: report,
|
||||||
calls,
|
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.equal(value.preflight.verification.kubernetesMutation, false);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
value.preflight.steps.map(({ name }) => name),
|
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(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[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(
|
assert.equal(
|
||||||
value.calls[1].args.includes(`--field-manager=${FIELD_MANAGER}`),
|
value.calls[2].args.includes(`--field-manager=${FIELD_MANAGER}`),
|
||||||
true,
|
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) => {
|
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,
|
output,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
const base = successfulRunner([]);
|
||||||
assert.throws(() =>
|
assert.throws(() =>
|
||||||
executeCommand(command, {
|
executeCommand(command, {
|
||||||
runProcess(_executable, args) {
|
runProcess(executable, args, input) {
|
||||||
if (args.includes('get')) {
|
if (args.includes('get') && args.includes('kube-system')) {
|
||||||
return {
|
return {
|
||||||
status: 0,
|
status: 0,
|
||||||
stdout:
|
stdout:
|
||||||
@@ -438,7 +563,14 @@ test('preflight rejects the wrong cluster and rejected server dry-run', (t) => {
|
|||||||
stderr: '',
|
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 = [];
|
const calls = [];
|
||||||
|
value.calls.length = 0;
|
||||||
const receipt = executeCommand(applyCommand, {
|
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.schema, RECEIPT_SCHEMA);
|
||||||
assert.equal(receipt.preflightDigest, value.preflight.preflightDigest);
|
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.crossResourceAtomicity, false);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
receipt.verification.recovery,
|
receipt.verification.recovery,
|
||||||
'reapply_exact_lock_with_same_field_manager',
|
'resume_exact_transition_from_target_head',
|
||||||
);
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
receipt.steps.map(({ name }) => name),
|
receipt.steps.map(({ name }) => name),
|
||||||
@@ -529,7 +665,7 @@ test('apply revalidates preflight, mutates explicitly and proves convergence', (
|
|||||||
'cluster_identity_after',
|
'cluster_identity_after',
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
assert.equal(calls.length, 5);
|
assert.equal(calls.length, 8);
|
||||||
assert.equal(fs.statSync(receiptPath).mode & 0o777, 0o600);
|
assert.equal(fs.statSync(receiptPath).mode & 0o777, 0o600);
|
||||||
|
|
||||||
const auditCommand = writeCommand(
|
const auditCommand = writeCommand(
|
||||||
@@ -550,8 +686,13 @@ test('apply revalidates preflight, mutates explicitly and proves convergence', (
|
|||||||
mutationId: receipt.mutationId,
|
mutationId: receipt.mutationId,
|
||||||
receiptDigest: receipt.receiptDigest,
|
receiptDigest: receipt.receiptDigest,
|
||||||
preflightDigest: receipt.preflightDigest,
|
preflightDigest: receipt.preflightDigest,
|
||||||
|
transitionKind: 'install',
|
||||||
lockDigest: value.report.lockDigest,
|
lockDigest: value.report.lockDigest,
|
||||||
manifestDigest: value.report.manifest.outputDigest,
|
manifestDigest: value.report.manifest.outputDigest,
|
||||||
|
deploymentGeneration: 1,
|
||||||
|
deploymentDigest: receipt.deploymentHead.deploymentDigest,
|
||||||
|
deploymentHeadStateDigest: receipt.deploymentHead.stateDigest,
|
||||||
|
resourceCount: 5,
|
||||||
clusterUid: CLUSTER_UID,
|
clusterUid: CLUSTER_UID,
|
||||||
externalResultsReplayed: false,
|
externalResultsReplayed: false,
|
||||||
kubernetesMutation: false,
|
kubernetesMutation: false,
|
||||||
@@ -560,14 +701,14 @@ test('apply revalidates preflight, mutates explicitly and proves convergence', (
|
|||||||
let replayCalls = 0;
|
let replayCalls = 0;
|
||||||
assert.equal(
|
assert.equal(
|
||||||
executeCommand(applyCommand, {
|
executeCommand(applyCommand, {
|
||||||
runProcess() {
|
runProcess(executable, args, input) {
|
||||||
replayCalls += 1;
|
replayCalls += 1;
|
||||||
throw new Error('must not run');
|
return value.runner(executable, args, input);
|
||||||
},
|
},
|
||||||
}).receiptDigest,
|
}).receiptDigest,
|
||||||
receipt.receiptDigest,
|
receipt.receiptDigest,
|
||||||
);
|
);
|
||||||
assert.equal(replayCalls, 0);
|
assert.equal(replayCalls, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('apply failure or post-apply drift never publishes a success receipt', (t) => {
|
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;
|
let identityCount = 0;
|
||||||
|
const base = value.runner;
|
||||||
assert.throws(() =>
|
assert.throws(() =>
|
||||||
executeCommand(applyCommand, {
|
executeCommand(applyCommand, {
|
||||||
runProcess(_executable, args) {
|
runProcess(executable, args, input) {
|
||||||
if (args.includes('get') && args.includes('-f=-')) {
|
if (args.includes('get') && args.includes('-f=-')) {
|
||||||
return {
|
return {
|
||||||
status: failure === 'convergence' ? 1 : 0,
|
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' : '',
|
failure === 'convergence' ? 'redacted convergence error' : '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (args.includes('get')) {
|
if (args.includes('get') && args.includes('kube-system')) {
|
||||||
identityCount += 1;
|
identityCount += 1;
|
||||||
return {
|
return {
|
||||||
status: 0,
|
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')) {
|
if (args.includes('--dry-run=server')) {
|
||||||
return { status: 0, stdout: 'dry-run', stderr: '' };
|
return { status: 0, stdout: 'dry-run', stderr: '' };
|
||||||
}
|
}
|
||||||
return {
|
if (failure === 'apply' && args.includes('apply')) {
|
||||||
status: failure === 'apply' ? 1 : 0,
|
return {
|
||||||
stdout: '',
|
status: 1,
|
||||||
stderr: failure === 'apply' ? 'redacted apply error' : '',
|
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) => {
|
test('receipt audit rejects a different command or recomputed receipt', (t) => {
|
||||||
const value = prepare(t);
|
const value = prepare(t);
|
||||||
const receiptPath = path.join(value.directory, 'receipt.json');
|
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(
|
if (args.includes('get') && args.includes('-f=-')) process.stdout.write(${JSON.stringify(
|
||||||
convergenceList(),
|
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(
|
else if (args.includes('get')) process.stdout.write(${JSON.stringify(
|
||||||
CLUSTER_UID,
|
CLUSTER_UID,
|
||||||
)});
|
)});
|
||||||
|
|||||||
@@ -207,10 +207,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
|||||||
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sourceFiles: 106,
|
sourceFiles: 107,
|
||||||
rootSourceFiles: 1,
|
rootSourceFiles: 1,
|
||||||
rootSourceLines: 50,
|
rootSourceLines: 50,
|
||||||
nestedSourceFiles: 105,
|
nestedSourceFiles: 106,
|
||||||
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user