仓库布局

SOW 创建的每一条路径、包池分组规则、名称约束,以及绝对不能对外暴露的目录。

SOW 的磁盘布局是固定的。没有 path: 配置项,没有模板,也没有办法把仓库挪到别处 —— 每一条路径都由工作区根目录、一个已校验的名称和固定的相对片段推导而来。 本页给出完整地图,让你清楚哪些该发布、哪些该复制、哪些必须留在本地。

Plain 平面模式

sow create 把索引写在软件包旁边,不碰任何别的东西。你指向的那个目录仍然是一个 平面仓库(flat repository):包与元数据同处一层。

/srv/offline/
├── blackbox_exporter-0.28.0-1.x86_64.rpm     # 你的文件,永不被修改
├── pev2-1.23.0-1.noarch.rpm                  # 你的文件
├── libpq5_18.3-1.pgdg12+1_amd64.deb          # 你的文件
├── repodata/                                 # SOW 生成,目录里有 RPM 时出现
│   ├── <sha256>-primary.xml.gz
│   ├── <sha256>-filelists.xml.gz
│   ├── <sha256>-other.xml.gz
│   └── repomd.xml
├── Packages                                  # SOW 生成,目录里有 DEB 时出现
├── Packages.gz
└── repo_complete                             # 仅 --pigsty 生成

RPM 与 DEB 索引可以共存:两种格式都在时,一条 sow create 同时写出两套。 只有上面这些路径属于 SOW —— 目录里其他文件保持原样。

平面元数据只引用同目录下的包,所以这个仓库通过 file:// 和以该目录为文档根的 HTTP 消费方式完全一致:

# repodata primary.xml —— location 是裸文件名
<location href="blackbox_exporter-0.28.0-1.x86_64.rpm"

# Packages —— Filename 是 ./文件名
Filename: ./libpq5_18.3-1.pgdg12+1_amd64.deb

生成的文件不继承调用者的 umask:repodata/ 固定 0755;索引文件、PackagesPackages.gzrepo_complete 固定 0644

sow create 执行期间,目录里还会临时出现一批私有工作路径,名字都以 .sow-plain- 开头: stage 目录(.sow-plain-stage-*)、持久 journal(.sow-plain-operation.json),以及 --pigsty 模式下存放待删除包的 recovery trash(.sow-plain-recovery-*)。成功时会被清理, 崩溃后由下一次运行清理。不要对外服务它们,也不要复制它们。

Managed 托管模式

工作区根目录下只有两样东西:配置文件与私有状态目录。其余每一项都是一个仓库, 而仓库目录就是你对外发布的单位。

<workspace>/
├── sow.yml                       # 配置文件 —— 不要对外服务
├── .sow/                         # 私有状态 —— 不要服务,也不要复制
│   ├── workspace.lock
│   ├── workspace-ops/            # init / repo new / repo rm 期间存放 active.json
│   ├── repo-locks/
│   │   └── <repo>.lock
│   ├── <repo>.db                 # 每仓库独立的 SQLite(含 -wal / -shm 协调文件)
│   └── <repo>/
│       ├── stage/                # 元数据先在这里生成校验,再原子换入
│       ├── recovery/             # 前镜像与等待删除的对象
│       └── pending/              # --skip 加入但尚未发布的包体,以 SHA-256 命名
└── <repo>/                       # ← 这个目录才是你要服务和 rsync 的
    ├── pool/
    └── dists/

私有状态目录权限是 0700。仓库锁位于一条稳定路径 .sow/repo-locks/<repo>.lock, 它永不移动 —— 这样恢复过程中即使替换了仓库私有状态,其他进程持有的锁也不会失效。

同一工作区内的两个仓库不共享任何东西:各自有独立的包池、SQLite、锁和代计数器。 两个仓库里字节完全相同的包会存两份 —— 去重不跨仓库边界。

仓库

<workspace>/pigsty/
├── pool/
│   ├── b/blackbox_exporter/blackbox_exporter-0.28.0-1.x86_64.rpm
│   ├── b/blackbox_exporter/blackbox_exporter-0.28.0-1.aarch64.rpm
│   ├── libf/libfoo/libfoo1_1.0-1_amd64.deb
│   ├── p/pev2/pev2-1.23.0-1.noarch.rpm
│   └── p/postgresql-18/libpq5_18.3-1.pgdg12+1_amd64.deb
└── dists/
    ├── el9/       # format: rpm
    └── trixie/    # format: deb

