跳转到主要内容

这是本节的多页打印视图。 .

返回本页常规视图.

SOW 文档

用一个自包含二进制创建并管理 RPM/YUM 与 DEB/APT 软件仓库。

SOW 是 Pigsty 出品的自包含软件仓库管理器。sow create 能把目录中的 RPM 与 DEB 文件直接变成可用平面仓库;Managed 工作区则进一步提供成员关系、筛选策略、签名、不可变 Generation、审计历史与发布目标。

本文档对应 SOW 0.5.0。新增发布时间参数见发布注记, 已有 Managed 工作区请参考升级说明。

按 Ctrl 加 K(macOS 上也可用 ⌘ 加 K)搜索本站; 焦点不在输入框时按 /,可直接打开命令模式。

  • 上手 — 安装 SOW、创建平面仓库,并构建第一个 Managed 工作区。
  • 教程 — 完整的 YUM、APT、签名、对外服务与发布实战。
  • 功能 — Plain/Managed 运行路径、包池投影、策略、签名、事务与审计。
  • 设计归档 — 按日期记录所有权、布局、发布、恢复与兼容性决策。
  • 命令 — 每条命令的语法、选择规则、输出、状态变化与退出行为。
  • 参考 — 配置、包引用、目录布局、JSON、退出码、平台与集成覆盖。

选择路径

目标 从这里开始
立即索引一个软件包目录 快速上手
替换已有 createrepo_c 工作流 迁移现有 YUM 仓库
长期维护精选仓库 第一个工作区
搭建完整 YUM 或 APT 仓库 教程
查询精确 CLI 行为 命令
核对字段、路径或兼容性结论 参考
理解一项架构决策 设计归档

1 - 上手

安装 SOW、创建平面仓库,并理解 Managed 工作区模型。

SOW 生成 RPM/YUM 与 DEB/APT 静态仓库,本身不是 HTTP 守护进程。先选择一条相互隔离的 运行路径:

  • Plain: sow create 在普通目录中为现有软件包重建索引。

  • Managed: 工作区持续记录成员关系、Dist、架构视图、策略、签名、Generation、 审计历史与发布目标。

  • 安装 — 选择 Release 归档、RPM/DEB 安装包或源码构建,并校验二进制。

  • 快速上手 — 从一个软件包目录创建平面仓库,并通过 HTTP 提供服务。

  • 第一个工作区 — 初始化 Managed 模式,创建 RPM/DEB Dist,添加软件包,然后构建并校验。

  • 核心概念 — Workspace、Repository、Dist、Package Object、Desired Membership 与 Built Generation。

Managed 工作区需要具备建议锁、fsync 与原子 rename 语义的本地 POSIX 文件系统。元数据 在进程内生成;可选的 RPM 包签名需要 rpm,agent:// 元数据密钥需要 gpg 与 gpg-agent。

1.1 - 安装

通过归档、RPM/DEB 安装包或源码安装 SOW,并核对二进制与文件系统要求。

SOW 只有一个可执行文件,不需要启用服务,也不依赖语言运行时。Release 构建目标是 Linux 与 macOS 的 amd64、arm64;Linux 另外提供 RPM 与 DEB 安装包。不支持 Windows。

在下载页选择匹配操作系统与架构的归档或 Linux 安装包。页面同时提供每个 已发布制品、对应源码 Tag 与 SHA256SUMS 的链接。

安装归档

下载一个归档与 SHA256SUMS,解压前只校验对应条目:

# Linux amd64
grep '  sow_0.5.0_linux_amd64.tar.gz$' SHA256SUMS | sha256sum -c -
tar -xzf sow_0.5.0_linux_amd64.tar.gz
sudo install -m 0755 sow /usr/local/bin/sow

macOS 选择 darwin_amd64 或 darwin_arm64,并把 sha256sum -c - 换成 shasum -a 256 -c -。没有 root 时,把二进制装到已经加入 PATH 的目录,例如 ~/.local/bin。

安装 Linux 软件包

Linux 软件包使用 1PGSTY Release 后缀:

sudo rpm -Uvh ./sow-0.5.0-1PGSTY.x86_64.rpm
sudo apt install ./sow_0.5.0-1PGSTY_amd64.deb

只执行符合本机发行版与架构的那条命令。RPM 把 License 安装到 /usr/share/licenses/sow/LICENSE;DEB 把版权/协议文件安装到 /usr/share/doc/sow/。

从源码构建

Go Module 声明使用 Go 1.27.1,元数据生成不需要 C 工具链。使用与固定版本下载一致的 v0.5.0 源码 Tag:

git clone https://github.com/pgsty/sow.git
cd sow
set -euo pipefail
SOW_TAG=v0.5.0
git checkout "$SOW_TAG"
SOW_VERSION="${SOW_TAG#v}"
CGO_ENABLED=0 go build -trimpath \
  -ldflags="-s -w -X github.com/pgsty/sow/internal/v2cli.Version=${SOW_VERSION}" \
  -o sow ./cmd/sow
sudo install -m 0755 sow /usr/local/bin/sow

这组命令使用 Release 构建参数,并把所选 Tag 的产品版本写入二进制。

校验

sow version
sow help

sow version 输出产品版本、目标 OS/架构与构建 Go 工具链;sow help 列出命令树。 归档中还包含 README.md、CHANGELOG.md、Apache-2.0 LICENSE 与 THIRD_PARTY_NOTICES。

从 0.4 升级到 0.5

通过下载页安装 SOW 0.5.0,本次变更见 0.5.0 发布注记。

SOW 0.5 使用数据库 Schema v13,新增候选包池路径的查询索引,sow/v3 配置与公共布局保持 不变。先停止写入并备份整个工作区,再在普通使用前逐个迁移 Repository:

sow repo migrate REPOSITORY -C /srv/sow
sow build -r REPOSITORY -C /srv/sow
sow check -r REPOSITORY -C /srv/sow

更新后的 RPM 认证与 APT 元数据契约需要对受影响的 Dist 重建一次。之后,未变化且具有匹配 Built 证据的 RPM 不必反复读取包体验签。迁移会增加私有数据库索引,发布历史较大时需要时间 和磁盘空间;迁移后不要再使用旧版本二进制打开数据库。sow repo migrate 会报告变化,例如 schema=12->13;重复执行显示 schema=13->13。Repository 迁移之前,其写命令以 5 退出并提示 repository schema v12 predates this binary (v13); back up the workspace, then run `sow repo migrate NAME`。

备份工具必须保留硬链接,因为 DEB 的 by-hash 条目是硬链接:Linux 上可用 GNU cp -a、 rsync -aH 或 tar;macOS 的 cp -a 不保留硬链接。

Plain 仓库新增 create --metadata-timestamp,请检查实际二进制的 create --help 是否包含 该参数。默认值仍为 0,因此仅升级二进制不会修复已发布 YUM 仓库的时间戳。替换已有维护 流程前,请按 YUM 迁移指南操作。

升级 0.3 Managed Workspace

SOW 0.4 引入了内部数据库 Schema v11 与 v12,0.5 升级到 v13。公共布局和 schema: sow/v3 配置标识均不改变, 但每个既有 v0.3 Repository 都必须在普通读写前显式迁移。备份前先停止 Workspace 全部写入:

cp -a /srv/sow /srv/sow.backup-before-upgrade   # GNU cp;其他平台请用 rsync -aH 或 tar
sow repo migrate REPOSITORY -C /srv/sow
sow build -r REPOSITORY -C /srv/sow
sow check -r REPOSITORY -C /srv/sow

对 sow.yml 中的每个 Repository 重复最后三条命令。数据库 Transition 是单向的;迁移完成后 不要再用旧版本二进制打开 Workspace。状态、Signer 与 Publication Evidence 的修复范围见 sow repo migrate。

权限与可选工具

执行用户需要读取输入软件包,并能写入 Plain 目标目录或 Managed 工作区。Managed 工作区 应放在本地 POSIX 文件系统上;锁、fsync、安全路径与原子 rename 都属于正确性契约。

软件包解析与元数据渲染都在进程内完成。只有两条可选路径需要主机工具:

  • RPM 包签名 需要 rpm 与可用的 GPG 环境;
  • agent:// 元数据密钥需要 gpg 与 gpg-agent。

接下来可用快速上手进入 Plain 模式,或用 第一个工作区进入 Managed 模式。

1.2 - 快速上手

索引一个 RPM/DEB 软件包目录,对外服务,并配置客户端。

Plain 模式在一个目录内生成平面仓库。它不读取 sow.yml,不创建工作区,也不维护数据库。

准备目录

把 RPM 和/或 DEB 文件放在目录顶层。sow create 不递归扫描,也不移动或改名包文件。

