@fabrika/provider-zerops 0.4.2 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -7
- package/src/api/client.ts +11 -14
- package/src/api/polling.ts +42 -0
- package/src/api/readers.ts +9 -2
- package/src/api/service-env-limit.ts +29 -0
- package/src/api/service-env-response.ts +34 -2
- package/src/api/types.ts +39 -9
- package/src/api.ts +10 -1
- package/src/control.ts +23 -8
- package/src/execution.ts +41 -3
- package/src/index.ts +5 -0
- package/src/installation/admin.ts +4 -2
- package/src/installation/checker-release.ts +44 -0
- package/src/installation/deploy.ts +66 -22
- package/src/installation/generated/platform-console-schema.ts +18 -2
- package/src/installation/generated/platform-proxy-manifest.ts +20 -0
- package/src/installation/guide.md +164 -52
- package/src/installation/index.ts +22 -15
- package/src/installation/init-options.ts +2 -2
- package/src/installation/init.ts +8 -2
- package/src/installation/install-collaborators.ts +4 -0
- package/src/installation/install-environment.ts +52 -30
- package/src/installation/install-options.ts +20 -19
- package/src/installation/install-project.ts +23 -15
- package/src/installation/install-provisioning.ts +12 -12
- package/src/installation/install-secrets.ts +89 -0
- package/src/installation/install-token.ts +37 -0
- package/src/installation/install.ts +34 -21
- package/src/installation/machine-key.ts +3 -2
- package/src/installation/manifest.ts +29 -12
- package/src/installation/platform-archive.ts +87 -0
- package/src/installation/platform-upload.ts +2 -2
- package/src/installation/secrets.ts +3 -4
- package/src/installation/topology/setups.ts +15 -27
- package/src/installation/topology/topology.ts +105 -104
- package/src/managed-env.ts +11 -0
- package/src/manifest.ts +25 -3
- package/src/namespace/codec.ts +6 -1
- package/src/namespace/errors.ts +3 -1
- package/src/namespace/provisioning.ts +18 -5
- package/src/namespace/proxy-attempt.ts +56 -14
- package/src/namespace/update.ts +50 -8
- package/src/provider.ts +57 -13
- package/src/proxy-manifest-carrier.ts +32 -0
- package/src/source/port.ts +9 -1
|
@@ -117,12 +117,11 @@ export interface ProjectTopology {
|
|
|
117
117
|
/** The source declaration compiled into the generated topology artifacts. */
|
|
118
118
|
target: ZeropsSourceTarget
|
|
119
119
|
/**
|
|
120
|
-
* The SAME services with no `project` block, for
|
|
120
|
+
* The SAME services with no `project` block, for the project `platform install` created or was handed.
|
|
121
121
|
*
|
|
122
122
|
* A document carrying `project:` is only valid at `POST /client/{clientId}/project/import`, which
|
|
123
123
|
* CREATES a project — and Zerops permits duplicate project names, so applying one at an existing
|
|
124
|
-
* installation risks a second project rather than failing. Present
|
|
125
|
-
* imports into an existing project.
|
|
124
|
+
* installation risks a second project rather than failing. Present on both platform tiers.
|
|
126
125
|
*/
|
|
127
126
|
servicesTarget?: ZeropsSourceTarget
|
|
128
127
|
/** The one service allowed to be publicly routed (ADR-0022). Always the proxy. */
|
|
@@ -182,6 +181,107 @@ export const platformTopology = (options: TopologyOptions): ProjectTopology => {
|
|
|
182
181
|
if (tier === 'light') {
|
|
183
182
|
return lightPlatformTopology(options, publicAccess)
|
|
184
183
|
}
|
|
184
|
+
// ONE services function behind both targets, as on the light tier.
|
|
185
|
+
const services = (): ZeropsServiceSpec[] => [
|
|
186
|
+
// ── data, created first (priority is descending) ──────────────────────────────────
|
|
187
|
+
{
|
|
188
|
+
hostname: 'db',
|
|
189
|
+
// HA is encoded in the TYPE. The deprecated `mode` field is not merely avoided here — it
|
|
190
|
+
// 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.
|
|
194
|
+
type: 'postgresql:ha@18',
|
|
195
|
+
// The identity and control-plane database: every login, every token mint, every run's
|
|
196
|
+
// state. `oltp-production` is also what an HA service gets by DEFAULT — stated here so
|
|
197
|
+
// the bill is a decision rather than a discovery, and because this is the one place the
|
|
198
|
+
// standard tier should pay for it: dedicated CPU is what keeps auth latency off the
|
|
199
|
+
// noisy-neighbour curve.
|
|
200
|
+
profile: 'oltp-production',
|
|
201
|
+
priority: 100,
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
hostname: 'storage',
|
|
205
|
+
// S3-compatible object storage: run logs (`FABRIKA_CONTROL_RUN_LOGS_*`) and deploy artifacts.
|
|
206
|
+
// `@fabrika/platform-node`'s `S3BlobStore` speaks to this unchanged — it is the same
|
|
207
|
+
// implementation that speaks to R2.
|
|
208
|
+
type: 'object-storage',
|
|
209
|
+
objectStorageSize: 25,
|
|
210
|
+
// Never `public-read`: a run log can quote a build's environment.
|
|
211
|
+
objectStoragePolicy: 'private',
|
|
212
|
+
// A CDN in front of private run logs would be a cache in front of a thing nobody reads twice.
|
|
213
|
+
enableCdn: false,
|
|
214
|
+
priority: 100,
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
hostname: 'operationsdb',
|
|
218
|
+
// Operations has an independent database service. This prevents its high-volume error
|
|
219
|
+
// history and migrations from sharing IAM/control's failure and capacity domain.
|
|
220
|
+
type: 'postgresql:ha@18',
|
|
221
|
+
// NOT `db`'s profile, and that is the point of stating both. `oltp-staging` keeps the
|
|
222
|
+
// same three-container redundancy and the same 8-core/48 GB ceiling, but starts at one
|
|
223
|
+
// SHARED core and 1 GB instead of two DEDICATED cores and 4 GB — measured off a live
|
|
224
|
+
// HA service, see `docs/reference/zerops-platform.md`. Error history is bursty and
|
|
225
|
+
// tolerant of jitter: slow ingest is a queue, not an outage. Holding two dedicated
|
|
226
|
+
// cores idle for it is the half of the default nobody chose.
|
|
227
|
+
profile: 'oltp-staging',
|
|
228
|
+
priority: 100,
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
hostname: 'operationsstorage',
|
|
232
|
+
type: 'object-storage',
|
|
233
|
+
objectStorageSize: 25,
|
|
234
|
+
objectStoragePolicy: 'private',
|
|
235
|
+
enableCdn: false,
|
|
236
|
+
priority: 100,
|
|
237
|
+
},
|
|
238
|
+
// ── services ──────────────────────────────────────────────────────────────────────
|
|
239
|
+
runtime({
|
|
240
|
+
hostname: 'iam',
|
|
241
|
+
type: 'alpine/bun@1.3',
|
|
242
|
+
priority: 50,
|
|
243
|
+
// Identity is on the critical path of every login and every cold-path token mint, and the
|
|
244
|
+
// service is stateless (its state is in `db`), so two containers cost little and remove a
|
|
245
|
+
// single point of failure the product cannot afford.
|
|
246
|
+
minContainers: 2,
|
|
247
|
+
maxContainers: 4,
|
|
248
|
+
}),
|
|
249
|
+
runtime({
|
|
250
|
+
hostname: 'operations',
|
|
251
|
+
type: 'alpine/bun@1.3',
|
|
252
|
+
priority: 40,
|
|
253
|
+
minContainers: 1,
|
|
254
|
+
maxContainers: 3,
|
|
255
|
+
}),
|
|
256
|
+
runtime({
|
|
257
|
+
hostname: 'control',
|
|
258
|
+
type: 'alpine/bun@1.3',
|
|
259
|
+
// IAM → Operations → proxy → control is the code deployment order too:
|
|
260
|
+
// control must never start against a missing private dependency.
|
|
261
|
+
priority: 20,
|
|
262
|
+
// Deliberately allowed to be a single container. The control plane is CRASH-SAFE across a
|
|
263
|
+
// deploy by design — it may trigger its own redeploy and die, then reconcile in-flight runs
|
|
264
|
+
// by polling `/app-version` on restart (ADR-0003) — so an outage window here is a delay,
|
|
265
|
+
// not a lost run. Scaling it out would also multiply the queue consumers for no gain.
|
|
266
|
+
minContainers: 1,
|
|
267
|
+
maxContainers: 2,
|
|
268
|
+
}),
|
|
269
|
+
runtime({
|
|
270
|
+
hostname: PROXY_HOSTNAME,
|
|
271
|
+
// Alpine custom runtime: a Caddy binary plus a compiled Bun auth service, no interpreter
|
|
272
|
+
// (ADR-0022). `run.os` is deprecated — the OS is the service type.
|
|
273
|
+
type: 'alpine@3.21',
|
|
274
|
+
priority: 30,
|
|
275
|
+
// THE ONLY PUBLICLY ROUTED SERVICE. Everything above is reachable only over the project's
|
|
276
|
+
// private network; the proxy is what the internet talks to, including for IAM's own OIDC
|
|
277
|
+
// login and the control-plane dashboard.
|
|
278
|
+
public: publicAccess === 'zerops-subdomain',
|
|
279
|
+
// It is on every request to every service in this project, and it is stateless by
|
|
280
|
+
// construction (ADR-0022 insists on that precisely so this line is allowed to exist).
|
|
281
|
+
minContainers: 2,
|
|
282
|
+
maxContainers: 6,
|
|
283
|
+
}),
|
|
284
|
+
]
|
|
185
285
|
return {
|
|
186
286
|
id: 'platform',
|
|
187
287
|
publicService: PROXY_HOSTNAME,
|
|
@@ -193,107 +293,9 @@ export const platformTopology = (options: TopologyOptions): ProjectTopology => {
|
|
|
193
293
|
corePackage: 'SERIOUS',
|
|
194
294
|
tags: ['fabrika', 'platform', options.env],
|
|
195
295
|
},
|
|
196
|
-
services
|
|
197
|
-
// ── data, created first (priority is descending) ──────────────────────────────────
|
|
198
|
-
{
|
|
199
|
-
hostname: 'db',
|
|
200
|
-
// HA is encoded in the TYPE. The deprecated `mode` field is not merely avoided here — it
|
|
201
|
-
// is absent from `ZeropsServiceSpec`, so it cannot be written. One database serves both
|
|
202
|
-
// IAM and the control plane; they are separate schemas in it, not separate services,
|
|
203
|
-
// because a second HA Postgres triples the cost of the isolation the private network
|
|
204
|
-
// already gives us.
|
|
205
|
-
type: 'postgresql:ha@18',
|
|
206
|
-
// The identity and control-plane database: every login, every token mint, every run's
|
|
207
|
-
// state. `oltp-production` is also what an HA service gets by DEFAULT — stated here so
|
|
208
|
-
// the bill is a decision rather than a discovery, and because this is the one place the
|
|
209
|
-
// standard tier should pay for it: dedicated CPU is what keeps auth latency off the
|
|
210
|
-
// noisy-neighbour curve.
|
|
211
|
-
profile: 'oltp-production',
|
|
212
|
-
priority: 100,
|
|
213
|
-
},
|
|
214
|
-
{
|
|
215
|
-
hostname: 'storage',
|
|
216
|
-
// S3-compatible object storage: run logs (`FABRIKA_CONTROL_RUN_LOGS_*`) and deploy artifacts.
|
|
217
|
-
// `@fabrika/platform-node`'s `S3BlobStore` speaks to this unchanged — it is the same
|
|
218
|
-
// implementation that speaks to R2.
|
|
219
|
-
type: 'object-storage',
|
|
220
|
-
objectStorageSize: 25,
|
|
221
|
-
// Never `public-read`: a run log can quote a build's environment.
|
|
222
|
-
objectStoragePolicy: 'private',
|
|
223
|
-
// A CDN in front of private run logs would be a cache in front of a thing nobody reads twice.
|
|
224
|
-
enableCdn: false,
|
|
225
|
-
priority: 100,
|
|
226
|
-
},
|
|
227
|
-
{
|
|
228
|
-
hostname: 'operationsdb',
|
|
229
|
-
// Operations has an independent database service. This prevents its high-volume error
|
|
230
|
-
// history and migrations from sharing IAM/control's failure and capacity domain.
|
|
231
|
-
type: 'postgresql:ha@18',
|
|
232
|
-
// NOT `db`'s profile, and that is the point of stating both. `oltp-staging` keeps the
|
|
233
|
-
// same three-container redundancy and the same 8-core/48 GB ceiling, but starts at one
|
|
234
|
-
// SHARED core and 1 GB instead of two DEDICATED cores and 4 GB — measured off a live
|
|
235
|
-
// HA service, see `docs/reference/zerops-platform.md`. Error history is bursty and
|
|
236
|
-
// tolerant of jitter: slow ingest is a queue, not an outage. Holding two dedicated
|
|
237
|
-
// cores idle for it is the half of the default nobody chose.
|
|
238
|
-
profile: 'oltp-staging',
|
|
239
|
-
priority: 100,
|
|
240
|
-
},
|
|
241
|
-
{
|
|
242
|
-
hostname: 'operationsstorage',
|
|
243
|
-
type: 'object-storage',
|
|
244
|
-
objectStorageSize: 25,
|
|
245
|
-
objectStoragePolicy: 'private',
|
|
246
|
-
enableCdn: false,
|
|
247
|
-
priority: 100,
|
|
248
|
-
},
|
|
249
|
-
// ── services ──────────────────────────────────────────────────────────────────────
|
|
250
|
-
runtime({
|
|
251
|
-
hostname: 'iam',
|
|
252
|
-
type: 'alpine/bun@1.3',
|
|
253
|
-
priority: 50,
|
|
254
|
-
// Identity is on the critical path of every login and every cold-path token mint, and the
|
|
255
|
-
// service is stateless (its state is in `db`), so two containers cost little and remove a
|
|
256
|
-
// single point of failure the product cannot afford.
|
|
257
|
-
minContainers: 2,
|
|
258
|
-
maxContainers: 4,
|
|
259
|
-
}),
|
|
260
|
-
runtime({
|
|
261
|
-
hostname: 'operations',
|
|
262
|
-
type: 'alpine/bun@1.3',
|
|
263
|
-
priority: 40,
|
|
264
|
-
minContainers: 1,
|
|
265
|
-
maxContainers: 3,
|
|
266
|
-
}),
|
|
267
|
-
runtime({
|
|
268
|
-
hostname: 'control',
|
|
269
|
-
type: 'alpine/bun@1.3',
|
|
270
|
-
// IAM → Operations → proxy → control is the code deployment order too:
|
|
271
|
-
// control must never start against a missing private dependency.
|
|
272
|
-
priority: 20,
|
|
273
|
-
// Deliberately allowed to be a single container. The control plane is CRASH-SAFE across a
|
|
274
|
-
// deploy by design — it may trigger its own redeploy and die, then reconcile in-flight runs
|
|
275
|
-
// by polling `/app-version` on restart (ADR-0003) — so an outage window here is a delay,
|
|
276
|
-
// not a lost run. Scaling it out would also multiply the queue consumers for no gain.
|
|
277
|
-
minContainers: 1,
|
|
278
|
-
maxContainers: 2,
|
|
279
|
-
}),
|
|
280
|
-
runtime({
|
|
281
|
-
hostname: PROXY_HOSTNAME,
|
|
282
|
-
// Alpine custom runtime: a Caddy binary plus a compiled Bun auth service, no interpreter
|
|
283
|
-
// (ADR-0022). `run.os` is deprecated — the OS is the service type.
|
|
284
|
-
type: 'alpine@3.21',
|
|
285
|
-
priority: 30,
|
|
286
|
-
// THE ONLY PUBLICLY ROUTED SERVICE. Everything above is reachable only over the project's
|
|
287
|
-
// private network; the proxy is what the internet talks to, including for IAM's own OIDC
|
|
288
|
-
// login and the control-plane dashboard.
|
|
289
|
-
public: publicAccess === 'zerops-subdomain',
|
|
290
|
-
// It is on every request to every service in this project, and it is stateless by
|
|
291
|
-
// construction (ADR-0022 insists on that precisely so this line is allowed to exist).
|
|
292
|
-
minContainers: 2,
|
|
293
|
-
maxContainers: 6,
|
|
294
|
-
}),
|
|
295
|
-
],
|
|
296
|
+
services,
|
|
296
297
|
},
|
|
298
|
+
servicesTarget: { platform: 'zerops', services },
|
|
297
299
|
}
|
|
298
300
|
}
|
|
299
301
|
|
|
@@ -371,7 +373,6 @@ const lightPlatformTopology = (options: TopologyOptions, publicAccess: PublicAcc
|
|
|
371
373
|
},
|
|
372
374
|
services,
|
|
373
375
|
},
|
|
374
|
-
// The light tier is the one an operator installs into a project they created themselves.
|
|
375
376
|
servicesTarget: { platform: 'zerops', services },
|
|
376
377
|
}
|
|
377
378
|
}
|
package/src/managed-env.ts
CHANGED
|
@@ -59,6 +59,17 @@ export const SERVICE_MANAGED_KEYS: readonly Exclude<ManagedKey, 'FABRIKA_RELEASE
|
|
|
59
59
|
'FABRIKA_IAM_RPC_KEY',
|
|
60
60
|
]
|
|
61
61
|
|
|
62
|
+
/** The managed keys that carry a credential, written sensitive and therefore never compared. */
|
|
63
|
+
export const SENSITIVE_MANAGED_KEYS: ReadonlySet<ManagedKey> = new Set<ManagedKey>([
|
|
64
|
+
'FABRIKA_OPERATIONS_DSN',
|
|
65
|
+
'FABRIKA_SERVICE_KEY',
|
|
66
|
+
'FABRIKA_PROXY_SECRET',
|
|
67
|
+
'FABRIKA_IAM_RPC_KEY',
|
|
68
|
+
])
|
|
69
|
+
|
|
70
|
+
/** A workload or connect key is a credential; a connect URL is an origin. */
|
|
71
|
+
export const isSensitiveFamilyVariable = (name: string): boolean => !isConnectUrlVariable(name)
|
|
72
|
+
|
|
62
73
|
export type ManagedValues = Record<ManagedKey, string | null>
|
|
63
74
|
|
|
64
75
|
const managedValuesSchema = z.object({
|
package/src/manifest.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { appSchemaSchema } from '@fabrika/auth'
|
|
2
|
-
import { type AppSchema, isDigest } from '@fabrika/auth-core'
|
|
3
|
-
import type {
|
|
2
|
+
import { type AppSchema, type ContractArtifact, isDigest } from '@fabrika/auth-core'
|
|
3
|
+
import type { OperationsSourceMapBuildUpload } from '@fabrika/operations-contract/releases'
|
|
4
|
+
import type { JsonValue, ProviderCodec, ProviderDeployInput, ProviderEnvironment } from '@fabrika/provider-contract'
|
|
4
5
|
import { proxyEndpointRowSchema } from '@fabrika/proxy-core/manifest'
|
|
5
6
|
import { z } from 'zod'
|
|
6
7
|
import { compileImport, ENV_ISOLATION, OVERRIDE, renderYaml } from './compile'
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
LOCKED_APP_ID_PATTERN,
|
|
12
13
|
lockedAuthoredBuild,
|
|
13
14
|
ZEROPS_RUNTIME_PACKAGES,
|
|
15
|
+
zeropsAssetRoots,
|
|
14
16
|
type ZeropsContractRun,
|
|
15
17
|
zeropsContractRun,
|
|
16
18
|
type ZeropsLockedExecution,
|
|
@@ -420,6 +422,7 @@ export const lockedExecutionOf = (manifest: FabrikaManifest): ZeropsLockedExecut
|
|
|
420
422
|
databaseUrl: applicationDatabaseUrl(manifest.target.importDocument.services, manifest.target.namespaceResources),
|
|
421
423
|
storageHostname: applicationStorageHostname(manifest.target.importDocument.services),
|
|
422
424
|
runtimePackages: manifest.target.runtimePackages ?? [],
|
|
425
|
+
sourceMaps: null,
|
|
423
426
|
}
|
|
424
427
|
}
|
|
425
428
|
|
|
@@ -430,7 +433,12 @@ export type ZeropsAccess = Pick<ProviderEnvironment, 'access'>
|
|
|
430
433
|
* The execution descriptor's lock-down at deploy (ADR-0050): Control's approved contract decides it, not the artifact,
|
|
431
434
|
* so a configuration cannot drop it. Without an approved contract nothing deploys outside a dry run.
|
|
432
435
|
*/
|
|
433
|
-
export function lockedExecutionFor(
|
|
436
|
+
export function lockedExecutionFor(
|
|
437
|
+
manifest: FabrikaManifest,
|
|
438
|
+
granted: ZeropsAccess,
|
|
439
|
+
dryRun: boolean,
|
|
440
|
+
artifactUpload?: ProviderDeployInput['artifactUpload'],
|
|
441
|
+
): ZeropsLockedExecution | null {
|
|
434
442
|
const access = granted.access
|
|
435
443
|
if (access === undefined) {
|
|
436
444
|
if (dryRun) return null
|
|
@@ -445,9 +453,23 @@ export function lockedExecutionFor(manifest: FabrikaManifest, granted: ZeropsAcc
|
|
|
445
453
|
databaseUrl: applicationDatabaseUrl(manifest.target.importDocument.services, manifest.target.namespaceResources),
|
|
446
454
|
storageHostname: applicationStorageHostname(manifest.target.importDocument.services),
|
|
447
455
|
runtimePackages: manifest.target.runtimePackages ?? [],
|
|
456
|
+
sourceMaps: artifactUpload === undefined ? null : sourceMapUpload(artifactUpload, access.contract),
|
|
448
457
|
}
|
|
449
458
|
}
|
|
450
459
|
|
|
460
|
+
/** The build's upload: Control's destination without its bearer, and the approved contract's asset roots. */
|
|
461
|
+
const sourceMapUpload = (upload: NonNullable<ProviderDeployInput['artifactUpload']>, contract: ContractArtifact): OperationsSourceMapBuildUpload => ({
|
|
462
|
+
destination: {
|
|
463
|
+
url: upload.url,
|
|
464
|
+
appId: upload.appId,
|
|
465
|
+
environment: upload.environment,
|
|
466
|
+
serviceKey: upload.serviceKey,
|
|
467
|
+
release: upload.release,
|
|
468
|
+
runId: upload.runId,
|
|
469
|
+
},
|
|
470
|
+
assetRoots: zeropsAssetRoots(contract),
|
|
471
|
+
})
|
|
472
|
+
|
|
451
473
|
/** The IAM schema a deploy, cancel or reconcile applies: the approved contract's. `undefined` reconciles nothing. */
|
|
452
474
|
export function approvedSchemaOf(granted: ZeropsAccess): AppSchema | undefined {
|
|
453
475
|
return granted.access?.schema
|
package/src/namespace/codec.ts
CHANGED
|
@@ -10,6 +10,9 @@ import { commitShaSchema, identifierSchema, rejectExplicitUndefined, repositoryS
|
|
|
10
10
|
import { namespaceProxySourceSchema } from './proxy-source'
|
|
11
11
|
import type { NormalizedZeropsNamespaceTarget, ZeropsNamespaceOptions, ZeropsNamespaceProxySource, ZeropsNamespaceTarget } from './types'
|
|
12
12
|
|
|
13
|
+
/** The one value an update may give `proxySource`: re-pin the namespace's proxy to the installation's default. */
|
|
14
|
+
export const ZEROPS_PROXY_SOURCE_CURRENT = 'current'
|
|
15
|
+
|
|
13
16
|
const encodeSource = (source: ZeropsNamespaceProxySource): JsonValue => ({
|
|
14
17
|
repository: { ...source.repository },
|
|
15
18
|
commitSha: source.commitSha,
|
|
@@ -73,10 +76,12 @@ const namespaceTargetSchema = namespaceTargetShape.transform((target) => {
|
|
|
73
76
|
/**
|
|
74
77
|
* What an update request's payload may name: any target field, each optional, and no field the target
|
|
75
78
|
* does not have — at the top level or inside `postgres`. The target schema STRIPS an unknown key, which
|
|
76
|
-
* would turn a misspelt field into a silent no-op.
|
|
79
|
+
* would turn a misspelt field into a silent no-op. `proxySource` also takes `current`, the installation's
|
|
80
|
+
* default pin; the plan refuses any other pin than that one (ADR-0045).
|
|
77
81
|
*/
|
|
78
82
|
export const namespaceUpdatePayloadSchema = namespaceTargetShape.partial().extend({
|
|
79
83
|
postgres: z.discriminatedUnion('type', [haPostgresSchema.strict(), singlePostgresSchema.strict()]).optional(),
|
|
84
|
+
proxySource: z.union([z.literal(ZEROPS_PROXY_SOURCE_CURRENT), namespaceProxySourceSchema]).optional(),
|
|
80
85
|
}).strict()
|
|
81
86
|
|
|
82
87
|
export const zeropsNamespaceTargetCodec: ProviderCodec<ZeropsNamespaceTarget> = {
|
package/src/namespace/errors.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ProviderNamespaceError } from '@fabrika/provider-contract'
|
|
2
|
-
import { ZeropsApiError } from '../api'
|
|
2
|
+
import { ZeropsApiError, ZeropsServiceEnvValueTooLargeError } from '../api'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Lifecycle codes this provider defines where the platform has none of its own. They are an
|
|
@@ -41,6 +41,8 @@ const splitApiMessage = (message: string, code: string): { summary: string; deta
|
|
|
41
41
|
/** Every namespace lifecycle failure leaves this provider as one typed, coded error. */
|
|
42
42
|
export const asNamespaceError = (cause: unknown, mode: 'provision' | 'reconcile' | 'update'): ProviderNamespaceError => {
|
|
43
43
|
if (cause instanceof ProviderNamespaceError) return cause
|
|
44
|
+
// The same value is refused again on every retry; only a smaller manifest or a new carrier fixes it.
|
|
45
|
+
if (cause instanceof ZeropsServiceEnvValueTooLargeError) return namespaceError(cause.code, cause.message, false)
|
|
44
46
|
if (cause instanceof ZeropsApiError) {
|
|
45
47
|
const { summary, detail } = splitApiMessage(cause.message, cause.code)
|
|
46
48
|
return namespaceError(
|
|
@@ -6,7 +6,16 @@ import {
|
|
|
6
6
|
type ProviderNamespaceRepublishInput,
|
|
7
7
|
type ProviderNamespaceRepublishResult,
|
|
8
8
|
} from '@fabrika/provider-contract'
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
enableSubdomainAccessWhenHttp,
|
|
11
|
+
readableContent,
|
|
12
|
+
waitForProcess,
|
|
13
|
+
ZEROPS_SERVICE_NOT_HTTP,
|
|
14
|
+
ZEROPS_TERMINAL,
|
|
15
|
+
ZeropsApiError,
|
|
16
|
+
type ZeropsProject,
|
|
17
|
+
type ZeropsService,
|
|
18
|
+
} from '../api'
|
|
10
19
|
import { defaultSleep } from '../collaborators'
|
|
11
20
|
import { namespaceResourceClaims, registrationResourceClaims } from './claims'
|
|
12
21
|
import { checkpoint, decodeEnvelope, normalizedNamespace, normalizeTarget } from './codec'
|
|
@@ -179,8 +188,8 @@ const readProxyHosts = async (
|
|
|
179
188
|
}
|
|
180
189
|
try {
|
|
181
190
|
const variables = await options.api.listServiceEnv({ serviceId: proxyServiceId, signal })
|
|
182
|
-
const subdomains = variables.find((variable) => variable.key === ZEROPS_NAMESPACE_SUBDOMAIN_VARIABLE)
|
|
183
|
-
return subdomains === undefined ? [] : parseZeropsProxyHosts(subdomains
|
|
191
|
+
const subdomains = readableContent(variables.find((variable) => variable.key === ZEROPS_NAMESPACE_SUBDOMAIN_VARIABLE))
|
|
192
|
+
return subdomains === undefined ? [] : parseZeropsProxyHosts(subdomains)
|
|
184
193
|
} catch {
|
|
185
194
|
// A host listing is a convenience; the proxy is already built, deployed and published. Failing the
|
|
186
195
|
// namespace over it would report a placement that WORKS as broken. Never the cause: it can carry
|
|
@@ -217,6 +226,7 @@ const deployProxy = async (
|
|
|
217
226
|
let currentNamespace = namespace
|
|
218
227
|
const manifest = compileNamespaceProxyManifest(namespace.id, input.members)
|
|
219
228
|
const result = await republishProxy({
|
|
229
|
+
namespaceId: namespace.id,
|
|
220
230
|
target,
|
|
221
231
|
manifest,
|
|
222
232
|
proxySecrets: namespaceProxySecrets(manifest, input.members),
|
|
@@ -264,6 +274,9 @@ const republishNamespace = async (
|
|
|
264
274
|
*
|
|
265
275
|
* The decision is the READ-BACK, never the call returning. On an already-published service the platform
|
|
266
276
|
* answers 2xx and then fails the process it handed back — live-verified — so a 2xx proves nothing.
|
|
277
|
+
*
|
|
278
|
+
* The HTTP ports lag the proxy's ACTIVE version, so `serviceStackIsNotHttp` is retried for a bounded
|
|
279
|
+
* window (`enableSubdomainAccessWhenHttp`) before it fails the namespace.
|
|
267
280
|
*/
|
|
268
281
|
const ensureSubdomainAccess = async (
|
|
269
282
|
target: NormalizedZeropsNamespaceTarget,
|
|
@@ -279,12 +292,12 @@ const ensureSubdomainAccess = async (
|
|
|
279
292
|
return
|
|
280
293
|
}
|
|
281
294
|
try {
|
|
282
|
-
await options.api
|
|
295
|
+
await enableSubdomainAccessWhenHttp({ api: options.api, serviceId: proxyServiceId, sleep: options.sleep ?? defaultSleep, signal })
|
|
283
296
|
} catch (error) {
|
|
284
297
|
if (error instanceof ZeropsApiError && error.code === ZEROPS_SERVICE_NOT_HTTP) {
|
|
285
298
|
throw namespaceError(
|
|
286
299
|
ZEROPS_SERVICE_NOT_HTTP,
|
|
287
|
-
`Zerops namespace proxy ${proxyServiceId} cannot be published on a zerops.app subdomain: it exposes no deployed HTTP port (${ZEROPS_SERVICE_NOT_HTTP})`,
|
|
300
|
+
`Zerops namespace proxy ${proxyServiceId} cannot be published on a zerops.app subdomain: it still exposes no deployed HTTP port (${ZEROPS_SERVICE_NOT_HTTP}) minutes after its deploy`,
|
|
288
301
|
false,
|
|
289
302
|
)
|
|
290
303
|
}
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { followLeaseLoss, isLeaseLoss, ProviderNamespaceError } from '@fabrika/provider-contract'
|
|
2
2
|
import { encodeProxyManifestJson, FABRIKA_PROXY_MANIFEST_JSON, type ProxyManifest } from '@fabrika/proxy-core/manifest'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
readableContent,
|
|
5
|
+
ZEROPS_ACTIVE,
|
|
6
|
+
ZEROPS_TERMINAL,
|
|
7
|
+
type ZeropsApi,
|
|
8
|
+
ZeropsApiError,
|
|
9
|
+
type ZeropsAppVersion,
|
|
10
|
+
ZeropsServiceEnvValueTooLargeError,
|
|
11
|
+
} from '../api'
|
|
4
12
|
import type { Sleeper } from '../collaborators'
|
|
5
13
|
import { prepareZeropsDescriptor } from '../descriptor'
|
|
14
|
+
import { ZEROPS_PROXY_MANIFEST_PATH, type ZeropsProxyManifestCarrier, zeropsProxyManifestCarrier } from '../proxy-manifest-carrier'
|
|
6
15
|
import { isZeropsUploadDestination } from '../source'
|
|
7
16
|
import type { ZeropsSourcePort } from '../source/port'
|
|
8
17
|
import { deleteVersion } from '../version-attempt'
|
|
@@ -12,10 +21,18 @@ import type { ZeropsNamespaceProxyBuild, ZeropsNamespaceTarget } from './types'
|
|
|
12
21
|
|
|
13
22
|
export type ZeropsProxyApi = Pick<
|
|
14
23
|
ZeropsApi,
|
|
15
|
-
|
|
24
|
+
| 'listServiceEnv'
|
|
25
|
+
| 'putServiceEnv'
|
|
26
|
+
| 'deleteServiceEnv'
|
|
27
|
+
| 'createAppVersion'
|
|
28
|
+
| 'deleteAppVersion'
|
|
29
|
+
| 'buildAndDeployAppVersion'
|
|
30
|
+
| 'getAppVersion'
|
|
16
31
|
>
|
|
17
32
|
|
|
18
33
|
export interface ZeropsProxyRepublishInput {
|
|
34
|
+
/** The namespace's id, named in the one refusal an operator acts on: re-pin it. */
|
|
35
|
+
readonly namespaceId: string
|
|
19
36
|
readonly target: ZeropsNamespaceTarget
|
|
20
37
|
readonly manifest: ProxyManifest
|
|
21
38
|
readonly source?: ZeropsSourcePort
|
|
@@ -46,19 +63,24 @@ const digest = async (text: string): Promise<string> =>
|
|
|
46
63
|
'',
|
|
47
64
|
)
|
|
48
65
|
|
|
49
|
-
const writeProxyIam = async (input: ZeropsProxyRepublishInput, serviceId: string): Promise<void> => {
|
|
66
|
+
const writeProxyIam = async (input: ZeropsProxyRepublishInput, serviceId: string, carrier: ZeropsProxyManifestCarrier): Promise<void> => {
|
|
50
67
|
const { api, signal } = input
|
|
51
68
|
const variables = await api.listServiceEnv({ serviceId, signal })
|
|
52
|
-
|
|
53
|
-
|
|
69
|
+
// The archive carries the manifest now; a copy left in the environment would describe nothing the proxy serves.
|
|
70
|
+
const stale = variables.find(variable => variable.key === FABRIKA_PROXY_MANIFEST_JSON)
|
|
71
|
+
if (carrier === 'archive' && stale !== undefined) {
|
|
72
|
+
await api.deleteServiceEnv({ envId: stale.id, signal })
|
|
54
73
|
}
|
|
55
|
-
if (variables.find(variable => variable.key ===
|
|
56
|
-
await api.putServiceEnv({ serviceId, key:
|
|
74
|
+
if (readableContent(variables.find(variable => variable.key === SESSION_OWNER_VARIABLE)) !== 'null') {
|
|
75
|
+
await api.putServiceEnv({ serviceId, key: SESSION_OWNER_VARIABLE, value: 'null', sensitive: false, signal })
|
|
57
76
|
}
|
|
58
|
-
|
|
59
|
-
|
|
77
|
+
if (readableContent(variables.find(variable => variable.key === ZEROPS_NAMESPACE_IAM_ISSUER_VARIABLE)) !== input.iamUrl) {
|
|
78
|
+
await api.putServiceEnv({ serviceId, key: ZEROPS_NAMESPACE_IAM_ISSUER_VARIABLE, value: input.iamUrl, sensitive: false, signal })
|
|
79
|
+
}
|
|
80
|
+
// A secret cannot be read back, so it is overwritten without inspecting its value.
|
|
81
|
+
await api.putServiceEnv({ serviceId, key: ZEROPS_NAMESPACE_IAM_KEY_VARIABLE, value: input.iamKey, sensitive: true, signal })
|
|
60
82
|
for (const [key, value] of input.proxySecrets ?? []) {
|
|
61
|
-
await api.putServiceEnv({ serviceId, key, value, signal })
|
|
83
|
+
await api.putServiceEnv({ serviceId, key, value, sensitive: true, signal })
|
|
62
84
|
}
|
|
63
85
|
}
|
|
64
86
|
|
|
@@ -76,6 +98,8 @@ export async function republishProxy(input: ZeropsProxyRepublishInput): Promise<
|
|
|
76
98
|
const serviceId = input.target.proxyServiceId
|
|
77
99
|
if (serviceId === undefined) throw new Error('Zerops namespace has no proxy service id')
|
|
78
100
|
await verifyZeropsNamespaceProxySource(pin)
|
|
101
|
+
// The pinned descriptor decides: a namespace kept on an older proxy revision still lifts the variable (ADR-0045).
|
|
102
|
+
const carrier = zeropsProxyManifestCarrier(pin.descriptor.contents)
|
|
79
103
|
const manifest = encodeProxyManifestJson(input.manifest)
|
|
80
104
|
// The attempt's identity covers the IAM configuration and the proxy secrets too, so rotating any forces a rebuild.
|
|
81
105
|
const proxySecrets = input.proxySecrets ?? new Map<string, string>()
|
|
@@ -162,8 +186,20 @@ export async function republishProxy(input: ZeropsProxyRepublishInput): Promise<
|
|
|
162
186
|
throw new Error('Proxy source resolution identity mismatch')
|
|
163
187
|
}
|
|
164
188
|
await input.boundary()
|
|
165
|
-
|
|
166
|
-
|
|
189
|
+
if (carrier === 'environment') {
|
|
190
|
+
try {
|
|
191
|
+
await input.api.putServiceEnv({ serviceId, key: FABRIKA_PROXY_MANIFEST_JSON, value: manifest, sensitive: false, signal: input.signal })
|
|
192
|
+
} catch (cause) {
|
|
193
|
+
if (!(cause instanceof ZeropsServiceEnvValueTooLargeError)) throw cause
|
|
194
|
+
throw new ProviderNamespaceError(
|
|
195
|
+
`${cause.message}. The namespace's proxy source predates the manifest file in the build archive; re-pin it with `
|
|
196
|
+
+ `\`fabrika control namespaces update --namespace=${input.namespaceId} --proxy-source=current\``,
|
|
197
|
+
cause.code,
|
|
198
|
+
false,
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
await writeProxyIam(input, serviceId, carrier)
|
|
167
203
|
input.signal.throwIfAborted()
|
|
168
204
|
const reserved = await input.api.createAppVersion({ serviceId, name: runId, signal: input.signal })
|
|
169
205
|
let build: ZeropsNamespaceProxyBuild = { appVersionId: reserved.id, triggered: false, manifestSha256 }
|
|
@@ -176,11 +212,17 @@ export async function republishProxy(input: ZeropsProxyRepublishInput): Promise<
|
|
|
176
212
|
repository: pin.repository,
|
|
177
213
|
commitSha: pin.commitSha,
|
|
178
214
|
descriptorSha256: pin.descriptor.sha256,
|
|
179
|
-
archive:
|
|
215
|
+
archive: carrier === 'archive'
|
|
216
|
+
? { kind: 'platform', dataFile: { path: ZEROPS_PROXY_MANIFEST_PATH, contents: manifest } }
|
|
217
|
+
: { kind: 'platform' },
|
|
180
218
|
uploadUrl: reserved.uploadUrl,
|
|
181
219
|
signal: input.signal,
|
|
182
220
|
})
|
|
183
|
-
|
|
221
|
+
const expectedDataFile = carrier === 'archive' ? await digest(manifest) : undefined
|
|
222
|
+
if (
|
|
223
|
+
uploaded.commitSha !== pin.commitSha || uploaded.descriptorSha256 !== pin.descriptor.sha256
|
|
224
|
+
|| uploaded.dataFileSha256 !== expectedDataFile
|
|
225
|
+
) {
|
|
184
226
|
throw new Error('Proxy source upload identity mismatch')
|
|
185
227
|
}
|
|
186
228
|
const descriptor = prepareZeropsDescriptor(pin.descriptor.contents, 'proxy')
|
package/src/namespace/update.ts
CHANGED
|
@@ -9,8 +9,9 @@ import {
|
|
|
9
9
|
} from '@fabrika/provider-contract'
|
|
10
10
|
import { waitForProcess, ZeropsApiError, type ZeropsProcess } from '../api'
|
|
11
11
|
import { defaultSleep } from '../collaborators'
|
|
12
|
-
import { envelope, namespaceUpdatePayloadSchema, normalizeTarget, zeropsNamespaceTargetCodec } from './codec'
|
|
12
|
+
import { envelope, namespaceUpdatePayloadSchema, normalizeTarget, ZEROPS_PROXY_SOURCE_CURRENT, zeropsNamespaceTargetCodec } from './codec'
|
|
13
13
|
import { asNamespaceError, namespaceError, ZEROPS_NAMESPACE_CANCELLED } from './errors'
|
|
14
|
+
import { verifyZeropsNamespaceProxySource } from './proxy-source'
|
|
14
15
|
import type { NormalizedZeropsNamespaceTarget, ZeropsNamespaceOptions, ZeropsNamespacePostgres, ZeropsNamespaceTarget } from './types'
|
|
15
16
|
import { validateService } from './validation'
|
|
16
17
|
|
|
@@ -21,11 +22,16 @@ const READBACK_INTERVAL_MS = 3_000
|
|
|
21
22
|
/** UNVERIFIED: how soon `autoscalingProfileId` reads the new profile after the PUT and its process finish. */
|
|
22
23
|
const READBACK_ATTEMPTS = 10
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
/** The plan field of a re-pin, which moves only the stored target; the reconcile core queues rebuilds the proxy. */
|
|
26
|
+
export const ZEROPS_PROXY_SOURCE_FIELD = 'proxySource'
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
type FixedField = Exclude<keyof ZeropsNamespaceTarget, 'postgres' | 'proxySource'>
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Every target field but the two an update may move: the PostgreSQL profile, and the proxy source pin, which only an
|
|
32
|
+
* explicit re-pin to the installation's default moves (ADR-0045). A new field fails to compile until listed.
|
|
33
|
+
*/
|
|
27
34
|
const FIXED_FIELDS: { readonly [K in FixedField]: (target: ZeropsNamespaceTarget) => ZeropsNamespaceTarget[K] } = {
|
|
28
|
-
proxySource: (target) => target.proxySource,
|
|
29
35
|
proxyBuild: (target) => target.proxyBuild,
|
|
30
36
|
proxyDeployment: (target) => target.proxyDeployment,
|
|
31
37
|
projectName: (target) => target.projectName,
|
|
@@ -69,10 +75,17 @@ const mergedTarget = (
|
|
|
69
75
|
if (typeof stored !== 'object' || stored === null || Array.isArray(stored)) {
|
|
70
76
|
throw new Error('Zerops namespace target did not encode to an object')
|
|
71
77
|
}
|
|
72
|
-
|
|
78
|
+
if (checked.data.proxySource !== ZEROPS_PROXY_SOURCE_CURRENT) {
|
|
79
|
+
return normalizeTarget({ ...namespace, target: { ...requested, payload: { ...stored, ...payload } } }, options)
|
|
80
|
+
}
|
|
81
|
+
const fields = Object.fromEntries(Object.entries(payload).filter(([key]) => key !== 'proxySource'))
|
|
82
|
+
return {
|
|
83
|
+
...normalizeTarget({ ...namespace, target: { ...requested, payload: { ...stored, ...fields } } }, options),
|
|
84
|
+
proxySource: options.proxySource,
|
|
85
|
+
}
|
|
73
86
|
}
|
|
74
87
|
|
|
75
|
-
/** Refuse every difference except the PostgreSQL profile, naming the fields. */
|
|
88
|
+
/** Refuse every difference except the PostgreSQL profile and a re-pin of the proxy source, naming the fields. */
|
|
76
89
|
const planUpdate = (
|
|
77
90
|
namespace: ProviderDeploymentNamespace,
|
|
78
91
|
requested: ProviderEnvelope,
|
|
@@ -84,8 +97,32 @@ const planUpdate = (
|
|
|
84
97
|
.filter(([, read]) => JSON.stringify(read(current)) !== JSON.stringify(read(merged)))
|
|
85
98
|
.map(([field]) => field)
|
|
86
99
|
if (changed.length > 0) {
|
|
87
|
-
throw immutable(
|
|
100
|
+
throw immutable(
|
|
101
|
+
`Zerops namespace ${namespace.id} cannot change ${changed.join(', ')} in place; only the PostgreSQL profile and the proxy source can`,
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
if (JSON.stringify(current.proxySource) === JSON.stringify(merged.proxySource)) return planProfile(namespace, current, merged)
|
|
105
|
+
if (JSON.stringify(merged.proxySource) !== JSON.stringify(options.proxySource)) {
|
|
106
|
+
throw new Error(`Zerops namespace ${namespace.id} can re-pin its proxy source only to the installation's default: proxySource "current"`)
|
|
107
|
+
}
|
|
108
|
+
if (JSON.stringify(current.postgres) !== JSON.stringify(merged.postgres)) {
|
|
109
|
+
throw new Error(`Zerops namespace ${namespace.id}: re-pin the proxy source and change the PostgreSQL profile in separate updates`)
|
|
110
|
+
}
|
|
111
|
+
// Rebuilding under a new pin settles an attempt first, and would record that attempt's version as the new pin's.
|
|
112
|
+
if (current.proxyBuild !== undefined) {
|
|
113
|
+
throw new Error(`Zerops namespace ${namespace.id} has a proxy build in flight; reconcile it before re-pinning the proxy source`)
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
namespace: { ...namespace, target: envelope(merged) },
|
|
117
|
+
change: { field: ZEROPS_PROXY_SOURCE_FIELD, from: current.proxySource.commitSha, to: merged.proxySource.commitSha },
|
|
88
118
|
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const planProfile = (
|
|
122
|
+
namespace: ProviderDeploymentNamespace,
|
|
123
|
+
current: NormalizedZeropsNamespaceTarget,
|
|
124
|
+
merged: NormalizedZeropsNamespaceTarget,
|
|
125
|
+
): ProviderNamespaceUpdatePlan => {
|
|
89
126
|
const was = current.postgres
|
|
90
127
|
const wanted = merged.postgres
|
|
91
128
|
const unchanged: ProviderNamespaceUpdatePlan = { namespace: { ...namespace, target: envelope(current) }, change: null }
|
|
@@ -186,10 +223,15 @@ const applyProfile = async (
|
|
|
186
223
|
|
|
187
224
|
const applyUpdate = async (input: ProviderNamespaceUpdateInput, options: ZeropsNamespaceOptions): Promise<ProviderDeploymentNamespace> => {
|
|
188
225
|
const current = normalizeTarget(input.namespace, options)
|
|
226
|
+
const plan = planUpdate(input.namespace, input.next.target, options)
|
|
227
|
+
// A re-pin touches no platform resource: it stores the pin, and the reconcile core queues builds from it.
|
|
228
|
+
if (plan.change?.field === ZEROPS_PROXY_SOURCE_FIELD) {
|
|
229
|
+
await verifyZeropsNamespaceProxySource(normalizeTarget(plan.namespace, options).proxySource)
|
|
230
|
+
return plan.namespace
|
|
231
|
+
}
|
|
189
232
|
if (!current.ready) {
|
|
190
233
|
throw new Error(`Zerops namespace ${input.namespace.id} is not ready`)
|
|
191
234
|
}
|
|
192
|
-
const plan = planUpdate(input.namespace, input.next.target, options)
|
|
193
235
|
const wanted = normalizeTarget(plan.namespace, options).postgres
|
|
194
236
|
// Checked even with no planned change: after an unconfirmed update, confirming the live service is
|
|
195
237
|
// what lets core return the namespace to ready.
|