distribution 0.0.1

A high-performance OCI Distribution Spec v1.1 registry implementation in Rust
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
# 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": ...}]}`

| 错误码 | HTTP | 描述 |
|--------|------|------|
| `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
  |     |-- link exists    --> 201 + Location (blob URL)
  |     |-- link missing   --> fallback: 202 + Location (upload URL)
  |-- (无参数)             --> 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 |