@starci/hfs 4.12.2 → 4.13.2
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/CHANGELOG.md +50 -0
- package/README.md +8 -9
- package/cache/app-families.d.mts +30 -0
- package/cache/app-families.mjs +94 -0
- package/cache/content-index.d.mts +13 -0
- package/cache/content-index.mjs +37 -0
- package/cache/db-family.d.mts +13 -0
- package/cache/db-family.mjs +37 -0
- package/cache/fake-session.d.mts +42 -0
- package/cache/fake-session.mjs +57 -0
- package/cache/fingerprint.d.mts +28 -0
- package/cache/fingerprint.mjs +120 -0
- package/cache/format-family.d.mts +13 -0
- package/cache/format-family.mjs +49 -0
- package/cache/git.d.mts +11 -0
- package/cache/git.mjs +13 -0
- package/cache/hash.d.mts +15 -0
- package/cache/hash.mjs +58 -0
- package/cache/hfs-family.d.mts +35 -0
- package/cache/hfs-family.mjs +164 -0
- package/cache/import-graph.d.mts +75 -0
- package/cache/import-graph.mjs +204 -0
- package/cache/jsonl.d.mts +31 -0
- package/cache/jsonl.mjs +115 -0
- package/cache/lint-family.d.mts +162 -0
- package/cache/lint-family.mjs +433 -0
- package/cache/order.d.mts +1 -0
- package/cache/order.mjs +2 -0
- package/cache/progress.d.mts +19 -0
- package/cache/progress.mjs +68 -0
- package/cache/quality-baselines.d.mts +109 -0
- package/cache/quality-baselines.mjs +235 -0
- package/cache/records.d.mts +33 -0
- package/cache/records.mjs +54 -0
- package/cache/report.d.mts +19 -0
- package/cache/report.mjs +32 -0
- package/cache/run-log.d.mts +17 -0
- package/cache/run-log.mjs +37 -0
- package/cache/run.d.mts +33 -0
- package/cache/run.mjs +65 -0
- package/cache/session.d.mts +29 -0
- package/cache/session.mjs +197 -0
- package/cache/side-key.d.mts +10 -0
- package/cache/side-key.mjs +47 -0
- package/cache/test-family.d.mts +130 -0
- package/cache/test-family.mjs +258 -0
- package/cache/trust.d.mts +53 -0
- package/cache/trust.mjs +157 -0
- package/cache/types-family.d.mts +74 -0
- package/cache/types-family.mjs +154 -0
- package/cache/types.d.mts +99 -0
- package/cache/types.mjs +8 -0
- package/cache/wiring-family.d.mts +14 -0
- package/cache/wiring-family.mjs +43 -0
- package/coverage/cli.d.mts +4 -2
- package/coverage/cli.mjs +44 -5
- package/db/migrate.d.mts +16 -0
- package/db/migrate.mjs +21 -4
- package/db/postgres.d.mts +23 -0
- package/db/postgres.mjs +32 -1
- package/lint/run.d.mts +47 -1
- package/lint/run.mjs +157 -5
- package/package.json +2 -1
- package/release/gate.d.mts +2 -0
- package/release/gate.mjs +4 -2
- package/release/records.d.mts +15 -0
- package/release/records.mjs +25 -0
- package/release/urgent.mjs +23 -4
- package/runtime/knowledge/grammars/core/playbook.yaml +12 -18
- package/runtime/knowledge/hfs/canon-pins.yaml +8 -8
- package/runtime/knowledge/hfs/ci-standard.yaml +29 -6
- package/runtime/knowledge/hfs/rules.yaml +97 -7
- package/runtime/knowledge/hfs/slots.yaml +20 -8
- package/runtime/modules/kernel/failure-codes.yaml +46 -4
- package/runtime/modules/schemas/application-stacks.schema.yaml +48 -0
- package/runtime/scripts/hfs/architecture/hfs.d.mts +6 -6
- package/runtime/scripts/hfs/check.mjs +1 -1
- package/runtime/scripts/hfs/gate-threshold.mjs +2 -1
- package/runtime/scripts/hfs/rules/boot-proof.d.mts +5 -0
- package/runtime/scripts/hfs/rules/boot-proof.mjs +15 -1
- package/runtime/scripts/hfs/rules/ci-custody.mjs +3 -1
- package/runtime/scripts/hfs/rules/ci-standard.mjs +32 -3
- package/runtime/scripts/hfs/rules/database-migrations.d.mts +14 -0
- package/runtime/scripts/hfs/rules/database-migrations.mjs +55 -0
- package/runtime/scripts/hfs/rules/database.mjs +2 -1
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +53 -6
- package/runtime/scripts/hfs/rules/release-provenance.mjs +32 -4
- package/runtime/scripts/hfs/rules/stacks.mjs +5 -1
- package/runtime/scripts/hfs/rules/supply-chain.mjs +4 -1
- package/runtime/scripts/hfs/rules/unused-symbol-translators.mjs +98 -7
- package/runtime/scripts/hfs/rules/unused-symbol.d.mts +9 -2
- package/runtime/scripts/hfs/rules/unused-symbol.mjs +15 -2
- package/runtime/scripts/hfs/runtime-rules/git-triggers.mjs +21 -5
- package/runtime/scripts/hfs/stack-deploy-config.d.mts +18 -0
- package/runtime/scripts/hfs/stack-deploy-config.mjs +53 -1
- package/runtime/scripts/hfs/stacks-gate.mjs +2 -0
- package/runtime/scripts/hfs/tree-findings.mjs +3 -1
- package/scaffold/add-inbox-lite.mjs +2 -4
- package/scaffold/add-queue-lite.mjs +2 -3
- package/scaffold/add-table.mjs +2 -4
- package/scaffold/migration-stamp.d.mts +11 -0
- package/scaffold/migration-stamp.mjs +32 -0
- package/src/helpers/hfs-arch-fixture.d.mts +1 -1
- package/src/main.d.mts +4 -1
- package/src/main.mjs +189 -74
- package/src/stack-records-double.d.mts +33 -0
- package/stack/ci-key.d.mts +11 -0
- package/stack/ci-key.mjs +44 -0
- package/stack/cli.d.mts +7 -1
- package/stack/cli.mjs +22 -13
- package/stack/config.d.mts +15 -0
- package/stack/converge.d.mts +2 -0
- package/stack/converge.mjs +1 -1
- package/stack/db-phases.d.mts +185 -0
- package/stack/db-phases.mjs +346 -0
- package/stack/deploy.d.mts +22 -5
- package/stack/deploy.mjs +242 -72
- package/stack/journal.d.mts +146 -0
- package/stack/journal.mjs +191 -0
- package/stack/manager-backup.d.mts +54 -0
- package/stack/manager-backup.mjs +136 -0
- package/stack/notify.d.mts +9 -0
- package/stack/notify.mjs +32 -0
- package/stack/release-store.d.mts +49 -1
- package/stack/release-store.mjs +102 -16
- package/stack/remote.d.mts +2 -0
- package/stack/remote.mjs +32 -11
- package/stack/rollback-guard.d.mts +10 -0
- package/stack/rollback-guard.mjs +41 -0
- package/stack/server-key.d.mts +2 -0
- package/stack/server-key.mjs +1 -1
- package/stack/ssh-gate.d.mts +35 -0
- package/stack/ssh-gate.mjs +145 -0
- package/stack/status.d.mts +51 -0
- package/stack/status.mjs +115 -0
- package/stack/urgent-floor.d.mts +10 -61
- package/stack/urgent-floor.mjs +28 -123
- package/stack/verify.mjs +26 -0
- package/sync/ci-commands.mjs +10 -5
- package/sync/format-worker.d.mts +1 -0
- package/sync/format-worker.mjs +17 -0
- package/sync/format.d.mts +21 -3
- package/sync/format.mjs +99 -7
- package/sync/index.d.mts +3 -6
- package/sync/index.mjs +12 -12
- package/sync/stack-images.d.mts +25 -10
- package/sync/stack-images.mjs +215 -54
- package/templates/app/ci-workflows/github/workflows/ci.yml +114 -7
- package/templates/app/ci-workflows/github/workflows/images.yml +21 -65
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +130 -37
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +114 -7
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +21 -65
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +130 -37
- package/templates/app/gitignore +1 -0
- package/templates/app/gitignore-lite +1 -0
- package/templates/app/package-scripts/db.json +1 -1
- package/templates/app/skeleton/.gitattributes +2 -0
- package/templates/app/skeleton-lite/.gitattributes +2 -0
- package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +2 -1
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +0 -63
package/stack/deploy.mjs
CHANGED
|
@@ -10,10 +10,16 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
10
10
|
// deploy: render, provision, store the release record, pre-pull, `docker stack deploy`, converge, post-deploy tasks (deploy.postDeploy, when declared), verify, accept, prune. A failure at or
|
|
11
11
|
// after the stack deploy redeploys the previous accepted release from its STORED bytes; a failure before it leaves the services untouched.
|
|
12
12
|
// Without `execute` a deploy renders locally and prints the plan: NO remote call at all. A rollback cannot undo data effects.
|
|
13
|
+
// An app that declares the `database:` block gets the database phases of db-phases.mjs in every deploy (owner approval 2026-10-31): migrate preview, a
|
|
14
|
+
// verified backup only when something is pending, the expand migrations inside the database container before the stack deploy, the contract migrations
|
|
15
|
+
// after accept, and an automatic first start when no release was ever accepted. An app without the block deploys exactly as before.
|
|
13
16
|
// An urgent deploy (`urgent`, owner ruling urgent-release 2026-10-29) runs the safety floor of urgent-floor.mjs first (fresh verified encrypted
|
|
14
17
|
// backup with a test decrypt, rollback target, migrate preview then execute) and refuses with the floor's own code when a step of it fails; its
|
|
15
18
|
// first start (`firstStart`) deploys the stack with only the database running (the `deploy-hold` step), proves the database empty and migrates it inside its own
|
|
16
19
|
// container (the `first-start` step), then deploys the real stack and converges.
|
|
20
|
+
// Every execute run is one recorded ATTEMPT (journal.mjs): the deploy lock of the manager (shared with the owner's urgent deploy), the deploy journal written at
|
|
21
|
+
// every phase boundary, the skip when the accepted release already runs this commit, and the live services snapshotted after converge, after a rollback and on accept
|
|
22
|
+
// (status.mjs; `stack status` and `stack verify` judge them). Preflight also pulls the images of the rollback target, so a registry clean-up cannot strand a rollback.
|
|
17
23
|
//
|
|
18
24
|
// `deps` are the collaborators, all factories or functions with the signature of their module, defaulting to lazy dynamic imports so this
|
|
19
25
|
// module never needs render.mjs / provision.mjs / remote.mjs at load time (the specs inject stubs):
|
|
@@ -28,11 +34,16 @@ import { convergeStack } from './converge.mjs';
|
|
|
28
34
|
import { describeClash, edgeCollisions, reconcileEdgeAliases } from './edge-alias.mjs';
|
|
29
35
|
import { firstLine, tool } from './context.mjs';
|
|
30
36
|
import { hasPlaceholderDigest, isReleaseId } from './digest.mjs';
|
|
37
|
+
import { contractPhase, declaredDatabase, expandPhase, firstStartHooks, FLOOR_DEPS, unknownDatabaseService } from './db-phases.mjs';
|
|
31
38
|
import { failure, refusal } from './errors.mjs';
|
|
39
|
+
import { recordedAttempt } from './journal.mjs';
|
|
32
40
|
import { postDeployStack } from './post-deploy.mjs';
|
|
33
41
|
import { pruneStack } from './prune.mjs';
|
|
42
|
+
import { compareWithRelease, observedServices, stackImages } from './status.mjs';
|
|
34
43
|
import { parseYaml } from '../runtime/engine/yaml.mjs';
|
|
35
44
|
import { createReleaseStore } from './release-store.mjs';
|
|
45
|
+
import { contractFinding, enforceContractGuard } from './rollback-guard.mjs';
|
|
46
|
+
import { gateIsOn } from './ssh-gate.mjs';
|
|
36
47
|
import { urgentDeploy } from './urgent-floor.mjs';
|
|
37
48
|
import { verifyStack } from './verify.mjs';
|
|
38
49
|
/** Loads `file` (relative to this module). Split out so a spec can load a real sibling module through `lazy`. */
|
|
@@ -49,6 +60,7 @@ export const DEFAULT_DEPS = Object.freeze({
|
|
|
49
60
|
verifyStack,
|
|
50
61
|
pruneStack,
|
|
51
62
|
releaseStore: createReleaseStore,
|
|
63
|
+
observedServices,
|
|
52
64
|
ssh: lazy('./remote.mjs', 'ownerSshRunner'),
|
|
53
65
|
floor: {},
|
|
54
66
|
});
|
|
@@ -136,10 +148,10 @@ function writeHoldOverride(config, out, database) {
|
|
|
136
148
|
`);
|
|
137
149
|
return file;
|
|
138
150
|
}
|
|
139
|
-
/** Runs [name, action] steps in order until one fails; records { step, ok } and collects the lines. Returns the failed step name or ''. */
|
|
140
|
-
async function runSteps(plan, lines, steps) {
|
|
151
|
+
/** Runs [name, action] steps in order until one fails; records { step, ok } and collects the lines; each step is also a phase of the journal. Returns the failed step name or ''. */
|
|
152
|
+
async function runSteps(plan, lines, steps, rec) {
|
|
141
153
|
const failed = await findInOrder(plan, async ([step, action]) => {
|
|
142
|
-
const result = await attempt(action);
|
|
154
|
+
const result = await rec.phase(step, () => attempt(action), (done) => done.ok);
|
|
143
155
|
lines.push(...result.lines);
|
|
144
156
|
steps.push({ step, ok: result.ok });
|
|
145
157
|
return !result.ok;
|
|
@@ -153,17 +165,17 @@ async function openRunner(ctx, config, options, d, cleanups) {
|
|
|
153
165
|
cleanups.push(() => run.cleanup?.());
|
|
154
166
|
return run;
|
|
155
167
|
}
|
|
156
|
-
const openStore = async (ctx, config, options, d, cleanups) => d.releaseStore(config, await openRunner(ctx, config, options, d, cleanups));
|
|
168
|
+
const openStore = async (ctx, config, options, d, cleanups) => d.releaseStore(config, await openRunner(ctx, config, options, d, cleanups), { gated: gateIsOn(ctx.env) });
|
|
157
169
|
/** Redeploys a stored release after a failed deploy: fetch, stack deploy, converge. Returns the lines to print. */
|
|
158
|
-
async function redeployStored(ctx, config, d, store, previous, out, steps) {
|
|
170
|
+
async function redeployStored(ctx, config, d, store, previous, out, steps, rec) {
|
|
159
171
|
const dir = path.join(out, 'previous');
|
|
160
172
|
const lines = [`ROLLING BACK to the previous accepted release ${previous}`];
|
|
161
173
|
const failed = await runSteps([
|
|
162
174
|
['rollback-fetch', async () => { await store.fetch(previous, dir); return { ok: true, lines: [] }; }],
|
|
163
175
|
['rollback-deploy', () => stackDeploy(ctx, config, path.join(dir, 'stack.yaml'))],
|
|
164
176
|
['rollback-edge', async () => d.reconcileEdge(ctx, config, path.join(dir, 'stack.yaml'))],
|
|
165
|
-
['rollback-converge', () => d.convergeStack(ctx, config, { since: isoBefore(ctx, 30), timeoutSeconds: 600 })],
|
|
166
|
-
], lines, steps);
|
|
177
|
+
['rollback-converge', async () => { const converged = await d.convergeStack(ctx, config, { since: isoBefore(ctx, 30), timeoutSeconds: 600 }); snapshot(ctx, config, d, rec, 'rollback'); return converged; }],
|
|
178
|
+
], lines, steps, rec);
|
|
167
179
|
lines.push(failed ? 'ROLLBACK DID NOT CONVERGE: follow the stack runbook, Rollback' : `rolled back to ${previous}`);
|
|
168
180
|
return { lines, ok: !failed };
|
|
169
181
|
}
|
|
@@ -192,28 +204,103 @@ function prePull(ctx, images) {
|
|
|
192
204
|
}
|
|
193
205
|
return lines;
|
|
194
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* Pulls the images of the rollback target (the release accepted at the start of the deploy) that the new release does not already name: a registry clean-up
|
|
209
|
+
* must not strand a rollback, so a target whose images no longer pull refuses the deploy before the services are touched.
|
|
210
|
+
*/
|
|
211
|
+
async function prePullRollbackTarget(ctx, store, previous, pulled) {
|
|
212
|
+
const target = await store.read(previous);
|
|
213
|
+
const images = [...new Set(Object.values(stackImages(target.stackYaml)))].filter((image) => IMAGE_REF.test(image) && !pulled.includes(image));
|
|
214
|
+
const lines = [];
|
|
215
|
+
for (const image of images) {
|
|
216
|
+
const run = tool(ctx, 'docker', ['pull', '--quiet', image]);
|
|
217
|
+
if (run.status !== 0)
|
|
218
|
+
throw failure('STACK_DEPLOY_ROLLBACK_PULL', `the rollback target ${previous} names ${image}, which no longer pulls (${firstLine(run.stderr)}): a failed deploy could not go back; nothing was changed`);
|
|
219
|
+
lines.push(`pulled ${image} (rollback target ${previous})`);
|
|
220
|
+
}
|
|
221
|
+
return lines;
|
|
222
|
+
}
|
|
223
|
+
/** The live services into the recorder under `moment`; null (and no snapshot) when docker cannot be asked: a snapshot never fails a deploy. */
|
|
224
|
+
function snapshot(ctx, config, d, rec, moment) {
|
|
225
|
+
try {
|
|
226
|
+
const services = d.observedServices(ctx, config);
|
|
227
|
+
rec.snapshot(moment, services);
|
|
228
|
+
return services;
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Accepts `releaseId`: stamps the services observed now into its release record (`observed.accept`, what `stack status` compares against), stores it
|
|
236
|
+
* again, and moves the pointers. Without a snapshot the release is accepted as before.
|
|
237
|
+
*/
|
|
238
|
+
async function acceptObserved(ctx, config, d, store, rec, { out, releaseId }) {
|
|
239
|
+
const services = snapshot(ctx, config, d, rec, 'accept');
|
|
240
|
+
if (services) {
|
|
241
|
+
try {
|
|
242
|
+
const file = path.join(out, 'release.json');
|
|
243
|
+
const record = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
244
|
+
fs.writeFileSync(file, `${JSON.stringify({ ...record, observed: { at: new Date(ctx.now()).toISOString(), accept: services } }, null, 2)}
|
|
245
|
+
`);
|
|
246
|
+
await store.store(out, releaseId);
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
rec.warnings.push(`journal: the observed services were not stored in the release record (${firstLine(error.message)})`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
await store.accept(releaseId);
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* The accepted release when it already runs this commit and its services still match it (nothing to deploy), else null. A same-commit release whose
|
|
256
|
+
* services drifted (Swarm rolled one back, a manual update) is deployed again; an unreadable swarm is deployed too.
|
|
257
|
+
*/
|
|
258
|
+
async function alreadyLive(ctx, config, d, store, sourceSha, lines) {
|
|
259
|
+
const current = await store.current();
|
|
260
|
+
if (!current)
|
|
261
|
+
return null;
|
|
262
|
+
try {
|
|
263
|
+
const stored = await store.read(current);
|
|
264
|
+
if (stored.release.sourceSha !== sourceSha)
|
|
265
|
+
return null;
|
|
266
|
+
const drift = compareWithRelease(stored, d.observedServices(ctx, config));
|
|
267
|
+
if (drift.length === 0)
|
|
268
|
+
return current;
|
|
269
|
+
lines.push(`release ${current} runs this commit, but ${drift.map((entry) => `${entry.service} (${entry.kind})`).join(', ')} differ: deploying again`);
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
lines.push(`release ${current} could not be compared with the live services: deploying`);
|
|
273
|
+
}
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
195
276
|
/** True when the deploy names the commit it deploys. */
|
|
196
277
|
const hasSourceSha = (options) => Boolean(options.sourceSha);
|
|
197
278
|
/** Everything before the stack deploy: any failure here leaves the services untouched. Returns { ok, lines, releaseId, images, previous }. */
|
|
198
|
-
async function prepareRelease(ctx, config, options, d, out,
|
|
199
|
-
const run
|
|
200
|
-
const store = d.releaseStore(config, run);
|
|
279
|
+
async function prepareRelease(ctx, config, options, d, out, seams) {
|
|
280
|
+
const { run, store, rec } = seams;
|
|
201
281
|
const previous = await store.current();
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
282
|
+
rec.note({ previous: previous || null });
|
|
283
|
+
const prepared = await rec.phase('render', async () => {
|
|
284
|
+
const rendered = await d.renderRelease(ctx, config, renderOptions(options, out, previous || null));
|
|
285
|
+
const inspected = inspectRendered(out, config);
|
|
286
|
+
await refuseEdgeCollisions(run, config, inspected.bytes);
|
|
287
|
+
return { rendered, ...inspected };
|
|
288
|
+
});
|
|
289
|
+
const { releaseId, images } = prepared;
|
|
290
|
+
rec.note({ releaseId });
|
|
291
|
+
const lines = [...(prepared.rendered.lines ?? [])];
|
|
292
|
+
const provisioned = await rec.phase('provision', () => d.provisionRelease(ctx, config, { execute: true, out, releaseFile: path.join(out, 'release.json') }), (done) => done.ok !== false);
|
|
207
293
|
lines.push(...(provisioned.lines ?? []));
|
|
208
294
|
if (provisioned.ok === false)
|
|
209
|
-
return { ok: false, lines, releaseId, previous,
|
|
210
|
-
await store.store(out, releaseId);
|
|
211
|
-
lines.push(...prePull(ctx, images));
|
|
212
|
-
return { ok: true, lines, releaseId, previous,
|
|
295
|
+
return { ok: false, lines, releaseId, previous, stepsDone: [{ step: 'render', ok: true }, { step: 'provision', ok: false }] };
|
|
296
|
+
await rec.phase('store', () => store.store(out, releaseId));
|
|
297
|
+
lines.push(...await rec.phase('pre-pull', async () => [...prePull(ctx, images), ...(previous ? await prePullRollbackTarget(ctx, store, previous, images) : [])]));
|
|
298
|
+
return { ok: true, lines, releaseId, previous, stepsDone: [{ step: 'render', ok: true }, { step: 'provision', ok: true }, { step: 'store', ok: true }, { step: 'pre-pull', ok: true }] };
|
|
213
299
|
}
|
|
214
|
-
async function deployExecute(ctx, config, options, d, out,
|
|
215
|
-
const
|
|
216
|
-
const
|
|
300
|
+
async function deployExecute(ctx, config, options, d, out, seams) {
|
|
301
|
+
const { store, rec, run } = seams;
|
|
302
|
+
const prepared = await prepareRelease(ctx, config, options, d, out, seams);
|
|
303
|
+
const { releaseId, previous } = prepared;
|
|
217
304
|
const lines = [...prepared.lines];
|
|
218
305
|
const steps = [...prepared.stepsDone];
|
|
219
306
|
const data = { releaseId, previous: previous || null, steps, rolledBack: false };
|
|
@@ -225,37 +312,76 @@ async function deployExecute(ctx, config, options, d, out, cleanups) {
|
|
|
225
312
|
// The first start of a self-hosted database (urgent-floor.mjs): the services that need its schema must not start against an empty one (their
|
|
226
313
|
// healthchecks fail and update_config rolls them back), so the stack is first deployed with only the database running, the schema is
|
|
227
314
|
// bootstrapped and migrated, and then the real stack is deployed.
|
|
228
|
-
|
|
315
|
+
// An app that declares the `database:` block migrates in every deploy (db-phases.mjs); with no accepted release the first start is chosen by itself.
|
|
316
|
+
const database = options.urgent === true ? null : await declaredDatabase(ctx, config);
|
|
317
|
+
const phase = database ? { ctx, config, database, f: { ...FLOOR_DEPS, ...d.floor }, run, dbUrlEnv: options.dbUrlEnv, backupOut: options.backupOut } : null;
|
|
318
|
+
const unknown = database ? unknownDatabaseService(config, database) : null;
|
|
319
|
+
if (unknown)
|
|
320
|
+
return { ok: false, refused: unknown, lines: [...lines, 'deploy not started; the services are untouched'], data: { ...data, refused: unknown.code } };
|
|
321
|
+
let first = options.firstStartStep && options.firstStartDb ? { step: options.firstStartStep, service: options.firstStartDb } : null;
|
|
322
|
+
let firstCode = null;
|
|
323
|
+
if (phase && !first && phase.database.target === 'stack' && !previous) {
|
|
324
|
+
const automatic = firstStartHooks(phase);
|
|
325
|
+
first = { service: automatic.service, step: async () => { const outcome = await automatic.step(); firstCode = outcome.code ?? null; return outcome; } };
|
|
326
|
+
}
|
|
327
|
+
if (phase && !first) {
|
|
328
|
+
const expanded = await rec.phase('migrate', () => expandPhase(phase), (done) => done.ok);
|
|
329
|
+
rec.note({ backup: expanded.record.backup, migrations: expanded.record.applied });
|
|
330
|
+
lines.push(...expanded.lines);
|
|
331
|
+
steps.push(...expanded.steps);
|
|
332
|
+
data.migrations = expanded.record;
|
|
333
|
+
if (!expanded.ok)
|
|
334
|
+
return { ok: false, refused: expanded.refused, lines: [...lines, 'deploy not started; the services are untouched'], data: { ...data, refused: expanded.refused?.code } };
|
|
335
|
+
}
|
|
336
|
+
const hold = first ? writeHoldOverride(config, out, first.service) : null;
|
|
229
337
|
const failed = await runSteps([
|
|
230
338
|
...(hold ? [['deploy-hold', () => stackDeploy(ctx, config, stackFile, [hold])]] : []),
|
|
231
|
-
...(
|
|
339
|
+
...(first ? [['first-start', first.step]] : []),
|
|
232
340
|
['deploy', () => stackDeploy(ctx, config, stackFile)],
|
|
233
341
|
['edge', async () => d.reconcileEdge(ctx, config, stackFile)],
|
|
234
|
-
['converge', () => d.convergeStack(ctx, config, { since, timeoutSeconds: options.timeoutSeconds })],
|
|
342
|
+
['converge', async () => { const converged = await d.convergeStack(ctx, config, { since, timeoutSeconds: options.timeoutSeconds }); snapshot(ctx, config, d, rec, 'converge'); return converged; }],
|
|
235
343
|
...(config.postDeploy.length > 0 ? [['post-deploy', () => d.postDeployStack(ctx, config)]] : []),
|
|
236
344
|
['verify', () => d.verifyStack(ctx, config, { retrySeconds: options.retrySeconds, skipPublic: skipPublic === true, stackFile: path.join(out, 'stack.yaml') })],
|
|
237
|
-
...(skipPublic ? [] : [['accept', async () => { await store
|
|
238
|
-
], lines, steps);
|
|
345
|
+
...(skipPublic ? [] : [['accept', async () => { await acceptObserved(ctx, config, d, store, rec, { out, releaseId }); return { ok: true, lines: [`accepted release ${releaseId}`] }; }]]),
|
|
346
|
+
], lines, steps, rec);
|
|
239
347
|
if (!failed && skipPublic) {
|
|
240
348
|
data.pendingAccept = true;
|
|
241
|
-
return { ok: true, lines: [...lines, `deployed release ${releaseId}: converged and verified inside the stack; the public hosts were NOT checked and the release is NOT accepted yet`, `next: starci app stack ingress publish --env ${config.env} --execute, then starci app stack accept --env ${config.env} --release ${releaseId} --execute`], data };
|
|
349
|
+
return { ok: true, lines: [...lines, ...(phase && !first ? ['db: the contract migrations (if any) run when the release is accepted'] : []), `deployed release ${releaseId}: converged and verified inside the stack; the public hosts were NOT checked and the release is NOT accepted yet`, `next: starci app stack ingress publish --env ${config.env} --execute, then starci app stack accept --env ${config.env} --release ${releaseId} --execute`], data };
|
|
242
350
|
}
|
|
243
351
|
if (!failed) {
|
|
244
352
|
await housekeeping(ctx, config, d, store, { previous, out }, lines);
|
|
353
|
+
// The contract migrations run only now, with the release accepted and serving: the previous release is no longer the one the stack runs.
|
|
354
|
+
const contract = phase && !first ? await rec.phase('contract', () => contractPhase(phase), (done) => done.ok) : null;
|
|
355
|
+
if (contract) {
|
|
356
|
+
lines.push(...contract.lines);
|
|
357
|
+
rec.note({ migrations: [...(data.migrations?.applied ?? []), ...contract.applied], contract: contract.applied });
|
|
358
|
+
steps.push({ step: 'contract', ok: contract.ok });
|
|
359
|
+
data.migrations = { ...data.migrations, contractApplied: contract.applied };
|
|
360
|
+
}
|
|
361
|
+
if (contract && !contract.ok)
|
|
362
|
+
return { ok: false, lines: [...lines, `release ${releaseId} is accepted and serving; its contract migration is red: the owner decides`], data: { ...data, accepted: true, contractFailed: true } };
|
|
245
363
|
return { ok: true, lines: [...lines, `deployed release ${releaseId}`], data };
|
|
246
364
|
}
|
|
247
365
|
lines.push(`deploy failed at ${failed}`);
|
|
366
|
+
const migrations = data.migrations;
|
|
367
|
+
if (migrations?.applied?.length)
|
|
368
|
+
lines.push(`db: the ${migrations.applied.length} expand migration(s) this deploy applied stay (the previous release keeps working on the expanded schema; a rollback restores services, never data); the backup ${migrations.backup?.receiptPath ?? ''} is the way back for data`);
|
|
369
|
+
const stopped = firstCode ? { refused: { code: firstCode, message: 'the first start failed' } } : {};
|
|
248
370
|
if (!previous)
|
|
249
|
-
return { ok: false, lines: [...lines, 'no previous accepted release; services were rolled back by update_config where they had a previous spec'], data };
|
|
250
|
-
const back = await redeployStored(ctx, config, d, store, previous, out, steps);
|
|
371
|
+
return { ok: false, lines: [...lines, 'no previous accepted release; services were rolled back by update_config where they had a previous spec'], data, ...stopped };
|
|
372
|
+
const back = await redeployStored(ctx, config, d, store, previous, out, steps, rec);
|
|
251
373
|
data.rolledBack = back.ok;
|
|
252
|
-
return { ok: false, lines: [...lines, ...back.lines], data };
|
|
374
|
+
return { ok: false, lines: [...lines, ...back.lines], data, ...stopped };
|
|
253
375
|
}
|
|
376
|
+
/** What starts an attempt: the owner's urgent deploy, a CI run (`--ci-run`) or a manual dispatch. */
|
|
377
|
+
const triggerOf = (options) => (options.urgent === true ? 'urgent' : options.ciRun ? 'ci' : 'dispatch');
|
|
254
378
|
/**
|
|
255
|
-
* Deploys a release: `options` = { images, sourceSha, execute, out, timeoutSeconds, retrySeconds, skipPublic, sshCmd, urgent, dbUrlEnv, backupOut }.
|
|
379
|
+
* Deploys a release: `options` = { images, sourceSha, execute, out, timeoutSeconds, retrySeconds, skipPublic, sshCmd, urgent, dbUrlEnv, backupOut, ciRun, redeploy }.
|
|
256
380
|
* skipPublic (the first deploy into a shared ingress, whose public routes exist only after `ingress publish`): the verification checks only what runs
|
|
257
381
|
* inside the stack, and the release stays PENDING (stored and deployed, pointer not moved) until `acceptRelease` has verified it in full.
|
|
258
|
-
*
|
|
382
|
+
* With `execute` the run is one recorded attempt under the deploy lock (journal.mjs); the accepted release that already runs this commit ends it green
|
|
383
|
+
* as `already-current` unless `redeploy` is set.
|
|
384
|
+
* Returns { ok, lines, data: { releaseId, previous, steps: [{ step, ok }], rolledBack, attempt } }.
|
|
259
385
|
*/
|
|
260
386
|
export async function deployRelease(ctx, config, options = {}, deps = {}) {
|
|
261
387
|
const d = { ...DEFAULT_DEPS, ...deps };
|
|
@@ -273,20 +399,44 @@ export async function deployRelease(ctx, config, options = {}, deps = {}) {
|
|
|
273
399
|
const dryRun = async () => {
|
|
274
400
|
const { rendered, releaseId } = await render();
|
|
275
401
|
const post = config.postDeploy.length > 0 ? ` post-deploy tasks (${config.postDeploy.join(', ')}),` : '';
|
|
402
|
+
const database = options.urgent === true ? null : await declaredDatabase(ctx, config);
|
|
403
|
+
const databasePlan = database ? `; database (${database.target === 'stack' ? `inside the ${database.service} container, no URL held` : 'external, by --db-url-env'}): migrate preview, a verified backup only when a migration is pending, the expand migrations before the deploy, the contract migrations after the accept, and an automatic first start when no release was ever accepted` : '';
|
|
276
404
|
const plan = options.skipPublic
|
|
277
|
-
? `dry run: release ${releaseId} would be provisioned, stored, pre-pulled, deployed, converged,${post} and verified inside the stack (public hosts skipped); it would stay pending until ingress publish and stack accept`
|
|
278
|
-
: `dry run: release ${releaseId} would be provisioned, stored, pre-pulled, deployed, converged,${post} verified, accepted`;
|
|
405
|
+
? `dry run: release ${releaseId} would be provisioned, stored, pre-pulled, deployed, converged,${post} and verified inside the stack (public hosts skipped); it would stay pending until ingress publish and stack accept${databasePlan}`
|
|
406
|
+
: `dry run: release ${releaseId} would be provisioned, stored, pre-pulled, deployed, converged,${post} verified, accepted${databasePlan}`;
|
|
279
407
|
return { ok: true, lines: [...(rendered.lines ?? []), plan], data: { releaseId, steps: [{ step: 'render', ok: true }], executed: false } };
|
|
280
408
|
};
|
|
281
|
-
if (options.
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
409
|
+
if (options.execute !== true) {
|
|
410
|
+
if (options.urgent !== true)
|
|
411
|
+
return await dryRun();
|
|
412
|
+
const never = (what) => () => Promise.reject(new Error(`a dry run ${what}`));
|
|
413
|
+
return await urgentDeploy(ctx, config, options, { render, dryRun, openStore: never('opens no store'), deployExecute: never('deploys nothing'), sha256: fileSha256 }, d.floor);
|
|
285
414
|
}
|
|
286
|
-
if (options.
|
|
287
|
-
|
|
288
|
-
await
|
|
289
|
-
|
|
415
|
+
if (options.urgent !== true)
|
|
416
|
+
await render();
|
|
417
|
+
const run = await openRunner(ctx, config, options, d, cleanups);
|
|
418
|
+
const store = d.releaseStore(config, run, { gated: gateIsOn(ctx.env) });
|
|
419
|
+
const meta = {
|
|
420
|
+
kind: 'deploy', trigger: triggerOf(options), sourceSha: options.sourceSha, ciRun: options.ciRun,
|
|
421
|
+
urgent: options.urgent === true ? { role: ctx.env.STARCI_ROLE ?? 'unknown', reason: options.reason ?? '' } : undefined,
|
|
422
|
+
};
|
|
423
|
+
return await recordedAttempt(ctx, config, store, meta, { waitSeconds: options.lockWaitSeconds }, async (rec) => {
|
|
424
|
+
const notes = [];
|
|
425
|
+
const current = options.redeploy === true ? null : await rec.phase('idempotence', () => alreadyLive(ctx, config, d, store, options.sourceSha, notes));
|
|
426
|
+
if (current) {
|
|
427
|
+
rec.note({ releaseId: current });
|
|
428
|
+
return { ok: true, lines: [`already current: release ${current} runs commit ${options.sourceSha.slice(0, 10)} and its services match it; nothing to deploy`], data: { releaseId: current, previous: null, steps: [], rolledBack: false, alreadyCurrent: true } };
|
|
429
|
+
}
|
|
430
|
+
const seams = { run, store, rec };
|
|
431
|
+
const done = options.urgent === true
|
|
432
|
+
? await urgentDeploy(ctx, config, options, {
|
|
433
|
+
render, dryRun, openStore: async () => store, sha256: fileSha256,
|
|
434
|
+
deployExecute: (extra = {}) => deployExecute(ctx, config, { ...options, ...extra }, d, out, seams),
|
|
435
|
+
note: (record) => rec.note({ urgent: record }),
|
|
436
|
+
}, d.floor)
|
|
437
|
+
: await deployExecute(ctx, config, options, d, out, seams);
|
|
438
|
+
return { ...done, lines: [...notes, ...done.lines] };
|
|
439
|
+
});
|
|
290
440
|
}
|
|
291
441
|
finally {
|
|
292
442
|
cleanups.forEach((close) => close());
|
|
@@ -296,7 +446,7 @@ export async function deployRelease(ctx, config, options = {}, deps = {}) {
|
|
|
296
446
|
}
|
|
297
447
|
/**
|
|
298
448
|
* Redeploys a stored release (default: the `previous` pointer): `options` = { release, execute, timeoutSeconds, sshCmd }.
|
|
299
|
-
* `release` is read from `options` only and must be null or 16 lowercase hex.
|
|
449
|
+
* `release` is read from `options` only and must be null or 16 lowercase hex. With `execute` it is one recorded attempt under the deploy lock.
|
|
300
450
|
*/
|
|
301
451
|
export async function rollbackRelease(ctx, config, options = {}, deps = {}) {
|
|
302
452
|
const d = { ...DEFAULT_DEPS, ...deps };
|
|
@@ -305,38 +455,47 @@ export async function rollbackRelease(ctx, config, options = {}, deps = {}) {
|
|
|
305
455
|
throw refusal('STACK_RELEASE_ID', 'the release must be 16 lowercase hex characters');
|
|
306
456
|
const cleanups = [];
|
|
307
457
|
const store = await openStore(ctx, config, options, d, cleanups);
|
|
308
|
-
let dir = null;
|
|
309
458
|
try {
|
|
310
459
|
const target = release ?? await store.previous();
|
|
311
460
|
if (!target)
|
|
312
461
|
throw refusal('STACK_ROLLBACK_NO_TARGET', 'no previous accepted release is recorded; name one with --release');
|
|
462
|
+
// A manual rollback to a release older than an applied contract migration is refused unless the owner acknowledges the data effect.
|
|
463
|
+
const notice = enforceContractGuard(contractFinding(await store.journals(), target), options.dataAcknowledged === true);
|
|
313
464
|
if (options.execute !== true)
|
|
314
|
-
return { ok: true, lines: [`dry run: would redeploy release ${target}`], data: { release: target, executed: false } };
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
465
|
+
return { ok: true, lines: [...notice, `dry run: would redeploy release ${target}`], data: { release: target, executed: false } };
|
|
466
|
+
return await recordedAttempt(ctx, config, store, { kind: 'rollback', trigger: 'dispatch' }, { waitSeconds: options.lockWaitSeconds }, async (rec) => {
|
|
467
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'stack-rollback-'));
|
|
468
|
+
try {
|
|
469
|
+
rec.note({ releaseId: target, previous: await store.current() || null });
|
|
470
|
+
const lines = [];
|
|
471
|
+
const steps = [];
|
|
472
|
+
const since = isoBefore(ctx, 30);
|
|
473
|
+
const stackFile = path.join(scratch, 'stack.yaml');
|
|
474
|
+
const failed = await runSteps([
|
|
475
|
+
['fetch', async () => { await store.fetch(target, scratch); return { ok: true, lines: [] }; }],
|
|
476
|
+
['deploy', () => stackDeploy(ctx, config, stackFile)],
|
|
477
|
+
['edge', async () => d.reconcileEdge(ctx, config, stackFile)],
|
|
478
|
+
['converge', async () => { const converged = await d.convergeStack(ctx, config, { since, timeoutSeconds: options.timeoutSeconds ?? 600 }); snapshot(ctx, config, d, rec, 'converge'); return converged; }],
|
|
479
|
+
['verify', () => d.verifyStack(ctx, config, { retrySeconds: 180, stackFile })],
|
|
480
|
+
['accept', async () => { await acceptObserved(ctx, config, d, store, rec, { out: scratch, releaseId: target }); return { ok: true, lines: [] }; }],
|
|
481
|
+
], lines, steps, rec);
|
|
482
|
+
lines.unshift(...notice);
|
|
483
|
+
lines.push(failed ? `rollback to ${target} failed at ${failed}` : `rolled back to release ${target}; a rollback cannot undo data effects`);
|
|
484
|
+
return { ok: !failed, lines, data: { release: target, executed: true, steps } };
|
|
485
|
+
}
|
|
486
|
+
finally {
|
|
487
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
488
|
+
}
|
|
489
|
+
});
|
|
330
490
|
}
|
|
331
491
|
finally {
|
|
332
|
-
if (dir)
|
|
333
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
334
492
|
cleanups.forEach((close) => close());
|
|
335
493
|
}
|
|
336
494
|
}
|
|
337
495
|
/**
|
|
338
496
|
* Accepts a release that a `skipPublic` deploy left pending: the FULL verification (public hosts and checks inside the stack) must pass first, then the
|
|
339
|
-
* pointer moves and the superseded secrets and configs are pruned. `options` = { release, execute, retrySeconds, sshCmd }.
|
|
497
|
+
* pointer moves and the superseded secrets and configs are pruned. `options` = { release, execute, retrySeconds, sshCmd }. With `execute` it is one recorded
|
|
498
|
+
* attempt under the deploy lock.
|
|
340
499
|
*/
|
|
341
500
|
export async function acceptRelease(ctx, config, options = {}, deps = {}) {
|
|
342
501
|
const d = { ...DEFAULT_DEPS, ...deps };
|
|
@@ -358,14 +517,25 @@ export async function acceptRelease(ctx, config, options = {}, deps = {}) {
|
|
|
358
517
|
return { ok: true, lines: [`release ${release} is already the accepted release`], data: { release, accepted: true, executed: options.execute === true } };
|
|
359
518
|
if (options.execute !== true)
|
|
360
519
|
return { ok: true, lines: [`dry run: would run the full verification (public hosts and checks inside the stack) and then accept release ${release}`], data: { release, accepted: false, executed: false } };
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
520
|
+
return await recordedAttempt(ctx, config, store, { kind: 'accept', trigger: 'dispatch' }, { waitSeconds: options.lockWaitSeconds }, async (rec) => {
|
|
521
|
+
rec.note({ releaseId: release, previous: previous || null });
|
|
522
|
+
const verified = await rec.phase('verify', () => d.verifyStack(ctx, config, { retrySeconds: options.retrySeconds, stackFile: path.join(out, 'stack.yaml') }), (done) => done.ok);
|
|
523
|
+
const lines = [...(verified.lines ?? [])];
|
|
524
|
+
if (!verified.ok)
|
|
525
|
+
return { ok: false, lines: [...lines, `release ${release} is NOT accepted: the full verification failed`], data: { release, accepted: false, executed: true } };
|
|
526
|
+
await rec.phase('accept', () => acceptObserved(ctx, config, d, store, rec, { out, releaseId: release }));
|
|
527
|
+
lines.push(`accepted release ${release}`);
|
|
528
|
+
await housekeeping(ctx, config, d, store, { previous: previous || null, out }, lines);
|
|
529
|
+
const database = await declaredDatabase(ctx, config);
|
|
530
|
+
const contract = database ? await rec.phase('contract', () => contractPhase({ ctx, config, database, f: { ...FLOOR_DEPS, ...d.floor }, run: null, dbUrlEnv: options.dbUrlEnv }), (done) => done.ok) : null;
|
|
531
|
+
if (contract) {
|
|
532
|
+
lines.push(...contract.lines);
|
|
533
|
+
rec.note({ migrations: contract.applied, contract: contract.applied });
|
|
534
|
+
}
|
|
535
|
+
if (contract && !contract.ok)
|
|
536
|
+
return { ok: false, lines: [...lines, `release ${release} is accepted and serving; its contract migration is red: the owner decides`], data: { release, accepted: true, executed: true, contractFailed: true } };
|
|
537
|
+
return { ok: true, lines, data: { release, accepted: true, executed: true, ...(contract ? { contractApplied: contract.applied } : {}) } };
|
|
538
|
+
});
|
|
369
539
|
}
|
|
370
540
|
finally {
|
|
371
541
|
fs.rmSync(out, { recursive: true, force: true });
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { DeployConfig } from './config.mjs';
|
|
2
|
+
import type { StackContext } from './context.mjs';
|
|
3
|
+
import type { LockTrigger, ReleaseStore } from './release-store.mjs';
|
|
4
|
+
import type { ActionResult } from './remote.mjs';
|
|
5
|
+
import type { ObservedService } from './status.mjs';
|
|
6
|
+
/** What started the attempt: a CI run, a manual dispatch or the owner's urgent deploy. */
|
|
7
|
+
export type Trigger = LockTrigger;
|
|
8
|
+
/** What the attempt does. */
|
|
9
|
+
export type AttemptKind = 'deploy' | 'rollback' | 'accept';
|
|
10
|
+
/** How an attempt ended; `running` is the document of an attempt that has not finished (or was cancelled). */
|
|
11
|
+
export type Outcome = 'running' | 'accepted' | 'pending' | 'rolled-back' | 'rollback-failed' | 'failed' | 'refused' | 'already-current';
|
|
12
|
+
/** One phase of an attempt. */
|
|
13
|
+
export interface PhaseRecord {
|
|
14
|
+
phase: string;
|
|
15
|
+
startedAt: string;
|
|
16
|
+
endedAt: string | null;
|
|
17
|
+
verdict: 'running' | 'ok' | 'failed';
|
|
18
|
+
}
|
|
19
|
+
/** The document of one attempt, as /srv/<stack>/deploys/<attempt>.json holds it. */
|
|
20
|
+
export interface AttemptDocument {
|
|
21
|
+
schema: 'starci/stack-deploy-attempt@1';
|
|
22
|
+
attempt: string;
|
|
23
|
+
kind: AttemptKind;
|
|
24
|
+
stack: string;
|
|
25
|
+
env: string;
|
|
26
|
+
startedAt: string;
|
|
27
|
+
endedAt: string | null;
|
|
28
|
+
trigger: Trigger;
|
|
29
|
+
sourceSha: string | null;
|
|
30
|
+
/** The release tag when the attempt ran from a tag (GITHUB_REF_NAME of a tag run). */
|
|
31
|
+
tag: string | null;
|
|
32
|
+
/** The CI run: its id and, when the runner environment names the repository, its URL. */
|
|
33
|
+
ci: {
|
|
34
|
+
run: string;
|
|
35
|
+
url: string | null;
|
|
36
|
+
} | null;
|
|
37
|
+
/** The owner's urgent record: who and why (the reason is the owner's own text, bounded). */
|
|
38
|
+
urgent: {
|
|
39
|
+
role: string;
|
|
40
|
+
reason: string;
|
|
41
|
+
tag: string | null;
|
|
42
|
+
} | null;
|
|
43
|
+
releaseId: string | null;
|
|
44
|
+
previous: string | null;
|
|
45
|
+
phases: PhaseRecord[];
|
|
46
|
+
/** Reserved for the backup phase: its receipt (names and digests only). Null when no backup ran. */
|
|
47
|
+
backup: Record<string, unknown> | null;
|
|
48
|
+
/** Reserved for the migrate and contract phases: the names of the migrations that were applied. */
|
|
49
|
+
migrations: string[];
|
|
50
|
+
/** The contract migrations this attempt applied (the marker the manual rollback guard reads); empty when it applied none. */
|
|
51
|
+
contract: string[];
|
|
52
|
+
/** `docker service inspect` snapshots by moment: `converge`, `rollback`, `accept`. */
|
|
53
|
+
observed: Record<string, ObservedService[]>;
|
|
54
|
+
outcome: Outcome;
|
|
55
|
+
error: {
|
|
56
|
+
code: string;
|
|
57
|
+
message: string;
|
|
58
|
+
} | null;
|
|
59
|
+
}
|
|
60
|
+
/** The facts of a finished attempt that a notification carries: names, digests and codes, never a message text or a value. */
|
|
61
|
+
export interface AttemptSummary {
|
|
62
|
+
attempt: string;
|
|
63
|
+
kind: AttemptKind;
|
|
64
|
+
stack: string;
|
|
65
|
+
env: string;
|
|
66
|
+
outcome: Outcome;
|
|
67
|
+
trigger: Trigger;
|
|
68
|
+
sourceSha: string | null;
|
|
69
|
+
tag: string | null;
|
|
70
|
+
releaseId: string | null;
|
|
71
|
+
ciUrl: string | null;
|
|
72
|
+
errorCode: string | null;
|
|
73
|
+
}
|
|
74
|
+
/** What an attempt is told about itself when it opens. */
|
|
75
|
+
export interface AttemptMeta {
|
|
76
|
+
kind: AttemptKind;
|
|
77
|
+
trigger: Trigger;
|
|
78
|
+
sourceSha?: string | undefined;
|
|
79
|
+
/** The id of the CI run (digits); with it the trigger is `ci`. */
|
|
80
|
+
ciRun?: string | undefined;
|
|
81
|
+
urgent?: {
|
|
82
|
+
role: string;
|
|
83
|
+
reason: string;
|
|
84
|
+
tag?: string | undefined;
|
|
85
|
+
} | undefined;
|
|
86
|
+
}
|
|
87
|
+
/** The facts an attempt learns while it runs. */
|
|
88
|
+
export interface Note {
|
|
89
|
+
releaseId?: string | null;
|
|
90
|
+
previous?: string | null;
|
|
91
|
+
backup?: Record<string, unknown> | null;
|
|
92
|
+
migrations?: string[];
|
|
93
|
+
contract?: string[];
|
|
94
|
+
urgent?: {
|
|
95
|
+
role: string;
|
|
96
|
+
reason: string;
|
|
97
|
+
tag?: string | undefined;
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** The open record of one attempt. */
|
|
101
|
+
export interface Recorder {
|
|
102
|
+
readonly id: string;
|
|
103
|
+
/** Journal failures after the first write; the caller prints them. */
|
|
104
|
+
readonly warnings: string[];
|
|
105
|
+
note(facts: Note): void;
|
|
106
|
+
/** Registers what runs at the start of every later phase (the lock refresh). */
|
|
107
|
+
beforePhase(next: () => Promise<void>): void;
|
|
108
|
+
/** Runs `action` as the named phase: start and end are written; a throw is recorded as failed and rethrown. `passed` judges a returned value (default: ok). */
|
|
109
|
+
phase<T>(name: string, action: () => T | Promise<T>, passed?: (value: T) => boolean): Promise<T>;
|
|
110
|
+
snapshot(moment: string, services: ObservedService[]): void;
|
|
111
|
+
finish(outcome: Outcome, error?: {
|
|
112
|
+
code: string;
|
|
113
|
+
message: string;
|
|
114
|
+
} | null): Promise<void>;
|
|
115
|
+
/** The facts of the attempt as it stands (call after `finish` for a notification). */
|
|
116
|
+
summary(): AttemptSummary;
|
|
117
|
+
}
|
|
118
|
+
/** A new attempt id: the UTC time and 8 random hex characters. */
|
|
119
|
+
export declare const newAttemptId: (ctx: Pick<StackContext, "now">) => string;
|
|
120
|
+
/**
|
|
121
|
+
* Opens the journal of an attempt and returns its recorder. Writes the first document and the index line; a failure of either is the refusal
|
|
122
|
+
* `STACK_DEPLOY_JOURNAL` (the manager cannot keep a record, so nothing is changed).
|
|
123
|
+
*/
|
|
124
|
+
export declare function openAttempt(ctx: StackContext, config: DeployConfig, store: ReleaseStore, meta: AttemptMeta): Promise<Recorder>;
|
|
125
|
+
/** What the lock wait takes. */
|
|
126
|
+
export interface LockWait {
|
|
127
|
+
waitSeconds?: number | undefined;
|
|
128
|
+
pollSeconds?: number | undefined;
|
|
129
|
+
}
|
|
130
|
+
/** The default wait for a held deploy lock (the design's ten minutes). */
|
|
131
|
+
export declare const LOCK_WAIT_SECONDS = 600;
|
|
132
|
+
/** A taken lock: refresh keeps it alive, release gives it back. */
|
|
133
|
+
export interface LockHold {
|
|
134
|
+
token: string;
|
|
135
|
+
refresh(): Promise<boolean>;
|
|
136
|
+
release(): Promise<boolean>;
|
|
137
|
+
}
|
|
138
|
+
/** Takes the deploy lock, waiting while another deploy holds it; `STACK_DEPLOY_LOCKED` when the wait ends. */
|
|
139
|
+
export declare function holdLock(ctx: StackContext, store: ReleaseStore, trigger: Trigger, wait?: LockWait): Promise<LockHold>;
|
|
140
|
+
/** How a finished result reads as an outcome. */
|
|
141
|
+
export declare function outcomeOf(result: ActionResult, kind: AttemptKind): Outcome;
|
|
142
|
+
/**
|
|
143
|
+
* Runs `body` as one recorded attempt under the deploy lock: journal opened, lock taken, `body(recorder)` run, outcome written, lock released. The result
|
|
144
|
+
* gains `data.attempt` (the attempt id) and the journal's warnings as lines; a throw is recorded as `refused` and rethrown.
|
|
145
|
+
*/
|
|
146
|
+
export declare function recordedAttempt(ctx: StackContext, config: DeployConfig, store: ReleaseStore, meta: AttemptMeta, wait: LockWait, body: (recorder: Recorder) => Promise<ActionResult>): Promise<ActionResult>;
|