@fabrika/provider-zerops 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) 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 +3 -1
  14. package/src/control.ts +203 -450
  15. package/src/descriptor.ts +135 -0
  16. package/src/index.ts +23 -127
  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 +191 -344
  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 -1271
  71. package/src/plan.ts +21 -23
  72. package/src/provider.ts +266 -241
  73. package/src/source/constants.ts +11 -0
  74. package/src/source/port.ts +49 -0
  75. package/src/source/types.ts +4 -0
  76. package/src/source/validation.ts +72 -0
  77. package/src/source.ts +2 -1220
  78. package/src/types.ts +8 -7
  79. package/src/version-attempt.ts +18 -0
  80. package/src/codec.ts +0 -108
  81. package/src/namespace-command.ts +0 -209
  82. package/src/source-connection.ts +0 -344
package/src/manifest.ts CHANGED
@@ -1,6 +1,11 @@
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'
4
9
  import { allowedReferenceHosts, foreignReferenceHost } from './service-references'
5
10
  import type { ZeropsAppConfig, ZeropsNamespaceResourceRequirement, ZeropsProxySpec, ZeropsSharedPostgresBinding } from './types'
6
11
 
@@ -47,7 +52,7 @@ export interface FabrikaManifest {
47
52
  /** The exact repository-root build descriptor used for this artifact. */
48
53
  sourceDescriptor: ZeropsArtifactSourceDescriptor
49
54
  /** The canonical document used both for resource claims and the Zerops API payload. */
50
- importDocument: FabrikaImportDocument
55
+ importDocument: FabrikaManifestImportDocument
51
56
  deployService: string
52
57
  zeropsSetup?: string
53
58
  proxy?: ZeropsProxySpec
@@ -60,171 +65,78 @@ export interface ManifestExpectation {
60
65
  env?: string
61
66
  }
62
67
 
63
- const prop = (value: unknown, key: string): unknown => typeof value === 'object' && value !== null ? Reflect.get(value, key) : undefined
64
-
65
- const jsonValue = (value: unknown): JsonValue => {
66
- if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
67
- return value
68
- }
69
- if (Array.isArray(value)) {
70
- return value.map(jsonValue)
71
- }
72
- if (typeof value === 'object') {
73
- const result: { [key: string]: JsonValue } = {}
74
- for (const [key, entry] of Object.entries(value)) {
75
- if (entry !== undefined) {
76
- result[key] = jsonValue(entry)
77
- }
78
- }
79
- return result
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}\``)
80
71
  }
81
- throw new Error(`Fabrika manifest contains a non-JSON ${typeof value} value`)
82
- }
83
-
84
- const jsonObject = (value: unknown, label: string): { [key: string]: JsonValue } => {
85
- const parsed = jsonValue(value)
86
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
87
- throw new Error(`invalid fabrika manifest ${label}`)
72
+ if (expected.env !== undefined && expected.env !== app.env) {
73
+ throw new Error(`manifest environment drift: expected \`${expected.env}\`, got \`${app.env}\``)
88
74
  }
89
- return parsed
90
- }
91
-
92
- const string = (value: unknown, key: string): string | undefined => {
93
- const candidate = prop(value, key)
94
- return typeof candidate === 'string' ? candidate : undefined
95
75
  }
96
76
 
