@fabrika/provider-zerops 0.8.1 → 0.9.0-cloudflare.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.
Files changed (133) hide show
  1. package/package.json +16 -14
  2. package/src/api/client.ts +18 -0
  3. package/src/api/readers.ts +29 -0
  4. package/src/api/types.ts +64 -0
  5. package/src/api.ts +2 -0
  6. package/src/cli.ts +11 -0
  7. package/src/collaborators.ts +0 -14
  8. package/src/control.ts +35 -57
  9. package/src/execution.ts +113 -102
  10. package/src/index.ts +2 -0
  11. package/src/installation/admin.ts +1 -1
  12. package/src/installation/deploy-options.ts +1 -2
  13. package/src/installation/deploy.ts +225 -80
  14. package/src/installation/generated/platform-console-schema.ts +3 -0
  15. package/src/installation/generated/platform-proxy-manifest.ts +21 -1
  16. package/src/installation/guide.md +48 -18
  17. package/src/installation/hosts.ts +1 -7
  18. package/src/installation/index.ts +13 -16
  19. package/src/installation/init-options.ts +2 -1
  20. package/src/installation/install-environment.ts +47 -108
  21. package/src/installation/install-options.ts +3 -1
  22. package/src/installation/install-secrets.ts +24 -68
  23. package/src/installation/install.ts +9 -4
  24. package/src/installation/sidecar.ts +5 -26
  25. package/src/installation/topology/setups.ts +15 -4
  26. package/src/installation/topology/topology.ts +31 -21
  27. package/src/installation/upgrade-options.ts +2 -2
  28. package/src/managed-env.ts +1 -174
  29. package/src/manifest.ts +11 -14
  30. package/src/namespace/claims.ts +56 -1
  31. package/src/namespace/errors.ts +1 -1
  32. package/src/namespace/manifest.ts +6 -19
  33. package/src/namespace/provisioning.ts +24 -4
  34. package/src/namespace/proxy-attempt.ts +43 -2
  35. package/src/namespace/proxy-reads.ts +12 -22
  36. package/src/namespace/types.ts +2 -0
  37. package/src/namespace-cli.ts +152 -0
  38. package/src/plan.ts +2 -7
  39. package/src/provider.ts +128 -126
  40. package/src/proxy-manifest-carrier.ts +18 -0
  41. package/src/types.ts +0 -2
  42. package/types/src/api/client.d.ts +5 -0
  43. package/types/src/api/errors.d.ts +24 -0
  44. package/types/src/api/polling.d.ts +41 -0
  45. package/types/src/api/readers.d.ts +1115 -0
  46. package/types/src/api/service-env-limit.d.ts +17 -0
  47. package/types/src/api/service-env-response.d.ts +23 -0
  48. package/types/src/api/types.d.ts +687 -0
  49. package/types/src/api.d.ts +6 -0
  50. package/types/src/authoring.d.ts +9 -0
  51. package/types/src/cli-args.d.ts +19 -0
  52. package/types/src/cli.d.ts +13 -0
  53. package/types/src/collaborators.d.ts +16 -0
  54. package/types/src/compile.d.ts +91 -0
  55. package/types/src/control.d.ts +40 -0
  56. package/types/src/database-budget.d.ts +38 -0
  57. package/types/src/descriptor.d.ts +28 -0
  58. package/types/src/execution.d.ts +125 -0
  59. package/types/src/exposure.d.ts +47 -0
  60. package/types/src/index.d.ts +31 -0
  61. package/types/src/installation/admin-options.d.ts +24 -0
  62. package/types/src/installation/admin.d.ts +35 -0
  63. package/types/src/installation/checker-release.d.ts +12 -0
  64. package/types/src/installation/deploy-options.d.ts +37 -0
  65. package/types/src/installation/deploy.d.ts +136 -0
  66. package/types/src/installation/generated/platform-console-schema.d.ts +2 -0
  67. package/types/src/installation/generated/platform-proxy-manifest.d.ts +2 -0
  68. package/types/src/installation/hosts.d.ts +34 -0
  69. package/types/src/installation/index.d.ts +7 -0
  70. package/types/src/installation/init-options.d.ts +38 -0
  71. package/types/src/installation/init.d.ts +60 -0
  72. package/types/src/installation/install-collaborators.d.ts +24 -0
  73. package/types/src/installation/install-environment.d.ts +63 -0
  74. package/types/src/installation/install-options.d.ts +49 -0
  75. package/types/src/installation/install-project.d.ts +13 -0
  76. package/types/src/installation/install-provisioning.d.ts +58 -0
  77. package/types/src/installation/install-secrets.d.ts +17 -0
  78. package/types/src/installation/install-token.d.ts +10 -0
  79. package/types/src/installation/install.d.ts +23 -0
  80. package/types/src/installation/installation-source.d.ts +8 -0
  81. package/types/src/installation/log.d.ts +44 -0
  82. package/types/src/installation/machine-key.d.ts +19 -0
  83. package/types/src/installation/placement-options.d.ts +11 -0
  84. package/types/src/installation/platform-archive.d.ts +7 -0
  85. package/types/src/installation/platform-source.d.ts +8 -0
  86. package/types/src/installation/platform-upload.d.ts +3 -0
  87. package/types/src/installation/sidecar.d.ts +49 -0
  88. package/types/src/installation/source-options.d.ts +1 -0
  89. package/types/src/installation/topology/artifacts.d.ts +8 -0
  90. package/types/src/installation/topology/index.d.ts +5 -0
  91. package/types/src/installation/topology/installation-schemas.d.ts +2242 -0
  92. package/types/src/installation/topology/invariants.d.ts +23 -0
  93. package/types/src/installation/topology/setups.d.ts +59 -0
  94. package/types/src/installation/topology/topology.d.ts +153 -0
  95. package/types/src/installation/topology/validate.d.ts +11 -0
  96. package/types/src/installation/upgrade-options.d.ts +26 -0
  97. package/types/src/installation/upgrade-output.d.ts +6 -0
  98. package/types/src/installation/upgrade.d.ts +88 -0
  99. package/types/src/managed-env.d.ts +24 -0
  100. package/types/src/manifest.d.ts +204 -0
  101. package/types/src/namespace/claims.d.ts +16 -0
  102. package/types/src/namespace/codec.d.ts +334 -0
  103. package/types/src/namespace/constants.d.ts +25 -0
  104. package/types/src/namespace/errors.d.ts +15 -0
  105. package/types/src/namespace/manifest.d.ts +4 -0
  106. package/types/src/namespace/operator.d.ts +12 -0
  107. package/types/src/namespace/provisioning.d.ts +4 -0
  108. package/types/src/namespace/proxy-attempt.d.ts +39 -0
  109. package/types/src/namespace/proxy-reads.d.ts +22 -0
  110. package/types/src/namespace/proxy-secrets.d.ts +8 -0
  111. package/types/src/namespace/proxy-source.d.ts +74 -0
  112. package/types/src/namespace/topology.d.ts +9 -0
  113. package/types/src/namespace/types.d.ts +101 -0
  114. package/types/src/namespace/update.d.ts +6 -0
  115. package/types/src/namespace/validation.d.ts +12 -0
  116. package/types/src/namespace-cli.d.ts +2 -0
  117. package/types/src/namespace.d.ts +9 -0
  118. package/types/src/plan.d.ts +11 -0
  119. package/types/src/provider.d.ts +63 -0
  120. package/types/src/proxy-manifest-carrier.d.ts +24 -0
  121. package/types/src/schema.generated.d.ts +376 -0
  122. package/types/src/service-names.d.ts +4 -0
  123. package/types/src/service-references.d.ts +13 -0
  124. package/types/src/source/constants.d.ts +6 -0
  125. package/types/src/source/port.d.ts +60 -0
  126. package/types/src/source/types.d.ts +4 -0
  127. package/types/src/source/validation.d.ts +19 -0
  128. package/types/src/source.d.ts +2 -0
  129. package/types/src/types.d.ts +119 -0
  130. package/types/src/version-attempt.d.ts +8 -0
  131. package/src/installation/mcp.ts +0 -102
  132. package/src/installation/proxy-manifest.ts +0 -214
  133. package/src/installation/secrets.ts +0 -78
