@moldesk/core 0.0.4 → 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,69 @@
|
|
|
1
|
+
import { CredentialStore, type CloudProvider, type CredentialSource, type MoldeskPaths } from "@moldesk/runtime";
|
|
2
|
+
import { type CloudJobRecord, type CloudResourceRecord, type ProviderResourceView, type ReconciliationReport } from "./state.js";
|
|
3
|
+
/** Builds a provider that resolves its key per request via `getApiKey`. Injected for tests. */
|
|
4
|
+
export type CloudProviderFactory = (provider: string, getApiKey: () => Promise<string>) => CloudProvider;
|
|
5
|
+
export interface CloudAccountDeps {
|
|
6
|
+
paths?: MoldeskPaths;
|
|
7
|
+
store?: CredentialStore;
|
|
8
|
+
createProvider?: CloudProviderFactory;
|
|
9
|
+
}
|
|
10
|
+
export declare const defaultProviderFactory: CloudProviderFactory;
|
|
11
|
+
export interface ConnectResult {
|
|
12
|
+
provider: string;
|
|
13
|
+
/** Where future runs will read the key from. An environment variable overrides the keyring. */
|
|
14
|
+
storedIn: "keyring";
|
|
15
|
+
/** Present when an environment key will still take precedence over the stored one. */
|
|
16
|
+
overriddenBy?: CredentialSource;
|
|
17
|
+
checkedAt: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Validates the candidate key with a minimal read-only call and only then stores it. A key that
|
|
21
|
+
* fails validation is never written anywhere.
|
|
22
|
+
*/
|
|
23
|
+
export declare function connectCloudProvider(provider: string, key: string, deps?: CloudAccountDeps): Promise<ConnectResult>;
|
|
24
|
+
export declare function disconnectCloudProvider(provider: string, deps?: CloudAccountDeps): Promise<{
|
|
25
|
+
removed: boolean;
|
|
26
|
+
}>;
|
|
27
|
+
export interface CloudStatus {
|
|
28
|
+
provider: string;
|
|
29
|
+
credential: {
|
|
30
|
+
source: CredentialSource | "none";
|
|
31
|
+
};
|
|
32
|
+
/** Result of a live read-only check; absent when no credential exists. */
|
|
33
|
+
connection?: {
|
|
34
|
+
ok: true;
|
|
35
|
+
checkedAt: string;
|
|
36
|
+
} | {
|
|
37
|
+
ok: false;
|
|
38
|
+
code: string;
|
|
39
|
+
message: string;
|
|
40
|
+
};
|
|
41
|
+
/** Non-secret credential identifier usable as an account scope. */
|
|
42
|
+
accountScope?: string;
|
|
43
|
+
journal: {
|
|
44
|
+
openJobs: CloudJobRecord[];
|
|
45
|
+
unresolvedResources: CloudResourceRecord[];
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export declare function getCloudStatus(provider: string, deps?: CloudAccountDeps): Promise<CloudStatus>;
|
|
49
|
+
export interface CloudResourceListing {
|
|
50
|
+
provider: string;
|
|
51
|
+
/** Provider pods/volumes the journal owns, with their current provider state. */
|
|
52
|
+
owned: Array<{
|
|
53
|
+
record: CloudResourceRecord;
|
|
54
|
+
kind: "pod" | "volume";
|
|
55
|
+
status?: string;
|
|
56
|
+
costUsdPerHour?: number | null;
|
|
57
|
+
sizeGb?: number;
|
|
58
|
+
}>;
|
|
59
|
+
/** Names that claim this client but have no journal owner. Report only; never auto-deleted. */
|
|
60
|
+
orphaned: ProviderResourceView[];
|
|
61
|
+
/** Deletion is still owed (pending, or past deadline). */
|
|
62
|
+
cleanupOwed: CloudResourceRecord[];
|
|
63
|
+
/** Everything else on the account, counted but not listed. */
|
|
64
|
+
unmanagedCount: number;
|
|
65
|
+
/** Journal changes made while reconciling. */
|
|
66
|
+
reconciliation: Pick<ReconciliationReport, "adopted" | "abandoned" | "vanished">;
|
|
67
|
+
}
|
|
68
|
+
/** Lists what this installation owns on the provider and reconciles the journal. Read-only toward the provider. */
|
|
69
|
+
export declare function listCloudResources(provider: string, deps?: CloudAccountDeps): Promise<CloudResourceListing>;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { CredentialStore, RunpodProvider, credentialFingerprint, getMoldeskPaths, } from "@moldesk/runtime";
|
|
2
|
+
import { MoldeskError } from "../errors.js";
|
|
3
|
+
import { CloudJournal } from "./state.js";
|
|
4
|
+
export const defaultProviderFactory = (provider, getApiKey) => {
|
|
5
|
+
if (provider !== "runpod") {
|
|
6
|
+
throw new MoldeskError({ code: "CREDENTIAL_UNSUPPORTED_PROVIDER", message: `Unknown cloud provider "${provider}".`, remediation: "Use a supported provider, e.g. runpod." });
|
|
7
|
+
}
|
|
8
|
+
return new RunpodProvider({ getApiKey });
|
|
9
|
+
};
|
|
10
|
+
function resolveDeps(deps) {
|
|
11
|
+
return {
|
|
12
|
+
paths: deps.paths ?? getMoldeskPaths(),
|
|
13
|
+
store: deps.store ?? new CredentialStore(),
|
|
14
|
+
createProvider: deps.createProvider ?? defaultProviderFactory,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Validates the candidate key with a minimal read-only call and only then stores it. A key that
|
|
19
|
+
* fails validation is never written anywhere.
|
|
20
|
+
*/
|
|
21
|
+
export async function connectCloudProvider(provider, key, deps = {}) {
|
|
22
|
+
const { store, createProvider } = resolveDeps(deps);
|
|
23
|
+
// Shape-check first so obviously broken pastes never leave the machine.
|
|
24
|
+
await store.validate(provider, key);
|
|
25
|
+
const check = await createProvider(provider, async () => key).validateCredentials();
|
|
26
|
+
await store.set(provider, key);
|
|
27
|
+
const active = await store.resolve(provider);
|
|
28
|
+
return {
|
|
29
|
+
provider,
|
|
30
|
+
storedIn: "keyring",
|
|
31
|
+
...(active && active.source !== "keyring" ? { overriddenBy: active.source } : {}),
|
|
32
|
+
checkedAt: check.checkedAt,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export async function disconnectCloudProvider(provider, deps = {}) {
|
|
36
|
+
const { store } = resolveDeps(deps);
|
|
37
|
+
return { removed: await store.delete(provider) };
|
|
38
|
+
}
|
|
39
|
+
export async function getCloudStatus(provider, deps = {}) {
|
|
40
|
+
const { paths, store, createProvider } = resolveDeps(deps);
|
|
41
|
+
const journal = CloudJournal.open(paths);
|
|
42
|
+
try {
|
|
43
|
+
const resolved = await store.resolve(provider);
|
|
44
|
+
const status = {
|
|
45
|
+
provider,
|
|
46
|
+
credential: { source: resolved?.source ?? "none" },
|
|
47
|
+
journal: {
|
|
48
|
+
openJobs: journal.listOpenJobs(),
|
|
49
|
+
unresolvedResources: journal.listResources({ states: ["create_pending", "active", "delete_pending"] }),
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
if (resolved) {
|
|
53
|
+
status.accountScope = credentialFingerprint(resolved.key, journal.clientId);
|
|
54
|
+
try {
|
|
55
|
+
status.connection = await createProvider(provider, async () => resolved.key).validateCredentials();
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
status.connection = {
|
|
59
|
+
ok: false,
|
|
60
|
+
code: error instanceof MoldeskError ? error.code : "CLOUD_UNAVAILABLE",
|
|
61
|
+
message: error instanceof Error ? error.message : String(error),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return status;
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
journal.close();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function toView(resource, kind) {
|
|
72
|
+
const match = /-(cpu|gpu|volume)$/.exec(resource.name);
|
|
73
|
+
return { providerId: resource.id, name: resource.name, role: match?.[1] ?? (kind === "volume" ? "volume" : "gpu") };
|
|
74
|
+
}
|
|
75
|
+
/** Lists what this installation owns on the provider and reconciles the journal. Read-only toward the provider. */
|
|
76
|
+
export async function listCloudResources(provider, deps = {}) {
|
|
77
|
+
const { paths, store, createProvider } = resolveDeps(deps);
|
|
78
|
+
const credential = await store.require(provider);
|
|
79
|
+
const api = createProvider(provider, async () => credential.key);
|
|
80
|
+
const [pods, volumes] = await Promise.all([api.listPods(), api.listVolumes()]);
|
|
81
|
+
const live = pods.filter((pod) => pod.status !== "TERMINATED");
|
|
82
|
+
const journal = CloudJournal.open(paths);
|
|
83
|
+
try {
|
|
84
|
+
const report = journal.reconcile([...live.map((pod) => toView(pod, "pod")), ...volumes.map((volume) => toView(volume, "volume"))]);
|
|
85
|
+
const podById = new Map(live.map((pod) => [pod.id, pod]));
|
|
86
|
+
const volumeById = new Map(volumes.map((volume) => [volume.id, volume]));
|
|
87
|
+
const owned = [];
|
|
88
|
+
for (const record of journal.listResources({ states: ["active", "delete_pending"] })) {
|
|
89
|
+
if (!record.providerId)
|
|
90
|
+
continue;
|
|
91
|
+
const pod = podById.get(record.providerId);
|
|
92
|
+
const volume = volumeById.get(record.providerId);
|
|
93
|
+
if (pod)
|
|
94
|
+
owned.push({ record, kind: "pod", status: pod.status, costUsdPerHour: pod.costUsdPerHour });
|
|
95
|
+
else if (volume)
|
|
96
|
+
owned.push({ record, kind: "volume", sizeGb: volume.sizeGb });
|
|
97
|
+
}
|
|
98
|
+
const ownedIds = new Set(owned.map((entry) => entry.record.providerId));
|
|
99
|
+
const orphanedIds = new Set(report.orphaned.map((entry) => entry.providerId));
|
|
100
|
+
const unmanagedCount = [...live, ...volumes].filter((resource) => !ownedIds.has(resource.id) && !orphanedIds.has(resource.id)).length;
|
|
101
|
+
return {
|
|
102
|
+
provider,
|
|
103
|
+
owned,
|
|
104
|
+
orphaned: report.orphaned,
|
|
105
|
+
cleanupOwed: report.cleanupOwed,
|
|
106
|
+
unmanagedCount,
|
|
107
|
+
reconciliation: { adopted: report.adopted, abandoned: report.abandoned, vanished: report.vanished },
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
journal.close();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,188 @@
|
|
|
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 { CredentialStore, getMoldeskPaths, } from "@moldesk/runtime";
|
|
6
|
+
import { MoldeskError } from "../errors.js";
|
|
7
|
+
import { connectCloudProvider, disconnectCloudProvider, getCloudStatus, listCloudResources } from "./accounts.js";
|
|
8
|
+
import { CloudJournal } from "./state.js";
|
|
9
|
+
const KEY = "rpa_TESTKEY0123456789";
|
|
10
|
+
const RUN = "2026-10-13T11-55-45-123Z-0a1b2c3d-1111-2222-3333-444455556666";
|
|
11
|
+
const dirs = [];
|
|
12
|
+
function sandbox() {
|
|
13
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), "moldesk cloud accounts "));
|
|
14
|
+
dirs.push(home);
|
|
15
|
+
const data = new Map();
|
|
16
|
+
const backend = {
|
|
17
|
+
async get(service, account) {
|
|
18
|
+
return data.get(`${service}/${account}`) ?? null;
|
|
19
|
+
},
|
|
20
|
+
async set(service, account, secret) {
|
|
21
|
+
data.set(`${service}/${account}`, secret);
|
|
22
|
+
},
|
|
23
|
+
async delete(service, account) {
|
|
24
|
+
return data.delete(`${service}/${account}`);
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
const paths = getMoldeskPaths({ MOLDESK_HOME: home });
|
|
28
|
+
const store = (env = {}) => new CredentialStore({ backend, env });
|
|
29
|
+
return { paths, data, store };
|
|
30
|
+
}
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
for (const dir of dirs.splice(0))
|
|
33
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
34
|
+
});
|
|
35
|
+
function fakeProvider(options = {}) {
|
|
36
|
+
const seenKeys = [];
|
|
37
|
+
const factory = (_name, getApiKey) => ({
|
|
38
|
+
name: "fake",
|
|
39
|
+
async validateCredentials() {
|
|
40
|
+
seenKeys.push(await getApiKey());
|
|
41
|
+
return options.validate ? options.validate() : { ok: true, checkedAt: "2026-10-13T12:00:00.000Z" };
|
|
42
|
+
},
|
|
43
|
+
async getCatalog() {
|
|
44
|
+
throw new Error("unused");
|
|
45
|
+
},
|
|
46
|
+
async createPod() {
|
|
47
|
+
throw new Error("must not create");
|
|
48
|
+
},
|
|
49
|
+
async getPod() {
|
|
50
|
+
return null;
|
|
51
|
+
},
|
|
52
|
+
async listPods() {
|
|
53
|
+
return options.pods ?? [];
|
|
54
|
+
},
|
|
55
|
+
async terminatePod() {
|
|
56
|
+
throw new Error("must not terminate");
|
|
57
|
+
},
|
|
58
|
+
async createVolume() {
|
|
59
|
+
throw new Error("must not create");
|
|
60
|
+
},
|
|
61
|
+
async getVolume() {
|
|
62
|
+
return null;
|
|
63
|
+
},
|
|
64
|
+
async listVolumes() {
|
|
65
|
+
return options.volumes ?? [];
|
|
66
|
+
},
|
|
67
|
+
async deleteVolume() {
|
|
68
|
+
throw new Error("must not delete");
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
return { factory, seenKeys };
|
|
72
|
+
}
|
|
73
|
+
const pod = (id, name, status = "RUNNING") => ({
|
|
74
|
+
id,
|
|
75
|
+
name,
|
|
76
|
+
status,
|
|
77
|
+
dataCenterId: "EU-RO-1",
|
|
78
|
+
costUsdPerHour: 0.57,
|
|
79
|
+
createdAt: null,
|
|
80
|
+
});
|
|
81
|
+
describe("connectCloudProvider", () => {
|
|
82
|
+
it("validates the candidate key before storing it", async () => {
|
|
83
|
+
const box = sandbox();
|
|
84
|
+
const { factory, seenKeys } = fakeProvider();
|
|
85
|
+
const result = await connectCloudProvider("runpod", KEY, { paths: box.paths, store: box.store(), createProvider: factory });
|
|
86
|
+
expect(result).toEqual({ provider: "runpod", storedIn: "keyring", checkedAt: "2026-10-13T12:00:00.000Z" });
|
|
87
|
+
expect(seenKeys).toEqual([KEY]);
|
|
88
|
+
expect([...box.data.values()]).toEqual([KEY]);
|
|
89
|
+
});
|
|
90
|
+
it("stores nothing when the provider rejects the key or the key is malformed", async () => {
|
|
91
|
+
const box = sandbox();
|
|
92
|
+
const rejecting = fakeProvider({
|
|
93
|
+
validate: async () => {
|
|
94
|
+
throw new MoldeskError({ code: "CLOUD_AUTH_FAILED", message: "Runpod rejected the API key.", remediation: "x" });
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
await expect(connectCloudProvider("runpod", KEY, { paths: box.paths, store: box.store(), createProvider: rejecting.factory })).rejects.toMatchObject({ code: "CLOUD_AUTH_FAILED" });
|
|
98
|
+
expect(box.data.size).toBe(0);
|
|
99
|
+
const unused = fakeProvider();
|
|
100
|
+
await expect(connectCloudProvider("runpod", "bad key", { paths: box.paths, store: box.store(), createProvider: unused.factory })).rejects.toMatchObject({ code: "CREDENTIAL_INVALID" });
|
|
101
|
+
expect(unused.seenKeys).toHaveLength(0);
|
|
102
|
+
expect(box.data.size).toBe(0);
|
|
103
|
+
});
|
|
104
|
+
it("warns when an environment key will override the stored one", async () => {
|
|
105
|
+
const box = sandbox();
|
|
106
|
+
const { factory } = fakeProvider();
|
|
107
|
+
const result = await connectCloudProvider("runpod", KEY, { paths: box.paths, store: box.store({ RUNPOD_API_KEY: `${KEY}-env` }), createProvider: factory });
|
|
108
|
+
expect(result.overriddenBy).toBe("env");
|
|
109
|
+
});
|
|
110
|
+
it("disconnect removes only the stored key", async () => {
|
|
111
|
+
const box = sandbox();
|
|
112
|
+
const { factory } = fakeProvider();
|
|
113
|
+
await connectCloudProvider("runpod", KEY, { paths: box.paths, store: box.store(), createProvider: factory });
|
|
114
|
+
expect(await disconnectCloudProvider("runpod", { paths: box.paths, store: box.store() })).toEqual({ removed: true });
|
|
115
|
+
expect(await disconnectCloudProvider("runpod", { paths: box.paths, store: box.store() })).toEqual({ removed: false });
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
describe("getCloudStatus", () => {
|
|
119
|
+
it("reports no credential without calling the provider", async () => {
|
|
120
|
+
const box = sandbox();
|
|
121
|
+
const { factory, seenKeys } = fakeProvider();
|
|
122
|
+
const status = await getCloudStatus("runpod", { paths: box.paths, store: box.store(), createProvider: factory });
|
|
123
|
+
expect(status.credential).toEqual({ source: "none" });
|
|
124
|
+
expect(status.connection).toBeUndefined();
|
|
125
|
+
expect(seenKeys).toHaveLength(0);
|
|
126
|
+
});
|
|
127
|
+
it("reports a live connection, a non-secret scope and open journal work", async () => {
|
|
128
|
+
const box = sandbox();
|
|
129
|
+
const journal = CloudJournal.open(box.paths);
|
|
130
|
+
journal.createJob({ runId: RUN, accountScope: "s", quoteDigest: "q", cachePolicy: "ephemeral", deadlineAt: "2026-10-13T13:00:00.000Z", manifestDigest: "m" });
|
|
131
|
+
journal.recordResourceIntent({ runId: RUN, role: "gpu" });
|
|
132
|
+
journal.close();
|
|
133
|
+
const { factory } = fakeProvider();
|
|
134
|
+
const status = await getCloudStatus("runpod", { paths: box.paths, store: box.store({ RUNPOD_API_KEY: KEY }), createProvider: factory });
|
|
135
|
+
expect(status.credential.source).toBe("env");
|
|
136
|
+
expect(status.connection).toMatchObject({ ok: true });
|
|
137
|
+
expect(status.accountScope).toMatch(/^[0-9a-f]{32}$/);
|
|
138
|
+
expect(JSON.stringify(status)).not.toContain(KEY);
|
|
139
|
+
expect(status.journal.openJobs.map((job) => job.runId)).toEqual([RUN]);
|
|
140
|
+
expect(status.journal.unresolvedResources).toHaveLength(1);
|
|
141
|
+
});
|
|
142
|
+
it("reports an auth failure as a result instead of throwing", async () => {
|
|
143
|
+
const box = sandbox();
|
|
144
|
+
const { factory } = fakeProvider({
|
|
145
|
+
validate: async () => {
|
|
146
|
+
throw new MoldeskError({ code: "CLOUD_AUTH_FAILED", message: "Runpod rejected the API key.", remediation: "x" });
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
const status = await getCloudStatus("runpod", { paths: box.paths, store: box.store({ RUNPOD_API_KEY: KEY }), createProvider: factory });
|
|
150
|
+
expect(status.connection).toEqual({ ok: false, code: "CLOUD_AUTH_FAILED", message: "Runpod rejected the API key." });
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
describe("listCloudResources", () => {
|
|
154
|
+
it("requires a configured credential", async () => {
|
|
155
|
+
const box = sandbox();
|
|
156
|
+
const { factory } = fakeProvider();
|
|
157
|
+
await expect(listCloudResources("runpod", { paths: box.paths, store: box.store(), createProvider: factory })).rejects.toMatchObject({ code: "CREDENTIAL_MISSING" });
|
|
158
|
+
});
|
|
159
|
+
it("separates owned, orphaned and unmanaged resources and reconciles the journal", async () => {
|
|
160
|
+
const box = sandbox();
|
|
161
|
+
const journal = CloudJournal.open(box.paths);
|
|
162
|
+
const prefix = journal.clientId.replace(/-/g, "").slice(0, 8);
|
|
163
|
+
const ownedIntent = journal.recordResourceIntent({ runId: RUN, role: "gpu" });
|
|
164
|
+
journal.recordResourceCreated(ownedIntent.intentId, "podOwned");
|
|
165
|
+
const volumeIntent = journal.recordResourceIntent({ role: "volume" });
|
|
166
|
+
journal.recordResourceCreated(volumeIntent.intentId, "volOwned");
|
|
167
|
+
const abandoned = journal.recordResourceIntent({ runId: RUN, role: "cpu" });
|
|
168
|
+
journal.close();
|
|
169
|
+
const { factory } = fakeProvider({
|
|
170
|
+
pods: [
|
|
171
|
+
pod("podOwned", ownedIntent.name),
|
|
172
|
+
pod("podOrphan", `moldesk-${prefix}-deadbeef-gpu`),
|
|
173
|
+
pod("podMine", "my-own-experiment"),
|
|
174
|
+
pod("podGone", "moldesk-old", "TERMINATED"),
|
|
175
|
+
],
|
|
176
|
+
volumes: [{ id: "volOwned", name: volumeIntent.name, sizeGb: 10, dataCenterId: "EU-RO-1", type: "STANDARD" }],
|
|
177
|
+
});
|
|
178
|
+
const listing = await listCloudResources("runpod", { paths: box.paths, store: box.store({ RUNPOD_API_KEY: KEY }), createProvider: factory });
|
|
179
|
+
expect(listing.owned.map((entry) => [entry.record.providerId, entry.kind, entry.status ?? entry.sizeGb])).toEqual([
|
|
180
|
+
["podOwned", "pod", "RUNNING"],
|
|
181
|
+
["volOwned", "volume", 10],
|
|
182
|
+
]);
|
|
183
|
+
expect(listing.orphaned.map((entry) => entry.providerId)).toEqual(["podOrphan"]);
|
|
184
|
+
expect(listing.unmanagedCount).toBe(1);
|
|
185
|
+
expect(listing.reconciliation.abandoned.map((entry) => entry.intentId)).toEqual([abandoned.intentId]);
|
|
186
|
+
expect(JSON.stringify(listing)).not.toContain(KEY);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|