跳转到主要内容

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

返回本页常规视图.

SOW 文档

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

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

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

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

选择路径

目标 从这里开始
立即索引一个软件包目录 快速上手
长期维护精选仓库 第一个工作区
搭建完整 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 包签名需要 rpmagent:// 元数据密钥需要 gpggpg-agent

1.1 - 安装

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

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

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

安装归档

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

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

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

安装 Linux 软件包

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

sudo rpm -Uvh ./sow-*-1PGSTY.x86_64.rpm
sudo apt install ./sow_*-1PGSTY_amd64.deb

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

从源码构建

Go Module 声明使用 Go 1.27.0,元数据生成不需要 C 工具链。请把 vX.Y.Z 替换为下载页 链接的源码 Tag:

git clone https://github.com/pgsty/sow.git
cd sow
set -euo pipefail
SOW_TAG=vX.Y.Z
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.mdCHANGELOG.md 与 Apache-2.0 LICENSE

升级 0.3 Managed Workspace

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

cp -a /srv/sow /srv/sow.backup-before-0.4.0
sow repo migrate REPOSITORY -C /srv/sow
sow check -r REPOSITORY -C /srv/sow

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

权限与可选工具

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

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

  • RPM 包签名 需要 rpm 与可用的 GPG 环境;
  • agent:// 元数据密钥需要 gpggpg-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 ReleaseInReleaseRelease.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

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

sow create /srv/repo --json

何时使用 Managed 模式

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

另见 sow createPlain 平面仓库

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_64aarch64。配置接受 amd64arm64 别名,并规范化为上述族名。

创建 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 开始查找。
  • Repository:-r NAME、当前路径所属 Repository,或唯一已配置 Repository。
  • Dist:-d NAME,可重复;只有命令能够唯一确定范围时才可省略。

存在歧义时直接报错;SOW 不会随便挑选 Repository 或 Dist。

下一步

1.4 - 核心概念

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

Plain 还是 Managed

两条运行路径相互独立。

Plain Managed
入口 sow create DIR initrepodistaddrmbuild
状态 软件包目录 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/

addrm 默认构建受影响的 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 - 教程

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

这里的教程都从全新工作区开始。请按顺序执行命令,并按你的环境替换大写占位符与包路径。

如果还没装 SOW,先看安装快速上手。 下面的教程介绍 Managed 仓库路径。

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

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

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

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

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

先看哪篇

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

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

本板块约定

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

每篇教程结尾都有验证步骤。sow check 返回 0,才证明所选 Repository 完整且与记录的 Generation 一致;非零结果不应作为交付物。

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,再执行 limitnoarch 包会投影到每个启用的架构视图,不能写进 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

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

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

2.2 - 搭建 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 对应 amd64aarch64 对应 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.3 - 仓库签名

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

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

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

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

1. 创建专用密钥

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

SIGNING_UID='SOW Repository <[email protected]>'
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 位十六进制指纹。该操作要求:

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

SOW 只会对私有 staged 副本调用 rpm --addsignrpm --resign,不会修改输入文件。 修改策略后重新校验并构建:

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 必须是恰好 16、40 或 64 位十六进制字符,不能带 0x 前缀;匹配私钥必须能被环境中的 rpm/GPG 使用。不带 --overwrite 时,已有签名的 RPM 保持字节不变;带上该参数则显式重签 所有 RPM。SOW 先签署私有 staged 副本,再替换包体与元数据。

更换密钥

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

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

2.4 - 服务与发布仓库

用 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。传输集成会针对固定 MinIO fixture 验证 list、HEAD、GET 与条件 PUT。启用生产目标前,请先在非生产 prefix 验证凭据、bucket policy、公共 endpoint、缓存行为、重放与恢复。详见平台与集成

下一步

2.5 - 演练构建 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 按“包名 + 原生架构”只保留最新一个版本。因此 rpmdeb 就是两个滚动更新的 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/rpminfra/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_64aarch64

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=0Trusted: 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

rpmdeblimit: 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 的默认 limit0,表示保留所有版本。最终策略是:

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

在晋升结束前暂停对 rpmdeb 的写入,然后复用这些 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-202608deb-202608 两个 Dist 提供。SOW 尚不强制快照 Dist 只读,因而创建后不再对它们 执行 addrm 是维护规约的一部分。

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 neverfillalways
事务日志与恢复 重新运行 create Workspace、Repository、发布
可查询 Operation Log 与 JSONL 导出
发布目标 filesystem 与 R2

SOW 在进程内解析软件包并渲染元数据,不调用 createrepo_cdpkg-scanpackagesreprepromodifyrepo_c。RPM 包签名是例外:它会改写包体,因此需要主机上的 rpm 命令与 GPG 环境。

仓库格式

