跳转到主要内容

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

返回本页常规视图.

教程

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

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

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

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

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

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

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

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

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

先看哪篇

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

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

本板块约定

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

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

1 - 搭建 YUM 仓库

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

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

1. 创建工作区

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

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

2. 配置成员策略

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

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

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

sow config check
sow config show --all

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

3. 添加 RPM

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

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

公共树如下:

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

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

4. HTTP 预览

本地预览可以直接使用:

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

在另一个终端检查入口:

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

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

5. 配置 dnf

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

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

刷新并查询仓库:

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

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

6. 发布或导出

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

sow check -r pigsty

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

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

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

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

更新仓库

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

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

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

2 - 迁移已有 YUM 仓库

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

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

1. 明确迁移范围

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

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

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

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

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

2. 保留发布时间历史

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

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

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

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

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

3. 构建并签署候选

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

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

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

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

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

4. 切换前验收

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

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

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

5. 发布与恢复资料保留

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

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

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

3 - 搭建 APT 仓库

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

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

1. 创建工作区

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

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

2. 配置成员策略

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

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

然后校验:

sow config check
sow config show --all

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

3. 添加 DEB

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

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

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

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

4. HTTP 预览

本地预览可以直接使用:

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

检查协议入口:

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

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

5. 配置 APT

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

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

刷新并查询:

sudo apt update
apt-cache policy

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

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

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

6. 安全发布

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

sow check -r pigsty

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

更新仓库

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

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

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

4 - 仓库签名

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

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

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

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

1. 创建专用密钥

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

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

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

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

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

2. 配置元数据签名

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

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

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

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

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

3. 手工验证元数据

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

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

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

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

4. 可选:签署 RPM 包体

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

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

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

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

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

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

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

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

5. 启用 dnf 验签

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

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

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

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

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

6. 启用 APT 验签

将公钥安装为独立 Keyring:

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

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

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

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

Plain 模式 RPM 签名

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

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

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

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

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

更换密钥

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

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

5 - 服务与发布仓库

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

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

公共与私有路径

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

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

1. 校验源 Generation

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

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

2. 配置 filesystem target

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

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

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

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

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

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

校验并发布:

sow config check
sow publish public

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

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

3. 用 Nginx 服务 target

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

    root /srv/repo-public;
    autoindex off;

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

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

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

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

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

4. 验证服务入口

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

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

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

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

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

手工与隔离交付

如果 sow publish 无法到达目标:

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

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

R2 target

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

下一步

6 - 演练构建 pigsty-infra 仓库

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

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

1. 把包集中到 ~/repo

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

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

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

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

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

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

2. 创建 infra Repository 与两个 Dist

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

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

现在模型已经确定:

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

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

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

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

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

3. 一次性导入并构建

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

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

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

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

预期至少出现:

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

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

4. 看懂生成的目录

打印实际目录:

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

关键结构应当是:

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

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

5. 用 Nginx 只读服务仓库

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

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

直接检查两种索引入口:

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

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

6. 在 EL9 中只用 infra 安装 RPM

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

删除只用于硬订正

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

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

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

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

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

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

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

10. 把 latest 晋升到 stable

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

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

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

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

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

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

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

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

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

11. 从 stable 创建 2026-08 快照

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

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

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

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

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

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

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

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

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

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

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

12. 客户端地址总表

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

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

最终验收:

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

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

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

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