mkdir -p /srv/repo
cp /path/to/packages/*.rpm /path/to/packages/*.deb /srv/repo/

如果某种格式不存在,请只复制你实际拥有的软件包。

生成元数据

sow create /srv/repo

混合格式输出形态如下:

created /srv/repo: rpm=1 deb=1 signed=0 removed=0 marker=false noop=false recovered=false

目录会变成:

/srv/repo/
├── package.rpm
├── package.deb
├── repodata/       # RPM: repomd.xml、primary、filelists、other
├── Packages        # DEB 平面索引
└── Packages.gz

Plain 模式不生成 DEB Release、InRelease 或 Release.gpg。RPM 与 DEB 元数据在一次 操作中生成;任何解析或渲染错误都会阻止新索引提交。

对外服务

本地检查可以使用任意静态文件服务器:

cd /srv/repo
python3 -m http.server --bind 127.0.0.1 8080

在另一个终端检查两个协议入口:

curl --fail http://127.0.0.1:8080/repodata/repomd.xml >/dev/null
curl --fail http://127.0.0.1:8080/Packages.gz >/dev/null

Python 服务器只适合预览;长期服务请使用正常维护的 HTTP 服务器。

配置客户端

把 REPO_HOST 换成客户端能访问的地址。

# /etc/yum.repos.d/sow-quickstart.repo
[sow-quickstart]
name=SOW Quick Start
baseurl=http://REPO_HOST:8080/
enabled=1
gpgcheck=0
repo_gpgcheck=0
# /etc/apt/sources.list.d/sow-quickstart.list
deb [trusted=yes] http://REPO_HOST:8080/ ./

刷新索引并安装软件包:

sudo dnf makecache
sudo dnf install PACKAGE_NAME
sudo apt update
sudo apt install PACKAGE_NAME

APT source 末尾的 ./ 表示平面仓库。[trusted=yes] 与关闭 DNF 签名检查只适用于这个 未签名的快速示例;需要真实性保证时应使用已签名 Managed 仓库。

更新仓库

增删包文件后重新执行同一条命令:

sow create /srv/repo

目录内容就是 Plain 模式的全部状态。包字节与参数(包括显式指定的元数据时间戳)均不变时, 生成的元数据具有确定性,重复运行会报告 noop=true。

对于已有客户端使用的 YUM 仓库,切换生成工具时应保留发布历史。SOW 0.5.0 新增 --metadata-timestamp 参数用于指定发布时间,默认仍为 0。备份、签名与带缓存客户端 验收的步骤见迁移现有 YUM 仓库。

自动化场景可使用带版本的 JSON 信封:

sow create /srv/repo --json

何时使用 Managed 模式

如果目录已经恰好包含要发布的全部内容,使用 Plain。需要具名 Dist、架构视图、成员策略、 已签名元数据、Generation、审计或发布目标时,使用 Managed 工作区。

另见 sow create 与 Plain 平面仓库。

1.3 - 第一个工作区

创建工作区,建立 RPM/DEB Dist,添加软件包并校验公共树。

Managed 模式会持久保存配置、成员关系、Generation 与审计状态。下面从空目录开始。

初始化工作区

sow init /srv/sow
cd /srv/sow

init 创建:

/srv/sow/
├── sow.yml   # 配置;schema: sow/v3
└── .sow/     # SQLite 状态、锁、staging、恢复与操作日志

不要编辑或对外服务 .sow/。init 是幂等操作:重复执行会校验并收敛已声明的 Repository 与 Dist,不会重置有效工作区。

默认架构族是 x86_64 与 aarch64。配置接受 amd64、arm64 别名,并规范化为上述族名。

创建 Repository 与两个 Dist

sow repo new local
sow dist new el9 --format rpm
sow dist new bookworm --format deb

一个 Repository 拥有一棵公共 pool/ + dists/ 树和一份私有状态数据库。每个 Dist 只有 一种格式。dist new 会立即生成合法空视图,客户端读取空 Dist 时得到空索引而不是 404。

此时公共布局为:

/srv/sow/local/
├── pool/
└── dists/
    ├── el9/
    │   ├── x86_64/repodata/
    │   └── aarch64/repodata/
    └── bookworm/
        ├── Release
        └── main/
            ├── binary-amd64/{Packages,Packages.gz,by-hash/}
            └── binary-arm64/{Packages,Packages.gz,by-hash/}

添加软件包

显式选择目标 Dist:

sow add /path/to/packages/*.rpm -d el9
sow add /path/to/packages/*.deb -d bookworm

SOW 从包本身读取身份与架构,把接受的字节存入 local/pool/,更新 Desired Membership, 并在返回前构建受影响的 Dist。输入路径只用于导入;后续构建使用 Managed 包池。

需要合并多次成员变更时,用 --skip 暂不构建,最后统一收敛:

sow add /path/to/more/*.rpm -d el9 --skip
sow build

Desired Membership 领先于 Built Generation 时,Repository 状态为 dirty,且 ready_to_copy=false。

查看与校验

sow status
sow ls -d el9
sow ls -d bookworm
sow check

status 是低成本状态读取。check 是交付门禁:它校验配置、状态、公共文件权限、保留根、 包字节、Desired Membership、索引、签名与 Generation manifest,且不写入任何内容。 只有 clean 且所有层都通过的 Repository 才返回成功。

查看规范化配置与默认值:

sow config show --all

对外服务 Repository

公共交付单元是 /srv/sow/local,不是工作区根。把这个目录挂到稳定 URL 前缀;不要暴露 sow.yml 或 .sow/。

  • DNF base URL:https://repo.example.com/local/dists/el9/x86_64/
  • APT source:deb https://repo.example.com/local bookworm main

安全的 Nginx 与 filesystem 发布流程见对外服务。

选择规则

  • Workspace:给了 -C DIR 就从该目录向上找,否则从当前目录找;未找到工作区时再尝试 SOW_DIR。显式 -C 不会禁用这条回退。
  • Repository:-r NAME、当前路径所属 Repository,或唯一已配置 Repository。
  • Dist:-d NAME,可重复;只有命令能够唯一确定范围时才可省略。

-C 指定的是发现起点,不保证该目录本身就是最终 Workspace Root。用 sow config check -C DIR 查看解析后的工作区;必须固定操作对象的脚本应指定明确的物理工作区根及 -r NAME。 存在歧义时直接报错;SOW 不会随便挑选 Repository 或 Dist。

下一步

1.4 - 核心概念

SOW 模型:Plain 与 Managed、包池与视图、Desired Membership 与 Built Generation。

Plain 还是 Managed

两条运行路径相互独立。

Plain Managed
入口 sow create DIR init、repo、dist、add、rm、build
状态 软件包目录 sow.yml 加私有 SQLite/操作日志
公共布局 平面 RPM/DEB 索引 Repository pool/ + dists/
格式 RPM 与 DEB 可共存于一个目录 每个 Dist 一种格式
架构视图 无 有
策略与审计 无 有
元数据签名与发布目标 无 有

目录内容已经等于目标仓库时使用 Plain。需要由 SOW 管理成员、策略、Generation、签名、 审计或发布时使用 Managed。

Managed 层级

Workspace                    /srv/sow
├── sow.yml                  配置
├── .sow/                    私有状态;绝不对外服务
└── Repository               /srv/sow/local
    ├── pool/                规范包体
    └── dists/
        └── Dist             一个具名 RPM 或 DEB 成员集
            └── views        按架构渲染的元数据
  • Workspace 是配置与发现边界。
  • Repository 是隔离、Generation、发布和公共树边界。不同 Repository 之间不去重包体。
  • Dist 是单一包格式的具名成员集合。
  • 架构视图 是派生输出,不是第二套成员关系。noarch RPM 与 all DEB 会进入所有适用 视图,但包池字节不重复。

一条规范包体路径

Package Object 以确切字节的 SHA-256 标识;逻辑坐标来自 RPM header 或 DEB control, 不来自文件名。

每个已接受包体在 Repository pool/ 下只有一条规范路径。RPM 架构视图只含 repodata/, 包位置通过父级相对路径回到包池;APT Packages 直接指向同一包池。

普通包管理器与镜像工具是不同契约。默认 dnf reposync 会拒绝规范 RPM 视图中的父级跳转。 需要自包含 RPM 镜像 leaf 时,使用 sow export rpm-leaf 生成独立产物。

Desired 与 Built

Managed 模式分别追踪意图与公共字节:

add / rm -> Desired Membership (revision)
                    |
                  build
                    v
             Built Generation -> pool/ + dists/

add 与 rm 默认构建受影响的 Dist。--skip 只记录成员变更,Repository 会保持 dirty; 随后用 sow build 把 Desired 收敛为新的 Built Generation。

  • sow status 低成本读取状态,并报告 ready_to_copy。
  • sow check 执行完整只读交付证明。dirty 或 recovering 状态不可交付。
  • sow changes [BASE_GENERATION] 描述某个已记录 Generation 到当前 Built Generation 的 物理差异。它是证据与计划输出,不能替代发布恢复或远端验证。
  • sow publish TARGET 通过配置的 Provider 发布已校验 Generation,并记录 target 级恢复与 checkpoint 状态。

事务与失败状态

写操作由 Workspace 或 Repository 锁串行化,并在公共变更前记录意图。包体与不可变元数据 先准备,可变协议指针最后更新。下一条 writer 会先恢复被中断操作,再开始新工作。

状态 含义
clean Desired 与 Built 一致
dirty Desired 已变化;Built 仍是上一个已提交 Generation
recovering 存在必须解决的非终态操作
error 持久证据冲突;SOW 拒绝猜测

用 status 诊断,用 check 做发布门禁。ready_to_copy=false 的 Repository 不应发布。

继续阅读

2 - 教程

端到端实操:从一组软件包文件开始,构建客户端可直接使用的已签名软件仓库。

这里的教程涵盖新建 Managed 工作区与迁移已有 Plain 仓库。请按顺序执行命令,并按你的环境 替换大写占位符与包路径。

如果还没装 SOW,先看安装与快速上手。

替换平面仓库的元数据生成工具,同时保留发布时间、签名,以及与已有元数据缓存客户端的兼容性。

托管 RPM 仓库:分架构视图、noarch 中性投影、debuginfo 过滤、版本数量上限,以及可用的 dnf 客户端配置。

托管 DEB 仓库:Debian 风格包池、by-hash 索引与 deb822 客户端配置。

生成专用 GPG 签名钥,为仓库元数据与 RPM 包签名,并配置客户端拒绝一切未签名内容。

用 Nginx 服务 Repository,并把已校验 Generation 发布到配置好的 filesystem target, 同时避免暴露工作区私有状态。

把 infra-pkg 已产出的双架构 RPM 与 DEB 组织成真实仓库,并演练本地安装、滚动更新、 Stable 晋升与月度快照。

先看哪篇

你的处境 从这里开始
已使用 createrepo_c 维护平面 RPM 目录 迁移现有 YUM 仓库
你要向 dnf 客户端分发 RPM 搭建 YUM 仓库
你要为 Debian 或 Ubuntu 分发 DEB 搭建 APT 仓库
需要已签名元数据或已签名 RPM 包体 仓库签名
树已经建好,但外部无法访问 对外服务
想把现有双架构包池变成可维护的 Infra 仓库 演练构建 pigsty-infra 仓库

YUM 与 APT 两篇是彼此独立的全新 Workspace 路径。实际使用中,如果它们适合共用同一所有权 边界,一个 Workspace 的同一 Repository 可以同时容纳 RPM 与 DEB Dist。

本板块约定

Shell 代码块里的命令不带 $ 提示符,方便整块复制。输出单独成块放在命令下方,只有一行时用注释 标注。需要你自行替换的值一律写成 大写。

每篇教程都有验证步骤。Managed 模式通过 sow check 返回 0,确认所选 Repository 完整且 与记录的 Generation 一致。Plain 模式按迁移指南验证元数据、签名和真实客户端行为; sow check 不用于检查平面目录。

2.1 - 搭建 YUM 仓库

创建托管 RPM 仓库,配置成员策略,对外服务并接入 dnf。

本教程从零创建一个 Managed RPM 仓库。你需要一个可写目录,以及一个或多个 RPM 文件。

1. 创建工作区

mkdir -p /srv/sow
cd /srv/sow
sow init .
sow repo new pigsty
sow dist new el9 --format rpm -r pigsty

Dist 名称由你定义。SOW 不会根据 el9 推断操作系统版本。

2. 配置成员策略

编辑生成的 sow.yml。下面的配置按包名与架构各保留一个版本,并排除调试包:

schema: sow/v3
architectures: [x86_64, aarch64]
repos:
  pigsty:
    dists:
      el9:
        format: rpm
        limit: 1
        exclude:
          - kind: [debuginfo, debugsource, llvmjit]
targets: {}

手工修改配置后,先校验再写仓库状态:

sow config check
sow config show --all

策略先执行 exclude,再执行 limit。noarch 包会投影到每个启用的架构视图,不能写进 architectures。

3. 添加 RPM

sow add /path/to/packages/*.rpm -r pigsty -d el9
sow status -r pigsty
sow check -r pigsty

add 解析包头,将每个接纳的包只保存一次,更新 Desired 成员关系并落成新 Generation。 被策略排除的输入会逐项报告,但不算命令失败。

公共树如下:

/srv/sow/pigsty/
├── pool/...
└── dists/el9/
    ├── x86_64/repodata/...
    └── aarch64/repodata/...

rpm-md 的 location href 通过相对路径访问根目录下的 pool/。不要只复制某个架构目录; 它不是独立仓库。

4. HTTP 预览

本地预览可以直接使用:

cd /srv/sow
python3 -m http.server --bind 127.0.0.1 8080

在另一个终端检查入口:

curl --fail http://127.0.0.1:8080/pigsty/dists/el9/x86_64/repodata/repomd.xml >/dev/null

长期服务应使用持续维护的 HTTP Server。它必须完整暴露 pigsty/ 树,确保客户端解析后落到 pigsty/pool/ 的软件包 URL 可访问。

5. 配置 dnf

将 REPO_HOST 替换为客户端可访问的地址:

# /etc/yum.repos.d/pigsty.repo
[pigsty-el9]
name=Pigsty EL9
baseurl=http://REPO_HOST:8080/pigsty/dists/el9/$basearch/
enabled=1
gpgcheck=0
repo_gpgcheck=0

刷新并查询仓库:

sudo dnf clean metadata
sudo dnf makecache --refresh
dnf --disablerepo='*' --enablerepo=pigsty-el9 list available

这里有意使用未签名配置。只有完成仓库签名后,才应打开客户端验签。

6. 发布或导出

交付前必须通过深度校验:

sow check -r pigsty

向已配置的 filesystem 或 R2 目标交付时,使用 sow publish。 只有先复制到离线 staging、再原子切换上线时,才适合整根复制;不要对在线仓库做无序原地同步。

默认 dnf reposync 等工具会拒绝指向根包池的父级相对路径。遇到这种消费者时,导出一份 自包含 RPM Leaf:

sow export rpm-leaf el9 x86_64 /srv/export/pigsty-el9-x86_64 -r pigsty

目标目录必须不存在或为空。默认会复制包体;--hardlink 只适用于同一文件系统、可信且只读的 显式优化场景。

更新仓库

sow add /path/to/new.rpm -r pigsty -d el9
sow rm PACKAGE_NAME -r pigsty -d el9
sow build -r pigsty
sow check -r pigsty

add 与 rm 修改 Desired 成员关系;策略或签名配置变化后用 build 收敛;发布门禁是 check,不能只看 status。

自动化客户端与平台覆盖见平台与集成。

2.2 - 迁移已有 YUM 仓库

将已有平面 RPM 仓库切换至 SOW,保留客户端缓存兼容性、软件包信任与发布时钟。

本指南适用于 RPM 与 repodata/ 放在同一层目录的平面仓库,例如此前用 createrepo_c 维护的仓库。它使用 Plain sow create,不创建 Managed 工作区。发布时间参数是 SOW 0.5.0 新增功能,请使用该版本或更新版本。

1. 明确迁移范围

在候选副本上操作,并完整保留旧软件包与元数据备份。可能重签的 RPM 不要用硬链接复制。 先确认真正使用的二进制:

SOW=/path/to/sow
"$SOW" version
"$SOW" create --help

帮助中必须包含 --metadata-timestamp;正式版 0.4.0 不接受该参数。

检查项 需要明确的选择
软件包布局 Plain 只读取顶层普通 RPM;嵌套目录需要单独规划迁移
客户端功能 Plain 生成 primary/filelists/other XML,不生成 SQLite 数据库或模块流
包信任 保留合法上游签名,或明确换成自己的密钥
索引信任 客户端启用 repo_gpgcheck=1 时,单独签署并验证新 repomd.xml
已有客户端 保留旧缓存,使用原来的 URL 和 repo ID 验收

在存在模块过滤的环境中,为普通 RPM 保留适当的 module_hotfixes=1 策略。这不会恢复 dnf module install 所需的模块流或 profile。

2. 保留发布时间历史

EL7 YUM 可能拒收 data 最大时间早于缓存的索引。检查当前 repomd.xml 与可信旧副本中的 <data><timestamp>,取客户端可能见过的最大值。如果在线索引此前已被写成零,只看这个 零值无法恢复历史。revision 和文件 mtime 不能替代该记录。

维护脚本应在同一个仓库锁内操作,并在上传目录外保存历史最大发布时间。下面的 +1 是区分新内容的 发布策略;EL7 本身也接受相等时间。真正产生新一代内容时,选择:

下一次时间 = max(当前 Unix 秒,
                 当前索引最大时间 + 1,
                 已保存的历史最大时间 + 1,
                 可信历史下限 + 1)

在切换公开索引之前持久保存该值。发布失败可以消耗一个时间值,跳号没有问题。恢复旧内容,或把 同一公开 URL 对应的本地目录移到新位置时,也必须保留这份时钟。

内容不变时,维护脚本可以在检查完整元数据内容身份、引用文件校验和以及签名后,复用旧索引与 分离签名。零时间或已经倒退的时间仍需修复。每次直接调用 create 都传入当前时间,会让索引 每次变化,从而失去空跑不写文件的效果。

3. 构建并签署候选

下面演示一次重建。先准备独立的 /srv/yum.candidate 副本,将 HISTORICAL_MAX 设为从当前 索引、持久时钟和可信历史中核实的最大值,将 SIGN_KEY 设为元数据签名密钥的完整指纹。 示例用 Python 3 选择严格递增的时间:

set -euo pipefail
: "${SOW:?Set the path to the tested SOW binary}"
: "${HISTORICAL_MAX:?Set the verified historical maximum Unix second}"
: "${SIGN_KEY:?Set the full metadata signing fingerprint}"
PUBLISH_TIME=$(python3 -c 'import sys,time; h=int(sys.argv[1]); assert 0 <= h < 253402300799; print(max(int(time.time()), h+1))' "$HISTORICAL_MAX")

"$SOW" create /srv/yum.candidate --metadata-timestamp "$PUBLISH_TIME"
gpg --batch --yes --armor --detach-sign --local-user "$SIGN_KEY" \
  --output /srv/yum.candidate/repodata/repomd.xml.asc \
  /srv/yum.candidate/repodata/repomd.xml
gpg --verify /srv/yum.candidate/repodata/repomd.xml.asc \
  /srv/yum.candidate/repodata/repomd.xml

这段命令只生成候选,不会保存持久时钟或发布目录。切换线上内容前,还要由维护系统持久记录 PUBLISH_TIME。验签应绑定预期公钥,不能只判断大信任库里是否存在某把可用公钥。

--sign-with KEY 只为未签名 RPM 补签;已经签名的包会保留原字节,包括其他供应商签署的包。 如果策略要求每个 RPM 都由自己的密钥签署,应逐包验证目标密钥,并只在候选中重签不满足要求的 包。--overwrite 是显式的批量替代方案:它重签所有保留 RPM,改变包字节,因此必须根据最终 包字节重建索引。最后一次 create 完成后,始终重新生成对应的索引分离签名。

4. 切换前验收

对候选与预期服务端点检查以下事项:

  1. 三类 XML 与 RPM 集合对应,引用文件校验和正确。若只改变发布时间,RPM 与压缩 XML 应保持 逐字节一致。
  2. 每个软件包与索引签名都满足预期客户端信任策略。
  3. 包字节、选项和时间戳相同时,重复构建元数据应为空操作;此项检查不要强制重签包。维护脚本还应 保留旧的有效分离签名,避免重新签名。
  4. 带旧缓存的客户端接受新索引,并实际下载、验证软件包。这个测试保留原 URL、repo ID 与缓存。
  5. 第二次真实内容更新使用更晚的时间,同一客户端仍能接受。需要确认安装行为时,在可丢弃主机上 使用生产中的确切客户端补做安装测试。

对已有签名仓库,验收全程保留 gpgcheck=1 与 repo_gpgcheck=1。清缓存或关闭验签,会绕过 本来需要测试的行为。与旧 createrepo_c 比较时,应核对依赖含义,而非要求 XML 逐字节一致; 相关规则见平台与集成。

5. 发布与恢复资料保留

Plain 会替换多个文件,不提供整个目录的原子切换。发布层需要协调包文件、索引、分离签名以及 缓存的可见顺序。仍可能被缓存客户端请求的旧元数据文件应继续保留;直接运行 Plain 重建,可能 删除目录中不再引用的 SOW checksum 命名元数据。

保留原始备份与持久时钟。回滚软件包内容时,把它当成更晚的一次发布重新建库、签名。只恢复旧 repomd.xml、却搭配已经改变的 RPM,会重新引入校验和或时间戳问题。

Managed 的 sow check 不用于审计 Plain 目录。这里的验收依据是上述检查与真实 YUM/DNF 客户端。精确命令契约见 sow create,两层签名的区别见 仓库签名。

2.3 - 搭建 APT 仓库

创建带 by-hash 索引的托管 DEB 仓库,并配置 APT 客户端。

本教程从零创建一个 Managed DEB 仓库。你需要一个可写目录,以及一个或多个 DEB 文件。

1. 创建工作区

mkdir -p /srv/sow
cd /srv/sow
sow init .
sow repo new pigsty
sow dist new trixie --format deb -r pigsty

Dist 名称会成为 APT Suite。它由你定义;SOW 不会根据 trixie 推断发行版语义。

2. 配置成员策略

如果需要过滤或限制版本,编辑生成的 sow.yml:

schema: sow/v3
architectures: [x86_64, aarch64]
repos:
  pigsty:
    dists:
      trixie:
        format: deb
        limit: 1
        exclude:
          - kind: [dbgsym, dbg]
targets: {}

然后校验:

sow config check
sow config show --all

配置中保存规范架构名,渲染时使用 Debian 生态名称:x86_64 对应 amd64,aarch64 对应 arm64;中立架构 all 包会进入两个视图。

3. 添加 DEB

sow add /path/to/packages/*.deb -r pigsty -d trixie
sow status -r pigsty
sow check -r pigsty

接纳的包体只保存一次。公共树如下:

/srv/sow/pigsty/
├── pool/...
└── dists/trixie/
    ├── Release
    └── main/
        ├── binary-amd64/
        │   ├── Packages
        │   ├── Packages.gz
        │   └── by-hash/SHA256/...
        └── binary-arm64/...

pool/ 下的路径按规范化源码包名分组;Packages 中的 Filename 相对 Archive Root; SOW 会写入 SHA-256 by-hash 副本,并在 Release 中声明。

4. HTTP 预览

本地预览可以直接使用:

cd /srv/sow
python3 -m http.server --bind 127.0.0.1 8080

检查协议入口:

curl --fail http://127.0.0.1:8080/pigsty/dists/trixie/Release >/dev/null
curl --fail http://127.0.0.1:8080/pigsty/dists/trixie/main/binary-amd64/Packages.gz >/dev/null

长期服务应使用持续维护的 HTTP Server,并完整暴露 pigsty/ 树。

5. 配置 APT

将 REPO_HOST 替换为客户端可访问的地址。未签名测试仓库可使用显式信任的 deb822 配置:

# /etc/apt/sources.list.d/pigsty.sources
Types: deb
URIs: http://REPO_HOST:8080/pigsty
Suites: trixie
Components: main
Architectures: amd64
Trusted: yes

刷新并查询:

sudo apt update
apt-cache policy

Trusted: yes 会关闭真实性校验,只适合受控测试。签名仓库应删除该行并配置 Keyring:

Types: deb
URIs: https://repo.example.com/pigsty
Suites: trixie
Components: main
Architectures: amd64
Signed-By: /usr/share/keyrings/pigsty-archive-keyring.gpg

打开 Signed-By 前,请先完成仓库签名。

6. 安全发布

交付前必须通过深度校验:

sow check -r pigsty

向已配置的 filesystem 或 R2 目标交付时,使用 sow publish。 如果使用其他传输方式,应把完整仓库复制到离线 staging,再原子切换上线。不要逐文件更新在线 dists/ 树,否则客户端可能同时看到不同 Generation 的元数据与包体。

更新仓库

sow add /path/to/new.deb -r pigsty -d trixie
sow rm PACKAGE_NAME -r pigsty -d trixie
sow build -r pigsty
sow check -r pigsty

策略或签名配置变化后用 build 收敛;发布门禁是 check,不能只看 status。

自动化客户端与平台覆盖见平台与集成。

2.4 - 仓库签名

签署 RPM 与 APT 元数据,可选签署 RPM 包体,并启用客户端验签。

SOW 提供两条互相独立的签名路径:

路径 产物 客户端开关
RPM 元数据 repodata/repomd.xml.asc repo_gpgcheck=1
APT 元数据 InRelease 与 Release.gpg Signed-By
RPM 包体 RPM 内嵌签名 gpgcheck=1

APT 通过签名的 Release 信任包哈希;SOW 不重签 DEB 包体。先配置元数据签名;只有当你负责 这些 RPM 字节的签名策略时,再启用包体签名。

1. 创建专用密钥

下面生成一把无口令的示例密钥。生产环境应使用受保护密钥并配置 passphrase 引用,详见 配置参考。

SIGNING_UID='SOW Repository <repo@example.com>'
gpg --batch --pinentry-mode loopback --passphrase '' \
  --quick-generate-key "$SIGNING_UID" rsa3072 sign 2y

FPR="$(gpg --batch --with-colons --list-secret-keys "$SIGNING_UID" \
  | awk -F: '$1 == "fpr" {print $10; exit}')"
test -n "$FPR"

sudo install -d -m 0700 /srv/sow-secrets
sudo chown "$(id -u):$(id -g)" /srv/sow-secrets
gpg --batch --pinentry-mode loopback --passphrase '' --armor \
  --export-secret-keys "$FPR" > /srv/sow-secrets/repo-signing.asc
gpg --armor --export "$FPR" > /srv/sow-secrets/repo-signing.pub
chmod 600 /srv/sow-secrets/repo-signing.asc

私钥必须放在 Workspace 公共 Repository 树与所有 Web Root 之外。若 SOW 由专用服务账户运行, 目录 owner 应是该账户,而不是交互用户。只向客户端分发 repo-signing.pub。

2. 配置元数据签名

在 /srv/sow/sow.yml 的 Repository 下添加所需配置;未使用的包生态可以省略:

repos:
  pigsty:
    signing:
      rpm:
        metadata:
          key: file:///srv/sow-secrets/repo-signing.asc
      deb:
        metadata:
          key: file:///srv/sow-secrets/repo-signing.asc
    dists:
      # 保留已有 Dist 定义

解析密钥引用、重建并执行发布门禁:

cd /srv/sow
sow config check
sow build -r pigsty
sow check -r pigsty

受口令保护的密钥可在 key 旁增加 passphrase: env://SOW_METADATA_PASSPHRASE,或使用 有界文件引用。SOW 不会把密钥或口令内容写入配置、SQLite、JSON 或日志。

3. 手工验证元数据

按实际 Dist 与架构调整路径:

gpg --verify \
  pigsty/dists/el9/x86_64/repodata/repomd.xml.asc \
  pigsty/dists/el9/x86_64/repodata/repomd.xml

gpg --verify pigsty/dists/trixie/InRelease
gpg --verify \
  pigsty/dists/trixie/Release.gpg \
  pigsty/dists/trixie/Release

sow check 会在深度一致性校验中检查配置的签名身份。建立客户端信任根时,仍应手工验证一次。

4. 可选:签署 RPM 包体

只有客户端要求内嵌包签名时,才添加 rpm.packages:

repos:
  pigsty:
    signing:
      rpm:
        packages:
          mode: fill
          key: agent://REPLACE_WITH_THE_FINGERPRINT
        metadata:
          key: file:///srv/sow-secrets/repo-signing.asc

将占位符替换为 $FPR 中的 40 位十六进制指纹。该操作要求:

  • 安装 rpm 与 gpg;
  • 匹配的私钥存在于 rpm 使用的环境 GPG Keyring 中;
  • fill 保留已经由配置 key 或 trusted_keys 签好的包;
  • always 重签所有未由配置身份签好的包;
  • never 保持输入字节不变。

SOW 只会对私有 staged 副本调用 rpm --addsign 或 rpm --resign,不会修改输入文件。 这些规则适用于新导入的包;build 不会重签已有的不可变包对象。在启用 fill 或更换 key 前,确认 所有已有 RPM 都由配置 key 或受信任身份签署。只检查 signature_key 是否为空还不够:已有的第三方签名也可能不受新策略信任。若确实信任该签名身份,可将其 公钥加入 trusted_keys;否则先在仓库外准备正确签名的包,使用新的包版本与文件名导入,或从 原始包重建新仓库。准备完成前保留旧策略。策略拒绝会保留已有 Desired 状态。

确认已有包兼容后,再校验并构建:

sow config check
sow build -r pigsty
sow check -r pigsty

用 rpmkeys --checksig /path/to/package.rpm 检查结果。

5. 启用 dnf 验签

通过可信通道把公钥传到客户端:

sudo install -m 0644 /path/to/repo-signing.pub /etc/pki/rpm-gpg/RPM-GPG-KEY-pigsty
sudo rpm --import /etc/pki/rpm-gpg/RPM-GPG-KEY-pigsty

再打开与你实际签名范围对应的检查:

[pigsty-el9]
name=Pigsty EL9
baseurl=https://repo.example.com/pigsty/dists/el9/$basearch/
enabled=1
repo_gpgcheck=1
gpgcheck=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-pigsty

没有配置包体签名时,将 gpgcheck 设为 0;既然已经配置元数据签名,就不要关闭 repo_gpgcheck。

6. 启用 APT 验签

将公钥安装为独立 Keyring:

sudo gpg --dearmor --yes \
  --output /usr/share/keyrings/pigsty-archive-keyring.gpg /path/to/repo-signing.pub

在 deb822 配置中引用它,且不要设置 Trusted: yes:

Types: deb
URIs: https://repo.example.com/pigsty
Suites: trixie
Components: main
Architectures: amd64
Signed-By: /usr/share/keyrings/pigsty-archive-keyring.gpg

执行 apt update。任何签名错误都应视为部署失败,不能靠削弱客户端配置绕过。

Plain 模式 RPM 签名

Plain 模式可以签署 RPM 包体,但不会签署仓库元数据,也不会生成 APT Release:

sow create /srv/flat --sign-with 0123456789ABCDEF

key 去掉可选的 0x 或 0X 前缀后,必须是恰好 16、40 或 64 位十六进制字符;SOW 会去掉 前缀并规范化为大写。匹配私钥必须能被环境中的 rpm/GPG 使用。不带 --overwrite 时,已有签名的 RPM 保持字节不变;带上该参数则显式重签 所有 RPM。SOW 先签署私有 staged 副本,再替换包体与元数据。

已有签名并不等于由 --sign-with 指定的密钥签署。如果要求全部 RPM 都使用自己的密钥, 应逐包验证,在候选副本上选择性重签不匹配的包;也可以明确使用 --overwrite 重签全部包。

已有在线 YUM 仓库还应保留发布时间。SOW 0.5.0 新增 --metadata-timestamp;最后一次 create 完成后,发布前应单独签署并验证新的 repomd.xml。包签名与索引签名是两项独立 检查。完整顺序及带缓存客户端验收见 YUM 迁移指南。

更换密钥

在 Managed 模式中,改变 key 引用或解析出的指纹会让相关 Dist 变为 dirty。元数据 key 可以先分发新公钥,再重建、 校验并切换客户端。RPM 包 key 必须分阶段轮换:Package Object 不可变,build 遇到不满足新策略的 既有 RPM 会拒绝,而不是原地重签。旧软件包坐标尚未下架或由新 Release 替代前,应使用 fill 并把旧公钥保留在 trusted_keys。最后在目标环境做真实客户端验收。

最后应使用生产中的确切 dnf/APT 版本与信任策略验收签名仓库。自动化覆盖见 平台与集成。

2.5 - 服务与发布仓库

用 Nginx 服务公共 Repository,并把已校验 Generation 发布到 filesystem target。

SOW 只生成静态文件,不是 HTTP 服务器。本指南把可写 Workspace 与 Nginx 服务路径分开。

公共与私有路径

模式 公共单元 绝不能服务
Plain 传给 sow create 的目录 临时 .sow-plain-stage-* 输出;没有持久 journal
Managed 一个 Repository 的完整 pool/ + dists/ 树 Workspace sow.yml、.sow/、SQLite、锁、日志与 staging

第一个工作区里的源 Repository 是 /srv/sow/local。不要把 /srv/sow 设为 document root。

1. 校验源 Generation

cd /srv/sow
sow build -r local
sow check -r local

只有 check 返回 0 才继续。status 适合诊断;check 才是完整只读交付证明。

2. 配置 filesystem target

先创建 endpoint 目录。它必须是真实、规范目录,不能是 symlink;SOW 不会替你创建缺失 endpoint。

sudo install -d -m 0755 /srv/repo-public
sudo chown "$(id -u):$(id -g)" /srv/repo-public

第二条命令把写权限交给当前操作者;若 sow publish 由专用服务账户执行,应改为该账户。

在 /srv/sow/sow.yml 中增加 target:

targets:
  public:
    repository: local
    provider: filesystem
    endpoint: file:///srv/repo-public
    prefix: local
    public_endpoint: file:///srv/repo-public/local/
    max_cache_ttl: 0s
    authoritative_workspace: true
    single_writer: true
    exclusive_write_authority: true

三个布尔字段都是必填安全确认。endpoint 与 prefix 合并为 /srv/repo-public/local; SOW 会在预先存在的 endpoint 下创建并拥有该 prefix。

校验并发布:

sow config check
sow publish public

发布先复制不可变包体和元数据,再更新可变协议指针,随后校验结果并记录 target checkpoint。 同一 Generation 重复发布是幂等空操作。

不要让其他工具写入同一 target prefix。target 契约是单 writer、独占写入。

3. 用 Nginx 服务 target

server {
    listen 80;
    server_name repo.example.com;

    root /srv/repo-public;
    autoindex off;

    location / {
        try_files $uri $uri/ =404;
    }

    location ~ (^|/)\. {
        deny all;
    }
}

校验配置后 reload Nginx。客户端 URL 为:

DNF baseurl: http://repo.example.com/local/dists/el9/x86_64/
APT source:  deb http://repo.example.com/local bookworm main

如果元数据或包体已签名,请单独发布对应公钥并配置 gpgkey/Signed-By;私钥绝不能放在 document root 下。

4. 验证服务入口

curl --fail --head \
  http://repo.example.com/local/dists/el9/x86_64/repodata/repomd.xml

curl --fail --head \
  http://repo.example.com/local/dists/bookworm/Release

curl --fail --head \
  http://repo.example.com/local/dists/bookworm/main/binary-amd64/Packages.gz

随后从客户端主机运行真实包管理器。HTTP 可达不等于客户端已验证,两层都要检查。

完整 Repository prefix 必须使用同一访问策略。RPM 元数据可能通过 ../../../pool/... 解析包路径,APT Filename 也直接指向 pool/...。只保护 dists/ 而误放开或拦截 pool/ 都会破坏仓库。

手工与隔离交付

如果 sow publish 无法到达目标:

  1. 在源端运行 sow check;
  2. 把完整 Repository 复制到新的、非 live staging/release 目录;
  3. 用 sow changes 0 或 archive manifest 校验传输哈希;
  4. 原子切换操作者拥有的父级引用到新目录;
  5. 保留上一版,直到客户端与缓存越过它。

不要直接对 live Repository root 执行无序 rsync --delete。它不保留 SOW 的指针顺序、 target checkpoint、缓存 grace 或恢复状态。sow changes 描述 Generation 差异,不代表可以 绕过这些控制直接修改 live target。

R2 target

provider: r2 使用 S3 兼容存储传输与只报告的 target GC。传输集成会针对固定的 PGSTY Silo fixture 验证 list、HEAD、GET 与单段条件 PUT;不覆盖条件式 Multipart 完成,也不能替代真实 R2 验收。启用生产目标前,请先在非生产 prefix 验证凭据、bucket policy、公共 endpoint、缓存行为、重放与恢复。详见平台与集成。

下一步

2.6 - 演练构建 pigsty-infra 仓库

把既有的双架构 RPM 与 DEB 包池组织成 infra 仓库,完成本地安装验收、滚动更新、Stable 晋升与月度快照。

pgsty/infra-pkg 是 Pigsty Infra 软件包的上游构建源码。 本教程假设双架构 RPM 与 DEB 已经构建完成,只处理后半程:从一堆包开始,用 SOW 建成真正可消费、可维护的 infra 仓库。

1. 把包集中到 ~/repo

本教程固定使用 ~/repo,不再为每条路径定义环境变量。先把已有包复制进两个输入目录:

mkdir -p ~/repo/packages/rpm ~/repo/packages/deb
cp ~/pgsty/infra-pkg/dist/rpm/*.rpm ~/repo/packages/rpm/
cp ~/pgsty/infra-pkg/dist/deb/*.deb ~/repo/packages/deb/

先确认四个“格式 × 架构”象限都有真实包体:

find ~/repo/packages/rpm -maxdepth 1 -type f -name '*.x86_64.rpm' | wc -l
find ~/repo/packages/rpm -maxdepth 1 -type f -name '*.aarch64.rpm' | wc -l
find ~/repo/packages/deb -maxdepth 1 -type f -name '*_amd64.deb' | wc -l
find ~/repo/packages/deb -maxdepth 1 -type f -name '*_arm64.deb' | wc -l

四个结果都必须大于零。此时目录只有输入包池:

~/repo/
└── packages/
    ├── rpm/                         # x86_64 + aarch64 RPM
    └── deb/                         # amd64 + arm64 DEB

2. 创建 infra Repository 与两个 Dist

初始化 Workspace,并创建名为 infra 的 Repository:

sow init ~/repo
cd ~/repo
sow repo new infra
sow dist new rpm --format rpm -r infra
sow dist new deb --format deb -r infra

现在模型已经确定:

Repository: infra
├── Dist: rpm    format=rpm    policy=latest
└── Dist: deb    format=deb    policy=latest

打开 ~/repo/sow.yml,把配置整理为:

schema: sow/v3
architectures: [x86_64, aarch64]
repos:
  infra:
    dists:
      rpm:
        format: rpm
        limit: 1
      deb:
        format: deb
        limit: 1

limit: 1 按“包名 + 原生架构”只保留最新一个版本。因此 rpm 与 deb 就是两个滚动更新的 latest channel;它们仍会同时保留 x86-64 与 ARM64 两个架构。

sow config check
sow config show --all -r infra

3. 一次性导入并构建

先更新 Desired Membership,最后只构建一次:

cd ~/repo
sow add ~/repo/packages/rpm --recursive -r infra -d rpm --skip
sow add ~/repo/packages/deb --recursive -r infra -d deb --skip
sow build -r infra -d rpm -d deb
sow check -r infra

sow check 返回 0,才算初始化完成。再核对 SOW 从包头读出的真实格式与架构:

sow ls -r infra -d rpm -d deb --json |
  jq -r '.result.packages | group_by(.format + "/" + .canonical_arch)[] |
    "\(.[0].format)\t\(.[0].canonical_arch)\t\(length) packages"'

预期至少出现:

deb     aarch64   ... packages
deb     x86_64    ... packages
rpm     aarch64   ... packages
rpm     x86_64    ... packages

SOW 使用规范化架构名,因此 DEB 的 amd64/arm64 在这里显示为 x86_64/aarch64。

4. 看懂生成的目录

打印实际目录:

find ~/repo -maxdepth 6 -type d | LC_ALL=C sort

关键结构应当是:

~/repo/
├── sow.yml                            # 配置,不对外服务
├── .sow/                              # 数据库、锁、恢复状态,不对外服务
├── packages/                          # 原始输入包池,可自行归档
│   ├── rpm/
│   └── deb/
└── infra/                             # 完整的公开 Repository Root
    ├── pool/                          # RPM 与 DEB 共享的单副本包池
    └── dists/
        ├── rpm/
        │   ├── x86_64/repodata/
        │   └── aarch64/repodata/
        └── deb/
            ├── Release
            └── main/
                ├── binary-amd64/
                └── binary-arm64/

这里有一个容易混淆、但必须记住的路径规则:SOW 的 Dist 固定放在 dists/ 下。因此逻辑上的 infra/rpm 与 infra/deb,真实视图路径分别是 /infra/dists/rpm/ 和 /infra/dists/deb/; 包体则统一放在 /infra/pool/。发布或挂载时必须使用完整的 ~/repo/infra,不能只拿走某个 Dist。

5. 用 Nginx 只读服务仓库

使用官方 nginx:alpine 镜像,把 Repository Root 只读挂载到 /infra:

docker network create --internal infra-lab
docker run --detach \
  --name infra-nginx \
  --network infra-lab \
  --publish 8080:80 \
  --volume "$HOME/repo/infra:/usr/share/nginx/html/infra:ro" \
  nginx:alpine

直接检查两种索引入口:

curl -fsS http://127.0.0.1:8080/infra/dists/rpm/x86_64/repodata/repomd.xml | head
curl -fsS http://127.0.0.1:8080/infra/dists/deb/Release | head

Nginx 只看得到 ~/repo/infra,既看不到 sow.yml 与 .sow/,也无法修改仓库。

6. 在 EL9 中只用 infra 安装 RPM

下面的 Rocky Linux 9 容器位于 --internal 网络中。脚本先删除所有预置仓库,再只启用刚创建的 infra,因此成功安装不能依赖公网软件源。

docker run --rm --interactive --network infra-lab rockylinux:9 bash -s <<'ROCKY'
set -euxo pipefail

rm -f /etc/yum.repos.d/*.repo
cat >/etc/yum.repos.d/infra.repo <<'REPO'
[infra]
name=Pigsty Infra RPM
baseurl=http://infra-nginx/infra/dists/rpm/$basearch/
enabled=1
gpgcheck=0
repo_gpgcheck=0
REPO

dnf clean all
dnf --disablerepo='*' --enablerepo=infra makecache
dnf --disablerepo='*' --enablerepo=infra install -y pg-exporter
rpm -q --qf '%{NAME}\t%{VERSION}-%{RELEASE}\t%{ARCH}\n' pg-exporter
command -v pg_exporter
ROCKY

RPM 的 baseurl 必须落到具体架构视图。$basearch 会由 dnf 展开为 x86_64 或 aarch64。

7. 在 Ubuntu 24.04 中只用 infra 安装 DEB

APT 的 URI 指向 Repository Root,Suites 才是 Dist 名 deb:

docker run --rm --interactive --network infra-lab ubuntu:24.04 bash -s <<'UBUNTU'
set -euxo pipefail

rm -f /etc/apt/sources.list
rm -f /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources
cat >/etc/apt/sources.list.d/infra.sources <<'SOURCE'
Types: deb
URIs: http://infra-nginx/infra
Suites: deb
Components: main
Trusted: yes
SOURCE

apt-get clean
apt-get update
apt-get install -y --no-install-recommends pg-exporter
dpkg-query -W -f='${Package}\t${Version}\t${Architecture}\n' pg-exporter
command -v pg_exporter
UBUNTU

本教程使用隔离 HTTP 仓库,所以临时关闭了验签。正式服务应配置 RPM/APT 元数据签名,并移除 gpgcheck=0 与 Trusted: yes。

Docker 默认验证宿主机架构。若要完成四格运行矩阵,分别给两条 docker run 增加 --platform linux/amd64 与 --platform linux/arm64 后各跑一次;跨架构运行需要 Docker 的 binfmt/QEMU 支持。仓库清单检查与客户端安装检查是两个独立门禁。

8. 日常维护:添加一个新版本

更新仓库的正常动作是 add,不是先删除旧包。假设已经拿到新版 pg-exporter 的四个包体:

cp ~/pgsty/infra-pkg/dist/rpm/pg-exporter-*.rpm ~/repo/packages/rpm/
cp ~/pgsty/infra-pkg/dist/deb/pg-exporter_*.deb ~/repo/packages/deb/

cd ~/repo
sow add ~/repo/packages/rpm/pg-exporter-*.rpm -r infra -d rpm --skip
sow add ~/repo/packages/deb/pg-exporter_*.deb -r infra -d deb --skip
sow build -r infra -d rpm -d deb
sow check -r infra

因为 latest Dist 配了 limit: 1,新版本胜出后,旧版本会自动退出该 Dist 的 Desired Membership。 旧字节不会被立即删除,也不会因为以后放宽策略而自动回来。

可以重新运行第 6、7 节的客户端,先 makecache/update,再安装或升级,完成更新验收。

删除只用于硬订正

正常发布不要先 sow rm。如果某个错误包必须紧急撤回,先用 sow ls -r infra -d rpm --json 或对应的 -d deb 找到精确 SHA-256, 再依次执行 sow rm sha256:... -r infra -d rpm --check 与不带 --check 的同一命令。 rm 只删除 Dist Membership,pool 字节仍由保守的 sow gc 独立回收;不要用裸包名误删所有版本与架构。

9. 两层保留策略:latest 与 stable

rpm、deb 的 limit: 1 适合持续滚动,但不能表达“保留所有正式发布历史”。为此再创建两个 Dist:

cd ~/repo
sow dist new rpm-stable --format rpm -r infra
sow dist new deb-stable --format deb -r infra
sow config show --all -r infra -d rpm-stable -d deb-stable

新 Dist 的默认 limit 是 0,表示保留所有版本。最终策略是:

Dist 格式 limit 角色
rpm RPM 1 RPM latest
deb DEB 1 DEB latest
rpm-stable RPM 0 累积所有已晋升 RPM
deb-stable DEB 0 累积所有已晋升 DEB

注意:stable 不是把“包池里的所有历史”自动放回来,而是从现在开始,累积每次明确晋升的版本。

10. 把 latest 晋升到 stable

SOW 没有独立的 promote 命令。当前可靠做法是先冻结写入并导出源 Dist 的精确 Membership, 再把这些对象加入目标 Dist。输入直接取自 infra/pool;SOW 会校验并复用已有 Package Object, 不会重新打包,也不会在 pool 中复制第二份包体。

先确保源状态干净,并保存本次晋升清单:

cd ~/repo
sow check -r infra
mkdir -p ~/repo/manifests

sow ls -r infra -d rpm --json |
  jq -r '.result.packages[].pool_path' > ~/repo/manifests/rpm-latest-202608.list
sow ls -r infra -d deb --json |
  jq -r '.result.packages[].pool_path' > ~/repo/manifests/deb-latest-202608.list

在晋升结束前暂停对 rpm 与 deb 的写入,然后复用这些 pool 对象:

cd ~/repo
(
  set -e
  while IFS= read -r pool_path; do
    sow add "$HOME/repo/infra/$pool_path" -r infra -d rpm-stable --skip
  done < ~/repo/manifests/rpm-latest-202608.list

  while IFS= read -r pool_path; do
    sow add "$HOME/repo/infra/$pool_path" -r infra -d deb-stable --skip
  done < ~/repo/manifests/deb-latest-202608.list

  sow build -r infra -d rpm-stable -d deb-stable
  sow check -r infra
)

每次 add 应报告 reused。若中途失败,源 Dist 不受影响;修复问题后对同一清单重跑即可。 随着以后重复晋升,rpm/deb 仍只保留最新版本,而 rpm-stable/deb-stable 会逐次累积历史版本。

11. 从 stable 创建 2026-08 快照

客户端可见的月度快照也是两个新的 Dist:

cd ~/repo
sow dist new rpm-202608 --format rpm -r infra
sow dist new deb-202608 --format deb -r infra
sow config show --all -r infra -d rpm-202608 -d deb-202608

在快照窗口内暂停 stable 写入,先把其精确 Membership 固化为清单:

sow check -r infra
sow ls -r infra -d rpm-stable --json |
  jq -r '.result.packages[].pool_path' > ~/repo/manifests/rpm-stable-202608.list
sow ls -r infra -d deb-stable --json |
  jq -r '.result.packages[].pool_path' > ~/repo/manifests/deb-stable-202608.list

再把清单加入对应快照 Dist:

cd ~/repo
(
  set -e
  while IFS= read -r pool_path; do
    sow add "$HOME/repo/infra/$pool_path" -r infra -d rpm-202608 --skip
  done < ~/repo/manifests/rpm-stable-202608.list

  while IFS= read -r pool_path; do
    sow add "$HOME/repo/infra/$pool_path" -r infra -d deb-202608 --skip
  done < ~/repo/manifests/deb-stable-202608.list

  sow build -r infra -d rpm-202608 -d deb-202608
  sow check -r infra
)

把这次已验证的完整 Repository Generation 也加入保留集合,防止后续 GC 把它当作不可达历史处理:

sow retain add "$(sow status -r infra --json | jq -r '.result.built_generation')" -r infra
sow retain ls -r infra

retain 保留的是整个 Repository Generation,用于恢复与 GC 安全根;客户端可见的固定 URL 仍由 rpm-202608 与 deb-202608 两个 Dist 提供。SOW 尚不强制快照 Dist 只读,因而创建后不再对它们 执行 add 或 rm 是维护规约的一部分。

12. 客户端地址总表

同一个 Nginx 与同一份 infra/pool 支撑所有 channel:

Channel dnf baseurl APT URIs / Suites
latest http://infra-nginx/infra/dists/rpm/$basearch/ http://infra-nginx/infra / deb
stable http://infra-nginx/infra/dists/rpm-stable/$basearch/ http://infra-nginx/infra / deb-stable
2026-08 http://infra-nginx/infra/dists/rpm-202608/$basearch/ http://infra-nginx/infra / deb-202608

最终验收:

cd ~/repo
sow dist ls -r infra
sow status -r infra
sow check -r infra

到这里,我们得到的不是一次性演示目录,而是一个可以继续收包、晋升与做月度快照的真实 Infra Repository:rpm/deb 负责快速更新,rpm-stable/deb-stable 负责积累正式历史,月度 Dist 提供固定入口, 所有视图复用同一份不可变包体。

实验结束后可停止临时服务:

docker rm --force infra-nginx
docker network rm infra-lab

3 - 功能

Plain/Managed 仓库生成、包池、策略、签名、事务、发布与审计。

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 见平台与集成。

3.1 - Plain 平面仓库

sow create 的单遍扫描、覆盖重建契约,以及确定性输出与 Pigsty 完成标记。

sow create 接手一个已经放着 .rpm / .deb 的目录,在包旁边生成平面仓库索引。Plain 模式没有工作区、配置文件、数据库、期望状态,也没有操作 journal。包目录就是权威事实来源,所有索引都是当前目录内容的可丢弃投影。

这个边界是刻意的:Managed 仓库保存状态并恢复事务;Plain 仓库失败了就便宜地重建。一次运行失败或被中断后,重新执行同一条命令,覆盖派生元数据即可。

契约

Plain 模式由四条规则定义:

  1. 包是权威事实。 默认 create 不修改包字节,只替换 repodata/、Packages 与 Packages.gz。--pigsty 和显式 RPM 签名是文档明确列出的例外。
  2. 包内容只扫一遍。 默认未签名路径中,每个选中包只打开一次、完整计算一次 SHA-256,并在同一遍里解析。完整 RPM/DEB 元数据保留给渲染阶段;渲染与输出校验不会再次打开包体。
  3. 收尾只做一次便宜校验。 发布前重新列出顶层包集合,把 stat 事实与扫描快照比较,不再计算第二遍包 SHA-256。
  4. 失败就重建。 Plain 没有事务 journal、pre-image、前滚或回滚。失败可能留下部分已替换的派生元数据;下一次 sow create 丢弃自有临时残留,按当前包目录完整重建。

输入字节与参数(包括元数据时间戳)均不变时,输出仍然确定,重复运行报告 noop=true。

单遍流水线

--jobs 默认等于逻辑 CPU 数,控制唯一一次包内容扫描:

锁定目录
  -> 列出并排序顶层 RPM/DEB
  -> 并行打开 + SHA-256 + 解析(每包一次)
  -> 处理坐标冲突与 Pigsty 过滤
  -> 从保留的解析事实渲染 RPM/DEB 元数据
  -> 只校验生成的元数据
  -> 重新列目录并比较包 stat 快照
  -> 覆盖派生输出;--pigsty 最后写 repo_complete

worker 完成先后不会影响结果:解析事实始终按规范 basename / 索引顺序消费。RPM XML 直接使用 worker 保留下来的完整解析对象;DEB Packages 直接使用保留的 control 段落与该 worker 已经算出的 SHA-256。

输出自校验仍会读取生成的 XML、repomd.xml、Packages 与 Packages.gz。这些是很小的派生元数据,不会再次读取包体。

最终 stat 校验保证什么

收尾校验要求:

  • 顶层普通 .rpm / .deb basename 的排序集合完全相同;
  • 文件 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:

/srv/repo/
├── pev2-1.23.0-1.noarch.rpm
├── xray_26.2.6-1_amd64.deb
├── Packages
├── Packages.gz
└── repodata/
    ├── <sha256>-primary.xml.gz
    ├── <sha256>-filelists.xml.gz
    ├── <sha256>-other.xml.gz
    └── repomd.xml

平面位置全部是相对路径:RPM 使用裸 basename,DEB 使用 ./<basename>。公开目录固定 0755,生成文件与 repo_complete 固定 0644,不受 umask 影响。

某种包格式消失时,SOW 删除该格式已知的派生输出。重跑也会覆盖中断留下的半套输出,例如只有一个 Packages;新一代不再引用的 SOW checksum 形状 RPM 元数据会被移除,未知文件保持不动。

确定性与 no-op

repomd.xml 的 revision 固定为 0,data 时间戳默认为 0。从 0.5 开始,可以用 --metadata-timestamp SECONDS 显式指定 data 时间。gzip header 仍固定,排序仍规范化,因此 同一包集合与参数生成逐字节一致的元数据。发布前 SOW 会比较 stage 与 live 元数据;如果无需 清理/签名且所有输出已经相同,就只删私有 stage,不替换公开 inode,并返回 noop=true。

只改变时间戳时,变化仅在 repomd.xml,不影响 RPM 字节、压缩 XML、包时间或 DEB 输出。 调用者负责保存发布历史和签署索引。迁移已有在线 YUM 仓库时,请按 迁移指南处理,避免时间倒退导致带缓存的 EL7 客户端拒收; 已有正数时间戳的索引不适合直接替换成默认的 0。

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 而被删除。

发布顺序为:

stage + 校验
  -> 最终 stat 校验
  -> 撤下旧 repo_complete
  -> 安装显式请求签名后的 RPM(如有)
  -> 安装 RPM 与 DEB 元数据
  -> 删除命中清理规则的包
  -> 最后写 repo_complete

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 工作区。

继续阅读

3.2 - Managed 工作区

工作区 → 仓库 → Dist 三层模型、固定磁盘布局、sow.yml 如何驱动一切,以及发现与选择规则。

当同一个仓库要维护好几个月 —— 包成批到达、由策略决定谁留下、事后还得说清楚什么时候变了什么 —— 你需要的是 Managed 模式。本页讲三层模型、它产出的布局,以及命令怎么判断你说的是哪个仓库、哪个 Dist。

三个层级

Workspace 工作区                    发现与配置边界
└── Repository 仓库                 所有权边界:pool、dists、SQLite、锁、Generation
    └── Dist 发行版                 单一格式的具名成员集合
        └── Architecture View 架构视图   渲染投影 —— 不是成员关系

每层只做一件事,边界很硬:

工作区(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 路径从不由用户输入拼装,而是由解析后的真实工作区根、已校验的名称和固定相对片段推导出来 —— 这就是为什么符号链接替换和路径逃逸没有可攻击的面。

<workspace>/
├── sow.yml                       # 唯一的配置文件
├── .sow/                         # 私有状态;绝不要对外服务
│   ├── workspace.lock
│   ├── workspace-ops/            # 工作区生命周期 journal
│   ├── repo-locks/<repo>.lock
│   ├── <repo>.db                 # 每个仓库一个 SQLite
│   └── <repo>/
│       ├── stage/                # 同文件系统的 staging 区
│       ├── recovery/             # 删除动作的原子移入目标
│       ├── pending/              # --skip 的耐久 payload
│       ├── retained/             # 保留代的私有根
│       └── transitions/          # 布局迁移的恢复状态
└── <repo>/                       # 可对外服务的树
    ├── pool/                     # 不可变包字节
    └── dists/
        └── <dist>/               # 架构视图渲染在这里

执行过两次 dist new 与两次 add 之后的真实工作区:

$ find .sow | sort
.sow
.sow/pigsty
.sow/pigsty.db
.sow/pigsty.db-shm
.sow/pigsty.db-wal
.sow/pigsty/pending
.sow/pigsty/recovery
.sow/pigsty/retained
.sow/pigsty/stage
.sow/pigsty/transitions
.sow/repo-locks
.sow/repo-locks/pigsty.lock
.sow/workspace-ops
.sow/workspace.lock

<repo>/ 下是公共交付树:可以直接服务、由 SOW 发布,或整根复制到离线 staging 后原子切换。 .sow/ 下全部是私有状态,绝不能暴露;详见对外服务。

名称必须匹配 [a-z0-9][a-z0-9._-]*;.、..、.sow、pool、dists 以及工作区保留名一律拒绝。

状态数据库与软件包事实

私有 SQLite 数据库按 package_sha256 索引 Desired/Built Membership,因此查询与构建可以 一次批量展开完整成员投影,而不是为每个软件包单独查询。数据库还保存以不可变包体 SHA-256 为键、可重建的软件包事实缓存。Ingest 从已认证快照解析一次软件包事实;快照安装到 pending、 再提升到 Pool 时,为保证恢复正确性,各阶段仍独立验证内容,整个 add 并不是只读一遍包体。生产构建只按本次 选中的 Digest,以有界、确定性批次读取 Facts Row,遇到缺失或损坏记录时再从已认证包体惰性 重建。无关或超大的 Facts Row 不会被读取。

对于未改变的 Pool 文件,暖构建使用设备号、inode、size、mtime 与 ctime 指纹避免重新读取 包体。指纹漂移与 Facts 缺失共享一次权威 SHA-256 校验并自动修复; sow check 仍是显式的完整密码学审计,并且无论指纹是否匹配,都会 对每个唯一物理包体哈希一次。

缓存与指纹只属于私有实现状态,不改变公共 pool/ + dists/ 布局。不要手工编辑数据库或 PRAGMA user_version。v0.3 或 v0.4 Repository 必须先备份,再通过 sow repo migrate 显式升级,之后才能执行普通 0.5 读写。Schema v13 为包池路径归属增加索引,使 add 只查询候选路径,不再扫描无关发布历史。 首次构建刷新受影响的 RPM 认证与 APT 元数据契约,之后未变化的包可复用匹配的 Built 认证证据。 其他维护只在 SOW 诊断明确指出时执行。

sow.yml 驱动一切

只有一个配置文件,用严格 decoder 解析。未知字段不会被忽略——它会失败。重复的规范化架构、非法名称或 format、Dist 架构不是工作区许可表的子集、非法 glob 或分类、不完整的 signing 块,同样失败。

schema: sow/v3
architectures:
  - x86_64
  - aarch64
repos:
  pigsty:
    signing:
      rpm:
        packages:
          mode: never
    dists:
      el9:
        format: rpm
      trixie:
        format: deb
targets:
  local:
    repository: pigsty
    provider: filesystem
    endpoint: file:///srv/mirror
    prefix: pigsty
    public_endpoint: file:///srv/mirror/pigsty/
    max_cache_ttl: 0s
    authoritative_workspace: true
    single_writer: true
    exclusive_write_authority: true

config show --all 展开全部默认值与规范化别名,让你看到 SOW 实际的决定:

$ sow config show --all
schema: sow/v3
architectures:
  - x86_64
  - aarch64
repos:
  pigsty:
    protected: false
    signing:
      rpm:
        packages:
          mode: never
    dists:
      el9:
        format: rpm
        architectures:
          - x86_64
          - aarch64
        limit: 0
        exclude: []
      trixie:
        format: deb
        architectures:
          - x86_64
          - aarch64
        limit: 0
        exclude: []

架构别名只在解析边界规范化一次: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 会不会被拒。

$ sow config check
configuration valid: /data/ws repositories=1 dists=2

完整 schema(包括 filesystem 与 r2 发布目标)见 sow.yml 配置参考。

发现:哪个工作区?

Managed 命令按以下顺序寻找最近祖先中的 sow.yml:

  1. 给了 -C/--workdir DIR:从 DIR 向上找,并跳过当前目录候选。
  2. 否则从当前目录向上找。
  3. 仍未找到:从 $SOW_DIR 向上找;显式 -C 查找失败后也保留这条回退。
  4. 还是没有:失败,并提示 sow init、--workdir 与 SOW_DIR。

找到第一个 sow.yml 就停,不会越过它继续往上找"更好的那个"。 查找沿传入路径的字面祖先进行,找到后再解析并绑定真实工作区根。符号链接起点可以使用, 但其字面祖先未必等于目标的物理祖先;别名引起选择歧义时,直接指定物理根。 sow config check 报告解析后的工作区,而不只是发现起点。

--workdir 不是 chdir。它只改变发现的起点。sow add 里的相对 PATH 仍然相对你真实的当前目录解析 —— 这正是 sow add ./build/*.rpm -C /srv/ws 应有的行为。

sow create 不参与上述任何一步。

选择:哪个仓库、哪个 Dist?

Repository 选择,按序:

  1. 显式 -r/--repo NAME。
  2. 命令起始目录位于 <workspace>/<repo>/ 内。
  3. 工作区只有一个 Repository。
  4. 否则失败并列出候选。

Dist 选择,按序:

  1. 一个或多个显式 -d/--dist NAME(可重复)。
  2. 起始目录位于 <workspace>/<repo>/dists/<dist>/ 内。
  3. 选定 Repository 只有一个 Dist。
  4. 否则失败并列出候选。

关键的不对称在这里:没给 -d 时,build、check、status 默认作用于选定 Repository 的 全部 Dist —— 对这几个命令而言,“没有过滤条件"解释成"全都要"是安全的。而 add、rm、ls 必须得到明确的 Dist 集合,因为猜一个包该落到哪里并不安全:

$ sow ls
workspace discovery error: managed: workspace discovery error: repository "pigsty" has multiple Dists (el9, trixie); select one or more with --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 状态使用的架构族则失败。
$ sow init .
initialized /data/ws: config_created=false repositories_initialized=0 dists_initialized=0

第三、第四条规则的意义在于: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 仓库

repos:
  pigsty:
    protected: true

protected: true 会拒绝 repo rm,即使加了 -f,返回退出码 6。它不限制别的:add、rm、build 和常规 Dist 维护照常。真要删这个 Repository,你必须先改 sow.yml、通过 config check,然后才能删 —— 这个摩擦正是该 flag 存在的意义。

继续阅读

3.3 - 包池与元数据视图

一份软件包、一个属主、纯元数据 APT/RPM 视图:正典包池寻址、中性包、搬迁与显式 reposync 导出。

不变式

在一个 Repository 内,每个 live Package Object 在 pool/ 下只有一条正典 payload 路径。 Dist 与架构 view 拥有元数据,不拥有包体 alias:

<repo>/pool/...                              正典包体
<repo>/dists/<rpm-dist>/<arch>/repodata/... 纯 RPM 元数据
<repo>/dists/<deb-dist>/main/binary-*/...   纯 APT 元数据

