@moldesk/core 0.0.5 → 0.0.6
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/dist/cloud/accounts.d.ts +69 -0
- package/dist/cloud/accounts.js +113 -0
- package/dist/cloud/accounts.test.d.ts +1 -0
- package/dist/cloud/accounts.test.js +188 -0
- package/dist/cloud/backend.test.d.ts +1 -0
- package/dist/cloud/backend.test.js +339 -0
- package/dist/cloud/deps.d.ts +15 -0
- package/dist/cloud/deps.js +30 -0
- package/dist/cloud/manage.d.ts +57 -0
- package/dist/cloud/manage.js +210 -0
- package/dist/cloud/manage.test.d.ts +1 -0
- package/dist/cloud/manage.test.js +105 -0
- package/dist/cloud/quote.d.ts +131 -0
- package/dist/cloud/quote.js +225 -0
- package/dist/cloud/quote.test.d.ts +1 -0
- package/dist/cloud/quote.test.js +264 -0
- package/dist/cloud/run.d.ts +114 -0
- package/dist/cloud/run.js +498 -0
- package/dist/cloud/run.test.d.ts +1 -0
- package/dist/cloud/run.test.js +597 -0
- package/dist/cloud/state.d.ts +159 -0
- package/dist/cloud/state.js +395 -0
- package/dist/cloud/state.test.d.ts +1 -0
- package/dist/cloud/state.test.js +199 -0
- package/dist/cloud/worker/cache.d.ts +75 -0
- package/dist/cloud/worker/cache.js +152 -0
- package/dist/cloud/worker/cache.test.d.ts +1 -0
- package/dist/cloud/worker/cache.test.js +123 -0
- package/dist/cloud/worker/main.d.ts +2 -0
- package/dist/cloud/worker/main.js +34 -0
- package/dist/cloud/worker/pipeline.d.ts +26 -0
- package/dist/cloud/worker/pipeline.js +160 -0
- package/dist/cloud/worker/pipeline.test.d.ts +1 -0
- package/dist/cloud/worker/pipeline.test.js +298 -0
- package/dist/cloud/worker/server.d.ts +38 -0
- package/dist/cloud/worker/server.js +287 -0
- package/dist/cloud/worker/server.test.d.ts +1 -0
- package/dist/cloud/worker/server.test.js +391 -0
- package/dist/cloud/worker/watchdog.d.ts +27 -0
- package/dist/cloud/worker/watchdog.js +46 -0
- package/dist/cloud/worker-release.d.ts +12 -0
- package/dist/cloud/worker-release.js +25 -0
- package/dist/cloud/worker-release.test.d.ts +1 -0
- package/dist/cloud/worker-release.test.js +21 -0
- package/dist/index.d.ts +17 -3
- package/dist/index.js +9 -2
- package/dist/run-lifecycle.d.ts +74 -0
- package/dist/run-lifecycle.js +178 -0
- package/dist/run.d.ts +61 -1
- package/dist/run.js +54 -125
- package/dist/run.test.js +47 -1
- package/package.json +10 -6
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { CloudJournal, cloudResourceName } from "./state.js";
|
|
6
|
+
const dirs = [];
|
|
7
|
+
const RUN = "2026-10-13T11-55-45-123Z-0a1b2c3d-1111-2222-3333-444455556666";
|
|
8
|
+
function tempFile() {
|
|
9
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk cloud journal "));
|
|
10
|
+
dirs.push(dir);
|
|
11
|
+
return path.join(dir, "cloud.sqlite");
|
|
12
|
+
}
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
for (const dir of dirs.splice(0))
|
|
15
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
function clock(start = "2026-10-13T12:00:00.000Z") {
|
|
18
|
+
let ms = Date.parse(start);
|
|
19
|
+
return { now: () => new Date(ms).toISOString(), advance: (seconds) => void (ms += seconds * 1000) };
|
|
20
|
+
}
|
|
21
|
+
const job = {
|
|
22
|
+
runId: RUN,
|
|
23
|
+
accountScope: "acct1",
|
|
24
|
+
quoteDigest: "q".repeat(64),
|
|
25
|
+
cachePolicy: "ephemeral",
|
|
26
|
+
gpuId: "NVIDIA A40",
|
|
27
|
+
datacenterId: "EU-RO-1",
|
|
28
|
+
deadlineAt: "2026-10-13T13:00:00.000Z",
|
|
29
|
+
manifestDigest: "m".repeat(64),
|
|
30
|
+
};
|
|
31
|
+
describe("CloudJournal", () => {
|
|
32
|
+
it("keeps a stable client id across reopen and rejects a newer schema", () => {
|
|
33
|
+
const file = tempFile();
|
|
34
|
+
const first = CloudJournal.openFile(file);
|
|
35
|
+
const id = first.clientId;
|
|
36
|
+
first.close();
|
|
37
|
+
const second = CloudJournal.openFile(file);
|
|
38
|
+
expect(second.clientId).toBe(id);
|
|
39
|
+
expect(id).toMatch(/^[0-9a-f-]{36}$/);
|
|
40
|
+
second.close();
|
|
41
|
+
});
|
|
42
|
+
it("tracks a job through stages and survives reopen", () => {
|
|
43
|
+
const file = tempFile();
|
|
44
|
+
const journal = CloudJournal.openFile(file);
|
|
45
|
+
const created = journal.createJob(job);
|
|
46
|
+
expect(created).toMatchObject({ stage: "approved", status: "active", gpuId: "NVIDIA A40", volumeId: null });
|
|
47
|
+
journal.updateJob(RUN, { stage: "running", volumeId: "vol1", transferState: "none" });
|
|
48
|
+
journal.close();
|
|
49
|
+
const reopened = CloudJournal.openFile(file);
|
|
50
|
+
expect(reopened.getJob(RUN)).toMatchObject({ stage: "running", volumeId: "vol1", transferState: "none" });
|
|
51
|
+
expect(reopened.listOpenJobs().map((j) => j.runId)).toEqual([RUN]);
|
|
52
|
+
reopened.updateJob(RUN, { stage: "done", status: "cleanup_pending", error: { code: "CLOUD_CLEANUP_PENDING", message: "x" } });
|
|
53
|
+
expect(reopened.getJob(RUN).error).toEqual({ code: "CLOUD_CLEANUP_PENDING", message: "x" });
|
|
54
|
+
expect(reopened.listOpenJobs()).toHaveLength(1);
|
|
55
|
+
reopened.updateJob(RUN, { status: "succeeded", error: null });
|
|
56
|
+
expect(reopened.listOpenJobs()).toHaveLength(0);
|
|
57
|
+
expect(() => reopened.updateJob("missing", { stage: "done" })).toThrow(/No cloud job/);
|
|
58
|
+
reopened.close();
|
|
59
|
+
});
|
|
60
|
+
it("persists resource intent before the provider call and reconstructs it after a crash", () => {
|
|
61
|
+
const file = tempFile();
|
|
62
|
+
const first = CloudJournal.openFile(file);
|
|
63
|
+
first.createJob(job);
|
|
64
|
+
const intent = first.recordResourceIntent({ runId: RUN, role: "gpu", deadlineAt: job.deadlineAt });
|
|
65
|
+
expect(intent).toMatchObject({ state: "create_pending", providerId: null });
|
|
66
|
+
expect(intent.name).toBe(cloudResourceName(first.clientId, RUN, "gpu"));
|
|
67
|
+
expect(intent.name).toMatch(/^moldesk-[0-9a-f]{8}-0a1b2c3d-gpu$/);
|
|
68
|
+
first.close(); // simulated client crash before the create response
|
|
69
|
+
const second = CloudJournal.openFile(file);
|
|
70
|
+
expect(second.listResources({ states: ["create_pending"] }).map((r) => r.intentId)).toEqual([intent.intentId]);
|
|
71
|
+
const active = second.recordResourceCreated(intent.intentId, "pod1");
|
|
72
|
+
expect(active).toMatchObject({ state: "active", providerId: "pod1" });
|
|
73
|
+
expect(second.findOwnedByProviderId("pod1")?.intentId).toBe(intent.intentId);
|
|
74
|
+
second.markResourceDeletePending(intent.intentId);
|
|
75
|
+
second.markResourceDeleted(intent.intentId);
|
|
76
|
+
expect(second.findOwnedByProviderId("pod1")).toBeUndefined();
|
|
77
|
+
second.close();
|
|
78
|
+
});
|
|
79
|
+
it("rejects illegal resource transitions", () => {
|
|
80
|
+
const journal = CloudJournal.openFile(tempFile());
|
|
81
|
+
const intent = journal.recordResourceIntent({ runId: RUN, role: "volume" });
|
|
82
|
+
journal.markResourceDeleted(intent.intentId);
|
|
83
|
+
expect(() => journal.recordResourceCreated(intent.intentId, "v1")).toThrow(/Cannot move/);
|
|
84
|
+
expect(() => journal.markResourceDeletePending("nope")).toThrow(/No cloud resource/);
|
|
85
|
+
journal.close();
|
|
86
|
+
});
|
|
87
|
+
describe("reconcile", () => {
|
|
88
|
+
it("adopts an ambiguous create that did take effect, matched by exact name", () => {
|
|
89
|
+
const journal = CloudJournal.openFile(tempFile());
|
|
90
|
+
const intent = journal.recordResourceIntent({ runId: RUN, role: "gpu" });
|
|
91
|
+
const report = journal.reconcile([{ providerId: "pod9", name: intent.name, role: "gpu" }]);
|
|
92
|
+
expect(report.adopted.map((r) => r.providerId)).toEqual(["pod9"]);
|
|
93
|
+
expect(journal.getResource(intent.intentId)).toMatchObject({ state: "active", providerId: "pod9" });
|
|
94
|
+
journal.close();
|
|
95
|
+
});
|
|
96
|
+
it("abandons a create that never took effect", () => {
|
|
97
|
+
const journal = CloudJournal.openFile(tempFile());
|
|
98
|
+
const intent = journal.recordResourceIntent({ runId: RUN, role: "gpu" });
|
|
99
|
+
const report = journal.reconcile([]);
|
|
100
|
+
expect(report.abandoned.map((r) => r.intentId)).toEqual([intent.intentId]);
|
|
101
|
+
expect(journal.getResource(intent.intentId).state).toBe("deleted");
|
|
102
|
+
journal.close();
|
|
103
|
+
});
|
|
104
|
+
it("marks resources the provider no longer has as vanished", () => {
|
|
105
|
+
const journal = CloudJournal.openFile(tempFile());
|
|
106
|
+
const intent = journal.recordResourceIntent({ runId: RUN, role: "cpu" });
|
|
107
|
+
journal.recordResourceCreated(intent.intentId, "pod1");
|
|
108
|
+
expect(journal.reconcile([]).vanished.map((r) => r.intentId)).toEqual([intent.intentId]);
|
|
109
|
+
expect(journal.getResource(intent.intentId).state).toBe("deleted");
|
|
110
|
+
journal.close();
|
|
111
|
+
});
|
|
112
|
+
it("reports, but never adopts, a provider resource that only claims this client by name", () => {
|
|
113
|
+
const journal = CloudJournal.openFile(tempFile());
|
|
114
|
+
const prefix = journal.clientId.replace(/-/g, "").slice(0, 8);
|
|
115
|
+
const lookalike = { providerId: "podX", name: `moldesk-${prefix}-deadbeef-gpu`, role: "gpu" };
|
|
116
|
+
const foreign = { providerId: "podY", name: "moldesk-ffffffff-deadbeef-gpu", role: "gpu" };
|
|
117
|
+
const unrelated = { providerId: "podZ", name: "my-own-pod", role: "gpu" };
|
|
118
|
+
const report = journal.reconcile([lookalike, foreign, unrelated]);
|
|
119
|
+
expect(report.orphaned).toEqual([lookalike]);
|
|
120
|
+
expect(journal.findOwnedByProviderId("podX")).toBeUndefined();
|
|
121
|
+
journal.close();
|
|
122
|
+
});
|
|
123
|
+
it("leaves ambiguous same-name candidates pending and flags them", () => {
|
|
124
|
+
const journal = CloudJournal.openFile(tempFile());
|
|
125
|
+
const intent = journal.recordResourceIntent({ runId: RUN, role: "gpu" });
|
|
126
|
+
const report = journal.reconcile([
|
|
127
|
+
{ providerId: "a", name: intent.name, role: "gpu" },
|
|
128
|
+
{ providerId: "b", name: intent.name, role: "gpu" },
|
|
129
|
+
]);
|
|
130
|
+
expect(report.adopted).toHaveLength(0);
|
|
131
|
+
expect(report.orphaned.map((r) => r.providerId).sort()).toEqual(["a", "b"]);
|
|
132
|
+
expect(journal.getResource(intent.intentId).state).toBe("create_pending");
|
|
133
|
+
journal.close();
|
|
134
|
+
});
|
|
135
|
+
it("lists owed cleanup: delete_pending and active resources past their deadline", () => {
|
|
136
|
+
const time = clock();
|
|
137
|
+
const journal = CloudJournal.openFile(tempFile(), { now: time.now });
|
|
138
|
+
const pending = journal.recordResourceIntent({ runId: RUN, role: "gpu" });
|
|
139
|
+
journal.recordResourceCreated(pending.intentId, "pod1");
|
|
140
|
+
journal.markResourceDeletePending(pending.intentId);
|
|
141
|
+
const overdue = journal.recordResourceIntent({ runId: RUN, role: "cpu", deadlineAt: "2026-10-13T12:30:00.000Z" });
|
|
142
|
+
journal.recordResourceCreated(overdue.intentId, "pod2");
|
|
143
|
+
const view = (providerId, name, role) => ({ providerId, name, role });
|
|
144
|
+
const listing = [view("pod1", pending.name, "gpu"), view("pod2", overdue.name, "cpu")];
|
|
145
|
+
expect(journal.reconcile(listing).cleanupOwed.map((r) => r.providerId)).toEqual(["pod1"]);
|
|
146
|
+
time.advance(31 * 60);
|
|
147
|
+
expect(journal.reconcile(listing).cleanupOwed.map((r) => r.providerId).sort()).toEqual(["pod1", "pod2"]);
|
|
148
|
+
journal.close();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
describe("caches", () => {
|
|
152
|
+
const key = { accountScope: "acct1", datacenterId: "EU-RO-1", volumeId: "vol1", cacheKey: "k".repeat(64) };
|
|
153
|
+
const base = {
|
|
154
|
+
...key,
|
|
155
|
+
model: "boltz",
|
|
156
|
+
modelVersion: "2.2.1",
|
|
157
|
+
layoutRevision: "1",
|
|
158
|
+
manifestDigest: "m".repeat(64),
|
|
159
|
+
cachePath: "/workspace/moldesk-cache/k",
|
|
160
|
+
sizeBytes: null,
|
|
161
|
+
checksumManifestDigest: null,
|
|
162
|
+
};
|
|
163
|
+
it("only reaches READY through VERIFYING with a checksum manifest", () => {
|
|
164
|
+
const journal = CloudJournal.openFile(tempFile());
|
|
165
|
+
expect(journal.upsertCache(base).state).toBe("PREPARING");
|
|
166
|
+
expect(() => journal.setCacheState(key, "READY")).toThrow(/Cannot move/);
|
|
167
|
+
journal.setCacheState(key, "VERIFYING");
|
|
168
|
+
expect(() => journal.setCacheState(key, "READY")).toThrow(/checksum manifest/);
|
|
169
|
+
const ready = journal.setCacheState(key, "READY", { sizeBytes: 123, checksumManifestDigest: "c".repeat(64) });
|
|
170
|
+
expect(ready).toMatchObject({ state: "READY", sizeBytes: 123 });
|
|
171
|
+
journal.setCacheState(key, "FAILED");
|
|
172
|
+
expect(journal.setCacheState(key, "PREPARING").state).toBe("PREPARING");
|
|
173
|
+
journal.close();
|
|
174
|
+
});
|
|
175
|
+
it("grants a lease to one owner at a time, allows renewal, and reclaims after expiry", () => {
|
|
176
|
+
const time = clock();
|
|
177
|
+
const journal = CloudJournal.openFile(tempFile(), { now: time.now });
|
|
178
|
+
journal.upsertCache(base);
|
|
179
|
+
expect(journal.acquireCacheLease(key, "a", 60)).toBe(true);
|
|
180
|
+
expect(journal.acquireCacheLease(key, "b", 60)).toBe(false);
|
|
181
|
+
expect(journal.acquireCacheLease(key, "a", 60)).toBe(true); // renewal
|
|
182
|
+
time.advance(61);
|
|
183
|
+
expect(journal.acquireCacheLease(key, "b", 60)).toBe(true);
|
|
184
|
+
journal.releaseCacheLease(key, "a"); // not the owner: no effect
|
|
185
|
+
expect(journal.getCache(key.accountScope, key.datacenterId, key.volumeId, key.cacheKey).leaseOwner).toBe("b");
|
|
186
|
+
journal.releaseCacheLease(key, "b");
|
|
187
|
+
expect(journal.acquireCacheLease(key, "a", 60)).toBe(true);
|
|
188
|
+
journal.close();
|
|
189
|
+
});
|
|
190
|
+
it("lists and deletes cache rows", () => {
|
|
191
|
+
const journal = CloudJournal.openFile(tempFile());
|
|
192
|
+
journal.upsertCache(base);
|
|
193
|
+
expect(journal.listCaches()).toHaveLength(1);
|
|
194
|
+
journal.deleteCache(key);
|
|
195
|
+
expect(journal.listCaches()).toHaveLength(0);
|
|
196
|
+
journal.close();
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { type ModelManifestV1 } from "@moldesk/registry";
|
|
2
|
+
/**
|
|
3
|
+
* On-volume model cache. Layout under `<volume>/moldesk-cache`:
|
|
4
|
+
* objects/ and home/... raw download cache (checksummed objects)
|
|
5
|
+
* <key>/assets/ materialized assets
|
|
6
|
+
* <key>/ready.json written LAST, inside a staging directory that is then renamed into place
|
|
7
|
+
* A cache is only ever reachable as `<key>` after the rename, so an interrupted preparation can
|
|
8
|
+
* never look READY, and a mutated or partial tree is detected by re-hashing against `ready.json`.
|
|
9
|
+
*/
|
|
10
|
+
export declare const CACHE_DIR_NAME = "moldesk-cache";
|
|
11
|
+
export declare const READY_FILE = "ready.json";
|
|
12
|
+
export interface CacheFileEntry {
|
|
13
|
+
path: string;
|
|
14
|
+
sizeBytes: number;
|
|
15
|
+
sha256: string;
|
|
16
|
+
}
|
|
17
|
+
export interface CacheReadyMetadata {
|
|
18
|
+
version: 1;
|
|
19
|
+
cacheKey: string;
|
|
20
|
+
layoutRevision: string;
|
|
21
|
+
manifestDigest: string;
|
|
22
|
+
files: CacheFileEntry[];
|
|
23
|
+
/** Digest of the canonical file list; also recorded in the client journal. */
|
|
24
|
+
checksumManifestSha256: string;
|
|
25
|
+
sizeBytes: number;
|
|
26
|
+
preparedAt: string;
|
|
27
|
+
}
|
|
28
|
+
/** Changes whenever anything that affects the prepared assets changes; conservative by design. */
|
|
29
|
+
export declare function cloudCacheKey(manifest: ModelManifestV1): string;
|
|
30
|
+
export declare const cacheRoot: (volumeMount: string) => string;
|
|
31
|
+
export declare const cacheDirFor: (root: string, key: string) => string;
|
|
32
|
+
/** Hashes every regular file under `dir`; symlinks, special files and unsafe names are refused. */
|
|
33
|
+
export declare function hashTree(dir: string, skip?: ReadonlySet<string>): Promise<CacheFileEntry[]>;
|
|
34
|
+
/**
|
|
35
|
+
* Publishes a fully prepared staging directory (`<staging>/assets/...`) as the cache for `key`:
|
|
36
|
+
* hashes the tree, writes ready.json into staging, then atomically renames staging into place.
|
|
37
|
+
*/
|
|
38
|
+
export declare function publishCache(params: {
|
|
39
|
+
root: string;
|
|
40
|
+
key: string;
|
|
41
|
+
stagingDir: string;
|
|
42
|
+
manifestDigest: string;
|
|
43
|
+
layoutRevision: string;
|
|
44
|
+
}): Promise<CacheReadyMetadata>;
|
|
45
|
+
export type CacheVerification = {
|
|
46
|
+
ok: true;
|
|
47
|
+
metadata: CacheReadyMetadata;
|
|
48
|
+
assetsDir: string;
|
|
49
|
+
} | {
|
|
50
|
+
ok: false;
|
|
51
|
+
code: "CLOUD_CACHE_MISSING" | "CLOUD_CACHE_INVALID";
|
|
52
|
+
message: string;
|
|
53
|
+
};
|
|
54
|
+
/** Verifies the on-volume cache before use: metadata matches this job, and every file hashes as recorded. */
|
|
55
|
+
export declare function verifyCache(params: {
|
|
56
|
+
root: string;
|
|
57
|
+
key: string;
|
|
58
|
+
manifestDigest: string;
|
|
59
|
+
layoutRevision: string;
|
|
60
|
+
}): Promise<CacheVerification>;
|
|
61
|
+
export interface VolumeLock {
|
|
62
|
+
release(): void;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Advisory lock for preparing one cache, as an atomic directory with owner and expiry. An expired
|
|
66
|
+
* lock is reclaimed only when the caller asserts the previous owner is gone (`reclaimStale`).
|
|
67
|
+
*/
|
|
68
|
+
export declare function acquireVolumeLock(params: {
|
|
69
|
+
root: string;
|
|
70
|
+
key: string;
|
|
71
|
+
owner: string;
|
|
72
|
+
ttlSeconds: number;
|
|
73
|
+
reclaimStale?: boolean;
|
|
74
|
+
now?: () => number;
|
|
75
|
+
}): VolumeLock;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { effectiveRuntimeSource, selectPythonRuntime } from "@moldesk/registry";
|
|
5
|
+
import { isSafeRelativePath } from "@moldesk/runtime";
|
|
6
|
+
import { MoldeskError } from "../../errors.js";
|
|
7
|
+
import { digest } from "../../hash.js";
|
|
8
|
+
/**
|
|
9
|
+
* On-volume model cache. Layout under `<volume>/moldesk-cache`:
|
|
10
|
+
* objects/ and home/... raw download cache (checksummed objects)
|
|
11
|
+
* <key>/assets/ materialized assets
|
|
12
|
+
* <key>/ready.json written LAST, inside a staging directory that is then renamed into place
|
|
13
|
+
* A cache is only ever reachable as `<key>` after the rename, so an interrupted preparation can
|
|
14
|
+
* never look READY, and a mutated or partial tree is detected by re-hashing against `ready.json`.
|
|
15
|
+
*/
|
|
16
|
+
export const CACHE_DIR_NAME = "moldesk-cache";
|
|
17
|
+
export const READY_FILE = "ready.json";
|
|
18
|
+
const REMOTE_PLATFORM = "linux-x64";
|
|
19
|
+
/** Changes whenever anything that affects the prepared assets changes; conservative by design. */
|
|
20
|
+
export function cloudCacheKey(manifest) {
|
|
21
|
+
const runtime = selectPythonRuntime(manifest.runtimes, REMOTE_PLATFORM);
|
|
22
|
+
const source = effectiveRuntimeSource(manifest, runtime);
|
|
23
|
+
return digest({
|
|
24
|
+
model: manifest.name,
|
|
25
|
+
modelVersion: manifest.modelVersion,
|
|
26
|
+
sourceRevision: source?.revision ?? null,
|
|
27
|
+
assets: (manifest.assets ?? []).map((asset) => ({ id: asset.id, sha256: asset.sha256.toLowerCase(), target: asset.target, archive: asset.archive ?? "none" })),
|
|
28
|
+
layout: manifest.cloud?.assetLayoutRevision ?? null,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
export const cacheRoot = (volumeMount) => path.posix.join(volumeMount, CACHE_DIR_NAME);
|
|
32
|
+
export const cacheDirFor = (root, key) => path.join(root, key);
|
|
33
|
+
function cacheError(code, message, remediation) {
|
|
34
|
+
return new MoldeskError({ code, message, remediation });
|
|
35
|
+
}
|
|
36
|
+
/** Hashes every regular file under `dir`; symlinks, special files and unsafe names are refused. */
|
|
37
|
+
export async function hashTree(dir, skip = new Set()) {
|
|
38
|
+
const files = [];
|
|
39
|
+
const walk = async (current, prefix) => {
|
|
40
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
41
|
+
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
42
|
+
const full = path.join(current, entry.name);
|
|
43
|
+
if (skip.has(relative))
|
|
44
|
+
continue;
|
|
45
|
+
const stat = fs.lstatSync(full);
|
|
46
|
+
if (stat.isSymbolicLink())
|
|
47
|
+
throw cacheError("CLOUD_CACHE_UNSAFE", `Cache contains a symlink: ${relative}.`, "The cache is rejected and will be rebuilt.");
|
|
48
|
+
if (!isSafeRelativePath(relative))
|
|
49
|
+
throw cacheError("CLOUD_CACHE_UNSAFE", `Cache contains an unsafe path: ${relative}.`, "The cache is rejected and will be rebuilt.");
|
|
50
|
+
if (stat.isDirectory())
|
|
51
|
+
await walk(full, relative);
|
|
52
|
+
else if (stat.isFile()) {
|
|
53
|
+
const hash = createHash("sha256");
|
|
54
|
+
for await (const chunk of fs.createReadStream(full))
|
|
55
|
+
hash.update(chunk);
|
|
56
|
+
files.push({ path: relative, sizeBytes: stat.size, sha256: hash.digest("hex") });
|
|
57
|
+
}
|
|
58
|
+
else
|
|
59
|
+
throw cacheError("CLOUD_CACHE_UNSAFE", `Cache contains a non-regular file: ${relative}.`, "The cache is rejected and will be rebuilt.");
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
await walk(dir, "");
|
|
63
|
+
return files.sort((a, b) => a.path.localeCompare(b.path));
|
|
64
|
+
}
|
|
65
|
+
const checksumDigest = (files) => digest(files.map((file) => [file.path, file.sizeBytes, file.sha256]));
|
|
66
|
+
/**
|
|
67
|
+
* Publishes a fully prepared staging directory (`<staging>/assets/...`) as the cache for `key`:
|
|
68
|
+
* hashes the tree, writes ready.json into staging, then atomically renames staging into place.
|
|
69
|
+
*/
|
|
70
|
+
export async function publishCache(params) {
|
|
71
|
+
const files = await hashTree(params.stagingDir, new Set([READY_FILE]));
|
|
72
|
+
const metadata = {
|
|
73
|
+
version: 1,
|
|
74
|
+
cacheKey: params.key,
|
|
75
|
+
layoutRevision: params.layoutRevision,
|
|
76
|
+
manifestDigest: params.manifestDigest,
|
|
77
|
+
files,
|
|
78
|
+
checksumManifestSha256: checksumDigest(files),
|
|
79
|
+
sizeBytes: files.reduce((total, file) => total + file.sizeBytes, 0),
|
|
80
|
+
preparedAt: new Date().toISOString(),
|
|
81
|
+
};
|
|
82
|
+
fs.writeFileSync(path.join(params.stagingDir, READY_FILE), `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600 });
|
|
83
|
+
const final = cacheDirFor(params.root, params.key);
|
|
84
|
+
if (fs.existsSync(final))
|
|
85
|
+
fs.renameSync(final, `${final}.replaced-${randomUUID()}`);
|
|
86
|
+
fs.renameSync(params.stagingDir, final);
|
|
87
|
+
return metadata;
|
|
88
|
+
}
|
|
89
|
+
/** Verifies the on-volume cache before use: metadata matches this job, and every file hashes as recorded. */
|
|
90
|
+
export async function verifyCache(params) {
|
|
91
|
+
const dir = cacheDirFor(params.root, params.key);
|
|
92
|
+
const readyPath = path.join(dir, READY_FILE);
|
|
93
|
+
if (!fs.existsSync(readyPath))
|
|
94
|
+
return { ok: false, code: "CLOUD_CACHE_MISSING", message: "No prepared cache exists on the volume." };
|
|
95
|
+
let metadata;
|
|
96
|
+
try {
|
|
97
|
+
metadata = JSON.parse(fs.readFileSync(readyPath, "utf8"));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return { ok: false, code: "CLOUD_CACHE_INVALID", message: "The cache metadata is unreadable." };
|
|
101
|
+
}
|
|
102
|
+
if (metadata.version !== 1 || metadata.cacheKey !== params.key || metadata.layoutRevision !== params.layoutRevision || metadata.manifestDigest !== params.manifestDigest) {
|
|
103
|
+
return { ok: false, code: "CLOUD_CACHE_INVALID", message: "The cache was prepared for a different model, source or layout." };
|
|
104
|
+
}
|
|
105
|
+
if (checksumDigest(metadata.files) !== metadata.checksumManifestSha256) {
|
|
106
|
+
return { ok: false, code: "CLOUD_CACHE_INVALID", message: "The cache checksum manifest was altered." };
|
|
107
|
+
}
|
|
108
|
+
let actual;
|
|
109
|
+
try {
|
|
110
|
+
actual = await hashTree(dir, new Set([READY_FILE]));
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
return { ok: false, code: "CLOUD_CACHE_INVALID", message: error instanceof Error ? error.message : String(error) };
|
|
114
|
+
}
|
|
115
|
+
if (checksumDigest(actual) !== metadata.checksumManifestSha256) {
|
|
116
|
+
return { ok: false, code: "CLOUD_CACHE_INVALID", message: "The cache contents no longer match their recorded checksums." };
|
|
117
|
+
}
|
|
118
|
+
return { ok: true, metadata, assetsDir: path.join(dir, "assets") };
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Advisory lock for preparing one cache, as an atomic directory with owner and expiry. An expired
|
|
122
|
+
* lock is reclaimed only when the caller asserts the previous owner is gone (`reclaimStale`).
|
|
123
|
+
*/
|
|
124
|
+
export function acquireVolumeLock(params) {
|
|
125
|
+
const now = params.now ?? Date.now;
|
|
126
|
+
const lock = path.join(params.root, `${params.key}.lock`);
|
|
127
|
+
fs.mkdirSync(params.root, { recursive: true });
|
|
128
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
129
|
+
try {
|
|
130
|
+
fs.mkdirSync(lock);
|
|
131
|
+
fs.writeFileSync(path.join(lock, "owner.json"), JSON.stringify({ owner: params.owner, expiresAt: now() + params.ttlSeconds * 1000 }));
|
|
132
|
+
return { release: () => fs.rmSync(lock, { recursive: true, force: true }) };
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
if (error.code !== "EEXIST")
|
|
136
|
+
throw error;
|
|
137
|
+
let expired = false;
|
|
138
|
+
try {
|
|
139
|
+
expired = JSON.parse(fs.readFileSync(path.join(lock, "owner.json"), "utf8")).expiresAt <= now();
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
expired = true;
|
|
143
|
+
}
|
|
144
|
+
if (expired && params.reclaimStale && attempt === 0) {
|
|
145
|
+
fs.rmSync(lock, { recursive: true, force: true });
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
throw cacheError("CLOUD_CACHE_LOCKED", "Another machine is preparing this cache.", "Wait for it to finish; a stale lock is reclaimed only after the previous owner is confirmed gone.");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
throw cacheError("CLOUD_CACHE_LOCKED", "Could not take the cache lock.", "Retry.");
|
|
152
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { parseManifest } from "@moldesk/registry";
|
|
6
|
+
import { acquireVolumeLock, cacheDirFor, cloudCacheKey, hashTree, publishCache, verifyCache } from "./cache.js";
|
|
7
|
+
const dirs = [];
|
|
8
|
+
const tmp = () => {
|
|
9
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk cache "));
|
|
10
|
+
dirs.push(dir);
|
|
11
|
+
return dir;
|
|
12
|
+
};
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
for (const dir of dirs.splice(0))
|
|
15
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
function manifest(patch = {}, layout = "1") {
|
|
18
|
+
return parseManifest({
|
|
19
|
+
schemaVersion: 1,
|
|
20
|
+
name: "m",
|
|
21
|
+
displayName: "M",
|
|
22
|
+
modelVersion: "1.0.0",
|
|
23
|
+
adapterVersion: "0.1.0",
|
|
24
|
+
description: "t",
|
|
25
|
+
category: "other",
|
|
26
|
+
status: "planned",
|
|
27
|
+
source: { repository: "https://example.com/r", revision: "b".repeat(40) },
|
|
28
|
+
runtimes: [{ kind: "python", python: "3.11", installer: "uv", platforms: ["linux-x64"], requirements: [{ name: "torch" }] }],
|
|
29
|
+
hardware: { platforms: ["linux-x64"] },
|
|
30
|
+
assets: [{ id: "w", url: "https://example.com/w.bin", sha256: "c".repeat(64), target: "w.bin" }],
|
|
31
|
+
input: { formats: [".txt"], required: true },
|
|
32
|
+
outputs: [{ id: "o", glob: "*.out", required: true }],
|
|
33
|
+
cloud: { schemaVersion: 1, platform: "linux-x64", images: { gpu: `r.example/g@sha256:${"a".repeat(64)}`, cpu: `r.example/c@sha256:${"a".repeat(64)}` }, minVramGb: 24, minRamGb: 32, ephemeralDiskGb: 40, volumeGb: 40, assetLayoutRevision: layout, cpuAssetPreparation: true },
|
|
34
|
+
...patch,
|
|
35
|
+
}, "x/manifest.yaml");
|
|
36
|
+
}
|
|
37
|
+
function stage(root, key) {
|
|
38
|
+
const staging = path.join(root, `${key}.staging-1`);
|
|
39
|
+
fs.mkdirSync(path.join(staging, "assets", "sub"), { recursive: true });
|
|
40
|
+
fs.writeFileSync(path.join(staging, "assets", "w.bin"), "weights");
|
|
41
|
+
fs.writeFileSync(path.join(staging, "assets", "sub", "x.dat"), "data");
|
|
42
|
+
return staging;
|
|
43
|
+
}
|
|
44
|
+
describe("cloudCacheKey", () => {
|
|
45
|
+
it("is stable, and changes with assets, source revision, version and layout", () => {
|
|
46
|
+
const base = cloudCacheKey(manifest());
|
|
47
|
+
expect(cloudCacheKey(manifest())).toBe(base);
|
|
48
|
+
expect(cloudCacheKey(manifest({}, "2"))).not.toBe(base);
|
|
49
|
+
expect(cloudCacheKey(manifest({ assets: [{ id: "w", url: "https://example.com/w.bin", sha256: "d".repeat(64), target: "w.bin" }] }))).not.toBe(base);
|
|
50
|
+
expect(cloudCacheKey(manifest({ source: { repository: "https://example.com/r", revision: "e".repeat(40) } }))).not.toBe(base);
|
|
51
|
+
expect(cloudCacheKey(manifest({ modelVersion: "1.0.1" }))).not.toBe(base);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
describe("publishCache / verifyCache", () => {
|
|
55
|
+
const params = (root, key = "k1") => ({ root, key, manifestDigest: "md", layoutRevision: "1" });
|
|
56
|
+
it("publishes atomically and verifies a warm cache", async () => {
|
|
57
|
+
const root = tmp();
|
|
58
|
+
const meta = await publishCache({ ...params(root), stagingDir: stage(root, "k1") });
|
|
59
|
+
expect(meta.files.map((f) => f.path)).toEqual(["assets/sub/x.dat", "assets/w.bin"]);
|
|
60
|
+
expect(fs.existsSync(path.join(root, "k1.staging-1"))).toBe(false);
|
|
61
|
+
const result = await verifyCache(params(root));
|
|
62
|
+
expect(result).toMatchObject({ ok: true, assetsDir: path.join(root, "k1", "assets") });
|
|
63
|
+
});
|
|
64
|
+
it("reports missing, wrong-key, mutated, added and symlinked caches as unusable", async () => {
|
|
65
|
+
const root = tmp();
|
|
66
|
+
expect(await verifyCache(params(root))).toMatchObject({ ok: false, code: "CLOUD_CACHE_MISSING" });
|
|
67
|
+
await publishCache({ ...params(root), stagingDir: stage(root, "k1") });
|
|
68
|
+
expect(await verifyCache({ ...params(root), manifestDigest: "other" })).toMatchObject({ ok: false, code: "CLOUD_CACHE_INVALID" });
|
|
69
|
+
expect(await verifyCache({ ...params(root), layoutRevision: "2" })).toMatchObject({ ok: false, code: "CLOUD_CACHE_INVALID" });
|
|
70
|
+
fs.writeFileSync(path.join(root, "k1", "assets", "w.bin"), "tampered");
|
|
71
|
+
expect(await verifyCache(params(root))).toMatchObject({ ok: false, code: "CLOUD_CACHE_INVALID", message: expect.stringMatching(/no longer match/) });
|
|
72
|
+
const added = tmp();
|
|
73
|
+
await publishCache({ ...params(added), stagingDir: stage(added, "k1") });
|
|
74
|
+
fs.writeFileSync(path.join(added, "k1", "assets", "extra.bin"), "x");
|
|
75
|
+
expect(await verifyCache(params(added))).toMatchObject({ ok: false, code: "CLOUD_CACHE_INVALID" });
|
|
76
|
+
const linked = tmp();
|
|
77
|
+
await publishCache({ ...params(linked), stagingDir: stage(linked, "k1") });
|
|
78
|
+
fs.symlinkSync("/etc/passwd", path.join(linked, "k1", "assets", "link"));
|
|
79
|
+
expect(await verifyCache(params(linked))).toMatchObject({ ok: false, message: expect.stringMatching(/symlink/) });
|
|
80
|
+
});
|
|
81
|
+
it("detects a tampered checksum manifest", async () => {
|
|
82
|
+
const root = tmp();
|
|
83
|
+
await publishCache({ ...params(root), stagingDir: stage(root, "k1") });
|
|
84
|
+
const readyPath = path.join(root, "k1", "ready.json");
|
|
85
|
+
const meta = JSON.parse(fs.readFileSync(readyPath, "utf8"));
|
|
86
|
+
meta.files[0].sha256 = "0".repeat(64);
|
|
87
|
+
fs.writeFileSync(readyPath, JSON.stringify(meta));
|
|
88
|
+
expect(await verifyCache(params(root))).toMatchObject({ ok: false, message: expect.stringMatching(/altered/) });
|
|
89
|
+
});
|
|
90
|
+
it("an interrupted preparation (no publish) is never reachable as READY", async () => {
|
|
91
|
+
const root = tmp();
|
|
92
|
+
stage(root, "k1"); // staging exists, publish never ran
|
|
93
|
+
expect(await verifyCache(params(root))).toMatchObject({ ok: false, code: "CLOUD_CACHE_MISSING" });
|
|
94
|
+
expect(fs.existsSync(cacheDirFor(root, "k1"))).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
it("hashTree refuses unsafe entries", async () => {
|
|
97
|
+
const dir = tmp();
|
|
98
|
+
fs.writeFileSync(path.join(dir, "ok"), "x");
|
|
99
|
+
fs.symlinkSync("ok", path.join(dir, "ln"));
|
|
100
|
+
await expect(hashTree(dir)).rejects.toMatchObject({ code: "CLOUD_CACHE_UNSAFE" });
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
describe("acquireVolumeLock", () => {
|
|
104
|
+
it("excludes a second holder, releases cleanly, and reclaims stale locks only when asserted", () => {
|
|
105
|
+
const root = tmp();
|
|
106
|
+
let now = 1_000;
|
|
107
|
+
const take = (extra = {}) => acquireVolumeLock({ root, key: "k", owner: "a", ttlSeconds: 10, now: () => now, ...extra });
|
|
108
|
+
const first = take();
|
|
109
|
+
expect(() => take()).toThrow(expect.objectContaining({ code: "CLOUD_CACHE_LOCKED" }));
|
|
110
|
+
first.release();
|
|
111
|
+
const second = take();
|
|
112
|
+
now += 60_000;
|
|
113
|
+
expect(() => take()).toThrow(expect.objectContaining({ code: "CLOUD_CACHE_LOCKED" })); // stale but not asserted gone
|
|
114
|
+
const reclaimed = take({ reclaimStale: true });
|
|
115
|
+
reclaimed.release();
|
|
116
|
+
second.release();
|
|
117
|
+
});
|
|
118
|
+
it("does not reclaim a live lock even when asked", () => {
|
|
119
|
+
const root = tmp();
|
|
120
|
+
acquireVolumeLock({ root, key: "k", owner: "a", ttlSeconds: 600, now: () => 1_000 });
|
|
121
|
+
expect(() => acquireVolumeLock({ root, key: "k", owner: "b", ttlSeconds: 600, reclaimStale: true, now: () => 2_000 })).toThrow(expect.objectContaining({ code: "CLOUD_CACHE_LOCKED" }));
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createManifestPipeline } from "./pipeline.js";
|
|
3
|
+
import { createWorkerServer } from "./server.js";
|
|
4
|
+
import { startDeadlineWatchdog } from "./watchdog.js";
|
|
5
|
+
/**
|
|
6
|
+
* Worker entry point, run inside the Pod. Configuration comes from the environment the client set
|
|
7
|
+
* at Pod creation; the token is never printed.
|
|
8
|
+
*/
|
|
9
|
+
async function main() {
|
|
10
|
+
const token = process.env.MOLDESK_WORKER_TOKEN;
|
|
11
|
+
if (!token || token.length < 24) {
|
|
12
|
+
console.error("MOLDESK_WORKER_TOKEN is missing or too short.");
|
|
13
|
+
process.exit(2);
|
|
14
|
+
}
|
|
15
|
+
const rootDir = process.env.MOLDESK_WORKER_ROOT ?? "/job";
|
|
16
|
+
const port = Number(process.env.MOLDESK_WORKER_PORT ?? "8000");
|
|
17
|
+
const secrets = [process.env.RUNPOD_API_KEY].filter((value) => Boolean(value));
|
|
18
|
+
const worker = createWorkerServer({ token, rootDir, pipeline: createManifestPipeline(), secrets });
|
|
19
|
+
await worker.listen(port);
|
|
20
|
+
console.error(`moldesk worker listening on ${port}`);
|
|
21
|
+
const deadlineAt = process.env.MOLDESK_WORKER_DEADLINE_AT;
|
|
22
|
+
const podId = process.env.RUNPOD_POD_ID;
|
|
23
|
+
const podKey = process.env.RUNPOD_API_KEY;
|
|
24
|
+
if (deadlineAt && podId && podKey) {
|
|
25
|
+
startDeadlineWatchdog({ deadlineAt, podId, apiKey: podKey, log: (message) => console.error(`watchdog: ${message}`) });
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
console.error("watchdog: not armed (missing deadline, pod id or pod-scoped key)");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
main().catch((error) => {
|
|
32
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
33
|
+
process.exit(1);
|
|
34
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type ModelAdapterDefinition } from "@moldesk/adapters";
|
|
2
|
+
import { type ModelManifestV1 } from "@moldesk/registry";
|
|
3
|
+
import { cacheAsset, getSystemReport, materializeAsset, preparePythonEnvironment, type RuntimeProvider } from "@moldesk/runtime";
|
|
4
|
+
import type { WorkerPipeline } from "./server.js";
|
|
5
|
+
export interface PipelineSteps {
|
|
6
|
+
cacheAsset: typeof cacheAsset;
|
|
7
|
+
materializeAsset: typeof materializeAsset;
|
|
8
|
+
preparePythonEnvironment: typeof preparePythonEnvironment;
|
|
9
|
+
getSystemReport: typeof getSystemReport;
|
|
10
|
+
runtime: RuntimeProvider;
|
|
11
|
+
}
|
|
12
|
+
export interface ManifestPipelineOptions {
|
|
13
|
+
/** Looks up the bundled manifest and adapter; the worker only ever runs what it ships with. */
|
|
14
|
+
resolveModel?: (name: string) => {
|
|
15
|
+
manifest: ModelManifestV1;
|
|
16
|
+
adapter: ModelAdapterDefinition;
|
|
17
|
+
} | undefined;
|
|
18
|
+
steps?: Partial<PipelineSteps>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The real worker pipeline: prepare assets, install the Linux runtime, verify (CUDA included), build
|
|
22
|
+
* the adapter's own command with POSIX paths, and execute. It reuses the same installer, adapter and
|
|
23
|
+
* execution code as local runs and never accepts a command or manifest from the client; the client's
|
|
24
|
+
* job only selects a bundled model and must match that manifest's digest exactly.
|
|
25
|
+
*/
|
|
26
|
+
export declare function createManifestPipeline(options?: ManifestPipelineOptions): WorkerPipeline;
|