@fabrika/provider-zerops 0.0.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 +40 -0
- package/src/api.ts +825 -0
- package/src/authoring.ts +33 -0
- package/src/cli-args.ts +101 -0
- package/src/cli.ts +82 -0
- package/src/codec.ts +50 -0
- package/src/collaborators.ts +47 -0
- package/src/compile.ts +382 -0
- package/src/control.ts +390 -0
- package/src/index.ts +98 -0
- package/src/manifest.ts +442 -0
- package/src/namespace-command.ts +209 -0
- package/src/namespace.ts +1028 -0
- package/src/plan.ts +51 -0
- package/src/provider.ts +311 -0
- package/src/schema.generated.ts +607 -0
- package/src/service-names.ts +38 -0
- package/src/types.ts +125 -0
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import type { AppActionDef, AppGates, AppSchema, AppScopeDef, GateRule, RoleDef } from '@fabrika/auth-core'
|
|
2
|
+
import type { JsonValue, ProviderCodec } from '@fabrika/provider-contract'
|
|
3
|
+
import { compileImport, ENV_ISOLATION, OVERRIDE, renderYaml } from './compile'
|
|
4
|
+
import type { ZeropsAppConfig, ZeropsNamespaceResourceRequirement, ZeropsProxySpec, ZeropsSharedPostgresBinding } from './types'
|
|
5
|
+
|
|
6
|
+
export const FABRIKA_MANIFEST_VERSION = 2
|
|
7
|
+
export const ZEROPS_SERVICE_HOSTNAME_PATTERN = /^[a-z0-9]{1,25}$/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The one reference an app may declare for the namespace-owned PostgreSQL service.
|
|
11
|
+
*
|
|
12
|
+
* Restated here rather than imported from `./namespace` — that module imports THIS one, and the
|
|
13
|
+
* literal is part of the manifest's parse contract, so it belongs on the parsing side. The reasoning
|
|
14
|
+
* for the database path and the TLS mode lives with `ZEROPS_SHARED_POSTGRES_CONNECTION_STRING`; a test
|
|
15
|
+
* asserts the two never diverge.
|
|
16
|
+
*/
|
|
17
|
+
const SHARED_POSTGRES_CONNECTION_STRING: ZeropsSharedPostgresBinding['connectionString'] =
|
|
18
|
+
'${postgres_connectionString}/${postgres_dbName}?sslmode=require'
|
|
19
|
+
|
|
20
|
+
export interface FabrikaImportDocument {
|
|
21
|
+
readonly project?: { readonly [key: string]: JsonValue }
|
|
22
|
+
readonly services: ReadonlyArray<{ readonly [key: string]: JsonValue }>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface FabrikaManifest {
|
|
26
|
+
manifestVersion: typeof FABRIKA_MANIFEST_VERSION
|
|
27
|
+
app: {
|
|
28
|
+
id: string
|
|
29
|
+
env: string
|
|
30
|
+
schema?: AppSchema
|
|
31
|
+
pipeline: {
|
|
32
|
+
secrets: string[]
|
|
33
|
+
vars: string[]
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
target: {
|
|
37
|
+
platform: 'zerops'
|
|
38
|
+
/** The canonical document used both for resource claims and the Zerops API payload. */
|
|
39
|
+
importDocument: FabrikaImportDocument
|
|
40
|
+
deployService: string
|
|
41
|
+
zeropsSetup?: string
|
|
42
|
+
proxy?: ZeropsProxySpec
|
|
43
|
+
namespaceResources?: ZeropsNamespaceResourceRequirement[]
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ManifestExpectation {
|
|
48
|
+
appId?: string
|
|
49
|
+
env?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const prop = (value: unknown, key: string): unknown => typeof value === 'object' && value !== null ? Reflect.get(value, key) : undefined
|
|
53
|
+
|
|
54
|
+
const jsonValue = (value: unknown): JsonValue => {
|
|
55
|
+
if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
56
|
+
return value
|
|
57
|
+
}
|
|
58
|
+
if (Array.isArray(value)) {
|
|
59
|
+
return value.map(jsonValue)
|
|
60
|
+
}
|
|
61
|
+
if (typeof value === 'object') {
|
|
62
|
+
const result: { [key: string]: JsonValue } = {}
|
|
63
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
64
|
+
if (entry !== undefined) {
|
|
65
|
+
result[key] = jsonValue(entry)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return result
|
|
69
|
+
}
|
|
70
|
+
throw new Error(`Fabrika manifest contains a non-JSON ${typeof value} value`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const jsonObject = (value: unknown, label: string): { [key: string]: JsonValue } => {
|
|
74
|
+
const parsed = jsonValue(value)
|
|
75
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
76
|
+
throw new Error(`invalid fabrika manifest ${label}`)
|
|
77
|
+
}
|
|
78
|
+
return parsed
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const string = (value: unknown, key: string): string | undefined => {
|
|
82
|
+
const candidate = prop(value, key)
|
|
83
|
+
return typeof candidate === 'string' ? candidate : undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const nonEmptyString = (value: unknown, key: string): string | undefined => {
|
|
87
|
+
const candidate = string(value, key)
|
|
88
|
+
return candidate !== undefined && candidate !== '' ? candidate : undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const stringArray = (value: unknown, key: string): string[] | undefined => {
|
|
92
|
+
const candidate = prop(value, key)
|
|
93
|
+
if (!Array.isArray(candidate) || candidate.some((item) => typeof item !== 'string' || item === '')) {
|
|
94
|
+
return undefined
|
|
95
|
+
}
|
|
96
|
+
return candidate
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const optionalString = (value: unknown, key: string): string | null => {
|
|
100
|
+
const candidate = prop(value, key)
|
|
101
|
+
if (candidate === undefined) {
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
return typeof candidate === 'string' && candidate !== '' ? candidate : null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseScope(value: unknown): AppScopeDef | null {
|
|
108
|
+
const type = nonEmptyString(value, 'type')
|
|
109
|
+
const label = optionalString(value, 'label')
|
|
110
|
+
if (type === undefined || (prop(value, 'label') !== undefined && label === null)) {
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
return label === null ? { type } : { type, label }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseAction(value: unknown): AppActionDef | null {
|
|
117
|
+
const action = nonEmptyString(value, 'action')
|
|
118
|
+
const description = optionalString(value, 'description')
|
|
119
|
+
if (action === undefined || (prop(value, 'description') !== undefined && description === null)) {
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
return description === null ? { action } : { action, description }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseRole(value: unknown): RoleDef | null {
|
|
126
|
+
const name = nonEmptyString(value, 'name')
|
|
127
|
+
const description = optionalString(value, 'description')
|
|
128
|
+
const permissions = stringArray(value, 'permissions')
|
|
129
|
+
if (name === undefined || permissions === undefined || (prop(value, 'description') !== undefined && description === null)) {
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
132
|
+
return description === null ? { name, permissions } : { name, description, permissions }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parseSchema(value: unknown): AppSchema | null {
|
|
136
|
+
const rawScopes = prop(value, 'scopes')
|
|
137
|
+
const rawActions = prop(value, 'actions')
|
|
138
|
+
const rawRoles = prop(value, 'roles')
|
|
139
|
+
if (!Array.isArray(rawScopes) || !Array.isArray(rawActions) || typeof rawRoles !== 'object' || rawRoles === null || Array.isArray(rawRoles)) {
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
const scopes: AppScopeDef[] = []
|
|
143
|
+
for (const raw of rawScopes) {
|
|
144
|
+
const scope = parseScope(raw)
|
|
145
|
+
if (scope === null) return null
|
|
146
|
+
scopes.push(scope)
|
|
147
|
+
}
|
|
148
|
+
const actions: AppActionDef[] = []
|
|
149
|
+
for (const raw of rawActions) {
|
|
150
|
+
const action = parseAction(raw)
|
|
151
|
+
if (action === null) return null
|
|
152
|
+
actions.push(action)
|
|
153
|
+
}
|
|
154
|
+
const roles: Record<string, RoleDef> = {}
|
|
155
|
+
for (const [key, raw] of Object.entries(rawRoles)) {
|
|
156
|
+
const role = parseRole(raw)
|
|
157
|
+
if (key === '' || role === null) return null
|
|
158
|
+
roles[key] = role
|
|
159
|
+
}
|
|
160
|
+
return { scopes, actions, roles }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseGate(value: unknown): GateRule | null {
|
|
164
|
+
const path = nonEmptyString(value, 'path')
|
|
165
|
+
const kind = string(value, 'kind')
|
|
166
|
+
if (path === undefined || !path.startsWith('/')) return null
|
|
167
|
+
if (kind === 'public' || kind === 'human') return { path, kind }
|
|
168
|
+
if (kind !== 'service') return null
|
|
169
|
+
const rawCredential = prop(value, 'credential')
|
|
170
|
+
if (rawCredential === undefined) return { path, kind }
|
|
171
|
+
const location = string(rawCredential, 'in')
|
|
172
|
+
const name = nonEmptyString(rawCredential, 'name')
|
|
173
|
+
if ((location !== 'header' && location !== 'query' && location !== 'cookie') || name === undefined) return null
|
|
174
|
+
return { path, kind, credential: { in: location, name } }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function parseGates(value: unknown): AppGates | null {
|
|
178
|
+
const rawRules = prop(value, 'rules')
|
|
179
|
+
if (!Array.isArray(rawRules)) return null
|
|
180
|
+
const rules: GateRule[] = []
|
|
181
|
+
for (const raw of rawRules) {
|
|
182
|
+
const rule = parseGate(raw)
|
|
183
|
+
if (rule === null) return null
|
|
184
|
+
rules.push(rule)
|
|
185
|
+
}
|
|
186
|
+
return { rules }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const parseNamespaceResources = (value: unknown): ZeropsNamespaceResourceRequirement[] | null => {
|
|
190
|
+
if (!Array.isArray(value)) return null
|
|
191
|
+
const resources: ZeropsNamespaceResourceRequirement[] = []
|
|
192
|
+
let hasPostgres = false
|
|
193
|
+
for (const resource of value) {
|
|
194
|
+
if (
|
|
195
|
+
string(resource, 'resourceKey') !== 'service:postgres'
|
|
196
|
+
|| string(resource, 'hostname') !== 'postgres'
|
|
197
|
+
|| string(resource, 'connectionString') !== SHARED_POSTGRES_CONNECTION_STRING
|
|
198
|
+
) {
|
|
199
|
+
return null
|
|
200
|
+
}
|
|
201
|
+
if (hasPostgres) return null
|
|
202
|
+
hasPostgres = true
|
|
203
|
+
resources.push({
|
|
204
|
+
resourceKey: 'service:postgres',
|
|
205
|
+
hostname: 'postgres',
|
|
206
|
+
connectionString: SHARED_POSTGRES_CONNECTION_STRING,
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
return resources
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function parseProxy(value: unknown): ZeropsProxySpec | null {
|
|
213
|
+
const upstream = nonEmptyString(value, 'upstream')
|
|
214
|
+
const gates = parseGates(prop(value, 'gates'))
|
|
215
|
+
return upstream === undefined || gates === null ? null : { upstream, gates }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const parseImportDocument = (value: unknown): FabrikaImportDocument => {
|
|
219
|
+
if (value === undefined) {
|
|
220
|
+
throw new Error('invalid fabrika manifest Zerops import document')
|
|
221
|
+
}
|
|
222
|
+
const raw = jsonObject(value, 'Zerops import document')
|
|
223
|
+
const unknownKeys = Object.keys(raw).filter((key) => key !== 'project' && key !== 'services')
|
|
224
|
+
if (unknownKeys.length > 0) {
|
|
225
|
+
throw new Error(`invalid fabrika manifest Zerops import document field \`${unknownKeys[0]}\``)
|
|
226
|
+
}
|
|
227
|
+
const rawServices = raw['services']
|
|
228
|
+
if (!Array.isArray(rawServices) || rawServices.length === 0) {
|
|
229
|
+
throw new Error('invalid fabrika manifest Zerops import services')
|
|
230
|
+
}
|
|
231
|
+
const services: Array<{ readonly [key: string]: JsonValue }> = []
|
|
232
|
+
const hostnames = new Set<string>()
|
|
233
|
+
for (const [index, rawService] of rawServices.entries()) {
|
|
234
|
+
const service = jsonObject(rawService, `Zerops import service ${index}`)
|
|
235
|
+
const hostname = service['hostname']
|
|
236
|
+
if (typeof hostname !== 'string' || !ZEROPS_SERVICE_HOSTNAME_PATTERN.test(hostname)) {
|
|
237
|
+
throw new Error(`invalid fabrika manifest Zerops service hostname at index ${index}`)
|
|
238
|
+
}
|
|
239
|
+
if (hostnames.has(hostname)) {
|
|
240
|
+
throw new Error(`invalid fabrika manifest: duplicate Zerops service hostname \`${hostname}\``)
|
|
241
|
+
}
|
|
242
|
+
if (typeof service['type'] !== 'string' || service['type'] === '') {
|
|
243
|
+
throw new Error(`invalid fabrika manifest Zerops service type for \`${hostname}\``)
|
|
244
|
+
}
|
|
245
|
+
// `override: true` is required on EVERY service class, managed included — without it the platform
|
|
246
|
+
// rejects a re-apply of the whole document as a name collision (see `compile.ts`).
|
|
247
|
+
if (service['envIsolation'] !== ENV_ISOLATION || service['override'] !== OVERRIDE) {
|
|
248
|
+
throw new Error(`invalid fabrika manifest Zerops service invariants for \`${hostname}\``)
|
|
249
|
+
}
|
|
250
|
+
if (service['envSecrets'] !== undefined || service['dotEnvSecrets'] !== undefined) {
|
|
251
|
+
throw new Error('invalid fabrika manifest: Zerops import contains a forbidden environment field')
|
|
252
|
+
}
|
|
253
|
+
hostnames.add(hostname)
|
|
254
|
+
services.push(service)
|
|
255
|
+
}
|
|
256
|
+
const rawProject = raw['project']
|
|
257
|
+
let project: { readonly [key: string]: JsonValue } | undefined
|
|
258
|
+
if (rawProject !== undefined) {
|
|
259
|
+
project = jsonObject(rawProject, 'Zerops import project')
|
|
260
|
+
if (project['envIsolation'] !== ENV_ISOLATION || project['envVariables'] !== undefined) {
|
|
261
|
+
throw new Error('invalid fabrika manifest: Zerops import contains a forbidden project environment field')
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return project === undefined ? { services } : { project, services }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export const manifestServiceHostnames = (manifest: FabrikaManifest): string[] =>
|
|
268
|
+
manifest.target.importDocument.services.map((service) => {
|
|
269
|
+
const hostname = service['hostname']
|
|
270
|
+
if (typeof hostname !== 'string') {
|
|
271
|
+
throw new Error('invalid canonical Zerops service hostname')
|
|
272
|
+
}
|
|
273
|
+
return hostname
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
export const renderFabrikaImportYaml = (manifest: FabrikaManifest): string => renderYaml(manifest.target.importDocument)
|
|
277
|
+
|
|
278
|
+
/** Render the codeless import used before registration has a deploy-service id. */
|
|
279
|
+
export const renderFabrikaProvisioningYaml = (manifest: FabrikaManifest): string =>
|
|
280
|
+
renderYaml({
|
|
281
|
+
...(manifest.target.importDocument.project === undefined ? {} : { project: manifest.target.importDocument.project }),
|
|
282
|
+
services: manifest.target.importDocument.services.map((service) => ({ ...service, startWithoutCode: true })),
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
export function parseFabrikaManifest(value: unknown, expected: ManifestExpectation = {}): FabrikaManifest {
|
|
286
|
+
if (prop(value, 'manifestVersion') !== FABRIKA_MANIFEST_VERSION) {
|
|
287
|
+
throw new Error(`unsupported fabrika manifest version (expected ${FABRIKA_MANIFEST_VERSION})`)
|
|
288
|
+
}
|
|
289
|
+
const rawApp = prop(value, 'app')
|
|
290
|
+
const id = nonEmptyString(rawApp, 'id')
|
|
291
|
+
const env = nonEmptyString(rawApp, 'env')
|
|
292
|
+
const rawPipeline = prop(rawApp, 'pipeline')
|
|
293
|
+
const secrets = stringArray(rawPipeline, 'secrets')
|
|
294
|
+
const vars = stringArray(rawPipeline, 'vars')
|
|
295
|
+
if (id === undefined || env === undefined || secrets === undefined || vars === undefined) {
|
|
296
|
+
throw new Error('invalid fabrika manifest app metadata')
|
|
297
|
+
}
|
|
298
|
+
if (expected.appId !== undefined && expected.appId !== id) {
|
|
299
|
+
throw new Error(`manifest app drift: expected \`${expected.appId}\`, got \`${id}\``)
|
|
300
|
+
}
|
|
301
|
+
if (expected.env !== undefined && expected.env !== env) {
|
|
302
|
+
throw new Error(`manifest environment drift: expected \`${expected.env}\`, got \`${env}\``)
|
|
303
|
+
}
|
|
304
|
+
const rawSchema = prop(rawApp, 'schema')
|
|
305
|
+
let schema: AppSchema | undefined
|
|
306
|
+
if (rawSchema !== undefined) {
|
|
307
|
+
const parsed = parseSchema(rawSchema)
|
|
308
|
+
if (parsed === null) {
|
|
309
|
+
throw new Error('invalid fabrika manifest schema')
|
|
310
|
+
}
|
|
311
|
+
schema = parsed
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const rawTarget = prop(value, 'target')
|
|
315
|
+
const rawImportDocument = prop(rawTarget, 'importDocument')
|
|
316
|
+
const deployService = nonEmptyString(rawTarget, 'deployService')
|
|
317
|
+
if (
|
|
318
|
+
string(rawTarget, 'platform') !== 'zerops'
|
|
319
|
+
|| deployService === undefined
|
|
320
|
+
) {
|
|
321
|
+
throw new Error('invalid fabrika manifest Zerops target')
|
|
322
|
+
}
|
|
323
|
+
const importDocument = parseImportDocument(rawImportDocument)
|
|
324
|
+
const serviceHostnames = importDocument.services.map((service) => {
|
|
325
|
+
const hostname = service['hostname']
|
|
326
|
+
if (typeof hostname !== 'string') {
|
|
327
|
+
throw new Error('invalid fabrika manifest Zerops service hostname')
|
|
328
|
+
}
|
|
329
|
+
return hostname
|
|
330
|
+
})
|
|
331
|
+
if (!serviceHostnames.includes(deployService)) {
|
|
332
|
+
throw new Error('invalid fabrika manifest Zerops deploy service')
|
|
333
|
+
}
|
|
334
|
+
const zeropsSetup = optionalString(rawTarget, 'zeropsSetup')
|
|
335
|
+
if (prop(rawTarget, 'zeropsSetup') !== undefined && zeropsSetup === null) {
|
|
336
|
+
throw new Error('invalid fabrika manifest zeropsSetup')
|
|
337
|
+
}
|
|
338
|
+
const rawProxy = prop(rawTarget, 'proxy')
|
|
339
|
+
let proxy: ZeropsProxySpec | undefined
|
|
340
|
+
if (rawProxy !== undefined) {
|
|
341
|
+
const parsed = parseProxy(rawProxy)
|
|
342
|
+
if (parsed === null) {
|
|
343
|
+
throw new Error('invalid fabrika manifest proxy declaration')
|
|
344
|
+
}
|
|
345
|
+
proxy = parsed
|
|
346
|
+
const separator = parsed.upstream.indexOf(':')
|
|
347
|
+
const upstreamHostname = separator === -1 ? parsed.upstream : parsed.upstream.slice(0, separator)
|
|
348
|
+
if (!serviceHostnames.includes(upstreamHostname)) {
|
|
349
|
+
throw new Error(`invalid fabrika manifest: proxy upstream service \`${upstreamHostname}\` is not owned by this app`)
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const rawNamespaceResources = prop(rawTarget, 'namespaceResources')
|
|
353
|
+
let namespaceResources: ZeropsNamespaceResourceRequirement[] | undefined
|
|
354
|
+
if (rawNamespaceResources !== undefined) {
|
|
355
|
+
const parsed = parseNamespaceResources(rawNamespaceResources)
|
|
356
|
+
if (parsed === null) {
|
|
357
|
+
throw new Error('invalid fabrika manifest namespace resources')
|
|
358
|
+
}
|
|
359
|
+
namespaceResources = parsed
|
|
360
|
+
}
|
|
361
|
+
if (namespaceResources?.some((resource) => serviceHostnames.includes(resource.hostname)) === true) {
|
|
362
|
+
throw new Error('invalid fabrika manifest: an app cannot declare a namespace-owned service')
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
manifestVersion: FABRIKA_MANIFEST_VERSION,
|
|
367
|
+
app: {
|
|
368
|
+
id,
|
|
369
|
+
env,
|
|
370
|
+
...(schema !== undefined ? { schema } : {}),
|
|
371
|
+
pipeline: { secrets, vars },
|
|
372
|
+
},
|
|
373
|
+
target: {
|
|
374
|
+
platform: 'zerops',
|
|
375
|
+
importDocument,
|
|
376
|
+
deployService,
|
|
377
|
+
...(zeropsSetup !== null ? { zeropsSetup } : {}),
|
|
378
|
+
...(proxy !== undefined ? { proxy } : {}),
|
|
379
|
+
...(namespaceResources !== undefined ? { namespaceResources } : {}),
|
|
380
|
+
},
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function compileFabrikaManifest(config: ZeropsAppConfig, env: string): FabrikaManifest {
|
|
385
|
+
if (config.target.services === undefined) {
|
|
386
|
+
throw new Error('fabrika app build requires a source Zerops config')
|
|
387
|
+
}
|
|
388
|
+
const declaredVars = config.pipeline?.vars ?? []
|
|
389
|
+
const prior: Record<string, string | undefined> = {}
|
|
390
|
+
for (const name of declaredVars) {
|
|
391
|
+
prior[name] = process.env[name]
|
|
392
|
+
process.env[name] = `\${${name}}`
|
|
393
|
+
}
|
|
394
|
+
try {
|
|
395
|
+
const document = compileImport({ target: config.target, ctx: { env } })
|
|
396
|
+
const serviceHostnames = document.services.map((service) => service.hostname)
|
|
397
|
+
const deployService = config.target.deployService ?? (serviceHostnames.length === 1 ? serviceHostnames[0] : undefined)
|
|
398
|
+
if (deployService === undefined || !serviceHostnames.includes(deployService)) {
|
|
399
|
+
throw new Error('fabrika app build: a multi-service Zerops app must name a valid deployService')
|
|
400
|
+
}
|
|
401
|
+
if (config.target.namespaceResources?.some((resource) => serviceHostnames.includes(resource.hostname)) === true) {
|
|
402
|
+
throw new Error('fabrika app build: an app cannot declare a namespace-owned service')
|
|
403
|
+
}
|
|
404
|
+
const manifest: FabrikaManifest = {
|
|
405
|
+
manifestVersion: FABRIKA_MANIFEST_VERSION,
|
|
406
|
+
app: {
|
|
407
|
+
id: config.id,
|
|
408
|
+
env,
|
|
409
|
+
...(config.schema !== undefined ? { schema: config.schema } : {}),
|
|
410
|
+
pipeline: {
|
|
411
|
+
secrets: [...(config.pipeline?.secrets ?? [])],
|
|
412
|
+
vars: [...declaredVars],
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
target: {
|
|
416
|
+
platform: 'zerops',
|
|
417
|
+
importDocument: parseImportDocument(document),
|
|
418
|
+
deployService,
|
|
419
|
+
...(config.target.zeropsSetup !== undefined ? { zeropsSetup: config.target.zeropsSetup } : {}),
|
|
420
|
+
...(config.target.proxy !== undefined ? { proxy: config.target.proxy } : {}),
|
|
421
|
+
...(config.target.namespaceResources !== undefined ? { namespaceResources: [...config.target.namespaceResources] } : {}),
|
|
422
|
+
},
|
|
423
|
+
}
|
|
424
|
+
return parseFabrikaManifest(manifest, { appId: config.id, env })
|
|
425
|
+
} finally {
|
|
426
|
+
for (const name of declaredVars) {
|
|
427
|
+
const value = prior[name]
|
|
428
|
+
if (value === undefined) {
|
|
429
|
+
delete process.env[name]
|
|
430
|
+
} else {
|
|
431
|
+
process.env[name] = value
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Provider artifact codec. Its envelope version and manifest payload version advance independently. */
|
|
438
|
+
export const zeropsArtifactCodec: ProviderCodec<FabrikaManifest> = {
|
|
439
|
+
version: 2,
|
|
440
|
+
encode: jsonValue,
|
|
441
|
+
decode: (payload) => parseFabrikaManifest(payload),
|
|
442
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { JsonValue, ProviderDeploymentNamespace, ProviderNamespacePlan } from '@fabrika/provider-contract'
|
|
2
|
+
import type { ZeropsCliArgs } from './cli-args'
|
|
3
|
+
import { createZeropsNamespaceOperator, type ZeropsNamespacePostgres, zeropsNamespaceTargetCodec } from './namespace'
|
|
4
|
+
|
|
5
|
+
export interface ZeropsNamespaceCommandDependencies {
|
|
6
|
+
readonly source: Readonly<Record<string, string | undefined>>
|
|
7
|
+
readonly fetch: (input: string | URL | Request, init?: RequestInit) => Promise<Response>
|
|
8
|
+
readonly write: (value: string) => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const required = (value: string | undefined, label: string): string => {
|
|
12
|
+
if (value === undefined || value.trim() === '') {
|
|
13
|
+
throw new Error(`${label} is required`)
|
|
14
|
+
}
|
|
15
|
+
return value.trim()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const postgres = (args: ZeropsCliArgs): ZeropsNamespacePostgres | undefined => {
|
|
19
|
+
const type = args.postgresType
|
|
20
|
+
const profile = args.postgresProfile
|
|
21
|
+
if (type === undefined && profile === undefined) return undefined
|
|
22
|
+
if (type === undefined) {
|
|
23
|
+
throw new Error('--postgres-type is required with --postgres-profile')
|
|
24
|
+
}
|
|
25
|
+
if (type === 'postgresql:ha@18') {
|
|
26
|
+
if (
|
|
27
|
+
profile !== undefined
|
|
28
|
+
&& profile !== 'oltp-staging'
|
|
29
|
+
&& profile !== 'oltp-production'
|
|
30
|
+
&& profile !== 'oltp-enterprise'
|
|
31
|
+
&& profile !== 'olap-production'
|
|
32
|
+
&& profile !== 'writeheavy-production'
|
|
33
|
+
) {
|
|
34
|
+
throw new Error(`--postgres-profile ${profile} is not supported by ${type}`)
|
|
35
|
+
}
|
|
36
|
+
return profile === undefined ? { type } : { type, profile }
|
|
37
|
+
}
|
|
38
|
+
if (type === 'postgresql:single@18') {
|
|
39
|
+
if (
|
|
40
|
+
profile !== undefined
|
|
41
|
+
&& profile !== 'oltp-hobby'
|
|
42
|
+
&& profile !== 'oltp-staging'
|
|
43
|
+
&& profile !== 'oltp-production'
|
|
44
|
+
&& profile !== 'olap-production'
|
|
45
|
+
&& profile !== 'writeheavy-production'
|
|
46
|
+
) {
|
|
47
|
+
throw new Error(`--postgres-profile ${profile} is not supported by ${type}`)
|
|
48
|
+
}
|
|
49
|
+
return profile === undefined ? { type } : { type, profile }
|
|
50
|
+
}
|
|
51
|
+
throw new Error('--postgres-type must be postgresql:ha@18 or postgresql:single@18')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const planOptions = (args: ZeropsCliArgs): { [key: string]: JsonValue } | undefined => {
|
|
55
|
+
const values: { [key: string]: JsonValue } = {}
|
|
56
|
+
if (args.projectName !== undefined) values['projectName'] = required(args.projectName, '--project-name')
|
|
57
|
+
if (args.corePackage !== undefined) values['corePackage'] = required(args.corePackage, '--core-package')
|
|
58
|
+
if (args.publicAccess !== undefined) values['publicAccess'] = required(args.publicAccess, '--public-access')
|
|
59
|
+
const database = postgres(args)
|
|
60
|
+
if (database !== undefined) {
|
|
61
|
+
values['postgres'] = database.profile === undefined ? { type: database.type } : { type: database.type, profile: database.profile }
|
|
62
|
+
}
|
|
63
|
+
return Object.keys(values).length === 0 ? undefined : values
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const namespacePlan = (
|
|
67
|
+
args: ZeropsCliArgs,
|
|
68
|
+
source: Readonly<Record<string, string | undefined>>,
|
|
69
|
+
): ProviderNamespacePlan => {
|
|
70
|
+
// `FABRIKA_ZEROPS_*` because Zerops reserves the bare `ZEROPS_` prefix and refuses to store a custom
|
|
71
|
+
// variable using it (`userDataZeropsPrefixForbidden`).
|
|
72
|
+
const proxyBuildFromGit = required(
|
|
73
|
+
args.proxyBuildFromGit ?? source['FABRIKA_ZEROPS_PROXY_BUILD_FROM_GIT'],
|
|
74
|
+
'--proxy-build-from-git or FABRIKA_ZEROPS_PROXY_BUILD_FROM_GIT',
|
|
75
|
+
)
|
|
76
|
+
const operator = createZeropsNamespaceOperator({ proxyBuildFromGit })
|
|
77
|
+
const options = planOptions(args)
|
|
78
|
+
return operator.plan({
|
|
79
|
+
id: required(args.namespaceId, '--id'),
|
|
80
|
+
env: required(args.env, '--env'),
|
|
81
|
+
preset: required(args.preset, '--preset'),
|
|
82
|
+
...(args.exclusiveAppId === undefined ? {} : { exclusiveAppId: required(args.exclusiveAppId, '--exclusive-app') }),
|
|
83
|
+
...(options === undefined ? {} : { options }),
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const outputJson = (value: unknown): string => `${JSON.stringify(value, null, '\t')}\n`
|
|
88
|
+
|
|
89
|
+
const responseText = async (response: Response): Promise<string> => {
|
|
90
|
+
const text = await response.text()
|
|
91
|
+
if (response.ok) return text.trim() === '' ? 'null' : text
|
|
92
|
+
let message = `Control API request failed (${response.status})`
|
|
93
|
+
try {
|
|
94
|
+
const body: unknown = JSON.parse(text)
|
|
95
|
+
if (typeof body === 'object' && body !== null) {
|
|
96
|
+
const error = Reflect.get(body, 'error')
|
|
97
|
+
if (typeof error === 'string' && error !== '') message = error
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
if (text.trim() !== '' && text.length < 500) message = text
|
|
101
|
+
}
|
|
102
|
+
throw new Error(message)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const controlCoordinates = (
|
|
106
|
+
args: ZeropsCliArgs,
|
|
107
|
+
source: Readonly<Record<string, string | undefined>>,
|
|
108
|
+
): { baseUrl: URL; token: string } => {
|
|
109
|
+
const value = required(args.controlUrl ?? source['FABRIKA_CONTROL_URL'], '--control-url or FABRIKA_CONTROL_URL')
|
|
110
|
+
const token = required(source['FABRIKA_CONTROL_TOKEN'], 'FABRIKA_CONTROL_TOKEN')
|
|
111
|
+
let baseUrl: URL
|
|
112
|
+
try {
|
|
113
|
+
baseUrl = new URL(value)
|
|
114
|
+
} catch {
|
|
115
|
+
throw new Error('--control-url or FABRIKA_CONTROL_URL must be an absolute URL')
|
|
116
|
+
}
|
|
117
|
+
if (baseUrl.protocol !== 'https:' && baseUrl.protocol !== 'http:') {
|
|
118
|
+
throw new Error('--control-url or FABRIKA_CONTROL_URL must use http or https')
|
|
119
|
+
}
|
|
120
|
+
return { baseUrl, token }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const callControl = async (
|
|
124
|
+
deps: ZeropsNamespaceCommandDependencies,
|
|
125
|
+
coordinates: { baseUrl: URL; token: string },
|
|
126
|
+
path: string,
|
|
127
|
+
body?: unknown,
|
|
128
|
+
): Promise<void> => {
|
|
129
|
+
const response = await deps.fetch(new URL(path, coordinates.baseUrl), {
|
|
130
|
+
method: 'POST',
|
|
131
|
+
headers: {
|
|
132
|
+
accept: 'application/json',
|
|
133
|
+
authorization: `Bearer ${coordinates.token}`,
|
|
134
|
+
...(body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
135
|
+
},
|
|
136
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
137
|
+
})
|
|
138
|
+
const text = await responseText(response)
|
|
139
|
+
let parsed: unknown
|
|
140
|
+
try {
|
|
141
|
+
parsed = JSON.parse(text)
|
|
142
|
+
} catch {
|
|
143
|
+
parsed = text
|
|
144
|
+
}
|
|
145
|
+
deps.write(outputJson(parsed))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const createBody = (namespace: ProviderDeploymentNamespace): object => ({
|
|
149
|
+
id: namespace.id,
|
|
150
|
+
env: namespace.env,
|
|
151
|
+
...(namespace.exclusiveAppId === undefined ? {} : { exclusiveAppId: namespace.exclusiveAppId }),
|
|
152
|
+
target: namespace.target,
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
const adoptBody = (namespace: ProviderDeploymentNamespace): object => ({
|
|
156
|
+
env: namespace.env,
|
|
157
|
+
...(namespace.exclusiveAppId === undefined ? {} : { exclusiveAppId: namespace.exclusiveAppId }),
|
|
158
|
+
target: namespace.target,
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
/** Execute one namespace command. All command inputs are validated before the first fetch. */
|
|
162
|
+
export const runZeropsNamespaceCommand = async (
|
|
163
|
+
args: ZeropsCliArgs,
|
|
164
|
+
deps: ZeropsNamespaceCommandDependencies,
|
|
165
|
+
): Promise<void> => {
|
|
166
|
+
const subcommand = required(args.subcommand, 'namespace command')
|
|
167
|
+
if (subcommand === 'plan') {
|
|
168
|
+
deps.write(outputJson(namespacePlan(args, deps.source)))
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
if (subcommand === 'reconcile') {
|
|
172
|
+
const id = required(args.namespaceId, '--id')
|
|
173
|
+
const control = controlCoordinates(args, deps.source)
|
|
174
|
+
await callControl(deps, control, `/api/namespaces/${encodeURIComponent(id)}/reconcile`)
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
if (subcommand !== 'create' && subcommand !== 'adopt') {
|
|
178
|
+
throw new Error(`Unknown namespace command: ${subcommand}`)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const planned = namespacePlan(args, deps.source)
|
|
182
|
+
const control = controlCoordinates(args, deps.source)
|
|
183
|
+
if (subcommand === 'create') {
|
|
184
|
+
await callControl(deps, control, '/api/namespaces', createBody(planned.namespace))
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const projectId = required(args.projectId, '--project-id')
|
|
189
|
+
const decoded = zeropsNamespaceTargetCodec.decode(planned.namespace.target.payload)
|
|
190
|
+
const namespace: ProviderDeploymentNamespace = {
|
|
191
|
+
...planned.namespace,
|
|
192
|
+
target: {
|
|
193
|
+
provider: 'zerops',
|
|
194
|
+
version: zeropsNamespaceTargetCodec.version,
|
|
195
|
+
payload: zeropsNamespaceTargetCodec.encode({
|
|
196
|
+
...decoded,
|
|
197
|
+
managed: false,
|
|
198
|
+
projectId,
|
|
199
|
+
ready: false,
|
|
200
|
+
}),
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
await callControl(
|
|
204
|
+
deps,
|
|
205
|
+
control,
|
|
206
|
+
`/api/namespaces/${encodeURIComponent(namespace.id)}/adopt`,
|
|
207
|
+
adoptBody(namespace),
|
|
208
|
+
)
|
|
209
|
+
}
|