相同 digest 出现在另一个 Repository 或发布 prefix 时,仍是另一个 owner 下的独立对象。 SOW 不会为了本地去重而制造共享的分布式所有权。

构建完成的 Repository

一个包含一份 x86_64 包与一份 noarch 包的 RPM Dist 形如:

demo/
├── pool/
│   ├── c/centos-release/centos-release-6-0.el6.centos.5.x86_64.rpm
│   └── e/epel-release/epel-release-7-5.noarch.rpm
└── dists/el9/
    ├── aarch64/repodata/
    │   ├── <sha256>-primary.xml.gz
    │   ├── <sha256>-filelists.xml.gz
    │   ├── <sha256>-other.xml.gz
    │   └── repomd.xml
    └── x86_64/repodata/
        ├── <sha256>-primary.xml.gz
        ├── <sha256>-filelists.xml.gz
        ├── <sha256>-other.xml.gz
        └── repomd.xml

不存在 dists/.../pool/ 子树。仍被保留的 live Generation 可以让多组内容寻址元数据并存; repomd.xml 指针决定当前生效的是哪一组。

RPM view 使用计算出的父级相对 href

rpm-md 相对架构 view 解析 <location href>。SOW 从实际 view 计算回到正典 Pool 的路径:

<location href="../../../pool/c/centos-release/centos-release-6-0.el6.centos.5.x86_64.rpm"/>
<location href="../../../pool/e/epel-release/epel-release-7-5.noarch.rpm"/>

深度从实际 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:

Filename: pool/p/postgresql-18/libpq5_18.3-1_amd64.deb

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 发布根之外创建导出:

sow export rpm-leaf el9 x86_64 /srv/exports/el9-x86_64

导出拥有自己的包体树、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/ 下列一次包体,随后是元数据与指针:

add  payload   pool/c/centos-release/centos-release-6-0.el6.centos.5.x86_64.rpm  ...
add  payload   pool/e/epel-release/epel-release-7-5.noarch.rpm                  ...
add  metadata  dists/el9/x86_64/repodata/<sha256>-primary.xml.gz               ...
add  pointer   dists/el9/x86_64/repodata/repomd.xml                            ...

dists/ 下不会出现 package payload 变更项。

继续阅读

3.4 - 成员策略

exclude 与 limit 如何决定哪些包留在 Dist 里:规则字段、glob 匹配、版本排序,以及放宽策略为什么永远不会复活已移出的成员。

策略回答的是这个问题:“我把整个构建目录倒进了这个 Dist,但我不想要 debuginfo 包,而且每个包只留最新版本。“两条规则完成这件事,它们按固定顺序执行,并且作用于 完整候选集,而不是你这次恰好 add 的那几个包。

两条规则与它们的顺序

候选集  →  exclude  →  limit  →  期望成员集(Desired Membership)

exclude 丢掉命中规则的包,limit 再按包名与架构限制存活的版本数。顺序固定且不可配置 —— 反过来的话,一个即将被排除的包会在离场路上白白占掉一个版本名额。

两条规则在每次 add、每次 rm 和每次 build 时都强制执行。最后这条很关键:在 sow.yml 里改 limit 或 exclude 会让受影响的 Dist 变 dirty,下一次 build 就把新策略重新施加到现有成员集上。想让收紧后的策略生效,你不需要重新 add 任何东西。

dists:
  el9:
    format: rpm
    limit: 1
    exclude:
      - kind: [debuginfo, debugsource, llvmjit]

exclude

exclude 是一个规则列表。同一条规则内,各字段之间是 AND;同一字段内,多个 pattern 之间是 OR;规则与规则之间是 OR —— 任一规则命中即排除。字段顺序和规则顺序都不影响结果。

exclude:
  - kind: [debuginfo, debugsource, dbgsym, dbg, llvmjit]
  - name: ["test-*", "*-experimental"]
    arch: [aarch64]

这段读作:不分架构地丢掉所有 debug 类包,并且 丢掉名字以 test- 开头或以 -experimental 结尾的 aarch64 包。

允许五个字段:

字段 匹配对象
name 二进制包名
source 规范化后的 source 名
arch x86_64、aarch64 或 neutral
kind 下表固定枚举
format rpm 或 deb

pattern 是区分大小写的精确字符串或 shell glob(*、?、[])。没有正则,没有版本比较,没有取反,也没有表达式语言。未知字段、空规则和非法 glob 会在 config check 时失败,而不是静默地什么都匹配不到。但取值不会按 kind 枚举校验:枚举之外的 kind(例如 debug)能通过校验,却什么都不匹配。

kind 由二进制包名推导,优先取最具体的后缀:

格式 名称后缀 kind
RPM -debuginfo debuginfo
RPM -debugsource debugsource
RPM -llvmjit llvmjit
DEB -dbgsym dbgsym
DEB -dbg dbg
任意 以上均不匹配 main

分类结果只来自包本身,不依赖文件所在目录,也不依赖当前主机,所以同一份输入永远分到同一类。sow show --json 会输出算出来的 kind。

被排除的包会被如实报告,不算解析失败,也不会被存下来:

$ sow add pkg/blackbox_exporter-0.28.0-1.x86_64.rpm pkg/pev2-1.23.0-1.noarch.rpm -r demo -d el9
add repository=demo operation=7877233225745514469 accepted=1 failed=0 memberships=+1/-0 revision=3 generation=3 dirty=false
item input="pkg/blackbox_exporter-0.28.0-1.x86_64.rpm" status=excluded format=rpm coordinate="blackbox_exporter-0:0.28.0-1.x86_64" sha256:5759c643… dists=el9:excluded
item input="pkg/pev2-1.23.0-1.noarch.rpm" status=accepted format=rpm coordinate="pev2-0:1.23.0-1.noarch" sha256:d06d7f23… dists=el9:accepted

命令退出码是 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 版本之间做决定:

$ sow add pkg/libpq5_18.4-1.bookworm_amd64.deb pkg/libpq5_18.4-1.trixie_amd64.deb -r demo -d trixielim
add repository=demo operation=2402398619981505515 accepted=1 failed=0 memberships=+1/-0 revision=4 generation=4 dirty=false
item input="pkg/libpq5_18.4-1.bookworm_amd64.deb" status=excluded format=deb coordinate="libpq5=3:18.4-1.bookworm:amd64" sha256:be8a2863… dists=trixielim:limited
item input="pkg/libpq5_18.4-1.trixie_amd64.deb" status=accepted format=deb coordinate="libpq5=3:18.4-1.trixie:amd64" sha256:0a7df397… dists=trixielim:accepted

注意这里有两级报告:条目的整体 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 的例子,把胜出的那个版本删掉:

$ sow rm 'deb:libpq5=3:18.4-1.trixie:amd64' -r demo -d trixielim
$ sow ls -d trixielim
repository=demo dists=trixielim dirty=false
SHA256	COORDINATE	DISTS	BUILT_DISTS	POOL_PATH

Dist 空了。bookworm 那个构建没有回来 —— 尽管它的字节还躺在 pool 里,尽管 limit: 1 此刻明明空出了一个名额。

原因在于 exclude 与 limit 移除的是 真实的期望成员。SOW 不维护一份"被策略压下、将来也许还能回来的候选"影子清单。Pool 字节是存储,不是候选集。因此提高 limit 或放宽 exclude 只是给未来的添加腾出空间;它不会回头翻历史,猜哪些你曾经拥有过的包该重新出现。

想让它回来,就再显式 add 一次:

$ sow add pkg/libpq5_18.4-1.bookworm_amd64.deb -r demo -d trixielim
add repository=demo operation=590501245267266669 accepted=1 failed=0 memberships=+1/-0 revision=6 generation=6 dirty=false
item input="pkg/libpq5_18.4-1.bookworm_amd64.deb" status=accepted format=deb coordinate="libpq5=3:18.4-1.bookworm:amd64" sha256:be8a2863… dists=trixielim:accepted

收敛是单向的,而且这是写进不变式的:收紧策略可以移除成员,放宽策略永远不会恢复成员。 正是这种不对称让 build 在任何时刻都能安全执行。假如它是对称的,那么编辑 sow.yml 就可能静默地重新发布一个你刻意下架的包 —— 而这恰恰是安全更新场景里最不能出的事故。

真正下架一个包

sow rm 移除的是成员关系,不是 pool 字节。包会从所有索引中消失,客户端不再能通过仓库 解析它。只有当包体不再被当前、保留、恢复、发布以及活动维护操作等任何安全根引用时, 才运行 sow gc。 已发布目标使用 sow gc TARGET;filesystem 删除是条件式的,R2 只生成报告。 不要绕过 SOW 状态手工删除规范包池文件。

预览一次决策

sow rm -c 计算将要移除的成员、策略后果,以及此刻 build 会产生的文件变化,但什么都不写:

sow rm patroni -r pgsql -d el9 -c

-c/--check 不取写锁,并且与 --skip 互斥。同时给出 --timeout 或 --no-wait 属于用法错误 —— 免得有人误以为一次预览会去等待写事务。

继续阅读

3.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 参数,也没有办法让这次构建和下次构建签得不一样。这是刻意的 —— 仓库的签名身份是仓库的属性,不是"碰巧更新了它的那条命令"的属性。

配置

repos:
  pigsty:
    signing:
      rpm:
        packages:
          mode: never              # never | fill | always
        metadata:
          key: "file:///secure/repo-signing.asc"
      deb:
        metadata:
          key: "file:///secure/repo-signing.asc"

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。

0.5.0 暂不支持 RPM v6 包格式的签名;包体签名与验签工作流请使用 RPM v4 格式包。 这是 RPM 容器格式的限制,不限制下面的元数据密钥引用形态。

四种密钥引用形态

密钥引用是一个 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 之前就告诉你。

Agent 元数据签名按身份与确定性发布时间探签一次小消息,检查 GPG 实际选中的子钥,再固定 完整指纹完成签名,因此支持一台机器只持有证书中部分签名子钥的部署。GPG 时钟被冻结, 同一操作的多个 Dist 复用选钥结果。新构建要求实际签名钥匙在发布时间和当前时间都有效; 历史验签及已冻结树的恢复继续使用留存证书与原字节。

新 APT 元数据使用 SHA-256 签名。创建新的发布尝试时,InRelease 与 Release.gpg 都必须使用 SHA-256、SHA-384 或 SHA-512。历史 SHA-1 签名仍可验证;升级后执行 sow build 刷新旧 APT 元数据,再开始新发布。已经冻结的发布尝试仍按原计划恢复。

RPM 包体签名

signing:
  rpm:
    packages:
      mode: fill
      key: agent://7F721C4AD40F4A9D8CA578BFAC7E4690B50CCF3B
      trusted_keys: [keys/pgdg.asc]

三种模式:

模式 行为
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 签两次会得到不同字节。SOW 对不可变的 header 与 payload(排除签名头)计算 signature-neutral payload digest。逻辑坐标与该摘要相同, 且既有对象满足当前策略时,才复用最终字节。重复 add 只有在满足复用条件时才是空操作;策略 变化导致重签时,可能产生不同字节并触发包池路径冲突。此时应使用新的包版本和文件名,不能 覆盖已经发布的 URL。

同一命令内,渲染复用 RPM 签名预检结果;跨命令时,未变化的包字节与完全一致的签名策略可 复用已认证的 Built 证据。策略、证书、文件指纹变化或证据缺失时重新验证。认证契约升级后, 首次构建会建立一次证据;sow check 仍是显式全量审计。常规目录/stat 检查与元数据生成仍然 需要与所选仓库范围对应的工作量。

这种复用的口子刻意开得很窄。never 模式要求完整字节一致,因为该模式承诺保留输入字节。如果 payload digest 不同,或既有对象不满足当前签名策略,那就是硬冲突 —— add 不会悄悄地在既有坐标上就地重签一个包。这里没有 --replace;如果重签导致字节变化,请提高 release,或专门规划一次密钥轮换流程。

更换密钥会让 Dist 变 dirty

一个 Dist 的 Built 配置摘要覆盖它的 format、canonical 架构、limit、exclude,以及 已冻结的签名身份。改动密钥引用或 fingerprint 会改变这个摘要,于是所有受影响的 Dist 变 dirty:

$ sow status
repository=pigsty status=dirty ready_to_copy=false revision=5 generation=4 dirty_dists=el9,trixie pending=0/0 locked=false

更换 元数据 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 的多个证书版本可以共存 —— 所以延长有效期或增加子钥,不会让已经发布出去的东西失效。

fill 或 always 模式下的包签名信任更严格。增加或轮换签名子钥不会影响已有包签名;但延长主钥 有效期并重新导出后会产生更新的自签名,SOW 0.5.0 随即拒绝在该自签名之前签署的包,而且 trusted_keys 不能同时容纳同一证书的两个版本。用于包签名时,建议主钥不设过期时间,改为轮换 签名子钥。

Plain 模式

sow create /srv/repo --sign-with 6D5C5A26C36B1F73
sow create /srv/repo --sign-with 6D5C5A26C36B1F73 --overwrite

Plain 模式只签 RPM 包体,没有元数据签名。KEY 去掉可选的 0x 或 0X 前缀后,必须是恰好 16、40 或 64 位十六进制 GPG key ID/fingerprint;去掉前缀并规范化为大写后作为 _gpg_name macro 传给 rpm。不带 --overwrite 时只签没有可解析嵌入签名的 RPM;带上则对全部保留的 RPM 重签。

--sign-with 要求 --pigsty 清理后至少保留一个顶层 RPM。纯 DEB 目录、缺少 rpm 可执行文件、密钥不可用,都在任何公开变更之前失败。签名是显式慢路径,必然包含复制、签名验证与最终 RPM 解析读取;中断后按当前包目录重跑,而不是重放 Plain journal。见 Plain 平面仓库。

客户端验证什么

[pigsty-el9]
name=Pigsty EL9
baseurl=https://repo.example.com/pigsty/dists/el9/$basearch/
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://repo.example.com/keys/repo-signing.asc
Types: deb
URIs: https://repo.example.com/pigsty
Suites: trixie
Components: main
Signed-By: /etc/apt/keyrings/repo-signing.asc

repo_gpgcheck=1 让 dnf 验证 repomd.xml.asc;gpgcheck=1 让它验证每个包的嵌入签名。 APT 侧的 Signed-By 让 apt 验证 InRelease。自动化检查会直接校验生成的签名;完整的签名 Managed dnf/APT 验收必须在目标环境中使用真实客户端执行。确切证据见 平台与集成。

sow check 在常规运行中就会校验全部已声明的签名与文件哈希,所以签名配置出错会在发货之前暴露,而不是在客户机器上暴露。

继续阅读

3.6 - 事务与恢复

Managed 模式的操作日志、两级锁模型、固定提交顺序与证据驱动崩溃恢复。

本页说明 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 → applied → built → done
                       └──────────────→ done_dirty
   任一非终态 → recovering → built / done / done_dirty / rolled_back / failed
   apply 前出错 → failed
状态 已耐久的东西
planned 命令、参数、目标与预期动作
staged 新包与元数据已写入私有 staging 区并校验
applied 期望状态与所需私有 pending payload 已提交;公开树可能仍是旧一代
built 完整静态 Generation 已切换
done / done_dirty 终态;作为审计记录保留

sow log <OPERATION> 展示带时间戳的状态迁移:

"events":[
  {"sequence":0,"state":"planned","occurred_at":"2026-08-04T04:06:32.907704Z"},
  {"sequence":1,"state":"staged","occurred_at":"2026-08-04T04:06:33.067824Z"},
  {"sequence":2,"state":"applied","occurred_at":"2026-08-04T04:06:33.253073Z"},
  {"sequence":3,"state":"built","occurred_at":"2026-08-04T04:06:34.074916Z"},
  {"sequence":4,"state":"done","occurred_at":"2026-08-04T04:06:34.077441Z"}
]

--skip 使 Desired 领先于 Built 时以 done_dirty 收尾;若之后仓库仍是干净的(例如重复加入 已有成员),则以 done 收尾。恢复旧操作时还有一个有限出口:Desired 已提交、构建计划尚未冻结, 且原构建被以下之一阻止:可识别的签名策略拒绝、渲染开始后 sow.yml 的字节发生任何变化(哪怕 只改注释),或已知契约升级。SOW 校验已提交状态与包字节, 保留 Desired 和旧 Built 视图,并记录暂未构建的原因;必要时修正策略,再重跑 build。 普通 I/O、损坏或已冻结构建失败仍是恢复错误,不会被静默改为 done_dirty。

在 applied 之前失败的 Operation 会成为 failed。这里有一处契约上的微妙之处:add 必须在解析包之前先记录 planned Operation,所以一个架构不被许可的包确实会留下审计记录。但除了那条终态 failed 记录之外,什么都不会被写入 —— 没有包对象、没有成员关系、没有 pending 字节、没有公开树变化、没有 Generation。既留住了审计线索,又保证无效架构不会进入任何产品投影。

如果 Ctrl-C 在 applied 之前中断操作,SOW 先记录 failed,再在 5 秒预算内删除该操作的临时 包字节;剩余部分由下一条写命令(例如 sow build)删除,在此之前 sow check 报告 recovering。

锁模型

锁是本机的 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。

