@fabrika/provider-zerops 0.2.0 → 0.3.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.
- package/package.json +18 -5
- package/src/api/client.ts +522 -0
- package/src/api/errors.ts +50 -0
- package/src/api/polling.ts +59 -0
- package/src/api/readers.ts +185 -0
- package/src/api/service-env-response.ts +34 -0
- package/src/api/types.ts +602 -0
- package/src/api.ts +29 -1378
- package/src/authoring.ts +7 -10
- package/src/cli-args.ts +0 -5
- package/src/cli.ts +3 -28
- package/src/collaborators.ts +2 -4
- package/src/compile.ts +3 -1
- package/src/control.ts +203 -450
- package/src/descriptor.ts +135 -0
- package/src/index.ts +23 -127
- package/src/installation/admin-options.ts +128 -0
- package/src/installation/admin.ts +123 -0
- package/src/installation/deploy-options.ts +155 -0
- package/src/installation/deploy.ts +668 -0
- package/src/installation/generated/platform-console-schema.ts +41 -0
- package/src/installation/generated/platform-proxy-manifest.ts +72 -0
- package/src/installation/hosts.ts +117 -0
- package/src/installation/index.ts +366 -0
- package/src/installation/init.ts +521 -0
- package/src/installation/install-collaborators.ts +28 -0
- package/src/installation/install-environment.ts +193 -0
- package/src/installation/install-options.ts +193 -0
- package/src/installation/install-project.ts +201 -0
- package/src/installation/install-provisioning.ts +185 -0
- package/src/installation/install.ts +286 -0
- package/src/installation/installation-source.ts +42 -0
- package/src/installation/log.ts +77 -0
- package/src/installation/machine-key.ts +65 -0
- package/src/installation/manifest.ts +95 -0
- package/src/installation/placement-options.ts +33 -0
- package/src/installation/platform-source.ts +106 -0
- package/src/installation/platform-upload.ts +34 -0
- package/src/installation/proxy-manifest.ts +113 -0
- package/src/installation/secrets.ts +79 -0
- package/src/installation/sidecar.ts +122 -0
- package/src/installation/source-options.ts +12 -0
- package/src/installation/templates/README.md +116 -0
- package/src/installation/templates/fabrika.ref +1 -0
- package/src/installation/templates/gitignore +3 -0
- package/src/installation/templates/platform.yml +144 -0
- package/src/installation/topology/artifacts.ts +141 -0
- package/src/installation/topology/index.ts +13 -0
- package/src/installation/topology/installation-schemas.ts +225 -0
- package/src/installation/topology/invariants.ts +86 -0
- package/src/installation/topology/setups.ts +442 -0
- package/src/installation/topology/topology.ts +501 -0
- package/src/installation/topology/validate.ts +26 -0
- package/src/installation/upgrade-options.ts +108 -0
- package/src/installation/upgrade-output.ts +31 -0
- package/src/installation/upgrade.ts +373 -0
- package/src/managed-env.ts +105 -0
- package/src/manifest.ts +191 -344
- package/src/namespace/claims.ts +77 -0
- package/src/namespace/codec.ts +179 -0
- package/src/namespace/constants.ts +30 -0
- package/src/namespace/errors.ts +60 -0
- package/src/namespace/manifest.ts +28 -0
- package/src/namespace/operator.ts +151 -0
- package/src/namespace/provisioning.ts +482 -0
- package/src/namespace/proxy-attempt.ts +220 -0
- package/src/namespace/proxy-source.ts +22 -0
- package/src/namespace/topology.ts +115 -0
- package/src/namespace/types.ts +114 -0
- package/src/namespace.ts +34 -1271
- package/src/plan.ts +21 -23
- package/src/provider.ts +266 -241
- package/src/source/constants.ts +11 -0
- package/src/source/port.ts +49 -0
- package/src/source/types.ts +4 -0
- package/src/source/validation.ts +72 -0
- package/src/source.ts +2 -1220
- package/src/types.ts +8 -7
- package/src/version-attempt.ts +18 -0
- package/src/codec.ts +0 -108
- package/src/namespace-command.ts +0 -209
- package/src/source-connection.ts +0 -344
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
// fabrika's OWN Zerops topology: the two projects an installation is made of, declared in TypeScript and
|
|
2
|
+
// compiled to `zerops-import.yaml` by the same compiler the deploy driver uses
|
|
3
|
+
// (`compileImportYaml` / `compileProvisioningYaml` in `src/compile.ts`).
|
|
4
|
+
//
|
|
5
|
+
// ── The two projects, and why two ─────────────────────────────────────────────────────────────────
|
|
6
|
+
//
|
|
7
|
+
// platform corePackage SERIOUS proxy · iam · operations · control · db/storage pairs
|
|
8
|
+
// apps-prod corePackage SERIOUS proxy — the apps themselves arrive as their own imports
|
|
9
|
+
//
|
|
10
|
+
// A Zerops project is the unit of isolation: one VXLAN private network, its own balancers, DNS and
|
|
11
|
+
// firewall. Inside a project every service reaches every other by hostname; ACROSS projects there is no
|
|
12
|
+
// private network at all. ADR-0006 therefore puts the control plane in its own project — "a client who
|
|
13
|
+
// breaks the apps project cannot take down the thing that repairs it" — and that decision is the only
|
|
14
|
+
// reason this file emits two documents instead of one.
|
|
15
|
+
//
|
|
16
|
+
// It also means the apps-project proxy reaches the IAM service over a PUBLIC endpoint. That is fine and
|
|
17
|
+
// was priced in: the warm path verifies tokens locally against a cached JWKS, so the cross-project hop
|
|
18
|
+
// is cold-path only (ADR-0022).
|
|
19
|
+
//
|
|
20
|
+
// ── What these documents are, and what they are not ───────────────────────────────────────────────
|
|
21
|
+
//
|
|
22
|
+
// They are IMPORT documents: `POST /client/{id}/project/import` creates the project and its services.
|
|
23
|
+
// They are NOT deploy targets — no `deployService` is set, because no single service "carries the code"
|
|
24
|
+
// for a whole project. Each platform service is deployed on its own, by its own pipeline, selecting its
|
|
25
|
+
// own named setup from the repository-root `zerops.yaml` (see `./setups.ts`).
|
|
26
|
+
//
|
|
27
|
+
// The light tier ALSO emits the same services with no `project:` block, for a project the operator
|
|
28
|
+
// created by hand — that is `POST /project/{id}/service-stack/import`, and a `project:` block is invalid
|
|
29
|
+
// there. See `ProjectTopology.servicesTarget`.
|
|
30
|
+
//
|
|
31
|
+
// ── Two things that cannot be fixed afterwards ────────────────────────────────────────────────────
|
|
32
|
+
//
|
|
33
|
+
// 1. `envIsolation` is settable at project CREATION only — `RequestPutProject` has no such field. A
|
|
34
|
+
// project created without it can never be corrected, so the compiler writes it unconditionally at
|
|
35
|
+
// both levels of a document that creates a project, and on every service of one that does not;
|
|
36
|
+
// `assertZeropsInvariants` refuses to serialize a document missing it.
|
|
37
|
+
// 2. `corePackage` is upgrade-only and its upgrade is partially destructive. `assertCorePackageIsExplicit`
|
|
38
|
+
// makes leaving it to the LIGHT default an error rather than a silent choice.
|
|
39
|
+
//
|
|
40
|
+
// ── Where the secrets are ─────────────────────────────────────────────────────────────────────────
|
|
41
|
+
//
|
|
42
|
+
// Nowhere in here, and that is structural rather than careful: `ZeropsProjectSpec` has no `envVariables`
|
|
43
|
+
// and `ZeropsServiceSpec` has no `envSecrets`/`dotEnvSecrets`, so a secret in this file would not
|
|
44
|
+
// compile. Every value below is a hostname, a size or a boolean. The service-level secrets each service
|
|
45
|
+
// needs are listed in `./setups.ts` next to the setup that consumes them; they are written through the
|
|
46
|
+
// env API, addressed by service, after the provisioning import creates it (ADR-0004).
|
|
47
|
+
|
|
48
|
+
import {
|
|
49
|
+
compileImportYaml,
|
|
50
|
+
compileProvisioningYaml,
|
|
51
|
+
type ZeropsImportDocument,
|
|
52
|
+
type ZeropsNamespacePostgres,
|
|
53
|
+
type ZeropsServiceSpec,
|
|
54
|
+
type ZeropsSourceTarget,
|
|
55
|
+
} from '../../index'
|
|
56
|
+
import { compileZeropsNamespaceTopology, zeropsNamespacePolicy } from '../../namespace/topology'
|
|
57
|
+
import { assertTopologyInvariants } from './invariants'
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* How a project's single public entry point is published.
|
|
61
|
+
*
|
|
62
|
+
* `enableSubdomainAccess` is documented as "not suitable for production", so production takes
|
|
63
|
+
* `custom-domain`: nothing in the import is publicly routed, and the domain is bound to the project's L7
|
|
64
|
+
* balancer out of band — the import format has NO field for a custom domain, so that step is manual by
|
|
65
|
+
* construction and not an oversight here. `zerops-subdomain` is the ONLY thing that ever writes
|
|
66
|
+
* `enableSubdomainAccess: true` — on the proxy, and on nothing else.
|
|
67
|
+
*
|
|
68
|
+
* **NEITHER VALUE IS DELIVERED BY THE IMPORT.** Live-verified: the platform accepts
|
|
69
|
+
* `enableSubdomainAccess` on a service the document CREATES and then silently drops it, so the service
|
|
70
|
+
* reads back `subdomainAccess: false` and stays there. The subdomain is established afterwards, by
|
|
71
|
+
* `PUT /service-stack/{id}/enable-subdomain-access` on a service that already publishes an HTTP port —
|
|
72
|
+
* which the namespace lifecycle does for itself (`ensureSubdomainAccess` in `src/namespace/provisioning.ts`)
|
|
73
|
+
* and which an operator applying the generated platform artifacts must do by hand; the artifact's header
|
|
74
|
+
* names the call. So `true` here records the INTENT and feeds ADR-0022's `assertOnlyPublicService`; it
|
|
75
|
+
* does not turn anything on.
|
|
76
|
+
*/
|
|
77
|
+
export type PublicAccess = 'custom-domain' | 'zerops-subdomain'
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* How much platform an installation pays for.
|
|
81
|
+
*
|
|
82
|
+
* `standard` is the production shape: HA Postgres, a separate database and bucket for Operations, and
|
|
83
|
+
* enough containers that no single one is a point of failure.
|
|
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
|
|
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.
|
|
89
|
+
*
|
|
90
|
+
* Sharing is safe rather than merely cheap, and that is a property of the code, not of this comment:
|
|
91
|
+
*
|
|
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
|
+
* • Their blob keys are prefix-disjoint — control writes `runs/`, Operations writes `events/`,
|
|
96
|
+
* `dead/` and `source-maps/` — so one bucket cannot collide.
|
|
97
|
+
*
|
|
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.
|
|
101
|
+
*/
|
|
102
|
+
export type PlatformTier = 'standard' | 'light'
|
|
103
|
+
|
|
104
|
+
export interface TopologyOptions {
|
|
105
|
+
/** Environment label handed to `services(ctx)`. Part of the project name, so it lands in the GUI too. */
|
|
106
|
+
env: string
|
|
107
|
+
/** How the proxy is published. Production is `custom-domain`. */
|
|
108
|
+
publicAccess?: PublicAccess
|
|
109
|
+
/** How much platform to provision. Defaults to `standard`. */
|
|
110
|
+
tier?: PlatformTier
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** One project's topology plus the facts the invariant checks need. */
|
|
114
|
+
export interface ProjectTopology {
|
|
115
|
+
/** Stem of the generated filenames, and how this topology is referred to in prose. */
|
|
116
|
+
id: string
|
|
117
|
+
/** The source declaration compiled into the generated topology artifacts. */
|
|
118
|
+
target: ZeropsSourceTarget
|
|
119
|
+
/**
|
|
120
|
+
* The SAME services with no `project` block, for a project the operator created by hand.
|
|
121
|
+
*
|
|
122
|
+
* A document carrying `project:` is only valid at `POST /client/{clientId}/project/import`, which
|
|
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 only on the tiers a bootstrap
|
|
125
|
+
* imports into an existing project.
|
|
126
|
+
*/
|
|
127
|
+
servicesTarget?: ZeropsSourceTarget
|
|
128
|
+
/** The one service allowed to be publicly routed (ADR-0022). Always the proxy. */
|
|
129
|
+
publicService: string
|
|
130
|
+
/** Present on app namespaces so fixtures can prove the selected isolation tier. */
|
|
131
|
+
namespacePreset?: 'cheap' | 'mid' | 'full'
|
|
132
|
+
/** Present only when this namespace is reserved for one app. */
|
|
133
|
+
exclusiveAppId?: string
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The proxy's hostname, in every project. Also the `zerops.yaml` setup name it builds from. */
|
|
137
|
+
export const PROXY_HOSTNAME = 'proxy'
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* A runtime service, with the field that must never be left to a default written explicitly.
|
|
141
|
+
*
|
|
142
|
+
* `enableSubdomainAccess` is written even when it is `false` — which is also Zerops' default — on
|
|
143
|
+
* purpose. The default protects a service nobody thought about; an explicit `false` makes turning it on
|
|
144
|
+
* a visible one-line diff in review. What it does NOT do is turn anything on or off: the platform drops
|
|
145
|
+
* the field on create and `override: true` leaves an existing service untouched, so this document can
|
|
146
|
+
* neither establish a subdomain nor correct one someone enabled in the GUI. Both directions are separate
|
|
147
|
+
* calls — `EnableSubdomainAccess` and `DisableSubdomainAccess`. See `PublicAccess` above.
|
|
148
|
+
*
|
|
149
|
+
* `verticalAutoscaling` is deliberately NOT written, and that is a decision rather than an omission.
|
|
150
|
+
* A live Bun runtime reads back a floor of 1 shared core / 0.125 GB and a ceiling of 8 cores / 48 GB /
|
|
151
|
+
* 250 GB. The floor is already the minimum the platform offers, so there is nothing to save by
|
|
152
|
+
* restating it; and capping the ceiling would convert a traffic spike into an outage on services whose
|
|
153
|
+
* only other lever is `maxContainers`, which IS written on every one of them. Where a floor genuinely
|
|
154
|
+
* costs money — the managed databases — it is chosen explicitly through `profile` instead.
|
|
155
|
+
*
|
|
156
|
+
* `zeropsSetup` is deliberately NOT written. It used to be, as a redundant restatement of Zerops' own
|
|
157
|
+
* "setup name defaults to the hostname" rule that would keep working the day a service was renamed. On a
|
|
158
|
+
* real account it does not keep working at all: the platform treats it as pipeline configuration and
|
|
159
|
+
* rejects the entire import with `{"iam.buildFromGit": ["parameter is required for use of
|
|
160
|
+
* pipelineConfig"]}` unless the service also names a repository to build from — which none of these do
|
|
161
|
+
* (ADR-0003: fabrika triggers Zerops' pipeline, it does not configure a git integration). The setup name
|
|
162
|
+
* travels at TRIGGER time instead, where the driver already passes it and no repository is required.
|
|
163
|
+
* `assertZeropsInvariants` now refuses the combination so this fails at generation rather than at import.
|
|
164
|
+
*/
|
|
165
|
+
const runtime = (
|
|
166
|
+
spec: Omit<ZeropsServiceSpec, 'enableSubdomainAccess' | 'zeropsSetup'> & { public?: boolean },
|
|
167
|
+
): ZeropsServiceSpec => {
|
|
168
|
+
const { public: isPublic, ...rest } = spec
|
|
169
|
+
return { ...rest, enableSubdomainAccess: isPublic === true }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The **platform** project: fabrika itself.
|
|
174
|
+
*
|
|
175
|
+
* `SERIOUS` is not a comfort choice. This project holds the thing that repairs the other one, so its
|
|
176
|
+
* availability tier is the recovery story; and because `corePackage` cannot be downgraded, starting
|
|
177
|
+
* LIGHT and "upgrading when it matters" costs a partially destructive migration at the worst moment.
|
|
178
|
+
*/
|
|
179
|
+
export const platformTopology = (options: TopologyOptions): ProjectTopology => {
|
|
180
|
+
const publicAccess = options.publicAccess ?? 'custom-domain'
|
|
181
|
+
const tier = options.tier ?? 'standard'
|
|
182
|
+
if (tier === 'light') {
|
|
183
|
+
return lightPlatformTopology(options, publicAccess)
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
id: 'platform',
|
|
187
|
+
publicService: PROXY_HOSTNAME,
|
|
188
|
+
target: {
|
|
189
|
+
platform: 'zerops',
|
|
190
|
+
project: {
|
|
191
|
+
name: 'platform',
|
|
192
|
+
description: 'fabrika control plane, Operations, IAM and the auth proxy. Separate from the apps project by ADR-0006.',
|
|
193
|
+
corePackage: 'SERIOUS',
|
|
194
|
+
tags: ['fabrika', 'platform', options.env],
|
|
195
|
+
},
|
|
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
|
+
},
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The **light** platform: the same six roles in one project, on shared data services.
|
|
302
|
+
*
|
|
303
|
+
* Deliberately NOT a parameterized variation of the standard declaration above. The two differ in
|
|
304
|
+
* which services exist, not merely in their sizes, and a single declaration threaded with conditionals
|
|
305
|
+
* would make both harder to read than either is alone — the standard topology's per-field commentary is
|
|
306
|
+
* the reason that file is worth reading at all.
|
|
307
|
+
*
|
|
308
|
+
* `corePackage: LIGHT` is stated rather than defaulted for exactly the reason `assertCorePackageIsExplicit`
|
|
309
|
+
* exists: it is upgrade-only, and an installation that starts here should have chosen to.
|
|
310
|
+
*/
|
|
311
|
+
const lightPlatformTopology = (options: TopologyOptions, publicAccess: PublicAccess): ProjectTopology => {
|
|
312
|
+
// ONE services function behind both targets, so the services-only form cannot drift from the one
|
|
313
|
+
// that creates the project. Same shape as `compileZeropsNamespaceTopology` in `src/namespace/topology.ts`.
|
|
314
|
+
const services = (): ZeropsServiceSpec[] => [
|
|
315
|
+
{
|
|
316
|
+
// ONE database for IAM, control, Operations and every app in this project. Which schema each
|
|
317
|
+
// one gets is not expressed here — it is `FABRIKA_*_DATABASE_URL`, written per installation
|
|
318
|
+
// through the env API, because this same repository-root `zerops.yaml` serves both tiers and
|
|
319
|
+
// a `${operationsdb_connectionString}` baked into it would name a service `light` does not have.
|
|
320
|
+
hostname: 'db',
|
|
321
|
+
type: 'postgresql:single@18',
|
|
322
|
+
// `oltp-hobby`, written out because a default is not a choice — this type's default is
|
|
323
|
+
// `oltp-staging`, whose only difference here is a 1 GB memory FLOOR.
|
|
324
|
+
//
|
|
325
|
+
// A floor is not headroom. Every PostgreSQL profile shares the same 8-core / 48 GB / 250 GB
|
|
326
|
+
// ceiling and the same vertical autoscaler, so `oltp-staging` does not let this service grow
|
|
327
|
+
// any further than `oltp-hobby` does — it only pre-pays for memory the tier is idle on. That
|
|
328
|
+
// this single service holds IAM, control, Operations and the apps beside them is an argument
|
|
329
|
+
// about the ceiling, which is unchanged.
|
|
330
|
+
//
|
|
331
|
+
// An installation that wants a specific floor sets one on the service — `minRam` accepts a
|
|
332
|
+
// float — which is where a per-installation value belongs (ADR-0004), not in this document.
|
|
333
|
+
profile: 'oltp-hobby',
|
|
334
|
+
priority: 100,
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
// ONE bucket. Prefix-disjoint by construction: `runs/` (control) vs `events/`, `dead/` and
|
|
338
|
+
// `source-maps/` (Operations).
|
|
339
|
+
hostname: 'storage',
|
|
340
|
+
type: 'object-storage',
|
|
341
|
+
objectStorageSize: 25,
|
|
342
|
+
objectStoragePolicy: 'private',
|
|
343
|
+
enableCdn: false,
|
|
344
|
+
priority: 100,
|
|
345
|
+
},
|
|
346
|
+
runtime({ hostname: 'iam', type: 'alpine/bun@1.3', priority: 50, minContainers: 1, maxContainers: 2 }),
|
|
347
|
+
runtime({ hostname: 'operations', type: 'alpine/bun@1.3', priority: 40, minContainers: 1, maxContainers: 2 }),
|
|
348
|
+
runtime({ hostname: 'control', type: 'alpine/bun@1.3', priority: 20, minContainers: 1, maxContainers: 2 }),
|
|
349
|
+
runtime({
|
|
350
|
+
hostname: PROXY_HOSTNAME,
|
|
351
|
+
type: 'alpine@3.21',
|
|
352
|
+
priority: 30,
|
|
353
|
+
public: publicAccess === 'zerops-subdomain',
|
|
354
|
+
// Still the only publicly routed service (ADR-0022). One container is a real availability
|
|
355
|
+
// trade and the reason this tier is not for production; it is not a relaxation of the rule
|
|
356
|
+
// about WHICH service faces the internet.
|
|
357
|
+
minContainers: 1,
|
|
358
|
+
maxContainers: 2,
|
|
359
|
+
}),
|
|
360
|
+
]
|
|
361
|
+
return {
|
|
362
|
+
id: 'platform-light',
|
|
363
|
+
publicService: PROXY_HOSTNAME,
|
|
364
|
+
target: {
|
|
365
|
+
platform: 'zerops',
|
|
366
|
+
project: {
|
|
367
|
+
name: 'platform',
|
|
368
|
+
description: 'fabrika light tier: control plane, Operations, IAM, the auth proxy and the apps they serve, on shared data services.',
|
|
369
|
+
corePackage: 'LIGHT',
|
|
370
|
+
tags: ['fabrika', 'platform', 'light', options.env],
|
|
371
|
+
},
|
|
372
|
+
services,
|
|
373
|
+
},
|
|
374
|
+
// The light tier is the one an operator installs into a project they created themselves.
|
|
375
|
+
servicesTarget: { platform: 'zerops', services },
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* An application namespace: one proxy plus the namespace-owned resources selected by its preset.
|
|
381
|
+
*
|
|
382
|
+
* This is only an adapter over the provider-owned namespace compiler. The control plane uses that same
|
|
383
|
+
* compiler for live create/adopt/reconcile operations, so generated bring-up artifacts cannot drift
|
|
384
|
+
* into a second definition of what an application namespace contains.
|
|
385
|
+
*
|
|
386
|
+
* `mid` stays the default for the committed apps-prod artifact: each app imports its own prefixed
|
|
387
|
+
* database. `cheap` adds namespace-owned Postgres. `full` reserves the project for one app, while the
|
|
388
|
+
* app's own import still owns its runtime services and database.
|
|
389
|
+
*/
|
|
390
|
+
export interface AppsTopologyOptions extends TopologyOptions {
|
|
391
|
+
readonly corePackage: 'LIGHT' | 'SERIOUS'
|
|
392
|
+
readonly preset?: 'cheap' | 'mid' | 'full'
|
|
393
|
+
readonly projectName?: string
|
|
394
|
+
readonly exclusiveAppId?: string
|
|
395
|
+
readonly postgres?: ZeropsNamespacePostgres
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export const appsTopology = (options: AppsTopologyOptions): ProjectTopology => {
|
|
399
|
+
const preset = options.preset ?? 'mid'
|
|
400
|
+
const id = options.projectName ?? `apps-${options.env}`
|
|
401
|
+
if (preset === 'full' && options.exclusiveAppId === undefined) {
|
|
402
|
+
throw new Error('A full Zerops namespace requires exclusiveAppId')
|
|
403
|
+
}
|
|
404
|
+
if (preset !== 'full' && options.exclusiveAppId !== undefined) {
|
|
405
|
+
throw new Error(`A ${preset} Zerops namespace cannot reserve an exclusive app`)
|
|
406
|
+
}
|
|
407
|
+
const namespacePolicy = zeropsNamespacePolicy({
|
|
408
|
+
preset,
|
|
409
|
+
env: options.env,
|
|
410
|
+
projectName: id,
|
|
411
|
+
corePackage: options.corePackage,
|
|
412
|
+
publicAccess: options.publicAccess ?? 'custom-domain',
|
|
413
|
+
...(options.postgres === undefined ? {} : { postgres: options.postgres }),
|
|
414
|
+
})
|
|
415
|
+
const namespace = compileZeropsNamespaceTopology({
|
|
416
|
+
id,
|
|
417
|
+
env: options.env,
|
|
418
|
+
...(options.exclusiveAppId === undefined ? {} : { exclusiveAppId: options.exclusiveAppId }),
|
|
419
|
+
target: { provider: 'zerops', version: 1, payload: {} },
|
|
420
|
+
}, namespacePolicy)
|
|
421
|
+
return {
|
|
422
|
+
id,
|
|
423
|
+
publicService: PROXY_HOSTNAME,
|
|
424
|
+
target: namespace.source,
|
|
425
|
+
namespacePreset: preset,
|
|
426
|
+
...(options.exclusiveAppId === undefined ? {} : { exclusiveAppId: options.exclusiveAppId }),
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** The topologies this repo commits generated documents for. */
|
|
431
|
+
export const fabrikaTopologies = (): ProjectTopology[] => [
|
|
432
|
+
platformTopology({ env: 'prod' }),
|
|
433
|
+
// The light tier is committed for the same reason the other two are: an operator applies the file, so
|
|
434
|
+
// it should be reviewable in a diff. `zerops-subdomain` because a single-project installation has no
|
|
435
|
+
// second project to bind a custom domain in front of, and this tier is where a throwaway starts.
|
|
436
|
+
platformTopology({ env: 'prod', tier: 'light', publicAccess: 'zerops-subdomain' }),
|
|
437
|
+
appsTopology({ env: 'prod', corePackage: 'SERIOUS' }),
|
|
438
|
+
]
|
|
439
|
+
|
|
440
|
+
/** One compiled form: the document, and the YAML text an operator applies. */
|
|
441
|
+
export interface CompiledDocument {
|
|
442
|
+
document: ZeropsImportDocument
|
|
443
|
+
yaml: string
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** A compiled topology in every form an operator needs. */
|
|
447
|
+
export interface CompiledTopology {
|
|
448
|
+
topology: ProjectTopology
|
|
449
|
+
/**
|
|
450
|
+
* First bring-up ONLY: every service `startWithoutCode: true`, so secrets can be written before any
|
|
451
|
+
* deploy. Applying this a second time at a project whose services already carry code activates a new
|
|
452
|
+
* EMPTY app version on each of them — verified live. It is not a reconcile document.
|
|
453
|
+
*/
|
|
454
|
+
provision: CompiledDocument
|
|
455
|
+
/**
|
|
456
|
+
* Steady state: the same document, re-appliable because every service carries `override: true`.
|
|
457
|
+
*
|
|
458
|
+
* "Re-appliable" is the whole claim, and deliberately not "reconciling". Live-verified: applying
|
|
459
|
+
* this at an existing project creates whatever is missing and leaves everything that exists exactly
|
|
460
|
+
* as it is — a changed `profile`, `minContainers` or `objectStorageSize` in here is accepted by the
|
|
461
|
+
* API and silently ignored. Changing a live service is a separate, field-specific API call.
|
|
462
|
+
*/
|
|
463
|
+
steady: CompiledDocument
|
|
464
|
+
/**
|
|
465
|
+
* The two forms above with no `project:` block, present only when the topology declares a
|
|
466
|
+
* `servicesTarget`. These are what `POST /project/{projectId}/service-stack/import` takes, so they
|
|
467
|
+
* are the ones a bootstrap applies to a project the operator created.
|
|
468
|
+
*
|
|
469
|
+
* Nothing is lost by dropping the block: `envIsolation` is settable at project CREATION only, so a
|
|
470
|
+
* `project:` here could not correct an existing project even if the endpoint tolerated it, and the
|
|
471
|
+
* per-service `envIsolation: service` the compiler writes overrides the project's value anyway
|
|
472
|
+
* (ADR-0004).
|
|
473
|
+
*/
|
|
474
|
+
servicesProvision?: CompiledDocument
|
|
475
|
+
servicesSteady?: CompiledDocument
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Compile one topology to both documents, asserting every invariant on each.
|
|
480
|
+
*
|
|
481
|
+
* The provisioning form exists because of the cost ADR-0004 accepted out loud: on Zerops no secret can
|
|
482
|
+
* be set before the service exists, since the env API is addressed BY SERVICE. So bring-up is
|
|
483
|
+
* import-without-code → write secrets → deploy, and `compileProvisioningYaml` is the first of those three.
|
|
484
|
+
*/
|
|
485
|
+
export const compileTopology = (topology: ProjectTopology, env: string): CompiledTopology => {
|
|
486
|
+
const input = { target: topology.target, ctx: { env } }
|
|
487
|
+
const provision = compileProvisioningYaml(input)
|
|
488
|
+
const steady = compileImportYaml(input)
|
|
489
|
+
assertTopologyInvariants(provision.document, topology.publicService)
|
|
490
|
+
assertTopologyInvariants(steady.document, topology.publicService)
|
|
491
|
+
const servicesTarget = topology.servicesTarget
|
|
492
|
+
if (servicesTarget === undefined) {
|
|
493
|
+
return { topology, provision, steady }
|
|
494
|
+
}
|
|
495
|
+
const servicesInput = { target: servicesTarget, ctx: { env } }
|
|
496
|
+
const servicesProvision = compileProvisioningYaml(servicesInput)
|
|
497
|
+
const servicesSteady = compileImportYaml(servicesInput)
|
|
498
|
+
assertTopologyInvariants(servicesProvision.document, topology.publicService)
|
|
499
|
+
assertTopologyInvariants(servicesSteady.document, topology.publicService)
|
|
500
|
+
return { topology, provision, steady, servicesProvision, servicesSteady }
|
|
501
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Artifact } from './artifacts'
|
|
2
|
+
import { importSchema, zeropsYamlSchema } from './installation-schemas'
|
|
3
|
+
|
|
4
|
+
export type SchemaKind = 'import' | 'zerops-yaml'
|
|
5
|
+
export interface SchemaViolation {
|
|
6
|
+
path: string
|
|
7
|
+
message: string
|
|
8
|
+
}
|
|
9
|
+
export const parseYaml = (text: string): unknown => Bun.YAML.parse(text)
|
|
10
|
+
export const validateDocument = (kind: SchemaKind, document: unknown): SchemaViolation[] => {
|
|
11
|
+
const result = (kind === 'import' ? importSchema : zeropsYamlSchema).safeParse(document)
|
|
12
|
+
return result.success ? [] : result.error.issues.map((issue) => ({
|
|
13
|
+
path: issue.path.map((part, index) => typeof part === 'number' ? `[${part}]` : `${index === 0 ? '' : '.'}${part}`).join(''),
|
|
14
|
+
message: issue.message,
|
|
15
|
+
}))
|
|
16
|
+
}
|
|
17
|
+
export const validateYaml = (kind: SchemaKind, text: string): SchemaViolation[] => validateDocument(kind, parseYaml(text))
|
|
18
|
+
export const schemaKindFor = (path: string): SchemaKind => path.endsWith('.zerops-import.yaml') ? 'import' : 'zerops-yaml'
|
|
19
|
+
export const assertArtifactMatchesSchema = (artifact: Artifact): void => {
|
|
20
|
+
const kind = schemaKindFor(artifact.path)
|
|
21
|
+
const violations = validateYaml(kind, artifact.content)
|
|
22
|
+
if (violations.length > 0) {
|
|
23
|
+
const detail = violations.map((violation) => ` ${violation.path === '' ? '(root)' : violation.path}: ${violation.message}`).join('\n')
|
|
24
|
+
throw new Error(`${artifact.path} does not match the supported installation ${kind} schema:\n${detail}`)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// The CLI surface of `fabrika platform upgrade --provider=zerops`, parsed once.
|
|
2
|
+
//
|
|
3
|
+
// The same shape the other verbs use: an unknown flag is an error rather than a value silently
|
|
4
|
+
// ignored, and every message names what would have supplied the missing input. This command holds no
|
|
5
|
+
// credential of its own — it drives `git` and `gh`, which carry the operator's own login — so unlike
|
|
6
|
+
// `deploy` there is nothing here that must be kept off the command line.
|
|
7
|
+
//
|
|
8
|
+
// The pin is checked BEFORE anything leaves this machine: `assertPinnedTag` refuses a branch or a SHA
|
|
9
|
+
// (ADR-0025) without a network call, so `--to=main` fails on the spot rather than after a clone.
|
|
10
|
+
|
|
11
|
+
import { assertPinnedTag, defaultCheckoutDir } from './sidecar'
|
|
12
|
+
|
|
13
|
+
/** Where the sidecar repository this command edits is found. */
|
|
14
|
+
export type UpgradeSidecar =
|
|
15
|
+
/** An existing local checkout — the one `platform init` left behind, or any other clone. */
|
|
16
|
+
| { readonly kind: 'checkout'; readonly dir: string }
|
|
17
|
+
/** `<owner>/<name>`, cloned into a temporary directory for the length of the run. */
|
|
18
|
+
| { readonly kind: 'repo'; readonly repo: string }
|
|
19
|
+
|
|
20
|
+
/** Every input `runUpgrade` takes from the operator. */
|
|
21
|
+
export interface PlatformUpgradeInput {
|
|
22
|
+
/** The published fabrika tag to roll onto, already checked to be one. */
|
|
23
|
+
readonly to: string
|
|
24
|
+
readonly sidecar: UpgradeSidecar
|
|
25
|
+
/** The operator's name for this installation, when they gave one. Only ever used in the commit subject. */
|
|
26
|
+
readonly installation?: string
|
|
27
|
+
/** Read everything, write nothing, push nothing. */
|
|
28
|
+
readonly dryRun: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const FLAGS = ['--to', '--sidecar'] as const
|
|
32
|
+
|
|
33
|
+
/** The one value-less flag. */
|
|
34
|
+
export const UPGRADE_DRY_RUN_FLAG = '--dry-run'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `<owner>/<name>`, and nothing that could also be a directory.
|
|
38
|
+
*
|
|
39
|
+
* `--sidecar` takes either form, so the two need a rule an operator can predict rather than a
|
|
40
|
+
* filesystem probe whose answer depends on what happens to exist. A value with exactly one slash and
|
|
41
|
+
* no leading `.`, `~` or `/` is a REPOSITORY; anything else is a path, which makes `./owner/name` the
|
|
42
|
+
* way to name a local directory shaped like a repository.
|
|
43
|
+
*/
|
|
44
|
+
const REPO_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/
|
|
45
|
+
|
|
46
|
+
const readFlag = (argv: readonly string[], name: string): string | undefined => {
|
|
47
|
+
const prefix = `${name}=`
|
|
48
|
+
const matches = argv.filter((arg) => arg.startsWith(prefix))
|
|
49
|
+
if (matches.length > 1) {
|
|
50
|
+
throw new Error(`${name} was given more than once`)
|
|
51
|
+
}
|
|
52
|
+
const value = matches[0]?.slice(prefix.length)
|
|
53
|
+
return value === undefined || value.trim() === '' ? undefined : value.trim()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const assertKnownArguments = (argv: readonly string[]): void => {
|
|
57
|
+
for (const arg of argv) {
|
|
58
|
+
if (arg === UPGRADE_DRY_RUN_FLAG || !arg.startsWith('-')) {
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
const name = arg.split('=')[0] ?? arg
|
|
62
|
+
if (!FLAGS.some((flag) => flag === name) || !arg.includes('=')) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`unexpected argument \`${arg}\`. Run \`fabrika platform upgrade --provider=zerops --help\` for the surface`,
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The single positional: the installation name, exactly as `platform init` takes it. */
|
|
71
|
+
const readInstallation = (argv: readonly string[]): string | undefined => {
|
|
72
|
+
const positional = argv.filter((arg) => !arg.startsWith('-'))
|
|
73
|
+
if (positional.length > 1) {
|
|
74
|
+
throw new Error(`unexpected argument \`${positional[1]}\`: name ONE installation`)
|
|
75
|
+
}
|
|
76
|
+
const installation = positional[0]?.trim()
|
|
77
|
+
return installation === undefined || installation === '' ? undefined : installation
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const readSidecar = (argv: readonly string[], installation: string | undefined): UpgradeSidecar => {
|
|
81
|
+
const named = readFlag(argv, '--sidecar')
|
|
82
|
+
if (named !== undefined) {
|
|
83
|
+
return REPO_PATTERN.test(named) ? { kind: 'repo', repo: named } : { kind: 'checkout', dir: named }
|
|
84
|
+
}
|
|
85
|
+
if (installation === undefined) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
'name the installation (`fabrika platform upgrade --provider=zerops --to=<tag> <installation>`) or its sidecar '
|
|
88
|
+
+ 'with --sidecar=<path>|<owner>/<name>',
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
return { kind: 'checkout', dir: defaultCheckoutDir(installation) }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Parse the command's arguments into the one input `runUpgrade` takes. */
|
|
95
|
+
export const parsePlatformUpgradeArgs = (argv: readonly string[]): PlatformUpgradeInput => {
|
|
96
|
+
assertKnownArguments(argv)
|
|
97
|
+
const to = readFlag(argv, '--to')
|
|
98
|
+
if (to === undefined) {
|
|
99
|
+
throw new Error('--to=<tag> is required — the published fabrika release to roll onto, for example --to=v0.1.0')
|
|
100
|
+
}
|
|
101
|
+
const installation = readInstallation(argv)
|
|
102
|
+
return {
|
|
103
|
+
to: assertPinnedTag(to),
|
|
104
|
+
sidecar: readSidecar(argv, installation),
|
|
105
|
+
dryRun: argv.includes(UPGRADE_DRY_RUN_FLAG),
|
|
106
|
+
...(installation === undefined ? {} : { installation }),
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
const refs = z.array(z.unknown())
|
|
4
|
+
const tagRef = z.object({ ref: z.string() })
|
|
5
|
+
const runs = z.array(z.unknown())
|
|
6
|
+
const run = z.object({
|
|
7
|
+
databaseId: z.union([z.number(), z.string()]).transform(String),
|
|
8
|
+
url: z.string().min(1),
|
|
9
|
+
status: z.string().min(1).catch('unknown'),
|
|
10
|
+
})
|
|
11
|
+
const readJson = (raw: string): unknown => {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(raw)
|
|
14
|
+
} catch {
|
|
15
|
+
return undefined
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export const carriesTag = (raw: string, tag: string): boolean => {
|
|
19
|
+
const parsed = refs.safeParse(readJson(raw))
|
|
20
|
+
return parsed.success && parsed.data.some((entry) => {
|
|
21
|
+
const parsedRef = tagRef.safeParse(entry)
|
|
22
|
+
return parsedRef.success && parsedRef.data.ref === `refs/tags/${tag}`
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
export const firstRun = (raw: string): { id: string; url: string; status: string } | undefined => {
|
|
26
|
+
const parsed = runs.safeParse(readJson(raw))
|
|
27
|
+
if (!parsed.success) return undefined
|
|
28
|
+
const first = run.safeParse(parsed.data[0])
|
|
29
|
+
if (!first.success) return undefined
|
|
30
|
+
return { id: first.data.databaseId, url: first.data.url, status: first.data.status }
|
|
31
|
+
}
|