@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.
Files changed (82) hide show
  1. package/package.json +18 -5
  2. package/src/api/client.ts +522 -0
  3. package/src/api/errors.ts +50 -0
  4. package/src/api/polling.ts +59 -0
  5. package/src/api/readers.ts +185 -0
  6. package/src/api/service-env-response.ts +34 -0
  7. package/src/api/types.ts +602 -0
  8. package/src/api.ts +29 -1378
  9. package/src/authoring.ts +7 -10
  10. package/src/cli-args.ts +0 -5
  11. package/src/cli.ts +3 -28
  12. package/src/collaborators.ts +2 -4
  13. package/src/compile.ts +3 -1
  14. package/src/control.ts +203 -450
  15. package/src/descriptor.ts +135 -0
  16. package/src/index.ts +23 -127
  17. package/src/installation/admin-options.ts +128 -0
  18. package/src/installation/admin.ts +123 -0
  19. package/src/installation/deploy-options.ts +155 -0
  20. package/src/installation/deploy.ts +668 -0
  21. package/src/installation/generated/platform-console-schema.ts +41 -0
  22. package/src/installation/generated/platform-proxy-manifest.ts +72 -0
  23. package/src/installation/hosts.ts +117 -0
  24. package/src/installation/index.ts +366 -0
  25. package/src/installation/init.ts +521 -0
  26. package/src/installation/install-collaborators.ts +28 -0
  27. package/src/installation/install-environment.ts +193 -0
  28. package/src/installation/install-options.ts +193 -0
  29. package/src/installation/install-project.ts +201 -0
  30. package/src/installation/install-provisioning.ts +185 -0
  31. package/src/installation/install.ts +286 -0
  32. package/src/installation/installation-source.ts +42 -0
  33. package/src/installation/log.ts +77 -0
  34. package/src/installation/machine-key.ts +65 -0
  35. package/src/installation/manifest.ts +95 -0
  36. package/src/installation/placement-options.ts +33 -0
  37. package/src/installation/platform-source.ts +106 -0
  38. package/src/installation/platform-upload.ts +34 -0
  39. package/src/installation/proxy-manifest.ts +113 -0
  40. package/src/installation/secrets.ts +79 -0
  41. package/src/installation/sidecar.ts +122 -0
  42. package/src/installation/source-options.ts +12 -0
  43. package/src/installation/templates/README.md +116 -0
  44. package/src/installation/templates/fabrika.ref +1 -0
  45. package/src/installation/templates/gitignore +3 -0
  46. package/src/installation/templates/platform.yml +144 -0
  47. package/src/installation/topology/artifacts.ts +141 -0
  48. package/src/installation/topology/index.ts +13 -0
  49. package/src/installation/topology/installation-schemas.ts +225 -0
  50. package/src/installation/topology/invariants.ts +86 -0
  51. package/src/installation/topology/setups.ts +442 -0
  52. package/src/installation/topology/topology.ts +501 -0
  53. package/src/installation/topology/validate.ts +26 -0
  54. package/src/installation/upgrade-options.ts +108 -0
  55. package/src/installation/upgrade-output.ts +31 -0
  56. package/src/installation/upgrade.ts +373 -0
  57. package/src/managed-env.ts +105 -0
  58. package/src/manifest.ts +191 -344
  59. package/src/namespace/claims.ts +77 -0
  60. package/src/namespace/codec.ts +179 -0
  61. package/src/namespace/constants.ts +30 -0
  62. package/src/namespace/errors.ts +60 -0
  63. package/src/namespace/manifest.ts +28 -0
  64. package/src/namespace/operator.ts +151 -0
  65. package/src/namespace/provisioning.ts +482 -0
  66. package/src/namespace/proxy-attempt.ts +220 -0
  67. package/src/namespace/proxy-source.ts +22 -0
  68. package/src/namespace/topology.ts +115 -0
  69. package/src/namespace/types.ts +114 -0
  70. package/src/namespace.ts +34 -1271
  71. package/src/plan.ts +21 -23
  72. package/src/provider.ts +266 -241
  73. package/src/source/constants.ts +11 -0
  74. package/src/source/port.ts +49 -0
  75. package/src/source/types.ts +4 -0
  76. package/src/source/validation.ts +72 -0
  77. package/src/source.ts +2 -1220
  78. package/src/types.ts +8 -7
  79. package/src/version-attempt.ts +18 -0
  80. package/src/codec.ts +0 -108
  81. package/src/namespace-command.ts +0 -209
  82. package/src/source-connection.ts +0 -344
