@highstate/backend 0.2.0 → 0.2.1
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/index.d.ts +508 -0
- package/dist/index.mjs +832 -0
- package/dist/library/workers/evaluator.mjs +43 -0
- package/dist/library/workers/loader.mjs +11 -0
- package/dist/shared-B55c8XjT.mjs +36 -0
- package/package.json +3 -3
package/dist/index.mjs
ADDED
@@ -0,0 +1,832 @@
|
|
1
|
+
import { BetterLock } from 'better-lock';
|
2
|
+
import { z } from 'zod';
|
3
|
+
import { basename, relative, dirname } from 'node:path';
|
4
|
+
import { findWorkspaceDir, readPackageJSON } from 'pkg-types';
|
5
|
+
import { pickBy, mapValues, debounce } from 'remeda';
|
6
|
+
import { fileURLToPath } from 'node:url';
|
7
|
+
import { EventEmitter, on } from 'node:events';
|
8
|
+
import { Worker } from 'node:worker_threads';
|
9
|
+
import Watcher from 'watcher';
|
10
|
+
import { resolve } from 'import-meta-resolve';
|
11
|
+
import { consola } from 'consola';
|
12
|
+
import { readFile, writeFile, mkdir } from 'fs/promises';
|
13
|
+
import { isUnitModel } from '@highstate/contract';
|
14
|
+
import { addDependency } from 'nypm';
|
15
|
+
|
16
|
+
class LocalPulumiProjectHost {
|
17
|
+
lock = new BetterLock();
|
18
|
+
password = process.env.PULUMI_CONFIG_PASSPHRASE ?? "";
|
19
|
+
hasPassword() {
|
20
|
+
return !!this.password;
|
21
|
+
}
|
22
|
+
setPassword(password) {
|
23
|
+
this.password = password;
|
24
|
+
}
|
25
|
+
async runInline(projectName, stackName, program, fn) {
|
26
|
+
return this.lock.acquire(`${projectName}.${stackName}`, async () => {
|
27
|
+
const { LocalWorkspace } = await import('@pulumi/pulumi/automation');
|
28
|
+
const stack = await LocalWorkspace.createOrSelectStack(
|
29
|
+
{
|
30
|
+
projectName,
|
31
|
+
stackName,
|
32
|
+
program
|
33
|
+
},
|
34
|
+
{
|
35
|
+
projectSettings: {
|
36
|
+
name: projectName,
|
37
|
+
runtime: "nodejs"
|
38
|
+
},
|
39
|
+
envVars: { PULUMI_CONFIG_PASSPHRASE: this.password }
|
40
|
+
}
|
41
|
+
);
|
42
|
+
return await fn(stack);
|
43
|
+
});
|
44
|
+
}
|
45
|
+
async runLocal(projectName, stackName, programPathResolver, fn) {
|
46
|
+
return this.lock.acquire(`${projectName}.${stackName}`, async () => {
|
47
|
+
const { LocalWorkspace } = await import('@pulumi/pulumi/automation');
|
48
|
+
const stack = await LocalWorkspace.createOrSelectStack(
|
49
|
+
{
|
50
|
+
stackName,
|
51
|
+
workDir: await programPathResolver()
|
52
|
+
},
|
53
|
+
{
|
54
|
+
envVars: { PULUMI_CONFIG_PASSPHRASE: this.password }
|
55
|
+
}
|
56
|
+
);
|
57
|
+
return await fn(stack);
|
58
|
+
});
|
59
|
+
}
|
60
|
+
}
|
61
|
+
|
62
|
+
const instanceStatusSchema = z.enum([
|
63
|
+
"not_created",
|
64
|
+
"updating",
|
65
|
+
"destroying",
|
66
|
+
"refreshing",
|
67
|
+
"created",
|
68
|
+
"error"
|
69
|
+
]);
|
70
|
+
const instanceStateSchema = z.object({
|
71
|
+
key: z.string(),
|
72
|
+
parentKey: z.string().optional(),
|
73
|
+
status: instanceStatusSchema,
|
74
|
+
resources: z.number(),
|
75
|
+
totalResources: z.number(),
|
76
|
+
error: z.string().optional(),
|
77
|
+
message: z.string().optional()
|
78
|
+
});
|
79
|
+
const operationType = z.enum(["update", "destroy", "recreate", "refresh"]);
|
80
|
+
const projectOperation = z.object({
|
81
|
+
type: operationType,
|
82
|
+
instanceIds: z.array(z.string())
|
83
|
+
});
|
84
|
+
const projectStateSchema = z.object({
|
85
|
+
currentOperation: projectOperation.optional(),
|
86
|
+
instances: z.record(instanceStateSchema.optional())
|
87
|
+
});
|
88
|
+
const positionSchema = z.object({
|
89
|
+
x: z.number(),
|
90
|
+
y: z.number()
|
91
|
+
});
|
92
|
+
const outputRefSchema = z.object({
|
93
|
+
instanceId: z.string(),
|
94
|
+
output: z.string()
|
95
|
+
});
|
96
|
+
const instanceModelSchema = z.object({
|
97
|
+
id: z.string().nanoid(),
|
98
|
+
type: z.string(),
|
99
|
+
name: z.string(),
|
100
|
+
position: positionSchema,
|
101
|
+
args: z.record(z.any()),
|
102
|
+
inputs: z.record(z.union([outputRefSchema, z.array(outputRefSchema)]))
|
103
|
+
});
|
104
|
+
|
105
|
+
const stringArrayType = z.string().transform((args) => args.split(",").map((arg) => arg.trim()));
|
106
|
+
|
107
|
+
async function resolveMainLocalProject(projectPath, projectName) {
|
108
|
+
if (!projectPath) {
|
109
|
+
projectPath = await findWorkspaceDir();
|
110
|
+
}
|
111
|
+
if (!projectName) {
|
112
|
+
const packageJson = await readPackageJSON(projectPath);
|
113
|
+
projectName = packageJson.name;
|
114
|
+
}
|
115
|
+
if (!projectName) {
|
116
|
+
projectName = basename(projectPath);
|
117
|
+
}
|
118
|
+
return [projectPath, projectName];
|
119
|
+
}
|
120
|
+
|
121
|
+
class SecretAccessDeniedError extends Error {
|
122
|
+
constructor(projectId, key) {
|
123
|
+
super(`Access to the secrets of component "${projectId}.${key}" is denied.`);
|
124
|
+
}
|
125
|
+
}
|
126
|
+
|
127
|
+
const localSecretBackendConfig = z.object({
|
128
|
+
HIGHSTATE_BACKEND_SECRET_PROJECT_PATH: z.string().optional(),
|
129
|
+
HIGHSTATE_BACKEND_SECRET_PROJECT_NAME: z.string().optional()
|
130
|
+
});
|
131
|
+
class LocalSecretBackend {
|
132
|
+
constructor(projectPath, projectName, pulumiProjectHost) {
|
133
|
+
this.projectPath = projectPath;
|
134
|
+
this.projectName = projectName;
|
135
|
+
this.pulumiProjectHost = pulumiProjectHost;
|
136
|
+
}
|
137
|
+
get(projectId, componentKey) {
|
138
|
+
return this.pulumiProjectHost.runLocal(
|
139
|
+
this.projectName,
|
140
|
+
projectId,
|
141
|
+
() => this.projectPath,
|
142
|
+
async (stack) => {
|
143
|
+
const config = await stack.getAllConfig();
|
144
|
+
const componentSecrets = pickBy(config, (_, key) => key.startsWith(`${componentKey}.`));
|
145
|
+
return mapValues(componentSecrets, (value) => value.value);
|
146
|
+
}
|
147
|
+
);
|
148
|
+
}
|
149
|
+
set(projectId, componentKey, values) {
|
150
|
+
return this.pulumiProjectHost.runLocal(
|
151
|
+
this.projectName,
|
152
|
+
projectId,
|
153
|
+
() => this.projectPath,
|
154
|
+
async (stack) => {
|
155
|
+
const componentSecrets = mapValues(values, (value, key) => ({
|
156
|
+
key: `${componentKey}.${key}`,
|
157
|
+
value
|
158
|
+
}));
|
159
|
+
const config = await stack.getAllConfig();
|
160
|
+
Object.assign(config, componentSecrets);
|
161
|
+
await stack.setAllConfig(config);
|
162
|
+
}
|
163
|
+
);
|
164
|
+
}
|
165
|
+
static async create(config, pulumiProjectHost) {
|
166
|
+
const [projectPath, projectName] = await resolveMainLocalProject(
|
167
|
+
config.HIGHSTATE_BACKEND_SECRET_PROJECT_PATH,
|
168
|
+
config.HIGHSTATE_BACKEND_SECRET_PROJECT_NAME
|
169
|
+
);
|
170
|
+
return new LocalSecretBackend(projectPath, projectName, pulumiProjectHost);
|
171
|
+
}
|
172
|
+
}
|
173
|
+
|
174
|
+
const secretBackendConfig = z.object({
|
175
|
+
HIGHSTATE_BACKEND_SECRET_TYPE: z.enum(["local"]).default("local"),
|
176
|
+
...localSecretBackendConfig.shape
|
177
|
+
});
|
178
|
+
function createSecretBackend(config, pulumiProjectHost) {
|
179
|
+
return LocalSecretBackend.create(config, pulumiProjectHost);
|
180
|
+
}
|
181
|
+
|
182
|
+
const localLibraryBackendConfig = z.object({
|
183
|
+
HIGHSTATE_BACKEND_LIBRARY_LOCAL_MODULES: stringArrayType.default("@highstate/library")
|
184
|
+
});
|
185
|
+
class LocalLibraryBackend {
|
186
|
+
constructor(modulePaths) {
|
187
|
+
this.modulePaths = modulePaths;
|
188
|
+
this.watcher = new Watcher(modulePaths, { recursive: true, ignoreInitial: true });
|
189
|
+
this.watcher.on("all", (event, path) => {
|
190
|
+
const prefixPath = modulePaths.find((modulePath) => path.startsWith(modulePath));
|
191
|
+
consola.info("Library event:", event, relative(prefixPath, path));
|
192
|
+
void this.updateLibrary();
|
193
|
+
});
|
194
|
+
consola.info("Library watchers set to:", modulePaths);
|
195
|
+
}
|
196
|
+
watcher;
|
197
|
+
lock = new BetterLock();
|
198
|
+
eventEmitter = new EventEmitter();
|
199
|
+
library = null;
|
200
|
+
async loadLibrary() {
|
201
|
+
if (this.library) {
|
202
|
+
return this.library;
|
203
|
+
}
|
204
|
+
return await this.updateLibrary();
|
205
|
+
}
|
206
|
+
async *watchLibrary(signal) {
|
207
|
+
for await (const [library] of on(this.eventEmitter, "library", { signal })) {
|
208
|
+
yield library;
|
209
|
+
}
|
210
|
+
}
|
211
|
+
async evaluateInstances(instances, instanceIds) {
|
212
|
+
const worker = this.createWorker("library-evaluator", {
|
213
|
+
modulePaths: this.modulePaths,
|
214
|
+
instances,
|
215
|
+
instanceIds
|
216
|
+
});
|
217
|
+
for await (const [registrations] of on(worker, "message")) {
|
218
|
+
return registrations;
|
219
|
+
}
|
220
|
+
throw new Error("Worker ended without sending registrations.");
|
221
|
+
}
|
222
|
+
async updateLibrary() {
|
223
|
+
return await this.lock.acquire(async () => {
|
224
|
+
const worker = this.createWorker("library-loader", { modulePaths: this.modulePaths });
|
225
|
+
for await (const [library] of on(worker, "message")) {
|
226
|
+
this.eventEmitter.emit("library", library);
|
227
|
+
this.library = library;
|
228
|
+
consola.success("Library reloaded");
|
229
|
+
return this.library;
|
230
|
+
}
|
231
|
+
throw new Error("Worker ended without sending library model.");
|
232
|
+
});
|
233
|
+
}
|
234
|
+
createWorker(module, workerData) {
|
235
|
+
const workerPathUrl = resolve(`@highstate/backend/${module}`, import.meta.url);
|
236
|
+
const workerPath = fileURLToPath(workerPathUrl);
|
237
|
+
return new Worker(workerPath, { workerData });
|
238
|
+
}
|
239
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
240
|
+
static async create(config) {
|
241
|
+
const modulePaths = [];
|
242
|
+
for (const module of config.HIGHSTATE_BACKEND_LIBRARY_LOCAL_MODULES) {
|
243
|
+
const url = resolve(module, import.meta.url);
|
244
|
+
let path = fileURLToPath(url);
|
245
|
+
if (basename(path).includes(".")) {
|
246
|
+
path = dirname(path);
|
247
|
+
}
|
248
|
+
modulePaths.push(path);
|
249
|
+
}
|
250
|
+
return new LocalLibraryBackend(modulePaths);
|
251
|
+
}
|
252
|
+
}
|
253
|
+
|
254
|
+
const libraryBackendConfig = z.object({
|
255
|
+
HIGHSTATE_BACKEND_LIBRARY_TYPE: z.enum(["local"]).default("local"),
|
256
|
+
...localLibraryBackendConfig.shape
|
257
|
+
});
|
258
|
+
function createLibraryBackend(config) {
|
259
|
+
switch (config.HIGHSTATE_BACKEND_LIBRARY_TYPE) {
|
260
|
+
case "local": {
|
261
|
+
return LocalLibraryBackend.create(config);
|
262
|
+
}
|
263
|
+
}
|
264
|
+
}
|
265
|
+
|
266
|
+
const localProjectBackendConfig = z.object({
|
267
|
+
HIGHSTATE_BACKEND_PROJECT_BLUEPRINTS_DIR: z.string().default("blueprints")
|
268
|
+
});
|
269
|
+
const blueprintModelSchema = z.object({
|
270
|
+
instances: z.record(instanceModelSchema)
|
271
|
+
});
|
272
|
+
class LocalProjectBackend {
|
273
|
+
constructor(blueprintsDir) {
|
274
|
+
this.blueprintsDir = blueprintsDir;
|
275
|
+
}
|
276
|
+
async getInstances(projectId) {
|
277
|
+
try {
|
278
|
+
const blueprint = await this.loadBlueprint(projectId);
|
279
|
+
return blueprint.instances;
|
280
|
+
} catch (error) {
|
281
|
+
console.error("Failed to read blueprint", error);
|
282
|
+
return {};
|
283
|
+
}
|
284
|
+
}
|
285
|
+
async updateInstance(projectId, instance) {
|
286
|
+
try {
|
287
|
+
const blueprint = await this.loadBlueprint(projectId);
|
288
|
+
blueprint.instances[instance.id] = instance;
|
289
|
+
await this.writeBlueprint(projectId, blueprint);
|
290
|
+
} catch (error) {
|
291
|
+
console.error("Failed to update blueprint instance", error);
|
292
|
+
}
|
293
|
+
}
|
294
|
+
async deleteInstance(projectId, instanceId) {
|
295
|
+
try {
|
296
|
+
const blueprint = await this.loadBlueprint(projectId);
|
297
|
+
delete blueprint.instances[instanceId];
|
298
|
+
await this.writeBlueprint(projectId, blueprint);
|
299
|
+
} catch (error) {
|
300
|
+
console.error("Failed to delete blueprint instance", error);
|
301
|
+
}
|
302
|
+
}
|
303
|
+
getBlueprintPath(projectId) {
|
304
|
+
return `${this.blueprintsDir}/${projectId}.json`;
|
305
|
+
}
|
306
|
+
async loadBlueprint(projectId) {
|
307
|
+
const blueprintPath = this.getBlueprintPath(projectId);
|
308
|
+
try {
|
309
|
+
const content = await readFile(blueprintPath, "utf-8");
|
310
|
+
return blueprintModelSchema.parse(JSON.parse(content));
|
311
|
+
} catch (error) {
|
312
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
313
|
+
return { instances: {} };
|
314
|
+
}
|
315
|
+
throw error;
|
316
|
+
}
|
317
|
+
}
|
318
|
+
async writeBlueprint(projectId, blueprint) {
|
319
|
+
const blueprintPath = this.getBlueprintPath(projectId);
|
320
|
+
const content = JSON.stringify(blueprint, void 0, 2);
|
321
|
+
await writeFile(blueprintPath, content);
|
322
|
+
}
|
323
|
+
static async create(config) {
|
324
|
+
await mkdir(config.HIGHSTATE_BACKEND_PROJECT_BLUEPRINTS_DIR, { recursive: true });
|
325
|
+
return new LocalProjectBackend(config.HIGHSTATE_BACKEND_PROJECT_BLUEPRINTS_DIR);
|
326
|
+
}
|
327
|
+
}
|
328
|
+
|
329
|
+
const projectBackendConfig = z.object({
|
330
|
+
HIGHSTATE_BACKEND_PROJECT_TYPE: z.enum(["local"]).default("local"),
|
331
|
+
...localProjectBackendConfig.shape
|
332
|
+
});
|
333
|
+
function createProjectBackend(config) {
|
334
|
+
return LocalProjectBackend.create(config);
|
335
|
+
}
|
336
|
+
|
337
|
+
const localStateBackendConfig = z.object({
|
338
|
+
HIGHSTATE_BACKEND_STATE_PROJECT_PATH: z.string().optional(),
|
339
|
+
HIGHSTATE_BACKEND_STATE_PROJECT_NAME: z.string().optional()
|
340
|
+
});
|
341
|
+
class LocalStateBackend {
|
342
|
+
constructor(projectName, pulumiProjectHost) {
|
343
|
+
this.projectName = projectName;
|
344
|
+
this.pulumiProjectHost = pulumiProjectHost;
|
345
|
+
}
|
346
|
+
get(projectId) {
|
347
|
+
return this.pulumiProjectHost.runInline(
|
348
|
+
this.projectName,
|
349
|
+
projectId,
|
350
|
+
() => Promise.resolve(),
|
351
|
+
(stack) => this.getAllState(stack)
|
352
|
+
);
|
353
|
+
}
|
354
|
+
set(projectId, state) {
|
355
|
+
return this.pulumiProjectHost.runInline(
|
356
|
+
this.projectName,
|
357
|
+
projectId,
|
358
|
+
() => Promise.resolve(),
|
359
|
+
async (stack) => {
|
360
|
+
await stack.setConfig("state", { value: JSON.stringify(state) });
|
361
|
+
await stack.up();
|
362
|
+
}
|
363
|
+
);
|
364
|
+
}
|
365
|
+
async getAllState(stack) {
|
366
|
+
const history = await stack.history(1);
|
367
|
+
if (!history.length) {
|
368
|
+
return { instances: {} };
|
369
|
+
}
|
370
|
+
if (!history[0].config["state"]) {
|
371
|
+
return { instances: {} };
|
372
|
+
}
|
373
|
+
const jsonValue = JSON.parse(history[0].config["state"].value);
|
374
|
+
return projectStateSchema.parse(jsonValue);
|
375
|
+
}
|
376
|
+
static async create(config, pulumiProjectHost) {
|
377
|
+
const [, projectName] = await resolveMainLocalProject(
|
378
|
+
config.HIGHSTATE_BACKEND_STATE_PROJECT_PATH,
|
379
|
+
config.HIGHSTATE_BACKEND_STATE_PROJECT_NAME
|
380
|
+
);
|
381
|
+
return new LocalStateBackend(projectName, pulumiProjectHost);
|
382
|
+
}
|
383
|
+
}
|
384
|
+
|
385
|
+
const stateBackendConfig = z.object({
|
386
|
+
HIGHSTATE_BACKEND_STATE_TYPE: z.enum(["local"]).default("local"),
|
387
|
+
...localStateBackendConfig.shape
|
388
|
+
});
|
389
|
+
function createStateBackend(config, pulumiProjectHost) {
|
390
|
+
return LocalStateBackend.create(config, pulumiProjectHost);
|
391
|
+
}
|
392
|
+
|
393
|
+
const configSchema = z.object({
|
394
|
+
...libraryBackendConfig.shape,
|
395
|
+
...projectBackendConfig.shape,
|
396
|
+
...secretBackendConfig.shape,
|
397
|
+
...stateBackendConfig.shape
|
398
|
+
});
|
399
|
+
async function loadConfig(env = process.env, useDotenv = true) {
|
400
|
+
if (useDotenv) {
|
401
|
+
await import('dotenv/config');
|
402
|
+
}
|
403
|
+
return configSchema.parse(env);
|
404
|
+
}
|
405
|
+
|
406
|
+
class ProjectOperator {
|
407
|
+
constructor(projectId, state, runnerBackend, stateBackend, libraryBackend, projectBackend, secretBackend) {
|
408
|
+
this.projectId = projectId;
|
409
|
+
this.state = state;
|
410
|
+
this.runnerBackend = runnerBackend;
|
411
|
+
this.stateBackend = stateBackend;
|
412
|
+
this.libraryBackend = libraryBackend;
|
413
|
+
this.projectBackend = projectBackend;
|
414
|
+
this.secretBackend = secretBackend;
|
415
|
+
}
|
416
|
+
unitPromises = /* @__PURE__ */ new Map();
|
417
|
+
eventEmitter = new EventEmitter();
|
418
|
+
debouncedUpdateState = debounce(() => this.stateBackend.set(this.projectId, this.state), {
|
419
|
+
waitMs: 1e3,
|
420
|
+
maxWaitMs: 1e4
|
421
|
+
});
|
422
|
+
abortController = new AbortController();
|
423
|
+
async *watchInstanceStates(signal) {
|
424
|
+
for await (const [state] of on(this.eventEmitter, "state", { signal })) {
|
425
|
+
yield state;
|
426
|
+
}
|
427
|
+
}
|
428
|
+
async *watchCurrentOperation(signal) {
|
429
|
+
for await (const [operation] of on(this.eventEmitter, "operation", { signal })) {
|
430
|
+
yield operation;
|
431
|
+
}
|
432
|
+
}
|
433
|
+
/**
|
434
|
+
* Launches the project operation.
|
435
|
+
* Throws an error if the operation is already running.
|
436
|
+
*
|
437
|
+
* @param operation The operation to launch.
|
438
|
+
*/
|
439
|
+
launch(operation) {
|
440
|
+
if (this.state.currentOperation) {
|
441
|
+
throw new Error("An operation is already running.");
|
442
|
+
}
|
443
|
+
this.abortController = new AbortController();
|
444
|
+
this.updateCurrentOperation(operation);
|
445
|
+
void this.operate().finally(() => {
|
446
|
+
this.updateCurrentOperation(void 0);
|
447
|
+
});
|
448
|
+
}
|
449
|
+
/**
|
450
|
+
* Cancels the current operation.
|
451
|
+
* Does nothing if no operation is running.
|
452
|
+
*/
|
453
|
+
cancel() {
|
454
|
+
if (!this.state.currentOperation) {
|
455
|
+
return;
|
456
|
+
}
|
457
|
+
this.abortController.abort();
|
458
|
+
}
|
459
|
+
async operate() {
|
460
|
+
await this.debouncedUpdateState.flush();
|
461
|
+
const operation = this.state.currentOperation;
|
462
|
+
const instances = await this.projectBackend.getInstances(this.projectId);
|
463
|
+
const library = await this.libraryBackend.loadLibrary();
|
464
|
+
const registrations = await this.libraryBackend.evaluateInstances(
|
465
|
+
instances,
|
466
|
+
operation.instanceIds
|
467
|
+
);
|
468
|
+
const promises = [];
|
469
|
+
for (const registration of Object.values(registrations)) {
|
470
|
+
const promise = this.upUnit(library, registrations, registration);
|
471
|
+
promises.push(promise);
|
472
|
+
}
|
473
|
+
await Promise.all(promises);
|
474
|
+
}
|
475
|
+
async upUnit(library, registrations, registration) {
|
476
|
+
const model = library.components[registration.type];
|
477
|
+
if (!isUnitModel(model)) {
|
478
|
+
throw new Error(`The component "${registration.type}" is not a unit.`);
|
479
|
+
}
|
480
|
+
const key = `${registration.type}.${registration.name}`;
|
481
|
+
const dependencyPromises = [];
|
482
|
+
for (const dependency of registration.dependencies) {
|
483
|
+
const state = this.state.instances[dependency];
|
484
|
+
if (state?.status === "created") {
|
485
|
+
continue;
|
486
|
+
}
|
487
|
+
const dependencyPromise = this.getOrCreateUnitPromise(
|
488
|
+
key,
|
489
|
+
() => this.upUnit(library, registrations, registrations[dependency])
|
490
|
+
);
|
491
|
+
dependencyPromises.push(dependencyPromise);
|
492
|
+
}
|
493
|
+
await Promise.all(dependencyPromises);
|
494
|
+
await this.runnerBackend.update({
|
495
|
+
projectName: registration.type,
|
496
|
+
stackName: registration.name,
|
497
|
+
config: await this.getUnitConfig(model, key, registration),
|
498
|
+
source: model.source
|
499
|
+
});
|
500
|
+
const stream = this.runnerBackend.watch({
|
501
|
+
projectName: registration.type,
|
502
|
+
stackName: registration.name,
|
503
|
+
finalStatuses: ["created", "error"]
|
504
|
+
});
|
505
|
+
await this.watchStateStream(stream);
|
506
|
+
}
|
507
|
+
async getUnitConfig(model, key, registration) {
|
508
|
+
const secrets = await this.secretBackend.get(this.projectId, `${model.type}.${key}`);
|
509
|
+
return {
|
510
|
+
...registration.config,
|
511
|
+
...secrets
|
512
|
+
};
|
513
|
+
}
|
514
|
+
async watchStateStream(stream) {
|
515
|
+
let state;
|
516
|
+
for await (state of stream) {
|
517
|
+
this.updateInstanceState(state);
|
518
|
+
}
|
519
|
+
if (!state) {
|
520
|
+
throw new Error("The stream ended without emitting any state.");
|
521
|
+
}
|
522
|
+
if (state.status === "error") {
|
523
|
+
throw new Error(`The operation on unit "${state.key}" failed.`);
|
524
|
+
}
|
525
|
+
}
|
526
|
+
updateCurrentOperation(operation) {
|
527
|
+
this.state.currentOperation = operation;
|
528
|
+
this.eventEmitter.emit("operation", operation);
|
529
|
+
void this.debouncedUpdateState.call();
|
530
|
+
}
|
531
|
+
updateInstanceState(state) {
|
532
|
+
this.state.instances[state.key] = state;
|
533
|
+
this.eventEmitter.emit("state", state);
|
534
|
+
void this.debouncedUpdateState.call();
|
535
|
+
}
|
536
|
+
getOrCreateUnitPromise(unitKey, fn) {
|
537
|
+
let promise = this.unitPromises.get(unitKey);
|
538
|
+
if (!promise) {
|
539
|
+
promise = fn().then(() => {
|
540
|
+
this.unitPromises.delete(unitKey);
|
541
|
+
});
|
542
|
+
this.unitPromises.set(unitKey, promise);
|
543
|
+
}
|
544
|
+
return promise;
|
545
|
+
}
|
546
|
+
static async create(projectId, runnerBackend, stateBackend, libraryBackend, projectBackend, secretBackend) {
|
547
|
+
const state = await stateBackend.get(projectId);
|
548
|
+
return new ProjectOperator(
|
549
|
+
projectId,
|
550
|
+
state,
|
551
|
+
runnerBackend,
|
552
|
+
stateBackend,
|
553
|
+
libraryBackend,
|
554
|
+
projectBackend,
|
555
|
+
secretBackend
|
556
|
+
);
|
557
|
+
}
|
558
|
+
}
|
559
|
+
|
560
|
+
class OperatorManager {
|
561
|
+
constructor(runnerBackend, stateBackend, libraryBackend, projectBackend, secretBackend) {
|
562
|
+
this.runnerBackend = runnerBackend;
|
563
|
+
this.stateBackend = stateBackend;
|
564
|
+
this.libraryBackend = libraryBackend;
|
565
|
+
this.projectBackend = projectBackend;
|
566
|
+
this.secretBackend = secretBackend;
|
567
|
+
}
|
568
|
+
operators = /* @__PURE__ */ new Map();
|
569
|
+
lock = new BetterLock();
|
570
|
+
async get(projectId) {
|
571
|
+
return await this.lock.acquire(projectId, async () => {
|
572
|
+
let orchestrator = this.operators.get(projectId);
|
573
|
+
if (!orchestrator) {
|
574
|
+
orchestrator = await ProjectOperator.create(
|
575
|
+
projectId,
|
576
|
+
this.runnerBackend,
|
577
|
+
this.stateBackend,
|
578
|
+
this.libraryBackend,
|
579
|
+
this.projectBackend,
|
580
|
+
this.secretBackend
|
581
|
+
);
|
582
|
+
this.operators.set(projectId, orchestrator);
|
583
|
+
}
|
584
|
+
return orchestrator;
|
585
|
+
});
|
586
|
+
}
|
587
|
+
}
|
588
|
+
|
589
|
+
class InvalidInstanceStatusError extends Error {
|
590
|
+
constructor(currentStatus, expectedStatuses) {
|
591
|
+
const expectedString = expectedStatuses.join(", ");
|
592
|
+
super(`The current state is "${currentStatus}", but it should be one of "${expectedString}".`);
|
593
|
+
this.currentStatus = currentStatus;
|
594
|
+
this.expectedStatuses = expectedStatuses;
|
595
|
+
}
|
596
|
+
}
|
597
|
+
|
598
|
+
class LocalRunnerBackend {
|
599
|
+
host = new LocalPulumiProjectHost();
|
600
|
+
events = new EventEmitter();
|
601
|
+
states = /* @__PURE__ */ new Map();
|
602
|
+
async *watch(options) {
|
603
|
+
const stream = on(
|
604
|
+
//
|
605
|
+
this.events,
|
606
|
+
`state:${LocalRunnerBackend.getInstanceKey(options)}`,
|
607
|
+
{ signal: options.signal }
|
608
|
+
);
|
609
|
+
for await (const [state] of stream) {
|
610
|
+
yield state;
|
611
|
+
if (options.finalStatuses.includes(state.status)) {
|
612
|
+
return;
|
613
|
+
}
|
614
|
+
}
|
615
|
+
}
|
616
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
617
|
+
async update(options) {
|
618
|
+
const currentStatus = this.validateStatus(options, [
|
619
|
+
"not_created",
|
620
|
+
"updating",
|
621
|
+
"created",
|
622
|
+
"error"
|
623
|
+
]);
|
624
|
+
if (currentStatus === "updating") {
|
625
|
+
return;
|
626
|
+
}
|
627
|
+
void this.host.runLocal(
|
628
|
+
options.projectName,
|
629
|
+
options.stackName,
|
630
|
+
() => LocalRunnerBackend.resolveProjectPath(options.source),
|
631
|
+
async (stack) => {
|
632
|
+
const configMap = mapValues(options.config, (value) => ({ value }));
|
633
|
+
await stack.setAllConfig(configMap);
|
634
|
+
const state = {
|
635
|
+
key: LocalRunnerBackend.getInstanceKey(options),
|
636
|
+
status: "updating",
|
637
|
+
resources: 0,
|
638
|
+
totalResources: 0
|
639
|
+
};
|
640
|
+
this.updateState(state);
|
641
|
+
try {
|
642
|
+
await stack.up({
|
643
|
+
onEvent: (event) => {
|
644
|
+
if (event.resourcePreEvent) {
|
645
|
+
state.totalResources++;
|
646
|
+
this.updateState(state);
|
647
|
+
return;
|
648
|
+
}
|
649
|
+
if (event.resOutputsEvent) {
|
650
|
+
state.resources++;
|
651
|
+
this.updateState(state);
|
652
|
+
return;
|
653
|
+
}
|
654
|
+
},
|
655
|
+
onOutput: (message) => {
|
656
|
+
state.message = message;
|
657
|
+
this.updateState(state);
|
658
|
+
},
|
659
|
+
signal: options.signal
|
660
|
+
});
|
661
|
+
state.status = "created";
|
662
|
+
this.updateState(state);
|
663
|
+
} catch (e) {
|
664
|
+
state.status = "error";
|
665
|
+
state.error = e instanceof Error ? e.message : String(e);
|
666
|
+
this.updateState(state);
|
667
|
+
}
|
668
|
+
}
|
669
|
+
);
|
670
|
+
}
|
671
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
672
|
+
async destroy(options) {
|
673
|
+
const currentStatus = this.validateStatus(options, [
|
674
|
+
"not_created",
|
675
|
+
"destroying",
|
676
|
+
"created",
|
677
|
+
"error"
|
678
|
+
]);
|
679
|
+
if (currentStatus === "destroying" || currentStatus === "not_created") {
|
680
|
+
return;
|
681
|
+
}
|
682
|
+
void this.host.runLocal(
|
683
|
+
options.projectName,
|
684
|
+
options.stackName,
|
685
|
+
() => LocalRunnerBackend.resolveProjectPath(options.source),
|
686
|
+
async (stack) => {
|
687
|
+
const stackSumary = await stack.workspace.stack();
|
688
|
+
const resources = stackSumary?.resourceCount ?? 0;
|
689
|
+
const state = {
|
690
|
+
key: LocalRunnerBackend.getInstanceKey(options),
|
691
|
+
status: "destroying",
|
692
|
+
resources,
|
693
|
+
totalResources: resources
|
694
|
+
};
|
695
|
+
this.updateState(state);
|
696
|
+
try {
|
697
|
+
await stack.destroy({
|
698
|
+
onEvent: (event) => {
|
699
|
+
if (event.resOutputsEvent) {
|
700
|
+
state.resources--;
|
701
|
+
this.updateState(state);
|
702
|
+
return;
|
703
|
+
}
|
704
|
+
},
|
705
|
+
signal: options.signal
|
706
|
+
});
|
707
|
+
state.status = "not_created";
|
708
|
+
this.updateState(state);
|
709
|
+
} catch (e) {
|
710
|
+
state.status = "error";
|
711
|
+
state.error = e instanceof Error ? e.message : String(e);
|
712
|
+
this.updateState(state);
|
713
|
+
}
|
714
|
+
}
|
715
|
+
);
|
716
|
+
}
|
717
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
718
|
+
async refresh(options) {
|
719
|
+
const currentStatus = this.validateStatus(options, [
|
720
|
+
"not_created",
|
721
|
+
"created",
|
722
|
+
"refreshing",
|
723
|
+
"error"
|
724
|
+
]);
|
725
|
+
if (currentStatus === "refreshing") {
|
726
|
+
return;
|
727
|
+
}
|
728
|
+
void this.host.runLocal(
|
729
|
+
options.projectName,
|
730
|
+
options.stackName,
|
731
|
+
() => LocalRunnerBackend.resolveProjectPath(options.source),
|
732
|
+
async (stack) => {
|
733
|
+
const stackSumary = await stack.workspace.stack();
|
734
|
+
const resources = stackSumary?.resourceCount ?? 0;
|
735
|
+
const state = {
|
736
|
+
key: LocalRunnerBackend.getInstanceKey(options),
|
737
|
+
status: "refreshing",
|
738
|
+
resources,
|
739
|
+
totalResources: resources
|
740
|
+
};
|
741
|
+
this.updateState(state);
|
742
|
+
try {
|
743
|
+
await stack.refresh({
|
744
|
+
onEvent: (event) => {
|
745
|
+
if (event.resOutputsEvent) {
|
746
|
+
if (event.resOutputsEvent.metadata.op === "create") {
|
747
|
+
state.resources++;
|
748
|
+
this.updateState(state);
|
749
|
+
return;
|
750
|
+
}
|
751
|
+
if (event.resOutputsEvent.metadata.op === "delete") {
|
752
|
+
state.totalResources--;
|
753
|
+
this.updateState(state);
|
754
|
+
return;
|
755
|
+
}
|
756
|
+
return;
|
757
|
+
}
|
758
|
+
},
|
759
|
+
signal: options.signal
|
760
|
+
});
|
761
|
+
state.status = "created";
|
762
|
+
this.updateState(state);
|
763
|
+
} catch (e) {
|
764
|
+
state.status = "error";
|
765
|
+
state.error = e instanceof Error ? e.message : String(e);
|
766
|
+
this.updateState(state);
|
767
|
+
}
|
768
|
+
}
|
769
|
+
);
|
770
|
+
}
|
771
|
+
updateState(state) {
|
772
|
+
this.states.set(state.key, state);
|
773
|
+
this.events.emit(`state:${state.key}`, state);
|
774
|
+
}
|
775
|
+
validateStatus(options, expectedStatuses) {
|
776
|
+
const key = LocalRunnerBackend.getInstanceKey(options);
|
777
|
+
const existingState = this.states.get(key);
|
778
|
+
if (!existingState) {
|
779
|
+
return;
|
780
|
+
}
|
781
|
+
if (!expectedStatuses.includes(existingState.status)) {
|
782
|
+
throw new InvalidInstanceStatusError(existingState.status, expectedStatuses);
|
783
|
+
}
|
784
|
+
return existingState.status;
|
785
|
+
}
|
786
|
+
static getInstanceKey(options) {
|
787
|
+
return `${options.projectName}.${options.stackName}`;
|
788
|
+
}
|
789
|
+
static async resolveProjectPath(source) {
|
790
|
+
await addDependency(source.package);
|
791
|
+
const url = resolve(source.package, import.meta.url);
|
792
|
+
const path = fileURLToPath(url);
|
793
|
+
const projectPath = dirname(path);
|
794
|
+
return projectPath;
|
795
|
+
}
|
796
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
797
|
+
static async create() {
|
798
|
+
return new LocalRunnerBackend();
|
799
|
+
}
|
800
|
+
}
|
801
|
+
|
802
|
+
function createRunnerBackend() {
|
803
|
+
return LocalRunnerBackend.create();
|
804
|
+
}
|
805
|
+
|
806
|
+
async function createServices(config) {
|
807
|
+
config ??= await loadConfig();
|
808
|
+
const pulumiProjectHost = new LocalPulumiProjectHost();
|
809
|
+
const libraryBackend = await createLibraryBackend(config);
|
810
|
+
const secretBackend = await createSecretBackend(config, pulumiProjectHost);
|
811
|
+
const runnerBackend = await createRunnerBackend();
|
812
|
+
const projectBackend = await createProjectBackend(config);
|
813
|
+
const stateBackend = await createStateBackend(config, pulumiProjectHost);
|
814
|
+
const operatorManager = new OperatorManager(
|
815
|
+
runnerBackend,
|
816
|
+
stateBackend,
|
817
|
+
libraryBackend,
|
818
|
+
projectBackend,
|
819
|
+
secretBackend
|
820
|
+
);
|
821
|
+
consola.success("Backend services initialized");
|
822
|
+
return {
|
823
|
+
libraryBackend,
|
824
|
+
secretBackend,
|
825
|
+
runnerBackend,
|
826
|
+
projectBackend,
|
827
|
+
stateBackend,
|
828
|
+
operatorManager
|
829
|
+
};
|
830
|
+
}
|
831
|
+
|
832
|
+
export { LocalPulumiProjectHost, OperatorManager, ProjectOperator, SecretAccessDeniedError, createLibraryBackend, createSecretBackend, createServices, createStateBackend, instanceModelSchema, instanceStateSchema, instanceStatusSchema, libraryBackendConfig, loadConfig, operationType, outputRefSchema, positionSchema, projectOperation, projectStateSchema, resolveMainLocalProject, secretBackendConfig, stateBackendConfig, stringArrayType };
|