pool/ 里每个包的字节只存一份,由仓库内所有 Dist 共享。包池中的内容不可变 —— 同一条路径永远是同样的字节。把包从某个 Dist 移除,移除的是成员关系而不是包池字节; 当前版本没有 GC。

包池分组规则

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

source 是源码包名,取自 RPM 的 SOURCERPM 头或 DEB 的 Source control 字段。 字段缺失时回落到二进制包名,并记录一条 warning。这就是 libpq5 落在 postgresql-18 下面的原因:那是它的构建来源,也是 reprepro 采用的同一套分组。

prefix 遵循 Debian 规则 —— source 名的首字符;source 名以 lib 开头时取前四个字符:

Source池前缀示例路径
postgresql-18ppool/p/postgresql-18/libpq5_18.3-1.pgdg12+1_amd64.deb
blackbox_exporterbpool/b/blackbox_exporter/blackbox_exporter-0.28.0-1.x86_64.rpm
libfoolibfpool/libf/libfoo/libfoo1_1.0-1_amd64.deb

前缀统一转为 ASCII 小写;sourcefilename 保持原始大小写。 两个包如果完整池路径在大小写不敏感比较下会碰撞,会被直接拒绝 —— 这样在 Linux 上构建的仓库复制到默认的 macOS 文件系统上仍然有效。

reprepro 不同,包池里没有 component 层:路径是 pool/<prefix>/<source>/,不是 pool/main/<prefix>/<source>/

RPM Dist

一个 RPM Dist 为每个架构族渲染一个目录,每个目录都是完整、可独立消费的仓库:

dists/el9/
├── x86_64/
│   ├── repodata/
│   │   ├── <sha256>-primary.xml.gz
│   │   ├── <sha256>-filelists.xml.gz
│   │   ├── <sha256>-other.xml.gz
│   │   ├── repomd.xml
│   │   └── repomd.xml.asc          # 仅配置了 signing.rpm.metadata.key 时生成
│   └── pool/                       # 指向仓库包池的硬链接
│       ├── b/blackbox_exporter/blackbox_exporter-0.28.0-1.x86_64.rpm
│       └── p/pev2/pev2-1.23.0-1.noarch.rpm
└── aarch64/
    ├── repodata/ ...
    └── pool/
        ├── b/blackbox_exporter/blackbox_exporter-0.28.0-1.aarch64.rpm
        └── p/pev2/pev2-1.23.0-1.noarch.rpm

每个视图包含本架构的原生包,加上全部中性(noarch)包。视图 pool/ 下的条目是指向 仓库包池的硬链接 —— 同一个 inode,不额外占用磁盘:

stat -f "%l links  inode=%i  %N" pool/p/pev2/pev2-1.23.0-1.noarch.rpm \
    dists/el9/x86_64/pool/p/pev2/pev2-1.23.0-1.noarch.rpm \
    dists/el9/aarch64/pool/p/pev2/pev2-1.23.0-1.noarch.rpm

3 links  inode=206234569  pool/p/pev2/pev2-1.23.0-1.noarch.rpm
3 links  inode=206234569  dists/el9/x86_64/pool/p/pev2/pev2-1.23.0-1.noarch.rpm
3 links  inode=206234569  dists/el9/aarch64/pool/p/pev2/pev2-1.23.0-1.noarch.rpm

三个链接:根包池加两个视图。原生 x86_64 包则是两个 —— 根包池加它所属的那一个视图。

这样设计的原因是客户端兼容性。元数据用不含 .. 的普通相对路径引用包:

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

指向 ../../../pool/... 的布局在 dnf install 下也能工作,但 dnf reposync 会拒绝 —— 规范化后的本地目标逃出了每仓库的下载根目录。硬链接视图让所有客户端都能正常工作, reposync 也包括在内。

因为用的是硬链接,pool/dists/ 必须位于同一个文件系统。不满足时 SOW 会明确失败, 绝不静默退化为复制。用不保留硬链接的工具复制仓库(普通 cp -r、多数对象存储上传) 在功能上没问题 —— 只是失去去重、多占一份空间。

元数据文件以自身校验和命名,所以推进一代是新增文件,而不是覆盖某个客户端可能正在 下载的旧文件。repomd.xml 是唯一可变的指针,并且最后一个被原子替换。 系统保留上一代的元数据文件;更早的会出现在 sow changesdelete 阶段。