$ sow add ./build/*.rpm -r pgsql -d el9 -N
lock unavailable

在"宁可跳过这轮、也不要堆积"的 cron 作业里用 -N;在"排一小会儿队可以、但绝不能挂死"的 CI 里用 -T 30s。

提交顺序

每一代都按同样的四个阶段写入,而顺序正是不变式成立的原因:

payload  →  metadata  →  pointer  →  delete
  1. payload —— 规范包字节写入 pool/。此时还没有任何东西引用它们。
  2. metadata —— checksum 命名的 RPM 元数据、Packages、Packages.gz,以及 by-hash 索引副本。此时仍没有指针指向它们。
  3. pointer —— 客户端入口:RPM 的 repomd.xml(配置了签名则连同 .asc);Managed APT 则在每个架构的 direct 与 by-hash 索引都就位之后,才发布 Release(连同 InRelease 与 Release.gpg)。这一步就是提交。
  4. 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 status
repository=pigsty status=recovering ready_to_copy=false ...

sow build 是唯一的显式前滚恢复入口:它在收敛之前,会先尝试完成或回滚任何可判定的非终态 Operation。看到 recovering 时,执行 sow build 就是标准反应。

error 专留给 journal、数据库与文件证据互相矛盾、任何自动选择都不安全的情况。此时 build 拒绝覆盖,最后完成的视图继续服务,你应当从备份恢复,再跑 check 与 build。这里刻意没有 repair --force —— 一个可能猜错的修复,比一个拒绝执行的修复更糟。

fail-closed 的路径安全

Managed 路径从不由用户提供的字符串拼装。每次创建、rename 和删除都走同一套流程:

  1. 把工作区根解析为绝对真实路径;
  2. 用固定相对片段重新构造目标,并验证相对路径不含任何逃逸分量;
  3. 对路径上每个已存在的受控组件执行 Lstat,拒绝符号链接和非预期文件类型;
  4. 只删除已经先被原子移入 .sow/.../recovery 的对象;
  5. 删除前再次证明该 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 记录。

以上没有一条声称能抵御以同一用户身份运行、拥有无限权限的恶意进程。它抵御的是现实中的失败模式:崩溃、协作进程之间的竞态,以及在检查与使用之间形态发生变化的路径。

继续阅读

3.7 - 可观测与审计

正确使用 status、check、changes、retention 与操作日志,不混淆状态和证明。

每个读取表面回答不同问题。

命令 问题 写入?
status Repository 当前是什么状态? 否
check 所选 Repository 是否满足完整交付契约? 否
changes 两个 Built Generation 之间哪些物理文件不同? 否
log 记录了哪些操作与处置结果? 否
retain ls 哪些 Generation 是显式本地 GC root? 否

status:低成本状态

sow status -r local

它报告 Desired revision、Built Generation、dirty Dist、pending 包体计数、锁状态与 ready_to_copy。它不哈希公共树,不恢复操作,也不构建。

Repository 状态 含义
clean Desired 与 Built 一致
dirty Desired 已变化;公共树仍是上一份 Built Generation
recovering 存在持久非终态操作
error 持久证据冲突,自动恢复无法安全决策

用 status 诊断,不要把它当成 check 的替代品。

check:交付证明

sow check -r local

稳态下,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 差异

sow changes -r local
sow changes 0 -r local
sow changes 42 -r local --json
  • 不给 base:比较当前 Built Generation 与前一代;
  • base 0:描述完整当前公共树;
  • base N:给出已记录 Generation N 到当前 Built 的净差异。

每行包含操作、phase、Repository 相对路径、大小与 SHA-256。phase 使用与本地构建相同的 payload、metadata、pointer、delete 词汇。

changes 是 manifest/差异表面。它不连接目标、不持久化远端 checkpoint、不执行 cache grace, 也不恢复中断传输。配置好的 live target 应使用 sow publish TARGET。离线复制应先 stage 完整树、复验,再原子切换上线。

Generation 保留与 GC

sow retain add 42 -r local
sow retain ls -r local
sow retain rm 42 -r local
sow gc -r local

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 是两回事。

操作日志

sow log -r local
sow log OPERATION -r local
sow log export operations.jsonl -r local
sow log prune 2026-01-01 -r local

日志按操作记录 kind/state、时间、配置/manifest identity、包处置、成员变化,以及适用时的 物理 changeset。

log export 输出稳定 JSONL,拒绝覆盖既有文件,并校验输出路径。log prune 接受日期或 RFC 3339 时间,只移除符合条件的终态审计记录;不会删除当前状态、恢复证据或仍被需要的 Generation manifest。

操作模式

sow build -r local
sow check -r local
sow publish public

status 用于监控,check 用于门禁,publish 用于目标变更,log 用于事后证据。

延伸阅读

4 - 参考

配置 Schema、包引用、磁盘布局、退出码、JSON 输出、平台与集成。

这一部分记录配置字段、包引用、路径、退出码、JSON、平台与集成等稳定契约。CLI 语法和状态变化 见命令,使用模型见上手。

输出示例只说明形态;标识符、路径、哈希、时间戳与计数会随工作区变化。二进制自带的 sow help 始终是精确语法权威。

完整配置 schema:工作区、仓库、Dist、成员策略、签名与发布目标。

命令行上指代一个软件包的五种写法、歧义如何裁决,以及 rm / show / where 各自接受哪些形态。

Plain 与 Managed 两种模式下 SOW 创建的每一条路径、包池分组规则、名称约束, 以及绝对不能通过 HTTP 暴露的目录。

退出码 0–6 与中断码 130 分别代表什么。

sow.cli/v1 Envelope、各顶层字段含义与主要命令族的 Result 形态。

Release 目标、文件系统要求、仓库客户端检查、发布 Provider,以及各项自动化集成的确切范围。

约定

命令示例不带 $ 提示符,方便整块复制。输出块只代表结构,可变值与长结构会在标注处省略。 二进制自带的 sow help 始终是精确语法权威。

语法块中占位符用大写(NAME、DIR、PACKAGE),字面量用小写。方括号表示可选参数, ... 表示可重复,竖线分隔互斥项 —— 与 sow help 的写法一致。

4.1 - sow.yml 配置参考

工作区配置文件的全部字段、校验规则,以及一份完整可用的示例。

sow.yml 是 Managed 托管工作区(Workspace)唯一的配置文件。它位于工作区根目录, 声明存在哪些仓库(Repository)与 Dist,并保存每次构建都要执行的成员策略与签名设置。 Plain 平面模式(sow create)完全不读它。

本页列出解析器接受的每一个字段。没有列在这里的字段一律拒绝 —— 不存在未公开的 键,也没有为将来预留的键。

文件是怎么读的

SOW 用严格 YAML 解析器读取 sow.yml。具体表现是:

  • 未知字段是错误,不是警告。 把 repos: 写成 repositories: 会直接失败(退出码 2), 并指出出问题的行号。
  • 只允许一个 YAML 文档。 用 --- 引入第二个文档会报错。
  • 只接受普通文件。 sow.yml 是符号链接,或者大于 16 MiB,在解析前就被拒绝。
  • 默认值在解析时补齐,不写回磁盘。 想看完全展开后的形态,用 sow config show --all。

文件的一部分是机器维护的:sow init、sow repo new、sow repo rm、sow dist new、 sow dist rm 会作为各自事务的一部分原子改写 sow.yml。成员策略与签名则由你手工编辑 —— 没有对应的命令行参数。

任何手工修改之后,跑一次 sow config check。它解析文件、与每个已初始化仓库的 SQLite 状态交叉核对、并解析每一个签名 key 引用,全程只读:

sow config check
configuration valid: /srv/repo repositories=1 dists=2

根级字段

schema: sow/v3
architectures: [x86_64, aarch64]
repos:
  <name>: <repository>
targets:
  <name>: <publication-target>
字段 类型 必填 默认值 含义
schema string 是 — 必须恰好是 sow/v3,其他值一律是配置错误。
architectures 字符串列表 否 [x86_64, aarch64] 本工作区允许管理的 CPU 架构族。
repos map 否 空 仓库名到仓库配置的映射。
targets map 否 空 发布目标名到目标配置的映射。

schema 配置值必须恰好是 sow/v3。

architectures

这是 上限,不是目标。它声明 SOW 最多可以接纳哪些架构;各 Dist 默认继承整张表, 除非自己再收窄。

目前只支持两个规范族(canonical family):x86_64 与 aarch64。DEB 生态名作为输入别名 被接受,并在解析边界规范化:

你可以写 存储与展示为
x86_64、amd64 x86_64
aarch64、arm64 aarch64

所以 architectures: [amd64, arm64] 与 architectures: [x86_64, aarch64] 是同一份配置。 把同一族的两个别名都写上 —— [amd64, x86_64] —— 属于重复,会失败:

configuration error: load config "/srv/repo/sow.yml": workspace architectures: duplicate architecture "x86_64" after normalization

noarch(RPM)与 all(DEB)不是 这里的架构。它们是中性(neutral)包,构建时投影进 每个适用视图,解析器拒绝把它们写进这个列表。不支持的值(如 riscv64)立即失败:

configuration error: load config "/srv/repo/sow.yml": workspace architectures: unsupported architecture "riscv64"; supported canonical families are x86_64 and aarch64

这个列表可以整体省略,但不能写成空列表。

Repository 仓库

repos:
  pigsty:
    protected: true
    signing: { ... }
    dists: { ... }
字段 类型 必填 默认值 含义
protected bool 否 false 为真时 sow repo rm 拒绝删除该仓库,-f 也不行。
signing map 否 无 包体与元数据签名设置,见签名。
dists map 否 空 Dist 名到 Dist 配置的映射。

protected

protected: true 是防止误删整个仓库的闸门,它只拦一件事 —— 仓库删除:

operation rejected: managed: operation rejected: repository "pigsty" is protected

这是退出码 6。其余一切照常:add、rm、build、建/删 Dist 都不受影响。 要真的删掉一个 protected 仓库,先把 sow.yml 改成 protected: false, 用 sow config check 确认,再执行 sow repo rm。

名称约束

仓库名与 Dist 名共用一套文法:必须匹配 [a-z0-9][a-z0-9._-]* —— 小写字母、数字、 点、下划线、连字符,且以字母或数字开头。大写被拒绝,因为名称会变成目录名, 必须在大小写敏感的 Linux 与默认大小写不敏感的 macOS 文件系统上表现一致:

configuration error: load config "/srv/repo/sow.yml": repository name "Infra": name "Infra" must match [a-z0-9][a-z0-9._-]*

下列名称是保留名,一律拒绝:.、..、.sow、pool、dists、sow.yml、 workspace.lock、workspace-ops、repo-locks。两个会在状态目录里撞车的仓库名 (比如 db 与 db.db)也会被拒绝:

configuration error: load config "/srv/repo/sow.yml": repository names "db" and "db.db" collide at reserved state path "db.db"

原因见仓库布局。

Dist

    dists:
      el9:
        format: rpm
        architectures: [x86_64]
        limit: 1
        exclude:
          - kind: [debuginfo, debugsource, llvmjit]
字段 类型 必填 默认值 含义
format string 是 — rpm 或 deb。一个 Dist 只承载一种格式。
architectures 字符串列表 否 继承工作区列表 把该 Dist 收窄到工作区架构的一个子集。
limit integer 否 0 同一包名 + 架构最多保留几个版本;0 表示全留。
exclude 规则列表 否 空 把命中的包挡在该 Dist 之外的规则。

format

format 是 sow dist new 唯一从命令行接受的业务参数,并且创建之后不可更改 —— RPM Dist 永远不会变成 DEB Dist。格式不匹配的包根本不会成为该 Dist 的候选:

configuration error: load config "/srv/repo/sow.yml": repository "a" dist "d1" format must be rpm or deb, got "apk"

architectures

省略这个字段,Dist 继承工作区列表 —— 绝大多数情况下这就是你要的。 只有需要 收窄 时才声明:比如双架构工作区里,某个 el9 Dist 只做 x86。

列表必须是工作区列表的子集,且不能为空:

configuration error: load config "/srv/repo/sow.yml": repository "a" dist "d1" architecture "aarch64" is not allowed by workspace

在这里新增一个架构族会让该 Dist 变为待构建(dirty),下一次 sow build 渲染新视图。 移除一个仍被现有成员关系或已构建代引用的族,config check 与所有写命令都会拒绝。

limit

limit 限定该 Dist 中同一个包保留几个版本。分组键是 (二进制包名, 原生架构), 所以同一个包的 x86_64 与 aarch64 构建各自计数,noarch/all 包自成一组。

  • 0(默认)保留全部版本。
  • N > 0 保留最新的 N 个,RPM 按 EVR 比较,DEB 按 Debian version 规则比较。
  • 负数是配置错误:
configuration error: load config "/srv/repo/sow.yml": repository "a" dist "d1" policy: limit must be zero or positive, got -1

limit: 1 时,把旧版本和新版本一起加入,旧版本会被报告为 limited 且不建立成员关系:

item input=".../libpq5_18.2-1.pgdg12+1_amd64.deb" status=excluded format=deb coordinate="libpq5=18.2-1.pgdg12+1:amd64" sha256:310611d0... dists=trixie:limited
item input=".../libpq5_18.3-1.pgdg12+1_amd64.deb" status=accepted format=deb coordinate="libpq5=18.3-1.pgdg12+1:amd64" sha256:4b526223... dists=trixie:accepted

事后调大 limit 不会 复活曾被策略移出的版本。包体字节可能还留在包池里, 但成员关系已经没了;要拿回来就重新 add 一次。理由见成员策略。

exclude

exclude 是规则列表。每条规则是若干字段的集合:规则内字段之间是 AND, 同一字段的多个 pattern 之间是 OR,规则与规则之间是 OR。任一规则命中即排除。

exclude:
  - kind: [debuginfo, debugsource, dbgsym, dbg, llvmjit]
  - name: ["test-*", "*-experimental"]
    arch: [aarch64]

读作:丢掉所有 debug 类包;另外,丢掉名字以 test- 开头或以 -experimental 结尾的 aarch64 包。

允许五个字段:

字段 匹配对象
name 二进制包名
source 规范化后的 source 名(RPM 取 SOURCERPM,DEB 取 Source)
arch x86_64、aarch64 或 neutral
kind 见下表分类
format rpm 或 deb

kind 由二进制包名的后缀决定,取最具体的一个。配置中枚举之外的 kind 能通过校验,但什么都不匹配:

格式 名称后缀 kind
RPM -debuginfo debuginfo
RPM -debugsource debugsource
RPM -llvmjit llvmjit
DEB -dbgsym dbgsym
DEB -dbg dbg
任意 以上都不匹配 main

pattern 区分大小写,只有两种形态:精确字符串,或使用 *、?、[...] 的 shell glob。 不支持正则、版本比较、否定,也没有表达式语法。空规则、空或带首尾空白的 pattern、 同一字段内重复的 pattern、非法 glob,都是配置错误:

configuration error: load config "/srv/repo/sow.yml": repository "a" dist "d1" policy: exclude rule 0 is empty
configuration error: load config "/srv/repo/sow.yml": repository "a" dist "d1" policy: exclude rule 0 field name has invalid glob "[bad": syntax error in pattern

策略顺序固定:先 exclude,后 limit。被排除的包逐条报告,不算失败:

item input=".../blackbox_exporter-0.28.0-1.x86_64.rpm" status=excluded format=rpm coordinate="blackbox_exporter-0:0.28.0-1.x86_64" sha256:5759c643... dists=el9:excluded

签名

签名配置挂在仓库级(不是 Dist 级),覆盖两条互相独立的信任链:软件包本身, 以及客户端在信任其他一切之前先验证的仓库元数据。

    signing:
      rpm:
        packages:
          mode: fill
          key: env://SOW_RPM_PACKAGE_KEY
          trusted_keys: [keys/pgdg.asc]
        metadata:
          key: keys/repo-signing.asc
          passphrase: env://SOW_METADATA_PASSPHRASE
      deb:
        metadata:
          key: keys/repo-signing.asc
          passphrase: env://SOW_METADATA_PASSPHRASE

树形是固定的:signing.rpm 下有 packages 与 metadata;signing.deb 下 只有 metadata —— DEB 包体永远不会被重签,因为 APT 通过 Release 验证整个仓库, 而不是逐包签名。

rpm.packages

字段 类型 默认值 含义
mode string 无 key 时 never,有 key 时 fill never、fill 或 always。
key key 引用 无 当前签名身份;公钥用于验证,匹配私钥必须存在于 rpm 使用的 GPG 环境。mode 不是 never 时必填。
trusted_keys key 引用列表 空 fill 额外认可的公钥。

三种模式:

  • never —— 原样保存输入字节。包进来时带什么签名(或没有签名),客户端拿到的就是什么。
  • fill —— 对没有签名、或签名无法被 key 及 trusted_keys 验证的包补签; 已经能验证通过的包保持字节不变。
  • always —— 最终每个包都必须由 key 有效签名。已经由该 key 签好的保持字节, 其余一律重签。

有 key 时默认是 fill,因为它是唯一保留上游签名的模式。设成 fill 或 always 却不给 key 是错误:

configuration error: load config "/srv/repo/sow.yml": repository "a" signing: rpm packages mode "fill" requires key

trusted_keys 列出哪些公钥的签名被 fill 视为"已经合格"。key 的公钥部分自动受信, 不需要重复列出。同一个引用写两次是错误:

configuration error: load config "/srv/repo/sow.yml": repository "a" signing: duplicate rpm trusted key reference "keys/x.asc"

RPM 包签名是唯一会调用外部程序的操作:SOW 对 私有 stage 副本 调用环境里的 rpm --addsign / rpm --resign,永远不碰你的输入文件。私钥必须已经存在于 rpm 使用的 GPG 环境中。

rpm.metadata 与 deb.metadata

字段 类型 默认值 含义
key key 引用 无 给仓库元数据签名的私钥。
passphrase passphrase 引用 无 私钥有口令时使用。

配置 rpm.metadata.key,每个 RPM 架构视图会额外发布分离签名 repodata/repomd.xml.asc;配置 deb.metadata.key,每个 DEB Dist 会额外发布 clearsign 的 InRelease 与分离的 Release.gpg。没配 key 就不生成这些文件 —— repomd.xml 与 Release 则始终会写。

file:// 与 env:// 引用由 SOW 在 进程内 完成签名,不需要 gpg 可执行文件。 只有 agent:// 需要环境里有 gpg。

改变 key 引用或它背后的 fingerprint 会让相关 Dist 变 dirty —— 签名身份是每个 Dist 已构建配置摘要的一部分。下一次 sow build 重新签名并推进新的代。

key 引用文法

key 引用是下列四种写法之一:

形态 例子 说明
路径 keys/repo-signing.asc ASCII-armored key 文件。相对路径相对 工作区根目录 解析,不是当前目录。
file://<path> file:///secure/repo-signing.asc 与上一行等价,只是显式写出。绝对路径因此是三个斜杠。
env://<VAR> env://SOW_METADATA_KEY 环境变量里存的是 armored key 内容本身,不是路径。变量名须匹配 [A-Za-z_][A-Za-z0-9_]*。
agent://<fingerprint> agent://7F721C4AD40F...CF3B 委托给环境里的 gpg-agent。fingerprint 为 16、40 或 64 位十六进制,不区分大小写。

其他 scheme 一律拒绝:

configuration error: load config "/srv/repo/sow.yml": repository "a" signing: deb metadata key: unsupported key reference scheme in "https://example.com/key.asc"

引用分两阶段校验。文法 在解析时检查,失败退出码 2;引用 能否解析出真实密钥 由 sow config check 和每条写命令检查,失败退出码 6:

operation rejected: ... deb metadata key: key reference does not resolve to a bounded regular file
operation rejected: ... deb metadata key: environment key reference SOW_METADATA_KEY is unset
operation rejected: ... deb metadata key: gpg public-key export returned no bounded key material

私钥与口令不会输出。sow config show 不带 --all 也可解析并显示 key_fingerprint。 启用 RPM 包签名时,还会显示公钥证书快照摘要和规范化的受信任 key 身份;指纹数组与摘要 数组独立排序、去重,不能按下标配对。元数据签名条目示例如下:

    signing:
      deb:
        metadata:
          key: file:///srv/repo/keys/repo-signing.asc
          key_fingerprint: 7F721C4AD40F4A9D8CA578BFAC7E4690B50CCF3B

私钥与口令永远不会写进 sow.yml、SQLite、操作日志、JSON 输出或错误文本。

passphrase 引用

passphrase 接受与 key 引用相同的路径、file://、env:// 三种写法, 但 不接受 agent:// —— 口令是一个值,不是密钥句柄。

两条规则:

  • 有 passphrase 没有 key 是错误,因为它没有可解锁的对象:

    configuration error: ... repository "a" signing: deb metadata passphrase requires key
    
  • passphrase 与 agent:// key 同时出现是错误。私钥由 agent 持有并自行处理口令交互, 第二条口令通道只会被忽略:

    configuration error: ... repository "a" signing: rpm metadata agent key uses its ambient gpg-agent and cannot accept a passphrase reference
    

发布目标

每个目标把一个已配置 Repository 绑定到一个存储命名空间。目标名使用与仓库相同的小写文法。

targets:
  local:
    repository: pigsty
    provider: filesystem
    endpoint: file:///srv/mirror
    prefix: pigsty
    public_endpoint: file:///srv/mirror/pigsty/
    max_cache_ttl: 0s
    authoritative_workspace: true
    single_writer: true
    exclusive_write_authority: true

  prod:
    repository: pigsty
    provider: r2
    endpoint: https://0123456789abcdef.r2.cloudflarestorage.com
    region: auto
    bucket: packages
    prefix: pigsty
    credential: env://SOW_R2_CREDENTIAL
    public_endpoint: https://repo.example.com/pigsty/
    max_cache_ttl: 24h0m0s
    authoritative_workspace: true
    single_writer: true
    exclusive_write_authority: true
字段 必填 含义
repository 是 本目标拥有的现有 Repository。
provider 是 filesystem 或 r2。
endpoint 是 无尾斜杠的规范 file:///absolute/path,或 R2 的规范 https://host。
region R2 必须是 auto;filesystem 禁用。
bucket R2 小写规范 bucket 名;filesystem 禁用。
prefix 是 相对公共树前缀;空串表示存储命名空间根。
credential R2 env://NAME 或 file:///absolute/path;禁止内联秘密。
public_endpoint 是 以 / 结尾、用于公共内容/缺失校验的规范 URL。Filesystem 接受 https://、http:// 或 file://;R2 必须为 HTTP(S)。
max_cache_ttl 是 有界、规范的非负 Go Duration,包括显式 0s;溢出会被拒绝。
authoritative_workspace 是 必须为 true。
single_writer 是 必须为 true。
exclusive_write_authority 是 必须为 true。

三个 authority 布尔值是显式安全确认,不是默认值。同一存储上的目标前缀不能重叠; filesystem 目标也不能解析到重叠的有效路径。这些规则防止并发写者破坏条件式发布与 GC。

对 provider: filesystem,配置校验只检查 URL 形态与重叠关系。真正发布时,endpoint 目录 必须已经存在、不能是 symlink,并且必须解析为唯一规范真实目录。SOW 会在 endpoint 下创建 配置的 prefix,但不会创建 endpoint 本身。

首次 publish 会持久绑定 Repository、Provider Storage Identity 与 Prefix。之后修改 Target Name、 public_endpoint 或 max_cache_ttl,必须通过 sow publish TARGET --rebind 得到操作者显式确认。 Provider、Storage Endpoint、Region、Bucket、Prefix 与 Repository Identity 均不能 rebind;应配置 新目标。每次接受的 rebind 都会追加不可变 Binding Revision。Pending Maintenance 会阻止 TTL 变化;Filesystem Conditional-delete Maintenance 还会阻止公共端点变化。

R2 凭据是私有引用。环境变量值或被引用文件必须包含一份严格 JSON 文档,不能写路径或 Shell 赋值:

{"access_key_id":"R2_ACCESS_KEY_ID","secret_access_key":"R2_SECRET_ACCESS_KEY"}

临时凭据可以增加可选的 "session_token":"..."。未知字段、尾随内容、缺少 Access/Secret, 以及超过 64 KiB 的文档都会被拒绝。file:// 凭据必须是普通文件;指向普通文件的符号链接(例如 挂载的 Kubernetes Secret)可以使用,FIFO、设备与目录会被立即拒绝而不会阻塞。config show、JSON 输出与公共树不会包含凭据材料。

完整示例

一个工作区,两个仓库:一个受保护的生产仓库(两条元数据签名链 + RPM 补签), 一个不签名、不过滤的临时仓库。

# sow.yml —— 工作区根配置
schema: sow/v3

# 本工作区允许管理的 CPU 架构族。这是上限,不是目标。
# amd64/arm64 作为输入别名被接受,规范化为 x86_64/aarch64。
architectures: [x86_64, aarch64]

repos:

  # 生产仓库。要删除它必须先改这个文件。
  pigsty:
    protected: true

    signing:
      rpm:
        packages:
          # 对无签名或签名不受信的 RPM 补签;
          # 已由受信 key 签好的包保持字节不变。
          mode: fill
          key: keys/package-signing.asc
          trusted_keys:
            - keys/pgdg.asc        # 上游 PGDG 的签名原样认可
        metadata:
          # 每个 repomd.xml 旁边额外发布 repodata/repomd.xml.asc
          key: keys/repo-signing.asc
          passphrase: env://SOW_METADATA_PASSPHRASE
      deb:
        metadata:
          # 每个 Release 旁边额外发布 InRelease 与 Release.gpg
          key: keys/repo-signing.asc
          passphrase: env://SOW_METADATA_PASSPHRASE

    dists:

      # 稳定 EL9 通道:每个包只留一个版本,不要 debug 产物
      el9:
        format: rpm
        limit: 1
        exclude:
          - kind: [debuginfo, debugsource, llvmjit]

      # Beta 通道:同样的包,保留全部版本以便回滚
      el9-beta:
        format: rpm
        limit: 0
        exclude:
          - kind: [debuginfo, debugsource, llvmjit]

      # Debian trixie,只做 x86,保留最新版本
      trixie:
        format: deb
        architectures: [x86_64]
        limit: 1
        exclude:
          - kind: [dbgsym, dbg]
          - name: ["*-experimental"]

  # 临时仓库:不签名、不过滤、可随时删除
  sandbox:
    dists:
      el9:
        format: rpm
      trixie:
        format: deb

targets:
  prod:
    repository: pigsty
    provider: r2
    endpoint: https://0123456789abcdef.r2.cloudflarestorage.com
    region: auto
    bucket: packages
    prefix: pigsty
    credential: env://SOW_R2_CREDENTIAL
    public_endpoint: https://repo.example.com/pigsty/
    max_cache_ttl: 24h0m0s
    authoritative_workspace: true
    single_writer: true
    exclusive_write_authority: true

用之前先验证:

sow config check
sow config show --all

sow.yml 里没有什么

有些你可能以为能配的东西,是 有意 不做成配置项的:

  • 仓库路径。 仓库永远位于 <workspace>/<name>,没有 path: 字段。 见仓库布局。
  • APT component。 固定为 main;YUM 没有 component 概念。
  • 架构视图。 由 architectures 与包头共同推导,不能逐包声明。
  • 内联秘密。 目标只接受 credential 引用;key 与 passphrase 材料同样留在引用背后。
  • 自动保留数量。 保留是显式 sow retain add/rm 操作,不是配置中的滚动计数。

延伸阅读

4.2 - 包引用

命令行上指代一个软件包的五种写法,以及歧义如何裁决。

sow rm、sow show、sow where 都接受一个 PACKAGE 参数。本页定义你能在那里写什么。 三条命令共用同一套文法,只有对 歧义名称 的处理不同。

这里的内容与 sow add 无关 —— add 接受的是文件系统路径,不是包引用。

五种形态

形态 例子 匹配
内容摘要 sha256:d06d7f23b9cf...b98b1229 恰好一个包对象
RPM 坐标 rpm:pev2-0:1.23.0-1.noarch 恰好一个 RPM
DEB 坐标 deb:libpq5=18.3-1.pgdg12+1:amd64 恰好一个 DEB
完整文件名 pev2-1.23.0-1.noarch.rpm 以该文件名存储的包
裸包名 pev2 该名称的全部版本与架构

前三种是 精确 引用:指名道姓,要么命中要么失败。后两种是便利写法,可能匹配多个对象。

你不需要手工拼这些字符串。sow ls 会直接打印每个包的摘要与坐标,可以原样粘回命令行:

sow ls -d el9
repository=pigsty dists=el9 dirty=false
SHA256	COORDINATE	DISTS	BUILT_DISTS	POOL_PATH
sha256:ceb1b8660f8bc1fe59fb7a28e750e19a1ccd010a254a50e82328adb5818a5943	rpm:blackbox_exporter-0:0.28.0-1.aarch64	el9	el9	pool/b/blackbox_exporter/blackbox_exporter-0.28.0-1.aarch64.rpm
sha256:5759c643a789631346e3ed315a696a0118f81f7cc3c65e5a4385a876983d3a18	rpm:blackbox_exporter-0:0.28.0-1.x86_64	el9	el9	pool/b/blackbox_exporter/blackbox_exporter-0.28.0-1.x86_64.rpm
sha256:d06d7f23b9cfc6aedaab7b60c8e890cda020efe84f1f246243414862b98b1229	rpm:pev2-0:1.23.0-1.noarch	el9	el9	pool/p/pev2/pev2-1.23.0-1.noarch.rpm

内容摘要

sha256:<64 位小写十六进制>

已存储包体完整字节的 SHA-256。这是 SOW 里最强的引用形式:它就是对象身份,不可能有歧义。

sow where sha256:d06d7f23b9cfc6aedaab7b60c8e890cda020efe84f1f246243414862b98b1229
{"reference":"sha256:d06d7f23...b98b1229","locations":[{"repository":"pigsty","dists":["el9"],"built_dists":["el9"],"sha256":"d06d7f23...b98b1229","coordinate":"rpm:pev2-0:1.23.0-1.noarch"}]}

摘要必须完整且小写。不支持 前缀匹配,也不做大小写折叠 —— 位数不足或大写都属于用法拒绝, 不是"没找到":

operation rejected: managed: operation rejected: sha256 reference requires 64 lowercase hexadecimal digits

注意这个摘要覆盖的是 已存储 的字节。如果仓库对 RPM 包体做了重签, 对象摘要与你交给 sow add 的那个文件的摘要就不一样了。

RPM 坐标

rpm:<name>-<epoch>:<version>-<release>.<arch>

完整 NEVRA,加 rpm: 前缀。每一段都必填,包括 epoch —— 包本身没有 epoch 时写 0。

sow where 'rpm:pev2-0:1.23.0-1.noarch'

在 shell 里请加引号:NEVRA 含冒号,否则可能被历史展开或路径补全改写。

前缀与 epoch 都是必需的。少任何一个,这串字符就会被当成裸名解析,从而什么都找不到:

sow where 'rpm:pev2-1.23.0-1.noarch'
operation rejected: managed: operation rejected: package reference "rpm:pev2-1.23.0-1.noarch" was not found in the selected Workspace scope

架构那一段取自 RPM 包头:x86_64、aarch64 或 noarch。它 不是 规范族名 —— noarch 包这里就写 noarch,尽管 SOW 内部把它归类为 neutral(中性)。

DEB 坐标

deb:<package>=<version>:<architecture>

Debian 身份三元组,加 deb: 前缀。版本是含 epoch 与 revision 的完整 Debian 版本号; 架构是生态名(amd64、arm64、all),不是规范族名。

sow where 'deb:libpq5=18.3-1.pgdg12+1:amd64'

三段都必填。deb:libpq5=18.3-1.pgdg12+1 不带架构,匹配不到任何东西。

完整文件名

包存储时的完整文件名,含扩展名:

sow where 'pev2-1.23.0-1.noarch.rpm'
sow where 'libpq5_18.3-1.pgdg12+1_amd64.deb'

看着目录列表操作时,这是最好敲的写法。但它 不是身份 —— SOW 不用文件名区分包, 理论上两个不同对象可以叫同一个名字。脚本里请优先用坐标或摘要。

裸包名

只写二进制包名:

sow where pev2

它的含义取决于命令:

  • sow rm 把它理解为所选 Dist 中该名称的 全部 版本与原生架构。这是有意设计的 —— 下架一个包通常意味着全部下架。先用 -c 预览:

    sow rm libpq5 -d trixie -c
    {"repository":"pigsty","desired_revision":10,"built_generation":"00000000000000000010","dirty":false,"check":true,
     "removed":[{"dist":"trixie","sha256":"310611d0...","coordinate":"deb:libpq5=18.2-1.pgdg12+1:amd64","name":"libpq5"},
                {"dist":"trixie","sha256":"4b526223...","coordinate":"deb:libpq5=18.3-1.pgdg12+1:amd64","name":"libpq5"},
                {"dist":"trixie","sha256":"cadeb929...","coordinate":"deb:libpq5=18.3-1.pgdg12+1:arm64","name":"libpq5"}], ...}
    
  • sow show 与 sow where 要求它唯一命中。这两条命令描述的是单个包, 名称匹配多个时会连同候选列表一起拒绝:

    operation rejected: managed: operation rejected: package reference "libpq5" is ambiguous: deb:libpq5=18.2-1.pgdg12+1:amd64 sha256:310611d0fea1ce82644f48d90d485c60738b21e52ab5a60e1de43875bdfef601, deb:libpq5=18.3-1.pgdg12+1:amd64 sha256:4b5262231787caf1f367f5c8705a8a03d3176c31a15e6096946d50514db128be, deb:libpq5=18.3-1.pgdg12+1:arm64 sha256:cadeb9294901ac5ae6228bd3471c444cc288d9894af0dd0730909596d9dfcefb
    

    每个候选都同时给出坐标与摘要,所以修正方式就是把其中一条粘回命令行。

哪些写法不成立

不带 rpm: 前缀的 NEVRA 看起来像坐标,实际会被当作裸名解析, 而裸名里不含 epoch 和架构:

sow rm 'pev2-0:1.23.0-1.noarch' -d el9 -c
operation rejected: managed: operation rejected: package reference not found: package reference "pev2-0:1.23.0-1.noarch" matches no Desired Membership

另外,这里没有 glob、没有正则、没有版本区间,也没有 --all 参数。 如果你想按模式 筛选 一批包,那是 sow.yml 里的成员策略, 不是命令行选择器。命令行永远只用来指代 已经存在 的包。

作用域

引用总是在某个作用域内解析,而作用域由常规的选择参数决定,与引用写法无关:

命令 默认作用域 收窄方式
sow rm 所选仓库的所选 Dist -r、-d(存在多个时必填)
sow show 所选仓库 -r、-d
sow where 工作区内全部仓库 -r、-d

sow where 是那条"广搜"命令 —— 当你知道某个包在某处、但不知道在哪个仓库时用它。 sow show 则是在一个仓库内把一个对象的细节全部展开。

两条命令没找到时的措辞也不同,可以据此判断自己跑的是哪一条:

# rm —— 引用本身解析成功,但所选 Dist 里没有对应成员
operation rejected: ... package reference "nosuchpkg" matches no Desired Membership

# show / where —— 搜索范围内根本不存在
operation rejected: ... package reference "nosuchpkg" was not found in the selected Workspace scope

坐标与身份

上面的坐标形态是包的 逻辑身份。SOW 强制约束:一个仓库内,一个坐标最多对应一个内容对象。 用已存在的坐标加入一个 不同 的文件是硬冲突 —— SOW 不会悄悄挑一个赢家,也没有 --replace。

因此,两个只有签名不同的包仍然会冲突,因为它们坐标相同。如果你真的要重签发布, 请提高 release 号;如果只是把同一个输入再加一次,SOW 会识别出来并报告 reused。

延伸阅读

  • sow rm —— 移除、预览与批量语义
  • sow ls、show 与 where —— 三条查询命令
  • 退出码 —— 6 同时覆盖"无匹配"与"歧义"

4.3 - 仓库布局

SOW 的公共与私有路径,包括唯一规范包池与纯元数据视图。

SOW 的 Managed 布局只有一种:软件包体在 pool/ 下只存一份,dists/ 只保存客户端视图元数据。对外服务、复制或发布时,单位始终是完整仓库目录。

Plain 模式

sow create 在现有软件包旁写入索引,不修改无关文件:

/srv/offline/
├── blackbox_exporter-0.28.0-1.x86_64.rpm
├── libpq5_18.3-1.pgdg12+1_amd64.deb
├── repodata/
│   ├── <sha256>-primary.xml.gz
│   ├── <sha256>-filelists.xml.gz
│   ├── <sha256>-other.xml.gz
│   └── repomd.xml
├── Packages
├── Packages.gz
└── repo_complete                         # 仅 --pigsty 生成

平面 RPM 元数据引用裸文件名,平面 DEB 元数据使用 ./<filename>。构建期间 .sow-plain-stage-* 保存私有生成输出。Plain 没有持久 journal 或 recovery 状态;下次 create 会丢弃保留命名空间里的陈旧临时路径并重建。不得服务或复制这些临时路径。

Managed 工作区

<workspace>/
├── sow.yml                               # 配置,保持私有
├── .sow/                                 # 数据库、锁、stage/recovery,保持私有
│   ├── workspace.lock
│   ├── workspace-ops/
│   ├── repo-locks/<repo>.lock
│   ├── <repo>.db
│   └── <repo>/
│       ├── stage/
│       ├── recovery/
│       ├── retained/
│       ├── transitions/
│       └── pending/
└── <repo>/                               # 发布这个完整目录
    ├── pool/
    └── dists/

去重不跨仓库边界。.sow/ 与 pending 目录权限为 0700。Pending 包体文件直接使用最终 公开权限 0644,因此提升只需修改命名空间。私有状态 可能包含尚未发布的包体、从凭据派生的状态与恢复数据。

<repo>.db 与可重建的软件包事实缓存都属于私有状态,不改变公共仓库布局或 sow/v3 配置标识。

SOW 0.5 使用内部数据库 Schema v13。v0.3 或 v0.4 数据库必须先备份,再显式执行 sow repo migrate;v13 新增索引,让包池路径归属检查只查询候选路径。Append-only Publication-target Binding Revision、Package Facts、Signer Projection 与 Recovery Evidence 均只存在于 <repo>.db。绝不要手工修改 PRAGMA user_version,也不要脱离匹配的公共/私有 Repository 状态单独复制数据库。

规范包池

每个包体只有一条规范路径:

pool/<prefix>/<source>/<filename>

源码名取自 RPM SOURCERPM 或 DEB Source;缺失时回落到二进制包名。 前缀是首个小写字符,以 lib 开头时取前四个字符:

Source 示例
postgresql-18 pool/p/postgresql-18/libpq5_18.3-1.pgdg12+1_amd64.deb
blackbox_exporter pool/b/blackbox_exporter/blackbox_exporter-0.28.0-1.x86_64.rpm
libfoo pool/libf/libfoo/libfoo1_1.0-1_amd64.deb

Pool 对象不可变。从 Dist 删除成员关系不会立即删除字节;sow gc 只有在检查当前、保留、 恢复、发布以及活动维护操作等全部安全根后,才会处理不可达包体。

RPM 纯元数据视图

<repo>/
├── pool/
│   ├── b/blackbox_exporter/blackbox_exporter-0.28.0-1.x86_64.rpm
│   └── p/pev2/pev2-1.23.0-1.noarch.rpm
└── dists/el9/
    ├── x86_64/repodata/
    │   ├── <sha256>-primary.xml.gz
    │   └── repomd.xml
    └── aarch64/repodata/
        ├── <sha256>-primary.xml.gz
        └── repomd.xml

这里没有 dists/<dist>/<arch>/pool/。原生包只出现在匹配架构的元数据中,noarch 出现在每个架构视图中。rpm-md 回指规范包池:

<location href="../../../pool/b/blackbox_exporter/blackbox_exporter-0.28.0-1.x86_64.rpm"/>

该布局要求客户端在完整 Repository Root 内正确处理 rpm-md 相对路径。默认 dnf reposync 会拒绝父级跳转 href,因为下载目标逃出 View Root。下游工具需要自包含 Leaf 时,请显式导出:

sow export rpm-leaf el9 x86_64 /srv/export/el9-x86_64

导出目录有自己的 pool/ 和改写后的 href;它是兼容性产物,不是规范 Managed 仓库。

DEB 视图

dists/trixie/
├── Release
├── InRelease                         # 配置元数据签名时生成
├── Release.gpg                       # 配置元数据签名时生成
└── main/
    ├── binary-amd64/
    │   ├── Packages
    │   ├── Packages.gz
    │   └── by-hash/SHA256/<digest>
    └── binary-arm64/
        └── ...

Packages 从 archive 根引用同一规范包池:

Filename: pool/p/postgresql-18/libpq5_18.3-1.pgdg12+1_amd64.deb

Release 使用 SHA256 清单并声明 Acquire-By-Hash: yes。校验和命名的 rpm-md 文件与 APT by-hash 条目让上一组元数据在可变指针最后替换时仍然可达。

发布目标

filesystem 与 r2 目标都会在配置前缀下得到同一棵逻辑公共树:

<prefix>/
├── pool/
└── dists/

发布单位始终是完整仓库命名空间。不要只发布某一个 RPM 架构目录,它的 href 会有意回指 根包池。

名称与服务边界

仓库名与 Dist 名必须匹配 [a-z0-9][a-z0-9._-]*。.、..、.sow、pool、 dists、sow.yml、workspace.lock、workspace-ops 与 repo-locks 在相应位置为 保留名。与已知路径在大小写折叠后冲突的新池路径会被拒绝,字节完全相同也不例外。在大小写 不敏感的工作区文件系统上(macOS 默认配置),源目录与已有目录仅大小写不同时同样会被拒绝; 在 Linux 上两者彼此独立,因此这样的 Repository 不能迁移到大小写不敏感的文件系统。详见 平台与集成。

绝不要暴露 .sow

Web 服务器应指向 <workspace>/<repo>/,而不是工作区根目录。公共仓库需要同时包含 pool/ 与 dists/;私有 .sow/ 必须隐藏。

延伸阅读

4.4 - 退出码

退出码 0–6 与中断码 130 的含义和示例。

每条 sow 命令都使用退出码 0–6 或中断码 130,对所有命令一致, 并且就是设计来给脚本做分支判断的 —— 区分"这件事失败了"和"这件事被正确地拒绝了", 正是设置多个非零码的全部意义。

码 含义
0 完整成功,或幂等 no-op
1 运行时 I/O、解析器、渲染器或未知内部错误
2 用法、工作区发现或配置错误
3 部分成功:至少一项已提交,至少一项失败
4 写锁不可用 —— 被占用且指定了 --no-wait,或等待超时
5 完整性/恢复错误,或 check 判定当前结果不可交付
6 预期拒绝:冲突、protected、无匹配、架构不兼容
130 命令被 Ctrl-C(SIGINT)或 SIGTERM 中断

人类可读的结果写 stdout,警告与诊断写 stderr。每个码在 stderr 上有稳定的消息前缀, 在 JSON 输出中有对应的 class:

码 stderr 前缀 JSON class
1 随子系统而异 runtime
2 usage error: / workspace discovery error: / configuration error: usage, discovery, config
3 ... batch partially succeeded partial
4 lock unavailable: lock
5 integrity or recovery error: integrity
6 operation rejected: rejected
130 随子系统而异 interrupted

sow create 是前缀那一列的例外:它不在 Managed 层内,stderr 上打印的是原始领域错误 (plain: scan …、plain: marker gate …),没有 CLI 类别前缀(例如 operation rejected:、 lock unavailable:)。该前缀仍然出现在它的 JSON errors[].message 里。


130 —— 中断

Ctrl-C(SIGINT)或 SIGTERM 取消命令并返回 130;普通等待锁超时仍返回 4。只有命令本身被中断才返回 130:内部超时(例如 R2 上传长时间没有进度)属于普通运行错误(1)。SOW 返回的是数字退出状态,不会向自身重发 SIGINT;父 shell 仍可能继续执行下一条命令。 脚本需要检查状态、使用 set -e,或自行处理信号。取消不会被报告成签名策略失败。 add 在 Desired 提交前被取消时,不会把任何输入报告为 accepted,且先持久化失败状态再清理 临时包字节;若清理未能在 5 秒预算内完成,剩余部分由下一条写命令(例如 sow build)删除, 在此之前 sow check 报告 recovering。已经提交的变更仍然保留,重跑命令即可恢复或继续收敛。操作完成前应保留原始输入包。

0 —— 成功或无操作

命令完成了你要求的事,或者发现无事可做。两者都算成功:对未变化的目录重跑 sow create,或对 clean 仓库执行 sow build,都返回 0 并如实说明。

sow create /srv/offline --json
{"schema":"sow.cli/v1","command":"create","ok":true,...,"result":{"dir":"/srv/offline","rpm":4,"deb":3,"kept":[...],"removed":[],"marker":false,"noop":true,"recovered":false},"errors":[]}

"noop":true 才是区分"没干活"与"干了活"的依据,退出码不区分这两者。

sow status 是有意为之的特例:只要状态数据库可读,它在 clean、dirty、 recovering、error 四种状态下都返回 0 —— 让脚本去读结构化状态, 而不是从退出码反推。需要"闸门"时请用 sow check。

1 —— 运行时错误

I/O、解析或渲染层面出了问题:目录不可写、磁盘满、包读不出来。 这类是 环境问题,不是用法问题。

chmod 500 /srv/readonly
sow create /srv/readonly
plain: create stage /srv/readonly: mkdir /srv/readonly/.sow-plain-stage-1457115008: permission denied

stage 目录之所以一开始就创建,正是为了让这类失败发生在 任何东西被发布之前。 本来就有合法索引的仓库,索引依然完好。

2 —— 用法、发现或配置错误

你要求的事情 CLI 无法执行:未知参数、目标不明确、找不到工作区,或 sow.yml 解析不通过。 什么都没有尝试执行。

未知参数:

sow status --nope
usage error: unknown option "--nope"

互斥参数:

sow build -N -T 5s
usage error: --no-wait and non-zero --timeout are mutually exclusive

目标不明确 —— 仓库有两个 Dist,而命令需要确定其一:

sow ls
workspace discovery error: managed: workspace discovery error: repository "pigsty" has multiple Dists (el9, trixie); select one or more with --dist

当前目录向上找不到任何工作区 —— 错误会说明搜索位置与修复方式:

workspace discovery error: managed: workspace discovery error: workspace not found (searched cwd="/home/vonng"); run sow init or set --workdir/SOW_DIR

起点指向普通文件而不是目录时,搜索不会开始:

workspace discovery error: managed: workspace discovery error: discover workspace from workdir "/srv/sow/sow.yml": start is not a directory

指向目录的符号链接可以作为发现起点,包括 macOS 上的 /tmp。发现仍按字面路径向上 搜索,未找到工作区时继续回退到 SOW_DIR。从起点隐式选择 Repository 与 Dist 的要求更严: 起点目录与工作区根之间只要有任一符号链接分量,就不能推断,此时请显式传入 -r。如果符号链接 位于 Repository 内部,会推断 Dist 的命令只给 -r 仍会失败,还要同时传入 -d。

配置文件格式有问题 —— 注意错误会指出具体行号:

sow config check
configuration error: load config "/srv/repo/sow.yml": parse sow.yml: yaml: unmarshal errors:
  line 3: field repositories not found in type config.Config

sow.yml 的所有文法与 schema 错误都归到这一码。

3 —— 部分成功

一个批次里有的项已提交、有的项失败。这个码存在的意义是:你永远不必猜测一次失败的 sow add 是否让仓库毫发无损 —— 返回 3 就意味着合法的包 已经进去了, 失败的那些会被逐条点名。

sow add ./incoming/ -d el9
add repository=pigsty operation=9162553676349401125 accepted=1 failed=1 memberships=+1/-0 revision=6 generation=6 dirty=false
item input="/incoming/broken-1.0-1.x86_64.rpm" status=failed error="invalid RPM package: parse RPM reader: unexpected EOF"
item input="/incoming/pgbouncer_fdw_18-1.4.0-1PGDG.rhel9.8.x86_64.rpm" status=reused format=rpm coordinate="pgbouncer_fdw_18-0:1.4.0-1PGDG.rhel9.8.x86_64" sha256:45171966... dists=el9:accepted
managed: batch partially succeeded

失败的输入文件原地不动。加上 --json 时,已提交的项仍然完整列出 —— 非零退出 从不 截断 result:

{..., "ok":false, "result":{"accepted":1,"failed":1,"items":[...]}, "errors":[{"code":3,"class":"partial","message":"managed: batch partially succeeded"}]}

sow init 在已提交了部分声明的仓库或 Dist、随后在后面某项上失败时,也用这个码。

4 —— 锁不可用

另一个进程持有写锁。SOW 在设计上就是单写者(single-writer), 所以这是 正常且预期 的结果 —— 重试,或者多等一会儿。

带 --no-wait 时立即失败:

sow build -N
lock unavailable: managed: lock unavailable

带超时时,恰好等待这么久后失败:

time sow build -T 2s
lock unavailable: managed: lock unavailable

real	0m2.016s

-T 0(默认)一直等待。只读命令不取写锁,永远不会返回 4; sow status 甚至把这种争用作为一个字段报告出来:

repository=pigsty status=clean ready_to_copy=false revision=7 generation=7 dirty_dists= pending=0/0 locked=true

5 —— 完整性、恢复,或不可交付

两种不同的情况共用这个码,它们的含义都是"先别把这棵树发出去"。

常见的那种:仓库的期望状态领先于已构建的内容 —— sow add --skip 之后, 或者改了策略/签名之后,对它执行 sow check。每一层校验都通过, 仓库只是 尚未收敛:

sow rm 'rpm:pev2-0:1.23.0-1.noarch' -d el9 --skip
sow check
repository=pigsty status=dirty ready_to_copy=false revision=7 generation=6
config	ok=true	checked=5
state	ok=true	checked=1
public-modes	ok=true	checked=69
retained	ok=true	checked=0
package-bytes	ok=true	checked=7
desired-membership	ok=true	checked=6
index	ok=true	checked=2
signature	ok=true	checked=11
generation-manifest	ok=true	checked=1
integrity or recovery error: managed: repository is not ready to copy: repository status is dirty

解决办法是 sow build。这正是部署脚本应该拿来做闸门的码 —— 它区分的是"磁盘上的树完整且最新"与"磁盘上的树完整但过期"。

少见的那种是真正的完整性失败:状态数据库、journal 与文件树互相矛盾, 且 SOW 无法安全地自行裁决。此时它拒绝覆盖任何东西,你应该从备份恢复,而不是强行修复。 这里 有意 没有 --force。

6 —— 预期拒绝

命令写法正确、环境也没问题,是 SOW 主动判定"不行"。 这些是策略与安全决策,不是故障。

受保护的仓库:

sow repo rm pigsty -f
operation rejected: managed: operation rejected: repository "pigsty" is protected

匹配不到任何东西的引用:

sow rm nosuchpkg -d el9
operation rejected: managed: operation rejected: package reference not found: package reference "nosuchpkg" matches no Desired Membership

有歧义的裸名 —— 候选会一并列出,方便你挑一个:

sow show libpq5 -d trixie
operation rejected: managed: operation rejected: package reference "libpq5" is ambiguous: deb:libpq5=18.2-1.pgdg12+1:amd64 sha256:310611d0..., deb:libpq5=18.3-1.pgdg12+1:amd64 sha256:4b526223..., deb:libpq5=18.3-1.pgdg12+1:arm64 sha256:cadeb929...

工作区不允许的架构。注意逐项错误会点名检测到的值,并告诉你去哪里改:

sow add ./centos-release-6-0.el6.centos.5.i686.rpm -d el9 --json
"items":[{"input":".../centos-release-6-0.el6.centos.5.i686.rpm","status":"failed",
 "error":"managed: operation rejected: unknown rpm package architecture \"i686\"; supported rpm package architectures are [x86_64, aarch64, noarch] (canonical families [x86_64, aarch64, neutral]); use a supported package or update only supported architecture families in sow.yml"}]

目录里没有任何可索引的包:

sow create /srv/empty
plain: scan /srv/empty: no supported top-level regular RPM or DEB packages

--pigsty 完成标记挡住了对既有构建的覆盖:

sow create /www/pigsty
plain: marker gate /www/pigsty/repo_complete: repo_complete exists; use --pigsty or remove it explicitly before rebuilding

签名 key 引用文法正确但解析不出密钥 —— 文法错误是 2,解析失败是 6:

operation rejected: ... deb metadata key: key reference does not resolve to a bounded regular file
operation rejected: ... deb metadata key: environment key reference SOW_METADATA_KEY is unset

在脚本里使用

这些码的设计目标就是让部署流水线 不必解析文本 即可分支:

#!/usr/bin/env bash
set -uo pipefail

sow add /incoming/*.rpm -r pigsty -d el9
case $? in
  0) ;;                                        # 全部落地
  3) echo "部分包被拒绝,继续处理已落地的部分" >&2 ;;
  4) echo "另一个写者持有锁,稍后重试" >&2; exit 75 ;;
  *) echo "add 失败" >&2; exit 1 ;;
esac

# 用完整且最新的树作为发布闸门
if ! sow check -r pigsty; then
  echo "仓库尚不可发布" >&2
  exit 1
fi

sow publish mirror

这里的 mirror 是 pigsty 已配置的 Publication Target。

两个值得养成的习惯:把 4 当作 可重试 而不是致命错误; 永远不要把 6 当作崩溃 —— 它通常意味着需要改的是你的输入,而不是 SOW。

延伸阅读

4.5 - JSON 输出

sow.cli/v1 Envelope、字段含义与主要命令族的 Result 形态。

所有产出数据的命令都接受 --json。输出是 stdout 上的 一行 版本化信封 —— 不管是哪条命令产生的,都能直接管道给 jq。

sow status --json
{"schema":"sow.cli/v1","command":"status","ok":true,"repository":"pigsty","operation":null,
 "result":{"repository":"pigsty","status":"clean","ready_to_copy":true,"desired_revision":4,
 "built_generation":"00000000000000000004","dirty_dists":[],"dirty_reasons":[],"pending":{"count":0,"bytes":0},
 "recent_operation":{"id":"8632724976452398569","kind":"add","state":"done",
 "created_at":"2026-08-04T04:07:17.665377Z","updated_at":"2026-08-04T04:07:18.293848Z"},
 "repository_locked":false},"errors":[]}

(此处为便于阅读做了折行,实际输出是一行。)

信封结构

字段 类型 含义
schema string 恒为 sow.cli/v1。解析其他字段前先检查它。
command string 实际调用的命令,含子命令:add、repo ls、config show。
ok bool errors 为空时为 true,等价于退出码 0。
repository string 或 null 选定的仓库;工作区级与 Plain 模式命令为 null。
operation string 或 null 命令对应的可用身份,见下表;null 不代表命令只读。
result object 或 null 命令专属载荷,详见下文。
errors array 零个或多个 {code, class, message} 对象。

七个字段 永远存在。命令在有意义工作之前失败时(例如未知参数、发现失败,或尚未选中 Repository 就遇到无效配置),result 为 null。已提交的部分结果,以及 check、 rm --check 的诊断结果,即使命令非零退出也会保留。

命令 Envelope 的 operation
add、不带 --check 的 rm、build 分配了日志 ID 时返回该 ID,否则为 null
本地 gc 真正执行回收时返回日志 ID;空操作为 null
publish、publish --abort 存在发布 attempt 时返回其 ID,否则为 null
log ID 回显所查询的日志 ID,虽然该命令只读
init、repo/dist/retain 命令、export、目标 gc、rm --check、log prune、Plain create 及其他查询 null;log prune 的日志 ID 位于 result.operation

errors

"errors":[{"code":3,"class":"partial","message":"managed: batch partially succeeded"}]
字段 含义
code 进程退出码 —— 1 到 6,或中断码 130。
class runtime、usage、discovery、config、partial、lock、integrity、rejected、interrupted 之一。discovery 与 config 是退出码 2 的细分类;interrupted 对应 130,仅在命令本身收到 SIGINT 或 SIGTERM 时使用。
message 分类后的错误文本。通常与 stderr 相同;Plain create 的 stderr 输出原始领域错误,不含额外的 CLI 类别前缀。

请对 class 做分支判断,不要匹配 message 文本。message 里含路径和包名,会变;class 不会。

非零退出仍然返回 result

批次部分成功时,ok 是 false,同时 result 会完整列出已提交的内容。 不要因为退出码非零就丢掉载荷 —— 对 add 来说,那正是你了解"哪些包落地了"的唯一途径。

Operation ID 是字符串

"operation":"8632724976452398569"

Operation ID 是 64 位值,序列化为十进制 字符串,因为它经常超出 IEEE 754 双精度 能精确表示的范围。在 JavaScript 里,JSON.parse 处理裸数字会静默损坏它们。 请保持字符串形态;jq 原样处理即可。

Generation ID 是固定宽度字符串

"built_generation":"00000000000000000004"

Generation ID 覆盖完整的无符号 64 位范围,并固定序列化为 20 位、左侧补零的十进制字符串。 generation、built_generation、base_generation 以及表示 Generation 的 base 字段都应 按字符串处理;固定宽度也能保持普通字节序比较与数值顺序一致。

stdout 与 stderr

结果和 JSON 信封写 stdout;警告与错误诊断写 stderr,同时 也出现在 errors 数组里。 所以这样写是可行的:

sow check --json 2>/dev/null | jq -e '.ok'

各命令的 result 形态

create

sow create /srv/offline --json
{"schema":"sow.cli/v1","command":"create","ok":true,"repository":null,"operation":null,
 "result":{"dir":"/srv/offline","rpm":4,"deb":3,
 "kept":["blackbox_exporter-0.28.0-1.aarch64.rpm","blackbox_exporter-0.28.0-1.x86_64.rpm",
 "libpq5_18.2-1.pgdg12+1_amd64.deb","pev2-1.23.0-1.noarch.rpm"],
 "removed":[],"marker":false,"noop":true,"recovered":false},"errors":[]}
字段 含义
dir 被索引的绝对目录
rpm、deb 各格式的包数量
kept 进入索引的文件名,已排序
removed 被 --pigsty 清理删除的包;否则为空
marker 是否写入了 repo_complete
marker_sha256 marker 文件的摘要;仅 --pigsty 时出现
noop 索引本来就正确、什么都没改时为 true
recovered 为兼容稳定 schema 保留;Plain create 没有 journal 恢复,始终为 false
signed 实际签过名的文件名;列表为空时省略,即使给了 --sign-with
signer 可用时返回解析后的签名者身份;否则省略

init

"result":{"workspace":"/srv/repo","config_created":true,
 "repositories_initialized":0,"dists_initialized":0,"existing":[]}

对已存在的工作区重跑时,计数为 0,existing 说明找到了什么:

"result":{"workspace":"/srv/repo","config_created":false,
 "repositories_initialized":0,"dists_initialized":0,"existing":["sow.yml"]}

config check 与 config show

"result":{"workspace":"/srv/repo","repositories":1,"dists":2}

config show 返回有效配置本身,形态与规范化之后的 sow.yml 一致:

"result":{"schema":"sow/v3","architectures":["x86_64","aarch64"],
 "repos":{"pigsty":{"protected":false,
 "signing":{"rpm":{"packages":{"mode":"never"}}},
 "dists":{"el9":{"format":"rpm","architectures":["x86_64","aarch64"],"limit":0,"exclude":null},
          "trixie":{"format":"deb","architectures":["x86_64","aarch64"],"limit":0,"exclude":null}}}}}

不带 --all 时,已解析的签名条目也可携带 key_fingerprint。启用 RPM 包签名时,还会按 可用情况输出 key_snapshot_sha256、规范化的 trusted_keys、trusted_key_fingerprints 和 trusted_key_snapshot_sha256s。指纹和快照摘要分别排序、去重,不能按数组下标配对。 这些是公钥证书身份,不包含私钥或口令。

repo ls / repo new / repo show

repo ls 返回数组;repo new 与 repo show 返回同一形态的单个对象。

"result":{"repositories":[{"name":"pigsty","path":"/srv/repo/pigsty","protected":false,
 "dists":2,"generation":"00000000000000000004","desired_revision":4,"status":"clean","packages":7,"memberships":7,
 "recent_operation":{"id":"8632724976452398569","kind":"add","state":"done",
  "created_at":"2026-08-04T04:07:17.665377Z","updated_at":"2026-08-04T04:07:18.293848Z"},
 "config":{"protected":false,"signing":{...},"dists":{...}}}]}

packages 统计包池中不同的包对象数;memberships 统计 Dist 成员关系数 —— 同一个包出现在两个 Dist 里,前者计一次,后者计两次。

repo rm 只返回结果:

"result":{"name":"demo","noop":false,"removed":true}

dist ls / dist new / dist show

"result":{"dists":[{"name":"el9","format":"rpm",
 "architectures":[{"family":"x86_64","ecosystem_arch":"x86_64"},
                  {"family":"aarch64","ecosystem_arch":"aarch64"}],
 "desired_members":4,"built_members":4,"generation":"00000000000000000003","dirty":false,"status":"clean",
 "effective_config_sha256":"39913af601d10d4d4033b0c29e8d66df385f8a6eb22f45219773a7fc170d4243",
 "config":{"format":"rpm","architectures":["x86_64","aarch64"],"limit":0,"exclude":null}}]}

每个架构条目同时给出两个名字:family 是配置里用的规范名, ecosystem_arch 是发布树里出现的名字 —— RPM 两者相同,DEB 分别是 amd64/arm64。

desired_members 大于 built_members,或 dirty: true,都表示还欠一次 sow build。 effective_config_sha256 是所有喂给渲染器的输入的摘要;它一变,该 Dist 就变 dirty。

dist rm 与 repo rm 一致:{"name":"el9","noop":false,"removed":true}。

add

"result":{"operation":"320653458389425222","repository":"demo",
 "desired_revision":2,"built_generation":"00000000000000000002","dirty":false,
 "accepted":1,"failed":0,"memberships_added":1,"memberships_removed":0,
 "items":[{"input":"/incoming/pev2-1.23.0-1.noarch.rpm","status":"accepted","format":"rpm",
 "coordinate":"pev2-0:1.23.0-1.noarch",
 "sha256":"d06d7f23b9cfc6aedaab7b60c8e890cda020efe84f1f246243414862b98b1229",
 "dists":{"el9":"accepted"}}]}

每个输入路径对应一条 items,顺序稳定。status 是该项的总体结果, dists 给出 逐 Dist 的裁决:

status 含义
accepted 新包对象,已建立成员关系
reused 完全相同的对象已存在;可能仍为其他 Dist 新增成员关系
excluded 被策略挡下 —— 看 dists 区分是 excluded 还是 limited
failed 未被接纳;error 给出原因

逐 Dist 的取值是 accepted、excluded、limited。同一条命令里, 一个包可以被某个 Dist 接受、被另一个 Dist 限流:

"items":[{"input":".../libpq5_18.2-1.pgdg12+1_amd64.deb","status":"excluded","format":"deb",
 "coordinate":"libpq5=18.2-1.pgdg12+1:amd64","sha256":"310611d0...","dists":{"trixie":"limited"}}]

失败项携带 error,不带包字段:

{"input":"/incoming/broken-1.0-1.x86_64.rpm","status":"failed",
 "error":"invalid RPM package: parse RPM reader: unexpected EOF"}

memberships_added 与 memberships_removed 双向计数,因为 limit 可能在接纳新版本的 同一个操作里淘汰旧版本。

rm

"result":{"operation":"3422380511083828695","repository":"pigsty",
 "desired_revision":5,"built_generation":"00000000000000000005","dirty":false,"check":false,
 "removed":[{"dist":"el9","sha256":"45171966...",
   "coordinate":"rpm:pgbouncer_fdw_18-0:1.4.0-1PGDG.rhel9.8.x86_64","name":"pgbouncer_fdw_18"}],
 "dists":["el9"],
 "changes":[{"op":"add","path":"dists/el9/x86_64/repodata/1a57aa2f...-filelists.xml.gz",
   "phase":"metadata","size":382,"sha256":"1a57aa2f..."},
  {"op":"update","path":"dists/el9/x86_64/repodata/repomd.xml","phase":"pointer",
   "size":1510,"sha256":"f28ffe14..."},
  {"op":"delete","path":"dists/el9/x86_64/repodata/0df96f0b...-primary.xml.gz","phase":"delete"}]}

带 -c/--check 运行时 check 为 true,此时 什么都没写,changes 是一份预测。 注意 removed 只列出成员关系的移除 —— rm 永远不删除包池字节。

预览字段 含义
prediction_complete 仅 rm --check 能完整预测变更时以 true 出现;缺失或为 false 不表示所列变更完整。

build

"result":{"operation":"3701044631565986409","repository":"pigsty",
 "dists":["el9","trixie"],"desired_revision":5,"built_generation":"00000000000000000005",
 "noop":true,"dirty":false}

noop: true 表示期望状态已经与已构建的树一致,没有产生新的代。 dists 列的是被纳入考量的 Dist,不一定是真正重建了的那些。

status

"result":{"repository":"pigsty","status":"clean","ready_to_copy":true,
 "desired_revision":4,"built_generation":"00000000000000000004","dirty_dists":[],"dirty_reasons":[],
 "pending":{"count":0,"bytes":0},
 "recent_operation":{"id":"8632724976452398569","kind":"add","state":"done",
  "created_at":"...","updated_at":"..."},
 "repository_locked":false}

status 取值为 clean、dirty、recovering、error。部署脚本该读的字段是 ready_to_copy —— 但记住 status 在任何状态下都返回 0,所以要判断 字段,不是退出码:

sow status --json | jq -e '.result.ready_to_copy' >/dev/null || exit 1

pending 统计 add --skip 之后私有保存、尚未发布的包体。 repository_locked 报告当前是否有其他进程持有写锁。

check

"result":{"repository":"pigsty","status":"clean","ready_to_copy":true,
 "built_generation":"00000000000000000004","desired_revision":4,
 "layers":[{"name":"config","ok":true,"checked":5,"issues":[]},
  {"name":"state","ok":true,"checked":1,"issues":[]},
  {"name":"public-modes","ok":true,"checked":72,"issues":[]},
  {"name":"retained","ok":true,"checked":0,"issues":[]},
  {"name":"package-bytes","ok":true,"checked":7,"issues":[]},
  {"name":"desired-membership","ok":true,"checked":7,"issues":[]},
  {"name":"index","ok":true,"checked":2,"issues":[]},
  {"name":"signature","ok":true,"checked":11,"issues":[]},
  {"name":"generation-manifest","ok":true,"checked":1,"issues":[]}]}

稳态 Check 按固定顺序返回九层,每层给出检查项数与问题。未完成布局迁移则只返回 config、 state、public-modes 与 layout-transition,随后停止并返回不可交付。dirty 仓库可以让全部稳态层 ok: true,但仍以退出码 5 失败 —— 因为层校验的是 自洽性, 而 ready_to_copy 报告的是 时效性:

{...,"ok":false,"result":{"status":"dirty","ready_to_copy":false,...},
 "errors":[{"code":5,"class":"integrity",
  "message":"integrity or recovery error: managed: repository is not ready to copy: repository status is dirty"}]}

changes

"result":{"repository":"pigsty","base":"00000000000000000004","generation":"00000000000000000005","dirty":false,
 "changes":[{"op":"add","path":"dists/el9/x86_64/repodata/1a57aa2f...-filelists.xml.gz",
   "phase":"metadata","size":382,"sha256":"1a57aa2f..."},
  {"op":"update","path":"dists/el9/x86_64/repodata/repomd.xml","phase":"pointer",
   "size":1510,"sha256":"f28ffe14..."},
  {"op":"delete","path":"dists/el9/x86_64/repodata/0df96f0b...-primary.xml.gz","phase":"delete"}]}
字段 取值
op add、update、delete
phase payload、metadata、pointer、delete
path 永远相对仓库根,永远用 / 分隔
size、sha256 add 与 update 有;delete 没有

这是一份本地 Generation 差异,不是可以直接在线回放的发布协议。仅按 phase 排序无法 覆盖客户端缓存、多文件指针提交和删除宽限期。目标发布应使用 sow publish,回收走独立的 目标 GC 协议;不要立即对在线仓库执行其中的 delete 条目。

sow changes 0 把当前整棵树作为一个 add 集合给出 —— 也就是一份完整交付清单。

ls / show / where

ls 返回包对象数组;show 在 package 下返回恰好一个。

"result":{"repository":"pigsty","dists":["el9"],"dirty":false,
 "packages":[{"sha256":"d06d7f23...","format":"rpm","coordinate":"pev2-0:1.23.0-1.noarch",
 "architecture":"noarch","canonical_arch":"neutral",
 "pool_path":"pool/p/pev2/pev2-1.23.0-1.noarch.rpm","filename":"pev2-1.23.0-1.noarch.rpm",
 "size":316372,"name":"pev2","source":"pev2","version":"1.23.0","epoch":"0","release":"1",
 "kind":"main","payload_sha256":"0413d629...","signature_key":"E7935D8DB9BD8B20",
 "storage":"pool","created_revision":3,"dists":["el9"],"built_dists":["el9"]}]}

值得关注的字段:

字段 含义
architecture 包头里的原始写法:x86_64、noarch、amd64、all
canonical_arch SOW 用于分组的族:x86_64、aarch64 或 neutral
payload_sha256 仅 RPM —— 签名无关摘要,用于识别同一包的重签副本
signature_key 包内嵌签名的 key ID(包带签名时)
storage 已发布为 pool,--skip 加入的为 pending
dists / built_dists 期望成员集 与 上一次构建实际发布的集合

dists 比 built_dists 长,是判断"还欠一次 build"的另一种方式。

where 搜索整个工作区,返回位置而不是完整对象:

"result":{"reference":"pev2","locations":[{"repository":"pigsty","dists":["el9"],
 "built_dists":["el9"],"sha256":"d06d7f23...","coordinate":"rpm:pev2-0:1.23.0-1.noarch"}]}

publish、retain、gc、export

Managed 生命周期命令使用同一 envelope,数字 Generation 仍序列化为 JSON 字符串:

命令 重要 result 字段
publish repository、target、provider、generation、attempt、checkpoint、phase、objects、noop
publish --abort repository、target、provider、attempt、phase、objects
publish --rebind 与 publish 相同;Binding Revision 属于持久私有审计状态,不新增 Wire Field
retain add / retain rm repository、record、record_identity、path
retain ls repository、generations[],元素使用同一 retained record 形态
本地 gc operation、repository、base_generation、generation、objects、bytes、noop
目标 gc repository、target、provider、phase、reports、candidates、deleted_objects、deleted_bytes、retained_objects、pending_grace、completed_attempts、noop
export rpm-leaf repository、repository_id、generation、dist、arch、directory、method、signed、signer_identity、packages、files、manifest_sha256

attempt、checkpoint 或本地 GC operation 等可选 identity 没有值时直接省略。 R2 目标 GC 会把候选计入 retained,SOW 从不报告自己执行了远端删除。

log

sow log 返回操作账本,由新到旧:

"result":{"repository":"pigsty","operations":[{"id":"3701044631565986409","kind":"build",
 "state":"done",
 "payload_json":"{\"version\":2,\"repository\":\"pigsty\",\"kind\":\"build\",\"config_sha256\":\"37eb6dcf...\",\"skip\":false,\"noop\":true,\"dists\":[\"el9\",\"trixie\"],\"build_dists\":[],\"manifest_sha256\":\"125d7266...\"}",
 "result_json":"{\"dists\":2,\"dropped_pending\":[]}",
 "created_at":"2026-08-04T04:08:08.691678Z","updated_at":"2026-08-04T04:08:08.763019Z"}]}

payload_json 与 result_json 是 内含 JSON 的字符串,不是对象。 它们原样保存以保证审计记录字节稳定;需要二次解析:

sow log --json | jq -r '.result.operations[] | .payload_json | fromjson | .config_sha256'

传入 Operation ID 会返回完整细节 —— 状态迁移、结构化 build_progress 事件、包、成员关系与每一个文件动作:

"result":{"repository":"pigsty","detail":{"operation":{...},"duration_ms":598,
 "events":[{"sequence":0,"state":"planned","detail_json":"{}","occurred_at":"..."},
  {"sequence":1,"state":"staged",...},{"sequence":2,"state":"applied",...},
  {"sequence":3,"state":"applied","detail_json":"{\"version\":1,\"kind\":\"build_progress\",\"phase\":\"rendering\",\"completed\":1,\"total\":2,\"jobs\":8}",...},
  {"sequence":4,"state":"built",...},{"sequence":5,"state":"done",...}],
 "packages":[{"sequence":0,"input_path":"pgbouncer_fdw_18","package_sha256":"45171966...",
  "coordinate":"rpm:pgbouncer_fdw_18-0:1.4.0-1PGDG.rhel9.8.x86_64","disposition":"removed"}],
 "memberships":[{"sequence":0,"dist":"el9","package_sha256":"45171966...","action":"remove"}],
 "files":[{"sequence":0,"action":"add","phase":"metadata","path":"dists/el9/x86_64/repodata/1a57aa2f...-filelists.xml.gz","size":382,"sha256":"1a57aa2f..."}]}}

sow log prune 返回它清理了什么:

"result":{"operation":"7140280533435786353","repository":"demo",
 "before":"2026-01-01T00:00:00+08:00","pruned":0,"compaction_deferred":false}

注意 before 会回显裸日期在本地时区解析出的绝对时间戳。

log export 不是信封

sow log export 输出 JSON Lines —— 每行一条完整的 Operation 记录, 没有信封,也没有 --json 参数。它是给归档用的,不是给单条命令脚本用的:

sow log export - | head -1
sow log export operations.jsonl

它拒绝覆盖已存在的文件,也拒绝父目录是符号链接的目标。

一个完整例子

仓库既自洽又最新时才允许部署,然后列出该复制哪些文件:

#!/usr/bin/env bash
set -euo pipefail

if ! sow check -r pigsty --json 2>/dev/null | jq -e '.ok' >/dev/null; then
  echo "仓库不可交付" >&2
  exit 1
fi

# 当前发布树的完整清单,按交付顺序排列
sow changes 0 -r pigsty --json \
  | jq -r '.result.changes[] | [.phase, .op, .path] | @tsv'

延伸阅读

4.6 - 平台与集成

Release 目标、文件系统要求、仓库客户端、发布 Provider 与自动化集成覆盖。

本页说明 SOW 提供哪些构建目标、工作区依赖什么存储语义,以及自动化集成具体覆盖哪些行为。 仓库生成在 SOW 二进制内部完成;部署后的最终门禁仍是实际软件包管理器。

Release 目标

操作系统 amd64 arm64 制品
Linux 是 是 归档、RPM、DEB
macOS 是 是 归档
Windows 否 否 不支持

Release 二进制使用 CGO_ENABLED=0,不需要语言运行时。源码 Module 要求 Go 1.27.1 或更新版本。 归档包含 README.md、CHANGELOG.md、Apache-2.0 LICENSE 与 THIRD_PARTY_NOTICES, Linux 软件包也随二进制包含同一份协议与第三方许可声明。 使用 sow version 查看产品版本、目标 OS/架构与构建工具链。

工作区文件系统

Managed 工作区应放在本地 POSIX 文件系统上。正确性依赖建议锁、fsync、基于描述符的路径 校验与同文件系统原子 rename;NFS 等网络文件系统不属于受支持的工作区位置。macOS 上请使用 APFS:HFS+ 不支持原子目录交换,SOW 会拒绝构建。

公共 <workspace>/<repo>/ 树是另一条边界:它是闭合的 pool/ + dists/ 命名空间,可整根 复制或发布,不依赖 SQLite、私有 journal 或 view-local hardlink identity。必须保持完整 Repository,不得暴露 .sow/。

SOW 会拒绝符号链接控制路径、不安全普通文件、重叠 filesystem target,以及与已有或已发布 路径在大小写折叠后冲突的新 Pool 路径;这也包括以仅大小写不同的文件名加入完全相同的字节 (请使用原文件名加入)。在大小写不敏感的工作区文件系统上(例如 macOS 默认配置),SOW 还会 拒绝源目录与已有包仅大小写不同的新包(例如 CaseDemo 与 casedemo);向位于大小写不敏感 卷上的 filesystem target 执行 publish 时,也会在创建 attempt 之前拒绝这类别名。在大小写 敏感的文件系统(Linux)上,这样的两个源目录彼此独立,因此同时包含两者的 Repository 不能 迁移到大小写不敏感的文件系统。

自动化集成矩阵

表面 环境 已验证行为
生产 CLI 干净环境 Linux CI 构建交付二进制;生成混合 Plain RPM/DEB 元数据;初始化 sow/v3;创建 RPM/DEB Dist;加入 Fixture;执行查询、build、check、changes、config 与 log 命令
Plain APT 客户端 Ubuntu 22.04 容器 通过 HTTP 服务 sow create 输出;在显式信任未签名源时执行 apt-get update、包发现、精确版本选择、下载与安装
RPM 分离签名切换 AlmaLinux 8、9、10 容器 使用真实 DNF 客户端遍历 repomd.xml / repomd.xml.asc 串行切换状态,并固定各组合的成功/失败行为
S3 兼容传输 固定的 PGSTY Silo 容器(兼容 MinIO) 验证 Bucket 列表、HEAD、GET、单段仅创建/CAS PUT、重放与对象 SHA-256 元数据;重试与 Prefix 约束由本地协议测试覆盖
Release 打包 Linux CI 构建四个归档、两个 RPM、两个 DEB 与 SHA256SUMS;检查包内路径、Apache-2.0 元数据与协议文件字节

DNF 签名切换是协议测试,不是完整 Managed RPM 安装;APT 作业覆盖未签名 Plain 仓库,不覆盖 Managed 元数据签名。正式上线前,应使用部署中的确切 dnf/APT 版本、仓库 URL、访问策略与 签名策略完成验收。

当前 Silo 集成没有验证条件式 Multipart 完成。Multipart 的请求/协议测试单独存在, 不能据此认定真实 R2 兼容;该路径的验收还需要真实 R2 Multipart 测试。

仓库客户端契约

Plain RPM 仓库在包文件旁提供 repodata/;Plain DEB 仓库在包文件旁提供 Packages 与 Packages.gz。配置好客户端信任策略后,可通过 file:// 或 HTTP 使用。

Managed 客户端必须消费完整 Repository Root:

  • APT 索引位于 dists/<dist>/main/binary-<arch>/,并引用根级 pool/。Release 声明 SHA-256 by-hash 索引;配置签名后增加 InRelease 与 Release.gpg。
  • RPM 元数据位于 dists/<dist>/<arch>/repodata/,通过相对位置回指根级 pool/。必须服务 整个 Repository,不能只发布一个架构目录。

默认 dnf reposync 会拒绝规范 Managed RPM 的父级相对包路径。该工作流应使用 sow export rpm-leaf 生成自包含副本。导出使用本地包路径并带有 完成清单,但不是第二个规范 Repository。

Managed RPM 与 export rpm-leaf 的 repomd data 时间戳固定为 0,没有 --metadata-timestamp 选项。因此它们不能直接接管保留了正数时间戳缓存的同一个 EL7 repository ID;换 baseurl 本身不能解决。需要客户端采用新的 repository ID 或清理元数据缓存。 若必须保留旧 ID 和缓存继续更新,请使用带显式时间戳的 Plain create,按 迁移指南 维护发布时间并重签 repomd。

迁移已有 Plain YUM 仓库

Plain 生成 primary、filelists、other 三份 XML,不生成旧式 SQLite 元数据或模块流。 普通 RPM 可以配合适当的 module_hotfixes=1 策略使用;模块 profile 与 dnf module install 需要另一套工作流。

EL7 YUM 会把 repomd.xml 中最大的 data 时间与缓存比较。SOW 0.5.0 新增 create --metadata-timestamp SECONDS,但默认仍为 0。已有在线仓库应使用不低于历史最大值 的时间,真实更新应继续递增;仅改变 revision 或文件 mtime 无效。调用者负责保存历史时间并 单独签署 repomd.xml。验收应保留原仓库 URL、ID、缓存和目标 GPG 检查策略。完整步骤见 迁移指南。

RPM 依赖投影遵循现代 createrepo_c 对 %pretrans、%posttrans 依赖的处理方式。0.5 新增的 回归测试巩固了这一契约,没有修改解析器。旧版 createrepo_c 可能输出不同数量的依赖行或 pre="1" 属性;应比较依赖含义和真实客户端行为,不能只比行数。对应上游变更见 PR #427。

0.5.0 暂不支持 RPM v6 包格式的签名;新的签名 tag 可能被当作未签名,签名或验签流程因而 拒绝该包。签名工作流请使用 RPM v4 格式包,不应期待 SOW 保留或验证 RPM v6 格式的签名。

发布 Provider

Provider 契约
filesystem 发布到预先存在且安全的 file:// endpoint 下。Target GC 只有在缓存 grace 与存储/公共缺失证据成立后,才执行精确条件删除。
r2 通过 S3 兼容存储传输发布。Target GC 只写入精确候选报告,绝不删除远端对象。

两种 Provider 都在配置 Prefix 下发布同一棵完整 pool/ + dists/ 树。public_endpoint 属于 Target 校验的一部分;SOW 不创建 HTTP 服务、DNS 记录、Bucket Policy、CDN 或凭据。启用生产 发布前,应先在非生产 Prefix 验证这些由部署方负责的表面。

Filesystem 与 R2 的 HTTP(S) 端点共用 canonical-GET 内容 verifier;Filesystem 还可使用基于 描述符的 file:// 校验,R2 公共端点则必须为 HTTP(S)。Target Name、public_endpoint 与 max_cache_ttl 只能通过显式 publish --rebind 修改;Storage Identity 与 Prefix 不可变。

部署门禁

交付前必须通过深度校验,并检查物理变更计划:

sow check -r REPOSITORY
sow changes 0 -r REPOSITORY

发布后,再访问实际 repomd.xml 或 Release URL,并运行目标软件包管理器。本地构建、Provider 写入、HTTP 可达与客户端安装是四个独立检查。

相关契约见仓库布局、签名模型与 发布与恢复。

5 - 命令

SOW CLI 的完整语法、参数、行为、输出与退出码。

每条顶层命令单独成页;config、repo、dist、retain、 export、log 等命令组在同一页说明其子命令。

二进制内置的 sow help 是语法权威。本手册在此基础上补充选择规则、状态变化、输出契约、 失败行为与可直接使用的示例。

命令索引

sow create 是 Plain 模式的仓库命令,直接作用于目录。sow init 用于启动 Managed 模式, 必要时会创建 sow.yml;其余有状态命令发现既有工作区。help 与 version 是工具命令, 不需要进入任何模式。

命令 模式 用途
sow create [DIR] Plain 就地生成平面 RPM/DEB 仓库
sow init [DIR] Managed 初始化工作区并收敛已声明的 Repository/Dist
sow config check|show Managed 校验配置或打印有效配置
sow repo ls|new|show|migrate|rm Managed 管理 Repository;migrate 是专用维护命令
sow dist ls|new|show|rm Managed 管理 Dist
sow add PATH... Managed 将软件包加入期望成员集
sow rm PACKAGE... Managed 从期望成员集中移除软件包
sow ls Managed 列出期望成员与已构建成员
sow show PACKAGE Managed 查看一个 Package Object
sow where PACKAGE Managed 在整个工作区定位 Package Object
sow status Managed 快速读取 Repository 状态
sow build Managed 将 Desired 状态收敛为 Built Generation
sow check Managed 校验配置、状态、包体、视图、签名与清单
sow changes [BASE_GENERATION] Managed 将 Generation 差异输出为文件交付计划
sow publish TARGET Managed 将已验证 Generation 发布到配置目标
sow retain add|ls|rm Managed 管理显式保留的 Generation 根
sow gc [TARGET] Managed 回收本地不可达包体,或维护发布目标
sow export rpm-leaf Managed 生成独立的 RPM 兼容 leaf
sow log [OPERATION] Managed 查询、导出与裁剪 Operation 审计账本

全局语法

sow [OPTIONS] COMMAND [ARGS]

不带参数运行 sow 会打印命令列表并退出 0。用 sow help COMMAND 或 sow help COMMAND SUBCOMMAND 查看内置帮助。sow version 与 sow --version 打印二进制身份。

SOW 没有全局 --format、--yes、--dry-run、-q、-v 或 --config。未知参数直接按 用法错误处理。

工作区发现

Managed 命令按以下规则寻找最近的 sow.yml:

  1. 有 -C/--workdir DIR 时从 DIR 开始,否则从当前目录开始。
  2. 逐级向上查找,在第一个 sow.yml 停止。
  3. 首次查找失败且设置了 SOW_DIR 时,再从该目录查找。显式 -C 会取代当前目录候选, 但不会禁用 SOW_DIR 回退。
  4. 仍未发现工作区则退出 2。

--workdir 只改变发现起点,不会切换进程工作目录;相对位置参数仍相对于真实当前目录解析。 sow create 完全不参与工作区发现。

Repository 选择

需要唯一 Repository 的命令按以下顺序选择:

  1. 显式 -r/--repo NAME;
  2. 发现起点所在的 Repository;
  3. 工作区中唯一的 Repository;
  4. 否则退出 2 并列出候选项。

repo new 与 repo rm 用位置参数接收 NAME,不接受 -r。sow where 默认搜索所有 Repository,-r 只用于收窄范围。发布目标自身绑定 Repository,因此 publish TARGET 与 gc TARGET 不再接受额外的 Repository 选择。

Dist 选择

add、rm、ls 要求明确的 Dist 集合,并按以下顺序选择:

  1. 一个或多个 -d/--dist NAME;
  2. 发现起点所在的 Dist;
  3. 所选 Repository 中唯一的 Dist;
  4. 否则退出 2 并列出候选项。

其他命令有意采用不同规则:

  • 未指定 -d 时,build、check、status 默认作用于全部 Dist;
  • show 默认搜索所选 Repository,-d 只用于收窄;
  • where 默认跨工作区搜索全部匹配 Dist,-r/-d 用于收窄;
  • changes 作用于整个 Repository,明确拒绝 -d。

锁

除 init 外,写命令接受 -T/--timeout DUR 与 -N/--no-wait。init 获取 Workspace 锁,且不提供 命令行超时覆盖;其他锁均为 Repository 级,但 repo new 与 repo rm 同样使用 Workspace 锁。 --timeout 0 表示无限等待;正数使用 Go duration,例如 500ms、30s、5m。--no-wait 立即失败;它与正数 timeout 互斥。获取锁失败退出 4。

只读命令不获取写锁;status 仍会报告 Repository 是否正被写者持锁。

并发

只有需要解析软件包、哈希包体、渲染索引或执行校验的命令才接受 -j/--jobs N:create、 add、rm、build、check 与 repo migrate。默认值是逻辑 CPU 数,且不得小于 1。

JSON 输出

支持 --json 的命令在 stdout 输出一个带版本的 Envelope,诊断信息仍写入 stderr:

{
  "schema": "sow.cli/v1",
  "command": "add",
  "ok": true,
  "repository": "demo",
  "operation": "1430722512865805553",
  "result": {},
  "errors": []
}

任何非零退出都会令 ok 为 false;部分成功的批处理仍会返回已提交项与失败项。完整结果结构见 JSON 输出。

不带 --json 时,每条 Managed 命令都有稳定的人类可读 renderer,适合交互使用,但不属于机器 协议。需要结构化字段的脚本应始终使用 --json;它也是唯一受支持的机器接口。

退出码

代码 含义
0 成功或幂等空操作
1 运行时 I/O、解析、渲染、签名或传输错误
2 用法、工作区发现或配置错误
3 批处理部分成功
4 写锁不可用
5 完整性/恢复失败,或 check 判定目录不可交付
6 可预期拒绝:冲突、受保护对象、无匹配或架构不兼容

各命令的精确触发条件见退出码。

5.1 - sow create

在普通目录中就地生成平面 RPM/DEB 仓库 —— Plain 平面模式的唯一入口。

sow create 把一个已经放着 .rpm / .deb 的目录变成平面仓库(flat repository):在包旁边写出索引 文件。它就是 Plain 平面模式的全部——没有 sow.yml、没有 SQLite、不做工作区发现。本页讲清单遍扫描 契约、RPM 发布时间、--pigsty 完成门禁,以及 --sign-with 的 RPM 包签名。

版本要求

--metadata-timestamp 要求 SOW 0.5.0 或更新版本。 切换已有维护脚本前,请检查实际二进制的 create --help。

语法

sow create [DIR] [-j N] [--metadata-timestamp SECONDS] [--pigsty] [-S KEY [--overwrite]] [-T DUR | -N] [--json]

DIR 默认为当前目录。

说明

create 读取 DIR 顶层的普通文件,按发现的内容渲染对应索引:有 RPM 就生成 repodata/,有 DEB 就 生成 Packages 与 Packages.gz,混合目录两套一起生成。架构全部来自包头——Plain 模式没有架构参数, 也没有架构许可表。

平面元数据只引用同目录的包:RPM 的 location 是裸 basename,DEB 的 Filename 是 ./<basename>。无论目录作为 file:// 源还是 HTTP 根暴露,两者都保持相对引用。

默认情况下 create 不删除、不移动、不重命名、不重签、不改写任何一个包字节。它只替换自己拥有的索引 路径,未知文件原样保留。

参数

参数 说明 默认
-j, --jobs N 唯一一次包哈希/解析扫描的并发 worker 数 逻辑 CPU 数
--metadata-timestamp SECONDS RPM repomd 的 data 时间戳,取值 0 至 253402300799 的 Unix 秒;不影响 DEB、包时间或 gzip 头 0
--pigsty 启用 Pigsty 兼容清理与完成 marker 关闭
-S, --sign-with KEY 用 16/40/64 位十六进制 GPG key ID 给未签名 RPM 补签 关闭
--overwrite 重签全部 RPM;必须与 --sign-with 同用 关闭
-T, --timeout DUR 等待锁的最长时间;0 表示无限等待 0
-N, --no-wait 锁被占用时立即失败 false
--json 输出版本化 JSON envelope false
-h, --help 显示帮助 —

扫描规则

  • 只考虑顶层、以 .rpm 或 .deb 结尾的普通文件。
  • 不递归、不跟随符号链接、不读工作区配置。
  • 所有有效版本都进入索引。两个文件对应同一逻辑坐标但内容不同时,硬失败。
  • 默认模式下没有受支持包会被拒绝;--pigsty 接受空权威集合,以便中断的“删除全部包”清理能够收敛并写 marker。
sow create /srv/empty
plain: scan /srv/empty: no supported top-level regular RPM or DEB packages

包 I/O 与最终校验

默认未签名路径中,每个选中包恰好只有一次完整内容扫描。worker 打开包、计算一次 SHA-256、解析 header/control,并保留完整解析结果。RPM XML 与 DEB Packages 都从该结果渲染;渲染和生成元数据 校验都不会重新打开包体。--jobs 并行化这一次扫描,规范结果顺序保证 worker 调度不改变输出字节。

发布前,create 重新列出顶层包集合,把文件 identity、类型/mode、size 与 mtime 同扫描后快照比较。 这是便宜的 stat 校验,不是第二次哈希。集合或 stat 变化会在任何 stage 输出发布前以完整性错误 5 退出。原地改字节同时刻意保持 inode、size、mtime 不变,不属于本机协作写者契约。

显式 RPM 签名是例外:复制、签名、签名验证以及解析最终签后 RPM,会对实际修改的包增加必要读取。

确定性输出与幂等

不强制重签软件包时,对给定输入集和显式时间参数,渲染出的元数据是字节稳定的:gzip 输出确定,repomd.xml 写 <revision>0</revision>,data timestamp 默认为 0,可用 --metadata-timestamp 显式指定。使用相同参数重跑未变化的目录不会改写文件,报告 noop=true:

sow create /srv/flat
created /srv/flat: rpm=3 deb=1 signed=0 removed=0 marker=false noop=false recovered=false

sow create /srv/flat
created /srv/flat: rpm=3 deb=1 signed=0 removed=0 marker=false noop=true recovered=false

已有 YUM 仓库的发布时间

EL7 的 YUM 会拒收 data 最大时间比缓存更早的 repomd。因此迁移已有在线仓库时,不要使用默认的 0;给本次发布指定一个不低于历史最大值的时间,真实内容更新建议严格递增。EL7 接受相等时间;迁移指南建议内容变化时至少加一秒,是为了区分各次发布:

每次更新都要传入 --metadata-timestamp;省略时仍使用 0,不会继承旧值。 接受范围为 0..253402300799 Unix 秒(截至 9999 年)。当代毫秒纪元值会因超出范围而被 拒绝;SOW 不会自动辨认较小数值的单位。这是输入范围,不代表所有客户端都支持任意日期。 过大的未来时间可能使 EL7 拒绝随后正常时间的索引,直到清理旧元数据缓存。repomd 变化后,必须在发布前 重新生成并替换 .asc 签名;create 不会更新或删除旧 repomd.xml.asc。

sow create /srv/flat --metadata-timestamp 1790049000

这里的数字是示例,生产值由维护流程选择并记录。只修改 revision 或文件 mtime 无效。参数仅控制 RPM repomd 的 data 时间,不改包字节、三份 XML.gz 或 DEB 索引;不能代替 repomd 的 GPG 签名。改动 repomd 后,调用者应重新签署 .asc,SOW create 不负责仓库入口签名。

SOW 不自动读取当前时钟、历史备份或远端缓存;维护包装应比较真实索引内容,无变化时复用原 repomd 与有效签名,真实更新时使用 max(当前秒, 历史高水位+1)。第一次从 timestamp=0 修复时,要把可信旧备份的时间纳入下限。恢复旧包集合也属于新发布,不应把索引时间倒退。

该参数仅用于 plain create,不改变 managed build 或 RPM leaf export 的时间语义。

备份、发布时间记录、签名与带缓存客户端验收的完整流程,见迁移现有 YUM 仓库。

repo_complete 门禁

默认模式永不生成 repo_complete。如果 marker 已经存在,create 宁可拒绝写索引,也不留下一个内容 已过期却仍宣称"完成"的旧 marker:

sow create /srv/pigsty
plain: marker gate /srv/pigsty/repo_complete: repo_complete exists; use --pigsty or remove it explicitly before rebuilding

要么加 --pigsty 重跑(由它按文档顺序撤下并重新发布 marker),要么自己先把 marker 移走。

–pigsty

--pigsty 在一次调用中同时启用三项相互关联的兼容动作。发布顺序受 marker 门禁保护,但中断后是 重新扫描重建,不会从 journal 恢复:

  1. 删除解析架构为 i386 的 DEB;RPM 不会仅因为架构是 i386/i486/i586/i686 而被删除。
  2. 删除二进制包名恰为 patroni 且 upstream 版本恰为 3.0.4 的 RPM/DEB。RPM 比较 VERSION,忽略 epoch 与 release;DEB 先剥掉 epoch 与 Debian revision 再比。3.0.4+foo 不算命中。
  3. 全部索引渲染成功后写出 repo_complete:剩余顶层 RPM/DEB 的 SHA-256,按 basename 字节序排序, 格式为 <sha256><两个空格><basename>。
sow create /srv/pigsty --pigsty
created /srv/pigsty: rpm=2 deb=0 signed=0 removed=2 marker=true noop=false recovered=false
cat /srv/pigsty/repo_complete
b4111ef2a51542eacc9bd1ebd080da02e53d400f9d172530c75a1e4ac06e7ead  centos-release-7-2.1511.el7.centos.2.10.x86_64.rpm
d6f332ed157de1d42058ec785b392a1cc4b5836c27830af8fbf083cce29ef0ab  epel-release-7-5.noarch.rpm

清理只触碰解析成功且命中规则的顶层普通包文件,绝不按宽泛 glob 删目录或未知文件。

发布顺序对以 marker 为门禁的调用方很关键:先撤下已有的 repo_complete,再切换索引,只在替换 元数据安装后删除命中包,最后才写入新 marker。调用方必须把 marker 缺失视为尚未完成。

Marker 语义

把 repo_complete 缺失当作"构建进行中"。这正是 --pigsty 设计围绕的契约。

RPM 包签名

-S/--sign-with KEY 是修改 RPM 字节的显式授权。KEY 必须是恰好 16、40 或 64 位十六进制 GPG key ID/fingerprint,接受可选的 0x 或 0X 前缀。SOW 将其规范化为大写,通过 _gpg_name macro 传给环境中的 rpm --addsign。私钥、passphrase、GPG home、pinentry 以及额外 RPM macro 都由你的运行环境提供—— SOW 不接收、不持久化、不回显任何秘密。

  • 默认只给没有可解析嵌入 OpenPGP 签名的 RPM 补签;已有签名的包保持原字节。
  • --overwrite 必须与 --sign-with 同用,改为对全部保留 RPM 执行 rpm --resign。
  • 签名发生在同文件系统的私有 stage 副本上。每个结果都会重新解析以确认嵌入签名存在、 signature-neutral digest 与 NEVRA 未变,并以最终完整字节生成 rpm-md。
  • --pigsty 清理后至少要保留一个顶层 RPM,且 PATH 中要有 rpm。
sow create /srv/flat -S 0123456789ABCDEF --overwrite
plain: sign rpm epel-release-7-5.noarch.rpm: rpm executable is required for --sign-with
sow create /srv/deb-only -S 0123456789ABCDEF
plain: sign rpm: --sign-with requires at least one retained top-level RPM package
sow create /srv/flat --overwrite
usage error: --overwrite requires --sign-with
sow create /srv/flat -S ZZZZ
usage error: --sign-with must be a 16, 40, or 64 hexadecimal GPG key ID/fingerprint

锁、staging 与覆盖重建

create 对目标目录取写锁,服从 --timeout/--no-wait。全部元数据先写入私有 stage 并验证,之后 才开始发布。锁协调本机 SOW 写者;任意外部进程同时修改包不属于受支持负载。

Plain create 不创建持久操作 journal、回滚 pre-image 或 recovery trash。发布由多个单文件 rename 组成, 因此崩溃可能留下部分替换的派生文件。使用你当前想要的参数重新执行 sow create:它丢弃保留命名空间 中的陈旧 Plain 临时状态,再按现在仍存在的包重建全部索引。recovered 始终为 false; 重跑是一次全新覆盖构建,不是事务重放。

平面目录没有整个仓库的 generation 指针,RPM 与 DEB 入口也无法用一次 POSIX rename 同时切换。因此 Plain 不承诺跨文件瞬时原子性。--pigsty 用 repo_complete 做门禁;需要事务恢复时使用 Managed。

示例

给混合目录建索引:

sow create /srv/flat
created /srv/flat: rpm=3 deb=1 signed=0 removed=0 marker=false noop=false recovered=false
ls /srv/flat
centos-release-6-0.el6.centos.5.x86_64.rpm
centos-release-7-2.1511.el7.centos.2.10.x86_64.rpm
epel-release-7-5.noarch.rpm
libpq5_18.3-1_amd64.deb
Packages
Packages.gz
repodata

机器可读结果:

sow create /srv/flat --json
{"schema":"sow.cli/v1","command":"create","ok":true,"repository":null,"operation":null,"result":{"dir":"/srv/flat","rpm":3,"deb":1,"kept":["centos-release-6-0.el6.centos.5.x86_64.rpm","centos-release-7-2.1511.el7.centos.2.10.x86_64.rpm","epel-release-7-5.noarch.rpm","libpq5_18.3-1_amd64.deb"],"removed":[],"marker":false,"noop":true,"recovered":false},"errors":[]}

用八个 worker 构建 Pigsty 平面仓库,并使用根据发布历史选定的时间:

sow create /www/pigsty -j 8 --pigsty --metadata-timestamp "${PUBLISH_TIME:?Set the verified publication time}"

调用者负责记录该时间,并在客户端要求时为生成的 repomd.xml 签名。

失败时的 envelope:

sow create /srv/empty --json
{"schema":"sow.cli/v1","command":"create","ok":false,"repository":null,"operation":null,"result":null,"errors":[{"code":6,"class":"rejected","message":"operation rejected: plain: scan /srv/empty: no supported top-level regular RPM or DEB packages"}]}

退出码

码 触发条件
0 索引写出成功,或输入未变化产生 no-op
1 目录不可读或不存在、包解析失败、渲染失败、签名工具失败
2 用法错误——时间戳非法或重复指定、--overwrite 未配 --sign-with、key 格式非法、--no-wait 与非零 --timeout 同用
4 目录写锁被占用,且给了 --no-wait 或 --timeout 到期
5 发布前输入集合/stat 变化,或受控输出路径未通过完整性检查
6 未找到受支持的包、撞上 repo_complete 门禁、对 DEB-only 目录用 --sign-with、坐标冲突

参见

5.2 - sow init

创建工作区,并收敛 sow.yml 中已声明的 Repository 与 Dist。

sow init 创建根级 sow.yml 与私有状态目录 .sow/,这两样东西让一个目录成为工作区(Workspace)。 它同时也是手写配置的收敛命令:如果 sow.yml 里已经声明了 Repository 与 Dist,init 会把还不存在 的那些实体化出来,已完成的原样跳过。

语法

sow init [DIR] [--json]

DIR 默认为当前目录。init 不接受 -C/--workdir——位置参数已经明确指定了目标。

说明

首次 init 写出最小配置与私有状态目录:

sow init .
initialized /srv/repo: config_created=true repositories_initialized=0 dists_initialized=0
cat sow.yml
schema: sow/v3
architectures:
  - x86_64
  - aarch64
ls -a /srv/repo
.  ..  .sow  sow.yml

.sow/ 里放着 workspace.lock、工作区生命周期命令使用的持久文件 journal workspace-ops/、 repo-locks/,以及后续每个 Repository 一个的 SQLite 数据库。它的权限是 0700,绝不能对外提供 HTTP 访问。

参数

参数 说明 默认
--json 输出版本化 JSON envelope false
-h, --help 显示帮助 —

幂等规则

init 被设计成可以反复运行——无论是在 provisioning 脚本里还是手工执行:

  1. 创建新配置时写入 schema: sow/v3 与默认 architectures: [x86_64, aarch64]。

  2. 它从不自动创建 Repository。请用 sow repo new,或先在 sow.yml 中声明。

  3. 它从不覆盖已存在的 sow.yml。重复运行只报告现状,并列出发现了什么:

    sow init .
    initialized /srv/repo: config_created=false repositories_initialized=0 dists_initialized=0
    
  4. 非空目录可以初始化,但若已有文件与 SOW 保留路径冲突则失败。

收敛已声明的配置

如果 sow.yml 里已经描述了 Repository 与 Dist,init 会为它们补齐缺失的目录树、SQLite 数据库与 空索引。已初始化的对象直接跳过,因此计数器准确反映本次运行做了什么。

schema: sow/v3
architectures: [x86_64, aarch64]

repos:
  pgsql:
    dists:
      el9:
        format: rpm
        limit: 1
        exclude:
          - kind: [debuginfo, debugsource]
      trixie:
        format: deb
  infra:
    protected: true
    dists:
      el9:
        format: rpm
sow init .
initialized /srv/repo: config_created=false repositories_initialized=2 dists_initialized=3
sow repo ls
NAME	PROTECTED	DISTS	GENERATION	STATUS	PACKAGES	MEMBERSHIPS
infra	true	1	1	clean	0	0
pgsql	false	2	2	clean	0	0

这样创建出来的 Dist 立刻具备协议完整的空发布面:RPM Dist 每个架构视图有一份空 repodata/,DEB Dist 有空的 Packages/Packages.gz、by-hash 与 Release。

再跑一次什么都不会变:

sow init . --json
{"schema":"sow.cli/v1","command":"init","ok":true,"repository":null,"operation":null,"result":{"workspace":"/srv/repo","config_created":false,"repositories_initialized":0,"dists_initialized":0,"existing":["sow.yml"]},"errors":[]}

锁与恢复

工作区生命周期命令——init、repo new、repo rm——运行在目标 Repository 数据库存在之前或被删除 之后,因此它们使用 .sow/workspace.lock 加 .sow/workspace-ops/ 里的持久文件 journal,而不是 SQLite Operation Journal。被中断的 init 会由下一条工作区生命周期命令前滚完成或回滚。

示例

建好工作区后手工添加 Repository:

mkdir -p /srv/repo && cd /srv/repo
sow init
sow repo new infra
sow repo new pgsql
sow dist new el9 --format rpm -r pgsql
sow dist new trixie --format deb -r pgsql

初始化当前目录之外的目录:

sow init /srv/repo

从版本控制中的配置文件 provision:

install -m 0644 sow.yml /srv/repo/sow.yml
sow init /srv/repo
sow config check -C /srv/repo

退出码

码 触发条件
0 工作区创建成功,或已收敛(no-op)
1 写配置或状态目录时的运行时 I/O 错误
2 用法错误,或已存在的 sow.yml 解析/校验不通过
3 部分成功——部分声明的 Repository/Dist 已提交,至少一个失败
5 工作区 journal 无法恢复到终态
6 已有文件与 SOW 保留路径冲突

参见

5.3 - sow config

只读校验 sow.yml,并打印任意作用域的有效配置。

sow config 有两个只读子命令。config check 是对 sow.yml 的全量预检——每次手工改完配置以及在 CI 里都该跑一遍。config show 打印 SOW 实际算出来的配置,用它确认默认值、继承的架构与规范化别名 是不是按你预期解析的。

两个子命令都不创建目录、不碰数据库、不自动修正你的文件。

语法

sow config check [-C DIR] [--json]
sow config show [--all] [-C DIR] [-r NAME] [-d NAME]... [--json]

sow help config 会列出两者。

sow config check

解析并校验完整的 sow.yml:schema 版本、名称、路径冲突、架构许可表、Dist 格式、成员策略与签名 key 引用。它会回报解析到的工作区以及校验了多少对象。

sow config check
configuration valid: /srv/repo repositories=1 dists=2
sow config check --json
{"schema":"sow.cli/v1","command":"config check","ok":true,"repository":null,"operation":null,"result":{"workspace":"/srv/repo","repositories":1,"dists":2},"errors":[]}

参数

参数 说明 默认
-C, --workdir DIR 工作区发现的起始目录 当前目录
--json 输出版本化 JSON envelope false
-h, --help 显示帮助 —

严格拒绝未知字段

未知键是错误,不是警告。一个拼写错误不会静默地让某条策略失效:

sow config check
configuration error: load config "/srv/repo/sow.yml": parse sow.yml: yaml: unmarshal errors:
  line 8: field bogus_field not found in type config.DistConfig

schema 版本被钉死:

sow config check
configuration error: load config "/srv/repo/sow.yml": config schema must be "sow/v3", got "invalid"

唯一有效值是 schema: sow/v3。不要靠修改 Schema 字符串绕过校验错误。

check 还会验证声明的每个签名 key 引用可解析且适用于签名——过程中绝不打印密钥材料。如果你从许可表 里删掉一个架构,而仍有 Dist 配置、Membership 或已构建代在用它,config check 会拒绝该配置。

sow config show

以 YAML 打印当前选定作用域的有效配置。

sow config show
schema: sow/v3
architectures:
  - x86_64
  - aarch64
repos:
  pigsty:
    protected: false
    signing:
      rpm:
        packages:
          mode: never
    dists:
      el9:
        format: rpm
        architectures:
          - x86_64
          - aarch64
        limit: 0
        exclude: []
      trixie:
        format: deb
        architectures:
          - x86_64
          - aarch64
        limit: 0
        exclude: []

对比磁盘上的文件——里面只有你写的内容:

cat sow.yml
schema: sow/v3
architectures:
  - x86_64
  - aarch64
repos:
  pigsty:
    signing:
      rpm:
        packages:
          mode: never
    dists:
      el9:
        format: rpm
      trixie:
        format: deb

show 补上了 protected: false、每个 Dist 继承来的 architectures、limit: 0 与空的 exclude 列表。架构一律以规范化 family(x86_64、aarch64)打印,绝不用生态别名——amd64 与 arm64 只是 同两个 family 的 DEB 写法。

参数

参数 说明 默认
--all 展开整个工作区的默认值与规范化架构 关闭
-C, --workdir DIR 工作区发现的起始目录 当前目录
-r, --repo NAME 选择一个仓库 按选择规则
-d, --dist NAME 选择一个 Dist;可重复 按选择规则
--json 输出版本化 JSON envelope false
-h, --help 显示帮助 —

用 -r/-d 做作用域投影

-r 与 -d 把输出收窄到选中的对象。要回答"这一个 Dist 上实际生效的策略是什么",这是最快的方式:

sow config show -r pigsty -d el9
schema: sow/v3
architectures:
  - x86_64
  - aarch64
repos:
  pigsty:
    protected: false
    signing:
      rpm:
        packages:
          mode: never
    dists:
      el9:
        format: rpm
        architectures:
          - x86_64
          - aarch64
        limit: 0
        exclude: []

--all 方向相反:无论你站在哪里,它都展开整个工作区。

秘密永不输出

密钥材料与 passphrase 不会出现在 config show、JSON、操作日志或错误文本中。只显示引用形态 (file://…、env://…、agent://…)与 fingerprint。

示例

在 CI 里先校验再构建:

sow config check -C /srv/repo || exit 1
sow build -r pgsql

比较两个 Dist 的有效策略:

sow config show -r pgsql -d el9 > /tmp/el9.yml
sow config show -r pgsql -d el9-beta > /tmp/beta.yml
diff -u /tmp/el9.yml /tmp/beta.yml

退出码

码 触发条件
0 配置合法,或输出成功打印
1 读取配置文件时的运行时 I/O 错误
2 用法错误、工作区未找到、未知字段、schema 不符,或任何校验失败
6 指定的仓库或 Dist 不存在

config check 把校验失败报为退出码 2 而不是 6:非法的 sow.yml 属于配置错误,不是被拒绝的 操作。

参见

5.4 - sow repo

列出、创建、查看与删除仓库 —— 锁、事务与 Generation 的边界。

一个仓库(Repository)独占一份 pool/、一份 dists/、一个 SQLite 数据库与一个私有状态目录。它是 锁、事务恢复、Generation 编号与 Changeset 的边界——跨仓库不去重,也不承诺跨仓库原子提交。 sow repo 管理的就是这条边界。

语法

sow repo ls [-C DIR] [--json]
sow repo new NAME [-C DIR] [-T DUR | -N] [--json]
sow repo show [NAME] [-C DIR] [-r NAME] [--json]
sow repo migrate [NAME] [--abort] [-j N] [-C DIR] [-r NAME] [-T DUR | -N] [--json]
sow repo rm NAME [-f|--force] [-C DIR] [-T DUR | -N] [--json]

命名

仓库名必须匹配 [a-z0-9][a-z0-9._-]*,且不能是 .、..、.sow、pool、dists,也不能与工作区 保留文件冲突。

sow repo new .sow
operation rejected: managed: operation rejected: name ".sow" must match [a-z0-9][a-z0-9._-]*

路径不可指定。仓库永远位于 <workspace>/<NAME>/。

sow repo ls

只读列出工作区里的全部仓库。

sow repo ls
NAME	PROTECTED	DISTS	GENERATION	STATUS	PACKAGES	MEMBERSHIPS
infra	true	1	1	clean	0	0
pgsql	false	2	2	clean	0	0
参数 说明 默认
-C, --workdir DIR 工作区发现的起始目录 当前目录
--json 输出版本化 JSON envelope false

STATUS 取值为 clean、dirty、recovering 或 error。各状态对客户端意味着什么,见 事务与恢复。

sow repo new

原子更新 sow.yml,然后创建 <workspace>/<NAME>/{pool,dists}、SQLite 数据库与私有状态目录。新仓库 处于 Generation 0、clean 状态。

sow repo new pigsty
created pigsty: path=/srv/repo/pigsty protected=false dists=0 generation=0 status=clean packages=0 memberships=0
参数 说明 默认
-C, --workdir DIR 工作区发现的起始目录 当前目录
-T, --timeout DUR 等待锁的最长时间;0 无限等待 0
-N, --no-wait 锁被占用时立即失败 false
--json 输出版本化 JSON envelope false

repo new 取的是工作区锁而不是仓库锁——此时仓库数据库还不存在。它不接受 -r,位置参数已经指明了 目标。

对已存在的仓库再跑一次是收敛型 no-op,只报告当前状态,因此在 provisioning 脚本里是安全的。

sow repo show

只读显示一个仓库的细节。省略 NAME 时按 CLI 全局约定 中的仓库选择规则 解析。

sow repo show pigsty
repository pigsty:
  path: /srv/repo/pigsty
  protected: false
  dists: 2
  generation: 6
  desired_revision: 6
  status: clean
  packages: 5
  memberships: 8
  config: {"protected":false,"signing":{"rpm":{"packages":{"mode":"never"}}},"dists":{"el9":{"format":"rpm","architectures":["x86_64","aarch64"],"limit":1,"exclude":[{"kind":["debuginfo","debugsource"]}]},"trixie":{"format":"deb","architectures":["x86_64","aarch64"],"limit":0,"exclude":null}}}
  dirty_reasons: []
  recent_operation: id=4142220455201181493 kind=add state=done error_class= created_at=2026-08-04T04:09:24.995538Z updated_at=2026-08-04T04:09:25.332772Z
参数 说明 默认
-C, --workdir DIR 工作区发现的起始目录 当前目录
-r, --repo NAME 省略 NAME 时用它选择仓库 按选择规则
--json 输出版本化 JSON envelope false

同时给出 NAME 与 -r 时两者必须一致;不一致会在读取任何状态前失败:

sow repo show demo -r empty
operation rejected: repo show NAME "demo" and --repo "empty" select different repositories

sow repo migrate

这是专用维护命令,不属于全新的 0.5 Managed 工作流。新建的 0.5 Repository 使用 Schema v13 与当前单包体布局。从既有 v0.3 或 v0.4 Workspace 升级时必须显式迁移:先停止全部写入并备份,再在 执行普通读写之前逐个迁移所有已配置 Repository。

cp -a /srv/sow /srv/sow.backup-before-0.5.0   # GNU cp;其他平台请用 rsync -aH 或 tar
sow repo migrate pigsty -C /srv/sow
sow repo migrate pgsql -C /srv/sow

备份必须保留硬链接(DEB 的 by-hash 条目是硬链接),macOS 的 cp -a 不保留硬链接。输出会报告 Schema 变化,例如 migrated repository pigsty: single-payload-v1 -> single-payload-v1 schema=12->13 …; JSON 中有 schema_from 与 schema_to,重复迁移报告 schema=13->13。Repository 迁移之前, 其写命令以 5 退出并提示 repository schema v12 predates this binary (v13); back up the workspace, then run `sow repo migrate NAME`。

Schema v13 为候选包池路径归属查询增加索引,避免每次 add 全表扫描发布历史。迁移时创建索引 需要一次性的时间与私有数据库空间。随后执行 sow build、sow check,刷新受影响的 RPM 认证 与 APT 元数据契约。v0.3 数据库还会获得 v11/v12 的修复:按全部 Dist 重新派生 Repository 状态;在不猜测缺失 历史签名者的前提下修复 Publication 与 Generation Signer Projection;移除陈旧 abandoned-object evidence;并回填 append-only publication-target binding ledger 的 Revision 1。v0.3 未记录的历史 Signer 保持显式未验证,不能进入 Current Head,也不能成为 retained trust assertion。

Schema Transition 完成后不可逆,不要再用旧版本 SOW 二进制打开数据库,也不要手工修改 PRAGMA user_version。--abort 只适用于诊断出的 pre-commit layout-maintenance attempt,不能 撤销已经完成的 Schema Migration。除升级或 SOW 明确诊断外,不要试探性执行 migrate。

不支持从开发期 C2 布局启动新的转换。请保留原文件并用受支持的发布版重建。已经记录的布局 迁移仍可按绑定的原计划完成,但结果不是受支持的 0.5 Repository:之后的下一次写入仍可能构建出 一个 Generation,随后 Repository 即处于 status=error。请用原始包重建。

参数 含义 默认值
-j, --jobs N 并行校验/渲染 worker 逻辑 CPU 数
--abort 在提交决策前放弃维护尝试 false
-C, --workdir DIR 工作区发现起点 当前目录
-r, --repo NAME 省略 NAME 时选择仓库 选择规则
-T, --timeout DUR 最长锁等待;0 无限等待 0
-N, --no-wait 锁被占用时立即失败 false
--json 输出版本化 JSON envelope false

sow repo rm

删除一个仓库:它在 sow.yml 中的条目、数据库、pool/、dists/ 与私有状态。绝不跟随符号链接, 也绝不越出固定的仓库路径。

不加 -f 时,只能删除空仓库——没有 Dist、没有 Membership、没有 Package Object:

sow repo rm infra
removed repository infra
sow repo rm pgsql
operation rejected: managed: operation rejected: repository "pgsql" is not empty; use --force
sow repo rm pgsql -f
removed repository pgsql
参数 说明 默认
-f, --force 删除非空的、未 protected 的仓库 false
-C, --workdir DIR 工作区发现的起始目录 当前目录
-T, --timeout DUR 等待锁的最长时间;0 无限等待 0
-N, --no-wait 锁被占用时立即失败 false
--json 输出版本化 JSON envelope false

-f 到底降级了什么

-f 只放宽为空这一个前置条件。它不绕过路径安全检查、不绕过符号链接拒绝、也不绕过 protected 门禁。

protected

sow.yml 中的 protected: true 直接封死仓库删除,加 -f 也不行:

sow repo rm alpha -f
operation rejected: managed: operation rejected: repository "alpha" is protected

要删除受保护的仓库,必须先改 sow.yml,通过 sow config check,再重试。没有 --yes,也没有临时覆盖开关。

protected 只作用于仓库删除。受保护仓库上的包级操作不受影响——add、rm、build,乃至 dist rm 都照常工作:

sow dist rm el9 -r alpha -f
removed dist el9 from alpha

示例

为两层结构创建仓库:

sow repo new infra
sow repo new pgsql

在 cron 任务中快速失败,而不是排队等另一个写者:

sow repo new nightly -N || echo "另一个写者持有工作区锁"

一行一个仓库地做审计:

sow repo ls --json | jq -r '.result.repositories[] | "\(.name)\t\(.status)\tgen=\(.generation)"'

退出码

码 触发条件
0 列出、创建、显示、迁移、放弃 pre-commit transition 或删除成功;或 repo new 收敛了已存在的仓库
1 创建或删除目录树时的运行时 I/O 错误
2 用法错误、工作区未找到,或仓库选择有歧义
4 工作区锁被占用,且给了 --no-wait 或 --timeout 到期
5 工作区 journal 的完整性或恢复错误
6 名称非法、仓库不存在、非空但未给 -f、protected,或 NAME 与 -r 冲突

参见

5.5 - sow dist

列出、创建、查看与删除 Dist —— 客户端真正消费的、单一格式的具名成员集。

Dist 是一个仓库内、单一格式(rpm 或 deb)的具名包集合。客户端指向的就是它。一个仓库可以同时拥有 RPM Dist 与 DEB Dist,两者共用一份 pool/,但渲染进完全独立的 dists/ 子树。

语法

sow dist ls [-C DIR] [-r NAME] [--json]
sow dist new NAME --format rpm|deb [-C DIR] [-r NAME] [-T DUR | -N] [--json]
sow dist show NAME [-C DIR] [-r NAME] [--json]
sow dist rm NAME [-f|--force] [-C DIR] [-r NAME] [-T DUR | -N] [--json]

命名

Dist 名与仓库名规则相同:[a-z0-9][a-z0-9._-]*,排除 .、..、.sow、pool、dists。

对 SOW 而言这个名字是不透明字符串。el9、trixie、el9-beta、customer-acme、2026-07-31 都只是 名字——beta 频道、按客户切分的视图、快照,都是你自己施加的命名约定,不是 SOW 建模的功能。

sow dist ls

只读平铺列出选定仓库的全部 Dist。

sow dist ls -r pigsty
NAME	FORMAT	ARCHITECTURES	DESIRED	BUILT	GENERATION	DIRTY	DIRTY_REASONS
el9	rpm	x86_64,aarch64	0	0	1	false	[]
trixie	deb	x86_64,aarch64	0	0	2	false	[]

DESIRED 与 BUILT 是成员计数。两者不一致时,DIRTY_REASONS 会说明原因:

sow dist ls -r demo
NAME	FORMAT	ARCHITECTURES	DESIRED	BUILT	GENERATION	DIRTY	DIRTY_REASONS
el9	rpm	x86_64,aarch64	2	1	4	true	["Desired and Built membership sets differ"]
参数 说明 默认
-C, --workdir DIR 工作区发现的起始目录 当前目录
-r, --repo NAME 选择一个仓库 按选择规则
--json 输出版本化 JSON envelope false

架构按规范化 family 打印。JSON 输出同时给出两种写法,用它可以确认 DEB Dist 渲染的是 binary-amd64 与 binary-arm64:

"architectures":[{"family":"x86_64","ecosystem_arch":"amd64"},{"family":"aarch64","ecosystem_arch":"arm64"}]

sow dist new

创建一个普通的、后续可继续修改的 Dist。唯一的业务参数是 --format。

sow dist new el9 --format rpm -r pigsty
created el9: format=rpm architectures=x86_64,aarch64 members=0/0 generation=1 dirty=false
sow dist new trixie --format deb -r pigsty
created trixie: format=deb architectures=x86_64,aarch64 members=0/0 generation=2 dirty=false
参数 说明 默认
--format FORMAT 必填;rpm 或 deb —
-C, --workdir DIR 工作区发现的起始目录 当前目录
-r, --repo NAME 选择一个仓库 按选择规则
-T, --timeout DUR 等待锁的最长时间;0 无限等待 0
-N, --no-wait 锁被占用时立即失败 false
--json 输出版本化 JSON envelope false

--format 必填且取值封闭:

sow dist new x -r alpha
usage error: dist new requires --format rpm|deb
sow dist new x --format zip -r alpha
usage error: --format must be rpm or deb

没有 --arch。架构从工作区许可表继承;高级用户在 sow.yml 里为某个 Dist 声明子集来收窄。策略 (limit、exclude)同样只在 sow.yml 中配置,绝不在命令行上重复建模。

用相同名称与相同格式重跑 dist new 是收敛操作,只报告当前状态。同名但格式不同会被拒绝:

sow dist new el9 --format deb -r alpha
operation rejected: managed: operation rejected: dist "el9" already exists with format rpm

三方事务

dist new 要在三个地方同时提交:sow.yml 条目、仓库数据库、磁盘目录树。它走 SQLite Operation Journal(此时仓库数据库已存在,与 repo new 不同),并产生一个带空索引的新 Built Generation。

因此新建的 Dist 立刻具备协议完整的空发布面。RPM Dist 在每个架构视图下有一份空 repodata/;DEB Dist 有空的 Packages、Packages.gz、by-hash/SHA256/ 条目, 以及 Release;配置签名时再生成 InRelease 与 Release.gpg。

sow dist show

只读显示一个 Dist 的细节。

sow dist show trixielim -r pgsql
dist trixielim:
  format: deb
  architectures: x86_64,aarch64
  desired_members: 3
  built_members: 3
  generation: 6
  status: clean
  dirty: false
  dirty_reasons: []
参数 说明 默认
-C, --workdir DIR 工作区发现的起始目录 当前目录
-r, --repo NAME 选择一个仓库 按选择规则
--json 输出版本化 JSON envelope false

JSON 形态额外给出 effective_config_sha256,即解析后 Dist 配置的摘要。当你改动 limit、exclude 或签名 key 时,正是这个摘要让 Dist 变 dirty——配置身份变了,已构建代就不再等于期望状态。

sow dist show el9 -r pgsql --json
{"schema":"sow.cli/v1","command":"dist show","ok":true,"repository":"pgsql","operation":null,"result":{"name":"el9","format":"rpm","architectures":[{"family":"x86_64","ecosystem_arch":"x86_64"},{"family":"aarch64","ecosystem_arch":"aarch64"}],"desired_members":0,"built_members":0,"generation":"00000000000000000001","dirty":false,"status":"clean","effective_config_sha256":"a0b3ae2f943bc4fce951aaadda0fc8fb146ccf7944b0193a0dcc2b86ddc7ce7e","config":{"format":"rpm","architectures":["x86_64","aarch64"],"limit":1,"exclude":[{"kind":["debuginfo","debugsource"]}]}},"errors":[]}
sow dist show nope -r demo
operation rejected: managed: operation rejected: dist "nope" does not exist

sow dist rm

删除一个 Dist 的 Membership 与衍生索引。

sow dist rm el9 -r pgsql
operation rejected: managed: operation rejected: dist "el9" is not empty; use --force
sow dist rm el9 -r pgsql -f
removed dist el9 from pgsql
参数 说明 默认
-f, --force 删除成员与索引,但保留 pool 中的包 false
-C, --workdir DIR 工作区发现的起始目录 当前目录
-r, --repo NAME 选择一个仓库 按选择规则
-T, --timeout DUR 等待锁的最长时间;0 无限等待 0
-N, --no-wait 锁被占用时立即失败 false
--json 输出版本化 JSON envelope false

删除 Dist 不会删除 pool 字节

删除 Dist 绝不会从 pool/ 删包。整个 Dist 目录被移入恢复区后原子移除,包池完全不受影响:

sow dist rm el9 -r pgsql -f
removed dist el9 from pgsql

find pgsql -type f
pgsql/pool/e/epel-release/epel-release-7-5.noarch.rpm

失去引用的 Pool 对象会继续保留,直到 sow gc 证明它不再被当前、保留、恢复、发布以及 活动维护操作等任何安全根引用。

仓库的 protected: true 只封死仓库删除;受保护仓库上的常规 Dist 维护照常进行。

示例

给一个仓库同时配上 RPM 与 DEB 两副面孔:

sow dist new el9 --format rpm -r pgsql
sow dist new trixie --format deb -r pgsql

加一个带独立保留策略的 beta 频道——先建 Dist,再在 sow.yml 里写策略并收敛:

sow dist new el9-beta --format rpm -r pgsql
$EDITOR sow.yml          # el9-beta: { limit: 0 }
sow config check
sow build -r pgsql -d el9-beta

哪些 Dist 落后于期望状态:

sow dist ls -r pgsql --json | jq -r '.result.dists[] | select(.dirty) | .name'

退出码

码 触发条件
0 列出、创建、显示或删除成功;或 dist new 收敛了已存在的 Dist
1 创建空索引时的运行时 I/O 或渲染错误
2 用法错误——--format 缺失或非法、工作区未找到、仓库选择有歧义
4 仓库锁被占用,且给了 --no-wait 或 --timeout 到期
5 Operation Journal 的完整性或恢复错误
6 名称非法、Dist 不存在、同名不同格式冲突、非空但未给 -f

参见

5.6 - sow add

把包加入期望成员集,执行成员策略,并重建受影响的索引。

sow add 是主要的写入路径。它解析你指定的包,从包头推导格式与架构,执行 Dist 的成员策略,并且—— 除非你加 --skip——在返回前重建全部受影响的索引。命令退出码为 0 时,客户端已经能看到新包了。

语法

sow add PATH... [-R|--recursive] [--skip] [-j|--jobs N] [-C|--workdir DIR] [-r|--repo NAME] [-d|--dist NAME]... [-T|--timeout DUR | -N|--no-wait] [--json]

参数

参数 说明 默认
-R, --recursive 递归进入 PATH 目录的子目录 关闭(只扫顶层)
--skip 只更新期望状态,不构建 关闭
-j, --jobs N 解析、哈希与渲染的并发 worker 数 逻辑 CPU 数
-C, --workdir DIR 工作区发现的起始目录 当前目录
-r, --repo NAME 选择一个仓库 按选择规则
-d, --dist NAME 选择一个 Dist;可重复 按选择规则
-T, --timeout DUR 等待锁的最长时间;0 无限等待 0
-N, --no-wait 锁被占用时立即失败 false
--json 输出版本化 JSON envelope false

输入与目标

PATH 可以是文件或目录。目录默认只扫描顶层,除非你加 -R。

最终必须确定恰好一个仓库与至少一个目标 Dist——见选择规则。RPM 与 DEB 混合批次是允许的:每个包只会被考虑放进格式相同的目标 Dist;一个包如果没有任何兼容目标,则该包失败。

SOW 绝不从 manifest、目录名或宿主机 OS 推断目标。

sow add /srv/pkg/centos-release-7-2.1511.el7.centos.2.10.x86_64.rpm /srv/pkg/epel-release-7-5.noarch.rpm -r pigsty -d el9
add repository=pigsty operation=8677129233475584643 accepted=2 failed=0 memberships=+2/-0 revision=3 generation=3 dirty=false
item input="/srv/pkg/centos-release-7-2.1511.el7.centos.2.10.x86_64.rpm" status=accepted format=rpm coordinate="centos-release-0:7-2.1511.el7.centos.2.10.x86_64" sha256:b4111ef2a51542eacc9bd1ebd080da02e53d400f9d172530c75a1e4ac06e7ead dists=el9:accepted
item input="/srv/pkg/epel-release-7-5.noarch.rpm" status=accepted format=rpm coordinate="epel-release-0:7-5.noarch" sha256:d6f332ed157de1d42058ec785b392a1cc4b5836c27830af8fbf083cce29ef0ab dists=el9:accepted

汇总行给出 Operation ID、逐项计数、成员增减、新的 Desired Revision、Built Generation,以及仓库是否 留在 dirty 状态。随后是每个输入一行 item,顺序稳定。

逐项状态

每行 item 带一个总体 status,以及 dists= 中的逐 Dist 判定。

状态 含义
accepted 新建 Package Object,且至少增加一条 Membership
reused 内容已存在于本仓库;可能只是新增了 Membership 引用
excluded 策略把它从所有目标 Dist 中移除——看 dists= 区分是 excluded 还是 limited
failed 该包被拒绝,error= 字段说明原因

reused 表示内容幂等:同一个文件加两次绝不会产生第二个对象或重复 Membership。默认重复执行 add 时还会收敛所选 Dist;Dist 已经最新时 Generation 不变,先前 --skip 或配置变更留下 dirty 状态时,则会补做构建并可能推进 Generation:

sow add /srv/pkg/epel-release-7-5.noarch.rpm -r pigsty -d el9
add repository=pigsty operation=656950149626836753 accepted=1 failed=0 memberships=+0/-0 revision=4 generation=4 dirty=false
item input="/srv/pkg/epel-release-7-5.noarch.rpm" status=reused format=rpm coordinate="epel-release-0:7-5.noarch" sha256:d6f332ed157de1d42058ec785b392a1cc4b5836c27830af8fbf083cce29ef0ab dists=el9:accepted

把同一个对象加进第二个 Dist 同样是 reused——包池只保留一份,只是多了一条 Membership。 如果仍想保留 dirty 批次,应再次显式使用 --skip。

架构是读出来的,不是猜的

add 从包头读取格式与原生架构,再对照工作区许可表。不在许可表中的架构会让该包失败,并明确告诉你 要改什么:

sow add /srv/pkg/centos-release-3.1-1.i386.rpm -r pigsty -d el9
item input="/srv/pkg/centos-release-3.1-1.i386.rpm" status=failed error="managed: operation rejected: unknown rpm package architecture \"i386\"; supported rpm package architectures are [x86_64, aarch64, noarch] (canonical families [x86_64, aarch64, neutral]); use a supported package or update only supported architecture families in sow.yml"

它不会创建目录,也不会修改 sow.yml。

RPM 的 noarch 与 DEB 的 all 是架构中性(neutral)的。它们只产生一个 Package Object 与一条 Membership,但会渲染进目标 Dist 的每个有效架构视图。它们不会自动扩散到你没有用 -d 选中的 Dist。

策略:exclude 与 limit

合并进目标 Membership 之后,SOW 会在完整的 Dist 候选集上重新求值 exclude,再求值 limit。被策略 移除的包会被明确报告,不算解析失败。

sow add /srv/pkg/debs -r pgsql -d trixielim
add repository=pgsql operation=4142220455201181493 accepted=3 failed=0 memberships=+3/-0 revision=6 generation=6 dirty=false
item input="/srv/pkg/debs/libpq5-dbgsym_18.3-1_amd64.deb" status=excluded format=deb coordinate="libpq5-dbgsym=18.3-1:amd64" sha256:cf491b9d9b218fa49ad2b41b4740d62cd972e1b515bf33677c2c3ead75acc60a dists=trixielim:excluded
item input="/srv/pkg/debs/libpq5_18.2-1_amd64.deb" status=excluded format=deb coordinate="libpq5=18.2-1:amd64" sha256:fa84dc641b7c686be2f9b512311ad0b74eac03e2afc9eff7e9af75b82b68ff41 dists=trixielim:limited
item input="/srv/pkg/debs/libpq5_18.3-1_amd64.deb" status=reused format=deb coordinate="libpq5=18.3-1:amd64" sha256:491992c502113627d44d0d66a2b189cdaa8accff293ebaf84fe10ccbc9da574c dists=trixielim:accepted
item input="/srv/pkg/debs/libpq5_18.3-1_arm64.deb" status=reused format=deb coordinate="libpq5=18.3-1:arm64" sha256:3a2f7ef7cddfa3dc06280ef59eda1dab9724d57499931ee80758b11531c1f40c dists=trixielim:accepted
item input="/srv/pkg/debs/pg-sample_1.17-1_all.deb" status=reused format=deb coordinate="pg-sample=1.17-1:all" sha256:f23581c5164a143e5e902232589adf1d30b73ba3857a692a11da607f246aacc3 dists=trixielim:accepted

这里 trixielim 配了 exclude: [{kind: [dbgsym]}] 与 limit: 1。dbgsym 包被规则排除; libpq5 18.2-1 在版本上限下输给了 18.3-1,报告为 limited。两者的顶层状态都是 excluded, 靠 dists= 字段区分。

limit 按 (二进制包名, 原生架构) 分组,因此 18.3-1:amd64 与 18.3-1:arm64 在 limit: 1 下都能 留下。同一次运行中,一个包可以被某个 Dist 接受、被另一个 Dist 跳过。

被策略移除的成员不会复活

exclude 与 limit 移除的是真实的期望成员。之后放宽策略不会把它们变回来——pool/ 里残留的字节 不构成候选集。请重新执行 sow add。

部分成功的批次

即使同批有失败项,合法且无冲突的包依然会提交。失败的输入原地不动,各自带自己的错误信息,命令退出 3:

sow add /srv/pkg/centos-release-3.1-1.i386.rpm /srv/pkg/centos-release-6-0.el6.centos.5.x86_64.rpm -r pigsty -d el9
add repository=pigsty operation=4623871845694427260 accepted=1 failed=1 memberships=+1/-0 revision=5 generation=5 dirty=false
item input="/srv/pkg/centos-release-3.1-1.i386.rpm" status=failed error="managed: operation rejected: unknown rpm package architecture \"i386\"; ..."
item input="/srv/pkg/centos-release-6-0.el6.centos.5.x86_64.rpm" status=accepted format=rpm coordinate="centos-release-0:6-0.el6.centos.5.x86_64" sha256:ffd9e7bdaa4884831a6c055ada01dac96b84c50a8d518dac409b445af5dadc16 dists=el9:accepted
managed: batch partially succeeded

如果每个输入都失败,操作以退出码 6 被拒绝,期望成员与公开内容保持原样。人类输出会列出 每个失败输入及其原因,JSON 则在 result.items 中保留这些信息:

sow add /srv/pkg/centos-release-3.1-1.i386.rpm -r pigsty -d el9

没有 rejected/隔离目录。

被策略排除不属于输入失败;全部被排除的批次可以正常成功,包括首次向空 Dist 添加时。 如果取消或预检拒绝导致 Desired 尚未提交,已准备好的输入不会被报告为 accepted。取消命令 返回 130,保留原始输入文件并重新执行即可;已经提交的成员变更在恢复时保持不变。

一个 pool 路径不能代表两份不同的包字节。本地 GC 后,历史发布与已放弃上传记录保留的路径仍然 受此约束。路径冲突只拒绝对应输入,无冲突的合法输入仍可提交。内容改变时,使用新的包版本与 文件名,不要复用已经发布过的 URL。路径按大小写折叠比较:以仅大小写不同的文件名重新加入完全 相同的字节也会被拒绝,请使用原文件名加入。在大小写不敏感的工作区文件系统上,源目录与已有目录 仅大小写不同的包同样会被拒绝(见平台与集成)。

–skip

--skip 在期望状态提交后就停下。公开的 pool/ 与 dists/ 字节不变,Built Generation 保持原位, 仓库变为 dirty。新包字节被持久保存在私有 pending 存储中,直到下一次 build 才发布。

sow add /srv/pkg/tree -R --skip -r pgsql -d trixie
add repository=pgsql operation=8405631664133415270 accepted=6 failed=0 memberships=+4/-0 revision=4 generation=3 dirty=true
sow status -r pgsql
repository=pgsql status=dirty ready_to_copy=false revision=4 generation=3 dirty_dists=trixie pending=4/2326 locked=false

pending=4/2326 表示私有存储里有 4 个对象、共 2326 字节在等待。它们不会出现在 sow changes 中——只有成功的 build 才会把它们提升进可交付树。

批量导入时用 --skip,最后一次性收敛:

sow add /srv/build/ -R -r pgsql -d el9 --skip
sow status -r pgsql
sow build -r pgsql -j 12
sow check -r pgsql

处理顺序

一次 add 的执行顺序如下:

  1. 取得仓库写锁,并恢复任何未完成的 Operation。
  2. 在 SQLite 中提交一条 planned Operation。
  3. 只读解析输入,计算逻辑坐标与输入字节 SHA-256(RPM 还会计算 signature-neutral payload digest)。
  4. 校验架构许可表,并查询已有坐标。
  5. 只对确实全新的坐标,在 stage 副本上执行可选的 RPM 签名并计算最终 SHA-256,再校验内容与路径唯一 性。
  6. 合并目标 Membership,然后在完整 Dist 集合上执行 exclude 与 limit。
  7. 提交期望状态;新字节写入私有 pending 内容存储。
  8. 除非给了 --skip,把仍被需要的 pending 对象发布进 pool/ 并渲染索引——一次命令中每个 Dist 最多 构建一次。

任何模式下,输入文件都不会被修改、移动或删除。

RPM 签名模式

Managed 模式的 RPM 包签名在 sow.yml 的 signing.rpm.packages.mode 中配置,命令行没有覆盖开关。

模式 行为
never 完整保留输入字节
fill 无签名或签名不受信任时用配置 key 签名;已有能被 trusted_keys 验证的签名则保持字节。配置了 key 时的默认值
always 确保最终包由配置 key 有效签名;否则对 stage 副本重签

没有配置 key 时只能用 never。

由于签名包含非确定字段,SOW 无法先重签再比较最终哈希。重试幂等因此建立在坐标上:输入字节完全相同 则直接复用;RPM signature-neutral digest 相同、且既有对象满足当前策略时也复用。payload digest 不同, 或既有对象已不满足策略,则是硬冲突——add 不会静默地对同一坐标原地重签。

退出码

码 触发条件
0 全部输入被接受或复用;索引已重建(或因 --skip 跳过)
1 运行时 I/O、解析器、渲染器或签名失败
2 用法错误、工作区未找到,或仓库/Dist 选择有歧义
3 部分批次——至少一项已提交,至少一项失败
4 仓库锁被占用,且给了 --no-wait 或 --timeout 到期
5 完整性或恢复错误,包括在 applied 之后构建失败
6 一个都没接受——架构不受支持、没有兼容的目标 Dist,或坐标/pool 路径冲突

参见

5.7 - sow rm

从选定 Dist 中移除期望成员,并提供不写盘的预览模式。

sow rm 把包从你选定的 Dist 的期望成员集中拿掉,并默认立即重建受影响的索引。它不会从 pool/ 删除字节——成员关系与内容是两个概念,回收由独立的保守操作 sow gc 完成。

语法

sow rm PACKAGE... [-c|--check] [--skip] [-j|--jobs N] [-C|--workdir DIR] [-r|--repo NAME] [-d|--dist NAME]... [-T|--timeout DUR | -N|--no-wait] [--json]

参数

参数 说明 默认
-c, --check 只预览:计算并打印方案,不写任何东西 关闭
--skip 只更新期望状态,不构建 关闭
-j, --jobs N 并发 worker 数 逻辑 CPU 数
-C, --workdir DIR 工作区发现的起始目录 当前目录
-r, --repo NAME 选择一个仓库 按选择规则
-d, --dist NAME 选择一个 Dist;可重复 按选择规则
-T, --timeout DUR 等待锁的最长时间;0 无限等待 0
-N, --no-wait 锁被占用时立即失败 false
--json 输出版本化 JSON envelope false

--check 与 --skip 互斥:

sow rm epel-release -c --skip
usage error: --check and --skip are mutually exclusive

包引用

PACKAGE 接受五种形态。完整文法与歧义规则见包引用,简版如下:

形态 例子
内容哈希 sha256:d6f332ed157de1d42058ec785b392a1cc4b5836c27830af8fbf083cce29ef0ab
RPM 坐标 rpm:epel-release-0:7-5.noarch
DEB 坐标 deb:libpq5=18.3-1:amd64
完整文件名 epel-release-7-5.noarch.rpm
裸二进制包名 epel-release

裸名表示选定 Dist 中该名称的全部版本与原生架构——正因如此,sow rm patroni 才是一条好用的下架 命令。非裸名的模糊短引用会失败并列出候选,而不是替你猜。

sow ls 会直接打印精确的 sha256: 引用与规范化坐标,你不需要手工 拼接。

引用匹配不到任何东西属于拒绝,不是静默成功:

sow rm nosuch -r pigsty -d el9
operation rejected: managed: operation rejected: package reference not found: package reference "nosuch" matches no Desired Membership

没有 --allow-empty、没有 --all、没有 --yes、没有 --source-list。

用 –check 预览

-c/--check 精确算出会移除什么、策略随后会怎么判定、以及立即构建会触碰哪些文件——并且什么都不写。 removed 只列出引用匹配到的成员。因策略重新求值而连带移除的成员不会被列出:预览只体现它 改动的索引文件,写入之后 sow log 会把它计入该操作的成员数。

sow rm centos-release -r demo -d el9 -c
preview repository=demo operation= dists=el9 memberships=2 revision=2 generation=00000000000000000003 dirty=false changes=2
membership dist=el9 name="centos-release" coordinate="rpm:centos-release-0:6-0.el6.centos.5.x86_64" sha256:ffd9e7bdaa4884831a6c055ada01dac96b84c50a8d518dac409b445af5dadc16
membership dist=el9 name="centos-release" coordinate="rpm:centos-release-0:7-2.1511.el7.centos.2.10.x86_64" sha256:b4111ef2a51542eacc9bd1ebd080da02e53d400f9d172530c75a1e4ac06e7ead
change op=update phase=pointer path="dists/el9/aarch64/repodata/repomd.xml" size=1509 sha256:1cfe38698967d11384f1a985618d75f5e690d1284accf951262fc663fa9afc81
change op=update phase=pointer path="dists/el9/x86_64/repodata/repomd.xml" size=1509 sha256:1cfe38698967d11384f1a985618d75f5e690d1284accf951262fc663fa9afc81

注意两个 centos-release 版本都被裸名命中了。change 行是一份真实交付计划,按 payload → metadata → pointer → delete 排列。其他程序需要对应的 removed[] 与 changes[] 数组时应使用 --json。

预览与写操作使用同一套候选配置和完整性预检;预览未通过门禁时,不能据此认为实际写入会成功。

--check 有意不取写锁。把它与锁参数一起用是用法错误,免得有人以为预览会排队等待写事务:

sow rm centos-release -r pigsty -d el9 -c -T 5s
usage error: rm --check does not accept --timeout or --no-wait

默认行为:移除并重建

不带 --check 或 --skip 时,rm 提交期望状态变更,并在返回前重建每个受影响的 Dist。pool 对象 留在磁盘上。

sow rm 'rpm:centos-release-0:6-0.el6.centos.5.x86_64' -r demo -d el9
removed repository=demo operation=2283442100870457321 dists=el9 memberships=1 revision=2 generation=00000000000000000003 dirty=false changes=8
membership dist=el9 name="centos-release" coordinate="rpm:centos-release-0:6-0.el6.centos.5.x86_64" sha256:ffd9e7bdaa4884831a6c055ada01dac96b84c50a8d518dac409b445af5dadc16

汇总之后会为每个受影响文件输出一行 change(本次运行共八行)。加上 --json 后,同一结果 以稳定的标准 Envelope 返回。

移除一个 Dist 的最后一个成员是允许的。SOW 仍会渲染合法的空索引(配了 key 就带签名)——空的 Packages 配可验签的 InRelease,或每架构的空 repodata/。

–skip

--skip 提交期望状态变更并把仓库标为 dirty,不触碰公开树。旧的 Built Generation 对客户端依然完全 自洽。

sow rm 'rpm:centos-release-0:7-2.1511.el7.centos.2.10.x86_64' --skip -r demo -d el9
removed repository=demo operation=314678479940914827 dists=el9 memberships=1 revision=4 generation=00000000000000000004 dirty=true changes=0
membership dist=el9 name="centos-release" coordinate="rpm:centos-release-0:7-2.1511.el7.centos.2.10.x86_64" sha256:b4111ef2a51542eacc9bd1ebd080da02e53d400f9d172530c75a1e4ac06e7ead
sow status -r pigsty
repository=pigsty status=dirty ready_to_copy=false revision=6 generation=5 dirty_dists=el9 pending=0/0 locked=false

changes 为空是因为什么都没构建。执行 sow build 收敛。

与策略的交互

移除属于期望状态编辑,因此策略会在新的候选集上重新求值——移除操作绝不会让先前被 limit 挤掉的包 复活。如果你从一个 limit: 1 的 Dist 里删掉 libpq5 18.3-1,18.2-1 不会回来;需要重新显式 add。

示例

安全下架——先预览,再执行:

sow rm patroni -r pgsql -d el9 -c
sow rm patroni -r pgsql -d el9

一次从两个 Dist 中移除同一个精确对象:

sow rm sha256:d6f332ed157de1d42058ec785b392a1cc4b5836c27830af8fbf083cce29ef0ab -r pgsql -d el9 -d el9-beta

批量移除后只重建一次:

sow rm old-tool legacy-agent -r pgsql -d el9 --skip
sow build -r pgsql -d el9
sow check -r pgsql

把预览计划喂给其他工具:

sow rm patroni -r pgsql -d el9 -c --json | jq -r '.result.changes[] | "\(.phase)\t\(.op)\t\(.path)"'

退出码

码 触发条件
0 成员已移除并重建,或 --check 预览已打印
1 运行时 I/O 或渲染失败
2 用法错误——--check 与 --skip 同用、--check 与锁参数同用、选择有歧义、工作区未找到
4 仓库锁被占用,且给了 --no-wait 或 --timeout 到期
5 完整性或恢复错误
6 引用无匹配,或非裸名的引用有歧义;整批拒绝,不删除任何成员

参见

5.8 - sow ls

列出所选 Dist 的期望成员与已构建成员。

sow ls 是针对 Package Object 与 Dist Membership 的只读查询。它显示所选 Dist 应包含哪些包, 以及这些成员是否已经进入当前 Built Generation。

语法

sow ls [-C|--workdir DIR] [-r|--repo NAME] [-d|--dist NAME]... [--json]
参数 含义 默认值
-C, --workdir DIR 工作区发现起点 当前目录
-r, --repo NAME 选择 Repository 选择规则
-d, --dist NAME 选择 Dist;可重复 选择规则
--json 输出 sow.cli/v1 Envelope false

该命令没有 --pool、--match 或输出格式参数。

输出

sow ls -r pigsty -d el9
repository=pigsty dists=el9 dirty=false
SHA256	COORDINATE	DISTS	BUILT_DISTS	POOL_PATH
sha256:d6f332ed157de1d42058ec785b392a1cc4b5836c27830af8fbf083cce29ef0ab	rpm:epel-release-0:7-5.noarch	el9	el9	pool/e/epel-release/epel-release-7-5.noarch.rpm
列 含义
SHA256 不可变内容身份,可直接传给 show 或 rm
COORDINATE 规范的 rpm: 或 deb: 包引用
DISTS 所选范围内的 Desired Membership
BUILT_DISTS 当前 Built Generation 中的成员关系
POOL_PATH Repository 内的不可变包体路径

Desired 与 Built 不一致时,首行显示 dirty=true。BUILT_DISTS 为空表示该包已进入期望状态, 但客户端尚不可见;运行 sow build 完成收敛。

多个所选 Dist 共享同一对象时,只输出一行,成员列表用逗号分隔。空 Dist 只有表头、没有包行, 仍然是成功结果。

选择范围

ls 要求 Dist 集合无歧义。Repository 包含多个 Dist 时,应传入一个或多个 -d,或从 <repo>/dists/<dist>/ 内运行。

sow ls -r pigsty
workspace discovery error: managed: workspace discovery error: repository "pigsty" has multiple Dists (el9, trixie); select one or more with --dist

该命令不获取写锁,也不重新哈希包文件。--json 在 result.packages 中返回同一批记录。

示例

列出尚未构建对象的精确引用:

sow ls -r pgsql -d el9 --json |
  jq -r '.result.packages[] | select(.built_dists | length == 0) | .sha256'

按路径列出包体:

sow ls -r pgsql -d el9 --json | jq -r '.result.packages[].pool_path' | sort

退出码

代码 触发条件
0 已输出成员列表,包括空列表
1 运行时 I/O 错误
2 用法错误、未发现工作区或隐式 Repository/Dist 选择有歧义
5 Repository 状态库不可读或不一致
6 显式指定的 Repository 或 Dist 未配置

参见

  • sow show —— 查看一个已列出的对象
  • sow where —— 在工作区中定位对象
  • sow rm —— 从期望成员集中移除引用
  • 包引用 —— 可接受的身份写法

5.9 - sow show

查看一个 Package Object 的身份、标准化事实、存储、签名与成员关系。

sow show 在所选 Repository 中解析一个包引用,并打印完整 Package Object。该命令只读, 不获取写锁。

语法

sow show PACKAGE [-C|--workdir DIR] [-r|--repo NAME] [-d|--dist NAME]... [--json]
参数 含义 默认值
-C, --workdir DIR 工作区发现起点 当前目录
-r, --repo NAME 选择 Repository 选择规则
-d, --dist NAME 将候选项收窄到指定 Dist;可重复 整个 Repository
--json 将结果包装进 sow.cli/v1 Envelope false

包引用

PACKAGE 可使用 sha256:<hex> 内容身份、规范的 rpm:<NEVRA> 或 deb:<name>=<version>:<arch> 坐标、完整包文件名或裸二进制包名。精确文法见 包引用。

裸包名必须在所选范围内唯一。sow rm foo 会移除所有匹配版本,而 sow show foo 只允许返回 一个对象;有歧义时会列出候选项:

sow show libpq5 -r pgsql -d trixie
operation rejected: managed: operation rejected: package reference "libpq5" is ambiguous: deb:libpq5=18.2-1:amd64 sha256:fa84dc64..., deb:libpq5=18.3-1:amd64 sha256:491992c5..., deb:libpq5=18.3-1:arm64 sha256:3a2f7ef7...

从错误信息或 sow ls 复制精确坐标/SHA-256 后重试。

输出

不带 --json 时,show 以紧凑的人类可读格式输出身份、存储路径与 Desired/Built 位置:

sow show centos-release-6-0.el6.centos.5.x86_64.rpm -r demo -d el9
package repository=demo coordinate="centos-release-0:6-0.el6.centos.5.x86_64" sha256:ffd9e7bdaa4884831a6c055ada01dac96b84c50a8d518dac409b445af5dadc16 format=rpm architecture=x86_64 size=19776 storage=pool
pool=pool/c/centos-release/centos-release-6-0.el6.centos.5.x86_64.rpm
dists=el9 built_dists=el9

加上 --json 后,标准 Envelope 的 result 会返回完整 Package Object,包括下列规范化字段。

字段 含义
canonical_arch x86_64、aarch64,或 RPM noarch / DEB all 对应的 neutral
kind 策略分类:main、debuginfo、debugsource、llvmjit、dbgsym、dbg
source 标准化源码包名
payload_sha256 RPM 去签名摘要,用于保证重签名幂等
signature_key 包内签名的 Key ID(如有)
storage 构建前为 pending,进入仓库树后为 pool
dists / built_dists Desired 与当前 Built Membership

-d 只收窄候选解析范围,不改变包身份。

退出码

代码 触发条件
0 已输出一个 Package Object
1 运行时 I/O 错误
2 用法错误、未发现工作区或隐式 Repository 选择有歧义
5 Repository 状态库不可读或不一致
6 显式范围未配置,或引用没有匹配/匹配多个对象

参见

  • sow ls —— 从 Dist Membership 获取精确身份
  • sow where —— 跨 Repository 搜索
  • sow rm —— 移除匹配的 Desired Membership
  • JSON 输出 —— 完整结果结构

5.10 - sow where

在工作区的 Repository 与 Dist 中定位一个 Package Object。

sow where 用于回答工作区中哪些 Dist 仍包含某个 Package Object。它默认搜索全部 Repository, 只读且不获取写锁。

语法

sow where PACKAGE [-C|--workdir DIR] [-r|--repo NAME] [-d|--dist NAME]... [--json]
参数 含义 默认值
-C, --workdir DIR 工作区发现起点 当前目录
-r, --repo NAME 将搜索限制到一个 Repository 全部 Repository
-d, --dist NAME 将搜索限制到指定 Dist;可重复 全部 Dist
--json 输出 sow.cli/v1 Envelope false

引用解析

PACKAGE 与 sow show 使用相同文法:SHA-256、规范 RPM/DEB 坐标、 完整文件名或裸包名。

解析范围是完整的所选工作区范围。裸包名必须标识唯一 Package Object;即使同名对象位于不同 Repository,也会产生歧义。使用 -r/-d 收窄范围,或提供精确坐标/SHA-256。

输出

不带 --json 时,where 先输出摘要,再为每个位置输出一行:

sow where 'rpm:centos-release-0:6-0.el6.centos.5.x86_64'
reference="rpm:centos-release-0:6-0.el6.centos.5.x86_64" locations=1
repository=demo coordinate="rpm:centos-release-0:6-0.el6.centos.5.x86_64" sha256:ffd9e7bdaa4884831a6c055ada01dac96b84c50a8d518dac409b445af5dadc16 dists=el9 built_dists=el9

每个位置同时给出 Desired dists 与当前 built_dists,可用于确认已移除或已替换版本是否仍对 客户端可见。

加上 --json 后,同一对象位于 result 下。引用不存在属于明确拒绝,而不是空成功:

sow where nosuchpkg
operation rejected: managed: operation rejected: package reference "nosuchpkg" was not found in the selected Workspace scope

示例

列出仍在提供某个精确版本的全部位置:

sow where 'rpm:patroni-0:3.0.4-1.noarch' --json |
  jq -r '.result.locations[] | "\(.repository)/\(.dists | join(","))"'

退出码

代码 触发条件
0 已输出一个解析后的 Package Object 及其位置
1 运行时 I/O 错误
2 用法错误或未发现工作区
5 某个 Repository 状态库不可读或不一致
6 显式 Repository/Dist 未配置,或引用没有匹配/在所选范围内有歧义

参见

  • sow show —— 查看解析后的 Package Object
  • sow ls —— 列出一个 Dist 集合
  • 包引用 —— 精确文法与歧义规则

5.11 - sow status

快速读取 Repository 的收敛、可交付、待处理包体、最近 Operation 与锁状态。

sow status 是低成本的 Repository 状态查询。它读取状态,但不哈希文件、不验签、不恢复 Operation、不构建元数据,也不获取写锁。

语法

sow status [-C|--workdir DIR] [-r|--repo NAME] [-d|--dist NAME]... [--json]
参数 含义 默认值
-C, --workdir DIR 工作区发现起点 当前目录
-r, --repo NAME 选择 Repository 选择规则
-d, --dist NAME 只查看指定 Dist;可重复 全部 Dist
--json 输出 sow.cli/v1 Envelope false

Repository 状态

每个 Repository 同时跟踪 SQLite 中的 Desired Revision,以及公开 dists/ 树对应的 Built Generation。

状态 含义 公开视图
clean Desired 与 Built 一致 当前且完整的 Generation
dirty Desired 已领先,常见于 --skip 或配置变化之后 上一个完整 Generation
recovering 存在非终态 Operation,下一条写命令必须先恢复 上一个已完成的协议指针
error 自动恢复无法安全裁决 保留上一个完整视图,不尝试覆盖

dirty 不代表仓库只写了一半。协议指针最后切换,因此读者看到的始终是完整旧视图或完整新视图。

输出

sow status -r pgsql
repository=pgsql status=dirty ready_to_copy=false revision=4 generation=3 dirty_dists=trixie pending=4/2326 locked=false

人类可读输出包含 Repository 状态、ready_to_copy、Desired Revision、Built Generation、 受影响 Dist、待处理对象数量/字节数与写锁状态。

JSON 结果还包含 dirty_reasons 与最近一次 Operation:

{
  "repository": "demo",
  "status": "dirty",
  "ready_to_copy": false,
  "desired_revision": 5,
  "built_generation": "00000000000000000004",
  "dirty_dists": ["el9"],
  "dirty_reasons": ["dist el9 Desired and Built membership sets differ"],
  "pending": {"count": 1, "bytes": 19776},
  "repository_locked": false
}

ready_to_copy=false 是明确警告;true 只是廉价状态判断,并非字节级完整性证明。交付前应运行 sow check。

只读契约

status 不迁移也不修复状态。Repository 数据库无法安全读取时,命令退出 5;请先执行 诊断信息明确指出的维护命令,再重新查询。v0.3 或 v0.4 Repository 使用 0.5 读取前必须先备份, 再逐个执行 sow repo migrate,升级到 Schema v13。

退出行为

只要状态可读,status 在 clean、dirty、recovering、error 四种状态下都返回 0。 脚本应读取结构化状态,而不是把后三者当作命令执行失败。

代码 触发条件
0 Repository 状态可读
1 运行时 I/O 错误
2 用法错误、未发现工作区或隐式 Repository 选择有歧义
5 状态库不可读或不一致
6 显式指定的 Repository 或 Dist 未配置

参见

5.12 - sow build

将 Desired Membership 与渲染配置收敛为完整 Built Generation。

sow build 是显式的 Desired-to-Built 收敛命令。它获取 Repository 写锁,恢复任何可裁决的 未完成 Operation,渲染并验证完整 Generation,最后切换协议指针。

语法

sow build [-j|--jobs N] [-C|--workdir DIR] [-r|--repo NAME] [-d|--dist NAME]... [-T|--timeout DUR | -N|--no-wait] [--json]
参数 含义 默认值
-j, --jobs N 并行 Worker 数,不得小于 1 逻辑 CPU 数
-C, --workdir DIR 工作区发现起点 当前目录
-r, --repo NAME 选择 Repository 选择规则
-d, --dist NAME 构建指定 Dist;可重复 全部受影响 Dist
-T, --timeout DUR 最长等锁时间;0 表示无限等待 0
-N, --no-wait 锁被占用时立即失败 false
--json 将结果包装进 sow.cli/v1 Envelope false

不带 -d 时,SOW 收敛所选 Repository 中全部受影响 Dist;带 -d 时只收敛指定 Dist, 未选择的变化继续保持 dirty。

结果

不带 --json 时,build 输出一行人类可读摘要:

sow build -r demo -d el9
built repository=demo operation=2769214987359113555 dists=el9 revision=4 generation=00000000000000000005 dirty=false

需要标准 Envelope 中的命令专属对象时使用 --json。

空操作构建

成员关系、相关策略、渲染设置与签名配置均未变化时,build 是幂等空操作,不增加 Generation:

sow build -r demo -d el9
build repository=demo dists=el9 already current (noop) revision=4 generation=00000000000000000005 dirty=false

策略收敛

build 会重新执行当前 exclude 与 limit 策略。收紧策略可能移除 Desired Membership; 放宽策略不会从残留包池字节恢复历史成员,需要重新运行 sow add。

提交与恢复

SOW 在同一文件系统暂存新元数据,验证完成后再切换可变协议指针。RPM 校验和命名元数据与 APT by-hash 确保新旧读者看到的视图始终自洽。

Pending 包体提升采用有界单写者 group commit。每批最多 512 个对象或 1 GiB:先创建 Pool 链接并持久化所有不同的目标父目录,再删除 pending 名称并持久化共享 pending 目录。中断只会 留下 pending-only、指向同一 inode 的双链接或 Pool-only 状态,都能按 journal 恢复;不会 持久地同时丢失两个名称。

一个 Operation 可以覆盖多个 Dist。每个 Dist 始终暴露完整视图;build 返回时,本次包含的所有 Dist 属于同一个 Built Generation。

开始新工作前,build 会尝试前向恢复或安全回滚非终态 Operation。如果日志、数据库与文件系统 证据互相矛盾,Repository 进入 error,build 拒绝猜测;不存在强制修复参数。

进度事件

耗时较长的构建会向 Operation Log 追加结构化 build_progress 记录。每条事件包含 phase、 completed、total 与 jobs。当前阶段为:

  • rendering;
  • promoting_payload;
  • publishing_dists;
  • normalizing_public_tree;
  • finalizing。

这些事件不会推进 Operation 状态,也不会在每次更新后 checkpoint SQLite;它们只用于审计 与可观测性,不参与恢复决策。使用 sow log OPERATION 查看明细。

元数据签名

Managed 元数据签名只从 sow.yml 读取,没有命令行 Key 覆盖。配置的 Key 引用或指纹改变时, 相关 Dist 变为 dirty,下一次 build 重新签名。

  • RPM:总是生成 repodata/repomd.xml;配置签名后额外生成 repomd.xml.asc。
  • DEB:总是生成 Release;配置签名后额外生成 InRelease 与 Release.gpg。

退出码

代码 触发条件
0 收敛成功或无需操作
1 渲染、签名或文件系统错误
2 用法错误、未发现工作区或隐式 Repository 选择有歧义
4 Repository 写锁不可用
5 无法安全完成恢复,或 Repository 处于 error
6 显式范围未配置,或当前配置拒绝既有状态

参见

5.13 - sow check

执行完整的只读完整性与可交付校验流水线。

sow check 是 Managed Repository 的深度只读门禁。它哈希包体、校验状态、重建期望视图并验证 已声明签名;不会修复、构建、恢复 Operation,也不会获取写锁。

语法

sow check [-j|--jobs N] [-C|--workdir DIR] [-r|--repo NAME] [-d|--dist NAME]... [--json]
参数 含义 默认值
-j, --jobs N 并行校验 Worker 数,不得小于 1 逻辑 CPU 数
-C, --workdir DIR 工作区发现起点 当前目录
-r, --repo NAME 选择 Repository 选择规则
-d, --dist NAME 校验指定 Dist;可重复 全部 Dist
--json 输出 sow.cli/v1 Envelope false

校验层

稳态下,checker 按顺序报告九层校验:

层 校验内容 checked 计数
config sow.yml 能否针对该 Repository 解析并通过校验 配置对象
state SQLite quick_check、外键、日志与恢复证据 一个状态库
public-modes 服务目录中所有文件与目录权限 已检查路径
retained 显式保留记录与冻结 Generation Manifest(损坏的保留根会使该层失败,见 retain rm) 保留记录
package-bytes 包池与私有 pending 包体的 SHA-256 Package Object
desired-membership Membership 能否在当前策略下解析到真实对象 成员关系
index 渲染索引是否与其声明的成员关系一致 Dist
signature 所有已声明元数据/软件包签名是否有效 签名
generation-manifest Built Generation Manifest 是否与磁盘文件一致 一个 Manifest

Repository 处于未完成布局迁移时,check 改为依次报告 config、state、public-modes 与条件 层 layout-transition,随后停止,并在诊断指定的 repo migrate 完成或在 commit 前中止前返回不可交付。

物理证据与 I/O 契约

package-bytes 绝不会把缓存指纹当作真实性证明。每次运行都会对每个唯一物理包体执行恰好一次 哈希;证据绑定设备号、inode、size、mtime、ctime 与真正读取的文件描述符。同 inode 的硬链接 共享证明;Retained Generation、最终 Manifest 遍历与 changes 复用它,不再扫描包体。 checked 列统计逻辑对象,不代表全文流数量。

DEB 或无签名 RPM 只需一遍完整包体流。带签名 RPM 最多再用一遍从主 Header 到 EOF 的流, 对全部签名 Packet 与候选 Trust Ring 验证;成本不会随 Dist、Retained Generation 或 Trust Ring 数量增加。伪造或并发替换文件会让描述符证据失效并失败关闭。

sow check
repository=pigsty status=clean ready_to_copy=true revision=5 generation=5
config	ok=true	checked=5
state	ok=true	checked=1
public-modes	ok=true	checked=67
retained	ok=true	checked=0
package-bytes	ok=true	checked=8
desired-membership	ok=true	checked=8
index	ok=true	checked=2
signature	ok=true	checked=9
generation-manifest	ok=true	checked=1

dirty 不可交付

dirty Repository 的九层校验可以分别成立:旧 Built Generation 完整,新 Desired 状态也有效; 但二者不一致,因此整体仍未通过交付门禁:

sow check
repository=pigsty status=dirty ready_to_copy=false revision=6 generation=5
...
integrity or recovery error: managed: repository is not ready to copy: repository status is dirty

此时退出 5。运行 sow build 后重新校验,不应让发布流水线放行该状态。

退出码

代码 触发条件
0 全部校验层通过,Repository 可复制交付
1 校验期间发生 I/O 错误
2 用法错误、未发现工作区或隐式 Repository 选择有歧义
5 某个校验层失败,或 Repository 不可交付
6 显式指定的 Repository 或 Dist 未配置

参见

5.14 - sow changes

将 Built Generation 差异输出为确定性的 Repository 相对文件交付计划。

sow changes 比较 Built Generation,输出物理的 Repository 相对文件差异。它不显示尚未构建的 Desired 变化,也不是远端事务协议。

语法

sow changes [BASE_GENERATION] [-C|--workdir DIR] [-r|--repo NAME] [--json]
参数 含义 默认值
-C, --workdir DIR 工作区发现起点 当前目录
-r, --repo NAME 选择 Repository 选择规则
--json 输出 sow.cli/v1 Envelope false

该命令作用于整个 Repository,明确拒绝 -d/--dist。

输出

sow changes
base=4 generation=5 dirty=false
add	payload	pool/c/centos-release/centos-release-6-0.el6.centos.5.x86_64.rpm	19776	ffd9e7bd...
add	metadata	dists/el9/x86_64/repodata/5bc463cb...-primary.xml.gz	1460	5bc463cb...
update	pointer	dists/el9/x86_64/repodata/repomd.xml	1514	05d3d5bf...
delete	delete	dists/el9/x86_64/repodata/0df96f0b...-primary.xml.gz	0	

各列依次为操作、阶段、Repository 相对路径、大小、SHA-256。

字段 取值
操作 add、update、delete
阶段 payload、metadata、pointer、delete

阶段描述 SOW 如何构建本地 Generation。不要把单行直接重放到线上目录;应使用 sow publish,或先暂存完整副本再原子切换。

Base Generation

不带参数时,SOW 比较当前 Built Generation 与它的前一代。

BASE_GENERATION 是 0..当前代 范围内的十进制整数。Base 0 输出当前 Generation 的完整 交付清单,但不包含私有 sow.yml 与 .sow/。Base 等于当前代时输出空计划;从未构建的 Repository 同样输出空的 0 -> 0 计划。

sow changes 99
operation rejected: managed: operation rejected: base generation 99 is outside 0..2

dirty 与恢复状态

Desired 为 dirty 时,首行显示 dirty=true,但计划仍以当前 Built Generation 结束。私有 pending 包体尚不可交付,不会出现在结果中。

Repository 为 recovering 或 error 时,changes 拒绝输出计划,避免把待定文件动作误认为已完成 Generation。

示例

输出当前完整清单:

sow changes 0 -r pgsql --json > pgsql-current.json

生成 Repository 级计划后按路径筛选一个 Dist:

sow changes -r pgsql --json |
  jq '.result.changes[] | select(.path | startswith("dists/el9/"))'

退出码

代码 触发条件
0 已输出计划,包括空计划
1 运行时 I/O 错误
2 用法错误、传入 -d、未发现工作区或隐式 Repository 选择有歧义
5 Repository 处于 recovering/error,或状态证据不一致
6 显式 Repository 未配置,或 Base Generation 超出有效范围

参见

5.15 - sow publish

将当前已验证 Generation 发布到配置的 filesystem 或 R2 目标。

sow publish 将某个 Repository 的当前 Built Generation 交付到 sow.yml 的 targets: 中指定的 目标。目标已经绑定 Repository 与 Provider,因此命令不接受 --repo 或 --dist。

语法

sow publish TARGET [--abort | --rebind] [-C|--workdir DIR] [-T|--timeout DUR | -N|--no-wait] [--json]
参数 含义 默认值
--abort 放弃已对账、但尚未写入持久 commit intent 的尝试 false
--rebind 确认并记录允许修改的目标名称、公共端点或缓存 TTL false
-C, --workdir DIR 工作区发现起点 当前目录
-T, --timeout DUR 最长 Repository 等锁时间;0 表示无限等待 0
-N, --no-wait 锁被占用时立即失败 false
--json 输出 sow.cli/v1 Envelope false

TARGET 必须是已配置的 filesystem 或 r2 发布目标。--abort 与 --rebind 互斥。

发布协议

交付前,SOW 要求存在已完成的 Built Generation,并验证公开树与冻结 Generation Manifest 精确 一致;然后按以下顺序规划并写入对象:

  1. 不可变包体;
  2. 校验和寻址元数据;
  3. 可变协议指针;
  4. 验证并持久化 Checkpoint。

精确对象集合、Receipt、阶段与 commit intent 都会落盘,确保中断后可以对账恢复。目标已经位于 当前 Generation 时,重复发布是幂等空操作。

sow publish local
published demo generation=00000000000000000005 to local (filesystem): phase=grace objects=14
sow publish local
publication demo generation=00000000000000000005 to local is already current (noop)

重绑定可变目标配置

首次成功 publish 会持久绑定 Repository、存储命名空间与 Target Identity。之后配置发生漂移时, SOW 会拒绝静默接受。只有诊断信息明确提示 --rebind,并且你已经复核变更后,才执行:

sow publish prod --rebind

如果改的是 targets: Map Key,请使用新目标名。Rebind 保留 active attempt 与 checkpoint identity, 并追加不可变、由操作者确认的 binding revision。

可以修改 不可变;必须配置新目标
目标名称 Repository Identity
public_endpoint Provider、存储 endpoint 或 region
max_cache_ttl bucket 或 prefix

Rebind 与发布使用同一组 Workspace/Repository 锁,并在数据库事务内重新核对不可变字段;它可以 继续前滚 active commit-intent attempt。Target Maintenance 未完成时禁止改变 TTL;filesystem 正在进行 conditional-delete 维护时禁止改变 public_endpoint。首次绑定必须运行普通 publish, 不能使用 --rebind。

Abort 与恢复

--abort 只允许在持久 commit intent 之前使用。SOW 会对账已经创建的对象,保留后续安全判断所需 证据,并在不继续复制或删除远端对象的前提下放弃本次尝试。

写入 commit intent 后只能前向恢复:重新运行 sow publish TARGET,不能使用 --abort。

包的 URL 不可变,新发布不能在同一路径覆盖成不同字节。对于旧程序留下的中断尝试,--abort 要求已有远端对象保持原样。如果旧程序已经覆盖了 payload,重新运行 publish 仅在远端字节 精确匹配该尝试记录的新身份时才能完成。字节不同或缺失仍会报错,恢复过程不会覆盖它们。

公共可见性校验

Provider 存储写入成功还不够:写入 Checkpoint 之前,发布流程还要校验 canonical public_endpoint。HTTP(S) 目标以普通 GET 为最终权威;no-cache Probe 只能促进 Revalidation, 必须由之后的普通 GET 才能通过。陈旧内容与缺失对象按 max_cache_ttl 重试;408、425、429 与 5xx 使用较短的有界重试窗口。等待 Header 与 Body 空闲进度分别计时;响应超长时失败关闭。

Filesystem 可使用 file:// 或 HTTP(S) 公共端点。执行条件删除时,file:// 需要精确文件身份 缺失,HTTP(S) 则需要 canonical 404/410。R2 必须使用 HTTP(S) 公共端点;R2 Target GC 仍然 只报告候选,不执行远端删除。每次 R2 发布都会列举目标前缀,并对列出的每个对象串行发出一次 HEAD 请求;改变目标内容的发布会执行两次。由于 R2 Target GC 从不删除对象,这项成本会随发布 历史增长。不要手工删除被报告的对象:下一次发布要求远端前缀与记录的清单完全一致,否则以完整性 错误失败(退出码 5)。

安全边界

  • SOW 只发布到配置目标,不接受任意目标路径。
  • 尚未构建的 Desired 变化不会进入发布。dirty Repository 因而可以发布上一个完整 Built Generation;如果目标必须反映当前 Desired 状态,应先运行 build。
  • 布局迁移与相互矛盾的恢复证据会阻止发布;可裁决的未完成 Dist 操作会在选择源 Generation 之前恢复。
  • 对象顺序保证包管理器指针不会引用尚不存在的内容。
  • 发布命令不负责外部 Web Server、Bucket Policy、DNS 路由或缓存配置。

退出行为

代码 触发条件
0 发布完成,或目标已经是当前 Generation
1 文件系统、Provider、网络、验证或绑定冲突(包括必须 rebind)
2 用法、工作区发现或 sow.yml 无效
4 Repository 写锁不可用
5 本地/发布恢复证据不一致,或源不可交付
6 目标不存在/不安全,或其他安全前置条件拒绝 Publish/Abort/Rebind

参见

5.16 - sow retain

添加、列出与移除供本地垃圾回收使用的显式 Generation 保留根。

sow retain 管理显式的本地 Generation 根。retain add 只能冻结当前 Built Generation;后续 构建使它成为历史版本后,该代所需的软件包体仍受保护。

语法

sow retain add GENERATION [-C|--workdir DIR] [-r|--repo NAME] [-T|--timeout DUR | -N|--no-wait] [--json]
sow retain ls             [-C|--workdir DIR] [-r|--repo NAME] [--json]
sow retain rm GENERATION  [-C|--workdir DIR] [-r|--repo NAME] [-T|--timeout DUR | -N|--no-wait] [--json]

GENERATION 必须是大于零的十进制整数。

retain add

要求 GENERATION 等于当前 Built Generation,校验后将其 Manifest 冻结到工作区私有状态中, 并添加显式 GC 根。不能在事后用 retain add 重建一个更老的 Generation。

sow retain add 12 -r pgsql
retained generation 00000000000000000012: /srv/sow/.sow/pgsql/retained/00000000000000000012

保留记录只保护包体,不切换当前视图,也不执行发布。重复添加同一 Generation 时,只有已验证记录 与当前证据一致才可视为幂等。

retain ls

列出显式保留记录。它是只读命令,因此不接受锁参数或 --dist。

sow retain ls -r pgsql
GENERATION	RECORD_IDENTITY	PATH
00000000000000000012	678beeae...	/srv/sow/.sow/pgsql/retained/00000000000000000012

空列表也是成功结果。

retain rm

只移除显式保留根:

sow retain rm 12 -r pgsql
removed retained generation 00000000000000000012

该命令不删除软件包体。移除一个未被保留的 Generation 是幂等空操作。只有在其他安全根也无法 到达这些包体时,后续本地 sow gc 才可能回收。

retain rm 在移除前会校验保留根,校验失败时拒绝移除;只要存在这样的损坏保留根,retain ls、 本地 gc 与 check 也会失败,check 会在 retained 层指出损坏的 Generation。确认损坏后, 在没有写入者运行时手工删除 .sow/<repo>/retained/<GENERATION>:这只移除 GC 根,不删除任何 包体;随后重跑 sow check。

参数

参数 适用命令 含义
-C, --workdir DIR 全部 工作区发现起点
-r, --repo NAME 全部 选择 Repository
-T, --timeout DUR add、rm 最长写锁等待时间
-N, --no-wait add、rm 锁被占用时立即失败
--json 全部 输出 sow.cli/v1 Envelope

退出行为

代码 触发条件
0 操作完成,包括空列表
1 文件系统或运行时 I/O 错误
2 Generation 语法无效、发现错误或隐式 Repository 选择有歧义
4 add/rm 无法获取写锁
5 Generation Manifest 或 Repository 状态不一致
6 显式 Repository 未配置、retain add 不是当前 Built Generation,或其他安全规则拒绝请求

参见

5.17 - sow gc

回收本地不可达包体,或对一个发布目标执行保守维护。

sow gc 有两种严格分离的模式:不带位置目标时,回收本地不可达包体;带 TARGET 时,维护一个 已配置发布目标。

语法

sow gc          [-C|--workdir DIR] [-r|--repo NAME] [-T|--timeout DUR | -N|--no-wait] [--json]
sow gc TARGET   [-C|--workdir DIR]                  [-T|--timeout DUR | -N|--no-wait] [--json]
参数 含义 默认值
-C, --workdir DIR 工作区发现起点 当前目录
-r, --repo NAME 仅用于选择本地 GC 的 Repository 选择规则
-T, --timeout DUR 最长 Repository 等锁时间;0 表示无限等待 0
-N, --no-wait 锁被占用时立即失败 false
--json 输出 sow.cli/v1 Envelope false

目标自身已绑定 Repository,因此 gc TARGET -r NAME 属于用法错误。两种模式都不接受 --dist。

本地 GC

本地 GC 只删除所有安全根都无法到达的包池对象。安全根包括:

  • 当前 Built Generation;
  • 显式 retain 记录;
  • 恢复状态与非终态 Operation;
  • 发布尝试及其证据;
  • 活跃维护操作。

操作会记入日志。实际删除包体时,Repository 前进到新 Generation;没有合格对象时为幂等空操作。

sow gc -r pgsql
local gc pgsql: generation=00000000000000000013 objects=4 bytes=1834200

目标 GC

目标维护使用发布 Checkpoint、不存在性证据与配置的 Cache Grace,具体行为取决于 Provider:

Provider 行为
filesystem 仅在 Grace 到期且已有存储/公开不存在性记录后,条件删除合格对象
r2 持久化精确的只报告候选集合;绝不发送对象删除请求
sow gc prod
target gc pgsql/prod (filesystem): phase=done candidates=14 deleted=8 retained=6 pending=0

空操作表示当前没有到期维护任务,并不代表目标已经做过穷尽式重新验证。

退出行为

代码 触发条件
0 GC 完成或没有合格对象
1 文件系统、Provider、网络或其他运行时错误
2 用法、工作区发现、sow.yml 无效或隐式 Repository 选择有歧义
4 Repository 写锁不可用
5 恢复、状态、Receipt 或 Manifest 证据不一致
6 显式 Repository/目标未配置或不安全,或删除被安全前置条件拒绝

参见

5.18 - sow export

将一个已构建 RPM Dist 架构导出为独立兼容仓库。

SOW 提供一个导出子命令:sow export rpm-leaf。它创建外部、独立的 RPM 仓库, repodata 使用本地 pool/... href。

语法

sow export rpm-leaf DIST ARCH DIR [--hardlink] [-C|--workdir DIR] [-r|--repo NAME] [--json]
参数 要求
DIST 已配置的规范 RPM Dist 名称
ARCH x86_64 或 aarch64
DIR 不存在或为空,且不与 Repository、私有状态、filesystem 目标根重叠的目录
选项 含义 默认值
--hardlink 对可信、同文件系统、只读目标使用硬链接 复制文件
-C, --workdir DIR 工作区发现起点 当前目录
-r, --repo NAME 选择 Repository 选择规则
--json 输出 sow.cli/v1 Envelope false

该命令不接受 --dist、jobs、timeout 或锁参数。

输出

sow export rpm-leaf el9 x86_64 /srv/export/el9-x86_64
exported RPM leaf el9/x86_64 generation=00000000000000000012 method=copy packages=84 to /srv/export/el9-x86_64

目标目录包含:

  • 使用本地包体 href 重写的 RPM repodata;
  • 所需软件包目录树;
  • 导出 Manifest;
  • .sow-export.json 来源记录。

源必须是已完成的 Built Generation。导出物是独立制品,不属于 Desired Membership、 Built Generation、发布输入或 GC 根。

导出失败可能在 DIR 留下半成品;导出没有 journal 或自动回滚。保持该目录离线,修正原因 (例如签名私钥不可用),然后换一个不存在或为空的输出目录重试。不要发布失败产物,也不要 期望重跑命令自动合并非空目录。

Managed RPM 与 export rpm-leaf 的 repomd data 时间戳固定为 0,没有 --metadata-timestamp 选项。因此它们不能直接接管保留了正数时间戳缓存的同一个 EL7 repository ID;换 baseurl 本身不能解决。需要客户端采用新的 repository ID 或清理元数据缓存。 若必须保留旧 ID 和缓存继续更新,请使用带显式时间戳的 Plain create,按 迁移指南 维护发布时间并重签 repomd。

复制与硬链接

复制是安全默认值。--hardlink 只适用于同一文件系统、且消费者无法修改的可信只读目标。硬链接 包体与 SOW 包池共享 inode,不能用于可写或不可信目标。创建(以及之后删除)这些硬链接会改变共享 Pool 文件的 ctime,因此下一次构建会对这些包体重新哈希一次,以刷新其快速校验指纹。

SOW 会拒绝与已配置 filesystem 发布根重叠的输出,避免导出物被误认为或修改 Managed 发布目标。

退出行为

代码 触发条件
0 独立 RPM leaf 导出完成
1 文件系统、复制、硬链接或元数据写入错误
2 命令语法、Dist/架构 Token 无效,或发现/隐式 Repository 选择有歧义
5 源 Generation 或 Repository 状态不一致
6 显式 Repository 未配置、Dist 不是 RPM、视图/签名者不可用,或目标不安全/非空/重叠

参见

5.19 - sow log

读取操作审计账本、导出为 JSONL,并清理符合条件的终态记录。

仓库内的每条写命令,都会先在该仓库的 SQLite 中提交一条应用级 Operation,然后才产生任何外部文件 副作用。这条记录让崩溃恢复成为可能——而当 Operation 进入终态之后,同一条记录就是你的审计轨迹。 sow log 读的就是它。

语法

sow log [OPERATION] [-C|--workdir DIR] [-r|--repo NAME] [-d|--dist NAME] [--json]
sow log export [FILE] [-C|--workdir DIR] [-r|--repo NAME] [-d|--dist NAME]
sow log prune BEFORE [-C|--workdir DIR] [-r|--repo NAME] [-T|--timeout DUR | -N|--no-wait] [--json]

Operation 生命周期

读懂 state 字段,日志就读懂了一大半:

planned → staged → applied → built → done
                        └────────→ done_dirty
   any nonterminal → recovering → built / done / done_dirty / rolled_back / failed
   pre-apply error  → failed
状态 含义
planned 命令、参数、目标与预期动作已持久化
staged 新包/元数据已写入临时位置并校验通过
applied 期望状态与所需的私有 pending 载荷已提交
built 完整的静态 Generation 已切换
done 终态——一次正常成功的命令
done_dirty 终态——--skip 后 Desired 领先于 Built(--skip 后仓库仍干净则以 done 收尾),或恢复旧的未冻结操作时通过文档所述安全出口收尾
failed 终态——在 applied 之前失败,什么都没提交
rolled_back 终态——恢复在 Operation 改变 Desired 之前放弃了它,例如被中断命令留下的 planned Operation
recovering 非终态;下一条写命令必须先完成或回滚它

工作区生命周期命令(init、repo new、repo rm)走的是工作区文件 journal,不会出现在仓库的 SQLite 日志中。dist new/dist rm 会出现——那时仓库数据库已经存在。

sow log

不带参数时,按由新到旧打印最近 50 条 Operation。

sow log -r pigsty

输出节选,operations 数组中的一个 Operation 对象:

{
  "id": "4262183287563704350",
  "kind": "build",
  "state": "done",
  "payload_json": "{\"version\":2,\"repository\":\"pigsty\",\"kind\":\"build\",\"config_sha256\":\"37eb6dcf...\",\"skip\":false,\"dists\":[\"el9\"],\"build_dists\":[\"el9\"],\"manifest_sha256\":\"678beeae...\"}",
  "result_json": "{\"dists\":1,\"dropped_pending\":[]}",
  "created_at": "2026-08-04T04:07:40.334787Z",
  "updated_at": "2026-08-04T04:07:40.907125Z"
}

payload_json 记录意图——包括当时生效配置的摘要 config_sha256,以及结果 Generation 的 manifest_sha256。result_json 记录结果。失败的 Operation 还会带 error_class 与 error_message:

{
  "id": "5995346754219751025",
  "kind": "add",
  "state": "failed",
  "result_json": "{\"accepted\":0,\"failed\":1}",
  "error_class": "rejected",
  "error_message": "no input package was accepted"
}
参数 说明 默认
-C, --workdir DIR 工作区发现的起始目录 当前目录
-r, --repo NAME 选择一个仓库 按选择规则
-d, --dist NAME 只显示触及该 Dist 的 Operation 全部
--json 输出版本化 JSON envelope false

查看单条 Operation

给出 Operation ID,就能得到它的完整状态迁移、耗时、包、成员与文件动作。

sow log 4262183287563704350 -r pigsty

输出节选:

{
  "duration_ms": 572,
  "events": [
    {"sequence": 0, "state": "planned",  "occurred_at": "2026-08-04T04:07:40.334787Z"},
    {"sequence": 1, "state": "staged",   "occurred_at": "2026-08-04T04:07:40.380963Z"},
    {"sequence": 2, "state": "applied",  "occurred_at": "2026-08-04T04:07:40.386186Z"},
    {"sequence": 3, "state": "built",    "occurred_at": "2026-08-04T04:07:40.904730Z"},
    {"sequence": 4, "state": "done",     "occurred_at": "2026-08-04T04:07:40.907125Z"}
  ],
  "packages": [],
  "memberships": [],
  "files": [
    {"sequence": 0, "action": "update", "phase": "pointer", "path": "dists/el9/aarch64/repodata/repomd.xml", "size": 1511, "sha256": "ef071821e06c9e86ab4f6d2a56906d82bb66df251e79d1086cfd44dc8395513e"},
    {"sequence": 1, "action": "update", "phase": "pointer", "path": "dists/el9/x86_64/repodata/repomd.xml",  "size": 1514, "sha256": "a31e90ec39169f0373b108458908333c96c5f600f3c63a50c44257856f0d2d55"}
  ]
}

files 数组使用与 sow changes 相同的 phase 词表:payload、 metadata、pointer、delete。

Build Operation 还会包含进度事件。它们保持当前 state,并把版本化对象放入 detail_json:

{
  "state": "applied",
  "detail_json": "{\"version\":1,\"kind\":\"build_progress\",\"phase\":\"rendering\",\"completed\":1,\"total\":2,\"jobs\":8}"
}

阶段包括 rendering、promoting_payload、publishing_dists、 normalizing_public_tree 与 finalizing。进度行是持久审计数据,但不会推进恢复状态机,也不会 单独触发 SQLite checkpoint。

按 Dist 过滤

-d 把列表限制为触及该 Dist 的 Operation——一个仓库服务多个发行版时很有用:

sow log -d trixie -r pigsty

sow log export

把终态 Operation 以 JSONL 写出——每行一条完整的 Operation 明细记录——用于归档或送入日志管道。

sow log export /srv/audit/pigsty-ops.jsonl -r pigsty
exported 12 operations to /srv/audit/pigsty-ops.jsonl

省略 FILE 或传 - 则写到 stdout:

sow log export - -r pigsty | gzip > pigsty-ops-$(date +%F).jsonl.gz
参数 说明 默认
-C, --workdir DIR 工作区发现的起始目录 当前目录
-r, --repo NAME 选择一个仓库 按选择规则
-d, --dist NAME 只导出触及该 Dist 的 Operation 全部

export 没有 --json——JSONL 就是它的输出格式。

它拒绝覆盖

目标已存在属于拒绝,绝不覆盖——审计导出不能静默毁掉上一份:

sow log export /srv/audit/pigsty-ops.jsonl -r pigsty
operation rejected: export target already exists: /srv/audit/pigsty-ops.jsonl

export 同样拒绝父目录不是真实目录的目标——符号链接,或根本不存在的目录:

sow log export /tmp/pigsty-ops.jsonl -r pigsty
log export parent is not a real directory

macOS 上 /tmp 是指向 /private/tmp 的符号链接,所以在那里会触发这条拒绝。请写到明确的真实路径。

sow log prune

删除早于 BEFORE 且符合条件的终态审计记录,并尝试回收数据库空间。如果长读连接阻止压缩 或最后的 WAL checkpoint,逻辑裁剪仍会完成,后续写入保持可用。结果以 compaction_deferred: true 表示延期;关闭读连接后,需要回收空间时再执行 prune。 真实数据库或 I/O 错误仍会使命令失败。

sow log prune 2027-01-01 -r pigsty
pruned log repository=pigsty operation=8150803833883584722 before=2026-12-31T16:00:00.000000000Z operations=1

绝对时间戳会被回显(此处为 UTC+8 的工作站),这样本地时区的解释永远不含糊。若有读者使空间回收 被推迟,输出会多一行 log deletion committed; space reclamation deferred while the database is busy; 使用 --json 时,日志 ID 与 compaction_deferred 位于 result 中。

参数 说明 默认
-C, --workdir DIR 工作区发现的起始目录 当前目录
-r, --repo NAME 选择一个仓库 按选择规则
-T, --timeout DUR 等待锁的最长时间;0 无限等待 0
-N, --no-wait 锁被占用时立即失败 false
--json 输出版本化 JSON envelope false

prune 在仓库级工作,不接受 -d——清掉半条 Operation 只会留下毫无意义的记录。

BEFORE 语法

BEFORE 是 ISO-8601 日期 YYYY-MM-DD(按本地时区零点解释),或带时区的 RFC 3339 时间戳。

sow log prune yesterday -r pigsty
usage error: BEFORE must be YYYY-MM-DD or an RFC 3339 timestamp with timezone

prune 永不删除什么

prune 在设计上是保守的。它绝不会删除:

  • 非终态的 Operation;
  • 当前恢复仍需要的记录;
  • 当前的 Package 或 Membership 状态;
  • Built Generation 或其 Changeset。

pruned 计数准确告诉你有多少条记录符合条件——通常少于截止时间之前的 Operation 总数。日志与 Changeset 位于同一个 SQLite 数据库,但保留规则不同。

示例

排查最近一次写入:

sow log -r pgsql --json | jq -r '.result.operations[0] | "\(.id)\t\(.kind)\t\(.state)"'

列出所有失败:

sow log -r pgsql --json | jq -r '.result.operations[] | select(.state=="failed") | "\(.id)\t\(.error_class)\t\(.error_message)"'

每月归档并收缩:

sow log export /srv/audit/pgsql-$(date +%Y%m).jsonl -r pgsql
sow log prune 2026-05-01 -r pgsql

哪条 Operation 最后触碰了某个 Dist:

sow log -d el9 -r pgsql --json | jq -r '.result.operations[0].id'

退出码

命令 码 触发条件
log 0 记录已打印,包括空账本
log 2 用法错误(包括非数字的 Operation ID)、工作区未找到,或选择有歧义
log 5 状态数据库不可读
log 6 给定的 Operation ID 不存在,或给出了多个不同的 -d
log export 0 导出成功
log export 1 写目标时 I/O 失败,或父目录不是真实目录
log export 2 用法错误或选择有歧义
log export 6 目标已存在
log prune 0 清理完成,包括一条都没清
log prune 2 BEFORE 格式非法、给了 -d,或选择有歧义
log prune 4 仓库锁被占用,且给了 --no-wait 或 --timeout 到期
log prune 5 完整性或恢复错误

参见