@fabrika/provider-zerops 0.0.26 → 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.
Files changed (83) hide show
  1. package/package.json +18 -5
  2. package/src/api/client.ts +522 -0
  3. package/src/api/errors.ts +50 -0
  4. package/src/api/polling.ts +59 -0
  5. package/src/api/readers.ts +185 -0
  6. package/src/api/service-env-response.ts +34 -0
  7. package/src/api/types.ts +602 -0
  8. package/src/api.ts +29 -1378
  9. package/src/authoring.ts +7 -10
  10. package/src/cli-args.ts +0 -5
  11. package/src/cli.ts +3 -28
  12. package/src/collaborators.ts +2 -4
  13. package/src/compile.ts +75 -20
  14. package/src/control.ts +217 -447
  15. package/src/descriptor.ts +135 -0
  16. package/src/index.ts +24 -128
  17. package/src/installation/admin-options.ts +128 -0
  18. package/src/installation/admin.ts +123 -0
  19. package/src/installation/deploy-options.ts +155 -0
  20. package/src/installation/deploy.ts +668 -0
  21. package/src/installation/generated/platform-console-schema.ts +41 -0
  22. package/src/installation/generated/platform-proxy-manifest.ts +72 -0
  23. package/src/installation/hosts.ts +117 -0
  24. package/src/installation/index.ts +366 -0
  25. package/src/installation/init.ts +521 -0
  26. package/src/installation/install-collaborators.ts +28 -0
  27. package/src/installation/install-environment.ts +193 -0
  28. package/src/installation/install-options.ts +193 -0
  29. package/src/installation/install-project.ts +201 -0
  30. package/src/installation/install-provisioning.ts +185 -0
  31. package/src/installation/install.ts +286 -0
  32. package/src/installation/installation-source.ts +42 -0
  33. package/src/installation/log.ts +77 -0
  34. package/src/installation/machine-key.ts +65 -0
  35. package/src/installation/manifest.ts +95 -0
  36. package/src/installation/placement-options.ts +33 -0
  37. package/src/installation/platform-source.ts +106 -0
  38. package/src/installation/platform-upload.ts +34 -0
  39. package/src/installation/proxy-manifest.ts +113 -0
  40. package/src/installation/secrets.ts +79 -0
  41. package/src/installation/sidecar.ts +122 -0
  42. package/src/installation/source-options.ts +12 -0
  43. package/src/installation/templates/README.md +116 -0
  44. package/src/installation/templates/fabrika.ref +1 -0
  45. package/src/installation/templates/gitignore +3 -0
  46. package/src/installation/templates/platform.yml +144 -0
  47. package/src/installation/topology/artifacts.ts +141 -0
  48. package/src/installation/topology/index.ts +13 -0
  49. package/src/installation/topology/installation-schemas.ts +225 -0
  50. package/src/installation/topology/invariants.ts +86 -0
  51. package/src/installation/topology/setups.ts +442 -0
  52. package/src/installation/topology/topology.ts +501 -0
  53. package/src/installation/topology/validate.ts +26 -0
  54. package/src/installation/upgrade-options.ts +108 -0
  55. package/src/installation/upgrade-output.ts +31 -0
  56. package/src/installation/upgrade.ts +373 -0
  57. package/src/managed-env.ts +105 -0
  58. package/src/manifest.ts +208 -315
  59. package/src/namespace/claims.ts +77 -0
  60. package/src/namespace/codec.ts +179 -0
  61. package/src/namespace/constants.ts +30 -0
  62. package/src/namespace/errors.ts +60 -0
  63. package/src/namespace/manifest.ts +28 -0
  64. package/src/namespace/operator.ts +151 -0
  65. package/src/namespace/provisioning.ts +482 -0
  66. package/src/namespace/proxy-attempt.ts +220 -0
  67. package/src/namespace/proxy-source.ts +22 -0
  68. package/src/namespace/topology.ts +115 -0
  69. package/src/namespace/types.ts +114 -0
  70. package/src/namespace.ts +34 -1260
  71. package/src/plan.ts +21 -23
  72. package/src/provider.ts +399 -254
  73. package/src/service-references.ts +28 -0
  74. package/src/source/constants.ts +11 -0
  75. package/src/source/port.ts +49 -0
  76. package/src/source/types.ts +4 -0
  77. package/src/source/validation.ts +72 -0
  78. package/src/source.ts +2 -1220
  79. package/src/types.ts +8 -7
  80. package/src/version-attempt.ts +18 -0
  81. package/src/codec.ts +0 -108
  82. package/src/namespace-command.ts +0 -209
  83. package/src/source-connection.ts +0 -344