表面 RPM/YUM DEB/APT
包事实来源 RPM header DEB control archive
身份 NEVRA + 确切字节 SHA-256 name=version:arch + 确切字节 SHA-256
索引 primaryfilelistsotherrepomd.xml PackagesPackages.gzRelease
中性架构 noarch all
不可变索引路径 校验和命名 rpm-md by-hash/SHA256
Managed 元数据签名 repomd.xml.asc InReleaseRelease.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/PackagesPackages.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.xmlPackagesPackages.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 就生成 PackagesPackages.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 与 timestamp 固定为 0,gzip header 固定,排序规范化。因此同一包集合生成逐字节一致的元数据。发布前 SOW 会比较 stage 与 live 元数据;如果无需清理/签名且所有输出已经相同,就只删私有 stage,不替换公开 inode,并返回 noop=true

Plain create 不做 journal 恢复,因此 JSON 字段 recovered 始终为 false

发布与中断语义

开始发布前,全部元数据都已在目标目录内的私有 stage 生成并验证。单文件替换使用同文件系统 rename;RPM 先安装 checksum 命名元数据,最后替换 repomd.xml

这不是多文件事务。进程在发布中被杀,可能留下新 RPM 元数据配旧 DEB 元数据、只剩一个 DEB 索引文件,或多余的旧 checksum RPM 元数据。这些状态不是需要调和的事务证据,只是可丢弃输出。下一次运行按当前包集合重新渲染完整投影,覆盖或删除残留。

实现不创建持久 journal 或 recovery trash。启动时会先丢弃 Plain 保留 staging 命名空间中 属于 SOW 的残留,再开始全新扫描。

--pigsty marker 门禁

--pigsty 还会删除命中兼容规则的解析包事实(DEB i386 与 Patroni 3.0.4),并把剩余包按 basename 排序写入 repo_complete,格式为 <sha256><两个空格><basename>。RPM 不会仅因为架构是 i386/i486/i586/i686 而被删除。

发布顺序为:

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 是一个单一格式(rpmdeb)的普通具名成员集合。名字对 SOW 是不透明字符串。el9trixieel9-betacustomer-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
└── <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/stage
.sow/repo-locks
.sow/repo-locks/pigsty.lock
.sow/workspace-ops
.sow/workspace.lock

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

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

状态数据库与软件包事实

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

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

缓存与指纹只属于私有实现状态,不改变公共 pool/ + dists/ 布局。不要手工编辑数据库或 PRAGMA user_version。v0.3 Repository 必须先备份,再通过 sow repo migrate 显式升级,之后才能执行普通 0.4 读写。全新 0.4 Repository 已使用当前 Schema;其他维护只在 SOW 诊断明确指出时执行。

sow.yml 驱动一切

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

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-amd64binary-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(包括 filesystemr2 发布目标)见 sow.yml 配置参考

发现:哪个工作区?

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

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

找到第一个 sow.yml 就停,不会越过它继续往上找"更好的那个"。

--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 时,buildcheckstatus 默认作用于选定 Repository 的 全部 Dist —— 对这几个命令而言,“没有过滤条件"解释成"全都要"是安全的。而 addrmls 必须得到明确的 Dist 集合,因为猜一个包该落到哪里并不安全:

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

退出码 2。所有推断都在路径类型与符号链接校验之后才进行。

init 只做收敛,不做重置

