@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.
Files changed (144) hide show
  1. package/dist/chunk-RCB4AFGD.js +159 -0
  2. package/dist/chunk-RCB4AFGD.js.map +1 -0
  3. package/dist/chunk-WHALQHEZ.js +2017 -0
  4. package/dist/chunk-WHALQHEZ.js.map +1 -0
  5. package/dist/highstate.manifest.json +3 -3
  6. package/dist/index.js +6158 -2178
  7. package/dist/index.js.map +1 -1
  8. package/dist/library/worker/main.js +47 -155
  9. package/dist/library/worker/main.js.map +1 -1
  10. package/dist/shared/index.js +159 -41
  11. package/package.json +25 -7
  12. package/src/artifact/abstractions.ts +46 -0
  13. package/src/artifact/encryption.ts +69 -0
  14. package/src/artifact/factory.ts +36 -0
  15. package/src/artifact/index.ts +3 -0
  16. package/src/artifact/local.ts +142 -0
  17. package/src/business/api-key.ts +65 -0
  18. package/src/business/artifact.ts +288 -0
  19. package/src/business/backend-unlock.ts +10 -0
  20. package/src/business/index.ts +9 -0
  21. package/src/business/instance-lock.ts +124 -0
  22. package/src/business/instance-state.ts +292 -0
  23. package/src/business/operation.ts +251 -0
  24. package/src/business/project-unlock.ts +242 -0
  25. package/src/business/secret.ts +187 -0
  26. package/src/business/worker.ts +161 -0
  27. package/src/common/index.ts +2 -1
  28. package/src/common/performance.ts +44 -0
  29. package/src/common/tree.ts +33 -0
  30. package/src/common/utils.ts +40 -1
  31. package/src/config.ts +14 -10
  32. package/src/hotstate/abstractions.ts +48 -0
  33. package/src/hotstate/factory.ts +17 -0
  34. package/src/{secret → hotstate}/index.ts +1 -0
  35. package/src/hotstate/manager.ts +192 -0
  36. package/src/hotstate/memory.ts +100 -0
  37. package/src/hotstate/validation.ts +101 -0
  38. package/src/index.ts +2 -1
  39. package/src/library/abstractions.ts +10 -23
  40. package/src/library/factory.ts +2 -2
  41. package/src/library/local.ts +89 -102
  42. package/src/library/worker/evaluator.ts +9 -42
  43. package/src/library/worker/loader.lite.ts +41 -0
  44. package/src/library/worker/main.ts +14 -65
  45. package/src/library/worker/protocol.ts +8 -24
  46. package/src/lock/abstractions.ts +6 -0
  47. package/src/lock/factory.ts +15 -0
  48. package/src/{workspace → lock}/index.ts +1 -0
  49. package/src/lock/manager.ts +82 -0
  50. package/src/lock/memory.ts +19 -0
  51. package/src/orchestrator/manager.ts +129 -82
  52. package/src/orchestrator/operation-workset.ts +168 -77
  53. package/src/orchestrator/operation.ts +967 -284
  54. package/src/project/abstractions.ts +20 -7
  55. package/src/project/factory.ts +1 -1
  56. package/src/project/index.ts +0 -1
  57. package/src/project/local.ts +73 -17
  58. package/src/project/manager.ts +272 -131
  59. package/src/pubsub/abstractions.ts +13 -0
  60. package/src/pubsub/factory.ts +19 -0
  61. package/src/pubsub/index.ts +3 -0
  62. package/src/pubsub/local.ts +36 -0
  63. package/src/pubsub/manager.ts +100 -0
  64. package/src/pubsub/validation.ts +33 -0
  65. package/src/runner/abstractions.ts +135 -68
  66. package/src/runner/artifact-env.ts +160 -0
  67. package/src/runner/factory.ts +20 -5
  68. package/src/runner/force-abort.ts +117 -0
  69. package/src/runner/local.ts +281 -371
  70. package/src/{common → runner}/pulumi.ts +86 -37
  71. package/src/services.ts +193 -35
  72. package/src/shared/index.ts +3 -11
  73. package/src/shared/models/backend/index.ts +3 -0
  74. package/src/shared/models/backend/project.ts +63 -0
  75. package/src/shared/models/backend/unlock-method.ts +20 -0
  76. package/src/shared/models/base.ts +151 -0
  77. package/src/shared/models/errors.ts +5 -0
  78. package/src/shared/models/index.ts +4 -0
  79. package/src/shared/models/project/api-key.ts +62 -0
  80. package/src/shared/models/project/artifact.ts +113 -0
  81. package/src/shared/models/project/component.ts +45 -0
  82. package/src/shared/models/project/index.ts +14 -0
  83. package/src/shared/{project.ts → models/project/instance.ts} +12 -0
  84. package/src/shared/models/project/lock.ts +91 -0
  85. package/src/shared/{operation.ts → models/project/operation.ts} +28 -7
  86. package/src/shared/models/project/page.ts +57 -0
  87. package/src/shared/models/project/secret.ts +112 -0
  88. package/src/shared/models/project/service-account.ts +22 -0
  89. package/src/shared/models/project/state.ts +432 -0
  90. package/src/shared/models/project/terminal.ts +99 -0
  91. package/src/shared/models/project/trigger.ts +56 -0
  92. package/src/shared/models/project/unlock-method.ts +31 -0
  93. package/src/shared/models/project/worker.ts +105 -0
  94. package/src/shared/resolvers/graph-resolver.ts +28 -0
  95. package/src/shared/resolvers/index.ts +5 -0
  96. package/src/shared/resolvers/input-hash.ts +53 -15
  97. package/src/shared/resolvers/input.ts +1 -9
  98. package/src/shared/resolvers/registry.ts +3 -2
  99. package/src/shared/resolvers/state.ts +2 -2
  100. package/src/shared/resolvers/validation.ts +61 -20
  101. package/src/shared/{async-batcher.ts → utils/async-batcher.ts} +13 -1
  102. package/src/shared/utils/hash.ts +6 -0
  103. package/src/shared/utils/index.ts +3 -0
  104. package/src/shared/utils/promise-tracker.ts +23 -0
  105. package/src/state/abstractions.ts +330 -101
  106. package/src/state/encryption.ts +59 -0
  107. package/src/state/factory.ts +3 -5
  108. package/src/state/index.ts +3 -0
  109. package/src/state/keyring.ts +22 -0
  110. package/src/state/local/backend.ts +299 -0
  111. package/src/state/local/collection.ts +342 -0
  112. package/src/state/local/index.ts +2 -0
  113. package/src/state/manager.ts +804 -18
  114. package/src/state/repository/index.ts +2 -0
  115. package/src/state/repository/repository.index.ts +193 -0
  116. package/src/state/repository/repository.ts +458 -0
  117. package/src/terminal/{shared.ts → abstractions.ts} +3 -3
  118. package/src/terminal/docker.ts +18 -14
  119. package/src/terminal/factory.ts +3 -3
  120. package/src/terminal/index.ts +1 -1
  121. package/src/terminal/manager.ts +131 -79
  122. package/src/terminal/run.sh.ts +21 -11
  123. package/src/worker/abstractions.ts +42 -0
  124. package/src/worker/docker.ts +83 -0
  125. package/src/worker/factory.ts +20 -0
  126. package/src/worker/index.ts +3 -0
  127. package/src/worker/manager.ts +139 -0
  128. package/dist/chunk-KTGKNSKM.js +0 -979
  129. package/dist/chunk-KTGKNSKM.js.map +0 -1
  130. package/dist/chunk-WXDYCRTT.js +0 -234
  131. package/dist/chunk-WXDYCRTT.js.map +0 -1
  132. package/src/library/worker/loader.ts +0 -114
  133. package/src/preferences/shared.ts +0 -1
  134. package/src/project/lock.ts +0 -39
  135. package/src/secret/abstractions.ts +0 -59
  136. package/src/secret/factory.ts +0 -22
  137. package/src/secret/local.ts +0 -152
  138. package/src/shared/state.ts +0 -247
  139. package/src/shared/terminal.ts +0 -14
  140. package/src/state/local.ts +0 -612
  141. package/src/workspace/abstractions.ts +0 -41
  142. package/src/workspace/factory.ts +0 -14
  143. package/src/workspace/local.ts +0 -54
  144. /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
+ }
@@ -1,3 +1,4 @@
1
1
  export * from "./utils"
2
- export * from "./pulumi"
3
2
  export * from "./local"
3
+ export * from "./tree"
4
+ export * from "./performance"
@@ -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
+ }