@fabrika/provider-zerops 0.4.2 → 0.4.4
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 +44 -5
- 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 +95 -37
- 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 +169 -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/platform-archive.ts +87 -0
- package/src/installation/platform-upload.ts +2 -2
- package/src/installation/secrets.ts +3 -4
- package/src/installation/sidecar.ts +38 -2
- package/src/installation/templates/README.md +1 -1
- package/src/installation/templates/platform.yml +23 -2
- package/src/installation/topology/setups.ts +15 -27
- package/src/installation/topology/topology.ts +105 -104
- package/src/installation/upgrade.ts +24 -7
- 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
- package/src/installation/manifest.ts +0 -95
|
@@ -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
|
}
|
|
@@ -19,12 +19,20 @@
|
|
|
19
19
|
// answer, never "no such tag": reporting a rate limit as a missing tag sends the operator to look
|
|
20
20
|
// at the wrong repository.
|
|
21
21
|
// • A directory that is not a checkout, and a branch the workflow does not trigger on.
|
|
22
|
-
// • A dirty sidecar tree. The commit stages `fabrika.ref` alone, so anything else the
|
|
23
|
-
// part-way through would be left behind on a branch that just deployed.
|
|
22
|
+
// • A dirty sidecar tree. The commit stages `fabrika.ref` and the workflow alone, so anything else the
|
|
23
|
+
// operator was part-way through would be left behind on a branch that just deployed.
|
|
24
24
|
// • A pin already equal to `--to` — but only when it is PUSHED. A pin committed and not pushed is
|
|
25
25
|
// the state a failed push leaves behind, and "already pins it" would be a lie there: the push that
|
|
26
26
|
// IS the trigger never happened, so nothing was deployed.
|
|
27
27
|
//
|
|
28
|
+
// ── The workflow rides along with the pin ─────────────────────────────────────────────────────────
|
|
29
|
+
//
|
|
30
|
+
// The workflow is CLI-owned (`sidecar.ts`), and a sidecar created by an older CLI keeps its old pipeline
|
|
31
|
+
// until something rewrites it. `init` does, but a roll is the one command every operator runs, so the
|
|
32
|
+
// roll re-renders it too, into the same commit: the pipeline that deploys the new tag is the current one.
|
|
33
|
+
// It is rendered for the GitHub Environment the existing workflow names, never for a name guessed from
|
|
34
|
+
// the repository.
|
|
35
|
+
//
|
|
28
36
|
// ── The commit carries no `[skip ci]`, deliberately ──────────────────────────────────────────────
|
|
29
37
|
//
|
|
30
38
|
// Both scaffold commits do (`@fabrika/installation-init`'s `scaffold.ts`), because a scaffold push must
|
|
@@ -38,7 +46,7 @@ import { tmpdir } from 'node:os'
|
|
|
38
46
|
import { join, resolve } from 'node:path'
|
|
39
47
|
import { defaultSleep, type Sleeper } from '../index'
|
|
40
48
|
import { consoleDeployLog, type DeployLog } from './log'
|
|
41
|
-
import { FABRIKA_REF_FILE, readPinnedTag } from './sidecar'
|
|
49
|
+
import { FABRIKA_REF_FILE, readPinnedTag, readSidecarWorkflowInstallation, renderSidecarWorkflow, SIDECAR_WORKFLOW_PATH } from './sidecar'
|
|
42
50
|
import type { PlatformUpgradeInput } from './upgrade-options'
|
|
43
51
|
import { carriesTag, firstRun } from './upgrade-output'
|
|
44
52
|
|
|
@@ -243,8 +251,8 @@ export const runUpgrade = async (input: PlatformUpgradeInput, collaborators: Upg
|
|
|
243
251
|
const repo = await effects.describeCheckout(dir)
|
|
244
252
|
if (await effects.isDirty(dir)) {
|
|
245
253
|
throw new Error(
|
|
246
|
-
`${dir} has uncommitted changes to tracked files — this command commits \`${FABRIKA_REF_FILE}\`
|
|
247
|
-
+ 'discard them first (untracked files are left alone)',
|
|
254
|
+
`${dir} has uncommitted changes to tracked files — this command commits \`${FABRIKA_REF_FILE}\` and \`${SIDECAR_WORKFLOW_PATH}\` `
|
|
255
|
+
+ 'alone, so commit or discard them first (untracked files are left alone)',
|
|
248
256
|
)
|
|
249
257
|
}
|
|
250
258
|
const unpushed = await checkedBranch(dir, repo, collaborators)
|
|
@@ -263,18 +271,27 @@ export const runUpgrade = async (input: PlatformUpgradeInput, collaborators: Upg
|
|
|
263
271
|
if (pinned === input.to) {
|
|
264
272
|
throw new Error(`${repo} already pins ${input.to}. Nothing to push, and the push is what triggers the deploy`)
|
|
265
273
|
}
|
|
274
|
+
const workflow = await renderSidecarWorkflow(await readSidecarWorkflowInstallation(dir))
|
|
275
|
+
const workflowChanged = (await Bun.file(resolve(dir, SIDECAR_WORKFLOW_PATH)).text()) !== workflow
|
|
276
|
+
const files = workflowChanged ? [FABRIKA_REF_FILE, SIDECAR_WORKFLOW_PATH] : [FABRIKA_REF_FILE]
|
|
266
277
|
const message = upgradeCommitMessage(installationLabel(input, repo), input.to)
|
|
267
278
|
log.info(`${repo}: ${pinned} → ${input.to}`)
|
|
279
|
+
if (workflowChanged) {
|
|
280
|
+
log.info(`${SIDECAR_WORKFLOW_PATH} differs from the one this CLI generates — the roll rewrites it`)
|
|
281
|
+
}
|
|
268
282
|
|
|
269
283
|
if (input.dryRun) {
|
|
270
284
|
log.step('Dry run complete — nothing was written')
|
|
271
|
-
log.info(`would write \`${input.to}\` to ${FABRIKA_REF_FILE} and commit
|
|
285
|
+
log.info(`would write \`${input.to}\` to ${FABRIKA_REF_FILE} and commit ${files.join(' and ')} as \`${message}\``)
|
|
272
286
|
return
|
|
273
287
|
}
|
|
274
288
|
|
|
275
289
|
log.step('Roll the pin forward')
|
|
276
290
|
await Bun.write(resolve(dir, FABRIKA_REF_FILE), `${input.to}\n`)
|
|
277
|
-
|
|
291
|
+
if (workflowChanged) {
|
|
292
|
+
await Bun.write(resolve(dir, SIDECAR_WORKFLOW_PATH), workflow)
|
|
293
|
+
}
|
|
294
|
+
const sha = await effects.commit({ dir, files, message })
|
|
278
295
|
await effects.push(dir)
|
|
279
296
|
log.info(`pushed ${sha}`)
|
|
280
297
|
|
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')
|