@fabrika/provider-zerops 0.4.3 → 0.4.5
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 -8
- package/src/execution.ts +3 -2
- package/src/installation/deploy.ts +31 -17
- package/src/installation/guide.md +6 -1
- 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/upgrade.ts +24 -7
- package/src/manifest.ts +3 -1
- package/src/installation/manifest.ts +0 -112
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/provider-zerops",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -43,13 +43,13 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"zod": "^3.25.76",
|
|
46
|
-
"@fabrika/auth": "^0.4.
|
|
47
|
-
"@fabrika/auth-core": "^0.4.
|
|
48
|
-
"@fabrika/iam-contract": "^0.4.
|
|
49
|
-
"@fabrika/installation-init": "^0.4.
|
|
50
|
-
"@fabrika/operations-contract": "^0.4.
|
|
51
|
-
"@fabrika/provider-contract": "^0.4.
|
|
52
|
-
"@fabrika/proxy-core": "^0.4.
|
|
46
|
+
"@fabrika/auth": "^0.4.5",
|
|
47
|
+
"@fabrika/auth-core": "^0.4.5",
|
|
48
|
+
"@fabrika/iam-contract": "^0.4.5",
|
|
49
|
+
"@fabrika/installation-init": "^0.4.5",
|
|
50
|
+
"@fabrika/operations-contract": "^0.4.5",
|
|
51
|
+
"@fabrika/provider-contract": "^0.4.5",
|
|
52
|
+
"@fabrika/proxy-core": "^0.4.5",
|
|
53
53
|
"yaml": "^2.9.1"
|
|
54
54
|
}
|
|
55
55
|
}
|
package/src/execution.ts
CHANGED
|
@@ -291,14 +291,15 @@ const sourceMapBuildVariables = (upload: OperationsSourceMapBuildUpload): Record
|
|
|
291
291
|
|
|
292
292
|
/**
|
|
293
293
|
* The generated build commands, in order; the authored SPA commands run before all of them. The application's tests
|
|
294
|
-
* run first: they are authored code, so everything after them is reinstalled, regenerated or checked again.
|
|
294
|
+
* run first: they are authored code, so everything after them is reinstalled, regenerated or checked again. They run
|
|
295
|
+
* `--isolate`, as the application skill runs them: a test file's DOM globals otherwise leak into the next file.
|
|
295
296
|
*/
|
|
296
297
|
export function generatedBuildCommands(execution: ZeropsLockedExecution): readonly string[] {
|
|
297
298
|
if (!LOCKED_APP_ID_PATTERN.test(execution.appId)) throw new Error('zerops: a contract application id must match [a-z0-9][a-z0-9-]*')
|
|
298
299
|
const database = execution.databaseUrl !== null
|
|
299
300
|
return [
|
|
300
301
|
INSTALL,
|
|
301
|
-
'bun test --pass-with-no-tests',
|
|
302
|
+
'bun test --isolate --pass-with-no-tests',
|
|
302
303
|
`rm -rf node_modules && ${INSTALL}`,
|
|
303
304
|
`bun -e "await import('@fabrika/app/build').then((build) => build.writeGeneratedEntry('.', 'bun'))"`,
|
|
304
305
|
`cd /tmp && bun x --bun -p @fabrika/check@${FABRIKA_CHECK_VERSION} fabrika-check ${SOURCE_ROOT}`,
|
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
//
|
|
20
20
|
// ── What this command writes, and what it deliberately does not ───────────────────────────────────
|
|
21
21
|
//
|
|
22
|
-
// It
|
|
23
|
-
// per-installation configuration derived from the resolved public hosts
|
|
24
|
-
// the origins it must know about, and the installation's environment name). **It writes no credential at all** — every
|
|
22
|
+
// It composes the proxy manifest and ships it in the proxy's build archive. It writes the exact namespace
|
|
23
|
+
// planning source default and the per-installation configuration derived from the resolved public hosts
|
|
24
|
+
// (each service's own origin, the origins it must know about, and the installation's environment name). **It writes no credential at all** — every
|
|
25
25
|
// secret an installation holds is `platform init`'s to place, and a deploy that cannot write one cannot
|
|
26
26
|
// leak one. The two credentials this command HOLDS are the Zerops access token and the IAM admin key,
|
|
27
27
|
// both from the environment, neither ever logged.
|
|
@@ -68,7 +68,6 @@ import {
|
|
|
68
68
|
validateInstallationSetups,
|
|
69
69
|
} from './installation-source'
|
|
70
70
|
import type { DeployLog } from './log'
|
|
71
|
-
import { mergePlatformProxyManifest, readLiveProxyManifest } from './manifest'
|
|
72
71
|
import { type IamOAuthConfig, platformIamOAuthConfig } from './mcp'
|
|
73
72
|
import { archiveWithDataFile } from './platform-archive'
|
|
74
73
|
import type { PreparedPlatformSource } from './platform-source'
|
|
@@ -388,6 +387,26 @@ const applyServiceEnv = async (
|
|
|
388
387
|
return written
|
|
389
388
|
}
|
|
390
389
|
|
|
390
|
+
/**
|
|
391
|
+
* Delete, unread, the manifest copy older releases wrote sensitive: nothing reads it, as each proxy version serves
|
|
392
|
+
* the file its own build wrote, and the platform proxy fronts only the platform's apps (ADR-0034). True if one existed.
|
|
393
|
+
*/
|
|
394
|
+
const removeLegacyProxyManifest = async (
|
|
395
|
+
proxyEnv: LiveServiceEnv,
|
|
396
|
+
input: PlatformDeployInput,
|
|
397
|
+
collaborators: PlatformDeployCollaborators,
|
|
398
|
+
): Promise<boolean> => {
|
|
399
|
+
const legacy = proxyEnv.get(FABRIKA_PROXY_MANIFEST_JSON)
|
|
400
|
+
if (legacy === undefined) {
|
|
401
|
+
return false
|
|
402
|
+
}
|
|
403
|
+
if (!input.dryRun) {
|
|
404
|
+
assertRunning(collaborators.signal)
|
|
405
|
+
await collaborators.api.deleteServiceEnv({ envId: legacy.id, signal: collaborators.signal })
|
|
406
|
+
}
|
|
407
|
+
return true
|
|
408
|
+
}
|
|
409
|
+
|
|
391
410
|
/** Only a known rejection permits another request against the same reservation. */
|
|
392
411
|
const triggerPipeline = async (
|
|
393
412
|
service: ResolvedService,
|
|
@@ -647,18 +666,7 @@ export const deployPreparedPlatform = async (
|
|
|
647
666
|
? 'MCP authoring: off'
|
|
648
667
|
: `MCP authoring: on, ${oauthConfig.clients.length} client(s), resource(s) ${oauthConfig.resources.map((entry) => entry.resource).join(', ')}`,
|
|
649
668
|
)
|
|
650
|
-
|
|
651
|
-
const merged = mergePlatformProxyManifest(platformManifest, readLiveProxyManifest(proxyEnv.get(FABRIKA_PROXY_MANIFEST_JSON)))
|
|
652
|
-
log.info(`${merged.manifest.apps.length} app(s): ${merged.manifest.apps.map((app) => app.id).join(', ')}`)
|
|
653
|
-
if (merged.carried.length > 0) {
|
|
654
|
-
log.info(`carried through from the live manifest: ${merged.carried.join(', ')}`)
|
|
655
|
-
}
|
|
656
|
-
for (const displaced of merged.superseded) {
|
|
657
|
-
log.warn(
|
|
658
|
-
`the live manifest entry \`${displaced.id}\` stood on the platform host ${displaced.host} and was replaced — `
|
|
659
|
-
+ 'that host now answers for the platform app that owns it',
|
|
660
|
-
)
|
|
661
|
-
}
|
|
669
|
+
log.info(`${platformManifest.apps.length} app(s): ${platformManifest.apps.map((app) => app.id).join(', ')}`)
|
|
662
670
|
|
|
663
671
|
log.step('Write the service environment')
|
|
664
672
|
const controlTableDigest = platformServiceTableDigest(platformManifest, platformProxyAppFor(PLATFORM_PROXY_MANIFEST_TEMPLATE, 'control').id)
|
|
@@ -679,13 +687,19 @@ export const deployPreparedPlatform = async (
|
|
|
679
687
|
: `${hostname}: ${input.dryRun ? 'would write' : 'wrote'} ${written.join(', ')}`,
|
|
680
688
|
)
|
|
681
689
|
}
|
|
690
|
+
if (await removeLegacyProxyManifest(proxyEnv, input, collaborators)) {
|
|
691
|
+
log.info(
|
|
692
|
+
`${PLATFORM_PROXY_SERVICE}: ${input.dryRun ? 'would remove' : 'removed'} ${FABRIKA_PROXY_MANIFEST_JSON}, the copy of the manifest a `
|
|
693
|
+
+ 'release before this one kept there',
|
|
694
|
+
)
|
|
695
|
+
}
|
|
682
696
|
|
|
683
697
|
// EVERY deploy is after EVERY write, and the manifest rides in the proxy's own archive. That is what makes
|
|
684
698
|
// the sequence fail closed: a proxy build that does not go ACTIVE leaves the previous manifest in front of
|
|
685
699
|
// the previous code, because control builds only after it — and `ENVIRONMENT` is in place before any
|
|
686
700
|
// service that refuses `local` restarts.
|
|
687
701
|
log.step(`Deploy ${PLATFORM_CONCURRENT_DEPLOY.join(' + ')} together, then ${PLATFORM_SEQUENTIAL_DEPLOY.join(' → ')}`)
|
|
688
|
-
const buildInput = { source, dryRun: input.dryRun, proxyManifest: encodeProxyManifestJson(
|
|
702
|
+
const buildInput = { source, dryRun: input.dryRun, proxyManifest: encodeProxyManifestJson(platformManifest) }
|
|
689
703
|
await deployConcurrently(services, buildInput, collaborators)
|
|
690
704
|
for (const hostname of PLATFORM_SEQUENTIAL_DEPLOY) {
|
|
691
705
|
await deployPlatformService(serviceOf(services, hostname), buildInput, collaborators)
|
|
@@ -293,7 +293,12 @@ fabrika platform upgrade --provider=zerops --to=<newer tag> --sidecar=<sidecar c
|
|
|
293
293
|
```
|
|
294
294
|
|
|
295
295
|
It commits the tag to the sidecar's `fabrika.ref`, pushes, and follows the workflow run it triggers. It
|
|
296
|
-
needs only the `gh` login.
|
|
296
|
+
needs only the `gh` login. When the sidecar's `.github/workflows/platform.yml` differs from the one this CLI
|
|
297
|
+
generates, the same commit rewrites it, so run `upgrade` with the newest CLI: a hand edit of that file does not
|
|
298
|
+
survive. The workflow installs the Bun release the pinned tag names in its `.bun-version` (1.3.14 for a tag
|
|
299
|
+
that has none), never the newest one. From 0.4.2 or earlier, skip 0.4.3: its deploy stops at "Compose the proxy
|
|
300
|
+
manifest" because the proxy's `FABRIKA_PROXY_MANIFEST_JSON` is sensitive. Do not re-save that variable. Upgrade to a
|
|
301
|
+
later tag: its deploy deletes the variable, and needs no sudo mode. Existing namespaces keep their proxy version. To move one to the proxy of the
|
|
297
302
|
new release, after the run succeeded:
|
|
298
303
|
|
|
299
304
|
```bash
|
|
@@ -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
|
|
@@ -19,12 +19,20 @@
|
|
|
19
19
|
// answer, never "no such tag": reporting a rate limit as a missing tag sends the operator to look
|
|
20
20
|
// at the wrong repository.
|
|
21
21
|
// • A directory that is not a checkout, and a branch the workflow does not trigger on.
|
|
22
|
-
// • A dirty sidecar tree. The commit stages `fabrika.ref` alone, so anything else the
|
|
23
|
-
// part-way through would be left behind on a branch that just deployed.
|
|
22
|
+
// • A dirty sidecar tree. The commit stages `fabrika.ref` and the workflow alone, so anything else the
|
|
23
|
+
// operator was part-way through would be left behind on a branch that just deployed.
|
|
24
24
|
// • A pin already equal to `--to` — but only when it is PUSHED. A pin committed and not pushed is
|
|
25
25
|
// the state a failed push leaves behind, and "already pins it" would be a lie there: the push that
|
|
26
26
|
// IS the trigger never happened, so nothing was deployed.
|
|
27
27
|
//
|
|
28
|
+
// ── The workflow rides along with the pin ─────────────────────────────────────────────────────────
|
|
29
|
+
//
|
|
30
|
+
// The workflow is CLI-owned (`sidecar.ts`), and a sidecar created by an older CLI keeps its old pipeline
|
|
31
|
+
// until something rewrites it. `init` does, but a roll is the one command every operator runs, so the
|
|
32
|
+
// roll re-renders it too, into the same commit: the pipeline that deploys the new tag is the current one.
|
|
33
|
+
// It is rendered for the GitHub Environment the existing workflow names, never for a name guessed from
|
|
34
|
+
// the repository.
|
|
35
|
+
//
|
|
28
36
|
// ── The commit carries no `[skip ci]`, deliberately ──────────────────────────────────────────────
|
|
29
37
|
//
|
|
30
38
|
// Both scaffold commits do (`@fabrika/installation-init`'s `scaffold.ts`), because a scaffold push must
|
|
@@ -38,7 +46,7 @@ import { tmpdir } from 'node:os'
|
|
|
38
46
|
import { join, resolve } from 'node:path'
|
|
39
47
|
import { defaultSleep, type Sleeper } from '../index'
|
|
40
48
|
import { consoleDeployLog, type DeployLog } from './log'
|
|
41
|
-
import { FABRIKA_REF_FILE, readPinnedTag } from './sidecar'
|
|
49
|
+
import { FABRIKA_REF_FILE, readPinnedTag, readSidecarWorkflowInstallation, renderSidecarWorkflow, SIDECAR_WORKFLOW_PATH } from './sidecar'
|
|
42
50
|
import type { PlatformUpgradeInput } from './upgrade-options'
|
|
43
51
|
import { carriesTag, firstRun } from './upgrade-output'
|
|
44
52
|
|
|
@@ -243,8 +251,8 @@ export const runUpgrade = async (input: PlatformUpgradeInput, collaborators: Upg
|
|
|
243
251
|
const repo = await effects.describeCheckout(dir)
|
|
244
252
|
if (await effects.isDirty(dir)) {
|
|
245
253
|
throw new Error(
|
|
246
|
-
`${dir} has uncommitted changes to tracked files — this command commits \`${FABRIKA_REF_FILE}\`
|
|
247
|
-
+ 'discard them first (untracked files are left alone)',
|
|
254
|
+
`${dir} has uncommitted changes to tracked files — this command commits \`${FABRIKA_REF_FILE}\` and \`${SIDECAR_WORKFLOW_PATH}\` `
|
|
255
|
+
+ 'alone, so commit or discard them first (untracked files are left alone)',
|
|
248
256
|
)
|
|
249
257
|
}
|
|
250
258
|
const unpushed = await checkedBranch(dir, repo, collaborators)
|
|
@@ -263,18 +271,27 @@ export const runUpgrade = async (input: PlatformUpgradeInput, collaborators: Upg
|
|
|
263
271
|
if (pinned === input.to) {
|
|
264
272
|
throw new Error(`${repo} already pins ${input.to}. Nothing to push, and the push is what triggers the deploy`)
|
|
265
273
|
}
|
|
274
|
+
const workflow = await renderSidecarWorkflow(await readSidecarWorkflowInstallation(dir))
|
|
275
|
+
const workflowChanged = (await Bun.file(resolve(dir, SIDECAR_WORKFLOW_PATH)).text()) !== workflow
|
|
276
|
+
const files = workflowChanged ? [FABRIKA_REF_FILE, SIDECAR_WORKFLOW_PATH] : [FABRIKA_REF_FILE]
|
|
266
277
|
const message = upgradeCommitMessage(installationLabel(input, repo), input.to)
|
|
267
278
|
log.info(`${repo}: ${pinned} → ${input.to}`)
|
|
279
|
+
if (workflowChanged) {
|
|
280
|
+
log.info(`${SIDECAR_WORKFLOW_PATH} differs from the one this CLI generates — the roll rewrites it`)
|
|
281
|
+
}
|
|
268
282
|
|
|
269
283
|
if (input.dryRun) {
|
|
270
284
|
log.step('Dry run complete — nothing was written')
|
|
271
|
-
log.info(`would write \`${input.to}\` to ${FABRIKA_REF_FILE} and commit
|
|
285
|
+
log.info(`would write \`${input.to}\` to ${FABRIKA_REF_FILE} and commit ${files.join(' and ')} as \`${message}\``)
|
|
272
286
|
return
|
|
273
287
|
}
|
|
274
288
|
|
|
275
289
|
log.step('Roll the pin forward')
|
|
276
290
|
await Bun.write(resolve(dir, FABRIKA_REF_FILE), `${input.to}\n`)
|
|
277
|
-
|
|
291
|
+
if (workflowChanged) {
|
|
292
|
+
await Bun.write(resolve(dir, SIDECAR_WORKFLOW_PATH), workflow)
|
|
293
|
+
}
|
|
294
|
+
const sha = await effects.commit({ dir, files, message })
|
|
278
295
|
await effects.push(dir)
|
|
279
296
|
log.info(`pushed ${sha}`)
|
|
280
297
|
|
package/src/manifest.ts
CHANGED
|
@@ -400,7 +400,9 @@ const applicationDatabaseUrl = (
|
|
|
400
400
|
}
|
|
401
401
|
if (shared !== undefined) return shared.connectionString
|
|
402
402
|
const host = own[0]
|
|
403
|
-
|
|
403
|
+
// The direct port, not `portTls`: the application's own roles (`app_<id>`, `run_<id>`) are refused by the TLS endpoint,
|
|
404
|
+
// which admits only the service's own users (measured 2026-10-15). `prefer` is TLS on `single`; `ha` offers none there.
|
|
405
|
+
return host === undefined ? null : `\${${host}_connectionString}/\${${host}_dbName}?sslmode=prefer`
|
|
404
406
|
}
|
|
405
407
|
|
|
406
408
|
/** The hostname the `FABRIKA_APP_STORAGE_*` references name: the application's one object-storage service. */
|
|
@@ -1,112 +0,0 @@
|
|
|
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 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.
|
|
14
|
-
//
|
|
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.
|
|
17
|
-
//
|
|
18
|
-
// ── What happens to an entry the platform does not know about ─────────────────────────────────────
|
|
19
|
-
//
|
|
20
|
-
// Carried through byte-for-byte, in its live order, after the platform apps — with ONE exception. A
|
|
21
|
-
// host belongs to exactly one app (`parseProxyManifest` refuses two apps claiming one, and rightly:
|
|
22
|
-
// route order would otherwise decide which app answers). The platform's three listeners are addresses
|
|
23
|
-
// the platform OWNS, so a live entry standing on one of them is superseded rather than carried, and
|
|
24
|
-
// the deploy says so out loud.
|
|
25
|
-
//
|
|
26
|
-
// That exception is not hypothetical: WU1 corrected IAM's app id from `iam-local` to `iam`, so the
|
|
27
|
-
// live document's `iam-local` claims the platform's own IAM host under a name the platform no longer
|
|
28
|
-
// uses. Carrying it would produce a manifest `parseProxyManifest` rejects, which the proxy build then
|
|
29
|
-
// fails on — a fail-closed outcome, but a needlessly confusing one.
|
|
30
|
-
//
|
|
31
|
-
// Superseding is itself CLOSED: the dropped host stops resolving to anything, it does not fall through
|
|
32
|
-
// to some other app. A genuinely misplaced application therefore goes dark and is reported, instead of
|
|
33
|
-
// quietly being served by the console.
|
|
34
|
-
|
|
35
|
-
import { FABRIKA_PROXY_MANIFEST_JSON, findDuplicateProxyHost, parseProxyManifestJson, type ProxyManifest } from '@fabrika/proxy-core/manifest'
|
|
36
|
-
import type { ZeropsServiceEnv } from '../index'
|
|
37
|
-
|
|
38
|
-
/** The composed document plus what happened to every live entry that was not the platform's. */
|
|
39
|
-
export interface MergedProxyManifest {
|
|
40
|
-
readonly manifest: ProxyManifest
|
|
41
|
-
/** App ids carried through from the live manifest, in their live order. */
|
|
42
|
-
readonly carried: readonly string[]
|
|
43
|
-
/** Live entries the platform's own listeners displaced, with the host that collided. */
|
|
44
|
-
readonly superseded: readonly { readonly id: string; readonly host: string }[]
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Compose the platform's apps with the application entries already in a live manifest.
|
|
49
|
-
*
|
|
50
|
-
* `live` is `null` when the proxy carries no manifest yet (a first deploy) — the result is then the
|
|
51
|
-
* platform document alone. An UNPARSEABLE live value is a different thing entirely and throws: it may
|
|
52
|
-
* be a manifest this build is too old to read, and silently replacing it would drop applications this
|
|
53
|
-
* code cannot see. Refusing is what keeps "must not take a deployed app offline" true in the one case
|
|
54
|
-
* where the deploy genuinely cannot tell.
|
|
55
|
-
*/
|
|
56
|
-
export const mergePlatformProxyManifest = (platform: ProxyManifest, live: ProxyManifest | null): MergedProxyManifest => {
|
|
57
|
-
const platformIds = new Set(platform.apps.map((app) => app.id))
|
|
58
|
-
const platformHosts = new Set(platform.apps.flatMap((app) => app.hosts))
|
|
59
|
-
const carried: string[] = []
|
|
60
|
-
const superseded: { id: string; host: string }[] = []
|
|
61
|
-
const apps = [...platform.apps]
|
|
62
|
-
for (const app of live?.apps ?? []) {
|
|
63
|
-
if (platformIds.has(app.id)) {
|
|
64
|
-
continue
|
|
65
|
-
}
|
|
66
|
-
const collision = app.hosts.find((host) => platformHosts.has(host))
|
|
67
|
-
if (collision !== undefined) {
|
|
68
|
-
superseded.push({ id: app.id, host: collision })
|
|
69
|
-
continue
|
|
70
|
-
}
|
|
71
|
-
carried.push(app.id)
|
|
72
|
-
apps.push(app)
|
|
73
|
-
}
|
|
74
|
-
// Both inputs are already parsed manifests, so a duplicate host is the one invariant composing can break.
|
|
75
|
-
// A duplicate host between two APPLICATION entries — which this function cannot fix — is refused
|
|
76
|
-
// here rather than at the proxy's next build.
|
|
77
|
-
if (findDuplicateProxyHost(apps) !== null) {
|
|
78
|
-
throw new Error('the composed proxy manifest is not one the proxy would accept — two application entries claim one host')
|
|
79
|
-
}
|
|
80
|
-
return { manifest: { apps }, carried, superseded }
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* The application entries the operator holds in the platform proxy's variable, as a manifest.
|
|
85
|
-
*
|
|
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.
|
|
88
|
-
*/
|
|
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() === '') {
|
|
102
|
-
return null
|
|
103
|
-
}
|
|
104
|
-
const manifest = parseProxyManifestJson(value)
|
|
105
|
-
if (manifest === null) {
|
|
106
|
-
throw new Error(
|
|
107
|
-
`the proxy's current FABRIKA_PROXY_MANIFEST_JSON cannot be parsed by this build — refusing to replace a document whose `
|
|
108
|
-
+ 'application entries cannot be read',
|
|
109
|
-
)
|
|
110
|
-
}
|
|
111
|
-
return manifest
|
|
112
|
-
}
|