@fabrika/builder 0.2.0 → 0.3.1
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/BUILD-CONTRACT.md +8 -0
- package/README.md +9 -0
- package/cli/src/bin.ts +35 -2
- package/cli/src/build-status.ts +7 -15
- package/cli/src/commands/advance.ts +3 -2
- package/cli/src/commands/ensure-daemon.ts +3 -3
- package/cli/src/commands/gate.ts +4 -10
- package/cli/src/commands/init.ts +44 -7
- package/cli/src/commands/next.ts +3 -3
- package/cli/src/commands/platform.ts +81 -0
- package/cli/src/config.ts +1 -9
- package/cli/src/context.ts +4 -1
- package/cli/src/daemon-client.ts +36 -185
- package/cli/src/daemon-lifecycle.ts +63 -142
- package/cli/src/derive.ts +4 -8
- package/cli/src/errors.ts +1 -0
- package/cli/src/index.ts +2 -3
- package/cli/src/inject-skills.ts +8 -31
- package/cli/src/layout.ts +8 -18
- package/cli/src/packet.ts +18 -17
- package/cli/src/platform-client.ts +40 -0
- package/daemon/src/admission.ts +19 -0
- package/daemon/src/bin.ts +7 -9
- package/daemon/src/handlers.ts +82 -79
- package/daemon/src/index.ts +14 -5
- package/daemon/src/paths.ts +5 -6
- package/daemon/src/phases.ts +27 -126
- package/daemon/src/platform-compiler.ts +21 -0
- package/daemon/src/platform-remote.ts +146 -0
- package/daemon/src/platform-routes.ts +46 -0
- package/daemon/src/platform-source.ts +147 -0
- package/daemon/src/platform-storage.ts +222 -0
- package/daemon/src/platform.ts +481 -0
- package/decisions/README.md +4 -0
- package/decisions/patterns/zerops-control-deploy.md +49 -0
- package/decisions/patterns/zerops-local-app.md +57 -0
- package/decisions/patterns/zerops-postgres-seeds.md +37 -0
- package/decisions/technologies/zerops-fabrika-auth.md +53 -0
- package/decisions/technologies/zerops-platform.md +64 -0
- package/package.json +13 -5
- package/shared/index.ts +365 -0
- package/{cli/src → shared}/phases.ts +42 -50
- package/shared/urls.ts +41 -0
- package/skill/SKILL.md +34 -0
- package/stacks/README.md +2 -0
- package/stacks/zerops-app.md +66 -0
- package/ui/dist/assets/client.2fkhreps.js +214 -0
- package/ui/dist/assets/client.css +1 -1
- package/ui/dist/assets/components.js +57 -57
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/client.fhjppkvr.js +0 -214
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import type { RpcFetch } from '@fabrika/app'
|
|
2
|
+
import {
|
|
3
|
+
type DeviceAuthorizationConsent,
|
|
4
|
+
parseExchangeDeviceAuthorizationResult,
|
|
5
|
+
parsePollDeviceAuthorizationResult,
|
|
6
|
+
parseStartDeviceAuthorizationResult,
|
|
7
|
+
type StartDeviceAuthorizationInput,
|
|
8
|
+
} from '@fabrika/auth-core'
|
|
9
|
+
import { parseBuilderConnectionReceipt } from '@fabrika/control-contract'
|
|
10
|
+
import { zeropsArtifactCodec, zeropsStoredTargetCodec } from '@fabrika/provider-zerops'
|
|
11
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
12
|
+
import { mkdirSync } from 'node:fs'
|
|
13
|
+
import { homedir } from 'node:os'
|
|
14
|
+
import { join, resolve } from 'node:path'
|
|
15
|
+
import { setTimeout } from 'node:timers/promises'
|
|
16
|
+
import { type BuilderPairingState, type BuilderPlatformStatus, parseBuilderOrigin, parseBuilderPreviewUrl } from '../../shared'
|
|
17
|
+
import { DATA_DIR } from './paths'
|
|
18
|
+
import { parseEnvironments, parseRun, PlatformRemote } from './platform-remote'
|
|
19
|
+
import { compileProject, repositoryIdentity, verifySource } from './platform-source'
|
|
20
|
+
import { PlatformError, PlatformStorage, type ProjectRecord, type StoredCredential } from './platform-storage'
|
|
21
|
+
|
|
22
|
+
const PERMISSIONS = ['app.manage', 'deploy.trigger', 'deploy.read']
|
|
23
|
+
const KEY_LIFETIME = 30 * 24 * 60 * 60
|
|
24
|
+
const RECOVERY = 'The credential may already exist. Revoke this connection in the platform console, disconnect locally, then pair again.'
|
|
25
|
+
|
|
26
|
+
class ObservationError extends PlatformError {}
|
|
27
|
+
|
|
28
|
+
function activeDeployment(project: ProjectRecord): boolean {
|
|
29
|
+
return project.deployment !== null && ['preparing', 'pending', 'running'].includes(project.deployment.status)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface BuilderPlatformOptions {
|
|
33
|
+
stateDir?: string
|
|
34
|
+
credentialDir?: string
|
|
35
|
+
dataDir?: string
|
|
36
|
+
fetch?: RpcFetch
|
|
37
|
+
now?: () => number
|
|
38
|
+
sleep?: (milliseconds: number, signal: AbortSignal) => Promise<void>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class BuilderPlatform {
|
|
42
|
+
readonly storage: PlatformStorage
|
|
43
|
+
private readonly stopped = new AbortController()
|
|
44
|
+
private readonly operations = new Map<string, AbortController>()
|
|
45
|
+
private readonly pairings = new Map<string, BuilderPairingState>()
|
|
46
|
+
private readonly targets = new Map<string, string>()
|
|
47
|
+
private readonly now: () => number
|
|
48
|
+
private readonly sleep: (milliseconds: number, signal: AbortSignal) => Promise<void>
|
|
49
|
+
private readonly fetcher: RpcFetch | undefined
|
|
50
|
+
|
|
51
|
+
constructor(private readonly sessionRoot: (id: string) => string | undefined, options: BuilderPlatformOptions = {}) {
|
|
52
|
+
const dataDir = resolve(options.dataDir ?? DATA_DIR)
|
|
53
|
+
this.storage = new PlatformStorage(
|
|
54
|
+
resolve(options.stateDir ?? join(dataDir, 'builder-projects')),
|
|
55
|
+
resolve(options.credentialDir ?? join(homedir(), '.fabrika-builder', 'credentials')),
|
|
56
|
+
dataDir,
|
|
57
|
+
)
|
|
58
|
+
this.now = options.now ?? (() => Math.floor(Date.now() / 1000))
|
|
59
|
+
this.sleep = options.sleep ?? (async (ms, signal) => {
|
|
60
|
+
await setTimeout(ms, undefined, { signal })
|
|
61
|
+
})
|
|
62
|
+
this.fetcher = options.fetch
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
stop(): void {
|
|
66
|
+
this.stopped.abort()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
projectRoot(id: string): string | undefined {
|
|
70
|
+
return this.storage.project(id)?.projectRoot ?? this.sessionRoot(id)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
register(id: string, root: string): BuilderPlatformStatus {
|
|
74
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id)) throw new PlatformError('Invalid project session ID')
|
|
75
|
+
const projectRoot = this.storage.assertProjectRoot(root)
|
|
76
|
+
const existing = this.storage.project(id)
|
|
77
|
+
const session = this.sessionRoot(id)
|
|
78
|
+
if ((existing && existing.projectRoot !== projectRoot) || (session && this.storage.assertProjectRoot(session) !== projectRoot)) {
|
|
79
|
+
throw new PlatformError('Session is already bound to another project root')
|
|
80
|
+
}
|
|
81
|
+
const project = existing ?? { sessionId: id, projectRoot, deployment: null, recoveryRequired: false }
|
|
82
|
+
this.storage.config(project)
|
|
83
|
+
this.storage.saveProject(project)
|
|
84
|
+
return this.status(id)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private project(id: string): ProjectRecord {
|
|
88
|
+
const project = this.storage.project(id)
|
|
89
|
+
if (!project) throw new PlatformError('Register the project before using platform actions')
|
|
90
|
+
this.storage.config(project)
|
|
91
|
+
return project
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
status(id: string): BuilderPlatformStatus {
|
|
95
|
+
const project = this.project(id)
|
|
96
|
+
const config = this.storage.config(project)
|
|
97
|
+
let credential: StoredCredential | null = null
|
|
98
|
+
try {
|
|
99
|
+
credential = this.storage.credential(id)
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (!project.recoveryRequired) throw error
|
|
102
|
+
}
|
|
103
|
+
if (
|
|
104
|
+
project.deployment?.runId && (project.deployment.status === 'pending' || project.deployment.status === 'running') && !this.operations.has(id)
|
|
105
|
+
&& credential
|
|
106
|
+
) {
|
|
107
|
+
this.resume(project, credential)
|
|
108
|
+
} else if (project.deployment?.status === 'preparing' && !project.deployment.commitSha && !this.operations.has(id)) {
|
|
109
|
+
project.deployment = {
|
|
110
|
+
...project.deployment,
|
|
111
|
+
status: 'failed',
|
|
112
|
+
message: project.deployment.commitSha
|
|
113
|
+
? 'Deployment submission was interrupted. Inspect Control for a run before retrying explicitly.'
|
|
114
|
+
: 'Preparation was interrupted. Start deployment again explicitly.',
|
|
115
|
+
}
|
|
116
|
+
this.storage.saveProject(project)
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
sessionId: id,
|
|
120
|
+
projectRoot: project.projectRoot,
|
|
121
|
+
connection: credential?.connection ?? config.connection ?? null,
|
|
122
|
+
credential: { available: !!credential && credential.expiresAt > this.now(), expiresAt: credential?.expiresAt ?? null },
|
|
123
|
+
pairing: this.pairings.get(id)
|
|
124
|
+
?? (credential ? { status: 'connected' } : project.recoveryRequired ? { status: 'recovery_required', message: RECOVERY } : null),
|
|
125
|
+
previewUrl: config.previewUrl ?? null,
|
|
126
|
+
deployment: project.deployment,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
preview(id: string, url: string | null): BuilderPlatformStatus {
|
|
131
|
+
const project = this.project(id)
|
|
132
|
+
const { previewUrl: _old, ...config } = this.storage.config(project)
|
|
133
|
+
this.storage.saveConfig(project, { ...config, ...(url === null ? {} : { previewUrl: parseBuilderPreviewUrl(url) }) })
|
|
134
|
+
return this.status(id)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private operation(id: string): AbortController {
|
|
138
|
+
if (this.stopped.signal.aborted) throw new PlatformError('Daemon is stopping')
|
|
139
|
+
if (this.operations.has(id)) throw new PlatformError('A project operation is already active')
|
|
140
|
+
const controller = new AbortController()
|
|
141
|
+
this.operations.set(id, controller)
|
|
142
|
+
return controller
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private signal(controller: AbortController): AbortSignal {
|
|
146
|
+
return AbortSignal.any([controller.signal, this.stopped.signal])
|
|
147
|
+
}
|
|
148
|
+
private remote(signal: AbortSignal): PlatformRemote {
|
|
149
|
+
return new PlatformRemote(this.fetcher, signal)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async connect(id: string, requestedOrigin: string): Promise<BuilderPlatformStatus> {
|
|
153
|
+
const project = this.project(id)
|
|
154
|
+
if (this.storage.credential(id) || this.storage.config(project).connection || project.recoveryRequired) {
|
|
155
|
+
throw new PlatformError('An existing connection requires explicit disconnect before pairing again')
|
|
156
|
+
}
|
|
157
|
+
const controller = this.operation(id)
|
|
158
|
+
const signal = this.signal(controller)
|
|
159
|
+
try {
|
|
160
|
+
const remote = this.remote(signal)
|
|
161
|
+
const pins = await remote.discover(parseBuilderOrigin(requestedOrigin, 'Control origin'))
|
|
162
|
+
const approval = randomBytes(32).toString('base64url')
|
|
163
|
+
const redemptionVerifier = randomBytes(32).toString('base64url')
|
|
164
|
+
const challenge = (verifier: string) => createHash('sha256').update(verifier).digest('base64url')
|
|
165
|
+
const label = `Local builder ${id}`.slice(0, 200)
|
|
166
|
+
const input: StartDeviceAuthorizationInput = {
|
|
167
|
+
app: 'vozka',
|
|
168
|
+
label,
|
|
169
|
+
permissions: [...PERMISSIONS],
|
|
170
|
+
keyLifetimeSeconds: KEY_LIFETIME,
|
|
171
|
+
approvalChallenge: challenge(approval),
|
|
172
|
+
redemptionChallenge: challenge(redemptionVerifier),
|
|
173
|
+
}
|
|
174
|
+
const started = parseStartDeviceAuthorizationResult(await remote.json(`${pins.iamIssuer}/auth/device/start`, input))
|
|
175
|
+
if (!started?.ok || started.expiresAt <= this.now() || started.expiresAt > this.now() + 600) {
|
|
176
|
+
throw new PlatformError('IAM refused or returned an invalid pairing attempt')
|
|
177
|
+
}
|
|
178
|
+
const fragment = new URLSearchParams({ attempt: started.attemptId, approval, expires: String(started.expiresAt) })
|
|
179
|
+
this.pairings.set(id, { status: 'pending', authorizationUrl: `${pins.controlOrigin}/builder/connect#${fragment}`, expiresAt: started.expiresAt })
|
|
180
|
+
void this.pair(project, pins, { attemptId: started.attemptId, redemptionVerifier }, started.expiresAt, label, remote, signal)
|
|
181
|
+
.catch(() => {
|
|
182
|
+
if (!signal.aborted) {
|
|
183
|
+
this.pairings.set(id, {
|
|
184
|
+
status: project.recoveryRequired ? 'recovery_required' : 'failed',
|
|
185
|
+
message: project.recoveryRequired ? RECOVERY : 'Pairing failed. Check the installation and start a new attempt.',
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
}).finally(() => {
|
|
189
|
+
if (this.operations.get(id) === controller) this.operations.delete(id)
|
|
190
|
+
})
|
|
191
|
+
return this.status(id)
|
|
192
|
+
} catch (error) {
|
|
193
|
+
this.operations.delete(id)
|
|
194
|
+
throw error instanceof PlatformError ? error : new PlatformError('Unable to start pairing')
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private validateConsent(consent: DeviceAuthorizationConsent, label: string): void {
|
|
199
|
+
if (
|
|
200
|
+
consent.app !== 'vozka' || consent.label !== label || consent.scope.type !== 'app'
|
|
201
|
+
|| !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(consent.scope.value)
|
|
202
|
+
|| [...consent.permissions].sort().join(',') !== [...PERMISSIONS].sort().join(',') || consent.keyExpiresAt <= this.now()
|
|
203
|
+
|| consent.keyExpiresAt > this.now() + KEY_LIFETIME
|
|
204
|
+
) {
|
|
205
|
+
throw new PlatformError('IAM consent does not match the requested app-scoped authorization')
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private async pair(
|
|
210
|
+
project: ProjectRecord,
|
|
211
|
+
pins: { controlOrigin: string; iamIssuer: string },
|
|
212
|
+
proof: { attemptId: string; redemptionVerifier: string },
|
|
213
|
+
expiresAt: number,
|
|
214
|
+
label: string,
|
|
215
|
+
remote: PlatformRemote,
|
|
216
|
+
signal: AbortSignal,
|
|
217
|
+
): Promise<void> {
|
|
218
|
+
const id = project.sessionId
|
|
219
|
+
for (let poll = 0; poll < 200 && this.now() < expiresAt; poll++) {
|
|
220
|
+
await this.sleep(3000, signal)
|
|
221
|
+
signal.throwIfAborted()
|
|
222
|
+
const result = parsePollDeviceAuthorizationResult(await remote.json(`${pins.iamIssuer}/auth/device/poll`, proof))
|
|
223
|
+
signal.throwIfAborted()
|
|
224
|
+
if (!result) throw new PlatformError('Invalid IAM pairing response')
|
|
225
|
+
if (!result.ok) {
|
|
226
|
+
if (result.reason === 'slow_down') continue
|
|
227
|
+
throw new PlatformError('Pairing attempt was not found')
|
|
228
|
+
}
|
|
229
|
+
if (result.status === 'pending') continue
|
|
230
|
+
if (result.status === 'issued') {
|
|
231
|
+
project.recoveryRequired = true
|
|
232
|
+
this.storage.saveProject(project)
|
|
233
|
+
this.pairings.set(id, { status: 'recovery_required', message: RECOVERY })
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
if (result.status !== 'approved') {
|
|
237
|
+
this.pairings.set(
|
|
238
|
+
id,
|
|
239
|
+
result.status === 'expired' || result.status === 'cancelled'
|
|
240
|
+
? { status: result.status }
|
|
241
|
+
: { status: 'failed', message: 'Authorization was denied or the approving account lost access.' },
|
|
242
|
+
)
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
const consent = result.consent
|
|
246
|
+
this.validateConsent(consent, label)
|
|
247
|
+
this.pairings.set(id, { status: 'approved' })
|
|
248
|
+
// Persist ambiguity before the one-time exchange; a lost response must never cause a remint.
|
|
249
|
+
project.recoveryRequired = true
|
|
250
|
+
this.storage.saveProject(project)
|
|
251
|
+
const exchanged = parseExchangeDeviceAuthorizationResult(
|
|
252
|
+
await remote.json(`${pins.iamIssuer}/auth/device/exchange`, { ...proof, consentId: consent.consentId }),
|
|
253
|
+
)
|
|
254
|
+
if (!exchanged?.ok) throw new PlatformError(RECOVERY)
|
|
255
|
+
if (
|
|
256
|
+
exchanged.receipt.attemptId !== proof.attemptId || exchanged.receipt.revokedAt !== null
|
|
257
|
+
|| JSON.stringify(exchanged.receipt.consent) !== JSON.stringify(consent)
|
|
258
|
+
) throw new PlatformError(RECOVERY)
|
|
259
|
+
const client = remote.controlForReceipt(pins.controlOrigin, exchanged.token)
|
|
260
|
+
const receipt = parseBuilderConnectionReceipt(await client.builderConnections.getReceipt({ attemptId: proof.attemptId }))
|
|
261
|
+
if (receipt.attemptId !== proof.attemptId || receipt.provider !== 'zerops' || receipt.app.id !== consent.scope.value) {
|
|
262
|
+
throw new PlatformError(RECOVERY)
|
|
263
|
+
}
|
|
264
|
+
signal.throwIfAborted()
|
|
265
|
+
const connection = { ...pins, receipt }
|
|
266
|
+
this.storage.saveCredential(id, { connection, token: exchanged.token, expiresAt: consent.keyExpiresAt })
|
|
267
|
+
this.storage.saveConfig(project, { ...this.storage.config(project), connection })
|
|
268
|
+
project.recoveryRequired = false
|
|
269
|
+
this.storage.saveProject(project)
|
|
270
|
+
this.pairings.set(id, { status: 'connected' })
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
this.pairings.set(id, { status: 'expired' })
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
disconnect(id: string): BuilderPlatformStatus {
|
|
277
|
+
const project = this.project(id)
|
|
278
|
+
if (activeDeployment(project)) {
|
|
279
|
+
throw new PlatformError('Deployment is still active or awaiting confirmation. Keep the connection until its outcome is known.')
|
|
280
|
+
}
|
|
281
|
+
this.operations.get(id)?.abort()
|
|
282
|
+
this.operations.delete(id)
|
|
283
|
+
this.storage.removeCredential(id)
|
|
284
|
+
const { connection: _old, ...config } = this.storage.config(project)
|
|
285
|
+
this.storage.saveConfig(project, config)
|
|
286
|
+
project.recoveryRequired = false
|
|
287
|
+
this.storage.saveProject(project)
|
|
288
|
+
this.pairings.set(id, { status: 'cancelled' })
|
|
289
|
+
return this.status(id)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
deploy(id: string): BuilderPlatformStatus {
|
|
293
|
+
const project = this.project(id)
|
|
294
|
+
const credential = this.storage.credential(id)
|
|
295
|
+
if (!credential || credential.expiresAt <= this.now()) throw new PlatformError('Connect the project with a valid credential before deploying')
|
|
296
|
+
if (activeDeployment(project)) {
|
|
297
|
+
throw new PlatformError('A deploy run is already active')
|
|
298
|
+
}
|
|
299
|
+
const key = this.targetKey(credential)
|
|
300
|
+
this.restoreReservations()
|
|
301
|
+
if (this.targets.has(key)) throw new PlatformError('This application environment already has a local operation')
|
|
302
|
+
const controller = this.operation(id)
|
|
303
|
+
this.targets.set(key, id)
|
|
304
|
+
project.deployment = {
|
|
305
|
+
status: 'preparing',
|
|
306
|
+
runId: null,
|
|
307
|
+
commitSha: null,
|
|
308
|
+
appUrl: null,
|
|
309
|
+
consoleUrl: credential.connection.controlOrigin,
|
|
310
|
+
message: 'Verifying committed source and compiling the application.',
|
|
311
|
+
}
|
|
312
|
+
this.storage.saveProject(project)
|
|
313
|
+
this.backgroundDeploy(project, credential, controller, key, false)
|
|
314
|
+
return this.status(id)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private targetKey(credential: StoredCredential): string {
|
|
318
|
+
return JSON.stringify([credential.connection.controlOrigin, credential.connection.receipt.app.id, credential.connection.receipt.environment.env])
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private restoreReservations(): void {
|
|
322
|
+
const reservations = new Map<string, string>()
|
|
323
|
+
for (const project of this.storage.projects()) {
|
|
324
|
+
if (!activeDeployment(project)) continue
|
|
325
|
+
const credential = this.storage.credential(project.sessionId)
|
|
326
|
+
if (!credential) throw new PlatformError('An active deployment has missing custody. Restore its connection before starting another deployment.')
|
|
327
|
+
const key = this.targetKey(credential)
|
|
328
|
+
const owner = reservations.get(key)
|
|
329
|
+
if (owner && owner !== project.sessionId) {
|
|
330
|
+
throw new PlatformError('Multiple saved runs reserve this application environment. Resolve them in the console.')
|
|
331
|
+
}
|
|
332
|
+
reservations.set(key, project.sessionId)
|
|
333
|
+
}
|
|
334
|
+
this.targets.clear()
|
|
335
|
+
for (const [key, owner] of reservations) this.targets.set(key, owner)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private resume(project: ProjectRecord, credential: StoredCredential): void {
|
|
339
|
+
const key = this.targetKey(credential)
|
|
340
|
+
const owner = this.targets.get(key)
|
|
341
|
+
if ((owner !== undefined && owner !== project.sessionId) || this.stopped.signal.aborted) return
|
|
342
|
+
const controller = this.operation(project.sessionId)
|
|
343
|
+
this.targets.set(key, project.sessionId)
|
|
344
|
+
this.backgroundDeploy(project, credential, controller, key, true)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private backgroundDeploy(project: ProjectRecord, credential: StoredCredential, controller: AbortController, key: string, resume: boolean): void {
|
|
348
|
+
const signal = this.signal(controller)
|
|
349
|
+
void (resume ? this.monitor(project, credential, signal) : this.prepare(project, credential, signal))
|
|
350
|
+
.catch((error: unknown) => {
|
|
351
|
+
if (signal.aborted) return
|
|
352
|
+
if (project.deployment) {
|
|
353
|
+
const unobserved = error instanceof ObservationError || (!project.deployment.runId && project.deployment.commitSha !== null)
|
|
354
|
+
project.deployment = {
|
|
355
|
+
...project.deployment,
|
|
356
|
+
status: unobserved ? project.deployment.status : 'failed',
|
|
357
|
+
appUrl: null,
|
|
358
|
+
message: unobserved
|
|
359
|
+
? project.deployment.runId
|
|
360
|
+
? 'Run observation is unavailable. The remote outcome is unknown; status will retry monitoring.'
|
|
361
|
+
: 'Deployment submission outcome is unknown. Inspect Control; this target remains reserved and cannot be retriggered.'
|
|
362
|
+
: error instanceof PlatformError
|
|
363
|
+
? error.message
|
|
364
|
+
: 'Deployment failed. Check the platform console and local source.',
|
|
365
|
+
}
|
|
366
|
+
this.storage.saveProject(project)
|
|
367
|
+
}
|
|
368
|
+
}).catch(() => {}).finally(() => {
|
|
369
|
+
if (!activeDeployment(project)) this.targets.delete(key)
|
|
370
|
+
if (this.operations.get(project.sessionId) === controller) this.operations.delete(project.sessionId)
|
|
371
|
+
})
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private async prepare(project: ProjectRecord, credential: StoredCredential, signal: AbortSignal): Promise<void> {
|
|
375
|
+
const { receipt } = credential.connection
|
|
376
|
+
const app = receipt.app
|
|
377
|
+
const env = receipt.environment.env
|
|
378
|
+
if (receipt.provider !== 'zerops') throw new PlatformError('Unsupported connection provider')
|
|
379
|
+
const sha = await verifySource(project.projectRoot, app, signal)
|
|
380
|
+
mkdirSync(this.storage.stateDir, { recursive: true, mode: 0o700 })
|
|
381
|
+
const manifest = await compileProject(project.projectRoot, env, app.id, this.storage.stateDir, signal)
|
|
382
|
+
if (await verifySource(project.projectRoot, app, signal) !== sha) throw new PlatformError('Source changed during compilation')
|
|
383
|
+
const client = this.remote(signal).control(credential)
|
|
384
|
+
const currentApp = parseBuilderConnectionReceipt({ ...receipt, app: await client.apps.get({ appId: app.id }) }).app
|
|
385
|
+
if (
|
|
386
|
+
currentApp.id !== app.id || repositoryIdentity(currentApp.repoUrl) !== repositoryIdentity(app.repoUrl) || currentApp.workerDir !== app.workerDir
|
|
387
|
+
|| currentApp.configPath !== app.configPath || currentApp.buildCmd !== app.buildCmd || currentApp.defaultBranch !== app.defaultBranch
|
|
388
|
+
|| currentApp.githubConnectionId !== app.githubConnectionId
|
|
389
|
+
|| currentApp.githubInstallationId !== app.githubInstallationId
|
|
390
|
+
) throw new PlatformError('The approved application source changed in Control; reconnect explicitly')
|
|
391
|
+
const current = parseEnvironments(await client.apps.environments.list({ appId: app.id })).find((item) => item.env === env)
|
|
392
|
+
if (
|
|
393
|
+
current
|
|
394
|
+
&& (current.appId !== app.id || current.provider !== 'zerops' || current.target.provider !== 'zerops' || current.artifact.provider !== 'zerops')
|
|
395
|
+
) throw new PlatformError('Environment provider or application does not match the connection')
|
|
396
|
+
const placement = current ?? receipt.environment
|
|
397
|
+
if (!placement.namespaceId) throw new PlatformError('Select a deployment namespace in the platform console before deploying')
|
|
398
|
+
signal.throwIfAborted()
|
|
399
|
+
const updated = parseEnvironments({
|
|
400
|
+
items: [
|
|
401
|
+
await client.apps.environments.put({
|
|
402
|
+
appId: app.id,
|
|
403
|
+
env,
|
|
404
|
+
environment: {
|
|
405
|
+
namespaceId: placement.namespaceId,
|
|
406
|
+
domain: placement.domain,
|
|
407
|
+
publicOrigin: placement.publicOrigin,
|
|
408
|
+
triggerRef: placement.triggerRef,
|
|
409
|
+
target: current?.target ?? { provider: 'zerops', version: zeropsStoredTargetCodec.version, payload: {} },
|
|
410
|
+
artifact: { provider: 'zerops', version: zeropsArtifactCodec.version, payload: zeropsArtifactCodec.encode(manifest) },
|
|
411
|
+
},
|
|
412
|
+
}),
|
|
413
|
+
],
|
|
414
|
+
})[0]
|
|
415
|
+
if (!updated || updated.appId !== app.id || updated.env !== env || updated.provider !== 'zerops') {
|
|
416
|
+
throw new PlatformError('Control returned a mismatched environment')
|
|
417
|
+
}
|
|
418
|
+
signal.throwIfAborted()
|
|
419
|
+
// Once triggering starts the response may be lost. Never retry this write automatically.
|
|
420
|
+
project.deployment = {
|
|
421
|
+
status: 'preparing',
|
|
422
|
+
runId: null,
|
|
423
|
+
commitSha: sha,
|
|
424
|
+
message: 'Submitting exact commit. If interrupted, inspect Control before retrying.',
|
|
425
|
+
appUrl: null,
|
|
426
|
+
consoleUrl: credential.connection.controlOrigin,
|
|
427
|
+
}
|
|
428
|
+
this.storage.saveProject(project)
|
|
429
|
+
const run = parseRun(await client.deploy({ appId: app.id, env, ref: sha }))
|
|
430
|
+
if (run.appId !== app.id || run.env !== env || run.ref !== sha) throw new PlatformError('Control returned a different deployment target')
|
|
431
|
+
project.deployment = { ...project.deployment, status: 'pending', runId: run.id, message: 'Deployment submitted; waiting for the platform run.' }
|
|
432
|
+
this.storage.saveProject(project)
|
|
433
|
+
await this.monitor(project, credential, signal)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private async monitor(project: ProjectRecord, credential: StoredCredential, signal: AbortSignal): Promise<void> {
|
|
437
|
+
const client = this.remote(signal).control(credential)
|
|
438
|
+
const receipt = credential.connection.receipt
|
|
439
|
+
const deadline = this.now() + 3600
|
|
440
|
+
for (let poll = 0; poll < 1200 && this.now() < deadline; poll++) {
|
|
441
|
+
const deployment = project.deployment
|
|
442
|
+
if (!deployment?.runId || !deployment.commitSha) throw new PlatformError('Saved deployment is missing its run or commit')
|
|
443
|
+
signal.throwIfAborted()
|
|
444
|
+
const run = await client.runs.get({ runId: deployment.runId }).then(parseRun).catch(() => {
|
|
445
|
+
throw new ObservationError('Run observation unavailable')
|
|
446
|
+
})
|
|
447
|
+
if (run.id !== deployment.runId || run.appId !== receipt.app.id || run.env !== receipt.environment.env || run.ref !== deployment.commitSha) {
|
|
448
|
+
throw new ObservationError('Platform returned a different run')
|
|
449
|
+
}
|
|
450
|
+
const terminal = run.status === 'succeeded' || run.status === 'failed'
|
|
451
|
+
if (terminal && run.commitSha !== deployment.commitSha) throw new PlatformError('Completed run commit does not match the requested SHA')
|
|
452
|
+
let appUrl: string | null = null
|
|
453
|
+
if (run.status === 'succeeded') {
|
|
454
|
+
try {
|
|
455
|
+
const environment = parseEnvironments(await client.apps.environments.list({ appId: receipt.app.id })).find((item) =>
|
|
456
|
+
item.appId === receipt.app.id && item.env === receipt.environment.env && item.provider === 'zerops'
|
|
457
|
+
)
|
|
458
|
+
if (environment?.publicOrigin) appUrl = parseBuilderPreviewUrl(environment.publicOrigin)
|
|
459
|
+
else if (environment?.domain) appUrl = parseBuilderPreviewUrl(`https://${environment.domain}`)
|
|
460
|
+
} catch {
|
|
461
|
+
// URL metadata is optional; it cannot change an already verified terminal run result.
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
signal.throwIfAborted()
|
|
465
|
+
project.deployment = {
|
|
466
|
+
...deployment,
|
|
467
|
+
status: run.status,
|
|
468
|
+
appUrl,
|
|
469
|
+
message: run.status === 'succeeded'
|
|
470
|
+
? 'Deployment succeeded. The configured URL has not been checked for reachability.'
|
|
471
|
+
: run.status === 'failed'
|
|
472
|
+
? 'Platform deployment failed. See the console for details.'
|
|
473
|
+
: 'Platform deployment is in progress.',
|
|
474
|
+
}
|
|
475
|
+
this.storage.saveProject(project)
|
|
476
|
+
if (terminal) return
|
|
477
|
+
await this.sleep(3000, signal)
|
|
478
|
+
}
|
|
479
|
+
throw new ObservationError('Deployment polling timed out. Inspect the saved run in the platform console.')
|
|
480
|
+
}
|
|
481
|
+
}
|
package/decisions/README.md
CHANGED
|
@@ -115,6 +115,8 @@ See `../stacks/README.md`.
|
|
|
115
115
|
| error tracking (`error-tracking`) | `sentry` |
|
|
116
116
|
| testing | `opice` |
|
|
117
117
|
|
|
118
|
+
The platform-managed Zerops path adds [`zerops-platform`](technologies/zerops-platform.md) and [`zerops-fabrika-auth`](technologies/zerops-fabrika-auth.md).
|
|
119
|
+
|
|
118
120
|
### Patterns (`patterns/`)
|
|
119
121
|
|
|
120
122
|
Applied _over_ the technologies they `require` (no package; `kind: pattern`).
|
|
@@ -128,6 +130,8 @@ Applied _over_ the technologies they `require` (no package; `kind: pattern`).
|
|
|
128
130
|
| CI / CD | `app-deploy` (direct), `vozka-app-deploy` (control plane), `lib-publish` |
|
|
129
131
|
| observability | `structured-logging`, `observability` |
|
|
130
132
|
|
|
133
|
+
Zerops composition uses [`zerops-local-app`](patterns/zerops-local-app.md), [`zerops-control-deploy`](patterns/zerops-control-deploy.md), and [`zerops-postgres-seeds`](patterns/zerops-postgres-seeds.md).
|
|
134
|
+
|
|
131
135
|
### Tooling (`tooling/`)
|
|
132
136
|
|
|
133
137
|
`deptective`, `edvabe` — additive dev tools (`slot: null`), not part of preset closure.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: zerops-control-deploy
|
|
3
|
+
kind: pattern
|
|
4
|
+
title: Deploy Zerops applications through installed Fabrika Control
|
|
5
|
+
slot: deploy-control-plane
|
|
6
|
+
provides: [managed-deploy, release-tracking]
|
|
7
|
+
requires: [zerops-platform, zerops-fabrika-auth]
|
|
8
|
+
recommends: []
|
|
9
|
+
conflicts: [app-deploy, vozka-app-deploy]
|
|
10
|
+
status: wip
|
|
11
|
+
sources:
|
|
12
|
+
- where: "packages/control-contract/src/rpc.ts + packages/provider-zerops/src/provider.ts in fabrika-platform"
|
|
13
|
+
read: "Current Control deployment contract and provider lifecycle; do not infer builder command syntax."
|
|
14
|
+
- where: "examples/zerops-app/README.md + fabrika.config.ts + zerops.yaml in fabrika-platform"
|
|
15
|
+
read: "Application-owned source artifacts versus platform-owned deployment and secrets."
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# Deploy through installed Control
|
|
19
|
+
|
|
20
|
+
Apply this to an application bound to an existing Fabrika installation. The human
|
|
21
|
+
creates or selects the app in the installed console and reviews its environment
|
|
22
|
+
and placement. Repository creation, the GitHub connection, namespace provisioning
|
|
23
|
+
and custom-domain binding remain administrator-owned prerequisites. Missing
|
|
24
|
+
prerequisites are a handoff to that administrator.
|
|
25
|
+
|
|
26
|
+
## Responsibilities
|
|
27
|
+
|
|
28
|
+
- The local agent implements, tests and prepares the application config and build
|
|
29
|
+
descriptor. It keeps the selected application identity stable on later changes.
|
|
30
|
+
- The human controls commits and pushes. Deployment consumes an immutable commit
|
|
31
|
+
already available in the configured remote repository; do not substitute an
|
|
32
|
+
uncommitted tree or automatically create another application on retry.
|
|
33
|
+
- Installed Control owns registry updates and deployment orchestration. Preserve
|
|
34
|
+
the reviewed namespace, source and public origin when updating an environment.
|
|
35
|
+
- Control and the Zerops provider supply credentials server-side, deliver source
|
|
36
|
+
and trigger Zerops' build/deploy pipeline. The local coding agent does not need
|
|
37
|
+
a Zerops account token or an installation provisioning credential.
|
|
38
|
+
- Follow the Control run to its terminal result and verify its requested revision
|
|
39
|
+
and public proxy URL. A successful build, a trigger response or a local emulator
|
|
40
|
+
result alone is not proof of a successful production release.
|
|
41
|
+
|
|
42
|
+
Do not deploy through `zops push`, direct provider API calls or a parallel
|
|
43
|
+
application-owned deployment workflow. Do not route a public domain directly to
|
|
44
|
+
the runtime service. App secrets remain platform-held service variables; keep
|
|
45
|
+
credentials out of source, manifests, browser state and compiler inputs.
|
|
46
|
+
|
|
47
|
+
This decision defines the deployment boundary. Consult the implemented CLI help
|
|
48
|
+
and workflow instructions for invocation syntax; it defines no builder connection
|
|
49
|
+
or deployment commands.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: zerops-local-app
|
|
3
|
+
kind: pattern
|
|
4
|
+
title: Local Bun SPA and API behind real Fabrika IAM and proxy
|
|
5
|
+
slot: app-delivery-shape
|
|
6
|
+
provides: [spa, local-development, proxy-preview]
|
|
7
|
+
requires: [zerops-platform, zerops-fabrika-auth, bun, postgres, react, buzola]
|
|
8
|
+
recommends: [tailwind, opice]
|
|
9
|
+
conflicts: [single-worker-spa]
|
|
10
|
+
status: wip
|
|
11
|
+
sources:
|
|
12
|
+
- where: "examples/zerops-app/src/server.ts + src/app.ts + README.md in fabrika-platform"
|
|
13
|
+
read: "Bun backend composition and the limits of the example's local run instructions."
|
|
14
|
+
- where: "docs/reference/local-development.md + packages/local-stack/CLAUDE.md in fabrika-platform"
|
|
15
|
+
read: "Real IAM/proxy/Postgres topology, local endpoints and IAM-owned login bypass."
|
|
16
|
+
- where: "packages/local-stack/src/app-registration.ts in fabrika-platform"
|
|
17
|
+
read: "Local schema and return-origin registration using the same IAM contract as deployment."
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
# Local Bun SPA and API
|
|
21
|
+
|
|
22
|
+
Apply this to a client-rendered React/Buzola app served with its API by a Bun
|
|
23
|
+
process. Use Bun-native asset bundling and Buzola codegen. API namespaces must
|
|
24
|
+
return their own errors and 404s; only UI routes receive SPA fallback.
|
|
25
|
+
|
|
26
|
+
## Local composition to scaffold
|
|
27
|
+
|
|
28
|
+
The development loop needs PostgreSQL, real IAM, a proxy with this application's
|
|
29
|
+
gates and a private Bun upstream. Register the application's schema and exact
|
|
30
|
+
local proxy return origin with local IAM. The app's configured issuer and audience
|
|
31
|
+
must match the tokens IAM mints. Keep the local database and credentials separate
|
|
32
|
+
from the installed production platform.
|
|
33
|
+
|
|
34
|
+
Use the existing Fabrika local-stack composition as the reference. From the
|
|
35
|
+
**fabrika-platform repository root**, `bun run local:up` starts its documented
|
|
36
|
+
services, and `bun run local:status` reports them. It pre-registers the fixture
|
|
37
|
+
apps, not every new builder project. The public notes fixture is available at
|
|
38
|
+
`http://notes.fabrika.localhost:18081` through its proxy.
|
|
39
|
+
|
|
40
|
+
A new app needs its own documented local service wiring and registration; the
|
|
41
|
+
example's bare server command does not provide those. Establish that wiring as
|
|
42
|
+
the app's foundation work and verify it before reporting a working preview.
|
|
43
|
+
If the local runtime cannot reach IAM, or a required registration cannot be made,
|
|
44
|
+
report the missing prerequisite rather than adding fake auth or bypassing the
|
|
45
|
+
proxy.
|
|
46
|
+
|
|
47
|
+
## Preview and acceptance
|
|
48
|
+
|
|
49
|
+
Use the **proxy origin** as the browser preview and opice target, including its
|
|
50
|
+
actual port. Do not assume `localhost:5173` or use the private upstream as the
|
|
51
|
+
authenticated preview. Run the real login/callback flow and an authorization
|
|
52
|
+
denial, in addition to the product's success scenario. The local IAM login bypass
|
|
53
|
+
is a bootstrap-admin convenience; role-specific tests use real restricted grants.
|
|
54
|
+
|
|
55
|
+
Keep the coding agent, source, dev runtime and browser test loop local. The local
|
|
56
|
+
Zerops emulator can witness Control orchestration; it cannot prove real Zerops
|
|
57
|
+
builds, routing or infrastructure behavior.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: zerops-postgres-seeds
|
|
3
|
+
kind: pattern
|
|
4
|
+
title: Named PostgreSQL fixtures for local Zerops application tests
|
|
5
|
+
slot: seed-strategy
|
|
6
|
+
provides: [seed-strategy, foundation-manifest]
|
|
7
|
+
requires: [postgres]
|
|
8
|
+
recommends: [opice]
|
|
9
|
+
conflicts: [named-seeds]
|
|
10
|
+
status: stable
|
|
11
|
+
sources:
|
|
12
|
+
- where: "packages/builder/decisions/technologies/opice.md in fabrika-platform"
|
|
13
|
+
read: "Scenario seeds metadata and its filename contract."
|
|
14
|
+
- where: "examples/zerops-app/src/migrate.ts + src/notes.ts in fabrika-platform"
|
|
15
|
+
read: "PostgreSQL access through Bun SQL and repeatable migration behavior."
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# Named PostgreSQL fixtures
|
|
19
|
+
|
|
20
|
+
Apply this to the preset's local PostgreSQL database. An opice scenario naming
|
|
21
|
+
`seeds: ['example']` requires `seeds/example.sql`. Run migrations first, then apply
|
|
22
|
+
the named SQL files through the application's PostgreSQL driver or `psql` before
|
|
23
|
+
the scenario. Document that local seed command in the scaffold.
|
|
24
|
+
|
|
25
|
+
Use stable fixture IDs and PostgreSQL `INSERT ... ON CONFLICT` semantics so
|
|
26
|
+
repeated application is safe. Tests that mutate fixtures must restore their
|
|
27
|
+
required initial state or use isolated data. Keep fixture writes explicitly
|
|
28
|
+
targeted at the local test database; deployment migrations must not load them.
|
|
29
|
+
|
|
30
|
+
This preserves the catalog's named-seed metadata contract without selecting
|
|
31
|
+
`named-seeds`, whose hard dependency and commands are D1-specific. Do not use
|
|
32
|
+
`INSERT OR IGNORE`, lopata D1 commands or Wrangler database commands here.
|
|
33
|
+
|
|
34
|
+
Application data fixtures do not grant identity. Provision scenario principals
|
|
35
|
+
and permissions through the real local IAM setup; never manufacture an
|
|
36
|
+
application auth context from a seed name or role label. A genuinely
|
|
37
|
+
self-provisioning scenario can declare `seeds: []`.
|