97
- const nonEmptyString = (value: unknown, key: string): string | undefined => {
98
- const candidate = string(value, key)
99
- return candidate !== undefined && candidate !== '' ? candidate : undefined
100
- }
101
-
102
- const stringArray = (value: unknown, key: string): string[] | undefined => {
103
- const candidate = prop(value, key)
104
- if (!Array.isArray(candidate) || candidate.some((item) => typeof item !== 'string' || item === '')) {
105
- return undefined
106
- }
107
- return candidate
108
- }
109
-
110
- const optionalString = (value: unknown, key: string): string | null => {
111
- const candidate = prop(value, key)
112
- if (candidate === undefined) {
113
- return null
114
- }
115
- return typeof candidate === 'string' && candidate !== '' ? candidate : null
116
- }
117
-
118
- function parseScope(value: unknown): AppScopeDef | null {
119
- const type = nonEmptyString(value, 'type')
120
- const label = optionalString(value, 'label')
121
- if (type === undefined || (prop(value, 'label') !== undefined && label === null)) {
122
- return null
123
- }
124
- return label === null ? { type } : { type, label }
125
- }
126
-
127
- function parseAction(value: unknown): AppActionDef | null {
128
- const action = nonEmptyString(value, 'action')
129
- const description = optionalString(value, 'description')
130
- if (action === undefined || (prop(value, 'description') !== undefined && description === null)) {
131
- return null
132
- }
133
- return description === null ? { action } : { action, description }
134
- }
135
-
136
- function parseRole(value: unknown): RoleDef | null {
137
- const name = nonEmptyString(value, 'name')
138
- const description = optionalString(value, 'description')
139
- const permissions = stringArray(value, 'permissions')
140
- if (name === undefined || permissions === undefined || (prop(value, 'description') !== undefined && description === null)) {
141
- return null
142
- }
143
- return description === null ? { name, permissions } : { name, description, permissions }
144
- }
145
-
146
- function parseSchema(value: unknown): AppSchema | null {
147
- const rawScopes = prop(value, 'scopes')
148
- const rawActions = prop(value, 'actions')
149
- const rawRoles = prop(value, 'roles')
150
- if (!Array.isArray(rawScopes) || !Array.isArray(rawActions) || typeof rawRoles !== 'object' || rawRoles === null || Array.isArray(rawRoles)) {
151
- return null
152
- }
153
- const scopes: AppScopeDef[] = []
154
- for (const raw of rawScopes) {
155
- const scope = parseScope(raw)
156
- if (scope === null) return null
157
- scopes.push(scope)
158
- }
159
- const actions: AppActionDef[] = []
160
- for (const raw of rawActions) {
161
- const action = parseAction(raw)
162
- if (action === null) return null
163
- actions.push(action)
164
- }
165
- const roles: Record<string, RoleDef> = {}
166
- for (const [key, raw] of Object.entries(rawRoles)) {
167
- const role = parseRole(raw)
168
- if (key === '' || role === null) return null
169
- roles[key] = role
170
- }
171
- return { scopes, actions, roles }
172
- }
173
-
174
- function parseGate(value: unknown): GateRule | null {
175
- const path = nonEmptyString(value, 'path')
176
- const kind = string(value, 'kind')
177
- if (path === undefined || !path.startsWith('/')) return null
178
- if (kind === 'public' || kind === 'human') return { path, kind }
179
- if (kind !== 'service') return null
180
- const rawCredential = prop(value, 'credential')
181
- if (rawCredential === undefined) return { path, kind }
182
- const location = string(rawCredential, 'in')
183
- const name = nonEmptyString(rawCredential, 'name')
184
- if ((location !== 'header' && location !== 'query' && location !== 'cookie') || name === undefined) return null
185
- return { path, kind, credential: { in: location, name } }
186
- }
187
-
188
- function parseGates(value: unknown): AppGates | null {
189
- const rawRules = prop(value, 'rules')
190
- if (!Array.isArray(rawRules)) return null
191
- const rules: GateRule[] = []
192
- for (const raw of rawRules) {
193
- const rule = parseGate(raw)
194
- if (rule === null) return null
195
- rules.push(rule)
196
- }
197
- return { rules }
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
198
95
  }
199
96
 
200
- const parseNamespaceResources = (value: unknown): ZeropsNamespaceResourceRequirement[] | null => {
201
- if (!Array.isArray(value)) return null
202
- const resources: ZeropsNamespaceResourceRequirement[] = []
203
- let hasPostgres = false
204
- for (const resource of value) {
205
- if (
206
- string(resource, 'resourceKey') !== 'service:postgres'
207
- || string(resource, 'hostname') !== 'postgres'
208
- || string(resource, 'connectionString') !== SHARED_POSTGRES_CONNECTION_STRING
209
- ) {
210
- return null
211
- }
212
- if (hasPostgres) return null
213
- hasPostgres = true
214
- resources.push({
215
- resourceKey: 'service:postgres',
216
- hostname: 'postgres',
217
- connectionString: SHARED_POSTGRES_CONNECTION_STRING,
218
- })
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 })
219
127
  }
220
- return resources
221
- }
222
-
223
- function parseProxy(value: unknown): ZeropsProxySpec | null {
224
- const upstream = nonEmptyString(value, 'upstream')
225
- const gates = parseGates(prop(value, 'gates'))
226
- return upstream === undefined || gates === null ? null : { upstream, gates }
227
128
  }
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'))
228
140
 
229
141
  /** The first foreign host referenced by any string leaf OR key of `value`, however deeply nested. */
230
142
  const foreignReferenceInValue = (value: unknown, allowed: ReadonlySet<string>): string | null => {
@@ -239,105 +151,85 @@ const foreignReferenceInValue = (value: unknown, allowed: ReadonlySet<string>):
239
151
  return null
240
152
  }
