create-windy 0.6.6 → 0.6.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/cli.js +11 -68
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/apps/agent-server/README.md +4 -4
- package/template/apps/server/Dockerfile +3 -2
- package/template/apps/server/src/ai-runtime-report/actor-handle.test.ts +2 -1
- package/template/apps/server/src/data-access/drizzle-predicate.ts +16 -6
- package/template/apps/web/Dockerfile +1 -0
- package/template/docs/architecture/ai-egress.md +1 -1
- package/template/docs/architecture/ai-runtime-readiness.md +2 -2
- package/template/docs/architecture/ai-runtime.md +15 -15
- package/template/docs/architecture/module-secret-resolution.md +4 -4
- package/template/docs/architecture/report-staging.md +1 -1
- package/template/docs/development/custom-login-page.md +3 -3
- package/template/docs/license/license-offline-activation-code.md +2 -2
- package/template/docs/operations/incremental-delivery.md +213 -0
- package/template/docs/platform/agent-runtime.md +4 -4
- package/template/docs/platform/document-ai-runtime.md +3 -3
- package/template/docs/platform/speech-ai-runtime.md +1 -1
- package/template/docs/platform/vision-ai-runtime.md +1 -1
- package/template/docs/product/built-in-roles.md +9 -1
- package/template/package.json +2 -1
- package/template/packages/delivery/bin/windy-delivery +2 -0
- package/template/packages/delivery/index.ts +14 -0
- package/template/packages/delivery/package.json +23 -0
- package/template/packages/delivery/src/archive-portability.test.ts +84 -0
- package/template/packages/delivery/src/archive.ts +247 -0
- package/template/packages/delivery/src/blob-store.ts +242 -0
- package/template/packages/delivery/src/builder.ts +199 -0
- package/template/packages/delivery/src/checksums.ts +83 -0
- package/template/packages/delivery/src/cli.ts +331 -0
- package/template/packages/delivery/src/delivery.test.ts +332 -0
- package/template/packages/delivery/src/digest.ts +31 -0
- package/template/packages/delivery/src/manifest.ts +255 -0
- package/template/packages/delivery/src/negotiation.ts +68 -0
- package/template/packages/delivery/src/pack.test.ts +165 -0
- package/template/packages/delivery/src/paths.ts +27 -0
- package/template/packages/delivery/src/process-command.ts +49 -0
- package/template/packages/delivery/src/redact.ts +14 -0
- package/template/packages/delivery/src/registry.test.ts +166 -0
- package/template/packages/delivery/src/registry.ts +148 -0
- package/template/packages/delivery/src/security.test.ts +41 -0
- package/template/packages/delivery/src/ssh-capabilities.ts +65 -0
- package/template/packages/delivery/src/ssh-process.ts +349 -0
- package/template/packages/delivery/src/ssh-sync.test.ts +236 -0
- package/template/packages/delivery/src/ssh-sync.ts +167 -0
- package/template/packages/delivery/src/staging.ts +241 -0
- package/template/packages/delivery/src/transfer.ts +54 -0
- package/template/packages/delivery/src/types.ts +78 -0
- package/template/packages/delivery/tsconfig.json +16 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createDeterministicTarGz, listTarGzEntries } from "./archive.js";
|
|
6
|
+
import {
|
|
7
|
+
ingestArchiveFiles,
|
|
8
|
+
inventoryBlobs,
|
|
9
|
+
partialBlobSize,
|
|
10
|
+
receiveBlobChunk,
|
|
11
|
+
} from "./blob-store.js";
|
|
12
|
+
import { sha256Bytes } from "./digest.js";
|
|
13
|
+
import { manifestDigest, readManifest } from "./manifest.js";
|
|
14
|
+
import { negotiateDelivery } from "./negotiation.js";
|
|
15
|
+
import {
|
|
16
|
+
acquireDeliveryLock,
|
|
17
|
+
cleanupStaging,
|
|
18
|
+
releaseDeliveryLock,
|
|
19
|
+
stageRelease,
|
|
20
|
+
} from "./staging.js";
|
|
21
|
+
import { planBlobTransfer } from "./transfer.js";
|
|
22
|
+
import type {
|
|
23
|
+
ContentBlob,
|
|
24
|
+
DeliveryCapabilities,
|
|
25
|
+
DeliveryManifestV2,
|
|
26
|
+
} from "./types.js";
|
|
27
|
+
|
|
28
|
+
const roots: string[] = [];
|
|
29
|
+
|
|
30
|
+
afterEach(async () => {
|
|
31
|
+
await Promise.all(
|
|
32
|
+
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("内容寻址增量交付", () => {
|
|
37
|
+
test("首次全量传输,第二次仅 Web 变化", () => {
|
|
38
|
+
const base = blob("base", 100);
|
|
39
|
+
const server = blob("server-v1", 200);
|
|
40
|
+
const webV1 = blob("web-v1", 300);
|
|
41
|
+
const first = planBlobTransfer([base, server, webV1], new Set());
|
|
42
|
+
expect(first.statistics).toEqual({
|
|
43
|
+
totalBytes: 600,
|
|
44
|
+
reusedBytes: 0,
|
|
45
|
+
uploadedBytes: 600,
|
|
46
|
+
totalBlobs: 3,
|
|
47
|
+
reusedBlobs: 0,
|
|
48
|
+
uploadedBlobs: 3,
|
|
49
|
+
});
|
|
50
|
+
const webV2 = blob("web-v2", 320);
|
|
51
|
+
const second = planBlobTransfer(
|
|
52
|
+
[base, server, webV2],
|
|
53
|
+
new Set([base.digest, server.digest, webV1.digest]),
|
|
54
|
+
);
|
|
55
|
+
expect(second.missing).toEqual([webV2]);
|
|
56
|
+
expect(second.statistics.reusedBytes).toBe(300);
|
|
57
|
+
expect(second.statistics.uploadedBytes).toBe(320);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("Server 与 Web 同时变化只上传两个新 blob", () => {
|
|
61
|
+
const base = blob("base", 100);
|
|
62
|
+
const oldServer = blob("server-v1", 200);
|
|
63
|
+
const oldWeb = blob("web-v1", 300);
|
|
64
|
+
const server = blob("server-v2", 210);
|
|
65
|
+
const web = blob("web-v2", 320);
|
|
66
|
+
const result = planBlobTransfer(
|
|
67
|
+
[base, server, web],
|
|
68
|
+
new Set([base.digest, oldServer.digest, oldWeb.digest]),
|
|
69
|
+
);
|
|
70
|
+
expect(result.missing).toEqual([server, web].sort(byDigest));
|
|
71
|
+
expect(result.statistics.uploadedBlobs).toBe(2);
|
|
72
|
+
expect(result.statistics.reusedBytes).toBe(100);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("远端损坏 blob 不计入复用且 stage fail closed", async () => {
|
|
76
|
+
const root = await temporaryRoot();
|
|
77
|
+
const store = join(root, "store");
|
|
78
|
+
const archive = join(root, "archive");
|
|
79
|
+
await mkdir(archive);
|
|
80
|
+
await writeFile(join(archive, "manifest.json"), "valid");
|
|
81
|
+
const graph = await ingestArchiveFiles(archive, ["manifest.json"], store);
|
|
82
|
+
await writeFile(
|
|
83
|
+
join(store, "blobs/sha256", graph.blobs[0]!.digest.slice(7)),
|
|
84
|
+
"broken",
|
|
85
|
+
);
|
|
86
|
+
expect((await inventoryBlobs(store, graph.blobs)).size).toBe(0);
|
|
87
|
+
const install = join(root, "install");
|
|
88
|
+
await mkdir(install);
|
|
89
|
+
const lock = await acquireDeliveryLock(install);
|
|
90
|
+
const manifest = releaseManifest(graph.entries, graph.blobs);
|
|
91
|
+
await expect(
|
|
92
|
+
stageRelease({
|
|
93
|
+
installRoot: install,
|
|
94
|
+
storeRoot: store,
|
|
95
|
+
manifest,
|
|
96
|
+
expectedManifestDigest: manifestDigest(manifest),
|
|
97
|
+
lock,
|
|
98
|
+
reserveBytes: 0,
|
|
99
|
+
}),
|
|
100
|
+
).rejects.toThrow("blob 不完整或损坏");
|
|
101
|
+
await releaseDeliveryLock(lock);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("传输中断后从已验明 offset 恢复", async () => {
|
|
105
|
+
const root = await temporaryRoot();
|
|
106
|
+
const bytes = new TextEncoder().encode("resumable-content");
|
|
107
|
+
const target = { digest: sha256Bytes(bytes), size: bytes.byteLength };
|
|
108
|
+
const first = await receiveBlobChunk({
|
|
109
|
+
storeRoot: root,
|
|
110
|
+
blob: target,
|
|
111
|
+
offset: 0,
|
|
112
|
+
bytes: bytes.slice(0, 8),
|
|
113
|
+
});
|
|
114
|
+
expect(first.complete).toBeFalse();
|
|
115
|
+
expect(await partialBlobSize(root, target)).toBe(8);
|
|
116
|
+
await expect(
|
|
117
|
+
receiveBlobChunk({
|
|
118
|
+
storeRoot: root,
|
|
119
|
+
blob: target,
|
|
120
|
+
offset: 7,
|
|
121
|
+
bytes: bytes.slice(8),
|
|
122
|
+
}),
|
|
123
|
+
).rejects.toThrow("offset 不匹配");
|
|
124
|
+
const resumed = await receiveBlobChunk({
|
|
125
|
+
storeRoot: root,
|
|
126
|
+
blob: target,
|
|
127
|
+
offset: 8,
|
|
128
|
+
bytes: bytes.slice(8),
|
|
129
|
+
});
|
|
130
|
+
expect(resumed.complete).toBeTrue();
|
|
131
|
+
expect(
|
|
132
|
+
(await inventoryBlobs(root, [target])).has(target.digest),
|
|
133
|
+
).toBeTrue();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("越界传输回退 partial 到调用前 offset", async () => {
|
|
137
|
+
const root = await temporaryRoot();
|
|
138
|
+
const target = { digest: sha256Bytes("1234"), size: 4 };
|
|
139
|
+
await expect(
|
|
140
|
+
receiveBlobChunk({
|
|
141
|
+
storeRoot: root,
|
|
142
|
+
blob: target,
|
|
143
|
+
offset: 0,
|
|
144
|
+
bytes: new TextEncoder().encode("12345"),
|
|
145
|
+
}),
|
|
146
|
+
).rejects.toThrow("超出声明尺寸");
|
|
147
|
+
expect(await partialBlobSize(root, target)).toBe(0);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("相同 manifest 的 stage 幂等且落盘字节绑定同一 digest", async () => {
|
|
151
|
+
const root = await temporaryRoot();
|
|
152
|
+
const archive = join(root, "archive");
|
|
153
|
+
const store = join(root, "store");
|
|
154
|
+
const install = join(root, "install");
|
|
155
|
+
await mkdir(archive);
|
|
156
|
+
await mkdir(install);
|
|
157
|
+
await writeFile(join(archive, "manifest.json"), "{}\n");
|
|
158
|
+
const graph = await ingestArchiveFiles(archive, ["manifest.json"], store);
|
|
159
|
+
const manifest = releaseManifest(graph.entries, graph.blobs);
|
|
160
|
+
const digest = manifestDigest(manifest);
|
|
161
|
+
const lock = await acquireDeliveryLock(install);
|
|
162
|
+
const input = {
|
|
163
|
+
installRoot: install,
|
|
164
|
+
storeRoot: store,
|
|
165
|
+
manifest,
|
|
166
|
+
expectedManifestDigest: digest,
|
|
167
|
+
lock,
|
|
168
|
+
reserveBytes: 0,
|
|
169
|
+
};
|
|
170
|
+
const first = await stageRelease(input);
|
|
171
|
+
expect(await stageRelease(input)).toBe(first);
|
|
172
|
+
expect(
|
|
173
|
+
(
|
|
174
|
+
await readFile(join(first, "..", "DELIVERY-MANIFEST.sha256"), "utf8")
|
|
175
|
+
).trim(),
|
|
176
|
+
).toBe(digest);
|
|
177
|
+
await releaseDeliveryLock(lock);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("manifest 与 digest 篡改均被拒绝", async () => {
|
|
181
|
+
const root = await temporaryRoot();
|
|
182
|
+
const path = join(root, "manifest.json");
|
|
183
|
+
const manifest = releaseManifest(
|
|
184
|
+
[{ path: "manifest.json", ...blob("archive", 10), mode: 0o644 }],
|
|
185
|
+
[blob("archive", 10)],
|
|
186
|
+
);
|
|
187
|
+
const expected = manifestDigest(manifest);
|
|
188
|
+
await writeFile(path, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
189
|
+
await expect(readManifest(path, expected)).resolves.toEqual(manifest);
|
|
190
|
+
const tampered = { ...manifest, releaseId: "tampered" };
|
|
191
|
+
await writeFile(path, `${JSON.stringify(tampered, null, 2)}\n`);
|
|
192
|
+
await expect(readManifest(path, expected)).rejects.toThrow("digest 不匹配");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("并发部署只能有一个 owner,失败清理不越过锁", async () => {
|
|
196
|
+
const root = await temporaryRoot();
|
|
197
|
+
const first = await acquireDeliveryLock(root);
|
|
198
|
+
await expect(acquireDeliveryLock(root)).rejects.toThrow("并发交付");
|
|
199
|
+
await cleanupStaging(root, first);
|
|
200
|
+
await releaseDeliveryLock(first);
|
|
201
|
+
const second = await acquireDeliveryLock(root);
|
|
202
|
+
await releaseDeliveryLock(second);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("schema negotiation 优先 registry digest,旧端显式回退 V1", () => {
|
|
206
|
+
const content = blob("archive", 10);
|
|
207
|
+
const manifest = releaseManifest(
|
|
208
|
+
[{ path: "manifest.json", ...content, mode: 0o644 }],
|
|
209
|
+
[content],
|
|
210
|
+
true,
|
|
211
|
+
);
|
|
212
|
+
const modern = capabilities({ registryByDigest: true });
|
|
213
|
+
expect(negotiateDelivery(manifest, modern)).toEqual({
|
|
214
|
+
mode: "registry",
|
|
215
|
+
images: [
|
|
216
|
+
{
|
|
217
|
+
role: "server",
|
|
218
|
+
reference: `registry.example.com/windy/server@${manifest.images[0]!.registry!.digest}`,
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
});
|
|
222
|
+
const legacy = capabilities({
|
|
223
|
+
schemaVersions: [1],
|
|
224
|
+
registryByDigest: false,
|
|
225
|
+
blobDelta: false,
|
|
226
|
+
resumableBlobTransfer: false,
|
|
227
|
+
});
|
|
228
|
+
expect(negotiateDelivery(manifest, legacy)).toMatchObject({
|
|
229
|
+
mode: "full-bundle",
|
|
230
|
+
schemaVersion: 1,
|
|
231
|
+
file: "windy-full.tar.gz",
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe("macOS 到 Linux 的确定性归档", () => {
|
|
237
|
+
test("显式文件集合不携带 AppleDouble,重复归档字节一致", async () => {
|
|
238
|
+
const root = await temporaryRoot();
|
|
239
|
+
const input = join(root, "input");
|
|
240
|
+
await mkdir(input);
|
|
241
|
+
await writeFile(join(input, "manifest.json"), "{}\n");
|
|
242
|
+
await writeFile(join(input, "._manifest.json"), "simulated xattr sidecar");
|
|
243
|
+
const first = join(root, "first.tar.gz");
|
|
244
|
+
const second = join(root, "second.tar.gz");
|
|
245
|
+
const entries = [
|
|
246
|
+
{ source: join(input, "manifest.json"), path: "manifest.json" },
|
|
247
|
+
];
|
|
248
|
+
await createDeterministicTarGz(first, entries);
|
|
249
|
+
await createDeterministicTarGz(second, entries);
|
|
250
|
+
expect(await listTarGzEntries(first)).toEqual(["manifest.json"]);
|
|
251
|
+
expect(await readFile(first)).toEqual(await readFile(second));
|
|
252
|
+
const extracted = join(root, "linux-extracted");
|
|
253
|
+
await mkdir(extracted);
|
|
254
|
+
const process = Bun.spawn(["tar", "-xzf", first, "-C", extracted], {
|
|
255
|
+
stdout: "pipe",
|
|
256
|
+
stderr: "pipe",
|
|
257
|
+
env: { ...globalThis.process.env, COPYFILE_DISABLE: "1" },
|
|
258
|
+
});
|
|
259
|
+
expect(await process.exited).toBe(0);
|
|
260
|
+
expect(await readFile(join(extracted, "manifest.json"), "utf8")).toBe(
|
|
261
|
+
"{}\n",
|
|
262
|
+
);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
function blob(seed: string, size: number): ContentBlob {
|
|
267
|
+
return { digest: sha256Bytes(seed), size };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function releaseManifest(
|
|
271
|
+
entries: DeliveryManifestV2["dockerArchive"]["entries"],
|
|
272
|
+
blobs: ContentBlob[],
|
|
273
|
+
withFallback = false,
|
|
274
|
+
): DeliveryManifestV2 {
|
|
275
|
+
const imageDigest = blobs[0]!.digest;
|
|
276
|
+
return {
|
|
277
|
+
schemaVersion: 2,
|
|
278
|
+
minimumReaderVersion: 2,
|
|
279
|
+
product: "windy",
|
|
280
|
+
releaseId: "release-1",
|
|
281
|
+
gitSha: "a".repeat(40),
|
|
282
|
+
platform: "linux/amd64",
|
|
283
|
+
createdAt: "2026-08-07T00:00:00.000Z",
|
|
284
|
+
images: [
|
|
285
|
+
{
|
|
286
|
+
role: "server",
|
|
287
|
+
platform: "linux/amd64",
|
|
288
|
+
configDigest: imageDigest,
|
|
289
|
+
registry: {
|
|
290
|
+
repository: "registry.example.com/windy/server",
|
|
291
|
+
digest: imageDigest,
|
|
292
|
+
layers: [blobs[0]!],
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
],
|
|
296
|
+
dockerArchive: { format: "oci-image-layout-v1", entries },
|
|
297
|
+
releaseFiles: [],
|
|
298
|
+
blobs,
|
|
299
|
+
compatibility: withFallback
|
|
300
|
+
? {
|
|
301
|
+
fullBundle: {
|
|
302
|
+
schemaVersion: 1,
|
|
303
|
+
file: "windy-full.tar.gz",
|
|
304
|
+
digest: sha256Bytes("fallback"),
|
|
305
|
+
size: 1000,
|
|
306
|
+
},
|
|
307
|
+
}
|
|
308
|
+
: {},
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function capabilities(
|
|
313
|
+
overrides: Partial<DeliveryCapabilities> = {},
|
|
314
|
+
): DeliveryCapabilities {
|
|
315
|
+
return {
|
|
316
|
+
schemaVersions: [1, 2],
|
|
317
|
+
registryByDigest: false,
|
|
318
|
+
blobDelta: true,
|
|
319
|
+
resumableBlobTransfer: true,
|
|
320
|
+
...overrides,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function temporaryRoot(): Promise<string> {
|
|
325
|
+
const root = await mkdtemp(join(tmpdir(), "windy-delivery-test-"));
|
|
326
|
+
roots.push(root);
|
|
327
|
+
return root;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function byDigest(left: ContentBlob, right: ContentBlob): number {
|
|
331
|
+
return left.digest.localeCompare(right.digest);
|
|
332
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import type { Sha256Digest } from "./types.js";
|
|
4
|
+
|
|
5
|
+
const digestPattern = /^sha256:[a-f0-9]{64}$/;
|
|
6
|
+
|
|
7
|
+
export function sha256Bytes(value: Uint8Array | string): Sha256Digest {
|
|
8
|
+
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function sha256File(path: string): Promise<Sha256Digest> {
|
|
12
|
+
const hash = createHash("sha256");
|
|
13
|
+
await new Promise<void>((resolve, reject) => {
|
|
14
|
+
const stream = createReadStream(path);
|
|
15
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
16
|
+
stream.on("error", reject);
|
|
17
|
+
stream.on("end", resolve);
|
|
18
|
+
});
|
|
19
|
+
return `sha256:${hash.digest("hex")}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function assertSha256Digest(value: unknown): Sha256Digest {
|
|
23
|
+
if (typeof value !== "string" || !digestPattern.test(value)) {
|
|
24
|
+
throw new Error("无效的 sha256 digest");
|
|
25
|
+
}
|
|
26
|
+
return value as Sha256Digest;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function digestHex(digest: Sha256Digest): string {
|
|
30
|
+
return digest.slice("sha256:".length);
|
|
31
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { assertSha256Digest, sha256Bytes } from "./digest.js";
|
|
3
|
+
import { assertRelativePortablePath, assertSafeName } from "./paths.js";
|
|
4
|
+
import type {
|
|
5
|
+
ArchiveEntry,
|
|
6
|
+
ContentBlob,
|
|
7
|
+
DeliveryManifestV2,
|
|
8
|
+
ReleaseImageV2,
|
|
9
|
+
ReleasePlatform,
|
|
10
|
+
Sha256Digest,
|
|
11
|
+
} from "./types.js";
|
|
12
|
+
|
|
13
|
+
export function canonicalManifestJson(manifest: DeliveryManifestV2): string {
|
|
14
|
+
const normalized = validateManifest(manifest);
|
|
15
|
+
return `${JSON.stringify(normalized, null, 2)}\n`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function manifestDigest(manifest: DeliveryManifestV2): Sha256Digest {
|
|
19
|
+
return sha256Bytes(canonicalManifestJson(manifest));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function writeManifest(
|
|
23
|
+
path: string,
|
|
24
|
+
manifest: DeliveryManifestV2,
|
|
25
|
+
): Promise<Sha256Digest> {
|
|
26
|
+
const json = canonicalManifestJson(manifest);
|
|
27
|
+
await writeFile(path, json, { flag: "wx" });
|
|
28
|
+
return sha256Bytes(json);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function readManifest(
|
|
32
|
+
path: string,
|
|
33
|
+
expectedDigest?: Sha256Digest,
|
|
34
|
+
): Promise<DeliveryManifestV2> {
|
|
35
|
+
const json = await readFile(path, "utf8");
|
|
36
|
+
if (expectedDigest && sha256Bytes(json) !== expectedDigest) {
|
|
37
|
+
throw new Error("release manifest digest 不匹配");
|
|
38
|
+
}
|
|
39
|
+
return validateManifest(JSON.parse(json));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function validateManifest(value: unknown): DeliveryManifestV2 {
|
|
43
|
+
const root = record(value, "manifest");
|
|
44
|
+
if (root.schemaVersion !== 2 || root.minimumReaderVersion !== 2) {
|
|
45
|
+
throw new Error("不支持的 delivery manifest schema");
|
|
46
|
+
}
|
|
47
|
+
const product = assertSafeName(string(root.product, "product"), "product");
|
|
48
|
+
const releaseId = assertSafeName(
|
|
49
|
+
string(root.releaseId, "releaseId"),
|
|
50
|
+
"releaseId",
|
|
51
|
+
);
|
|
52
|
+
const gitSha = string(root.gitSha, "gitSha");
|
|
53
|
+
if (!/^[a-f0-9]{40}$/.test(gitSha)) throw new Error("gitSha 无效");
|
|
54
|
+
const platform = parsePlatform(root.platform);
|
|
55
|
+
const createdAt = string(root.createdAt, "createdAt");
|
|
56
|
+
if (Number.isNaN(Date.parse(createdAt))) throw new Error("createdAt 无效");
|
|
57
|
+
if (!Array.isArray(root.images) || root.images.length === 0) {
|
|
58
|
+
throw new Error("manifest images 不能为空");
|
|
59
|
+
}
|
|
60
|
+
const images = root.images.map((image) => parseImage(image, platform));
|
|
61
|
+
assertUnique(
|
|
62
|
+
images.map(({ role }) => role),
|
|
63
|
+
"镜像 role",
|
|
64
|
+
);
|
|
65
|
+
const archive = record(root.dockerArchive, "dockerArchive");
|
|
66
|
+
if (archive.format !== "oci-image-layout-v1") {
|
|
67
|
+
throw new Error("dockerArchive format 不受支持");
|
|
68
|
+
}
|
|
69
|
+
if (!Array.isArray(archive.entries) || archive.entries.length === 0) {
|
|
70
|
+
throw new Error("dockerArchive entries 不能为空");
|
|
71
|
+
}
|
|
72
|
+
const entries = archive.entries.map(parseEntry).sort(byPath);
|
|
73
|
+
assertUnique(
|
|
74
|
+
entries.map(({ path }) => path),
|
|
75
|
+
"archive path",
|
|
76
|
+
);
|
|
77
|
+
if (!Array.isArray(root.releaseFiles)) {
|
|
78
|
+
throw new Error("releaseFiles 必须是数组");
|
|
79
|
+
}
|
|
80
|
+
const releaseFiles = root.releaseFiles.map(parseEntry).sort(byPath);
|
|
81
|
+
assertUnique(
|
|
82
|
+
releaseFiles.map(({ path }) => path),
|
|
83
|
+
"release file path",
|
|
84
|
+
);
|
|
85
|
+
if (releaseFiles.some(({ path }) => path === "images.tar")) {
|
|
86
|
+
throw new Error("releaseFiles 不得重复声明 images.tar");
|
|
87
|
+
}
|
|
88
|
+
if (!Array.isArray(root.blobs) || root.blobs.length === 0) {
|
|
89
|
+
throw new Error("manifest blobs 不能为空");
|
|
90
|
+
}
|
|
91
|
+
const blobs = root.blobs.map(parseBlob).sort(byDigest);
|
|
92
|
+
assertUnique(
|
|
93
|
+
blobs.map(({ digest }) => digest),
|
|
94
|
+
"blob digest",
|
|
95
|
+
);
|
|
96
|
+
const blobSizes = new Map(blobs.map((blob) => [blob.digest, blob.size]));
|
|
97
|
+
for (const entry of [...entries, ...releaseFiles]) {
|
|
98
|
+
if (blobSizes.get(entry.digest) !== entry.size) {
|
|
99
|
+
throw new Error(`archive entry 未绑定同尺寸 blob:${entry.path}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const imageBlobSizes = new Map(
|
|
103
|
+
entries.map(({ digest, size }) => [digest, size]),
|
|
104
|
+
);
|
|
105
|
+
for (const image of images) {
|
|
106
|
+
if (!imageBlobSizes.has(image.configDigest)) {
|
|
107
|
+
throw new Error(`镜像 config digest 未绑定 OCI blob:${image.role}`);
|
|
108
|
+
}
|
|
109
|
+
if (!image.registry) continue;
|
|
110
|
+
if (!imageBlobSizes.has(image.registry.digest)) {
|
|
111
|
+
throw new Error(`镜像 manifest digest 未绑定 OCI blob:${image.role}`);
|
|
112
|
+
}
|
|
113
|
+
for (const layer of image.registry.layers) {
|
|
114
|
+
if (imageBlobSizes.get(layer.digest) !== layer.size) {
|
|
115
|
+
throw new Error(`镜像 layer 未绑定同尺寸 OCI blob:${image.role}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const compatibility = record(root.compatibility, "compatibility");
|
|
120
|
+
const fullBundle = compatibility.fullBundle
|
|
121
|
+
? parseFallback(compatibility.fullBundle)
|
|
122
|
+
: undefined;
|
|
123
|
+
return {
|
|
124
|
+
schemaVersion: 2,
|
|
125
|
+
minimumReaderVersion: 2,
|
|
126
|
+
product,
|
|
127
|
+
releaseId,
|
|
128
|
+
gitSha,
|
|
129
|
+
platform,
|
|
130
|
+
createdAt,
|
|
131
|
+
images,
|
|
132
|
+
dockerArchive: { format: "oci-image-layout-v1", entries },
|
|
133
|
+
releaseFiles,
|
|
134
|
+
blobs,
|
|
135
|
+
compatibility: { ...(fullBundle ? { fullBundle } : {}) },
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function parseImage(value: unknown, platform: ReleasePlatform): ReleaseImageV2 {
|
|
140
|
+
const item = record(value, "images[]");
|
|
141
|
+
const imagePlatform = parsePlatform(item.platform);
|
|
142
|
+
if (imagePlatform !== platform) throw new Error("镜像平台与 release 不一致");
|
|
143
|
+
const registry = item.registry
|
|
144
|
+
? (() => {
|
|
145
|
+
const value = record(item.registry, "image.registry");
|
|
146
|
+
const repository = string(value.repository, "repository");
|
|
147
|
+
if (!validRegistryRepository(repository)) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
"registry repository 必须是不含 tag、digest、凭据的 OCI repository",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
if (!Array.isArray(value.layers) || value.layers.length === 0) {
|
|
153
|
+
throw new Error("registry layers 不能为空");
|
|
154
|
+
}
|
|
155
|
+
const layers = value.layers.map(parseBlob).sort(byDigest);
|
|
156
|
+
assertUnique(
|
|
157
|
+
layers.map(({ digest }) => digest),
|
|
158
|
+
"registry layer digest",
|
|
159
|
+
);
|
|
160
|
+
return {
|
|
161
|
+
repository,
|
|
162
|
+
digest: assertSha256Digest(value.digest),
|
|
163
|
+
layers,
|
|
164
|
+
};
|
|
165
|
+
})()
|
|
166
|
+
: undefined;
|
|
167
|
+
return {
|
|
168
|
+
role: assertSafeName(string(item.role, "image.role"), "image.role"),
|
|
169
|
+
platform: imagePlatform,
|
|
170
|
+
configDigest: assertSha256Digest(item.configDigest),
|
|
171
|
+
...(registry ? { registry } : {}),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function validRegistryRepository(value: string): boolean {
|
|
176
|
+
const match =
|
|
177
|
+
/^([a-z0-9]+(?:[._-][a-z0-9]+)*)(?::([0-9]{1,5}))?(\/[a-z0-9]+(?:[._-][a-z0-9]+)*)+$/.exec(
|
|
178
|
+
value,
|
|
179
|
+
);
|
|
180
|
+
if (!match || value.includes("@") || value.includes("://")) return false;
|
|
181
|
+
const port = match[2] ? Number(match[2]) : undefined;
|
|
182
|
+
return port === undefined || (port > 0 && port <= 65_535);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function parseEntry(value: unknown): ArchiveEntry {
|
|
186
|
+
const entry = record(value, "dockerArchive.entries[]");
|
|
187
|
+
const mode = number(entry.mode, "entry.mode");
|
|
188
|
+
if (!Number.isInteger(mode) || mode < 0 || mode > 0o777) {
|
|
189
|
+
throw new Error("archive entry mode 无效");
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
path: assertRelativePortablePath(string(entry.path, "entry.path")),
|
|
193
|
+
digest: assertSha256Digest(entry.digest),
|
|
194
|
+
size: safeSize(entry.size),
|
|
195
|
+
mode,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function parseBlob(value: unknown): ContentBlob {
|
|
200
|
+
const blob = record(value, "blobs[]");
|
|
201
|
+
return { digest: assertSha256Digest(blob.digest), size: safeSize(blob.size) };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function parseFallback(value: unknown) {
|
|
205
|
+
const fallback = record(value, "compatibility.fullBundle");
|
|
206
|
+
if (fallback.schemaVersion !== 1) throw new Error("fallback schema 必须为 1");
|
|
207
|
+
return {
|
|
208
|
+
schemaVersion: 1 as const,
|
|
209
|
+
file: assertRelativePortablePath(string(fallback.file, "fallback.file")),
|
|
210
|
+
digest: assertSha256Digest(fallback.digest),
|
|
211
|
+
size: safeSize(fallback.size),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function parsePlatform(value: unknown): ReleasePlatform {
|
|
216
|
+
if (value !== "linux/amd64" && value !== "linux/arm64") {
|
|
217
|
+
throw new Error("release platform 不受支持");
|
|
218
|
+
}
|
|
219
|
+
return value;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function safeSize(value: unknown): number {
|
|
223
|
+
const result = number(value, "size");
|
|
224
|
+
if (!Number.isSafeInteger(result) || result < 0) throw new Error("size 无效");
|
|
225
|
+
return result;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function record(value: unknown, label: string): Record<string, unknown> {
|
|
229
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
230
|
+
throw new Error(`${label} 必须是对象`);
|
|
231
|
+
}
|
|
232
|
+
return value as Record<string, unknown>;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function string(value: unknown, label: string): string {
|
|
236
|
+
if (typeof value !== "string") throw new Error(`${label} 必须是字符串`);
|
|
237
|
+
return value;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function number(value: unknown, label: string): number {
|
|
241
|
+
if (typeof value !== "number") throw new Error(`${label} 必须是数字`);
|
|
242
|
+
return value;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function assertUnique(values: readonly string[], label: string): void {
|
|
246
|
+
if (new Set(values).size !== values.length) throw new Error(`${label} 重复`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function byPath(left: ArchiveEntry, right: ArchiveEntry): number {
|
|
250
|
+
return left.path.localeCompare(right.path);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function byDigest(left: ContentBlob, right: ContentBlob): number {
|
|
254
|
+
return left.digest.localeCompare(right.digest);
|
|
255
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DeliveryCapabilities,
|
|
3
|
+
DeliveryManifestV2,
|
|
4
|
+
Sha256Digest,
|
|
5
|
+
} from "./types.js";
|
|
6
|
+
|
|
7
|
+
export type DeliverySelection =
|
|
8
|
+
| { mode: "registry"; images: { role: string; reference: string }[] }
|
|
9
|
+
| { mode: "blob-delta"; schemaVersion: 2 }
|
|
10
|
+
| {
|
|
11
|
+
mode: "full-bundle";
|
|
12
|
+
schemaVersion: 1;
|
|
13
|
+
file: string;
|
|
14
|
+
digest: Sha256Digest;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function negotiateDelivery(
|
|
18
|
+
manifest: DeliveryManifestV2,
|
|
19
|
+
capabilities: DeliveryCapabilities,
|
|
20
|
+
preference: "auto" | "registry" | "blob-delta" | "full-bundle" = "auto",
|
|
21
|
+
): DeliverySelection {
|
|
22
|
+
const registry = registrySelection(manifest, capabilities);
|
|
23
|
+
const delta =
|
|
24
|
+
capabilities.schemaVersions.includes(2) &&
|
|
25
|
+
capabilities.blobDelta &&
|
|
26
|
+
capabilities.resumableBlobTransfer
|
|
27
|
+
? ({ mode: "blob-delta", schemaVersion: 2 } as const)
|
|
28
|
+
: undefined;
|
|
29
|
+
const fallback = manifest.compatibility.fullBundle
|
|
30
|
+
? {
|
|
31
|
+
mode: "full-bundle" as const,
|
|
32
|
+
schemaVersion: 1 as const,
|
|
33
|
+
file: manifest.compatibility.fullBundle.file,
|
|
34
|
+
digest: manifest.compatibility.fullBundle.digest,
|
|
35
|
+
}
|
|
36
|
+
: undefined;
|
|
37
|
+
if (preference === "registry") return required(registry, "registry digest");
|
|
38
|
+
if (preference === "blob-delta") return required(delta, "OCI blob 差量");
|
|
39
|
+
if (preference === "full-bundle")
|
|
40
|
+
return required(fallback, "完整 bundle fallback");
|
|
41
|
+
const selected = registry ?? delta ?? fallback;
|
|
42
|
+
if (!selected) throw new Error("客户端与安装端没有共同支持的交付 schema");
|
|
43
|
+
return selected;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function registrySelection(
|
|
47
|
+
manifest: DeliveryManifestV2,
|
|
48
|
+
capabilities: DeliveryCapabilities,
|
|
49
|
+
): DeliverySelection | undefined {
|
|
50
|
+
if (!capabilities.registryByDigest) return undefined;
|
|
51
|
+
const images = manifest.images.map((image) => {
|
|
52
|
+
if (!image.registry) return undefined;
|
|
53
|
+
return {
|
|
54
|
+
role: image.role,
|
|
55
|
+
reference: `${image.registry.repository}@${image.registry.digest}`,
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
if (images.some((image) => !image)) return undefined;
|
|
59
|
+
return {
|
|
60
|
+
mode: "registry",
|
|
61
|
+
images: images as { role: string; reference: string }[],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function required<T>(value: T | undefined, label: string): T {
|
|
66
|
+
if (!value) throw new Error(`目标不支持请求的${label}交付模式`);
|
|
67
|
+
return value;
|
|
68
|
+
}
|