@fabrika/provider-zerops 0.0.26 → 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/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 +17 -6
- package/src/provider.ts +137 -17
- 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/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
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
type ProviderNamespacePreset,
|
|
15
15
|
type ProviderRegistration,
|
|
16
16
|
} from '@fabrika/provider-contract'
|
|
17
|
-
import { encodeProxyManifestJson, FABRIKA_PROXY_MANIFEST_JSON } from '@fabrika/proxy-contract'
|
|
17
|
+
import { encodeProxyManifestJson, FABRIKA_PROXY_MANIFEST_JSON, isProxyHost } from '@fabrika/proxy-contract'
|
|
18
18
|
import {
|
|
19
19
|
ZEROPS_ACTIVE,
|
|
20
20
|
ZEROPS_SERVICE_NOT_HTTP,
|
|
@@ -377,11 +377,22 @@ const registrationResourceClaims = (registration: ProviderRegistration): string[
|
|
|
377
377
|
})
|
|
378
378
|
// A proxy target is routed by HOST, so a registration with no domain has nothing the proxy can serve:
|
|
379
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
+
}
|
|
385
396
|
}
|
|
386
397
|
const namespaceClaims = new Set(namespaceResourceClaims(namespace))
|
|
387
398
|
for (const requirement of manifest.target.namespaceResources ?? []) {
|
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. */
|
|
@@ -488,10 +610,8 @@ export const createZeropsProvider = (
|
|
|
488
610
|
throw new Error(`zerops: artifact environment drift: expected \`${run.env}\`, got \`${run.artifact.app.env}\``)
|
|
489
611
|
}
|
|
490
612
|
const hostnames = manifestServiceHostnames(run.artifact)
|
|
491
|
-
const
|
|
492
|
-
|
|
493
|
-
hostnames,
|
|
494
|
-
}
|
|
613
|
+
const document = interpolateManifest(run.artifact.target.importDocument, run.artifact.app.pipeline.vars, run.vars, hostnames)
|
|
614
|
+
const compiled = { yaml: renderYaml(document), hostnames }
|
|
495
615
|
const deployHostname = resolveDeployHostname(run.artifact)
|
|
496
616
|
const plan: ZeropsPlan = buildZeropsPlan(run.artifact, run.target, run.env)
|
|
497
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
|
+
}
|