241
153
  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)
154
+ for (const [key, entry] of Object.entries(value)) {
155
+ const host = foreignReferenceHost(key, allowed) ?? foreignReferenceInValue(entry, allowed)
244
156
  if (host !== null) return host
245
157
  }
246
158
  }
247
159
  return null
248
160
  }
249
161
 
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
-
259
- const parseImportDocument = (value: unknown): FabrikaImportDocument => {
260
- if (value === undefined) {
261
- throw new Error('invalid fabrika manifest Zerops import document')
262
- }
263
- const raw = jsonObject(value, 'Zerops import document')
264
- const unknownKeys = Object.keys(raw).filter((key) => key !== 'project' && key !== 'services')
265
- if (unknownKeys.length > 0) {
266
- throw new Error(`invalid fabrika manifest Zerops import document field \`${unknownKeys[0]}\``)
267
- }
268
- const rawServices = raw['services']
269
- if (!Array.isArray(rawServices) || rawServices.length === 0) {
270
- throw new Error('invalid fabrika manifest Zerops import services')
271
- }
272
- const parsed: Array<[string, { readonly [key: string]: JsonValue }]> = []
273
- const hostnames = new Set<string>()
274
- for (const [index, rawService] of rawServices.entries()) {
275
- const service = jsonObject(rawService, `Zerops import service ${index}`)
276
- const hostname = service['hostname']
277
- if (typeof hostname !== 'string' || !ZEROPS_SERVICE_HOSTNAME_PATTERN.test(hostname)) {
278
- throw new Error(`invalid fabrika manifest Zerops service hostname at index ${index}`)
279
- }
280
- if (hostnames.has(hostname)) {
281
- throw new Error(`invalid fabrika manifest: duplicate Zerops service hostname \`${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
282
170
  }
283
- if (typeof service['type'] !== 'string' || service['type'] === '') {
284
- throw new Error(`invalid fabrika manifest Zerops service type for \`${hostname}\``)
285
- }
286
- // `override: true` is required on EVERY service class, managed included — without it the platform
287
- // rejects a re-apply of the whole document as a name collision (see `compile.ts`).
288
- if (service['envIsolation'] !== ENV_ISOLATION || service['override'] !== OVERRIDE) {
289
- throw new Error(`invalid fabrika manifest Zerops service invariants for \`${hostname}\``)
290
- }
291
- if (service['envSecrets'] !== undefined || service['dotEnvSecrets'] !== undefined) {
292
- throw new Error('invalid fabrika manifest: Zerops import contains a forbidden environment field')
293
- }
294
- hostnames.add(hostname)
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
- }
306
- }
307
- const services = parsed.map(([, service]) => service)
308
- const rawProject = raw['project']
309
- let project: { readonly [key: string]: JsonValue } | undefined
310
- if (rawProject !== undefined) {
311
- project = jsonObject(rawProject, 'Zerops import project')
312
- if (project['envIsolation'] !== ENV_ISOLATION || project['envVariables'] !== undefined) {
313
- 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
+ }
314
184
  }
315
- }
316
- 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}`
317
212
  }
318
213
 
319
- const parseSourceDescriptor = (value: unknown): ZeropsArtifactSourceDescriptor => {
320
- if (value === undefined) {
321
- throw new Error('invalid fabrika manifest Zerops source descriptor')
322
- }
323
- const raw = jsonObject(value, 'Zerops source descriptor')
324
- const unknownKeys = Object.keys(raw).filter((key) => key !== 'path' && key !== 'contents' && key !== 'sha256')
325
- if (unknownKeys.length > 0) {
326
- throw new Error(`invalid fabrika manifest Zerops source descriptor field \`${unknownKeys[0]}\``)
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'
327
232
  }
328
- const contents = raw['contents']
329
- const sha256 = raw['sha256']
330
- if (
331
- raw['path'] !== 'zerops.yaml'
332
- || typeof contents !== 'string'
333
- || contents === ''
334
- || new TextEncoder().encode(contents).byteLength > ZEROPS_SOURCE_DESCRIPTOR_MAX_BYTES
335
- || typeof sha256 !== 'string'
336
- || !SHA256_PATTERN.test(sha256)
337
- ) {
338
- throw new Error('invalid fabrika manifest Zerops source descriptor')
339
- }
340
- return { path: 'zerops.yaml', contents, sha256 }
341
233
  }
342
234
 
