@highstate/backend 0.4.1 → 0.4.2

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.mjs CHANGED
@@ -1,108 +1,207 @@
1
- import { BetterLock } from 'better-lock';
2
1
  import { z } from 'zod';
3
- import { basename, relative, dirname } from 'node:path';
2
+ import { pickBy, mapKeys, mapValues, funnel } from 'remeda';
3
+ import { BetterLock } from 'better-lock';
4
+ import { consola } from 'consola';
5
+ import { basename, relative, dirname, resolve as resolve$1 } from 'node:path';
4
6
  import { findWorkspaceDir, readPackageJSON } from 'pkg-types';
5
- import { pickBy, mapValues, debounce } from 'remeda';
6
7
  import { fileURLToPath } from 'node:url';
7
8
  import { EventEmitter, on } from 'node:events';
8
9
  import { Worker } from 'node:worker_threads';
9
10
  import Watcher from 'watcher';
10
11
  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';
12
+ import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
13
+ import { getInstanceId, isUnitModel, parseInstanceId } from '@highstate/contract';
14
+ import { b as instanceModelSchema, I as InputHashCalculator, l as createInstanceState, t as terminalFactorySchema, h as instanceRepresentationSchema, f as instanceStatusFieldMapSchema, c as compositeInstanceSchema, s as projectOperationSchema, k as instanceStateSchema, m as applyInstanceStatePatch, o as createInstanceStateFrontendPatch } from './input-hash-C8HEDMjz.mjs';
15
+ import 'crypto-hash';
16
+ import { Readable, PassThrough } from 'node:stream';
17
+ import { tmpdir, homedir } from 'node:os';
18
+ import spawn from 'nano-spawn';
19
+ import { randomUUID } from 'node:crypto';
20
+ import { ensureDependencyInstalled } from 'nypm';
21
+ import { uuidv7 } from 'uuidv7';
22
+ import { pino } from 'pino';
15
23
 
