@highstate/backend 0.9.14 → 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 +6146 -2174
- package/dist/index.js.map +1 -1
- package/dist/library/worker/main.js +51 -159
- package/dist/library/worker/main.js.map +1 -1
- package/dist/shared/index.js +159 -43
- 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 +14 -47
- 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 +131 -82
- package/src/orchestrator/operation-workset.ts +188 -77
- package/src/orchestrator/operation.ts +975 -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 -372
- 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} +43 -8
- 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 +74 -13
- 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 +7 -2
- package/src/shared/resolvers/state.ts +12 -0
- 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 +134 -80
- package/src/terminal/run.sh.ts +22 -10
- 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-C2TJAQAD.js +0 -937
- package/dist/chunk-C2TJAQAD.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 -270
- package/src/shared/terminal.ts +0 -13
- 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,124 @@
|
|
|
1
|
+
import type { Logger } from "pino"
|
|
2
|
+
import type { LockManager } from "../lock"
|
|
3
|
+
import type { PubSubManager } from "../pubsub"
|
|
4
|
+
import type { StateManager } from "../state"
|
|
5
|
+
import { compareInstanceLockSpecs, type InstanceLockSpec, type ObjectMeta } from "../shared"
|
|
6
|
+
|
|
7
|
+
export class InstanceLockService {
|
|
8
|
+
constructor(
|
|
9
|
+
private readonly stateManager: StateManager,
|
|
10
|
+
private readonly lockManager: LockManager,
|
|
11
|
+
private readonly pubsubManager: PubSubManager,
|
|
12
|
+
private readonly logger: Logger,
|
|
13
|
+
) {}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Tries to lock instances in the project.
|
|
17
|
+
* Returns true if the lock was acquired successfully, false otherwise.
|
|
18
|
+
*
|
|
19
|
+
* @param projectId The project ID to lock instances in.
|
|
20
|
+
* @param instanceIds The instance IDs to lock.
|
|
21
|
+
* @param lockMeta The metadata for the lock.
|
|
22
|
+
* @param spec The lock specification containing the lock type and other parameters.
|
|
23
|
+
* @param allowPartialLock Whether to allow partial locking of instances.
|
|
24
|
+
* If true, it will lock as many instances as possible even if some are already locked.
|
|
25
|
+
*
|
|
26
|
+
* @return A tuple containing a boolean indicating if the lock was acquired,
|
|
27
|
+
* and an array of instance IDs that were successfully locked.
|
|
28
|
+
* If `allowPartialLock` is false and some instances are already locked,
|
|
29
|
+
* it will return false and an empty array.
|
|
30
|
+
*/
|
|
31
|
+
async tryLockInstances(
|
|
32
|
+
projectId: string,
|
|
33
|
+
instanceIds: string[],
|
|
34
|
+
lockMeta: ObjectMeta,
|
|
35
|
+
spec: InstanceLockSpec,
|
|
36
|
+
allowPartialLock = false,
|
|
37
|
+
): Promise<[locked: boolean, lockedInstanceIds: string[]]> {
|
|
38
|
+
const lockKeys = instanceIds.map(id => ["instance-lock", projectId, id] as const)
|
|
39
|
+
|
|
40
|
+
// acquire the hard lock to ensure no other process can manage the lock entries
|
|
41
|
+
return await this.lockManager.acquire(lockKeys, async () => {
|
|
42
|
+
// check if the instances are already locked
|
|
43
|
+
const existingLocks = await this.stateManager
|
|
44
|
+
.getInstanceLockRepository(projectId)
|
|
45
|
+
.getManyItems(instanceIds)
|
|
46
|
+
|
|
47
|
+
// only the locks with another spec are considered as the conflicting locks
|
|
48
|
+
const conflictingLocks = existingLocks.filter(
|
|
49
|
+
lock => !compareInstanceLockSpecs(lock.spec, spec),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if (conflictingLocks.length > 0) {
|
|
53
|
+
this.logger.debug(
|
|
54
|
+
{
|
|
55
|
+
projectId,
|
|
56
|
+
requestedInstanceIds: instanceIds,
|
|
57
|
+
lockedInstanceIds: conflictingLocks.map(lock => lock.id),
|
|
58
|
+
},
|
|
59
|
+
`failed to lock %s of %s instances in project "%s"`,
|
|
60
|
+
conflictingLocks.length,
|
|
61
|
+
instanceIds.length,
|
|
62
|
+
projectId,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if (!allowPartialLock) {
|
|
66
|
+
// if partial locking is not allowed, return false
|
|
67
|
+
return [false, []]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// if partial locking is allowed, filter out the already locked instances and proceed with the rest
|
|
71
|
+
instanceIds = instanceIds.filter(id => !conflictingLocks.some(lock => lock.id === id))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const newLocks = instanceIds.map(id => ({ id, projectId, meta: lockMeta, spec }))
|
|
75
|
+
|
|
76
|
+
// persist the new locks
|
|
77
|
+
await this.stateManager.getInstanceLockRepository(projectId).putManyItems(newLocks)
|
|
78
|
+
|
|
79
|
+
this.logger.debug(
|
|
80
|
+
{
|
|
81
|
+
projectId,
|
|
82
|
+
instanceIds: newLocks.map(lock => lock.id),
|
|
83
|
+
spec,
|
|
84
|
+
},
|
|
85
|
+
`locked %s instances in project "%s"`,
|
|
86
|
+
newLocks.length,
|
|
87
|
+
projectId,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
// publish the lock event
|
|
91
|
+
await this.pubsubManager.publish(["instance-lock", projectId], {
|
|
92
|
+
type: "locked",
|
|
93
|
+
locks: newLocks,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
return [true, newLocks.map(lock => lock.id)]
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Unlocks instances in the project unconditionally.
|
|
102
|
+
* This will remove the locks regardless of their current state.
|
|
103
|
+
*
|
|
104
|
+
* @param projectId The project ID to unlock instances in.
|
|
105
|
+
* @param instanceIds The instance IDs to unlock.
|
|
106
|
+
*/
|
|
107
|
+
async unlockInstancesUnconditionally(projectId: string, instanceIds: string[]): Promise<void> {
|
|
108
|
+
this.logger.debug(
|
|
109
|
+
{ projectId, instanceIds },
|
|
110
|
+
`unconditionally unlocking %s instances in project "%s"`,
|
|
111
|
+
instanceIds.length,
|
|
112
|
+
projectId,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
// remove the locks from the state
|
|
116
|
+
await this.stateManager.getInstanceLockRepository(projectId).deleteMany(instanceIds)
|
|
117
|
+
|
|
118
|
+
// publish the unlock event
|
|
119
|
+
await this.pubsubManager.publish(["instance-lock", projectId], {
|
|
120
|
+
type: "unlocked",
|
|
121
|
+
instanceIds,
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import type { HotStateManager } from "../hotstate"
|
|
2
|
+
import type { LockManager } from "../lock"
|
|
3
|
+
import type { PubSubManager } from "../pubsub"
|
|
4
|
+
import type { StateManager } from "../state"
|
|
5
|
+
import type { Logger } from "pino"
|
|
6
|
+
import type { RunnerBackend } from "../runner"
|
|
7
|
+
import type { ArtifactService } from "../artifact"
|
|
8
|
+
import { randomBytes } from "node:crypto"
|
|
9
|
+
import { parseInstanceId } from "@highstate/contract"
|
|
10
|
+
import { unique } from "remeda"
|
|
11
|
+
import {
|
|
12
|
+
applyStatePatch,
|
|
13
|
+
isStableOperationStatus,
|
|
14
|
+
isStateEmpty,
|
|
15
|
+
type InstanceCustomStatus,
|
|
16
|
+
type InstanceState,
|
|
17
|
+
type InstanceStatePatch,
|
|
18
|
+
type Operation,
|
|
19
|
+
} from "../shared"
|
|
20
|
+
|
|
21
|
+
export class InstanceStateService {
|
|
22
|
+
constructor(
|
|
23
|
+
private readonly stateManager: StateManager,
|
|
24
|
+
private readonly hotStateManager: HotStateManager,
|
|
25
|
+
private readonly pubsubManager: PubSubManager,
|
|
26
|
+
private readonly lockManager: LockManager,
|
|
27
|
+
private readonly runnerBackend: RunnerBackend,
|
|
28
|
+
private readonly artifactManager: ArtifactService,
|
|
29
|
+
private readonly logger: Logger,
|
|
30
|
+
) {}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Gets the current states of all instances in a project.
|
|
34
|
+
*
|
|
35
|
+
* @param projectId The ID of the project for which to retrieve instance states.
|
|
36
|
+
*/
|
|
37
|
+
async getInstanceStates(projectId: string): Promise<InstanceState[]> {
|
|
38
|
+
const entries = await this.hotStateManager.hgetall(["instance-states", projectId])
|
|
39
|
+
|
|
40
|
+
return entries.map(([, value]) => value)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async getInstanceState(projectId: string, instanceId: string): Promise<InstanceState | null> {
|
|
44
|
+
return await this.hotStateManager.hget(["instance-states", projectId], instanceId)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async updateInstanceStates(
|
|
48
|
+
projectId: string,
|
|
49
|
+
states: InstanceState[],
|
|
50
|
+
persistent = false,
|
|
51
|
+
): Promise<InstanceState[]> {
|
|
52
|
+
const batch = this.stateManager.batch()
|
|
53
|
+
const promises: Promise<void>[] = []
|
|
54
|
+
|
|
55
|
+
for (const state of states) {
|
|
56
|
+
promises.push(this.hotStateManager.hset(["instance-states", projectId], state.id, state))
|
|
57
|
+
|
|
58
|
+
promises.push(
|
|
59
|
+
this.pubsubManager.publish(["instance-state", projectId], { type: "updated", state }),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if (persistent) {
|
|
63
|
+
promises.push(this.stateManager.getInstanceStateRepository(projectId).putItem(state, batch))
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
await Promise.allSettled(promises)
|
|
68
|
+
await batch.write()
|
|
69
|
+
|
|
70
|
+
this.logger.debug({ projectId, states }, `instance states updated for project "%s"`, projectId)
|
|
71
|
+
|
|
72
|
+
return states
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async patchInstanceState(projectId: string, patch: InstanceStatePatch): Promise<InstanceState> {
|
|
76
|
+
return await this.lockManager.acquire(["instance-state", projectId, patch.id], async () => {
|
|
77
|
+
const state = await this.hotStateManager.hget(["instance-states", projectId], patch.id)
|
|
78
|
+
|
|
79
|
+
if (!state) {
|
|
80
|
+
throw new Error(`Instance state with ID "${patch.id}" not found in project "${projectId}"`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const patchedState = await this.applyStatePatch(
|
|
84
|
+
projectId,
|
|
85
|
+
state,
|
|
86
|
+
patch,
|
|
87
|
+
patch.operationStatus !== undefined,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
this.logger.debug(
|
|
91
|
+
{ projectId, instanceId: patch.id, patch },
|
|
92
|
+
`instance state updated for instance "%s" in project "%s"`,
|
|
93
|
+
patch.id,
|
|
94
|
+
projectId,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return patchedState
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async patchOperationInstanceState(
|
|
102
|
+
projectId: string,
|
|
103
|
+
operation: Operation,
|
|
104
|
+
patch: InstanceStatePatch,
|
|
105
|
+
): Promise<InstanceState> {
|
|
106
|
+
return await this.lockManager.acquire(["instance-state", projectId, patch.id], async () => {
|
|
107
|
+
const state = await this.hotStateManager.hget(["instance-states", projectId], patch.id)
|
|
108
|
+
|
|
109
|
+
const patchedState = await this.applyStatePatch(
|
|
110
|
+
projectId,
|
|
111
|
+
state,
|
|
112
|
+
patch,
|
|
113
|
+
operation.type !== "preview",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
this.logger.debug(
|
|
117
|
+
{ projectId, instanceId: patch.id, patch },
|
|
118
|
+
`instance state updated for instance "%s" in project "%s"`,
|
|
119
|
+
patch.id,
|
|
120
|
+
projectId,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return patchedState
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async updateStateSecretNames(
|
|
128
|
+
projectId: string,
|
|
129
|
+
instanceId: string,
|
|
130
|
+
secretNamesToAdd: string[],
|
|
131
|
+
secretNamesToRemove: string[],
|
|
132
|
+
invalidateState = true,
|
|
133
|
+
): Promise<void> {
|
|
134
|
+
await this.lockManager.acquire(["instance-state", projectId, instanceId], async () => {
|
|
135
|
+
const state = await this.hotStateManager.hget(["instance-states", projectId], instanceId)
|
|
136
|
+
|
|
137
|
+
const secretNameSet = new Set(state?.secretNames ?? [])
|
|
138
|
+
for (const name of secretNamesToAdd) {
|
|
139
|
+
secretNameSet.add(name)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for (const name of secretNamesToRemove) {
|
|
143
|
+
secretNameSet.delete(name)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const patch: InstanceStatePatch = {
|
|
147
|
+
id: instanceId,
|
|
148
|
+
secretNames: Array.from(secretNameSet),
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (state?.operationStatus && invalidateState) {
|
|
152
|
+
// invalidate the input hash nonce if the operation status is present
|
|
153
|
+
// if not, the invalidation makes no sense
|
|
154
|
+
patch.operationStatus = {
|
|
155
|
+
inputHashNonce: randomBytes(4).readInt32LE(),
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
await this.applyStatePatch(projectId, state, patch, true)
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Completely deletes the instance state from the system including all related resources.
|
|
165
|
+
*
|
|
166
|
+
* @param projectId The ID of the project from which to delete the instance state.
|
|
167
|
+
* @param instanceId The ID of the instance whose state is to be deleted.
|
|
168
|
+
*/
|
|
169
|
+
async deleteInstanceState(projectId: string, instanceId: string): Promise<void> {
|
|
170
|
+
await this.lockManager.acquire(["instance-state", projectId, instanceId], async () => {
|
|
171
|
+
const [instanceType, instanceName] = parseInstanceId(instanceId)
|
|
172
|
+
const state = await this.getInstanceState(projectId, instanceId)
|
|
173
|
+
|
|
174
|
+
const batch = this.stateManager.batch()
|
|
175
|
+
|
|
176
|
+
await Promise.allSettled([
|
|
177
|
+
// remove from the state
|
|
178
|
+
this.stateManager.getInstanceStateRepository(projectId).delete(instanceId, batch),
|
|
179
|
+
|
|
180
|
+
// clear related resources
|
|
181
|
+
this.stateManager.getInstanceLockRepository(projectId).delete(instanceId, batch),
|
|
182
|
+
|
|
183
|
+
this.stateManager
|
|
184
|
+
.getPageRepository(projectId)
|
|
185
|
+
.deleteMany(Object.values(state?.extra?.pageIds ?? {}), batch),
|
|
186
|
+
|
|
187
|
+
this.stateManager
|
|
188
|
+
.getTriggerRepository(projectId)
|
|
189
|
+
.deleteMany(Object.values(state?.extra?.triggerIds ?? {}), batch),
|
|
190
|
+
|
|
191
|
+
this.stateManager
|
|
192
|
+
.getTerminalRepository(projectId)
|
|
193
|
+
.deleteMany(Object.values(state?.extra?.terminalIds ?? {}), batch),
|
|
194
|
+
])
|
|
195
|
+
|
|
196
|
+
await Promise.allSettled([
|
|
197
|
+
// commit the state batch
|
|
198
|
+
batch.write(),
|
|
199
|
+
|
|
200
|
+
// update hotstate
|
|
201
|
+
this.hotStateManager.hdel(["instance-states", projectId], instanceId),
|
|
202
|
+
|
|
203
|
+
// publish the instance state deletion event
|
|
204
|
+
this.pubsubManager.publish(["instance-state", projectId], { type: "deleted", instanceId }),
|
|
205
|
+
|
|
206
|
+
// remove all artifact references
|
|
207
|
+
this.artifactManager.removeUsages(
|
|
208
|
+
projectId,
|
|
209
|
+
unique([
|
|
210
|
+
...Object.values(state?.extra?.ownedArtifactIds ?? []).flat(),
|
|
211
|
+
...(state?.extra?.usedArtifactIds ?? []),
|
|
212
|
+
]),
|
|
213
|
+
[{ type: "instance", instanceId }],
|
|
214
|
+
),
|
|
215
|
+
|
|
216
|
+
// clear pulumi state
|
|
217
|
+
this.runnerBackend.deleteState({ projectId, instanceName, instanceType }),
|
|
218
|
+
])
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async updateCustomStatus(
|
|
223
|
+
projectId: string,
|
|
224
|
+
instanceId: string,
|
|
225
|
+
status: InstanceCustomStatus,
|
|
226
|
+
): Promise<void> {
|
|
227
|
+
await this.lockManager.acquire(["instance-state", projectId, instanceId], async () => {
|
|
228
|
+
const state = await this.hotStateManager.hget(["instance-states", projectId], instanceId)
|
|
229
|
+
if (!state) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`Instance state with ID "${instanceId}" not found in project "${projectId}"`,
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const existingStatus = state.extra?.customStatuses?.find(s => s.name === status.name)
|
|
236
|
+
if (existingStatus) {
|
|
237
|
+
Object.assign(existingStatus, status)
|
|
238
|
+
} else {
|
|
239
|
+
state.extra ??= {}
|
|
240
|
+
state.extra.customStatuses ??= []
|
|
241
|
+
state.extra.customStatuses?.push(status)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
await this.applyStatePatch(projectId, state, { id: instanceId, extra: state.extra })
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private async applyStatePatch(
|
|
249
|
+
projectId: string,
|
|
250
|
+
state: InstanceState | null,
|
|
251
|
+
patch: InstanceStatePatch,
|
|
252
|
+
persistent = true,
|
|
253
|
+
): Promise<InstanceState> {
|
|
254
|
+
const patchedState = applyStatePatch(state, patch)
|
|
255
|
+
|
|
256
|
+
const promises: Promise<void>[] = [
|
|
257
|
+
this.hotStateManager.hset(["instance-states", projectId], patchedState.id, patchedState),
|
|
258
|
+
]
|
|
259
|
+
|
|
260
|
+
const shouldPersist =
|
|
261
|
+
persistent &&
|
|
262
|
+
(!patchedState.operationStatus ||
|
|
263
|
+
isStableOperationStatus(patchedState.operationStatus?.status))
|
|
264
|
+
|
|
265
|
+
const repo = this.stateManager.getInstanceStateRepository(projectId)
|
|
266
|
+
|
|
267
|
+
if (isStateEmpty(patchedState)) {
|
|
268
|
+
promises.push(
|
|
269
|
+
this.pubsubManager.publish(["instance-state", projectId], {
|
|
270
|
+
type: "deleted",
|
|
271
|
+
instanceId: patchedState.id,
|
|
272
|
+
}),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
if (shouldPersist) {
|
|
276
|
+
promises.push(repo.delete(patchedState.id))
|
|
277
|
+
}
|
|
278
|
+
} else {
|
|
279
|
+
promises.push(
|
|
280
|
+
this.pubsubManager.publish(["instance-state", projectId], { type: "patched", patch }),
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
if (shouldPersist) {
|
|
284
|
+
promises.push(repo.putItem(patchedState))
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
await Promise.allSettled(promises)
|
|
289
|
+
|
|
290
|
+
return patchedState
|
|
291
|
+
}
|
|
292
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import type { Logger } from "pino"
|
|
2
|
+
import type { PubSubManager } from "../pubsub"
|
|
3
|
+
import type { HotStateManager } from "../hotstate"
|
|
4
|
+
import { v7 as uuidv7 } from "uuid"
|
|
5
|
+
import { SAME_KEY, type StateManager } from "../state"
|
|
6
|
+
import { isFinalOperationStatus, type Operation } from "../shared"
|
|
7
|
+
|
|
8
|
+
export class OperationService {
|
|
9
|
+
constructor(
|
|
10
|
+
private readonly stateManager: StateManager,
|
|
11
|
+
private readonly hotStateManager: HotStateManager,
|
|
12
|
+
private readonly pubsubManager: PubSubManager,
|
|
13
|
+
private readonly logger: Logger,
|
|
14
|
+
) {}
|
|
15
|
+
|
|
16
|
+
async getActiveOperaiton(projectId: string, operationId: string): Promise<Operation | null> {
|
|
17
|
+
try {
|
|
18
|
+
return await this.hotStateManager.hget(["active-operations", projectId], operationId)
|
|
19
|
+
} catch (error) {
|
|
20
|
+
this.logger.error(
|
|
21
|
+
{ projectId, operationId, error },
|
|
22
|
+
`failed to get active operation with ID "%s" in project "%s" from hot state`,
|
|
23
|
+
operationId,
|
|
24
|
+
projectId,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Persists an operation to the state and publishes an update event.
|
|
33
|
+
*
|
|
34
|
+
* @param projectId The project ID to which the operation belongs.
|
|
35
|
+
* @param operation The operation to persist.
|
|
36
|
+
*/
|
|
37
|
+
async updateOperation(projectId: string, operation: Operation): Promise<void> {
|
|
38
|
+
// 1. update the operation in the hot state
|
|
39
|
+
try {
|
|
40
|
+
if (isFinalOperationStatus(operation.status)) {
|
|
41
|
+
await this.hotStateManager.hdel(["active-operations", projectId], operation.id)
|
|
42
|
+
} else {
|
|
43
|
+
await this.hotStateManager.hset(["active-operations", projectId], operation.id, operation)
|
|
44
|
+
}
|
|
45
|
+
} catch (error) {
|
|
46
|
+
this.logger.error(
|
|
47
|
+
{ projectId, operationId: operation.id, error },
|
|
48
|
+
`failed to update operation with ID "%s" in project "%s" in hot state`,
|
|
49
|
+
operation.id,
|
|
50
|
+
projectId,
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 2. persist the operation in the state repository
|
|
55
|
+
try {
|
|
56
|
+
await using batch = this.stateManager.batch()
|
|
57
|
+
|
|
58
|
+
// persist the operation to the operation repository
|
|
59
|
+
await this.stateManager.getOperationRepository(projectId).put(operation.id, operation, batch)
|
|
60
|
+
|
|
61
|
+
if (operation.status === "pending") {
|
|
62
|
+
// add the just created operation to the active operations index
|
|
63
|
+
await this.stateManager
|
|
64
|
+
.getActiveOperationIndexRepository(projectId)
|
|
65
|
+
.indexRepository.put(operation.id, operation.id, batch)
|
|
66
|
+
} else if (isFinalOperationStatus(operation.status)) {
|
|
67
|
+
// remove the finished operation from the active operations index
|
|
68
|
+
await this.stateManager
|
|
69
|
+
.getActiveOperationIndexRepository(projectId)
|
|
70
|
+
.indexRepository.delete(operation.id, batch)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
await batch.write()
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
await this.pubsubManager.publish(["operation", operation.id], {
|
|
77
|
+
type: "updated",
|
|
78
|
+
operation,
|
|
79
|
+
})
|
|
80
|
+
} catch (error) {
|
|
81
|
+
this.logger.error(
|
|
82
|
+
{ projectId, operationId: operation.id, error },
|
|
83
|
+
`failed to publish operation update for operation with ID "%s" in project "%s"`,
|
|
84
|
+
operation.id,
|
|
85
|
+
projectId,
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
this.logger.info(
|
|
90
|
+
{ projectId, operationId: operation.id },
|
|
91
|
+
`updated operation with ID "%s" in project "%s"`,
|
|
92
|
+
operation.id,
|
|
93
|
+
projectId,
|
|
94
|
+
)
|
|
95
|
+
} catch (error) {
|
|
96
|
+
this.logger.error(
|
|
97
|
+
{ projectId, operationId: operation.id, error },
|
|
98
|
+
`failed to update operation with ID "%s" in project "%s"`,
|
|
99
|
+
operation.id,
|
|
100
|
+
projectId,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
throw new Error(`Failed to update operation "${operation.id}" in project "${projectId}"`, {
|
|
104
|
+
cause: error,
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Appends logs for a specific operation.
|
|
111
|
+
* Creates all necessary indexes to retrieve the logs by instance ID.
|
|
112
|
+
*
|
|
113
|
+
* @param projectId The ID of the project to persist the logs for.
|
|
114
|
+
* @param operationId The ID of the operation to persist the logs for.
|
|
115
|
+
* @param instanceIds The IDs of the instances to persist the logs for. Should include the whole hierarchy of instances produced the logs.
|
|
116
|
+
* @param logs The logs entries to persist.
|
|
117
|
+
*/
|
|
118
|
+
async appendLogs(
|
|
119
|
+
projectId: string,
|
|
120
|
+
operationId: string,
|
|
121
|
+
instanceIds: string[],
|
|
122
|
+
logs: string[],
|
|
123
|
+
): Promise<void> {
|
|
124
|
+
const records: [string, string][] = logs.map(log => [uuidv7(), log])
|
|
125
|
+
|
|
126
|
+
// 1. publish logs to the pubsub system, ignore errors
|
|
127
|
+
try {
|
|
128
|
+
const events = instanceIds.flatMap(instanceId =>
|
|
129
|
+
records.map(
|
|
130
|
+
record => [[`operation-instance-log`, operationId, instanceId], record] as const,
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
await this.pubsubManager.publishMany(events)
|
|
135
|
+
} catch (error) {
|
|
136
|
+
this.logger.error(
|
|
137
|
+
{ projectId, operationId, instanceIds, error },
|
|
138
|
+
`failed to publish logs for operation "%s" in project "%s"`,
|
|
139
|
+
operationId,
|
|
140
|
+
projectId,
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 2. persist logs to the hot state and state repository, ignore errors
|
|
145
|
+
try {
|
|
146
|
+
// insert logs into the hotstate
|
|
147
|
+
await this.hotStateManager.hmset(["operation-logs", projectId], records)
|
|
148
|
+
} catch (error) {
|
|
149
|
+
this.logger.error(
|
|
150
|
+
{ projectId, operationId, instanceIds, error },
|
|
151
|
+
`failed to append logs for operation "%s" in project "%s"`,
|
|
152
|
+
operationId,
|
|
153
|
+
projectId,
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 3. persist logs to the state repository, fail on error
|
|
158
|
+
try {
|
|
159
|
+
await using batch = this.stateManager.batch()
|
|
160
|
+
|
|
161
|
+
// insert logs into the operation log repository
|
|
162
|
+
await this.stateManager
|
|
163
|
+
.getOperationLogRepository(projectId, operationId)
|
|
164
|
+
.putMany(records, batch)
|
|
165
|
+
|
|
166
|
+
for (const instanceId of instanceIds) {
|
|
167
|
+
// create an index for the instance logs
|
|
168
|
+
await this.stateManager
|
|
169
|
+
.getInstanceLogIndexRepository(projectId, operationId, instanceId)
|
|
170
|
+
.indexRepository.putMany(
|
|
171
|
+
records.map(([id]) => [id, SAME_KEY]),
|
|
172
|
+
batch,
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
await batch.write()
|
|
177
|
+
} catch (error) {
|
|
178
|
+
this.logger.error(
|
|
179
|
+
{ projectId, operationId, instanceIds, error },
|
|
180
|
+
`failed to persist instance logs for operation "%s" in project "%s"`,
|
|
181
|
+
operationId,
|
|
182
|
+
projectId,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
throw new Error(
|
|
186
|
+
`Failed to persist logs for operation "${operationId}" in project "${projectId}"`,
|
|
187
|
+
{ cause: error },
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Retrieves logs for a specific operation and instance.
|
|
194
|
+
* First tries to get logs from the hot state, then falls back to the state repository.
|
|
195
|
+
*
|
|
196
|
+
* @param projectId The ID of the project to retrieve logs for.
|
|
197
|
+
* @param operationId The ID of the operation to retrieve logs for.
|
|
198
|
+
* @param instanceId The ID of the instance to retrieve logs for.
|
|
199
|
+
* @returns An array of log entries as tuples of [logId, logContent].
|
|
200
|
+
*/
|
|
201
|
+
async getInstanceLogs(
|
|
202
|
+
projectId: string,
|
|
203
|
+
operationId: string,
|
|
204
|
+
instanceId: string,
|
|
205
|
+
): Promise<[string, string][]> {
|
|
206
|
+
// 1. first try to get logs from the hot state
|
|
207
|
+
try {
|
|
208
|
+
const logs = await this.hotStateManager.hgetall(["operation-logs", projectId])
|
|
209
|
+
|
|
210
|
+
if (logs.length > 0) {
|
|
211
|
+
// if there are logs, assume they are up-to-date
|
|
212
|
+
return logs
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// otherwise, check if the operation is active to determine if logs are expected
|
|
216
|
+
const hasActiveOperation = await this.hotStateManager.hexists(
|
|
217
|
+
["active-operations", projectId],
|
|
218
|
+
operationId,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if (hasActiveOperation) {
|
|
222
|
+
// if the operation is active, there is just no logs yet
|
|
223
|
+
return []
|
|
224
|
+
}
|
|
225
|
+
} catch (error) {
|
|
226
|
+
this.logger.error(
|
|
227
|
+
{ projectId, operationId, instanceId, error },
|
|
228
|
+
`failed to get logs for operation "%s" in project "%s"`,
|
|
229
|
+
operationId,
|
|
230
|
+
projectId,
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 2. if not found or failed, try to get logs from the state repository
|
|
235
|
+
try {
|
|
236
|
+
// if no logs are found in the hot state, load them from the state repository
|
|
237
|
+
return await this.stateManager
|
|
238
|
+
.getInstanceLogIndexRepository(projectId, operationId, instanceId)
|
|
239
|
+
.getAll()
|
|
240
|
+
} catch (error) {
|
|
241
|
+
this.logger.error(
|
|
242
|
+
{ projectId, operationId, instanceId, error },
|
|
243
|
+
`failed to get logs for operation "%s" in project "%s"`,
|
|
244
|
+
operationId,
|
|
245
|
+
projectId,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
return []
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|