@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.
@@ -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
+ }