@@ -82,22 +82,27 @@ export type PublicAccess = 'custom-domain' | 'zerops-subdomain'
82
82
  * `standard` is the production shape: HA Postgres, a separate database and bucket for Operations, and
83
83
  * enough containers that no single one is a point of failure.
84
84
  *
85
- * `light` is one project, one `postgresql:single@18` and one bucket shared by IAM, Operations, control
86
- * AND the apps deployed alongside them, at one container per service. This tier exists because the standard
85
+ * `light` is one project with two `postgresql:single@18` services — `db` for IAM and control, `operationsdb`
86
+ * for Operations — and one bucket, at one container per service. This tier exists because the standard
87
87
  * shape costs ~13 containers before a single application is deployed, which is more platform than a
88
- * small fleet — or an evaluation — can justify.
88
+ * small fleet — or an evaluation — can justify. Applications run in their own namespace projects
89
+ * (ADR-0034); a production namespace that must keep its data through a node loss asks for HA there.
89
90
  *
90
- * Sharing is safe rather than merely cheap, and that is a property of the code, not of this comment:
91
+ * Operations gets its own database, not a schema beside IAM's, because Zerops backs up a whole service on
92
+ * one schedule: identity and deploy state is worth an hourly dump, error history is not, and only two
93
+ * services can carry two schedules. It also keeps Operations' high-volume writes off the identity path.
91
94
  *
92
- * • The three services' table names do not intersect (IAM 10, Operations 24, control 16), and
93
- * ADR-0017 already gives each its own migration ledger and advisory lock, so one database holds
94
- * all three without coordination.
95
+ * Sharing the rest is safe rather than merely cheap, and that is a property of the code, not of this comment:
96
+ *
97
+ * • IAM's and control's table names do not intersect, and ADR-0017 gives each its own migration ledger
98
+ * and advisory lock, so one database holds both without coordination. A light installation created
99
+ * before Operations moved out still holds all three in `db`, which the same property covers.
95
100
  * • Their blob keys are prefix-disjoint — control writes `runs/`, Operations writes `events/`,
96
101
  * `dead/` and `source-maps/` — so one bucket cannot collide.
97
102
  *
98
- * What `light` genuinely gives up is FAILURE-DOMAIN isolation: Operations' high-volume error history
99
- * now shares capacity with the identity path, and losing the one database loses all three services.
100
- * That is the trade, and it is why `standard` remains the default rather than the special case.
103
+ * What `light` genuinely gives up is AVAILABILITY and DURABILITY: one container per service, and a
104
+ * single-node database loses everything since its last backup when its node fails (Zerops has no
105
+ * point-in-time recovery). That is the trade, and it is why `standard` remains the default rather than the special case.
101
106
  */
102
107
  export type PlatformTier = 'standard' | 'light'
103
108
 
