@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.
- 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 +0 -5
- package/src/cli.ts +3 -28
- package/src/collaborators.ts +2 -4
- package/src/compile.ts +75 -20
- package/src/control.ts +217 -447
- package/src/descriptor.ts +135 -0
- package/src/index.ts +24 -128
- package/src/installation/admin-options.ts +128 -0
- package/src/installation/admin.ts +123 -0
- package/src/installation/deploy-options.ts +155 -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 +366 -0
- package/src/installation/init.ts +521 -0
- package/src/installation/install-collaborators.ts +28 -0
- package/src/installation/install-environment.ts +193 -0
- package/src/installation/install-options.ts +193 -0
- package/src/installation/install-project.ts +201 -0
- package/src/installation/install-provisioning.ts +185 -0
- package/src/installation/install.ts +286 -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 +122 -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 +108 -0
- package/src/installation/upgrade-output.ts +31 -0
- package/src/installation/upgrade.ts +373 -0
- package/src/managed-env.ts +105 -0
- package/src/manifest.ts +208 -315
- 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 -1260
- package/src/plan.ts +21 -23
- package/src/provider.ts +399 -254
- package/src/service-references.ts +28 -0
- 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,12 @@
|
|
|
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 { 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:
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
159
|
+
return null
|
|
220
160
|
}
|
|
221
161
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
hostnames
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
if (
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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 !==
|
|
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
|
-
|
|
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:
|
|
353
|
+
importDocument: document,
|
|
475
354
|
deployService,
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
355
|
+
zeropsSetup: config.target.zeropsSetup,
|
|
356
|
+
proxy: config.target.proxy,
|
|
357
|
+
namespaceResources: config.target.namespaceResources,
|
|
479
358
|
},
|
|
480
359
|
}
|
|
481
|
-
|
|
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
|
+
}
|