package/src/manifest.ts CHANGED
@@ -1,6 +1,12 @@
1
- import type { AppActionDef, AppGates, AppSchema, AppScopeDef, GateRule, RoleDef } from '@fabrika/auth-core'
1
+ import { appSchemaSchema } from '@fabrika/auth'
2
+ import type { AppSchema } from '@fabrika/auth-core'
2
3
  import type { JsonValue, ProviderCodec } from '@fabrika/provider-contract'
4
+ import { normalizeGateRule, proxyGateRuleFieldsSchema } from '@fabrika/proxy-core/manifest'
5
+ import { z } from 'zod'
3
6
  import { compileImport, ENV_ISOLATION, OVERRIDE, renderYaml } from './compile'
7
+ import { parseZeropsDescriptor, selectZeropsDescriptorSetup } from './descriptor'
8
+ import { assertAuthoredManagedDocument, assertAuthoredManagedInputs } from './managed-env'
9
+ import { allowedReferenceHosts, foreignReferenceHost } from './service-references'
4
10
  import type { ZeropsAppConfig, ZeropsNamespaceResourceRequirement, ZeropsProxySpec, ZeropsSharedPostgresBinding } from './types'
5
11
 
6
12
  export const FABRIKA_MANIFEST_VERSION = 3
@@ -46,7 +52,7 @@ export interface FabrikaManifest {
46
52
  /** The exact repository-root build descriptor used for this artifact. */
47
53
  sourceDescriptor: ZeropsArtifactSourceDescriptor
48
54
  /** The canonical document used both for resource claims and the Zerops API payload. */
49
- importDocument: FabrikaImportDocument
55
+ importDocument: FabrikaManifestImportDocument
50
56
  deployService: string
51
57
  zeropsSetup?: string
52
58
  proxy?: ZeropsProxySpec
@@ -59,243 +65,171 @@ export interface ManifestExpectation {
59
65
  env?: string
60
66
  }
61
67
 
62
- const prop = (value: unknown, key: string): unknown => typeof value === 'object' && value !== null ? Reflect.get(value, key) : undefined
63
-
64
- const jsonValue = (value: unknown): JsonValue => {
65
- if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
66
- return value
67
- }
68
- if (Array.isArray(value)) {
69
- return value.map(jsonValue)
70
- }
71
- if (typeof value === 'object') {
72
- const result: { [key: string]: JsonValue } = {}
73
- for (const [key, entry] of Object.entries(value)) {
74
- if (entry !== undefined) {
75
- result[key] = jsonValue(entry)
76
- }
77
- }
78
- return result
79
- }
80
- throw new Error(`Fabrika manifest contains a non-JSON ${typeof value} value`)
81
- }
82
-
83
- const jsonObject = (value: unknown, label: string): { [key: string]: JsonValue } => {
84
- const parsed = jsonValue(value)
85
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
86
- throw new Error(`invalid fabrika manifest ${label}`)
87
- }
88
- return parsed
89
- }
90
-
91
- const string = (value: unknown, key: string): string | undefined => {
92
- const candidate = prop(value, key)
93
- return typeof candidate === 'string' ? candidate : undefined
94
- }
95
-
96
- const nonEmptyString = (value: unknown, key: string): string | undefined => {
97
- const candidate = string(value, key)
98
- return candidate !== undefined && candidate !== '' ? candidate : undefined
99
- }
100
-
101
- const stringArray = (value: unknown, key: string): string[] | undefined => {
102
- const candidate = prop(value, key)
103
- if (!Array.isArray(candidate) || candidate.some((item) => typeof item !== 'string' || item === '')) {
104
- return undefined
68
+ export const assertFabrikaManifestCoordinates = (app: Pick<FabrikaManifest['app'], 'id' | 'env'>, expected: ManifestExpectation): void => {
69
+ if (expected.appId !== undefined && expected.appId !== app.id) {
70
+ throw new Error(`manifest app drift: expected \`${expected.appId}\`, got \`${app.id}\``)
105
71
  }
106
- return candidate
107
- }
108
-
109
- const optionalString = (value: unknown, key: string): string | null => {
110
- const candidate = prop(value, key)
111
- if (candidate === undefined) {
112
- return null
72
+ if (expected.env !== undefined && expected.env !== app.env) {
73
+ throw new Error(`manifest environment drift: expected \`${expected.env}\`, got \`${app.env}\``)
113
74
  }
114
- return typeof candidate === 'string' && candidate !== '' ? candidate : null
115
75
  }
116
76
 
117
- function parseScope(value: unknown): AppScopeDef | null {
118
- const type = nonEmptyString(value, 'type')
119
- const label = optionalString(value, 'label')
120
- if (type === undefined || (prop(value, 'label') !== undefined && label === null)) {
121
- return null
122
- }
123
- return label === null ? { type } : { type, label }
77
+ const manifestJsonSchema: z.ZodType<JsonValue, z.ZodTypeDef, unknown> = z.lazy(() =>
78
+ z.union([
79
+ z.null(),
80
+ z.string(),
81
+ z.number(),
82
+ z.nan(),
83
+ z.boolean(),
84
+ z.array(manifestJsonSchema),
85
+ z.preprocess((value) =>
86
+ typeof value === 'object' && value !== null && !Array.isArray(value)
87
+ ? Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined))
88
+ : value, z.record(manifestJsonSchema)),
89
+ ])
90
+ )
91
+ const jsonValue = (value: unknown): JsonValue => {
92
+ const parsed = manifestJsonSchema.safeParse(value)
93
+ if (!parsed.success) throw new Error('Fabrika manifest contains a non-JSON value')
94
+ return parsed.data
124
95
  }
125
96
 
126
- function parseAction(value: unknown): AppActionDef | null {
127
- const action = nonEmptyString(value, 'action')
128
- const description = optionalString(value, 'description')
129
- if (action === undefined || (prop(value, 'description') !== undefined && description === null)) {
130
- return null
97
+ const nonEmptyStringsSchema = z.array(z.string().min(1))
98
+ // Unlike the proxy wire contract, the manifest refuses a null service credential.
99
+ const manifestGateSchema = proxyGateRuleFieldsSchema
100
+ .refine((rule) => rule.kind !== 'service' || rule.credential !== null)
101
+ .transform(normalizeGateRule)
102
+ const manifestProxySchema = z.object({ upstream: z.string().min(1), gates: z.object({ rules: z.array(manifestGateSchema) }) })
103
+ const namespaceResourcesSchema = z.array(z.object({
104
+ resourceKey: z.literal('service:postgres'),
105
+ hostname: z.literal('postgres'),
106
+ connectionString: z.literal(SHARED_POSTGRES_CONNECTION_STRING),
107
+ })).max(1)
108
+
109
+ const manifestAppSchema = z.object({
110
+ id: z.string().min(1),
111
+ env: z.string().min(1),
112
+ schema: appSchemaSchema.optional(),
113
+ pipeline: z.object({ secrets: nonEmptyStringsSchema, vars: nonEmptyStringsSchema }),
114
+ })
115
+ const manifestEnvelopeSchema = z.object({ manifestVersion: z.literal(FABRIKA_MANIFEST_VERSION), app: manifestAppSchema, target: z.unknown() })
116
+
117
+ export const zeropsArtifactSourceDescriptorSchema = z.object({
118
+ path: z.literal('zerops.yaml'),
119
+ contents: z.string().min(1).refine((contents) => new TextEncoder().encode(contents).byteLength <= ZEROPS_SOURCE_DESCRIPTOR_MAX_BYTES),
120
+ sha256: z.string().regex(SHA256_PATTERN),
121
+ }).strict()
122
+
123
+ /** Refuse fields fabrika owns, so an authored document can never set them. */
124
+ const forbidFields = (fields: readonly string[], message: string) => (record: Record<string, JsonValue>, ctx: z.RefinementCtx): void => {
125
+ for (const field of fields) {
126
+ if (Object.hasOwn(record, field)) ctx.addIssue({ code: 'custom', path: [field], message })
131
127
  }
132
- return description === null ? { action } : { action, description }
133
128
  }
134
-
135
- function parseRole(value: unknown): RoleDef | null {
136
- const name = nonEmptyString(value, 'name')
137
- const description = optionalString(value, 'description')
138
- const permissions = stringArray(value, 'permissions')
139
- if (name === undefined || permissions === undefined || (prop(value, 'description') !== undefined && description === null)) {
140
- return null
129
+ const importServiceSchema = z.intersection(
130
+ z.record(manifestJsonSchema),
131
+ z.object({
132
+ hostname: z.string().regex(ZEROPS_SERVICE_HOSTNAME_PATTERN),
133
+ type: z.string().min(1),
134
+ envIsolation: z.literal(ENV_ISOLATION),
135
+ override: z.literal(OVERRIDE),
136
+ }),
137
+ ).superRefine(forbidFields(['envSecrets', 'dotEnvSecrets'], 'invalid fabrika manifest: Zerops import contains a forbidden environment field'))
138
+ const importProjectSchema = z.intersection(z.record(manifestJsonSchema), z.object({ envIsolation: z.literal(ENV_ISOLATION) }))
139
+ .superRefine(forbidFields(['envVariables'], 'invalid fabrika manifest: Zerops import contains a forbidden project environment field'))
140
+
141
+ /** The first foreign host referenced by any string leaf OR key of `value`, however deeply nested. */
142
+ const foreignReferenceInValue = (value: unknown, allowed: ReadonlySet<string>): string | null => {
143
+ if (typeof value === 'string') {
144
+ return foreignReferenceHost(value, allowed)
141
145
  }
142
- return description === null ? { name, permissions } : { name, description, permissions }
143
- }
144
-
145
- function parseSchema(value: unknown): AppSchema | null {
146
- const rawScopes = prop(value, 'scopes')
147
- const rawActions = prop(value, 'actions')
148
- const rawRoles = prop(value, 'roles')
149
- if (!Array.isArray(rawScopes) || !Array.isArray(rawActions) || typeof rawRoles !== 'object' || rawRoles === null || Array.isArray(rawRoles)) {
146
+ if (Array.isArray(value)) {
147
+ for (const entry of value) {
148
+ const host = foreignReferenceInValue(entry, allowed)
149
+ if (host !== null) return host
150
+ }
150
151
  return null
151
152
  }
152
- const scopes: AppScopeDef[] = []
153
- for (const raw of rawScopes) {
154
- const scope = parseScope(raw)
155
- if (scope === null) return null
156
- scopes.push(scope)
157
- }
158
- const actions: AppActionDef[] = []
159
- for (const raw of rawActions) {
160
- const action = parseAction(raw)
161
- if (action === null) return null
162
- actions.push(action)
163
- }
164
- const roles: Record<string, RoleDef> = {}
165
- for (const [key, raw] of Object.entries(rawRoles)) {
166
- const role = parseRole(raw)
167
- if (key === '' || role === null) return null
168
- roles[key] = role
169
- }
170
- return { scopes, actions, roles }
171
- }
172
-
173
- function parseGate(value: unknown): GateRule | null {
174
- const path = nonEmptyString(value, 'path')
175
- const kind = string(value, 'kind')
176
- if (path === undefined || !path.startsWith('/')) return null
177
- if (kind === 'public' || kind === 'human') return { path, kind }
178
- if (kind !== 'service') return null
179
- const rawCredential = prop(value, 'credential')
180
- if (rawCredential === undefined) return { path, kind }
181
- const location = string(rawCredential, 'in')
182
- const name = nonEmptyString(rawCredential, 'name')
183
- if ((location !== 'header' && location !== 'query' && location !== 'cookie') || name === undefined) return null
184
- return { path, kind, credential: { in: location, name } }
185
- }
186
-
187
- function parseGates(value: unknown): AppGates | null {
188
- const rawRules = prop(value, 'rules')
189
- if (!Array.isArray(rawRules)) return null
190
- const rules: GateRule[] = []
191
- for (const raw of rawRules) {
192
- const rule = parseGate(raw)
193
- if (rule === null) return null
194
- rules.push(rule)
195
- }
196
- return { rules }
197
- }
198
-
199
- const parseNamespaceResources = (value: unknown): ZeropsNamespaceResourceRequirement[] | null => {
200
- if (!Array.isArray(value)) return null
201
- const resources: ZeropsNamespaceResourceRequirement[] = []
202
- let hasPostgres = false
203
- for (const resource of value) {
204
- if (
205
- string(resource, 'resourceKey') !== 'service:postgres'
206
- || string(resource, 'hostname') !== 'postgres'
207
- || string(resource, 'connectionString') !== SHARED_POSTGRES_CONNECTION_STRING
208
- ) {
209
- return null
153
+ if (typeof value === 'object' && value !== null) {
154
+ for (const [key, entry] of Object.entries(value)) {
155
+ const host = foreignReferenceHost(key, allowed) ?? foreignReferenceInValue(entry, allowed)
156
+ if (host !== null) return host
210
157
  }
211
- if (hasPostgres) return null
212
- hasPostgres = true
213
- resources.push({
214
- resourceKey: 'service:postgres',
215
- hostname: 'postgres',
216
- connectionString: SHARED_POSTGRES_CONNECTION_STRING,
217
- })
218
158
  }
219
- return resources
159
+ return null
220
160
  }
221
161
 
222
- function parseProxy(value: unknown): ZeropsProxySpec | null {
223
- const upstream = nonEmptyString(value, 'upstream')
224
- const gates = parseGates(prop(value, 'gates'))
225
- return upstream === undefined || gates === null ? null : { upstream, gates }
226
- }
227
-
228
- const parseImportDocument = (value: unknown): FabrikaImportDocument => {
229
- if (value === undefined) {
230
- throw new Error('invalid fabrika manifest Zerops import document')
231
- }
232
- const raw = jsonObject(value, 'Zerops import document')
233
- const unknownKeys = Object.keys(raw).filter((key) => key !== 'project' && key !== 'services')
234
- if (unknownKeys.length > 0) {
235
- throw new Error(`invalid fabrika manifest Zerops import document field \`${unknownKeys[0]}\``)
236
- }
237
- const rawServices = raw['services']
238
- if (!Array.isArray(rawServices) || rawServices.length === 0) {
239
- throw new Error('invalid fabrika manifest Zerops import services')
240
- }
241
- const services: Array<{ readonly [key: string]: JsonValue }> = []
242
- const hostnames = new Set<string>()
243
- for (const [index, rawService] of rawServices.entries()) {
244
- const service = jsonObject(rawService, `Zerops import service ${index}`)
245
- const hostname = service['hostname']
246
- if (typeof hostname !== 'string' || !ZEROPS_SERVICE_HOSTNAME_PATTERN.test(hostname)) {
247
- throw new Error(`invalid fabrika manifest Zerops service hostname at index ${index}`)
248
- }
249
- if (hostnames.has(hostname)) {
250
- throw new Error(`invalid fabrika manifest: duplicate Zerops service hostname \`${hostname}\``)
251
- }
252
- if (typeof service['type'] !== 'string' || service['type'] === '') {
253
- throw new Error(`invalid fabrika manifest Zerops service type for \`${hostname}\``)
254
- }
255
- // `override: true` is required on EVERY service class, managed included — without it the platform
256
- // rejects a re-apply of the whole document as a name collision (see `compile.ts`).
257
- if (service['envIsolation'] !== ENV_ISOLATION || service['override'] !== OVERRIDE) {
258
- throw new Error(`invalid fabrika manifest Zerops service invariants for \`${hostname}\``)
162
+ const importDocumentSchema = z.object({ project: importProjectSchema.optional(), services: z.array(importServiceSchema).min(1) })
163
+ .strict()
164
+ .superRefine(({ services }, ctx) => {
165
+ const hostnames = services.map((service) => service.hostname)
166
+ const duplicate = hostnames.find((hostname, index) => hostnames.indexOf(hostname) !== index)
167
+ if (duplicate !== undefined) {
168
+ ctx.addIssue({ code: 'custom', message: `invalid fabrika manifest: duplicate Zerops service hostname \`${duplicate}\`` })
169
+ return
259
170
  }
260
- if (service['envSecrets'] !== undefined || service['dotEnvSecrets'] !== undefined) {
261
- throw new Error('invalid fabrika manifest: Zerops import contains a forbidden environment field')
262
- }
263
- hostnames.add(hostname)
264
- services.push(service)
265
- }
266
- const rawProject = raw['project']
267
- let project: { readonly [key: string]: JsonValue } | undefined
268
- if (rawProject !== undefined) {
269
- project = jsonObject(rawProject, 'Zerops import project')
270
- if (project['envIsolation'] !== ENV_ISOLATION || project['envVariables'] !== undefined) {
271
- throw new Error('invalid fabrika manifest: Zerops import contains a forbidden project environment field')
171
+ // A `${host_KEY}` reference crosses `envIsolation: service` by design, so the declared hostnames are the
172
+ // boundary. They are never rewritten at deploy: a shared namespace's app prefix is authored INTO the
173
+ // hostname (`service-names.ts`), which `registrationResourceClaims` then enforces.
174
+ const allowed = allowedReferenceHosts(hostnames)
175
+ for (const service of services) {
176
+ const foreign = foreignReferenceInValue(service, allowed)
177
+ if (foreign !== null) {
178
+ ctx.addIssue({
179
+ code: 'custom',
180
+ message: `invalid fabrika manifest: Zerops service \`${service.hostname}\` references service \`${foreign}\`, which this app does not own`,
181
+ })
182
+ return
183
+ }
272
184
  }
273
- }
274
- return project === undefined ? { services } : { project, services }
185
+ })
186
+
187
+ /** The import document a manifest carries: every service already holds the ADR-0004 invariants. */
188
+ export type FabrikaManifestImportDocument = z.infer<typeof importDocumentSchema>
189
+
190
+ const manifestTargetSchema = z.object({
191
+ sourceDescriptor: zeropsArtifactSourceDescriptorSchema,
192
+ platform: z.literal('zerops'),
193
+ deployService: z.string().min(1),
194
+ importDocument: importDocumentSchema,
195
+ zeropsSetup: z.string().min(1).optional(),
196
+ proxy: manifestProxySchema.optional(),
197
+ namespaceResources: namespaceResourcesSchema.optional(),
198
+ })
199
+
200
+ const importDocumentError = (issue: z.ZodIssue, path: readonly (string | number)[]): string => {
201
+ const [section, index, field] = path
202
+ if (issue.code === 'custom') return issue.message
203
+ if (issue.code === 'unrecognized_keys' && section === undefined) return `invalid fabrika manifest Zerops import document field \`${issue.keys[0]}\``
204
+ if (section === 'project') {
205
+ return index === 'envIsolation'
206
+ ? 'invalid fabrika manifest: Zerops import contains a forbidden project environment field'
207
+ : 'invalid fabrika manifest Zerops import project'
208
+ }
209
+ if (typeof index !== 'number') return 'invalid fabrika manifest Zerops import services'
210
+ if (field === 'hostname') return `invalid fabrika manifest Zerops service hostname at index ${index}`
211
+ return `invalid fabrika manifest Zerops service ${field === 'type' ? 'type' : 'invariants'} at index ${index}`
275
212
  }
276
213
 
277
- const parseSourceDescriptor = (value: unknown): ZeropsArtifactSourceDescriptor => {
278
- if (value === undefined) {
279
- throw new Error('invalid fabrika manifest Zerops source descriptor')
280
- }
281
- const raw = jsonObject(value, 'Zerops source descriptor')
282
- const unknownKeys = Object.keys(raw).filter((key) => key !== 'path' && key !== 'contents' && key !== 'sha256')
283
- if (unknownKeys.length > 0) {
284
- throw new Error(`invalid fabrika manifest Zerops source descriptor field \`${unknownKeys[0]}\``)
285
- }
286
- const contents = raw['contents']
287
- const sha256 = raw['sha256']
288
- if (
289
- raw['path'] !== 'zerops.yaml'
290
- || typeof contents !== 'string'
291
- || contents === ''
292
- || new TextEncoder().encode(contents).byteLength > ZEROPS_SOURCE_DESCRIPTOR_MAX_BYTES
293
- || typeof sha256 !== 'string'
294
- || !SHA256_PATTERN.test(sha256)
295
- ) {
296
- throw new Error('invalid fabrika manifest Zerops source descriptor')
214
+ const targetError = (issue: z.ZodIssue | undefined): string => {
215
+ const [section, ...rest] = issue?.path ?? []
216
+ if (issue === undefined) return 'invalid fabrika manifest Zerops target'
217
+ switch (section) {
218
+ case 'sourceDescriptor':
219
+ return issue.code === 'unrecognized_keys' && rest.length === 0
220
+ ? `invalid fabrika manifest Zerops source descriptor field \`${issue.keys[0]}\``
221
+ : 'invalid fabrika manifest Zerops source descriptor'
222
+ case 'importDocument':
223
+ return importDocumentError(issue, rest)
224
+ case 'zeropsSetup':
225
+ return 'invalid fabrika manifest zeropsSetup'
226
+ case 'proxy':
227
+ return 'invalid fabrika manifest proxy declaration'
228
+ case 'namespaceResources':
229
+ return 'invalid fabrika manifest namespace resources'
230
+ default:
231
+ return 'invalid fabrika manifest Zerops target'
297
232
  }
298
- return { path: 'zerops.yaml', contents, sha256 }
299
233
  }
300
234
 
301
235
  export const createZeropsArtifactSourceDescriptor = async (contents: string): Promise<ZeropsArtifactSourceDescriptor> => {
@@ -319,13 +253,7 @@ export const verifyZeropsArtifactSourceDescriptor = async (descriptor: ZeropsArt
319
253
  }
320
254
 
321
255
  export const manifestServiceHostnames = (manifest: FabrikaManifest): string[] =>
322
- manifest.target.importDocument.services.map((service) => {
323
- const hostname = service['hostname']
324
- if (typeof hostname !== 'string') {
325
- throw new Error('invalid canonical Zerops service hostname')
326
- }
327
- return hostname
328
- })
256
+ manifest.target.importDocument.services.map((service) => service.hostname)
329
257
 
330
258
  export const renderFabrikaImportYaml = (manifest: FabrikaManifest): string => renderYaml(manifest.target.importDocument)
331
259
 
@@ -333,86 +261,48 @@ export const renderFabrikaImportYaml = (manifest: FabrikaManifest): string => re
333
261
  export const renderFabrikaProvisioningYaml = (manifest: FabrikaManifest): string =>
334
262
  renderYaml({
335
263
  ...(manifest.target.importDocument.project === undefined ? {} : { project: manifest.target.importDocument.project }),
336
- services: manifest.target.importDocument.services.map((service) => ({ ...service, startWithoutCode: true })),
264
+ services: manifest.target.importDocument.services.map((service) => ({ ...service, override: false, startWithoutCode: true })),
337
265
  })
338
266
 
339
267
  export function parseFabrikaManifest(value: unknown, expected: ManifestExpectation = {}): FabrikaManifest {
340
- if (prop(value, 'manifestVersion') !== FABRIKA_MANIFEST_VERSION) {
341
- throw new Error(`unsupported fabrika manifest version (expected ${FABRIKA_MANIFEST_VERSION})`)
342
- }
343
- const rawApp = prop(value, 'app')
344
- const id = nonEmptyString(rawApp, 'id')
345
- const env = nonEmptyString(rawApp, 'env')
346
- const rawPipeline = prop(rawApp, 'pipeline')
347
- const secrets = stringArray(rawPipeline, 'secrets')
348
- const vars = stringArray(rawPipeline, 'vars')
349
- if (id === undefined || env === undefined || secrets === undefined || vars === undefined) {
350
- throw new Error('invalid fabrika manifest app metadata')
351
- }
352
- if (expected.appId !== undefined && expected.appId !== id) {
353
- throw new Error(`manifest app drift: expected \`${expected.appId}\`, got \`${id}\``)
354
- }
355
- if (expected.env !== undefined && expected.env !== env) {
356
- throw new Error(`manifest environment drift: expected \`${expected.env}\`, got \`${env}\``)
357
- }
358
- const rawSchema = prop(rawApp, 'schema')
359
- let schema: AppSchema | undefined
360
- if (rawSchema !== undefined) {
361
- const parsed = parseSchema(rawSchema)
362
- if (parsed === null) {
363
- throw new Error('invalid fabrika manifest schema')
364
- }
365
- schema = parsed
366
- }
367
-
368
- const rawTarget = prop(value, 'target')
369
- const sourceDescriptor = parseSourceDescriptor(prop(rawTarget, 'sourceDescriptor'))
370
- const rawImportDocument = prop(rawTarget, 'importDocument')
371
- const deployService = nonEmptyString(rawTarget, 'deployService')
372
- if (
373
- string(rawTarget, 'platform') !== 'zerops'
374
- || deployService === undefined
375
- ) {
376
- throw new Error('invalid fabrika manifest Zerops target')
377
- }
378
- const importDocument = parseImportDocument(rawImportDocument)
379
- const serviceHostnames = importDocument.services.map((service) => {
380
- const hostname = service['hostname']
381
- if (typeof hostname !== 'string') {
382
- throw new Error('invalid fabrika manifest Zerops service hostname')
383
- }
384
- return hostname
385
- })
268
+ const envelope = manifestEnvelopeSchema.safeParse(value)
269
+ if (!envelope.success) {
270
+ const [section, field] = envelope.error.issues[0]?.path ?? []
271
+ if (section !== 'app') throw new Error(`unsupported fabrika manifest version (expected ${FABRIKA_MANIFEST_VERSION})`)
272
+ throw new Error(field === 'schema' ? 'invalid fabrika manifest schema' : 'invalid fabrika manifest app metadata')
273
+ }
274
+ const { id, env, schema, pipeline: { secrets, vars } } = envelope.data.app
275
+ assertFabrikaManifestCoordinates({ id, env }, expected)
276
+
277
+ const target = manifestTargetSchema.safeParse(envelope.data.target)
278
+ if (!target.success) {
279
+ throw new Error(targetError(target.error.issues[0]))
280
+ }
281
+ const { sourceDescriptor, deployService, importDocument, zeropsSetup, proxy, namespaceResources } = target.data
282
+ assertAuthoredManagedDocument(importDocument.services)
283
+ if (importDocument.project !== undefined) assertAuthoredManagedDocument(importDocument.project)
284
+ assertAuthoredManagedInputs({ variableNames: vars, secretNames: secrets })
285
+ const parsedDescriptor = parseZeropsDescriptor(sourceDescriptor.contents)
286
+ assertAuthoredManagedDocument(parsedDescriptor.document)
287
+ const serviceHostnames = importDocument.services.map((service) => service.hostname)
386
288
  if (!serviceHostnames.includes(deployService)) {
387
289
  throw new Error('invalid fabrika manifest Zerops deploy service')
388
290
  }
389
- const zeropsSetup = optionalString(rawTarget, 'zeropsSetup')
390
- if (prop(rawTarget, 'zeropsSetup') !== undefined && zeropsSetup === null) {
391
- throw new Error('invalid fabrika manifest zeropsSetup')
392
- }
393
- const rawProxy = prop(rawTarget, 'proxy')
394
- let proxy: ZeropsProxySpec | undefined
395
- if (rawProxy !== undefined) {
396
- const parsed = parseProxy(rawProxy)
397
- if (parsed === null) {
398
- throw new Error('invalid fabrika manifest proxy declaration')
399
- }
400
- proxy = parsed
401
- const separator = parsed.upstream.indexOf(':')
402
- const upstreamHostname = separator === -1 ? parsed.upstream : parsed.upstream.slice(0, separator)
291
+ const foreignInDescriptor = foreignReferenceInValue(
292
+ parsedDescriptor.document,
293
+ allowedReferenceHosts(serviceHostnames),
294
+ )
295
+ if (foreignInDescriptor !== null) {
296
+ throw new Error(`invalid fabrika manifest: zerops.yaml references service \`${foreignInDescriptor}\`, which this app does not own`)
297
+ }
298
+ selectZeropsDescriptorSetup(parsedDescriptor, zeropsSetup)
299
+ if (proxy !== undefined) {
300
+ const separator = proxy.upstream.indexOf(':')
301
+ const upstreamHostname = separator === -1 ? proxy.upstream : proxy.upstream.slice(0, separator)
403
302
  if (!serviceHostnames.includes(upstreamHostname)) {
404
303
  throw new Error(`invalid fabrika manifest: proxy upstream service \`${upstreamHostname}\` is not owned by this app`)
405
304
  }
406
305
  }
407
- const rawNamespaceResources = prop(rawTarget, 'namespaceResources')
408
- let namespaceResources: ZeropsNamespaceResourceRequirement[] | undefined
409
- if (rawNamespaceResources !== undefined) {
410
- const parsed = parseNamespaceResources(rawNamespaceResources)
411
- if (parsed === null) {
412
- throw new Error('invalid fabrika manifest namespace resources')
413
- }
414
- namespaceResources = parsed
415
- }
416
306
  if (namespaceResources?.some((resource) => serviceHostnames.includes(resource.hostname)) === true) {
417
307
  throw new Error('invalid fabrika manifest: an app cannot declare a namespace-owned service')
418
308
  }
@@ -430,7 +320,7 @@ export function parseFabrikaManifest(value: unknown, expected: ManifestExpectati
430
320
  sourceDescriptor,
431
321
  importDocument,
432
322
  deployService,
433
- ...(zeropsSetup !== null ? { zeropsSetup } : {}),
323
+ ...(zeropsSetup !== undefined ? { zeropsSetup } : {}),
434
324
  ...(proxy !== undefined ? { proxy } : {}),
435
325
  ...(namespaceResources !== undefined ? { namespaceResources } : {}),
436
326
  },
@@ -454,31 +344,21 @@ export function compileFabrikaManifest(config: ZeropsAppConfig, env: string, sou
454
344
  if (deployService === undefined || !serviceHostnames.includes(deployService)) {
455
345
  throw new Error('fabrika app build: a multi-service Zerops app must name a valid deployService')
456
346
  }
457
- if (config.target.namespaceResources?.some((resource) => serviceHostnames.includes(resource.hostname)) === true) {
458
- throw new Error('fabrika app build: an app cannot declare a namespace-owned service')
459
- }
460
- const manifest: FabrikaManifest = {
347
+ const candidate = {
461
348
  manifestVersion: FABRIKA_MANIFEST_VERSION,
462
- app: {
463
- id: config.id,
464
- env,
465
- ...(config.schema !== undefined ? { schema: config.schema } : {}),
466
- pipeline: {
467
- secrets: [...(config.pipeline?.secrets ?? [])],
468
- vars: [...declaredVars],
469
- },
470
- },
349
+ app: { id: config.id, env, schema: config.schema, pipeline: { secrets: config.pipeline?.secrets ?? [], vars: declaredVars } },
471
350
  target: {
472
351
  platform: 'zerops',
473
352
  sourceDescriptor,
474
- importDocument: parseImportDocument(document),
353
+ importDocument: document,
475
354
  deployService,
476
- ...(config.target.zeropsSetup !== undefined ? { zeropsSetup: config.target.zeropsSetup } : {}),
477
- ...(config.target.proxy !== undefined ? { proxy: config.target.proxy } : {}),
478
- ...(config.target.namespaceResources !== undefined ? { namespaceResources: [...config.target.namespaceResources] } : {}),
355
+ zeropsSetup: config.target.zeropsSetup,
356
+ proxy: config.target.proxy,
357
+ namespaceResources: config.target.namespaceResources,
479
358
  },
480
359
  }
481
- return parseFabrikaManifest(manifest, { appId: config.id, env })
360
+ // The JSON copy is the artifact's own form: authored objects lose inherited fields and undefined keys once, here.
361
+ return parseFabrikaManifest(jsonValue(candidate), { appId: config.id, env })
482
362
  } finally {
483
363
  for (const name of declaredVars) {
484
364
  const value = prior[name]
@@ -497,3 +377,16 @@ export const zeropsArtifactCodec: ProviderCodec<FabrikaManifest> = {
497
377
  encode: jsonValue,
498
378
  decode: (payload) => parseFabrikaManifest(payload),
499
379
  }
380
+
381
+ /** Hash the original validated artifact, never an interpolated import or execution overlay. */
382
+ export async function hashFabrikaManifest(manifest: FabrikaManifest): Promise<string> {
383
+ const canonical = (value: JsonValue): JsonValue => {
384
+ if (Array.isArray(value)) return value.map(canonical)
385
+ if (typeof value !== 'object' || value === null) return value
386
+ return Object.fromEntries(
387
+ Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([key, entry]) => [key, canonical(entry)]),
388
+ )
389
+ }
390
+ const bytes = new TextEncoder().encode(JSON.stringify(canonical(zeropsArtifactCodec.encode(manifest))))
391
+ return Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)), (byte) => byte.toString(16).padStart(2, '0')).join('')
392
+ }