16
- class LocalPulumiProjectHost {
17
- lock = new BetterLock();
18
- password = process.env.PULUMI_CONFIG_PASSPHRASE ?? "";
19
- hasPassword() {
20
- return !!this.password;
24
+ class SecretAccessDeniedError extends Error {
25
+ constructor(projectId, key) {
26
+ super(`Access to the secrets of component "${projectId}.${key}" is denied.`);
27
+ }
28
+ }
29
+
30
+ async function runWithRetryOnError(runner, tryHandleError, maxRetries = 1) {
31
+ let lastError;
32
+ for (let i = 0; i < maxRetries + 1; i++) {
33
+ try {
34
+ return await runner();
35
+ } catch (e) {
36
+ lastError = e;
37
+ if (await tryHandleError(e)) {
38
+ continue;
39
+ }
40
+ throw e;
41
+ }
42
+ }
43
+ throw lastError;
44
+ }
45
+ class AbortError extends Error {
46
+ constructor() {
47
+ super("Operation aborted");
48
+ }
49
+ }
50
+ function isAbortError(error) {
51
+ return error instanceof Error && error.name === "AbortError";
52
+ }
53
+ const stringArrayType = z.string().transform((args) => args.split(",").map((arg) => arg.trim()));
54
+ function errorToString(error) {
55
+ if (error instanceof Error) {
56
+ return error.stack || error.message;
57
+ }
58
+ return JSON.stringify(error);
59
+ }
60
+
61
+ class LocalPulumiHost {
62
+ constructor(logger) {
63
+ this.logger = logger;
21
64
  }
22
- setPassword(password) {
23
- this.password = password;
65
+ lock = new BetterLock();
66
+ async getCurrentUser() {
67
+ const { LocalWorkspace } = await import('@pulumi/pulumi/automation/index.js');
68
+ const workspace = await LocalWorkspace.create({});
69
+ try {
70
+ return await workspace.whoAmI();
71
+ } catch (error) {
72
+ this.logger.error({ msg: "failed to get current user", error });
73
+ return null;
74
+ }
24
75
  }
25
- async runInline(projectName, stackName, program, fn) {
26
- return this.lock.acquire(`${projectName}.${stackName}`, async () => {
27
- const { LocalWorkspace } = await import('@pulumi/pulumi/automation');
76
+ async runInline(projectId, pulumiProjectName, pulumiStackName, program, fn) {
77
+ return this.lock.acquire(`${pulumiProjectName}.${pulumiStackName}`, async () => {
78
+ const { LocalWorkspace } = await import('@pulumi/pulumi/automation/index.js');
28
79
  const stack = await LocalWorkspace.createOrSelectStack(
29
80
  {
30
- projectName,
31
- stackName,
81
+ projectName: pulumiProjectName,
82
+ stackName: pulumiStackName,
32
83
  program
33
84
  },
34
85
  {
35
86
  projectSettings: {
36
- name: projectName,
87
+ name: pulumiProjectName,
37
88
  runtime: "nodejs"
38
89
  },
39
- envVars: { PULUMI_CONFIG_PASSPHRASE: this.password }
90
+ envVars: { PULUMI_CONFIG_PASSPHRASE: this.getPassword(projectId) }
40
91
  }
41
92
  );
42
- return await fn(stack);
93
+ try {
94
+ return await runWithRetryOnError(
95
+ () => fn(stack),
96
+ (error) => LocalPulumiHost.tryUnlockStack(stack, error)
97
+ );
98
+ } catch (e) {
99
+ if (e instanceof Error && e.message.includes("canceled")) {
100
+ throw new AbortError();
101
+ }
102
+ throw e;
103
+ }
43
104
  });
44
105
  }
45
- async runLocal(projectName, stackName, programPathResolver, fn) {
46
- return this.lock.acquire(`${projectName}.${stackName}`, async () => {
47
- const { LocalWorkspace } = await import('@pulumi/pulumi/automation');
106
+ async runEmpty(projectId, pulumiProjectName, pulumiStackName, fn) {
107
+ return this.runInline(projectId, pulumiProjectName, pulumiStackName, async () => {
108
+ }, fn);
109
+ }
110
+ // TODO: extract args to options object
111
+ async runLocal(projectId, pulumiProjectName, pulumiStackName, programPathResolver, fn, stackConfig) {
112
+ return this.lock.acquire(`${pulumiProjectName}.${pulumiStackName}`, async () => {
113
+ const { LocalWorkspace } = await import('@pulumi/pulumi/automation/index.js');
48
114
  const stack = await LocalWorkspace.createOrSelectStack(
49
115
  {
50
- stackName,
116
+ stackName: pulumiStackName,
51
117
  workDir: await programPathResolver()
52
118
  },
53
119
  {
54
- envVars: { PULUMI_CONFIG_PASSPHRASE: this.password }
120
+ projectSettings: {
121
+ name: pulumiProjectName,
122
+ runtime: "nodejs"
123
+ },
124
+ stackSettings: stackConfig ? {
125
+ [pulumiStackName]: {
126
+ config: stackConfig
127
+ }
128
+ } : undefined,
129
+ envVars: { PULUMI_CONFIG_PASSPHRASE: this.getPassword(projectId) }
55
130
  }
56
131
  );
57
- return await fn(stack);
132
+ try {
133
+ return await runWithRetryOnError(
134
+ () => fn(stack),
135
+ (error) => LocalPulumiHost.tryUnlockStack(stack, error)
136
+ );
137
+ } catch (e) {
138
+ if (e instanceof Error && e.message.includes("canceled")) {
139
+ throw new AbortError();
140
+ }
141
+ throw e;
142
+ }
58
143
  });
59
144
  }
145
+ sharedPassword = process.env.PULUMI_CONFIG_PASSPHRASE ?? "";
146
+ passwords = /* @__PURE__ */ new Map();
147
+ hasPassword(projectId) {
148
+ return !!this.sharedPassword || this.passwords.has(projectId);
149
+ }
150
+ setPassword(projectId, password) {
151
+ this.passwords.set(projectId, password);
152
+ }
153
+ getPassword(projectId) {
154
+ return this.sharedPassword || this.passwords.get(projectId) || "";
155
+ }
156
+ static async tryUnlockStack(stack, error) {
157
+ if (error instanceof Error && error.message.includes("the stack is currently locked")) {
158
+ consola.warn("Unlocking stack", stack.name);
159
+ await stack.cancel();
160
+ return true;
161
+ }
162
+ return false;
163
+ }
164
+ static create(logger) {
165
+ return new LocalPulumiHost(logger.child({ service: "LocalPulumiHost" }));
166
+ }
167
+ }
168
+ function valueToString(value) {
169
+ if (typeof value === "string") {
170
+ return value;
171
+ }
172
+ return JSON.stringify(value);
173
+ }
174
+ function stringToValue(value) {
175
+ try {
176
+ return JSON.parse(value);
177
+ } catch {
178
+ return value;
179
+ }
180
+ }
181
+ function updateResourceCount(opType, currentCount) {
182
+ switch (opType) {
183
+ case "same":
184
+ case "create":
185
+ case "update":
186
+ case "replace":
187
+ case "create-replacement":
188
+ case "import":
189
+ case "import-replacement":
190
+ return currentCount + 1;
191
+ case "delete":
192
+ case "delete-replaced":
193
+ case "discard":
194
+ case "discard-replaced":
195
+ case "remove-pending-replace":
196
+ return currentCount - 1;
197
+ case "refresh":
198
+ case "read-replacement":
199
+ case "read":
200
+ return currentCount;
201
+ default:
202
+ throw new Error(`Unknown operation type: ${opType}`);
203
+ }
60
204
  }
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
205
 
107
206
  async function resolveMainLocalProject(projectPath, projectName) {
108
207
  if (!projectPath) {
@@ -118,56 +217,96 @@ async function resolveMainLocalProject(projectPath, projectName) {
118
217
  return [projectPath, projectName];
119
218
  }
120
219
 
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
220
  const localSecretBackendConfig = z.object({
128
221
  HIGHSTATE_BACKEND_SECRET_PROJECT_PATH: z.string().optional(),
129
222
  HIGHSTATE_BACKEND_SECRET_PROJECT_NAME: z.string().optional()
130
223
  });
131
224
  class LocalSecretBackend {
132
- constructor(projectPath, projectName, pulumiProjectHost) {
225
+ constructor(projectPath, projectName, pulumiProjectHost, logger) {
133
226
  this.projectPath = projectPath;
134
227
  this.projectName = projectName;
135
228
  this.pulumiProjectHost = pulumiProjectHost;
229
+ this.logger = logger;
230
+ this.logger.debug({ msg: "initialized", projectPath, projectName });
231
+ }
232
+ isLocked(projectId) {
233
+ return Promise.resolve(!this.pulumiProjectHost.hasPassword(projectId));
234
+ }
235
+ async unlock(projectId, password) {
236
+ this.pulumiProjectHost.setPassword(projectId, password);
237
+ try {
238
+ await this.pulumiProjectHost.runLocal(
239
+ projectId,
240
+ this.projectName,
241
+ projectId,
242
+ () => this.projectPath,
243
+ async (stack) => {
244
+ this.logger.debug("checking password", { projectId });
245
+ await stack.info(true);
246
+ }
247
+ );
248
+ return true;
249
+ } catch (error) {
250
+ if (error instanceof Error) {
251
+ if (error.message.includes("incorrect passphrase")) {
252
+ return false;
253
+ }
254
+ }
255
+ throw error;
256
+ }
136
257
  }
137
- get(projectId, componentKey) {
258
+ get(projectId, instanceId) {
138
259
  return this.pulumiProjectHost.runLocal(
260
+ projectId,
139
261
  this.projectName,
140
262
  projectId,
141
263
  () => this.projectPath,
142
264
  async (stack) => {
265
+ this.logger.debug("getting secrets", { projectId, instanceId });
143
266
  const config = await stack.getAllConfig();
144
- const componentSecrets = pickBy(config, (_, key) => key.startsWith(`${componentKey}.`));
145
- return mapValues(componentSecrets, (value) => value.value);
267
+ const prefix = this.getPrefix(projectId, instanceId);
268
+ const secrets = pickBy(config, (_, key) => key.startsWith(prefix));
269
+ const trimmedSecrets = mapKeys(secrets, (key) => key.slice(prefix.length));
270
+ return mapValues(trimmedSecrets, (value) => stringToValue(value.value));
146
271
  }
147
272
  );
148
273
  }
149
- set(projectId, componentKey, values) {
274
+ set(projectId, instanceId, values) {
150
275
  return this.pulumiProjectHost.runLocal(
276
+ projectId,
151
277
  this.projectName,
152
278
  projectId,
153
279
  () => this.projectPath,
154
280
  async (stack) => {
155
- const componentSecrets = mapValues(values, (value, key) => ({
156
- key: `${componentKey}.${key}`,
157
- value
158
- }));
281
+ this.logger.debug("setting secrets", { projectId, instanceId });
282
+ const componentSecrets = mapValues(
283
+ mapKeys(values, (key) => `${this.getPrefix(projectId, instanceId)}${key}`),
284
+ (value) => ({
285
+ value: valueToString(value),
286
+ secret: true
287
+ })
288
+ );
159
289
  const config = await stack.getAllConfig();
160
290
  Object.assign(config, componentSecrets);
161
291
  await stack.setAllConfig(config);
162
292
  }
163
293
  );
164
294
  }
165
- static async create(config, pulumiProjectHost) {
295
+ getPrefix(projectId, instanceId) {
296
+ instanceId = instanceId.replace(/:/g, ".");
297
+ return `${this.projectName}:${projectId}/${instanceId}/`;
298
+ }
299
+ static async create(config, pulumiProjectHost, logger) {
166
300
  const [projectPath, projectName] = await resolveMainLocalProject(
167
301
  config.HIGHSTATE_BACKEND_SECRET_PROJECT_PATH,
168
302
  config.HIGHSTATE_BACKEND_SECRET_PROJECT_NAME
169
303
  );
170
- return new LocalSecretBackend(projectPath, projectName, pulumiProjectHost);
304
+ return new LocalSecretBackend(
305
+ projectPath,
306
+ projectName,
307
+ pulumiProjectHost,
308
+ logger.child({ backend: "SecretBackend", service: "LocalSecretBackend" })
309
+ );
171
310
  }
172
311
  }
173
312
 
@@ -175,69 +314,106 @@ const secretBackendConfig = z.object({
175
314
  HIGHSTATE_BACKEND_SECRET_TYPE: z.enum(["local"]).default("local"),
176
315
  ...localSecretBackendConfig.shape
177
316
  });
178
- function createSecretBackend(config, pulumiProjectHost) {
179
- return LocalSecretBackend.create(config, pulumiProjectHost);
317
+ function createSecretBackend(config, localPulumiHost, logger) {
318
+ switch (config.HIGHSTATE_BACKEND_SECRET_TYPE) {
319
+ case "local": {
320
+ return LocalSecretBackend.create(config, localPulumiHost, logger);
321
+ }
322
+ }
180
323
  }
181
324
 
182
325
  const localLibraryBackendConfig = z.object({
183
326
  HIGHSTATE_BACKEND_LIBRARY_LOCAL_MODULES: stringArrayType.default("@highstate/library")
184
327
  });
185
328
  class LocalLibraryBackend {
186
- constructor(modulePaths) {
329
+ constructor(modulePaths, logger) {
187
330
  this.modulePaths = modulePaths;
331
+ this.logger = logger;
188
332
  this.watcher = new Watcher(modulePaths, { recursive: true, ignoreInitial: true });
189
333
  this.watcher.on("all", (event, path) => {
190
334
  const prefixPath = modulePaths.find((modulePath) => path.startsWith(modulePath));
191
- consola.info("Library event:", event, relative(prefixPath, path));
192
- void this.updateLibrary();
335
+ this.logger.info({ msg: "library event", event, path: relative(prefixPath, path) });
336
+ void this.lock.acquire(() => this.updateLibrary());
193
337
  });
194
- consola.info("Library watchers set to:", modulePaths);
338
+ this.logger.debug({ msg: "initialized", modulePaths });
195
339
  }
196
340
  watcher;
197
341
  lock = new BetterLock();
198
342
  eventEmitter = new EventEmitter();
199
343
  library = null;
344
+ worker = null;
200
345
  async loadLibrary() {
201
- if (this.library) {
202
- return this.library;
203
- }
204
- return await this.updateLibrary();
346
+ return await this.lock.acquire(async () => {
347
+ const [library] = await this.getLibrary();
348
+ return library;
349
+ });
205
350
  }
206
351
  async *watchLibrary(signal) {
207
352
  for await (const [library] of on(this.eventEmitter, "library", { signal })) {
208
353
  yield library;
209
354
  }
210
355
  }
211
- async evaluateInstances(instances, instanceIds) {
212
- const worker = this.createWorker("library-evaluator", {
213
- modulePaths: this.modulePaths,
214
- instances,
215
- instanceIds
356
+ async evaluateCompositeInstances(allInstances, instanceIds) {
357
+ return await this.lock.acquire(async () => {
358
+ this.logger.info("evaluating composite instances", { instanceIds });
359
+ const [, worker] = await this.getLibrary();
360
+ worker.postMessage({ type: "evaluate-composite-instances", allInstances, instanceIds });
361
+ this.logger.debug("evaluation request sent");
362
+ return this.getInstances(worker);
216
363
  });
217
- for await (const [registrations] of on(worker, "message")) {
218
- return registrations;
219
- }
220
- throw new Error("Worker ended without sending registrations.");
221
364
  }
222
- async updateLibrary() {
365
+ async evaluateModules(modulePaths) {
223
366
  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.");
367
+ this.logger.info({ msg: "evaluating modules", modulePaths });
368
+ const [, worker] = await this.getLibrary();
369
+ worker.postMessage({ type: "evaluate-modules", modulePaths });
370
+ this.logger.debug("evaluation request sent");
371
+ return this.getInstances(worker);
232
372
  });
233
373
  }
234
- createWorker(module, workerData) {
235
- const workerPathUrl = resolve(`@highstate/backend/${module}`, import.meta.url);
374
+ async getInstances(worker) {
375
+ for await (const [event] of on(worker, "message")) {
376
+ const eventData = event;
377
+ if (eventData.type === "error") {
378
+ throw new Error(`Worker error: ${eventData.error}`);
379
+ }
380
+ if (eventData.type !== "instances") {
381
+ throw new Error(`Unexpected message type '${eventData.type}', expected 'instances'`);
382
+ }
383
+ return eventData.instances;
384
+ }
385
+ throw new Error("Worker ended without sending instances");
386
+ }
387
+ async getLibrary() {
388
+ if (this.library && this.worker) {
389
+ return [this.library, this.worker];
390
+ }
391
+ return await this.updateLibrary();
392
+ }
393
+ async updateLibrary() {
394
+ this.logger.info("creating library worker");
395
+ this.worker = this.createWorker({ modulePaths: this.modulePaths, logLevel: "silent" });
396
+ for await (const [event] of on(this.worker, "message")) {
397
+ const eventData = event;
398
+ if (eventData.type === "error") {
399
+ throw new Error(`Worker error: ${eventData.error}`);
400
+ }
401
+ if (eventData.type !== "library") {
402
+ throw new Error(`Unexpected message type '${eventData.type}', expected 'library'`);
403
+ }
404
+ this.eventEmitter.emit("library", eventData.library);
405
+ this.library = eventData.library;
406
+ this.logger.info("library reloaded");
407
+ return [this.library, this.worker];
408
+ }
409
+ throw new Error("Worker ended without sending library model");
410
+ }
411
+ createWorker(workerData) {
412
+ const workerPathUrl = resolve(`@highstate/backend/library-worker`, import.meta.url);
236
413
  const workerPath = fileURLToPath(workerPathUrl);
237
414
  return new Worker(workerPath, { workerData });
238
415
  }
239
- // eslint-disable-next-line @typescript-eslint/require-await
240
- static async create(config) {
416
+ static create(config, logger) {
241
417
  const modulePaths = [];
242
418
  for (const module of config.HIGHSTATE_BACKEND_LIBRARY_LOCAL_MODULES) {
243
419
  const url = resolve(module, import.meta.url);
@@ -247,7 +423,10 @@ class LocalLibraryBackend {
247
423
  }
248
424
  modulePaths.push(path);
249
425
  }
250
- return new LocalLibraryBackend(modulePaths);
426
+ return new LocalLibraryBackend(
427
+ modulePaths,
428
+ logger.child({ backend: "LibraryBackend", service: "LocalLibraryBackend" })
429
+ );
251
430
  }
252
431
  }
253
432
 
@@ -255,59 +434,148 @@ const libraryBackendConfig = z.object({
255
434
  HIGHSTATE_BACKEND_LIBRARY_TYPE: z.enum(["local"]).default("local"),
256
435
  ...localLibraryBackendConfig.shape
257
436
  });
258
- function createLibraryBackend(config) {
437
+ function createLibraryBackend(config, logger) {
259
438
  switch (config.HIGHSTATE_BACKEND_LIBRARY_TYPE) {
260
439
  case "local": {
261
- return LocalLibraryBackend.create(config);
440
+ return LocalLibraryBackend.create(config, logger);
262
441
  }
263
442
  }
264
443
  }
265
444
 
266
445
  const localProjectBackendConfig = z.object({
267
- HIGHSTATE_BACKEND_PROJECT_BLUEPRINTS_DIR: z.string().default("blueprints")
446
+ HIGHSTATE_BACKEND_PROJECT_PROJECTS_DIR: z.string().optional()
268
447
  });
269
- const blueprintModelSchema = z.object({
448
+ const projectModelSchema = z.object({
270
449
  instances: z.record(instanceModelSchema)
271
450
  });
272
451
  class LocalProjectBackend {
273
- constructor(blueprintsDir) {
274
- this.blueprintsDir = blueprintsDir;
452
+ constructor(projectsDir) {
453
+ this.projectsDir = projectsDir;
454
+ }
455
+ async getProjectNames() {
456
+ try {
457
+ const files = await readdir(this.projectsDir);
458
+ return files.filter((file) => file.endsWith(".json")).map((file) => file.replace(/\.json$/, ""));
459
+ } catch (error) {
460
+ throw new Error("Failed to get project names", { cause: error });
461
+ }
275
462
  }
276
463
  async getInstances(projectId) {
277
464
  try {
278
- const blueprint = await this.loadBlueprint(projectId);
279
- return blueprint.instances;
465
+ const project = await this.loadProject(projectId);
466
+ return Object.values(project.instances);
467
+ } catch (error) {
468
+ throw new Error("Failed to get project instances", { cause: error });
469
+ }
470
+ }
471
+ async createInstance(projectId, instance) {
472
+ try {
473
+ return await this.withProject(projectId, (project) => {
474
+ if (project.instances[instance.id]) {
475
+ throw new Error(`Instance ${instance.id} already exists`);
476
+ }
477
+ project.instances[instance.id] = instance;
478
+ return instance;
479
+ });
480
+ } catch (error) {
481
+ throw new Error("Failed to create project instance", { cause: error });
482
+ }
483
+ }
484
+ async moveInstance(projectId, instanceId, position) {
485
+ try {
486
+ return await this.withInstance(projectId, instanceId, (instance) => {
487
+ instance.position = position;
488
+ return instance;
489
+ });
490
+ } catch (error) {
491
+ throw new Error("Failed to move project instance", { cause: error });
492
+ }
493
+ }
494
+ async updateInstanceArgs(projectId, instanceId, args) {
495
+ try {
496
+ return await this.withInstance(projectId, instanceId, (instance) => {
497
+ instance.args = args;
498
+ return instance;
499
+ });
280
500
  } catch (error) {
281
- console.error("Failed to read blueprint", error);
282
- return {};
501
+ throw new Error("Failed to update project instance arguments", { cause: error });
283
502
  }
284
503
  }
285
- async updateInstance(projectId, instance) {
504
+ async updateInstanceInputs(projectId, instanceId, inputs) {
286
505
  try {
287
- const blueprint = await this.loadBlueprint(projectId);
288
- blueprint.instances[instance.id] = instance;
289
- await this.writeBlueprint(projectId, blueprint);
506
+ return await this.withInstance(projectId, instanceId, (instance) => {
507
+ instance.inputs = inputs;
508
+ return instance;
509
+ });
290
510
  } catch (error) {
291
- console.error("Failed to update blueprint instance", error);
511
+ throw new Error("Failed to update project instance inputs", { cause: error });
292
512
  }
293
513
  }
294
514
  async deleteInstance(projectId, instanceId) {
295
515
  try {
296
- const blueprint = await this.loadBlueprint(projectId);
297
- delete blueprint.instances[instanceId];
298
- await this.writeBlueprint(projectId, blueprint);
516
+ await this.withProject(projectId, (project) => {
517
+ if (!project.instances[instanceId]) {
518
+ throw new Error(`Instance ${instanceId} not found`);
519
+ }
520
+ delete project.instances[instanceId];
521
+ for (const otherInstance of Object.values(project.instances)) {
522
+ for (const [inputKey, input] of Object.entries(otherInstance.inputs)) {
523
+ if (Array.isArray(input)) {
524
+ otherInstance.inputs[inputKey] = input.filter(
525
+ (inputItem) => inputItem.instanceId !== instanceId
526
+ );
527
+ } else if (input.instanceId === instanceId) {
528
+ delete otherInstance.inputs[inputKey];
529
+ }
530
+ }
531
+ }
532
+ });
533
+ } catch (error) {
534
+ throw new Error("Failed to delete project instance", { cause: error });
535
+ }
536
+ }
537
+ async renameInstance(projectId, instanceId, newName) {
538
+ try {
539
+ return await this.withProject(projectId, (project) => {
540
+ const instance = project.instances[instanceId];
541
+ if (!instance) {
542
+ throw new Error(`Instance ${instanceId} not found`);
543
+ }
544
+ const newInstanceId = getInstanceId(instance.type, newName);
545
+ if (project.instances[newInstanceId]) {
546
+ throw new Error(`Instance ${newInstanceId} already exists`);
547
+ }
548
+ delete project.instances[instanceId];
549
+ instance.id = newInstanceId;
550
+ instance.name = newName;
551
+ project.instances[newInstanceId] = instance;
552
+ for (const otherInstance of Object.values(project.instances)) {
553
+ for (const input of Object.values(otherInstance.inputs)) {
554
+ if (Array.isArray(input)) {
555
+ for (const inputItem of input) {
556
+ if (inputItem.instanceId === instanceId) {
557
+ inputItem.instanceId = instance.id;
558
+ }
559
+ }
560
+ } else if (input.instanceId === instanceId) {
561
+ input.instanceId = instance.id;
562
+ }
563
+ }
564
+ }
565
+ return instance;
566
+ });
299
567
  } catch (error) {
300
- console.error("Failed to delete blueprint instance", error);
568
+ throw new Error("Failed to rename project instance", { cause: error });
301
569
  }
302
570
  }
303
- getBlueprintPath(projectId) {
304
- return `${this.blueprintsDir}/${projectId}.json`;
571
+ getProjectPath(projectId) {
572
+ return `${this.projectsDir}/${projectId}.json`;
305
573
  }
306
- async loadBlueprint(projectId) {
307
- const blueprintPath = this.getBlueprintPath(projectId);
574
+ async loadProject(projectId) {
575
+ const projectPath = this.getProjectPath(projectId);
308
576
  try {
309
- const content = await readFile(blueprintPath, "utf-8");
310
- return blueprintModelSchema.parse(JSON.parse(content));
577
+ const content = await readFile(projectPath, "utf-8");
578
+ return projectModelSchema.parse(JSON.parse(content));
311
579
  } catch (error) {
312
580
  if (error instanceof Error && "code" in error && error.code === "ENOENT") {
313
581
  return { instances: {} };
@@ -315,14 +583,34 @@ class LocalProjectBackend {
315
583
  throw error;
316
584
  }
317
585
  }
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);
586
+ async writeProject(projectId, project) {
587
+ const projectPath = this.getProjectPath(projectId);
588
+ const content = JSON.stringify(project, undefined, 2);
589
+ await writeFile(projectPath, content);
590
+ }
591
+ async withInstance(projectId, instanceId, callback) {
592
+ return await this.withProject(projectId, (project) => {
593
+ const instance = project.instances[instanceId];
594
+ if (!instance) {
595
+ throw new Error(`Instance ${instanceId} not found`);
596
+ }
597
+ return callback(instance);
598
+ });
599
+ }
600
+ async withProject(projectId, callback) {
601
+ const project = await this.loadProject(projectId);
602
+ const result = callback(project);
603
+ await this.writeProject(projectId, project);
604
+ return result;
322
605
  }
323
606
  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);
607
+ let projectsPath = config.HIGHSTATE_BACKEND_PROJECT_PROJECTS_DIR;
608
+ if (!projectsPath) {
609
+ const [mainProjectPath] = await resolveMainLocalProject();
610
+ projectsPath = resolve$1(mainProjectPath, "projects");
611
+ }
612
+ await mkdir(projectsPath, { recursive: true });
613
+ return new LocalProjectBackend(projectsPath);
326
614
  }
327
615
  }
328
616
 
@@ -334,260 +622,327 @@ function createProjectBackend(config) {
334
622
  return LocalProjectBackend.create(config);
335
623
  }
336
624
 
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
- );
625
+ class ProjectLock {
626
+ constructor(lock, projectId) {
627
+ this.lock = lock;
628
+ this.projectId = projectId;
364
629
  }
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);
630
+ lockInstance(instanceId, fn) {
631
+ return this.lock.acquire(`${this.projectId}/${instanceId}`, fn);
375
632
  }
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
633
+ lockInstances(instanceIds, fn) {
634
+ return this.lock.acquire(
635
+ instanceIds.map((id) => `${this.projectId}/${id}`),
636
+ fn
380
637
  );
381
- return new LocalStateBackend(projectName, pulumiProjectHost);
382
638
  }
383
639
  }
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');
640
+ class ProjectLockManager {
641
+ lock = new BetterLock();
642
+ getLock(projectId) {
643
+ return new ProjectLock(this.lock, projectId);
402
644
  }
403
- return configSchema.parse(env);
404
645
  }
405
646
 
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;
647
+ class ProjectManager {
648
+ constructor(projectBackend, stateBackend, operationManager, library, logger) {
413
649
  this.projectBackend = projectBackend;
414
- this.secretBackend = secretBackend;
650
+ this.stateBackend = stateBackend;
651
+ this.operationManager = operationManager;
652
+ this.library = library;
653
+ this.logger = logger;
415
654
  }
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;
655
+ async getCompositeInstance(projectId, instanceId) {
656
+ const instances = await this.projectBackend.getInstances(projectId);
657
+ const instance = instances.find((instance2) => instance2.id === instanceId);
658
+ const instanceMap = /* @__PURE__ */ new Map();
659
+ for (const instance2 of instances) {
660
+ instanceMap.set(instance2.id, instance2);
426
661
  }
427
- }
428
- async *watchCurrentOperation(signal) {
429
- for await (const [operation] of on(this.eventEmitter, "operation", { signal })) {
430
- yield operation;
662
+ if (!instance) {
663
+ throw new Error(`instance not found: ${instanceId}`);
431
664
  }
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;
665
+ const calculator = new InputHashCalculator(instanceMap);
666
+ const [compositeInstance, actualInputHash] = await Promise.all([
667
+ this.stateBackend.getCompositeInstance(projectId, instanceId),
668
+ calculator.calculate(instance)
669
+ ]);
670
+ if (compositeInstance && compositeInstance.inputHash === actualInputHash) {
671
+ return compositeInstance;
456
672
  }
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
673
+ this.logger.info("re-evaluating instance since input hash has changed", {
674
+ projectId,
675
+ instanceId
499
676
  });
500
- const stream = this.runnerBackend.watch({
501
- projectName: registration.type,
502
- stackName: registration.name,
503
- finalStatuses: ["created", "error"]
677
+ const instancePromise = this.waitForCompositeInstance(
678
+ projectId,
679
+ instanceId,
680
+ actualInputHash,
681
+ instanceMap
682
+ );
683
+ await this.operationManager.launch({
684
+ type: "evaluate",
685
+ projectId,
686
+ instanceIds: [instanceId]
504
687
  });
505
- await this.watchStateStream(stream);
688
+ return await instancePromise;
506
689
  }
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
- };
690
+ async createInstance(projectId, instance) {
691
+ const createdInstance = await this.projectBackend.createInstance(projectId, instance);
692
+ await this.updateInstanceChildren(projectId, createdInstance);
693
+ return createdInstance;
513
694
  }
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
- }
695
+ async updateInstanceArgs(projectId, instanceId, args) {
696
+ const instance = await this.projectBackend.updateInstanceArgs(projectId, instanceId, args);
697
+ await this.updateInstanceChildren(projectId, instance);
698
+ return instance;
699
+ }
700
+ async updateInstanceInputs(projectId, instanceId, inputs) {
701
+ const instance = await this.projectBackend.updateInstanceInputs(projectId, instanceId, inputs);
702
+ await this.updateInstanceChildren(projectId, instance);
703
+ return instance;
525
704
  }
526
- updateCurrentOperation(operation) {
527
- this.state.currentOperation = operation;
528
- this.eventEmitter.emit("operation", operation);
529
- void this.debouncedUpdateState.call();
705
+ async moveInstance(projectId, instanceId, position) {
706
+ const instance = await this.projectBackend.moveInstance(projectId, instanceId, position);
707
+ await this.updateInstanceChildren(projectId, instance);
708
+ return instance;
530
709
  }
531
- updateInstanceState(state) {
532
- this.state.instances[state.key] = state;
533
- this.eventEmitter.emit("state", state);
534
- void this.debouncedUpdateState.call();
710
+ async renameInstance(projectId, instanceId, newName) {
711
+ const instance = await this.projectBackend.renameInstance(projectId, instanceId, newName);
712
+ await this.updateInstanceChildren(projectId, instance);
713
+ return instance;
714
+ }
715
+ async deleteInstance(projectId, instanceId) {
716
+ await Promise.all([
717
+ this.projectBackend.deleteInstance(projectId, instanceId),
718
+ this.stateBackend.clearCompositeInstances(projectId, [instanceId])
719
+ ]);
535
720
  }
536
- getOrCreateUnitPromise(unitKey, fn) {
537
- let promise = this.unitPromises.get(unitKey);
538
- if (!promise) {
539
- promise = fn().then(() => {
540
- this.unitPromises.delete(unitKey);
721
+ async updateInstanceChildren(projectId, instance) {
722
+ const library = await this.library.loadLibrary();
723
+ const component = library.components[instance.type];
724
+ if (!component) {
725
+ return;
726
+ }
727
+ if (isUnitModel(component)) {
728
+ return;
729
+ }
730
+ const instances = await this.projectBackend.getInstances(projectId);
731
+ const instanceMap = /* @__PURE__ */ new Map();
732
+ for (const instance2 of instances) {
733
+ instanceMap.set(instance2.id, instance2);
734
+ }
735
+ const inputHashCalculator = new InputHashCalculator(instanceMap);
736
+ const inputHash = await this.stateBackend.getCompositeInstanceInputHash(projectId, instance.id);
737
+ const expectedInputHash = await inputHashCalculator.calculate(instance);
738
+ if (inputHash !== expectedInputHash) {
739
+ this.logger.info("re-evaluating instance since input hash has changed", {
740
+ projectId,
741
+ instanceId: instance.id
742
+ });
743
+ await this.operationManager.launch({
744
+ type: "evaluate",
745
+ projectId,
746
+ instanceIds: [instance.id]
541
747
  });
542
- this.unitPromises.set(unitKey, promise);
543
748
  }
544
- return promise;
545
749
  }
546
- static async create(projectId, runnerBackend, stateBackend, libraryBackend, projectBackend, secretBackend) {
547
- const state = await stateBackend.get(projectId);
548
- return new ProjectOperator(
750
+ async waitForCompositeInstance(projectId, instanceId, expectedInputHash, instanceMap) {
751
+ for await (const instance of this.operationManager.watchCompositeInstance(
549
752
  projectId,
550
- state,
551
- runnerBackend,
552
- stateBackend,
553
- libraryBackend,
753
+ instanceId
754
+ )) {
755
+ const calculator = new InputHashCalculator(instanceMap);
756
+ const actualInputHash = await calculator.calculate(instance.instance);
757
+ if (actualInputHash !== expectedInputHash) {
758
+ throw new Error("Composite instance input hash changed while waiting for evaluation");
759
+ }
760
+ return instance;
761
+ }
762
+ throw new Error("Composite instance stream ended without receiving the expected instance");
763
+ }
764
+ static create(projectBackend, stateBackend, operationManager, library, logger) {
765
+ return new ProjectManager(
554
766
  projectBackend,
555
- secretBackend
767
+ stateBackend,
768
+ operationManager,
769
+ library,
770
+ logger.child({ service: "ProjectManager" })
556
771
  );
557
772
  }
558
773
  }
559
774
 
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
- }
775
+ const runScript = `set -e -o pipefail
776
+ read -r data
588
777
 
589
- class InvalidInstanceStatusError extends Error {
590
- constructor(currentStatus, expectedStatuses) {
778
+ # Extract env and files as key-value pairs, and command as an array
779
+ envKeys=($(jq -r '.env | keys[]' <<<"$data"))
780
+ filesKeys=($(jq -r '.files | keys[]' <<<"$data"))
781
+ commandArr=($(jq -r '.command[]' <<<"$data"))
782
+
783
+ # Set environment variables
784
+ for key in "\${envKeys[@]}"; do
785
+ value=$(jq -r ".env[\\"$key\\"]" <<<"$data")
786
+ export "$key=$value"
787
+ done
788
+
789
+ # Create files
790
+ for key in "\${filesKeys[@]}"; do
791
+ content=$(jq -r ".files[\\"$key\\"]" <<<"$data")
792
+ echo -n "$content" > "$key"
793
+ done
794
+
795
+ # Execute the command, keeping stdin/stdout open
796
+ exec "\${commandArr[@]}"`;
797
+
798
+ const dockerTerminalBackendConfig = z.object({
799
+ HIGHSTATE_BACKEND_TERMINAL_DOCKER_BINARY: z.string().default("docker"),
800
+ HIGHSTATE_BACKEND_TERMINAL_DOCKER_HOST: z.string().optional()
801
+ });
802
+ class DockerTerminalBackend {
803
+ constructor(binary, host, logger) {
804
+ this.binary = binary;
805
+ this.host = host;
806
+ this.logger = logger;
807
+ }
808
+ async run({ factory, stdin, stdout, signal }) {
809
+ const hsTempDir = resolve$1(tmpdir(), "highstate");
810
+ await mkdir(hsTempDir, { recursive: true });
811
+ const runScriptPath = resolve$1(hsTempDir, "run.sh");
812
+ await writeFile(runScriptPath, runScript, { mode: 493 });
813
+ const args = [
814
+ "run",
815
+ "-i",
816
+ "-v",
817
+ `${runScriptPath}:/run.sh:ro`,
818
+ factory.image,
819
+ "/bin/bash",
820
+ "/run.sh"
821
+ ];
822
+ const initData = {
823
+ command: factory.command,
824
+ cwd: factory.cwd,
825
+ env: factory.env ?? {},
826
+ files: factory.files ?? {}
827
+ };
828
+ const initDataStream = Readable.from(JSON.stringify(initData) + "\n");
829
+ const process = spawn(this.binary, args, {
830
+ env: {
831
+ DOCKER_HOST: this.host
832
+ },
833
+ signal
834
+ });
835
+ const childProcess = await process.nodeChildProcess;
836
+ initDataStream.pipe(childProcess.stdin, { end: false });
837
+ initDataStream.on("end", () => stdin.pipe(childProcess.stdin));
838
+ childProcess.stdout.pipe(stdout);
839
+ childProcess.stderr.pipe(stdout);
840
+ this.logger.info("process started", { pid: childProcess.pid });
841
+ await process;
842
+ }
843
+ static create(config, logger) {
844
+ return new DockerTerminalBackend(
845
+ config.HIGHSTATE_BACKEND_TERMINAL_DOCKER_BINARY,
846
+ config.HIGHSTATE_BACKEND_TERMINAL_DOCKER_HOST,
847
+ logger.child({ backend: "TerminalBackend", service: "DockerTerminalBackend" })
848
+ );
849
+ }
850
+ }
851
+
852
+ const terminalBackendConfig = z.object({
853
+ HIGHSTATE_BACKEND_TERMINAL_TYPE: z.enum(["docker"]).default("docker"),
854
+ ...dockerTerminalBackendConfig.shape
855
+ });
856
+ function createTerminalBackend(config, logger) {
857
+ switch (config.HIGHSTATE_BACKEND_TERMINAL_TYPE) {
858
+ case "docker": {
859
+ return DockerTerminalBackend.create(config, logger);
860
+ }
861
+ }
862
+ }
863
+
864
+ const notAttachedTerminalLifetime = 30 * 1e3;
865
+ class TerminalManager {
866
+ constructor(terminalBackend, logger) {
867
+ this.terminalBackend = terminalBackend;
868
+ this.logger = logger;
869
+ }
870
+ terminals = /* @__PURE__ */ new Map();
871
+ create(factory) {
872
+ const terminal = {
873
+ id: randomUUID(),
874
+ abortController: new AbortController(),
875
+ attached: false,
876
+ stdin: new PassThrough(),
877
+ stdout: new PassThrough(),
878
+ history: []
879
+ };
880
+ terminal.stdout.on("data", (data) => {
881
+ terminal.history.push(String(data));
882
+ });
883
+ this.terminals.set(terminal.id, terminal);
884
+ void this.terminalBackend.run({
885
+ factory,
886
+ stdin: terminal.stdin,
887
+ stdout: terminal.stdout,
888
+ signal: terminal.abortController.signal
889
+ }).catch((error) => {
890
+ this.logger.error("terminal failed", { id: terminal.id, error });
891
+ console.error(error);
892
+ }).finally(() => {
893
+ this.logger.info("terminal finished", { id: terminal.id });
894
+ this.terminals.delete(terminal.id);
895
+ });
896
+ setTimeout(() => this.closeTerminalIfNotAttached(terminal), notAttachedTerminalLifetime);
897
+ this.logger.info("terminal created", { id: terminal.id });
898
+ return terminal;
899
+ }
900
+ close(id) {
901
+ const terminal = this.terminals.get(id);
902
+ if (!terminal) {
903
+ return;
904
+ }
905
+ terminal.abortController.abort();
906
+ this.terminals.delete(id);
907
+ this.logger.info("terminal closed", { id });
908
+ }
909
+ attach(id, stdin, stdout, signal) {
910
+ const terminal = this.terminals.get(id);
911
+ if (!terminal) {
912
+ throw new Error("Terminal not found");
913
+ }
914
+ terminal.attached = true;
915
+ for (const line of terminal.history) {
916
+ stdout.write(line + "\n");
917
+ }
918
+ this.logger.info("history replayed", { id });
919
+ stdin.pipe(terminal.stdin);
920
+ terminal.stdout.pipe(stdout);
921
+ this.logger.info("terminal attached", { id });
922
+ signal.addEventListener("abort", () => {
923
+ terminal.attached = false;
924
+ this.logger.info("terminal detached", { id });
925
+ });
926
+ }
927
+ closeTerminalIfNotAttached(terminal) {
928
+ if (!this.terminals.has(terminal.id)) {
929
+ return;
930
+ }
931
+ if (!terminal.attached) {
932
+ this.logger.info("terminal not attached for too long, closing", { id: terminal.id });
933
+ terminal.abortController.abort();
934
+ this.terminals.delete(terminal.id);
935
+ return;
936
+ }
937
+ setTimeout(() => this.closeTerminalIfNotAttached(terminal), notAttachedTerminalLifetime);
938
+ }
939
+ static create(terminalBackend, logger) {
940
+ return new TerminalManager(terminalBackend, logger.child({ service: "TerminalManager" }));
941
+ }
942
+ }
943
+
944
+ class InvalidInstanceStatusError extends Error {
945
+ constructor(currentStatus, expectedStatuses) {
591
946
  const expectedString = expectedStatuses.join(", ");
592
947
  super(`The current state is "${currentStatus}", but it should be one of "${expectedString}".`);
593
948
  this.currentStatus = currentStatus;
@@ -595,27 +950,94 @@ class InvalidInstanceStatusError extends Error {
595
950
  }
596
951
  }
597
952
 
953
+ const localRunnerBackendConfig = z.object({
954
+ HIGHSTATE_BACKEND_RUNNER_LOCAL_SKIP_SOURCE_CHECK: z.boolean({ coerce: true }).default(false),
955
+ HIGHSTATE_BACKEND_RUNNER_LOCAL_SKIP_STATE_CHECK: z.boolean({ coerce: true }).default(false),
956
+ HIGHSTATE_BACKEND_RUNNER_LOCAL_PRINT_OUTPUT: z.boolean({ coerce: true }).default(true),
957
+ HIGHSTATE_BACKEND_RUNNER_LOCAL_SOURCE_BASE_PATH: z.string().optional()
958
+ });
598
959
  class LocalRunnerBackend {
599
- host = new LocalPulumiProjectHost();
960
+ constructor(skipSourceCheck, skipStateCheck, printOutput, sourceBasePath, pulumiProjectHost) {
961
+ this.skipSourceCheck = skipSourceCheck;
962
+ this.skipStateCheck = skipStateCheck;
963
+ this.printOutput = printOutput;
964
+ this.sourceBasePath = sourceBasePath;
965
+ this.pulumiProjectHost = pulumiProjectHost;
966
+ }
600
967
  events = new EventEmitter();
601
- states = /* @__PURE__ */ new Map();
602
968
  async *watch(options) {
603
969
  const stream = on(
604
970
  //
605
971
  this.events,
606
- `state:${LocalRunnerBackend.getInstanceKey(options)}`,
972
+ `state:${LocalRunnerBackend.getInstanceId(options)}`,
607
973
  { signal: options.signal }
608
974
  );
609
- for await (const [state] of stream) {
610
- yield state;
611
- if (options.finalStatuses.includes(state.status)) {
975
+ for await (const [statePatch] of stream) {
976
+ yield statePatch;
977
+ if (statePatch.status && options.finalStatuses?.includes(statePatch.status)) {
612
978
  return;
613
979
  }
614
980
  }
615
981
  }
616
- // eslint-disable-next-line @typescript-eslint/require-await
982
+ getState(options) {
983
+ return this.pulumiProjectHost.runEmpty(
984
+ options.projectId,
985
+ options.instanceType,
986
+ options.instanceName,
987
+ async (stack) => {
988
+ const info = await stack.info();
989
+ const instanceId = getInstanceId(options.instanceType, options.instanceName);
990
+ if (!info) {
991
+ return createInstanceState(instanceId);
992
+ }
993
+ if (info.result === "failed") {
994
+ return createInstanceState(instanceId, "error");
995
+ }
996
+ if (info.result !== "succeeded") {
997
+ return createInstanceState(instanceId, "unknown");
998
+ }
999
+ const summary = await stack.workspace.stack();
1000
+ const resourceCount = summary?.resourceCount;
1001
+ if (!resourceCount) {
1002
+ return createInstanceState(instanceId, "not_created");
1003
+ }
1004
+ return createInstanceState(instanceId, "created", {
1005
+ currentResourceCount: resourceCount,
1006
+ totalResourceCount: resourceCount
1007
+ });
1008
+ }
1009
+ );
1010
+ }
1011
+ getTerminalFactory(options) {
1012
+ return this.pulumiProjectHost.runEmpty(
1013
+ options.projectId,
1014
+ options.instanceType,
1015
+ options.instanceName,
1016
+ async (stack) => {
1017
+ const outputs = await stack.outputs();
1018
+ if (!outputs["$terminal"]) {
1019
+ return null;
1020
+ }
1021
+ return terminalFactorySchema.parse(outputs["$terminal"].value);
1022
+ }
1023
+ );
1024
+ }
1025
+ getRepresentationContent(options) {
1026
+ return this.pulumiProjectHost.runEmpty(
1027
+ options.projectId,
1028
+ options.instanceType,
1029
+ options.instanceName,
1030
+ async (stack) => {
1031
+ const outputs = await stack.outputs();
1032
+ if (!outputs["$representation"]) {
1033
+ return null;
1034
+ }
1035
+ return instanceRepresentationSchema.parse(outputs["$representation"].value).content;
1036
+ }
1037
+ );
1038
+ }
617
1039
  async update(options) {
618
- const currentStatus = this.validateStatus(options, [
1040
+ const currentStatus = await this.validateStatus(options, [
619
1041
  "not_created",
620
1042
  "updating",
621
1043
  "created",
@@ -624,53 +1046,79 @@ class LocalRunnerBackend {
624
1046
  if (currentStatus === "updating") {
625
1047
  return;
626
1048
  }
627
- void this.host.runLocal(
628
- options.projectName,
629
- options.stackName,
630
- () => LocalRunnerBackend.resolveProjectPath(options.source),
1049
+ const configMap = {
1050
+ ...mapValues(options.config, (value) => ({ value })),
1051
+ ...mapValues(options.secrets, (value) => ({ value, secret: true }))
1052
+ };
1053
+ void this.pulumiProjectHost.runLocal(
1054
+ options.projectId,
1055
+ options.instanceType,
1056
+ options.instanceName,
1057
+ () => this.resolveProjectPath(options.source),
631
1058
  async (stack) => {
632
- const configMap = mapValues(options.config, (value) => ({ value }));
633
1059
  await stack.setAllConfig(configMap);
634
- const state = {
635
- key: LocalRunnerBackend.getInstanceKey(options),
1060
+ const instanceId = LocalRunnerBackend.getInstanceId(options);
1061
+ this.updateState({
1062
+ id: instanceId,
636
1063
  status: "updating",
637
- resources: 0,
638
- totalResources: 0
639
- };
640
- this.updateState(state);
1064
+ currentResourceCount: 0,
1065
+ totalResourceCount: 0
1066
+ });
1067
+ let currentResourceCount = 0;
1068
+ let totalResourceCount = 0;
641
1069
  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
- }
1070
+ await runWithRetryOnError(
1071
+ async () => {
1072
+ await stack.up({
1073
+ color: "always",
1074
+ onEvent: (event) => {
1075
+ if (event.resourcePreEvent) {
1076
+ totalResourceCount = updateResourceCount(
1077
+ event.resourcePreEvent.metadata.op,
1078
+ totalResourceCount
1079
+ );
1080
+ this.updateState({ id: instanceId, totalResourceCount });
1081
+ return;
1082
+ }
1083
+ if (event.resOutputsEvent) {
1084
+ currentResourceCount = updateResourceCount(
1085
+ event.resOutputsEvent.metadata.op,
1086
+ currentResourceCount
1087
+ );
1088
+ this.updateState({ id: instanceId, currentResourceCount });
1089
+ return;
1090
+ }
1091
+ },
1092
+ onOutput: (message) => {
1093
+ this.updateState({ id: instanceId, message });
1094
+ if (this.printOutput) {
1095
+ console.log(message);
1096
+ }
1097
+ },
1098
+ signal: options.signal
1099
+ });
1100
+ const extraOutputs = await this.getExtraOutputsStatePatch(stack);
1101
+ this.updateState({
1102
+ id: instanceId,
1103
+ status: "created",
1104
+ ...extraOutputs
1105
+ });
654
1106
  },
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);
1107
+ (error) => LocalPulumiHost.tryUnlockStack(stack, error)
1108
+ );
663
1109
  } catch (e) {
664
- state.status = "error";
665
- state.error = e instanceof Error ? e.message : String(e);
666
- this.updateState(state);
1110
+ this.updateState({
1111
+ id: instanceId,
1112
+ status: "error",
1113
+ error: e instanceof Error ? e.message : String(e)
1114
+ });
667
1115
  }
668
- }
1116
+ },
1117
+ configMap
669
1118
  );
670
1119
  }
671
- // eslint-disable-next-line @typescript-eslint/require-await
672
1120
  async destroy(options) {
673
- const currentStatus = this.validateStatus(options, [
1121
+ const currentStatus = await this.validateStatus(options, [
674
1122
  "not_created",
675
1123
  "destroying",
676
1124
  "created",
@@ -679,44 +1127,64 @@ class LocalRunnerBackend {
679
1127
  if (currentStatus === "destroying" || currentStatus === "not_created") {
680
1128
  return;
681
1129
  }
682
- void this.host.runLocal(
683
- options.projectName,
684
- options.stackName,
685
- () => LocalRunnerBackend.resolveProjectPath(options.source),
1130
+ void this.pulumiProjectHost.runEmpty(
1131
+ options.projectId,
1132
+ options.instanceType,
1133
+ options.instanceName,
686
1134
  async (stack) => {
687
- const stackSumary = await stack.workspace.stack();
688
- const resources = stackSumary?.resourceCount ?? 0;
689
- const state = {
690
- key: LocalRunnerBackend.getInstanceKey(options),
1135
+ const summary = await stack.workspace.stack();
1136
+ let currentResourceCount = summary?.resourceCount ?? 0;
1137
+ const instanceId = LocalRunnerBackend.getInstanceId(options);
1138
+ this.updateState({
1139
+ id: instanceId,
691
1140
  status: "destroying",
692
- resources,
693
- totalResources: resources
694
- };
695
- this.updateState(state);
1141
+ currentResourceCount,
1142
+ totalResourceCount: currentResourceCount
1143
+ });
696
1144
  try {
697
- await stack.destroy({
698
- onEvent: (event) => {
699
- if (event.resOutputsEvent) {
700
- state.resources--;
701
- this.updateState(state);
702
- return;
703
- }
1145
+ await runWithRetryOnError(
1146
+ async () => {
1147
+ await stack.destroy({
1148
+ color: "always",
1149
+ onEvent: (event) => {
1150
+ if (event.resOutputsEvent) {
1151
+ currentResourceCount = updateResourceCount(
1152
+ event.resOutputsEvent.metadata.op,
1153
+ currentResourceCount
1154
+ );
1155
+ this.updateState({ id: instanceId, currentResourceCount });
1156
+ return;
1157
+ }
1158
+ },
1159
+ onOutput: (message) => {
1160
+ this.updateState({ id: instanceId, message });
1161
+ if (this.printOutput) {
1162
+ console.log(message);
1163
+ }
1164
+ },
1165
+ signal: options.signal
1166
+ });
1167
+ const extraOutputs = await this.getExtraOutputsStatePatch(stack);
1168
+ this.updateState({
1169
+ id: instanceId,
1170
+ status: "not_created",
1171
+ ...extraOutputs
1172
+ });
704
1173
  },
705
- signal: options.signal
706
- });
707
- state.status = "not_created";
708
- this.updateState(state);
1174
+ (error) => LocalPulumiHost.tryUnlockStack(stack, error)
1175
+ );
709
1176
  } catch (e) {
710
- state.status = "error";
711
- state.error = e instanceof Error ? e.message : String(e);
712
- this.updateState(state);
1177
+ this.updateState({
1178
+ id: instanceId,
1179
+ status: "error",
1180
+ error: e instanceof Error ? e.message : String(e)
1181
+ });
713
1182
  }
714
1183
  }
715
1184
  );
716
1185
  }
717
- // eslint-disable-next-line @typescript-eslint/require-await
718
1186
  async refresh(options) {
719
- const currentStatus = this.validateStatus(options, [
1187
+ const currentStatus = await this.validateStatus(options, [
720
1188
  "not_created",
721
1189
  "created",
722
1190
  "refreshing",
@@ -725,56 +1193,103 @@ class LocalRunnerBackend {
725
1193
  if (currentStatus === "refreshing") {
726
1194
  return;
727
1195
  }
728
- void this.host.runLocal(
729
- options.projectName,
730
- options.stackName,
731
- () => LocalRunnerBackend.resolveProjectPath(options.source),
1196
+ void this.pulumiProjectHost.runEmpty(
1197
+ options.projectId,
1198
+ options.instanceType,
1199
+ options.instanceName,
732
1200
  async (stack) => {
733
- const stackSumary = await stack.workspace.stack();
734
- const resources = stackSumary?.resourceCount ?? 0;
735
- const state = {
736
- key: LocalRunnerBackend.getInstanceKey(options),
1201
+ const summary = await stack.workspace.stack();
1202
+ let currentResourceCount = summary?.resourceCount ?? 0;
1203
+ const instanceId = LocalRunnerBackend.getInstanceId(options);
1204
+ this.updateState({
1205
+ id: instanceId,
737
1206
  status: "refreshing",
738
- resources,
739
- totalResources: resources
740
- };
741
- this.updateState(state);
1207
+ currentResourceCount,
1208
+ totalResourceCount: currentResourceCount
1209
+ });
742
1210
  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
- }
1211
+ await runWithRetryOnError(
1212
+ async () => {
1213
+ await stack.refresh({
1214
+ color: "always",
1215
+ onEvent: (event) => {
1216
+ if (event.resOutputsEvent) {
1217
+ currentResourceCount = updateResourceCount(
1218
+ event.resOutputsEvent.metadata.op,
1219
+ currentResourceCount
1220
+ );
1221
+ this.updateState({ id: instanceId, currentResourceCount });
1222
+ return;
1223
+ }
1224
+ },
1225
+ onOutput: (message) => {
1226
+ this.updateState({ id: instanceId, message });
1227
+ if (this.printOutput) {
1228
+ console.log(message);
1229
+ }
1230
+ },
1231
+ signal: options.signal
1232
+ });
1233
+ const extraOutputs = await this.getExtraOutputsStatePatch(stack);
1234
+ this.updateState({
1235
+ id: instanceId,
1236
+ status: "created",
1237
+ ...extraOutputs
1238
+ });
758
1239
  },
759
- signal: options.signal
760
- });
761
- state.status = "created";
762
- this.updateState(state);
1240
+ (error) => LocalPulumiHost.tryUnlockStack(stack, error)
1241
+ );
763
1242
  } catch (e) {
764
- state.status = "error";
765
- state.error = e instanceof Error ? e.message : String(e);
766
- this.updateState(state);
1243
+ this.updateState({
1244
+ id: instanceId,
1245
+ status: "error",
1246
+ error: e instanceof Error ? e.message : String(e)
1247
+ });
767
1248
  }
768
1249
  }
769
1250
  );
770
1251
  }
771
- updateState(state) {
772
- this.states.set(state.key, state);
773
- this.events.emit(`state:${state.key}`, state);
1252
+ async getExtraOutputsStatePatch(stack) {
1253
+ const outputs = await stack.outputs();
1254
+ const patch = {};
1255
+ if (outputs["$status"]) {
1256
+ patch.statusFields = instanceStatusFieldMapSchema.parse(outputs["$status"].value);
1257
+ } else {
1258
+ patch.statusFields = null;
1259
+ }
1260
+ if (outputs["$representation"]) {
1261
+ const instanceRepresentation = instanceRepresentationSchema.parse(
1262
+ outputs["$representation"].value
1263
+ );
1264
+ patch.representationMeta = {
1265
+ contentType: instanceRepresentation.contentType,
1266
+ fileName: instanceRepresentation.fileName,
1267
+ showQRCode: instanceRepresentation.showQRCode
1268
+ };
1269
+ } else {
1270
+ patch.representationMeta = null;
1271
+ }
1272
+ if (outputs["$terminal"]) {
1273
+ terminalFactorySchema.parse(outputs["$terminal"].value);
1274
+ patch.hasTerminal = true;
1275
+ } else {
1276
+ patch.hasTerminal = false;
1277
+ }
1278
+ if (outputs["$secrets"]) {
1279
+ patch.secrets = z.record(z.string()).parse(outputs["$secrets"].value);
1280
+ } else {
1281
+ patch.secrets = null;
1282
+ }
1283
+ return patch;
774
1284
  }
775
- validateStatus(options, expectedStatuses) {
776
- const key = LocalRunnerBackend.getInstanceKey(options);
777
- const existingState = this.states.get(key);
1285
+ updateState(patch) {
1286
+ this.events.emit(`state:${patch.id}`, patch);
1287
+ }
1288
+ async validateStatus(options, expectedStatuses) {
1289
+ if (this.skipStateCheck) {
1290
+ return;
1291
+ }
1292
+ const existingState = await this.getState(options);
778
1293
  if (!existingState) {
779
1294
  return;
780
1295
  }
@@ -783,50 +1298,1177 @@ class LocalRunnerBackend {
783
1298
  }
784
1299
  return existingState.status;
785
1300
  }
786
- static getInstanceKey(options) {
787
- return `${options.projectName}.${options.stackName}`;
1301
+ static getInstanceId(options) {
1302
+ return getInstanceId(options.instanceType, options.instanceName);
788
1303
  }
789
- static async resolveProjectPath(source) {
790
- await addDependency(source.package);
791
- const url = resolve(source.package, import.meta.url);
1304
+ async resolveProjectPath(source) {
1305
+ if (source.type === "local") {
1306
+ if (!source.path) {
1307
+ throw new Error("Source path is required for local units");
1308
+ }
1309
+ return resolve(this.sourceBasePath, source.type);
1310
+ }
1311
+ if (!this.skipSourceCheck) {
1312
+ const packageName = source.version ? `${source.package}@${source.version}` : source.package;
1313
+ await ensureDependencyInstalled(packageName);
1314
+ }
1315
+ if (!source.path) {
1316
+ throw new Error("Source path is required for npm units");
1317
+ }
1318
+ const fullPath = `${source.package}/${source.path}`;
1319
+ const url = resolve(fullPath, import.meta.url);
792
1320
  const path = fileURLToPath(url);
793
1321
  const projectPath = dirname(path);
794
1322
  return projectPath;
795
1323
  }
796
- // eslint-disable-next-line @typescript-eslint/require-await
797
- static async create() {
798
- return new LocalRunnerBackend();
1324
+ static async create(config, pulumiProjectHost) {
1325
+ let sourceBasePath = config.HIGHSTATE_BACKEND_RUNNER_LOCAL_SOURCE_BASE_PATH;
1326
+ if (!sourceBasePath) {
1327
+ const [projectPath] = await resolveMainLocalProject();
1328
+ sourceBasePath = resolve(projectPath, "units");
1329
+ }
1330
+ return new LocalRunnerBackend(
1331
+ config.HIGHSTATE_BACKEND_RUNNER_LOCAL_SKIP_SOURCE_CHECK,
1332
+ config.HIGHSTATE_BACKEND_RUNNER_LOCAL_SKIP_STATE_CHECK,
1333
+ config.HIGHSTATE_BACKEND_RUNNER_LOCAL_PRINT_OUTPUT,
1334
+ sourceBasePath,
1335
+ pulumiProjectHost
1336
+ );
1337
+ }
1338
+ }
1339
+
1340
+ const runnerBackendConfig = z.object({
1341
+ HIGHSTATE_BACKEND_RUNNER_TYPE: z.enum(["local"]).default("local"),
1342
+ ...localRunnerBackendConfig.shape
1343
+ });
1344
+ function createRunnerBackend(config, pulumiProjectHost) {
1345
+ switch (config.HIGHSTATE_BACKEND_RUNNER_TYPE) {
1346
+ case "local": {
1347
+ return LocalRunnerBackend.create(config, pulumiProjectHost);
1348
+ }
1349
+ }
1350
+ }
1351
+
1352
+ const evaluatedCompositeInstanceSchema = compositeInstanceSchema.extend({
1353
+ inputHash: z.string()
1354
+ });
1355
+
1356
+ const localStateBackendConfig = z.object({
1357
+ HIGHSTATE_BACKEND_STATE_LOCAL_DIR: z.string().optional()
1358
+ });
1359
+ class LocalStateBackend {
1360
+ constructor(db, logger) {
1361
+ this.db = db;
1362
+ this.logger = logger;
1363
+ this.logger.debug({ msg: "initialized", dbLocation: db.location });
1364
+ }
1365
+ async getActiveOperations() {
1366
+ const sublevel = this.getJsonSublevel("activeOperations");
1367
+ const result = [];
1368
+ for await (const operation of sublevel.values()) {
1369
+ result.push(projectOperationSchema.parse(operation));
1370
+ }
1371
+ return result;
1372
+ }
1373
+ async getOperations(projectId, beforeOperationId) {
1374
+ const sublevel = this.getJsonSublevel(`projects/${projectId}/operations`);
1375
+ const result = [];
1376
+ const pageSize = 10;
1377
+ for await (const operation of sublevel.values({ lt: beforeOperationId, reverse: true })) {
1378
+ result.push(projectOperationSchema.parse(operation));
1379
+ if (result.length >= pageSize) {
1380
+ break;
1381
+ }
1382
+ }
1383
+ return result;
1384
+ }
1385
+ async getInstanceStates(projectId) {
1386
+ const sublevel = this.getJsonSublevel(`projects/${projectId}/instances`);
1387
+ const result = [];
1388
+ for await (const state of sublevel.values()) {
1389
+ result.push(instanceStateSchema.parse(state));
1390
+ }
1391
+ return result;
1392
+ }
1393
+ async getInstanceState(projectId, instanceID) {
1394
+ const sublevel = this.getJsonSublevel(`projects/${projectId}/instances`);
1395
+ const state = await sublevel.get(instanceID);
1396
+ if (!state) {
1397
+ return undefined;
1398
+ }
1399
+ return instanceStateSchema.parse(state);
1400
+ }
1401
+ async getAffectedInstanceStates(operationId) {
1402
+ const sublevel = this.getJsonSublevel(`operations/${operationId}/instances`);
1403
+ const result = [];
1404
+ for await (const state of sublevel.values()) {
1405
+ result.push(instanceStateSchema.parse(state));
1406
+ }
1407
+ return result;
1408
+ }
1409
+ async getInstanceLogs(operationId, instanceId) {
1410
+ const sublevel = this.db.sublevel(`operations/${operationId}/instanceLogs/${instanceId}`);
1411
+ const result = [];
1412
+ for await (const line of sublevel.values()) {
1413
+ result.push(line);
1414
+ }
1415
+ return result;
1416
+ }
1417
+ async putOperation(operation) {
1418
+ const sublevel = this.getJsonSublevel(`projects/${operation.projectId}/operations`);
1419
+ const activeOperationsSublevel = this.getJsonSublevel("activeOperations");
1420
+ if (operation.status !== "completed" && operation.status !== "failed") {
1421
+ await this.db.batch([
1422
+ { type: "put", key: operation.id, value: operation, sublevel },
1423
+ { type: "put", key: operation.id, value: operation, sublevel: activeOperationsSublevel }
1424
+ ]);
1425
+ } else {
1426
+ await this.db.batch([
1427
+ { type: "put", key: operation.id, value: operation, sublevel },
1428
+ { type: "del", key: operation.id, sublevel: activeOperationsSublevel }
1429
+ ]);
1430
+ }
1431
+ }
1432
+ async putAffectedInstanceStates(projectId, operationId, states) {
1433
+ const sublevel = this.getJsonSublevel(`operations/${operationId}/instances`);
1434
+ const projectSublevel = this.getJsonSublevel(`projects/${projectId}/instances`);
1435
+ await this.db.batch(
1436
+ // put the states to both the operation and project sublevels
1437
+ // denormalization is cool
1438
+ // this separation is also necessary because the instance states can be updated without operations
1439
+ states.flatMap((state) => [
1440
+ {
1441
+ type: "put",
1442
+ key: state.id,
1443
+ value: state,
1444
+ sublevel
1445
+ },
1446
+ {
1447
+ type: "put",
1448
+ key: state.id,
1449
+ value: state,
1450
+ sublevel: projectSublevel
1451
+ }
1452
+ ])
1453
+ );
1454
+ }
1455
+ async putInstanceStates(projectId, states) {
1456
+ const sublevel = this.getJsonSublevel(`projects/${projectId}/instances`);
1457
+ await sublevel.batch(
1458
+ // as i told before, we update the instance states without operations
1459
+ // this method is used when upstream instance state changes are detected
1460
+ states.map((state) => ({
1461
+ type: "put",
1462
+ key: state.id,
1463
+ value: state
1464
+ }))
1465
+ );
1466
+ }
1467
+ async appendInstanceLogs(operationId, logs) {
1468
+ const sublevels = /* @__PURE__ */ new Map();
1469
+ for (const [instanceId] of logs) {
1470
+ if (sublevels.has(instanceId)) {
1471
+ continue;
1472
+ }
1473
+ const sublevel = this.db.sublevel(`operations/${operationId}/instanceLogs/${instanceId}`);
1474
+ sublevels.set(instanceId, sublevel);
1475
+ }
1476
+ await this.db.batch(
1477
+ logs.map(([instanceID, line]) => ({
1478
+ type: "put",
1479
+ key: uuidv7(),
1480
+ value: line,
1481
+ sublevel: sublevels.get(instanceID)
1482
+ }))
1483
+ );
1484
+ }
1485
+ async getCompositeInstance(projectId, instanceId) {
1486
+ const sublevel = this.getJsonSublevel(`projects/${projectId}/compositeInstances`);
1487
+ const instance = await sublevel.get(instanceId);
1488
+ if (!instance) {
1489
+ return null;
1490
+ }
1491
+ return evaluatedCompositeInstanceSchema.parse(instance);
1492
+ }
1493
+ async getCompositeInstanceInputHash(projectId, instanceId) {
1494
+ const sublevel = this.db.sublevel(`projects/${projectId}/compositeInstanceInputHashes`);
1495
+ return await sublevel.get(instanceId) ?? null;
1496
+ }
1497
+ async putCompositeInstances(projectId, instances) {
1498
+ const sublevel = this.getJsonSublevel(`projects/${projectId}/compositeInstances`);
1499
+ const inputHashesSublevel = this.db.sublevel(
1500
+ `projects/${projectId}/compositeInstanceInputHashes`
1501
+ );
1502
+ await this.db.batch(
1503
+ instances.flatMap((instance) => [
1504
+ {
1505
+ type: "put",
1506
+ key: instance.instance.id,
1507
+ value: evaluatedCompositeInstanceSchema.parse(instance),
1508
+ sublevel
1509
+ },
1510
+ {
1511
+ type: "put",
1512
+ key: instance.instance.id,
1513
+ value: instance.inputHash,
1514
+ sublevel: inputHashesSublevel
1515
+ }
1516
+ ])
1517
+ );
1518
+ }
1519
+ async clearCompositeInstances(projectId, instanceIds) {
1520
+ const sublevel = this.getJsonSublevel(`projects/${projectId}/compositeInstances`);
1521
+ const inputHashesSublevel = this.db.sublevel(
1522
+ `projects/${projectId}/compositeInstanceInputHashes`
1523
+ );
1524
+ await this.db.batch(
1525
+ instanceIds.flatMap((instanceId) => [
1526
+ { type: "del", key: instanceId, sublevel },
1527
+ { type: "del", key: instanceId, sublevel: inputHashesSublevel }
1528
+ ])
1529
+ );
1530
+ }
1531
+ getJsonSublevel(path) {
1532
+ return this.db.sublevel(path, { valueEncoding: "json" });
1533
+ }
1534
+ static async create(config, localPulumiHost, logger) {
1535
+ const childLogger = logger.child({ backend: "StateBackend", service: "LocalStateBackend" });
1536
+ let location = config.HIGHSTATE_BACKEND_STATE_LOCAL_DIR;
1537
+ if (!location) {
1538
+ const currentUser = await localPulumiHost.getCurrentUser();
1539
+ if (!currentUser) {
1540
+ throw new Error(
1541
+ "The pulumi is not authenticated, please login first or specify the state location manually"
1542
+ );
1543
+ }
1544
+ if (!currentUser.url) {
1545
+ throw new Error(
1546
+ "The pulumi user does not have a URL, please specify the state location manually"
1547
+ );
1548
+ }
1549
+ const path = currentUser.url.replace("file://", "").replace("~", process.env.HOME);
1550
+ location = resolve$1(path, ".pulumi", ".highstate");
1551
+ childLogger.debug({
1552
+ msg: "auto-detected state location",
1553
+ pulumiStateUrl: currentUser.url,
1554
+ location
1555
+ });
1556
+ }
1557
+ const { ClassicLevel } = await import('classic-level');
1558
+ const db = new ClassicLevel(location);
1559
+ return new LocalStateBackend(db, childLogger);
1560
+ }
1561
+ }
1562
+
1563
+ const stateBackendConfig = z.object({
1564
+ HIGHSTATE_BACKEND_STATE_TYPE: z.enum(["local"]).default("local"),
1565
+ ...localStateBackendConfig.shape
1566
+ });
1567
+ function createStateBackend(config, localPulumiHost, logger) {
1568
+ switch (config.HIGHSTATE_BACKEND_STATE_TYPE) {
1569
+ case "local": {
1570
+ return LocalStateBackend.create(config, localPulumiHost, logger);
1571
+ }
1572
+ }
1573
+ }
1574
+
1575
+ const localWorkspaceBackendConfig = z.object({
1576
+ HIGHSTATE_BACKEND_WORKSPACE_LOCAL_DIR: z.string().optional()
1577
+ });
1578
+ class LocalWorkspaceBackend {
1579
+ constructor(db) {
1580
+ this.db = db;
1581
+ }
1582
+ async getWorkspaceLayout() {
1583
+ return await this.db.get("layout");
1584
+ }
1585
+ async setWorkspaceLayout(layout) {
1586
+ await this.db.put("layout", layout);
1587
+ }
1588
+ async getProjectViewport(projectId) {
1589
+ return await this.db.get(`viewports/projects/${projectId}`);
1590
+ }
1591
+ async setProjectViewport(projectId, viewport) {
1592
+ await this.db.put(`viewports/projects/${projectId}`, viewport);
1593
+ }
1594
+ async getInstanceViewport(projectId, instanceId) {
1595
+ return await this.db.get(`viewports/instances/${projectId}/${instanceId}`);
1596
+ }
1597
+ async setInstanceViewport(projectId, instanceId, viewport) {
1598
+ await this.db.put(`viewports/instances/${projectId}/${instanceId}`, viewport);
1599
+ }
1600
+ static async create(config) {
1601
+ let location = config.HIGHSTATE_BACKEND_WORKSPACE_LOCAL_DIR;
1602
+ if (!location) {
1603
+ const home = homedir();
1604
+ location = resolve$1(home, ".highstate/workspace");
1605
+ }
1606
+ const { ClassicLevel } = await import('classic-level');
1607
+ const db = new ClassicLevel(location, { valueEncoding: "json" });
1608
+ return new LocalWorkspaceBackend(db);
1609
+ }
1610
+ }
1611
+
1612
+ const workspaceBackendConfig = z.object({
1613
+ HIGHSTATE_BACKEND_WORKSPACE_TYPE: z.enum(["local"]).default("local"),
1614
+ ...localWorkspaceBackendConfig.shape
1615
+ });
1616
+ function createWorkspaceBackend(config) {
1617
+ return LocalWorkspaceBackend.create(config);
1618
+ }
1619
+
1620
+ const loggerConfig = z.object({
1621
+ HIGHSTATE_BACKEND_LOGGER_NAME: z.string().default("highstate-backend"),
1622
+ HIGHSTATE_BACKEND_LOGGER_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info")
1623
+ });
1624
+ const configSchema = z.object({
1625
+ ...libraryBackendConfig.shape,
1626
+ ...projectBackendConfig.shape,
1627
+ ...secretBackendConfig.shape,
1628
+ ...stateBackendConfig.shape,
1629
+ ...runnerBackendConfig.shape,
1630
+ ...terminalBackendConfig.shape,
1631
+ ...workspaceBackendConfig.shape,
1632
+ ...loggerConfig.shape
1633
+ });
1634
+ async function loadConfig(env = process.env, useDotenv = true) {
1635
+ if (useDotenv) {
1636
+ await import('dotenv/config');
1637
+ }
1638
+ return configSchema.parse(env);
1639
+ }
1640
+
1641
+ class RuntimeOperation {
1642
+ constructor(operation, runnerBackend, stateBackend, libraryBackend, projectBackend, secretBackend, projectLock, operationEE, stateEE, compositeInstanceEE, instanceLogsEE, logger) {
1643
+ this.operation = operation;
1644
+ this.runnerBackend = runnerBackend;
1645
+ this.stateBackend = stateBackend;
1646
+ this.libraryBackend = libraryBackend;
1647
+ this.projectBackend = projectBackend;
1648
+ this.secretBackend = secretBackend;
1649
+ this.projectLock = projectLock;
1650
+ this.operationEE = operationEE;
1651
+ this.stateEE = stateEE;
1652
+ this.compositeInstanceEE = compositeInstanceEE;
1653
+ this.instanceLogsEE = instanceLogsEE;
1654
+ this.logger = logger;
1655
+ }
1656
+ abortController = new AbortController();
1657
+ instanceMap = /* @__PURE__ */ new Map();
1658
+ compositeInstanceLock = new BetterLock();
1659
+ compositeInstanceMap = /* @__PURE__ */ new Map();
1660
+ initialStatusMap = /* @__PURE__ */ new Map();
1661
+ stateMap = /* @__PURE__ */ new Map();
1662
+ instancePromiseMap = /* @__PURE__ */ new Map();
1663
+ childrenStateMap = /* @__PURE__ */ new Map();
1664
+ library;
1665
+ inputHashCalculator;
1666
+ async operateSafe() {
1667
+ try {
1668
+ await this.operate();
1669
+ } catch (error) {
1670
+ if (isAbortError(error)) {
1671
+ this.logger.info("the operation was cancelled");
1672
+ this.operation.status = "cancelled";
1673
+ await this.updateOperation();
1674
+ return;
1675
+ }
1676
+ this.logger.error({ msg: "an error occurred while running the operation", error });
1677
+ this.operation.status = "failed";
1678
+ this.operation.error = errorToString(error);
1679
+ await this.updateOperation();
1680
+ } finally {
1681
+ this.resetPendingStateStatuses();
1682
+ await Promise.all([
1683
+ this.persistStates.flush(),
1684
+ this.persistLogs.flush(),
1685
+ this.persistSecrets.flush()
1686
+ ]);
1687
+ this.logger.debug("operation finished, all entries persisted");
1688
+ }
1689
+ }
1690
+ resetPendingStateStatuses() {
1691
+ for (const state of this.stateMap.values()) {
1692
+ if (state.status !== "pending") {
1693
+ continue;
1694
+ }
1695
+ const initialStatus = this.initialStatusMap.get(state.id);
1696
+ if (initialStatus) {
1697
+ this.updateInstanceState({ id: state.id, status: initialStatus });
1698
+ }
1699
+ }
1700
+ }
1701
+ async operate() {
1702
+ this.logger.info("starting operation");
1703
+ const allInstances = await this.projectBackend.getInstances(this.operation.projectId);
1704
+ this.abortController.signal.throwIfAborted();
1705
+ const states = await this.stateBackend.getInstanceStates(this.operation.projectId);
1706
+ this.abortController.signal.throwIfAborted();
1707
+ for (const instance of allInstances) {
1708
+ this.instanceMap.set(instance.id, instance);
1709
+ }
1710
+ this.inputHashCalculator = new InputHashCalculator(this.instanceMap);
1711
+ for (const state of states) {
1712
+ this.stateMap.set(state.id, state);
1713
+ this.initialStatusMap.set(state.id, state.status);
1714
+ this.tryAddStateToParent(state);
1715
+ }
1716
+ if (this.operation.type === "update") {
1717
+ await this.extendWithNotCreatedDependencies();
1718
+ await this.updateOperation();
1719
+ } else if (this.operation.type === "destroy") {
1720
+ this.extendWithCreatedDependents();
1721
+ await this.updateOperation();
1722
+ }
1723
+ this.logger.info("all instances loaded", {
1724
+ count: allInstances.length,
1725
+ affectedCount: this.operation.instanceIds.length
1726
+ });
1727
+ if (this.operation.type === "evaluate") {
1728
+ this.logger.info("evaluating instances");
1729
+ await this.evaluateCompositeInstances(allInstances);
1730
+ } else {
1731
+ this.library = await this.libraryBackend.loadLibrary();
1732
+ this.logger.info("library ready");
1733
+ this.abortController.signal.throwIfAborted();
1734
+ const promises = [];
1735
+ for (const instanceId of this.operation.instanceIds) {
1736
+ promises.push(this.getInstancePromiseForOperation(instanceId));
1737
+ }
1738
+ this.logger.info("all units started");
1739
+ this.operation.status = "running";
1740
+ await this.updateOperation();
1741
+ await Promise.all(promises);
1742
+ this.logger.info("all units completed");
1743
+ }
1744
+ this.operation.status = "completed";
1745
+ await this.updateOperation();
1746
+ this.logger.info("operation completed");
1747
+ }
1748
+ cancel() {
1749
+ this.abortController.abort();
1750
+ }
1751
+ getInstancePromiseForOperation(instanceId) {
1752
+ const instance = this.instanceMap.get(instanceId);
1753
+ if (!instance) {
1754
+ throw new Error(`Instance not found: ${instanceId}`);
1755
+ }
1756
+ const component = this.library.components[instance.type];
1757
+ if (!component) {
1758
+ throw new Error(`Component not found: ${instance.type}`);
1759
+ }
1760
+ if (isUnitModel(component)) {
1761
+ return this.getUnitPromise(instance, component);
1762
+ }
1763
+ return this.getCompositePromise(instance);
1764
+ }
1765
+ async getUnitPromise(instance, component) {
1766
+ switch (this.operation.type) {
1767
+ case "update": {
1768
+ return this.updateUnit(instance, component);
1769
+ }
1770
+ case "recreate": {
1771
+ return this.recreateUnit(instance, component);
1772
+ }
1773
+ case "destroy": {
1774
+ return this.destroyUnit(instance.id);
1775
+ }
1776
+ case "refresh": {
1777
+ return this.refreshUnit(instance.id);
1778
+ }
1779
+ }
1780
+ }
1781
+ async getCompositePromise(instance) {
1782
+ const logger = this.logger.child({ instanceId: instance.id });
1783
+ return this.getInstancePromise(instance.id, async () => {
1784
+ const initialStatus = this.stateMap.get(instance.id)?.status ?? "not_created";
1785
+ this.updateInstanceState({
1786
+ id: instance.id,
1787
+ latestOperationId: this.operation.id,
1788
+ status: this.getStatusByOperationType(),
1789
+ currentResourceCount: 0,
1790
+ totalResourceCount: 0
1791
+ });
1792
+ const { children } = await this.loadCompositeInstance(instance.id);
1793
+ const childPromises = [];
1794
+ if (!children.length) {
1795
+ logger.warn("no children found for composite component");
1796
+ }
1797
+ for (const child of children) {
1798
+ logger.info("waiting for child", { childId: child.id });
1799
+ childPromises.push(this.getInstancePromiseForOperation(child.id));
1800
+ }
1801
+ try {
1802
+ await Promise.all(childPromises);
1803
+ this.abortController.signal.throwIfAborted();
1804
+ if (children.length > 0) {
1805
+ logger.info("all children completed", { count: children.length });
1806
+ }
1807
+ this.updateInstanceState({
1808
+ id: instance.id,
1809
+ status: "created"
1810
+ });
1811
+ } catch (error) {
1812
+ if (isAbortError(error)) {
1813
+ this.updateInstanceState({
1814
+ id: instance.id,
1815
+ status: initialStatus
1816
+ });
1817
+ return;
1818
+ }
1819
+ this.updateInstanceState({
1820
+ id: instance.id,
1821
+ status: "error",
1822
+ error: errorToString(error)
1823
+ });
1824
+ }
1825
+ });
1826
+ }
1827
+ updateUnit(instance, component) {
1828
+ const logger = this.logger.child({ instanceId: instance.id });
1829
+ return this.getInstancePromise(instance.id, async () => {
1830
+ this.updateInstanceState({
1831
+ id: instance.id,
1832
+ latestOperationId: this.operation.id,
1833
+ status: "pending",
1834
+ currentResourceCount: 0,
1835
+ totalResourceCount: 0
1836
+ });
1837
+ const dependencies = this.getInstanceDependencies(instance);
1838
+ const dependencyPromises = [];
1839
+ for (const dependency of dependencies) {
1840
+ if (!this.operation.instanceIds.includes(dependency.id)) {
1841
+ continue;
1842
+ }
1843
+ logger.info("waiting for dependency", { dependencyId: dependency.id });
1844
+ dependencyPromises.push(this.updateUnit(dependency, component));
1845
+ }
1846
+ await Promise.all(dependencyPromises);
1847
+ this.abortController.signal.throwIfAborted();
1848
+ if (dependencies.length > 0) {
1849
+ logger.info("all dependencies completed", { count: dependencies.length });
1850
+ }
1851
+ logger.info("updating unit...");
1852
+ const secrets = await this.secretBackend.get(this.operation.projectId, instance.id);
1853
+ this.abortController.signal.throwIfAborted();
1854
+ logger.debug("secrets loaded", { count: Object.keys(secrets).length });
1855
+ await this.runnerBackend.update({
1856
+ projectId: this.operation.projectId,
1857
+ instanceType: instance.type,
1858
+ instanceName: instance.name,
1859
+ config: await this.prepareUnitConfig(instance),
1860
+ secrets: mapValues(secrets, (value) => valueToString(value)),
1861
+ source: this.resolveUnitSource(component),
1862
+ signal: this.abortController.signal
1863
+ });
1864
+ logger.debug("unit update requested");
1865
+ const stream = this.runnerBackend.watch({
1866
+ projectId: this.operation.projectId,
1867
+ instanceType: instance.type,
1868
+ instanceName: instance.name,
1869
+ finalStatuses: ["created", "error"]
1870
+ });
1871
+ await this.watchStateStream(stream);
1872
+ const inputHash = await this.inputHashCalculator.calculate(instance);
1873
+ this.updateInstanceState({ id: instance.id, inputHash });
1874
+ logger.debug("input hash after update", { inputHash });
1875
+ logger.info("unit updated");
1876
+ });
1877
+ }
1878
+ async destroyUnit(instanceId) {
1879
+ const logger = this.logger.child({ instanceId });
1880
+ return this.getInstancePromise(instanceId, async () => {
1881
+ this.updateInstanceState({
1882
+ id: instanceId,
1883
+ latestOperationId: this.operation.id,
1884
+ status: "pending",
1885
+ currentResourceCount: 0,
1886
+ totalResourceCount: 0
1887
+ });
1888
+ const state = this.stateMap.get(instanceId);
1889
+ if (!state) {
1890
+ logger.warn("state not found for unit, but destroy was requested");
1891
+ return;
1892
+ }
1893
+ const dependentPromises = [];
1894
+ for (const dependentKey of state.dependentKeys ?? []) {
1895
+ logger.info("destroying dependent unit", { dependentKey });
1896
+ dependentPromises.push(this.destroyUnit(dependentKey));
1897
+ }
1898
+ await Promise.all(dependentPromises);
1899
+ this.abortController.signal.throwIfAborted();
1900
+ logger.info("destroying unit...");
1901
+ const [type, name] = parseInstanceId(instanceId);
1902
+ await this.runnerBackend.destroy({
1903
+ projectId: this.operation.projectId,
1904
+ instanceType: type,
1905
+ instanceName: name,
1906
+ signal: this.abortController.signal
1907
+ });
1908
+ this.logger.debug("destroy request sent");
1909
+ const stream = this.runnerBackend.watch({
1910
+ projectId: this.operation.projectId,
1911
+ instanceType: type,
1912
+ instanceName: name,
1913
+ finalStatuses: ["not_created", "error"]
1914
+ });
1915
+ await this.watchStateStream(stream);
1916
+ this.logger.info("unit destroyed");
1917
+ });
1918
+ }
1919
+ async recreateUnit(instance, component) {
1920
+ const logger = this.logger.child({ instanceId: instance.id });
1921
+ return this.getInstancePromise(instance.id, async () => {
1922
+ this.updateInstanceState({
1923
+ id: instance.id,
1924
+ latestOperationId: this.operation.id,
1925
+ status: "pending",
1926
+ currentResourceCount: 0,
1927
+ totalResourceCount: 0
1928
+ });
1929
+ logger.info("destroying unit...");
1930
+ await this.runnerBackend.destroy({
1931
+ projectId: this.operation.projectId,
1932
+ instanceType: instance.type,
1933
+ instanceName: instance.name,
1934
+ signal: this.abortController.signal
1935
+ });
1936
+ logger.debug("destroy request sent");
1937
+ const destroyStream = this.runnerBackend.watch({
1938
+ projectId: this.operation.projectId,
1939
+ instanceType: instance.type,
1940
+ instanceName: instance.name,
1941
+ finalStatuses: ["not_created", "error"]
1942
+ });
1943
+ await this.watchStateStream(destroyStream);
1944
+ this.abortController.signal.throwIfAborted();
1945
+ logger.info("unit destroyed");
1946
+ logger.info("updating unit...");
1947
+ const secrets = await this.secretBackend.get(this.operation.projectId, instance.id);
1948
+ this.abortController.signal.throwIfAborted();
1949
+ logger.debug("secrets loaded", { count: Object.keys(secrets).length });
1950
+ await this.runnerBackend.update({
1951
+ projectId: this.operation.projectId,
1952
+ instanceType: instance.type,
1953
+ instanceName: instance.name,
1954
+ config: await this.prepareUnitConfig(instance),
1955
+ secrets: mapValues(secrets, (value) => valueToString(value)),
1956
+ source: this.resolveUnitSource(component),
1957
+ signal: this.abortController.signal
1958
+ });
1959
+ logger.debug("unit update requested");
1960
+ const updateStream = this.runnerBackend.watch({
1961
+ projectId: this.operation.projectId,
1962
+ instanceType: instance.type,
1963
+ instanceName: instance.name,
1964
+ finalStatuses: ["created", "error"]
1965
+ });
1966
+ this.updateInstanceState({ id: instance.id, inputHash: null });
1967
+ await this.watchStateStream(updateStream);
1968
+ logger.info("unit recreated");
1969
+ });
1970
+ }
1971
+ async refreshUnit(instanceId) {
1972
+ const logger = this.logger.child({ instanceId });
1973
+ return this.getInstancePromise(instanceId, async () => {
1974
+ this.updateInstanceState({
1975
+ id: instanceId,
1976
+ latestOperationId: this.operation.id,
1977
+ status: "refreshing",
1978
+ currentResourceCount: 0,
1979
+ totalResourceCount: 0
1980
+ });
1981
+ logger.info("refreshing unit...");
1982
+ const [type, name] = parseInstanceId(instanceId);
1983
+ await this.runnerBackend.refresh({
1984
+ projectId: this.operation.projectId,
1985
+ instanceType: type,
1986
+ instanceName: name,
1987
+ signal: this.abortController.signal
1988
+ });
1989
+ logger.debug("unit refresh requested");
1990
+ const stream = this.runnerBackend.watch({
1991
+ projectId: this.operation.projectId,
1992
+ instanceType: type,
1993
+ instanceName: name,
1994
+ finalStatuses: ["created", "error"]
1995
+ });
1996
+ await this.watchStateStream(stream);
1997
+ logger.info("unit refreshed");
1998
+ });
1999
+ }
2000
+ async watchStateStream(stream) {
2001
+ let statePatch;
2002
+ for await (statePatch of stream) {
2003
+ if (statePatch.status === "not_created" && this.operation.type === "recreate") {
2004
+ continue;
2005
+ }
2006
+ this.updateInstanceState(statePatch);
2007
+ }
2008
+ if (!statePatch) {
2009
+ throw new Error("The stream ended without emitting any state.");
2010
+ }
2011
+ if (statePatch.status === "error") {
2012
+ throw new Error(`The operation on unit "${statePatch.id}" failed: ${statePatch.error}`);
2013
+ }
2014
+ }
2015
+ async prepareUnitConfig(instance) {
2016
+ const config = {};
2017
+ for (const [key, value] of Object.entries(instance.args)) {
2018
+ config[key] = valueToString(value);
2019
+ }
2020
+ for (const [key, value] of Object.entries(instance.inputs)) {
2021
+ config[`input.${key}`] = JSON.stringify(await this.resolveInstanceInput(value));
2022
+ }
2023
+ return config;
2024
+ }
2025
+ async resolveInstanceInput(input) {
2026
+ if (Array.isArray(input)) {
2027
+ return (await Promise.all(input.map((x) => this.resolveInstanceInput(x)))).flat();
2028
+ }
2029
+ return await this.resolveSingleInstanceInput(input);
2030
+ }
2031
+ async resolveSingleInstanceInput(input) {
2032
+ const loadedInstance = this.instanceMap.get(input.instanceId);
2033
+ if (loadedInstance && !loadedInstance.parentId) {
2034
+ return input;
2035
+ }
2036
+ const parentInstance = await this.loadCompositeInstance(input.instanceId);
2037
+ const parentOutput = parentInstance.instance.outputs?.[input.output];
2038
+ if (!parentOutput) {
2039
+ throw new Error(`Output "${input.output}" not found in "${input.instanceId}"`);
2040
+ }
2041
+ return await this.resolveInstanceInput(parentOutput);
2042
+ }
2043
+ async loadCompositeInstance(instanceId) {
2044
+ return await this.compositeInstanceLock.acquire(instanceId, async () => {
2045
+ const existingInstance = this.compositeInstanceMap.get(instanceId);
2046
+ if (existingInstance) {
2047
+ return existingInstance;
2048
+ }
2049
+ const compositeInstance = await this.stateBackend.getCompositeInstance(
2050
+ this.operation.projectId,
2051
+ instanceId
2052
+ );
2053
+ if (!compositeInstance) {
2054
+ throw new Error(`Composite instance not found: ${instanceId}`);
2055
+ }
2056
+ this.abortController.signal.throwIfAborted();
2057
+ for (const child of compositeInstance.children) {
2058
+ this.instanceMap.set(child.id, child);
2059
+ }
2060
+ this.compositeInstanceMap.set(instanceId, compositeInstance);
2061
+ return compositeInstance;
2062
+ });
2063
+ }
2064
+ async evaluateCompositeInstances(allInstances) {
2065
+ await this.projectLock.lockInstances(this.operation.instanceIds, async () => {
2066
+ const compositeInstances = await this.libraryBackend.evaluateCompositeInstances(
2067
+ allInstances,
2068
+ this.operation.instanceIds
2069
+ );
2070
+ this.abortController.signal.throwIfAborted();
2071
+ for (const instance of compositeInstances) {
2072
+ this.compositeInstanceEE.emit(
2073
+ `${this.operation.projectId}/${instance.instance.id}`,
2074
+ instance
2075
+ );
2076
+ }
2077
+ await this.stateBackend.putCompositeInstances(
2078
+ this.operation.projectId,
2079
+ await Promise.all(
2080
+ compositeInstances.map(async (instance) => ({
2081
+ ...instance,
2082
+ inputHash: await this.inputHashCalculator.calculate(instance.instance)
2083
+ }))
2084
+ )
2085
+ );
2086
+ });
2087
+ }
2088
+ async updateOperation() {
2089
+ this.operationEE.emit(this.operation.projectId, this.operation);
2090
+ await this.stateBackend.putOperation(this.operation);
2091
+ }
2092
+ updateInstanceState(patch) {
2093
+ if (patch.message) {
2094
+ this.persistLogs.call([patch.id, patch.message]);
2095
+ this.instanceLogsEE.emit(`${this.operation.id}/${patch.id}`, patch.message);
2096
+ return;
2097
+ }
2098
+ const state = applyInstanceStatePatch(this.stateMap, patch);
2099
+ this.persistStates.call(state);
2100
+ if (patch.secrets) {
2101
+ this.persistSecrets.call([patch.id, patch.secrets]);
2102
+ }
2103
+ this.stateEE.emit(this.operation.projectId, createInstanceStateFrontendPatch(patch));
2104
+ }
2105
+ tryAddStateToParent(state) {
2106
+ if (!state.parentId) {
2107
+ return;
2108
+ }
2109
+ const children = this.childrenStateMap.get(state.parentId) ?? [];
2110
+ children.push(state);
2111
+ this.childrenStateMap.set(state.parentId, children);
2112
+ }
2113
+ async extendWithNotCreatedDependencies() {
2114
+ const instanceIdsSet = /* @__PURE__ */ new Set();
2115
+ const traverse = async (instanceId) => {
2116
+ if (instanceIdsSet.has(instanceId)) {
2117
+ return;
2118
+ }
2119
+ const instance = this.instanceMap.get(instanceId);
2120
+ if (!instance) {
2121
+ return;
2122
+ }
2123
+ for (const input of Object.values(instance.inputs)) {
2124
+ if (Array.isArray(input)) {
2125
+ for (const { instanceId: instanceId2 } of input) {
2126
+ await traverse(instanceId2);
2127
+ }
2128
+ } else {
2129
+ await traverse(input.instanceId);
2130
+ }
2131
+ }
2132
+ const state = this.stateMap.get(instance.id);
2133
+ const expectedInputHash = await this.inputHashCalculator.calculate(instance);
2134
+ if (state?.status !== "created" || state.inputHash !== expectedInputHash) {
2135
+ instanceIdsSet.add(instanceId);
2136
+ }
2137
+ };
2138
+ for (const instanceId of this.operation.instanceIds) {
2139
+ await traverse(instanceId);
2140
+ instanceIdsSet.add(instanceId);
2141
+ }
2142
+ this.operation.instanceIds = Array.from(instanceIdsSet);
2143
+ }
2144
+ extendWithCreatedDependents() {
2145
+ const instanceIdsSet = /* @__PURE__ */ new Set();
2146
+ const traverse = (instanceKey) => {
2147
+ if (instanceIdsSet.has(instanceKey)) {
2148
+ return;
2149
+ }
2150
+ const state = this.stateMap.get(instanceKey);
2151
+ if (!state || state.status === "not_created") {
2152
+ return;
2153
+ }
2154
+ for (const dependent of state.dependentKeys ?? []) {
2155
+ traverse(dependent);
2156
+ instanceIdsSet.add(instanceKey);
2157
+ }
2158
+ };
2159
+ for (const instanceId of this.operation.instanceIds) {
2160
+ const instance = this.instanceMap.get(instanceId);
2161
+ if (!instance) {
2162
+ throw new Error(`Instance not found: ${instanceId}`);
2163
+ }
2164
+ traverse(instance.id);
2165
+ instanceIdsSet.add(instanceId);
2166
+ }
2167
+ this.operation.instanceIds = Array.from(instanceIdsSet);
2168
+ }
2169
+ getInstancePromise(instanceId, fn) {
2170
+ let instancePromise = this.instancePromiseMap.get(instanceId);
2171
+ if (instancePromise) {
2172
+ return instancePromise;
2173
+ }
2174
+ instancePromise = this.projectLock.lockInstance(instanceId, fn);
2175
+ instancePromise = instancePromise.finally(() => this.instancePromiseMap.delete(instanceId));
2176
+ this.instancePromiseMap.set(instanceId, instancePromise);
2177
+ return instancePromise;
2178
+ }
2179
+ getStatusByOperationType() {
2180
+ switch (this.operation.type) {
2181
+ case "update":
2182
+ return "updating";
2183
+ case "recreate":
2184
+ return "updating";
2185
+ case "destroy":
2186
+ return "destroying";
2187
+ case "refresh":
2188
+ return "refreshing";
2189
+ default:
2190
+ throw new Error(`Unexpected operation type: ${this.operation.type}`);
2191
+ }
2192
+ }
2193
+ getInstanceDependencies(instance) {
2194
+ const dependencies = [];
2195
+ for (const input of Object.values(instance.inputs)) {
2196
+ if (Array.isArray(input)) {
2197
+ for (const { instanceId } of input) {
2198
+ const dependency = this.instanceMap.get(instanceId);
2199
+ if (dependency) {
2200
+ dependencies.push(dependency);
2201
+ }
2202
+ }
2203
+ } else {
2204
+ const dependency = this.instanceMap.get(input.instanceId);
2205
+ if (dependency) {
2206
+ dependencies.push(dependency);
2207
+ }
2208
+ }
2209
+ }
2210
+ return dependencies;
2211
+ }
2212
+ resolveUnitSource(component) {
2213
+ if (!component.source) {
2214
+ return {
2215
+ type: "local",
2216
+ path: component.type.replaceAll(".", "/")
2217
+ };
2218
+ }
2219
+ return {
2220
+ ...component.source,
2221
+ path: component.source.path ?? component.type.replaceAll(".", "/")
2222
+ };
2223
+ }
2224
+ static accumulator(values, value) {
2225
+ if (!values) {
2226
+ return [value];
2227
+ }
2228
+ return [...values, value];
799
2229
  }
2230
+ persistStates = funnel(
2231
+ (states) => {
2232
+ this.logger.debug({ msg: "persisting states", count: states.length });
2233
+ void this.stateBackend.putAffectedInstanceStates(
2234
+ this.operation.projectId,
2235
+ this.operation.id,
2236
+ states
2237
+ );
2238
+ },
2239
+ {
2240
+ minQuietPeriodMs: 100,
2241
+ maxBurstDurationMs: 1e3,
2242
+ triggerAt: "end",
2243
+ reducer: RuntimeOperation.accumulator
2244
+ }
2245
+ );
2246
+ persistLogs = funnel(
2247
+ (entries) => {
2248
+ this.logger.debug({ msg: "persisting logs", count: entries.length });
2249
+ void this.stateBackend.appendInstanceLogs(this.operation.id, entries);
2250
+ },
2251
+ {
2252
+ minQuietPeriodMs: 100,
2253
+ maxBurstDurationMs: 200,
2254
+ reducer: RuntimeOperation.accumulator
2255
+ }
2256
+ );
2257
+ persistSecrets = funnel(
2258
+ (entries) => {
2259
+ this.logger.debug({ msg: "persisting secrets", count: entries.length });
2260
+ for (const [instanceId, secrets] of entries) {
2261
+ void this.secretBackend.get(this.operation.projectId, instanceId).then((existingSecrets) => {
2262
+ Object.assign(existingSecrets, secrets);
2263
+ return this.secretBackend.set(this.operation.projectId, instanceId, existingSecrets);
2264
+ });
2265
+ }
2266
+ },
2267
+ {
2268
+ minQuietPeriodMs: 100,
2269
+ maxBurstDurationMs: 200,
2270
+ reducer: RuntimeOperation.accumulator
2271
+ }
2272
+ );
800
2273
  }
801
2274
 
802
- function createRunnerBackend() {
803
- return LocalRunnerBackend.create();
2275
+ class OperationManager {
2276
+ constructor(runnerBackend, stateBackend, libraryBackend, projectBackend, secretBackend, projectLockManager, logger) {
2277
+ this.runnerBackend = runnerBackend;
2278
+ this.stateBackend = stateBackend;
2279
+ this.libraryBackend = libraryBackend;
2280
+ this.projectBackend = projectBackend;
2281
+ this.secretBackend = secretBackend;
2282
+ this.projectLockManager = projectLockManager;
2283
+ this.logger = logger;
2284
+ }
2285
+ operationEE = new EventEmitter();
2286
+ stateEE = new EventEmitter();
2287
+ compositeInstanceEE = new EventEmitter();
2288
+ instanceLogsEE = new EventEmitter();
2289
+ runtimeOperations = /* @__PURE__ */ new Map();
2290
+ /**
2291
+ * Watches for all instance state changes in the project.
2292
+ *
2293
+ * @param projectId The project ID to watch.
2294
+ * @param signal The signal to abort the operation.
2295
+ */
2296
+ async *watchInstanceStates(projectId, signal) {
2297
+ for await (const [state] of on(this.stateEE, projectId, { signal })) {
2298
+ yield state;
2299
+ }
2300
+ }
2301
+ /**
2302
+ * Watches for all project operations in the project.
2303
+ *
2304
+ * @param projectId The project ID to watch.
2305
+ * @param signal The signal to abort the operation.
2306
+ */
2307
+ async *watchOperations(projectId, signal) {
2308
+ for await (const [operation] of on(this.operationEE, projectId, { signal })) {
2309
+ yield operation;
2310
+ }
2311
+ }
2312
+ /**
2313
+ * Watches for changes in the composite instance.
2314
+ *
2315
+ * @param projectId The project ID to watch.
2316
+ * @param instanceId The instance ID to watch.
2317
+ * @param signal The signal to abort the operation.
2318
+ */
2319
+ async *watchCompositeInstance(projectId, instanceId, signal) {
2320
+ const eventKey = `${projectId}/${instanceId}`;
2321
+ for await (const [instance] of on(this.compositeInstanceEE, eventKey, { signal })) {
2322
+ yield instance;
2323
+ }
2324
+ }
2325
+ /**
2326
+ * Watches for logs of the instance in the operation.
2327
+ *
2328
+ * @param operationId The operation ID to watch.
2329
+ * @param instanceId The instance ID to watch.
2330
+ * @param signal The signal to abort the operation.
2331
+ */
2332
+ async *watchInstanceLogs(operationId, instanceId, signal) {
2333
+ const eventKey = `${operationId}/${instanceId}`;
2334
+ for await (const [log] of on(this.instanceLogsEE, eventKey, { signal })) {
2335
+ yield log;
2336
+ }
2337
+ }
2338
+ /**
2339
+ * Launches the project operation.
2340
+ *
2341
+ * @param request The operation request to launch.
2342
+ */
2343
+ async launch(request) {
2344
+ const operation = {
2345
+ id: uuidv7(),
2346
+ projectId: request.projectId,
2347
+ type: request.type,
2348
+ instanceIds: request.instanceIds,
2349
+ status: "pending",
2350
+ error: null,
2351
+ startedAt: Date.now(),
2352
+ completedAt: null
2353
+ };
2354
+ await this.stateBackend.putOperation(operation);
2355
+ this.startOperation(operation);
2356
+ }
2357
+ /**
2358
+ * Cancels the current operation.
2359
+ * Does nothing if no operation is running.
2360
+ */
2361
+ cancel(operationId) {
2362
+ const runtimeOperation = this.runtimeOperations.get(operationId);
2363
+ if (runtimeOperation) {
2364
+ runtimeOperation.cancel();
2365
+ }
2366
+ }
2367
+ startOperation(operation) {
2368
+ const runtimeOperation = new RuntimeOperation(
2369
+ operation,
2370
+ this.runnerBackend,
2371
+ this.stateBackend,
2372
+ this.libraryBackend,
2373
+ this.projectBackend,
2374
+ this.secretBackend,
2375
+ this.projectLockManager.getLock(operation.projectId),
2376
+ this.operationEE,
2377
+ this.stateEE,
2378
+ this.compositeInstanceEE,
2379
+ this.instanceLogsEE,
2380
+ this.logger.child({ service: "RuntimeOperation", operationId: operation.id })
2381
+ );
2382
+ this.runtimeOperations.set(operation.id, runtimeOperation);
2383
+ void runtimeOperation.operateSafe().finally(() => this.runtimeOperations.delete(operation.id));
2384
+ }
2385
+ static async create(runnerBackend, stateBackend, libraryBackend, projectBackend, secretBackend, projectLockManager, logger) {
2386
+ const operator = new OperationManager(
2387
+ runnerBackend,
2388
+ stateBackend,
2389
+ libraryBackend,
2390
+ projectBackend,
2391
+ secretBackend,
2392
+ projectLockManager,
2393
+ logger.child({ service: "OperationManager" })
2394
+ );
2395
+ const activeOperations = await stateBackend.getActiveOperations();
2396
+ for (const operation of activeOperations) {
2397
+ logger.info({ msg: "relaunching operation", operationId: operation.id });
2398
+ operator.startOperation(operation);
2399
+ }
2400
+ return operator;
2401
+ }
804
2402
  }
805
2403
 
806
- async function createServices(config) {
2404
+ async function createServices({
2405
+ config,
2406
+ services: {
2407
+ logger,
2408
+ libraryBackend,
2409
+ secretBackend,
2410
+ runnerBackend,
2411
+ projectBackend,
2412
+ projectManager,
2413
+ stateBackend,
2414
+ operationManager,
2415
+ terminalBackend,
2416
+ terminalManager,
2417
+ workspaceBackend
2418
+ } = {}
2419
+ } = {}) {
807
2420
  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(
2421
+ logger ??= pino({
2422
+ name: config.HIGHSTATE_BACKEND_LOGGER_NAME,
2423
+ level: config.HIGHSTATE_BACKEND_LOGGER_LEVEL
2424
+ });
2425
+ const localPulumiHost = LocalPulumiHost.create(logger);
2426
+ const projectLockManager = new ProjectLockManager();
2427
+ libraryBackend ??= createLibraryBackend(config, logger);
2428
+ secretBackend ??= await createSecretBackend(config, localPulumiHost, logger);
2429
+ stateBackend ??= await createStateBackend(config, localPulumiHost, logger);
2430
+ runnerBackend ??= await createRunnerBackend(config, localPulumiHost);
2431
+ projectBackend ??= await createProjectBackend(config);
2432
+ terminalBackend ??= createTerminalBackend(config, logger);
2433
+ terminalManager ??= TerminalManager.create(terminalBackend, logger);
2434
+ operationManager ??= await OperationManager.create(
815
2435
  runnerBackend,
816
2436
  stateBackend,
817
2437
  libraryBackend,
818
2438
  projectBackend,
819
- secretBackend
2439
+ secretBackend,
2440
+ projectLockManager,
2441
+ logger
2442
+ );
2443
+ projectManager ??= ProjectManager.create(
2444
+ projectBackend,
2445
+ stateBackend,
2446
+ operationManager,
2447
+ libraryBackend,
2448
+ logger
820
2449
  );
821
- consola.success("Backend services initialized");
2450
+ workspaceBackend ??= await createWorkspaceBackend(config);
2451
+ logger.info("services created");
822
2452
  return {
2453
+ logger,
823
2454
  libraryBackend,
824
2455
  secretBackend,
825
2456
  runnerBackend,
826
2457
  projectBackend,
2458
+ projectManager,
827
2459
  stateBackend,
828
- operatorManager
2460
+ operationManager,
2461
+ terminalBackend,
2462
+ terminalManager,
2463
+ workspaceBackend
829
2464
  };
830
2465
  }
2466
+ let sharedServicesPromise;
2467
+ function getSharedServices(options = {}) {
2468
+ if (!sharedServicesPromise) {
2469
+ sharedServicesPromise = createServices(options);
2470
+ }
2471
+ return sharedServicesPromise;
2472
+ }
831
2473
 
832
- export { LocalPulumiProjectHost, OperatorManager, ProjectOperator, SecretAccessDeniedError, createLibraryBackend, createSecretBackend, createServices, createStateBackend, instanceModelSchema, instanceStateSchema, instanceStatusSchema, libraryBackendConfig, loadConfig, operationType, outputRefSchema, positionSchema, projectOperation, projectStateSchema, resolveMainLocalProject, secretBackendConfig, stateBackendConfig, stringArrayType };
2474
+ export { OperationManager, SecretAccessDeniedError, TerminalManager, createLibraryBackend, createSecretBackend, createServices, createTerminalBackend, getSharedServices, libraryBackendConfig, loadConfig, secretBackendConfig, terminalBackendConfig };