mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): qualify plugin upgrades before activation
This commit is contained in:
@@ -34,6 +34,7 @@
|
||||
- 真实 K3s `v1.34.3+k3s1` 已完成 admission 编译与现场门:合规 Job 的 server dry-run 通过,篡改镜像被策略拒绝,删除参数 ConfigMap 后创建被拒绝;controller SA 的 `list|watch|delete jobs`、Pod 创建和 Secret 读取均被拒绝,action SA 的 Job/Pod 创建与 Secret 读取也均被拒绝。实现仍保持 18 个 package,未新增 workspace package、Edge daemon/timer/watcher 或低配设备常驻负担;短生命周期 controller 与按需 action Job 仅属于 Cluster profile。完整 18-package clean build/test 退出 0;backend 1196 项为 1194 pass、2 条条件 skip、0 fail;cluster-admin 339 pass/3 skip、cluster-postgres 328 pass/2 skip,package boundary、cluster dependency、edge import 和 cluster deployment 审计均无 finding。PostgreSQL `18.4` arm64 physical HA 125 项、timeline `1→2` 通过,报告 SHA-256 为 `a3d34e61ea2064e1cde574e533137186e09fdce9048455da64f582906037fa0d`,临时 Docker 资源已清理。ADR 仍为 Proposed:升级失败自动回滚、终态 Job 的 durable 恢复决议和固定物理低配设备证据尚未完成。
|
||||
- 2026-08-14 终态恢复更新:Secret Action controller 不再把所有终态 Job 仅计为瞬时 `recoveryRequired`。Job 到达 Complete/Failed 后已停止执行,controller 会用 started execution 的原 lease fence 复验不可变业务结果:首次 binding 必须与 approval plan、`startedAtMs` 推导出的 binding 完全一致;transition 必须与 plan、authority evidence、commit time 推导出的 receipt 完全一致。精确 durable result 存在时补写 `succeeded`,即使 Job 已被 TTL 清理也能收敛;Failed 且无 durable mutation 时写 `failed`;Complete 但无 receipt 时以 `indeterminate` 写 `blocked`。Job 在 start barrier 前终态或审批过期且尚未创建时,controller 复用既有 claim→release fence 写 `blocked`,不让坏 Job 永久占据 reconciler 页首。任何 stored result 漂移继续抛出 conflict,`executing + Job 缺失 + receipt 缺失` 继续要求人工处理,绝不自动重建可能已产生副作用的动作。该切片不修改共享 execution schema、PostgreSQL migration 或角色权限,不新增 package、连接与常驻进程;Cluster controller 复用现有 package-executor Pool,Edge 零变化。controller/process 定向 21/21,cluster-admin 全包 348 项为 345 pass/3 条件 skip/0 fail;完整 18-package 串行 build/test 退出 0;backend 1196 项为 1194 pass/2 条件 skip/0 fail;package boundary、cluster dependency、edge import、cluster deployment 均无 finding,部署/包边界聚焦测试 61/61。PostgreSQL `18.4` arm64 physical HA 125 项、timeline `1→2` 通过,报告 SHA-256 为 `bec512767fbbd7774baa9366698f60c25c8b017ed66f459b154d143fe86293bc`,临时 Docker 资源已清理。
|
||||
- 2026-08-14 人工恢复更新(ADR-0397,已接受):上述唯一保留的 `executing + Job/receipt 均缺失` 不确定窗口现在具有显式 Cluster 产品处置路径。既有 Approval management mTLS/OIDC endpoint 新增 `approval.recover.inspect|resolve`,只接受五分钟内 `multi_factor|hardware` User、独立 `approval.recover` 权限、二次认证、exact execution version/digest 和外部 evidence SHA-256。只允许 Secret binding/transition action;`confirm_failed` 写 failed,`abandon_unknown` 写 blocked,永远禁止人工 succeeded、Job 重建或 execution 重置。PostgreSQL `pg-0065`/capability v64 新增不可变 resolution ledger 与单个 SECURITY DEFINER resolver,在同一事务内锁 Policy/execution fence、写 allowed audit、推进终态并写 receipt;Approval manager 只有 dispatch/execution/resolution SELECT 与函数 EXECUTE,没有 execution UPDATE。通用 execution repository 与 Worker Credential 调用链保持不变。真实 PostgreSQL 18.4 已从空库完成 65 migration,证明原子提交、exact replay 不重复审计和 direct UPDATE `42501`;实现不新增 package、依赖、Pod、Pool、daemon、timer、watcher 或 Edge/Standalone 负担。18-package clean build/test 退出 0,backend 1,194 pass/2 skip/0 fail,package/dependency/edge/deployment 审计零 finding;新 migration 与 repository 内聚到 `approved-action` 领域,migration ledger 直属源码保持审定上限 65。PostgreSQL 18.4 arm64 physical HA 125 项 gate、timeline `1→2` 通过,报告 SHA-256 为 `6d4921cba74475d15722a13c6a8034793c0ee25681bc7dcaf91024927c5752fe`,临时 Docker 资源已清理。
|
||||
- 2026-08-14 升级失败收口(ADR-0398,进行中):已存在旧 active 的 `upgrade|reinstall|rollback` 不再先切 active pointer 再物化 Package 资源。共享 activation prerequisite sequence 固定为 Secret binding/transition receipt 就绪后,从 staged install 与 immutable lock 构建候选 generation,按既有字节上限完成 Manifest、Task、Workflow、Prompt、Tool 语义物化,并以 generation digest 预发布 immutable revision;只有成功后才进入 active pointer CAS。确定性候选错误把本次 install 写为 `failed(activation_fact_conflict)`,`activeLockDigest` 保持 `previousActiveLockDigest` 且 publisher 不被调用;瞬时文件/OCI/数据库故障保持 staged 重试。generation 1 没有旧版本可保留,Secret-aware 首次安装继续使用 ADR-0395 的 post-activation B1 binding ceremony,不被候选门错误拒绝。激活后的 publication recovery 只复用 revision 做 generation-fenced reconciliation。实现复用既有 18 个 package、双方言 repository、Local 单 SQLite authority 与 Cluster caller-driven recovery Job/单 Pool,不新增 migration、表、依赖、daemon、timer、watcher、listener、连接池或常驻 cache。定向 Runtime Core 21/21、Runtime Core 全量 548/548、Local Application 47 pass/4 条件 skip、Cluster Admin 347 pass/3 条件 skip;18-package clean build/test 退出 0,backend 1196 项为 1194 pass/2 条件 skip/0 fail,package boundary、cluster dependency、cluster deployment 与 edge import 审计均通过。PostgreSQL `18.4` arm64 physical HA 125 项、timeline `1→2` 通过,报告 SHA-256 `75d7a52be75c22b2aacf32f2d7e2c432a467ebaab4d639668ff3a4b98767a17e`。真实 Kubernetes 失败升级未移动 active pointer/head 的现场门与固定物理低配设备证据仍待闭合。
|
||||
- D-302/ADR-0390(已接受)
|
||||
Cluster operator context 增加无网络、无 mutation 的内建 `ql3-cluster-admin context validate` 预检。它先复用 owner-private context
|
||||
reader,再让每个 entry 经过与真实请求相同的 production HTTPS/Kubernetes configuration preparation,验证精确 route、hostname、CA、
|
||||
|
||||
@@ -51,3 +51,8 @@ D-306B1 只允许给当前 active 且尚未绑定的 Package generation 做首
|
||||
- start barrier 前的终态 Job、以及 approval 过期且 Job 尚未创建,复用共享 execution repository 的 claim→release-before-start 转换持久化为 `blocked`;controller 崩溃后 lease 可回收,不增加新状态、表、migration 或专用恢复 daemon。Durable result 漂移仍是全局 conflict;`executing + Job 缺失 + receipt 缺失` 不能排除孤儿 Pod 或未知副作用,继续保持 `recoveryRequired` 且绝不重建。
|
||||
- 本切片定向 controller/process 21/21;cluster-admin 全包 348 项为 345 pass、3 条件 skip、0 fail;完整 18-package 串行 build/test 退出 0;backend 1196 项为 1194 pass、2 条件 skip、0 fail;package boundary、cluster dependency、edge import、cluster deployment 均无 finding,部署/包边界聚焦测试 61/61。PostgreSQL `18.4` arm64 physical HA 125 项、timeline `1→2` 通过,报告 SHA-256 `bec512767fbbd7774baa9366698f60c25c8b017ed66f459b154d143fe86293bc`,临时 Docker 资源已清理。共享 `approved_action_executions` contract、PostgreSQL 权限与 Worker Credential 调用链均未修改;实现继续位于既有 `cluster-admin/plugin-package/executor`,Edge/Standalone 不加载 controller,也没有新增 workspace package、连接、timer、watcher 或常驻内存。
|
||||
- ADR 继续保持 Proposed:升级失败自动回滚、`executing + Job/receipt 均缺失` 的显式人工处置产品路径,以及固定物理低配设备证据仍待完成。
|
||||
- 后续 ADR-0397 已完成上述 Secret Action 显式人工处置。ADR-0398 进一步把 Local/Cluster 安装恢复顺序改为“Secret transition prerequisite → staged candidate materialization/publish → active pointer CAS → active-only reconciliation”。确定性候选错误在 pointer 发布前把当前安装写为 failed,`activeLockDigest` 保持上一 lock,旧 generation/binding/revision 继续服务;可用性错误只重试。该实现不新增 package、migration、连接或常驻组件。
|
||||
- ADR-0398 的 18-package clean build/test、backend、边界/依赖/部署/edge import 审计与 PostgreSQL
|
||||
`18.4` arm64 physical HA 已闭合;HA 通过 125 项门、timeline `1→2`,报告 SHA-256
|
||||
`75d7a52be75c22b2aacf32f2d7e2c432a467ebaab4d639668ff3a4b98767a17e`。真实 Kubernetes
|
||||
失败升级未移动 active pointer/head 的现场门与固定物理低配设备证据仍阻断 ADR Accepted。
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# ADR-0398:Plugin Package 激活前候选资格校验与自动保留旧版本
|
||||
|
||||
- 状态:Proposed
|
||||
- 日期:2026-08-14
|
||||
- 关联 RFC:QL-RFC-0001 D-306B2
|
||||
- 关联 ADR:ADR-0153、ADR-0394、ADR-0396
|
||||
|
||||
## 问题
|
||||
|
||||
现有 Local 与 Cluster 启动恢复先发布 active pointer、把安装记录推进为
|
||||
`active`,随后才读取 staged bytes、物化 Package Task/Workflow/Prompt/Tool 资源。
|
||||
因此一个摘要正确但资源语义无效的升级可能先替换健康版本,再在任务发布阶段失败,
|
||||
迫使整个启动门失败。安装状态机虽然保留 `previousActiveLockDigest`,却没有在指针切换前
|
||||
使用该事实形成真正的失败隔离。
|
||||
|
||||
“先切换、失败后再把指针写回去”也不安全:回写会与并发发布竞争,Kubernetes
|
||||
ConfigMap 和数据库 head 之间会出现第二次分布式提交窗口,并且历史 generation 可能被
|
||||
静默重新激活。自动恢复应避免制造需要补偿的外部事实,而不是依赖补偿事务。
|
||||
|
||||
## 决策
|
||||
|
||||
1. 已存在旧 active 的 `upgrade|reinstall|rollback` 在 `staged → activating` 之前必须依次通过所有前置条件。Secret binding/transition
|
||||
receipt 先完成;随后从 staged install、immutable lock 和 content-addressed bytes
|
||||
构建目标 resource generation,读取有硬上限的 Manifest/资源并执行完整语义物化。
|
||||
generation 1 没有可回退的旧指针,并且 Secret-aware 首次安装仍需 ADR-0395 的
|
||||
post-activation B1 binding ceremony,因此不进入本 ADR 的候选物化门。
|
||||
2. 候选 materialized revision 以既有 `generationDigest` repository key 在激活前发布。
|
||||
相同 revision exact replay 返回 existing;不同事实冲突。该 revision 尚不构成 active,
|
||||
Task reconciliation、Automation publication 与 Tool snapshot 仍只消费 active generation。
|
||||
3. 确定性的候选语义错误或 durable revision 冲突把当前安装从 `staged` 原子推进为
|
||||
`failed(reason=activation_fact_conflict)`。状态机必须保留
|
||||
`activeLockDigest=previousActiveLockDigest`,并且 activation publisher 调用次数为零。
|
||||
4. OCI、文件、SQLite/PostgreSQL 或 reader close 的瞬时不可用不写失败事实,安装保持
|
||||
`staged` 并由既有有界 recovery 重试。不得在不可区分时把可用性故障伪装成坏包。
|
||||
5. active pointer 发布成功后,既有 Task publication recovery 复用预先持久化的 revision,
|
||||
只执行 generation-fenced reconciliation;它继续承担响应丢失与并发 superseded 检查。
|
||||
6. Local 与 Cluster 必须使用同一个 runtime-core prerequisite sequence 和候选物化实现。
|
||||
Local 复用单 SQLite authority 与本地 staging reader;Cluster 复用 caller-driven recovery
|
||||
Job、单 PostgreSQL Pool、OCI reader 与 Kubernetes CAS publisher。
|
||||
7. 本决策不增加 workspace package、migration、表、第三方依赖、daemon、timer、watcher、
|
||||
listener、连接池或常驻 cache。Edge/Standalone 只在已有启动恢复遇到 staged install 时
|
||||
按需读取候选字节;没有待恢复安装时只创建少量短生命周期对象,不增加后台 cadence。
|
||||
|
||||
## 接受条件
|
||||
|
||||
- 共享测试证明有效候选在激活前发布且 exact replay 不重复写;语义无效候选不发布 revision。
|
||||
- 升级恢复测试证明 rejected 候选进入 failed、旧 active lock 保留且 publisher 未调用。
|
||||
- Local 与 Cluster 组合测试证明恢复顺序一致,既有 active publication/reconciliation 不回归。
|
||||
- 完整 18-package、backend、package/dependency/deployment/edge/import 审计通过。
|
||||
- 真实 PostgreSQL/Kubernetes 门证明失败升级没有移动 active ConfigMap/head;physical HA 门通过。
|
||||
- 固定物理低配设备证据仍由 ADR-0396 单独阻断,不能用开发机观测替代。
|
||||
|
||||
## 影响与替代方案
|
||||
|
||||
- 失败候选可能留下一个不可达、不可变的 materialized revision。它按 generation 有界,保留
|
||||
失败取证事实;物理清理由独立 retention/GC receipt 决定,不在失败路径同步删除。
|
||||
- 不把 materialization 塞入 Kubernetes publisher。Publisher 只拥有 pointer CAS 与投影
|
||||
evidence;让它读取 OCI/PostgreSQL 会聚合执行和发布 authority。
|
||||
- 不新增 `rolling_back` 状态。指针从未移动时,健康旧版本本来就仍是 active;新增补偿状态
|
||||
只会扩大恢复矩阵并让低配设备承担无收益的持久化协议。
|
||||
|
||||
## 当前验证
|
||||
|
||||
- Runtime Core 定向 21/21 通过,覆盖前置条件顺序、候选预物化、exact replay、无效语义拒绝、generation 1 B1 兼容和升级失败保留
|
||||
旧 `activeLockDigest`;拒绝路径的 activation publisher 调用次数为零。
|
||||
- Local Application 47 pass/4 条件 skip;Cluster Admin 347 pass/3 条件 skip。Cluster 首次在
|
||||
沙箱内运行的 32 个失败均为回环监听 `EPERM`,在允许本机监听的环境重跑后全部通过。
|
||||
- 18-package clean build/test 退出 0;Runtime Core 548/548;backend 1196 项为
|
||||
1194 pass/2 条件 skip/0 fail。package boundary 保持 18 个 package 且
|
||||
`singleSourcePackages=[]`、`shallowSourcePackages=[]`;cluster dependency、cluster deployment
|
||||
与 edge import 审计均无 finding。
|
||||
- PostgreSQL `18.4` arm64 physical HA 通过 125 项门,timeline `1→2`,报告 SHA-256
|
||||
`75d7a52be75c22b2aacf32f2d7e2c432a467ebaab4d639668ff3a4b98767a17e`,临时 Docker
|
||||
资源已清理。真实 Kubernetes 失败升级未移动 active pointer/head 的现场门与固定物理低配设备
|
||||
证据仍待完成,因此本 ADR 保持 Proposed。
|
||||
@@ -401,6 +401,7 @@
|
||||
| [ADR-0395](./ADR-0395-owner-confirmed-plugin-package-secret-binding.md) | Owner 确认的 Plugin Package Secret 首次绑定 | Proposed |
|
||||
| [ADR-0396](./ADR-0396-generation-transition-plugin-package-secret-binding.md) | 按 Package Generation 切换 Plugin Package Secret Binding | Proposed |
|
||||
| [ADR-0397](./ADR-0397-explicit-cluster-secret-action-manual-recovery.md) | Cluster Secret Action 显式人工恢复 | Accepted(实现、单节点 PostgreSQL、完整 workspace/后端/边界与 physical HA 门完成) |
|
||||
| [ADR-0398](./ADR-0398-pre-activation-plugin-package-candidate-qualification.md) | Plugin Package 激活前候选资格校验与自动保留旧版本 | Proposed(实现、全量/HA 门完成,Kubernetes 失败升级与物理低配门待闭合) |
|
||||
|
||||
## 规则
|
||||
|
||||
|
||||
@@ -13,12 +13,16 @@ import {
|
||||
PluginPackageRecoveryCoordinator,
|
||||
type PluginPackageRecoveryCycleResult,
|
||||
} from '@qinglong/runtime-core/plugin-package-recovery';
|
||||
import { sequencePluginPackageActivationPrerequisites } from '@qinglong/runtime-core/plugin-package-installation';
|
||||
import {
|
||||
PluginPackageAutomationPublicationCoordinator,
|
||||
PluginPackageAutomationPublicationRecoveryCoordinator,
|
||||
type PluginPackageAutomationPublicationRecoveryCycleResult,
|
||||
} from '@qinglong/runtime-core/plugin-package-automation-publication';
|
||||
import type { PluginPackageResourceByteSource } from '@qinglong/runtime-core/plugin-package-resource-materialization';
|
||||
import {
|
||||
PluginPackageResourceActivationPrerequisite,
|
||||
type PluginPackageResourceByteSource,
|
||||
} from '@qinglong/runtime-core/plugin-package-resource-materialization';
|
||||
import {
|
||||
MAX_PLUGIN_PACKAGE_TASK_PUBLICATION_RECOVERY_PAGES,
|
||||
MAX_PLUGIN_PACKAGE_TASK_PUBLICATION_RECOVERY_PAGE_SIZE,
|
||||
@@ -326,14 +330,31 @@ export async function recoverClusterPluginPackages(
|
||||
authority: stageAuthority as ClusterPluginPackageOciStageAuthority,
|
||||
lockSource: repository,
|
||||
});
|
||||
const taskSpecSemanticRegistry = createBuiltInTaskSpecSemanticRegistry();
|
||||
const materializedRepository =
|
||||
new PostgresPluginPackageMaterializedRevisionRepository(
|
||||
database.pool,
|
||||
taskSpecSemanticRegistry,
|
||||
);
|
||||
const secretBindingRepository =
|
||||
new PostgresPluginPackageSecretBindingRepository(database.pool);
|
||||
const secretBindingActivationPrerequisite =
|
||||
new PostgresPluginPackageSecretBindingActivationPrerequisite(
|
||||
database.pool,
|
||||
);
|
||||
const recovery = await new PluginPackageRecoveryCoordinator({
|
||||
repository,
|
||||
stageProvider: stageAuthority,
|
||||
publisher,
|
||||
activationPrerequisite:
|
||||
new PostgresPluginPackageSecretBindingActivationPrerequisite(
|
||||
database.pool,
|
||||
),
|
||||
activationPrerequisite: sequencePluginPackageActivationPrerequisites([
|
||||
secretBindingActivationPrerequisite,
|
||||
new PluginPackageResourceActivationPrerequisite({
|
||||
byteSource: resourceByteSource,
|
||||
materializedRepository,
|
||||
secretBindingSource: secretBindingRepository,
|
||||
taskSpecSemanticRegistry,
|
||||
}),
|
||||
]),
|
||||
now: options.now,
|
||||
}).recover({
|
||||
...(options.pageSize === undefined ? {} : { pageSize: options.pageSize }),
|
||||
@@ -342,19 +363,11 @@ export async function recoverClusterPluginPackages(
|
||||
if (!recovery.safeToAdmit) {
|
||||
throw new ClusterPluginPackageRecoveryRequiredError(recovery);
|
||||
}
|
||||
const taskSpecSemanticRegistry = createBuiltInTaskSpecSemanticRegistry();
|
||||
const taskReconciliationRepository =
|
||||
new PostgresPluginPackageTaskReconciliationRepository(
|
||||
database.pool,
|
||||
taskSpecSemanticRegistry,
|
||||
);
|
||||
const materializedRepository =
|
||||
new PostgresPluginPackageMaterializedRevisionRepository(
|
||||
database.pool,
|
||||
taskSpecSemanticRegistry,
|
||||
);
|
||||
const secretBindingRepository =
|
||||
new PostgresPluginPackageSecretBindingRepository(database.pool);
|
||||
const taskPublicationRecovery =
|
||||
await new PluginPackageTaskPublicationRecoveryCoordinator({
|
||||
source: taskReconciliationRepository,
|
||||
|
||||
+13
-3
@@ -11,9 +11,10 @@ import {
|
||||
normalizePluginPackageResourceGeneration,
|
||||
type PluginPackageResourceGeneration,
|
||||
} from '@qinglong/runtime-core/plugin-package-resource-generation';
|
||||
import type {
|
||||
PluginPackageResourceByteReader,
|
||||
PluginPackageResourceByteSource,
|
||||
import {
|
||||
PluginPackageResourceActivationPrerequisite,
|
||||
type PluginPackageResourceByteReader,
|
||||
type PluginPackageResourceByteSource,
|
||||
} from '@qinglong/runtime-core/plugin-package-resource-materialization';
|
||||
|
||||
const STAGE_RECEIPT_SCHEMA = 'qinglong/plugin-package-stage-receipt@v1';
|
||||
@@ -27,6 +28,15 @@ export interface LocalPluginPackageResourceByteSourceOptions {
|
||||
readonly stagingRoot: string;
|
||||
}
|
||||
|
||||
export type LocalPluginPackageResourceActivationPrerequisiteOptions =
|
||||
ConstructorParameters<typeof PluginPackageResourceActivationPrerequisite>[0];
|
||||
|
||||
export function createLocalPluginPackageResourceActivationPrerequisite(
|
||||
options: LocalPluginPackageResourceActivationPrerequisiteOptions,
|
||||
): PluginPackageResourceActivationPrerequisite {
|
||||
return new PluginPackageResourceActivationPrerequisite(options);
|
||||
}
|
||||
|
||||
export class InvalidLocalPluginPackageResourceSourceError extends Error {
|
||||
readonly code = 'LOCAL_PLUGIN_PACKAGE_RESOURCE_SOURCE_INVALID';
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { LocalPluginPackageActivationPublisher } from '@qinglong/local-admin/package-activation';
|
||||
import { LocalPluginPackageResourceByteSource } from '@qinglong/local-admin/package-resource-materialization';
|
||||
import {
|
||||
createLocalPluginPackageResourceActivationPrerequisite,
|
||||
LocalPluginPackageResourceByteSource,
|
||||
} from '@qinglong/local-admin/package-resource-materialization';
|
||||
import {
|
||||
PluginPackageRecoveryCoordinator,
|
||||
type PluginPackageRecoveryCycleResult,
|
||||
} from '@qinglong/runtime-core/plugin-package-recovery';
|
||||
import { sequencePluginPackageActivationPrerequisites } from '@qinglong/runtime-core/plugin-package-installation';
|
||||
import {
|
||||
PluginPackageAutomationPublicationCoordinator,
|
||||
PluginPackageAutomationPublicationRecoveryCoordinator,
|
||||
@@ -47,6 +51,15 @@ export async function recoverLocalApplicationPluginPackages(
|
||||
const pluginPackageInstalls = await storage.pluginPackageInstalls();
|
||||
const pluginPackageActivationPrerequisite =
|
||||
await storage.pluginPackageActivationPrerequisite();
|
||||
const taskSpecSemanticRegistry = createBuiltInTaskSpecSemanticRegistry();
|
||||
const pluginPackageMaterializedRevisions =
|
||||
await storage.pluginPackageMaterializedRevisions();
|
||||
const pluginPackageSecretBindings =
|
||||
await storage.pluginPackageSecretBindings();
|
||||
const pluginPackageResourceByteSource =
|
||||
new LocalPluginPackageResourceByteSource({
|
||||
stagingRoot: options.pluginPackages.stagingRoot,
|
||||
});
|
||||
const pluginPackageActivation = new LocalPluginPackageActivationPublisher({
|
||||
stagingRoot: options.pluginPackages.stagingRoot,
|
||||
activationRoot: options.pluginPackages.activationRoot,
|
||||
@@ -56,7 +69,15 @@ export async function recoverLocalApplicationPluginPackages(
|
||||
repository: pluginPackageInstalls,
|
||||
stageProvider: options.pluginPackages.stageProvider,
|
||||
publisher: pluginPackageActivation,
|
||||
activationPrerequisite: pluginPackageActivationPrerequisite,
|
||||
activationPrerequisite: sequencePluginPackageActivationPrerequisites([
|
||||
pluginPackageActivationPrerequisite,
|
||||
createLocalPluginPackageResourceActivationPrerequisite({
|
||||
byteSource: pluginPackageResourceByteSource,
|
||||
materializedRepository: pluginPackageMaterializedRevisions,
|
||||
secretBindingSource: pluginPackageSecretBindings,
|
||||
taskSpecSemanticRegistry,
|
||||
}),
|
||||
]),
|
||||
now: options.pluginPackages.now,
|
||||
}).recover({
|
||||
...(options.pluginPackages.pageSize === undefined
|
||||
@@ -77,22 +98,15 @@ export async function recoverLocalApplicationPluginPackages(
|
||||
pluginPackageRecovery,
|
||||
});
|
||||
|
||||
const taskSpecSemanticRegistry = createBuiltInTaskSpecSemanticRegistry();
|
||||
const pluginPackageTaskReconciliations =
|
||||
await storage.pluginPackageTaskReconciliations();
|
||||
const pluginPackageMaterializedRevisions =
|
||||
await storage.pluginPackageMaterializedRevisions();
|
||||
const pluginPackageSecretBindings =
|
||||
await storage.pluginPackageSecretBindings();
|
||||
const pluginPackageTaskPublicationRecovery =
|
||||
await new PluginPackageTaskPublicationRecoveryCoordinator({
|
||||
source: pluginPackageTaskReconciliations,
|
||||
publisher: new PluginPackageTaskPublicationCoordinator({
|
||||
generationSource: pluginPackageActivation,
|
||||
lockSource: pluginPackageInstalls,
|
||||
byteSource: new LocalPluginPackageResourceByteSource({
|
||||
stagingRoot: options.pluginPackages.stagingRoot,
|
||||
}),
|
||||
byteSource: pluginPackageResourceByteSource,
|
||||
materializedRepository: pluginPackageMaterializedRevisions,
|
||||
secretBindingSource: pluginPackageSecretBindings,
|
||||
reconciliationRepository: pluginPackageTaskReconciliations,
|
||||
|
||||
+48
@@ -40,6 +40,10 @@ export type PluginPackageActivationPrerequisiteObservation =
|
||||
| Readonly<{
|
||||
status: 'deferred';
|
||||
reason: 'secret_binding_transition_required';
|
||||
}>
|
||||
| Readonly<{
|
||||
status: 'rejected';
|
||||
reason: 'activation_fact_conflict';
|
||||
}>;
|
||||
|
||||
export interface PluginPackageActivationPrerequisite {
|
||||
@@ -49,6 +53,37 @@ export interface PluginPackageActivationPrerequisite {
|
||||
): Promise<Readonly<PluginPackageActivationPrerequisiteObservation>>;
|
||||
}
|
||||
|
||||
export function sequencePluginPackageActivationPrerequisites(
|
||||
prerequisites: readonly PluginPackageActivationPrerequisite[],
|
||||
): PluginPackageActivationPrerequisite {
|
||||
if (
|
||||
!Array.isArray(prerequisites) ||
|
||||
prerequisites.length < 1 ||
|
||||
prerequisites.length > 8 ||
|
||||
prerequisites.some(
|
||||
(prerequisite) =>
|
||||
!prerequisite || typeof prerequisite.inspect !== 'function',
|
||||
)
|
||||
) {
|
||||
throw new InvalidPluginPackageInstallError(
|
||||
'activation prerequisite sequence is invalid',
|
||||
);
|
||||
}
|
||||
const sequence = Object.freeze([...prerequisites]);
|
||||
return Object.freeze({
|
||||
async inspect(
|
||||
record: Readonly<PluginPackageInstallRecord>,
|
||||
lock: Readonly<PluginPackageLock>,
|
||||
): Promise<Readonly<PluginPackageActivationPrerequisiteObservation>> {
|
||||
for (const prerequisite of sequence) {
|
||||
const observation = await prerequisite.inspect(record, lock);
|
||||
if (observation.status !== 'ready') return observation;
|
||||
}
|
||||
return Object.freeze({ status: 'ready' as const });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface InstallPluginPackageOptions {
|
||||
readonly lock: PluginPackageLock;
|
||||
readonly proposalDigest: string;
|
||||
@@ -224,6 +259,19 @@ export class PluginPackageInstallationCoordinator {
|
||||
lock,
|
||||
);
|
||||
if (prerequisite?.status === 'deferred') return record;
|
||||
if (prerequisite?.status === 'rejected') {
|
||||
const failed = transitionPluginPackageInstall(lock, record, {
|
||||
type: 'failed',
|
||||
mutationId: options.activationFailedMutationId,
|
||||
occurredAtMs: options.activationObservedAtMs,
|
||||
reason: prerequisite.reason,
|
||||
});
|
||||
return (
|
||||
await this.#repository.commit(
|
||||
pluginPackageInstallCommit(record, failed),
|
||||
)
|
||||
).record;
|
||||
}
|
||||
return this.#activation.activate({
|
||||
...identity,
|
||||
activationStartedMutationId: options.activationStartedMutationId,
|
||||
|
||||
@@ -311,6 +311,25 @@ export class PluginPackageRecoveryCoordinator {
|
||||
lock,
|
||||
);
|
||||
if (prerequisite?.status === 'deferred') return record;
|
||||
if (prerequisite?.status === 'rejected') {
|
||||
const failed = transitionPluginPackageInstall(lock, record, {
|
||||
type: 'failed',
|
||||
mutationId: mutationId(
|
||||
'activation-preparation-failed',
|
||||
record,
|
||||
occurredAtMs,
|
||||
),
|
||||
occurredAtMs,
|
||||
reason: prerequisite.reason,
|
||||
});
|
||||
return normalizePluginPackageInstallRecord(
|
||||
(
|
||||
await this.#repository.commit(
|
||||
pluginPackageInstallCommit(record, failed),
|
||||
)
|
||||
).record,
|
||||
);
|
||||
}
|
||||
return this.#activation.activate({
|
||||
...identity,
|
||||
activationStartedMutationId: mutationId(
|
||||
|
||||
+215
-31
@@ -10,12 +10,21 @@ import {
|
||||
type PluginPackageContentEntryDescriptor,
|
||||
} from './pluginPackageBundle';
|
||||
import {
|
||||
PluginPackageInstallUnavailableError,
|
||||
assertPluginPackageInstallMatchesLock,
|
||||
normalizePluginPackageInstallRecord,
|
||||
normalizePluginPackageLock,
|
||||
pluginPackageManifestDigest,
|
||||
serializePluginPackageManifest,
|
||||
type PluginPackageInstallRecord,
|
||||
type PluginPackageLock,
|
||||
} from './installation/pluginPackageInstall';
|
||||
import type {
|
||||
PluginPackageActivationPrerequisite,
|
||||
PluginPackageActivationPrerequisiteObservation,
|
||||
} from './installation/pluginPackageInstallation';
|
||||
import {
|
||||
createPluginPackageResourceGenerationFromReferences,
|
||||
normalizePluginPackageResourceGeneration,
|
||||
pluginPackageResourceReferencesFromContents,
|
||||
type PluginPackageResourceGeneration,
|
||||
@@ -185,6 +194,17 @@ export interface MaterializeActivePluginPackageResourcesOptions {
|
||||
readonly taskSpecSemanticRegistry: TaskSpecSemanticRegistry;
|
||||
}
|
||||
|
||||
export interface MaterializePluginPackageResourceGenerationOptions {
|
||||
readonly generation: Readonly<PluginPackageResourceGeneration>;
|
||||
readonly lock: Readonly<PluginPackageLock>;
|
||||
readonly byteSource: PluginPackageResourceByteSource;
|
||||
readonly secretBindingSource?: Pick<
|
||||
PluginPackageSecretBindingRepository,
|
||||
'find'
|
||||
>;
|
||||
readonly taskSpecSemanticRegistry: TaskSpecSemanticRegistry;
|
||||
}
|
||||
|
||||
export interface PluginPackageMaterializedRevisionRepository {
|
||||
find(
|
||||
generationDigest: string,
|
||||
@@ -197,6 +217,115 @@ export interface PluginPackageMaterializedRevisionRepository {
|
||||
>;
|
||||
}
|
||||
|
||||
export class PluginPackageResourceActivationPrerequisite
|
||||
implements PluginPackageActivationPrerequisite
|
||||
{
|
||||
constructor(
|
||||
private readonly options: {
|
||||
readonly byteSource: PluginPackageResourceByteSource;
|
||||
readonly materializedRepository: PluginPackageMaterializedRevisionRepository;
|
||||
readonly secretBindingSource?: Pick<
|
||||
PluginPackageSecretBindingRepository,
|
||||
'find'
|
||||
>;
|
||||
readonly taskSpecSemanticRegistry: TaskSpecSemanticRegistry;
|
||||
},
|
||||
) {
|
||||
const authorities = dataRecord(options, 'activation prerequisite options');
|
||||
exactKeys(
|
||||
authorities,
|
||||
['byteSource', 'materializedRepository', 'taskSpecSemanticRegistry'],
|
||||
['secretBindingSource'],
|
||||
'activation prerequisite options',
|
||||
);
|
||||
if (
|
||||
!options.byteSource ||
|
||||
typeof options.byteSource.open !== 'function' ||
|
||||
!options.materializedRepository ||
|
||||
typeof options.materializedRepository.find !== 'function' ||
|
||||
typeof options.materializedRepository.publish !== 'function' ||
|
||||
(options.secretBindingSource !== undefined &&
|
||||
(!options.secretBindingSource ||
|
||||
typeof options.secretBindingSource.find !== 'function')) ||
|
||||
!(options.taskSpecSemanticRegistry instanceof TaskSpecSemanticRegistry)
|
||||
) {
|
||||
invalid('activation prerequisite authority is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
async inspect(
|
||||
recordValue: Readonly<PluginPackageInstallRecord>,
|
||||
lockValue: Readonly<PluginPackageLock>,
|
||||
): Promise<Readonly<PluginPackageActivationPrerequisiteObservation>> {
|
||||
try {
|
||||
const record = normalizePluginPackageInstallRecord(recordValue);
|
||||
const lock = normalizePluginPackageLock(lockValue);
|
||||
assertPluginPackageInstallMatchesLock(lock, record);
|
||||
if (record.state !== 'staged') {
|
||||
invalid('activation prerequisite requires a staged install');
|
||||
}
|
||||
// Generation one may require the post-activation B1 binding ceremony.
|
||||
// There is no healthy previous pointer to preserve in that flow.
|
||||
if (record.previousActiveLockDigest === null) {
|
||||
return Object.freeze({ status: 'ready' as const });
|
||||
}
|
||||
const generation = createPluginPackageResourceGenerationFromReferences({
|
||||
installationId: record.installationId,
|
||||
projectId: record.projectId,
|
||||
packageName: record.packageName,
|
||||
lockDigest: lock.lockDigest,
|
||||
generation: lock.targetGeneration,
|
||||
previousActiveLockDigest: record.previousActiveLockDigest,
|
||||
contentDigest: lock.source.contentDigest,
|
||||
resources: lock.resources,
|
||||
});
|
||||
let revision = await this.options.materializedRepository.find(
|
||||
generation.generationDigest,
|
||||
);
|
||||
if (revision === null) {
|
||||
const candidate = await materializePluginPackageResourceGeneration({
|
||||
generation,
|
||||
lock,
|
||||
byteSource: this.options.byteSource,
|
||||
...(this.options.secretBindingSource === undefined
|
||||
? {}
|
||||
: { secretBindingSource: this.options.secretBindingSource }),
|
||||
taskSpecSemanticRegistry: this.options.taskSpecSemanticRegistry,
|
||||
});
|
||||
revision = (
|
||||
await this.options.materializedRepository.publish(candidate)
|
||||
).revision;
|
||||
}
|
||||
const durable = normalizePluginPackageMaterializedRevision(
|
||||
revision,
|
||||
this.options.taskSpecSemanticRegistry,
|
||||
);
|
||||
if (
|
||||
durable.generation.generationDigest !== generation.generationDigest ||
|
||||
durable.generation.installationId !== record.installationId ||
|
||||
durable.generation.lockDigest !== lock.lockDigest
|
||||
) {
|
||||
throw new PluginPackageResourceMaterializationConflictError(
|
||||
'durable candidate revision does not match the staged generation',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ status: 'ready' as const });
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof InvalidPluginPackageResourceMaterializationError ||
|
||||
error instanceof PluginPackageResourceMaterializationConflictError
|
||||
) {
|
||||
return Object.freeze({
|
||||
status: 'rejected' as const,
|
||||
reason: 'activation_fact_conflict' as const,
|
||||
});
|
||||
}
|
||||
if (error instanceof PluginPackageInstallUnavailableError) throw error;
|
||||
throw new PluginPackageInstallUnavailableError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface PluginPackageTaskDefinitionDraft {
|
||||
readonly projectId: string;
|
||||
readonly taskId: string;
|
||||
@@ -1282,32 +1411,39 @@ function materializationSources(
|
||||
}
|
||||
}
|
||||
|
||||
export async function materializeActivePluginPackageResources(
|
||||
value: MaterializeActivePluginPackageResourcesOptions,
|
||||
): Promise<Readonly<PluginPackageMaterializedRevision> | null> {
|
||||
materializationSources(value);
|
||||
function generationMaterializationSources(
|
||||
value: MaterializePluginPackageResourceGenerationOptions,
|
||||
): void {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
!value.byteSource ||
|
||||
typeof value.byteSource.open !== 'function' ||
|
||||
(value.secretBindingSource !== undefined &&
|
||||
(!value.secretBindingSource ||
|
||||
typeof value.secretBindingSource.find !== 'function')) ||
|
||||
!(value.taskSpecSemanticRegistry instanceof TaskSpecSemanticRegistry)
|
||||
) {
|
||||
invalid('generation materialization sources are invalid');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Qualifies one immutable candidate generation without consulting or moving
|
||||
* the active pointer. Callers may durably publish the returned revision before
|
||||
* activation so deterministic Package errors cannot replace a healthy head.
|
||||
*/
|
||||
export async function materializePluginPackageResourceGeneration(
|
||||
value: MaterializePluginPackageResourceGenerationOptions,
|
||||
): Promise<Readonly<PluginPackageMaterializedRevision>> {
|
||||
generationMaterializationSources(value);
|
||||
try {
|
||||
const first = await value.generationSource.findActiveResourceGeneration(
|
||||
value.projectId,
|
||||
value.packageName,
|
||||
const generation = normalizePluginPackageResourceGeneration(
|
||||
value.generation,
|
||||
);
|
||||
if (first === null) return null;
|
||||
const generation = normalizePluginPackageResourceGeneration(first);
|
||||
if (
|
||||
generation.projectId !== value.projectId ||
|
||||
generation.packageName !== value.packageName
|
||||
) {
|
||||
throw new PluginPackageResourceMaterializationConflictError(
|
||||
'generation source returned another Package identity',
|
||||
);
|
||||
}
|
||||
const lockValue = await value.lockSource.findLock(generation.lockDigest);
|
||||
if (lockValue === null) {
|
||||
throw new PluginPackageResourceMaterializationConflictError(
|
||||
'active generation lock is missing',
|
||||
);
|
||||
}
|
||||
const lock = normalizePluginPackageLock(lockValue);
|
||||
const lock = normalizePluginPackageLock(value.lock);
|
||||
assertGenerationMatchesLock(generation, lock);
|
||||
const reader = await value.byteSource.open(generation);
|
||||
if (
|
||||
!reader ||
|
||||
@@ -1350,22 +1486,70 @@ export async function materializeActivePluginPackageResources(
|
||||
});
|
||||
}
|
||||
}
|
||||
const activeManifest = normalizeManifestBytes(manifestBytes, lock);
|
||||
const activeSecretBindingValue =
|
||||
activeManifest.spec.permissions.secrets.length === 0
|
||||
const manifest = normalizeManifestBytes(manifestBytes, lock);
|
||||
const bindingValue =
|
||||
manifest.spec.permissions.secrets.length === 0
|
||||
? undefined
|
||||
: await value.secretBindingSource?.find(generation.generationDigest);
|
||||
const activeSecretBinding = activeSecretBindingValue ?? undefined;
|
||||
const revision = materializePluginPackageResources({
|
||||
return materializePluginPackageResources({
|
||||
generation,
|
||||
lock,
|
||||
manifestBytes,
|
||||
...(activeSecretBinding === undefined
|
||||
...(bindingValue === undefined || bindingValue === null
|
||||
? {}
|
||||
: { secretBinding: activeSecretBinding }),
|
||||
: { secretBinding: bindingValue }),
|
||||
resources: Object.freeze(resources),
|
||||
taskSpecSemanticRegistry: value.taskSpecSemanticRegistry,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof InvalidPluginPackageResourceMaterializationError ||
|
||||
error instanceof PluginPackageResourceMaterializationConflictError ||
|
||||
error instanceof PluginPackageResourceMaterializationUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new PluginPackageResourceMaterializationUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function materializeActivePluginPackageResources(
|
||||
value: MaterializeActivePluginPackageResourcesOptions,
|
||||
): Promise<Readonly<PluginPackageMaterializedRevision> | null> {
|
||||
materializationSources(value);
|
||||
try {
|
||||
const first = await value.generationSource.findActiveResourceGeneration(
|
||||
value.projectId,
|
||||
value.packageName,
|
||||
);
|
||||
if (first === null) return null;
|
||||
const generation = normalizePluginPackageResourceGeneration(first);
|
||||
if (
|
||||
generation.projectId !== value.projectId ||
|
||||
generation.packageName !== value.packageName
|
||||
) {
|
||||
throw new PluginPackageResourceMaterializationConflictError(
|
||||
'generation source returned another Package identity',
|
||||
);
|
||||
}
|
||||
const lockValue = await value.lockSource.findLock(generation.lockDigest);
|
||||
if (lockValue === null) {
|
||||
throw new PluginPackageResourceMaterializationConflictError(
|
||||
'active generation lock is missing',
|
||||
);
|
||||
}
|
||||
const lock = normalizePluginPackageLock(lockValue);
|
||||
const revision = await materializePluginPackageResourceGeneration({
|
||||
generation,
|
||||
lock,
|
||||
byteSource: value.byteSource,
|
||||
...(value.secretBindingSource === undefined
|
||||
? {}
|
||||
: { secretBindingSource: value.secretBindingSource }),
|
||||
taskSpecSemanticRegistry: value.taskSpecSemanticRegistry,
|
||||
});
|
||||
const secondValue =
|
||||
await value.generationSource.findActiveResourceGeneration(
|
||||
value.projectId,
|
||||
|
||||
@@ -20,13 +20,18 @@ const {
|
||||
const {
|
||||
PluginPackageRecoveryCoordinator,
|
||||
} = require('../dist/plugin-package/installation/pluginPackageRecovery');
|
||||
const {
|
||||
sequencePluginPackageActivationPrerequisites,
|
||||
} = require('../dist/plugin-package/installation/pluginPackageInstallation');
|
||||
|
||||
const ARTIFACT_DIGEST = 'a'.repeat(64);
|
||||
const CONTENT_DIGEST = 'b'.repeat(64);
|
||||
const PREVIOUS_LOCK_DIGEST = 'c'.repeat(64);
|
||||
|
||||
function fixture(
|
||||
packageName = 'example-monitor',
|
||||
installationId = 'install-001',
|
||||
options = {},
|
||||
) {
|
||||
const manifest = {
|
||||
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
||||
@@ -65,13 +70,24 @@ function fixture(
|
||||
availableMemoryBytes: 128 * 1024 * 1024,
|
||||
availableDiskBytes: 256 * 1024 * 1024,
|
||||
};
|
||||
const plan = planPluginPackageInstall(manifest, environment);
|
||||
const previousManifest = options.upgrade
|
||||
? {
|
||||
...manifest,
|
||||
metadata: { ...manifest.metadata, version: '1.1.0' },
|
||||
}
|
||||
: undefined;
|
||||
const plan = planPluginPackageInstall(
|
||||
manifest,
|
||||
environment,
|
||||
previousManifest,
|
||||
);
|
||||
const action = {
|
||||
lockId: `lock-${packageName}`,
|
||||
projectId: 'default',
|
||||
manifest,
|
||||
plan,
|
||||
environment,
|
||||
...(previousManifest === undefined ? {} : { previousManifest }),
|
||||
source: {
|
||||
kind: 'offline',
|
||||
locator: `offline:sha256:${ARTIFACT_DIGEST}`,
|
||||
@@ -81,7 +97,8 @@ function fixture(
|
||||
},
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
targetGeneration: 1,
|
||||
targetGeneration: options.upgrade ? 2 : 1,
|
||||
...(options.upgrade ? { previousLockDigest: PREVIOUS_LOCK_DIGEST } : {}),
|
||||
};
|
||||
const lock = createPluginPackageLock({
|
||||
...action,
|
||||
@@ -116,8 +133,8 @@ function stageEvidence(lock) {
|
||||
};
|
||||
}
|
||||
|
||||
function stagedFixture(packageName, installationId) {
|
||||
const value = fixture(packageName, installationId);
|
||||
function stagedFixture(packageName, installationId, options) {
|
||||
const value = fixture(packageName, installationId, options);
|
||||
const staged = transitionPluginPackageInstall(value.lock, value.queued, {
|
||||
type: 'stage_completed',
|
||||
mutationId: `mutation-stage-${value.lock.packageName}`,
|
||||
@@ -353,6 +370,80 @@ test('stages but defers activation until the exact prerequisite is ready', async
|
||||
});
|
||||
});
|
||||
|
||||
test('orders activation prerequisites and stops before later authorities', async () => {
|
||||
const value = stagedFixture();
|
||||
const calls = [];
|
||||
let first = {
|
||||
status: 'deferred',
|
||||
reason: 'secret_binding_transition_required',
|
||||
};
|
||||
const sequence = sequencePluginPackageActivationPrerequisites([
|
||||
{
|
||||
async inspect() {
|
||||
calls.push('secret');
|
||||
return first;
|
||||
},
|
||||
},
|
||||
{
|
||||
async inspect() {
|
||||
calls.push('candidate');
|
||||
return {
|
||||
status: 'rejected',
|
||||
reason: 'activation_fact_conflict',
|
||||
};
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(await sequence.inspect(value.staged, value.lock), first);
|
||||
assert.deepEqual(calls, ['secret']);
|
||||
|
||||
first = { status: 'ready' };
|
||||
assert.deepEqual(await sequence.inspect(value.staged, value.lock), {
|
||||
status: 'rejected',
|
||||
reason: 'activation_fact_conflict',
|
||||
});
|
||||
assert.deepEqual(calls, ['secret', 'secret', 'candidate']);
|
||||
});
|
||||
|
||||
test('rejects an invalid upgrade before publication and retains the previous active lock', async () => {
|
||||
const value = stagedFixture('example-monitor', 'install-upgrade', {
|
||||
upgrade: true,
|
||||
});
|
||||
const repository = new MemoryRepository([{ ...value, record: value.staged }]);
|
||||
const calls = { stage: 0, publish: 0, inspect: 0 };
|
||||
const coordinator = new PluginPackageRecoveryCoordinator({
|
||||
repository,
|
||||
stageProvider: {
|
||||
async stage() {
|
||||
calls.stage += 1;
|
||||
throw new Error('stage must not run');
|
||||
},
|
||||
},
|
||||
publisher: publisherFor(repository, calls),
|
||||
activationPrerequisite: {
|
||||
async inspect() {
|
||||
return {
|
||||
status: 'rejected',
|
||||
reason: 'activation_fact_conflict',
|
||||
};
|
||||
},
|
||||
},
|
||||
now: () => 250,
|
||||
});
|
||||
|
||||
const page = await coordinator.recoverPage({ limit: 1 });
|
||||
const durable = await repository.find('default', 'example-monitor');
|
||||
|
||||
assert.equal(page.items[0].status, 'settled');
|
||||
assert.equal(durable.state, 'failed');
|
||||
assert.equal(durable.previousActiveLockDigest, PREVIOUS_LOCK_DIGEST);
|
||||
assert.equal(durable.activeLockDigest, PREVIOUS_LOCK_DIGEST);
|
||||
assert.equal(durable.failure.reason, 'activation_fact_conflict');
|
||||
assert.equal(durable.failure.failedFrom, 'staged');
|
||||
assert.deepEqual(calls, { stage: 0, publish: 0, inspect: 0 });
|
||||
});
|
||||
|
||||
test('inspects an activating install without republishing it', async () => {
|
||||
const value = activatingFixture();
|
||||
const repository = new MemoryRepository([
|
||||
|
||||
@@ -12,9 +12,11 @@ const {
|
||||
} = require('../dist/plugin-package/pluginPackage');
|
||||
const {
|
||||
createPluginPackageLock,
|
||||
createPluginPackageInstall,
|
||||
pluginPackageInstallActionDigest,
|
||||
pluginPackageInstallPlanDigest,
|
||||
serializePluginPackageManifest,
|
||||
transitionPluginPackageInstall,
|
||||
} = require('../dist/plugin-package/installation/pluginPackageInstall');
|
||||
const {
|
||||
pluginPackageContentTreeDigest,
|
||||
@@ -30,6 +32,7 @@ const {
|
||||
InvalidPluginPackageResourceMaterializationError,
|
||||
MAX_PLUGIN_PACKAGE_MATERIALIZED_RESOURCE_BYTES,
|
||||
PLUGIN_PACKAGE_MATERIALIZED_REVISION_SCHEMA,
|
||||
PluginPackageResourceActivationPrerequisite,
|
||||
PluginPackageResourceMaterializationConflictError,
|
||||
materializeActivePluginPackageResources,
|
||||
materializePluginPackageResources,
|
||||
@@ -40,6 +43,7 @@ const {
|
||||
|
||||
const ARTIFACT_DIGEST = 'a'.repeat(64);
|
||||
const OCI_MANIFEST_DIGEST = 'f'.repeat(64);
|
||||
const PREVIOUS_LOCK_DIGEST = 'c'.repeat(64);
|
||||
|
||||
function resourceValues(overrides = {}) {
|
||||
return {
|
||||
@@ -208,13 +212,24 @@ function fixture(options = {}) {
|
||||
.sort((left, right) => left.path.localeCompare(right.path));
|
||||
const contentDigest = pluginPackageContentTreeDigest(descriptors);
|
||||
const installEnvironment = environment();
|
||||
const plan = planPluginPackageInstall(packageManifest, installEnvironment);
|
||||
const previousManifest = options.upgrade
|
||||
? {
|
||||
...packageManifest,
|
||||
metadata: { ...packageManifest.metadata, version: '0.9.0' },
|
||||
}
|
||||
: undefined;
|
||||
const plan = planPluginPackageInstall(
|
||||
packageManifest,
|
||||
installEnvironment,
|
||||
previousManifest,
|
||||
);
|
||||
const actionInput = {
|
||||
lockId: 'lock-001',
|
||||
projectId: 'project-001',
|
||||
manifest: packageManifest,
|
||||
plan,
|
||||
environment: installEnvironment,
|
||||
...(previousManifest === undefined ? {} : { previousManifest }),
|
||||
source: {
|
||||
kind: 'oci',
|
||||
locator:
|
||||
@@ -226,7 +241,10 @@ function fixture(options = {}) {
|
||||
},
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
targetGeneration: 1,
|
||||
targetGeneration: options.upgrade ? 2 : 1,
|
||||
...(options.upgrade
|
||||
? { previousLockDigest: PREVIOUS_LOCK_DIGEST }
|
||||
: {}),
|
||||
};
|
||||
const lock = createPluginPackageLock({
|
||||
...actionInput,
|
||||
@@ -249,7 +267,9 @@ function fixture(options = {}) {
|
||||
packageName: lock.packageName,
|
||||
lockDigest: lock.lockDigest,
|
||||
generation: lock.targetGeneration,
|
||||
previousActiveLockDigest: null,
|
||||
previousActiveLockDigest: options.upgrade
|
||||
? PREVIOUS_LOCK_DIGEST
|
||||
: null,
|
||||
contentDigest,
|
||||
resources: lock.resources,
|
||||
});
|
||||
@@ -268,6 +288,24 @@ function fixture(options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function stagedRecord(value) {
|
||||
const queued = createPluginPackageInstall(value.lock, {
|
||||
installationId: value.generation.installationId,
|
||||
mutationId: 'candidate-created',
|
||||
occurredAtMs: 201,
|
||||
});
|
||||
return transitionPluginPackageInstall(value.lock, queued, {
|
||||
type: 'stage_completed',
|
||||
mutationId: 'candidate-staged',
|
||||
occurredAtMs: 202,
|
||||
stageRef: `stage:${value.lock.lockDigest}`,
|
||||
artifactDigest: value.lock.source.artifactDigest,
|
||||
manifestDigest: value.lock.manifestDigest,
|
||||
contentDigest: value.lock.source.contentDigest,
|
||||
evidenceDigest: 'e'.repeat(64),
|
||||
});
|
||||
}
|
||||
|
||||
test('materializes exact Task, Workflow, Prompt and Tool JSON into one immutable revision', () => {
|
||||
const value = fixture();
|
||||
const revision = materializePluginPackageResources({
|
||||
@@ -707,6 +745,134 @@ test('reads active bytes sequentially with explicit bounds and rejects a generat
|
||||
);
|
||||
});
|
||||
|
||||
test('qualifies and durably publishes a staged candidate before activation', async () => {
|
||||
const value = fixture({ upgrade: true });
|
||||
const record = stagedRecord(value);
|
||||
let durable = null;
|
||||
let publications = 0;
|
||||
const prerequisite = new PluginPackageResourceActivationPrerequisite({
|
||||
byteSource: {
|
||||
async open(generation) {
|
||||
assert.equal(
|
||||
generation.generationDigest,
|
||||
value.generation.generationDigest,
|
||||
);
|
||||
return {
|
||||
async read(path) {
|
||||
return path === 'package.json'
|
||||
? value.manifestBytes
|
||||
: value.resourceBytes[path];
|
||||
},
|
||||
async close() {},
|
||||
};
|
||||
},
|
||||
},
|
||||
materializedRepository: {
|
||||
async find(generationDigest) {
|
||||
return durable?.generation.generationDigest === generationDigest
|
||||
? durable
|
||||
: null;
|
||||
},
|
||||
async publish(revision) {
|
||||
publications += 1;
|
||||
durable = revision;
|
||||
return { status: 'created', revision };
|
||||
},
|
||||
},
|
||||
taskSpecSemanticRegistry: value.registry,
|
||||
});
|
||||
|
||||
assert.deepEqual(await prerequisite.inspect(record, value.lock), {
|
||||
status: 'ready',
|
||||
});
|
||||
assert.equal(publications, 1);
|
||||
assert.equal(
|
||||
durable.generation.generationDigest,
|
||||
value.generation.generationDigest,
|
||||
);
|
||||
assert.deepEqual(await prerequisite.inspect(record, value.lock), {
|
||||
status: 'ready',
|
||||
});
|
||||
assert.equal(publications, 1);
|
||||
});
|
||||
|
||||
test('rejects a semantically invalid staged candidate without publishing it', async () => {
|
||||
const value = fixture({
|
||||
upgrade: true,
|
||||
resourceValues: {
|
||||
'workflows/daily.json': {
|
||||
schema: 'qinglong/plugin-package-workflow-resource@v1',
|
||||
id: 'daily',
|
||||
name: 'Daily report',
|
||||
enabled: true,
|
||||
steps: [{ id: 'missing', task: 'missing', needs: [] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const record = stagedRecord(value);
|
||||
let publications = 0;
|
||||
const prerequisite = new PluginPackageResourceActivationPrerequisite({
|
||||
byteSource: {
|
||||
async open() {
|
||||
return {
|
||||
async read(path) {
|
||||
return path === 'package.json'
|
||||
? value.manifestBytes
|
||||
: value.resourceBytes[path];
|
||||
},
|
||||
async close() {},
|
||||
};
|
||||
},
|
||||
},
|
||||
materializedRepository: {
|
||||
async find() {
|
||||
return null;
|
||||
},
|
||||
async publish(revision) {
|
||||
publications += 1;
|
||||
return { status: 'created', revision };
|
||||
},
|
||||
},
|
||||
taskSpecSemanticRegistry: value.registry,
|
||||
});
|
||||
|
||||
assert.deepEqual(await prerequisite.inspect(record, value.lock), {
|
||||
status: 'rejected',
|
||||
reason: 'activation_fact_conflict',
|
||||
});
|
||||
assert.equal(publications, 0);
|
||||
});
|
||||
|
||||
test('leaves generation-one qualification to the post-activation B1 binding ceremony', async () => {
|
||||
const value = fixture();
|
||||
const record = stagedRecord(value);
|
||||
let authorityCalls = 0;
|
||||
const prerequisite = new PluginPackageResourceActivationPrerequisite({
|
||||
byteSource: {
|
||||
async open() {
|
||||
authorityCalls += 1;
|
||||
throw new Error('generation one must not read candidate bytes');
|
||||
},
|
||||
},
|
||||
materializedRepository: {
|
||||
async find() {
|
||||
authorityCalls += 1;
|
||||
return null;
|
||||
},
|
||||
async publish(revision) {
|
||||
authorityCalls += 1;
|
||||
return { status: 'created', revision };
|
||||
},
|
||||
},
|
||||
taskSpecSemanticRegistry: value.registry,
|
||||
});
|
||||
|
||||
assert.deepEqual(await prerequisite.inspect(record, value.lock), {
|
||||
status: 'ready',
|
||||
});
|
||||
assert.equal(authorityCalls, 0);
|
||||
});
|
||||
|
||||
test('publishes materialization only through the explicit runtime-core subpath', () => {
|
||||
assert.equal(require('../dist').materializePluginPackageResources, undefined);
|
||||
assert.equal(
|
||||
|
||||
Reference in New Issue
Block a user