@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,69 @@
|
|
|
1
|
+
import type { EncryptionBackend, StateManager } from "../state"
|
|
2
|
+
import type { ArtifactBackend } from "./abstractions"
|
|
3
|
+
|
|
4
|
+
const nonceSize = 24
|
|
5
|
+
|
|
6
|
+
export class EncryptionArtifactBackend implements ArtifactBackend {
|
|
7
|
+
constructor(
|
|
8
|
+
private readonly artifactBackend: ArtifactBackend,
|
|
9
|
+
private readonly stateManager: StateManager,
|
|
10
|
+
) {}
|
|
11
|
+
|
|
12
|
+
async store(
|
|
13
|
+
projectId: string,
|
|
14
|
+
hash: string,
|
|
15
|
+
chunkSize: number,
|
|
16
|
+
content: AsyncIterable<Uint8Array>,
|
|
17
|
+
): Promise<void> {
|
|
18
|
+
const encryptionBackend = this.stateManager.getEncryptionBackend(projectId)
|
|
19
|
+
const encryptedContent = this.getEncryptedContent(encryptionBackend, content)
|
|
20
|
+
|
|
21
|
+
await this.artifactBackend.store(projectId, hash, chunkSize + nonceSize, encryptedContent)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private async *getEncryptedContent(
|
|
25
|
+
encryptionBackend: EncryptionBackend,
|
|
26
|
+
content: AsyncIterable<Uint8Array>,
|
|
27
|
+
): AsyncIterable<Uint8Array> {
|
|
28
|
+
for await (const chunk of content) {
|
|
29
|
+
yield await encryptionBackend.encrypt(chunk)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async retrieve(
|
|
34
|
+
projectId: string,
|
|
35
|
+
hash: string,
|
|
36
|
+
chunkSize: number,
|
|
37
|
+
): Promise<AsyncIterable<Uint8Array> | null> {
|
|
38
|
+
const encryptionBackend = this.stateManager.getEncryptionBackend(projectId)
|
|
39
|
+
|
|
40
|
+
const encryptedContent = await this.artifactBackend.retrieve(
|
|
41
|
+
projectId,
|
|
42
|
+
hash,
|
|
43
|
+
chunkSize + nonceSize,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if (encryptedContent === null) {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return this.getDecryptedContent(encryptionBackend, encryptedContent)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private async *getDecryptedContent(
|
|
54
|
+
encryptionBackend: EncryptionBackend,
|
|
55
|
+
encryptedContent: AsyncIterable<Uint8Array>,
|
|
56
|
+
): AsyncIterable<Uint8Array> {
|
|
57
|
+
for await (const chunk of encryptedContent) {
|
|
58
|
+
yield await encryptionBackend.decrypt(chunk)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async delete(projectId: string, hash: string): Promise<void> {
|
|
63
|
+
return await this.artifactBackend.delete(projectId, hash)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async exists(projectId: string, hash: string): Promise<boolean> {
|
|
67
|
+
return await this.artifactBackend.exists(projectId, hash)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ArtifactBackend } from "./abstractions"
|
|
2
|
+
import type { Logger } from "pino"
|
|
3
|
+
import type { StateManager } from "../state"
|
|
4
|
+
import { z } from "zod"
|
|
5
|
+
import { LocalArtifactBackend, localArtifactBackendConfig } from "./local"
|
|
6
|
+
import { EncryptionArtifactBackend } from "./encryption"
|
|
7
|
+
|
|
8
|
+
export const artifactBackendConfig = z.object({
|
|
9
|
+
HIGHSTATE_ARTIFACT_BACKEND_TYPE: z.enum(["local"]).default("local"),
|
|
10
|
+
HIGHSTATE_ARTIFACT_ENABLE_ENCRYPTION: z.coerce.boolean().default(true),
|
|
11
|
+
...localArtifactBackendConfig.shape,
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
export async function createArtifactBackend(
|
|
15
|
+
config: z.infer<typeof artifactBackendConfig> & Record<string, unknown>,
|
|
16
|
+
stateManager: StateManager,
|
|
17
|
+
logger: Logger,
|
|
18
|
+
): Promise<ArtifactBackend> {
|
|
19
|
+
let backend: ArtifactBackend
|
|
20
|
+
|
|
21
|
+
const fileExtension = config.HIGHSTATE_ARTIFACT_ENABLE_ENCRYPTION ? ".enc" : ".tgz"
|
|
22
|
+
|
|
23
|
+
switch (config.HIGHSTATE_ARTIFACT_BACKEND_TYPE) {
|
|
24
|
+
case "local": {
|
|
25
|
+
backend = await LocalArtifactBackend.create(config, fileExtension, stateManager, logger)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (config.HIGHSTATE_ARTIFACT_ENABLE_ENCRYPTION) {
|
|
30
|
+
backend = new EncryptionArtifactBackend(backend, stateManager)
|
|
31
|
+
} else {
|
|
32
|
+
logger.warn("artifact encryption is disabled, this is not recommended")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return backend
|
|
36
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { Logger } from "pino"
|
|
2
|
+
import type { StateManager } from "../state"
|
|
3
|
+
import { createReadStream, createWriteStream } from "node:fs"
|
|
4
|
+
import { access, mkdir, readdir, rmdir, unlink } from "node:fs/promises"
|
|
5
|
+
import { join, resolve } from "node:path"
|
|
6
|
+
import { z } from "zod"
|
|
7
|
+
import { type ArtifactBackend } from "./abstractions"
|
|
8
|
+
|
|
9
|
+
export const localArtifactBackendConfig = z.object({
|
|
10
|
+
HIGHSTATE_ARTIFACT_BACKEND_LOCAL_DIR: z.string().optional(),
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A local artifact backend that stores artifacts in the filesystem.
|
|
15
|
+
*
|
|
16
|
+
* The default artifact location is `~/.highstate/artifacts`.
|
|
17
|
+
*
|
|
18
|
+
* File structure:
|
|
19
|
+
* - `{artifactDir}/{first2chars}/{hash}` - actual artifact content files
|
|
20
|
+
*/
|
|
21
|
+
export class LocalArtifactBackend implements ArtifactBackend {
|
|
22
|
+
constructor(
|
|
23
|
+
private readonly storageDir: string,
|
|
24
|
+
private readonly fileExtension: string,
|
|
25
|
+
private readonly stateManager: StateManager,
|
|
26
|
+
private readonly logger: Logger,
|
|
27
|
+
) {
|
|
28
|
+
this.logger.debug({ msg: "initialized", baseDir: storageDir })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async store(
|
|
32
|
+
projectId: string,
|
|
33
|
+
hash: string,
|
|
34
|
+
chunkSize: number,
|
|
35
|
+
content: AsyncIterable<Uint8Array>,
|
|
36
|
+
): Promise<void> {
|
|
37
|
+
const [baseDir, fileName] = this.getArtifactPath(projectId, hash)
|
|
38
|
+
await mkdir(baseDir, { recursive: true })
|
|
39
|
+
|
|
40
|
+
// check if the artifact already exists
|
|
41
|
+
try {
|
|
42
|
+
await access(fileName)
|
|
43
|
+
this.logger.debug({ msg: "artifact already exists", hash })
|
|
44
|
+
|
|
45
|
+
return
|
|
46
|
+
} catch {
|
|
47
|
+
// artifact does not exist, continue with storing
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const file = createWriteStream(fileName, { highWaterMark: chunkSize })
|
|
51
|
+
this.logger.debug({ msg: "opened file for writing", hash, fileName })
|
|
52
|
+
|
|
53
|
+
for await (const chunk of content) {
|
|
54
|
+
file.write(chunk)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
file.end()
|
|
58
|
+
this.logger.info({ msg: "artifact stored", hash, fileName })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async retrieve(
|
|
62
|
+
projectId: string,
|
|
63
|
+
hash: string,
|
|
64
|
+
chunkSize: number,
|
|
65
|
+
): Promise<AsyncIterable<Uint8Array> | null> {
|
|
66
|
+
const [, fileName] = this.getArtifactPath(projectId, hash)
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
return Promise.resolve(createReadStream(fileName, { highWaterMark: chunkSize }))
|
|
70
|
+
} catch (error) {
|
|
71
|
+
this.logger.debug({ msg: "artifact retrieval failed", hash, error })
|
|
72
|
+
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async delete(projectId: string, hash: string): Promise<void> {
|
|
78
|
+
const [baseDir, fileName] = this.getArtifactPath(projectId, hash)
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
await unlink(fileName)
|
|
82
|
+
await this.deleteDirectoryIfEmpty(baseDir)
|
|
83
|
+
|
|
84
|
+
this.logger.info({ msg: "artifact deleted", hash, fileName })
|
|
85
|
+
} catch (error) {
|
|
86
|
+
this.logger.error({ msg: "artifact deletion failed", hash, fileName, error })
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async deleteDirectoryIfEmpty(dirPath: string): Promise<void> {
|
|
91
|
+
try {
|
|
92
|
+
const files = await readdir(dirPath)
|
|
93
|
+
if (files.length === 0) {
|
|
94
|
+
await rmdir(dirPath)
|
|
95
|
+
this.logger.info({ msg: "deleted empty directory", dirPath })
|
|
96
|
+
} else {
|
|
97
|
+
this.logger.debug({ msg: "directory not empty, skipping deletion", dirPath, files })
|
|
98
|
+
}
|
|
99
|
+
} catch (error) {
|
|
100
|
+
this.logger.error({ msg: "failed to delete directory", dirPath, error })
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async exists(projectId: string, hash: string): Promise<boolean> {
|
|
105
|
+
const [, fileName] = this.getArtifactPath(projectId, hash)
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
await access(fileName)
|
|
109
|
+
return true
|
|
110
|
+
} catch {
|
|
111
|
+
return false
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private getArtifactPath(projectId: string, hash: string): [baseDir: string, fileName: string] {
|
|
116
|
+
const hashedProjectId = this.stateManager.getHashedProjectId(projectId)
|
|
117
|
+
const baseDir = resolve(this.storageDir, hashedProjectId, hash.substring(0, 2))
|
|
118
|
+
const fileName = join(baseDir, `${hash}${this.fileExtension}`)
|
|
119
|
+
|
|
120
|
+
return [baseDir, fileName]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
static async create(
|
|
124
|
+
config: z.infer<typeof localArtifactBackendConfig>,
|
|
125
|
+
fileExtension: string,
|
|
126
|
+
stateManager: StateManager,
|
|
127
|
+
logger: Logger,
|
|
128
|
+
): Promise<LocalArtifactBackend> {
|
|
129
|
+
const childLogger = logger.child({
|
|
130
|
+
backend: "ArtifactBackend",
|
|
131
|
+
service: "LocalArtifactBackend",
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
let location = config.HIGHSTATE_ARTIFACT_BACKEND_LOCAL_DIR
|
|
135
|
+
if (!location) {
|
|
136
|
+
location = resolve(process.env.HOME!, ".highstate", "artifacts")
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
await mkdir(location, { recursive: true })
|
|
140
|
+
return new LocalArtifactBackend(location, fileExtension, stateManager, childLogger)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { LockManager } from "../lock"
|
|
2
|
+
import type { StateManager } from "../state"
|
|
3
|
+
import { randomBytes } from "node:crypto"
|
|
4
|
+
import { AccessError, type ProjectApiKey } from "../shared"
|
|
5
|
+
|
|
6
|
+
export class ApiKeyService {
|
|
7
|
+
constructor(
|
|
8
|
+
private readonly stateManager: StateManager,
|
|
9
|
+
private readonly lockManager: LockManager,
|
|
10
|
+
) {}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Regenerates the token for an API key in a project.
|
|
14
|
+
*
|
|
15
|
+
* @param projectId The ID of the project containing the API key.
|
|
16
|
+
* @param apiKeyId The ID of the API key to regenerate.
|
|
17
|
+
*/
|
|
18
|
+
async regenerateToken(projectId: string, apiKeyId: string): Promise<ProjectApiKey> {
|
|
19
|
+
return await this.lockManager.acquire([["api-key", projectId, apiKeyId]], async () => {
|
|
20
|
+
const apiKey = await this.stateManager.getApiKeyRepository(projectId).get(apiKeyId)
|
|
21
|
+
if (!apiKey) {
|
|
22
|
+
throw new Error(`API key with ID "${apiKeyId}" not found in project "${projectId}"`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const batch = this.stateManager.batch()
|
|
26
|
+
|
|
27
|
+
// delete the old token from the index
|
|
28
|
+
await this.stateManager
|
|
29
|
+
.getApiKeyTokenIndexRepository(projectId)
|
|
30
|
+
.indexRepository.delete(apiKey.token, batch)
|
|
31
|
+
|
|
32
|
+
// generate a new token
|
|
33
|
+
apiKey.token = randomBytes(32).toString("hex")
|
|
34
|
+
|
|
35
|
+
// update the API key with the new token and update the index
|
|
36
|
+
await Promise.all([
|
|
37
|
+
this.stateManager.getApiKeyRepository(projectId).putItem(apiKey, batch),
|
|
38
|
+
this.stateManager
|
|
39
|
+
.getApiKeyTokenIndexRepository(projectId)
|
|
40
|
+
.indexRepository.put(apiKey.token, apiKey.id, batch),
|
|
41
|
+
])
|
|
42
|
+
|
|
43
|
+
await batch.write()
|
|
44
|
+
|
|
45
|
+
return apiKey
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Retrieves an API key by its token for a specific project.
|
|
51
|
+
*
|
|
52
|
+
* @param projectId The ID of the project containing the API key.
|
|
53
|
+
* @param token The token of the API key to retrieve.
|
|
54
|
+
* @returns The ProjectApiKey object if found.
|
|
55
|
+
* @throws AccessError if the token is not valid for the project.
|
|
56
|
+
*/
|
|
57
|
+
async getApiKeyByToken(projectId: string, token: string): Promise<ProjectApiKey> {
|
|
58
|
+
const apiKey = await this.stateManager.getApiKeyTokenIndexRepository(projectId).get(token)
|
|
59
|
+
if (!apiKey || apiKey.token !== token) {
|
|
60
|
+
throw new AccessError(`Token is not valid for project "${projectId}"`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return apiKey
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import type { Logger } from "pino"
|
|
2
|
+
import type { StateManager } from "../state"
|
|
3
|
+
import type { ArtifactBackend } from "../artifact/abstractions"
|
|
4
|
+
import type { LockManager } from "../lock"
|
|
5
|
+
import { v7 as uuidv7 } from "uuid"
|
|
6
|
+
import { unique } from "remeda"
|
|
7
|
+
import { compareArtifactUsage, type Artifact, type ArtifactUsage, type ObjectMeta } from "../shared"
|
|
8
|
+
|
|
9
|
+
const artifactChunkSize = 1024 * 1024 // 1 MB
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The service which handles the storage, retrieval, and management of artifacts in the system.
|
|
13
|
+
*/
|
|
14
|
+
export class ArtifactService {
|
|
15
|
+
constructor(
|
|
16
|
+
private readonly stateManager: StateManager,
|
|
17
|
+
private readonly artifactBackend: ArtifactBackend,
|
|
18
|
+
private readonly lockManager: LockManager,
|
|
19
|
+
private readonly logger: Logger,
|
|
20
|
+
) {}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Stores an artifact in the backend and indexes it in the state manager.
|
|
24
|
+
* If the artifact already exists, it does nothing.
|
|
25
|
+
*
|
|
26
|
+
* Returns the artifact ID of the stored artifact.
|
|
27
|
+
*
|
|
28
|
+
* @param projectId The project ID to store the artifact under.
|
|
29
|
+
* @param hash The unique hash of the artifact content.
|
|
30
|
+
* @param meta The metadata for the artifact, including name, size, and other properties.
|
|
31
|
+
* @param size The total size of the artifact in bytes.
|
|
32
|
+
* @param chunkSize The size of each chunk in bytes.
|
|
33
|
+
* @param content An async iterable providing the artifact content in chunks.
|
|
34
|
+
* @param usages The list of usages for this artifact. Can be provided later.
|
|
35
|
+
*/
|
|
36
|
+
async store(
|
|
37
|
+
projectId: string,
|
|
38
|
+
hash: string,
|
|
39
|
+
size: number,
|
|
40
|
+
meta: ObjectMeta,
|
|
41
|
+
content: AsyncIterable<Uint8Array>,
|
|
42
|
+
usages: ArtifactUsage[] = [],
|
|
43
|
+
): Promise<string> {
|
|
44
|
+
return await this.lockManager.acquire(["artifact-hash", projectId, hash], async () => {
|
|
45
|
+
const existingArtifact = await this.stateManager
|
|
46
|
+
.getArtifactHashIndexRepository(projectId)
|
|
47
|
+
.get(hash)
|
|
48
|
+
|
|
49
|
+
if (existingArtifact) {
|
|
50
|
+
const fileExists = await this.artifactBackend.exists(projectId, hash)
|
|
51
|
+
if (fileExists) {
|
|
52
|
+
this.logger.debug(`artifact with hash "%s" already exists`, hash)
|
|
53
|
+
return existingArtifact.id
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// TODO: make this check configurable
|
|
57
|
+
this.logger.warn(
|
|
58
|
+
`artifact with hash "%s" exists in index but not in backend, re-uploading`,
|
|
59
|
+
hash,
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await this.artifactBackend.store(projectId, hash, artifactChunkSize, content)
|
|
64
|
+
|
|
65
|
+
const artifact: Artifact = {
|
|
66
|
+
id: uuidv7(),
|
|
67
|
+
hash,
|
|
68
|
+
size,
|
|
69
|
+
usages,
|
|
70
|
+
meta,
|
|
71
|
+
chunkSize: artifactChunkSize,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const batch = this.stateManager.batch()
|
|
75
|
+
|
|
76
|
+
await Promise.all([
|
|
77
|
+
this.stateManager.getArtifactRepository(projectId).putItem(artifact, batch),
|
|
78
|
+
this.stateManager
|
|
79
|
+
.getArtifactHashIndexRepository(projectId)
|
|
80
|
+
.indexRepository.put(hash, artifact.id, batch),
|
|
81
|
+
])
|
|
82
|
+
|
|
83
|
+
await batch.write()
|
|
84
|
+
|
|
85
|
+
this.logger.info(
|
|
86
|
+
{ projectId },
|
|
87
|
+
`stored artifact with hash "%s" and ID "%s"`,
|
|
88
|
+
hash,
|
|
89
|
+
artifact.id,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return artifact.id
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Add usages for artifacts in the project.
|
|
98
|
+
*
|
|
99
|
+
* @param projectId The project ID to which the artifacts belong.
|
|
100
|
+
* @param artifactIds The IDs of the artifacts to track usages for.
|
|
101
|
+
* @param usages The list of usages to track for the artifacts.
|
|
102
|
+
* @returns
|
|
103
|
+
*/
|
|
104
|
+
async addUsages(
|
|
105
|
+
projectId: string,
|
|
106
|
+
artifactIds: string[],
|
|
107
|
+
usages: ArtifactUsage[],
|
|
108
|
+
): Promise<void> {
|
|
109
|
+
if (artifactIds.length === 0 || usages.length === 0) {
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await this.lockManager.acquire(
|
|
114
|
+
artifactIds.map(id => ["artifact", projectId, id] as const),
|
|
115
|
+
() => this._addUsages(projectId, artifactIds, usages),
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private async _addUsages(
|
|
120
|
+
projectId: string,
|
|
121
|
+
artifactIds: string[],
|
|
122
|
+
usages: ArtifactUsage[],
|
|
123
|
+
): Promise<void> {
|
|
124
|
+
this.logger.debug({
|
|
125
|
+
msg: "tracking artifact usages",
|
|
126
|
+
projectId,
|
|
127
|
+
artifactIds,
|
|
128
|
+
usages,
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const artifacts = await this.stateManager
|
|
132
|
+
.getArtifactRepository(projectId)
|
|
133
|
+
.getManyRecord(artifactIds)
|
|
134
|
+
|
|
135
|
+
const artifactsToUpdate: Artifact[] = []
|
|
136
|
+
|
|
137
|
+
for (const artifactId of artifactIds) {
|
|
138
|
+
const artifact = artifacts[artifactId]
|
|
139
|
+
if (!artifact) {
|
|
140
|
+
this.logger.warn({
|
|
141
|
+
msg: "artifact not found during usage addition",
|
|
142
|
+
projectId,
|
|
143
|
+
artifactId,
|
|
144
|
+
})
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// add new usages to the artifact
|
|
149
|
+
for (const usage of usages) {
|
|
150
|
+
if (!artifact.usages.some(u => compareArtifactUsage(u, usage))) {
|
|
151
|
+
artifact.usages.push(usage)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
artifactsToUpdate.push(artifact)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
await this.stateManager.getArtifactRepository(projectId).putManyItems(artifactsToUpdate)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Removes usages for artifacts in the project.
|
|
163
|
+
*
|
|
164
|
+
* If an artifact has no usages left, it will be deleted immediately.
|
|
165
|
+
*
|
|
166
|
+
* @param projectId The project ID to which the artifacts belong.
|
|
167
|
+
* @param artifactIds The IDs of the artifacts to remove usages for.
|
|
168
|
+
* @param usages The list of usages to remove from the artifacts.
|
|
169
|
+
*/
|
|
170
|
+
async removeUsages(
|
|
171
|
+
projectId: string,
|
|
172
|
+
artifactIds: string[],
|
|
173
|
+
usages: ArtifactUsage[],
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
if (artifactIds.length === 0 || usages.length === 0) {
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
await this.lockManager.acquire(
|
|
180
|
+
artifactIds.map(id => ["artifact", projectId, id] as const),
|
|
181
|
+
() => this._removeUsages(projectId, artifactIds, usages),
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private async _removeUsages(
|
|
186
|
+
projectId: string,
|
|
187
|
+
artifactIds: string[],
|
|
188
|
+
usages: ArtifactUsage[],
|
|
189
|
+
): Promise<void> {
|
|
190
|
+
this.logger.debug({
|
|
191
|
+
msg: "removing artifact usages",
|
|
192
|
+
projectId,
|
|
193
|
+
artifactIds,
|
|
194
|
+
usages,
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
const artifacts = await this.stateManager
|
|
198
|
+
.getArtifactRepository(projectId)
|
|
199
|
+
.getManyRecord(artifactIds)
|
|
200
|
+
|
|
201
|
+
const artifactsToUpdate: Artifact[] = []
|
|
202
|
+
const artifactsToDelete: Artifact[] = []
|
|
203
|
+
|
|
204
|
+
for (const artifactId of artifactIds) {
|
|
205
|
+
const artifact = artifacts[artifactId]
|
|
206
|
+
if (!artifact) {
|
|
207
|
+
this.logger.warn({
|
|
208
|
+
msg: "artifact not found during usage removal",
|
|
209
|
+
projectId,
|
|
210
|
+
artifactId,
|
|
211
|
+
})
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// remove specified usages from the artifact
|
|
216
|
+
artifact.usages = artifact.usages.filter(
|
|
217
|
+
u => !usages.some(usage => compareArtifactUsage(u, usage)),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
if (artifact.usages.length === 0) {
|
|
221
|
+
// if no usages left, mark for deletion
|
|
222
|
+
artifactsToDelete.push(artifact)
|
|
223
|
+
} else {
|
|
224
|
+
// otherwise, update the artifact metadata
|
|
225
|
+
artifactsToUpdate.push(artifact)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (artifactsToUpdate.length > 0) {
|
|
230
|
+
await this.stateManager.getArtifactRepository(projectId).putManyItems(artifactsToUpdate)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (artifactsToDelete.length > 0) {
|
|
234
|
+
await this.removeArtifacts(projectId, artifactsToDelete)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Updates the usage of artifacts which still have it and removes the usage from artifacts which no longer have it.
|
|
240
|
+
*
|
|
241
|
+
* @param projectId The project ID to which the artifacts belong.
|
|
242
|
+
* @param usage The usage to track for the artifacts.
|
|
243
|
+
* @param oldArtifactIds The IDs of the artifacts that previously had this usage.
|
|
244
|
+
* @param newArtifactIds The IDs of the artifacts that now have this usage.
|
|
245
|
+
*/
|
|
246
|
+
async updateUsage(
|
|
247
|
+
projectId: string,
|
|
248
|
+
usage: ArtifactUsage,
|
|
249
|
+
oldArtifactIds: string[],
|
|
250
|
+
newArtifactIds: string[],
|
|
251
|
+
): Promise<void> {
|
|
252
|
+
const removedArtifactIds = oldArtifactIds.filter(id => !newArtifactIds.includes(id))
|
|
253
|
+
const allArtifactIds = unique([...oldArtifactIds, ...newArtifactIds])
|
|
254
|
+
|
|
255
|
+
await this.lockManager.acquire(
|
|
256
|
+
allArtifactIds.map(id => ["artifact", projectId, id] as const),
|
|
257
|
+
async () => {
|
|
258
|
+
await this._addUsages(projectId, newArtifactIds, [usage])
|
|
259
|
+
await this._removeUsages(projectId, removedArtifactIds, [usage])
|
|
260
|
+
},
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private async removeArtifacts(projectId: string, artifacts: Artifact[]): Promise<void> {
|
|
265
|
+
const batch = this.stateManager.batch()
|
|
266
|
+
|
|
267
|
+
await Promise.all([
|
|
268
|
+
this.stateManager.getArtifactRepository(projectId).deleteMany(
|
|
269
|
+
artifacts.map(a => a.id),
|
|
270
|
+
batch,
|
|
271
|
+
),
|
|
272
|
+
this.stateManager.getArtifactHashIndexRepository(projectId).indexRepository.deleteMany(
|
|
273
|
+
artifacts.map(a => a.hash),
|
|
274
|
+
batch,
|
|
275
|
+
),
|
|
276
|
+
])
|
|
277
|
+
|
|
278
|
+
await Promise.allSettled(
|
|
279
|
+
artifacts.map(artifact => this.artifactBackend.delete(projectId, artifact.hash)),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
this.logger.info({
|
|
283
|
+
msg: "deleted dangling artifacts",
|
|
284
|
+
projectId,
|
|
285
|
+
artifactHashes: artifacts.map(a => a.hash),
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { BackendUnlockMethod } from "../shared"
|
|
2
|
+
import type { StateBackend } from "../state/abstractions"
|
|
3
|
+
|
|
4
|
+
export class BackendUnlockService {
|
|
5
|
+
constructor(private readonly stateBackend: StateBackend) {}
|
|
6
|
+
|
|
7
|
+
getUnlockMethods(): Promise<BackendUnlockMethod[]> {}
|
|
8
|
+
|
|
9
|
+
unlock(): Promise<string> {}
|
|
10
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from "./api-key"
|
|
2
|
+
export * from "./artifact"
|
|
3
|
+
export * from "./backend-unlock"
|
|
4
|
+
export * from "./instance-lock"
|
|
5
|
+
export * from "./instance-state"
|
|
6
|
+
export * from "./operation"
|
|
7
|
+
export * from "./project-unlock"
|
|
8
|
+
export * from "./secret"
|
|
9
|
+
export * from "./worker"
|