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,247 @@
|
|
|
1
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
2
|
+
import { lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises";
|
|
3
|
+
import { createGzip } from "node:zlib";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
import { pipeline } from "node:stream/promises";
|
|
6
|
+
import { assertRelativePortablePath } from "./paths.js";
|
|
7
|
+
|
|
8
|
+
export interface DeterministicArchiveEntry {
|
|
9
|
+
source: string;
|
|
10
|
+
path: string;
|
|
11
|
+
mode?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function createDeterministicTarGz(
|
|
15
|
+
output: string,
|
|
16
|
+
entries: readonly DeterministicArchiveEntry[],
|
|
17
|
+
): Promise<void> {
|
|
18
|
+
const target = resolve(output);
|
|
19
|
+
const temporaryTar = `${target}.tar.tmp`;
|
|
20
|
+
const temporaryGzip = `${target}.tmp`;
|
|
21
|
+
await mkdir(dirname(target), { recursive: true });
|
|
22
|
+
await assertOutputAbsent(target);
|
|
23
|
+
const normalized = await normalizeEntries(entries);
|
|
24
|
+
try {
|
|
25
|
+
await writeTar(temporaryTar, normalized);
|
|
26
|
+
await pipeline(
|
|
27
|
+
createReadStream(temporaryTar),
|
|
28
|
+
createGzip({ level: 9 }),
|
|
29
|
+
createWriteStream(temporaryGzip, { flags: "wx", mode: 0o644 }),
|
|
30
|
+
);
|
|
31
|
+
await rename(temporaryGzip, target);
|
|
32
|
+
} finally {
|
|
33
|
+
await rm(temporaryTar, { force: true });
|
|
34
|
+
await rm(temporaryGzip, { force: true });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function createDeterministicTar(
|
|
39
|
+
output: string,
|
|
40
|
+
entries: readonly DeterministicArchiveEntry[],
|
|
41
|
+
): Promise<void> {
|
|
42
|
+
const target = resolve(output);
|
|
43
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
44
|
+
await mkdir(dirname(target), { recursive: true });
|
|
45
|
+
await assertOutputAbsent(target);
|
|
46
|
+
try {
|
|
47
|
+
await writeTar(temporary, await normalizeEntries(entries));
|
|
48
|
+
await rename(temporary, target);
|
|
49
|
+
} finally {
|
|
50
|
+
await rm(temporary, { force: true });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function listTarGzEntries(path: string): Promise<string[]> {
|
|
55
|
+
const process = Bun.spawn(["tar", "-tzf", resolve(path)], {
|
|
56
|
+
stdout: "pipe",
|
|
57
|
+
stderr: "pipe",
|
|
58
|
+
env: { ...processEnvWithoutCopyfile(), COPYFILE_DISABLE: "1" },
|
|
59
|
+
});
|
|
60
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
61
|
+
process.exited,
|
|
62
|
+
new Response(process.stdout).text(),
|
|
63
|
+
new Response(process.stderr).text(),
|
|
64
|
+
]);
|
|
65
|
+
if (exitCode !== 0) throw new Error(`无法读取归档:${stderr.trim()}`);
|
|
66
|
+
return stdout
|
|
67
|
+
.split(/\r?\n/)
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
.map((entry) => entry.replace(/^\.\//, "").replace(/\/$/, ""))
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
.map(assertRelativePortablePath)
|
|
72
|
+
.sort();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function extractPortableTar(
|
|
76
|
+
archive: string,
|
|
77
|
+
destination: string,
|
|
78
|
+
compressed = false,
|
|
79
|
+
): Promise<string[]> {
|
|
80
|
+
const source = resolve(archive);
|
|
81
|
+
const target = resolve(destination);
|
|
82
|
+
const flag = compressed ? "-tzf" : "-tf";
|
|
83
|
+
const verboseFlag = compressed ? "-tvzf" : "-tvf";
|
|
84
|
+
const [listing, verbose] = await Promise.all([
|
|
85
|
+
captureTar([flag, source]),
|
|
86
|
+
captureTar([verboseFlag, source]),
|
|
87
|
+
]);
|
|
88
|
+
const paths = listing
|
|
89
|
+
.split(/\r?\n/)
|
|
90
|
+
.filter(Boolean)
|
|
91
|
+
.map((entry) => entry.replace(/^\.\//, "").replace(/\/$/, ""))
|
|
92
|
+
.filter(Boolean)
|
|
93
|
+
.map(assertRelativePortablePath);
|
|
94
|
+
for (const line of verbose.split(/\r?\n/).filter(Boolean)) {
|
|
95
|
+
if (!new Set(["-", "d"]).has(line[0] ?? "")) {
|
|
96
|
+
throw new Error("归档只允许普通文件与目录");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
await mkdir(target, { recursive: false });
|
|
100
|
+
const extractFlag = compressed ? "-xzf" : "-xf";
|
|
101
|
+
await captureTar([extractFlag, source, "-C", target]);
|
|
102
|
+
return paths.sort();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface NormalizedEntry {
|
|
106
|
+
source: string;
|
|
107
|
+
path: string;
|
|
108
|
+
size: number;
|
|
109
|
+
mode: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function normalizeEntries(
|
|
113
|
+
entries: readonly DeterministicArchiveEntry[],
|
|
114
|
+
): Promise<NormalizedEntry[]> {
|
|
115
|
+
const output: NormalizedEntry[] = [];
|
|
116
|
+
for (const entry of entries) {
|
|
117
|
+
const path = assertRelativePortablePath(entry.path);
|
|
118
|
+
const info = await lstat(entry.source);
|
|
119
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
120
|
+
throw new Error(`确定性归档只允许普通文件:${path}`);
|
|
121
|
+
}
|
|
122
|
+
const mode = entry.mode ?? (info.mode & 0o111 ? 0o755 : 0o644);
|
|
123
|
+
output.push({ source: resolve(entry.source), path, size: info.size, mode });
|
|
124
|
+
}
|
|
125
|
+
output.sort((left, right) => left.path.localeCompare(right.path));
|
|
126
|
+
if (new Set(output.map(({ path }) => path)).size !== output.length) {
|
|
127
|
+
throw new Error("确定性归档路径重复");
|
|
128
|
+
}
|
|
129
|
+
return output;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function writeTar(path: string, entries: readonly NormalizedEntry[]) {
|
|
133
|
+
const file = await open(path, "wx", 0o644);
|
|
134
|
+
try {
|
|
135
|
+
let position = 0;
|
|
136
|
+
for (const entry of entries) {
|
|
137
|
+
const header = tarHeader(entry);
|
|
138
|
+
await file.write(header, 0, header.length, position);
|
|
139
|
+
position += header.length;
|
|
140
|
+
const content = await readFile(entry.source);
|
|
141
|
+
await file.write(content, 0, content.length, position);
|
|
142
|
+
position += content.length;
|
|
143
|
+
const padding = (512 - (content.length % 512)) % 512;
|
|
144
|
+
if (padding) {
|
|
145
|
+
await file.write(Buffer.alloc(padding), 0, padding, position);
|
|
146
|
+
position += padding;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
await file.write(Buffer.alloc(1024), 0, 1024, position);
|
|
150
|
+
await file.sync();
|
|
151
|
+
} finally {
|
|
152
|
+
await file.close();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function tarHeader(entry: NormalizedEntry): Buffer {
|
|
157
|
+
const header = Buffer.alloc(512);
|
|
158
|
+
const { name, prefix } = splitTarPath(entry.path);
|
|
159
|
+
writeString(header, 0, 100, name);
|
|
160
|
+
writeOctal(header, 100, 8, entry.mode);
|
|
161
|
+
writeOctal(header, 108, 8, 0);
|
|
162
|
+
writeOctal(header, 116, 8, 0);
|
|
163
|
+
writeOctal(header, 124, 12, entry.size);
|
|
164
|
+
writeOctal(header, 136, 12, 0);
|
|
165
|
+
header.fill(0x20, 148, 156);
|
|
166
|
+
header[156] = "0".charCodeAt(0);
|
|
167
|
+
writeString(header, 257, 6, "ustar\0");
|
|
168
|
+
writeString(header, 263, 2, "00");
|
|
169
|
+
writeString(header, 265, 32, "root");
|
|
170
|
+
writeString(header, 297, 32, "root");
|
|
171
|
+
writeString(header, 345, 155, prefix);
|
|
172
|
+
const checksum = header.reduce((sum, byte) => sum + byte, 0);
|
|
173
|
+
writeChecksum(header, checksum);
|
|
174
|
+
return header;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function splitTarPath(path: string): { name: string; prefix: string } {
|
|
178
|
+
if (Buffer.byteLength(path) <= 100) return { name: path, prefix: "" };
|
|
179
|
+
for (
|
|
180
|
+
let index = path.lastIndexOf("/");
|
|
181
|
+
index > 0;
|
|
182
|
+
index = path.lastIndexOf("/", index - 1)
|
|
183
|
+
) {
|
|
184
|
+
const prefix = path.slice(0, index);
|
|
185
|
+
const name = path.slice(index + 1);
|
|
186
|
+
if (Buffer.byteLength(prefix) <= 155 && Buffer.byteLength(name) <= 100) {
|
|
187
|
+
return { name, prefix };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
throw new Error(`归档路径超过 ustar 限制:${path}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function writeString(
|
|
194
|
+
buffer: Buffer,
|
|
195
|
+
offset: number,
|
|
196
|
+
length: number,
|
|
197
|
+
value: string,
|
|
198
|
+
) {
|
|
199
|
+
const bytes = Buffer.from(value);
|
|
200
|
+
if (bytes.length > length) throw new Error("tar header 字段超长");
|
|
201
|
+
bytes.copy(buffer, offset);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function writeOctal(
|
|
205
|
+
buffer: Buffer,
|
|
206
|
+
offset: number,
|
|
207
|
+
length: number,
|
|
208
|
+
value: number,
|
|
209
|
+
) {
|
|
210
|
+
const text = value.toString(8).padStart(length - 1, "0");
|
|
211
|
+
writeString(buffer, offset, length, `${text}\0`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function writeChecksum(buffer: Buffer, value: number) {
|
|
215
|
+
const text = value.toString(8).padStart(6, "0");
|
|
216
|
+
writeString(buffer, 148, 8, `${text}\0 `);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function assertOutputAbsent(path: string): Promise<void> {
|
|
220
|
+
try {
|
|
221
|
+
await lstat(path);
|
|
222
|
+
throw new Error(`归档已存在,拒绝覆盖:${path}`);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function processEnvWithoutCopyfile(): NodeJS.ProcessEnv {
|
|
229
|
+
const env = { ...process.env };
|
|
230
|
+
delete env.COPYFILE_DISABLE;
|
|
231
|
+
return env;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function captureTar(args: readonly string[]): Promise<string> {
|
|
235
|
+
const child = Bun.spawn(["tar", ...args], {
|
|
236
|
+
stdout: "pipe",
|
|
237
|
+
stderr: "pipe",
|
|
238
|
+
env: { ...processEnvWithoutCopyfile(), COPYFILE_DISABLE: "1" },
|
|
239
|
+
});
|
|
240
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
241
|
+
child.exited,
|
|
242
|
+
new Response(child.stdout).text(),
|
|
243
|
+
new Response(child.stderr).text(),
|
|
244
|
+
]);
|
|
245
|
+
if (exitCode !== 0) throw new Error(`tar 命令失败:${stderr.trim()}`);
|
|
246
|
+
return stdout;
|
|
247
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import {
|
|
2
|
+
copyFile,
|
|
3
|
+
lstat,
|
|
4
|
+
link,
|
|
5
|
+
mkdir,
|
|
6
|
+
open,
|
|
7
|
+
rename,
|
|
8
|
+
rm,
|
|
9
|
+
stat,
|
|
10
|
+
} from "node:fs/promises";
|
|
11
|
+
import { dirname, join, resolve } from "node:path";
|
|
12
|
+
import { digestHex, sha256File } from "./digest.js";
|
|
13
|
+
import { assertRelativePortablePath } from "./paths.js";
|
|
14
|
+
import type { ArchiveEntry, ContentBlob, Sha256Digest } from "./types.js";
|
|
15
|
+
|
|
16
|
+
export function blobPath(root: string, digest: Sha256Digest): string {
|
|
17
|
+
return join(resolve(root), "blobs", "sha256", digestHex(digest));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function ingestArchiveFiles(
|
|
21
|
+
archiveRoot: string,
|
|
22
|
+
paths: readonly string[],
|
|
23
|
+
storeRoot: string,
|
|
24
|
+
): Promise<{ entries: ArchiveEntry[]; blobs: ContentBlob[] }> {
|
|
25
|
+
const entries: ArchiveEntry[] = [];
|
|
26
|
+
const blobs = new Map<Sha256Digest, ContentBlob>();
|
|
27
|
+
for (const rawPath of [...paths].sort()) {
|
|
28
|
+
const path = assertRelativePortablePath(rawPath);
|
|
29
|
+
const source = join(resolve(archiveRoot), path);
|
|
30
|
+
const info = await lstat(source);
|
|
31
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
32
|
+
throw new Error(`Docker archive 只允许普通文件:${path}`);
|
|
33
|
+
}
|
|
34
|
+
const digest = await sha256File(source);
|
|
35
|
+
const blob = { digest, size: info.size };
|
|
36
|
+
const existing = blobs.get(digest);
|
|
37
|
+
if (existing && existing.size !== info.size) {
|
|
38
|
+
throw new Error(`相同 digest 出现不同尺寸:${digest}`);
|
|
39
|
+
}
|
|
40
|
+
blobs.set(digest, blob);
|
|
41
|
+
await installBlob(source, storeRoot, blob);
|
|
42
|
+
entries.push({ ...blob, path, mode: info.mode & 0o111 ? 0o755 : 0o644 });
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
entries,
|
|
46
|
+
blobs: [...blobs.values()].sort((left, right) =>
|
|
47
|
+
left.digest.localeCompare(right.digest),
|
|
48
|
+
),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function inventoryBlobs(
|
|
53
|
+
storeRoot: string,
|
|
54
|
+
blobs: readonly ContentBlob[],
|
|
55
|
+
): Promise<Set<Sha256Digest>> {
|
|
56
|
+
const present = new Set<Sha256Digest>();
|
|
57
|
+
for (const blob of blobs) {
|
|
58
|
+
const path = blobPath(storeRoot, blob.digest);
|
|
59
|
+
try {
|
|
60
|
+
const info = await stat(path);
|
|
61
|
+
if (
|
|
62
|
+
info.isFile() &&
|
|
63
|
+
info.size === blob.size &&
|
|
64
|
+
(await sha256File(path)) === blob.digest
|
|
65
|
+
) {
|
|
66
|
+
present.add(blob.digest);
|
|
67
|
+
}
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return present;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function materializeArchive(
|
|
76
|
+
storeRoot: string,
|
|
77
|
+
destination: string,
|
|
78
|
+
entries: readonly ArchiveEntry[],
|
|
79
|
+
): Promise<void> {
|
|
80
|
+
const target = resolve(destination);
|
|
81
|
+
await mkdir(target, { recursive: false });
|
|
82
|
+
try {
|
|
83
|
+
for (const entry of entries) {
|
|
84
|
+
const relativePath = assertRelativePortablePath(entry.path);
|
|
85
|
+
const source = blobPath(storeRoot, entry.digest);
|
|
86
|
+
await verifyBlob(source, entry);
|
|
87
|
+
const output = join(target, relativePath);
|
|
88
|
+
await mkdir(dirname(output), { recursive: true });
|
|
89
|
+
try {
|
|
90
|
+
await link(source, output);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (
|
|
93
|
+
!new Set(["EXDEV", "EPERM", "EACCES"]).has(
|
|
94
|
+
(error as NodeJS.ErrnoException).code ?? "",
|
|
95
|
+
)
|
|
96
|
+
) {
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
await copyFile(source, output, 0);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
} catch (error) {
|
|
103
|
+
await rm(target, { recursive: true, force: true });
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function receiveBlobChunk(input: {
|
|
109
|
+
storeRoot: string;
|
|
110
|
+
blob: ContentBlob;
|
|
111
|
+
offset: number;
|
|
112
|
+
bytes: Uint8Array;
|
|
113
|
+
}): Promise<{ complete: boolean; receivedBytes: number }> {
|
|
114
|
+
return receiveBlobStream({
|
|
115
|
+
storeRoot: input.storeRoot,
|
|
116
|
+
blob: input.blob,
|
|
117
|
+
offset: input.offset,
|
|
118
|
+
chunks: oneChunk(input.bytes),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function receiveBlobStream(input: {
|
|
123
|
+
storeRoot: string;
|
|
124
|
+
blob: ContentBlob;
|
|
125
|
+
offset: number;
|
|
126
|
+
chunks: AsyncIterable<Uint8Array>;
|
|
127
|
+
}): Promise<{ complete: boolean; receivedBytes: number }> {
|
|
128
|
+
const { storeRoot, blob, offset, chunks } = input;
|
|
129
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || offset > blob.size) {
|
|
130
|
+
throw new Error("blob resume offset 无效");
|
|
131
|
+
}
|
|
132
|
+
const finalPath = blobPath(storeRoot, blob.digest);
|
|
133
|
+
const partialPath = `${finalPath}.partial`;
|
|
134
|
+
await mkdir(dirname(finalPath), { recursive: true });
|
|
135
|
+
if (await validFinalBlob(finalPath, blob)) {
|
|
136
|
+
return { complete: true, receivedBytes: 0 };
|
|
137
|
+
}
|
|
138
|
+
const currentSize = await fileSize(partialPath);
|
|
139
|
+
if (currentSize !== offset) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`blob resume offset 不匹配:期望 ${currentSize},收到 ${offset}`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
const file = await open(partialPath, currentSize === 0 ? "wx" : "r+");
|
|
145
|
+
let receivedBytes = 0;
|
|
146
|
+
try {
|
|
147
|
+
for await (const chunk of chunks) {
|
|
148
|
+
if (currentSize + receivedBytes + chunk.byteLength > blob.size) {
|
|
149
|
+
throw new Error("blob chunk 超出声明尺寸");
|
|
150
|
+
}
|
|
151
|
+
await file.write(chunk, 0, chunk.byteLength, currentSize + receivedBytes);
|
|
152
|
+
receivedBytes += chunk.byteLength;
|
|
153
|
+
}
|
|
154
|
+
await file.sync();
|
|
155
|
+
} catch (error) {
|
|
156
|
+
await file.truncate(currentSize);
|
|
157
|
+
await file.sync();
|
|
158
|
+
throw error;
|
|
159
|
+
} finally {
|
|
160
|
+
await file.close();
|
|
161
|
+
}
|
|
162
|
+
const size = currentSize + receivedBytes;
|
|
163
|
+
if (size !== blob.size) return { complete: false, receivedBytes };
|
|
164
|
+
if ((await sha256File(partialPath)) !== blob.digest) {
|
|
165
|
+
await rm(partialPath, { force: true });
|
|
166
|
+
throw new Error(`blob digest 不匹配:${blob.digest}`);
|
|
167
|
+
}
|
|
168
|
+
await rename(partialPath, finalPath);
|
|
169
|
+
return { complete: true, receivedBytes };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function partialBlobSize(
|
|
173
|
+
storeRoot: string,
|
|
174
|
+
blob: ContentBlob,
|
|
175
|
+
): Promise<number> {
|
|
176
|
+
const finalPath = blobPath(storeRoot, blob.digest);
|
|
177
|
+
if (await validFinalBlob(finalPath, blob)) return blob.size;
|
|
178
|
+
const size = await fileSize(`${finalPath}.partial`);
|
|
179
|
+
if (size > blob.size)
|
|
180
|
+
throw new Error(`partial blob 尺寸超限:${blob.digest}`);
|
|
181
|
+
return size;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function installBlob(source: string, root: string, blob: ContentBlob) {
|
|
185
|
+
const target = blobPath(root, blob.digest);
|
|
186
|
+
if (await validFinalBlob(target, blob)) return;
|
|
187
|
+
await mkdir(dirname(target), { recursive: true });
|
|
188
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
189
|
+
try {
|
|
190
|
+
await copyFile(source, temporary, 0);
|
|
191
|
+
await verifyBlob(temporary, blob);
|
|
192
|
+
try {
|
|
193
|
+
await rename(temporary, target);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
196
|
+
}
|
|
197
|
+
} finally {
|
|
198
|
+
await rm(temporary, { force: true });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function verifyBlob(path: string, blob: ContentBlob): Promise<void> {
|
|
203
|
+
const info = await stat(path);
|
|
204
|
+
if (
|
|
205
|
+
!info.isFile() ||
|
|
206
|
+
info.size !== blob.size ||
|
|
207
|
+
(await sha256File(path)) !== blob.digest
|
|
208
|
+
) {
|
|
209
|
+
throw new Error(`blob 完整性校验失败:${blob.digest}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function validFinalBlob(
|
|
214
|
+
path: string,
|
|
215
|
+
blob: ContentBlob,
|
|
216
|
+
): Promise<boolean> {
|
|
217
|
+
let info: Awaited<ReturnType<typeof stat>>;
|
|
218
|
+
try {
|
|
219
|
+
info = await stat(path);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
return (
|
|
225
|
+
info.isFile() &&
|
|
226
|
+
info.size === blob.size &&
|
|
227
|
+
(await sha256File(path)) === blob.digest
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function fileSize(path: string): Promise<number> {
|
|
232
|
+
try {
|
|
233
|
+
return (await stat(path)).size;
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return 0;
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function* oneChunk(bytes: Uint8Array): AsyncIterable<Uint8Array> {
|
|
241
|
+
yield bytes;
|
|
242
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmod,
|
|
3
|
+
copyFile,
|
|
4
|
+
lstat,
|
|
5
|
+
mkdir,
|
|
6
|
+
readdir,
|
|
7
|
+
rm,
|
|
8
|
+
stat,
|
|
9
|
+
} from "node:fs/promises";
|
|
10
|
+
import { basename, join, relative, resolve, sep } from "node:path";
|
|
11
|
+
import {
|
|
12
|
+
createDeterministicTar,
|
|
13
|
+
createDeterministicTarGz,
|
|
14
|
+
type DeterministicArchiveEntry,
|
|
15
|
+
} from "./archive.js";
|
|
16
|
+
import { ingestArchiveFiles } from "./blob-store.js";
|
|
17
|
+
import { sha256File } from "./digest.js";
|
|
18
|
+
import { releaseChecksumFile, writeReleaseChecksums } from "./checksums.js";
|
|
19
|
+
import { validateManifest } from "./manifest.js";
|
|
20
|
+
import { assertRelativePortablePath } from "./paths.js";
|
|
21
|
+
import type {
|
|
22
|
+
DeliveryManifestV2,
|
|
23
|
+
FullBundleFallback,
|
|
24
|
+
ReleaseImageV2,
|
|
25
|
+
ReleasePlatform,
|
|
26
|
+
} from "./types.js";
|
|
27
|
+
|
|
28
|
+
export interface BuildDeliveryManifestInput {
|
|
29
|
+
archiveRoot: string;
|
|
30
|
+
releaseRoot: string;
|
|
31
|
+
storeRoot: string;
|
|
32
|
+
product: string;
|
|
33
|
+
releaseId: string;
|
|
34
|
+
gitSha: string;
|
|
35
|
+
platform: ReleasePlatform;
|
|
36
|
+
createdAt: string;
|
|
37
|
+
images: ReleaseImageV2[];
|
|
38
|
+
fullBundle?: FullBundleFallback;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function buildDeliveryManifest(
|
|
42
|
+
input: BuildDeliveryManifestInput,
|
|
43
|
+
): Promise<DeliveryManifestV2> {
|
|
44
|
+
const paths = await listArchiveFiles(input.archiveRoot);
|
|
45
|
+
const graph = await ingestArchiveFiles(
|
|
46
|
+
input.archiveRoot,
|
|
47
|
+
paths,
|
|
48
|
+
input.storeRoot,
|
|
49
|
+
);
|
|
50
|
+
assertOciBlobIdentity(graph.entries);
|
|
51
|
+
const releasePaths = (await listArchiveFiles(input.releaseRoot)).filter(
|
|
52
|
+
(path) => path !== "images.tar",
|
|
53
|
+
);
|
|
54
|
+
const releaseGraph = await ingestArchiveFiles(
|
|
55
|
+
input.releaseRoot,
|
|
56
|
+
releasePaths,
|
|
57
|
+
input.storeRoot,
|
|
58
|
+
);
|
|
59
|
+
return validateManifest({
|
|
60
|
+
schemaVersion: 2,
|
|
61
|
+
minimumReaderVersion: 2,
|
|
62
|
+
product: input.product,
|
|
63
|
+
releaseId: input.releaseId,
|
|
64
|
+
gitSha: input.gitSha,
|
|
65
|
+
platform: input.platform,
|
|
66
|
+
createdAt: input.createdAt,
|
|
67
|
+
images: input.images,
|
|
68
|
+
dockerArchive: {
|
|
69
|
+
format: "oci-image-layout-v1",
|
|
70
|
+
entries: graph.entries,
|
|
71
|
+
},
|
|
72
|
+
releaseFiles: releaseGraph.entries,
|
|
73
|
+
blobs: mergeBlobs(graph.blobs, releaseGraph.blobs),
|
|
74
|
+
compatibility: {
|
|
75
|
+
...(input.fullBundle ? { fullBundle: input.fullBundle } : {}),
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function createFullBundleFallback(
|
|
81
|
+
releaseRoot: string,
|
|
82
|
+
output: string,
|
|
83
|
+
): Promise<FullBundleFallback> {
|
|
84
|
+
const root = resolve(releaseRoot);
|
|
85
|
+
const target = resolve(output);
|
|
86
|
+
if (target === root || target.startsWith(`${root}${sep}`)) {
|
|
87
|
+
throw new Error("完整 bundle 输出不得位于 release 输入目录内");
|
|
88
|
+
}
|
|
89
|
+
const paths = await listArchiveFiles(root);
|
|
90
|
+
const entries: DeterministicArchiveEntry[] = paths.map((path) => ({
|
|
91
|
+
source: join(root, path),
|
|
92
|
+
path,
|
|
93
|
+
}));
|
|
94
|
+
await mkdir(resolve(target, ".."), { recursive: true });
|
|
95
|
+
await createDeterministicTarGz(target, entries);
|
|
96
|
+
const info = await stat(target);
|
|
97
|
+
return {
|
|
98
|
+
schemaVersion: 1,
|
|
99
|
+
file: basename(target),
|
|
100
|
+
digest: await sha256File(target),
|
|
101
|
+
size: info.size,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function prepareDeterministicRelease(
|
|
106
|
+
inputRoot: string,
|
|
107
|
+
ociRoot: string,
|
|
108
|
+
outputRoot: string,
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
const sourceRoot = resolve(inputRoot);
|
|
111
|
+
const targetRoot = resolve(outputRoot);
|
|
112
|
+
const releasePaths = (await listArchiveFiles(sourceRoot)).filter(
|
|
113
|
+
(path) => path !== "images.tar" && path !== releaseChecksumFile,
|
|
114
|
+
);
|
|
115
|
+
const ociPaths = await listArchiveFiles(ociRoot);
|
|
116
|
+
await mkdir(targetRoot);
|
|
117
|
+
try {
|
|
118
|
+
for (const path of releasePaths) {
|
|
119
|
+
const source = join(sourceRoot, path);
|
|
120
|
+
const target = join(targetRoot, path);
|
|
121
|
+
const info = await lstat(source);
|
|
122
|
+
await mkdir(resolve(target, ".."), { recursive: true });
|
|
123
|
+
await copyFile(source, target, 0);
|
|
124
|
+
await chmod(target, info.mode & 0o111 ? 0o755 : 0o644);
|
|
125
|
+
}
|
|
126
|
+
await createDeterministicTar(
|
|
127
|
+
join(targetRoot, "images.tar"),
|
|
128
|
+
ociPaths.map((path) => ({ source: join(ociRoot, path), path })),
|
|
129
|
+
);
|
|
130
|
+
await writeReleaseChecksums(targetRoot, [...releasePaths, "images.tar"]);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
await rm(targetRoot, { recursive: true, force: true });
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function mergeBlobs(
|
|
138
|
+
left: DeliveryManifestV2["blobs"],
|
|
139
|
+
right: DeliveryManifestV2["blobs"],
|
|
140
|
+
): DeliveryManifestV2["blobs"] {
|
|
141
|
+
const merged = new Map(left.map((blob) => [blob.digest, blob]));
|
|
142
|
+
for (const blob of right) {
|
|
143
|
+
const current = merged.get(blob.digest);
|
|
144
|
+
if (current && current.size !== blob.size) {
|
|
145
|
+
throw new Error(`相同 digest 出现不同尺寸:${blob.digest}`);
|
|
146
|
+
}
|
|
147
|
+
merged.set(blob.digest, blob);
|
|
148
|
+
}
|
|
149
|
+
return [...merged.values()].sort((leftBlob, rightBlob) =>
|
|
150
|
+
leftBlob.digest.localeCompare(rightBlob.digest),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function listArchiveFiles(root: string): Promise<string[]> {
|
|
155
|
+
const resolvedRoot = resolve(root);
|
|
156
|
+
const output: string[] = [];
|
|
157
|
+
await walk(resolvedRoot, resolvedRoot, output);
|
|
158
|
+
if (output.length === 0) throw new Error("Docker archive 目录为空");
|
|
159
|
+
return output.sort();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function walk(root: string, directory: string, output: string[]) {
|
|
163
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
164
|
+
const absolute = join(directory, entry.name);
|
|
165
|
+
const info = await lstat(absolute);
|
|
166
|
+
if (info.isSymbolicLink()) throw new Error("Docker archive 不允许符号链接");
|
|
167
|
+
if (entry.isDirectory()) {
|
|
168
|
+
await walk(root, absolute, output);
|
|
169
|
+
} else if (entry.isFile()) {
|
|
170
|
+
output.push(
|
|
171
|
+
assertRelativePortablePath(
|
|
172
|
+
relative(root, absolute).split(sep).join("/"),
|
|
173
|
+
),
|
|
174
|
+
);
|
|
175
|
+
} else {
|
|
176
|
+
throw new Error("Docker archive 包含不支持的文件类型");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function assertOciBlobIdentity(
|
|
182
|
+
entries: DeliveryManifestV2["dockerArchive"]["entries"],
|
|
183
|
+
) {
|
|
184
|
+
const paths = new Set(entries.map(({ path }) => path));
|
|
185
|
+
for (const required of ["index.json", "oci-layout"]) {
|
|
186
|
+
if (!paths.has(required))
|
|
187
|
+
throw new Error(`Docker OCI archive 缺少 ${required}`);
|
|
188
|
+
}
|
|
189
|
+
let blobCount = 0;
|
|
190
|
+
for (const entry of entries) {
|
|
191
|
+
const match = /^blobs\/sha256\/([a-f0-9]{64})$/.exec(entry.path);
|
|
192
|
+
if (!match) continue;
|
|
193
|
+
blobCount += 1;
|
|
194
|
+
if (entry.digest !== `sha256:${match[1]}`) {
|
|
195
|
+
throw new Error(`OCI blob 路径与内容 digest 不一致:${entry.path}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (blobCount === 0) throw new Error("Docker OCI archive 不含内容 blob");
|
|
199
|
+
}
|