@@ -0,0 +1,95 @@
1
+ // Composing the platform's three apps with whatever else the installation's one proxy already fronts.
2
+ //
3
+ // ── Why this is a MERGE and not a write ───────────────────────────────────────────────────────────
4
+ //
5
+ // On the `light` tier an application shares the platform's project and therefore the platform proxy's
6
+ // single `FABRIKA_PROXY_MANIFEST_JSON`. The live document on `fabrika-test` carries a fourth app —
7
+ // `notes` → `notesapi:3000` on listener 8084 — beside the three platform ones. `platformProxyManifest`
8
+ // deliberately emits only the platform apps (it is generated from the platform's own gate modules and
9
+ // knows nothing about applications), so writing it alone would DELETE every application entry and take
10
+ // the deployed applications offline at the next proxy build.
11
+ //
12
+ // So: read the live value, compose, write. That composition is this command's job and nothing else's.
13
+ //
14
+ // ── What happens to an entry the platform does not know about ─────────────────────────────────────
15
+ //
16
+ // Carried through byte-for-byte, in its live order, after the platform apps — with ONE exception. A
17
+ // host belongs to exactly one app (`parseProxyManifest` refuses two apps claiming one, and rightly:
18
+ // route order would otherwise decide which app answers). The platform's three listeners are addresses
19
+ // the platform OWNS, so a live entry standing on one of them is superseded rather than carried, and
20
+ // the deploy says so out loud.
21
+ //
22
+ // That exception is not hypothetical: WU1 corrected IAM's app id from `iam-local` to `iam`, so the
23
+ // live document's `iam-local` claims the platform's own IAM host under a name the platform no longer
24
+ // uses. Carrying it would produce a manifest `parseProxyManifest` rejects, which the proxy build then
25
+ // fails on — a fail-closed outcome, but a needlessly confusing one.
26
+ //
27
+ // Superseding is itself CLOSED: the dropped host stops resolving to anything, it does not fall through
28
+ // to some other app. A genuinely misplaced application therefore goes dark and is reported, instead of
29
+ // quietly being served by the console.
30
+
31
+ import { findDuplicateProxyHost, parseProxyManifestJson, type ProxyManifest } from '@fabrika/proxy-core/manifest'
32
+
33
+ /** The composed document plus what happened to every live entry that was not the platform's. */
34
+ export interface MergedProxyManifest {
35
+ readonly manifest: ProxyManifest
36
+ /** App ids carried through from the live manifest, in their live order. */
37
+ readonly carried: readonly string[]
38
+ /** Live entries the platform's own listeners displaced, with the host that collided. */
39
+ readonly superseded: readonly { readonly id: string; readonly host: string }[]
40
+ }
41
+
42
+ /**
43
+ * Compose the platform's apps with the application entries already in a live manifest.
44
+ *
45
+ * `live` is `null` when the proxy carries no manifest yet (a first deploy) — the result is then the
46
+ * platform document alone. An UNPARSEABLE live value is a different thing entirely and throws: it may
47
+ * be a manifest this build is too old to read, and silently replacing it would drop applications this
48
+ * code cannot see. Refusing is what keeps "must not take a deployed app offline" true in the one case
49
+ * where the deploy genuinely cannot tell.
50
+ */
51
+ export const mergePlatformProxyManifest = (platform: ProxyManifest, live: ProxyManifest | null): MergedProxyManifest => {
52
+ const platformIds = new Set(platform.apps.map((app) => app.id))
53
+ const platformHosts = new Set(platform.apps.flatMap((app) => app.hosts))
54
+ const carried: string[] = []
55
+ const superseded: { id: string; host: string }[] = []
56
+ const apps = [...platform.apps]
57
+ for (const app of live?.apps ?? []) {
58
+ if (platformIds.has(app.id)) {
59
+ continue
60
+ }
61
+ const collision = app.hosts.find((host) => platformHosts.has(host))
62
+ if (collision !== undefined) {
63
+ superseded.push({ id: app.id, host: collision })
64
+ continue
65
+ }
66
+ carried.push(app.id)
67
+ apps.push(app)
68
+ }
69
+ // Both inputs are already parsed manifests, so a duplicate host is the one invariant composing can break.
70
+ // A duplicate host between two APPLICATION entries — which this function cannot fix — is refused
71
+ // here rather than at the proxy's next build.
72
+ if (findDuplicateProxyHost(apps) !== null) {
73
+ throw new Error('the composed proxy manifest is not one the proxy would accept — two application entries claim one host')
74
+ }
75
+ return { manifest: { apps }, carried, superseded }
76
+ }
77
+
78
+ /**
79
+ * The live manifest a proxy service currently carries.
80
+ *
81
+ * Absent means "no manifest yet". Present-but-unparseable throws: see `mergePlatformProxyManifest`.
82
+ */
83
+ export const readLiveProxyManifest = (value: string | undefined): ProxyManifest | null => {
84
+ if (value === undefined || value.trim() === '') {
85
+ return null
86
+ }
87
+ const manifest = parseProxyManifestJson(value)
88
+ if (manifest === null) {
89
+ throw new Error(
90
+ `the proxy's current FABRIKA_PROXY_MANIFEST_JSON cannot be parsed by this build — refusing to replace a document whose `
91
+ + 'application entries cannot be read',
92
+ )
93
+ }
94
+ return manifest
95
+ }
@@ -0,0 +1,33 @@
1
+ import { z } from 'zod'
2
+
3
+ const scheme = z.enum(['http', 'https']).default('https')
4
+ const bareHostname = z.string().refine(
5
+ (host) => !host.includes('/') && !host.includes(':'),
6
+ (host) => ({ message: `\`${host}\` is not a bare hostname — a platform host carries no scheme and no port` }),
7
+ )
8
+ const hosts = z.object({ iam: bareHostname, control: bareHostname, operations: bareHostname })
9
+
10
+ export const parsePlatformScheme = (raw: string | undefined): 'http' | 'https' => {
11
+ const parsed = scheme.safeParse(raw)
12
+ if (!parsed.success) throw new Error('--scheme must be http or https')
13
+ return parsed.data
14
+ }
15
+ export const parsePlatformHostname = (raw: string): string => {
16
+ const parsed = bareHostname.safeParse(raw)
17
+ if (!parsed.success) throw new Error(`\`${raw}\` is not a bare hostname — a platform host carries no scheme and no port`)
18
+ return parsed.data
19
+ }
20
+ export const parsePlatformHosts = (
21
+ raw: { iam: string | undefined; control: string | undefined; operations: string | undefined },
22
+ ): { iam: string; control: string; operations: string } | undefined => {
23
+ if (raw.iam === undefined && raw.control === undefined && raw.operations === undefined) return undefined
24
+ const parsed = hosts.safeParse(raw)
25
+ if (parsed.success) return parsed.data
26
+ if (raw.iam === undefined || raw.control === undefined || raw.operations === undefined) {
27
+ throw new Error(
28
+ 'name every platform host or none: --iam-host, --console-host and --operations-host go together '
29
+ + "(omitting all three derives them from the proxy's Zerops subdomains)",
30
+ )
31
+ }
32
+ throw new Error(parsed.error.issues[0]?.message ?? 'invalid platform hosts')
33
+ }
@@ -0,0 +1,106 @@
1
+ import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { createZeropsArtifactSourceDescriptor, ZEROPS_SOURCE_DESCRIPTOR_MAX_BYTES, type ZeropsArtifactSourceDescriptor } from '../index'
5
+
6
+ export interface PreparedPlatformSource {
7
+ commitSha: string
8
+ descriptor: ZeropsArtifactSourceDescriptor
9
+ archivePath: string
10
+ dispose(): Promise<void>
11
+ }
12
+
13
+ // The caller must separately establish that the executing CLI belongs to this checkout.
14
+ export async function preparePlatformSource(sourceDir: string, signal: AbortSignal): Promise<PreparedPlatformSource> {
15
+ signal.throwIfAborted()
16
+ const root = await realpath(sourceDir)
17
+ const git = (args: string[], cwd = root, env: Record<string, string> = {}) => runGit(cwd, args, signal, env)
18
+ const topLevel = (await git(['rev-parse', '--show-toplevel'])).trimEnd()
19
+ if (await realpath(topLevel) !== root) throw new Error('Platform source directory must be the Git repository root')
20
+ const commitSha = (await git(['rev-parse', '--verify', 'HEAD^{commit}'])).trim()
21
+ if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commitSha)) throw new Error('Invalid platform source commit')
22
+ const tree = await git(['ls-tree', '-rz', commitSha])
23
+ if (tree.split('\0').some((entry) => entry.startsWith('160000 '))) throw new Error('Platform source submodules are not supported')
24
+ const tracked = await git(['ls-files', '-v', '-z'])
25
+ if (tracked.split('\0').some((entry) => entry !== '' && !entry.startsWith('H '))) {
26
+ throw new Error('Platform source requires a full checkout without skip-worktree or assume-unchanged entries')
27
+ }
28
+ await git(['diff', '--no-ext-diff', '--no-textconv', '--quiet', '--cached', commitSha, '--'])
29
+ await git(['diff', '--no-ext-diff', '--no-textconv', '--quiet', '--'])
30
+ const descriptorEntry = await git(['ls-tree', '-z', commitSha, '--', 'zerops.yaml'])
31
+ if (!/^100(?:644|755) blob [a-f0-9]+\tzerops\.yaml\0$/.test(descriptorEntry)) {
32
+ throw new Error('Platform source requires a regular root zerops.yaml')
33
+ }
34
+ const size = Number((await git(['cat-file', '-s', `${commitSha}:zerops.yaml`])).trim())
35
+ if (!Number.isSafeInteger(size) || size <= 0 || size > ZEROPS_SOURCE_DESCRIPTOR_MAX_BYTES) {
36
+ throw new Error('Platform source zerops.yaml is empty or exceeds the descriptor size limit')
37
+ }
38
+ const contents = await git(['show', `${commitSha}:zerops.yaml`])
39
+ const descriptor = await createZeropsArtifactSourceDescriptor(contents)
40
+ const objectDirectory = (await git(['rev-parse', '--path-format=absolute', '--git-path', 'objects'])).trimEnd()
41
+ const objectFormat = (await git(['rev-parse', '--show-object-format'])).trim()
42
+ if (objectFormat !== 'sha1' && objectFormat !== 'sha256') throw new Error('Unsupported Git object format')
43
+ signal.throwIfAborted()
44
+ const temporary = await mkdtemp(join(tmpdir(), 'fabrika-platform-source-'))
45
+ const dispose = () => rm(temporary, { recursive: true, force: true })
46
+ try {
47
+ const repository = join(temporary, 'repository.git')
48
+ await git(['init', '--bare', `--object-format=${objectFormat}`, '--template=', repository], temporary)
49
+ // Override nested committed attributes too: archive must neither omit nor transform Git blob bytes.
50
+ await mkdir(join(repository, 'info'))
51
+ await writeFile(join(repository, 'info', 'attributes'), '* -export-ignore -export-subst -text -eol -crlf -ident -working-tree-encoding -filter\n')
52
+ const archivePath = join(temporary, 'source.tar.gz')
53
+ await git(['archive', '--format=tar.gz', `--output=${archivePath}`, commitSha], repository, {
54
+ GIT_OBJECT_DIRECTORY: objectDirectory,
55
+ })
56
+ signal.throwIfAborted()
57
+ const archived = (await new Bun.Archive(await Bun.file(archivePath).arrayBuffer()).files('zerops.yaml')).get('zerops.yaml')
58
+ if (!archived || archived.size !== size) throw new Error('Archived platform descriptor differs from the committed descriptor')
59
+ const archivedContents = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(await archived.arrayBuffer())
60
+ if (archivedContents !== contents) throw new Error('Archived platform descriptor differs from the committed descriptor')
61
+ signal.throwIfAborted()
62
+ return { commitSha, descriptor, archivePath, dispose }
63
+ } catch (error) {
64
+ await dispose()
65
+ throw error
66
+ }
67
+ }
68
+
69
+ // installation-init capture inherits stderr and has no cancellation; source commands must do neither.
70
+ async function runGit(cwd: string, args: string[], signal: AbortSignal, extraEnv: Record<string, string>): Promise<string> {
71
+ signal.throwIfAborted()
72
+ const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_')))
73
+ try {
74
+ const child = Bun.spawn(['git', '-c', 'core.attributesFile=/dev/null', '-c', 'core.fsmonitor=false', ...args], {
75
+ cwd,
76
+ env: {
77
+ ...env,
78
+ GIT_CONFIG_NOSYSTEM: '1',
79
+ GIT_CONFIG_GLOBAL: '/dev/null',
80
+ GIT_ATTR_NOSYSTEM: '1',
81
+ GIT_NO_REPLACE_OBJECTS: '1',
82
+ GIT_NO_LAZY_FETCH: '1',
83
+ ...extraEnv,
84
+ },
85
+ stdin: 'ignore',
86
+ stdout: 'pipe',
87
+ stderr: 'ignore',
88
+ })
89
+ const abort = () => child.kill()
90
+ signal.addEventListener('abort', abort, { once: true })
91
+ try {
92
+ if (signal.aborted) abort()
93
+ const [bytes, exitCode] = await Promise.all([new Response(child.stdout).arrayBuffer(), child.exited])
94
+ signal.throwIfAborted()
95
+ if (exitCode !== 0) throw new Error('Git source check failed')
96
+ return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes)
97
+ } finally {
98
+ signal.removeEventListener('abort', abort)
99
+ child.kill()
100
+ await child.exited
101
+ }
102
+ } catch {
103
+ signal.throwIfAborted()
104
+ throw new Error('Git platform source preparation failed; verify a clean tracked index and worktree and valid UTF-8 source metadata')
105
+ }
106
+ }
@@ -0,0 +1,34 @@
1
+ import { isZeropsUploadDestination } from '../index'
2
+
3
+ export type PlatformUploadFetch = (url: string, init: RequestInit) => Promise<Response>
4
+
5
+ export const validatePlatformUploadUrl = (value: string): void => {
6
+ if (!isZeropsUploadDestination(value)) {
7
+ throw new Error('Zerops archive upload destination rejected')
8
+ }
9
+ }
10
+
11
+ export const uploadPlatformArchive = async (
12
+ url: string,
13
+ archivePath: string,
14
+ signal: AbortSignal,
15
+ uploadFetch: PlatformUploadFetch,
16
+ ): Promise<void> => {
17
+ validatePlatformUploadUrl(url)
18
+ const uploadSignal = AbortSignal.any([signal, AbortSignal.timeout(10 * 60_000)])
19
+ const body = Bun.file(archivePath).stream()
20
+ try {
21
+ const response = await uploadFetch(url, {
22
+ method: 'PUT',
23
+ body,
24
+ redirect: 'error',
25
+ signal: uploadSignal,
26
+ })
27
+ await response.body?.cancel()
28
+ if (uploadSignal.aborted || !response.ok) throw new Error('upload rejected')
29
+ } catch {
30
+ throw new Error('Zerops archive upload failed')
31
+ } finally {
32
+ if (!body.locked) await body.cancel().catch(() => {})
33
+ }
34
+ }
@@ -0,0 +1,113 @@
1
+ // The proxy manifest a platform installation is configured with, split into the half that is the same
2
+ // on every installation and the half that is one account's.
3
+ //
4
+ // ── Why a template rather than a `ProxyManifest` with the hosts left out ──────────────────────────
5
+ //
6
+ // `parseProxyManifest` refuses an app that declares no hosts, and rightly — a host-less app is fronted
7
+ // by nothing and gates nothing. So the committed half cannot BE a manifest with empty hosts; it is a
8
+ // type of its own, and `resolvePlatformProxyManifest` is the one way to turn it into the document the
9
+ // proxy parses.
10
+ //
11
+ // ── The split ────────────────────────────────────────────────────────────────────────────────────
12
+ //
13
+ // template which apps exist, their ids, the private service each is dialed at, the public listener
14
+ // each answers on, and their gates. True of every installation, so it is GENERATED into
15
+ // `src/generated/` and reviewed in a diff.
16
+ // placement the hostnames and the browser-facing scheme. `proxy-292c-8080.prg1.zerops.app` names one
17
+ // account's project, and a production installation binds custom domains instead, so
18
+ // whoever is deploying supplies these.
19
+ //
20
+ // Nothing in this file imports the packages that OWN the gates. That is the point of the split: the
21
+ // gate sets live in `@fabrika/control` (private) and reach a deployed installation as DATA, through the
22
+ // generated artifact, so the published deploy path never depends on a package it cannot resolve.
23
+
24
+ import type { AppGates } from '@fabrika/auth-core'
25
+ import { parseProxyManifest, type ProxyManifest } from '@fabrika/proxy-core/manifest'
26
+
27
+ /** The platform services an installation's proxy fronts. Each name is a service hostname in the topology. */
28
+ export type PlatformProxyService = 'iam' | 'control' | 'operations'
29
+
30
+ /** One fronted app, in the form that is the same on every installation. */
31
+ export interface PlatformProxyApp {
32
+ /** The IAM app id. It is the audience of every token the proxy mints for this app's host. */
33
+ readonly id: string
34
+ /** The topology service that answers for it. */
35
+ readonly service: PlatformProxyService
36
+ /**
37
+ * The proxy's public listener this app answers on.
38
+ *
39
+ * On a `zerops-subdomain` installation a port IS a hostname — Zerops mints one generated host per
40
+ * HTTP port — so this is the only installation-independent half of `hosts`. A custom-domain
41
+ * installation ignores it: every domain arrives on one listener and the `Host` header decides.
42
+ */
43
+ readonly port: number
44
+ /** Where the proxy dials it over the project's private network. */
45
+ readonly upstream: string
46
+ /** Owned by the service's own gate module and copied in by the generator. Never written by hand. */
47
+ readonly gates: AppGates
48
+ }
49
+
50
+ /** Everything about an installation's manifest that does not depend on WHICH installation it is. */
51
+ export interface PlatformProxyManifestTemplate {
52
+ readonly apps: readonly PlatformProxyApp[]
53
+ }
54
+
55
+ /**
56
+ * The template's entry for one platform service.
57
+ *
58
+ * The app IDS live in the template, so a caller that needs one — the deploy's schema reconcile needs
59
+ * the console's — reads it from there rather than restating a literal that could drift from the
60
+ * document the proxy is actually configured with.
61
+ */
62
+ export const platformProxyAppFor = (
63
+ template: PlatformProxyManifestTemplate,
64
+ service: PlatformProxyService,
65
+ ): PlatformProxyApp => {
66
+ const app = template.apps.find((candidate) => candidate.service === service)
67
+ if (app === undefined) {
68
+ throw new Error(`the platform proxy manifest template fronts no \`${service}\` service`)
69
+ }
70
+ return app
71
+ }
72
+
73
+ /** Where one app answers in a particular composition. */
74
+ export interface PlatformProxyPlacement {
75
+ /** Public hostnames, without a port — `parseProxyManifest` refuses a `:port` suffix. */
76
+ readonly hosts: readonly string[]
77
+ /** Overrides the private dial address. The local composition runs IAM on a port of its own. */
78
+ readonly upstream?: string
79
+ }
80
+
81
+ /** One composition of the platform: a deployed installation, or the local Docker stack. */
82
+ export interface PlatformProxyComposition {
83
+ /** The scheme the BROWSER speaks to these apps — configuration, never a header (see `ProxyApp.scheme`). */
84
+ readonly scheme: 'http' | 'https'
85
+ readonly placement: Readonly<Record<PlatformProxyService, PlatformProxyPlacement>>
86
+ }
87
+
88
+ /**
89
+ * Bind a template to one composition and get the document the proxy actually parses.
90
+ *
91
+ * The result is returned THROUGH `parseProxyManifest`, so a duplicate id, a repeated host or a `:port`
92
+ * suffix is refused right here instead of at a deployed proxy's next build.
93
+ */
94
+ export const resolvePlatformProxyManifest = (
95
+ template: PlatformProxyManifestTemplate,
96
+ composition: PlatformProxyComposition,
97
+ ): ProxyManifest => {
98
+ const apps = template.apps.map((app) => {
99
+ const placement = composition.placement[app.service]
100
+ return {
101
+ id: app.id,
102
+ hosts: [...placement.hosts],
103
+ upstream: placement.upstream ?? app.upstream,
104
+ gates: app.gates,
105
+ scheme: composition.scheme,
106
+ }
107
+ })
108
+ const manifest = parseProxyManifest({ apps })
109
+ if (manifest === null) {
110
+ throw new Error('the resolved platform proxy manifest is not one the proxy would accept')
111
+ }
112
+ return manifest
113
+ }
@@ -0,0 +1,79 @@
1
+ // The six values `fabrika platform install --provider=zerops` GENERATES, and their shapes.
2
+ //
3
+ // Every one of them is written to a Zerops service and never stored anywhere else — there is no `.env`
4
+ // on this path, deliberately (see `init.ts`). Only the provisioning key is ever printed, once, at the
5
+ // end of the install, because `platform init` asks the operator for it afterwards.
6
+ //
7
+ // The shapes are not free choices. Each one is what the consuming code already parses:
8
+ //
9
+ // • `FABRIKA_IAM_SIGNING_KEYS` is a JSON array of PRIVATE JWKs, index 0 the active signer
10
+ // (`@fabrika/iam`'s `Signer.fromPrivateJwks`). ES256 / P-256, RFC 7638 thumbprint `kid`.
11
+ // • A `px_` key is IAM's own credential spelling, and every shared secret must be at least 32
12
+ // characters or the service refuses to boot — 32 random bytes at base64url is 43.
13
+ // • The vault KEK is 32 RAW BYTES at base64, not base64url: `@fabrika/control` imports it as an
14
+ // AES key, and base64url is a different alphabet.
15
+ //
16
+ // Nothing here re-reads an existing value. `install` is a bring-up; rotating a live installation's
17
+ // signing keys or vault KEK is not something a bring-up may do by accident, so the command refuses to
18
+ // run against a project that already carries these keys rather than generating over them.
19
+
20
+ import { createHash, generateKeyPairSync, randomBytes } from 'node:crypto'
21
+
22
+ /**
23
+ * A 256-bit secret in base64url, never starting with `-` or `_`.
24
+ *
25
+ * The re-roll is copied from `@fabrika/local-stack`'s `prepare.ts` and it is not superstition: a
26
+ * credential beginning with `-` reads as a FLAG to anything that later takes it on a command line, no
27
+ * matter how it is quoted. One rolled password beginning with `-` made `mc alias set` print its usage
28
+ * and loop forever. It costs nothing and removes a bug that appears about one install in sixteen.
29
+ */
30
+ const randomSecret = (): string => {
31
+ for (;;) {
32
+ const value = randomBytes(32).toString('base64url')
33
+ if (!value.startsWith('-') && !value.startsWith('_')) {
34
+ return value
35
+ }
36
+ }
37
+ }
38
+
39
+ /** The `px_` prefix IAM's own credentials carry, so a key looks like what it is wherever it turns up. */
40
+ const randomPxKey = (): string => `px_${randomSecret()}`
41
+
42
+ /**
43
+ * One ES256 (EC P-256) private JWK with an RFC 7638 thumbprint `kid`, shaped exactly as IAM's
44
+ * `Signer.fromPrivateJwks` loads it.
45
+ */
46
+ const generateEs256Jwk = (): JsonWebKey & { kid: string; alg: string; use: string } => {
47
+ const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' })
48
+ const jwk = privateKey.export({ format: 'jwk' })
49
+ // RFC 7638 thumbprint over the canonical EC members, in lexicographic order (crv, kty, x, y).
50
+ const thumbprint = createHash('sha256').update(JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y })).digest('base64url')
51
+ return { ...jwk, kid: thumbprint, alg: 'ES256', use: 'sig' }
52
+ }
53
+
54
+ /** Everything one installation is seeded with. Several of these are shared by more than one service. */
55
+ export interface InstallationSecrets {
56
+ /** IAM's token-signing material: a JSON array of ES256 private JWKs, index 0 active. */
57
+ readonly signingKeys: string
58
+ /** Gates IAM's management `/rpc/*` surface. Held by IAM, control and Operations. */
59
+ readonly rpcKey: string
60
+ /** Gates IAM's `/auth/mint/*` surface, which the proxy calls on the cold path. */
61
+ readonly proxyKey: string
62
+ /** The `px_` admin bearer IAM admits as a synthetic global admin. The one value this command prints. */
63
+ readonly provisioningKey: string
64
+ /** The catalog credential control and Operations share. No `px_` prefix — it is not an IAM credential. */
65
+ readonly operationsSyncKey: string
66
+ /** The control plane's vault KEK: 32 raw bytes at base64. Its loss is unrecoverable by design. */
67
+ readonly vaultKey: string
68
+ }
69
+
70
+ /** Roll one installation's secrets. Pure entropy in, no I/O, nothing persisted. */
71
+ export const generateInstallationSecrets = (): InstallationSecrets => ({
72
+ signingKeys: JSON.stringify([generateEs256Jwk()]),
73
+ rpcKey: randomPxKey(),
74
+ proxyKey: randomPxKey(),
75
+ provisioningKey: randomPxKey(),
76
+ operationsSyncKey: randomSecret(),
77
+ // base64, NOT base64url: the vault imports these bytes as an AES key.
78
+ vaultKey: randomBytes(32).toString('base64'),
79
+ })
@@ -0,0 +1,122 @@
1
+ // What the operator's sidecar repository contains, and the one rule about its pin.
2
+ //
3
+ // ── Why a sidecar repository at all ───────────────────────────────────────────────────────────────
4
+ //
5
+ // [ADR-0025](../../../docs/decisions/0025-the-operator-installs-the-platform-fabrika-deploys-apps.md):
6
+ // a pipeline that deploys an installation needs write access to that installation's cloud account, and
7
+ // this is ONE public repository installed by many operators. So the pipeline cannot live here. fabrika
8
+ // ships the generator; the operator owns the pipeline, its GitHub Environment, and the credentials in
9
+ // it.
10
+ //
11
+ // ── The pin is a TAG, and this file is where that is enforced twice ───────────────────────────────
12
+ //
13
+ // `fabrika.ref` names a published tag of `contember/fabrika-platform`, never a branch: a namespace
14
+ // proxy and an installation both build from tags, so a moving ref would make "which version is this
15
+ // installation running" unanswerable. `assertPinnedTag` refuses a branch when init writes the file, and
16
+ // the generated workflow refuses one again at run time — two gates, because the file is operator-owned
17
+ // after creation and the second gate is the only one that sees what the operator later wrote.
18
+ //
19
+ // ── The templates are not Cloudflare's ────────────────────────────────────────────────────────────
20
+ //
21
+ // The shape is the same (a workflow, a ref, a README, a `.gitignore`) and almost nothing else is.
22
+ // Cloudflare's workflow runs three deploy stages because on that provider the ORDER lives in the
23
+ // pipeline; this one runs a single step because on Zerops the order is inside the command (ADR-0027).
24
+ // Cloudflare builds a runner image and needs a GitHub App; Zerops has no runner at all (ADR-0003).
25
+
26
+ import { resolve } from 'node:path'
27
+
28
+ const TEMPLATES = resolve(import.meta.dir, 'templates')
29
+
30
+ /** Everything the scaffold stages: the CLI-owned trio plus the operator-owned, write-once pin. */
31
+ export const SIDECAR_FILES = ['.gitignore', 'README.md', 'fabrika.ref', '.github/workflows/platform.yml'] as const
32
+
33
+ /** The file that pins which published fabrika tag this installation runs. */
34
+ export const FABRIKA_REF_FILE = 'fabrika.ref'
35
+
36
+ /** What a rendered sidecar repository is named after. */
37
+ export interface SidecarValues {
38
+ /** The operator's name for this installation. The GitHub Environment is named after it too. */
39
+ readonly installation: string
40
+ /** `<owner>/<name>` of the sidecar repository itself. */
41
+ readonly repo: string
42
+ /** The published fabrika tag to pin, used only when `fabrika.ref` does not exist yet. */
43
+ readonly fabrikaRef: string
44
+ }
45
+
46
+ /**
47
+ * A published release tag: `v` and a semantic version.
48
+ *
49
+ * The same shape `scripts/release.ts` accepts, restated rather than imported — that script is not a
50
+ * package and this check has to hold in a published tarball.
51
+ */
52
+ const TAG_PATTERN = /^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
53
+
54
+ /** The pinned ref, or an error naming what was found. A branch is refused, deliberately and loudly. */
55
+ export const assertPinnedTag = (ref: string): string => {
56
+ const trimmed = ref.trim()
57
+ if (trimmed === '') {
58
+ throw new Error('a published fabrika tag is required, for example v0.1.0')
59
+ }
60
+ if (!TAG_PATTERN.test(trimmed)) {
61
+ throw new Error(
62
+ `\`${trimmed}\` is not a published tag. A sidecar repository pins a TAG such as v0.1.0 and never a branch: `
63
+ + 'an installation that tracked a moving ref could not say which version it runs (ADR-0025)',
64
+ )
65
+ }
66
+ return trimmed
67
+ }
68
+
69
+ /** Substitute every placeholder in a template. */
70
+ const render = async (name: string, values: SidecarValues): Promise<string> => {
71
+ const raw = await Bun.file(resolve(TEMPLATES, name)).text()
72
+ return raw
73
+ .replaceAll('{{INSTALLATION}}', values.installation)
74
+ .replaceAll('{{REPO}}', values.repo)
75
+ .replaceAll('{{FABRIKA_REF}}', values.fabrikaRef)
76
+ }
77
+
78
+ const write = async (dir: string, rel: string, content: string): Promise<void> => {
79
+ await Bun.write(resolve(dir, rel), content)
80
+ }
81
+
82
+ /**
83
+ * Write the sidecar files into `dir`.
84
+ *
85
+ * The workflow, the README and the `.gitignore` are CLI-owned and refreshed on every run, so a fix to
86
+ * the generated pipeline reaches every installation on its operator's next `init`. `fabrika.ref` is
87
+ * written ONLY when absent: bumping it is how an operator rolls the installation forward, and a refresh
88
+ * that reset it would silently roll them back.
89
+ *
90
+ * An existing `fabrika.ref` is validated rather than ignored — a sidecar that has drifted onto a branch
91
+ * is exactly the case the two gates exist for, and finding it here beats finding it in a failed run.
92
+ */
93
+ export const materializeSidecarScaffold = async (dir: string, values: SidecarValues): Promise<void> => {
94
+ assertPinnedTag(values.fabrikaRef)
95
+ const pin = Bun.file(resolve(dir, FABRIKA_REF_FILE))
96
+ const existing = (await pin.exists()) ? (await pin.text()).trim() : undefined
97
+ if (existing !== undefined) {
98
+ assertPinnedTag(existing)
99
+ }
100
+ await write(dir, '.gitignore', await Bun.file(resolve(TEMPLATES, 'gitignore')).text())
101
+ await write(dir, 'README.md', await render('README.md', values))
102
+ await write(dir, '.github/workflows/platform.yml', await render('platform.yml', values))
103
+ if (existing === undefined) {
104
+ await write(dir, FABRIKA_REF_FILE, await render('fabrika.ref', values))
105
+ }
106
+ }
107
+
108
+ /** The pin a checkout carries, for the closing summary. */
109
+ export const readPinnedTag = async (dir: string): Promise<string> => {
110
+ const file = Bun.file(resolve(dir, FABRIKA_REF_FILE))
111
+ return (await file.exists()) ? (await file.text()).trim() : ''
112
+ }
113
+
114
+ /**
115
+ * The default sidecar repository for an installation, following the `contember/vozka-platform-mangoweb`
116
+ * precedent: named so its relation to the installation is obvious. A parameter, never a constant — one
117
+ * operator's installations are not another's.
118
+ */
119
+ export const defaultSidecarRepo = (installation: string): string => `contember/fabrika-zerops-${installation}`
120
+
121
+ /** Where the sidecar is checked out locally: `./fabrika-zerops-<installation>` under the cwd. */
122
+ export const defaultCheckoutDir = (installation: string): string => resolve(process.cwd(), `fabrika-zerops-${installation}`)
@@ -0,0 +1,12 @@
1
+ export const readInstallationSourceDir = (argv: readonly string[], env: Record<string, string | undefined>): string => {
2
+ if (argv.some((arg) => arg === '--from-git' || arg.startsWith('--from-git=')) || env['FABRIKA_ZEROPS_BUILD_FROM_GIT'] !== undefined) {
3
+ throw new Error(
4
+ 'Mutable installation Git sources are refused. Remove --from-git and unset FABRIKA_ZEROPS_BUILD_FROM_GIT; run the CLI from a clean full checkout with --source-dir=<checkout>.',
5
+ )
6
+ }
7
+ const values = argv.filter((arg) => arg.startsWith('--source-dir='))
8
+ if (values.length > 1) throw new Error('--source-dir was given more than once')
9
+ const value = values[0]?.slice('--source-dir='.length).trim()
10
+ if (!value) throw new Error('--source-dir=<checkout> is required; run the CLI from that clean full checkout')
11
+ return value
12
+ }