sow init 的幂等性是设计出来的,它的规则是架构不变式而不是使用便利:

  • 没有 sow.yml: 创建一个,写入 schema: sow/v3architectures: [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 有 PackagesPackages.gz、by-hash 条目和 Release。如果 Repository 配了元数据密钥,空 Dist 也一样签名。

因此从 Dist 里移除最后一个包之后,留下的是一份合法的空索引(配置签名时仍签名), 而不是缺失或损坏的协议入口。真实包管理器验收仍是另一层兼容性门禁。

Protected 仓库

repos:
  pigsty:
    protected: true

protected: true 会拒绝 repo rm,即使加了 -f,返回退出码 6。它不限制别的:addrmbuild 和常规 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 + noarchaarch64 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>/ 下渲染 PackagesPackages.gzby-hashReleaseInReleaseRelease.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。必须使用其他传输方式时, 用 rsynccp 或 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 里改 limitexclude 会让受影响的 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_64aarch64neutral
kind 下表固定枚举
format rpmdeb

pattern 是区分大小写的精确字符串或 shell glob(*?[])。没有正则,没有版本比较,没有取反,也没有表达式语言。未知字段、空规则和非法 glob 会在 config check 时失败,而不是静默地什么都匹配不到。

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

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

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

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

$ 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

注意这里有两级报告:条目的整体 statusexcluded(它最终没在任何地方成为成员),而逐 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 此刻明明空出了一个名额。

原因在于 excludelimit 移除的是 真实的期望成员。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.metadatasigning.deb.metadata signing.rpm.packages
产出 repodata/repomd.xml.ascInReleaseRelease.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.xmlRelease

四种密钥引用形态

密钥引用是一个 URI,scheme 决定由谁来签:

引用 含义 签名者
keys/repo-signing.asc 相对 Workspace Root 的 ASCII-armored 密钥路径 进程内 Go signer
file:///绝对路径.asc 磁盘上的 ASCII-armored 私钥 进程内 Go signer
env://VAR_NAME 环境变量里的 armored 密钥材料 进程内 Go signer
agent://<fingerprint> 由环境中 GPG agent 持有的密钥 外部 gpg

file://env:// 不需要装任何东西 —— SOW 自己签元数据,这也是为什么用 file:// 元数据密钥的仓库在 macOS 和最小化容器里能构建出一致的结果。agent:// 把签名委托给你的 GPG agent,适合私钥在智能卡上、或绝不能落盘的场景。agent:// 不能与 passphrase 引用同时使用,因为那次交互归 agent 管。

passphrase 引用接受相对 Workspace Root 的路径、file://env://,不接受 agent://

任何秘密都不会被持久化。 配置、SQLite、日志、JSON 输出和错误文本里,只有引用字符串、fingerprint 和公钥验证证书。config show --all 打印引用与 fingerprint,绝不打印密钥材料。如果某个密钥引用无法解析或不可用于签名,config check 会在你执行 build 之前就告诉你。

RPM 包体签名

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 --addsignrpm --resign,不会就地 修改输入文件。签完后 SOW 会重新解析结果,要求嵌入签名存在、signature-neutral digest 与 NEVRA 不变,并且签名身份与配置完全一致。fillalways 必须有 rpmgpg,且匹配私钥 必须存在于 rpm 使用的 GPG 环境中。key 引用用于标识并验证签名者,不会把私钥自动导入该环境。

由于签名里嵌入了时间戳,签名过程不可复现 —— 同一个未签名 RPM 签两次会得到不同字节。于是重复 add 一个已经加过的包看起来就像内容冲突。SOW 用 signature-neutral payload digest 解决这个问题:对不可变的 header 与 payload(排除 RPM signature header)计算 SHA-256。如果逻辑坐标已存在、neutral digest 相同,且既有对象满足当前策略,SOW 就复用既有的最终字节而不再签名。重复 add 同一个包是稳定的空操作。

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

更换密钥会让 Dist 变 dirty

一个 Dist 的 Built 配置摘要覆盖它的 format、canonical 架构、limitexclude,以及 已冻结的签名身份。改动密钥引用或 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 的多个证书版本可以共存 —— 所以延长有效期或增加子钥,不会让已经发布出去的东西失效。

Plain 模式

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

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

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

客户端验证什么

[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.ascgpgcheck=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 initrepo newrepo rm 下一条工作区生命周期命令
Repository 操作日志 该仓库的 SQLite dist new/rmaddrmbuildlog 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 / rolled_back
   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 才可能走到 done_dirty。默认 add 如果在 applied 之后渲染失败,命令返回错误、旧 Built 视图继续服务、Operation 保持可恢复 —— 它不会悄悄地以 dirty 收尾。

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

锁模型

锁是本机的 POSIX advisory flock。产品契约是单机、单写、本地 POSIX、协作式锁 —— 网络文件系统既不检测也不支持。

文件 谁持有
工作区锁 .sow/workspace.lock initrepo new/rmdist new/rm
仓库锁 .sow/repo-locks/<repo>.lock addrmbuilddist new/rmlog prune
Plain 目录锁 目标目录及其稳定父目录 sow create

两把都需要时,顺序固定:先工作区,后仓库,释放顺序相反。仓库锁的 inode 位于稳定路径,绝不随私有状态目录移动 —— 这样删除仓库时可以在别的进程还持有旧描述符的情况下撤下锁路径,而不会有第二个写者在新 inode 上形成。

sow create 同时锁住目标目录 它稳定的父目录。父目录锁的作用是:阻止另一个协作写者用 rename 把目录整个换掉,再对替身取得一把独立的锁。

只读命令从不取写锁,也不接受锁参数。其中需要组合读取配置、SQLite 与 live 元数据的那几个(config checkrepo ls/showdist ls/show)会在整个快照期间持有共享锁。status 刻意更轻:它只探测仓库锁,以便在写入进行中报告 recoveringlocked,而不会被它阻塞。

两个参数控制等待行为,适用于所有取写锁的命令:

参数 行为
-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 元数据、PackagesPackages.gz,以及 by-hash 索引副本。此时仍没有指针指向它们。
  3. pointer —— 客户端入口:RPM 的 repomd.xml(配置了签名则连同 .asc);Managed APT 则在每个架构的 direct 与 by-hash 索引都就位之后,才发布 Release(连同 InReleaseRelease.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,下一条 addrmbuilddist new/rmlog 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 拒绝覆盖,最后完成的视图继续服务,你应当从备份恢复,再跑 checkbuild。这里刻意没有 repair --force —— 一个可能猜错的修复,比一个拒绝执行的修复更糟。

fail-closed 的路径安全

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

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

名称必须匹配 [a-z0-9][a-z0-9._-]*,....sowpooldists 及工作区保留名一律拒绝。

对文件句柄也是同样的姿态。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 与公共树

未完成布局迁移使用更短的诊断表面:依次报告 configstatepublic-modeslayout-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 暴露的目录。

七个退出码分别代表什么。

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

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

约定

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

语法块中占位符用大写(NAMEDIRPACKAGE),字面量用小写。方括号表示可选参数, ... 表示可重复,竖线分隔互斥项 —— 与 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 initsow repo newsow repo rmsow dist newsow 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_64aarch64。DEB 生态名作为输入别名 被接受,并在解析边界规范化:

你可以写 存储与展示为
x86_64amd64 x86_64
aarch64arm64 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。其余一切照常:addrmbuild、建/删 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._-]*

下列名称是保留名,一律拒绝:....sowpooldistssow.ymlworkspace.lockworkspace-opsrepo-locks。两个会在状态目录里撞车的仓库名 (比如 dbdb.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 rpmdeb。一个 Dist 只承载一种格式。
architectures 字符串列表 继承工作区列表 把该 Dist 收窄到工作区架构的一个子集。
limit integer 0 同一包名 + 架构最多保留几个版本;0 表示全留。
exclude 规则列表 把命中的包挡在该 Dist 之外的规则。

format

formatsow 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_64aarch64neutral
kind 见下表分类
format rpmdeb

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 下有 packagesmetadata;signing.deb只有 metadata —— DEB 包体永远不会被重签,因为 APT 通过 Release 验证整个仓库, 而不是逐包签名。

rpm.packages

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

三种模式:

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

有 key 时默认是 fill,因为它是唯一保留上游签名的模式。设成 fillalways 却不给 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.xmlRelease 则始终会写。

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 只显示引用与解析出的 fingerprint:

    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 filesystemr2
endpoint 无尾斜杠的规范 file:///absolute/path,或 R2 的规范 https://host
region R2 必须是 auto;filesystem 禁用。
bucket R2 小写规范 bucket 名;filesystem 禁用。
prefix 相对公共树前缀;空串表示存储命名空间根。
credential R2 env://NAMEfile:///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_endpointmax_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 的文档都会被拒绝。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 rmsow showsow 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_64aarch64noarch。它 不是 规范族名 —— noarch 包这里就写 noarch,尽管 SOW 内部把它归类为 neutral(中性)。

DEB 坐标

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

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

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 showsow 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 lsshowwhere —— 三条查询命令
  • 退出码 —— 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/
│       └── pending/
└── <repo>/                               # 发布这个完整目录
    ├── pool/
    └── dists/

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

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

SOW 0.4 使用内部数据库 Schema v12。Append-only Publication-target Binding Revision、Package Facts、Signer Projection 与 Recovery Evidence 均只存在于 <repo>.db。v0.3 数据库必须先备份, 再通过 sow repo migrate 升级;绝不要手工修改 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 条目让上一组元数据在可变指针最后替换时仍然可达。

发布目标

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

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

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

名称与服务边界

仓库名与 Dist 名必须匹配 [a-z0-9][a-z0-9._-]*....sowpooldistssow.ymlworkspace.lockworkspace-opsrepo-locks 在相应位置为 保留名。SOW 会拒绝大小写不敏感的池路径冲突,使产物能在 Linux 与默认 macOS 文件系统间迁移。

绝不要暴露 .sow

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

延伸阅读

4.4 - 退出码

七个退出码分别代表什么,以及每个码一条可复现的触发命令。

每条 sow 命令都以七个退出码之一结束。它们是稳定的、对所有命令一致, 并且就是设计来给脚本做分支判断的 —— 区分"这件事失败了"和"这件事被正确地拒绝了", 正是设置多个非零码的全部意义。

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

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

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

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


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 是有意为之的特例:只要状态数据库可读,它在 cleandirtyrecoveringerror 四种状态下都返回 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 or configuration error: repository "pigsty" has multiple Dists (el9, trixie); select one or more with --dist

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

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

起始目录本身不是真实目录(比如符号链接,macOS 上的 /tmp 就是)时,搜索根本不会开始:

workspace discovery error: managed: workspace discovery or configuration error: discover workspace from cwd "/tmp": start is not a directory

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

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

这里的 mirrorpigsty 已配置的 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 实际调用的命令,含子命令:addrepo lsconfig show
ok bool errors 为空时为 true,等价于退出码 0
repository string 或 null 选定的仓库;工作区级与 Plain 模式命令为 null
operation string 或 null 写命令的 Operation ID,只读命令为 null
result object 或 null 命令专属载荷,详见下文。
errors array 零个或多个 {code, class, message} 对象。

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

errors

"errors":[{"code":3,"class":"partial","message":"managed: batch partially succeeded"}]
字段 含义
code 进程退出码 —— 16
class runtimeusagediscoveryconfigpartiallockintegrityrejected 之一。discoveryconfig 是退出码 2 的稳定细分类。
message 与写到 stderr 的文本相同。

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

非零退出仍然返回 result

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

Operation ID 是字符串

"operation":"8632724976452398569"

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

Generation ID 是固定宽度字符串

"built_generation":"00000000000000000004"

Generation ID 覆盖完整的无符号 64 位范围,并固定序列化为 20 位、左侧补零的十进制字符串。 generationbuilt_generationbase_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 被索引的绝对目录
rpmdeb 各格式的包数量
kept 进入索引的文件名,已排序
removed --pigsty 清理删除的包;否则为空
marker 是否写入了 repo_complete
marker_sha256 marker 文件的摘要;仅 --pigsty 时出现
noop 索引本来就正确、什么都没改时为 true
recovered 为兼容稳定 schema 保留;Plain create 没有 journal 恢复,始终为 false
signed 被重签的文件名;仅 --sign-with 时出现

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。私钥内容与口令永不出现。

repo ls / repo new / repo show

repo ls 返回数组;repo newrepo 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 buildeffective_config_sha256 是所有喂给渲染器的输入的摘要;它一变,该 Dist 就变 dirty。

dist rmrepo 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 的取值是 acceptedexcludedlimited。同一条命令里, 一个包可以被某个 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_addedmemberships_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 运行时 checktrue,此时 什么都没写,changes 是一份预测。 注意 removed 只列出成员关系的移除 —— rm 永远不删除包池字节。

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 取值为 cleandirtyrecoveringerror。部署脚本该读的字段是 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 按固定顺序返回九层,每层给出检查项数与问题。未完成布局迁移则只返回 configstatepublic-modeslayout-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 addupdatedelete
phase payloadmetadatapointerdelete
path 永远相对仓库根,永远用 / 分隔
sizesha256 addupdate 有;delete 没有

按这个 phase 顺序施加变更,客户端永远不会取到悬空引用:先包体, 再校验和命名的元数据,然后是协议指针(repomd.xmlRelease),最后才删除被取代的文件。

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

ls / show / where

ls 返回包对象数组;showpackage 下返回恰好一个。

"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_64noarchamd64all
canonical_arch SOW 用于分组的族:x86_64aarch64neutral
payload_sha256 仅 RPM —— 签名无关摘要,用于识别同一包的重签副本
signature_key 包内嵌签名的 key ID(包带签名时)
storage 已发布为 pool,--skip 加入的为 pending
dists / built_dists 期望成员集 与 上一次构建实际发布的集合

distsbuilt_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 repositorytargetprovidergenerationattemptcheckpointphaseobjectsnoop
publish --abort repositorytargetproviderattemptphaseobjects
publish --rebind publish 相同;Binding Revision 属于持久私有审计状态,不新增 Wire Field
retain add / retain rm repositoryrecordrecord_identitypath
retain ls repositorygenerations[],元素使用同一 retained record 形态
本地 gc operationrepositorybase_generationgenerationobjectsbytesnoop
目标 gc repositorytargetproviderphasereportscandidatesdeleted_objectsdeleted_bytesretained_objectspending_gracecompleted_attemptsnoop
export rpm-leaf repositoryrepository_idgenerationdistarchdirectorymethodsignedsigner_identitypackagesfilesmanifest_sha256

attemptcheckpoint 或本地 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_jsonresult_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}

注意 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,不需要语言运行时。0.4.0 制品使用 Go 1.27.0 构建; 源码 Module 要求 Go 1.27.0 或更新版本。归档包含 README.mdCHANGELOG.md 与 Apache-2.0 LICENSE,Linux 软件包会随二进制安装同一份 协议文件。使用 sow version 查看产品版本、目标 OS/架构与构建工具链。

工作区文件系统

Managed 工作区应放在本地 POSIX 文件系统上。正确性依赖建议锁、fsync、基于描述符的路径 校验与同文件系统原子 rename;NFS 等网络文件系统不属于受支持的工作区位置。

公共 <workspace>/<repo>/ 树是另一条边界:它是闭合的 pool/ + dists/ 命名空间,可整根 复制或发布,不依赖 SQLite、私有 journal 或 view-local hardlink identity。必须保持完整 Repository,不得暴露 .sow/

SOW 会拒绝符号链接控制路径、不安全普通文件、重叠 filesystem target,以及大小写折叠后冲突 的 Pool 路径,使同一个 Repository 可以在大小写敏感的 Linux 与默认大小写不敏感的 macOS 文件系统间移动。

自动化集成矩阵

表面 环境 已验证行为
生产 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 兼容传输 固定 MinIO 容器 验证 Bucket 列表、HEAD、GET、仅创建/CAS 写入、重放、条件式 Multipart Upload、对象元数据、重试与 Prefix 约束
Release 打包 Linux CI 构建四个归档、两个 RPM、两个 DEB 与 SHA256SUMS;检查包内路径、Apache-2.0 元数据与协议文件字节

DNF 签名切换是协议测试,不是完整 Managed RPM 安装;APT 作业覆盖未签名 Plain 仓库,不覆盖 Managed 元数据签名。正式上线前,应使用部署中的确切 dnf/APT 版本、仓库 URL、访问策略与 签名策略完成验收。

仓库客户端契约

Plain RPM 仓库在包文件旁提供 repodata/;Plain DEB 仓库在包文件旁提供 PackagesPackages.gz。配置好客户端信任策略后,可通过 file:// 或 HTTP 使用。

Managed 客户端必须消费完整 Repository Root:

  • APT 索引位于 dists/<dist>/main/binary-<arch>/,并引用根级 pool/Release 声明 SHA-256 by-hash 索引;配置签名后增加 InReleaseRelease.gpg
  • RPM 元数据位于 dists/<dist>/<arch>/repodata/,通过相对位置回指根级 pool/。必须服务 整个 Repository,不能只发布一个架构目录。

默认 dnf reposync 会拒绝规范 Managed RPM 的父级相对包路径。该工作流应使用 sow export rpm-leaf 生成自包含副本。导出使用本地包路径并带有 完成清单,但不是第二个规范 Repository。

发布 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_endpointmax_cache_ttl 只能通过显式 publish --rebind 修改;Storage Identity 与 Prefix 不可变。

部署门禁

交付前必须通过深度校验,并检查物理变更计划:

sow check -r REPOSITORY
sow changes 0 -r REPOSITORY

发布后,再访问实际 repomd.xmlRelease URL,并运行目标软件包管理器。本地构建、Provider 写入、HTTP 可达与客户端安装是四个独立检查。

相关契约见仓库布局签名模型发布与恢复

5 - 命令

SOW CLI 的完整语法、参数、行为、输出与退出码。

每条顶层命令单独成页;configrepodistretainexportlog 等命令组在同一页说明其子命令。

二进制内置的 sow help 是语法权威。本手册在此基础上补充选择规则、状态变化、输出契约、 失败行为与可直接使用的示例。

命令索引

sow create 是 Plain 模式的仓库命令,直接作用于目录。sow init 用于启动 Managed 模式, 必要时会创建 sow.yml;其余有状态命令发现既有工作区。helpversion 是工具命令, 不需要进入任何模式。

命令 模式 用途
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 COMMANDsow help COMMAND SUBCOMMAND 查看内置帮助。sow versionsow --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 newrepo rm 用位置参数接收 NAME,不接受 -rsow where 默认搜索所有 Repository,-r 只用于收窄范围。发布目标自身绑定 Repository,因此 publish TARGETgc TARGET 不再接受额外的 Repository 选择。

Dist 选择

addrmls 要求明确的 Dist 集合,并按以下顺序选择:

  1. 一个或多个 -d/--dist NAME
  2. 发现起点所在的 Dist;
  3. 所选 Repository 中唯一的 Dist;
  4. 否则退出 2 并列出候选项。

其他命令有意采用不同规则:

  • 未指定 -d 时,buildcheckstatus 默认作用于全部 Dist;
  • show 默认搜索所选 Repository,-d 只用于收窄;
  • where 默认跨工作区搜索全部匹配 Dist,-r/-d 用于收窄;
  • changes 作用于整个 Repository,明确拒绝 -d

init 外,写命令接受 -T/--timeout DUR-N/--no-waitinit 获取 Workspace 锁,且不提供 命令行超时覆盖;其他锁均为 Repository 级,但 repo newrepo rm 同样使用 Workspace 锁。 --timeout 0 表示无限等待;正数使用 Go duration,例如 500ms30s5m--no-wait 立即失败;它与正数 timeout 互斥。获取锁失败退出 4

只读命令不获取写锁;status 仍会报告 Repository 是否正被写者持锁。

并发

只有需要解析软件包、哈希包体、渲染索引或执行校验的命令才接受 -j/--jobs Ncreateaddrmbuildcheckrepo 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、不做工作区发现。本页讲清单遍扫描 契约、--pigsty 完成门禁,以及 --sign-with 的 RPM 包签名。

语法

sow create [DIR] [-j N] [--pigsty] [-S KEY [--overwrite]] [-T DUR | -N] [--json]

DIR 默认为当前目录。

说明

create 读取 DIR 顶层的普通文件,按发现的内容渲染对应索引:有 RPM 就生成 repodata/,有 DEB 就 生成 PackagesPackages.gz,混合目录两套一起生成。架构全部来自包头——Plain 模式没有架构参数, 也没有架构许可表。

平面元数据只引用同目录的包:RPM 的 location 是裸 basename,DEB 的 Filename./<basename>。无论目录作为 file:// 源还是 HTTP 根暴露,两者都保持相对引用。

默认情况下 create 不删除、不移动、不重命名、不重签、不改写任何一个包字节。它只替换自己拥有的索引 路径,未知文件原样保留。

参数

参数 说明 默认
-j, --jobs N 唯一一次包哈希/解析扫描的并发 worker 数 逻辑 CPU 数
--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> 与 timestamp 0。对未变化的目录重跑 create 不写任何字节,报告 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

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 前缀。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 不承诺跨文件瞬时原子性。--pigstyrepo_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

失败时的 envelope:

sow create /srv/empty --json
{"schema":"sow.cli/v1","command":"create","ok":false,"repository":null,"operation":null,"result":{"dir":"","rpm":0,"deb":0,"kept":null,"removed":null,"marker":false,"noop":false,"recovered":false},"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.gzby-hashRelease

再跑一次什么都不会变:

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":[]}

锁与恢复

工作区生命周期命令——initrepo newrepo 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 继承来的 architectureslimit: 0 与空的 exclude 列表。架构一律以规范化 family(x86_64aarch64)打印,绝不用生态别名——amd64arm64 只是 同两个 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._-]*,且不能是 ....sowpooldists,也不能与工作区 保留文件冲突。

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 取值为 cleandirtyrecoveringerror。各状态对客户端意味着什么,见 事务与恢复

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.4 Managed 工作流。由 SOW 0.4 创建的 Repository 已经使用 当前单包体布局与 Schema。

但从既有 v0.3 Workspace 升级时,迁移是强制步骤:先停止全部 Workspace 写入并完成备份,再在 执行普通读写之前逐个迁移所有已配置 Repository。

cp -a /srv/sow /srv/sow.backup-before-0.4.0
sow repo migrate pigsty -C /srv/sow
sow repo migrate pgsql -C /srv/sow

0.4 Transition 会安装 Schema 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 0.3 打开数据库,也不要手工修改 PRAGMA user_version--abort 只适用于诊断出的 pre-commit layout-maintenance attempt,不能 撤销已经完成的 Schema Migration。除升级或 SOW 明确诊断外,不要试探性执行 migrate。

参数 含义 默认值
-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 只作用于仓库删除。受保护仓库上的包级操作不受影响——addrmbuild,乃至 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 名称非法、仓库不存在、非空但未给 -fprotected,或 NAME-r 冲突

参见

5.5 - sow dist

列出、创建、查看与删除 Dist —— 客户端真正消费的、单一格式的具名成员集。

Dist 是一个仓库内、单一格式(rpmdeb)的具名包集合。客户端指向的就是它。一个仓库可以同时拥有 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._-]*,排除 ....sowpooldists

对 SOW 而言这个名字是不透明字符串。el9trixieel9-betacustomer-acme2026-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	[]

DESIREDBUILT 是成员计数。两者不一致时,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-amd64binary-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 必填;rpmdeb
-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 声明子集来收窄。策略 (limitexclude)同样只在 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 有空的 PackagesPackages.gzby-hash/SHA256/ 条目, 以及 Release;配置签名时再生成 InReleaseRelease.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 配置的摘要。当你改动 limitexclude 或签名 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:amd6418.3-1:arm64limit: 1 下都能 留下。同一次运行中,一个包可以被某个 Dist 接受、被另一个 Dist 跳过。

被策略移除的成员不会复活

excludelimit 移除的是真实的期望成员。之后放宽策略不会把它们变回来——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 被拒绝,仓库保持原样:

sow add /srv/pkg/centos-release-3.1-1.i386.rpm -r pigsty -d el9
operation rejected: managed: operation rejected: no input package was accepted

没有 rejected/隔离目录。

–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 集合上执行 excludelimit
  7. 提交期望状态;新字节写入私有 pending 内容存储。
  8. 除非给了 --skip,把仍被需要的 pending 对象发布进 pool/ 并渲染索引——一次命令中每个 Dist 最多 构建一次。

任何模式下,输入文件都不会被修改、移动或删除。

RPM 签名模式

Managed 模式的 RPM 包签名在 sow.ymlsigning.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,或坐标冲突

参见

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 精确算出会移除什么、策略随后会怎么判定、以及立即构建会触碰哪些文件——并且什么都不写。

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-118.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 与锁参数同用、选择有歧义、工作区未找到
3 部分批次——至少一个引用被移除,至少一个失败
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 不可变内容身份,可直接传给 showrm
COORDINATE 规范的 rpm:deb: 包引用
DISTS 所选范围内的 Desired Membership
BUILT_DISTS 当前 Built Generation 中的成员关系
POOL_PATH Repository 内的不可变包体路径

Desired 与 Built 不一致时,首行显示 dirty=trueBUILT_DISTS 为空表示该包已进入期望状态, 但客户端尚不可见;运行 sow build 完成收敛。

多个所选 Dist 共享同一对象时,只输出一行,成员列表用逗号分隔。空 Dist 只有表头、没有包行, 仍然是成功结果。

选择范围

ls 要求 Dist 集合无歧义。Repository 包含多个 Dist 时,应传入一个或多个 -d,或从 <repo>/dists/<dist>/ 内运行。

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

该命令不获取写锁,也不重新哈希包文件。--jsonresult.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_64aarch64,或 RPM noarch / DEB all 对应的 neutral
kind 策略分类:maindebuginfodebugsourcellvmjitdbgsymdbg
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

引用解析

PACKAGEsow 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 Repository,使用 0.4 读取表面前必须先 备份,并逐个执行 sow repo migrate

退出行为

只要状态可读,statuscleandirtyrecoveringerror 四种状态下都返回 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 会重新执行当前 excludelimit 策略。收紧策略可能移除 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 进入 errorbuild 拒绝猜测;不存在强制修复参数。

进度事件

耗时较长的构建会向 Operation Log 追加结构化 build_progress 记录。每条事件包含 phasecompletedtotaljobs。当前阶段为:

  • 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;配置签名后额外生成 InReleaseRelease.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 保留记录
package-bytes 包池与私有 pending 包体的 SHA-256 Package Object
desired-membership Membership 能否在当前策略下解析到真实对象 成员关系
index 渲染索引是否与其声明的成员关系一致 Dist
signature 所有已声明元数据/软件包签名是否有效 签名
generation-manifest Built Generation Manifest 是否与磁盘文件一致 一个 Manifest

Repository 处于未完成布局迁移时,check 改为依次报告 configstatepublic-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。

字段 取值
操作 addupdatedelete
阶段 payloadmetadatapointerdelete

阶段描述 SOW 如何构建本地 Generation。不要把单行直接重放到线上目录;应使用 sow publish,或先暂存完整副本再原子切换。

Base Generation

不带参数时,SOW 比较当前 Built Generation 与它的前一代。

BASE_GENERATION0..当前代 范围内的十进制整数。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 为 recoveringerror 时,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.ymltargets: 中指定的 目标。目标已经绑定 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 必须是已配置的 filesystemr2 发布目标。--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

公共可见性校验

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 仍然 只报告候选,不执行远端删除。

安全边界

  • 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 才可能回收。

参数

参数 适用命令 含义
-C, --workdir DIR 全部 工作区发现起点
-r, --repo NAME 全部 选择 Repository
-T, --timeout DUR addrm 最长写锁等待时间
-N, --no-wait addrm 锁被占用时立即失败
--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_64aarch64
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 根。

复制与硬链接

复制是安全默认值。--hardlink 只适用于同一文件系统、且消费者无法修改的可信只读目标。硬链接 包体与 SOW 包池共享 inode,不能用于可写或不可信目标。

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 / rolled_back
   pre-apply error  → failed
状态 含义
planned 命令、参数、目标与预期动作已持久化
staged 新包/元数据已写入临时位置并校验通过
applied 期望状态与所需的私有 pending 载荷已提交
built 完整的静态 Generation 已切换
done 终态——一次正常成功的命令
done_dirty 终态——给了 --skip,公开树被有意保留在旧代
failed 终态——在 applied 之前失败,什么都没提交
rolled_back 终态——applied 之后失败,但进程安全地回滚了
recovering 非终态;下一条写命令必须先完成或回滚它

工作区生命周期命令(initrepo newrepo 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_sha256result_json 记录结果。失败的 Operation 还会带 error_classerror_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 词表:payloadmetadatapointerdelete

Build Operation 还会包含进度事件。它们保持当前 state,并把版本化对象放入 detail_json

{
  "state": "applied",
  "detail_json": "{\"version\":1,\"kind\":\"build_progress\",\"phase\":\"rendering\",\"completed\":1,\"total\":2,\"jobs\":8}"
}

阶段包括 renderingpromoting_payloadpublishing_distsnormalizing_public_treefinalizing。进度行是持久审计数据,但不会推进恢复状态机,也不会 单独触发 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 且符合条件的终态审计记录,并安全压缩数据库。

sow log prune 2027-01-01 -r pigsty
{"operation":"8150803833883584722","repository":"pigsty","before":"2027-01-01T00:00:00+08:00","pruned":1}

绝对时间戳会被回显,这样本地时区的解释永远不含糊。

参数 说明 默认
-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 不存在
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 完整性或恢复错误

参见