这是本节的多页打印视图。 .
功能
- 1: Plain 平面仓库
- 2: Managed 工作区
- 3: 包池与元数据视图
- 4: 成员策略
- 5: 签名模型
- 6: 事务与恢复
- 7: 可观测与审计
SOW 提供两条相互隔离的运行路径。Plain 模式无状态地重建一个目录;Managed 模式在工作区中 持续记录软件包成员关系与不可变仓库 Generation。两者都不会暗中接管对方的状态。
能力矩阵
| 能力 | Plain | Managed |
|---|---|---|
| RPM 与 DEB 元数据 | 是 | 是 |
| RPM + DEB 混合操作 | 同一目录 | 同一 Repository、不同 Dist |
| 持久成员关系与 Generation | 否 | 是 |
| 分架构视图与中性包投影 | 否 | 是 |
exclude 与版本 limit 策略 |
否 | 是 |
| 元数据签名 | 否 | RPM 与 DEB |
| RPM 包签名 | --sign-with |
never、fill、always |
| 事务日志与恢复 | 重新运行 create |
Workspace、Repository、发布 |
| 可查询 Operation Log 与 JSONL 导出 | 否 | 是 |
| 发布目标 | 否 | filesystem 与 R2 |
SOW 在进程内解析软件包并渲染元数据,不调用 createrepo_c、dpkg-scanpackages、
reprepro 或 modifyrepo_c。RPM 包签名是例外:它会改写包体,因此需要主机上的 rpm
命令与 GPG 环境。
仓库格式
| 表面 | RPM/YUM | DEB/APT |
|---|---|---|
| 包事实来源 | RPM header | DEB control archive |
| 身份 | NEVRA + 确切字节 SHA-256 | name=version:arch + 确切字节 SHA-256 |
| 索引 | primary、filelists、other、repomd.xml |
Packages、Packages.gz、Release |
| 中性架构 | noarch |
all |
| 不可变索引路径 | 校验和命名 rpm-md | by-hash/SHA256 |
| Managed 元数据签名 | repomd.xml.asc |
InRelease、Release.gpg |
SOW 有意不生成 SQLite rpm-md、zchunk、modulemd、源码包索引与 MD5/SHA1 DEB manifest。 它负责构建仓库文件,不提供 HTTP 服务或 CDN。
按问题阅读
| 问题 | 页面 |
|---|---|
sow create 写什么、替代什么? |
Plain 平面仓库 |
| Workspace、Repository、Dist 与私有状态如何关联? | Managed 工作区 |
| 一个包池如何供给多个纯元数据视图? | 包池与元数据视图 |
| 为什么软件包被排除或限量? | 成员策略 |
| 哪把密钥签哪个对象? | 签名模型 |
| 中断后会发生什么? | 事务与恢复 |
| 如何查看、校验并审计仓库? | 可观测与审计 |
Release 目标、文件系统要求、客户端与 Provider 见平台与集成。
1 - Plain 平面仓库
sow create 接手一个已经放着 .rpm / .deb 的目录,在包旁边生成平面仓库索引。Plain 模式没有工作区、配置文件、数据库、期望状态,也没有操作 journal。包目录就是权威事实来源,所有索引都是当前目录内容的可丢弃投影。
这个边界是刻意的:Managed 仓库保存状态并恢复事务;Plain 仓库失败了就便宜地重建。一次运行失败或被中断后,重新执行同一条命令,覆盖派生元数据即可。
契约
Plain 模式由四条规则定义:
- 包是权威事实。 默认
create不修改包字节,只替换repodata/、Packages与Packages.gz。--pigsty和显式 RPM 签名是文档明确列出的例外。 - 包内容只扫一遍。 默认未签名路径中,每个选中包只打开一次、完整计算一次 SHA-256,并在同一遍里解析。完整 RPM/DEB 元数据保留给渲染阶段;渲染与输出校验不会再次打开包体。
- 收尾只做一次便宜校验。 发布前重新列出顶层包集合,把
stat事实与扫描快照比较,不再计算第二遍包 SHA-256。 - 失败就重建。 Plain 没有事务 journal、pre-image、前滚或回滚。失败可能留下部分已替换的派生元数据;下一次
sow create丢弃自有临时残留,按当前包目录完整重建。
输入字节不变时,输出仍然确定,重复运行报告 noop=true。
单遍流水线
--jobs 默认等于逻辑 CPU 数,控制唯一一次包内容扫描:
worker 完成先后不会影响结果:解析事实始终按规范 basename / 索引顺序消费。RPM XML 直接使用 worker 保留下来的完整解析对象;DEB Packages 直接使用保留的 control 段落与该 worker 已经算出的 SHA-256。
输出自校验仍会读取生成的 XML、repomd.xml、Packages 与 Packages.gz。这些是很小的派生元数据,不会再次读取包体。
最终 stat 校验保证什么
收尾校验要求:
- 顶层普通
.rpm/.debbasename 的排序集合完全相同; - 文件 identity / inode 相同;
- 文件类型与 mode 不变;
- size 与 mtime 不变。
任一事实不同,都在发布前以完整性退出码 5 拒绝。这能以一次列目录的成本发现正常的新增、删除、替换、截断与重写竞争。
它刻意不是密码学复验。若外部写者原地改字节,同时伪造保持 inode、size 与 mtime 不变,stat 无法发现。Plain 接受这个权衡,因为目标场景是本机单进程、协作写入、结果可重建。需要对抗并发篡改证据或持久恢复时,应使用 Managed 仓库。
扫描与输出规则
- 只考虑目录顶层普通文件;永不递归,也不跟随符号链接。
- 只选择
.rpm与.deb后缀。 - 包身份与架构来自 RPM header 或 DEB control,绝不从文件名猜;RPM
src/nosrc被拒绝。 - 所有合法版本都进索引;同一逻辑坐标对应不同字节流时拒绝。
- 默认模式拒绝空目录;
--pigsty可以把一次中断清理后已经为空的权威包集合收敛完成。
有 RPM 就生成 repodata/,有 DEB 就生成 Packages 与 Packages.gz:
平面位置全部是相对路径:RPM 使用裸 basename,DEB 使用 ./<basename>。公开目录固定 0755,生成文件与 repo_complete 固定 0644,不受 umask 影响。
某种包格式消失时,SOW 删除该格式已知的派生输出。重跑也会覆盖中断留下的半套输出,例如只有一个 Packages;新一代不再引用的 SOW checksum 形状 RPM 元数据会被移除,未知文件保持不动。
确定性与 no-op
repomd.xml 的 revision 与 timestamp 固定为 0,gzip header 固定,排序规范化。因此同一包集合生成逐字节一致的元数据。发布前 SOW 会比较 stage 与 live 元数据;如果无需清理/签名且所有输出已经相同,就只删私有 stage,不替换公开 inode,并返回 noop=true。
Plain create 不做 journal 恢复,因此 JSON 字段 recovered 始终为 false。
发布与中断语义
开始发布前,全部元数据都已在目标目录内的私有 stage 生成并验证。单文件替换使用同文件系统 rename;RPM 先安装 checksum 命名元数据,最后替换 repomd.xml。
这不是多文件事务。进程在发布中被杀,可能留下新 RPM 元数据配旧 DEB 元数据、只剩一个 DEB 索引文件,或多余的旧 checksum RPM 元数据。这些状态不是需要调和的事务证据,只是可丢弃输出。下一次运行按当前包集合重新渲染完整投影,覆盖或删除残留。
实现不创建持久 journal 或 recovery trash。启动时会先丢弃 Plain 保留 staging 命名空间中 属于 SOW 的残留,再开始全新扫描。
--pigsty marker 门禁
--pigsty 还会删除命中兼容规则的解析包事实(DEB i386 与 Patroni 3.0.4),并把剩余包按 basename 排序写入 repo_complete,格式为 <sha256><两个空格><basename>。RPM 不会仅因为架构是 i386/i486/i586/i686 而被删除。
发布顺序为:
marker 缺失就表示“尚未完成”,消费方不得使用该目录。若运行在撤 marker 后停止,重新执行 sow create --pigsty:它扫描现在仍存在的包,覆盖元数据、完成清理,最后写新 marker,无需动作日志。
默认模式看到已有 repo_complete 会拒绝运行,避免未受门禁控制的命令留下过期就绪声明。
显式 RPM 签名
--sign-with 是修改包体的显式授权,也是独立慢路径。SOW 在私有 stage 副本上签名,验证嵌入签名与 signature-neutral digest,重新解析结果,再先于元数据安装签后字节。这些必要的复制、签名与签后验证读取不属于默认未签名的一遍保证。签名中断后同样按当前包目录重跑,不从 journal 重放签名事务。
锁与适用范围
sow create 在一次运行期间锁定目标目录及其稳定父目录;--timeout / --no-wait 控制协作锁等待。锁能阻止另一个协作 SOW 进程同时写入,但不会把任意外部包修改变成受支持负载。
本地单进程创建一个可随时重建的平面仓库,用 Plain。需要期望状态、审计历史、原子 generation 切换或证据驱动崩溃恢复,用 Managed 工作区。
继续阅读
sow create参考 —— 参数、输出与失败契约- 事务与恢复 —— Managed 的耐久边界
- 快速上手 —— 五分钟建一个仓库
2 - Managed 工作区
当同一个仓库要维护好几个月 —— 包成批到达、由策略决定谁留下、事后还得说清楚什么时候变了什么 —— 你需要的是 Managed 模式。本页讲三层模型、它产出的布局,以及命令怎么判断你说的是哪个仓库、哪个 Dist。
三个层级
每层只做一件事,边界很硬:
工作区(Workspace) 只拥有两样东西:根级 sow.yml 和 .sow/ 状态目录。工作区根下其他任何东西都不属于 SOW。它是发现的单位 —— 命令从某个起始目录向上找到工作区 —— 也是架构许可表所在的地方。
仓库(Repository) 固定在 <workspace>/<name>。你不能把它指到别处,没有 path 选项。一个 Repository 拥有自己的 pool/、dists/、SQLite、锁、恢复状态、Generation、保留代根、发布 checkpoint 与 GC 证据。两个 Repository 之间永不去重 —— 同一个包 add 进两个仓库就存两份,这是刻意的:这样删掉一个仓库永远不可能伤到另一个。
Dist 是一个单一格式(rpm 或 deb)的普通具名成员集合。名字对 SOW 是不透明字符串。el9、trixie、el9-beta、customer-acme —— 它们都不产生状态机、晋升流程或快照。想要一个 beta 频道,就建一个叫 el9-beta 的 Dist;含义存在于你的脑子和 .repo 文件里,不在 SOW 里。
架构视图(Architecture View) 是 build 渲染出来的东西,不产生第二份成员关系。一个 noarch RPM 只有一个包对象、一条成员记录,构建时投影进每个适用视图。参见包池与元数据视图。
一个 Repository 可以同时拥有 RPM Dist 与 DEB Dist,共用同一个 pool/。
磁盘布局
Managed 路径从不由用户输入拼装,而是由解析后的真实工作区根、已校验的名称和固定相对片段推导出来 —— 这就是为什么符号链接替换和路径逃逸没有可攻击的面。
执行过两次 dist new 与两次 add 之后的真实工作区:
<repo>/ 下是公共交付树:可以直接服务、由 SOW 发布,或整根复制到离线 staging 后原子切换。
.sow/ 下全部是私有状态,绝不能暴露;详见对外服务。
名称必须匹配 [a-z0-9][a-z0-9._-]*;.、..、.sow、pool、dists 以及工作区保留名一律拒绝。
状态数据库与软件包事实
私有 SQLite 数据库按 package_sha256 索引 Desired/Built Membership,因此查询与构建可以
一次批量展开完整成员投影,而不是为每个软件包单独查询。数据库还保存以不可变包体 SHA-256
为键、可重建的软件包事实缓存。Ingest 只需完整认证并解析每个新软件包一次;生产构建只按本次
选中的 Digest,以有界、确定性批次读取 Facts Row,遇到缺失或损坏记录时再从已认证包体惰性
重建。无关或超大的 Facts Row 不会被读取。
对于未改变的 Pool 文件,暖构建使用设备号、inode、size、mtime 与 ctime 指纹避免重新读取
包体。指纹漂移与 Facts 缺失共享一次权威 SHA-256 校验并自动修复;
sow check 仍是显式的完整密码学审计,并且无论指纹是否匹配,都会
对每个唯一物理包体哈希一次。
缓存与指纹只属于私有实现状态,不改变公共 pool/ + dists/ 布局。不要手工编辑数据库或
PRAGMA user_version。v0.3 Repository 必须先备份,再通过
sow repo migrate 显式升级,之后才能执行普通 0.4
读写。全新 0.4 Repository 已使用当前 Schema;其他维护只在 SOW 诊断明确指出时执行。
sow.yml 驱动一切
只有一个配置文件,用严格 decoder 解析。未知字段不会被忽略——它会失败。重复的规范化架构、非法名称或 format、Dist 架构不是工作区许可表的子集、非法 glob 或分类、不完整的 signing 块,同样失败。
config show --all 展开全部默认值与规范化别名,让你看到 SOW 实际的决定:
架构别名只在解析边界规范化一次:amd64 → x86_64,arm64 → aarch64。输出永远是 canonical family。生态名只在渲染出来的 DEB 视图目录名里出现(binary-amd64、binary-arm64)。
config check 不是 YAML lint。它会打开每个已初始化 Repository 的 SQLite,把候选配置与实际的 Dist、架构、成员集、Built 状态和签名可用性逐项比对。移除仍被成员或 Built 状态引用的架构族是预期拒绝(退出码 6);数据库或协议证据损坏是完整性错误(退出码 5)。每个写命令在写 journal 之前都跑同一套预检,所以 config check 能提前告诉你下一条 add 会不会被拒。
完整 schema(包括 filesystem 与 r2 发布目标)见
sow.yml 配置参考。
发现:哪个工作区?
Managed 命令按以下顺序寻找最近祖先中的 sow.yml:
- 给了
-C/--workdir DIR:从DIR向上找,并跳过当前目录候选。 - 否则从当前目录向上找。
- 仍未找到:从
$SOW_DIR向上找;显式-C查找失败后也保留这条回退。 - 还是没有:失败,并提示
sow init、--workdir与SOW_DIR。
找到第一个 sow.yml 就停,不会越过它继续往上找"更好的那个"。
--workdir 不是 chdir。它只改变发现的起点。sow add 里的相对 PATH 仍然相对你真实的当前目录解析 —— 这正是 sow add ./build/*.rpm -C /srv/ws 应有的行为。
sow create 不参与上述任何一步。
选择:哪个仓库、哪个 Dist?
Repository 选择,按序:
- 显式
-r/--repo NAME。 - 命令起始目录位于
<workspace>/<repo>/内。 - 工作区只有一个 Repository。
- 否则失败并列出候选。
Dist 选择,按序:
- 一个或多个显式
-d/--dist NAME(可重复)。 - 起始目录位于
<workspace>/<repo>/dists/<dist>/内。 - 选定 Repository 只有一个 Dist。
- 否则失败并列出候选。
关键的不对称在这里:没给 -d 时,build、check、status 默认作用于选定 Repository 的 全部 Dist —— 对这几个命令而言,“没有过滤条件"解释成"全都要"是安全的。而 add、rm、ls 必须得到明确的 Dist 集合,因为猜一个包该落到哪里并不安全:
退出码 2。所有推断都在路径类型与符号链接校验之后才进行。
init 只做收敛,不做重置
sow init 的幂等性是设计出来的,它的规则是架构不变式而不是使用便利:
- 没有
sow.yml: 创建一个,写入schema: sow/v3与architectures: [x86_64, aarch64],同时创建.sow/。不自动创建任何 Repository。 - 已有合法配置: 按稳定名称顺序补齐尚未初始化的部分 —— 缺失的 Repository 外壳、缺失的 SQLite、整个缺失的 Dist。新建的 Dist 立刻生成其当前有效架构的全部空视图。
- 已有有效数据库状态或有效协议指针: 只校验。绝不覆盖、绝不清零 Generation、绝不重写字节。
- Dist 已初始化之后又往配置里加了架构:
init不渲染新视图、不推进 Generation。该 Dist 保持 dirty,等待显式build。移除仍被成员或 Built 状态使用的架构族则失败。
第三、第四条规则的意义在于:init 必须能安全地在装着真实内容的仓库上执行。它朝声明的配置收敛,但绝不会拿"还没初始化"当借口去重建一个本来就好好的东西。
对象按稳定顺序处理。如果靠前的配置、Repository 或 Dist 已经耐久提交,而靠后的对象失败了,已提交的计数会被保留:人类输出先报告已提交的结果,--json 保留结构化 result,命令以 3(部分成功)退出。如果此时还没有任何东西提交过,则按原始错误类别退出。
空 Dist 也有合法协议发布面
dist new 建出来的 Dist,在 add 第一个包之前就已具备完整协议入口。RPM Dist 每个架构族
有一份合法的空 repodata;DEB Dist 有 Packages、Packages.gz、by-hash 条目和
Release。如果 Repository 配了元数据密钥,空 Dist 也一样签名。
因此从 Dist 里移除最后一个包之后,留下的是一份合法的空索引(配置签名时仍签名), 而不是缺失或损坏的协议入口。真实包管理器验收仍是另一层兼容性门禁。
Protected 仓库
protected: true 会拒绝 repo rm,即使加了 -f,返回退出码 6。它不限制别的:add、rm、build 和常规 Dist 维护照常。真要删这个 Repository,你必须先改 sow.yml、通过 config check,然后才能删 —— 这个摩擦正是该 flag 存在的意义。
继续阅读
- 包池与元数据视图 ——
build到底写了什么 - 发布模型 —— Generation 如何抵达目标
- 成员策略 ——
exclude与limit如何决定谁留下 - 第一个工作区 —— 本页的十分钟动手版
sow.yml配置参考 —— 完整 schema
3 - 包池与元数据视图
不变式
在一个 Repository 内,每个 live Package Object 在 pool/ 下只有一条正典 payload 路径。
Dist 与架构 view 拥有元数据,不拥有包体 alias:
相同 digest 出现在另一个 Repository 或发布 prefix 时,仍是另一个 owner 下的独立对象。 SOW 不会为了本地去重而制造共享的分布式所有权。
构建完成的 Repository
一个包含一份 x86_64 包与一份 noarch 包的 RPM Dist 形如:
不存在 dists/.../pool/ 子树。仍被保留的 live Generation 可以让多组内容寻址元数据并存;
repomd.xml 指针决定当前生效的是哪一组。
RPM view 使用计算出的父级相对 href
rpm-md 相对架构 view 解析 <location href>。SOW 从实际 view 计算回到正典 Pool 的路径:
深度从实际 view root 推导,不来自域名,也不写死部署路径。sow check 会解析、规范化每条
href,拒绝逃出 Repository 的路径,并证明它抵达预期 Pool 对象。
因此完整 Repository 根才是客户端与交付边界。DNF 指向 dists/el9/x86_64/,但对外服务或
复制时必须包含同级根 pool/ 的整个 Repository。
为什么 view 只含元数据
如果把软件包复制到每个架构 view,在没有 inode 身份的存储系统上就会产生额外 object key 与重复上传。SOW 因此把包体所有权集中在 Repository 包池,让索引负责投影成员关系。 完整复制、归档或发布都能保持这份契约,无需依赖 hardlink。
“只存一份”的边界是一个 Repository 或一个发布前缀,不是 Workspace、bucket、账号或整套 系统。相同软件包位于不同 Repository 或 target 时仍有各自独立的 owner。
中性包只被选入,不会复制
x86_64 view 选择 x86_64 + noarch;aarch64 view 选择 aarch64 + noarch。
中性包仍只有一份 Pool 对象,每个 view 只增加一条指回它的元数据记录。
DEB 在 archive root 层面同理:all 包进入每个适用的 Packages 索引,Filename: pool/...
始终指向唯一正典包体。
APT view
APT 原生把 Filename 定义成相对 archive root:
SOW 在 dists/<dist>/main/binary-<arch>/ 下渲染 Packages、Packages.gz 与 by-hash。
Release、InRelease、Release.gpg 是协议指针与签名。没有每 view 包体 alias,也没有
每架构 Release 存根。
普通客户端与 reposync 是两份契约
规范布局面向能消费完整 Repository 并正确处理协议相对路径的软件包客户端。默认 EL
dnf reposync 是另一份契约:它的 safe-write 检查会
拒绝规范化后落到 per-repository 下载目录上方的软件包路径。这是明确不支持的组合;该工作流
应使用导出的 Leaf。
需要自包含 RPM leaf 时,在 Repository 与所有已配置 filesystem 发布根之外创建导出:
导出拥有自己的包体树、repodata、manifest 与 .sow-export.json 完成标记。默认复制;
--hardlink 是显式的同文件系统、可信只读优化。导出不会成为 Membership、Generation、
publish input 或 GC root。
复制与发布
正典正确性不依赖 inode 身份。优先使用已配置的 Publication Target。必须使用其他传输方式时,
用 rsync、cp 或 tar 把完整、稳定的 pool/ + dists/ 复制到离线 staging,复验后再原子
切换上线;不要逐文件更新在线树。只复制某个 RPM 架构 Leaf 不受支持,因为其中元数据有意
引用同级根 Pool。
sow changes 只在 pool/ 下列一次包体,随后是元数据与指针:
dists/ 下不会出现 package payload 变更项。
继续阅读
- Managed 工作区——所有权与 Generation 状态
- 平台与集成——已验证与明确不支持的组合
- 对外服务——HTTP、复制与发布目标
- 仓库布局——准确的公有/私有路径
4 - 成员策略
策略回答的是这个问题:“我把整个构建目录倒进了这个 Dist,但我不想要 debuginfo 包,而且每个包只留最新版本。“两条规则完成这件事,它们按固定顺序执行,并且作用于 完整候选集,而不是你这次恰好 add 的那几个包。
两条规则与它们的顺序
exclude 丢掉命中规则的包,limit 再按包名与架构限制存活的版本数。顺序固定且不可配置 —— 反过来的话,一个即将被排除的包会在离场路上白白占掉一个版本名额。
两条规则在每次 add、每次 rm 和每次 build 时都强制执行。最后这条很关键:在 sow.yml 里改 limit 或 exclude 会让受影响的 Dist 变 dirty,下一次 build 就把新策略重新施加到现有成员集上。想让收紧后的策略生效,你不需要重新 add 任何东西。
exclude
exclude 是一个规则列表。同一条规则内,各字段之间是 AND;同一字段内,多个 pattern 之间是 OR;规则与规则之间是 OR —— 任一规则命中即排除。字段顺序和规则顺序都不影响结果。
这段读作:不分架构地丢掉所有 debug 类包,并且 丢掉名字以 test- 开头或以 -experimental 结尾的 aarch64 包。
允许五个字段:
| 字段 | 匹配对象 |
|---|---|
name |
二进制包名 |
source |
规范化后的 source 名 |
arch |
x86_64、aarch64 或 neutral |
kind |
下表固定枚举 |
format |
rpm 或 deb |
pattern 是区分大小写的精确字符串或 shell glob(*、?、[])。没有正则,没有版本比较,没有取反,也没有表达式语言。未知字段、空规则和非法 glob 会在 config check 时失败,而不是静默地什么都匹配不到。
kind 由二进制包名推导,优先取最具体的后缀:
| 格式 | 名称后缀 | kind |
|---|---|---|
| RPM | -debuginfo |
debuginfo |
| RPM | -debugsource |
debugsource |
| RPM | -llvmjit |
llvmjit |
| DEB | -dbgsym |
dbgsym |
| DEB | -dbg |
dbg |
| 任意 | 以上均不匹配 | main |
分类结果只来自包本身,不依赖文件所在目录,也不依赖当前主机,所以同一份输入永远分到同一类。sow show --json 会输出算出来的 kind。
被排除的包会被如实报告,不算解析失败,也不会被存下来:
命令退出码是 0。被排除的包本身没有任何问题,它只是不属于这个 Dist。如果一个包没有被任何 Dist 接受,就不会为它写下无主的 pool 对象。
limit
limit 按 (二进制包名, 原生架构) 分组,保留最新的 N 个:
0—— 保留全部版本,这是默认值。- 正整数
N—— 按原生版本序保留最新的 N 个。 - 负数 —— 配置错误。
有两个细节能回答现实中的绝大多数疑问。
分组键包含架构。 limit: 1 不是"这个 Dist 里这个包只留一个版本”,而是"每个包名 + 每个原生架构留一个版本”。所以 pg_sample-1.13(x86_64)与 pg_sample-1.17(noarch)可以同时存在于 limit: 1 的 Dist 里,因为它们属于不同分组。中性包(noarch/all)作为自己的原生架构只计一次,尽管它会渲染进多个视图。
排序用格式的原生规则。 RPM 用 EVR 比较 —— epoch、version、release,遵循标准 rpm 分段规则。DEB 用 Debian version 比较,版本串本身已经包含 epoch 与 revision。SOW 不发明版本方案,也不做字典序比较。
下面是 limit: 1 在同名同架构的两个 Debian 版本之间做决定:
注意这里有两级报告:条目的整体 status 是 excluded(它最终没在任何地方成为成员),而逐 Dist 的结果是 limited —— 告诉你它是输在版本上,不是被某条 exclude 规则命中。当你同时选中多个 Dist 时,每个 Dist 各报各的结果,所以一条命令里同一个包完全可能在一个 Dist 是 accepted、在另一个是 limited。
limit 移除旧成员、加入新成员发生在同一个 Operation 内,所以账本上看到的是一次原子决策,而不是一次删除加一次不相干的插入。
策略作用于完整候选集
一个常见误读是:add 只对命令行上的包施加策略。并非如此。把你的输入合并进目标成员集之后,SOW 会对每个选中 Dist 的 完整 成员集执行 exclude,再执行 limit。
现实后果是:往一个已经装着版本 1 和版本 2 的 limit: 2 Dist 里加版本 3,会在同一个操作里移除版本 1。你没法靠"分开单独 add"绕过版本上限,也不会因为"只拿增量与上限比"而落得 N+1 个成员。
放宽策略永远不会复活任何东西
这条语义最常被误以为是反过来的,所以值得直接演示。接着上面 limit: 1 的例子,把胜出的那个版本删掉:
Dist 空了。bookworm 那个构建没有回来 —— 尽管它的字节还躺在 pool 里,尽管 limit: 1 此刻明明空出了一个名额。
原因在于 exclude 与 limit 移除的是 真实的期望成员。SOW 不维护一份"被策略压下、将来也许还能回来的候选"影子清单。Pool 字节是存储,不是候选集。因此提高 limit 或放宽 exclude 只是给未来的添加腾出空间;它不会回头翻历史,猜哪些你曾经拥有过的包该重新出现。
想让它回来,就再显式 add 一次:
收敛是单向的,而且这是写进不变式的:收紧策略可以移除成员,放宽策略永远不会恢复成员。 正是这种不对称让 build 在任何时刻都能安全执行。假如它是对称的,那么编辑 sow.yml 就可能静默地重新发布一个你刻意下架的包 —— 而这恰恰是安全更新场景里最不能出的事故。
sow rm 移除的是成员关系,不是 pool 字节。包会从所有索引中消失,客户端不再能通过仓库
解析它。只有当包体不再被当前、保留、恢复、发布以及活动维护操作等任何安全根引用时,
才运行 sow gc。
已发布目标使用 sow gc TARGET;filesystem 删除是条件式的,R2 只生成报告。
不要绕过 SOW 状态手工删除规范包池文件。
预览一次决策
sow rm -c 计算将要移除的成员、策略后果,以及此刻 build 会产生的文件变化,但什么都不写:
-c/--check 不取写锁,并且与 --skip 互斥。同时给出 --timeout 或 --no-wait 属于用法错误 —— 免得有人误以为一次预览会去等待写事务。
继续阅读
sow.yml配置参考 —— 完整策略 schemasow add参考 —— 逐条目状态与部分成功退出码- 包池与架构视图 —— 存活下来的成员被渲染到哪里
sow retain与sow gc—— 包体生命周期控制
5 - 签名模型
客户端会对一个仓库提两个不同的问题,SOW 用两套彼此独立的机制分别回答。把它们混为一谈,是"我明明签了名,dnf 还是报错"这类问题最常见的来源 —— 所以本页先把两者拆开。
两条独立的信任链
| 元数据签名 | RPM 包体签名 | |
|---|---|---|
| 回答的问题 | “这份索引真是你出的、没被改过吗?” | “这个 .rpm 文件真是你出的吗?” |
| 配置项 | signing.rpm.metadata、signing.deb.metadata |
signing.rpm.packages |
| 产出 | repodata/repomd.xml.asc、InRelease、Release.gpg |
嵌入包内的 OpenPGP 签名 |
| 是否改变包字节 | 否 | 是 |
| 客户端配置 | dnf repo_gpgcheck=1、apt Signed-By |
dnf gpgcheck=1 |
| Plain 模式可用 | 否 | 是,通过 create -S KEY |
二者分别配置、可分别使用。通常正确的起点是只做元数据签名:它在一个地方为整份索引背书,而且完全不需要改动你从上游拿到的那些包。
Managed 的元数据签名完全由 sow.yml 控制。没有 CLI 覆盖开关,build 上没有 --sign 参数,也没有办法让这次构建和下次构建签得不一样。这是刻意的 —— 仓库的签名身份是仓库的属性,不是"碰巧更新了它的那条命令"的属性。
配置
RPM 与 DEB 的元数据密钥分开声明,所以你可以像上面这样两边共用同一把钥匙,也可以拆开用。每个 metadata 块除 key 外还接受可选的 passphrase 引用。
配了元数据密钥之后,每次构建都会产出签名文件 —— 空 Dist 也不例外:
- RPM,每个架构视图:
repodata/repomd.xml加一份 ASCII-armored 的repodata/repomd.xml.asc - DEB,每个 Dist:
Release加一份 clearsigned 的InRelease与一份分离式 armored 的Release.gpg
InRelease 的 clearsign 正文与 Release 完全一致。没有配元数据密钥时,这两个签名文件根本不会生成 —— 你只会得到 repomd.xml 和 Release。
四种密钥引用形态
密钥引用是一个 URI,scheme 决定由谁来签:
| 引用 | 含义 | 签名者 |
|---|---|---|
keys/repo-signing.asc |
相对 Workspace Root 的 ASCII-armored 密钥路径 | 进程内 Go signer |
file:///绝对路径.asc |
磁盘上的 ASCII-armored 私钥 | 进程内 Go signer |
env://VAR_NAME |
环境变量里的 armored 密钥材料 | 进程内 Go signer |
agent://<fingerprint> |
由环境中 GPG agent 持有的密钥 | 外部 gpg |
file:// 与 env:// 不需要装任何东西 —— SOW 自己签元数据,这也是为什么用 file:// 元数据密钥的仓库在 macOS 和最小化容器里能构建出一致的结果。agent:// 把签名委托给你的 GPG agent,适合私钥在智能卡上、或绝不能落盘的场景。agent:// 不能与 passphrase 引用同时使用,因为那次交互归 agent 管。
passphrase 引用接受相对 Workspace Root 的路径、file:// 或 env://,不接受 agent://。
任何秘密都不会被持久化。 配置、SQLite、日志、JSON 输出和错误文本里,只有引用字符串、fingerprint 和公钥验证证书。config show --all 打印引用与 fingerprint,绝不打印密钥材料。如果某个密钥引用无法解析或不可用于签名,config check 会在你执行 build 之前就告诉你。
RPM 包体签名
三种模式:
| 模式 | 行为 |
|---|---|
never |
原样保留输入字节 |
fill |
包未签名、或签名不受信任时用配置的 key 签;已有签名能被 trusted_keys 验证通过则保持字节不变 |
always |
确保最终包由配置的 key 有效签名;已经是了就保持字节,否则重签 |
trusted_keys 自动包含配置 key 的公钥部分。没有 key 时只能用 never;有 key 时默认 fill。
信任环彼此独立验证
对于带签名 RPM,SOW 会分别评估每个 Retained 单 Key Ring、当前 Policy Ring 与组合
trusted_keys Ring。所有可识别 OpenPGP Signature Packet 必须在同一个候选 Ring 内验证通过,
且至少一条通过的路径必须认证 Payload。SOW 绝不会把一个 Key 接受的 Packet 与另一个单 Key
Ring 接受的 Packet 拼在一起,虚构出 Retained Signer。
这条区别在有意双签过渡时尤其重要:组合 Trusted Ring 可以接受该包,但任一单独 Retained Key 都不能宣称自己独立证明了它。历史 CentOS OpenPGP v3/v4 签名继续受支持。所有候选 Ring 共享 同一遍 Signed-byte Stream,因此增加 Trusted Key 只改变授权结论,不会放大包体读取。
包体签名总是对私有 staged 副本调用环境中的 rpm --addsign 或 rpm --resign,不会就地
修改输入文件。签完后 SOW 会重新解析结果,要求嵌入签名存在、signature-neutral digest 与
NEVRA 不变,并且签名身份与配置完全一致。fill 与 always 必须有 rpm、gpg,且匹配私钥
必须存在于 rpm 使用的 GPG 环境中。key 引用用于标识并验证签名者,不会把私钥自动导入该环境。
由于签名里嵌入了时间戳,签名过程不可复现 —— 同一个未签名 RPM 签两次会得到不同字节。于是重复 add 一个已经加过的包看起来就像内容冲突。SOW 用 signature-neutral payload digest 解决这个问题:对不可变的 header 与 payload(排除 RPM signature header)计算 SHA-256。如果逻辑坐标已存在、neutral digest 相同,且既有对象满足当前策略,SOW 就复用既有的最终字节而不再签名。重复 add 同一个包是稳定的空操作。
这种复用的口子刻意开得很窄。never 模式要求完整字节一致,因为该模式承诺保留输入字节。如果 payload digest 不同,或既有对象不满足当前签名策略,那就是硬冲突 —— add 不会悄悄地在既有坐标上就地重签一个包。这里没有 --replace;如果重签导致字节变化,请提高 release,或专门规划一次密钥轮换流程。
更换密钥会让 Dist 变 dirty
一个 Dist 的 Built 配置摘要覆盖它的 format、canonical 架构、limit、exclude,以及 已冻结的签名身份。改动密钥引用或 fingerprint 会改变这个摘要,于是所有受影响的 Dist 变 dirty:
更换 元数据 key 后,sow build 会用新身份签署索引并产生新 Generation。
RPM 包体是不可变 Package Object;build 不会在同一坐标下静默重签既有对象。如果当前 Desired
RPM 不满足新的包签名策略,build 会拒绝。分阶段轮换通常使用 fill:将新 key 设为当前 key,
同时把旧公钥保留在 trusted_keys;旧 key 软件包保持字节不变,新加入的软件包使用新 key。
只有当旧坐标已下架或被新 Release 替代后,才移除旧信任。直接切到新 key 的 always,要求
每个 Desired RPM 已经由新 key 签名。
当前 Built 元数据的精确公钥证书身份按 Dist 记录,同一 primary fingerprint 的多个证书版本可以共存 —— 所以延长有效期或增加子钥,不会让已经发布出去的东西失效。
Plain 模式
Plain 模式只签 RPM 包体,没有元数据签名。KEY 必须是恰好 16、40 或 64 位十六进制 GPG key ID/fingerprint,不接受 0x 前缀;规范化为大写后作为 _gpg_name macro 传给 rpm。不带 --overwrite 时只签没有可解析嵌入签名的 RPM;带上则对全部保留的 RPM 重签。
--sign-with 要求 --pigsty 清理后至少保留一个顶层 RPM。纯 DEB 目录、缺少 rpm 可执行文件、密钥不可用,都在任何公开变更之前失败。签名是显式慢路径,必然包含复制、签名验证与最终 RPM 解析读取;中断后按当前包目录重跑,而不是重放 Plain journal。见 Plain 平面仓库。
客户端验证什么
repo_gpgcheck=1 让 dnf 验证 repomd.xml.asc;gpgcheck=1 让它验证每个包的嵌入签名。
APT 侧的 Signed-By 让 apt 验证 InRelease。自动化检查会直接校验生成的签名;完整的签名
Managed dnf/APT 验收必须在目标环境中使用真实客户端执行。确切证据见
平台与集成。
sow check 在常规运行中就会校验全部已声明的签名与文件哈希,所以签名配置出错会在发货之前暴露,而不是在客户机器上暴露。
继续阅读
- 仓库签名 —— 生成专用密钥并把两条链都接起来
sow.yml配置参考 —— 完整签名 schema 与密钥引用文法- 可观测与审计 ——
check如何证明这些签名
6 - 事务与恢复
本页说明 Managed 模式如何防止 live 指针指向缺失内容,以及如何协调 SQLite 状态与文件系统变更。
不变式
在受支持的本地 POSIX 文件系统上,沿 Managed 协议指针读取的客户端只会得到完整旧视图或 完整新视图,包括进程中断之后。
下面所有内容都是为了守住这条线:元数据在任何公开变更之前完整 stage 并校验,指针切换就是提交决策,每个操作都留下足够的持久证据,让下一条命令能把它做完或撤销,而不需要猜。
Plain sow create 刻意不属于这套事务模型。它以包目录为权威事实、把元数据视为可丢弃投影:一遍内容
扫描、一次最终 stat 校验,然后覆盖发布。中断后重新运行 sow create,而不是重放 journal。详见
Plain 平面仓库。
也要注意它 没有 声称什么。dirty 不是指索引写了一半;它表示 Desired 状态领先于
Built Generation,而旧的 Built View 仍然完整。SOW 也不承诺两个不同 Dist 在同一瞬间翻代;
它承诺每个协议视图始终自洽,且写命令返回时,本次 Operation 包含的每个 Dist 都处于记录的
Built Generation。
两类持久日志
Managed 仓库生命周期与变更使用两类持久化载体,各自作用域很窄:
| 日志 | 位置 | 覆盖范围 | 由谁恢复 |
|---|---|---|---|
| Workspace 文件 journal | .sow/workspace-ops/active.json |
init、repo new、repo rm |
下一条工作区生命周期命令 |
| Repository 操作日志 | 该仓库的 SQLite | dist new/rm、add、rm、build、log prune |
该仓库的下一条写命令 |
这个划分不是随意的。工作区生命周期操作发生在目标仓库数据库尚不存在、或即将被删除的时候,因此不能用它;仓库变更有可用数据库,就用数据库。Plain 两者都没有,因为它的恢复单元是按包重新构建。
Workspace journal 保存操作类型、随机 64 位十六进制 id、仓库名,以及新旧 sow.yml 的原始字节与各自 SHA-256。工作区锁保证同时只有一条 active operation。sow.yml 的原子 rename 就是提交决策:如果当前 config 仍然哈希为旧值,就清理 planned journal 并回滚;如果哈希为新值,就幂等地补齐仓库外壳,或把自有对象移入 recovery。两边都不匹配则拒绝猜测。
Repository 操作日志 在任何公开文件副作用 之前 先向 SQLite 提交一条 planned Operation,随后记录每次状态迁移。它的 payload 绑定仓库、config SHA-256、精确的选中 Dist 集合、精确的 build_dists、--skip 决策,以及一个 manifest 哈希 —— 后者覆盖新对象事实、完整期望集、逐 Dist 策略结果、RPM 公钥证书快照与目标 Generation。
这不是 SQLite 的 WAL。WAL 负责 SQLite 自己的页面事务,它无法原子地协调 pool、staging 区与 dists/。跨数据库记录与 POSIX 文件动作的,是这套应用级操作日志。
操作生命周期
| 状态 | 已耐久的东西 |
|---|---|
planned |
命令、参数、目标与预期动作 |
staged |
新包与元数据已写入私有 staging 区并校验 |
applied |
期望状态与所需私有 pending payload 已提交;公开树可能仍是旧一代 |
built |
完整静态 Generation 已切换 |
done / done_dirty |
终态;作为审计记录保留 |
sow log <OPERATION> 展示带时间戳的状态迁移:
只有你显式给出 --skip 才可能走到 done_dirty。默认 add 如果在 applied 之后渲染失败,命令返回错误、旧 Built 视图继续服务、Operation 保持可恢复 —— 它不会悄悄地以 dirty 收尾。
在 applied 之前失败的 Operation 会成为 failed。这里有一处契约上的微妙之处:add 必须在解析包之前先记录 planned Operation,所以一个架构不被许可的包确实会留下审计记录。但除了那条终态 failed 记录之外,什么都不会被写入 —— 没有包对象、没有成员关系、没有 pending 字节、没有公开树变化、没有 Generation。既留住了审计线索,又保证无效架构不会进入任何产品投影。
锁模型
锁是本机的 POSIX advisory flock。产品契约是单机、单写、本地 POSIX、协作式锁 —— 网络文件系统既不检测也不支持。
| 锁 | 文件 | 谁持有 |
|---|---|---|
| 工作区锁 | .sow/workspace.lock |
init、repo new/rm、dist new/rm |
| 仓库锁 | .sow/repo-locks/<repo>.lock |
add、rm、build、dist new/rm、log prune |
| Plain 目录锁 | 目标目录及其稳定父目录 | sow create |
两把都需要时,顺序固定:先工作区,后仓库,释放顺序相反。仓库锁的 inode 位于稳定路径,绝不随私有状态目录移动 —— 这样删除仓库时可以在别的进程还持有旧描述符的情况下撤下锁路径,而不会有第二个写者在新 inode 上形成。
sow create 同时锁住目标目录 和 它稳定的父目录。父目录锁的作用是:阻止另一个协作写者用 rename 把目录整个换掉,再对替身取得一把独立的锁。
只读命令从不取写锁,也不接受锁参数。其中需要组合读取配置、SQLite 与 live 元数据的那几个(config check、repo ls/show、dist ls/show)会在整个快照期间持有共享锁。status 刻意更轻:它只探测仓库锁,以便在写入进行中报告 recovering 或 locked,而不会被它阻塞。
两个参数控制等待行为,适用于所有取写锁的命令:
| 参数 | 行为 |
|---|---|
-T, --timeout DUR |
最多等待 DUR;0(默认)一直等 |
-N, --no-wait |
只尝试一次,锁被占用立即失败 |
两条失败路径都以 4 退出。--no-wait 与非零 --timeout 同时出现是用法错误,退出码 2。
在"宁可跳过这轮、也不要堆积"的 cron 作业里用 -N;在"排一小会儿队可以、但绝不能挂死"的 CI 里用 -T 30s。
提交顺序
每一代都按同样的四个阶段写入,而顺序正是不变式成立的原因:
- payload —— 规范包字节写入
pool/。此时还没有任何东西引用它们。 - metadata —— checksum 命名的 RPM 元数据、
Packages、Packages.gz,以及 by-hash 索引副本。此时仍没有指针指向它们。 - pointer —— 客户端入口:RPM 的
repomd.xml(配置了签名则连同.asc);Managed APT 则在每个架构的 direct 与 by-hash 索引都就位之后,才发布Release(连同InRelease与Release.gpg)。这一步就是提交。 - delete —— 清理已过保留窗口的旧代元数据。
Pending 包体在单写者下分批提升,每次 group commit 最多 512 个对象或 1 GiB。SOW 先持久化 Pool 目录项,再删除 pending 名称;恢复因此能把 pending-only、指向同一 inode 的双链接或 Pool-only 状态重新绑定到 Operation,而不会冒同时丢失两个名称的风险。
正着读:包一定先于引用它的索引存在,索引一定先于指向它的指针存在。反着读:在一个不再引用某文件的指针耐久落地之前,那个文件不会被删。不存在任何一个窗口,让客户端沿活的指针走到一个不存在的文件。
这一切都通过与目标同文件系统的 staging 区完成,初始化时通过比较 st_dev 校验。挂载点或设备不同是明确失败,绝不降级为复制。文件先写入、fsync、由 SOW 自己的解析器与闭包校验器验证,之后才用原子 rename 换入。公开文件不继承你的 umask:repodata/ 是 0755,索引文件与指针是 0644。
sow changes 用于审计与交付规划,描述 Generation Delta;它不能替代发布协议。请使用
sow publish,或把完整树复制到离线 staging 后再原子切换上线。见
可观测与审计。
崩溃恢复
每条 Managed 写命令都先恢复,再做自己的事。 没有单独的修复命令,也没有守护进程盯着陈旧状态;恢复是变更的前置条件。只要存在非终态 Operation,下一条 add、rm、build、dist new/rm 或 log prune 就先把它做完或回滚,然后才继续。
全局恢复顺序是固定的:先在工作区锁下恢复工作区生命周期;如果那不是一次仓库删除,再按仓库名顺序、在各自稳定的仓库锁下恢复仓库 Operation。已经越过"删除仓库"提交决策的工作区操作具有支配权,并禁止任何嵌套的仓库恢复 —— 在一个正被删除的仓库内部恢复状态毫无意义。
恢复由证据驱动,不做乐观假设。每个阶段都有明确规则:
| 已到达的阶段 | 恢复规则 |
|---|---|
planned |
config 仍为旧值 → 回滚 stage;否则证据冲突,退出 5 |
staged |
config 仍为旧值 → 可回滚;config 已为新值 → 只允许前滚 |
applied |
新 config 已原子换入,这就是提交决策,因此一律前滚 |
built |
指针与目录已耐久,前滚提交数据库行 |
done |
数据库、config 与树同代;清理 stage,重复恢复是空操作 |
这套规则的验收方式是在多个不同时机向 sow add 发送 SIGKILL。每一次,status 都报告 recovering,下一条写命令都先恢复该 Operation 再执行自身,最终 check 全部层通过,公开树从未撕裂。
sow build 是唯一的显式前滚恢复入口:它在收敛之前,会先尝试完成或回滚任何可判定的非终态 Operation。看到 recovering 时,执行 sow build 就是标准反应。
error 专留给 journal、数据库与文件证据互相矛盾、任何自动选择都不安全的情况。此时 build 拒绝覆盖,最后完成的视图继续服务,你应当从备份恢复,再跑 check 与 build。这里刻意没有 repair --force —— 一个可能猜错的修复,比一个拒绝执行的修复更糟。
fail-closed 的路径安全
Managed 路径从不由用户提供的字符串拼装。每次创建、rename 和删除都走同一套流程:
- 把工作区根解析为绝对真实路径;
- 用固定相对片段重新构造目标,并验证相对路径不含任何逃逸分量;
- 对路径上每个已存在的受控组件执行
Lstat,拒绝符号链接和非预期文件类型; - 只删除已经先被原子移入
.sow/.../recovery的对象; - 删除前再次证明该 recovery 目标确实位于对应的私有状态目录内。
名称必须匹配 [a-z0-9][a-z0-9._-]*,.、..、.sow、pool、dists 及工作区保留名一律拒绝。
对文件句柄也是同样的姿态。SQLite 以 O_NOFOLLOW 打开并绑定普通文件 inode,连接建立后再按路径复核一次;数据库、WAL、shm 或 rollback journal 中任何一个是符号链接、非普通文件、有多个硬链接,或在打开期间被换绑,都会被拒绝。log export 拒绝覆盖已存在的文件,也拒绝父目录是符号链接的目标 —— 这就是为什么在 macOS 上往 /tmp 导出会失败:那里的 /tmp 本身是个符号链接。
各类 journal 都有大小上限:工作区 32 MiB、仓库 Operation payload 16 MiB,外置的 mutation manifest 与 base manifest 各 64 MiB。超限既不截断也不降级,而是在提交窗口之外直接失败 —— 这样写者永远不会产出一条"自己写得进去、恢复读者却永远读不回来"的 Operation 记录。
以上没有一条声称能抵御以同一用户身份运行、拥有无限权限的恶意进程。它抵御的是现实中的失败模式:崩溃、协作进程之间的竞态,以及在检查与使用之间形态发生变化的路径。
继续阅读
7 - 可观测与审计
每个读取表面回答不同问题。
| 命令 | 问题 | 写入? |
|---|---|---|
status |
Repository 当前是什么状态? | 否 |
check |
所选 Repository 是否满足完整交付契约? | 否 |
changes |
两个 Built Generation 之间哪些物理文件不同? | 否 |
log |
记录了哪些操作与处置结果? | 否 |
retain ls |
哪些 Generation 是显式本地 GC root? | 否 |
status:低成本状态
它报告 Desired revision、Built Generation、dirty Dist、pending 包体计数、锁状态与
ready_to_copy。它不哈希公共树,不恢复操作,也不构建。
| Repository 状态 | 含义 |
|---|---|
clean |
Desired 与 Built 一致 |
dirty |
Desired 已变化;公共树仍是上一份 Built Generation |
recovering |
存在持久非终态操作 |
error |
持久证据冲突,自动恢复无法安全决策 |
用 status 诊断,不要把它当成 check 的替代品。
check:交付证明
稳态下,checker 按顺序报告九层:
| 层 | 校验内容 |
|---|---|
config |
严格配置与有效 Dist 输入 |
state |
SQLite schema 与关系状态 |
public-modes |
公共文件/目录权限 |
retained |
显式保留 Generation 记录与冻结元数据 |
package-bytes |
pool/pending object 与已记录 SHA-256 |
desired-membership |
包身份、成员关系与架构一致性 |
index |
渲染元数据与引用 closure |
signature |
声明的元数据与 RPM 包信任要求 |
generation-manifest |
已记录 Built manifest 与公共树 |
未完成布局迁移使用更短的诊断表面:依次报告 config、state、public-modes 与
layout-transition,随后停止,并在诊断指定的维护操作完成或在 commit 前安全中止前返回不可交付。
check 不写入也不修复。dirty 或 recovering Repository 不可交付,即使上一份已提交树仍可读取。
在发布流水线中执行 check,任何非零退出都应停止。
每次 Check 都会对每个唯一物理包体执行一次权威 SHA-256,不会因缓存指纹匹配而省略;随后在
Retained Record、Index、Signature、最终 Generation Manifest 与 changes 之间共享基于描述符
的证据。带签名 RPM 只额外使用一遍所有 Signature Packet 与 Trust Ring 共用的签名流,不会随
Dist、Key 或 Retained Generation 数量放大。
changes:Generation 差异
- 不给 base:比较当前 Built Generation 与前一代;
- base
0:描述完整当前公共树; - base
N:给出已记录 GenerationN到当前 Built 的净差异。
每行包含操作、phase、Repository 相对路径、大小与 SHA-256。phase 使用与本地构建相同的 payload、metadata、pointer、delete 词汇。
changes 是 manifest/差异表面。它不连接目标、不持久化远端 checkpoint、不执行 cache grace,
也不恢复中断传输。配置好的 live target 应使用 sow publish TARGET。离线复制应先 stage
完整树、复验,再原子切换上线。
Generation 保留与 GC
retain add 校验并冻结 Generation 的元数据与引用集合,不复制另一棵包体树。保留记录是显式
GC root。retain rm 移除该 root,本身不删除包字节。
本地 sow gc 只删除已证明不被当前状态、显式 retention、active recovery/publication 状态及
其他记录根引用的包体。Target GC 是另一项操作:sow gc TARGET 使用该 Provider 的安全模型。
普通构建会携带紧邻前一代的 RPM 不可变元数据与 APT by-hash object,让已读取旧指针的客户端
完成下载。这个有界协议窗口与显式 retain 是两回事。
操作日志
日志按操作记录 kind/state、时间、配置/manifest identity、包处置、成员变化,以及适用时的 物理 changeset。
log export 输出稳定 JSONL,拒绝覆盖既有文件,并校验输出路径。log prune 接受日期或
RFC 3339 时间,只移除符合条件的终态审计记录;不会删除当前状态、恢复证据或仍被需要的
Generation manifest。
操作模式
status 用于监控,check 用于门禁,publish 用于目标变更,log 用于事后证据。