@starci/hfs 4.12.0 → 4.13.0
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 +43 -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 +19 -7
- package/runtime/modules/kernel/failure-codes.yaml +46 -4
- package/runtime/modules/schemas/application-stacks.schema.yaml +26 -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 +6 -0
- package/runtime/scripts/hfs/stack-deploy-config.mjs +32 -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 +31 -0
- package/stack/cli.d.mts +3 -1
- package/stack/cli.mjs +17 -11
- package/stack/config.d.mts +6 -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 +18 -4
- package/stack/deploy.mjs +235 -70
- package/stack/journal.d.mts +127 -0
- package/stack/journal.mjs +182 -0
- package/stack/manager-backup.d.mts +54 -0
- package/stack/manager-backup.mjs +136 -0
- package/stack/release-store.d.mts +44 -0
- package/stack/release-store.mjs +89 -6
- package/stack/remote.d.mts +2 -0
- package/stack/remote.mjs +31 -11
- package/stack/server-key.d.mts +2 -0
- package/stack/server-key.mjs +1 -1
- package/stack/status.d.mts +49 -0
- package/stack/status.mjs +114 -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,9 +34,12 @@ 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';
|
|
36
45
|
import { urgentDeploy } from './urgent-floor.mjs';
|
|
@@ -49,6 +58,7 @@ export const DEFAULT_DEPS = Object.freeze({
|
|
|
49
58
|
verifyStack,
|
|
50
59
|
pruneStack,
|
|
51
60
|
releaseStore: createReleaseStore,
|
|
61
|
+
observedServices,
|
|
52
62
|
ssh: lazy('./remote.mjs', 'ownerSshRunner'),
|
|
53
63
|
floor: {},
|
|
54
64
|
});
|
|
@@ -136,10 +146,10 @@ function writeHoldOverride(config, out, database) {
|
|
|
136
146
|
`);
|
|
137
147
|
return file;
|
|
138
148
|
}
|
|
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) {
|
|
149
|
+
/** 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 ''. */
|
|
150
|
+
async function runSteps(plan, lines, steps, rec) {
|
|
141
151
|
const failed = await findInOrder(plan, async ([step, action]) => {
|
|
142
|
-
const result = await attempt(action);
|
|
152
|
+
const result = await rec.phase(step, () => attempt(action), (done) => done.ok);
|
|
143
153
|
lines.push(...result.lines);
|
|
144
154
|
steps.push({ step, ok: result.ok });
|
|
145
155
|
return !result.ok;
|
|
@@ -155,15 +165,15 @@ async function openRunner(ctx, config, options, d, cleanups) {
|
|
|
155
165
|
}
|
|
156
166
|
const openStore = async (ctx, config, options, d, cleanups) => d.releaseStore(config, await openRunner(ctx, config, options, d, cleanups));
|
|
157
167
|
/** 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) {
|
|
168
|
+
async function redeployStored(ctx, config, d, store, previous, out, steps, rec) {
|
|
159
169
|
const dir = path.join(out, 'previous');
|
|
160
170
|
const lines = [`ROLLING BACK to the previous accepted release ${previous}`];
|
|
161
171
|
const failed = await runSteps([
|
|
162
172
|
['rollback-fetch', async () => { await store.fetch(previous, dir); return { ok: true, lines: [] }; }],
|
|
163
173
|
['rollback-deploy', () => stackDeploy(ctx, config, path.join(dir, 'stack.yaml'))],
|
|
164
174
|
['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);
|
|
175
|
+
['rollback-converge', async () => { const converged = await d.convergeStack(ctx, config, { since: isoBefore(ctx, 30), timeoutSeconds: 600 }); snapshot(ctx, config, d, rec, 'rollback'); return converged; }],
|
|
176
|
+
], lines, steps, rec);
|
|
167
177
|
lines.push(failed ? 'ROLLBACK DID NOT CONVERGE: follow the stack runbook, Rollback' : `rolled back to ${previous}`);
|
|
168
178
|
return { lines, ok: !failed };
|
|
169
179
|
}
|
|
@@ -192,28 +202,103 @@ function prePull(ctx, images) {
|
|
|
192
202
|
}
|
|
193
203
|
return lines;
|
|
194
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* 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
|
|
207
|
+
* must not strand a rollback, so a target whose images no longer pull refuses the deploy before the services are touched.
|
|
208
|
+
*/
|
|
209
|
+
async function prePullRollbackTarget(ctx, store, previous, pulled) {
|
|
210
|
+
const target = await store.read(previous);
|
|
211
|
+
const images = [...new Set(Object.values(stackImages(target.stackYaml)))].filter((image) => IMAGE_REF.test(image) && !pulled.includes(image));
|
|
212
|
+
const lines = [];
|
|
213
|
+
for (const image of images) {
|
|
214
|
+
const run = tool(ctx, 'docker', ['pull', '--quiet', image]);
|
|
215
|
+
if (run.status !== 0)
|
|
216
|
+
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`);
|
|
217
|
+
lines.push(`pulled ${image} (rollback target ${previous})`);
|
|
218
|
+
}
|
|
219
|
+
return lines;
|
|
220
|
+
}
|
|
221
|
+
/** The live services into the recorder under `moment`; null (and no snapshot) when docker cannot be asked: a snapshot never fails a deploy. */
|
|
222
|
+
function snapshot(ctx, config, d, rec, moment) {
|
|
223
|
+
try {
|
|
224
|
+
const services = d.observedServices(ctx, config);
|
|
225
|
+
rec.snapshot(moment, services);
|
|
226
|
+
return services;
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Accepts `releaseId`: stamps the services observed now into its release record (`observed.accept`, what `stack status` compares against), stores it
|
|
234
|
+
* again, and moves the pointers. Without a snapshot the release is accepted as before.
|
|
235
|
+
*/
|
|
236
|
+
async function acceptObserved(ctx, config, d, store, rec, { out, releaseId }) {
|
|
237
|
+
const services = snapshot(ctx, config, d, rec, 'accept');
|
|
238
|
+
if (services) {
|
|
239
|
+
try {
|
|
240
|
+
const file = path.join(out, 'release.json');
|
|
241
|
+
const record = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
242
|
+
fs.writeFileSync(file, `${JSON.stringify({ ...record, observed: { at: new Date(ctx.now()).toISOString(), accept: services } }, null, 2)}
|
|
243
|
+
`);
|
|
244
|
+
await store.store(out, releaseId);
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
rec.warnings.push(`journal: the observed services were not stored in the release record (${firstLine(error.message)})`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
await store.accept(releaseId);
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* 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
|
|
254
|
+
* services drifted (Swarm rolled one back, a manual update) is deployed again; an unreadable swarm is deployed too.
|
|
255
|
+
*/
|
|
256
|
+
async function alreadyLive(ctx, config, d, store, sourceSha, lines) {
|
|
257
|
+
const current = await store.current();
|
|
258
|
+
if (!current)
|
|
259
|
+
return null;
|
|
260
|
+
try {
|
|
261
|
+
const stored = await store.read(current);
|
|
262
|
+
if (stored.release.sourceSha !== sourceSha)
|
|
263
|
+
return null;
|
|
264
|
+
const drift = compareWithRelease(stored, d.observedServices(ctx, config));
|
|
265
|
+
if (drift.length === 0)
|
|
266
|
+
return current;
|
|
267
|
+
lines.push(`release ${current} runs this commit, but ${drift.map((entry) => `${entry.service} (${entry.kind})`).join(', ')} differ: deploying again`);
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
lines.push(`release ${current} could not be compared with the live services: deploying`);
|
|
271
|
+
}
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
195
274
|
/** True when the deploy names the commit it deploys. */
|
|
196
275
|
const hasSourceSha = (options) => Boolean(options.sourceSha);
|
|
197
276
|
/** 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);
|
|
277
|
+
async function prepareRelease(ctx, config, options, d, out, seams) {
|
|
278
|
+
const { run, store, rec } = seams;
|
|
201
279
|
const previous = await store.current();
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
280
|
+
rec.note({ previous: previous || null });
|
|
281
|
+
const prepared = await rec.phase('render', async () => {
|
|
282
|
+
const rendered = await d.renderRelease(ctx, config, renderOptions(options, out, previous || null));
|
|
283
|
+
const inspected = inspectRendered(out, config);
|
|
284
|
+
await refuseEdgeCollisions(run, config, inspected.bytes);
|
|
285
|
+
return { rendered, ...inspected };
|
|
286
|
+
});
|
|
287
|
+
const { releaseId, images } = prepared;
|
|
288
|
+
rec.note({ releaseId });
|
|
289
|
+
const lines = [...(prepared.rendered.lines ?? [])];
|
|
290
|
+
const provisioned = await rec.phase('provision', () => d.provisionRelease(ctx, config, { execute: true, out, releaseFile: path.join(out, 'release.json') }), (done) => done.ok !== false);
|
|
207
291
|
lines.push(...(provisioned.lines ?? []));
|
|
208
292
|
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,
|
|
293
|
+
return { ok: false, lines, releaseId, previous, stepsDone: [{ step: 'render', ok: true }, { step: 'provision', ok: false }] };
|
|
294
|
+
await rec.phase('store', () => store.store(out, releaseId));
|
|
295
|
+
lines.push(...await rec.phase('pre-pull', async () => [...prePull(ctx, images), ...(previous ? await prePullRollbackTarget(ctx, store, previous, images) : [])]));
|
|
296
|
+
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
297
|
}
|
|
214
|
-
async function deployExecute(ctx, config, options, d, out,
|
|
215
|
-
const
|
|
216
|
-
const
|
|
298
|
+
async function deployExecute(ctx, config, options, d, out, seams) {
|
|
299
|
+
const { store, rec, run } = seams;
|
|
300
|
+
const prepared = await prepareRelease(ctx, config, options, d, out, seams);
|
|
301
|
+
const { releaseId, previous } = prepared;
|
|
217
302
|
const lines = [...prepared.lines];
|
|
218
303
|
const steps = [...prepared.stepsDone];
|
|
219
304
|
const data = { releaseId, previous: previous || null, steps, rolledBack: false };
|
|
@@ -225,37 +310,76 @@ async function deployExecute(ctx, config, options, d, out, cleanups) {
|
|
|
225
310
|
// 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
311
|
// healthchecks fail and update_config rolls them back), so the stack is first deployed with only the database running, the schema is
|
|
227
312
|
// bootstrapped and migrated, and then the real stack is deployed.
|
|
228
|
-
|
|
313
|
+
// 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.
|
|
314
|
+
const database = options.urgent === true ? null : await declaredDatabase(ctx, config);
|
|
315
|
+
const phase = database ? { ctx, config, database, f: { ...FLOOR_DEPS, ...d.floor }, run, dbUrlEnv: options.dbUrlEnv, backupOut: options.backupOut } : null;
|
|
316
|
+
const unknown = database ? unknownDatabaseService(config, database) : null;
|
|
317
|
+
if (unknown)
|
|
318
|
+
return { ok: false, refused: unknown, lines: [...lines, 'deploy not started; the services are untouched'], data: { ...data, refused: unknown.code } };
|
|
319
|
+
let first = options.firstStartStep && options.firstStartDb ? { step: options.firstStartStep, service: options.firstStartDb } : null;
|
|
320
|
+
let firstCode = null;
|
|
321
|
+
if (phase && !first && phase.database.target === 'stack' && !previous) {
|
|
322
|
+
const automatic = firstStartHooks(phase);
|
|
323
|
+
first = { service: automatic.service, step: async () => { const outcome = await automatic.step(); firstCode = outcome.code ?? null; return outcome; } };
|
|
324
|
+
}
|
|
325
|
+
if (phase && !first) {
|
|
326
|
+
const expanded = await rec.phase('migrate', () => expandPhase(phase), (done) => done.ok);
|
|
327
|
+
rec.note({ backup: expanded.record.backup, migrations: expanded.record.applied });
|
|
328
|
+
lines.push(...expanded.lines);
|
|
329
|
+
steps.push(...expanded.steps);
|
|
330
|
+
data.migrations = expanded.record;
|
|
331
|
+
if (!expanded.ok)
|
|
332
|
+
return { ok: false, refused: expanded.refused, lines: [...lines, 'deploy not started; the services are untouched'], data: { ...data, refused: expanded.refused?.code } };
|
|
333
|
+
}
|
|
334
|
+
const hold = first ? writeHoldOverride(config, out, first.service) : null;
|
|
229
335
|
const failed = await runSteps([
|
|
230
336
|
...(hold ? [['deploy-hold', () => stackDeploy(ctx, config, stackFile, [hold])]] : []),
|
|
231
|
-
...(
|
|
337
|
+
...(first ? [['first-start', first.step]] : []),
|
|
232
338
|
['deploy', () => stackDeploy(ctx, config, stackFile)],
|
|
233
339
|
['edge', async () => d.reconcileEdge(ctx, config, stackFile)],
|
|
234
|
-
['converge', () => d.convergeStack(ctx, config, { since, timeoutSeconds: options.timeoutSeconds })],
|
|
340
|
+
['converge', async () => { const converged = await d.convergeStack(ctx, config, { since, timeoutSeconds: options.timeoutSeconds }); snapshot(ctx, config, d, rec, 'converge'); return converged; }],
|
|
235
341
|
...(config.postDeploy.length > 0 ? [['post-deploy', () => d.postDeployStack(ctx, config)]] : []),
|
|
236
342
|
['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);
|
|
343
|
+
...(skipPublic ? [] : [['accept', async () => { await acceptObserved(ctx, config, d, store, rec, { out, releaseId }); return { ok: true, lines: [`accepted release ${releaseId}`] }; }]]),
|
|
344
|
+
], lines, steps, rec);
|
|
239
345
|
if (!failed && skipPublic) {
|
|
240
346
|
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 };
|
|
347
|
+
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
348
|
}
|
|
243
349
|
if (!failed) {
|
|
244
350
|
await housekeeping(ctx, config, d, store, { previous, out }, lines);
|
|
351
|
+
// The contract migrations run only now, with the release accepted and serving: the previous release is no longer the one the stack runs.
|
|
352
|
+
const contract = phase && !first ? await rec.phase('contract', () => contractPhase(phase), (done) => done.ok) : null;
|
|
353
|
+
if (contract) {
|
|
354
|
+
lines.push(...contract.lines);
|
|
355
|
+
rec.note({ migrations: [...(data.migrations?.applied ?? []), ...contract.applied] });
|
|
356
|
+
steps.push({ step: 'contract', ok: contract.ok });
|
|
357
|
+
data.migrations = { ...data.migrations, contractApplied: contract.applied };
|
|
358
|
+
}
|
|
359
|
+
if (contract && !contract.ok)
|
|
360
|
+
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
361
|
return { ok: true, lines: [...lines, `deployed release ${releaseId}`], data };
|
|
246
362
|
}
|
|
247
363
|
lines.push(`deploy failed at ${failed}`);
|
|
364
|
+
const migrations = data.migrations;
|
|
365
|
+
if (migrations?.applied?.length)
|
|
366
|
+
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`);
|
|
367
|
+
const stopped = firstCode ? { refused: { code: firstCode, message: 'the first start failed' } } : {};
|
|
248
368
|
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);
|
|
369
|
+
return { ok: false, lines: [...lines, 'no previous accepted release; services were rolled back by update_config where they had a previous spec'], data, ...stopped };
|
|
370
|
+
const back = await redeployStored(ctx, config, d, store, previous, out, steps, rec);
|
|
251
371
|
data.rolledBack = back.ok;
|
|
252
|
-
return { ok: false, lines: [...lines, ...back.lines], data };
|
|
372
|
+
return { ok: false, lines: [...lines, ...back.lines], data, ...stopped };
|
|
253
373
|
}
|
|
374
|
+
/** What starts an attempt: the owner's urgent deploy, a CI run (`--ci-run`) or a manual dispatch. */
|
|
375
|
+
const triggerOf = (options) => (options.urgent === true ? 'urgent' : options.ciRun ? 'ci' : 'dispatch');
|
|
254
376
|
/**
|
|
255
|
-
* Deploys a release: `options` = { images, sourceSha, execute, out, timeoutSeconds, retrySeconds, skipPublic, sshCmd, urgent, dbUrlEnv, backupOut }.
|
|
377
|
+
* Deploys a release: `options` = { images, sourceSha, execute, out, timeoutSeconds, retrySeconds, skipPublic, sshCmd, urgent, dbUrlEnv, backupOut, ciRun, redeploy }.
|
|
256
378
|
* skipPublic (the first deploy into a shared ingress, whose public routes exist only after `ingress publish`): the verification checks only what runs
|
|
257
379
|
* inside the stack, and the release stays PENDING (stored and deployed, pointer not moved) until `acceptRelease` has verified it in full.
|
|
258
|
-
*
|
|
380
|
+
* 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
|
|
381
|
+
* as `already-current` unless `redeploy` is set.
|
|
382
|
+
* Returns { ok, lines, data: { releaseId, previous, steps: [{ step, ok }], rolledBack, attempt } }.
|
|
259
383
|
*/
|
|
260
384
|
export async function deployRelease(ctx, config, options = {}, deps = {}) {
|
|
261
385
|
const d = { ...DEFAULT_DEPS, ...deps };
|
|
@@ -273,20 +397,44 @@ export async function deployRelease(ctx, config, options = {}, deps = {}) {
|
|
|
273
397
|
const dryRun = async () => {
|
|
274
398
|
const { rendered, releaseId } = await render();
|
|
275
399
|
const post = config.postDeploy.length > 0 ? ` post-deploy tasks (${config.postDeploy.join(', ')}),` : '';
|
|
400
|
+
const database = options.urgent === true ? null : await declaredDatabase(ctx, config);
|
|
401
|
+
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
402
|
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`;
|
|
403
|
+
? `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}`
|
|
404
|
+
: `dry run: release ${releaseId} would be provisioned, stored, pre-pulled, deployed, converged,${post} verified, accepted${databasePlan}`;
|
|
279
405
|
return { ok: true, lines: [...(rendered.lines ?? []), plan], data: { releaseId, steps: [{ step: 'render', ok: true }], executed: false } };
|
|
280
406
|
};
|
|
281
|
-
if (options.
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
407
|
+
if (options.execute !== true) {
|
|
408
|
+
if (options.urgent !== true)
|
|
409
|
+
return await dryRun();
|
|
410
|
+
const never = (what) => () => Promise.reject(new Error(`a dry run ${what}`));
|
|
411
|
+
return await urgentDeploy(ctx, config, options, { render, dryRun, openStore: never('opens no store'), deployExecute: never('deploys nothing'), sha256: fileSha256 }, d.floor);
|
|
285
412
|
}
|
|
286
|
-
if (options.
|
|
287
|
-
|
|
288
|
-
await
|
|
289
|
-
|
|
413
|
+
if (options.urgent !== true)
|
|
414
|
+
await render();
|
|
415
|
+
const run = await openRunner(ctx, config, options, d, cleanups);
|
|
416
|
+
const store = d.releaseStore(config, run);
|
|
417
|
+
const meta = {
|
|
418
|
+
kind: 'deploy', trigger: triggerOf(options), sourceSha: options.sourceSha, ciRun: options.ciRun,
|
|
419
|
+
urgent: options.urgent === true ? { role: ctx.env.STARCI_ROLE ?? 'unknown', reason: options.reason ?? '' } : undefined,
|
|
420
|
+
};
|
|
421
|
+
return await recordedAttempt(ctx, config, store, meta, { waitSeconds: options.lockWaitSeconds }, async (rec) => {
|
|
422
|
+
const notes = [];
|
|
423
|
+
const current = options.redeploy === true ? null : await rec.phase('idempotence', () => alreadyLive(ctx, config, d, store, options.sourceSha, notes));
|
|
424
|
+
if (current) {
|
|
425
|
+
rec.note({ releaseId: current });
|
|
426
|
+
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 } };
|
|
427
|
+
}
|
|
428
|
+
const seams = { run, store, rec };
|
|
429
|
+
const done = options.urgent === true
|
|
430
|
+
? await urgentDeploy(ctx, config, options, {
|
|
431
|
+
render, dryRun, openStore: async () => store, sha256: fileSha256,
|
|
432
|
+
deployExecute: (extra = {}) => deployExecute(ctx, config, { ...options, ...extra }, d, out, seams),
|
|
433
|
+
note: (record) => rec.note({ urgent: record }),
|
|
434
|
+
}, d.floor)
|
|
435
|
+
: await deployExecute(ctx, config, options, d, out, seams);
|
|
436
|
+
return { ...done, lines: [...notes, ...done.lines] };
|
|
437
|
+
});
|
|
290
438
|
}
|
|
291
439
|
finally {
|
|
292
440
|
cleanups.forEach((close) => close());
|
|
@@ -296,7 +444,7 @@ export async function deployRelease(ctx, config, options = {}, deps = {}) {
|
|
|
296
444
|
}
|
|
297
445
|
/**
|
|
298
446
|
* 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.
|
|
447
|
+
* `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
448
|
*/
|
|
301
449
|
export async function rollbackRelease(ctx, config, options = {}, deps = {}) {
|
|
302
450
|
const d = { ...DEFAULT_DEPS, ...deps };
|
|
@@ -305,38 +453,44 @@ export async function rollbackRelease(ctx, config, options = {}, deps = {}) {
|
|
|
305
453
|
throw refusal('STACK_RELEASE_ID', 'the release must be 16 lowercase hex characters');
|
|
306
454
|
const cleanups = [];
|
|
307
455
|
const store = await openStore(ctx, config, options, d, cleanups);
|
|
308
|
-
let dir = null;
|
|
309
456
|
try {
|
|
310
457
|
const target = release ?? await store.previous();
|
|
311
458
|
if (!target)
|
|
312
459
|
throw refusal('STACK_ROLLBACK_NO_TARGET', 'no previous accepted release is recorded; name one with --release');
|
|
313
460
|
if (options.execute !== true)
|
|
314
461
|
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
|
-
|
|
462
|
+
return await recordedAttempt(ctx, config, store, { kind: 'rollback', trigger: 'dispatch' }, { waitSeconds: options.lockWaitSeconds }, async (rec) => {
|
|
463
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'stack-rollback-'));
|
|
464
|
+
try {
|
|
465
|
+
rec.note({ releaseId: target, previous: await store.current() || null });
|
|
466
|
+
const lines = [];
|
|
467
|
+
const steps = [];
|
|
468
|
+
const since = isoBefore(ctx, 30);
|
|
469
|
+
const stackFile = path.join(scratch, 'stack.yaml');
|
|
470
|
+
const failed = await runSteps([
|
|
471
|
+
['fetch', async () => { await store.fetch(target, scratch); return { ok: true, lines: [] }; }],
|
|
472
|
+
['deploy', () => stackDeploy(ctx, config, stackFile)],
|
|
473
|
+
['edge', async () => d.reconcileEdge(ctx, config, stackFile)],
|
|
474
|
+
['converge', async () => { const converged = await d.convergeStack(ctx, config, { since, timeoutSeconds: options.timeoutSeconds ?? 600 }); snapshot(ctx, config, d, rec, 'converge'); return converged; }],
|
|
475
|
+
['verify', () => d.verifyStack(ctx, config, { retrySeconds: 180, stackFile })],
|
|
476
|
+
['accept', async () => { await acceptObserved(ctx, config, d, store, rec, { out: scratch, releaseId: target }); return { ok: true, lines: [] }; }],
|
|
477
|
+
], lines, steps, rec);
|
|
478
|
+
lines.push(failed ? `rollback to ${target} failed at ${failed}` : `rolled back to release ${target}; a rollback cannot undo data effects`);
|
|
479
|
+
return { ok: !failed, lines, data: { release: target, executed: true, steps } };
|
|
480
|
+
}
|
|
481
|
+
finally {
|
|
482
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
483
|
+
}
|
|
484
|
+
});
|
|
330
485
|
}
|
|
331
486
|
finally {
|
|
332
|
-
if (dir)
|
|
333
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
334
487
|
cleanups.forEach((close) => close());
|
|
335
488
|
}
|
|
336
489
|
}
|
|
337
490
|
/**
|
|
338
491
|
* 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 }.
|
|
492
|
+
* pointer moves and the superseded secrets and configs are pruned. `options` = { release, execute, retrySeconds, sshCmd }. With `execute` it is one recorded
|
|
493
|
+
* attempt under the deploy lock.
|
|
340
494
|
*/
|
|
341
495
|
export async function acceptRelease(ctx, config, options = {}, deps = {}) {
|
|
342
496
|
const d = { ...DEFAULT_DEPS, ...deps };
|
|
@@ -358,14 +512,25 @@ export async function acceptRelease(ctx, config, options = {}, deps = {}) {
|
|
|
358
512
|
return { ok: true, lines: [`release ${release} is already the accepted release`], data: { release, accepted: true, executed: options.execute === true } };
|
|
359
513
|
if (options.execute !== true)
|
|
360
514
|
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
|
-
|
|
515
|
+
return await recordedAttempt(ctx, config, store, { kind: 'accept', trigger: 'dispatch' }, { waitSeconds: options.lockWaitSeconds }, async (rec) => {
|
|
516
|
+
rec.note({ releaseId: release, previous: previous || null });
|
|
517
|
+
const verified = await rec.phase('verify', () => d.verifyStack(ctx, config, { retrySeconds: options.retrySeconds, stackFile: path.join(out, 'stack.yaml') }), (done) => done.ok);
|
|
518
|
+
const lines = [...(verified.lines ?? [])];
|
|
519
|
+
if (!verified.ok)
|
|
520
|
+
return { ok: false, lines: [...lines, `release ${release} is NOT accepted: the full verification failed`], data: { release, accepted: false, executed: true } };
|
|
521
|
+
await rec.phase('accept', () => acceptObserved(ctx, config, d, store, rec, { out, releaseId: release }));
|
|
522
|
+
lines.push(`accepted release ${release}`);
|
|
523
|
+
await housekeeping(ctx, config, d, store, { previous: previous || null, out }, lines);
|
|
524
|
+
const database = await declaredDatabase(ctx, config);
|
|
525
|
+
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;
|
|
526
|
+
if (contract) {
|
|
527
|
+
lines.push(...contract.lines);
|
|
528
|
+
rec.note({ migrations: contract.applied });
|
|
529
|
+
}
|
|
530
|
+
if (contract && !contract.ok)
|
|
531
|
+
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 } };
|
|
532
|
+
return { ok: true, lines, data: { release, accepted: true, executed: true, ...(contract ? { contractApplied: contract.applied } : {}) } };
|
|
533
|
+
});
|
|
369
534
|
}
|
|
370
535
|
finally {
|
|
371
536
|
fs.rmSync(out, { recursive: true, force: true });
|
|
@@ -0,0 +1,127 @@
|
|
|
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
|
+
/** `docker service inspect` snapshots by moment: `converge`, `rollback`, `accept`. */
|
|
51
|
+
observed: Record<string, ObservedService[]>;
|
|
52
|
+
outcome: Outcome;
|
|
53
|
+
error: {
|
|
54
|
+
code: string;
|
|
55
|
+
message: string;
|
|
56
|
+
} | null;
|
|
57
|
+
}
|
|
58
|
+
/** What an attempt is told about itself when it opens. */
|
|
59
|
+
export interface AttemptMeta {
|
|
60
|
+
kind: AttemptKind;
|
|
61
|
+
trigger: Trigger;
|
|
62
|
+
sourceSha?: string | undefined;
|
|
63
|
+
/** The id of the CI run (digits); with it the trigger is `ci`. */
|
|
64
|
+
ciRun?: string | undefined;
|
|
65
|
+
urgent?: {
|
|
66
|
+
role: string;
|
|
67
|
+
reason: string;
|
|
68
|
+
tag?: string | undefined;
|
|
69
|
+
} | undefined;
|
|
70
|
+
}
|
|
71
|
+
/** The facts an attempt learns while it runs. */
|
|
72
|
+
export interface Note {
|
|
73
|
+
releaseId?: string | null;
|
|
74
|
+
previous?: string | null;
|
|
75
|
+
backup?: Record<string, unknown> | null;
|
|
76
|
+
migrations?: string[];
|
|
77
|
+
urgent?: {
|
|
78
|
+
role: string;
|
|
79
|
+
reason: string;
|
|
80
|
+
tag?: string | undefined;
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** The open record of one attempt. */
|
|
84
|
+
export interface Recorder {
|
|
85
|
+
readonly id: string;
|
|
86
|
+
/** Journal failures after the first write; the caller prints them. */
|
|
87
|
+
readonly warnings: string[];
|
|
88
|
+
note(facts: Note): void;
|
|
89
|
+
/** Registers what runs at the start of every later phase (the lock refresh). */
|
|
90
|
+
beforePhase(next: () => Promise<void>): void;
|
|
91
|
+
/** 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). */
|
|
92
|
+
phase<T>(name: string, action: () => T | Promise<T>, passed?: (value: T) => boolean): Promise<T>;
|
|
93
|
+
snapshot(moment: string, services: ObservedService[]): void;
|
|
94
|
+
finish(outcome: Outcome, error?: {
|
|
95
|
+
code: string;
|
|
96
|
+
message: string;
|
|
97
|
+
} | null): Promise<void>;
|
|
98
|
+
}
|
|
99
|
+
/** A new attempt id: the UTC time and 8 random hex characters. */
|
|
100
|
+
export declare const newAttemptId: (ctx: Pick<StackContext, "now">) => string;
|
|
101
|
+
/**
|
|
102
|
+
* 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
|
|
103
|
+
* `STACK_DEPLOY_JOURNAL` (the manager cannot keep a record, so nothing is changed).
|
|
104
|
+
*/
|
|
105
|
+
export declare function openAttempt(ctx: StackContext, config: DeployConfig, store: ReleaseStore, meta: AttemptMeta): Promise<Recorder>;
|
|
106
|
+
/** What the lock wait takes. */
|
|
107
|
+
export interface LockWait {
|
|
108
|
+
waitSeconds?: number | undefined;
|
|
109
|
+
pollSeconds?: number | undefined;
|
|
110
|
+
}
|
|
111
|
+
/** The default wait for a held deploy lock (the design's ten minutes). */
|
|
112
|
+
export declare const LOCK_WAIT_SECONDS = 600;
|
|
113
|
+
/** A taken lock: refresh keeps it alive, release gives it back. */
|
|
114
|
+
export interface LockHold {
|
|
115
|
+
token: string;
|
|
116
|
+
refresh(): Promise<boolean>;
|
|
117
|
+
release(): Promise<boolean>;
|
|
118
|
+
}
|
|
119
|
+
/** Takes the deploy lock, waiting while another deploy holds it; `STACK_DEPLOY_LOCKED` when the wait ends. */
|
|
120
|
+
export declare function holdLock(ctx: StackContext, store: ReleaseStore, trigger: Trigger, wait?: LockWait): Promise<LockHold>;
|
|
121
|
+
/** How a finished result reads as an outcome. */
|
|
122
|
+
export declare function outcomeOf(result: ActionResult, kind: AttemptKind): Outcome;
|
|
123
|
+
/**
|
|
124
|
+
* Runs `body` as one recorded attempt under the deploy lock: journal opened, lock taken, `body(recorder)` run, outcome written, lock released. The result
|
|
125
|
+
* gains `data.attempt` (the attempt id) and the journal's warnings as lines; a throw is recorded as `refused` and rethrown.
|
|
126
|
+
*/
|
|
127
|
+
export declare function recordedAttempt(ctx: StackContext, config: DeployConfig, store: ReleaseStore, meta: AttemptMeta, wait: LockWait, body: (recorder: Recorder) => Promise<ActionResult>): Promise<ActionResult>;
|