@fabrika/provider-zerops 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -7
- package/src/api/client.ts +11 -14
- package/src/api/polling.ts +42 -0
- package/src/api/readers.ts +9 -2
- package/src/api/service-env-limit.ts +29 -0
- package/src/api/service-env-response.ts +34 -2
- package/src/api/types.ts +39 -9
- package/src/api.ts +10 -1
- package/src/control.ts +23 -8
- package/src/execution.ts +44 -5
- package/src/index.ts +5 -0
- package/src/installation/admin.ts +4 -2
- package/src/installation/checker-release.ts +44 -0
- package/src/installation/deploy.ts +95 -37
- package/src/installation/generated/platform-console-schema.ts +18 -2
- package/src/installation/generated/platform-proxy-manifest.ts +20 -0
- package/src/installation/guide.md +169 -52
- package/src/installation/index.ts +22 -15
- package/src/installation/init-options.ts +2 -2
- package/src/installation/init.ts +8 -2
- package/src/installation/install-collaborators.ts +4 -0
- package/src/installation/install-environment.ts +52 -30
- package/src/installation/install-options.ts +20 -19
- package/src/installation/install-project.ts +23 -15
- package/src/installation/install-provisioning.ts +12 -12
- package/src/installation/install-secrets.ts +89 -0
- package/src/installation/install-token.ts +37 -0
- package/src/installation/install.ts +34 -21
- package/src/installation/machine-key.ts +3 -2
- package/src/installation/platform-archive.ts +87 -0
- package/src/installation/platform-upload.ts +2 -2
- package/src/installation/secrets.ts +3 -4
- package/src/installation/sidecar.ts +38 -2
- package/src/installation/templates/README.md +1 -1
- package/src/installation/templates/platform.yml +23 -2
- package/src/installation/topology/setups.ts +15 -27
- package/src/installation/topology/topology.ts +105 -104
- package/src/installation/upgrade.ts +24 -7
- package/src/managed-env.ts +11 -0
- package/src/manifest.ts +25 -3
- package/src/namespace/codec.ts +6 -1
- package/src/namespace/errors.ts +3 -1
- package/src/namespace/provisioning.ts +18 -5
- package/src/namespace/proxy-attempt.ts +56 -14
- package/src/namespace/update.ts +50 -8
- package/src/provider.ts +57 -13
- package/src/proxy-manifest-carrier.ts +32 -0
- package/src/source/port.ts +9 -1
- package/src/installation/manifest.ts +0 -95
|
@@ -11,7 +11,8 @@ import {
|
|
|
11
11
|
warn as consoleWarn,
|
|
12
12
|
} from '@fabrika/installation-init'
|
|
13
13
|
import type { SchemaReconciler } from '@fabrika/provider-contract'
|
|
14
|
-
import { createZeropsApi, defaultSleep, type ZeropsNamespaceProxySource } from '../index'
|
|
14
|
+
import { createZeropsApi, defaultSleep, readableContent, type ZeropsNamespaceProxySource } from '../index'
|
|
15
|
+
import { assertCheckerPublished, npmCheckerPublication } from './checker-release'
|
|
15
16
|
import {
|
|
16
17
|
deployPlatformService,
|
|
17
18
|
deployPreparedPlatform,
|
|
@@ -26,7 +27,7 @@ import { PLATFORM_PROXY_MANIFEST_TEMPLATE } from './generated/platform-proxy-man
|
|
|
26
27
|
import { derivePlatformHosts, type PlatformHosts, platformOrigin, ZEROPS_SUBDOMAIN_VARIABLE } from './hosts'
|
|
27
28
|
import { checkedEnvironmentName } from './init-options'
|
|
28
29
|
import { assertRunning, type InstallCollaborators, type InstallPrompts } from './install-collaborators'
|
|
29
|
-
import { installationServiceEnv, passOneProxyEnv, writeServiceEnv } from './install-environment'
|
|
30
|
+
import { installationServiceEnv, PASS_ONE_PROXY_MANIFEST, passOneProxyEnv, writeServiceEnv } from './install-environment'
|
|
30
31
|
import type { PlatformInstallInput } from './install-options'
|
|
31
32
|
import { resolveInstallationProject } from './install-project'
|
|
32
33
|
import {
|
|
@@ -38,6 +39,8 @@ import {
|
|
|
38
39
|
resolvePlatformServices,
|
|
39
40
|
serviceOf,
|
|
40
41
|
} from './install-provisioning'
|
|
42
|
+
import { renderSecretsFile, writeSecretsFile } from './install-secrets'
|
|
43
|
+
import { explainMintFailure } from './install-token'
|
|
41
44
|
import { prepareInstallationSource, prepareNamespaceProxySource, validateInstallationSetups } from './installation-source'
|
|
42
45
|
import type { PreparedPlatformSource } from './platform-source'
|
|
43
46
|
import { generateInstallationSecrets, type InstallationSecrets } from './secrets'
|
|
@@ -61,14 +64,14 @@ const passOne = async (
|
|
|
61
64
|
const { log, prompts } = collaborators
|
|
62
65
|
log.step('Pass 1 — build the proxy so the installation has public hostnames')
|
|
63
66
|
log.info('An empty manifest is a legal one: the proxy comes up answering 404 and publishes one hostname per HTTP port.')
|
|
64
|
-
if (!(await prompts.confirm(`Write
|
|
67
|
+
if (!(await prompts.confirm(`Write 3 variables on \`proxy\` and build commit ${source.commitSha}? (about 4 minutes)`, true))) {
|
|
65
68
|
throw new Error('declined: no variable was written, and re-running the install is safe')
|
|
66
69
|
}
|
|
67
70
|
await writeServiceEnv(proxy, passOneProxyEnv(secrets), collaborators)
|
|
68
|
-
await deployPlatformService(proxy, { source, dryRun: false }, collaborators)
|
|
71
|
+
await deployPlatformService(proxy, { source, dryRun: false, proxyManifest: PASS_ONE_PROXY_MANIFEST }, collaborators)
|
|
69
72
|
|
|
70
73
|
const proxyEnv = await readServiceEnv(proxy.id, collaborators)
|
|
71
|
-
const subdomains = proxyEnv.get(ZEROPS_SUBDOMAIN_VARIABLE)
|
|
74
|
+
const subdomains = readableContent(proxyEnv.get(ZEROPS_SUBDOMAIN_VARIABLE))
|
|
72
75
|
if (subdomains === undefined || subdomains.trim() === '') {
|
|
73
76
|
throw new Error('the proxy published no Zerops subdomain after its first deploy — this installation has no address to be named by')
|
|
74
77
|
}
|
|
@@ -88,6 +91,7 @@ export const runInstall = async (input: PlatformInstallInput, collaborators: Ins
|
|
|
88
91
|
const source = await collaborators.prepareSource(input.sourceDir, collaborators.signal)
|
|
89
92
|
try {
|
|
90
93
|
validateInstallationSetups(source, PLATFORM_DEPLOY_ORDER)
|
|
94
|
+
await assertCheckerPublished(collaborators.checkerPublished, collaborators.signal)
|
|
91
95
|
const proxySource = await prepareNamespaceProxySource(source)
|
|
92
96
|
await installPreparedPlatform(input, collaborators, source, proxySource)
|
|
93
97
|
} finally {
|
|
@@ -114,7 +118,7 @@ const installPreparedPlatform = async (
|
|
|
114
118
|
log.ok(`project ${project.name} (${project.id})`)
|
|
115
119
|
|
|
116
120
|
log.step('Provision the topology')
|
|
117
|
-
const document = provisioningDocument(environment)
|
|
121
|
+
const document = provisioningDocument(environment, input.tier)
|
|
118
122
|
const declared = document.document.services.map((service) => service.hostname)
|
|
119
123
|
const existing = existingServices(declared, await collaborators.api.listProjectServices({ projectId: project.id, signal }))
|
|
120
124
|
assertNoServiceIsBeingCreated(existing)
|
|
@@ -143,12 +147,21 @@ const installPreparedPlatform = async (
|
|
|
143
147
|
// application at all (ADR-0034). `NO_ACCESS` still keeps every project it did not create out of reach.
|
|
144
148
|
canCreateProjects: true,
|
|
145
149
|
signal,
|
|
150
|
+
}).catch((failure: unknown) => {
|
|
151
|
+
throw explainMintFailure(failure, project.id)
|
|
146
152
|
})
|
|
147
153
|
if (token.token === '') {
|
|
148
154
|
throw new Error('Zerops minted an integration token with no value — the control plane cannot deploy anything without one')
|
|
149
155
|
}
|
|
150
156
|
log.ok(`integration token ${token.id} minted, ${token.projects.length} project grant(s)`)
|
|
151
157
|
|
|
158
|
+
log.step('Store the credentials locally')
|
|
159
|
+
// BEFORE the first one is placed: Zerops keeps them sensitive and will not hand them back.
|
|
160
|
+
await collaborators.storeSecrets(
|
|
161
|
+
renderSecretsFile({ environment, projectId: project.id, clientId: input.clientId, secrets, zeropsAccessToken: token.token }),
|
|
162
|
+
)
|
|
163
|
+
log.ok(`wrote ${input.secretsFile} (mode 0600) — every credential this installation was created with; nothing else holds a readable copy`)
|
|
164
|
+
|
|
152
165
|
const hosts = await passOne(source, serviceOf(services, PLATFORM_PROXY_SERVICE), secrets, collaborators)
|
|
153
166
|
log.ok(`iam ${hosts.iam}`)
|
|
154
167
|
log.ok(`console ${hosts.control}`)
|
|
@@ -157,6 +170,7 @@ const installPreparedPlatform = async (
|
|
|
157
170
|
log.step('Write every remaining variable')
|
|
158
171
|
const desired = installationServiceEnv({
|
|
159
172
|
environment,
|
|
173
|
+
tier: input.tier,
|
|
160
174
|
scheme: input.scheme,
|
|
161
175
|
hosts,
|
|
162
176
|
clientId: input.clientId,
|
|
@@ -170,7 +184,7 @@ const installPreparedPlatform = async (
|
|
|
170
184
|
throw new Error(
|
|
171
185
|
'declined: the proxy is built but the installation is not configured. Its generated proxy key is already placed, so '
|
|
172
186
|
+ 're-running `platform install` is refused. Neither `platform deploy` nor `platform init` completes a partial bootstrap. '
|
|
173
|
-
+
|
|
187
|
+
+ `Keep ${input.secretsFile} and arrange operator-owned recovery, or start a fresh installation in a separate empty project`,
|
|
174
188
|
)
|
|
175
189
|
}
|
|
176
190
|
for (const hostname of PLATFORM_DEPLOY_ORDER) {
|
|
@@ -178,9 +192,9 @@ const installPreparedPlatform = async (
|
|
|
178
192
|
}
|
|
179
193
|
|
|
180
194
|
log.step('Pass 2 — deploy the installation')
|
|
195
|
+
const withSecrets = (command: string): string => `( set -a && . ${shellQuoted(input.secretsFile)} && set +a && ${command} )`
|
|
181
196
|
log.info(
|
|
182
|
-
|
|
183
|
-
+ 'and FABRIKA_ZEROPS_ACCESS_TOKEN from `control` in Zerops, then run `fabrika platform deploy --provider=zerops` with this project and source checkout.',
|
|
197
|
+
`Every bootstrap variable is placed. If this deploy fails, finish it with \`platform deploy\`, which takes its credentials from ${input.secretsFile}.`,
|
|
184
198
|
)
|
|
185
199
|
log.info(
|
|
186
200
|
`\`platform deploy\` owns the whole ordered sequence: ${PLATFORM_CONCURRENT_DEPLOY.join(' + ')} together, then ${
|
|
@@ -189,7 +203,7 @@ const installPreparedPlatform = async (
|
|
|
189
203
|
)
|
|
190
204
|
if (!(await prompts.confirm('Deploy the installation now? (about 15 minutes)', true))) {
|
|
191
205
|
log.action('OPERATOR ACTION — every variable is placed; the installation is one command from running', [
|
|
192
|
-
`
|
|
206
|
+
withSecrets(`fabrika platform deploy --provider=zerops --source-dir=. --project-id=${project.id} --env=${environment}`),
|
|
193
207
|
])
|
|
194
208
|
} else {
|
|
195
209
|
await deployPreparedPlatform(
|
|
@@ -209,22 +223,19 @@ const installPreparedPlatform = async (
|
|
|
209
223
|
}
|
|
210
224
|
|
|
211
225
|
log.step('Hand off')
|
|
212
|
-
|
|
213
|
-
log.
|
|
214
|
-
|
|
215
|
-
])
|
|
216
|
-
log.info('`fabrika platform init --provider=zerops <installation>` asks for two required deployment secrets:')
|
|
217
|
-
log.info(' FABRIKA_IAM_PROVISIONING_KEY the key above')
|
|
218
|
-
log.info(' FABRIKA_ZEROPS_ACCESS_TOKEN the integration token just minted — read it back from the `control` service, never printed here')
|
|
226
|
+
log.info(`Every credential is in ${input.secretsFile}, and nothing prints one. Keep that file private and backed up:`)
|
|
227
|
+
log.info('it holds the vault key, whose loss is unrecoverable, and Zerops does not hand any of them back.')
|
|
228
|
+
log.info('`platform init` and `control key issue` read their credentials from it under the names it uses.')
|
|
219
229
|
log.info(`The console answers at ${platformOrigin(input.scheme, hosts.control)}, once someone can sign in: nobody can yet.`)
|
|
220
|
-
//
|
|
221
|
-
// that carries a secret value, so the ready-to-run command does not become a second place one is
|
|
222
|
-
// printed. `platform admin` is idempotent, so re-running it is safe.
|
|
230
|
+
// `platform admin` is idempotent, so re-running it is safe.
|
|
223
231
|
log.action('ADMIT YOURSELF — the console has no administrator until this runs', [
|
|
224
|
-
`
|
|
232
|
+
withSecrets(`fabrika platform admin --provider=zerops --email=<you> --iam-host=${hosts.iam}`),
|
|
225
233
|
])
|
|
226
234
|
}
|
|
227
235
|
|
|
236
|
+
/** A path as one shell word. */
|
|
237
|
+
const shellQuoted = (path: string): string => `'${path.replaceAll("'", "'\\''")}'`
|
|
238
|
+
|
|
228
239
|
/**
|
|
229
240
|
* The confirmations stand between a stray invocation and an installation: this command generates six
|
|
230
241
|
* credentials, mints a Zerops token on the account and deploys.
|
|
@@ -242,6 +253,7 @@ export const consoleInstallCollaborators = (input: PlatformInstallInput, reconci
|
|
|
242
253
|
const prompts = installPrompts(input, process.stdin, (line) => consoleInfo(line))
|
|
243
254
|
return {
|
|
244
255
|
prepareSource: prepareInstallationSource,
|
|
256
|
+
checkerPublished: npmCheckerPublication(),
|
|
245
257
|
uploadFetch: (url, init) => fetch(url, init),
|
|
246
258
|
api: createZeropsApi({
|
|
247
259
|
token: input.accessToken,
|
|
@@ -258,5 +270,6 @@ export const consoleInstallCollaborators = (input: PlatformInstallInput, reconci
|
|
|
258
270
|
},
|
|
259
271
|
prompts,
|
|
260
272
|
signal: new AbortController().signal,
|
|
273
|
+
storeSecrets: (text) => writeSecretsFile(input.secretsFile, text),
|
|
261
274
|
}
|
|
262
275
|
}
|
|
@@ -50,8 +50,9 @@ export const machineKeyNextStepLines = (step: MachineKeyNextStep = {}): readonly
|
|
|
50
50
|
// Never a guessed origin: the control service records the real one, and reading it is one command.
|
|
51
51
|
? readFrom('FABRIKA_IAM_RPC_URL', "`control`'s own FABRIKA_IAM_ISSUER, in the Zerops project")
|
|
52
52
|
: `export FABRIKA_IAM_RPC_URL=${step.iamOrigin}`,
|
|
53
|
-
|
|
54
|
-
readFrom('
|
|
53
|
+
// Not the service env: Zerops reads a sensitive value back as `REDACTED` outside sudo mode.
|
|
54
|
+
readFrom('FABRIKA_IAM_RPC_KEY', 'the secrets file `platform install` wrote (--secrets-file)'),
|
|
55
|
+
readFrom('FABRIKA_IAM_PROVISIONING_KEY', 'the same file — the copy an operator can read;'),
|
|
55
56
|
continued(
|
|
56
57
|
step.sidecarEnvironment === undefined
|
|
57
58
|
? "the sidecar repository's Environment holds the copy the pipeline uses"
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ZeropsSourceDataFile } from '../source/port'
|
|
2
|
+
|
|
3
|
+
const BLOCK = 512
|
|
4
|
+
const END_OF_ARCHIVE = new Uint8Array(BLOCK * 2)
|
|
5
|
+
|
|
6
|
+
const text = (block: Uint8Array, start: number, length: number): string => {
|
|
7
|
+
const field = block.subarray(start, start + length)
|
|
8
|
+
const end = field.indexOf(0)
|
|
9
|
+
return new TextDecoder().decode(end < 0 ? field : field.subarray(0, end))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const octal = (block: Uint8Array, start: number, length: number): number => {
|
|
13
|
+
const raw = text(block, start, length).trim()
|
|
14
|
+
const value = raw === '' ? 0 : Number.parseInt(raw, 8)
|
|
15
|
+
if (!/^[0-7]*$/.test(raw) || !Number.isSafeInteger(value)) throw new Error('the prepared platform archive is not a tar archive')
|
|
16
|
+
return value
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const blocksOf = (size: number): number => Math.ceil(size / BLOCK) * BLOCK
|
|
20
|
+
|
|
21
|
+
/** The `path` record of a pax extended header, the form `git archive` uses for a name over 100 bytes. */
|
|
22
|
+
const paxPath = (payload: Uint8Array): string | undefined => {
|
|
23
|
+
for (const record of new TextDecoder().decode(payload).split('\n')) {
|
|
24
|
+
const match = /^\d+ path=(.*)$/.exec(record)
|
|
25
|
+
if (match?.[1] !== undefined) return match[1]
|
|
26
|
+
}
|
|
27
|
+
return undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const header = (path: string, size: number): Uint8Array => {
|
|
31
|
+
const block = new Uint8Array(BLOCK)
|
|
32
|
+
const write = (start: number, value: string): void => void block.set(new TextEncoder().encode(value), start)
|
|
33
|
+
write(0, path)
|
|
34
|
+
write(100, '0000644\0')
|
|
35
|
+
write(108, '0000000\0')
|
|
36
|
+
write(116, '0000000\0')
|
|
37
|
+
write(124, `${size.toString(8).padStart(11, '0')}\0`)
|
|
38
|
+
write(136, '00000000000\0')
|
|
39
|
+
block.fill(0x20, 148, 156)
|
|
40
|
+
write(156, '0')
|
|
41
|
+
write(257, 'ustar\0')
|
|
42
|
+
write(263, '00')
|
|
43
|
+
const checksum = block.reduce((sum, byte) => sum + byte, 0)
|
|
44
|
+
write(148, `${checksum.toString(8).padStart(6, '0')}\0 `)
|
|
45
|
+
return block
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The prepared platform archive plus the one generated data file (ADR-0045): every entry of the commit is copied
|
|
50
|
+
* byte for byte, and the file is appended after the last one. A commit that already holds the path, as a file or
|
|
51
|
+
* as a directory, is refused rather than overridden.
|
|
52
|
+
*/
|
|
53
|
+
export const archiveWithDataFile = (compressed: Uint8Array<ArrayBuffer>, file: ZeropsSourceDataFile): Uint8Array<ArrayBuffer> => {
|
|
54
|
+
const path = new TextEncoder().encode(file.path)
|
|
55
|
+
if (path.byteLength === 0 || path.byteLength > 100 || file.path.includes('..') || file.path.startsWith('/')) {
|
|
56
|
+
throw new Error(`the data file path \`${file.path}\` is not one a ustar header holds`)
|
|
57
|
+
}
|
|
58
|
+
const tar = Bun.gunzipSync(compressed)
|
|
59
|
+
let offset = 0
|
|
60
|
+
let pendingPath: string | undefined
|
|
61
|
+
for (;;) {
|
|
62
|
+
const block = tar.subarray(offset, offset + BLOCK)
|
|
63
|
+
if (block.byteLength < BLOCK) throw new Error('the prepared platform archive ends without its end-of-archive blocks')
|
|
64
|
+
if (block.every((byte) => byte === 0)) break
|
|
65
|
+
const size = octal(block, 124, 12)
|
|
66
|
+
const type = text(block, 156, 1)
|
|
67
|
+
const content = tar.subarray(offset + BLOCK, offset + BLOCK + size)
|
|
68
|
+
if (type === 'x') {
|
|
69
|
+
pendingPath = paxPath(content)
|
|
70
|
+
} else if (type !== 'g') {
|
|
71
|
+
const prefix = text(block, 345, 155)
|
|
72
|
+
const named = pendingPath ?? (prefix === '' ? text(block, 0, 100) : `${prefix}/${text(block, 0, 100)}`)
|
|
73
|
+
pendingPath = undefined
|
|
74
|
+
const entry = named.replace(/\/$/, '')
|
|
75
|
+
if (entry === file.path || entry.startsWith(`${file.path}/`)) {
|
|
76
|
+
throw new Error(`the platform source already holds \`${file.path}\`, the proxy manifest's place in the build archive`)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
offset += BLOCK + blocksOf(size)
|
|
80
|
+
}
|
|
81
|
+
const contents = new TextEncoder().encode(file.contents)
|
|
82
|
+
const appended = new Uint8Array(offset + BLOCK + blocksOf(contents.byteLength) + END_OF_ARCHIVE.byteLength)
|
|
83
|
+
appended.set(tar.subarray(0, offset))
|
|
84
|
+
appended.set(header(file.path, contents.byteLength), offset)
|
|
85
|
+
appended.set(contents, offset + BLOCK)
|
|
86
|
+
return Bun.gzipSync(appended)
|
|
87
|
+
}
|
|
@@ -10,13 +10,13 @@ export const validatePlatformUploadUrl = (value: string): void => {
|
|
|
10
10
|
|
|
11
11
|
export const uploadPlatformArchive = async (
|
|
12
12
|
url: string,
|
|
13
|
-
|
|
13
|
+
archive: Blob,
|
|
14
14
|
signal: AbortSignal,
|
|
15
15
|
uploadFetch: PlatformUploadFetch,
|
|
16
16
|
): Promise<void> => {
|
|
17
17
|
validatePlatformUploadUrl(url)
|
|
18
18
|
const uploadSignal = AbortSignal.any([signal, AbortSignal.timeout(10 * 60_000)])
|
|
19
|
-
const body =
|
|
19
|
+
const body = archive.stream()
|
|
20
20
|
try {
|
|
21
21
|
const response = await uploadFetch(url, {
|
|
22
22
|
method: 'PUT',
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// The six values `fabrika platform install --provider=zerops` GENERATES, and their shapes.
|
|
2
2
|
//
|
|
3
|
-
// Every one of them is written to a Zerops service
|
|
4
|
-
//
|
|
5
|
-
// end of the install, because `platform init` asks the operator for it afterwards.
|
|
3
|
+
// Every one of them is written to a Zerops service as a SENSITIVE variable, which Zerops does not hand
|
|
4
|
+
// back, and to the operator's local secrets file (`install-secrets.ts`) before that. None is printed.
|
|
6
5
|
//
|
|
7
6
|
// The shapes are not free choices. Each one is what the consuming code already parses:
|
|
8
7
|
//
|
|
@@ -59,7 +58,7 @@ export interface InstallationSecrets {
|
|
|
59
58
|
readonly rpcKey: string
|
|
60
59
|
/** Authenticates IAM's `/auth/mint/*` surface, which the proxy calls on the cold path. */
|
|
61
60
|
readonly proxyKey: string
|
|
62
|
-
/** The `px_` admin bearer IAM admits as a synthetic global admin.
|
|
61
|
+
/** The `px_` admin bearer IAM admits as a synthetic global admin. `platform init` and `admin` take it. */
|
|
63
62
|
readonly provisioningKey: string
|
|
64
63
|
/** The catalog credential control and Operations share. No `px_` prefix — it is not an IAM credential. */
|
|
65
64
|
readonly operationsSyncKey: string
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
// Cloudflare builds a runner image and needs a GitHub App; Zerops has no runner at all (ADR-0003).
|
|
25
25
|
|
|
26
26
|
import { resolve } from 'node:path'
|
|
27
|
+
import { z } from 'zod'
|
|
27
28
|
|
|
28
29
|
const TEMPLATES = resolve(import.meta.dir, 'templates')
|
|
29
30
|
|
|
@@ -33,6 +34,9 @@ export const SIDECAR_FILES = ['.gitignore', 'README.md', 'fabrika.ref', '.github
|
|
|
33
34
|
/** The file that pins which published fabrika tag this installation runs. */
|
|
34
35
|
export const FABRIKA_REF_FILE = 'fabrika.ref'
|
|
35
36
|
|
|
37
|
+
/** The generated pipeline. CLI-owned: `init` and `upgrade` both rewrite it. */
|
|
38
|
+
export const SIDECAR_WORKFLOW_PATH = '.github/workflows/platform.yml'
|
|
39
|
+
|
|
36
40
|
/** What a rendered sidecar repository is named after. */
|
|
37
41
|
export interface SidecarValues {
|
|
38
42
|
/** The operator's name for this installation. The GitHub Environment is named after it too. */
|
|
@@ -75,6 +79,37 @@ const render = async (name: string, values: SidecarValues): Promise<string> => {
|
|
|
75
79
|
.replaceAll('{{FABRIKA_REF}}', values.fabrikaRef)
|
|
76
80
|
}
|
|
77
81
|
|
|
82
|
+
/** The generated pipeline for one installation. Its only placeholder is the installation, which names its GitHub Environment. */
|
|
83
|
+
export const renderSidecarWorkflow = async (installation: string): Promise<string> =>
|
|
84
|
+
(await Bun.file(resolve(TEMPLATES, 'platform.yml')).text()).replaceAll('{{INSTALLATION}}', installation)
|
|
85
|
+
|
|
86
|
+
const sidecarWorkflowSchema = z.object({ jobs: z.object({ deploy: z.object({ environment: z.string().trim().min(1) }) }) })
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The installation a sidecar's current workflow was rendered for, read back from its GitHub Environment.
|
|
90
|
+
*
|
|
91
|
+
* Not from the repository name: `init --repo` accepts any name, and a workflow rendered for the wrong
|
|
92
|
+
* Environment deploys with no credentials.
|
|
93
|
+
*/
|
|
94
|
+
export const readSidecarWorkflowInstallation = async (dir: string): Promise<string> => {
|
|
95
|
+
const regenerate = 'run `fabrika platform init --provider=zerops <installation>` to regenerate it'
|
|
96
|
+
const file = Bun.file(resolve(dir, SIDECAR_WORKFLOW_PATH))
|
|
97
|
+
if (!(await file.exists())) {
|
|
98
|
+
throw new Error(`${dir} has no ${SIDECAR_WORKFLOW_PATH} — ${regenerate}`)
|
|
99
|
+
}
|
|
100
|
+
let document: unknown
|
|
101
|
+
try {
|
|
102
|
+
document = Bun.YAML.parse(await file.text())
|
|
103
|
+
} catch {
|
|
104
|
+
throw new Error(`${dir}/${SIDECAR_WORKFLOW_PATH} is not valid YAML — ${regenerate}`)
|
|
105
|
+
}
|
|
106
|
+
const parsed = sidecarWorkflowSchema.safeParse(document)
|
|
107
|
+
if (!parsed.success) {
|
|
108
|
+
throw new Error(`${dir}/${SIDECAR_WORKFLOW_PATH} names no \`jobs.deploy.environment\` — ${regenerate}`)
|
|
109
|
+
}
|
|
110
|
+
return parsed.data.jobs.deploy.environment
|
|
111
|
+
}
|
|
112
|
+
|
|
78
113
|
const write = async (dir: string, rel: string, content: string): Promise<void> => {
|
|
79
114
|
await Bun.write(resolve(dir, rel), content)
|
|
80
115
|
}
|
|
@@ -83,7 +118,8 @@ const write = async (dir: string, rel: string, content: string): Promise<void> =
|
|
|
83
118
|
* Write the sidecar files into `dir`.
|
|
84
119
|
*
|
|
85
120
|
* 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
|
|
121
|
+
* the generated pipeline reaches every installation on its operator's next `init` (and the workflow on
|
|
122
|
+
* the next `upgrade`). `fabrika.ref` is
|
|
87
123
|
* written ONLY when absent: bumping it is how an operator rolls the installation forward, and a refresh
|
|
88
124
|
* that reset it would silently roll them back.
|
|
89
125
|
*
|
|
@@ -99,7 +135,7 @@ export const materializeSidecarScaffold = async (dir: string, values: SidecarVal
|
|
|
99
135
|
}
|
|
100
136
|
await write(dir, '.gitignore', await Bun.file(resolve(TEMPLATES, 'gitignore')).text())
|
|
101
137
|
await write(dir, 'README.md', await render('README.md', values))
|
|
102
|
-
await write(dir,
|
|
138
|
+
await write(dir, SIDECAR_WORKFLOW_PATH, await renderSidecarWorkflow(values.installation))
|
|
103
139
|
if (existing === undefined) {
|
|
104
140
|
await write(dir, FABRIKA_REF_FILE, await render('fabrika.ref', values))
|
|
105
141
|
}
|
|
@@ -122,4 +122,4 @@ Only a change to `fabrika.ref` or to the workflow triggers a push-driven run; ed
|
|
|
122
122
|
not deploy.
|
|
123
123
|
|
|
124
124
|
`fabrika.ref` is operator-owned: `fabrika platform init` writes it once and never overwrites a later
|
|
125
|
-
version pin.
|
|
125
|
+
version pin. The workflow is not: `init` and `upgrade` both rewrite it to the one their CLI generates.
|
|
@@ -21,7 +21,8 @@ name: platform
|
|
|
21
21
|
#
|
|
22
22
|
# contember/fabrika-platform is public and checked out tokenless at the TAG pinned in `fabrika.ref`.
|
|
23
23
|
# Bump that tag and push to roll this installation forward — `fabrika platform upgrade --provider=zerops
|
|
24
|
-
# --to=<tag> {{INSTALLATION}}` does exactly that
|
|
24
|
+
# --to=<tag> {{INSTALLATION}}` does exactly that, rewrites this file to the one its CLI generates in the
|
|
25
|
+
# same commit, and then follows the run this file starts. `init` rewrites it too: an edit here does not last.
|
|
25
26
|
|
|
26
27
|
on:
|
|
27
28
|
workflow_dispatch:
|
|
@@ -87,9 +88,29 @@ jobs:
|
|
|
87
88
|
working-directory: fabrika-platform
|
|
88
89
|
run: echo "commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
|
89
90
|
|
|
91
|
+
- name: Resolve the Bun release of the pinned tag
|
|
92
|
+
id: bun
|
|
93
|
+
working-directory: fabrika-platform
|
|
94
|
+
env:
|
|
95
|
+
PINNED_REF: ${{ steps.ref.outputs.fabrika }}
|
|
96
|
+
# Exactly the Bun the tag was built and released with, never `latest`: a newer Bun rewrites the
|
|
97
|
+
# lockfile and `--frozen-lockfile` refuses it. Tags up to v0.4.3 carry no `.bun-version` and were
|
|
98
|
+
# all released with Bun 1.3.14.
|
|
99
|
+
run: |
|
|
100
|
+
if [ -f .bun-version ]; then
|
|
101
|
+
version="$(tr -d '[:space:]' < .bun-version)"
|
|
102
|
+
else
|
|
103
|
+
version=1.3.14
|
|
104
|
+
fi
|
|
105
|
+
if ! printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
|
106
|
+
echo "fabrika-platform@$PINNED_REF names no exact Bun release in .bun-version: '$version'." >&2
|
|
107
|
+
exit 1
|
|
108
|
+
fi
|
|
109
|
+
echo "version=$version" >> "$GITHUB_OUTPUT"
|
|
110
|
+
|
|
90
111
|
- uses: oven-sh/setup-bun@v2
|
|
91
112
|
with:
|
|
92
|
-
bun-version:
|
|
113
|
+
bun-version: ${{ steps.bun.outputs.version }}
|
|
93
114
|
|
|
94
115
|
- name: Install fabrika
|
|
95
116
|
working-directory: fabrika-platform
|
|
@@ -89,7 +89,6 @@
|
|
|
89
89
|
// resolves nested references (a `RUNTIME_` lift of a variable that is itself `${db_connectionString}`
|
|
90
90
|
// arrives fully resolved). Only the proxy needs this, and it declares it explicitly below.
|
|
91
91
|
|
|
92
|
-
import { FABRIKA_PROXY_MANIFEST_JSON } from '@fabrika/proxy-core/manifest'
|
|
93
92
|
import type { ZeropsHealthCheckSpec, ZeropsReadinessCheckSpec, ZeropsYamlSetupSpec, ZeropsYamlSpec } from '../../index'
|
|
94
93
|
|
|
95
94
|
// ── Readiness and liveness: two mechanisms, two questions ──────────────────────────────────────────
|
|
@@ -324,37 +323,28 @@ const operations: ZeropsYamlSetupSpec = {
|
|
|
324
323
|
*
|
|
325
324
|
* Per-service variables (env API): `FABRIKA_IAM_ISSUER` — `http://iam:3000` in the platform project, the
|
|
326
325
|
* IAM service's PUBLIC origin in an apps project, because there is no private network between projects
|
|
327
|
-
* (ADR-0006).
|
|
326
|
+
* (ADR-0006). The manifest is not a variable; see below.
|
|
328
327
|
*
|
|
329
328
|
* Secret (`envSecrets`): `FABRIKA_IAM_KEY` — the value IAM knows as `FABRIKA_IAM_PROXY_KEY`.
|
|
330
329
|
*
|
|
331
|
-
* ── How the manifest reaches the build
|
|
330
|
+
* ── How the manifest reaches the build ───────────────────────────────────────────────────────────
|
|
332
331
|
*
|
|
333
332
|
* `proxy.manifest.json` tells the proxy which apps it fronts, at which upstream, under which endpoint table. It
|
|
334
333
|
* is baked into the artifact at BUILD time, which is what `docs/backlog/08` chose ("redeploy") over
|
|
335
|
-
* pushing to Caddy's admin API.
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
334
|
+
* pushing to Caddy's admin API. It arrives as the one generated data file of the uploaded build archive
|
|
335
|
+
* (ADR-0045), beside the exact public commit, at a path the commit does not hold: Control streams it into a
|
|
336
|
+
* namespace proxy's archive (`republishProxy`, `packages/provider-zerops/src/namespace/proxy-attempt.ts`), and
|
|
337
|
+
* `platform install` / `platform deploy` append it to the platform proxy's (`installation/platform-archive.ts`).
|
|
338
|
+
* It never travels through the service environment, whose values Zerops caps at ~32 KB.
|
|
339
339
|
*
|
|
340
|
-
*
|
|
341
|
-
*
|
|
340
|
+
* Releases before this one lifted `FABRIKA_PROXY_MANIFEST_JSON` from the env API into the build instead.
|
|
341
|
+
* A namespace pinned to such a release keeps that descriptor, and Control keeps writing the variable for it
|
|
342
|
+
* (`zeropsProxyManifestCarrier`): never add the lift back, or a pin of this release would get the variable.
|
|
342
343
|
*
|
|
343
|
-
*
|
|
344
|
-
* of its service's env-API variables.** Read directly, `${FABRIKA_PROXY_MANIFEST_JSON}` in a build
|
|
345
|
-
* command is the empty string — no error, no warning. Left uncorrected, every proxy build would have
|
|
346
|
-
* written an empty manifest, failed `generate-config.ts`, and made this service undeployable on Zerops.
|
|
347
|
-
*
|
|
348
|
-
* The fix is the `build.envVariables` declaration below rather than the move into `run.initCommands`
|
|
349
|
-
* this header used to anticipate: `${RUNTIME_x}` lifts a service's own runtime variable into its build
|
|
350
|
-
* context, and it resolves nested references too. The delivery channel and the fail-closed parser are
|
|
351
|
-
* unchanged, which is what that contingency promised.
|
|
352
|
-
*
|
|
353
|
-
* What IS structural: missing or malformed JSON makes `generate-config.ts` exit non-zero, so the
|
|
344
|
+
* What IS structural: a missing or malformed file makes `generate-config.ts` exit non-zero, so the
|
|
354
345
|
* pipeline fails and the previous version keeps serving. A valid empty app list generates only the
|
|
355
346
|
* terminal 404 route, and the auth service denies every forwarded request.
|
|
356
347
|
*/
|
|
357
|
-
const proxyManifestReference = `\${${FABRIKA_PROXY_MANIFEST_JSON}}`
|
|
358
348
|
|
|
359
349
|
/**
|
|
360
350
|
* The proxy's public listeners.
|
|
@@ -395,19 +385,17 @@ const proxy: ZeropsYamlSetupSpec = {
|
|
|
395
385
|
// database, a stronger supply-chain guarantee than a release tarball with a hand-copied hash — and
|
|
396
386
|
// the toolchain floats. If a future Go release breaks the build, this is the line that moved.
|
|
397
387
|
base: ['alpine/go@latest', 'alpine/bun@1.3'],
|
|
398
|
-
// Runtime values reach the build only through the explicit RUNTIME_ bridge.
|
|
388
|
+
// Runtime values reach the build only through the explicit RUNTIME_ bridge. The manifest is not one:
|
|
389
|
+
// it is the archive's data file (see the header).
|
|
399
390
|
envVariables: {
|
|
400
|
-
[FABRIKA_PROXY_MANIFEST_JSON]: `\${RUNTIME_${FABRIKA_PROXY_MANIFEST_JSON}}`,
|
|
401
391
|
FABRIKA_PROXY_SESSION_OWNER_JSON: '${RUNTIME_FABRIKA_PROXY_SESSION_OWNER_JSON}',
|
|
402
392
|
},
|
|
403
393
|
// CGO off makes the binary static, which is what lets it run on Alpine/musl.
|
|
404
394
|
prepareCommands: ['CGO_ENABLED=0 GOBIN=/tmp/gobin go install github.com/caddyserver/caddy/v2/cmd/caddy@v2.10.2'],
|
|
405
395
|
buildCommands: [
|
|
406
396
|
'bun install --frozen-lockfile',
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
`printf %s "${proxyManifestReference}" > ./proxy.manifest.json`,
|
|
410
|
-
// Missing/malformed JSON fails the build; an empty app list emits only deny/404 routes.
|
|
397
|
+
// The archive's data file (see the header). Missing/malformed JSON fails the build; an empty app list
|
|
398
|
+
// emits only deny/404 routes.
|
|
411
399
|
`bun run packages/proxy/src/generate-config.ts ./proxy.manifest.json ./caddy.json --auth-upstream 127.0.0.1:9000 --listen ${
|
|
412
400
|
PROXY_PUBLIC_PORTS.map((port) => `:${port}`).join(',')
|
|
413
401
|
}`,
|