@daugasauron/dolly-build 0.0.0-stage → 0.1.5
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/LICENSE +21 -0
- package/README.md +11 -2
- package/host/abi.mjs +5 -0
- package/host/requirements.mjs +62 -0
- package/package.json +16 -4
- package/scripts/dolly-build-init/Dollyfile +54 -0
- package/scripts/dolly-build-init/public/index.html +61 -0
- package/scripts/dolly-build-init/public/theme.json +67 -0
- package/scripts/dolly-build-init/public/workspace/README.md +5 -0
- package/scripts/dolly-build.mjs +278 -0
- package/scripts/dollyfile-graph.mjs +41 -0
- package/scripts/image-directory.mjs +85 -0
- package/scripts/page-image-build.mjs +53 -0
- package/scripts/recipe-files.mjs +31 -0
- package/scripts/release-layout.mjs +29 -0
- package/scripts/serve.mjs +112 -0
- package/scripts/share-pages-snapshots.mjs +219 -0
- package/scripts/snapshot-identity.mjs +36 -0
- package/scripts/system-snapshot-format.mjs +103 -0
- package/scripts/wasm-interface.mjs +7 -0
- package/src/alphabetical.mjs +4 -0
- package/src/dollyfile-graph.mjs +127 -0
- package/src/dollyfile-view.mjs +335 -0
- package/src/image-entry.mjs +29 -0
- package/src/image-inputs.mjs +20 -0
- package/src/image-names.mjs +11 -0
- package/src/process-abi.mjs +69 -0
- package/src/snapshot-records.mjs +221 -0
- package/src/static-asset.mjs +124 -0
- package/src/version.mjs +3 -0
- package/src/wasm-interface.mjs +440 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { recipeRecords } from "./dollyfile-graph.mjs";
|
|
3
|
+
import { alphabetical } from "../src/alphabetical.mjs";
|
|
4
|
+
import { siteReference } from "../src/static-asset.mjs";
|
|
5
|
+
|
|
6
|
+
// The generated files of an image directory (src/images.mjs): its catalog,
|
|
7
|
+
// dist/dolly-images.json, each image's metadata, dist/dolly-IMAGE-system-snapshot.json,
|
|
8
|
+
// its package index amy-index.txt, and the index.mjs of a directory boot() loads.
|
|
9
|
+
// The catalog's build, the npm packages and dolly-build (an npm user's image)
|
|
10
|
+
// write them here.
|
|
11
|
+
|
|
12
|
+
const sha256 = bytes => createHash("sha256").update(bytes).digest("hex");
|
|
13
|
+
|
|
14
|
+
// An image's catalog record: its recipe, at FILENAME in the directory, and the
|
|
15
|
+
// recipe graph it roots.
|
|
16
|
+
export function catalogRecord({ image, filename, source, parsed }, graph) {
|
|
17
|
+
return {
|
|
18
|
+
image,
|
|
19
|
+
role: parsed.role,
|
|
20
|
+
entry: parsed.entry,
|
|
21
|
+
dollyfile: filename,
|
|
22
|
+
byteLength: Buffer.byteLength(source),
|
|
23
|
+
sha256: sha256(source),
|
|
24
|
+
recipes: recipeRecords(graph),
|
|
25
|
+
artifacts: graph.artifacts,
|
|
26
|
+
hostRequirements: graph.root.hostRequirements,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The catalog: its images' records and the files their recipes read by site path.
|
|
31
|
+
export const catalogJson = (images, staticSources = []) => `${JSON.stringify({ images, staticSources }, null, 2)}\n`;
|
|
32
|
+
|
|
33
|
+
// An image's metadata before it is packed: the snapshot SNAPSHOT (its bytes,
|
|
34
|
+
// decoded as PARSED, with the ENTRY it was checked for), built from GRAPH on
|
|
35
|
+
// INPUTS by the runtime whose identities are BUILD.
|
|
36
|
+
export function snapshotMetadata(image, graph, inputs, snapshot, parsed, entry, build) {
|
|
37
|
+
return {
|
|
38
|
+
image,
|
|
39
|
+
buildId: build.imageBuildId,
|
|
40
|
+
runtimeBuildId: build.buildId,
|
|
41
|
+
formatVersion: 2,
|
|
42
|
+
identityVersion: 2,
|
|
43
|
+
inputs,
|
|
44
|
+
recipes: recipeRecords(graph),
|
|
45
|
+
hostRequirements: graph.root.hostRequirements,
|
|
46
|
+
entry,
|
|
47
|
+
manifest: parsed.manifest,
|
|
48
|
+
byteLength: snapshot.length,
|
|
49
|
+
sha256: sha256(snapshot),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const metadataJson = metadata => `${JSON.stringify(metadata, null, 2)}\n`;
|
|
54
|
+
|
|
55
|
+
// An image directory's index.mjs, for boot(): the directory, IMAGE, and the
|
|
56
|
+
// manifests of the host modules it declares at PATHS, relative to the directory.
|
|
57
|
+
export const indexSource = (image, paths) => `// The ${image} image for boot(): its directory, and the manifests of the host modules it
|
|
58
|
+
// declares, from the packages that hold them.
|
|
59
|
+
export const images = new URL("./", import.meta.url).href;
|
|
60
|
+
export const image = ${JSON.stringify(image)};
|
|
61
|
+
export const modules = ${JSON.stringify(paths)}
|
|
62
|
+
.map(path => new URL(path, import.meta.url).href);
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
// The package index people and amy read: "NAME RECIPE SHA256 DESCRIPTION" per
|
|
66
|
+
// package, the INSTALL row's operands and the description the menu shows, of
|
|
67
|
+
// PACKAGES ({ image, dollyfile, sha256 } records) described by DESCRIPTIONS.
|
|
68
|
+
// amy prints a description in a terminal: a README's Markdown link becomes its text.
|
|
69
|
+
export const plainText = description => description.replaceAll("`", "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1");
|
|
70
|
+
export const amyIndex = (packages, descriptions) => [...packages]
|
|
71
|
+
.sort((left, right) => alphabetical(left.image, right.image))
|
|
72
|
+
.map(({ image, dollyfile, sha256 }) => `${image} ${siteReference(dollyfile)} ${sha256} ${plainText(descriptions.get(image))}\n`)
|
|
73
|
+
.join("");
|
|
74
|
+
|
|
75
|
+
// The packages an image of CATALOG holds, by name: those its build installs
|
|
76
|
+
// (its record's install rows, which name theirs too) and, but in a package, its base's.
|
|
77
|
+
export function heldPackages(catalog, image) {
|
|
78
|
+
const record = catalog.find(candidate => candidate.image === image);
|
|
79
|
+
return [...new Set(record.artifacts.flatMap(({ operation, image: name }) => operation === "install" ? [name]
|
|
80
|
+
: operation === "from" && record.role !== "package" ? heldPackages(catalog, name) : []))];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The descriptions of an amy-index.txt's packages, by name.
|
|
84
|
+
export const indexDescriptions = text => new Map(text.split("\n").filter(Boolean)
|
|
85
|
+
.map(line => /^(\S+) \S+ \S+ (.*)$/.exec(line).slice(1)));
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { open, rm } from "node:fs/promises";
|
|
2
|
+
import { MAX_SNAPSHOT_BYTES } from "../src/snapshot-records.mjs";
|
|
3
|
+
|
|
4
|
+
// Runs in a page (pass its source to page.evaluate): builds IMAGE of the image
|
|
5
|
+
// directory at IMAGES, or "custom" from CUSTOM, after its missing dependencies,
|
|
6
|
+
// with the builder and HTTP broker of the runtime at RUNTIME and the host
|
|
7
|
+
// modules whose module.json URLs are MODULES; on Dolly's site both directories
|
|
8
|
+
// are the site and the modules every one it builds (host/manifests.mjs). Logs
|
|
9
|
+
// through the exposed dollyBuildLog(text), keeps the snapshot's Blob in
|
|
10
|
+
// globalThis.dollySnapshot and returns { inputs, dependenciesBuilt }.
|
|
11
|
+
export async function buildImageInPage(runtime, images, image, custom, modules) {
|
|
12
|
+
const log = text => void globalThis.dollyBuildLog(text);
|
|
13
|
+
const [registry, policy, transport, builder, graph] = await Promise.all([
|
|
14
|
+
"src/images.mjs", "host/http/policy.mjs", "host/http/local-services.mjs",
|
|
15
|
+
"src/image-builder.mjs", "src/image-build.mjs",
|
|
16
|
+
].map(path => import(new URL(path, runtime).href)));
|
|
17
|
+
await registry.loadImages(images);
|
|
18
|
+
const network = { ...transport.localServicesTransport(new policy.DollyHttpPolicy(globalThis.DOLLY_HTTP_POLICY,
|
|
19
|
+
registry.bootstrapSources(), new URL(images))), site: images };
|
|
20
|
+
modules ??= (await import(new URL("host/manifests.mjs", runtime).href)).hostModuleUrls;
|
|
21
|
+
const build = (name, artifacts) => builder.buildImage(name, artifacts, { http: { network } }, log,
|
|
22
|
+
{ modules, customSource: name === "custom" ? custom : undefined });
|
|
23
|
+
let dependenciesBuilt = 0;
|
|
24
|
+
const dependencies = await graph.prepareImageArtifacts(image, custom, (name, artifacts) => {
|
|
25
|
+
dependenciesBuilt++;
|
|
26
|
+
return build(name, artifacts);
|
|
27
|
+
}, text => log(`${text}\n`));
|
|
28
|
+
const { bytes, inputs } = await build(image, dependencies);
|
|
29
|
+
globalThis.dollySnapshot = bytes;
|
|
30
|
+
return { inputs, dependenciesBuilt };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Copies the snapshot buildImageInPage left in PAGE to the new file OUTPUT, in
|
|
34
|
+
// pieces. Not by the page's fetch: Playwright keeps the body of every request a
|
|
35
|
+
// page sends, so an upload held the whole snapshot a second time.
|
|
36
|
+
export async function savePageSnapshot(page, output) {
|
|
37
|
+
const length = await page.evaluate(() => globalThis.dollySnapshot.size), piece = 16 * 1024 * 1024;
|
|
38
|
+
if (length <= 0 || length > MAX_SNAPSHOT_BYTES) throw new Error("invalid snapshot size");
|
|
39
|
+
const file = await open(output, "wx");
|
|
40
|
+
try {
|
|
41
|
+
for (let offset = 0; offset < length; offset += piece) {
|
|
42
|
+
const bytes = Buffer.from(await page.evaluate(async ([offset, piece]) =>
|
|
43
|
+
new Uint8Array(await globalThis.dollySnapshot.slice(offset, offset + piece).arrayBuffer()).toBase64(), [offset, piece]), "base64");
|
|
44
|
+
await file.write(bytes, 0, bytes.length, offset);
|
|
45
|
+
}
|
|
46
|
+
} catch (error) {
|
|
47
|
+
await rm(output, { force: true });
|
|
48
|
+
throw error;
|
|
49
|
+
} finally {
|
|
50
|
+
await file.close();
|
|
51
|
+
}
|
|
52
|
+
return length;
|
|
53
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { siteReference } from "../src/static-asset.mjs";
|
|
4
|
+
import { imageFileName } from "../src/dollyfile-view.mjs";
|
|
5
|
+
import { validName } from "../src/image-names.mjs";
|
|
6
|
+
|
|
7
|
+
// Recipes are published at their checkout path: core images at the top
|
|
8
|
+
// level, each demo's in demos/DEMO/. Returns site reference ->
|
|
9
|
+
// project-relative file. Image names are unique.
|
|
10
|
+
export async function recipeFiles(projectDir) {
|
|
11
|
+
const files = new Map(), names = new Map();
|
|
12
|
+
async function scan(directory) {
|
|
13
|
+
for (const entry of await entries(resolve(projectDir, directory))) {
|
|
14
|
+
if (!entry.isFile() || !validName(imageFileName(entry.name))) continue;
|
|
15
|
+
const path = directory === "." ? entry.name : `${directory}/${entry.name}`;
|
|
16
|
+
if (names.has(entry.name)) throw new Error(`${path}: ${entry.name} is already ${names.get(entry.name)}`);
|
|
17
|
+
names.set(entry.name, path);
|
|
18
|
+
files.set(siteReference(path), path);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
await scan(".");
|
|
22
|
+
for (const demo of await entries(resolve(projectDir, "demos"))) {
|
|
23
|
+
if (demo.isDirectory()) await scan(`demos/${demo.name}`);
|
|
24
|
+
}
|
|
25
|
+
return files;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function entries(directory) {
|
|
29
|
+
try { return await readdir(directory, { withFileTypes: true }); }
|
|
30
|
+
catch (error) { if (error.code === "ENOENT") return []; throw error; }
|
|
31
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// A published version's directory and public path: /vX.Y.Z/.
|
|
2
|
+
export const versionName = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
3
|
+
|
|
4
|
+
// The version a packaged site carries in its src/version.mjs, read as data.
|
|
5
|
+
export function releaseVersion(source) {
|
|
6
|
+
const name = `v${/^export const DOLLY_VERSION = "([^"]*)";$/m.exec(source)?.[1]}`;
|
|
7
|
+
if (!versionName.test(name)) throw new Error("the release carries no version");
|
|
8
|
+
return name;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function compareVersions(left, right) {
|
|
12
|
+
const [a, b] = [left, right].map(name => versionName.exec(name).slice(1).map(Number));
|
|
13
|
+
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// The robots.txt at a host's root, for the versions it serves: the newest
|
|
17
|
+
// version's llms.txt as comments, without its Markdown marks and with its
|
|
18
|
+
// links as that version's paths, then one rule per version for its bulk
|
|
19
|
+
// (dist/: packs, sources, the runtime). A rule is an exact prefix, which
|
|
20
|
+
// every crawler reads and which matches no page.
|
|
21
|
+
export function robotsText(about, names) {
|
|
22
|
+
const base = `https://site.invalid/${[...names].sort(compareVersions).at(-1)}/`;
|
|
23
|
+
const comments = about.trimEnd().split("\n").map(line => `# ${line.replace(/^(#+|>) ?/, "")
|
|
24
|
+
.replace(/\[([^\]]+)\]\((\S+)\)/g, (_, label, target) => {
|
|
25
|
+
const url = new URL(target, base);
|
|
26
|
+
return `${label} ${url.origin === "https://site.invalid" ? url.pathname + url.hash : url.href}`;
|
|
27
|
+
})}`.trimEnd());
|
|
28
|
+
return [...comments, "", "User-agent: *", ...names.map(name => `Disallow: /${name}/dist/`), ""].join("\n");
|
|
29
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFile, readlink } from "node:fs/promises";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { basename, extname, resolve } from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
import { sha256 } from "./snapshot-identity.mjs";
|
|
8
|
+
import { releaseVersion, robotsText } from "./release-layout.mjs";
|
|
9
|
+
|
|
10
|
+
export const mimeTypes = new Map([
|
|
11
|
+
[".html", "text/html; charset=utf-8"],
|
|
12
|
+
[".md", "text/markdown; charset=utf-8"],
|
|
13
|
+
[".txt", "text/plain; charset=utf-8"],
|
|
14
|
+
[".wat", "text/plain; charset=utf-8"],
|
|
15
|
+
[".h", "text/plain; charset=utf-8"],
|
|
16
|
+
[".c", "text/plain; charset=utf-8"],
|
|
17
|
+
[".cpp", "text/plain; charset=utf-8"],
|
|
18
|
+
[".patch", "text/plain; charset=utf-8"],
|
|
19
|
+
[".py", "text/plain; charset=utf-8"],
|
|
20
|
+
[".json", "application/json; charset=utf-8"],
|
|
21
|
+
[".sh", "text/plain; charset=utf-8"],
|
|
22
|
+
[".js", "text/javascript; charset=utf-8"],
|
|
23
|
+
[".mjs", "text/javascript; charset=utf-8"],
|
|
24
|
+
[".wasm", "application/wasm"],
|
|
25
|
+
[".woff2", "font/woff2"],
|
|
26
|
+
[".png", "image/png"],
|
|
27
|
+
[".mp4", "video/mp4"],
|
|
28
|
+
]);
|
|
29
|
+
export const isolationHeaders = {
|
|
30
|
+
"cross-origin-opener-policy": "same-origin",
|
|
31
|
+
"cross-origin-embedder-policy": "require-corp",
|
|
32
|
+
"cross-origin-resource-policy": "same-origin",
|
|
33
|
+
"cache-control": "no-store",
|
|
34
|
+
};
|
|
35
|
+
const releaseDigest = /^[0-9a-f]{64}$/;
|
|
36
|
+
|
|
37
|
+
// Serves RELEASES/current as it would be deployed: / leads to its version, the
|
|
38
|
+
// release's files are under /vX.Y.Z/, robots.txt speaks for that version, and
|
|
39
|
+
// every other path is 404. Nothing is cacheable: a candidate keeps its version
|
|
40
|
+
// while it changes. dist/ and the source checkout are build inputs, never the
|
|
41
|
+
// running app.
|
|
42
|
+
export function createReleaseServer(releases) {
|
|
43
|
+
let current;
|
|
44
|
+
async function release() {
|
|
45
|
+
const digest = await readlink(resolve(releases, "current"));
|
|
46
|
+
if (current?.digest === digest) return current;
|
|
47
|
+
if (!releaseDigest.test(digest)) throw new Error("invalid release ID");
|
|
48
|
+
const manifest = await readFile(resolve(releases, digest, "release/files.sha256"), "utf8");
|
|
49
|
+
if (sha256(manifest) !== digest) throw new Error("release manifest changed");
|
|
50
|
+
const files = new Map();
|
|
51
|
+
for (const row of manifest.trimEnd().split("\n")) {
|
|
52
|
+
const match = /^([0-9a-f]{64}) (.+)$/.exec(row);
|
|
53
|
+
if (!match || /[\\\0]/.test(match[2]) ||
|
|
54
|
+
match[2].split("/").some(part => !part || part === "." || part === "..") ||
|
|
55
|
+
files.has(match[2])) throw new Error("invalid release file manifest");
|
|
56
|
+
files.set(match[2], match[1]);
|
|
57
|
+
}
|
|
58
|
+
const version = await readFile(resolve(releases, digest, "src/version.mjs"));
|
|
59
|
+
if (sha256(version) !== files.get("src/version.mjs")) throw new Error("published file changed");
|
|
60
|
+
return current = { digest, files, base: `/${releaseVersion(version.toString())}/` };
|
|
61
|
+
}
|
|
62
|
+
return createServer(async (request, response) => {
|
|
63
|
+
try {
|
|
64
|
+
if (!["GET", "HEAD"].includes(request.method)) throw new Error("unsupported method");
|
|
65
|
+
const { digest, files, base } = await release();
|
|
66
|
+
const path = decodeURIComponent(new URL(request.url, "http://127.0.0.1").pathname);
|
|
67
|
+
if (path === "/" || path === "/llms.txt") {
|
|
68
|
+
response.writeHead(302, { ...isolationHeaders, location: base + path.slice(1) }).end();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (path === "/robots.txt") {
|
|
72
|
+
const about = await readFile(resolve(releases, digest, "llms.txt"));
|
|
73
|
+
if (sha256(about) !== files.get("llms.txt")) throw new Error("published file changed");
|
|
74
|
+
response.writeHead(200, { ...isolationHeaders, "content-type": mimeTypes.get(".txt") });
|
|
75
|
+
response.end(robotsText(about.toString(), [base.slice(1, -1)]));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (!path.startsWith(base)) throw new Error("unversioned path");
|
|
79
|
+
// A directory's address ends in a slash, as its page's relative links expect.
|
|
80
|
+
const relative = path.slice(base.length), page = `${relative}${relative && !relative.endsWith("/") ? "/" : ""}index.html`;
|
|
81
|
+
const file = files.has(relative) ? relative : page;
|
|
82
|
+
if (!files.has(file)) throw new Error("not published");
|
|
83
|
+
if (file === page && !path.endsWith("/")) {
|
|
84
|
+
response.writeHead(308, { ...isolationHeaders, location: `${path}/` }).end();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const body = await readFile(resolve(releases, digest, file));
|
|
88
|
+
if (sha256(body) !== files.get(file)) throw new Error("published file changed");
|
|
89
|
+
response.writeHead(200, {
|
|
90
|
+
...isolationHeaders,
|
|
91
|
+
"content-type": /^Dollyfile(?:-|$)/.test(basename(file)) ? "text/plain; charset=utf-8" :
|
|
92
|
+
mimeTypes.get(extname(file)) ?? "application/octet-stream",
|
|
93
|
+
});
|
|
94
|
+
response.end(request.method === "HEAD" ? undefined : body);
|
|
95
|
+
} catch {
|
|
96
|
+
response.writeHead(404, isolationHeaders).end("not found");
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
102
|
+
const releases = process.argv[2] ? resolve(process.argv[2]) : resolve(import.meta.dirname, "../build/releases");
|
|
103
|
+
try {
|
|
104
|
+
if (!releaseDigest.test(await readlink(resolve(releases, "current")))) throw new Error("invalid current release");
|
|
105
|
+
} catch {
|
|
106
|
+
throw new Error("No published Dolly app. Run npm run publish after building; failed builds leave it untouched.");
|
|
107
|
+
}
|
|
108
|
+
const server = createReleaseServer(releases);
|
|
109
|
+
server.listen(Number(process.env.DOLLY_PORT ?? 8080), "127.0.0.1", () => {
|
|
110
|
+
console.log(`dolly: http://127.0.0.1:${server.address().port}/`);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { createWriteStream } from "node:fs";
|
|
4
|
+
import { mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { resolve } from "node:path";
|
|
6
|
+
import { pipeline } from "node:stream/promises";
|
|
7
|
+
import { createGzip } from "node:zlib";
|
|
8
|
+
import { decodeSnapshotRecords, validateSnapshotPacks, MAX_SNAPSHOT_BYTES } from "../src/snapshot-records.mjs";
|
|
9
|
+
import { metadataJson } from "./image-directory.mjs";
|
|
10
|
+
|
|
11
|
+
// A record is either decoded (`data`) or indexed in a snapshot file on disk
|
|
12
|
+
// (`byteLength`, `source`, `offset`); the catalog is never held in memory.
|
|
13
|
+
const CHUNK_BYTES = 4 * 1024 * 1024;
|
|
14
|
+
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
15
|
+
const digest = bytes => createHash("sha256").update(bytes).digest("hex");
|
|
16
|
+
const recordLength = record => record.byteLength ?? record.data.length;
|
|
17
|
+
const recordKey = (kind, path) => createHash("sha256").update(`${kind}\0${path}\0`);
|
|
18
|
+
|
|
19
|
+
export function splitSnapshotRecords(records) {
|
|
20
|
+
const entries = [...records].map(([path, record]) => ({ path, record, encodedPath: Buffer.from(path) }))
|
|
21
|
+
.sort((left, right) => Buffer.compare(left.encodedPath, right.encodedPath));
|
|
22
|
+
const parts = [];
|
|
23
|
+
let part = new Map(), size = 16;
|
|
24
|
+
const finish = () => {
|
|
25
|
+
if (part.size) parts.push(part);
|
|
26
|
+
part = new Map(); size = 16;
|
|
27
|
+
};
|
|
28
|
+
for (const { path, record, encodedPath } of entries) {
|
|
29
|
+
const length = 16 + encodedPath.length + recordLength(record);
|
|
30
|
+
if (size + length > 4 * 1024 * 1024) finish();
|
|
31
|
+
part.set(path, record);
|
|
32
|
+
size += length;
|
|
33
|
+
// Large files stand alone. Path landmarks let later packs stabilize after
|
|
34
|
+
// small edits without tying their boundaries to the catalog's image names.
|
|
35
|
+
if (size >= 4 * 1024 * 1024 || (size >= 2 * 1024 * 1024 &&
|
|
36
|
+
(createHash("sha256").update(encodedPath).digest()[0] & 31) === 0)) finish();
|
|
37
|
+
}
|
|
38
|
+
finish();
|
|
39
|
+
return parts;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function snapshotHeader(count) {
|
|
43
|
+
const header = Buffer.alloc(16, "DOLLYSNP", "latin1");
|
|
44
|
+
header.writeUInt32LE(2, 8);
|
|
45
|
+
header.writeUInt32LE(count, 12);
|
|
46
|
+
return header;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function recordHeader(kind, encodedPath, byteLength) {
|
|
50
|
+
const header = Buffer.alloc(16 + encodedPath.length);
|
|
51
|
+
header.writeUInt32LE(kind, 0);
|
|
52
|
+
header.writeUInt32LE(encodedPath.length, 4);
|
|
53
|
+
header.writeBigUInt64LE(BigInt(byteLength), 8);
|
|
54
|
+
header.set(encodedPath, 16);
|
|
55
|
+
return header;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function readExact(handle, buffer, position, length) {
|
|
59
|
+
let done = 0;
|
|
60
|
+
while (done < length) {
|
|
61
|
+
const { bytesRead } = await handle.read(buffer, done, length - done, position + done);
|
|
62
|
+
if (!bytesRead) throw new Error("truncated snapshot record");
|
|
63
|
+
done += bytesRead;
|
|
64
|
+
}
|
|
65
|
+
return buffer.subarray(0, length);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function* indexedData(record) {
|
|
69
|
+
const hash = recordKey(record.kind, record.path);
|
|
70
|
+
for (let done = 0; done < record.byteLength;) {
|
|
71
|
+
const length = Math.min(CHUNK_BYTES, record.byteLength - done);
|
|
72
|
+
const chunk = await readExact(record.source.handle, Buffer.allocUnsafe(length), record.offset + done, length);
|
|
73
|
+
hash.update(chunk);
|
|
74
|
+
done += length;
|
|
75
|
+
yield chunk;
|
|
76
|
+
}
|
|
77
|
+
if (hash.digest("hex") !== record.key) throw new Error(`${record.source.name}: packed record mismatch: ${record.path}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function* decodedData(record) { yield record.data; }
|
|
81
|
+
|
|
82
|
+
// Encodes like encodeSnapshotRecords, as chunks of at least CHUNK_BYTES.
|
|
83
|
+
async function* snapshotChunks(records, readData) {
|
|
84
|
+
const entries = [...records].map(([path, record]) => ({ encodedPath: Buffer.from(path), record }))
|
|
85
|
+
.sort((left, right) => Buffer.compare(left.encodedPath, right.encodedPath));
|
|
86
|
+
let pending = [snapshotHeader(entries.length)], size = pending[0].length;
|
|
87
|
+
for (const { encodedPath, record } of entries) {
|
|
88
|
+
pending.push(recordHeader(record.kind, encodedPath, recordLength(record)));
|
|
89
|
+
size += pending.at(-1).length;
|
|
90
|
+
for await (const chunk of readData(record)) {
|
|
91
|
+
pending.push(chunk);
|
|
92
|
+
size += chunk.length;
|
|
93
|
+
if (size >= CHUNK_BYTES) { yield Buffer.concat(pending); pending = []; size = 0; }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (size) yield Buffer.concat(pending);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function writeSnapshotPack(directory, records, readData) {
|
|
100
|
+
const hash = createHash("sha256"), temporary = resolve(directory, "packs", `${randomUUID()}.tmp`);
|
|
101
|
+
let byteLength = 0;
|
|
102
|
+
try {
|
|
103
|
+
await pipeline(async function* () {
|
|
104
|
+
for await (const chunk of snapshotChunks(records, readData)) {
|
|
105
|
+
hash.update(chunk);
|
|
106
|
+
byteLength += chunk.length;
|
|
107
|
+
yield chunk;
|
|
108
|
+
}
|
|
109
|
+
}, createGzip({ level: 6 }), createWriteStream(temporary));
|
|
110
|
+
const sha256 = hash.digest("hex"), path = resolve(directory, "packs", `${sha256}.snapshot.gz`);
|
|
111
|
+
await rename(temporary, path);
|
|
112
|
+
return { sha256, byteLength, encodedByteLength: (await stat(path)).size };
|
|
113
|
+
} finally { await rm(temporary, { force: true }); }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function ensureSnapshotPacks(directory, metadata) {
|
|
117
|
+
if (metadata.encoding === "packs") {
|
|
118
|
+
const present = await Promise.all(validateSnapshotPacks(metadata).map(async pack => {
|
|
119
|
+
try { return (await stat(resolve(directory, "packs", `${pack.sha256}.snapshot.gz`))).size === pack.encodedByteLength; }
|
|
120
|
+
catch (error) { if (error.code === "ENOENT") return false; throw error; }
|
|
121
|
+
}));
|
|
122
|
+
if (present.every(Boolean)) return metadata;
|
|
123
|
+
}
|
|
124
|
+
const bytes = await readFile(resolve(directory, `dolly-${metadata.image}-system.snapshot`));
|
|
125
|
+
if (bytes.length !== metadata.byteLength || digest(bytes) !== metadata.sha256) throw new Error("snapshot mismatch");
|
|
126
|
+
await mkdir(resolve(directory, "packs"), { recursive: true });
|
|
127
|
+
const packs = [];
|
|
128
|
+
for (const records of splitSnapshotRecords(decodeSnapshotRecords(bytes))) {
|
|
129
|
+
packs.push(await writeSnapshotPack(directory, records, decodedData));
|
|
130
|
+
}
|
|
131
|
+
const result = { ...metadata, encoding: "packs", packs };
|
|
132
|
+
validateSnapshotPacks(result);
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Reads the snapshot once, sequentially, keeping one chunk and the record
|
|
137
|
+
// index: path, kind, size, file offset and content key. The structure is
|
|
138
|
+
// validated in full by the acceptance step, which merges the packs again.
|
|
139
|
+
async function indexSnapshot(source) {
|
|
140
|
+
const { handle, metadata } = source;
|
|
141
|
+
if ((await handle.stat()).size !== metadata.byteLength) throw new Error(`${source.name}: snapshot mismatch`);
|
|
142
|
+
const fileHash = createHash("sha256"), buffer = Buffer.allocUnsafe(CHUNK_BYTES), records = [];
|
|
143
|
+
let position = 0;
|
|
144
|
+
const read = async (length, hash) => {
|
|
145
|
+
const bytes = await readExact(handle, buffer, position, length);
|
|
146
|
+
fileHash.update(bytes);
|
|
147
|
+
hash?.update(bytes);
|
|
148
|
+
position += length;
|
|
149
|
+
return bytes;
|
|
150
|
+
};
|
|
151
|
+
const header = await read(16);
|
|
152
|
+
const count = header.readUInt32LE(12);
|
|
153
|
+
if (header.toString("latin1", 0, 8) !== "DOLLYSNP" || header.readUInt32LE(8) !== 2 || count === 0 || count > 100_000) {
|
|
154
|
+
throw new Error(`${source.name}: invalid Dolly snapshot header`);
|
|
155
|
+
}
|
|
156
|
+
for (let index = 0; index < count; index += 1) {
|
|
157
|
+
const head = await read(16);
|
|
158
|
+
const kind = head.readUInt32LE(0), pathLength = head.readUInt32LE(4), byteLength = Number(head.readBigUInt64LE(8));
|
|
159
|
+
if (kind < 1 || kind > 3 || pathLength < 2 || pathLength >= 4096 || byteLength > MAX_SNAPSHOT_BYTES ||
|
|
160
|
+
position > metadata.byteLength - pathLength - byteLength) throw new Error(`${source.name}: invalid snapshot record size`);
|
|
161
|
+
const path = decoder.decode(await read(pathLength)), hash = recordKey(kind, path), offset = position;
|
|
162
|
+
for (let done = 0; done < byteLength; done += Math.min(CHUNK_BYTES, byteLength - done)) await read(Math.min(CHUNK_BYTES, byteLength - done), hash);
|
|
163
|
+
records.push({ key: hash.digest("hex"), path, kind, byteLength, source, offset });
|
|
164
|
+
}
|
|
165
|
+
if (position !== metadata.byteLength) throw new Error(`${source.name}: trailing snapshot bytes`);
|
|
166
|
+
if (fileHash.digest("hex") !== metadata.sha256) throw new Error(`${source.name}: snapshot mismatch`);
|
|
167
|
+
return records;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function shareSnapshots(directory, snapshots) {
|
|
171
|
+
const images = [], identical = new Map();
|
|
172
|
+
try {
|
|
173
|
+
for (const name of (await readdir(directory)).sort()) {
|
|
174
|
+
if (!/^dolly-.+-system-snapshot\.json$/.test(name)) continue;
|
|
175
|
+
const metadata = JSON.parse(await readFile(resolve(directory, name), "utf8"));
|
|
176
|
+
const image = { metadata, name, packs: [], records: 0, packed: 0,
|
|
177
|
+
handle: await open(resolve(snapshots, `dolly-${metadata.image}-system.snapshot`)) };
|
|
178
|
+
images.push(image);
|
|
179
|
+
for (const record of await indexSnapshot(image)) {
|
|
180
|
+
image.records += 1;
|
|
181
|
+
let shared = identical.get(record.key);
|
|
182
|
+
if (!shared) { shared = { ...record, images: [] }; identical.set(record.key, shared); }
|
|
183
|
+
shared.images.push(image);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// Group identical records by the images that need them. This keeps request
|
|
187
|
+
// counts small while each file's bytes appear in exactly one published pack.
|
|
188
|
+
const groups = new Map();
|
|
189
|
+
for (const shared of identical.values()) {
|
|
190
|
+
const key = shared.images.map(image => image.metadata.image).join(",");
|
|
191
|
+
if (!groups.has(key)) groups.set(key, { images: shared.images, records: new Map() });
|
|
192
|
+
groups.get(key).records.set(shared.path, shared);
|
|
193
|
+
}
|
|
194
|
+
await mkdir(resolve(directory, "packs"), { recursive: true });
|
|
195
|
+
const packs = new Set();
|
|
196
|
+
let packedBytes = 0;
|
|
197
|
+
for (const group of groups.values()) {
|
|
198
|
+
for (const records of splitSnapshotRecords(group.records)) {
|
|
199
|
+
const pack = await writeSnapshotPack(directory, records, indexedData);
|
|
200
|
+
packs.add(pack.sha256);
|
|
201
|
+
packedBytes += pack.encodedByteLength;
|
|
202
|
+
for (const image of group.images) {
|
|
203
|
+
image.packs.push(pack);
|
|
204
|
+
image.packed += records.size;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
for (const image of images) {
|
|
209
|
+
if (image.packed !== image.records) throw new Error(`${image.name}: packed reconstruction mismatch`);
|
|
210
|
+
image.packs.sort((left, right) => left.sha256.localeCompare(right.sha256));
|
|
211
|
+
const metadata = { ...image.metadata, encoding: "packs", packs: image.packs };
|
|
212
|
+
validateSnapshotPacks(metadata);
|
|
213
|
+
await writeFile(resolve(directory, image.name), metadataJson(metadata));
|
|
214
|
+
}
|
|
215
|
+
console.log(`dolly: ${images.length} images share ${packs.size} packs (${packedBytes} compressed bytes)`);
|
|
216
|
+
return { images: images.length, packs: packs.size, packedBytes };
|
|
217
|
+
} finally { await Promise.all(images.map(image => image.handle.close())); }
|
|
218
|
+
}
|
|
219
|
+
if (process.argv[1] === import.meta.filename) await shareSnapshots(resolve(process.argv[2]), resolve(process.argv[3]));
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { recipeRecords } from "./dollyfile-graph.mjs";
|
|
3
|
+
import { decodeSnapshotEnvironment, validateSnapshotEntry } from "./system-snapshot-format.mjs";
|
|
4
|
+
|
|
5
|
+
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
6
|
+
export const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
7
|
+
|
|
8
|
+
export function verifySnapshotIdentity(definition, graph, parsed, processContract, processAbiDigest) {
|
|
9
|
+
const image = definition.image;
|
|
10
|
+
const recipes = recipeRecords(graph);
|
|
11
|
+
const selected = decoder.decode(parsed.files.get("/etc/dolly/image") ?? new Uint8Array());
|
|
12
|
+
if (selected !== image) throw new Error(`snapshot selected image ${selected}, expected ${image}`);
|
|
13
|
+
const expectedLock = "DOLLY-RECIPES 1\n" +
|
|
14
|
+
recipes.map((recipe) => `${recipe.locator} ${recipe.sha256}\n`).join("");
|
|
15
|
+
const actualLock = decoder.decode(parsed.files.get("/etc/dolly/recipes.lock") ?? new Uint8Array());
|
|
16
|
+
if (actualLock !== expectedLock) throw new Error("snapshot recipe lock does not match source");
|
|
17
|
+
const sourceByPath = new Map(graph.records.map((record) => [record.location, record.source]));
|
|
18
|
+
for (const recipe of recipes) {
|
|
19
|
+
const embedded = parsed.files.get(recipe.retainedPath);
|
|
20
|
+
if (!embedded || sha256(embedded) !== recipe.sha256 ||
|
|
21
|
+
decoder.decode(embedded) !== sourceByPath.get(recipe.sourcePath)) {
|
|
22
|
+
throw new Error(`snapshot did not retain the exact ${recipe.sourcePath}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const selectedBytes = parsed.files.get("/etc/dolly/Dollyfile");
|
|
26
|
+
if (!selectedBytes || decoder.decode(selectedBytes) !== definition.source) {
|
|
27
|
+
throw new Error("snapshot canonical Dollyfile does not match the selected recipe");
|
|
28
|
+
}
|
|
29
|
+
// Runtime ordering determines ENV, not the advisory source inspection graph.
|
|
30
|
+
decodeSnapshotEnvironment(parsed.files.get("/etc/dolly/environment"));
|
|
31
|
+
const entry = validateSnapshotEntry(parsed, processContract, processAbiDigest);
|
|
32
|
+
if (JSON.stringify(entry) !== JSON.stringify(definition.parsed.entry)) {
|
|
33
|
+
throw new Error("snapshot ENTRY does not match the selected recipe");
|
|
34
|
+
}
|
|
35
|
+
return entry;
|
|
36
|
+
}
|