@fabrika/provider-zerops 0.4.2 → 0.4.3
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 +41 -3
- 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 +66 -22
- 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 +164 -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/manifest.ts +29 -12
- 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/topology/setups.ts +15 -27
- package/src/installation/topology/topology.ts +105 -104
- 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
|
@@ -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"
|
|
@@ -2,14 +2,18 @@
|
|
|
2
2
|
//
|
|
3
3
|
// ── Why this is a MERGE and not a write ───────────────────────────────────────────────────────────
|
|
4
4
|
//
|
|
5
|
-
// On the `light` tier an application
|
|
6
|
-
//
|
|
7
|
-
// `notes` → `notesapi:3000` on listener 8084
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
5
|
+
// On the `light` tier an application can share the platform's project and therefore the platform proxy.
|
|
6
|
+
// Its entry is held by the operator in the proxy's `FABRIKA_PROXY_MANIFEST_JSON` variable — on `fabrika-test`
|
|
7
|
+
// a fourth app, `notes` → `notesapi:3000` on listener 8084, beside the three platform ones. The variable is
|
|
8
|
+
// no longer the carrier: the manifest travels as the data file of the proxy's build archive (ADR-0045), and
|
|
9
|
+
// nothing in fabrika writes the variable any more; releases before that wrote the whole composed document
|
|
10
|
+
// into it, which is why it may still hold platform entries. `platformProxyManifest` deliberately emits only
|
|
11
|
+
// the platform apps (it is generated from the platform's own service contracts and knows nothing about
|
|
12
|
+
// applications), so building it alone would DELETE every application entry and take the deployed
|
|
13
|
+
// applications offline.
|
|
11
14
|
//
|
|
12
|
-
// So: read the
|
|
15
|
+
// So: read the variable, compose, and ship the result in the archive. That composition is this command's
|
|
16
|
+
// job and nothing else's.
|
|
13
17
|
//
|
|
14
18
|
// ── What happens to an entry the platform does not know about ─────────────────────────────────────
|
|
15
19
|
//
|
|
@@ -28,7 +32,8 @@
|
|
|
28
32
|
// to some other app. A genuinely misplaced application therefore goes dark and is reported, instead of
|
|
29
33
|
// quietly being served by the console.
|
|
30
34
|
|
|
31
|
-
import { findDuplicateProxyHost, parseProxyManifestJson, type ProxyManifest } from '@fabrika/proxy-core/manifest'
|
|
35
|
+
import { FABRIKA_PROXY_MANIFEST_JSON, findDuplicateProxyHost, parseProxyManifestJson, type ProxyManifest } from '@fabrika/proxy-core/manifest'
|
|
36
|
+
import type { ZeropsServiceEnv } from '../index'
|
|
32
37
|
|
|
33
38
|
/** The composed document plus what happened to every live entry that was not the platform's. */
|
|
34
39
|
export interface MergedProxyManifest {
|
|
@@ -76,12 +81,24 @@ export const mergePlatformProxyManifest = (platform: ProxyManifest, live: ProxyM
|
|
|
76
81
|
}
|
|
77
82
|
|
|
78
83
|
/**
|
|
79
|
-
* The
|
|
84
|
+
* The application entries the operator holds in the platform proxy's variable, as a manifest.
|
|
80
85
|
*
|
|
81
|
-
* Absent means "
|
|
86
|
+
* Absent means "none". Present-but-unparseable throws: see `mergePlatformProxyManifest`. So
|
|
87
|
+
* does a SENSITIVE record, which an older release wrote: Zerops will not hand its value back.
|
|
82
88
|
*/
|
|
83
|
-
export const readLiveProxyManifest = (
|
|
84
|
-
if (
|
|
89
|
+
export const readLiveProxyManifest = (record: ZeropsServiceEnv | undefined): ProxyManifest | null => {
|
|
90
|
+
if (record === undefined) {
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
if (record.sensitive) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`the proxy's ${FABRIKA_PROXY_MANIFEST_JSON} is stored as a SENSITIVE variable, which Zerops returns as \`REDACTED\`, so the `
|
|
96
|
+
+ 'application entries it may carry cannot be read and are not replaced. A release before this one wrote it that way. '
|
|
97
|
+
+ 'Re-save it in the Zerops GUI as a non-sensitive variable with the same value, then run this again',
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
const value = record.content
|
|
101
|
+
if (value.trim() === '') {
|
|
85
102
|
return null
|
|
86
103
|
}
|
|
87
104
|
const manifest = parseProxyManifestJson(value)
|
|
@@ -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
|
|
@@ -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
|
}`,
|