343
235
  export const createZeropsArtifactSourceDescriptor = async (contents: string): Promise<ZeropsArtifactSourceDescriptor> => {
@@ -361,13 +253,7 @@ export const verifyZeropsArtifactSourceDescriptor = async (descriptor: ZeropsArt
361
253
  }
362
254
 
363
255
  export const manifestServiceHostnames = (manifest: FabrikaManifest): string[] =>
364
- manifest.target.importDocument.services.map((service) => {
365
- const hostname = service['hostname']
366
- if (typeof hostname !== 'string') {
367
- throw new Error('invalid canonical Zerops service hostname')
368
- }
369
- return hostname
370
- })
256
+ manifest.target.importDocument.services.map((service) => service.hostname)
371
257
 
372
258
  export const renderFabrikaImportYaml = (manifest: FabrikaManifest): string => renderYaml(manifest.target.importDocument)
373
259
 
@@ -375,90 +261,48 @@ export const renderFabrikaImportYaml = (manifest: FabrikaManifest): string => re
375
261
  export const renderFabrikaProvisioningYaml = (manifest: FabrikaManifest): string =>
376
262
  renderYaml({
377
263
  ...(manifest.target.importDocument.project === undefined ? {} : { project: manifest.target.importDocument.project }),
378
- services: manifest.target.importDocument.services.map((service) => ({ ...service, startWithoutCode: true })),
264
+ services: manifest.target.importDocument.services.map((service) => ({ ...service, override: false, startWithoutCode: true })),
379
265
  })
380
266
 
381
267
  export function parseFabrikaManifest(value: unknown, expected: ManifestExpectation = {}): FabrikaManifest {
382
- if (prop(value, 'manifestVersion') !== FABRIKA_MANIFEST_VERSION) {
383
- throw new Error(`unsupported fabrika manifest version (expected ${FABRIKA_MANIFEST_VERSION})`)
384
- }
385
- const rawApp = prop(value, 'app')
386
- const id = nonEmptyString(rawApp, 'id')
387
- const env = nonEmptyString(rawApp, 'env')
388
- const rawPipeline = prop(rawApp, 'pipeline')
389
- const secrets = stringArray(rawPipeline, 'secrets')
390
- const vars = stringArray(rawPipeline, 'vars')
391
- if (id === undefined || env === undefined || secrets === undefined || vars === undefined) {
392
- throw new Error('invalid fabrika manifest app metadata')
393
- }
394
- if (expected.appId !== undefined && expected.appId !== id) {
395
- throw new Error(`manifest app drift: expected \`${expected.appId}\`, got \`${id}\``)
396
- }
397
- if (expected.env !== undefined && expected.env !== env) {
398
- throw new Error(`manifest environment drift: expected \`${expected.env}\`, got \`${env}\``)
399
- }
400
- const rawSchema = prop(rawApp, 'schema')
401
- let schema: AppSchema | undefined
402
- if (rawSchema !== undefined) {
403
- const parsed = parseSchema(rawSchema)
404
- if (parsed === null) {
405
- throw new Error('invalid fabrika manifest schema')
406
- }
407
- schema = parsed
408
- }
409
-
410
- const rawTarget = prop(value, 'target')
411
- const sourceDescriptor = parseSourceDescriptor(prop(rawTarget, 'sourceDescriptor'))
412
- const rawImportDocument = prop(rawTarget, 'importDocument')
413
- const deployService = nonEmptyString(rawTarget, 'deployService')
414
- if (
415
- string(rawTarget, 'platform') !== 'zerops'
416
- || deployService === undefined
417
- ) {
418
- throw new Error('invalid fabrika manifest Zerops target')
419
- }
420
- const importDocument = parseImportDocument(rawImportDocument)
421
- const serviceHostnames = importDocument.services.map((service) => {
422
- const hostname = service['hostname']
423
- if (typeof hostname !== 'string') {
424
- throw new Error('invalid fabrika manifest Zerops service hostname')
425
- }
426
- return hostname
427
- })
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)
428
288
  if (!serviceHostnames.includes(deployService)) {
429
289
  throw new Error('invalid fabrika manifest Zerops deploy service')
430
290
  }
431
- const foreignInDescriptor = foreignReferenceInValue(parseDescriptorDocument(sourceDescriptor.contents), allowedReferenceHosts(serviceHostnames))
291
+ const foreignInDescriptor = foreignReferenceInValue(
292
+ parsedDescriptor.document,
293
+ allowedReferenceHosts(serviceHostnames),
294
+ )
432
295
  if (foreignInDescriptor !== null) {
433
296
  throw new Error(`invalid fabrika manifest: zerops.yaml references service \`${foreignInDescriptor}\`, which this app does not own`)
434
297
  }
435
- const zeropsSetup = optionalString(rawTarget, 'zeropsSetup')
436
- if (prop(rawTarget, 'zeropsSetup') !== undefined && zeropsSetup === null) {
437
- throw new Error('invalid fabrika manifest zeropsSetup')
438
- }
439
- const rawProxy = prop(rawTarget, 'proxy')
440
- let proxy: ZeropsProxySpec | undefined
441
- if (rawProxy !== undefined) {
442
- const parsed = parseProxy(rawProxy)
443
- if (parsed === null) {
444
- throw new Error('invalid fabrika manifest proxy declaration')
445
- }
446
- proxy = parsed
447
- const separator = parsed.upstream.indexOf(':')
448
- const upstreamHostname = separator === -1 ? parsed.upstream : parsed.upstream.slice(0, separator)
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)
449
302
  if (!serviceHostnames.includes(upstreamHostname)) {
450
303
  throw new Error(`invalid fabrika manifest: proxy upstream service \`${upstreamHostname}\` is not owned by this app`)
451
304
  }
452
305
  }
