@fabrika/provider-zerops 0.2.0 → 0.3.2
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 +18 -5
- package/src/api/client.ts +522 -0
- package/src/api/errors.ts +50 -0
- package/src/api/polling.ts +59 -0
- package/src/api/readers.ts +185 -0
- package/src/api/service-env-response.ts +34 -0
- package/src/api/types.ts +602 -0
- package/src/api.ts +29 -1378
- package/src/authoring.ts +7 -10
- package/src/cli-args.ts +2 -6
- package/src/cli.ts +3 -28
- package/src/collaborators.ts +2 -4
- package/src/compile.ts +3 -1
- package/src/control.ts +203 -450
- package/src/descriptor.ts +179 -0
- package/src/index.ts +23 -127
- package/src/installation/admin-options.ts +100 -0
- package/src/installation/admin.ts +123 -0
- package/src/installation/deploy-options.ts +127 -0
- package/src/installation/deploy.ts +668 -0
- package/src/installation/generated/platform-console-schema.ts +41 -0
- package/src/installation/generated/platform-proxy-manifest.ts +72 -0
- package/src/installation/hosts.ts +117 -0
- package/src/installation/index.ts +390 -0
- package/src/installation/init-options.ts +148 -0
- package/src/installation/init.ts +394 -0
- package/src/installation/install-collaborators.ts +27 -0
- package/src/installation/install-environment.ts +193 -0
- package/src/installation/install-options.ts +156 -0
- package/src/installation/install-project.ts +201 -0
- package/src/installation/install-provisioning.ts +185 -0
- package/src/installation/install.ts +262 -0
- package/src/installation/installation-source.ts +42 -0
- package/src/installation/log.ts +77 -0
- package/src/installation/machine-key.ts +65 -0
- package/src/installation/manifest.ts +95 -0
- package/src/installation/placement-options.ts +33 -0
- package/src/installation/platform-source.ts +106 -0
- package/src/installation/platform-upload.ts +34 -0
- package/src/installation/proxy-manifest.ts +113 -0
- package/src/installation/secrets.ts +79 -0
- package/src/installation/sidecar.ts +125 -0
- package/src/installation/source-options.ts +12 -0
- package/src/installation/templates/README.md +116 -0
- package/src/installation/templates/fabrika.ref +1 -0
- package/src/installation/templates/gitignore +3 -0
- package/src/installation/templates/platform.yml +144 -0
- package/src/installation/topology/artifacts.ts +141 -0
- package/src/installation/topology/index.ts +13 -0
- package/src/installation/topology/installation-schemas.ts +225 -0
- package/src/installation/topology/invariants.ts +86 -0
- package/src/installation/topology/setups.ts +442 -0
- package/src/installation/topology/topology.ts +501 -0
- package/src/installation/topology/validate.ts +26 -0
- package/src/installation/upgrade-options.ts +99 -0
- package/src/installation/upgrade-output.ts +31 -0
- package/src/installation/upgrade.ts +373 -0
- package/src/managed-env.ts +113 -0
- package/src/manifest.ts +194 -344
- package/src/namespace/claims.ts +77 -0
- package/src/namespace/codec.ts +179 -0
- package/src/namespace/constants.ts +30 -0
- package/src/namespace/errors.ts +60 -0
- package/src/namespace/manifest.ts +28 -0
- package/src/namespace/operator.ts +151 -0
- package/src/namespace/provisioning.ts +482 -0
- package/src/namespace/proxy-attempt.ts +220 -0
- package/src/namespace/proxy-source.ts +22 -0
- package/src/namespace/topology.ts +115 -0
- package/src/namespace/types.ts +114 -0
- package/src/namespace.ts +34 -1271
- package/src/plan.ts +21 -23
- package/src/provider.ts +266 -241
- package/src/source/constants.ts +11 -0
- package/src/source/port.ts +49 -0
- package/src/source/types.ts +4 -0
- package/src/source/validation.ts +72 -0
- package/src/source.ts +2 -1220
- package/src/types.ts +8 -7
- package/src/version-attempt.ts +18 -0
- package/src/codec.ts +0 -108
- package/src/namespace-command.ts +0 -209
- package/src/source-connection.ts +0 -344
package/src/manifest.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import
|
|
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 { assertOwnRunWhenExtending, executionZeropsDocument, 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:
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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.
|
|
243
|
-
const host = foreignReferenceHost(key, allowed) ?? foreignReferenceInValue(
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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,51 @@ 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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
if (
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
291
|
+
// Only the selected setup and what it extends reach this app's service, so a sibling setup for another
|
|
292
|
+
// placement may reference services this app does not own.
|
|
293
|
+
const selectedSetup = selectZeropsDescriptorSetup(parsedDescriptor, zeropsSetup)
|
|
294
|
+
assertOwnRunWhenExtending(parsedDescriptor, selectedSetup)
|
|
295
|
+
const foreignInDescriptor = foreignReferenceInValue(
|
|
296
|
+
executionZeropsDocument(parsedDescriptor, selectedSetup),
|
|
297
|
+
allowedReferenceHosts(serviceHostnames),
|
|
298
|
+
)
|
|
432
299
|
if (foreignInDescriptor !== null) {
|
|
433
300
|
throw new Error(`invalid fabrika manifest: zerops.yaml references service \`${foreignInDescriptor}\`, which this app does not own`)
|
|
434
301
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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)
|
|
302
|
+
if (proxy !== undefined) {
|
|
303
|
+
const separator = proxy.upstream.indexOf(':')
|
|
304
|
+
const upstreamHostname = separator === -1 ? proxy.upstream : proxy.upstream.slice(0, separator)
|
|
449
305
|
if (!serviceHostnames.includes(upstreamHostname)) {
|
|
450
306
|
throw new Error(`invalid fabrika manifest: proxy upstream service \`${upstreamHostname}\` is not owned by this app`)
|
|
451
307
|
}
|
|
452
308
|
}
|
|
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
309
|
if (namespaceResources?.some((resource) => serviceHostnames.includes(resource.hostname)) === true) {
|
|
463
310
|
throw new Error('invalid fabrika manifest: an app cannot declare a namespace-owned service')
|
|
464
311
|
}
|
|
@@ -476,7 +323,7 @@ export function parseFabrikaManifest(value: unknown, expected: ManifestExpectati
|
|
|
476
323
|
sourceDescriptor,
|
|
477
324
|
importDocument,
|
|
478
325
|
deployService,
|
|
479
|
-
...(zeropsSetup !==
|
|
326
|
+
...(zeropsSetup !== undefined ? { zeropsSetup } : {}),
|
|
480
327
|
...(proxy !== undefined ? { proxy } : {}),
|
|
481
328
|
...(namespaceResources !== undefined ? { namespaceResources } : {}),
|
|
482
329
|
},
|
|
@@ -500,31 +347,21 @@ export function compileFabrikaManifest(config: ZeropsAppConfig, env: string, sou
|
|
|
500
347
|
if (deployService === undefined || !serviceHostnames.includes(deployService)) {
|
|
501
348
|
throw new Error('fabrika app build: a multi-service Zerops app must name a valid deployService')
|
|
502
349
|
}
|
|
503
|
-
|
|
504
|
-
throw new Error('fabrika app build: an app cannot declare a namespace-owned service')
|
|
505
|
-
}
|
|
506
|
-
const manifest: FabrikaManifest = {
|
|
350
|
+
const candidate = {
|
|
507
351
|
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
|
-
},
|
|
352
|
+
app: { id: config.id, env, schema: config.schema, pipeline: { secrets: config.pipeline?.secrets ?? [], vars: declaredVars } },
|
|
517
353
|
target: {
|
|
518
354
|
platform: 'zerops',
|
|
519
355
|
sourceDescriptor,
|
|
520
|
-
importDocument:
|
|
356
|
+
importDocument: document,
|
|
521
357
|
deployService,
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
358
|
+
zeropsSetup: config.target.zeropsSetup,
|
|
359
|
+
proxy: config.target.proxy,
|
|
360
|
+
namespaceResources: config.target.namespaceResources,
|
|
525
361
|
},
|
|
526
362
|
}
|
|
527
|
-
|
|
363
|
+
// The JSON copy is the artifact's own form: authored objects lose inherited fields and undefined keys once, here.
|
|
364
|
+
return parseFabrikaManifest(jsonValue(candidate), { appId: config.id, env })
|
|
528
365
|
} finally {
|
|
529
366
|
for (const name of declaredVars) {
|
|
530
367
|
const value = prior[name]
|
|
@@ -543,3 +380,16 @@ export const zeropsArtifactCodec: ProviderCodec<FabrikaManifest> = {
|
|
|
543
380
|
encode: jsonValue,
|
|
544
381
|
decode: (payload) => parseFabrikaManifest(payload),
|
|
545
382
|
}
|
|
383
|
+
|
|
384
|
+
/** Hash the original validated artifact, never an interpolated import or execution overlay. */
|
|
385
|
+
export async function hashFabrikaManifest(manifest: FabrikaManifest): Promise<string> {
|
|
386
|
+
const canonical = (value: JsonValue): JsonValue => {
|
|
387
|
+
if (Array.isArray(value)) return value.map(canonical)
|
|
388
|
+
if (typeof value !== 'object' || value === null) return value
|
|
389
|
+
return Object.fromEntries(
|
|
390
|
+
Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([key, entry]) => [key, canonical(entry)]),
|
|
391
|
+
)
|
|
392
|
+
}
|
|
393
|
+
const bytes = new TextEncoder().encode(JSON.stringify(canonical(zeropsArtifactCodec.encode(manifest))))
|
|
394
|
+
return Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)), (byte) => byte.toString(16).padStart(2, '0')).join('')
|
|
395
|
+
}
|