@highstate/backend 0.9.26 → 0.9.28
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-GJTMJQUW.js → chunk-QSHSXLO2.js} +18 -9
- package/dist/chunk-QSHSXLO2.js.map +1 -0
- package/dist/index.js +292 -132
- package/dist/index.js.map +1 -1
- package/dist/shared/index.js +1 -1
- package/package.json +3 -3
- package/prisma/backend/_schema/layout.prisma +3 -1
- package/prisma/backend/_schema/project.prisma +4 -2
- package/prisma/backend/_schema/unlock-method.prisma +19 -0
- package/prisma/backend/sqlite/migrations/{20250818082732_add_models → 20250928124105_initial_migration}/migration.sql +48 -16
- package/prisma/project/api-key.prisma +3 -1
- package/prisma/project/artifact.prisma +2 -2
- package/prisma/project/custom-status.prisma +1 -1
- package/prisma/project/layout.prisma +4 -0
- package/prisma/project/migrations/{20250816081310_initial → 20250928130725_initial_migration}/migration.sql +132 -46
- package/prisma/project/terminal.prisma +2 -2
- package/prisma/project/unlock-method.prisma +1 -1
- package/prisma/project/worker.prisma +1 -1
- package/src/business/backend-unlock.test.ts +133 -0
- package/src/business/backend-unlock.ts +76 -0
- package/src/business/index.ts +1 -0
- package/src/business/settings.test.ts +3 -2
- package/src/database/_generated/backend/postgresql/client.ts +9 -4
- package/src/database/_generated/backend/postgresql/internal/class.ts +147 -168
- package/src/database/_generated/backend/postgresql/internal/prismaNamespace.ts +127 -40
- package/src/database/_generated/backend/postgresql/models/BackendUnlockMethod.ts +1156 -0
- package/src/database/_generated/backend/postgresql/models/Project.ts +2 -2
- package/src/database/_generated/backend/postgresql/models/ProjectSpace.ts +7 -1
- package/src/database/_generated/backend/postgresql/models/UserWorkspaceLayout.ts +1067 -0
- package/src/database/_generated/backend/postgresql/models.ts +2 -1
- package/src/database/_generated/backend/sqlite/client.ts +9 -4
- package/src/database/_generated/backend/sqlite/internal/class.ts +146 -165
- package/src/database/_generated/backend/sqlite/internal/prismaNamespace.ts +127 -40
- package/src/database/_generated/backend/sqlite/models/BackendUnlockMethod.ts +1154 -0
- package/src/database/_generated/backend/sqlite/models/Project.ts +2 -2
- package/src/database/_generated/backend/sqlite/models/ProjectSpace.ts +7 -1
- package/src/database/_generated/backend/sqlite/models/UserWorkspaceLayout.ts +1065 -0
- package/src/database/_generated/backend/sqlite/models.ts +2 -1
- package/src/database/_generated/project/commonInputTypes.ts +26 -26
- package/src/database/_generated/project/internal/class.ts +7 -8
- package/src/database/_generated/project/internal/prismaNamespace.ts +8 -7
- package/src/database/_generated/project/models/ApiKey.ts +2 -0
- package/src/database/_generated/project/models/Artifact.ts +2 -2
- package/src/database/_generated/project/models/InstanceCustomStatus.ts +1 -1
- package/src/database/_generated/project/models/OperationLog.ts +49 -1
- package/src/database/_generated/project/models/UnlockMethod.ts +2 -2
- package/src/database/_generated/project/models/UserCompositeViewport.ts +16 -14
- package/src/database/_generated/project/models/UserProjectViewport.ts +11 -9
- package/src/database/_generated/project/models/WorkerVersion.ts +1 -5
- package/src/database/abstractions.ts +25 -3
- package/src/database/factory.ts +5 -6
- package/src/database/local/backend.ts +148 -18
- package/src/database/manager.ts +30 -2
- package/src/database/prisma.ts +1 -0
- package/src/orchestrator/operation-plan.ts +0 -19
- package/src/orchestrator/operation.ts +21 -4
- package/src/services.ts +12 -3
- package/src/shared/models/backend/unlock-method.ts +7 -13
- package/src/shared/models/errors.ts +14 -0
- package/src/shared/models/prisma.ts +10 -2
- package/src/test-utils/database.ts +34 -6
- package/dist/chunk-GJTMJQUW.js.map +0 -1
- package/prisma/backend/sqlite/migrations/20250817070609_initiial/migration.sql +0 -34
- package/prisma/backend/sqlite/migrations/20250817104948_add_fields/migration.sql +0 -59
- package/prisma/backend/sqlite/migrations/20250818083106_a/migration.sql +0 -19
- package/prisma/backend/sqlite/migrations/20250818101945_hi/migration.sql +0 -1
- package/prisma/backend/sqlite/migrations/20250819082315_a/migration.sql +0 -5
- package/prisma/project/migrations/20250816082523_test/migration.sql +0 -72
- package/prisma/project/migrations/20250818065643_update/migration.sql +0 -42
- package/prisma/project/migrations/20250818070758_a/migration.sql +0 -8
- package/prisma/project/migrations/20250818070913_a/migration.sql +0 -8
- package/prisma/project/migrations/20250818082720_add_motels/migration.sql +0 -11
- package/prisma/project/migrations/20250818112523_hello/migration.sql +0 -35
- package/prisma/project/migrations/20250819082305_a/migration.sql +0 -14
- package/prisma/project/migrations/20250819165004_add_missing_fields/migration.sql +0 -216
- package/prisma/project/migrations/20250819171309_a/migration.sql +0 -22
- package/prisma/project/migrations/20250820113949_a/migration.sql +0 -66
- package/prisma/project/migrations/20250820144256_b/migration.sql +0 -31
- package/prisma/project/migrations/20250820145547_a/migration.sql +0 -24
- package/prisma/project/migrations/20250820182517_b/migration.sql +0 -2
- package/prisma/project/migrations/20250821172324_a/migration.sql +0 -2
- package/prisma/project/migrations/20250822081339_a/migration.sql +0 -219
- package/prisma/project/migrations/20250822083742_b/migration.sql +0 -1
- package/prisma/project/migrations/20250822105134_boom/migration.sql +0 -1
- package/prisma/project/migrations/20250822141028_b/migration.sql +0 -1
- package/prisma/project/migrations/20250822142342_b/migration.sql +0 -16
- package/prisma/project/migrations/20250824072720_a/migration.sql +0 -1
- package/prisma/project/migrations/20250824093656_b/migration.sql +0 -21
- package/prisma/project/migrations/20250825082518_a/migration.sql +0 -1
- package/prisma/project/migrations/20250825085343_b/migration.sql +0 -1
- package/prisma/project/migrations/20250825091312_a/migration.sql +0 -1
- package/prisma/project/migrations/20250903095431_hi/migration.sql +0 -44
- package/prisma/project/migrations/20250903174255_a/migration.sql +0 -24
- package/prisma/project/migrations/20250908095205_hi/migration.sql +0 -18
- package/prisma/project/migrations/20250909155857_hi/migration.sql +0 -15
- package/prisma/project/migrations/20250921092621_b/migration.sql +0 -33
- package/prisma/project/migrations/20250921093911_b/migration.sql +0 -1
- package/src/database/_generated/backend/postgresql/models/UserWorkspaseLayout.ts +0 -1065
- package/src/database/_generated/backend/sqlite/models/UserWorkspaseLayout.ts +0 -1063
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { Logger } from "pino"
|
|
2
2
|
import type { BackendDatabase } from "../prisma"
|
|
3
3
|
import { randomBytes } from "node:crypto"
|
|
4
|
+
import { hostname } from "node:os"
|
|
4
5
|
import { PrismaLibSQL } from "@prisma/adapter-libsql"
|
|
5
6
|
import { armor, Decrypter, Encrypter, identityToRecipient } from "age-encryption"
|
|
6
7
|
import { z } from "zod"
|
|
7
8
|
import { codebaseConfig, getCodebaseHighstatePath } from "../../common"
|
|
8
9
|
import { PrismaClient } from "../_generated/backend/sqlite/client"
|
|
9
|
-
import { backendDatabaseVersion } from "../abstractions"
|
|
10
|
+
import { type BackendDatabaseBackend, backendDatabaseVersion } from "../abstractions"
|
|
10
11
|
import { migrateDatabase } from "../migrate"
|
|
11
12
|
import { ensureWellKnownEntitiesCreated } from "../well-known"
|
|
12
13
|
import { getOrCreateBackendIdentity } from "./keyring"
|
|
@@ -18,6 +19,59 @@ export const localBackendDatabaseConfig = z.object({
|
|
|
18
19
|
HIGHSTATE_ENCRYPTION_ENABLED: z.stringbool().default(true),
|
|
19
20
|
})
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Local implementation backed by a LibSQL database with optional encryption.
|
|
24
|
+
*/
|
|
25
|
+
class LocalBackendDatabaseBackend implements BackendDatabaseBackend {
|
|
26
|
+
constructor(
|
|
27
|
+
readonly database: BackendDatabase,
|
|
28
|
+
private readonly databasePath: string,
|
|
29
|
+
private readonly logger: Logger,
|
|
30
|
+
readonly isEncryptionEnabled: boolean,
|
|
31
|
+
) {}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Rewrites the encrypted master key to match the provided recipients.
|
|
35
|
+
*
|
|
36
|
+
* @param recipients AGE recipients that should retain access to the backend master key.
|
|
37
|
+
*/
|
|
38
|
+
async reencryptMasterKey(recipients: string[]): Promise<void> {
|
|
39
|
+
if (!this.isEncryptionEnabled) {
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const meta = await readMetaFile(this.databasePath)
|
|
44
|
+
if (!meta?.masterKey) {
|
|
45
|
+
this.logger.warn(
|
|
46
|
+
{ databasePath: this.databasePath },
|
|
47
|
+
"backend meta file does not contain a master key; skipping re-encryption",
|
|
48
|
+
)
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const identity = await getOrCreateBackendIdentity(this.logger)
|
|
53
|
+
const decrypter = new Decrypter()
|
|
54
|
+
decrypter.addIdentity(identity)
|
|
55
|
+
|
|
56
|
+
const plaintextMasterKey = await decrypter.decrypt(armor.decode(meta.masterKey), "text")
|
|
57
|
+
|
|
58
|
+
const encrypter = new Encrypter()
|
|
59
|
+
const allowedRecipients = new Set<string>(recipients)
|
|
60
|
+
allowedRecipients.add(await identityToRecipient(identity))
|
|
61
|
+
|
|
62
|
+
for (const recipient of allowedRecipients) {
|
|
63
|
+
encrypter.addRecipient(recipient)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const encrypted = await encrypter.encrypt(plaintextMasterKey)
|
|
67
|
+
|
|
68
|
+
await writeMetaFile(this.databasePath, {
|
|
69
|
+
...meta,
|
|
70
|
+
masterKey: armor.encode(encrypted),
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
21
75
|
async function createMasterKey(logger: Logger) {
|
|
22
76
|
const identity = await getOrCreateBackendIdentity(logger)
|
|
23
77
|
|
|
@@ -30,14 +84,22 @@ async function createMasterKey(logger: Logger) {
|
|
|
30
84
|
const encryptedMasterKey = await encrypter.encrypt(masterKey)
|
|
31
85
|
const armoredMasterKey = armor.encode(encryptedMasterKey)
|
|
32
86
|
|
|
33
|
-
return { armoredMasterKey, masterKey }
|
|
87
|
+
return { armoredMasterKey, masterKey, recipient }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type DatabaseInitializationResult = {
|
|
91
|
+
shouldMigrate: boolean
|
|
92
|
+
masterKey?: string
|
|
93
|
+
metaFile: DatabaseMetaFile
|
|
94
|
+
created: boolean
|
|
95
|
+
initialRecipient?: string
|
|
34
96
|
}
|
|
35
97
|
|
|
36
98
|
async function ensureDatabaseInitialized(
|
|
37
99
|
databasePath: string,
|
|
38
100
|
encryptionEnabled: boolean,
|
|
39
101
|
logger: Logger,
|
|
40
|
-
) {
|
|
102
|
+
): Promise<DatabaseInitializationResult> {
|
|
41
103
|
const meta = await readMetaFile(databasePath)
|
|
42
104
|
|
|
43
105
|
if (!meta) {
|
|
@@ -50,7 +112,13 @@ async function ensureDatabaseInitialized(
|
|
|
50
112
|
masterKey: masterKey?.armoredMasterKey,
|
|
51
113
|
}
|
|
52
114
|
|
|
53
|
-
return {
|
|
115
|
+
return {
|
|
116
|
+
shouldMigrate: true,
|
|
117
|
+
masterKey: masterKey?.masterKey,
|
|
118
|
+
metaFile,
|
|
119
|
+
created: true,
|
|
120
|
+
initialRecipient: masterKey?.recipient,
|
|
121
|
+
}
|
|
54
122
|
}
|
|
55
123
|
|
|
56
124
|
if (meta.version > backendDatabaseVersion) {
|
|
@@ -64,12 +132,13 @@ async function ensureDatabaseInitialized(
|
|
|
64
132
|
shouldMigrate: meta.version < backendDatabaseVersion,
|
|
65
133
|
masterKey: undefined,
|
|
66
134
|
metaFile: meta,
|
|
135
|
+
created: false,
|
|
67
136
|
}
|
|
68
137
|
}
|
|
69
138
|
|
|
70
139
|
if (!meta.masterKey) {
|
|
71
140
|
throw new Error(
|
|
72
|
-
`Database meta file at "${databasePath}/meta.yaml" does not contain a master key.`,
|
|
141
|
+
`Database meta file at "${databasePath}/backend.meta.yaml" does not contain a master key.`,
|
|
73
142
|
)
|
|
74
143
|
}
|
|
75
144
|
|
|
@@ -87,13 +156,21 @@ async function ensureDatabaseInitialized(
|
|
|
87
156
|
shouldMigrate: meta.version < backendDatabaseVersion,
|
|
88
157
|
masterKey,
|
|
89
158
|
metaFile: meta,
|
|
159
|
+
created: false,
|
|
90
160
|
}
|
|
91
161
|
}
|
|
92
162
|
|
|
93
|
-
|
|
163
|
+
/**
|
|
164
|
+
* Creates the local backend database backend with migrations applied.
|
|
165
|
+
*
|
|
166
|
+
* @param config Backend database configuration resolved from environment variables.
|
|
167
|
+
* @param logger Logger scoped to backend startup.
|
|
168
|
+
* @returns The backend database backend bound to the local LibSQL store.
|
|
169
|
+
*/
|
|
170
|
+
export async function createLocalBackendDatabaseBackend(
|
|
94
171
|
config: z.infer<typeof localBackendDatabaseConfig>,
|
|
95
172
|
logger: Logger,
|
|
96
|
-
): Promise<
|
|
173
|
+
): Promise<BackendDatabaseBackend> {
|
|
97
174
|
if (!config.HIGHSTATE_ENCRYPTION_ENABLED) {
|
|
98
175
|
logger.warn("local database encryption is disabled, this is not recommended for production use")
|
|
99
176
|
}
|
|
@@ -101,18 +178,14 @@ export async function createLocalBackendDatabase(
|
|
|
101
178
|
let databasePath = config.HIGHSTATE_BACKEND_DATABASE_LOCAL_PATH
|
|
102
179
|
databasePath ??= await getCodebaseHighstatePath(config, logger)
|
|
103
180
|
|
|
104
|
-
const { shouldMigrate, masterKey, metaFile } =
|
|
105
|
-
databasePath,
|
|
106
|
-
config.HIGHSTATE_ENCRYPTION_ENABLED,
|
|
107
|
-
logger,
|
|
108
|
-
)
|
|
181
|
+
const { shouldMigrate, masterKey, metaFile, created, initialRecipient } =
|
|
182
|
+
await ensureDatabaseInitialized(databasePath, config.HIGHSTATE_ENCRYPTION_ENABLED, logger)
|
|
109
183
|
|
|
110
184
|
const databaseUrl = `file:${databasePath}/backend.db`
|
|
111
185
|
|
|
112
186
|
if (shouldMigrate) {
|
|
113
187
|
await migrateDatabase(databaseUrl, "backend/sqlite", masterKey, logger)
|
|
114
188
|
|
|
115
|
-
// update version in meta file
|
|
116
189
|
await writeMetaFile(databasePath, { ...metaFile, version: backendDatabaseVersion })
|
|
117
190
|
}
|
|
118
191
|
|
|
@@ -125,10 +198,67 @@ export async function createLocalBackendDatabase(
|
|
|
125
198
|
adapter,
|
|
126
199
|
})
|
|
127
200
|
|
|
128
|
-
|
|
129
|
-
|
|
201
|
+
const database = prismaClient as BackendDatabase
|
|
202
|
+
|
|
203
|
+
await ensureWellKnownEntitiesCreated(database)
|
|
204
|
+
|
|
205
|
+
const backendLogger = logger.child({ service: "LocalBackendDatabaseBackend" })
|
|
206
|
+
|
|
207
|
+
await ensureInitialUnlockMethod(database, created, initialRecipient, backendLogger)
|
|
208
|
+
|
|
209
|
+
backendLogger.info("database is ready")
|
|
210
|
+
|
|
211
|
+
return new LocalBackendDatabaseBackend(
|
|
212
|
+
database,
|
|
213
|
+
databasePath,
|
|
214
|
+
backendLogger,
|
|
215
|
+
config.HIGHSTATE_ENCRYPTION_ENABLED,
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Derives the meta payload for the auto-generated backend unlock method.
|
|
221
|
+
*
|
|
222
|
+
* @param host Raw host name captured during backend initialization.
|
|
223
|
+
*/
|
|
224
|
+
export function getInitialBackendUnlockMethodMeta(host: string | undefined): {
|
|
225
|
+
title: string
|
|
226
|
+
description: string
|
|
227
|
+
} {
|
|
228
|
+
const trimmed = host?.trim() ?? ""
|
|
229
|
+
const title = trimmed.length > 0 ? trimmed : "initial"
|
|
230
|
+
const description =
|
|
231
|
+
trimmed.length > 0
|
|
232
|
+
? `Identity automatically registered for ${trimmed} when this backend database was created.`
|
|
233
|
+
: "Identity automatically registered when this backend database was created."
|
|
234
|
+
|
|
235
|
+
return { title, description }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Registers the machine that initialized the backend as the first unlock method.
|
|
240
|
+
*/
|
|
241
|
+
async function ensureInitialUnlockMethod(
|
|
242
|
+
database: BackendDatabase,
|
|
243
|
+
created: boolean,
|
|
244
|
+
initialRecipient: string | undefined,
|
|
245
|
+
logger: Logger,
|
|
246
|
+
): Promise<void> {
|
|
247
|
+
if (!created || !initialRecipient) {
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const meta = getInitialBackendUnlockMethodMeta(hostname())
|
|
130
252
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
253
|
+
await database.backendUnlockMethod.create({
|
|
254
|
+
data: {
|
|
255
|
+
recipient: initialRecipient,
|
|
256
|
+
meta,
|
|
257
|
+
},
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
logger.info(
|
|
261
|
+
{ title: meta.title, recipient: initialRecipient },
|
|
262
|
+
"registered initial backend unlock method",
|
|
263
|
+
)
|
|
134
264
|
}
|
package/src/database/manager.ts
CHANGED
|
@@ -5,7 +5,11 @@ import { LRUCache } from "lru-cache"
|
|
|
5
5
|
import z from "zod"
|
|
6
6
|
import { createProjectLogger } from "../common"
|
|
7
7
|
import { BackendError, ProjectLockedError, ProjectNotFoundError } from "../shared"
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
type BackendDatabaseBackend,
|
|
10
|
+
type ProjectDatabaseBackend,
|
|
11
|
+
projectDatabaseVersion,
|
|
12
|
+
} from "./abstractions"
|
|
9
13
|
import { migrateDatabase } from "./migrate"
|
|
10
14
|
|
|
11
15
|
export const databaseManagerConfig = z.object({
|
|
@@ -23,6 +27,13 @@ export interface DatabaseManager {
|
|
|
23
27
|
*/
|
|
24
28
|
readonly isEncryptionEnabled: boolean
|
|
25
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Re-encrypts the backend master key so it can be decrypted by the provided recipients.
|
|
32
|
+
*
|
|
33
|
+
* @param recipients AGE recipients that must be able to decrypt the backend master key.
|
|
34
|
+
*/
|
|
35
|
+
updateBackendUnlockRecipients(recipients: string[]): Promise<void>
|
|
36
|
+
|
|
26
37
|
/**
|
|
27
38
|
* Returns the master key of the project with the given ID.
|
|
28
39
|
*
|
|
@@ -60,13 +71,17 @@ export interface DatabaseManager {
|
|
|
60
71
|
|
|
61
72
|
export class DatabaseManagerImpl implements DatabaseManager {
|
|
62
73
|
constructor(
|
|
63
|
-
readonly
|
|
74
|
+
private readonly backendBackend: BackendDatabaseBackend,
|
|
64
75
|
private readonly projectUnlockBackend: ProjectUnlockBackend,
|
|
65
76
|
private readonly projectDatabaseBackend: ProjectDatabaseBackend,
|
|
66
77
|
private readonly config: z.infer<typeof databaseManagerConfig>,
|
|
67
78
|
private readonly logger: Logger,
|
|
68
79
|
) {}
|
|
69
80
|
|
|
81
|
+
get backend(): BackendDatabase {
|
|
82
|
+
return this.backendBackend.database
|
|
83
|
+
}
|
|
84
|
+
|
|
70
85
|
// store the master keys in memory cache for 30 seconds
|
|
71
86
|
private readonly projectMasterKeys = new LRUCache<string, Buffer>({
|
|
72
87
|
ttl: 30_000,
|
|
@@ -80,6 +95,19 @@ export class DatabaseManagerImpl implements DatabaseManager {
|
|
|
80
95
|
return this.config.HIGHSTATE_ENCRYPTION_ENABLED
|
|
81
96
|
}
|
|
82
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Delegates backend master-key rotation to the active backend database backend.
|
|
100
|
+
*
|
|
101
|
+
* @param recipients AGE recipients that must retain access to the backend master key.
|
|
102
|
+
*/
|
|
103
|
+
async updateBackendUnlockRecipients(recipients: string[]): Promise<void> {
|
|
104
|
+
if (!this.backendBackend.isEncryptionEnabled) {
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
await this.backendBackend.reencryptMasterKey(recipients)
|
|
109
|
+
}
|
|
110
|
+
|
|
83
111
|
async getProjectMasterKey(projectId: string): Promise<Buffer | undefined> {
|
|
84
112
|
if (!this.isEncryptionEnabled) {
|
|
85
113
|
return undefined
|
package/src/database/prisma.ts
CHANGED
|
@@ -35,11 +35,6 @@ export function createOperationPlan(
|
|
|
35
35
|
requestedInstanceIds: string[],
|
|
36
36
|
options: OperationOptions,
|
|
37
37
|
): OperationPhase[] {
|
|
38
|
-
// handle preview restrictions
|
|
39
|
-
if (type === "preview") {
|
|
40
|
-
validatePreviewRestrictions(context, requestedInstanceIds)
|
|
41
|
-
}
|
|
42
|
-
|
|
43
38
|
// initialize work state
|
|
44
39
|
const workState: WorkState = {
|
|
45
40
|
included: new Map(),
|
|
@@ -89,20 +84,6 @@ export function createOperationPlan(
|
|
|
89
84
|
return createOrderedPhases(workState, context, type, options)
|
|
90
85
|
}
|
|
91
86
|
|
|
92
|
-
function validatePreviewRestrictions(
|
|
93
|
-
context: OperationContext,
|
|
94
|
-
requestedInstanceIds: string[],
|
|
95
|
-
): void {
|
|
96
|
-
for (const instanceId of requestedInstanceIds) {
|
|
97
|
-
const dependents = context.getDependentStates(instanceId as InstanceId)
|
|
98
|
-
if (dependents.length > 0) {
|
|
99
|
-
throw new Error(
|
|
100
|
-
`Preview operation not allowed for instance ${instanceId} - has dependent instances`,
|
|
101
|
-
)
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
87
|
function processInstance(
|
|
107
88
|
instanceId: InstanceId,
|
|
108
89
|
workState: WorkState,
|
|
@@ -649,10 +649,14 @@ export class RuntimeOperation {
|
|
|
649
649
|
state: InstanceState,
|
|
650
650
|
): Promise<void> {
|
|
651
651
|
await this.workset.updateState(update.unitId, {
|
|
652
|
-
instanceState:
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
652
|
+
instanceState:
|
|
653
|
+
this.operation.type === "preview"
|
|
654
|
+
? // do not change instance status in preview mode
|
|
655
|
+
undefined
|
|
656
|
+
: {
|
|
657
|
+
// keep "deployed" status for initially deployed instances even if the operation was failed or cancelled
|
|
658
|
+
status: state.status === "deployed" ? "deployed" : "failed",
|
|
659
|
+
},
|
|
656
660
|
operationState: {
|
|
657
661
|
status: isAbortErrorLike(update.message) ? "cancelled" : "failed",
|
|
658
662
|
finishedAt: new Date(),
|
|
@@ -664,6 +668,17 @@ export class RuntimeOperation {
|
|
|
664
668
|
update: TypedUnitStateUpdate<"completion">,
|
|
665
669
|
state: InstanceState,
|
|
666
670
|
): Promise<void> {
|
|
671
|
+
if (this.operation.type === "preview") {
|
|
672
|
+
// do not change instance status in preview mode
|
|
673
|
+
await this.workset.updateState(update.unitId, {
|
|
674
|
+
operationState: {
|
|
675
|
+
status: this.workset.getStableStatusByOperationPhase(),
|
|
676
|
+
finishedAt: new Date(),
|
|
677
|
+
},
|
|
678
|
+
})
|
|
679
|
+
return
|
|
680
|
+
}
|
|
681
|
+
|
|
667
682
|
const instance = this.context.getInstance(update.unitId)
|
|
668
683
|
|
|
669
684
|
const data: InstanceStatePatch = {
|
|
@@ -696,6 +711,8 @@ export class RuntimeOperation {
|
|
|
696
711
|
data.dependencyOutputHash = null
|
|
697
712
|
data.outputHash = null
|
|
698
713
|
data.parentId = null
|
|
714
|
+
data.model = null
|
|
715
|
+
data.resolvedInputs = null
|
|
699
716
|
}
|
|
700
717
|
|
|
701
718
|
// update the operation state
|
package/src/services.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { type Logger, pino } from "pino"
|
|
|
4
4
|
import { type ArtifactBackend, ArtifactService, createArtifactBackend } from "./artifact"
|
|
5
5
|
import {
|
|
6
6
|
ApiKeyService,
|
|
7
|
+
BackendUnlockService,
|
|
7
8
|
InstanceLockService,
|
|
8
9
|
InstanceStateService,
|
|
9
10
|
OperationService,
|
|
@@ -19,7 +20,7 @@ import {
|
|
|
19
20
|
import { ProjectEvaluationSubsystem } from "./business/evaluation"
|
|
20
21
|
import { type Config, loadConfig } from "./config"
|
|
21
22
|
import {
|
|
22
|
-
|
|
23
|
+
createBackendDatabaseBackend,
|
|
23
24
|
createProjectDatabaseBackend,
|
|
24
25
|
type DatabaseManager,
|
|
25
26
|
DatabaseManagerImpl,
|
|
@@ -72,6 +73,7 @@ export type Services = {
|
|
|
72
73
|
readonly artifactBackend: ArtifactBackend
|
|
73
74
|
|
|
74
75
|
// business services
|
|
76
|
+
readonly backendUnlockService: BackendUnlockService
|
|
75
77
|
readonly instanceLockService: InstanceLockService
|
|
76
78
|
readonly projectUnlockService: ProjectUnlockService
|
|
77
79
|
readonly operationService: OperationService
|
|
@@ -131,6 +133,7 @@ export async function createServices({
|
|
|
131
133
|
artifactService,
|
|
132
134
|
|
|
133
135
|
// business services
|
|
136
|
+
backendUnlockService,
|
|
134
137
|
instanceLockService,
|
|
135
138
|
projectUnlockService,
|
|
136
139
|
operationService,
|
|
@@ -152,11 +155,11 @@ export async function createServices({
|
|
|
152
155
|
|
|
153
156
|
projectUnlockBackend ??= new MemoryProjectUnlockBackend()
|
|
154
157
|
|
|
155
|
-
const
|
|
158
|
+
const backendDatabaseBackend = await createBackendDatabaseBackend(config, logger)
|
|
156
159
|
const projectDatabaseBackend = await createProjectDatabaseBackend(config, logger)
|
|
157
160
|
|
|
158
161
|
database ??= new DatabaseManagerImpl(
|
|
159
|
-
|
|
162
|
+
backendDatabaseBackend,
|
|
160
163
|
projectUnlockBackend,
|
|
161
164
|
projectDatabaseBackend,
|
|
162
165
|
config,
|
|
@@ -174,6 +177,11 @@ export async function createServices({
|
|
|
174
177
|
artifactBackend ??= await createArtifactBackend(config, database, logger)
|
|
175
178
|
artifactService ??= new ArtifactService(database, artifactBackend, logger)
|
|
176
179
|
|
|
180
|
+
backendUnlockService ??= new BackendUnlockService(
|
|
181
|
+
database,
|
|
182
|
+
logger.child({ service: "BackendUnlockService" }),
|
|
183
|
+
)
|
|
184
|
+
|
|
177
185
|
secretService ??= new SecretService(
|
|
178
186
|
database,
|
|
179
187
|
pubsubManager,
|
|
@@ -335,6 +343,7 @@ export async function createServices({
|
|
|
335
343
|
artifactService,
|
|
336
344
|
|
|
337
345
|
// business services
|
|
346
|
+
backendUnlockService,
|
|
338
347
|
instanceLockService,
|
|
339
348
|
projectUnlockService,
|
|
340
349
|
operationService,
|
|
@@ -1,20 +1,14 @@
|
|
|
1
1
|
import { objectMetaSchema } from "@highstate/contract"
|
|
2
2
|
import { z } from "zod"
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
*
|
|
8
|
-
* Do not confuse with the project unlock method schema.
|
|
9
|
-
*/
|
|
10
|
-
export const backendUnlockMethodSchema = z.object({
|
|
11
|
-
id: z.string(),
|
|
12
|
-
meta: objectMetaSchema,
|
|
4
|
+
export const backendUnlockMethodMetaSchema = objectMetaSchema
|
|
5
|
+
.pick({ title: true, description: true })
|
|
6
|
+
.required({ title: true })
|
|
13
7
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
*/
|
|
8
|
+
export const backendUnlockMethodInputSchema = z.object({
|
|
9
|
+
meta: backendUnlockMethodMetaSchema,
|
|
17
10
|
recipient: z.string(),
|
|
18
11
|
})
|
|
19
12
|
|
|
20
|
-
export type
|
|
13
|
+
export type BackendUnlockMethodMeta = z.infer<typeof backendUnlockMethodMetaSchema>
|
|
14
|
+
export type BackendUnlockMethodInput = z.infer<typeof backendUnlockMethodInputSchema>
|
|
@@ -84,3 +84,17 @@ export class WorkerVersionNotFoundError extends BackendError {
|
|
|
84
84
|
this.name = "WorkerVersionNotFoundError"
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
|
+
|
|
88
|
+
export class BackendUnlockMethodNotFoundError extends BackendError {
|
|
89
|
+
constructor(id: string) {
|
|
90
|
+
super(`Backend unlock method with ID "${id}" not found.`)
|
|
91
|
+
this.name = "BackendUnlockMethodNotFoundError"
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export class CannotDeleteLastBackendUnlockMethodError extends BackendError {
|
|
96
|
+
constructor() {
|
|
97
|
+
super(`Refused to delete the last backend unlock method.`)
|
|
98
|
+
this.name = "CannotDeleteLastBackendUnlockMethodError"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -2,14 +2,22 @@ import type * as contract from "@highstate/contract"
|
|
|
2
2
|
import type * as shared from "../../shared"
|
|
3
3
|
|
|
4
4
|
declare global {
|
|
5
|
+
// Common
|
|
5
6
|
namespace PrismaJson {
|
|
6
|
-
type LibrarySpec = shared.LibrarySpec
|
|
7
7
|
type CommonObjectMeta = contract.CommonObjectMeta
|
|
8
|
+
}
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
// Backend
|
|
11
|
+
namespace PrismaJson {
|
|
12
|
+
type LibrarySpec = shared.LibrarySpec
|
|
13
|
+
type BackendUnlockMethodMeta = shared.BackendUnlockMethodMeta
|
|
10
14
|
|
|
15
|
+
type PulumiBackendSpec = shared.PulumiBackendSpec
|
|
11
16
|
type ProjectModelStorageSpec = shared.ProjectModelStorageSpec
|
|
17
|
+
}
|
|
12
18
|
|
|
19
|
+
// Project
|
|
20
|
+
namespace PrismaJson {
|
|
13
21
|
type InstanceId = contract.InstanceId
|
|
14
22
|
type NullableInstanceId = contract.InstanceId | null
|
|
15
23
|
type InstanceIds = contract.InstanceId[]
|
|
@@ -2,11 +2,13 @@ import type { Logger } from "pino"
|
|
|
2
2
|
import type { BackendDatabase, ProjectDatabase } from "../database/prisma"
|
|
3
3
|
import { constants } from "node:fs"
|
|
4
4
|
import { access, mkdtemp, rm } from "node:fs/promises"
|
|
5
|
-
import { tmpdir } from "node:os"
|
|
5
|
+
import { hostname, tmpdir } from "node:os"
|
|
6
6
|
import { join } from "node:path"
|
|
7
7
|
import { PrismaLibSQL } from "@prisma/adapter-libsql"
|
|
8
|
+
import { generateIdentity, identityToRecipient } from "age-encryption"
|
|
8
9
|
import { type DatabaseManager, ensureWellKnownEntitiesCreated } from "../database"
|
|
9
10
|
import { PrismaClient as BackendPrismaClient } from "../database/_generated/backend/sqlite/client"
|
|
11
|
+
import { getInitialBackendUnlockMethodMeta } from "../database/local/backend"
|
|
10
12
|
import { migrateDatabase } from "../database/migrate"
|
|
11
13
|
import {
|
|
12
14
|
type BackendDatabase as BackendDatabaseClient,
|
|
@@ -16,16 +18,25 @@ import {
|
|
|
16
18
|
export class TestDatabaseManager implements DatabaseManager {
|
|
17
19
|
private readonly projectDatabases = new Map<string, Promise<ProjectDatabase>>()
|
|
18
20
|
private readonly tempDirs: string[] = []
|
|
21
|
+
readonly backendUnlockRecipientUpdates: string[][] = []
|
|
19
22
|
|
|
20
23
|
constructor(
|
|
21
24
|
readonly backend: BackendDatabase,
|
|
22
25
|
private readonly logger: Logger,
|
|
26
|
+
readonly isEncryptionEnabled: boolean,
|
|
23
27
|
) {}
|
|
24
28
|
|
|
25
|
-
|
|
29
|
+
updateBackendUnlockRecipients(recipients: string[]): Promise<void> {
|
|
30
|
+
if (!this.isEncryptionEnabled) {
|
|
31
|
+
return Promise.resolve()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
this.backendUnlockRecipientUpdates.push(recipients)
|
|
35
|
+
return Promise.resolve()
|
|
36
|
+
}
|
|
26
37
|
|
|
27
|
-
getProjectMasterKey(): Promise<Buffer> {
|
|
28
|
-
return Promise.resolve(
|
|
38
|
+
getProjectMasterKey(): Promise<Buffer | undefined> {
|
|
39
|
+
return Promise.resolve(undefined)
|
|
29
40
|
}
|
|
30
41
|
|
|
31
42
|
setupDatabase(projectId: string): Promise<ProjectDatabase> {
|
|
@@ -55,7 +66,11 @@ export class TestDatabaseManager implements DatabaseManager {
|
|
|
55
66
|
})
|
|
56
67
|
}
|
|
57
68
|
|
|
58
|
-
static async create(
|
|
69
|
+
static async create(
|
|
70
|
+
logger: Logger,
|
|
71
|
+
options: { isEncryptionEnabled?: boolean } = {},
|
|
72
|
+
): Promise<TestDatabaseManager> {
|
|
73
|
+
const { isEncryptionEnabled = false } = options
|
|
59
74
|
const tempRoot = await findWritableTempDir()
|
|
60
75
|
const tempPath = await mkdtemp(join(tempRoot, "highstate"))
|
|
61
76
|
const backendUrl = `file:${join(tempPath, "backend.db")}`
|
|
@@ -68,7 +83,20 @@ export class TestDatabaseManager implements DatabaseManager {
|
|
|
68
83
|
|
|
69
84
|
await ensureWellKnownEntitiesCreated(backend)
|
|
70
85
|
|
|
71
|
-
|
|
86
|
+
if (isEncryptionEnabled) {
|
|
87
|
+
const identity = await generateIdentity()
|
|
88
|
+
const recipient = await identityToRecipient(identity)
|
|
89
|
+
const meta = getInitialBackendUnlockMethodMeta(hostname())
|
|
90
|
+
|
|
91
|
+
await backend.backendUnlockMethod.create({
|
|
92
|
+
data: {
|
|
93
|
+
recipient,
|
|
94
|
+
meta,
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return new TestDatabaseManager(backend, logger, isEncryptionEnabled)
|
|
72
100
|
}
|
|
73
101
|
|
|
74
102
|
private async createTempPath(): Promise<string> {
|