跳转到主要内容

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

返回本页常规视图.

参考

配置 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 的写法一致。

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 操作,不是配置中的滚动计数。

延伸阅读

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 同时覆盖"无匹配"与"歧义"

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 - 退出码

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

每条 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。

延伸阅读

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'

延伸阅读

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 可达与客户端安装是四个独立检查。

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