@highstate/backend 0.9.15 → 0.9.16
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/chunk-RCB4AFGD.js +159 -0
- package/dist/chunk-RCB4AFGD.js.map +1 -0
- package/dist/chunk-WHALQHEZ.js +2017 -0
- package/dist/chunk-WHALQHEZ.js.map +1 -0
- package/dist/highstate.manifest.json +3 -3
- package/dist/index.js +6158 -2178
- package/dist/index.js.map +1 -1
- package/dist/library/worker/main.js +47 -155
- package/dist/library/worker/main.js.map +1 -1
- package/dist/shared/index.js +159 -41
- package/package.json +25 -7
- package/src/artifact/abstractions.ts +46 -0
- package/src/artifact/encryption.ts +69 -0
- package/src/artifact/factory.ts +36 -0
- package/src/artifact/index.ts +3 -0
- package/src/artifact/local.ts +142 -0
- package/src/business/api-key.ts +65 -0
- package/src/business/artifact.ts +288 -0
- package/src/business/backend-unlock.ts +10 -0
- package/src/business/index.ts +9 -0
- package/src/business/instance-lock.ts +124 -0
- package/src/business/instance-state.ts +292 -0
- package/src/business/operation.ts +251 -0
- package/src/business/project-unlock.ts +242 -0
- package/src/business/secret.ts +187 -0
- package/src/business/worker.ts +161 -0
- package/src/common/index.ts +2 -1
- package/src/common/performance.ts +44 -0
- package/src/common/tree.ts +33 -0
- package/src/common/utils.ts +40 -1
- package/src/config.ts +14 -10
- package/src/hotstate/abstractions.ts +48 -0
- package/src/hotstate/factory.ts +17 -0
- package/src/{secret → hotstate}/index.ts +1 -0
- package/src/hotstate/manager.ts +192 -0
- package/src/hotstate/memory.ts +100 -0
- package/src/hotstate/validation.ts +101 -0
- package/src/index.ts +2 -1
- package/src/library/abstractions.ts +10 -23
- package/src/library/factory.ts +2 -2
- package/src/library/local.ts +89 -102
- package/src/library/worker/evaluator.ts +9 -42
- package/src/library/worker/loader.lite.ts +41 -0
- package/src/library/worker/main.ts +14 -65
- package/src/library/worker/protocol.ts +8 -24
- package/src/lock/abstractions.ts +6 -0
- package/src/lock/factory.ts +15 -0
- package/src/{workspace → lock}/index.ts +1 -0
- package/src/lock/manager.ts +82 -0
- package/src/lock/memory.ts +19 -0
- package/src/orchestrator/manager.ts +129 -82
- package/src/orchestrator/operation-workset.ts +168 -77
- package/src/orchestrator/operation.ts +967 -284
- package/src/project/abstractions.ts +20 -7
- package/src/project/factory.ts +1 -1
- package/src/project/index.ts +0 -1
- package/src/project/local.ts +73 -17
- package/src/project/manager.ts +272 -131
- package/src/pubsub/abstractions.ts +13 -0
- package/src/pubsub/factory.ts +19 -0
- package/src/pubsub/index.ts +3 -0
- package/src/pubsub/local.ts +36 -0
- package/src/pubsub/manager.ts +100 -0
- package/src/pubsub/validation.ts +33 -0
- package/src/runner/abstractions.ts +135 -68
- package/src/runner/artifact-env.ts +160 -0
- package/src/runner/factory.ts +20 -5
- package/src/runner/force-abort.ts +117 -0
- package/src/runner/local.ts +281 -371
- package/src/{common → runner}/pulumi.ts +86 -37
- package/src/services.ts +193 -35
- package/src/shared/index.ts +3 -11
- package/src/shared/models/backend/index.ts +3 -0
- package/src/shared/models/backend/project.ts +63 -0
- package/src/shared/models/backend/unlock-method.ts +20 -0
- package/src/shared/models/base.ts +151 -0
- package/src/shared/models/errors.ts +5 -0
- package/src/shared/models/index.ts +4 -0
- package/src/shared/models/project/api-key.ts +62 -0
- package/src/shared/models/project/artifact.ts +113 -0
- package/src/shared/models/project/component.ts +45 -0
- package/src/shared/models/project/index.ts +14 -0
- package/src/shared/{project.ts → models/project/instance.ts} +12 -0
- package/src/shared/models/project/lock.ts +91 -0
- package/src/shared/{operation.ts → models/project/operation.ts} +28 -7
- package/src/shared/models/project/page.ts +57 -0
- package/src/shared/models/project/secret.ts +112 -0
- package/src/shared/models/project/service-account.ts +22 -0
- package/src/shared/models/project/state.ts +432 -0
- package/src/shared/models/project/terminal.ts +99 -0
- package/src/shared/models/project/trigger.ts +56 -0
- package/src/shared/models/project/unlock-method.ts +31 -0
- package/src/shared/models/project/worker.ts +105 -0
- package/src/shared/resolvers/graph-resolver.ts +28 -0
- package/src/shared/resolvers/index.ts +5 -0
- package/src/shared/resolvers/input-hash.ts +53 -15
- package/src/shared/resolvers/input.ts +1 -9
- package/src/shared/resolvers/registry.ts +3 -2
- package/src/shared/resolvers/state.ts +2 -2
- package/src/shared/resolvers/validation.ts +61 -20
- package/src/shared/{async-batcher.ts → utils/async-batcher.ts} +13 -1
- package/src/shared/utils/hash.ts +6 -0
- package/src/shared/utils/index.ts +3 -0
- package/src/shared/utils/promise-tracker.ts +23 -0
- package/src/state/abstractions.ts +330 -101
- package/src/state/encryption.ts +59 -0
- package/src/state/factory.ts +3 -5
- package/src/state/index.ts +3 -0
- package/src/state/keyring.ts +22 -0
- package/src/state/local/backend.ts +299 -0
- package/src/state/local/collection.ts +342 -0
- package/src/state/local/index.ts +2 -0
- package/src/state/manager.ts +804 -18
- package/src/state/repository/index.ts +2 -0
- package/src/state/repository/repository.index.ts +193 -0
- package/src/state/repository/repository.ts +458 -0
- package/src/terminal/{shared.ts → abstractions.ts} +3 -3
- package/src/terminal/docker.ts +18 -14
- package/src/terminal/factory.ts +3 -3
- package/src/terminal/index.ts +1 -1
- package/src/terminal/manager.ts +131 -79
- package/src/terminal/run.sh.ts +21 -11
- package/src/worker/abstractions.ts +42 -0
- package/src/worker/docker.ts +83 -0
- package/src/worker/factory.ts +20 -0
- package/src/worker/index.ts +3 -0
- package/src/worker/manager.ts +139 -0
- package/dist/chunk-KTGKNSKM.js +0 -979
- package/dist/chunk-KTGKNSKM.js.map +0 -1
- package/dist/chunk-WXDYCRTT.js +0 -234
- package/dist/chunk-WXDYCRTT.js.map +0 -1
- package/src/library/worker/loader.ts +0 -114
- package/src/preferences/shared.ts +0 -1
- package/src/project/lock.ts +0 -39
- package/src/secret/abstractions.ts +0 -59
- package/src/secret/factory.ts +0 -22
- package/src/secret/local.ts +0 -152
- package/src/shared/state.ts +0 -247
- package/src/shared/terminal.ts +0 -14
- package/src/state/local.ts +0 -612
- package/src/workspace/abstractions.ts +0 -41
- package/src/workspace/factory.ts +0 -14
- package/src/workspace/local.ts +0 -54
- /package/src/shared/{library.ts → models/backend/library.ts} +0 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import type { StateBatch, StateManager } from "../state"
|
|
2
|
+
import type { Logger } from "pino"
|
|
3
|
+
import type { HotStateManager } from "../hotstate"
|
|
4
|
+
import type { PubSubManager } from "../pubsub"
|
|
5
|
+
import { randomBytes } from "node:crypto"
|
|
6
|
+
import { armor, Decrypter, Encrypter } from "age-encryption"
|
|
7
|
+
import { type ProjectUnlockState, type UnlockMethod } from "../shared"
|
|
8
|
+
|
|
9
|
+
type UnlockTask = {
|
|
10
|
+
name: string
|
|
11
|
+
handler: (projectId: string) => Promise<void> | void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class ProjectUnlockService {
|
|
15
|
+
constructor(
|
|
16
|
+
private readonly stateManager: StateManager,
|
|
17
|
+
private readonly hotStateManager: HotStateManager,
|
|
18
|
+
private readonly pubsubManager: PubSubManager,
|
|
19
|
+
private readonly logger: Logger,
|
|
20
|
+
) {}
|
|
21
|
+
|
|
22
|
+
private readonly unlockTasks: UnlockTask[] = []
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Checks if the project is unlocked.
|
|
26
|
+
* A project is considered locked if it has no master key set in memory.
|
|
27
|
+
*
|
|
28
|
+
* @param projectId The ID of the project to check.
|
|
29
|
+
* @returns True if the project is locked, false otherwise.
|
|
30
|
+
*/
|
|
31
|
+
async isProjectUnlocked(projectId: string): Promise<boolean> {
|
|
32
|
+
return await this.hotStateManager.exists(["project-master-key", projectId])
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Gets the current unlock state of the project.
|
|
37
|
+
* If the project is unlocked, it returns an object with type "unlocked".
|
|
38
|
+
* If the project is locked, it returns an object with type "locked" and the unlock suite.
|
|
39
|
+
*
|
|
40
|
+
* @param projectId The ID of the project to get the unlock state for.
|
|
41
|
+
* @returns The unlock state of the project.
|
|
42
|
+
*/
|
|
43
|
+
async getProjectUnlockState(projectId: string): Promise<ProjectUnlockState> {
|
|
44
|
+
const isUnlocked = await this.isProjectUnlocked(projectId)
|
|
45
|
+
if (isUnlocked) {
|
|
46
|
+
return { type: "unlocked" }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const unlockSuite = await this.stateManager.getProjectUnlockSuiteRepository().get(projectId)
|
|
50
|
+
return {
|
|
51
|
+
type: "locked",
|
|
52
|
+
unlockSuite,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Creates a new project state and generates a master key for it.
|
|
58
|
+
*
|
|
59
|
+
* @param projectId The ID of the project to create the state for.
|
|
60
|
+
* @param unlockMethod The unlock method to use to encrypt the master key. Should be provided by the frontend.
|
|
61
|
+
*/
|
|
62
|
+
async createProjectState(projectId: string, unlockMethod: UnlockMethod): Promise<void> {
|
|
63
|
+
const masterKey = randomBytes(32)
|
|
64
|
+
await using batch = this.stateManager.batch()
|
|
65
|
+
|
|
66
|
+
// unlock the project by storing the master key in memory
|
|
67
|
+
await this.hotStateManager.set(["project-master-key", projectId], masterKey)
|
|
68
|
+
|
|
69
|
+
// store the master key in the backend
|
|
70
|
+
await this.updateUnlockSuite(projectId, [unlockMethod], batch)
|
|
71
|
+
await this.updateEncryptedMasterKey(projectId, [unlockMethod], batch, masterKey)
|
|
72
|
+
|
|
73
|
+
// persist the unlock method as any other project data
|
|
74
|
+
await this.stateManager
|
|
75
|
+
.getUnlockMethodRepository(projectId)
|
|
76
|
+
.put(unlockMethod.id, unlockMethod, batch)
|
|
77
|
+
|
|
78
|
+
await batch.write()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Unlocks the project state using the provided identity.
|
|
83
|
+
*
|
|
84
|
+
* @param projectId The ID of the project to unlock.
|
|
85
|
+
* @param decryptedIdentity The decrypted identity to use for unlocking the project. Should be provided by the frontend.
|
|
86
|
+
*/
|
|
87
|
+
async unlockProject(projectId: string, decryptedIdentity: string): Promise<void> {
|
|
88
|
+
if (await this.isProjectUnlocked(projectId)) {
|
|
89
|
+
this.logger.warn(
|
|
90
|
+
{ projectId },
|
|
91
|
+
`project "%s" is already unlocked, skipping unlock operation`,
|
|
92
|
+
projectId,
|
|
93
|
+
)
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// load the encrypted master key for the project
|
|
98
|
+
const armoredMasterKey = await this.stateManager.getProjectMasterKeyRepository().get(projectId)
|
|
99
|
+
if (!armoredMasterKey) {
|
|
100
|
+
throw new Error(`Project ${projectId} does not have a master key set.`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const encryptedMasterKey = armor.decode(armoredMasterKey)
|
|
104
|
+
|
|
105
|
+
const decrypter = new Decrypter()
|
|
106
|
+
decrypter.addIdentity(decryptedIdentity)
|
|
107
|
+
|
|
108
|
+
// decrypt the master key using the provided identity
|
|
109
|
+
const masterKey = await decrypter.decrypt(encryptedMasterKey)
|
|
110
|
+
|
|
111
|
+
// store the master key in memory to unlock the project
|
|
112
|
+
await this.hotStateManager.set(["project-master-key", projectId], masterKey)
|
|
113
|
+
|
|
114
|
+
// load instance states to the hot state
|
|
115
|
+
// TODO: this should be done by something else, ideally lazy-loaded when needed
|
|
116
|
+
const instanceStates = await this.stateManager
|
|
117
|
+
.getInstanceStateRepository(projectId)
|
|
118
|
+
.getAllItems()
|
|
119
|
+
|
|
120
|
+
await this.hotStateManager.hmset(
|
|
121
|
+
["instance-states", projectId],
|
|
122
|
+
instanceStates.map(state => [state.id, state]),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
await this.pubsubManager.publish(["project-unlock-state", projectId], { type: "unlocked" })
|
|
126
|
+
|
|
127
|
+
// run unlock tasks
|
|
128
|
+
await this.runUnlockTasks(projectId)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private async runUnlockTasks(projectId: string): Promise<void> {
|
|
132
|
+
for (const task of this.unlockTasks) {
|
|
133
|
+
try {
|
|
134
|
+
await task.handler(projectId)
|
|
135
|
+
} catch (error) {
|
|
136
|
+
this.logger.error({ error }, `unlock task "%s" failed`, task.name)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Adds a new unlock method to the project and updates the master identity set.
|
|
143
|
+
* The project must be unlocked.
|
|
144
|
+
*
|
|
145
|
+
* @param projectId The ID of the project to add the unlock method to.
|
|
146
|
+
* @param unlockMethod The unlock method to add. Should be provided by the frontend.
|
|
147
|
+
*/
|
|
148
|
+
async addProjectUnlockMethod(projectId: string, unlockMethod: UnlockMethod): Promise<void> {
|
|
149
|
+
await using batch = this.stateManager.batch()
|
|
150
|
+
|
|
151
|
+
// add the unlock method to the repository
|
|
152
|
+
await this.stateManager
|
|
153
|
+
.getUnlockMethodRepository(projectId)
|
|
154
|
+
.put(unlockMethod.id, unlockMethod, batch)
|
|
155
|
+
|
|
156
|
+
// get all existing unlock methods
|
|
157
|
+
const existingMethods = await this.stateManager
|
|
158
|
+
.getUnlockMethodRepository(projectId)
|
|
159
|
+
.getAllItems()
|
|
160
|
+
|
|
161
|
+
// update the encrypted master identity set and master key
|
|
162
|
+
await this.updateUnlockSuite(projectId, existingMethods, batch)
|
|
163
|
+
await this.updateEncryptedMasterKey(projectId, existingMethods, batch)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Removes an unlock method from the project and updates the master identity set.
|
|
168
|
+
* The project must be unlocked.
|
|
169
|
+
*
|
|
170
|
+
* @param projectId The ID of the project to remove the unlock method from.
|
|
171
|
+
* @param unlockMethodId The ID of the unlock method to remove.
|
|
172
|
+
*/
|
|
173
|
+
async removeProjectUnlockMethod(projectId: string, unlockMethodId: string): Promise<void> {
|
|
174
|
+
await using batch = this.stateManager.batch()
|
|
175
|
+
|
|
176
|
+
// get all existing unlock methods
|
|
177
|
+
let existingMethods = await this.stateManager.getUnlockMethodRepository(projectId).getAllItems()
|
|
178
|
+
|
|
179
|
+
// do not allow removing the last unlock method under any circumstances
|
|
180
|
+
if (existingMethods.length === 1) {
|
|
181
|
+
throw new Error("Rejected removing the last unlock method!")
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// remove the unlock method from the repository
|
|
185
|
+
await this.stateManager.getUnlockMethodRepository(projectId).delete(unlockMethodId, batch)
|
|
186
|
+
existingMethods = existingMethods.filter(method => method.id !== unlockMethodId)
|
|
187
|
+
|
|
188
|
+
// update the encrypted master identity set and master key
|
|
189
|
+
await this.updateUnlockSuite(projectId, existingMethods, batch)
|
|
190
|
+
await this.updateEncryptedMasterKey(projectId, existingMethods, batch)
|
|
191
|
+
|
|
192
|
+
await batch.write()
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Registers a new unlock task that will be executed when the project is unlocked.
|
|
197
|
+
* This can be used to perform additional actions after unlocking, such as loading instance states.
|
|
198
|
+
*
|
|
199
|
+
* @param name The name of the unlock task.
|
|
200
|
+
* @param handler The handler function for the unlock task. It receives the project ID as an argument.
|
|
201
|
+
*/
|
|
202
|
+
registerUnlockTask(name: string, handler: (projectId: string) => Promise<void> | void): void {
|
|
203
|
+
this.unlockTasks.push({ name, handler })
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private async updateUnlockSuite(
|
|
207
|
+
projectId: string,
|
|
208
|
+
unlockMethods: UnlockMethod[],
|
|
209
|
+
batch: StateBatch,
|
|
210
|
+
): Promise<void> {
|
|
211
|
+
// write new encrypted master identity set
|
|
212
|
+
await this.stateManager.getProjectUnlockSuiteRepository().put(
|
|
213
|
+
projectId,
|
|
214
|
+
{
|
|
215
|
+
encryptedIdentities: unlockMethods.map(method => method.encryptedIdentity),
|
|
216
|
+
hasPasskey: unlockMethods.some(method => method.type === "passkey"),
|
|
217
|
+
},
|
|
218
|
+
batch,
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private async updateEncryptedMasterKey(
|
|
223
|
+
projectId: string,
|
|
224
|
+
unlockMethods: UnlockMethod[],
|
|
225
|
+
batch: StateBatch,
|
|
226
|
+
masterKey?: Uint8Array,
|
|
227
|
+
): Promise<void> {
|
|
228
|
+
masterKey ??= await this.stateManager.getProjectMasterKey(projectId)
|
|
229
|
+
|
|
230
|
+
const encrypter = new Encrypter()
|
|
231
|
+
for (const method of unlockMethods) {
|
|
232
|
+
encrypter.addRecipient(method.recipient)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// encrypt the master key for all unlock methods
|
|
236
|
+
const encryptedMasterKey = await encrypter.encrypt(masterKey)
|
|
237
|
+
|
|
238
|
+
// set the encrypted master key in the backend
|
|
239
|
+
const armoredMasterKey = armor.encode(encryptedMasterKey)
|
|
240
|
+
await this.stateManager.getProjectMasterKeyRepository().put(projectId, armoredMasterKey, batch)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { Logger } from "pino"
|
|
2
|
+
import type { LibraryBackend } from "../library"
|
|
3
|
+
import type { ProjectBackend } from "../project"
|
|
4
|
+
import type { StateManager } from "../state"
|
|
5
|
+
import type { InstanceStateService } from "./instance-state"
|
|
6
|
+
import { v7 as uuidv7 } from "uuid"
|
|
7
|
+
import { isUnitModel, parseInstanceId } from "@highstate/contract"
|
|
8
|
+
import { formatSecretDescriptor, type Secret, type SecretDescriptor } from "../shared"
|
|
9
|
+
|
|
10
|
+
export class SecretService {
|
|
11
|
+
constructor(
|
|
12
|
+
private readonly stateManager: StateManager,
|
|
13
|
+
private readonly libraryBackend: LibraryBackend,
|
|
14
|
+
private readonly projectBackend: ProjectBackend,
|
|
15
|
+
private readonly instanceStateService: InstanceStateService,
|
|
16
|
+
private readonly logger: Logger,
|
|
17
|
+
) {}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Updates secrets for a specific instance, handling both creation/updates and deletions.
|
|
21
|
+
* Also updates the instance state's secretNames field.
|
|
22
|
+
*
|
|
23
|
+
* @param projectId The project ID.
|
|
24
|
+
* @param instanceId The instance ID.
|
|
25
|
+
* @param changedSecretValues The secrets to create or update.
|
|
26
|
+
* @param deletedSecretNames The names of secrets to delete.
|
|
27
|
+
* @param invalidateState Whether to invalidate the instance state after updating secrets.
|
|
28
|
+
*/
|
|
29
|
+
async updateInstanceSecrets(
|
|
30
|
+
projectId: string,
|
|
31
|
+
instanceId: string,
|
|
32
|
+
changedSecretValues: Record<string, unknown>,
|
|
33
|
+
deletedSecretNames: string[],
|
|
34
|
+
invalidateState = true,
|
|
35
|
+
): Promise<void> {
|
|
36
|
+
const projectInfo = await this.projectBackend.getProjectInfo(projectId)
|
|
37
|
+
const library = await this.libraryBackend.loadLibrary(projectInfo.libraryId)
|
|
38
|
+
|
|
39
|
+
const [type] = parseInstanceId(instanceId)
|
|
40
|
+
const component = library.components[type]
|
|
41
|
+
if (!component) {
|
|
42
|
+
throw new Error(`Component type ${type} not found in library`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!isUnitModel(component)) {
|
|
46
|
+
throw new Error(`Component type ${type} is not a unit model`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// validate changed secrets
|
|
50
|
+
for (const secretName of Object.keys(changedSecretValues)) {
|
|
51
|
+
if (!component.secrets[secretName]) {
|
|
52
|
+
throw new Error(`Secret ${secretName} not found in component ${type}`)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// validate deleted secrets
|
|
57
|
+
for (const secretName of deletedSecretNames) {
|
|
58
|
+
if (!component.secrets[secretName]) {
|
|
59
|
+
throw new Error(`Secret ${secretName} not found in component ${type}`)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// use a batch for atomic operations
|
|
64
|
+
await using batch = this.stateManager.batch()
|
|
65
|
+
|
|
66
|
+
const secretRepo = this.stateManager.getSecretRepository(projectId)
|
|
67
|
+
const secretContentRepo = this.stateManager.getSecretContentRepository(projectId)
|
|
68
|
+
const indexRepo = this.stateManager.createSecretIndexRepository(projectId)
|
|
69
|
+
|
|
70
|
+
// process changed secrets
|
|
71
|
+
for (const [secretName, value] of Object.entries(changedSecretValues)) {
|
|
72
|
+
const descriptor: SecretDescriptor = { type: "instance", instanceId, secretName }
|
|
73
|
+
const indexKey = formatSecretDescriptor(descriptor)
|
|
74
|
+
|
|
75
|
+
// check if secret already exists
|
|
76
|
+
const existingSecret = await indexRepo.get(indexKey)
|
|
77
|
+
|
|
78
|
+
// create or update secret info
|
|
79
|
+
const secret: Secret = {
|
|
80
|
+
id: existingSecret?.id ?? uuidv7(),
|
|
81
|
+
descriptor: descriptor,
|
|
82
|
+
meta: {
|
|
83
|
+
...existingSecret?.meta,
|
|
84
|
+
...component.secrets[secretName].meta,
|
|
85
|
+
icon: component.secrets[secretName].meta.primaryIcon ?? component.meta.primaryIcon,
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// store secret info and content
|
|
90
|
+
await secretRepo.putItem(secret, batch)
|
|
91
|
+
await secretContentRepo.put(indexKey, value, batch)
|
|
92
|
+
|
|
93
|
+
// update index mapping
|
|
94
|
+
await indexRepo.indexRepository.put(indexKey, secret.id, batch)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// process deleted secrets
|
|
98
|
+
for (const secretName of deletedSecretNames) {
|
|
99
|
+
const indexKey = formatSecretDescriptor({ type: "instance", instanceId, secretName })
|
|
100
|
+
|
|
101
|
+
// get the secret ID from index
|
|
102
|
+
const secretId = await indexRepo.indexRepository.get(indexKey)
|
|
103
|
+
if (secretId) {
|
|
104
|
+
// delete secret info, content, and index entry
|
|
105
|
+
await secretRepo.delete(secretId, batch)
|
|
106
|
+
await secretContentRepo.delete(indexKey, batch)
|
|
107
|
+
await indexRepo.indexRepository.delete(indexKey, batch)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// write all changes atomically
|
|
112
|
+
await batch.write()
|
|
113
|
+
|
|
114
|
+
// update instance state secretNames
|
|
115
|
+
await this.instanceStateService.updateStateSecretNames(
|
|
116
|
+
projectId,
|
|
117
|
+
instanceId,
|
|
118
|
+
Object.keys(changedSecretValues),
|
|
119
|
+
deletedSecretNames,
|
|
120
|
+
invalidateState,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
this.logger.info(
|
|
124
|
+
{
|
|
125
|
+
projectId,
|
|
126
|
+
instanceId,
|
|
127
|
+
changedCount: Object.keys(changedSecretValues).length,
|
|
128
|
+
deletedCount: deletedSecretNames.length,
|
|
129
|
+
},
|
|
130
|
+
"updated instance secrets for instance %s in project %s",
|
|
131
|
+
instanceId,
|
|
132
|
+
projectId,
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Gets the secrets for a specific instance.
|
|
138
|
+
*
|
|
139
|
+
* @param projectId The project ID.
|
|
140
|
+
* @param instanceId The instance ID.
|
|
141
|
+
* @returns A record of secret key-value pairs.
|
|
142
|
+
*/
|
|
143
|
+
async getInstanceSecrets(
|
|
144
|
+
projectId: string,
|
|
145
|
+
instanceId: string,
|
|
146
|
+
): Promise<Record<string, unknown>> {
|
|
147
|
+
const projectInfo = await this.projectBackend.getProjectInfo(projectId)
|
|
148
|
+
const library = await this.libraryBackend.loadLibrary(projectInfo.libraryId)
|
|
149
|
+
|
|
150
|
+
const [type] = parseInstanceId(instanceId)
|
|
151
|
+
const component = library.components[type]
|
|
152
|
+
if (!component) {
|
|
153
|
+
throw new Error(`Component type ${type} not found in library`)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!isUnitModel(component)) {
|
|
157
|
+
throw new Error(`Component type ${type} is not a unit model`)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const secretNames = Object.keys(component.secrets)
|
|
161
|
+
if (secretNames.length === 0) {
|
|
162
|
+
return {}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// get all index keys for this instance's secrets
|
|
166
|
+
const indexKeys = secretNames.map(secretName =>
|
|
167
|
+
formatSecretDescriptor({ type: "instance", instanceId, secretName }),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
// get the secret contents using the formatted secret IDs directly
|
|
171
|
+
const secretContentsMap = await this.stateManager
|
|
172
|
+
.getSecretContentRepository(projectId)
|
|
173
|
+
.getManyRecord(indexKeys)
|
|
174
|
+
|
|
175
|
+
// build the result mapping secret names to values
|
|
176
|
+
const secrets: Record<string, unknown> = {}
|
|
177
|
+
for (const secretName of secretNames) {
|
|
178
|
+
const indexKey = formatSecretDescriptor({ type: "instance", instanceId, secretName })
|
|
179
|
+
const value = secretContentsMap[indexKey]
|
|
180
|
+
if (value) {
|
|
181
|
+
secrets[secretName] = value
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return secrets
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { Logger } from "pino"
|
|
2
|
+
import type { LockManager } from "../lock"
|
|
3
|
+
import type { WorkerManager } from "../worker"
|
|
4
|
+
import type { InstanceStateService } from "./instance-state"
|
|
5
|
+
import { randomBytes } from "node:crypto"
|
|
6
|
+
import { v7 as uuidv7 } from "uuid"
|
|
7
|
+
import {
|
|
8
|
+
getWorkerIdentity,
|
|
9
|
+
type InstanceState,
|
|
10
|
+
type ProjectApiKey,
|
|
11
|
+
type ServiceAccount,
|
|
12
|
+
type UnitWorker,
|
|
13
|
+
type Worker,
|
|
14
|
+
type WorkerUnitRegistration,
|
|
15
|
+
} from "../shared"
|
|
16
|
+
import { SAME_KEY, type StateManager } from "../state"
|
|
17
|
+
|
|
18
|
+
export class WorkerService {
|
|
19
|
+
constructor(
|
|
20
|
+
private readonly stateManager: StateManager,
|
|
21
|
+
private readonly workerManager: WorkerManager,
|
|
22
|
+
private readonly lockManager: LockManager,
|
|
23
|
+
private readonly instanceStateService: InstanceStateService,
|
|
24
|
+
private readonly logger: Logger,
|
|
25
|
+
) {}
|
|
26
|
+
|
|
27
|
+
async handleUnitWorker(
|
|
28
|
+
projectId: string,
|
|
29
|
+
state: InstanceState,
|
|
30
|
+
unitWorker: UnitWorker,
|
|
31
|
+
): Promise<void> {
|
|
32
|
+
await this.lockManager.acquire(["worker-image", projectId, unitWorker.image], async () => {
|
|
33
|
+
const existingRegistrations = await this.stateManager
|
|
34
|
+
.getWorkerRegistrationRepository(projectId)
|
|
35
|
+
.getManyItems(state.extra?.workerRegistrationIds ?? [])
|
|
36
|
+
|
|
37
|
+
const existingReg = existingRegistrations.find(reg => reg.image === unitWorker.image)
|
|
38
|
+
if (existingReg && JSON.stringify(existingReg.params) === JSON.stringify(unitWorker.params)) {
|
|
39
|
+
this.logger.debug(
|
|
40
|
+
`instance "%s" already registered with worker "%s" with the same parameters`,
|
|
41
|
+
state.id,
|
|
42
|
+
unitWorker.image,
|
|
43
|
+
)
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let workerId = existingReg?.workerId
|
|
48
|
+
if (!workerId) {
|
|
49
|
+
workerId = await this.ensureWorkerCreated(projectId, unitWorker)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const registration: WorkerUnitRegistration = {
|
|
53
|
+
id: existingReg?.id ?? uuidv7(),
|
|
54
|
+
meta: existingReg?.meta ?? {},
|
|
55
|
+
image: unitWorker.image,
|
|
56
|
+
params: unitWorker.params,
|
|
57
|
+
instanceId: state.id,
|
|
58
|
+
workerId,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const batch = this.stateManager.batch()
|
|
62
|
+
|
|
63
|
+
await this.stateManager
|
|
64
|
+
.getWorkerRegistrationRepository(projectId)
|
|
65
|
+
.putItem(registration, batch)
|
|
66
|
+
|
|
67
|
+
if (!existingReg) {
|
|
68
|
+
// update the index if this is a new registration
|
|
69
|
+
await this.stateManager
|
|
70
|
+
.getWorkerRegistrationIndexRepository(projectId, workerId)
|
|
71
|
+
.indexRepository.put(registration.id, SAME_KEY, batch)
|
|
72
|
+
|
|
73
|
+
// update the instance state with the new registration ID
|
|
74
|
+
await this.instanceStateService.patchInstanceState(projectId, {
|
|
75
|
+
id: state.id,
|
|
76
|
+
extra: {
|
|
77
|
+
workerRegistrationIds: [...(state.extra?.workerRegistrationIds ?? []), registration.id],
|
|
78
|
+
},
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await batch.write()
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private async ensureWorkerCreated(projectId: string, unitWorker: UnitWorker): Promise<string> {
|
|
87
|
+
const identity = getWorkerIdentity(unitWorker.image)
|
|
88
|
+
|
|
89
|
+
return await this.lockManager.acquire(["worker-image", projectId, identity], async () => {
|
|
90
|
+
// it is not critical to fetch all workers since their count is not expected to be high
|
|
91
|
+
const workers = await this.stateManager.getWorkerRepository(projectId).getAllItems()
|
|
92
|
+
|
|
93
|
+
const existingWorker = workers.find(worker => worker.image === unitWorker.image)
|
|
94
|
+
if (existingWorker) {
|
|
95
|
+
this.logger.debug(`worker with image "%s" already exists, reusing it`, unitWorker.image)
|
|
96
|
+
return existingWorker.id
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let serviceAccountId: string | undefined
|
|
100
|
+
|
|
101
|
+
const siblingWorker = workers.find(worker => worker.identity === identity)
|
|
102
|
+
if (siblingWorker) {
|
|
103
|
+
this.logger.debug(
|
|
104
|
+
`sibling worker with identity "%s" already exists, using its service account`,
|
|
105
|
+
identity,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
serviceAccountId = siblingWorker.serviceAccountId
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const batch = this.stateManager.batch()
|
|
112
|
+
|
|
113
|
+
// create an empty service account if it is the first worker with this identity
|
|
114
|
+
// the meta of the account will be updated by the worker itself
|
|
115
|
+
if (!serviceAccountId) {
|
|
116
|
+
const serviceAccount: ServiceAccount = {
|
|
117
|
+
id: uuidv7(),
|
|
118
|
+
meta: {},
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
serviceAccountId = serviceAccount.id
|
|
122
|
+
await this.stateManager
|
|
123
|
+
.getServiceAccountRepository(projectId)
|
|
124
|
+
.putItem(serviceAccount, batch)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const apiKey: ProjectApiKey = {
|
|
128
|
+
id: uuidv7(),
|
|
129
|
+
meta: {},
|
|
130
|
+
token: randomBytes(32).toString("hex"),
|
|
131
|
+
scopes: [
|
|
132
|
+
{
|
|
133
|
+
type: "service-account",
|
|
134
|
+
actions: ["full"],
|
|
135
|
+
serviceAccountIds: [serviceAccountId],
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
await this.stateManager.getApiKeyRepository(projectId).putItem(apiKey, batch)
|
|
141
|
+
|
|
142
|
+
const worker: Worker = {
|
|
143
|
+
id: uuidv7(),
|
|
144
|
+
meta: {},
|
|
145
|
+
status: "starting",
|
|
146
|
+
failedStartAttempts: 5,
|
|
147
|
+
identity,
|
|
148
|
+
image: unitWorker.image,
|
|
149
|
+
serviceAccountId,
|
|
150
|
+
apiKeyId: apiKey.id,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
await this.stateManager.getWorkerRepository(projectId).putItem(worker, batch)
|
|
154
|
+
await batch.write()
|
|
155
|
+
|
|
156
|
+
this.workerManager.startWorker(projectId, worker)
|
|
157
|
+
|
|
158
|
+
return worker.id
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
}
|
package/src/common/index.ts
CHANGED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Logger } from "pino"
|
|
2
|
+
|
|
3
|
+
export class PerformanceLogger {
|
|
4
|
+
private readonly start: number
|
|
5
|
+
private last: number
|
|
6
|
+
|
|
7
|
+
constructor(private readonly logger: Logger) {
|
|
8
|
+
this.start = Date.now()
|
|
9
|
+
this.last = this.start
|
|
10
|
+
|
|
11
|
+
this.logger.child({})
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
15
|
+
log(message: string, ...args: any[]): void {
|
|
16
|
+
const now = Date.now()
|
|
17
|
+
const diff = now - this.last
|
|
18
|
+
const absDiff = now - this.start
|
|
19
|
+
|
|
20
|
+
this.last = now
|
|
21
|
+
|
|
22
|
+
this.logger.debug(
|
|
23
|
+
{
|
|
24
|
+
message,
|
|
25
|
+
diff: PerformanceLogger.msToHumanReadable(diff),
|
|
26
|
+
absDiff: PerformanceLogger.msToHumanReadable(absDiff),
|
|
27
|
+
},
|
|
28
|
+
message,
|
|
29
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
30
|
+
...args,
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private static msToHumanReadable(ms: number): string {
|
|
35
|
+
const seconds = Math.floor(ms / 1000)
|
|
36
|
+
const msPart = ms % 1000
|
|
37
|
+
|
|
38
|
+
if (seconds > 0) {
|
|
39
|
+
return `${seconds}s ${msPart}ms`
|
|
40
|
+
} else {
|
|
41
|
+
return `${msPart}ms`
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type TreeNode = {
|
|
2
|
+
text: string
|
|
3
|
+
children: TreeNode[]
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Renders a tree structure similar to the Linux tree command output.
|
|
8
|
+
* Uses box-drawing characters to create visual hierarchy.
|
|
9
|
+
*/
|
|
10
|
+
export function renderTree(node: TreeNode): string {
|
|
11
|
+
const lines: string[] = []
|
|
12
|
+
|
|
13
|
+
function renderNode(node: TreeNode, prefix: string = "", isLast: boolean = true): void {
|
|
14
|
+
// Add current node
|
|
15
|
+
lines.push(prefix + (isLast ? "└── " : "├── ") + node.text)
|
|
16
|
+
|
|
17
|
+
// Add children
|
|
18
|
+
const childPrefix = prefix + (isLast ? " " : "│ ")
|
|
19
|
+
node.children.forEach((child, index) => {
|
|
20
|
+
const isLastChild = index === node.children.length - 1
|
|
21
|
+
renderNode(child, childPrefix, isLastChild)
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Start with root node (no prefix)
|
|
26
|
+
lines.push(node.text)
|
|
27
|
+
node.children.forEach((child, index) => {
|
|
28
|
+
const isLastChild = index === node.children.length - 1
|
|
29
|
+
renderNode(child, "", isLastChild)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
return lines.join("\n")
|
|
33
|
+
}
|