@fabrika/provider-zerops 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/compile.ts ADDED
@@ -0,0 +1,382 @@
1
+ // Compiles an app's Zerops declaration into the `zerops-import.yaml` document the platform's
2
+ // service-import endpoint takes. PURE — no network, no filesystem, no clock.
3
+ //
4
+ // THIS FILE IS WHERE ADR-0004's TWO INVARIANTS ARE ENFORCED, and they are enforced STRUCTURALLY, at three
5
+ // layers, so that "someone forgot" is not a reachable state:
6
+ //
7
+ // 1. TYPE. `ZeropsServiceSpec` / `ZeropsProjectSpec` are the platform's contract MINUS
8
+ // the fields fabrika owns — an app literally cannot type `envIsolation`, `envSecrets`,
9
+ // `dotEnvSecrets`, `override`, or a project-level `envVariables`.
10
+ // 2. CONSTRUCTION. The compiler does not COPY an authored object and patch it; it BUILDS each entry and
11
+ // writes `envIsolation: 'service'` (and `override: true`) itself. There is no code path that emits a
12
+ // service or a project without them, because there is only one construction site.
13
+ // 3. ASSERTION. Every document passes `assertZeropsInvariants` before it is serialized. That catches the
14
+ // case the types cannot: a config that arrived from JSON, a hand-built document, a future edit here.
15
+ //
16
+ // The failure this prevents is concrete: with `envIsolation: none` — a legal setting, and NOT reliably the
17
+ // default — every service in the project sees every other service's SERVICE-level variables, so app A's
18
+ // credentials leak to app B. "Never write to project-level env" alone does not prevent that; the isolation
19
+ // setting is the other half.
20
+
21
+ import type { ZeropsImportProject, ZeropsImportService } from './schema.generated'
22
+ import type { ZeropsProjectSpec, ZeropsResourceContext, ZeropsServiceSpec, ZeropsSourceTarget } from './types'
23
+
24
+ /** The ONLY value fabrika ever writes for `envIsolation`, at either level. Not a default — an assertion. */
25
+ export const ENV_ISOLATION: 'service' = 'service'
26
+
27
+ /**
28
+ * The ONLY value fabrika ever writes for `override`, on EVERY service class including managed ones.
29
+ * Live-verified: without it a re-apply is `400 serviceStackNameUnavailable` — see `compileService`.
30
+ */
31
+ export const OVERRIDE: true = true
32
+
33
+ /** A compiled import document: exactly the two top-level keys the platform's schema defines. */
34
+ export interface ZeropsImportDocument {
35
+ /** Present only when fabrika is creating/reconciling the project itself. */
36
+ project?: ZeropsImportProject
37
+ services: ZeropsImportService[]
38
+ }
39
+
40
+ /** What the compiler needs beyond the app's declaration. */
41
+ export interface CompileInput {
42
+ target: ZeropsSourceTarget
43
+ ctx: ZeropsResourceContext
44
+ /**
45
+ * Provision every service WITHOUT code — the answer to ADR-0004's "no secret can be set before the
46
+ * service exists" gap. Import the services, write their secrets through the env API, deploy later.
47
+ * Applied on top of whatever a service declared, because it is a property of THIS import, not of the app.
48
+ */
49
+ startWithoutCode?: boolean
50
+ }
51
+
52
+ /**
53
+ * Build one service entry. `envIsolation` and `override` are written HERE, by the compiler, from
54
+ * constants — never copied from the app and never conditional. Widening the spread to `...spec` would be
55
+ * the bug: it would let an untyped config smuggle an `envIsolation` in, which is why the fields are listed.
56
+ *
57
+ * ── What `override: true` actually does ───────────────────────────────────────────────────────────
58
+ *
59
+ * Settled against a live account, and it is neither what the published documentation says nor what this
60
+ * file used to claim. It means **"tolerate a hostname that already exists"** — nothing more:
61
+ *
62
+ * • WITHOUT it, re-applying a document that names an existing service is rejected outright,
63
+ * `400 serviceStackNameUnavailable`, and the whole document fails with it.
64
+ * • WITH it, an existing service is left EXACTLY as it is. A changed `profile`, `maxContainers` or
65
+ * `objectStorageSize` in the document is silently ignored; no process runs; `lastUpdate` does not
66
+ * move. It is not an update, and it is not the destructive replace upstream describes.
67
+ * • Upstream says the field is runtime-services-only. It is not: a managed `postgresql` or
68
+ * `object-storage` service accepts it, and NEEDS it — without it a re-apply fails on the database
69
+ * before it ever reaches a runtime service.
70
+ *
71
+ * So it is written on every service, and the reason is re-appliability, not reconciliation. Nothing in
72
+ * this compiler can converge an existing service; a field that must change is changed through the API
73
+ * that owns it (`PUT /service-stack/{id}/autoscaling`, `UpdateObjectStorageSize`, …).
74
+ */
75
+ const compileService = (spec: ZeropsServiceSpec, startWithoutCode: boolean | undefined): ZeropsImportService => {
76
+ const service: ZeropsImportService = {
77
+ hostname: spec.hostname,
78
+ type: spec.type,
79
+ // ── compiler-owned, unconditional ────────────────────────────────────────
80
+ // Each service sees ONLY its own variables. Set at service level too (not just on the project)
81
+ // because the schema says a service-level value overrides the project's — so this holds even for a
82
+ // project fabrika did not create and cannot correct (`RequestPutProject` has no `envIsolation`).
83
+ envIsolation: ENV_ISOLATION,
84
+ // Re-applying the document does not COLLIDE with the existing services (see above — it also does
85
+ // not update them). That is the whole idempotency claim of the `apply-import` step (ADR-0003).
86
+ override: OVERRIDE,
87
+ }
88
+ if (spec.nginxConfig !== undefined) {
89
+ service.nginxConfig = spec.nginxConfig
90
+ }
91
+ if (spec.objectStorageSize !== undefined) {
92
+ service.objectStorageSize = spec.objectStorageSize
93
+ }
94
+ if (spec.objectStoragePolicy !== undefined) {
95
+ service.objectStoragePolicy = spec.objectStoragePolicy
96
+ }
97
+ if (spec.objectStorageRawPolicy !== undefined) {
98
+ service.objectStorageRawPolicy = spec.objectStorageRawPolicy
99
+ }
100
+ if (spec.buildFromGit !== undefined) {
101
+ service.buildFromGit = spec.buildFromGit
102
+ }
103
+ if (spec.enableSubdomainAccess !== undefined) {
104
+ service.enableSubdomainAccess = spec.enableSubdomainAccess
105
+ }
106
+ if (spec.priority !== undefined) {
107
+ service.priority = spec.priority
108
+ }
109
+ if (spec.verticalAutoscaling !== undefined) {
110
+ service.verticalAutoscaling = spec.verticalAutoscaling
111
+ }
112
+ if (spec.minContainers !== undefined) {
113
+ service.minContainers = spec.minContainers
114
+ }
115
+ if (spec.maxContainers !== undefined) {
116
+ service.maxContainers = spec.maxContainers
117
+ }
118
+ if (spec.profile !== undefined) {
119
+ service.profile = spec.profile
120
+ }
121
+ if (spec.profileOverrides !== undefined) {
122
+ service.profileOverrides = spec.profileOverrides
123
+ }
124
+ if (spec.mount !== undefined) {
125
+ service.mount = spec.mount
126
+ }
127
+ if (spec.enableCdn !== undefined) {
128
+ service.enableCdn = spec.enableCdn
129
+ }
130
+ if (spec.sshIsolation !== undefined) {
131
+ service.sshIsolation = spec.sshIsolation
132
+ }
133
+ if (spec.zeropsSetup !== undefined) {
134
+ service.zeropsSetup = spec.zeropsSetup
135
+ }
136
+ if (spec.zeropsYaml !== undefined) {
137
+ service.zeropsYaml = spec.zeropsYaml
138
+ }
139
+ // `startWithoutCode` on the IMPORT wins over the app's own value: a provisioning import must be
140
+ // code-free even for a service that normally builds from git.
141
+ const codeless = startWithoutCode ?? spec.startWithoutCode
142
+ if (codeless !== undefined) {
143
+ service.startWithoutCode = codeless
144
+ }
145
+ return service
146
+ }
147
+
148
+ /**
149
+ * Build the project entry. Note what is NOT here: `envVariables`. A project-level variable is injected
150
+ * into EVERY service in the project and one project holds many apps, so writing one would hand app A's
151
+ * credentials to app B (ADR-0004). There is no field to set and no branch that could set it.
152
+ */
153
+ const compileProject = (spec: ZeropsProjectSpec): ZeropsImportProject => {
154
+ const project: ZeropsImportProject = {
155
+ name: spec.name,
156
+ // Compiler-owned, unconditional. Settable at project CREATION only — `RequestPutProject` has no
157
+ // `envIsolation` — so getting it wrong here is not correctable through the project API afterwards.
158
+ envIsolation: ENV_ISOLATION,
159
+ }
160
+ if (spec.description !== undefined) {
161
+ project.description = spec.description
162
+ }
163
+ if (spec.corePackage !== undefined) {
164
+ project.corePackage = spec.corePackage
165
+ }
166
+ if (spec.tags !== undefined) {
167
+ project.tags = spec.tags
168
+ }
169
+ if (spec.sshIsolation !== undefined) {
170
+ project.sshIsolation = spec.sshIsolation
171
+ }
172
+ if (spec.sharedIpv4 !== undefined) {
173
+ project.sharedIpv4 = spec.sharedIpv4
174
+ }
175
+ if (spec.location !== undefined) {
176
+ project.location = spec.location
177
+ }
178
+ return project
179
+ }
180
+
181
+ /**
182
+ * The last gate before serialization: re-check ADR-0004's invariants on the FINISHED document. The types
183
+ * make a violation unauthorable and the construction makes it unemittable; this catches everything else —
184
+ * a config deserialized from JSON, a document assembled by hand, a future edit to this file.
185
+ *
186
+ * Deliberately checks the emitted document rather than the input: it is the document that reaches Zerops.
187
+ */
188
+ export const assertZeropsInvariants = (document: ZeropsImportDocument): void => {
189
+ const project = document.project
190
+ if (project !== undefined) {
191
+ if (project.envVariables !== undefined) {
192
+ throw new Error('zerops: refusing to write PROJECT-level env variables — they reach every service in the project (ADR-0004)')
193
+ }
194
+ if (project.envIsolation !== ENV_ISOLATION) {
195
+ throw new Error(`zerops: project envIsolation must be \`${ENV_ISOLATION}\`, got \`${String(project.envIsolation)}\` (ADR-0004)`)
196
+ }
197
+ }
198
+ if (document.services.length === 0) {
199
+ throw new Error('zerops: import document declares no services')
200
+ }
201
+ const seen = new Set<string>()
202
+ for (const service of document.services) {
203
+ if (service.envIsolation !== ENV_ISOLATION) {
204
+ throw new Error(
205
+ `zerops: service \`${service.hostname}\` envIsolation must be \`${ENV_ISOLATION}\`, got \`${String(service.envIsolation)}\` (ADR-0004)`,
206
+ )
207
+ }
208
+ // Every service class, managed included. A document missing it anywhere cannot be re-applied at
209
+ // all: the platform answers `400 serviceStackNameUnavailable` on the first hostname that exists
210
+ // and rejects the whole import, so one omission breaks every later apply, not just that service.
211
+ if (service.override !== OVERRIDE) {
212
+ throw new Error(
213
+ `zerops: service \`${service.hostname}\` must carry \`override: true\`; without it a re-apply is rejected as a name collision`,
214
+ )
215
+ }
216
+ if (service.envSecrets !== undefined || service.dotEnvSecrets !== undefined) {
217
+ // Never assert on the VALUE — only that the field is present.
218
+ throw new Error(
219
+ `zerops: service \`${service.hostname}\` carries secrets in the import document; secrets are written through the env API (ADR-0004)`,
220
+ )
221
+ }
222
+ // `zeropsSetup` is part of the import's pipeline configuration, and the platform rejects the WHOLE
223
+ // document when a service names a setup without also naming the repository to build it from:
224
+ //
225
+ // 400 projectImportInvalidParameter
226
+ // {"iam.buildFromGit": ["parameter is required for use of pipelineConfig"]}
227
+ //
228
+ // Verified against a real account, and it is not a corner case — it failed the very first step of
229
+ // the documented bring-up (import → write secrets → deploy) for every runtime service at once.
230
+ // Nothing is lost by dropping the setup name here: it is only redundant restatement of Zerops' own
231
+ // "setup name defaults to the hostname" rule, and a deploy that needs to say otherwise passes
232
+ // `zeropsSetup` at TRIGGER time, where no repository is required.
233
+ if (service.zeropsSetup !== undefined && service.buildFromGit === undefined) {
234
+ throw new Error(
235
+ `zerops: service \`${service.hostname}\` names \`zeropsSetup\` without \`buildFromGit\`; the platform rejects the import (pass the setup when triggering the pipeline instead)`,
236
+ )
237
+ }
238
+ if (seen.has(service.hostname)) {
239
+ throw new Error(`zerops: duplicate service hostname \`${service.hostname}\` in the import document`)
240
+ }
241
+ seen.add(service.hostname)
242
+ }
243
+ }
244
+
245
+ /** Compile an app's Zerops declaration into the import document, invariants already checked. */
246
+ export const compileImport = (input: CompileInput): ZeropsImportDocument => {
247
+ const services = input.target.services(input.ctx).map((spec) => compileService(spec, input.startWithoutCode))
248
+ const document: ZeropsImportDocument = input.target.project === undefined
249
+ ? { services }
250
+ : { project: compileProject(input.target.project), services }
251
+ assertZeropsInvariants(document)
252
+ return document
253
+ }
254
+
255
+ // ── YAML serialization ──────────────────────────────────────────────────────────
256
+
257
+ /** Keys/scalars that are safe unquoted in YAML. Everything else gets a double-quoted, JSON-escaped form. */
258
+ const PLAIN = /^[A-Za-z_][A-Za-z0-9_./@+-]*$/
259
+
260
+ const scalar = (value: string): string => {
261
+ // Reserved plain scalars that YAML would read as booleans/null, plus anything with structure.
262
+ const reserved = new Set(['true', 'false', 'null', 'yes', 'no', 'on', 'off', '~'])
263
+ if (value !== '' && PLAIN.test(value) && !reserved.has(value.toLowerCase())) {
264
+ return value
265
+ }
266
+ return JSON.stringify(value)
267
+ }
268
+
269
+ const isArray = (value: unknown): value is unknown[] => Array.isArray(value)
270
+
271
+ const isPlainObject = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !isArray(value)
272
+
273
+ /**
274
+ * Serialize a compiled document to YAML. Deliberately hand-rolled and tiny: the value space is closed
275
+ * (strings, numbers, booleans, arrays, plain objects — no dates, no cycles, no anchors), a YAML dependency
276
+ * for that would be all risk and no benefit, and the emitter is exercised by every compiler test.
277
+ *
278
+ * Multi-line strings (an inline `nginxConfig`, a `zerops.yaml` command) are emitted as a literal block so
279
+ * their newlines survive intact.
280
+ */
281
+ const emit = (value: unknown, indent: number): string[] => {
282
+ const pad = ' '.repeat(indent)
283
+ if (isArray(value)) {
284
+ if (value.length === 0) {
285
+ return [`${pad}[]`]
286
+ }
287
+ const lines: string[] = []
288
+ for (const item of value) {
289
+ const rendered = emit(item, indent + 1)
290
+ const first = rendered[0] ?? ''
291
+ lines.push(`${pad}- ${first.trimStart()}`)
292
+ lines.push(...rendered.slice(1))
293
+ }
294
+ return lines
295
+ }
296
+ if (isPlainObject(value)) {
297
+ const entries = Object.entries(value).filter(([, entry]) => entry !== undefined)
298
+ if (entries.length === 0) {
299
+ return [`${pad}{}`]
300
+ }
301
+ const lines: string[] = []
302
+ for (const [key, entry] of entries) {
303
+ if (isPlainObject(entry) || isArray(entry)) {
304
+ const rendered = emit(entry, indent + 1)
305
+ // An empty collection renders inline so the key never dangles.
306
+ if (rendered.length === 1 && (rendered[0]?.trim() === '{}' || rendered[0]?.trim() === '[]')) {
307
+ lines.push(`${pad}${scalar(key)}: ${rendered[0]?.trim() ?? ''}`)
308
+ continue
309
+ }
310
+ lines.push(`${pad}${scalar(key)}:`)
311
+ lines.push(...rendered)
312
+ continue
313
+ }
314
+ lines.push(...emit(entry, indent).map((line, index) => (index === 0 ? `${pad}${scalar(key)}: ${line.trimStart()}` : line)))
315
+ }
316
+ return lines
317
+ }
318
+ if (typeof value === 'string') {
319
+ if (value.includes('\n')) {
320
+ const body = value.replace(/\n$/, '').split('\n').map((line) => `${pad} ${line}`)
321
+ return [`${pad}|-`, ...body]
322
+ }
323
+ return [`${pad}${scalar(value)}`]
324
+ }
325
+ if (value === null) {
326
+ return [`${pad}null`]
327
+ }
328
+ return [`${pad}${String(value)}`]
329
+ }
330
+
331
+ /**
332
+ * Render an arbitrary Zerops document (a `zerops.yaml`, a fragment) with the SAME emitter the import
333
+ * document uses. Exported so nothing outside this file has to grow a second YAML writer: two emitters
334
+ * disagreeing about quoting or block scalars is a class of bug worth spending one export to avoid.
335
+ *
336
+ * Deliberately does NOT assert the import invariants — it does not know it is looking at an import
337
+ * document. `renderImportYaml` is the entry point that does, and it is the one the driver calls.
338
+ */
339
+ export const renderYaml = (value: unknown): string => `${emit(value, 0).join('\n')}\n`
340
+
341
+ /**
342
+ * Render a compiled document as the `zerops-import.yaml` text the API takes. Re-asserts the invariants:
343
+ * this is the last place the document is still inspectable, and the only place it becomes a string.
344
+ */
345
+ export const renderImportYaml = (document: ZeropsImportDocument): string => {
346
+ assertZeropsInvariants(document)
347
+ const lines: string[] = []
348
+ if (document.project !== undefined) {
349
+ lines.push('project:')
350
+ lines.push(...emit(document.project, 1))
351
+ }
352
+ lines.push('services:')
353
+ lines.push(...emit(document.services, 1))
354
+ return `${lines.join('\n')}\n`
355
+ }
356
+
357
+ /** Compile + render in one step — what the driver's `apply-import` step actually calls. */
358
+ export const compileImportYaml = (input: CompileInput): { document: ZeropsImportDocument; yaml: string } => {
359
+ const document = compileImport(input)
360
+ return { document, yaml: renderImportYaml(document) }
361
+ }
362
+
363
+ /**
364
+ * Compile the PROVISIONING import: the same document with every service forced `startWithoutCode: true`.
365
+ *
366
+ * This is ADR-0004's recorded gap closed. The ADR accepted a cost — "no secret can be set before the
367
+ * Zerops service exists, so registration must provision" — and `startWithoutCode` makes that provisioning
368
+ * cheap and code-free. The order becomes: register the app → import its services with no code → write
369
+ * their secrets through the SERVICE-level env API → deploy later. No local secret buffer, no secret in
370
+ * this document, and no secret held anywhere fabrika could leak it.
371
+ *
372
+ * Called by the control plane at EDIT time, not by the driver: on Zerops, pushing secrets is not a deploy
373
+ * step at all.
374
+ *
375
+ * **This document is the one that is NOT safe to re-apply, and `override` does not make it safe.**
376
+ * Verified live: re-applying it at a service that already carries code starts a `stack.deploy` and
377
+ * activates a new, EMPTY app version — the running code becomes a `BACKUP`. The steady-state document
378
+ * (no `startWithoutCode`) is a complete no-op on the same services. So this form belongs to first
379
+ * bring-up only; anything that reconciles an existing project applies `compileImportYaml`.
380
+ */
381
+ export const compileProvisioningYaml = (input: Omit<CompileInput, 'startWithoutCode'>): { document: ZeropsImportDocument; yaml: string } =>
382
+ compileImportYaml({ ...input, startWithoutCode: true })