@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,1028 @@
1
+ import type {
2
+ JsonValue,
3
+ ProviderCodec,
4
+ ProviderDeploymentNamespace,
5
+ ProviderEnvelope,
6
+ ProviderNamespaceCapabilities,
7
+ ProviderNamespaceMutationInput,
8
+ ProviderNamespaceOperator,
9
+ ProviderNamespacePlanInput,
10
+ ProviderNamespacePresentation,
11
+ ProviderNamespacePreset,
12
+ ProviderRegistration,
13
+ } from '@fabrika/provider-contract'
14
+ import { encodeProxyManifestJson, FABRIKA_PROXY_MANIFEST_JSON } from '@fabrika/proxy-contract'
15
+ import {
16
+ ZEROPS_ACTIVE,
17
+ ZEROPS_SERVICE_NOT_HTTP,
18
+ ZEROPS_TERMINAL,
19
+ type ZeropsApi,
20
+ ZeropsApiError,
21
+ type ZeropsAppVersion,
22
+ type ZeropsProject,
23
+ type ZeropsProjectMode,
24
+ type ZeropsService,
25
+ } from './api'
26
+ import { defaultSleep, type Sleeper } from './collaborators'
27
+ import { compileImportYaml, compileProvisioningYaml } from './compile'
28
+ import { manifestServiceHostnames, parseFabrikaManifest, zeropsArtifactCodec } from './manifest'
29
+ import { zeropsSharedServicePrefix } from './service-names'
30
+ import type { ZeropsServiceSpec, ZeropsSourceTarget } from './types'
31
+
32
+ export const ZEROPS_NAMESPACE_PROXY_HOSTNAME = 'proxy'
33
+ export const ZEROPS_NAMESPACE_POSTGRES_HOSTNAME = 'postgres'
34
+ export const ZEROPS_NAMESPACE_IAM_URL_VARIABLE = 'FABRIKA_IAM_URL'
35
+ export const ZEROPS_NAMESPACE_IAM_KEY_VARIABLE = 'FABRIKA_IAM_KEY'
36
+ /**
37
+ * How an app reaches the namespace-owned PostgreSQL service — database and TLS mode NAMED, not defaulted.
38
+ *
39
+ * Three facts settled against a live account, none of them safe to infer:
40
+ *
41
+ * • `${x_connectionString}` is `postgresql://${user}:${password}@${hostname}:${port}` — no database
42
+ * path. With none, the libpq/postgres.js convention falls back to the USER name.
43
+ * • `dbName` and `user` are both literally `db` on EVERY PostgreSQL service, whatever its hostname
44
+ * (checked on a service named `wu2db`). So the fallback lands right today — by construction rather
45
+ * than by luck, but still by a rule nobody wrote down. `/${x_dbName}` says it out loud.
46
+ * • Port 5432 speaks TLS. `sslmode=require` connects with `pg_stat_ssl.ssl = true`; `sslmode=disable`
47
+ * connects in the clear. `verify-full` fails (`self signed certificate`), so `require` is the
48
+ * strongest mode available and the one worth pinning — the alternative is whatever the driver
49
+ * happens to default to.
50
+ */
51
+ export const ZEROPS_SHARED_POSTGRES_CONNECTION_STRING = '${postgres_connectionString}/${postgres_dbName}?sslmode=require'
52
+
53
+ const PROXY_TYPE = 'alpine@3.21'
54
+ const EMPTY_PROXY_MANIFEST = encodeProxyManifestJson({ apps: [] })
55
+ const POLL_INTERVAL_MS = 3000
56
+ const POLL_TIMEOUT_MS = 70 * 60 * 1000
57
+ /** The live enable finishes in under a second, but one run still read `false` once before flipping; ten reads is slack. */
58
+ const SUBDOMAIN_READBACK_ATTEMPTS = 10
59
+
60
+ export type ZeropsNamespacePublicAccess = 'custom-domain' | 'zerops-subdomain'
61
+
62
+ export type ZeropsNamespacePostgres =
63
+ | {
64
+ readonly type: 'postgresql:ha@18'
65
+ readonly profile?: 'oltp-staging' | 'oltp-production' | 'oltp-enterprise' | 'olap-production' | 'writeheavy-production'
66
+ }
67
+ | {
68
+ readonly type: 'postgresql:single@18'
69
+ readonly profile?: 'oltp-hobby' | 'oltp-staging' | 'oltp-production' | 'olap-production' | 'writeheavy-production'
70
+ }
71
+
72
+ /**
73
+ * Provider-owned namespace state. Optional fields are durable progress checkpoints; values that are
74
+ * absent are resolved by `normalize()` before a mutation starts.
75
+ */
76
+ export interface ZeropsNamespaceTarget {
77
+ readonly projectName?: string
78
+ readonly corePackage?: ZeropsProjectMode
79
+ readonly publicAccess?: ZeropsNamespacePublicAccess
80
+ readonly proxyBuildFromGit?: string
81
+ readonly postgres?: ZeropsNamespacePostgres
82
+ readonly managed?: boolean
83
+ readonly projectId?: string
84
+ readonly proxyServiceId?: string
85
+ readonly postgresServiceId?: string
86
+ readonly proxyConfigured?: boolean
87
+ readonly proxyBaselineSequence?: number
88
+ readonly proxyAppVersionId?: string
89
+ readonly ready?: boolean
90
+ }
91
+
92
+ interface NormalizedZeropsNamespaceTarget extends ZeropsNamespaceTarget {
93
+ readonly projectName: string
94
+ readonly corePackage: ZeropsProjectMode
95
+ readonly publicAccess: ZeropsNamespacePublicAccess
96
+ readonly proxyBuildFromGit: string
97
+ readonly managed: boolean
98
+ readonly ready: boolean
99
+ }
100
+
101
+ export interface ZeropsNamespaceOptions {
102
+ readonly clientId: string
103
+ readonly proxyBuildFromGit: string
104
+ /** Public IAM origin reachable from an app project. */
105
+ readonly iamUrl: string
106
+ /** Same credential IAM receives as `FABRIKA_IAM_PROXY_KEY`. */
107
+ readonly iamKey: string
108
+ readonly api: ZeropsApi
109
+ readonly sleep?: Sleeper
110
+ }
111
+
112
+ export interface ZeropsNamespaceTopology {
113
+ readonly source: ZeropsSourceTarget
114
+ readonly createYaml: string
115
+ readonly servicesProvisionYaml: string
116
+ readonly servicesSteadyYaml: string
117
+ }
118
+
119
+ export interface ZeropsNamespacePresetInput {
120
+ readonly preset: 'cheap' | 'mid' | 'full'
121
+ readonly env: string
122
+ readonly projectName: string
123
+ readonly corePackage?: ZeropsProjectMode
124
+ readonly publicAccess?: ZeropsNamespacePublicAccess
125
+ readonly proxyBuildFromGit: string
126
+ readonly postgres?: ZeropsNamespacePostgres
127
+ }
128
+
129
+ export interface ZeropsNamespaceOperatorOptions {
130
+ readonly proxyBuildFromGit: string
131
+ }
132
+
133
+ export interface ZeropsNamespacePlanOptions {
134
+ readonly projectName?: string
135
+ readonly corePackage?: ZeropsProjectMode
136
+ readonly publicAccess?: ZeropsNamespacePublicAccess
137
+ readonly postgres?: ZeropsNamespacePostgres
138
+ }
139
+
140
+ const property = (payload: JsonValue, key: string): JsonValue | undefined => {
141
+ if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
142
+ throw new Error('Zerops namespace target must be an object')
143
+ }
144
+ return payload[key]
145
+ }
146
+
147
+ const optionalString = (payload: JsonValue, key: string): string | undefined => {
148
+ const value = property(payload, key)
149
+ if (value === undefined) return undefined
150
+ if (typeof value !== 'string' || value === '') {
151
+ throw new Error(`Zerops namespace target ${key} must be a non-empty string`)
152
+ }
153
+ return value
154
+ }
155
+
156
+ const optionalBoolean = (payload: JsonValue, key: string): boolean | undefined => {
157
+ const value = property(payload, key)
158
+ if (value === undefined) return undefined
159
+ if (typeof value !== 'boolean') {
160
+ throw new Error(`Zerops namespace target ${key} must be a boolean`)
161
+ }
162
+ return value
163
+ }
164
+
165
+ const optionalInteger = (payload: JsonValue, key: string): number | undefined => {
166
+ const value = property(payload, key)
167
+ if (value === undefined) return undefined
168
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < -1) {
169
+ throw new Error(`Zerops namespace target ${key} must be an integer greater than or equal to -1`)
170
+ }
171
+ return value
172
+ }
173
+
174
+ const postgresProfiles = {
175
+ 'postgresql:ha@18': new Set(['oltp-staging', 'oltp-production', 'oltp-enterprise', 'olap-production', 'writeheavy-production']),
176
+ 'postgresql:single@18': new Set(['oltp-hobby', 'oltp-staging', 'oltp-production', 'olap-production', 'writeheavy-production']),
177
+ }
178
+
179
+ const parsePostgres = (payload: JsonValue): ZeropsNamespacePostgres | undefined => {
180
+ const value = property(payload, 'postgres')
181
+ if (value === undefined) return undefined
182
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
183
+ throw new Error('Zerops namespace target postgres must be an object')
184
+ }
185
+ const type = property(value, 'type')
186
+ const profile = property(value, 'profile')
187
+ if (type !== 'postgresql:ha@18' && type !== 'postgresql:single@18') {
188
+ throw new Error('Zerops namespace PostgreSQL type must be postgresql:ha@18 or postgresql:single@18')
189
+ }
190
+ if (profile !== undefined && (typeof profile !== 'string' || !postgresProfiles[type].has(profile))) {
191
+ throw new Error(`Zerops namespace PostgreSQL profile is not supported by ${type}`)
192
+ }
193
+ if (type === 'postgresql:ha@18') {
194
+ if (
195
+ profile !== undefined
196
+ && profile !== 'oltp-staging'
197
+ && profile !== 'oltp-production'
198
+ && profile !== 'oltp-enterprise'
199
+ && profile !== 'olap-production'
200
+ && profile !== 'writeheavy-production'
201
+ ) {
202
+ throw new Error(`Zerops namespace PostgreSQL profile is not supported by ${type}`)
203
+ }
204
+ return profile === undefined ? { type } : { type, profile }
205
+ }
206
+ if (
207
+ profile !== undefined
208
+ && profile !== 'oltp-hobby'
209
+ && profile !== 'oltp-staging'
210
+ && profile !== 'oltp-production'
211
+ && profile !== 'olap-production'
212
+ && profile !== 'writeheavy-production'
213
+ ) {
214
+ throw new Error(`Zerops namespace PostgreSQL profile is not supported by ${type}`)
215
+ }
216
+ return profile === undefined ? { type } : { type, profile }
217
+ }
218
+
219
+ export const zeropsNamespaceTargetCodec: ProviderCodec<ZeropsNamespaceTarget> = {
220
+ version: 1,
221
+ encode: (target) => ({
222
+ ...(target.projectName !== undefined ? { projectName: target.projectName } : {}),
223
+ ...(target.corePackage !== undefined ? { corePackage: target.corePackage } : {}),
224
+ ...(target.publicAccess !== undefined ? { publicAccess: target.publicAccess } : {}),
225
+ ...(target.proxyBuildFromGit !== undefined ? { proxyBuildFromGit: target.proxyBuildFromGit } : {}),
226
+ ...(target.postgres !== undefined
227
+ ? { postgres: { type: target.postgres.type, ...(target.postgres.profile !== undefined ? { profile: target.postgres.profile } : {}) } }
228
+ : {}),
229
+ ...(target.managed !== undefined ? { managed: target.managed } : {}),
230
+ ...(target.projectId !== undefined ? { projectId: target.projectId } : {}),
231
+ ...(target.proxyServiceId !== undefined ? { proxyServiceId: target.proxyServiceId } : {}),
232
+ ...(target.postgresServiceId !== undefined ? { postgresServiceId: target.postgresServiceId } : {}),
233
+ ...(target.proxyConfigured !== undefined ? { proxyConfigured: target.proxyConfigured } : {}),
234
+ ...(target.proxyBaselineSequence !== undefined ? { proxyBaselineSequence: target.proxyBaselineSequence } : {}),
235
+ ...(target.proxyAppVersionId !== undefined ? { proxyAppVersionId: target.proxyAppVersionId } : {}),
236
+ ...(target.ready !== undefined ? { ready: target.ready } : {}),
237
+ }),
238
+ decode: (payload) => {
239
+ const corePackage = optionalString(payload, 'corePackage')
240
+ if (corePackage !== undefined && corePackage !== 'LIGHT' && corePackage !== 'SERIOUS') {
241
+ throw new Error('Zerops namespace target corePackage must be LIGHT or SERIOUS')
242
+ }
243
+ const publicAccess = optionalString(payload, 'publicAccess')
244
+ if (publicAccess !== undefined && publicAccess !== 'custom-domain' && publicAccess !== 'zerops-subdomain') {
245
+ throw new Error('Zerops namespace target publicAccess is not supported')
246
+ }
247
+ return {
248
+ ...(optionalString(payload, 'projectName') !== undefined ? { projectName: optionalString(payload, 'projectName') } : {}),
249
+ ...(corePackage !== undefined ? { corePackage } : {}),
250
+ ...(publicAccess !== undefined ? { publicAccess } : {}),
251
+ ...(optionalString(payload, 'proxyBuildFromGit') !== undefined
252
+ ? { proxyBuildFromGit: optionalString(payload, 'proxyBuildFromGit') }
253
+ : {}),
254
+ ...(parsePostgres(payload) !== undefined ? { postgres: parsePostgres(payload) } : {}),
255
+ ...(optionalBoolean(payload, 'managed') !== undefined ? { managed: optionalBoolean(payload, 'managed') } : {}),
256
+ ...(optionalString(payload, 'projectId') !== undefined ? { projectId: optionalString(payload, 'projectId') } : {}),
257
+ ...(optionalString(payload, 'proxyServiceId') !== undefined ? { proxyServiceId: optionalString(payload, 'proxyServiceId') } : {}),
258
+ ...(optionalString(payload, 'postgresServiceId') !== undefined
259
+ ? { postgresServiceId: optionalString(payload, 'postgresServiceId') }
260
+ : {}),
261
+ ...(optionalBoolean(payload, 'proxyConfigured') !== undefined
262
+ ? { proxyConfigured: optionalBoolean(payload, 'proxyConfigured') }
263
+ : {}),
264
+ ...(optionalInteger(payload, 'proxyBaselineSequence') !== undefined
265
+ ? { proxyBaselineSequence: optionalInteger(payload, 'proxyBaselineSequence') }
266
+ : {}),
267
+ ...(optionalString(payload, 'proxyAppVersionId') !== undefined
268
+ ? { proxyAppVersionId: optionalString(payload, 'proxyAppVersionId') }
269
+ : {}),
270
+ ...(optionalBoolean(payload, 'ready') !== undefined ? { ready: optionalBoolean(payload, 'ready') } : {}),
271
+ }
272
+ },
273
+ }
274
+
275
+ const envelope = (target: ZeropsNamespaceTarget): ProviderEnvelope => ({
276
+ provider: 'zerops',
277
+ version: zeropsNamespaceTargetCodec.version,
278
+ payload: zeropsNamespaceTargetCodec.encode(target),
279
+ })
280
+
281
+ const decodeEnvelope = (value: ProviderEnvelope): ZeropsNamespaceTarget => {
282
+ if (value.provider !== 'zerops') {
283
+ throw new Error(`namespace belongs to provider "${value.provider}", expected "zerops"`)
284
+ }
285
+ if (value.version !== zeropsNamespaceTargetCodec.version) {
286
+ throw new Error(`namespace schema version ${value.version} is not supported by provider "zerops"`)
287
+ }
288
+ return zeropsNamespaceTargetCodec.decode(value.payload)
289
+ }
290
+
291
+ const namespaceResourceClaims = (namespace: ProviderDeploymentNamespace): string[] => {
292
+ const target = decodeEnvelope(namespace.target)
293
+ return [
294
+ `service:${ZEROPS_NAMESPACE_PROXY_HOSTNAME}`,
295
+ ...(target.postgres === undefined ? [] : [`service:${ZEROPS_NAMESPACE_POSTGRES_HOSTNAME}`]),
296
+ ]
297
+ }
298
+
299
+ const registrationResourceClaims = (registration: ProviderRegistration): string[] => {
300
+ const namespace = registration.environment.namespace
301
+ if (namespace === undefined) {
302
+ throw new Error('Zerops registration requires a deployment namespace')
303
+ }
304
+ if (
305
+ namespace.exclusiveAppId !== undefined
306
+ && namespace.exclusiveAppId !== registration.app.id
307
+ ) {
308
+ throw new Error(`Zerops namespace is exclusive to app \`${namespace.exclusiveAppId}\``)
309
+ }
310
+ const artifactEnvelope = registration.environment.artifact
311
+ if (artifactEnvelope.provider !== 'zerops' || artifactEnvelope.version !== zeropsArtifactCodec.version) {
312
+ throw new Error('Zerops registration artifact envelope is not supported')
313
+ }
314
+ const manifest = parseFabrikaManifest(zeropsArtifactCodec.decode(artifactEnvelope.payload), {
315
+ appId: registration.app.id,
316
+ env: registration.environment.env,
317
+ })
318
+ const namespaceClaims = new Set(namespaceResourceClaims(namespace))
319
+ for (const requirement of manifest.target.namespaceResources ?? []) {
320
+ if (!namespaceClaims.has(requirement.resourceKey)) {
321
+ throw new Error(`Zerops namespace does not provide required resource \`${requirement.resourceKey}\``)
322
+ }
323
+ }
324
+ const hostnames = manifestServiceHostnames(manifest)
325
+ for (const hostname of hostnames) {
326
+ const key = `service:${hostname}`
327
+ if (namespaceClaims.has(key)) {
328
+ throw new Error(`Zerops service hostname \`${hostname}\` is reserved by the namespace`)
329
+ }
330
+ }
331
+ if (namespace.exclusiveAppId === undefined) {
332
+ const prefix = zeropsSharedServicePrefix(registration.app.id)
333
+ for (const hostname of hostnames) {
334
+ if (!hostname.startsWith(prefix) || hostname.length === prefix.length) {
335
+ throw new Error(`Zerops shared namespace service \`${hostname}\` must use app prefix \`${prefix}\``)
336
+ }
337
+ }
338
+ }
339
+ return hostnames.map((hostname) => `service:${hostname}`).sort()
340
+ }
341
+
342
+ /**
343
+ * The namespace-owned PostgreSQL service, sized rather than defaulted.
344
+ *
345
+ * Omitting `profile` is not neutral: an HA service silently gets `oltp-production` (two DEDICATED cores
346
+ * and 4 GB per container, three containers) and a single one gets `oltp-staging`. Both verified by
347
+ * reading the profile back off a live service. Production takes the production preset deliberately;
348
+ * everything else takes the cheapest one its type offers, because a namespace database for a staging
349
+ * app should cost what staging is worth.
350
+ */
351
+ const defaultPostgres = (env: string): ZeropsNamespacePostgres =>
352
+ env === 'prod' ? { type: 'postgresql:ha@18', profile: 'oltp-production' } : { type: 'postgresql:single@18', profile: 'oltp-hobby' }
353
+
354
+ export const zeropsNamespacePreset = (input: ZeropsNamespacePresetInput): ZeropsNamespaceTarget => ({
355
+ projectName: input.projectName,
356
+ corePackage: input.corePackage ?? (input.env === 'prod' ? 'SERIOUS' : 'LIGHT'),
357
+ publicAccess: input.publicAccess ?? 'custom-domain',
358
+ proxyBuildFromGit: input.proxyBuildFromGit,
359
+ managed: true,
360
+ ready: false,
361
+ ...(input.preset === 'cheap' ? { postgres: input.postgres ?? defaultPostgres(input.env) } : {}),
362
+ })
363
+
364
+ const projectMarker = (namespace: ProviderDeploymentNamespace): string => `Managed by Fabrika namespace ${namespace.id} (${namespace.env}).`
365
+
366
+ // No `zeropsSetup`: the platform reads it as pipeline configuration and rejects the whole import unless
367
+ // the service also names a `buildFromGit`, which an import that only PROVISIONS does not. The setup name
368
+ // is supplied where it is actually needed — the `triggerPipeline` call in the namespace lifecycle below,
369
+ // which passes both. `assertZeropsInvariants` refuses the invalid combination.
370
+ //
371
+ // `enableSubdomainAccess` is a DECLARATION here and not a mechanism: the platform accepts it and drops it,
372
+ // including on the service this document creates. `ensureSubdomainAccess` below is what establishes the
373
+ // subdomain, after the proxy's first deploy. Written anyway because it is what ADR-0007's
374
+ // `assertOnlyPublicService` reads to prove no other service is claimed public.
375
+ const proxyService = (publicAccess: ZeropsNamespacePublicAccess): ZeropsServiceSpec => ({
376
+ hostname: ZEROPS_NAMESPACE_PROXY_HOSTNAME,
377
+ type: PROXY_TYPE,
378
+ priority: 10,
379
+ enableSubdomainAccess: publicAccess === 'zerops-subdomain',
380
+ minContainers: 2,
381
+ maxContainers: 6,
382
+ })
383
+
384
+ const postgresService = (postgres: ZeropsNamespacePostgres): ZeropsServiceSpec => ({
385
+ hostname: ZEROPS_NAMESPACE_POSTGRES_HOSTNAME,
386
+ type: postgres.type,
387
+ priority: 100,
388
+ ...(postgres.profile !== undefined ? { profile: postgres.profile } : {}),
389
+ })
390
+
391
+ /** Compile the one project and its namespace-owned services without depending on private deploy code. */
392
+ export const compileZeropsNamespaceTopology = (
393
+ namespace: ProviderDeploymentNamespace,
394
+ target: ZeropsNamespaceTarget,
395
+ ): ZeropsNamespaceTopology => {
396
+ const projectName = target.projectName
397
+ const corePackage = target.corePackage
398
+ const publicAccess = target.publicAccess
399
+ if (projectName === undefined || corePackage === undefined || publicAccess === undefined) {
400
+ throw new Error('Zerops namespace topology requires normalized project policy')
401
+ }
402
+ const services = (): ZeropsServiceSpec[] => [
403
+ ...(target.postgres === undefined ? [] : [postgresService(target.postgres)]),
404
+ proxyService(publicAccess),
405
+ ]
406
+ const source: ZeropsSourceTarget = {
407
+ platform: 'zerops',
408
+ project: {
409
+ name: projectName,
410
+ description: projectMarker(namespace),
411
+ corePackage,
412
+ tags: ['fabrika', 'namespace', namespace.id, namespace.env],
413
+ },
414
+ services,
415
+ }
416
+ const serviceSource: ZeropsSourceTarget = { platform: 'zerops', services }
417
+ return {
418
+ source,
419
+ createYaml: compileProvisioningYaml({ target: source, ctx: { env: namespace.env } }).yaml,
420
+ servicesProvisionYaml: compileProvisioningYaml({ target: serviceSource, ctx: { env: namespace.env } }).yaml,
421
+ servicesSteadyYaml: compileImportYaml({ target: serviceSource, ctx: { env: namespace.env } }).yaml,
422
+ }
423
+ }
424
+
425
+ const ZEROPS_NAMESPACE_PRESETS: readonly ProviderNamespacePreset[] = [
426
+ {
427
+ id: 'cheap',
428
+ label: 'Cheap',
429
+ description: 'Several apps share one project, proxy, and namespace-owned PostgreSQL service.',
430
+ requiresExclusiveApp: false,
431
+ },
432
+ {
433
+ id: 'mid',
434
+ label: 'Mid',
435
+ description: 'Several apps share one project and proxy; each app owns its prefixed database services.',
436
+ requiresExclusiveApp: false,
437
+ },
438
+ {
439
+ id: 'full',
440
+ label: 'Full',
441
+ description: 'One project and proxy are reserved for exactly one app.',
442
+ requiresExclusiveApp: true,
443
+ },
444
+ ]
445
+
446
+ const parsePlanOptions = (value: JsonValue | undefined): ZeropsNamespacePlanOptions => {
447
+ if (value === undefined) return {}
448
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
449
+ throw new Error('Zerops namespace plan options must be an object')
450
+ }
451
+ const supported = new Set(['projectName', 'corePackage', 'publicAccess', 'postgres'])
452
+ for (const key of Object.keys(value)) {
453
+ if (!supported.has(key)) {
454
+ throw new Error(`Zerops namespace plan option ${key} is not supported`)
455
+ }
456
+ }
457
+ const projectName = optionalString(value, 'projectName')
458
+ const corePackage = optionalString(value, 'corePackage')
459
+ if (corePackage !== undefined && corePackage !== 'LIGHT' && corePackage !== 'SERIOUS') {
460
+ throw new Error('Zerops namespace plan corePackage must be LIGHT or SERIOUS')
461
+ }
462
+ const publicAccess = optionalString(value, 'publicAccess')
463
+ if (publicAccess !== undefined && publicAccess !== 'custom-domain' && publicAccess !== 'zerops-subdomain') {
464
+ throw new Error('Zerops namespace plan publicAccess is not supported')
465
+ }
466
+ const postgres = parsePostgres(value)
467
+ return {
468
+ ...(projectName === undefined ? {} : { projectName }),
469
+ ...(corePackage === undefined ? {} : { corePackage }),
470
+ ...(publicAccess === undefined ? {} : { publicAccess }),
471
+ ...(postgres === undefined ? {} : { postgres }),
472
+ }
473
+ }
474
+
475
+ const namespacePresentation = (
476
+ namespace: ProviderDeploymentNamespace,
477
+ target: ZeropsNamespaceTarget,
478
+ ): ProviderNamespacePresentation => {
479
+ const preset = target.postgres !== undefined ? 'cheap' : namespace.exclusiveAppId === undefined ? 'mid' : 'full'
480
+ const projectName = target.projectName ?? namespace.id
481
+ const corePackage = target.corePackage ?? (namespace.env === 'prod' ? 'SERIOUS' : 'LIGHT')
482
+ const publicAccess = target.publicAccess ?? 'custom-domain'
483
+ const postgres = target.postgres === undefined
484
+ ? 'App-owned database services'
485
+ : `Shared ${target.postgres.type}${target.postgres.profile === undefined ? '' : ` (${target.postgres.profile})`}`
486
+ const instructions = [
487
+ ...(publicAccess === 'custom-domain'
488
+ ? [`Bind each application domain to the \`proxy\` service in Zerops project \`${projectName}\`; keep every app service private.`]
489
+ : ['Use the proxy Zerops subdomain only for non-production access; app services must remain private.']),
490
+ ...(target.postgres === undefined
491
+ ? []
492
+ : ['Every app consuming the shared PostgreSQL binding shares its physical service and provider-issued credential.']),
493
+ ]
494
+ return {
495
+ preset,
496
+ title: `${ZEROPS_NAMESPACE_PRESETS.find((candidate) => candidate.id === preset)?.label ?? preset} namespace`,
497
+ facts: [
498
+ { label: 'Project', value: projectName },
499
+ { label: 'Environment', value: namespace.env },
500
+ { label: 'Core package', value: corePackage },
501
+ { label: 'Placement', value: namespace.exclusiveAppId === undefined ? 'Shared' : `Exclusive to ${namespace.exclusiveAppId}` },
502
+ { label: 'PostgreSQL', value: postgres },
503
+ { label: 'Public access', value: publicAccess },
504
+ ],
505
+ instructions,
506
+ }
507
+ }
508
+
509
+ /** Build the provider-owned namespace planning surface without any network collaborators. */
510
+ export const createZeropsNamespaceOperator = (
511
+ options: ZeropsNamespaceOperatorOptions,
512
+ ): ProviderNamespaceOperator => {
513
+ if (options.proxyBuildFromGit.trim() === '') {
514
+ throw new Error('Zerops namespace proxyBuildFromGit must be a non-empty string')
515
+ }
516
+ return {
517
+ presets: ZEROPS_NAMESPACE_PRESETS,
518
+ plan(input: ProviderNamespacePlanInput) {
519
+ if (input.id.trim() === '' || input.env.trim() === '') {
520
+ throw new Error('Zerops namespace plan id and env must be non-empty')
521
+ }
522
+ if (input.preset !== 'cheap' && input.preset !== 'mid' && input.preset !== 'full') {
523
+ throw new Error(`Zerops namespace preset ${input.preset} is not supported`)
524
+ }
525
+ const exclusiveAppId = input.exclusiveAppId?.trim()
526
+ if (input.preset === 'full' && (exclusiveAppId === undefined || exclusiveAppId === '')) {
527
+ throw new Error('Zerops full namespace plan requires exclusiveAppId')
528
+ }
529
+ if (input.preset !== 'full' && exclusiveAppId !== undefined) {
530
+ throw new Error(`Zerops ${input.preset} namespace plan cannot reserve an exclusive app`)
531
+ }
532
+ const planOptions = parsePlanOptions(input.options)
533
+ if (input.preset !== 'cheap' && planOptions.postgres !== undefined) {
534
+ throw new Error(`Zerops ${input.preset} namespace plan cannot own PostgreSQL`)
535
+ }
536
+ const target = zeropsNamespacePreset({
537
+ preset: input.preset,
538
+ env: input.env,
539
+ projectName: planOptions.projectName ?? input.id,
540
+ proxyBuildFromGit: options.proxyBuildFromGit,
541
+ ...(planOptions.corePackage === undefined ? {} : { corePackage: planOptions.corePackage }),
542
+ ...(planOptions.publicAccess === undefined ? {} : { publicAccess: planOptions.publicAccess }),
543
+ ...(planOptions.postgres === undefined ? {} : { postgres: planOptions.postgres }),
544
+ })
545
+ const namespace: ProviderDeploymentNamespace = {
546
+ id: input.id,
547
+ env: input.env,
548
+ ...(exclusiveAppId === undefined ? {} : { exclusiveAppId }),
549
+ target: envelope(target),
550
+ }
551
+ return { namespace, presentation: namespacePresentation(namespace, target) }
552
+ },
553
+ present(namespace) {
554
+ return namespacePresentation(namespace, decodeEnvelope(namespace.target))
555
+ },
556
+ }
557
+ }
558
+
559
+ const normalizeTarget = (
560
+ namespace: ProviderDeploymentNamespace,
561
+ options: ZeropsNamespaceOptions,
562
+ ): NormalizedZeropsNamespaceTarget => {
563
+ if (namespace.id.trim() === '' || namespace.env.trim() === '') {
564
+ throw new Error('Zerops namespace id and env must be non-empty')
565
+ }
566
+ const decoded = decodeEnvelope(namespace.target)
567
+ const proxyBuildFromGit = decoded.proxyBuildFromGit ?? options.proxyBuildFromGit
568
+ if (proxyBuildFromGit.trim() === '') {
569
+ throw new Error('Zerops namespace proxy source must be a non-empty public Git URL')
570
+ }
571
+ return {
572
+ ...decoded,
573
+ projectName: decoded.projectName ?? namespace.id,
574
+ corePackage: decoded.corePackage ?? (namespace.env === 'prod' ? 'SERIOUS' : 'LIGHT'),
575
+ publicAccess: decoded.publicAccess ?? 'custom-domain',
576
+ proxyBuildFromGit,
577
+ managed: decoded.managed ?? decoded.projectId === undefined,
578
+ ready: decoded.ready ?? false,
579
+ }
580
+ }
581
+
582
+ const normalizedNamespace = (
583
+ namespace: ProviderDeploymentNamespace,
584
+ options: ZeropsNamespaceOptions,
585
+ ): ProviderDeploymentNamespace => ({
586
+ ...namespace,
587
+ target: envelope(normalizeTarget(namespace, options)),
588
+ })
589
+
590
+ const withTarget = (
591
+ namespace: ProviderDeploymentNamespace,
592
+ target: ZeropsNamespaceTarget,
593
+ ): ProviderDeploymentNamespace => ({ ...namespace, target: envelope(target) })
594
+
595
+ const checkpoint = async (
596
+ input: ProviderNamespaceMutationInput,
597
+ namespace: ProviderDeploymentNamespace,
598
+ target: ZeropsNamespaceTarget,
599
+ ): Promise<ProviderDeploymentNamespace> => {
600
+ const updated = withTarget(namespace, target)
601
+ await input.events.checkpoint(updated)
602
+ return updated
603
+ }
604
+
605
+ const validateProject = (
606
+ namespace: ProviderDeploymentNamespace,
607
+ target: NormalizedZeropsNamespaceTarget,
608
+ project: ZeropsProject,
609
+ adopting: boolean,
610
+ ): void => {
611
+ if (project.id === '') {
612
+ throw new Error('Zerops namespace project returned an empty id')
613
+ }
614
+ if (target.projectName !== project.name) {
615
+ throw new Error(`Zerops namespace project name mismatch: expected \`${target.projectName}\`, got \`${project.name}\``)
616
+ }
617
+ if (project.mode !== target.corePackage) {
618
+ throw new Error(`Zerops namespace project ${project.id} uses ${project.mode ?? 'an unknown core package'}, expected ${target.corePackage}`)
619
+ }
620
+ // Zerops does not expose project envIsolation. Every managed service import writes its own override.
621
+ if (!adopting && target.managed && project.description?.includes(projectMarker(namespace)) !== true) {
622
+ throw new Error(`Zerops namespace project ${project.id} has a different Fabrika ownership marker`)
623
+ }
624
+ }
625
+
626
+ const managedProjectByMarker = async (
627
+ namespace: ProviderDeploymentNamespace,
628
+ target: NormalizedZeropsNamespaceTarget,
629
+ options: ZeropsNamespaceOptions,
630
+ signal: AbortSignal,
631
+ ): Promise<ZeropsProject | null> => {
632
+ const matches = await options.api.findProjects({
633
+ clientId: options.clientId,
634
+ name: target.projectName,
635
+ signal,
636
+ })
637
+ const owned: ZeropsProject[] = []
638
+ for (const match of matches) {
639
+ const project = await options.api.getProject({ projectId: match.id, signal })
640
+ if (project.description?.includes(projectMarker(namespace)) === true) {
641
+ owned.push(project)
642
+ }
643
+ }
644
+ if (owned.length > 1) {
645
+ throw new Error(`Zerops namespace project name \`${target.projectName}\` has multiple matching Fabrika ownership markers`)
646
+ }
647
+ if (owned.length === 1) {
648
+ return owned[0] ?? null
649
+ }
650
+ if (matches.length > 0) {
651
+ throw new Error(`Zerops namespace project name \`${target.projectName}\` is already used without this namespace's ownership marker`)
652
+ }
653
+ return null
654
+ }
655
+
656
+ const uniqueService = (services: ZeropsService[], hostname: string): ZeropsService | null => {
657
+ const matches = services.filter((service) => service.name === hostname)
658
+ if (matches.length > 1) {
659
+ throw new Error(`Zerops namespace has multiple services named \`${hostname}\``)
660
+ }
661
+ return matches[0] ?? null
662
+ }
663
+
664
+ const validateService = (
665
+ service: ZeropsService,
666
+ projectId: string,
667
+ expectedBase: string,
668
+ adopting: boolean,
669
+ expectedPublic?: boolean,
670
+ expectedProfile?: string,
671
+ ): void => {
672
+ if (service.id === '') {
673
+ throw new Error(`Zerops namespace service \`${service.name}\` returned an empty id`)
674
+ }
675
+ if (service.projectId !== undefined && service.projectId !== projectId) {
676
+ throw new Error(`Zerops namespace service \`${service.name}\` belongs to another project`)
677
+ }
678
+ if ((adopting || service.base !== undefined) && service.base !== expectedBase) {
679
+ throw new Error(`Zerops namespace service \`${service.name}\` uses ${service.base ?? 'an unknown type'}, expected ${expectedBase}`)
680
+ }
681
+ if (expectedPublic !== undefined && adopting && service.subdomainAccess !== expectedPublic) {
682
+ throw new Error(`Zerops namespace service \`${service.name}\` has incompatible public access`)
683
+ }
684
+ if (expectedProfile !== undefined && service.autoscalingProfileId !== expectedProfile) {
685
+ throw new Error(
686
+ `Zerops namespace service \`${service.name}\` uses profile ${service.autoscalingProfileId ?? 'unknown'}, expected ${expectedProfile}`,
687
+ )
688
+ }
689
+ }
690
+
691
+ const resolveServices = async (
692
+ namespace: ProviderDeploymentNamespace,
693
+ target: NormalizedZeropsNamespaceTarget,
694
+ options: ZeropsNamespaceOptions,
695
+ signal: AbortSignal,
696
+ adopting: boolean,
697
+ ): Promise<{ proxy: ZeropsService; postgres?: ZeropsService }> => {
698
+ const projectId = target.projectId
699
+ if (projectId === undefined) {
700
+ throw new Error('Zerops namespace has no project id')
701
+ }
702
+ const services = await options.api.listProjectServices({ projectId, signal })
703
+ const proxy = uniqueService(services, ZEROPS_NAMESPACE_PROXY_HOSTNAME)
704
+ if (proxy === null) {
705
+ throw new Error(`Zerops namespace project ${projectId} has no proxy service`)
706
+ }
707
+ validateService(
708
+ proxy,
709
+ projectId,
710
+ PROXY_TYPE,
711
+ adopting,
712
+ target.publicAccess === 'zerops-subdomain',
713
+ )
714
+ const postgres = uniqueService(services, ZEROPS_NAMESPACE_POSTGRES_HOSTNAME)
715
+ if (target.postgres === undefined && postgres !== null && adopting) {
716
+ throw new Error(`Zerops namespace project ${projectId} has an undeclared namespace PostgreSQL service`)
717
+ }
718
+ if (target.postgres !== undefined) {
719
+ if (postgres === null) {
720
+ throw new Error(`Zerops namespace project ${projectId} has no shared PostgreSQL service`)
721
+ }
722
+ validateService(postgres, projectId, target.postgres.type, adopting, undefined, target.postgres.profile)
723
+ }
724
+ if (adopting && namespace.exclusiveAppId !== undefined) {
725
+ const reserved = new Set([
726
+ ZEROPS_NAMESPACE_PROXY_HOSTNAME,
727
+ ...(target.postgres === undefined ? [] : [ZEROPS_NAMESPACE_POSTGRES_HOSTNAME]),
728
+ ])
729
+ const unexpected = services.filter((service) => !reserved.has(service.name))
730
+ if (unexpected.length > 0) {
731
+ throw new Error(`exclusive Zerops namespace project ${projectId} contains unmanaged service \`${unexpected[0]?.name ?? 'unknown'}\``)
732
+ }
733
+ }
734
+ return postgres === null ? { proxy } : { proxy, postgres }
735
+ }
736
+
737
+ const ensureProxyConfiguration = async (
738
+ target: NormalizedZeropsNamespaceTarget,
739
+ options: ZeropsNamespaceOptions,
740
+ signal: AbortSignal,
741
+ ): Promise<boolean> => {
742
+ const proxyServiceId = target.proxyServiceId
743
+ if (proxyServiceId === undefined) {
744
+ throw new Error('Zerops namespace has no proxy service id')
745
+ }
746
+ const variables = await options.api.listServiceEnv({ serviceId: proxyServiceId, signal })
747
+ const byKey = new Map(variables.map((variable) => [variable.key, variable]))
748
+ let deployRequired = !target.ready
749
+ const manifest = byKey.get(FABRIKA_PROXY_MANIFEST_JSON)
750
+ if (!target.ready || manifest === undefined) {
751
+ await options.api.putServiceEnv({
752
+ serviceId: proxyServiceId,
753
+ key: FABRIKA_PROXY_MANIFEST_JSON,
754
+ value: EMPTY_PROXY_MANIFEST,
755
+ signal,
756
+ })
757
+ deployRequired = true
758
+ }
759
+ const iamUrl = byKey.get(ZEROPS_NAMESPACE_IAM_URL_VARIABLE)
760
+ if (iamUrl?.content !== options.iamUrl) {
761
+ await options.api.putServiceEnv({
762
+ serviceId: proxyServiceId,
763
+ key: ZEROPS_NAMESPACE_IAM_URL_VARIABLE,
764
+ value: options.iamUrl,
765
+ signal,
766
+ })
767
+ }
768
+ // Secret read-back is not trustworthy, so overwrite it idempotently without inspecting its value.
769
+ await options.api.putServiceEnv({
770
+ serviceId: proxyServiceId,
771
+ key: ZEROPS_NAMESPACE_IAM_KEY_VARIABLE,
772
+ value: options.iamKey,
773
+ signal,
774
+ })
775
+ return deployRequired
776
+ }
777
+
778
+ const latestAfterBaseline = async (
779
+ target: NormalizedZeropsNamespaceTarget,
780
+ options: ZeropsNamespaceOptions,
781
+ signal: AbortSignal,
782
+ ): Promise<ZeropsAppVersion | null> => {
783
+ const proxyServiceId = target.proxyServiceId
784
+ if (proxyServiceId === undefined) {
785
+ throw new Error('Zerops namespace has no proxy service id')
786
+ }
787
+ const latest = await options.api.latestAppVersion({ serviceId: proxyServiceId, signal })
788
+ const baseline = target.proxyBaselineSequence
789
+ if (latest === null || baseline === undefined || latest.sequence === undefined || latest.sequence <= baseline) {
790
+ return null
791
+ }
792
+ return latest
793
+ }
794
+
795
+ const terminalFailure = (version: ZeropsAppVersion): Error =>
796
+ new Error(`Zerops namespace proxy deploy ${version.id} finished as ${version.status ?? 'unknown'}`)
797
+
798
+ const waitForProxy = async (
799
+ appVersionId: string,
800
+ options: ZeropsNamespaceOptions,
801
+ signal: AbortSignal,
802
+ ): Promise<ZeropsAppVersion> => {
803
+ const deadline = Date.now() + POLL_TIMEOUT_MS
804
+ for (;;) {
805
+ if (signal.aborted) {
806
+ throw new Error('Zerops namespace provisioning was cancelled')
807
+ }
808
+ const version = await options.api.getAppVersion({ appVersionId, signal })
809
+ if (version.status !== undefined && ZEROPS_TERMINAL.has(version.status)) {
810
+ return version
811
+ }
812
+ if (Date.now() > deadline) {
813
+ throw new Error(`Zerops namespace proxy deploy ${appVersionId} timed out`)
814
+ }
815
+ await (options.sleep ?? defaultSleep)(POLL_INTERVAL_MS, signal)
816
+ }
817
+ }
818
+
819
+ const deployProxy = async (
820
+ input: ProviderNamespaceMutationInput,
821
+ namespace: ProviderDeploymentNamespace,
822
+ target: NormalizedZeropsNamespaceTarget,
823
+ options: ZeropsNamespaceOptions,
824
+ ): Promise<{ namespace: ProviderDeploymentNamespace; target: NormalizedZeropsNamespaceTarget }> => {
825
+ let currentNamespace = namespace
826
+ let currentTarget = target
827
+ const proxyServiceId = currentTarget.proxyServiceId
828
+ if (proxyServiceId === undefined) {
829
+ throw new Error('Zerops namespace has no proxy service id')
830
+ }
831
+ if (currentTarget.proxyAppVersionId !== undefined) {
832
+ const version = await waitForProxy(currentTarget.proxyAppVersionId, options, input.signal)
833
+ if (version.status === ZEROPS_ACTIVE) {
834
+ return { namespace: currentNamespace, target: currentTarget }
835
+ }
836
+ currentTarget = {
837
+ ...currentTarget,
838
+ proxyAppVersionId: undefined,
839
+ proxyBaselineSequence: undefined,
840
+ ready: false,
841
+ }
842
+ currentNamespace = await checkpoint(input, currentNamespace, currentTarget)
843
+ }
844
+
845
+ let recovered: ZeropsAppVersion | null = null
846
+ if (currentTarget.proxyBaselineSequence !== undefined) {
847
+ recovered = await latestAfterBaseline(currentTarget, options, input.signal)
848
+ }
849
+ if (recovered === null) {
850
+ if (currentTarget.proxyBaselineSequence === undefined) {
851
+ const prior = await options.api.latestAppVersion({ serviceId: proxyServiceId, signal: input.signal })
852
+ currentTarget = {
853
+ ...currentTarget,
854
+ proxyBaselineSequence: prior?.sequence ?? -1,
855
+ }
856
+ currentNamespace = await checkpoint(input, currentNamespace, currentTarget)
857
+ }
858
+ const process = await options.api.triggerPipeline({
859
+ serviceId: proxyServiceId,
860
+ buildFromGit: currentTarget.proxyBuildFromGit,
861
+ zeropsSetup: ZEROPS_NAMESPACE_PROXY_HOSTNAME,
862
+ signal: input.signal,
863
+ })
864
+ recovered = process?.appVersionId === undefined
865
+ ? await latestAfterBaseline(currentTarget, options, input.signal)
866
+ : { id: process.appVersionId }
867
+ }
868
+ if (recovered === null || recovered.id === '') {
869
+ throw new Error('Zerops namespace proxy pipeline did not expose a new app-version')
870
+ }
871
+ currentTarget = { ...currentTarget, proxyAppVersionId: recovered.id }
872
+ currentNamespace = await checkpoint(input, currentNamespace, currentTarget)
873
+ const version = await waitForProxy(recovered.id, options, input.signal)
874
+ if (version.status !== ZEROPS_ACTIVE) {
875
+ throw terminalFailure(version)
876
+ }
877
+ return { namespace: currentNamespace, target: currentTarget }
878
+ }
879
+
880
+ /**
881
+ * Publish the proxy on its `*.zerops.app` subdomain — the ONE thing the import document cannot do.
882
+ *
883
+ * `enableSubdomainAccess: true` is accepted by the import and then silently dropped, on a service the
884
+ * document CREATES as much as on one `override` leaves alone; a service imported with it reads back
885
+ * `subdomainAccess: false` for ever. The subdomain is established by
886
+ * `PUT /service-stack/{id}/enable-subdomain-access` and by nothing else, and that call needs a DEPLOYED
887
+ * HTTP port — which is why this runs after `deployProxy` rather than beside the import.
888
+ *
889
+ * It runs on RECONCILE too, so a subdomain someone turned off in the GUI comes back. What it must never
890
+ * do is finish quietly: `zerops-subdomain` is the only public entry a namespace has, so this either
891
+ * observes `subdomainAccess === true` or throws naming what is missing.
892
+ *
893
+ * The decision is the READ-BACK, never the call returning. On an already-published service the platform
894
+ * answers 2xx and then fails the process it handed back — live-verified — so a 2xx proves nothing.
895
+ */
896
+ const ensureSubdomainAccess = async (
897
+ target: NormalizedZeropsNamespaceTarget,
898
+ options: ZeropsNamespaceOptions,
899
+ signal: AbortSignal,
900
+ ): Promise<void> => {
901
+ const proxyServiceId = target.proxyServiceId
902
+ if (proxyServiceId === undefined) {
903
+ throw new Error('Zerops namespace has no proxy service id')
904
+ }
905
+ const published = async (): Promise<boolean> => (await options.api.getService({ serviceId: proxyServiceId, signal })).subdomainAccess === true
906
+ if (await published()) {
907
+ return
908
+ }
909
+ try {
910
+ await options.api.enableSubdomainAccess({ serviceId: proxyServiceId, signal })
911
+ } catch (error) {
912
+ if (error instanceof ZeropsApiError && error.code === ZEROPS_SERVICE_NOT_HTTP) {
913
+ throw new Error(
914
+ `Zerops namespace proxy ${proxyServiceId} cannot be published on a zerops.app subdomain: it exposes no deployed HTTP port (${ZEROPS_SERVICE_NOT_HTTP})`,
915
+ )
916
+ }
917
+ throw error
918
+ }
919
+ for (let attempt = 0;; attempt += 1) {
920
+ if (signal.aborted) {
921
+ throw new Error('Zerops namespace provisioning was cancelled')
922
+ }
923
+ if (await published()) {
924
+ return
925
+ }
926
+ if (attempt >= SUBDOMAIN_READBACK_ATTEMPTS) {
927
+ throw new Error(
928
+ `Zerops namespace proxy ${proxyServiceId} accepted enable-subdomain-access but still reads subdomainAccess: false — the namespace has no public entry point`,
929
+ )
930
+ }
931
+ await (options.sleep ?? defaultSleep)(POLL_INTERVAL_MS, signal)
932
+ }
933
+ }
934
+
935
+ const mutateNamespace = async (
936
+ input: ProviderNamespaceMutationInput,
937
+ options: ZeropsNamespaceOptions,
938
+ mode: 'provision' | 'reconcile',
939
+ ): Promise<ProviderDeploymentNamespace> => {
940
+ const suppliedTarget = decodeEnvelope(input.namespace.target)
941
+ let namespace = normalizedNamespace(input.namespace, options)
942
+ let target = normalizeTarget(namespace, options)
943
+ const initiallyAdopted = target.projectId !== undefined && target.managed === false && !target.ready
944
+ const topology = compileZeropsNamespaceTopology(namespace, target)
945
+
946
+ if (target.projectId === undefined) {
947
+ const recovered = await managedProjectByMarker(namespace, target, options, input.signal)
948
+ const result = recovered === null
949
+ ? await options.api.importProject({ clientId: options.clientId, yaml: topology.createYaml, signal: input.signal })
950
+ : { projectId: recovered.id, projectName: recovered.name, services: [] }
951
+ if (result.projectId === '') {
952
+ throw new Error('Zerops namespace project import returned an empty project id')
953
+ }
954
+ target = { ...target, projectId: result.projectId, managed: true }
955
+ namespace = await checkpoint(input, namespace, target)
956
+ }
957
+
958
+ const projectId = target.projectId
959
+ if (projectId === undefined) {
960
+ throw new Error('Zerops namespace project id was not checkpointed')
961
+ }
962
+ const project = await options.api.getProject({ projectId, signal: input.signal })
963
+ if (target.projectName !== project.name && initiallyAdopted && suppliedTarget.projectName === undefined) {
964
+ target = { ...target, projectName: project.name }
965
+ namespace = await checkpoint(input, namespace, target)
966
+ }
967
+ validateProject(namespace, target, project, initiallyAdopted)
968
+
969
+ if (initiallyAdopted) {
970
+ // Validate before the first mutation; the import below then enforces service-level envIsolation.
971
+ await resolveServices(namespace, target, options, input.signal, true)
972
+ }
973
+ await options.api.importServices({
974
+ projectId,
975
+ yaml: mode === 'provision' && !target.ready ? topology.servicesProvisionYaml : topology.servicesSteadyYaml,
976
+ signal: input.signal,
977
+ })
978
+ const resolved = await resolveServices(namespace, target, options, input.signal, false)
979
+ target = {
980
+ ...target,
981
+ proxyServiceId: resolved.proxy.id,
982
+ ...(resolved.postgres !== undefined ? { postgresServiceId: resolved.postgres.id } : {}),
983
+ }
984
+ namespace = await checkpoint(input, namespace, target)
985
+
986
+ const deployRequired = await ensureProxyConfiguration(target, options, input.signal)
987
+ target = { ...target, proxyConfigured: true }
988
+ namespace = await checkpoint(input, namespace, target)
989
+
990
+ if (deployRequired || !target.ready) {
991
+ const deployed = await deployProxy(input, namespace, target, options)
992
+ namespace = deployed.namespace
993
+ target = deployed.target
994
+ }
995
+ // After the deploy, because the platform refuses a service with no HTTP port — and never before `ready`
996
+ // is checkpointed, because a namespace with no public entry point is not ready.
997
+ if (target.publicAccess === 'zerops-subdomain') {
998
+ await ensureSubdomainAccess(target, options, input.signal)
999
+ }
1000
+ target = {
1001
+ ...target,
1002
+ proxyBaselineSequence: undefined,
1003
+ proxyAppVersionId: undefined,
1004
+ ready: true,
1005
+ }
1006
+ return checkpoint(input, namespace, target)
1007
+ }
1008
+
1009
+ /** Build the provider-owned namespace lifecycle capability. */
1010
+ export const createZeropsNamespaceCapabilities = (options: ZeropsNamespaceOptions): ProviderNamespaceCapabilities => {
1011
+ const required = (name: string, value: string): void => {
1012
+ if (value.trim() === '') {
1013
+ throw new Error(`Zerops namespace ${name} must be a non-empty string`)
1014
+ }
1015
+ }
1016
+ required('clientId', options.clientId)
1017
+ required('proxyBuildFromGit', options.proxyBuildFromGit)
1018
+ required('iamUrl', options.iamUrl)
1019
+ required('iamKey', options.iamKey)
1020
+ return {
1021
+ normalize: (namespace) => normalizedNamespace(namespace, options),
1022
+ namespaceResourceClaims,
1023
+ registrationResourceClaims,
1024
+ provision: (input) => mutateNamespace(input, options, 'provision'),
1025
+ reconcile: (input) => mutateNamespace(input, options, 'reconcile'),
1026
+ operator: createZeropsNamespaceOperator({ proxyBuildFromGit: options.proxyBuildFromGit }),
1027
+ }
1028
+ }