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,165 @@
|
|
|
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 { dirname, join } from "node:path";
|
|
5
|
+
import { createDeterministicTar, listTarGzEntries } from "./archive.js";
|
|
6
|
+
import { listArchiveFiles } from "./builder.js";
|
|
7
|
+
import { sha256Bytes } from "./digest.js";
|
|
8
|
+
import { readManifest } from "./manifest.js";
|
|
9
|
+
import {
|
|
10
|
+
acquireDeliveryLock,
|
|
11
|
+
releaseDeliveryLock,
|
|
12
|
+
stageRelease,
|
|
13
|
+
} from "./staging.js";
|
|
14
|
+
|
|
15
|
+
const roots: string[] = [];
|
|
16
|
+
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
await Promise.all(
|
|
19
|
+
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("release pack 实链", () => {
|
|
24
|
+
test("从完整 release 生成独立 V1 bundle、V2 OCI blob store 并重建完整 stage", async () => {
|
|
25
|
+
const root = await temporaryRoot();
|
|
26
|
+
const release = join(root, "release");
|
|
27
|
+
const oci = join(root, "oci");
|
|
28
|
+
const output = join(root, "output");
|
|
29
|
+
const imagesJson = join(root, "images.json");
|
|
30
|
+
await mkdir(join(release, "bin"), { recursive: true });
|
|
31
|
+
await mkdir(oci);
|
|
32
|
+
await writeFile(join(release, "compose.yml"), "services: {}\n");
|
|
33
|
+
await writeFile(join(release, "bin/delivery"), "#!/bin/sh\n", {
|
|
34
|
+
mode: 0o755,
|
|
35
|
+
});
|
|
36
|
+
const layerBytes = new TextEncoder().encode("oci-layer-content");
|
|
37
|
+
const layerDigest = sha256Bytes(layerBytes);
|
|
38
|
+
const layerPath = join(oci, "blobs/sha256", layerDigest.slice(7));
|
|
39
|
+
await mkdir(dirname(layerPath), { recursive: true });
|
|
40
|
+
await writeFile(layerPath, layerBytes);
|
|
41
|
+
const configBytes = new TextEncoder().encode('{"architecture":"amd64"}');
|
|
42
|
+
const configDigest = sha256Bytes(configBytes);
|
|
43
|
+
await writeFile(
|
|
44
|
+
join(oci, "blobs/sha256", configDigest.slice(7)),
|
|
45
|
+
configBytes,
|
|
46
|
+
);
|
|
47
|
+
const imageManifestBytes = new TextEncoder().encode(
|
|
48
|
+
'{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json"}',
|
|
49
|
+
);
|
|
50
|
+
const imageManifestDigest = sha256Bytes(imageManifestBytes);
|
|
51
|
+
await writeFile(
|
|
52
|
+
join(oci, "blobs/sha256", imageManifestDigest.slice(7)),
|
|
53
|
+
imageManifestBytes,
|
|
54
|
+
);
|
|
55
|
+
await writeFile(
|
|
56
|
+
join(oci, "oci-layout"),
|
|
57
|
+
'{"imageLayoutVersion":"1.0.0"}\n',
|
|
58
|
+
);
|
|
59
|
+
await writeFile(join(oci, "index.json"), '{"schemaVersion":2}\n');
|
|
60
|
+
const ociFiles = [
|
|
61
|
+
"oci-layout",
|
|
62
|
+
"index.json",
|
|
63
|
+
`blobs/sha256/${layerDigest.slice(7)}`,
|
|
64
|
+
`blobs/sha256/${configDigest.slice(7)}`,
|
|
65
|
+
`blobs/sha256/${imageManifestDigest.slice(7)}`,
|
|
66
|
+
];
|
|
67
|
+
await createDeterministicTar(
|
|
68
|
+
join(release, "images.tar"),
|
|
69
|
+
ociFiles.map((path) => ({ source: join(oci, path), path })),
|
|
70
|
+
);
|
|
71
|
+
await writeFile(
|
|
72
|
+
imagesJson,
|
|
73
|
+
`${JSON.stringify([
|
|
74
|
+
{
|
|
75
|
+
role: "server",
|
|
76
|
+
platform: "linux/amd64",
|
|
77
|
+
configDigest,
|
|
78
|
+
registry: {
|
|
79
|
+
repository: "registry.example.com/windy/server",
|
|
80
|
+
digest: imageManifestDigest,
|
|
81
|
+
layers: [{ digest: layerDigest, size: layerBytes.byteLength }],
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
])}\n`,
|
|
85
|
+
);
|
|
86
|
+
const child = Bun.spawn(
|
|
87
|
+
[
|
|
88
|
+
"bun",
|
|
89
|
+
"run",
|
|
90
|
+
join(import.meta.dir, "cli.ts"),
|
|
91
|
+
"pack",
|
|
92
|
+
"--release-dir",
|
|
93
|
+
release,
|
|
94
|
+
"--output-dir",
|
|
95
|
+
output,
|
|
96
|
+
"--product",
|
|
97
|
+
"windy",
|
|
98
|
+
"--release-id",
|
|
99
|
+
"release-1",
|
|
100
|
+
"--git-sha",
|
|
101
|
+
"a".repeat(40),
|
|
102
|
+
"--platform",
|
|
103
|
+
"linux/amd64",
|
|
104
|
+
"--created-at",
|
|
105
|
+
"2026-08-08T00:00:00.000Z",
|
|
106
|
+
"--images-json",
|
|
107
|
+
imagesJson,
|
|
108
|
+
],
|
|
109
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
110
|
+
);
|
|
111
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
112
|
+
child.exited,
|
|
113
|
+
new Response(child.stdout).text(),
|
|
114
|
+
new Response(child.stderr).text(),
|
|
115
|
+
]);
|
|
116
|
+
expect(exitCode, stderr).toBe(0);
|
|
117
|
+
const result = JSON.parse(stdout) as { manifestDigest: `sha256:${string}` };
|
|
118
|
+
const manifest = await readManifest(
|
|
119
|
+
join(output, "delivery-manifest.v2.json"),
|
|
120
|
+
result.manifestDigest,
|
|
121
|
+
);
|
|
122
|
+
expect(
|
|
123
|
+
(await readFile(join(output, "DELIVERY-MANIFEST.sha256"), "utf8")).trim(),
|
|
124
|
+
).toBe(result.manifestDigest);
|
|
125
|
+
expect(manifest.releaseFiles.map(({ path }) => path)).toEqual([
|
|
126
|
+
"bin/delivery",
|
|
127
|
+
"CHECKSUMS.sha256",
|
|
128
|
+
"compose.yml",
|
|
129
|
+
]);
|
|
130
|
+
expect(await listTarGzEntries(join(output, "full-bundle.tar.gz"))).toEqual([
|
|
131
|
+
"CHECKSUMS.sha256",
|
|
132
|
+
"bin/delivery",
|
|
133
|
+
"compose.yml",
|
|
134
|
+
"images.tar",
|
|
135
|
+
]);
|
|
136
|
+
const install = join(root, "install");
|
|
137
|
+
await mkdir(install);
|
|
138
|
+
const lock = await acquireDeliveryLock(install);
|
|
139
|
+
const staged = await stageRelease({
|
|
140
|
+
installRoot: install,
|
|
141
|
+
storeRoot: output,
|
|
142
|
+
manifest,
|
|
143
|
+
expectedManifestDigest: result.manifestDigest,
|
|
144
|
+
lock,
|
|
145
|
+
reserveBytes: 0,
|
|
146
|
+
});
|
|
147
|
+
expect(await readFile(join(staged, "compose.yml"), "utf8")).toBe(
|
|
148
|
+
"services: {}\n",
|
|
149
|
+
);
|
|
150
|
+
expect(await Bun.file(join(staged, "images.tar")).exists()).toBeTrue();
|
|
151
|
+
expect(await listArchiveFiles(staged)).toEqual([
|
|
152
|
+
"CHECKSUMS.sha256",
|
|
153
|
+
"bin/delivery",
|
|
154
|
+
"compose.yml",
|
|
155
|
+
"images.tar",
|
|
156
|
+
]);
|
|
157
|
+
await releaseDeliveryLock(lock);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
async function temporaryRoot(): Promise<string> {
|
|
162
|
+
const root = await mkdtemp(join(tmpdir(), "windy-pack-test-"));
|
|
163
|
+
roots.push(root);
|
|
164
|
+
return root;
|
|
165
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { isAbsolute, posix } from "node:path";
|
|
2
|
+
|
|
3
|
+
export function assertRelativePortablePath(value: string): string {
|
|
4
|
+
if (
|
|
5
|
+
!value ||
|
|
6
|
+
value.includes("\\") ||
|
|
7
|
+
isAbsolute(value) ||
|
|
8
|
+
value.startsWith("/") ||
|
|
9
|
+
value.split("/").some((part) => !part || part === "." || part === "..") ||
|
|
10
|
+
value
|
|
11
|
+
.split("/")
|
|
12
|
+
.some(
|
|
13
|
+
(part) =>
|
|
14
|
+
part.startsWith("._") || part === ".DS_Store" || part === "__MACOSX",
|
|
15
|
+
)
|
|
16
|
+
) {
|
|
17
|
+
throw new Error(`不安全或不可移植的相对路径:${value}`);
|
|
18
|
+
}
|
|
19
|
+
return posix.normalize(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function assertSafeName(value: string, label: string): string {
|
|
23
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value)) {
|
|
24
|
+
throw new Error(`${label} 格式无效`);
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { redactSensitiveText } from "./redact.js";
|
|
2
|
+
|
|
3
|
+
export interface ProcessCommandResult {
|
|
4
|
+
exitCode: number;
|
|
5
|
+
stdout: string;
|
|
6
|
+
stderr: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function runProcessCommand(
|
|
10
|
+
command: readonly string[],
|
|
11
|
+
options: {
|
|
12
|
+
allowFailure?: boolean;
|
|
13
|
+
stdin?: string;
|
|
14
|
+
errorLabel: string;
|
|
15
|
+
},
|
|
16
|
+
): Promise<ProcessCommandResult> {
|
|
17
|
+
const child = Bun.spawn([...command], {
|
|
18
|
+
stdin: options.stdin === undefined ? "ignore" : "pipe",
|
|
19
|
+
stdout: "pipe",
|
|
20
|
+
stderr: "pipe",
|
|
21
|
+
});
|
|
22
|
+
const feed =
|
|
23
|
+
options.stdin === undefined
|
|
24
|
+
? Promise.resolve()
|
|
25
|
+
: feedStdin(child, options.stdin);
|
|
26
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
27
|
+
child.exited,
|
|
28
|
+
new Response(child.stdout).text(),
|
|
29
|
+
new Response(child.stderr).text(),
|
|
30
|
+
feed,
|
|
31
|
+
]);
|
|
32
|
+
if (exitCode !== 0 && !options.allowFailure) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`${options.errorLabel} (${exitCode}):${redactSensitiveText(stderr)}`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return { exitCode, stdout, stderr };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function feedStdin(
|
|
41
|
+
process: ReturnType<typeof Bun.spawn>,
|
|
42
|
+
input: string,
|
|
43
|
+
): Promise<void> {
|
|
44
|
+
if (process.stdin === undefined || typeof process.stdin === "number") {
|
|
45
|
+
throw new Error("SSH 子进程 stdin 不可写");
|
|
46
|
+
}
|
|
47
|
+
process.stdin.write(input);
|
|
48
|
+
await process.stdin.end();
|
|
49
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function redactSensitiveText(value: string): string {
|
|
2
|
+
return value
|
|
3
|
+
.replace(/(authorization\s*:\s*(?:bearer|basic)\s+)\S+/gi, "$1<redacted>")
|
|
4
|
+
.replace(
|
|
5
|
+
/((?:token|password|secret|key|signature|sig|x-amz-signature)=)[^&\s]+/gi,
|
|
6
|
+
"$1<redacted>",
|
|
7
|
+
)
|
|
8
|
+
.replace(/(https?:\/\/)[^/@\s]+@/gi, "$1<redacted>@")
|
|
9
|
+
.replace(
|
|
10
|
+
/("(?:auth|identitytoken|registrytoken)"\s*:\s*")[^"]+/gi,
|
|
11
|
+
"$1<redacted>",
|
|
12
|
+
)
|
|
13
|
+
.slice(0, 2000);
|
|
14
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { sha256Bytes } from "./digest.js";
|
|
3
|
+
import {
|
|
4
|
+
pullRegistryRelease,
|
|
5
|
+
type RegistryCommandResult,
|
|
6
|
+
type RegistryCommandRunner,
|
|
7
|
+
} from "./registry.js";
|
|
8
|
+
import type {
|
|
9
|
+
ContentBlob,
|
|
10
|
+
DeliveryManifestV2,
|
|
11
|
+
ReleaseImageV2,
|
|
12
|
+
Sha256Digest,
|
|
13
|
+
} from "./types.js";
|
|
14
|
+
|
|
15
|
+
describe("OCI Registry digest 拉取", () => {
|
|
16
|
+
test("首次拉取统计全部 layer 实际下载", async () => {
|
|
17
|
+
const base = layer("base", 100);
|
|
18
|
+
const app = layer("app", 200);
|
|
19
|
+
const image = registryImage("server", "server-config", [base, app]);
|
|
20
|
+
const runner = scriptedRunner([
|
|
21
|
+
missing(),
|
|
22
|
+
pull([downloaded(base), downloaded(app)]),
|
|
23
|
+
inspect(image.configDigest),
|
|
24
|
+
]);
|
|
25
|
+
expect(await pullRegistryRelease(manifest([image]), runner)).toEqual({
|
|
26
|
+
totalBytes: 300,
|
|
27
|
+
reusedBytes: 0,
|
|
28
|
+
uploadedBytes: null,
|
|
29
|
+
descriptorPulledBytes: 300,
|
|
30
|
+
totalBlobs: 2,
|
|
31
|
+
reusedBlobs: 0,
|
|
32
|
+
uploadedBlobs: null,
|
|
33
|
+
descriptorPulledBlobs: 2,
|
|
34
|
+
measurement: "docker-layer-status",
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("Server 已存在且 Web 变化时只统计 Web 新 layer", async () => {
|
|
39
|
+
const base = layer("base", 100);
|
|
40
|
+
const serverLayer = layer("server", 200);
|
|
41
|
+
const webLayer = layer("web-v2", 50);
|
|
42
|
+
const server = registryImage("server", "server-config", [
|
|
43
|
+
base,
|
|
44
|
+
serverLayer,
|
|
45
|
+
]);
|
|
46
|
+
const web = registryImage("web", "web-config", [base, webLayer]);
|
|
47
|
+
const runner = scriptedRunner([
|
|
48
|
+
inspect(server.configDigest),
|
|
49
|
+
missing(),
|
|
50
|
+
pull([reused(base), downloaded(webLayer)]),
|
|
51
|
+
inspect(web.configDigest),
|
|
52
|
+
]);
|
|
53
|
+
expect(await pullRegistryRelease(manifest([server, web]), runner)).toEqual({
|
|
54
|
+
totalBytes: 350,
|
|
55
|
+
reusedBytes: 300,
|
|
56
|
+
uploadedBytes: null,
|
|
57
|
+
descriptorPulledBytes: 50,
|
|
58
|
+
totalBlobs: 3,
|
|
59
|
+
reusedBlobs: 2,
|
|
60
|
+
uploadedBlobs: null,
|
|
61
|
+
descriptorPulledBlobs: 1,
|
|
62
|
+
measurement: "docker-layer-status",
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("pull 后 config digest 不一致时 fail closed", async () => {
|
|
67
|
+
const app = layer("app", 200);
|
|
68
|
+
const image = registryImage("server", "expected", [app]);
|
|
69
|
+
const runner = scriptedRunner([
|
|
70
|
+
missing(),
|
|
71
|
+
pull([downloaded(app)]),
|
|
72
|
+
inspect(sha256Bytes("tampered")),
|
|
73
|
+
]);
|
|
74
|
+
await expect(
|
|
75
|
+
pullRegistryRelease(manifest([image]), runner),
|
|
76
|
+
).rejects.toThrow("config digest 不匹配");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
function registryImage(
|
|
81
|
+
role: string,
|
|
82
|
+
configSeed: string,
|
|
83
|
+
layers: ContentBlob[],
|
|
84
|
+
): ReleaseImageV2 {
|
|
85
|
+
return {
|
|
86
|
+
role,
|
|
87
|
+
platform: "linux/amd64",
|
|
88
|
+
configDigest: sha256Bytes(configSeed),
|
|
89
|
+
registry: {
|
|
90
|
+
repository: `registry.example.com/windy/${role}`,
|
|
91
|
+
digest: sha256Bytes(`${role}-manifest`),
|
|
92
|
+
layers,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function manifest(images: ReleaseImageV2[]): DeliveryManifestV2 {
|
|
98
|
+
const content = new Map<Sha256Digest, ContentBlob>();
|
|
99
|
+
for (const image of images) {
|
|
100
|
+
content.set(image.configDigest, { digest: image.configDigest, size: 1 });
|
|
101
|
+
if (!image.registry) continue;
|
|
102
|
+
content.set(image.registry.digest, {
|
|
103
|
+
digest: image.registry.digest,
|
|
104
|
+
size: 1,
|
|
105
|
+
});
|
|
106
|
+
for (const item of image.registry.layers) content.set(item.digest, item);
|
|
107
|
+
}
|
|
108
|
+
const archiveBlobs = [...content.values()];
|
|
109
|
+
return {
|
|
110
|
+
schemaVersion: 2,
|
|
111
|
+
minimumReaderVersion: 2,
|
|
112
|
+
product: "windy",
|
|
113
|
+
releaseId: "release-1",
|
|
114
|
+
gitSha: "a".repeat(40),
|
|
115
|
+
platform: "linux/amd64",
|
|
116
|
+
createdAt: "2026-08-08T00:00:00.000Z",
|
|
117
|
+
images,
|
|
118
|
+
dockerArchive: {
|
|
119
|
+
format: "oci-image-layout-v1",
|
|
120
|
+
entries: archiveBlobs.map((blob) => ({
|
|
121
|
+
path: `blobs/sha256/${blob.digest.slice(7)}`,
|
|
122
|
+
...blob,
|
|
123
|
+
mode: 0o644,
|
|
124
|
+
})),
|
|
125
|
+
},
|
|
126
|
+
releaseFiles: [],
|
|
127
|
+
blobs: archiveBlobs,
|
|
128
|
+
compatibility: {},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function layer(seed: string, size: number): ContentBlob {
|
|
133
|
+
return { digest: sha256Bytes(seed), size };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function inspect(digest: Sha256Digest): RegistryCommandResult {
|
|
137
|
+
return { exitCode: 0, stdout: `${digest}\n`, stderr: "" };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function missing(): RegistryCommandResult {
|
|
141
|
+
return { exitCode: 1, stdout: "", stderr: "No such image" };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function pull(lines: string[]): RegistryCommandResult {
|
|
145
|
+
return { exitCode: 0, stdout: lines.join("\n"), stderr: "" };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function downloaded(blob: ContentBlob): string {
|
|
149
|
+
return `${blob.digest.slice(7, 19)}: Pull complete`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function reused(blob: ContentBlob): string {
|
|
153
|
+
return `${blob.digest.slice(7, 19)}: Already exists`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function scriptedRunner(
|
|
157
|
+
results: RegistryCommandResult[],
|
|
158
|
+
): RegistryCommandRunner {
|
|
159
|
+
let index = 0;
|
|
160
|
+
return async () => {
|
|
161
|
+
const result = results[index];
|
|
162
|
+
index += 1;
|
|
163
|
+
if (!result) throw new Error("unexpected registry command");
|
|
164
|
+
return result;
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { planBlobTransfer } from "./transfer.js";
|
|
2
|
+
import { validateManifest } from "./manifest.js";
|
|
3
|
+
import { redactSensitiveText } from "./redact.js";
|
|
4
|
+
import type {
|
|
5
|
+
ContentBlob,
|
|
6
|
+
DeliveryManifestV2,
|
|
7
|
+
RegistryTransferStatistics,
|
|
8
|
+
Sha256Digest,
|
|
9
|
+
} from "./types.js";
|
|
10
|
+
|
|
11
|
+
export interface RegistryCommandResult {
|
|
12
|
+
exitCode: number;
|
|
13
|
+
stdout: string;
|
|
14
|
+
stderr: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type RegistryCommandRunner = (
|
|
18
|
+
command: readonly string[],
|
|
19
|
+
) => Promise<RegistryCommandResult>;
|
|
20
|
+
|
|
21
|
+
export async function pullRegistryRelease(
|
|
22
|
+
manifest: DeliveryManifestV2,
|
|
23
|
+
runner: RegistryCommandRunner = runCommand,
|
|
24
|
+
): Promise<RegistryTransferStatistics> {
|
|
25
|
+
const validated = validateManifest(manifest);
|
|
26
|
+
const layers = uniqueLayers(validated);
|
|
27
|
+
const present = new Set<Sha256Digest>();
|
|
28
|
+
const downloaded = new Set<Sha256Digest>();
|
|
29
|
+
for (const image of validated.images) {
|
|
30
|
+
if (!image.registry)
|
|
31
|
+
throw new Error(`镜像缺少 registry digest:${image.role}`);
|
|
32
|
+
const reference = `${image.registry.repository}@${image.registry.digest}`;
|
|
33
|
+
const before = await inspectImage(reference, runner);
|
|
34
|
+
if (before !== undefined) {
|
|
35
|
+
assertConfigDigest(image.role, before, image.configDigest);
|
|
36
|
+
for (const layer of image.registry.layers) {
|
|
37
|
+
if (!downloaded.has(layer.digest)) present.add(layer.digest);
|
|
38
|
+
}
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const pull = await runner(["docker", "pull", reference]);
|
|
42
|
+
if (pull.exitCode !== 0) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`registry pull 失败:${image.role} ${redactSensitiveText(pull.stderr)}`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
classifyLayers(
|
|
48
|
+
image.registry.layers,
|
|
49
|
+
`${pull.stdout}\n${pull.stderr}`,
|
|
50
|
+
present,
|
|
51
|
+
downloaded,
|
|
52
|
+
);
|
|
53
|
+
const after = await inspectImage(reference, runner);
|
|
54
|
+
if (after === undefined)
|
|
55
|
+
throw new Error(`pull 后镜像不存在:${image.role}`);
|
|
56
|
+
assertConfigDigest(image.role, after, image.configDigest);
|
|
57
|
+
}
|
|
58
|
+
const descriptors = planBlobTransfer(layers, present).statistics;
|
|
59
|
+
return {
|
|
60
|
+
totalBytes: descriptors.totalBytes,
|
|
61
|
+
reusedBytes: descriptors.reusedBytes,
|
|
62
|
+
uploadedBytes: null,
|
|
63
|
+
descriptorPulledBytes: descriptors.uploadedBytes,
|
|
64
|
+
totalBlobs: descriptors.totalBlobs,
|
|
65
|
+
reusedBlobs: descriptors.reusedBlobs,
|
|
66
|
+
uploadedBlobs: null,
|
|
67
|
+
descriptorPulledBlobs: descriptors.uploadedBlobs,
|
|
68
|
+
measurement: "docker-layer-status",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function uniqueLayers(manifest: DeliveryManifestV2): ContentBlob[] {
|
|
73
|
+
const layers = new Map<Sha256Digest, ContentBlob>();
|
|
74
|
+
for (const image of manifest.images) {
|
|
75
|
+
for (const layer of image.registry?.layers ?? []) {
|
|
76
|
+
const current = layers.get(layer.digest);
|
|
77
|
+
if (current && current.size !== layer.size) {
|
|
78
|
+
throw new Error(`registry layer 尺寸漂移:${layer.digest}`);
|
|
79
|
+
}
|
|
80
|
+
layers.set(layer.digest, layer);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return [...layers.values()];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function classifyLayers(
|
|
87
|
+
layers: readonly ContentBlob[],
|
|
88
|
+
output: string,
|
|
89
|
+
present: Set<Sha256Digest>,
|
|
90
|
+
downloaded: Set<Sha256Digest>,
|
|
91
|
+
): void {
|
|
92
|
+
for (const layer of layers) {
|
|
93
|
+
if (present.has(layer.digest) || downloaded.has(layer.digest)) continue;
|
|
94
|
+
const prefix = layer.digest.slice("sha256:".length, "sha256:".length + 12);
|
|
95
|
+
const lines = output
|
|
96
|
+
.split(/\r?\n/)
|
|
97
|
+
.filter((line) => line.startsWith(`${prefix}:`));
|
|
98
|
+
if (lines.some((line) => /Already exists/i.test(line))) {
|
|
99
|
+
present.add(layer.digest);
|
|
100
|
+
} else if (lines.some((line) => /Pull complete/i.test(line))) {
|
|
101
|
+
downloaded.add(layer.digest);
|
|
102
|
+
} else {
|
|
103
|
+
throw new Error(`registry 未返回 layer 传输状态:${layer.digest}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function inspectImage(
|
|
109
|
+
reference: string,
|
|
110
|
+
runner: RegistryCommandRunner,
|
|
111
|
+
): Promise<Sha256Digest | undefined> {
|
|
112
|
+
const result = await runner([
|
|
113
|
+
"docker",
|
|
114
|
+
"image",
|
|
115
|
+
"inspect",
|
|
116
|
+
"--format",
|
|
117
|
+
"{{.Id}}",
|
|
118
|
+
reference,
|
|
119
|
+
]);
|
|
120
|
+
if (result.exitCode !== 0) return undefined;
|
|
121
|
+
const digest = result.stdout.trim();
|
|
122
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(digest)) {
|
|
123
|
+
throw new Error("docker inspect 返回无效 config digest");
|
|
124
|
+
}
|
|
125
|
+
return digest as Sha256Digest;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function assertConfigDigest(
|
|
129
|
+
role: string,
|
|
130
|
+
actual: Sha256Digest,
|
|
131
|
+
expected: Sha256Digest,
|
|
132
|
+
): void {
|
|
133
|
+
if (actual !== expected) {
|
|
134
|
+
throw new Error(`镜像 config digest 不匹配:${role}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function runCommand(
|
|
139
|
+
command: readonly string[],
|
|
140
|
+
): Promise<RegistryCommandResult> {
|
|
141
|
+
const child = Bun.spawn([...command], { stdout: "pipe", stderr: "pipe" });
|
|
142
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
143
|
+
child.exited,
|
|
144
|
+
new Response(child.stdout).text(),
|
|
145
|
+
new Response(child.stderr).text(),
|
|
146
|
+
]);
|
|
147
|
+
return { exitCode, stdout, stderr };
|
|
148
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { redactSensitiveText } from "./redact.js";
|
|
3
|
+
import {
|
|
4
|
+
isExplicitLegacyCapabilityResponse,
|
|
5
|
+
parseCapabilities,
|
|
6
|
+
} from "./ssh-capabilities.js";
|
|
7
|
+
|
|
8
|
+
describe("交付协议安全边界", () => {
|
|
9
|
+
test("脱敏 header、URL userinfo、签名 query 和 Docker auth JSON", () => {
|
|
10
|
+
const output = redactSensitiveText(
|
|
11
|
+
'Authorization: Bearer abc https://user:pass@example.test/a?X-Amz-Signature=deadbeef&token=xyz {"auth":"base64-secret"}',
|
|
12
|
+
);
|
|
13
|
+
expect(output).not.toContain("abc");
|
|
14
|
+
expect(output).not.toContain("user:pass");
|
|
15
|
+
expect(output).not.toContain("deadbeef");
|
|
16
|
+
expect(output).not.toContain("xyz");
|
|
17
|
+
expect(output).not.toContain("base64-secret");
|
|
18
|
+
expect(output.match(/<redacted>/g)?.length).toBe(5);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("只有明确旧端响应可降级,鉴权和畸形 JSON fail closed", () => {
|
|
22
|
+
expect(
|
|
23
|
+
isExplicitLegacyCapabilityResponse({
|
|
24
|
+
exitCode: 127,
|
|
25
|
+
stdout: "",
|
|
26
|
+
stderr: "windy-delivery: command not found",
|
|
27
|
+
}),
|
|
28
|
+
).toBeTrue();
|
|
29
|
+
expect(
|
|
30
|
+
isExplicitLegacyCapabilityResponse({
|
|
31
|
+
exitCode: 255,
|
|
32
|
+
stdout: "",
|
|
33
|
+
stderr: "Permission denied (publickey)",
|
|
34
|
+
}),
|
|
35
|
+
).toBeFalse();
|
|
36
|
+
expect(() => parseCapabilities("not-json")).toThrow("不是合法 JSON");
|
|
37
|
+
expect(() =>
|
|
38
|
+
parseCapabilities('{"schemaVersions":[2],"blobDelta":true}'),
|
|
39
|
+
).toThrow("字段无效");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { DeliveryCapabilities } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export function parseCapabilities(value: string): DeliveryCapabilities {
|
|
4
|
+
let parsed: unknown;
|
|
5
|
+
try {
|
|
6
|
+
parsed = JSON.parse(value);
|
|
7
|
+
} catch {
|
|
8
|
+
throw new Error("SSH capability 响应不是合法 JSON");
|
|
9
|
+
}
|
|
10
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
11
|
+
throw new Error("SSH capability 响应格式无效");
|
|
12
|
+
}
|
|
13
|
+
const record = parsed as Record<string, unknown>;
|
|
14
|
+
if (
|
|
15
|
+
!Array.isArray(record.schemaVersions) ||
|
|
16
|
+
!record.schemaVersions.every((item) => Number.isSafeInteger(item)) ||
|
|
17
|
+
typeof record.registryByDigest !== "boolean" ||
|
|
18
|
+
typeof record.blobDelta !== "boolean" ||
|
|
19
|
+
typeof record.resumableBlobTransfer !== "boolean"
|
|
20
|
+
) {
|
|
21
|
+
throw new Error("SSH capability 响应字段无效");
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
schemaVersions: record.schemaVersions as number[],
|
|
25
|
+
registryByDigest: record.registryByDigest,
|
|
26
|
+
blobDelta: record.blobDelta,
|
|
27
|
+
resumableBlobTransfer: record.resumableBlobTransfer,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isExplicitLegacyCapabilityResponse(result: {
|
|
32
|
+
exitCode: number;
|
|
33
|
+
stdout: string;
|
|
34
|
+
stderr: string;
|
|
35
|
+
}): boolean {
|
|
36
|
+
const text = `${result.stdout}\n${result.stderr}`;
|
|
37
|
+
return (
|
|
38
|
+
result.exitCode === 127 ||
|
|
39
|
+
/unknown command|unsupported command|不支持[^\n]*capabilities/i.test(
|
|
40
|
+
text,
|
|
41
|
+
) ||
|
|
42
|
+
(/用法:windy-delivery/.test(text) && !text.includes("capabilities"))
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function safeSshTarget(value: string): string {
|
|
47
|
+
if (!/^[A-Za-z0-9._-]+@[A-Za-z0-9._:-]+$/.test(value)) {
|
|
48
|
+
throw new Error("SSH target 格式无效");
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function safeRemotePath(value: string, label: string): string {
|
|
54
|
+
if (
|
|
55
|
+
!/^\/?[A-Za-z0-9._/-]+$/.test(value) ||
|
|
56
|
+
value.split("/").some((part) => part === "..")
|
|
57
|
+
) {
|
|
58
|
+
throw new Error(`${label} 路径无效`);
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function assertOwnerToken(value: string): void {
|
|
64
|
+
if (!/^[a-f0-9]{48}$/.test(value)) throw new Error("ownerToken 无效");
|
|
65
|
+
}
|