@fabrika/provider-zerops 0.0.25 → 0.2.0
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/package.json +5 -5
- package/src/api.ts +79 -22
- package/src/compile.ts +72 -19
- package/src/control.ts +17 -0
- package/src/index.ts +1 -1
- package/src/manifest.ts +48 -2
- package/src/namespace.ts +116 -1
- package/src/provider.ts +163 -25
- package/src/service-references.ts +28 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/provider-zerops",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"gen:schema": "bun scripts/generate-zerops-types.ts"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@fabrika/auth": "^0.0
|
|
36
|
-
"@fabrika/auth-core": "^0.0
|
|
37
|
-
"@fabrika/provider-contract": "^0.0
|
|
38
|
-
"@fabrika/proxy-contract": "^0.0
|
|
35
|
+
"@fabrika/auth": "^0.2.0",
|
|
36
|
+
"@fabrika/auth-core": "^0.2.0",
|
|
37
|
+
"@fabrika/provider-contract": "^0.2.0",
|
|
38
|
+
"@fabrika/proxy-contract": "^0.2.0"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/src/api.ts
CHANGED
|
@@ -258,7 +258,7 @@ export interface ZeropsServiceEnv {
|
|
|
258
258
|
|
|
259
259
|
/**
|
|
260
260
|
* A short-lived grant for the project's LOG service. VERIFIED: `ResponseProjectLog` — the field names and
|
|
261
|
-
* types are exactly these. What
|
|
261
|
+
* types are exactly these. What the URLs speak was measured separately; see `readBuildLog`.
|
|
262
262
|
*/
|
|
263
263
|
export interface ZeropsLogAccess {
|
|
264
264
|
url: string
|
|
@@ -271,10 +271,14 @@ export interface ZeropsLogAccess {
|
|
|
271
271
|
expiration: string
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
/** The RFC3339 stamp the plaintext log window puts first on every line. Anchored so a bare year cannot pass. */
|
|
275
|
+
const LOG_LINE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/
|
|
276
|
+
|
|
274
277
|
/** One relayed build/runtime log line. Shape is fabrika's, not the platform's. */
|
|
275
278
|
export interface ZeropsLogLine {
|
|
276
|
-
/**
|
|
279
|
+
/** RFC3339 stamp Zerops put at the head of the line, when the line carried one. */
|
|
277
280
|
timestamp?: string
|
|
281
|
+
/** The line as the log service wrote it, timestamp and tag included. */
|
|
278
282
|
message: string
|
|
279
283
|
}
|
|
280
284
|
|
|
@@ -457,8 +461,9 @@ export interface ZeropsApi {
|
|
|
457
461
|
* Every environment variable of ONE service, each with the record id `deleteServiceEnv` takes.
|
|
458
462
|
* VERIFIED: `GET /service-stack/{id}/env` (`{ items }`).
|
|
459
463
|
*
|
|
460
|
-
* NOT `GET /service-stack/{id}/user-data
|
|
461
|
-
*
|
|
464
|
+
* NOT `GET /service-stack/{id}/user-data`: it answers a list now (2026-08-21), but `/env` is the
|
|
465
|
+
* endpoint whose record ids `PUT`/`DELETE /user-data/{id}` take, and the list's paging terms were
|
|
466
|
+
* never verified — see `docs/reference/zerops-platform.md`. The `/env` reading omits
|
|
462
467
|
* variables the service's own `zerops.yaml` declares (`type: ENV`); fabrika never writes those.
|
|
463
468
|
* The OpenAPI publishes no lookup/pagination terms and live probes found query terms ignored, so the
|
|
464
469
|
* implementation reads one full response under a byte bound rather than imposing an entry-count cap.
|
|
@@ -548,17 +553,42 @@ export interface ZeropsApi {
|
|
|
548
553
|
getLogAccess(input: { projectId: string; signal: AbortSignal }): Promise<ZeropsLogAccess>
|
|
549
554
|
|
|
550
555
|
/**
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
*
|
|
556
|
+
* One window of the project's log, narrowed to ONE app version.
|
|
557
|
+
*
|
|
558
|
+
* VERIFIED 2026-08-21 by measurement, not by document: the OpenAPI file stops at the grant, so the
|
|
559
|
+
* separate log service was read against a live project. `urlPlain` answers one
|
|
560
|
+
* `<RFC3339 timestamp> <tag> <message>` line per record and accepts `serviceStackId`, `limit`, `tags`
|
|
561
|
+
* and `desc`; `from` and unknown terms are accepted and silently ignored, so nothing may depend on them.
|
|
562
|
+
*
|
|
563
|
+
* ORDERING, also measured: `limit=N` without `desc` returns the NEWEST N records in ASCENDING timestamp
|
|
564
|
+
* order (`limit=3` against a log starting 15:50 answered 17:00:00, 17:00:00, 17:15:00), and `desc=1`
|
|
565
|
+
* returns the same newest N, newest first. So `desc` is needed neither for the `since` cut nor for a
|
|
566
|
+
* build longer than the limit: each poll re-reads the newest window and the caller's dedupe streams it.
|
|
556
567
|
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
*
|
|
568
|
+
* A version's BUILD lines carry the tag `zbuilder@<appVersionId>` and are produced on a separate
|
|
569
|
+
* builder stack, so they are selected by `tags` ALONE — combining that tag with the runtime
|
|
570
|
+
* `serviceStackId` matches nothing. RUNTIME lines carry no version marker at all and the runtime
|
|
571
|
+
* service's window mixes every version that ever ran, so they are selected by `serviceStackId` and cut
|
|
572
|
+
* at `since` (the version's `build.pipelineStart`). Without `since` no runtime line is read: a line
|
|
573
|
+
* from an earlier version is worse in a run log than a missing one. The cut is by TIME, not identity —
|
|
574
|
+
* the outgoing container keeps logging until it is replaced, so its lines stamped after `since` still
|
|
575
|
+
* pass, and only the earlier version's older history is excluded.
|
|
576
|
+
*
|
|
577
|
+
* The two windows are read INDEPENDENTLY: one failing must not discard what the other returned, and only
|
|
578
|
+
* a failure of every window attempted is raised. The driver then treats that as non-fatal anyway — log
|
|
579
|
+
* relay degrades to "no lines" and the deploy succeeds or fails on `/app-version` status alone. A log
|
|
580
|
+
* service must never be able to fail a deploy.
|
|
560
581
|
*/
|
|
561
|
-
readBuildLog(input: {
|
|
582
|
+
readBuildLog(input: {
|
|
583
|
+
access: ZeropsLogAccess
|
|
584
|
+
serviceId: string
|
|
585
|
+
/** Select this version's build lines by their `zbuilder@` tag; without it no build line is read. */
|
|
586
|
+
appVersionId?: string
|
|
587
|
+
/** RFC3339 lower bound for runtime lines, normally `build.pipelineStart`; without it none are read. */
|
|
588
|
+
since?: string
|
|
589
|
+
limit?: number
|
|
590
|
+
signal: AbortSignal
|
|
591
|
+
}): Promise<ZeropsLogLine[]>
|
|
562
592
|
}
|
|
563
593
|
|
|
564
594
|
// ── default (real) implementation ───────────────────────────────────────────────
|
|
@@ -1245,20 +1275,47 @@ export const createZeropsApi = (options: ZeropsApiOptions): ZeropsApi => {
|
|
|
1245
1275
|
}
|
|
1246
1276
|
},
|
|
1247
1277
|
|
|
1248
|
-
readBuildLog: async ({ access, serviceId, limit, signal }) => {
|
|
1249
|
-
// UNVERIFIED (see the interface): this is a best reading of an undocumented log service, not a
|
|
1250
|
-
// contract. It is written so a wrong guess degrades to "no lines" instead of failing a deploy.
|
|
1278
|
+
readBuildLog: async ({ access, serviceId, appVersionId, since, limit, signal }) => {
|
|
1251
1279
|
if (access.urlPlain === '') {
|
|
1252
1280
|
return []
|
|
1253
1281
|
}
|
|
1254
1282
|
const separator = access.urlPlain.includes('?') ? '&' : '?'
|
|
1255
|
-
const
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1283
|
+
const readWindow = async (terms: string): Promise<{ lines: ZeropsLogLine[]; error: unknown }> => {
|
|
1284
|
+
try {
|
|
1285
|
+
const url = `${access.urlPlain}${separator}${terms}&limit=${limit ?? 200}`
|
|
1286
|
+
const response = await doFetch(url, { headers: { authorization: `Bearer ${access.accessToken}` }, signal })
|
|
1287
|
+
if (!response.ok) {
|
|
1288
|
+
throw new Error(`zerops: read build log failed (${response.status})`)
|
|
1289
|
+
}
|
|
1290
|
+
const text = await response.text()
|
|
1291
|
+
const lines = text.split('\n').filter((line) => line.trim() !== '').map((line) => {
|
|
1292
|
+
const head = line.slice(0, Math.max(line.indexOf(' '), 0))
|
|
1293
|
+
return LOG_LINE_TIMESTAMP.test(head) ? { timestamp: head, message: line } : { message: line }
|
|
1294
|
+
})
|
|
1295
|
+
return { lines, error: undefined }
|
|
1296
|
+
} catch (error) {
|
|
1297
|
+
return { lines: [], error }
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
// Selected by tag alone: the build runs on a builder stack, so pairing the tag with the runtime
|
|
1301
|
+
// service id matches nothing (see the interface).
|
|
1302
|
+
const build = appVersionId === undefined ? undefined : await readWindow(`tags=${encodeURIComponent(`zbuilder@${appVersionId}`)}`)
|
|
1303
|
+
const runtime = since === undefined ? undefined : await readWindow(`serviceStackId=${encodeURIComponent(serviceId)}`)
|
|
1304
|
+
const attempted = [build, runtime].filter((result) => result !== undefined)
|
|
1305
|
+
if (attempted.length === 0) {
|
|
1306
|
+
return []
|
|
1307
|
+
}
|
|
1308
|
+
// Independent windows: only a failure of every one attempted is worth raising.
|
|
1309
|
+
const failure = attempted.find((result) => result.error !== undefined)
|
|
1310
|
+
if (failure !== undefined && attempted.every((result) => result.error !== undefined)) {
|
|
1311
|
+
throw failure.error
|
|
1259
1312
|
}
|
|
1260
|
-
const
|
|
1261
|
-
|
|
1313
|
+
const boundary = since === undefined ? Number.NaN : Date.parse(since)
|
|
1314
|
+
// The runtime window carries every version that ever ran, so a line the driver cannot date is dropped.
|
|
1315
|
+
const runtimeLines = runtime === undefined || Number.isNaN(boundary)
|
|
1316
|
+
? runtime?.lines ?? []
|
|
1317
|
+
: runtime.lines.filter((line) => line.timestamp !== undefined && Date.parse(line.timestamp) >= boundary)
|
|
1318
|
+
return [...build?.lines ?? [], ...runtimeLines]
|
|
1262
1319
|
},
|
|
1263
1320
|
}
|
|
1264
1321
|
}
|
package/src/compile.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// credentials leak to app B. "Never write to project-level env" alone does not prevent that; the isolation
|
|
19
19
|
// setting is the other half.
|
|
20
20
|
|
|
21
|
+
import type { JsonValue } from '@fabrika/provider-contract'
|
|
21
22
|
import type { ZeropsImportProject, ZeropsImportService } from './schema.generated'
|
|
22
23
|
import type { ZeropsProjectSpec, ZeropsResourceContext, ZeropsServiceSpec, ZeropsSourceTarget } from './types'
|
|
23
24
|
|
|
@@ -37,6 +38,16 @@ export interface ZeropsImportDocument {
|
|
|
37
38
|
services: ZeropsImportService[]
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
/**
|
|
42
|
+
* The same document once it has been through JSON — a manifest's stored `importDocument`, or one whose
|
|
43
|
+
* values were substituted. Structurally identical, leaves typed as JSON, so `assertZeropsInvariants`
|
|
44
|
+
* checks it too rather than trusting whoever produced it.
|
|
45
|
+
*/
|
|
46
|
+
export interface ZeropsJsonImportDocument {
|
|
47
|
+
readonly project?: { readonly [key: string]: JsonValue }
|
|
48
|
+
readonly services: ReadonlyArray<{ readonly [key: string]: JsonValue }>
|
|
49
|
+
}
|
|
50
|
+
|
|
40
51
|
/** What the compiler needs beyond the app's declaration. */
|
|
41
52
|
export interface CompileInput {
|
|
42
53
|
target: ZeropsSourceTarget
|
|
@@ -185,14 +196,14 @@ const compileProject = (spec: ZeropsProjectSpec): ZeropsImportProject => {
|
|
|
185
196
|
*
|
|
186
197
|
* Deliberately checks the emitted document rather than the input: it is the document that reaches Zerops.
|
|
187
198
|
*/
|
|
188
|
-
export const assertZeropsInvariants = (document: ZeropsImportDocument): void => {
|
|
199
|
+
export const assertZeropsInvariants = (document: ZeropsImportDocument | ZeropsJsonImportDocument): void => {
|
|
189
200
|
const project = document.project
|
|
190
201
|
if (project !== undefined) {
|
|
191
|
-
if (project
|
|
202
|
+
if (project['envVariables'] !== undefined) {
|
|
192
203
|
throw new Error('zerops: refusing to write PROJECT-level env variables — they reach every service in the project (ADR-0004)')
|
|
193
204
|
}
|
|
194
|
-
if (project
|
|
195
|
-
throw new Error(`zerops: project envIsolation must be \`${ENV_ISOLATION}\`, got \`${String(project
|
|
205
|
+
if (project['envIsolation'] !== ENV_ISOLATION) {
|
|
206
|
+
throw new Error(`zerops: project envIsolation must be \`${ENV_ISOLATION}\`, got \`${String(project['envIsolation'])}\` (ADR-0004)`)
|
|
196
207
|
}
|
|
197
208
|
}
|
|
198
209
|
if (document.services.length === 0) {
|
|
@@ -200,23 +211,24 @@ export const assertZeropsInvariants = (document: ZeropsImportDocument): void =>
|
|
|
200
211
|
}
|
|
201
212
|
const seen = new Set<string>()
|
|
202
213
|
for (const service of document.services) {
|
|
203
|
-
|
|
214
|
+
const hostname = String(service['hostname'])
|
|
215
|
+
if (service['envIsolation'] !== ENV_ISOLATION) {
|
|
204
216
|
throw new Error(
|
|
205
|
-
`zerops: service \`${
|
|
217
|
+
`zerops: service \`${hostname}\` envIsolation must be \`${ENV_ISOLATION}\`, got \`${String(service['envIsolation'])}\` (ADR-0004)`,
|
|
206
218
|
)
|
|
207
219
|
}
|
|
208
220
|
// Every service class, managed included. A document missing it anywhere cannot be re-applied at
|
|
209
221
|
// all: the platform answers `400 serviceStackNameUnavailable` on the first hostname that exists
|
|
210
222
|
// and rejects the whole import, so one omission breaks every later apply, not just that service.
|
|
211
|
-
if (service
|
|
223
|
+
if (service['override'] !== OVERRIDE) {
|
|
212
224
|
throw new Error(
|
|
213
|
-
`zerops: service \`${
|
|
225
|
+
`zerops: service \`${hostname}\` must carry \`override: true\`; without it a re-apply is rejected as a name collision`,
|
|
214
226
|
)
|
|
215
227
|
}
|
|
216
|
-
if (service
|
|
228
|
+
if (service['envSecrets'] !== undefined || service['dotEnvSecrets'] !== undefined) {
|
|
217
229
|
// Never assert on the VALUE — only that the field is present.
|
|
218
230
|
throw new Error(
|
|
219
|
-
`zerops: service \`${
|
|
231
|
+
`zerops: service \`${hostname}\` carries secrets in the import document; secrets are written through the env API (ADR-0004)`,
|
|
220
232
|
)
|
|
221
233
|
}
|
|
222
234
|
// `zeropsSetup` is part of the import's pipeline configuration, and the platform rejects the WHOLE
|
|
@@ -230,15 +242,15 @@ export const assertZeropsInvariants = (document: ZeropsImportDocument): void =>
|
|
|
230
242
|
// Nothing is lost by dropping the setup name here: it is only redundant restatement of Zerops' own
|
|
231
243
|
// "setup name defaults to the hostname" rule, and a deploy that needs to say otherwise passes
|
|
232
244
|
// `zeropsSetup` at TRIGGER time, where no repository is required.
|
|
233
|
-
if (service
|
|
245
|
+
if (service['zeropsSetup'] !== undefined && service['buildFromGit'] === undefined) {
|
|
234
246
|
throw new Error(
|
|
235
|
-
`zerops: service \`${
|
|
247
|
+
`zerops: service \`${hostname}\` names \`zeropsSetup\` without \`buildFromGit\`; the platform rejects the import (pass the setup when triggering the pipeline instead)`,
|
|
236
248
|
)
|
|
237
249
|
}
|
|
238
|
-
if (seen.has(
|
|
239
|
-
throw new Error(`zerops: duplicate service hostname \`${
|
|
250
|
+
if (seen.has(hostname)) {
|
|
251
|
+
throw new Error(`zerops: duplicate service hostname \`${hostname}\` in the import document`)
|
|
240
252
|
}
|
|
241
|
-
seen.add(
|
|
253
|
+
seen.add(hostname)
|
|
242
254
|
}
|
|
243
255
|
}
|
|
244
256
|
|
|
@@ -257,13 +269,55 @@ export const compileImport = (input: CompileInput): ZeropsImportDocument => {
|
|
|
257
269
|
/** Keys/scalars that are safe unquoted in YAML. Everything else gets a double-quoted, JSON-escaped form. */
|
|
258
270
|
const PLAIN = /^[A-Za-z_][A-Za-z0-9_./@+-]*$/
|
|
259
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Code points a YAML 1.1 reader (go-yaml, which parses this on Zerops) ends a line on or refuses outright,
|
|
274
|
+
* while Bun's 1.2 reader takes them for ordinary text — so no test using Bun can see the difference. `\n`
|
|
275
|
+
* and `\r` are excluded: the block form owns one and `blockScalarSafe` refuses the other.
|
|
276
|
+
*/
|
|
277
|
+
const isYamlHostileCodePoint = (code: number): boolean =>
|
|
278
|
+
(code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d)
|
|
279
|
+
|| (code >= 0x7f && code <= 0x9f)
|
|
280
|
+
|| code === 0x2028
|
|
281
|
+
|| code === 0x2029
|
|
282
|
+
|| code === 0xfeff
|
|
283
|
+
|| (code >= 0xd800 && code <= 0xdfff)
|
|
284
|
+
|
|
285
|
+
/** `JSON.stringify` leaves DEL, the C1 block, U+2028/9 and U+FEFF raw; YAML's own `\uXXXX` carries them. */
|
|
286
|
+
const escapeYamlHostile = (quoted: string): string =>
|
|
287
|
+
[...quoted].map((character) => {
|
|
288
|
+
const code = character.codePointAt(0) ?? 0
|
|
289
|
+
return isYamlHostileCodePoint(code) ? `\\u${code.toString(16).padStart(4, '0')}` : character
|
|
290
|
+
}).join('')
|
|
291
|
+
|
|
260
292
|
const scalar = (value: string): string => {
|
|
261
293
|
// Reserved plain scalars that YAML would read as booleans/null, plus anything with structure.
|
|
262
294
|
const reserved = new Set(['true', 'false', 'null', 'yes', 'no', 'on', 'off', '~'])
|
|
263
295
|
if (value !== '' && PLAIN.test(value) && !reserved.has(value.toLowerCase())) {
|
|
264
296
|
return value
|
|
265
297
|
}
|
|
266
|
-
return JSON.stringify(value)
|
|
298
|
+
return escapeYamlHostile(JSON.stringify(value))
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* A `|-` block reproduces the string only when the text owns its own lines. An indented or blank first
|
|
303
|
+
* line moves (or breaks) the block's own indentation, `\r` and a blank line do not survive it, and `-`
|
|
304
|
+
* eats a trailing newline. A code point a YAML 1.1 reader breaks a line on cannot go in at all — the block
|
|
305
|
+
* is the one form with no escapes. Everything else goes out double-quoted, which is exact YAML.
|
|
306
|
+
*/
|
|
307
|
+
const blockScalarSafe = (value: string): boolean => {
|
|
308
|
+
if (!value.includes('\n') || value.includes('\r')) {
|
|
309
|
+
return false
|
|
310
|
+
}
|
|
311
|
+
if ([...value].some((character) => isYamlHostileCodePoint(character.codePointAt(0) ?? 0))) {
|
|
312
|
+
return false
|
|
313
|
+
}
|
|
314
|
+
const lines = value.split('\n')
|
|
315
|
+
const first = lines[0] ?? ''
|
|
316
|
+
if (first === '' || first.startsWith(' ') || first.startsWith('\t')) {
|
|
317
|
+
return false
|
|
318
|
+
}
|
|
319
|
+
// A blank line loses its whitespace, and an empty last line is the trailing newline `-` strips.
|
|
320
|
+
return !lines.some((line, index) => line.trim() === '' && (line !== '' || index === lines.length - 1))
|
|
267
321
|
}
|
|
268
322
|
|
|
269
323
|
const isArray = (value: unknown): value is unknown[] => Array.isArray(value)
|
|
@@ -316,9 +370,8 @@ const emit = (value: unknown, indent: number): string[] => {
|
|
|
316
370
|
return lines
|
|
317
371
|
}
|
|
318
372
|
if (typeof value === 'string') {
|
|
319
|
-
if (value
|
|
320
|
-
|
|
321
|
-
return [`${pad}|-`, ...body]
|
|
373
|
+
if (blockScalarSafe(value)) {
|
|
374
|
+
return [`${pad}|-`, ...value.split('\n').map((line) => `${pad} ${line}`)]
|
|
322
375
|
}
|
|
323
376
|
return [`${pad}${scalar(value)}`]
|
|
324
377
|
}
|
package/src/control.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
RuntimeProviderRun,
|
|
18
18
|
SchemaReconciler,
|
|
19
19
|
} from '@fabrika/provider-contract'
|
|
20
|
+
import { ProviderSecretError } from '@fabrika/provider-contract'
|
|
20
21
|
import {
|
|
21
22
|
createZeropsApi,
|
|
22
23
|
ZEROPS_ACTIVE,
|
|
@@ -40,6 +41,7 @@ import {
|
|
|
40
41
|
} from './manifest'
|
|
41
42
|
import { createZeropsNamespaceCapabilities, type ZeropsNamespaceTarget, zeropsNamespaceTargetCodec } from './namespace'
|
|
42
43
|
import { createZeropsProvider, type ZeropsSourceTransportBinding } from './provider'
|
|
44
|
+
import { allowedReferenceHosts, foreignReferenceHost, serviceReferenceHosts } from './service-references'
|
|
43
45
|
import { normalizeZeropsSourceRepository, type ZeropsSourceClient, type ZeropsSourceClientV2, type ZeropsSourceResolveResult } from './source'
|
|
44
46
|
import type { ZeropsRunState, ZeropsRuntimeTarget } from './types'
|
|
45
47
|
|
|
@@ -316,6 +318,20 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
|
|
|
316
318
|
{ appId: environment.appId, env: environment.env },
|
|
317
319
|
)
|
|
318
320
|
|
|
321
|
+
/**
|
|
322
|
+
* A variable written through the env API resolves `${host_KEY}` at container start like `run.envVariables`
|
|
323
|
+
* does, across services whatever `envIsolation` says — so an unchecked secret reads the proxy's key.
|
|
324
|
+
*/
|
|
325
|
+
const assertOwnReferencesOnly = (environment: ProviderEnvironment, name: string, value: string): void => {
|
|
326
|
+
// A secret naming no service at all must not start failing on an artifact that no longer parses.
|
|
327
|
+
if (serviceReferenceHosts(name).length === 0 && serviceReferenceHosts(value).length === 0) return
|
|
328
|
+
const allowed = allowedReferenceHosts(manifestServiceHostnames(artifactFor(environment)))
|
|
329
|
+
const foreign = foreignReferenceHost(name, allowed) ?? foreignReferenceHost(value, allowed)
|
|
330
|
+
if (foreign !== null) {
|
|
331
|
+
throw new ProviderSecretError(`zerops: secret \`${name}\` references service \`${foreign}\`, which this app does not own`, 'foreignReference')
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
319
335
|
const cancelSourceBestEffort = async (runId: string, appVersionId: string): Promise<void> => {
|
|
320
336
|
const sourceController = new AbortController()
|
|
321
337
|
const timeoutController = new AbortController()
|
|
@@ -678,6 +694,7 @@ export const createZeropsControlProvider = (options: ZeropsControlProviderOption
|
|
|
678
694
|
},
|
|
679
695
|
secrets: {
|
|
680
696
|
put: async (input) => {
|
|
697
|
+
assertOwnReferencesOnly(input.environment, input.name, input.value)
|
|
681
698
|
const target = decodeEnvironment(input.environment).target
|
|
682
699
|
await api.putServiceEnv({
|
|
683
700
|
serviceId: target.serviceId,
|
package/src/index.ts
CHANGED
|
@@ -49,7 +49,7 @@ export {
|
|
|
49
49
|
renderImportYaml,
|
|
50
50
|
renderYaml,
|
|
51
51
|
} from './compile'
|
|
52
|
-
export type { CompileInput, ZeropsImportDocument } from './compile'
|
|
52
|
+
export type { CompileInput, ZeropsImportDocument, ZeropsJsonImportDocument } from './compile'
|
|
53
53
|
export { createZeropsControlProvider, zeropsStoredTargetCodec } from './control'
|
|
54
54
|
export type { ZeropsBeforeDeploy, ZeropsBeforeDeployInput, ZeropsControlProviderOptions, ZeropsProviderExecutor, ZeropsStoredTarget } from './control'
|
|
55
55
|
export {
|
package/src/manifest.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AppActionDef, AppGates, AppSchema, AppScopeDef, GateRule, RoleDef } from '@fabrika/auth-core'
|
|
2
2
|
import type { JsonValue, ProviderCodec } from '@fabrika/provider-contract'
|
|
3
3
|
import { compileImport, ENV_ISOLATION, OVERRIDE, renderYaml } from './compile'
|
|
4
|
+
import { allowedReferenceHosts, foreignReferenceHost } from './service-references'
|
|
4
5
|
import type { ZeropsAppConfig, ZeropsNamespaceResourceRequirement, ZeropsProxySpec, ZeropsSharedPostgresBinding } from './types'
|
|
5
6
|
|
|
6
7
|
export const FABRIKA_MANIFEST_VERSION = 3
|
|
@@ -225,6 +226,36 @@ function parseProxy(value: unknown): ZeropsProxySpec | null {
|
|
|
225
226
|
return upstream === undefined || gates === null ? null : { upstream, gates }
|
|
226
227
|
}
|
|
227
228
|
|
|
229
|
+
/** The first foreign host referenced by any string leaf OR key of `value`, however deeply nested. */
|
|
230
|
+
const foreignReferenceInValue = (value: unknown, allowed: ReadonlySet<string>): string | null => {
|
|
231
|
+
if (typeof value === 'string') {
|
|
232
|
+
return foreignReferenceHost(value, allowed)
|
|
233
|
+
}
|
|
234
|
+
if (Array.isArray(value)) {
|
|
235
|
+
for (const entry of value) {
|
|
236
|
+
const host = foreignReferenceInValue(entry, allowed)
|
|
237
|
+
if (host !== null) return host
|
|
238
|
+
}
|
|
239
|
+
return null
|
|
240
|
+
}
|
|
241
|
+
if (typeof value === 'object' && value !== null) {
|
|
242
|
+
for (const key of Object.keys(value)) {
|
|
243
|
+
const host = foreignReferenceHost(key, allowed) ?? foreignReferenceInValue(prop(value, key), allowed)
|
|
244
|
+
if (host !== null) return host
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return null
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Read the descriptor as the platform does. Text scanning misses what YAML folds, escapes or hides in a block scalar. */
|
|
251
|
+
const parseDescriptorDocument = (contents: string): unknown => {
|
|
252
|
+
try {
|
|
253
|
+
return Bun.YAML.parse(contents)
|
|
254
|
+
} catch {
|
|
255
|
+
throw new Error('invalid fabrika manifest: zerops.yaml is not parseable YAML')
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
228
259
|
const parseImportDocument = (value: unknown): FabrikaImportDocument => {
|
|
229
260
|
if (value === undefined) {
|
|
230
261
|
throw new Error('invalid fabrika manifest Zerops import document')
|
|
@@ -238,7 +269,7 @@ const parseImportDocument = (value: unknown): FabrikaImportDocument => {
|
|
|
238
269
|
if (!Array.isArray(rawServices) || rawServices.length === 0) {
|
|
239
270
|
throw new Error('invalid fabrika manifest Zerops import services')
|
|
240
271
|
}
|
|
241
|
-
const
|
|
272
|
+
const parsed: Array<[string, { readonly [key: string]: JsonValue }]> = []
|
|
242
273
|
const hostnames = new Set<string>()
|
|
243
274
|
for (const [index, rawService] of rawServices.entries()) {
|
|
244
275
|
const service = jsonObject(rawService, `Zerops import service ${index}`)
|
|
@@ -261,8 +292,19 @@ const parseImportDocument = (value: unknown): FabrikaImportDocument => {
|
|
|
261
292
|
throw new Error('invalid fabrika manifest: Zerops import contains a forbidden environment field')
|
|
262
293
|
}
|
|
263
294
|
hostnames.add(hostname)
|
|
264
|
-
|
|
295
|
+
parsed.push([hostname, service])
|
|
296
|
+
}
|
|
297
|
+
// A `${host_KEY}` reference crosses `envIsolation: service` by design, so the declared hostnames are the
|
|
298
|
+
// boundary. They are never rewritten at deploy: a shared namespace's app prefix is authored INTO the
|
|
299
|
+
// hostname (`service-names.ts`), which `registrationResourceClaims` then enforces.
|
|
300
|
+
const allowed = allowedReferenceHosts([...hostnames])
|
|
301
|
+
for (const [hostname, service] of parsed) {
|
|
302
|
+
const foreign = foreignReferenceInValue(service, allowed)
|
|
303
|
+
if (foreign !== null) {
|
|
304
|
+
throw new Error(`invalid fabrika manifest: Zerops service \`${hostname}\` references service \`${foreign}\`, which this app does not own`)
|
|
305
|
+
}
|
|
265
306
|
}
|
|
307
|
+
const services = parsed.map(([, service]) => service)
|
|
266
308
|
const rawProject = raw['project']
|
|
267
309
|
let project: { readonly [key: string]: JsonValue } | undefined
|
|
268
310
|
if (rawProject !== undefined) {
|
|
@@ -386,6 +428,10 @@ export function parseFabrikaManifest(value: unknown, expected: ManifestExpectati
|
|
|
386
428
|
if (!serviceHostnames.includes(deployService)) {
|
|
387
429
|
throw new Error('invalid fabrika manifest Zerops deploy service')
|
|
388
430
|
}
|
|
431
|
+
const foreignInDescriptor = foreignReferenceInValue(parseDescriptorDocument(sourceDescriptor.contents), allowedReferenceHosts(serviceHostnames))
|
|
432
|
+
if (foreignInDescriptor !== null) {
|
|
433
|
+
throw new Error(`invalid fabrika manifest: zerops.yaml references service \`${foreignInDescriptor}\`, which this app does not own`)
|
|
434
|
+
}
|
|
389
435
|
const zeropsSetup = optionalString(rawTarget, 'zeropsSetup')
|
|
390
436
|
if (prop(rawTarget, 'zeropsSetup') !== undefined && zeropsSetup === null) {
|
|
391
437
|
throw new Error('invalid fabrika manifest zeropsSetup')
|
package/src/namespace.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type ProviderNamespaceCapabilities,
|
|
7
7
|
ProviderNamespaceError,
|
|
8
8
|
type ProviderNamespaceFact,
|
|
9
|
+
type ProviderNamespaceHost,
|
|
9
10
|
type ProviderNamespaceMutationInput,
|
|
10
11
|
type ProviderNamespaceOperator,
|
|
11
12
|
type ProviderNamespacePlanInput,
|
|
@@ -13,7 +14,7 @@ import {
|
|
|
13
14
|
type ProviderNamespacePreset,
|
|
14
15
|
type ProviderRegistration,
|
|
15
16
|
} from '@fabrika/provider-contract'
|
|
16
|
-
import { encodeProxyManifestJson, FABRIKA_PROXY_MANIFEST_JSON } from '@fabrika/proxy-contract'
|
|
17
|
+
import { encodeProxyManifestJson, FABRIKA_PROXY_MANIFEST_JSON, isProxyHost } from '@fabrika/proxy-contract'
|
|
17
18
|
import {
|
|
18
19
|
ZEROPS_ACTIVE,
|
|
19
20
|
ZEROPS_SERVICE_NOT_HTTP,
|
|
@@ -35,6 +36,8 @@ export const ZEROPS_NAMESPACE_PROXY_HOSTNAME = 'proxy'
|
|
|
35
36
|
export const ZEROPS_NAMESPACE_POSTGRES_HOSTNAME = 'postgres'
|
|
36
37
|
export const ZEROPS_NAMESPACE_IAM_ISSUER_VARIABLE = 'FABRIKA_IAM_ISSUER'
|
|
37
38
|
export const ZEROPS_NAMESPACE_IAM_KEY_VARIABLE = 'FABRIKA_IAM_KEY'
|
|
39
|
+
/** The platform-owned variable naming one URL per published HTTP port. READ_ONLY, written by Zerops. */
|
|
40
|
+
export const ZEROPS_NAMESPACE_SUBDOMAIN_VARIABLE = 'zeropsSubdomain'
|
|
38
41
|
/**
|
|
39
42
|
* How an app reaches the namespace-owned PostgreSQL service — port, database and TLS mode NAMED, not
|
|
40
43
|
* defaulted. Every part settled against a live account, none of it safe to infer:
|
|
@@ -91,6 +94,8 @@ export interface ZeropsNamespaceTarget {
|
|
|
91
94
|
readonly proxyConfigured?: boolean
|
|
92
95
|
readonly proxyBaselineSequence?: number
|
|
93
96
|
readonly proxyAppVersionId?: string
|
|
97
|
+
/** The hosts the published proxy answers on, recorded once so a namespace read needs no platform call. */
|
|
98
|
+
readonly proxyHosts?: readonly ProviderNamespaceHost[]
|
|
94
99
|
readonly ready?: boolean
|
|
95
100
|
}
|
|
96
101
|
|
|
@@ -221,6 +226,54 @@ const parsePostgres = (payload: JsonValue): ZeropsNamespacePostgres | undefined
|
|
|
221
226
|
return profile === undefined ? { type } : { type, profile }
|
|
222
227
|
}
|
|
223
228
|
|
|
229
|
+
const parseProxyHosts = (payload: JsonValue): readonly ProviderNamespaceHost[] | undefined => {
|
|
230
|
+
const value = property(payload, 'proxyHosts')
|
|
231
|
+
if (value === undefined) return undefined
|
|
232
|
+
if (!Array.isArray(value)) {
|
|
233
|
+
throw new Error('Zerops namespace target proxyHosts must be an array')
|
|
234
|
+
}
|
|
235
|
+
return value.map((entry) => {
|
|
236
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
237
|
+
throw new Error('Zerops namespace target proxyHosts entry must be an object')
|
|
238
|
+
}
|
|
239
|
+
const host = property(entry, 'host')
|
|
240
|
+
const port = property(entry, 'port')
|
|
241
|
+
if (typeof host !== 'string' || host === '') {
|
|
242
|
+
throw new Error('Zerops namespace target proxyHosts host must be a non-empty string')
|
|
243
|
+
}
|
|
244
|
+
if (typeof port !== 'number' || !Number.isInteger(port) || port <= 0) {
|
|
245
|
+
throw new Error('Zerops namespace target proxyHosts port must be a positive integer')
|
|
246
|
+
}
|
|
247
|
+
return { host, port }
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* One host per published HTTP port, out of the newline-separated URLs the platform writes.
|
|
253
|
+
*
|
|
254
|
+
* The form is `<hostname>-<hash>-<port>.<region>.zerops.app` (e.g. `proxy-2ec8-8080`). Read as URLs
|
|
255
|
+
* rather than split on `-`, so a hostname whose own first label ends in digits cannot be misread; a line
|
|
256
|
+
* that is not a URL, or whose first label carries no trailing port — the single portless line a proxy
|
|
257
|
+
* that has never deployed publishes — is skipped rather than guessed at.
|
|
258
|
+
*/
|
|
259
|
+
const parseZeropsProxyHosts = (value: string): ProviderNamespaceHost[] => {
|
|
260
|
+
const hosts: ProviderNamespaceHost[] = []
|
|
261
|
+
for (const line of value.split('\n')) {
|
|
262
|
+
const trimmed = line.trim()
|
|
263
|
+
if (trimmed === '') continue
|
|
264
|
+
let hostname: string
|
|
265
|
+
try {
|
|
266
|
+
hostname = new URL(trimmed).hostname
|
|
267
|
+
} catch {
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
270
|
+
const port = /-(\d+)$/.exec(hostname.split('.')[0] ?? '')?.[1]
|
|
271
|
+
if (port === undefined) continue
|
|
272
|
+
hosts.push({ host: hostname, port: Number.parseInt(port, 10) })
|
|
273
|
+
}
|
|
274
|
+
return hosts.sort((left, right) => left.port - right.port)
|
|
275
|
+
}
|
|
276
|
+
|
|
224
277
|
export const zeropsNamespaceTargetCodec: ProviderCodec<ZeropsNamespaceTarget> = {
|
|
225
278
|
version: 1,
|
|
226
279
|
encode: (target) => ({
|
|
@@ -238,6 +291,7 @@ export const zeropsNamespaceTargetCodec: ProviderCodec<ZeropsNamespaceTarget> =
|
|
|
238
291
|
...(target.proxyConfigured !== undefined ? { proxyConfigured: target.proxyConfigured } : {}),
|
|
239
292
|
...(target.proxyBaselineSequence !== undefined ? { proxyBaselineSequence: target.proxyBaselineSequence } : {}),
|
|
240
293
|
...(target.proxyAppVersionId !== undefined ? { proxyAppVersionId: target.proxyAppVersionId } : {}),
|
|
294
|
+
...(target.proxyHosts === undefined ? {} : { proxyHosts: target.proxyHosts.map((entry) => ({ host: entry.host, port: entry.port })) }),
|
|
241
295
|
...(target.ready !== undefined ? { ready: target.ready } : {}),
|
|
242
296
|
}),
|
|
243
297
|
decode: (payload) => {
|
|
@@ -272,6 +326,7 @@ export const zeropsNamespaceTargetCodec: ProviderCodec<ZeropsNamespaceTarget> =
|
|
|
272
326
|
...(optionalString(payload, 'proxyAppVersionId') !== undefined
|
|
273
327
|
? { proxyAppVersionId: optionalString(payload, 'proxyAppVersionId') }
|
|
274
328
|
: {}),
|
|
329
|
+
...(parseProxyHosts(payload) !== undefined ? { proxyHosts: parseProxyHosts(payload) } : {}),
|
|
275
330
|
...(optionalBoolean(payload, 'ready') !== undefined ? { ready: optionalBoolean(payload, 'ready') } : {}),
|
|
276
331
|
}
|
|
277
332
|
},
|
|
@@ -320,6 +375,25 @@ const registrationResourceClaims = (registration: ProviderRegistration): string[
|
|
|
320
375
|
appId: registration.app.id,
|
|
321
376
|
env: registration.environment.env,
|
|
322
377
|
})
|
|
378
|
+
// A proxy target is routed by HOST, so a registration with no domain has nothing the proxy can serve:
|
|
379
|
+
// refuse it here, where nothing has been created yet, instead of at the first deploy's manifest compile.
|
|
380
|
+
if (manifest.target.proxy !== undefined) {
|
|
381
|
+
const domain = registration.environment.domain ?? ''
|
|
382
|
+
if (domain.trim() === '') {
|
|
383
|
+
throw new Error(
|
|
384
|
+
`Zerops proxy target \`${registration.app.id}/${registration.environment.env}\` requires a public domain: `
|
|
385
|
+
+ `pass --domain with a host this namespace serves (\`namespaces get ${namespace.id}\` lists them)`,
|
|
386
|
+
)
|
|
387
|
+
}
|
|
388
|
+
// One Caddy fronts the whole namespace and its host matcher reads `*` as a wildcard label, so a
|
|
389
|
+
// pattern here would answer for every sibling app. The manifest parser refuses it either way.
|
|
390
|
+
if (!isProxyHost(domain.toLowerCase())) {
|
|
391
|
+
throw new Error(
|
|
392
|
+
`Zerops proxy target \`${registration.app.id}/${registration.environment.env}\` domain \`${domain}\` is not a literal hostname: `
|
|
393
|
+
+ 'use dot-separated labels of letters, digits and `-` (no wildcard, no port, no placeholder)',
|
|
394
|
+
)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
323
397
|
const namespaceClaims = new Set(namespaceResourceClaims(namespace))
|
|
324
398
|
for (const requirement of manifest.target.namespaceResources ?? []) {
|
|
325
399
|
if (!namespaceClaims.has(requirement.resourceKey)) {
|
|
@@ -503,10 +577,14 @@ const namespacePresentation = (
|
|
|
503
577
|
const postgres = target.postgres === undefined
|
|
504
578
|
? 'App-owned database services'
|
|
505
579
|
: `Shared ${target.postgres.type}${target.postgres.profile === undefined ? '' : ` (${target.postgres.profile})`}`
|
|
580
|
+
// Only a published subdomain namespace knows its own hosts; custom domains are bound out of band, so
|
|
581
|
+
// the provider names none and an operator reads them where they were bound.
|
|
582
|
+
const hosts = publicAccess === 'zerops-subdomain' && target.ready === true ? target.proxyHosts ?? [] : []
|
|
506
583
|
const instructions = [
|
|
507
584
|
...(publicAccess === 'custom-domain'
|
|
508
585
|
? [`Bind each application domain to the \`proxy\` service in Zerops project \`${projectName}\`; keep every app service private.`]
|
|
509
586
|
: ['Use the proxy Zerops subdomain only for non-production access; app services must remain private.']),
|
|
587
|
+
...(hosts.length === 0 ? [] : ['Register a proxy-target environment with one of the hosts below as its domain; each host serves one app.']),
|
|
510
588
|
...(target.postgres === undefined
|
|
511
589
|
? []
|
|
512
590
|
: ['Every app consuming the shared PostgreSQL binding shares its physical service and provider-issued credential.']),
|
|
@@ -528,6 +606,7 @@ const namespacePresentation = (
|
|
|
528
606
|
{ label: 'Public access', value: publicAccess },
|
|
529
607
|
],
|
|
530
608
|
instructions,
|
|
609
|
+
...(hosts.length === 0 ? {} : { hosts }),
|
|
531
610
|
}
|
|
532
611
|
}
|
|
533
612
|
|
|
@@ -832,6 +911,38 @@ const ensureProxyConfiguration = async (
|
|
|
832
911
|
return deployRequired
|
|
833
912
|
}
|
|
834
913
|
|
|
914
|
+
/**
|
|
915
|
+
* The hosts the proxy publishes, off the platform-owned `zeropsSubdomain` variable.
|
|
916
|
+
*
|
|
917
|
+
* Read through the same `/env` listing every other namespace variable uses, and read AFTER the proxy is
|
|
918
|
+
* published: the variable names a host per DEPLOYED HTTP port, so a proxy that never deployed names none.
|
|
919
|
+
* Recording the answer in the target is what keeps `namespaces get` a database read.
|
|
920
|
+
*/
|
|
921
|
+
const readProxyHosts = async (
|
|
922
|
+
namespace: ProviderDeploymentNamespace,
|
|
923
|
+
target: NormalizedZeropsNamespaceTarget,
|
|
924
|
+
options: ZeropsNamespaceOptions,
|
|
925
|
+
signal: AbortSignal,
|
|
926
|
+
): Promise<readonly ProviderNamespaceHost[]> => {
|
|
927
|
+
const proxyServiceId = target.proxyServiceId
|
|
928
|
+
if (proxyServiceId === undefined) {
|
|
929
|
+
throw new Error('Zerops namespace has no proxy service id')
|
|
930
|
+
}
|
|
931
|
+
try {
|
|
932
|
+
const variables = await options.api.listServiceEnv({ serviceId: proxyServiceId, signal })
|
|
933
|
+
const subdomains = variables.find((variable) => variable.key === ZEROPS_NAMESPACE_SUBDOMAIN_VARIABLE)
|
|
934
|
+
return subdomains === undefined ? [] : parseZeropsProxyHosts(subdomains.content)
|
|
935
|
+
} catch {
|
|
936
|
+
// A host listing is a convenience; the proxy is already built, deployed and published. Failing the
|
|
937
|
+
// namespace over it would report a placement that WORKS as broken. Never the cause: it can carry
|
|
938
|
+
// upstream text this provider has not redacted.
|
|
939
|
+
console.warn(
|
|
940
|
+
`Zerops namespace ${namespace.id}: reading the published proxy hosts failed; the placement is ready and lists none until its next reconcile`,
|
|
941
|
+
)
|
|
942
|
+
return []
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
835
946
|
const latestAfterBaseline = async (
|
|
836
947
|
target: NormalizedZeropsNamespaceTarget,
|
|
837
948
|
options: ZeropsNamespaceOptions,
|
|
@@ -1125,10 +1236,14 @@ const runNamespaceMutation = async (
|
|
|
1125
1236
|
if (target.publicAccess === 'zerops-subdomain') {
|
|
1126
1237
|
await ensureSubdomainAccess(target, options, input.signal)
|
|
1127
1238
|
}
|
|
1239
|
+
const proxyHosts = target.publicAccess === 'zerops-subdomain' ? await readProxyHosts(namespace, target, options, input.signal) : []
|
|
1128
1240
|
target = {
|
|
1129
1241
|
...target,
|
|
1130
1242
|
proxyBaselineSequence: undefined,
|
|
1131
1243
|
proxyAppVersionId: undefined,
|
|
1244
|
+
// Cleared when the proxy publishes none and on a custom-domain namespace, so the record never
|
|
1245
|
+
// outlives what it describes.
|
|
1246
|
+
proxyHosts: proxyHosts.length === 0 ? undefined : proxyHosts,
|
|
1132
1247
|
ready: true,
|
|
1133
1248
|
}
|
|
1134
1249
|
return checkpoint(input, namespace, target)
|
package/src/provider.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// the poll loop rechecks it, the sleep between polls is interruptible, and a cancelled run asks Zerops to
|
|
10
10
|
// cancel the build it started rather than leaving it running.
|
|
11
11
|
|
|
12
|
-
import { createProvider, type ProviderDeploySession, type ProviderModule, type TypedProviderRun } from '@fabrika/provider-contract'
|
|
12
|
+
import { createProvider, type JsonValue, type ProviderDeploySession, type ProviderModule, type TypedProviderRun } from '@fabrika/provider-contract'
|
|
13
13
|
import {
|
|
14
14
|
waitForProcess,
|
|
15
15
|
ZEROPS_ACTIVE,
|
|
@@ -23,14 +23,16 @@ import {
|
|
|
23
23
|
} from './api'
|
|
24
24
|
import { zeropsTargetCodec } from './codec'
|
|
25
25
|
import { defaultSleep, defaultZeropsCollaborators, type ZeropsCollaboratorFactory, type ZeropsCollaborators } from './collaborators'
|
|
26
|
+
import { assertZeropsInvariants, renderYaml } from './compile'
|
|
26
27
|
import {
|
|
28
|
+
type FabrikaImportDocument,
|
|
27
29
|
type FabrikaManifest,
|
|
28
30
|
manifestServiceHostnames,
|
|
29
|
-
renderFabrikaImportYaml,
|
|
30
31
|
verifyZeropsArtifactSourceDescriptor,
|
|
31
32
|
zeropsArtifactCodec,
|
|
32
33
|
} from './manifest'
|
|
33
34
|
import { buildZeropsPlan, resolveDeployHostname, type ZeropsJobSpec, type ZeropsPlan } from './plan'
|
|
35
|
+
import { allowedReferenceHosts, foreignReferenceHost } from './service-references'
|
|
34
36
|
import type { ZeropsSourceUploadInputV2, ZeropsSourceUploadResult } from './source'
|
|
35
37
|
import type { ZeropsRunState, ZeropsRuntimeSource, ZeropsRuntimeTarget } from './types'
|
|
36
38
|
|
|
@@ -70,19 +72,139 @@ const assertRunning = (signal: AbortSignal): void => {
|
|
|
70
72
|
|
|
71
73
|
const PLACEHOLDER = /\$\{([A-Z][A-Z0-9_]*)\}/g
|
|
72
74
|
|
|
73
|
-
/**
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
/** Fields that decide WHAT a service is: a placeholder there would let a value redefine the service. */
|
|
76
|
+
const STRUCTURAL_FIELDS: readonly string[] = ['hostname', 'type', 'envIsolation', 'override']
|
|
77
|
+
|
|
78
|
+
/** Rebuild the tree with every string leaf resolved — arrays and nested objects included. */
|
|
79
|
+
const substituteLeaves = (value: JsonValue, resolve: (text: string) => string): JsonValue => {
|
|
80
|
+
if (typeof value === 'string') {
|
|
81
|
+
return resolve(value)
|
|
82
|
+
}
|
|
83
|
+
if (Array.isArray(value)) {
|
|
84
|
+
return value.map((item) => substituteLeaves(item, resolve))
|
|
85
|
+
}
|
|
86
|
+
if (value !== null && typeof value === 'object') {
|
|
87
|
+
return substituteRecord(value, resolve)
|
|
88
|
+
}
|
|
89
|
+
return value
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const substituteRecord = (
|
|
93
|
+
record: { readonly [key: string]: JsonValue },
|
|
94
|
+
resolve: (text: string) => string,
|
|
95
|
+
): { [key: string]: JsonValue } => {
|
|
96
|
+
const substituted: { [key: string]: JsonValue } = {}
|
|
97
|
+
for (const [key, entry] of Object.entries(record)) {
|
|
98
|
+
const planted = key.match(PLACEHOLDER)
|
|
99
|
+
if (planted !== null) {
|
|
100
|
+
throw new Error(`zerops: placeholder \`${String(planted[0])}\` stands in an import document key; a variable may only fill a value`)
|
|
79
101
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
102
|
+
// Defined rather than assigned: a literal `__proto__` key would otherwise set the prototype.
|
|
103
|
+
Object.defineProperty(substituted, key, {
|
|
104
|
+
value: substituteLeaves(entry, resolve),
|
|
105
|
+
enumerable: true,
|
|
106
|
+
writable: true,
|
|
107
|
+
configurable: true,
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
return substituted
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A structural field that moved means a placeholder sat where only the compiler may write. */
|
|
114
|
+
const assertStructureUnchanged = (before: FabrikaImportDocument, after: FabrikaImportDocument): void => {
|
|
115
|
+
if (JSON.stringify(before.project) !== JSON.stringify(after.project)) {
|
|
116
|
+
throw new Error('zerops: variable substitution changed the project entry; a variable may not stand in the project fabrika creates')
|
|
117
|
+
}
|
|
118
|
+
for (const [index, service] of after.services.entries()) {
|
|
119
|
+
const original = before.services[index]
|
|
120
|
+
for (const field of STRUCTURAL_FIELDS) {
|
|
121
|
+
if (original?.[field] !== service[field]) {
|
|
122
|
+
throw new Error(`zerops: variable substitution changed service field \`${field}\`; a variable may not stand in a service's identity`)
|
|
123
|
+
}
|
|
83
124
|
}
|
|
84
|
-
|
|
85
|
-
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Substitution is a single pass, so a placeholder left in the document came out of a variable's VALUE. */
|
|
129
|
+
const assertNoPlaceholderLeft = (document: FabrikaImportDocument): void => {
|
|
130
|
+
const refuse = (text: string): void => {
|
|
131
|
+
const left = text.match(PLACEHOLDER)
|
|
132
|
+
if (left !== null) {
|
|
133
|
+
throw new Error(`zerops: placeholder \`${String(left[0])}\` survived substitution; a variable value may not carry one`)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const visitRecord = (record: { readonly [key: string]: JsonValue }): void => {
|
|
137
|
+
for (const [key, entry] of Object.entries(record)) {
|
|
138
|
+
refuse(key)
|
|
139
|
+
visit(entry)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const visit = (value: JsonValue): void => {
|
|
143
|
+
if (typeof value === 'string') {
|
|
144
|
+
refuse(value)
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
if (Array.isArray(value)) {
|
|
148
|
+
for (const item of value) visit(item)
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
if (value !== null && typeof value === 'object') {
|
|
152
|
+
visitRecord(value)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (document.project !== undefined) {
|
|
156
|
+
visitRecord(document.project)
|
|
157
|
+
}
|
|
158
|
+
for (const service of document.services) {
|
|
159
|
+
visitRecord(service)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Resolve the manifest's declared variables INTO THE DOCUMENT — never into the rendered YAML, where a
|
|
165
|
+
* value carrying a quote or a newline would be read as structure and could add a service or turn
|
|
166
|
+
* `envIsolation` off. Only variables the manifest declares resolve; every other placeholder fails the
|
|
167
|
+
* deploy, and so does a value that names a service outside the app (Zerops resolves `${host_KEY}`
|
|
168
|
+
* regardless of isolation). The document is re-checked here because this is the last point it is an object.
|
|
169
|
+
*/
|
|
170
|
+
export function interpolateManifest(
|
|
171
|
+
document: FabrikaImportDocument,
|
|
172
|
+
declared: readonly string[],
|
|
173
|
+
values: Record<string, string>,
|
|
174
|
+
ownHostnames: readonly string[],
|
|
175
|
+
): FabrikaImportDocument {
|
|
176
|
+
const allowed = new Set(declared)
|
|
177
|
+
const hosts = allowedReferenceHosts(ownHostnames)
|
|
178
|
+
const resolve = (text: string): string => {
|
|
179
|
+
const substituted = text.replace(PLACEHOLDER, (_placeholder, name: string) => {
|
|
180
|
+
if (!allowed.has(name)) {
|
|
181
|
+
throw new Error(`zerops: manifest uses undeclared variable \`${name}\``)
|
|
182
|
+
}
|
|
183
|
+
const value = values[name]
|
|
184
|
+
if (value === undefined) {
|
|
185
|
+
throw new Error(`zerops: manifest variable \`${name}\` has no deploy-time value`)
|
|
186
|
+
}
|
|
187
|
+
const foreign = foreignReferenceHost(value, hosts)
|
|
188
|
+
if (foreign !== null) {
|
|
189
|
+
throw new Error(`zerops: manifest variable \`${name}\` references service \`${foreign}\`, which this app does not own`)
|
|
190
|
+
}
|
|
191
|
+
return value
|
|
192
|
+
})
|
|
193
|
+
// Adjacent values can spell a reference neither of them carries alone.
|
|
194
|
+
const foreign = substituted === text ? null : foreignReferenceHost(substituted, hosts)
|
|
195
|
+
if (foreign !== null) {
|
|
196
|
+
throw new Error(`zerops: a substituted value references service \`${foreign}\`, which this app does not own`)
|
|
197
|
+
}
|
|
198
|
+
return substituted
|
|
199
|
+
}
|
|
200
|
+
const services = document.services.map((service) => substituteRecord(service, resolve))
|
|
201
|
+
const substituted: FabrikaImportDocument = document.project === undefined
|
|
202
|
+
? { services }
|
|
203
|
+
: { project: substituteRecord(document.project, resolve), services }
|
|
204
|
+
assertZeropsInvariants(substituted)
|
|
205
|
+
assertStructureUnchanged(document, substituted)
|
|
206
|
+
assertNoPlaceholderLeft(substituted)
|
|
207
|
+
return substituted
|
|
86
208
|
}
|
|
87
209
|
|
|
88
210
|
/** Human-readable failure for a version that finished in a non-`ACTIVE` state. */
|
|
@@ -170,23 +292,41 @@ const cleanupPreTriggerVersion = async (env: StepEnv, source: ZeropsRuntimeSourc
|
|
|
170
292
|
}
|
|
171
293
|
|
|
172
294
|
/**
|
|
173
|
-
* Relay
|
|
295
|
+
* Relay ONE app version's build log into the run's progress sink.
|
|
296
|
+
*
|
|
297
|
+
* Scoped to the version being deployed: the runtime service's log window mixes every version that ever ran
|
|
298
|
+
* on it, so an unscoped read stamps a run's log with lines from earlier releases. `appVersionId` selects
|
|
299
|
+
* the build lines and `since` — the version's pipeline start — cuts the runtime ones
|
|
300
|
+
* (see `ZeropsApi.readBuildLog`). Before the platform reports a pipeline start there is nothing to cut
|
|
301
|
+
* against, so no runtime line is relayed yet. The cut is by time, so the OUTGOING container's own lines
|
|
302
|
+
* still arrive until it is replaced; what it removes is every earlier version's history.
|
|
174
303
|
*
|
|
175
|
-
* Deliberately FAILURE-TOLERANT:
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
* the platform's process and app-version statuses.
|
|
304
|
+
* Deliberately FAILURE-TOLERANT: a log service must never be able to fail a deploy, so an error degrades
|
|
305
|
+
* to "no lines relayed" and the run still succeeds or fails on the platform's process and app-version
|
|
306
|
+
* statuses.
|
|
179
307
|
*
|
|
180
308
|
* Relay is pull-based (ADR-0003), so each poll re-reads a window that overlaps the last one; `seen` keeps
|
|
181
309
|
* the run log from repeating itself. Two genuinely identical lines with no timestamp collapse into one —
|
|
182
310
|
* an acceptable trade for not printing the same window every three seconds.
|
|
183
311
|
*/
|
|
184
|
-
const relayLog = async (
|
|
312
|
+
const relayLog = async (
|
|
313
|
+
env: StepEnv,
|
|
314
|
+
access: ZeropsLogAccess | null,
|
|
315
|
+
seen: Set<string>,
|
|
316
|
+
appVersionId: string,
|
|
317
|
+
since: string | undefined,
|
|
318
|
+
): Promise<void> => {
|
|
185
319
|
if (access === null) {
|
|
186
320
|
return
|
|
187
321
|
}
|
|
188
322
|
try {
|
|
189
|
-
const lines = await env.zerops.api.readBuildLog({
|
|
323
|
+
const lines = await env.zerops.api.readBuildLog({
|
|
324
|
+
access,
|
|
325
|
+
serviceId: env.run.target.serviceId,
|
|
326
|
+
appVersionId,
|
|
327
|
+
...(since === undefined ? {} : { since }),
|
|
328
|
+
signal: env.signal,
|
|
329
|
+
})
|
|
190
330
|
for (const line of lines) {
|
|
191
331
|
const key = `${line.timestamp ?? ''}\u0000${line.message}`
|
|
192
332
|
if (seen.has(key)) {
|
|
@@ -248,7 +388,7 @@ const awaitVersion = async (env: StepEnv, appVersionId: string, processId: strin
|
|
|
248
388
|
log(` ${appVersionId}: ${version.status ?? 'unknown'}`)
|
|
249
389
|
previous = version.status
|
|
250
390
|
}
|
|
251
|
-
await relayLog(env, access, seen)
|
|
391
|
+
await relayLog(env, access, seen, appVersionId, version.build?.pipelineStart)
|
|
252
392
|
|
|
253
393
|
if (process?.status !== undefined && ZEROPS_PROCESS_TERMINAL.has(process.status) && process.status !== ZEROPS_PROCESS_FINISHED) {
|
|
254
394
|
throw new Error(
|
|
@@ -470,10 +610,8 @@ export const createZeropsProvider = (
|
|
|
470
610
|
throw new Error(`zerops: artifact environment drift: expected \`${run.env}\`, got \`${run.artifact.app.env}\``)
|
|
471
611
|
}
|
|
472
612
|
const hostnames = manifestServiceHostnames(run.artifact)
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
hostnames,
|
|
476
|
-
}
|
|
613
|
+
const document = interpolateManifest(run.artifact.target.importDocument, run.artifact.app.pipeline.vars, run.vars, hostnames)
|
|
614
|
+
const compiled = { yaml: renderYaml(document), hostnames }
|
|
477
615
|
const deployHostname = resolveDeployHostname(run.artifact)
|
|
478
616
|
const plan: ZeropsPlan = buildZeropsPlan(run.artifact, run.target, run.env)
|
|
479
617
|
const env: StepEnv = {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `${<host>_<KEY>}` — Zerops resolves it across services regardless of `envIsolation`, so the host is the boundary.
|
|
3
|
+
* `${RUNTIME_<host>_<KEY>}`, the build-time bridge, counts as a reference to that host until measured otherwise.
|
|
4
|
+
*/
|
|
5
|
+
const SERVICE_REFERENCE = /\$\{(?:RUNTIME_)?([a-z0-9]{1,25})_[A-Za-z0-9_]+\}/g
|
|
6
|
+
|
|
7
|
+
/** Namespace services every app in it may reference; the proxy is deliberately not one of them. */
|
|
8
|
+
const SHARED_NAMESPACE_HOSTNAMES: readonly string[] = ['postgres']
|
|
9
|
+
|
|
10
|
+
/** Hosts named by cross-service references in `text`, deduplicated, in order of first appearance. */
|
|
11
|
+
export function serviceReferenceHosts(text: string): string[] {
|
|
12
|
+
const hosts: string[] = []
|
|
13
|
+
for (const match of text.matchAll(SERVICE_REFERENCE)) {
|
|
14
|
+
const host = match[1]
|
|
15
|
+
if (host !== undefined && !hosts.includes(host)) hosts.push(host)
|
|
16
|
+
}
|
|
17
|
+
return hosts
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The hosts a service of this app may reference: its own declared hostnames plus the namespace's shared services. */
|
|
21
|
+
export function allowedReferenceHosts(ownHostnames: readonly string[]): ReadonlySet<string> {
|
|
22
|
+
return new Set([...ownHostnames, ...SHARED_NAMESPACE_HOSTNAMES])
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The first referenced host outside `allowed`, or null when every reference stays inside the app. */
|
|
26
|
+
export function foreignReferenceHost(text: string, allowed: ReadonlySet<string>): string | null {
|
|
27
|
+
return serviceReferenceHosts(text).find((host) => !allowed.has(host)) ?? null
|
|
28
|
+
}
|