@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.d.ts +498 -338
- package/dist/index.mjs +2155 -513
- package/dist/input-hash-C8HEDMjz.mjs +292 -0
- package/dist/library/worker/main.mjs +169 -0
- package/dist/operation-8k4Tv4dw.d.ts +841 -0
- package/dist/shared/index.d.ts +21 -0
- package/dist/shared/index.mjs +4 -0
- package/package.json +20 -9
- package/dist/library/workers/evaluator.mjs +0 -43
- package/dist/library/workers/loader.mjs +0 -11
- package/dist/shared-B55c8XjT.mjs +0 -36
package/dist/index.mjs
CHANGED
@@ -1,108 +1,207 @@
|
|
1
|
-
import { BetterLock } from 'better-lock';
|
2
1
|
import { z } from 'zod';
|
3
|
-
import {
|
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 {
|
12
|
-
import {
|
13
|
-
import {
|
14
|
-
import
|
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
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
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
|
-
|
23
|
-
|
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(
|
26
|
-
return this.lock.acquire(`${
|
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:
|
87
|
+
name: pulumiProjectName,
|
37
88
|
runtime: "nodejs"
|
38
89
|
},
|
39
|
-
envVars: { PULUMI_CONFIG_PASSPHRASE: this.
|
90
|
+
envVars: { PULUMI_CONFIG_PASSPHRASE: this.getPassword(projectId) }
|
40
91
|
}
|
41
92
|
);
|
42
|
-
|
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
|
46
|
-
return this.
|
47
|
-
|
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
|
-
|
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
|
-
|
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,
|
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
|
145
|
-
|
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,
|
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
|
-
|
156
|
-
|
157
|
-
|
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
|
-
|
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(
|
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,
|
179
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
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
|
212
|
-
|
213
|
-
|
214
|
-
|
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
|
365
|
+
async evaluateModules(modulePaths) {
|
223
366
|
return await this.lock.acquire(async () => {
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
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
|
-
|
235
|
-
const
|
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
|
-
|
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(
|
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
|
-
|
446
|
+
HIGHSTATE_BACKEND_PROJECT_PROJECTS_DIR: z.string().optional()
|
268
447
|
});
|
269
|
-
const
|
448
|
+
const projectModelSchema = z.object({
|
270
449
|
instances: z.record(instanceModelSchema)
|
271
450
|
});
|
272
451
|
class LocalProjectBackend {
|
273
|
-
constructor(
|
274
|
-
this.
|
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
|
279
|
-
return
|
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
|
-
|
282
|
-
return {};
|
501
|
+
throw new Error("Failed to update project instance arguments", { cause: error });
|
283
502
|
}
|
284
503
|
}
|
285
|
-
async
|
504
|
+
async updateInstanceInputs(projectId, instanceId, inputs) {
|
286
505
|
try {
|
287
|
-
|
288
|
-
|
289
|
-
|
506
|
+
return await this.withInstance(projectId, instanceId, (instance) => {
|
507
|
+
instance.inputs = inputs;
|
508
|
+
return instance;
|
509
|
+
});
|
290
510
|
} catch (error) {
|
291
|
-
|
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
|
-
|
297
|
-
|
298
|
-
|
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
|
-
|
568
|
+
throw new Error("Failed to rename project instance", { cause: error });
|
301
569
|
}
|
302
570
|
}
|
303
|
-
|
304
|
-
return `${this.
|
571
|
+
getProjectPath(projectId) {
|
572
|
+
return `${this.projectsDir}/${projectId}.json`;
|
305
573
|
}
|
306
|
-
async
|
307
|
-
const
|
574
|
+
async loadProject(projectId) {
|
575
|
+
const projectPath = this.getProjectPath(projectId);
|
308
576
|
try {
|
309
|
-
const content = await readFile(
|
310
|
-
return
|
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
|
319
|
-
const
|
320
|
-
const content = JSON.stringify(
|
321
|
-
await writeFile(
|
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
|
-
|
325
|
-
|
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
|
-
|
338
|
-
|
339
|
-
|
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
|
-
|
366
|
-
|
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
|
-
|
377
|
-
|
378
|
-
|
379
|
-
|
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
|
-
|
386
|
-
|
387
|
-
|
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
|
407
|
-
constructor(
|
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.
|
650
|
+
this.stateBackend = stateBackend;
|
651
|
+
this.operationManager = operationManager;
|
652
|
+
this.library = library;
|
653
|
+
this.logger = logger;
|
415
654
|
}
|
416
|
-
|
417
|
-
|
418
|
-
|
419
|
-
|
420
|
-
|
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
|
-
|
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
|
-
|
435
|
-
|
436
|
-
|
437
|
-
|
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.
|
458
|
-
|
459
|
-
|
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
|
501
|
-
|
502
|
-
|
503
|
-
|
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
|
688
|
+
return await instancePromise;
|
506
689
|
}
|
507
|
-
async
|
508
|
-
const
|
509
|
-
|
510
|
-
|
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
|
515
|
-
|
516
|
-
|
517
|
-
|
518
|
-
|
519
|
-
|
520
|
-
|
521
|
-
|
522
|
-
|
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
|
-
|
527
|
-
this.
|
528
|
-
this.
|
529
|
-
|
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
|
-
|
532
|
-
this.
|
533
|
-
this.
|
534
|
-
|
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
|
-
|
537
|
-
|
538
|
-
|
539
|
-
|
540
|
-
|
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
|
-
|
547
|
-
const
|
548
|
-
return new ProjectOperator(
|
750
|
+
async waitForCompositeInstance(projectId, instanceId, expectedInputHash, instanceMap) {
|
751
|
+
for await (const instance of this.operationManager.watchCompositeInstance(
|
549
752
|
projectId,
|
550
|
-
|
551
|
-
|
552
|
-
|
553
|
-
|
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
|
-
|
767
|
+
stateBackend,
|
768
|
+
operationManager,
|
769
|
+
library,
|
770
|
+
logger.child({ service: "ProjectManager" })
|
556
771
|
);
|
557
772
|
}
|
558
773
|
}
|
559
774
|
|
560
|
-
|
561
|
-
|
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
|
-
|
590
|
-
|
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
|
-
|
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.
|
972
|
+
`state:${LocalRunnerBackend.getInstanceId(options)}`,
|
607
973
|
{ signal: options.signal }
|
608
974
|
);
|
609
|
-
for await (const [
|
610
|
-
yield
|
611
|
-
if (options.finalStatuses
|
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
|
-
|
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
|
-
|
628
|
-
options.
|
629
|
-
options.
|
630
|
-
|
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
|
635
|
-
|
1060
|
+
const instanceId = LocalRunnerBackend.getInstanceId(options);
|
1061
|
+
this.updateState({
|
1062
|
+
id: instanceId,
|
636
1063
|
status: "updating",
|
637
|
-
|
638
|
-
|
639
|
-
};
|
640
|
-
|
1064
|
+
currentResourceCount: 0,
|
1065
|
+
totalResourceCount: 0
|
1066
|
+
});
|
1067
|
+
let currentResourceCount = 0;
|
1068
|
+
let totalResourceCount = 0;
|
641
1069
|
try {
|
642
|
-
await
|
643
|
-
|
644
|
-
|
645
|
-
|
646
|
-
|
647
|
-
|
648
|
-
|
649
|
-
|
650
|
-
|
651
|
-
|
652
|
-
|
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
|
-
|
656
|
-
|
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
|
-
|
665
|
-
|
666
|
-
|
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.
|
683
|
-
options.
|
684
|
-
options.
|
685
|
-
|
1130
|
+
void this.pulumiProjectHost.runEmpty(
|
1131
|
+
options.projectId,
|
1132
|
+
options.instanceType,
|
1133
|
+
options.instanceName,
|
686
1134
|
async (stack) => {
|
687
|
-
const
|
688
|
-
|
689
|
-
const
|
690
|
-
|
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
|
-
|
693
|
-
|
694
|
-
};
|
695
|
-
this.updateState(state);
|
1141
|
+
currentResourceCount,
|
1142
|
+
totalResourceCount: currentResourceCount
|
1143
|
+
});
|
696
1144
|
try {
|
697
|
-
await
|
698
|
-
|
699
|
-
|
700
|
-
|
701
|
-
|
702
|
-
|
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
|
-
|
706
|
-
|
707
|
-
state.status = "not_created";
|
708
|
-
this.updateState(state);
|
1174
|
+
(error) => LocalPulumiHost.tryUnlockStack(stack, error)
|
1175
|
+
);
|
709
1176
|
} catch (e) {
|
710
|
-
|
711
|
-
|
712
|
-
|
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.
|
729
|
-
options.
|
730
|
-
options.
|
731
|
-
|
1196
|
+
void this.pulumiProjectHost.runEmpty(
|
1197
|
+
options.projectId,
|
1198
|
+
options.instanceType,
|
1199
|
+
options.instanceName,
|
732
1200
|
async (stack) => {
|
733
|
-
const
|
734
|
-
|
735
|
-
const
|
736
|
-
|
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
|
-
|
739
|
-
|
740
|
-
};
|
741
|
-
this.updateState(state);
|
1207
|
+
currentResourceCount,
|
1208
|
+
totalResourceCount: currentResourceCount
|
1209
|
+
});
|
742
1210
|
try {
|
743
|
-
await
|
744
|
-
|
745
|
-
|
746
|
-
|
747
|
-
|
748
|
-
|
749
|
-
|
750
|
-
|
751
|
-
|
752
|
-
|
753
|
-
|
754
|
-
|
755
|
-
|
756
|
-
|
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
|
-
|
760
|
-
|
761
|
-
state.status = "created";
|
762
|
-
this.updateState(state);
|
1240
|
+
(error) => LocalPulumiHost.tryUnlockStack(stack, error)
|
1241
|
+
);
|
763
1242
|
} catch (e) {
|
764
|
-
|
765
|
-
|
766
|
-
|
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
|
-
|
772
|
-
|
773
|
-
|
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
|
-
|
776
|
-
|
777
|
-
|
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
|
787
|
-
return
|
1301
|
+
static getInstanceId(options) {
|
1302
|
+
return getInstanceId(options.instanceType, options.instanceName);
|
788
1303
|
}
|
789
|
-
|
790
|
-
|
791
|
-
|
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
|
-
|
797
|
-
|
798
|
-
|
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
|
-
|
803
|
-
|
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(
|
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
|
-
|
809
|
-
|
810
|
-
|
811
|
-
|
812
|
-
const
|
813
|
-
const
|
814
|
-
|
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
|
-
|
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
|
-
|
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 {
|
2474
|
+
export { OperationManager, SecretAccessDeniedError, TerminalManager, createLibraryBackend, createSecretBackend, createServices, createTerminalBackend, getSharedServices, libraryBackendConfig, loadConfig, secretBackendConfig, terminalBackendConfig };
|