453
- const rawNamespaceResources = prop(rawTarget, 'namespaceResources')
454
- let namespaceResources: ZeropsNamespaceResourceRequirement[] | undefined
455
- if (rawNamespaceResources !== undefined) {
456
- const parsed = parseNamespaceResources(rawNamespaceResources)
457
- if (parsed === null) {
458
- throw new Error('invalid fabrika manifest namespace resources')
459
- }
460
- namespaceResources = parsed
461
- }
462
306
  if (namespaceResources?.some((resource) => serviceHostnames.includes(resource.hostname)) === true) {
463
307
  throw new Error('invalid fabrika manifest: an app cannot declare a namespace-owned service')
464
308
  }
@@ -476,7 +320,7 @@ export function parseFabrikaManifest(value: unknown, expected: ManifestExpectati
476
320
  sourceDescriptor,
477
321
  importDocument,
478
322
  deployService,
479
- ...(zeropsSetup !== null ? { zeropsSetup } : {}),
323
+ ...(zeropsSetup !== undefined ? { zeropsSetup } : {}),
480
324
  ...(proxy !== undefined ? { proxy } : {}),
481
325
  ...(namespaceResources !== undefined ? { namespaceResources } : {}),
482
326
  },
@@ -500,31 +344,21 @@ export function compileFabrikaManifest(config: ZeropsAppConfig, env: string, sou
500
344
  if (deployService === undefined || !serviceHostnames.includes(deployService)) {
501
345
  throw new Error('fabrika app build: a multi-service Zerops app must name a valid deployService')
502
346
  }
503
- if (config.target.namespaceResources?.some((resource) => serviceHostnames.includes(resource.hostname)) === true) {
504
- throw new Error('fabrika app build: an app cannot declare a namespace-owned service')
505
- }
506
- const manifest: FabrikaManifest = {
347
+ const candidate = {
507
348
  manifestVersion: FABRIKA_MANIFEST_VERSION,
508
- app: {
509
- id: config.id,
510
- env,
511
- ...(config.schema !== undefined ? { schema: config.schema } : {}),
512
- pipeline: {
513
- secrets: [...(config.pipeline?.secrets ?? [])],
514
- vars: [...declaredVars],
515
- },
516
- },
349
+ app: { id: config.id, env, schema: config.schema, pipeline: { secrets: config.pipeline?.secrets ?? [], vars: declaredVars } },
517
350
  target: {
518
351
  platform: 'zerops',
519
352
  sourceDescriptor,
520
- importDocument: parseImportDocument(document),
353
+ importDocument: document,
521
354
  deployService,
522
- ...(config.target.zeropsSetup !== undefined ? { zeropsSetup: config.target.zeropsSetup } : {}),
523
- ...(config.target.proxy !== undefined ? { proxy: config.target.proxy } : {}),
524
- ...(config.target.namespaceResources !== undefined ? { namespaceResources: [...config.target.namespaceResources] } : {}),
355
+ zeropsSetup: config.target.zeropsSetup,
356
+ proxy: config.target.proxy,
357
+ namespaceResources: config.target.namespaceResources,
525
358
  },
526
359
  }
527
- 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 })
528
362
  } finally {
529
363
  for (const name of declaredVars) {
530
364
  const value = prior[name]
@@ -543,3 +377,16 @@ export const zeropsArtifactCodec: ProviderCodec<FabrikaManifest> = {
543
377
  encode: jsonValue,
544
378
  decode: (payload) => parseFabrikaManifest(payload),
545
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
+ }