@@ -188,9 +193,9 @@ export const platformTopology = (options: TopologyOptions): ProjectTopology => {
188
193
  hostname: 'db',
189
194
  // HA is encoded in the TYPE. The deprecated `mode` field is not merely avoided here — it
190
195
  // is absent from `ZeropsServiceSpec`, so it cannot be written. One database serves both
191
- // IAM and the control plane; they are separate schemas in it, not separate services,
192
- // because a second HA Postgres triples the cost of the isolation the private network
193
- // already gives us.
196
+ // IAM and the control plane; both use its default schema with disjoint tables and their own
197
+ // migration ledgers (ADR-0017), not separate services, because a second HA Postgres triples
198
+ // the cost of the isolation the private network already gives us.
194
199
  type: 'postgresql:ha@18',
195
200
  // The identity and control-plane database: every login, every token mint, every run's
196
201
  // state. `oltp-production` is also what an HA service gets by DEFAULT — stated here so
@@ -315,10 +320,9 @@ const lightPlatformTopology = (options: TopologyOptions, publicAccess: PublicAcc
315
320
  // that creates the project. Same shape as `compileZeropsNamespaceTopology` in `src/namespace/topology.ts`.
316
321
  const services = (): ZeropsServiceSpec[] => [
317
322
  {
318
- // ONE database for IAM, control, Operations and every app in this project. Which schema each
319
- // one gets is not expressed here — it is `FABRIKA_*_DATABASE_URL`, written per installation
320
- // through the env API, because this same repository-root `zerops.yaml` serves both tiers and
321
- // a `${operationsdb_connectionString}` baked into it would name a service `light` does not have.
323
+ // IAM and control. Which schema each one gets is not expressed here — it is `FABRIKA_*_DATABASE_URL`,
324
+ // written per installation through the env API, because this same repository-root `zerops.yaml`
325
+ // serves both tiers.
322
326
  hostname: 'db',
323
327
  type: 'postgresql:single@18',
324
328
  // `oltp-hobby`, written out because a default is not a choice — this type's default is
@@ -326,15 +330,21 @@ const lightPlatformTopology = (options: TopologyOptions, publicAccess: PublicAcc
326
330
  //
327
331
  // A floor is not headroom. Every PostgreSQL profile shares the same 8-core / 48 GB / 250 GB
328
332
  // ceiling and the same vertical autoscaler, so `oltp-staging` does not let this service grow
329
- // any further than `oltp-hobby` does — it only pre-pays for memory the tier is idle on. That
330
- // this single service holds IAM, control, Operations and the apps beside them is an argument
331
- // about the ceiling, which is unchanged.
333
+ // any further than `oltp-hobby` does — it only pre-pays for memory the tier is idle on.
332
334
  //
333
335
  // An installation that wants a specific floor sets one on the service — `minRam` accepts a
334
336
  // float — which is where a per-installation value belongs (ADR-0004), not in this document.
335
337
  profile: 'oltp-hobby',
336
338
  priority: 100,
337
339
  },
340
+ {
341
+ // Operations' own database: its own backup schedule (the Zerops default, daily) and its own
342
+ // capacity, off the identity path. Same type and profile as `db`, for the same reasons.
343
+ hostname: 'operationsdb',
344
+ type: 'postgresql:single@18',
345
+ profile: 'oltp-hobby',
346
+ priority: 100,
347
+ },
338
348
  {
339
349
  // ONE bucket. Prefix-disjoint by construction: `runs/` (control) vs `events/`, `dead/` and
340
350
  // `source-maps/` (Operations).
@@ -367,7 +377,7 @@ const lightPlatformTopology = (options: TopologyOptions, publicAccess: PublicAcc
367
377
  platform: 'zerops',
368
378
  project: {
369
379
  name: 'platform',
370
- description: 'fabrika light tier: control plane, Operations, IAM, the auth proxy and the apps they serve, on shared data services.',
380
+ description: 'fabrika light tier: control plane, Operations, IAM and the auth proxy, on single-node data services.',
371
381
  corePackage: 'LIGHT',
372
382
  tags: ['fabrika', 'platform', 'light', options.env],
373
383
  },
@@ -8,8 +8,8 @@
8
8
  // The pin is checked BEFORE anything leaves this machine: `assertPinnedTag` refuses a branch or a SHA
9
9
  // (ADR-0025) without a network call, so `--to=main` fails on the spot rather than after a clone.
10
10
 
11
- import { describeArgument, readFlag } from '@fabrika/installation-init'
12
- import { assertPinnedTag, defaultCheckoutDir } from './sidecar'
11
+ import { assertPinnedTag, describeArgument, readFlag } from '@fabrika/installation-init'
12
+ import { defaultCheckoutDir } from './sidecar'
13
13
 
14
14
  /** Where the sidecar repository this command edits is found. */
15
15
  export type UpgradeSidecar =
@@ -1,18 +1,5 @@
1
- import {
2
- FRAMEWORK_VARIABLE_PREFIXES,
3
- type FrameworkVariableName,
4
- frameworkVariableReference,
5
- isConnectKeyVariable,
6
- isConnectUrlVariable,
7
- isEmailTransportVariable,
8
- isFrameworkVariableName,
9
- isPlatformTransportVariable,
10
- isWorkloadKeyVariable,
11
- PLATFORM_RECEIVERS,
12
- platformTransportVariables,
13
- } from '@fabrika/auth-core'
1
+ import { frameworkVariableReference, isFrameworkVariableName } from '@fabrika/auth-core'
14
2
  import type { JsonValue } from '@fabrika/provider-contract'
15
- import { z } from 'zod'
16
3
  import { renderYaml } from './compile'
17
4
  import {
18
5
  assertOwnRunWhenExtending,
@@ -22,166 +9,6 @@ import {
22
9
  selectZeropsDescriptorSetup,
23
10
  } from './descriptor'
24
11
 
25
- /** The framework-only names Control hands every Zerops deploy; the rest of the list is the provider's own. */
26
- export type ManagedKey = Extract<
27
- FrameworkVariableName,
28
- | 'FABRIKA_RELEASE'
29
- | 'FABRIKA_OPERATIONS_DSN'
30
- | 'FABRIKA_IAM_ISSUER'
31
- | 'FABRIKA_IAM_APP_ID'
32
- | 'FABRIKA_APP_ID'
33
- | 'FABRIKA_ENVIRONMENT'
34
- | 'FABRIKA_SERVICE_KEY'
35
- | 'FABRIKA_PROXY_SECRET'
36
- | 'FABRIKA_IAM_RPC_URL'
37
- | 'FABRIKA_IAM_RPC_KEY'
38
- | 'FABRIKA_APP_ORIGIN'
39
- | 'FABRIKA_TOKEN_KEYS'
40
- >
41
-
42
- export const MANAGED_KEYS: readonly ManagedKey[] = [
43
- 'FABRIKA_RELEASE',
44
- 'FABRIKA_OPERATIONS_DSN',
45
- 'FABRIKA_IAM_ISSUER',
46
- 'FABRIKA_IAM_APP_ID',
47
- 'FABRIKA_APP_ID',
48
- 'FABRIKA_ENVIRONMENT',
49
- 'FABRIKA_SERVICE_KEY',
50
- 'FABRIKA_PROXY_SECRET',
51
- 'FABRIKA_IAM_RPC_URL',
52
- 'FABRIKA_IAM_RPC_KEY',
53
- 'FABRIKA_APP_ORIGIN',
54
- 'FABRIKA_TOKEN_KEYS',
55
- ]
56
-
57
- /** The managed keys written as service variables; `FABRIKA_RELEASE` travels only in the execution descriptor (ADR-0044). */
58
- export const SERVICE_MANAGED_KEYS: readonly Exclude<ManagedKey, 'FABRIKA_RELEASE'>[] = [
59
- 'FABRIKA_OPERATIONS_DSN',
60
- 'FABRIKA_IAM_ISSUER',
61
- 'FABRIKA_IAM_APP_ID',
62
- 'FABRIKA_APP_ID',
63
- 'FABRIKA_ENVIRONMENT',
64
- 'FABRIKA_SERVICE_KEY',
65
- 'FABRIKA_PROXY_SECRET',
66
- 'FABRIKA_IAM_RPC_URL',
67
- 'FABRIKA_IAM_RPC_KEY',
68
- 'FABRIKA_APP_ORIGIN',
69
- 'FABRIKA_TOKEN_KEYS',
70
- ]
71
-
72
- /** The managed keys that carry a credential, written sensitive and therefore never compared. */
73
- export const SENSITIVE_MANAGED_KEYS: ReadonlySet<ManagedKey> = new Set<ManagedKey>([
74
- 'FABRIKA_OPERATIONS_DSN',
75
- 'FABRIKA_SERVICE_KEY',
76
- 'FABRIKA_PROXY_SECRET',
77
- 'FABRIKA_IAM_RPC_KEY',
78
- 'FABRIKA_TOKEN_KEYS',
79
- ])
80
-
81
- const PLATFORM_URL_VARIABLES: ReadonlySet<string> = new Set(PLATFORM_RECEIVERS.map((receiver) => platformTransportVariables(receiver).url))
82
-
83
- /**
84
- * A workload, connect or platform transport key and every mail transport variable are written sensitive; a connect or
85
- * platform transport URL is an origin.
86
- */
87
- export const isSensitiveFamilyVariable = (name: string): boolean => !isConnectUrlVariable(name) && !PLATFORM_URL_VARIABLES.has(name)
88
-
89
- export type ManagedValues = Record<ManagedKey, string | null>
90
-
91
- const managedValuesSchema = z.object({
92
- FABRIKA_RELEASE: z.string().nullable(),
93
- FABRIKA_OPERATIONS_DSN: z.string().nullable(),
94
- FABRIKA_IAM_ISSUER: z.string().nullable(),
95
- FABRIKA_IAM_APP_ID: z.string().nullable(),
96
- FABRIKA_APP_ID: z.string().nullable(),
97
- FABRIKA_ENVIRONMENT: z.string().nullable(),
98
- FABRIKA_SERVICE_KEY: z.string().nullable(),
99
- FABRIKA_PROXY_SECRET: z.string().nullable(),
100
- // IAM's membership transport (ADR-0022 rule 6): its public origin and this environment's key, or both null.
101
- FABRIKA_IAM_RPC_URL: z.string().nullable(),
102
- FABRIKA_IAM_RPC_KEY: z.string().nullable(),
103
- // The environment's canonical origin (`ctx.url`), or null when it has no public origin.
104
- FABRIKA_APP_ORIGIN: z.string().nullable(),
105
- // The keys of the application's signed tokens, or null when its contract declares no token purpose.
106
- FABRIKA_TOKEN_KEYS: z.string().nullable(),
107
- }).strict()
108
-
109
- export function parseManagedValues(value: unknown): ManagedValues {
110
- const result = managedValuesSchema.safeParse(value)
111
- if (!result.success) throw new Error('zerops: managed environment must name exactly the twelve managed keys')
112
- return result.data
113
- }
114
-
115
- /**
116
- * What Control hands a deploy: the twelve managed keys, the key of each job and webhook of the approved contract, the
117
- * key and owner origin of each connector import, and the installation's mail transport when a job sends through it.
118
- */
119
- export interface ManagedEnvironment {
120
- readonly values: ManagedValues
121
- /**
122
- * `FABRIKA_<KIND>_<NAME>_KEY` → the workload's `px_` key, `FABRIKA_CONNECT_<IMPORT>_KEY` / `_URL`, each
123
- * `FABRIKA_EMAIL_*` of the installation's mail transport, and `FABRIKA_PLATFORM_<RECEIVER>_URL` / `_KEY`.
124
- */
125
- readonly familyVariables: Readonly<Record<string, string>>
126
- }
127
-
128
- const keySchema = z.string().startsWith('px_').min(4)
129
- const mailValueSchema = z.string().min(1)
130
- const originSchema = (schemes: readonly string[]) =>
131
- z.string().refine((value) => {
132
- try {
133
- const url = new URL(value)
134
- return url.origin === value && schemes.includes(url.protocol)
135
- } catch {
136
- return false
137
- }
138
- })
139
- const connectOriginSchema = originSchema(['https:'])
140
- // Control's public origin is http in the local stack (`http://control.fabrika.localhost:18080`).
141
- const platformOriginSchema = originSchema(['http:', 'https:'])
142
-
143
- export function parseManagedEnvironment(value: Readonly<Record<string, string | null>>): ManagedEnvironment {
144
- const managed: Record<string, string | null> = {}
145
- const familyVariables: Record<string, string> = {}
146
- for (const [name, entry] of Object.entries(value)) {
147
- if (!isFrameworkFamilyVariable(name)) {
148
- managed[name] = entry
149
- continue
150
- }
151
- if (isEmailTransportVariable(name)) {
152
- const parsed = mailValueSchema.safeParse(entry)
153
- if (!parsed.success) throw new Error(`zerops: ${name} must not be empty`)
154
- familyVariables[name] = parsed.data
155
- continue
156
- }
157
- if (isPlatformTransportVariable(name)) {
158
- const address = PLATFORM_URL_VARIABLES.has(name)
159
- const parsed = (address ? platformOriginSchema : keySchema).safeParse(entry)
160
- if (!parsed.success) throw new Error(`zerops: ${name} must be ${address ? 'an http(s) origin' : 'a px_ key'}`)
161
- familyVariables[name] = parsed.data
162
- continue
163
- }
164
- const origin = isConnectUrlVariable(name)
165
- if (!origin && !isWorkloadKeyVariable(name) && !isConnectKeyVariable(name)) {
166
- throw new Error(`zerops: ${name} is not a variable a deploy writes`)
167
- }
168
- const parsed = (origin ? connectOriginSchema : keySchema).safeParse(entry)
169
- if (!parsed.success) {
170
- throw new Error(`zerops: ${name} must be ${origin ? 'an https origin' : 'a px_ key'}`)
171
- }
172
- familyVariables[name] = parsed.data
173
- }
174
- return { values: parseManagedValues(managed), familyVariables }
175
- }
176
-
177
- /**
178
- * A service variable only the platform writes per workload, import, platform receiver or the installation's mail
179
- * transport: a stale one is removed when the deploy no longer hands it.
180
- */
181
- export function isFrameworkFamilyVariable(name: string): boolean {
182
- return FRAMEWORK_VARIABLE_PREFIXES.some((prefix) => name.startsWith(prefix))
183
- }
184
-
185
12
  const refuse = (): never => {
186
13
  throw new Error('zerops: application configuration uses a Fabrika-managed environment name')
187
14
  }
package/src/manifest.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { CONTRACT_JOB_NAME_PATTERN, CRON_SCHEDULE_PATTERN } from '@fabrika/app/build'
1
2
  import { appSchemaSchema } from '@fabrika/auth'
2
3
  import { type AppSchema, type ContractArtifact, contractDigest, isDigest } from '@fabrika/auth-core'
3
4
  import type { OperationsSourceMapBuildUpload } from '@fabrika/operations-contract/releases'
@@ -12,10 +13,12 @@ import {
12
13
  FABRIKA_CHECK_VERSION,
13
14
  LOCKED_APP_ID_PATTERN,
14
15
  lockedAuthoredBuild,
16
+ READINESS_PATH_PATTERN,
15
17
  ZEROPS_RUNTIME_PACKAGES,
16
18
  zeropsAssetRoots,
17
19
  type ZeropsContractRun,
18
20
  zeropsContractRun,
21
+ type ZeropsDevExecution,
19
22
  type ZeropsLockedExecution,
20
23
  type ZeropsRuntimePackage,
21
24
  } from './execution'
@@ -310,15 +313,10 @@ const importDocumentSchema = z.object({ project: importProjectSchema.optional(),
310
313
  /** The import document a manifest carries: every service already holds the ADR-0004 invariants. */
311
314
  export type FabrikaManifestImportDocument = z.infer<typeof importDocumentSchema>
312
315
 
313
- /** A literal path of unreserved characters: it is written into the generated probes unquoted by any shell. */
314
- const READINESS_PATH = /^(?:\/[A-Za-z0-9_~-][A-Za-z0-9._~-]*)+$/
315
- const JOB_NAME = /^[A-Za-z][A-Za-z0-9_]*$/
316
- const CRON_SCHEDULE = /^[0-9*/,-]+(?: [0-9*/,-]+){4}$/
317
-
318
316
  const contractRunSchema = z.object({
319
317
  digest: z.string().regex(CONTRACT_DIGEST_PATTERN),
320
- readinessPath: z.string().regex(READINESS_PATH),
321
- jobs: z.array(z.object({ name: z.string().regex(JOB_NAME), schedule: z.string().regex(CRON_SCHEDULE) }).strict()),
318
+ readinessPath: z.string().regex(READINESS_PATH_PATTERN),
319
+ jobs: z.array(z.object({ name: z.string().regex(CONTRACT_JOB_NAME_PATTERN), schedule: z.string().regex(CRON_SCHEDULE_PATTERN) }).strict()),
322
320
  files: z.literal(true).optional(),
323
321
  scan: z.literal(true).optional(),
324
322
  }).strict()
@@ -476,8 +474,11 @@ export const lockedExecutionOf = (manifest: FabrikaManifest): ZeropsLockedExecut
476
474
  }
477
475
  }
478
476
 
479
- /** What Control lets a provider publish for one environment (`ProviderEnvironment.access`). */
480
- export type ZeropsAccess = Pick<ProviderEnvironment, 'access'>
477
+ /**
478
+ * What Control lets a provider publish for one environment (`ProviderEnvironment.access`), and, for an environment of
479
+ * kind `dev`, the commit its bootstrap builds.
480
+ */
481
+ export type ZeropsAccess = Pick<ProviderEnvironment, 'access'> & { readonly dev?: ZeropsDevExecution }
481
482
 
482
483
  /**
483
484
  * The execution descriptor's lock-down at deploy (ADR-0050): Control's approved contract decides it, not the artifact,
@@ -506,6 +507,7 @@ export function lockedExecutionFor(
506
507
  storageHostname: applicationStorageHostname(manifest.target.importDocument.services),
507
508
  runtimePackages: manifest.target.runtimePackages ?? [],
508
509
  sourceMaps: artifactUpload === undefined ? null : sourceMapUpload(artifactUpload, access.contract),
510
+ ...(granted.dev === undefined ? {} : { dev: granted.dev }),
509
511
  }
510
512
  }
511
513
 
@@ -522,11 +524,6 @@ const sourceMapUpload = (upload: NonNullable<ProviderDeployInput['artifactUpload
522
524
  assetRoots: zeropsAssetRoots(contract),
523
525
  })
524
526
 
525
- /** The IAM schema a deploy, cancel or reconcile applies: the approved contract's. `undefined` reconciles nothing. */
526
- export function approvedSchemaOf(granted: ZeropsAccess): AppSchema | undefined {
527
- return granted.access?.schema
528
- }
529
-
530
527
  export const manifestServiceHostnames = (manifest: FabrikaManifest): string[] =>
531
528
  manifest.target.importDocument.services.map((service) => service.hostname)
532
529
 
@@ -1,9 +1,16 @@
1
- import type { ProviderDeploymentNamespace, ProviderRegistration } from '@fabrika/provider-contract'
1
+ import type {
2
+ ProviderClaimedResource,
3
+ ProviderClaimedResourcesInput,
4
+ ProviderDeploymentNamespace,
5
+ ProviderRegistration,
6
+ } from '@fabrika/provider-contract'
2
7
  import { isProxyHost } from '@fabrika/proxy-core/manifest'
8
+ import { type ZeropsApi, ZeropsApiError } from '../api'
3
9
  import { assertFabrikaManifestCoordinates, manifestServiceHostnames, zeropsArtifactCodec } from '../manifest'
4
10
  import { zeropsSharedServicePrefix } from '../service-names'
5
11
  import { decodeEnvelope } from './codec'
6
12
  import { ZEROPS_NAMESPACE_POSTGRES_HOSTNAME, ZEROPS_NAMESPACE_PROXY_HOSTNAME } from './constants'
13
+ import { asNamespaceError } from './errors'
7
14
 
8
15
  export const namespaceResourceClaims = (namespace: ProviderDeploymentNamespace): string[] => {
9
16
  const target = decodeEnvelope(namespace.target)
@@ -75,3 +82,51 @@ export const registrationResourceClaims = (registration: ProviderRegistration):
75
82
  }
76
83
  return hostnames.map((hostname) => `service:${hostname}`).sort()
77
84
  }
85
+
86
+ /**
87
+ * The platform's answer for a project that does not exist, a deleted one included. A project the token holds no grant
88
+ * on answers `403 insufficientPermissions` instead (zerops-platform.md), so this never reads an unreachable one as gone.
89
+ */
90
+ export const ZEROPS_PROJECT_NOT_FOUND = 'projectNotFound'
91
+
92
+ const SERVICE_CLAIM_PREFIX = 'service:'
93
+
94
+ /**
95
+ * Whether each claimed service still exists in the namespace's project. Gone means the project reads `projectNotFound`,
96
+ * or it exists and has no service under the claimed hostname. Any other answer, a key that names no service, or a
97
+ * namespace that never recorded its project throws: Control then releases nothing.
98
+ */
99
+ export const claimedResources = async (
100
+ api: Pick<ZeropsApi, 'getProject' | 'findService'>,
101
+ input: ProviderClaimedResourcesInput,
102
+ ): Promise<ProviderClaimedResource[]> => {
103
+ try {
104
+ const services = input.resourceKeys.map((resourceKey) => {
105
+ const hostname = resourceKey.startsWith(SERVICE_CLAIM_PREFIX) ? resourceKey.slice(SERVICE_CLAIM_PREFIX.length) : ''
106
+ if (hostname === '') throw new Error(`Zerops cannot tell whether claimed resource \`${resourceKey}\` exists`)
107
+ return { resourceKey, hostname }
108
+ })
109
+ const projectId = decodeEnvelope(input.namespace.target).projectId
110
+ // An interrupted first provision can leave a project its marker names but the target does not.
111
+ if (projectId === undefined) {
112
+ throw new Error(`Zerops namespace \`${input.namespace.id}\` records no project, so its services cannot be read back`)
113
+ }
114
+ if (!(await projectExists(api, projectId, input.signal))) return services.map(({ resourceKey }) => ({ resourceKey, exists: false }))
115
+ return await Promise.all(services.map(async ({ resourceKey, hostname }) => ({
116
+ resourceKey,
117
+ exists: (await api.findService({ projectId, hostname, signal: input.signal })) !== null,
118
+ })))
119
+ } catch (cause) {
120
+ throw asNamespaceError(cause, 'claim check')
121
+ }
122
+ }
123
+
124
+ export const projectExists = async (api: Pick<ZeropsApi, 'getProject'>, projectId: string, signal: AbortSignal): Promise<boolean> => {
125
+ try {
126
+ await api.getProject({ projectId, signal })
127
+ return true
128
+ } catch (error) {
129
+ if (error instanceof ZeropsApiError && error.code === ZEROPS_PROJECT_NOT_FOUND) return false
130
+ throw error
131
+ }
132
+ }
@@ -42,7 +42,7 @@ const splitApiMessage = (message: string, code: string): { summary: string; deta
42
42
  }
43
43
 
44
44
  /** Every namespace lifecycle failure leaves this provider as one typed, coded error. */
45
- export const asNamespaceError = (cause: unknown, mode: 'provision' | 'reconcile' | 'update'): ProviderNamespaceError => {
45
+ export const asNamespaceError = (cause: unknown, mode: 'provision' | 'reconcile' | 'update' | 'claim check'): ProviderNamespaceError => {
46
46
  if (cause instanceof ProviderNamespaceError) return cause
47
47
  // The same value is refused again on every retry; only a smaller manifest or a new carrier fixes it.
48
48
  if (cause instanceof ZeropsServiceEnvValueTooLargeError) return namespaceError(cause.code, cause.message, false)
@@ -1,7 +1,6 @@
1
1
  import { appEnvironmentId } from '@fabrika/auth-core'
2
2
  import { decodeEnvelope, type ProviderNamespaceMember } from '@fabrika/provider-contract'
3
- import { isCrossOrigin, isPolicyOrigin, MAX_CROSS_ORIGINS } from '@fabrika/proxy-core/headers'
4
- import { contractProxyTable, type ProxyManifest } from '@fabrika/proxy-core/manifest'
3
+ import { contractProxyEntry, contractProxyTable, type ProxyManifest } from '@fabrika/proxy-core/manifest'
5
4
  import { assertFabrikaManifestCoordinates, zeropsArtifactCodec } from '../manifest'
6
5
 
7
6
  /** Compile every public member of one deployment namespace into its strict proxy manifest. */
@@ -28,29 +27,17 @@ export function compileNamespaceProxyManifest(namespaceId: string, members: read
28
27
  if (hosts.has(host)) throw new Error(`duplicate proxy host \`${host}\` in deployment namespace ${namespaceId}`)
29
28
  ids.add(id)
30
29
  hosts.add(host)
31
- const operationsOrigin = member.operationsOrigin
32
- if (operationsOrigin !== undefined && !isPolicyOrigin(operationsOrigin, ['https:', 'http:'])) {
33
- throw new Error(`Zerops proxy target ${member.appId}/${member.env} has an Operations ingest origin that is not an http(s) origin`)
34
- }
35
- const crossOrigins = member.crossOrigins ?? []
36
- if (
37
- crossOrigins.length > MAX_CROSS_ORIGINS
38
- || new Set(crossOrigins).size !== crossOrigins.length
39
- || crossOrigins.some((origin) => !isCrossOrigin(origin) || new URL(origin).hostname === host)
40
- ) {
41
- throw new Error(`Zerops proxy target ${member.appId}/${member.env} has a cross-origin list the proxy manifest refuses`)
42
- }
43
- apps.push({
30
+ apps.push(contractProxyEntry({
44
31
  id,
45
32
  hosts: [host],
46
33
  upstream: proxy.upstream,
47
34
  // A Zerops public domain is always served over the project's TLS-terminating L7 balancer, so the
48
35
  // browser speaks https even though the proxy itself is reached over plain HTTP (ADR-0022).
49
36
  scheme: 'https',
50
- ...contractProxyTable(access.contract, access.contractDigest),
51
- ...(operationsOrigin === undefined ? {} : { operationsOrigin }),
52
- ...(crossOrigins.length === 0 ? {} : { crossOrigins: [...crossOrigins] }),
53
- })
37
+ table: contractProxyTable(access.contract, access.contractDigest),
38
+ ...(member.operationsOrigin === undefined ? {} : { operationsOrigin: member.operationsOrigin }),
39
+ ...(member.crossOrigins === undefined ? {} : { crossOrigins: member.crossOrigins }),
40
+ }))
54
41
  }
55
42
  return { apps }
56
43
  }
@@ -17,7 +17,7 @@ import {
17
17
  type ZeropsService,
18
18
  } from '../api'
19
19
  import { defaultSleep } from '../collaborators'
20
- import { namespaceResourceClaims, registrationResourceClaims } from './claims'
20
+ import { claimedResources, namespaceResourceClaims, projectExists, registrationResourceClaims, ZEROPS_PROJECT_NOT_FOUND } from './claims'
21
21
  import { checkpoint, decodeEnvelope, normalizedNamespace, normalizeTarget } from './codec'
22
22
  import { ZEROPS_NAMESPACE_POSTGRES_HOSTNAME, ZEROPS_NAMESPACE_PROXY_HOSTNAME, ZEROPS_NAMESPACE_SUBDOMAIN_VARIABLE } from './constants'
23
23
  import { asNamespaceError, namespaceError, ZEROPS_NAMESPACE_CANCELLED, ZEROPS_SUBDOMAIN_NOT_PUBLISHED } from './errors'
@@ -67,10 +67,18 @@ const validateProject = (
67
67
  target: NormalizedZeropsNamespaceTarget,
68
68
  project: ZeropsProject,
69
69
  adopting: boolean,
70
+ installationProjectId: string | undefined,
70
71
  ): void => {
71
72
  if (project.id === '') {
72
73
  throw new Error('Zerops namespace project returned an empty id')
73
74
  }
75
+ // Checked on every reconcile, before the import and the proxy build, so a namespace that already adopted it stops
76
+ // here instead of rebuilding the platform's enforcement point with an empty manifest (ADR-0034).
77
+ if (project.id === installationProjectId) {
78
+ throw new Error(
79
+ `Zerops project ${project.id} is the installation's own project; a deployment namespace needs a project of its own`,
80
+ )
81
+ }
74
82
  if (target.projectName !== project.name) {
75
83
  throw new Error(`Zerops namespace project name mismatch: expected \`${target.projectName}\`, got \`${project.name}\``)
76
84
  }
@@ -222,6 +230,7 @@ const deployProxy = async (
222
230
  target: NormalizedZeropsNamespaceTarget,
223
231
  options: ZeropsNamespaceOptions,
224
232
  wait: ProviderNamespaceRepublishInput['wait'],
233
+ reuseActive: boolean,
225
234
  ): Promise<{ namespace: ProviderDeploymentNamespace; target: NormalizedZeropsNamespaceTarget }> => {
226
235
  let currentNamespace = namespace
227
236
  const manifest = compileNamespaceProxyManifest(namespace.id, input.members)
@@ -229,6 +238,7 @@ const deployProxy = async (
229
238
  namespaceId: namespace.id,
230
239
  target,
231
240
  manifest,
241
+ brand: input.brand ?? null,
232
242
  proxySecrets: namespaceProxySecrets(manifest, input.members),
233
243
  ...(options.source === undefined ? {} : { source: options.source }),
234
244
  api: options.api,
@@ -236,6 +246,7 @@ const deployProxy = async (
236
246
  iamKey: options.iamKey,
237
247
  signal: input.signal,
238
248
  wait,
249
+ reuseActive,
239
250
  sleep: options.sleep ?? defaultSleep,
240
251
  boundary: () => input.events.boundary(),
241
252
  checkpoint: async next => {
@@ -255,7 +266,7 @@ const republishNamespace = async (
255
266
  if (target.proxyServiceId === undefined || (target.proxyBuild === undefined && !target.ready)) {
256
267
  throw new Error(`Namespace ${namespace.id} is not ready`)
257
268
  }
258
- const deployed = await deployProxy(input, namespace, target, options, input.wait)
269
+ const deployed = await deployProxy(input, namespace, target, options, input.wait, true)
259
270
  return { namespace: deployed.namespace, settled: deployed.target.proxyBuild === undefined && deployed.target.ready }
260
271
  }
261
272
 
@@ -358,6 +369,14 @@ const runNamespaceMutation = async (
358
369
  } catch {
359
370
  input.signal.throwIfAborted()
360
371
  await checkpoint(input, namespace, { ...target, ready: false })
372
+ // A deleted project never resolves the version: retrying would hold the namespace `provisioning`, unremovable.
373
+ if (target.projectId !== undefined && !(await projectExists(options.api, target.projectId, input.signal))) {
374
+ throw namespaceError(
375
+ ZEROPS_PROJECT_NOT_FOUND,
376
+ `Zerops project ${target.projectId} of namespace \`${namespace.id}\` no longer exists; remove the namespace`,
377
+ false,
378
+ )
379
+ }
361
380
  throw namespaceError('proxyAttemptUnresolved', `Proxy version ${target.proxyDeployment.appVersionId} remains unresolved`, true)
362
381
  }
363
382
  }
@@ -398,7 +417,7 @@ const runNamespaceMutation = async (
398
417
  target = { ...target, projectName: project.name }
399
418
  namespace = await checkpoint(input, namespace, target)
400
419
  }
401
- validateProject(namespace, target, project, initiallyAdopted)
420
+ validateProject(namespace, target, project, initiallyAdopted, options.installationProjectId)
402
421
 
403
422
  if (initiallyAdopted) {
404
423
  // Validate before the first mutation; the import below then enforces service-level envIsolation.
@@ -433,7 +452,7 @@ const runNamespaceMutation = async (
433
452
  }
434
453
  namespace = await checkpoint(input, namespace, target)
435
454
 
436
- const deployed = await deployProxy(input, namespace, target, options, 'active')
455
+ const deployed = await deployProxy(input, namespace, target, options, 'active', false)
437
456
  namespace = deployed.namespace
438
457
  target = { ...deployed.target, proxyConfigured: true }
439
458
  // After the deploy, because the platform refuses a service with no HTTP port — and never before `ready`
@@ -470,6 +489,7 @@ export const createZeropsNamespaceCapabilities = (options: ZeropsNamespaceOption
470
489
  provision: (input) => mutateNamespace(input, options, 'provision'),
471
490
  reconcile: (input) => mutateNamespace(input, options, 'reconcile'),
472
491
  republish: (input) => republishNamespace(input, options),
492
+ claimedResources: (input) => claimedResources(options.api, input),
473
493
  updates: createZeropsNamespaceUpdates(options),
474
494
  operator: createZeropsNamespaceOperator({ proxySource: options.proxySource }),
475
495
  }