DEB Dist

一个 DEB Dist 在固定的 main component 下为每个架构渲染一个 binary-<arch> 目录。 这里没有视图级包池 —— APT 从 archive 根解析 Filename:

dists/trixie/
├── Release
├── InRelease                       # 仅配置了 signing.deb.metadata.key 时生成
├── Release.gpg                     # 同上
└── main/
    ├── binary-amd64/
    │   ├── Packages
    │   ├── Packages.gz
    │   └── by-hash/
    │       └── SHA256/
    │           ├── <Packages 的 sha256>
    │           ├── <Packages.gz 的 sha256>
    │           └── ...              # 保留上一代
    └── binary-arm64/
        └── ...

注意 DEB 视图用生态架构名(amd64arm64),RPM 视图用族名(x86_64aarch64), 两者出自同一份规范化配置。

Packages 里的条目相对 archive 根指向共享包池,也就是 <repo>/pool/... —— 这正是"要服务仓库目录而不是 Dist 目录"的原因:

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

Release 声明支持 by-hash,并且只带 SHA256 清单 —— 没有 MD5Sum,没有 SHA1:

Origin: SOW
Label: trixie
Suite: trixie
Codename: trixie
Date: Tue, 04 Aug 2026 04:07:16 UTC
X-SOW-Generation: 4
Architectures: amd64 arm64
Components: main
Acquire-By-Hash: yes
Description: SOW managed distribution
SHA256:
 95e8c59d21d69285ac788bd8ea78b0544b0a1395ae9a0e3a700ec13b420e5c39 2245 main/binary-amd64/Packages
 4d658bdf6a542999f737e5f89e3bdb504c205fb85cda76f3e4b1ef73619c5900 751 main/binary-amd64/Packages.gz
 c924dbbd01d2e14bc3a4892a355b3674cb238f8315e55c609d25043568f59dc8 1122 main/binary-arm64/Packages
 a4289540a3224dbfbdf1c5b23db355c6541df34baf378070a493d9380f03b1ee 668 main/binary-arm64/Packages.gz

by-hash/SHA256/ 的作用是:让刚刚取过 Release 的客户端在索引更新时不会扑空 —— 新索引发布的同时,旧索引仍可按摘要访问。APT 1.2 及以后版本会自动使用它。

空 Dist 同样是合法 Dist。没有任何包的 DEB Dist 会发布空的 Packages、对应的 .gz、 by-hash 条目和已签名的 Release;RPM Dist 则为每个架构发布空但可消费的 repodata

名称

仓库名与 Dist 名必须匹配 [a-z0-9][a-z0-9._-]*,因为它们都会变成目录名, 必须在大小写敏感与不敏感的文件系统上表现一致。

下列名称是保留名,一律拒绝:

保留名原因
...路径穿越
.sow私有状态目录
pooldists仓库固定子目录
sow.yml配置文件
workspace.lockworkspace-opsrepo-locks工作区状态路径

两个会在 SQLite 附属文件名上碰撞的仓库名也会被拒绝 —— 名为 db 的仓库和名为 db.db 的仓库会争夺 .sow/db.db:

configuration error: ... repository names "db" and "db.db" collide at reserved state path "db.db"

服务什么,隐藏什么

安全的做法是把 Web 服务器指向某个仓库目录,而不是工作区根目录:

# 正确:文档根是一个仓库
location /pigsty/ {
    alias /srv/repo/pigsty/;
    autoindex on;
}

如果确实需要服务工作区根目录(比如把多个仓库放在同一前缀下),请显式拒绝私有路径:

location ^~ /.sow  { deny all; }
location = /sow.yml { deny all; }

复制时同理。sow changes 0 列出的正是构成当前发布树的全部文件, 每一条路径都在 pool/dists/ 之下;sow.yml.sow/ 永远不会出现在其中, 因为它们不属于镜像。

# 发布一个仓库。--hard-links 保留视图去重。
rsync -a --hard-links --delete /srv/repo/pigsty/ mirror:/var/www/pigsty/

复制前先确认这棵树可交付:

sow check -r pigsty

处于 dirty(待构建)的仓库,其发布树仍然是上一次 build 留下的完整自洽版本 —— 只是还没反映你最新的改动。这种情况下 sow check 退出码为 5, 好让部署脚本停下来,而不是误把过期的树推上去。

延伸阅读

最后修改:2026-08-08: init commit (fe725aa)