# distribution-rs — OCI Distribution Spec 实现计划
Rust 生态中首个完整的、生产级 OCI Registry 服务端实现,为 PaaS 平台设计。
**定位**: 单二进制、OCI-native,可被 PaaS 控制面集成的 registry 子服务。
**基于规范**: OCI Distribution Spec v1.1
---
## 设计决策
| 元数据持久层 | 阶段 1 SQLite,阶段 2 PostgreSQL | SQLite 快速启动;PaaS 多实例部署需要 PostgreSQL |
| ORM | SeaORM (sqlx) | 原生 async,自带连接池和 migration;换 PostgreSQL 只改 feature flag |
| Storage trait | 对象安全 `dyn Storage` | 单二进制多后端(FS/S3),启动时通过配置选择 |
| Blob 存储模型 | CAS 去重 + DB repo-blob link | 物理存储按 digest 去重,`repo_blob_links` 表记录 repo 关联 |
| Upload offset | `next_offset`(下一个写入位置) | Range 头: `next_offset == 0` -> `0-0`,否则 `0-{next_offset - 1}` |
| 跨层写入顺序 | Validate -> Storage -> Metadata | 先校验,再写 storage(原子 rename),最后写 DB(事务);孤立文件由 GC 清理 |
| tags.digest 外键 | 不加 | manifest 和 tag 在同一事务内写入;悬挂 tag 由 GC 补偿 |
| 认证模型 | 平台签发 JWT + 可选 Basic auth | PaaS 场景下 registry 不管理用户,接受平台 token |
| 多租户时序 | 阶段 1 `tenant_id NOT NULL DEFAULT '_default'`,阶段 2 主键化 | 避免 NULL 唯一性陷阱和阶段 3 破坏性迁移 |
| 部署模型 | 阶段 1 单实例,阶段 2 多实例 | SQLite 限制单实例;PostgreSQL 解锁水平扩展 |
---
## 第一阶段:conformance 可过的 OCI Registry
### 1.1 项目脚手架
- [ ] 确定 crate 结构 (workspace 还是单 crate)
- [ ] 配置 clippy pedantic + nursery
- [ ] 引入核心依赖: `axum`, `tower`, `tokio`, `oci-spec`, `serde`, `sha2`, `sea-orm`, `sea-orm-migration`, `uuid`, `tracing`, `toml`, `proptest` (dev)
- [ ] 配置管理 (TOML 配置文件解析)
- [ ] 结构化日志 (`tracing` crate)
### 1.2 数据模型
- [ ] 集成 `oci-spec` crate (Image Manifest, Index, Descriptor 等类型)
- [ ] 定义内部领域模型: Repository, Tag, Upload Session
- [ ] 领域 newtype 强类型封装,禁止裸 `String` 传核心标识:
```rust
/// Repository name, validated per OCI spec (lowercase, [a-z0-9._-/]+, max 256 chars).
/// Construction only through `TryFrom<&str>` / `parse()`.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RepoName(String);
/// Content-addressable digest, e.g. "sha256:abcdef...".
/// Parses and validates algorithm + hex on construction.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Digest {
algorithm: DigestAlgorithm, // sha256 | sha512
hex: String,
}
/// Upload session identifier (UUID v4).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct UploadId(Uuid);
/// Tenant identifier extracted from JWT claims.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct TenantId(String);
/// Tag name, validated (alphanumeric + [._-], max 128 chars).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct TagName(String);
/// Manifest reference: either a tag name or a digest.
pub enum Reference {
Tag(TagName),
Digest(Digest),
}
```
**实现约束 (防止回退到裸 String):**
- All newtypes implement `TryFrom<&str>` with validation; invalid input -> `NAME_INVALID` / `DIGEST_INVALID`
- SeaORM column mapping via `From<NewType> for sea_orm::Value` + `TryGetable`
- Storage trait methods accept `&Digest`, `&UploadId` etc., never raw strings
- API 层 typed extractor: axum `Path<(RepoName, Reference)>` 直接从路径解析并校验,handler 禁止接收裸 `String`;解析失败自动返回 400 `NAME_INVALID` / `DIGEST_INVALID`
- DB 持久化格式: `Digest` 统一存储为 canonical string `"{algorithm}:{hex_lowercase}"`,`Display` / `FromStr` 保证往返一致;`impl From<Digest> for sea_orm::Value` 固定输出格式,避免同义不同表示导致查询不命中
- Property/fuzz 测试覆盖 `TryFrom` 校验边界:
- `RepoName`: 空串、超长 (>256)、大写、非法字符、连续 `/`、前后 `/`
- `Digest`: 缺 `:`、未知算法、hex 长度不匹配、大写 hex、含非 hex 字符
- `TagName`: 空串、超长 (>128)、起始非字母数字
- 使用 `proptest` 或 `quickcheck` 生成随机输入,验证 `TryFrom` 的 Ok/Err 与手写 regex 一致
### 1.3 元数据层 (SeaORM + SQLite)
- [ ] SeaORM 初始化,`SqliteConnectOptions` 设置 `foreign_keys(true)` + `journal_mode(Wal)`
- [ ] `sea-orm-migration` 编写 migration
- [ ] SeaORM entity 定义
**Schema (由 migration 生成):**
```sql
CREATE TABLE repositories (
id INTEGER PRIMARY KEY,
tenant_id TEXT NOT NULL DEFAULT '_default', -- avoids NULL uniqueness trap; stage 2 migrates to real tenant IDs
name TEXT NOT NULL,
UNIQUE (tenant_id, name)
);
CREATE TABLE repo_blob_links (
repo_id INTEGER NOT NULL REFERENCES repositories(id),
digest TEXT NOT NULL,
size INTEGER NOT NULL,
PRIMARY KEY (repo_id, digest)
);
CREATE TABLE manifests (
repo_id INTEGER NOT NULL REFERENCES repositories(id),
digest TEXT NOT NULL,
media_type TEXT NOT NULL,
size INTEGER NOT NULL,
PRIMARY KEY (repo_id, digest)
);
-- No FK to manifests(digest): manifest + tag written in same txn,
-- composite FK would cause lock contention. Dangling tags cleaned by GC.
CREATE TABLE tags (
repo_id INTEGER NOT NULL REFERENCES repositories(id),
name TEXT NOT NULL,
digest TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_pulled_at TEXT, -- updated on pull, NULL = never pulled
pull_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (repo_id, name)
);
CREATE TABLE upload_sessions (
id TEXT PRIMARY KEY,
repo_id INTEGER NOT NULL REFERENCES repositories(id),
offset INTEGER NOT NULL DEFAULT 0, -- next_offset semantics
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE referrers (
repo_id INTEGER NOT NULL REFERENCES repositories(id),
subject_digest TEXT NOT NULL,
referrer_digest TEXT NOT NULL,
artifact_type TEXT,
PRIMARY KEY (repo_id, subject_digest, referrer_digest)
);
```
- [ ] Tag -> Digest 映射 (`on_conflict` upsert)
- [ ] Upload session 持久化
- [ ] `repo_blob_links` 管理: put 时插入, mount 时复制, delete 时删除
- [ ] Referrer 索引表 (阶段 1 建表不填充)
### 1.4 存储抽象层
Storage trait 对象安全 (`dyn Storage`),只负责 content-addressable 字节存储,不感知 repo。
所有流式方法必须真正流式处理,不能将整个 blob 读入内存(PaaS 场景下 >1GB 的 ML 模型镜像很常见)。
```rust
pub type BoxAsyncRead = Box<dyn AsyncRead + Send + Unpin>;
#[async_trait]
pub trait Storage: Send + Sync {
// Blob: stream in/out, never buffer entire blob in memory
async fn blob_exists(&self, digest: &Digest) -> Result<bool>;
async fn get_blob(&self, digest: &Digest) -> Result<BoxAsyncRead>;
/// Range read for large blob GET with Range header.
/// Returns a reader for bytes [offset, offset+length).
async fn get_blob_range(&self, digest: &Digest, offset: u64, length: u64) -> Result<BoxAsyncRead>;
async fn put_blob(&self, digest: &Digest, data: BoxAsyncRead) -> Result<()>;
async fn delete_blob(&self, digest: &Digest) -> Result<()>;
async fn blob_size(&self, digest: &Digest) -> Result<u64>;
// Manifest: small enough to buffer as Vec<u8>
async fn get_manifest_bytes(&self, digest: &Digest) -> Result<Vec<u8>>;
async fn put_manifest_bytes(&self, digest: &Digest, data: &[u8]) -> Result<()>;
async fn delete_manifest_bytes(&self, digest: &Digest) -> Result<()>;
// Upload session temp files (streaming)
async fn create_upload(&self, id: &UploadId) -> Result<()>;
/// Write a chunk from a streaming reader at the given offset.
/// Implementations stream data to disk/S3 without buffering the entire chunk.
async fn write_upload_chunk(&self, id: &UploadId, data: BoxAsyncRead, offset: u64) -> Result<u64>;
async fn complete_upload(&self, id: &UploadId, digest: &Digest) -> Result<()>;
async fn abort_upload(&self, id: &UploadId) -> Result<()>;
}
```
`write_upload_chunk` 接受 `BoxAsyncRead` 而非 `&[u8]`,实现端从 reader 流式写入磁盘/S3,返回实际写入字节数。这避免了大 PATCH 请求的内存放大。
`get_blob_range` 支持 HTTP Range 请求,FS 后端用 `seek` + `take`,S3 后端用 `GetObject(Range)`。
Registry 层协调 API / metadata / storage:
- `blob_exists(repo, digest)` = 查 `repo_blob_links`
- `mount_blob(from, to, digest)` = 查源 repo link -> 插入目标 link(无需复制文件)
- `put_blob(repo, digest)` = storage.put_blob -> 插入 `repo_blob_links`
- `delete_blob(repo, digest)` = 删除 link(物理文件由 GC 清理)
### 1.5 本地文件存储后端
- [ ] Content-addressable storage, 按 digest 存储 blob (全局去重)
- [ ] **原子写入**: 临时文件 -> fsync -> rename
- [ ] **并发安全**: CAS 天然幂等,无需加锁
- [ ] Upload 临时文件管理:
- 启动时扫描 tmp 目录,删除无 DB 记录的孤儿文件
- 清理过期 session
### 1.6 HTTP 端点 (axum + tower)
**OCI Distribution 数据面端点:**
| `/v2/` | GET | API 版本检查 |
| `/v2/<name>/manifests/<reference>` | GET | 拉取 manifest |
| `/v2/<name>/manifests/<reference>` | HEAD | 检查 manifest 存在 |
| `/v2/<name>/blobs/<digest>` | GET | 拉取 blob (流式, 支持 Range 请求) |
| `/v2/<name>/blobs/<digest>` | HEAD | 检查 blob 存在 |
| `/v2/<name>/blobs/uploads/` | POST | 发起上传 / monolithic / mount |
| `<location>` | PATCH | 分块上传 |
| `<location>?digest=<d>` | PUT | 完成上传 |
| `<location>` | GET | 上传进度 |
| `<location>` | DELETE | 取消上传 |
| `/v2/<name>/manifests/<reference>` | PUT | 推送 manifest |
| `/v2/<name>/tags/list` | GET | 列出 tags |
| `/v2/<name>/manifests/<digest>` | DELETE | 删除 manifest |
| `/v2/<name>/blobs/<digest>` | DELETE | 删除 blob |
| `/v2/_catalog` | GET | 列出仓库 (分页, 权限过滤, 见下方详述) |
**运维端点 (阶段 1 即实现):**
| `/healthz` | GET | 存活检查 (进程存活即 200) |
| `/readyz` | GET | 就绪检查 (DB 连接正常 + storage 可写) |
### 1.7 协议语义 Checklist
**响应头:**
- [ ] `Docker-Content-Digest`: manifest GET/HEAD/PUT 必须返回
- [ ] `Content-Type`: manifest 返回实际 media type
- [ ] `Accept` 协商: 尊重客户端 Accept 头
- [ ] `Location`: upload POST/PATCH 返回
- [ ] `Range`: `next_offset == 0` -> `0-0`, 否则 `0-{next_offset - 1}`
- [ ] `Docker-Upload-UUID`: upload 相关响应
- [ ] `Content-Length`: 所有响应准确
**状态码:**
- [ ] `GET /v2/` -> 200 / 401
- [ ] manifest GET -> 200 / 404
- [ ] manifest HEAD -> 200 / 404 (body 空, 头一致)
- [ ] manifest PUT -> 201 + Location
- [ ] manifest DELETE -> 202
- [ ] blob GET -> 200 / 206 (Range) / 307 (redirect) / 404
- [ ] blob HEAD -> 200 / 404
- [ ] blob DELETE -> 202
- [ ] upload POST -> 202 + Location + Range: 0-0
- [ ] upload PATCH -> 202 + Location + Range
- [ ] upload PATCH (bad range) -> 416
- [ ] upload PUT -> 201 + Location
- [ ] upload PUT (bad digest) -> 400 `DIGEST_INVALID`
- [ ] upload GET -> 204 + Range + Docker-Upload-UUID
- [ ] upload DELETE -> 204 / 404 `BLOB_UPLOAD_UNKNOWN`
- [ ] tag list -> 200 + Link 头 (分页)
**Content-Type 协商:**
- [ ] 按 Accept 优先级返回 OCI / Docker schema v2 / fat manifest
- [ ] 不支持的 Accept -> 返回已有格式 (不能 406)
**Tag 列表分页:**
- [ ] `?n=<int>` + `?last=<tag>` 分页, 字典序排序
- [ ] 有更多时返回 `Link` 头
**Catalog 分页与权限:**
- [ ] `?n=<int>` + `?last=<repo_name>` 分页, 字典序排序 (与 tag list 一致)
- [ ] 有更多时返回 `Link` 头
- [ ] 匿名请求: 仅返回允许匿名访问的 repo (或 403)
- [ ] 普通用户: 仅返回该用户有 pull 权限的 repo (基于 token scopes)
- [ ] Admin: 返回全部 repo (阶段 3 按 tenant 范围过滤)
- [ ] 默认 `n` 上限 (如 100),防止大量 repo 时一次性全查
**Blob Range 请求 (大文件断点续传):**
- [ ] blob GET 支持 `Range` 请求头 -> 206 Partial Content
- [ ] 返回 `Accept-Ranges: bytes` 头
- [ ] 上传超时可配置
**OCI v1.1 错误码:**
`{"errors": [{"code": "...", "message": "...", "detail": ...}]}`
| `BLOB_UNKNOWN` | 404 | Blob 不存在 |
| `BLOB_UPLOAD_INVALID` | 400 | Upload 无效 |
| `BLOB_UPLOAD_UNKNOWN` | 404 | Upload session 不存在 |
| `DIGEST_INVALID` | 400 | Digest 不合法或不匹配 |
| `MANIFEST_BLOB_UNKNOWN` | 404 | Manifest 引用的 blob 不存在 |
| `MANIFEST_INVALID` | 400 | Manifest 格式无效 |
| `MANIFEST_UNKNOWN` | 404 | Manifest 不存在 |
| `NAME_INVALID` | 400 | 仓库名不合法 |
| `NAME_UNKNOWN` | 404 | 仓库不存在 |
| `SIZE_INVALID` | 400 | 大小不匹配 |
| `UNAUTHORIZED` | 401 | 未认证 |
| `DENIED` | 403 | 无权限 |
| `UNSUPPORTED` | 405 | 不支持 |
| `TOOMANYREQUESTS` | 429 | 超限 |
### 1.8 Blob 上传状态机
```
POST /blobs/uploads/
|-- ?digest=<d> --> monolithic upload --> 201
|-- ?mount=<d>&from=<n> --> cross-repo mount
|-- (无参数) --> 202 + Location + Range: 0-0
|-- GET <location> --> 204 + Range
|-- PATCH <location> --> 202 + Range
| |-- offset mismatch --> 416
|-- PUT <location>?digest=<d> --> 201
| |-- digest mismatch --> 400 DIGEST_INVALID
|-- DELETE <location> --> 204
```
- [ ] Monolithic upload, chunked upload, cross-repo mount, cancel
- [ ] Upload session UUID + DB 跟踪
- [ ] 所有 upload 操作校验 `repo_id` 一致,不一致 -> 404 `BLOB_UPLOAD_UNKNOWN`
- [ ] Content-Range 解析, offset 不连续 -> 416
- [ ] Digest 校验, 不匹配 -> 400 `DIGEST_INVALID`
- [ ] Upload session 超时清理
### 1.9 认证 (tower middleware)
- [ ] JWT 验证: 接受平台签发的 JWT (JWKS 验签)
- [ ] Token 中提取 `tenant_id`, `user_id`, `scopes` (阶段 1 无 token 时用 `_default`,不强制隔离)
- [ ] Basic auth (用于 CLI 工具兼容)
- [ ] `WWW-Authenticate` challenge 流程
- [ ] 匿名访问模式 (可配置)
### 1.10 跨层写入一致性
**原则: Validate -> Storage -> Metadata**
```
Manifest PUT:
0. 校验: 解析 JSON, 校验 media type, 按 descriptor mediaType 分流:
- blob/layer -> 查 repo_blob_links
- manifest/index -> 查 manifests
- 失败 -> 400/404, 不落盘
1. storage.put_manifest_bytes(digest, data)
2. SeaORM txn: upsert repositories, manifests, tags
3. 步骤 2 失败: 孤立文件由 GC 清理
Blob upload complete (repo-scoped):
0. 校验 upload_sessions WHERE id = ? AND repo_id = ?
1. storage.complete_upload(id, digest)
2. SeaORM txn: upsert repositories, repo_blob_links; delete upload_session
3. 步骤 2 失败: 孤立 blob 由 GC 清理
Manifest DELETE (repo-scoped):
1. SeaORM txn: delete tags + manifests WHERE repo_id = ? AND digest = ?
2. 物理文件由 GC 清理 (其他 repo 可能仍引用)
Blob DELETE (repo-scoped):
1. SeaORM txn: delete repo_blob_links WHERE repo_id = ? AND digest = ?
2. 物理文件由 GC 清理
Upload cancel (repo-scoped):
0. 校验 upload_sessions WHERE id = ? AND repo_id = ?
1. delete upload_session (先删 DB)
2. storage.abort_upload(id) (幂等, 文件不存在返回 Ok)
-- 崩溃恢复: 孤儿临时文件由启动清理兜底
```
### 1.11 优雅关闭
- [ ] SIGTERM/SIGINT 触发 graceful shutdown
- [ ] 停止接受新连接,等待进行中的请求完成 (可配置超时)
- [ ] 进行中的 upload session 不清理 (DB 持久化,重启后可续传)
### 1.12 错误处理
- [ ] OCI v1.1 完整错误码枚举
- [ ] 统一 JSON 错误响应
- [ ] 错误码 -> HTTP 状态码映射
### 1.13 合规性验证
- [ ] OCI Distribution Spec conformance tests (pull + push + discovery + management)
- [ ] `docker push` / `docker pull` 端到端测试
- [ ] `crane` / `skopeo` 兼容性测试
- [ ] CI 集成 conformance test suite
**定向回归测试:**
Manifest 引用校验分流:
- [ ] multi-arch index, 子 manifest 存在 -> 201
- [ ] multi-arch index, 子 manifest 不存在 -> 404 `MANIFEST_BLOB_UNKNOWN`
- [ ] manifest, blob layer 不存在 -> 404 `MANIFEST_BLOB_UNKNOWN`
- [ ] manifest, config blob 不存在 -> 404 `MANIFEST_BLOB_UNKNOWN`
Upload session repo 隔离:
- [ ] repo A session, repo B 路径 PATCH/PUT/DELETE/GET -> 404 `BLOB_UPLOAD_UNKNOWN`
Cancel 崩溃恢复:
- [ ] cancel 后临时文件已删
- [ ] DB 删成功但文件未删,重启后孤儿清理
- [ ] 启动时清理无 DB 记录的 tmp 文件,不清理有 DB 记录的
---
## 第二阶段:生产可用
### 2.1 PostgreSQL 支持 (多实例部署前置)
SQLite 限制单实例写入,PaaS 场景必须水平扩展。PostgreSQL 提前到阶段 2。
- [ ] SeaORM 切换 `sqlx-postgres` feature (通过 Cargo feature 或配置切换)
- [ ] Migration 兼容 PostgreSQL 语法
- [ ] Upload session: 多实例共享 DB,临时文件走共享存储 (S3) 或实例本地 + session affinity
### 2.2 多租户数据模型 (tenant_id 主键化)
阶段 1 已用 `NOT NULL DEFAULT '_default'` 避免 NULL 陷阱,阶段 2 引入 `tenants` 表并建立外键:
- [ ] 新增 `tenants` 表: `id TEXT PK`, `name`, `status (active/disabled)`, `created_at`
- [ ] Migration: 插入 `_default` 租户行,`repositories.tenant_id` 加 FK -> `tenants(id)`
- [ ] 所有查询加 `tenant_id` 条件 (middleware 从 JWT 提取注入)
- [ ] `repositories` 唯一键: `(tenant_id, name)`
- [ ] Upload session 全链路绑定 tenant
- [ ] Mount 限制同租户,跨租户 -> 403 `DENIED`
> 阶段 3 的多租户章节聚焦 RBAC、配额、审计等高级功能,数据模型已在此阶段完成。
### 2.3 S3 存储后端
- [ ] 实现 `dyn Storage` for S3,基于 `object_store` crate
- [ ] 支持 S3 / MinIO / R2
- [ ] Multipart upload 映射到 S3 multipart
- [ ] 全局 digest 去重 (S3 key = digest)
### 2.4 管理 API (PaaS 控制面集成)
OCI Distribution 端点是数据面,PaaS 控制面需要独立的管理接口。
- [ ] 管理 API 路由前缀: `/api/v1/` (与 `/v2/` 数据面隔离)
- [ ] 仓库管理: 列出 / 创建 / 删除 / 统计
- [ ] GC 触发: POST `/api/v1/gc` -> 202 + `job_id` (异步作业)
- GET `/api/v1/gc/<job_id>` 查询作业状态 (pending / running / completed / failed)
- 作业记录存 DB (`gc_jobs` 表: `id`, `status`, `started_at`, `completed_at`, `stats JSON`)
- 多实例互斥: DB advisory lock (PostgreSQL) 或 `gc_jobs` 状态行锁 (SQLite)
- 已有 running 作业时拒绝新请求 -> 409 Conflict
- [ ] 系统信息: GET `/api/v1/info` (版本, 存储后端, 数据库类型)
- [ ] 管理 API 认证: 独立的 admin token 或平台 service-to-service JWT
### 2.5 Referrers API (v1.1)
- [ ] `GET /v2/<name>/referrers/<digest>` 端点
- [ ] Referrers 索引维护 (`referrers` 表,阶段 1 已建)
- [ ] Tag fallback 模式
- [ ] manifest PUT 时自动更新 referrers 索引
### 2.6 垃圾回收
- [ ] 标记-清除: 扫描 `repo_blob_links` + `manifests`,清理无引用的物理文件
- [ ] 处理 Validate->Storage->Metadata 孤立文件
- [ ] 处理悬挂 tag
- [ ] Online GC (不停机)
- [ ] 可配置策略 (保留时间, 白名单)
### 2.7 镜像保留策略 (Retention Policy)
GC 只清理无引用 blob 不够,PaaS 环境镜像增长快:
- [ ] 按 tag 数量保留: 每个 repo 最多保留 N 个 tag (按 `updated_at` 排序淘汰)
- [ ] 按时间保留: `last_pulled_at` 超过 N 天自动清理 (数据来源: `tags.last_pulled_at`)
- [ ] 按正则排除: `latest`、semver tag 不清理
- [ ] 全局默认策略 + repo 级覆盖 (阶段 3 支持 tenant 级)
- [ ] Pull 访问统计更新 `tags.last_pulled_at` 和 `tags.pull_count`:
- manifest GET by tag -> 直接更新该 tag 行
- manifest GET/HEAD by digest -> 更新当前 repo 内指向该 digest 的 tag 行 (`UPDATE tags SET ... WHERE repo_id = ? AND digest = ?`)
- blob GET 不计入 (blob 是共享资源,无法归属到具体 tag)
### 2.8 并发控制与背压
PaaS 多 CI/CD pipeline 同时 push:
- [ ] 全局并发上传数限制
- [ ] 单 repo 并发上传数限制
- [ ] 上传速率限制
- [ ] 超限返回 429 `TOOMANYREQUESTS` 或 503
### 2.9 Pull-through Cache
- [ ] 代理上游 registry (Docker Hub, ghcr.io)
- [ ] 本地缓存 + 过期策略
- [ ] 透明代理
### 2.10 镜像复制 (Replication)
- [ ] 基于 `oci-client` 的 registry-to-registry 复制
- [ ] 增量同步
- [ ] 定时 / 事件触发
### 2.11 TLS
- [ ] 内置 `rustls` TLS 终止
- [ ] ACME 自动证书
- [ ] 自签证书
### 2.12 内部事件总线
Webhook 是外部通知,PaaS 内部组件间需要轻量事件驱动。多实例部署下进程内 channel 会丢失跨实例事件。
**两层设计:**
- [ ] 事件类型: push_complete, pull, delete, gc_complete
- [ ] 进程内: `tokio::broadcast` 驱动本地异步工作流 (扫描, 通知)
- [ ] 跨实例: DB outbox 表 + 轮询(阶段 2 起步方案,无外部依赖)
- `event_outbox` 表: `id`, `event_type`, `payload JSON`, `created_at`, `locked_by TEXT`, `locked_at TEXT`, `processed_at TEXT`, `attempts INTEGER DEFAULT 0`
- 原子抢占: `UPDATE ... SET locked_by=?, locked_at=NOW(), attempts=attempts+1 WHERE processed_at IS NULL AND locked_by IS NULL AND attempts < max_retries LIMIT N`
- 处理成功 -> 设 `processed_at`;失败/超时 -> 释放 `locked_by` (定时扫描 `locked_at` 超时的行)
- 消费端幂等: handler 按 `event.id` 去重 (或业务层幂等)
- 可选: 阶段 3 替换为 PostgreSQL LISTEN/NOTIFY 或外部消息队列
- [ ] 事件发布统一入口: `EventBus::publish()` 同时写 outbox + broadcast 本地
### 2.13 Webhook / 事件通知
- [ ] Push / Pull / Delete 事件
- [ ] HTTP webhook
- [ ] CloudEvents 格式
### 2.14 可观测性
- [ ] Prometheus metrics (`/metrics`)
- [ ] OpenTelemetry tracing
- [ ] 请求级 trace ID
---
## 第三阶段:企业级
### 3.1 多租户高级功能
> 数据模型 (tenants 表, tenant_id 主键化, 请求链路注入) 已在阶段 2.2 完成。
> 本阶段聚焦隔离强化、RBAC、配额与运维。
安全与运维:
- [ ] RBAC 两层: tenant 级 + repo 级,默认拒绝跨租户
- [ ] GC/清理按 tenant 边界
- [ ] 审计日志带 `tenant_id`
- [ ] 配额: 存储容量 / 请求速率 / 并发上传数 (按 tenant)
隔离测试:
- [ ] 同名 repo 跨租户并存
- [ ] 跨租户访问 -> 404 (不泄露存在性)
- [ ] 跨租户 upload session -> 404 `BLOB_UPLOAD_UNKNOWN`
- [ ] 跨租户 mount -> 403 `DENIED`
- [ ] 禁用租户后所有操作 -> 403 `DENIED`
计费与策略:
- [ ] Tenant 级用量统计与计费指标
- [ ] Tenant 级策略开关 (delete / 匿名 pull)
- [ ] Tenant 级告警 (配额阈值)
### 3.2 RBAC
- [ ] 用户/组/角色管理
- [ ] tenant 级角色 (admin/member/viewer) + repo 级角色 (push/pull/admin)
- [ ] 默认拒绝跨租户
- [ ] OIDC / LDAP 集成
### 3.3 审计日志
- [ ] 所有操作追踪: tenant_id, actor, repo, action, result, timestamp
- [ ] 可查询 API (按 tenant/actor/repo/时间过滤)
- [ ] 导出 (Syslog / S3)
### 3.4 漏洞扫描
- [ ] 集成 Trivy / Grype 或内置扫描引擎
- [ ] 扫描结果关联镜像
- [ ] 阻止拉取高危镜像 (支持 tenant 级策略)
### 3.5 镜像签名验证
- [ ] Cosign / Notation 支持
- [ ] 签名策略 (支持 tenant 级)
### 3.6 Web UI
- [ ] 仓库浏览 (按 tenant 隔离)
- [ ] 镜像详情
- [ ] 用户/权限/租户管理
- [ ] 监控 dashboard
### 3.7 高级存储
- [ ] GCS / Azure Blob 后端
- [ ] 存储配额 (按 tenant)
- [ ] 跨区域复制策略
---
## 技术选型
| 组件 | 选择 | 理由 |
|------|------|------|
| HTTP 框架 | `axum` + `tower` | 生态成熟, middleware 体系 |
| 异步运行时 | `tokio` | 事实标准 |
| OCI 类型 | `oci-spec` | youki 团队维护 |
| 序列化 | `serde` + `serde_json` | 标准 |
| ORM | `sea-orm` (`sqlx-sqlite` / `sqlx-postgres`) | 原生 async, migration 内置, 多数据库 |
| 存储抽象 | `object_store` | S3/GCS/Azure/Local 统一抽象 |
| OCI 客户端 | `oci-client` (ORAS) | pull-through cache + replication |
| TLS | `rustls` | 纯 Rust |
| 日志/追踪 | `tracing` | 结构化日志 + OpenTelemetry |
| 配置 | `serde` + `toml` | 简洁 |
## 目录结构
```
distribution-rs/
├── Cargo.toml
├── src/
│ ├── main.rs # Entry, CLI, server bootstrap, graceful shutdown
│ ├── config.rs # Configuration
│ ├── error.rs # OCI v1.1 error codes
│ ├── types.rs # Domain newtypes: RepoName, Digest, UploadId, TenantId, TagName, Reference
│ ├── api/
│ │ ├── mod.rs # Router: /v2/ + /api/v1/ + /healthz + /readyz
│ │ ├── v2.rs # GET /v2/
│ │ ├── manifest.rs # Manifest CRUD
│ │ ├── blob.rs # Blob CRUD (streaming)
│ │ ├── upload.rs # Upload state machine
│ │ ├── tag.rs # Tag list
│ │ ├── catalog.rs # GET /v2/_catalog
│ │ ├── referrer.rs # Referrers API
│ │ └── admin.rs # /api/v1/ management endpoints (stage 2)
│ ├── auth/
│ │ ├── mod.rs # Auth middleware
│ │ ├── jwt.rs # JWT/JWKS verification
│ │ └── basic.rs # Basic auth
│ ├── entity/
│ │ ├── mod.rs
│ │ ├── repository.rs
│ │ ├── repo_blob_link.rs
│ │ ├── manifest.rs
│ │ ├── tag.rs
│ │ ├── upload_session.rs
│ │ └── referrer.rs
│ ├── migration/
│ │ ├── mod.rs
│ │ └── m20250305_000001_init.rs
│ ├── storage/
│ │ ├── mod.rs # Storage trait (dyn-compatible, repo-unaware)
│ │ ├── filesystem.rs # Local CAS backend
│ │ └── s3.rs # S3 backend (stage 2)
│ └── registry.rs # Core logic (API -> metadata + storage)
└── tests/
├── conformance.rs
└── integration.rs
```
## 里程碑
| 里程碑 | 目标 | 验收标准 |
|--------|------|----------|
| M1 | Pull 可用 | `docker pull` 拉取预存镜像 |
| M2 | Push 可用 | `docker push` + `docker pull` 往返 |
| M3 | Conformance | OCI conformance tests 全过 |
| M4 | 生产存储 + 多租户数据模型 | S3 后端 + PostgreSQL 多实例 + tenant_id 主键化 |
| M5 | 平台集成 | 管理 API, GC 作业化, 保留策略, 并发控制, 事件总线 |
| M6 | Pull-through | 代理 Docker Hub, 本地缓存 |
| M7 | 多租户高级 | RBAC, 配额, 审计, 隔离测试集通过 |
| M8 | 企业功能 | 扫描, 签名, Web UI |