@starci/hfs 4.13.0 → 4.15.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 +31 -0
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
- package/runtime/knowledge/hfs/rules.yaml +140 -2
- package/runtime/knowledge/hfs/slots.yaml +6 -2
- package/runtime/modules/kernel/failure-codes.yaml +5 -5
- package/runtime/modules/schemas/application-stacks.schema.yaml +22 -0
- package/runtime/scripts/hfs/declaration-shape.mjs +4 -2
- package/runtime/scripts/hfs/manifest-shape.d.mts +6 -0
- package/runtime/scripts/hfs/rule-params-shape.d.mts +5 -0
- package/runtime/scripts/hfs/rule-params-shape.mjs +7 -2
- package/runtime/scripts/hfs/slots.d.mts +2 -1
- package/runtime/scripts/hfs/slots.mjs +15 -5
- package/runtime/scripts/hfs/stack-deploy-config.d.mts +12 -0
- package/runtime/scripts/hfs/stack-deploy-config.mjs +22 -1
- package/src/stack-records-double.d.mts +3 -1
- package/stack/ci-key.d.mts +11 -0
- package/stack/ci-key.mjs +44 -0
- package/stack/cli.d.mts +5 -1
- package/stack/cli.mjs +9 -6
- package/stack/config.d.mts +9 -0
- package/stack/deploy.d.mts +4 -1
- package/stack/deploy.mjs +10 -5
- package/stack/journal.d.mts +19 -0
- package/stack/journal.mjs +11 -2
- package/stack/notify.d.mts +9 -0
- package/stack/notify.mjs +32 -0
- package/stack/release-store.d.mts +10 -6
- package/stack/release-store.mjs +40 -37
- package/stack/remote.mjs +5 -4
- package/stack/rollback-guard.d.mts +10 -0
- package/stack/rollback-guard.mjs +41 -0
- package/stack/ssh-gate.d.mts +35 -0
- package/stack/ssh-gate.mjs +145 -0
- package/stack/status.d.mts +3 -1
- package/stack/status.mjs +2 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/database.client.spec.ts +25 -6
- package/templates/be/skeleton-lite/src/modules/platform/database/database.client.ts +13 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ReleaseStore, StoredRelease } from '../stack/release-store.mjs';
|
|
2
2
|
/** The records half of a ReleaseStore, plus what it kept. */
|
|
3
|
-
export interface RecordsDouble extends Pick<ReleaseStore, 'read' | 'tryLock' | 'refreshLock' | 'unlock' | 'journal'> {
|
|
3
|
+
export interface RecordsDouble extends Pick<ReleaseStore, 'read' | 'tryLock' | 'refreshLock' | 'unlock' | 'journal' | 'journals'> {
|
|
4
4
|
/** The latest document of each attempt, as the journal wrote it (a copy at each write). */
|
|
5
5
|
documents: Map<string, Record<string, unknown>>;
|
|
6
6
|
/** Every write of every document, in order: the phase boundaries a cancelled job would have left. */
|
|
@@ -25,6 +25,8 @@ export interface RecordsOptions {
|
|
|
25
25
|
freeAfterTries?: number;
|
|
26
26
|
} | undefined;
|
|
27
27
|
failJournal?: boolean | undefined;
|
|
28
|
+
/** The attempt documents the journal already holds (what journals() answers): the evidence of the manual rollback guard. */
|
|
29
|
+
history?: Record<string, unknown>[] | undefined;
|
|
28
30
|
/** The journal accepts this many document writes and fails every later one (a manager that drops mid-deploy). */
|
|
29
31
|
failJournalAfter?: number | undefined;
|
|
30
32
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { DeployConfig } from './config.mjs';
|
|
2
|
+
import type { StackContext } from './context.mjs';
|
|
3
|
+
import type { ActionResult } from './remote.mjs';
|
|
4
|
+
/** What the verb takes. */
|
|
5
|
+
export interface CiKeyOptions {
|
|
6
|
+
publicKey?: string | null | undefined;
|
|
7
|
+
printAuthorizedKeys?: boolean | undefined;
|
|
8
|
+
printGate?: boolean | undefined;
|
|
9
|
+
}
|
|
10
|
+
/** Prints the gate, the authorized_keys line or the install steps (see the top of the file). */
|
|
11
|
+
export declare function ciKey(_ctx: StackContext, config: DeployConfig, options?: CiKeyOptions): Promise<ActionResult>;
|
package/stack/ci-key.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// ci-key.mjs - `starci app stack ci-key`: the owner-run side of the forced-command gate of the CI SSH key (ssh-gate.mts).
|
|
2
|
+
// --print-gate prints the text of starci-ssh-gate, to install executable as /usr/local/bin/starci-ssh-gate on the manager
|
|
3
|
+
// --print-authorized-keys prints the authorized_keys line of the CI key from its PUBLIC key file (--public-key <file>)
|
|
4
|
+
// Both print only their text, so they pipe straight into the owner's ssh. Nothing here reaches a server, reads a sealed member or touches a private
|
|
5
|
+
// key: creating, installing and rotating the key stay owner-run steps. Without a flag the verb prints the install steps.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import { refusal } from './errors.mjs';
|
|
8
|
+
import { GATE_PATH, authorizedKeysLine, gateScript } from './ssh-gate.mjs';
|
|
9
|
+
const lineOf = (file) => {
|
|
10
|
+
if (!file)
|
|
11
|
+
throw refusal('STACK_USAGE', '--print-authorized-keys needs --public-key <the .pub file of the CI key>');
|
|
12
|
+
let text;
|
|
13
|
+
try {
|
|
14
|
+
text = fs.readFileSync(file, 'utf8');
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw refusal('STACK_CI_KEY_PUBLIC', `cannot read the public key file ${file}`);
|
|
18
|
+
}
|
|
19
|
+
return authorizedKeysLine(text);
|
|
20
|
+
};
|
|
21
|
+
/** Prints the gate, the authorized_keys line or the install steps (see the top of the file). */
|
|
22
|
+
export async function ciKey(_ctx, config, options = {}) {
|
|
23
|
+
if (options.printGate === true && options.printAuthorizedKeys === true)
|
|
24
|
+
throw refusal('STACK_USAGE', 'print the gate or the authorized_keys line, one at a time');
|
|
25
|
+
if (options.printGate === true)
|
|
26
|
+
return { ok: true, lines: [gateScript().trimEnd()], data: { gatePath: GATE_PATH } };
|
|
27
|
+
if (options.printAuthorizedKeys === true) {
|
|
28
|
+
const line = lineOf(options.publicKey);
|
|
29
|
+
return { ok: true, lines: [line], data: { authorizedKeys: line } };
|
|
30
|
+
}
|
|
31
|
+
const env = config.env;
|
|
32
|
+
return {
|
|
33
|
+
ok: true,
|
|
34
|
+
lines: [
|
|
35
|
+
'the CI key reaches Docker, which is root on the manager: its authorized_keys line forces starci-ssh-gate, which admits docker system dial-stdio and the release store\'s named operations only.',
|
|
36
|
+
'owner-run steps on the manager (as root; <ops> is your own ssh access):',
|
|
37
|
+
` 1. starci app stack ci-key --env ${env} --print-gate | ssh <ops> 'install -m 0755 -o root -g root /dev/stdin ${GATE_PATH}'`,
|
|
38
|
+
` 2. starci app stack ci-key --env ${env} --public-key <ci-key.pub> --print-authorized-keys | ssh <ops> 'cat >> ~<ci-account>/.ssh/authorized_keys'`,
|
|
39
|
+
' 3. seal VPS_GATE=1 in the vps-ci member of the environment, so `stack connect` tells the deploy to use the gate (STARCI_SSH_GATE=1); remove it to go back.',
|
|
40
|
+
'not yet admitted by the gate: the post-deploy tasks and the manager backup/decrypt of a stack database run free commands; keep those environments on an ungated key until they are named operations.',
|
|
41
|
+
],
|
|
42
|
+
data: { gatePath: GATE_PATH },
|
|
43
|
+
};
|
|
44
|
+
}
|
package/stack/cli.d.mts
CHANGED
|
@@ -15,6 +15,10 @@ export interface Flags {
|
|
|
15
15
|
urgent?: boolean;
|
|
16
16
|
'first-start'?: boolean;
|
|
17
17
|
redeploy?: boolean;
|
|
18
|
+
'data-acknowledged'?: boolean;
|
|
19
|
+
'print-authorized-keys'?: boolean;
|
|
20
|
+
'print-gate'?: boolean;
|
|
21
|
+
'public-key'?: string;
|
|
18
22
|
image?: string[];
|
|
19
23
|
keep?: string[];
|
|
20
24
|
'extra-compose'?: string[];
|
|
@@ -36,7 +40,7 @@ export interface Flags {
|
|
|
36
40
|
remote?: string;
|
|
37
41
|
'ci-run'?: string;
|
|
38
42
|
}
|
|
39
|
-
export declare const ACTIONS: readonly ["prepare", "connect", "disconnect", "build-args", "render", "deploy", "converge", "verify", "rollback", "prune", "backup", "jobs", "task", "ingress", "retire", "build", "accept", "status"];
|
|
43
|
+
export declare const ACTIONS: readonly ["prepare", "connect", "disconnect", "build-args", "render", "deploy", "converge", "verify", "rollback", "prune", "backup", "jobs", "task", "ingress", "retire", "build", "accept", "status", "ci-key"];
|
|
40
44
|
/** One action of `starci app stack`. */
|
|
41
45
|
export type Action = (typeof ACTIONS)[number];
|
|
42
46
|
/** True when `value` names one action of `starci app stack`. */
|
package/stack/cli.mjs
CHANGED
|
@@ -20,11 +20,11 @@ import { parseTargetBinding, bindStackTarget, nativeToolEnvironment } from './ta
|
|
|
20
20
|
import { oneCommand, prepareUrgentRelease } from './urgent-release.mjs';
|
|
21
21
|
/** The actions that drive the docker CLI of the operator machine (pointed at the manager by docker-transport.mjs when the key is sealed). */
|
|
22
22
|
const DOCKER_ACTIONS = new Set(['deploy', 'accept', 'converge', 'verify', 'rollback', 'prune', 'status']);
|
|
23
|
-
export const ACTIONS = Object.freeze(['prepare', 'connect', 'disconnect', 'build-args', 'render', 'deploy', 'converge', 'verify', 'rollback', 'prune', 'backup', 'jobs', 'task', 'ingress', 'retire', 'build', 'accept', 'status']);
|
|
23
|
+
export const ACTIONS = Object.freeze(['prepare', 'connect', 'disconnect', 'build-args', 'render', 'deploy', 'converge', 'verify', 'rollback', 'prune', 'backup', 'jobs', 'task', 'ingress', 'retire', 'build', 'accept', 'status', 'ci-key']);
|
|
24
24
|
/** True when `value` names one action of `starci app stack`. */
|
|
25
25
|
export const isAction = (value) => ACTIONS.includes(value);
|
|
26
|
-
export const BOOLEAN = new Set(['--execute', '--json', '--swarm-state', '--init-swarm', '--owner-approved', '--fix-live-restore', '--skip-public', '--skip-internal', '--allow-unpinned', '--push', '--urgent', '--first-start', '--redeploy']);
|
|
27
|
-
export const VALUE = new Set(['--env', '--image', '--sha', '--previous', '--out', '--release', '--since', '--timeout', '--retry', '--keep', '--app', '--ssh-cmd', '--extra-compose', '--project', '--target-binding', '--db-url-env', '--backup-out', '--db-service', '--reason', '--remote', '--ci-run']);
|
|
26
|
+
export const BOOLEAN = new Set(['--execute', '--json', '--swarm-state', '--init-swarm', '--owner-approved', '--fix-live-restore', '--skip-public', '--skip-internal', '--allow-unpinned', '--push', '--urgent', '--first-start', '--redeploy', '--data-acknowledged', '--print-authorized-keys', '--print-gate']);
|
|
27
|
+
export const VALUE = new Set(['--env', '--image', '--sha', '--previous', '--out', '--release', '--since', '--timeout', '--retry', '--keep', '--app', '--ssh-cmd', '--extra-compose', '--project', '--target-binding', '--db-url-env', '--backup-out', '--db-service', '--reason', '--remote', '--ci-run', '--public-key']);
|
|
28
28
|
export const REPEAT = new Set(['--image', '--keep', '--extra-compose', '--project']);
|
|
29
29
|
/** `--ssh-cmd` replaces the configured alias for an action whose module (or the Docker transport) reaches the manager over SSH. */
|
|
30
30
|
const SSH = ['--ssh-cmd'];
|
|
@@ -41,7 +41,7 @@ export const FLAGS = Object.freeze({
|
|
|
41
41
|
deploy: ['--image', '--sha', '--execute', '--out', '--timeout', '--retry', '--skip-public', '--target-binding', '--urgent', '--reason', '--remote', '--db-url-env', '--backup-out', '--first-start', '--db-service', '--ci-run', '--redeploy', ...SSH],
|
|
42
42
|
converge: ['--since', '--timeout', ...SSH],
|
|
43
43
|
verify: ['--retry', '--skip-public', '--skip-internal', '--target-binding', ...SSH],
|
|
44
|
-
rollback: ['--release', '--execute', '--timeout', ...SSH],
|
|
44
|
+
rollback: ['--release', '--execute', '--timeout', '--data-acknowledged', ...SSH],
|
|
45
45
|
prune: ['--keep', '--execute', ...SSH],
|
|
46
46
|
backup: ['--swarm-state', '--execute', ...SSH],
|
|
47
47
|
jobs: ['--execute', ...SSH],
|
|
@@ -51,11 +51,12 @@ export const FLAGS = Object.freeze({
|
|
|
51
51
|
build: ['--push', '--execute'],
|
|
52
52
|
accept: ['--release', '--execute', '--retry', '--db-url-env', ...SSH],
|
|
53
53
|
status: [...SSH],
|
|
54
|
+
'ci-key': ['--public-key', '--print-authorized-keys', '--print-gate'],
|
|
54
55
|
});
|
|
55
56
|
/** The operands of the actions that take any, as the usage prints them. */
|
|
56
57
|
export const OPERANDS = Object.freeze({ jobs: '<action> [<arg>...]', task: '<name> [<arg>...]', ingress: '<action> [<arg>...]' });
|
|
57
58
|
/** The read-only actions, whose `--env` defaults to production. Every action with an effect names its environment, so none reaches production by omission. */
|
|
58
|
-
export const READ_ONLY = Object.freeze(['build-args', 'render', 'verify', 'status']);
|
|
59
|
+
export const READ_ONLY = Object.freeze(['build-args', 'render', 'verify', 'status', 'ci-key']);
|
|
59
60
|
/** Splits argv into { flags, positional }; an unknown flag, a flag of another action or a flag without its value is a refusal. */
|
|
60
61
|
function parseFlags(action, argv) {
|
|
61
62
|
const own = new Set(['--env', '--json', ...FLAGS[action]]);
|
|
@@ -137,13 +138,14 @@ const OPTIONS = {
|
|
|
137
138
|
}),
|
|
138
139
|
converge: ({ flags }) => ({ since: flags.since, timeoutSeconds: number(flags, 'timeout') }),
|
|
139
140
|
verify: ({ flags }) => ({ retrySeconds: number(flags, 'retry'), skipPublic: flags['skip-public'] === true, skipInternal: flags['skip-internal'] === true }),
|
|
140
|
-
rollback: ({ flags }) => ({ release: flags.release ?? null, execute: flags.execute === true, timeoutSeconds: number(flags, 'timeout') }),
|
|
141
|
+
rollback: ({ flags }) => ({ release: flags.release ?? null, execute: flags.execute === true, timeoutSeconds: number(flags, 'timeout'), dataAcknowledged: flags['data-acknowledged'] === true }),
|
|
141
142
|
prune: ({ flags, cwd }) => ({ keepFiles: (flags.keep ?? []).map((file) => path.resolve(cwd, file)), execute: flags.execute === true }),
|
|
142
143
|
backup: ({ flags }) => ({ swarmState: flags['swarm-state'] === true, execute: flags.execute === true }),
|
|
143
144
|
ingress: ({ flags, positional }) => ({ action: positional[0], execute: flags.execute === true, args: positional.slice(1) }),
|
|
144
145
|
build: ({ flags }) => ({ push: flags.push === true, execute: flags.execute === true }),
|
|
145
146
|
accept: ({ flags }) => ({ release: flags.release ?? null, execute: flags.execute === true, retrySeconds: number(flags, 'retry'), dbUrlEnv: flags['db-url-env'] }),
|
|
146
147
|
status: () => ({}),
|
|
148
|
+
'ci-key': ({ flags, cwd }) => ({ publicKey: flags['public-key'] ? path.resolve(cwd, flags['public-key']) : null, printAuthorizedKeys: flags['print-authorized-keys'] === true, printGate: flags['print-gate'] === true }),
|
|
147
149
|
retire: ({ flags }) => ({ projects: flags.project ?? [], execute: flags.execute === true }),
|
|
148
150
|
jobs: ({ flags, positional }) => ({ action: positional[0], args: positional.slice(1), execute: flags.execute === true }),
|
|
149
151
|
task: ({ flags, positional }) => ({ name: positional[0], args: positional.slice(1), execute: flags.execute === true }),
|
|
@@ -160,6 +162,7 @@ const MODULES = {
|
|
|
160
162
|
build: ['./build.mjs', 'buildImages'],
|
|
161
163
|
accept: ['./deploy.mjs', 'acceptRelease'],
|
|
162
164
|
status: ['./status.mjs', 'statusStack'],
|
|
165
|
+
'ci-key': ['./ci-key.mjs', 'ciKey'],
|
|
163
166
|
};
|
|
164
167
|
async function handlerOf(action, importer) {
|
|
165
168
|
const [file, name] = MODULES[action];
|
package/stack/config.d.mts
CHANGED
|
@@ -84,6 +84,14 @@ export interface BackupBlock {
|
|
|
84
84
|
dir: string | null;
|
|
85
85
|
volumes: BackupVolume[];
|
|
86
86
|
}
|
|
87
|
+
/** The normalised `deploy.notify` block: the sealed webhook URL and the outcomes announced. */
|
|
88
|
+
export interface NotifyBlock {
|
|
89
|
+
webhook: {
|
|
90
|
+
member: string;
|
|
91
|
+
key: string;
|
|
92
|
+
};
|
|
93
|
+
on: string[];
|
|
94
|
+
}
|
|
87
95
|
export interface RegistryBlock {
|
|
88
96
|
host: string;
|
|
89
97
|
visibility: string;
|
|
@@ -172,6 +180,7 @@ export interface DeployBlock {
|
|
|
172
180
|
tasks: Record<string, TaskEntry>;
|
|
173
181
|
postDeploy: string[];
|
|
174
182
|
backup: BackupBlock | null;
|
|
183
|
+
notify: NotifyBlock | null;
|
|
175
184
|
ingress: IngressConfig | null;
|
|
176
185
|
registry: RegistryBlock;
|
|
177
186
|
}
|
package/stack/deploy.d.mts
CHANGED
|
@@ -41,6 +41,7 @@ export interface RollbackOptions {
|
|
|
41
41
|
timeoutSeconds?: number | undefined;
|
|
42
42
|
sshCmd?: string | null | undefined;
|
|
43
43
|
lockWaitSeconds?: number | undefined;
|
|
44
|
+
dataAcknowledged?: boolean | undefined;
|
|
44
45
|
}
|
|
45
46
|
/** What an accept takes. */
|
|
46
47
|
export interface AcceptOptions {
|
|
@@ -70,7 +71,9 @@ export interface DeployDeps {
|
|
|
70
71
|
keepFiles: string[];
|
|
71
72
|
execute: boolean;
|
|
72
73
|
}) => Promise<ActionResult>;
|
|
73
|
-
releaseStore: (config: DeployConfig, ssh: Runner
|
|
74
|
+
releaseStore: (config: DeployConfig, ssh: Runner, options?: {
|
|
75
|
+
gated?: boolean | undefined;
|
|
76
|
+
}) => ReleaseStore;
|
|
74
77
|
observedServices: (ctx: StackContext, config: DeployConfig) => ObservedService[];
|
|
75
78
|
ssh: (ctx: StackContext, config: DeployConfig, options: SshOptions) => Runner | Promise<Runner>;
|
|
76
79
|
floor: Partial<FloorDeps>;
|
package/stack/deploy.mjs
CHANGED
|
@@ -42,6 +42,8 @@ import { pruneStack } from './prune.mjs';
|
|
|
42
42
|
import { compareWithRelease, observedServices, stackImages } from './status.mjs';
|
|
43
43
|
import { parseYaml } from '../runtime/engine/yaml.mjs';
|
|
44
44
|
import { createReleaseStore } from './release-store.mjs';
|
|
45
|
+
import { contractFinding, enforceContractGuard } from './rollback-guard.mjs';
|
|
46
|
+
import { gateIsOn } from './ssh-gate.mjs';
|
|
45
47
|
import { urgentDeploy } from './urgent-floor.mjs';
|
|
46
48
|
import { verifyStack } from './verify.mjs';
|
|
47
49
|
/** Loads `file` (relative to this module). Split out so a spec can load a real sibling module through `lazy`. */
|
|
@@ -163,7 +165,7 @@ async function openRunner(ctx, config, options, d, cleanups) {
|
|
|
163
165
|
cleanups.push(() => run.cleanup?.());
|
|
164
166
|
return run;
|
|
165
167
|
}
|
|
166
|
-
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) });
|
|
167
169
|
/** Redeploys a stored release after a failed deploy: fetch, stack deploy, converge. Returns the lines to print. */
|
|
168
170
|
async function redeployStored(ctx, config, d, store, previous, out, steps, rec) {
|
|
169
171
|
const dir = path.join(out, 'previous');
|
|
@@ -352,7 +354,7 @@ async function deployExecute(ctx, config, options, d, out, seams) {
|
|
|
352
354
|
const contract = phase && !first ? await rec.phase('contract', () => contractPhase(phase), (done) => done.ok) : null;
|
|
353
355
|
if (contract) {
|
|
354
356
|
lines.push(...contract.lines);
|
|
355
|
-
rec.note({ migrations: [...(data.migrations?.applied ?? []), ...contract.applied] });
|
|
357
|
+
rec.note({ migrations: [...(data.migrations?.applied ?? []), ...contract.applied], contract: contract.applied });
|
|
356
358
|
steps.push({ step: 'contract', ok: contract.ok });
|
|
357
359
|
data.migrations = { ...data.migrations, contractApplied: contract.applied };
|
|
358
360
|
}
|
|
@@ -413,7 +415,7 @@ export async function deployRelease(ctx, config, options = {}, deps = {}) {
|
|
|
413
415
|
if (options.urgent !== true)
|
|
414
416
|
await render();
|
|
415
417
|
const run = await openRunner(ctx, config, options, d, cleanups);
|
|
416
|
-
const store = d.releaseStore(config, run);
|
|
418
|
+
const store = d.releaseStore(config, run, { gated: gateIsOn(ctx.env) });
|
|
417
419
|
const meta = {
|
|
418
420
|
kind: 'deploy', trigger: triggerOf(options), sourceSha: options.sourceSha, ciRun: options.ciRun,
|
|
419
421
|
urgent: options.urgent === true ? { role: ctx.env.STARCI_ROLE ?? 'unknown', reason: options.reason ?? '' } : undefined,
|
|
@@ -457,8 +459,10 @@ export async function rollbackRelease(ctx, config, options = {}, deps = {}) {
|
|
|
457
459
|
const target = release ?? await store.previous();
|
|
458
460
|
if (!target)
|
|
459
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);
|
|
460
464
|
if (options.execute !== true)
|
|
461
|
-
return { ok: true, lines: [`dry run: would redeploy release ${target}`], data: { release: target, executed: false } };
|
|
465
|
+
return { ok: true, lines: [...notice, `dry run: would redeploy release ${target}`], data: { release: target, executed: false } };
|
|
462
466
|
return await recordedAttempt(ctx, config, store, { kind: 'rollback', trigger: 'dispatch' }, { waitSeconds: options.lockWaitSeconds }, async (rec) => {
|
|
463
467
|
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'stack-rollback-'));
|
|
464
468
|
try {
|
|
@@ -475,6 +479,7 @@ export async function rollbackRelease(ctx, config, options = {}, deps = {}) {
|
|
|
475
479
|
['verify', () => d.verifyStack(ctx, config, { retrySeconds: 180, stackFile })],
|
|
476
480
|
['accept', async () => { await acceptObserved(ctx, config, d, store, rec, { out: scratch, releaseId: target }); return { ok: true, lines: [] }; }],
|
|
477
481
|
], lines, steps, rec);
|
|
482
|
+
lines.unshift(...notice);
|
|
478
483
|
lines.push(failed ? `rollback to ${target} failed at ${failed}` : `rolled back to release ${target}; a rollback cannot undo data effects`);
|
|
479
484
|
return { ok: !failed, lines, data: { release: target, executed: true, steps } };
|
|
480
485
|
}
|
|
@@ -525,7 +530,7 @@ export async function acceptRelease(ctx, config, options = {}, deps = {}) {
|
|
|
525
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;
|
|
526
531
|
if (contract) {
|
|
527
532
|
lines.push(...contract.lines);
|
|
528
|
-
rec.note({ migrations: contract.applied });
|
|
533
|
+
rec.note({ migrations: contract.applied, contract: contract.applied });
|
|
529
534
|
}
|
|
530
535
|
if (contract && !contract.ok)
|
|
531
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 } };
|
package/stack/journal.d.mts
CHANGED
|
@@ -47,6 +47,8 @@ export interface AttemptDocument {
|
|
|
47
47
|
backup: Record<string, unknown> | null;
|
|
48
48
|
/** Reserved for the migrate and contract phases: the names of the migrations that were applied. */
|
|
49
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[];
|
|
50
52
|
/** `docker service inspect` snapshots by moment: `converge`, `rollback`, `accept`. */
|
|
51
53
|
observed: Record<string, ObservedService[]>;
|
|
52
54
|
outcome: Outcome;
|
|
@@ -55,6 +57,20 @@ export interface AttemptDocument {
|
|
|
55
57
|
message: string;
|
|
56
58
|
} | null;
|
|
57
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
|
+
}
|
|
58
74
|
/** What an attempt is told about itself when it opens. */
|
|
59
75
|
export interface AttemptMeta {
|
|
60
76
|
kind: AttemptKind;
|
|
@@ -74,6 +90,7 @@ export interface Note {
|
|
|
74
90
|
previous?: string | null;
|
|
75
91
|
backup?: Record<string, unknown> | null;
|
|
76
92
|
migrations?: string[];
|
|
93
|
+
contract?: string[];
|
|
77
94
|
urgent?: {
|
|
78
95
|
role: string;
|
|
79
96
|
reason: string;
|
|
@@ -95,6 +112,8 @@ export interface Recorder {
|
|
|
95
112
|
code: string;
|
|
96
113
|
message: string;
|
|
97
114
|
} | null): Promise<void>;
|
|
115
|
+
/** The facts of the attempt as it stands (call after `finish` for a notification). */
|
|
116
|
+
summary(): AttemptSummary;
|
|
98
117
|
}
|
|
99
118
|
/** A new attempt id: the UTC time and 8 random hex characters. */
|
|
100
119
|
export declare const newAttemptId: (ctx: Pick<StackContext, "now">) => string;
|
package/stack/journal.mjs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { randomBytes } from 'node:crypto';
|
|
12
12
|
import { firstLine } from './context.mjs';
|
|
13
13
|
import { StackError, refusal } from './errors.mjs';
|
|
14
|
+
import { notifyOutcome } from './notify.mjs';
|
|
14
15
|
const REASON_MAX = 500;
|
|
15
16
|
const SAFE = /[^A-Za-z0-9._/:@ -]/gu;
|
|
16
17
|
const isoOf = (ctx) => new Date(ctx.now()).toISOString();
|
|
@@ -39,7 +40,7 @@ export async function openAttempt(ctx, config, store, meta) {
|
|
|
39
40
|
schema: 'starci/stack-deploy-attempt@1', attempt: id, kind: meta.kind, stack: config.stack, env: config.env, startedAt: isoOf(ctx), endedAt: null,
|
|
40
41
|
trigger: meta.trigger, sourceSha: meta.sourceSha ?? null, tag: facts.tag, ci: facts.ci,
|
|
41
42
|
urgent: meta.urgent ? { role: clean(meta.urgent.role, 40), reason: meta.urgent.reason.slice(0, REASON_MAX), tag: meta.urgent.tag ?? null } : null,
|
|
42
|
-
releaseId: null, previous: null, phases: [], backup: null, migrations: [], observed: {}, outcome: 'running', error: null,
|
|
43
|
+
releaseId: null, previous: null, phases: [], backup: null, migrations: [], contract: [], observed: {}, outcome: 'running', error: null,
|
|
43
44
|
};
|
|
44
45
|
const warnings = [];
|
|
45
46
|
let beat = async () => { };
|
|
@@ -70,6 +71,8 @@ export async function openAttempt(ctx, config, store, meta) {
|
|
|
70
71
|
document.backup = found.backup;
|
|
71
72
|
if (found.migrations !== undefined)
|
|
72
73
|
document.migrations = found.migrations;
|
|
74
|
+
if (found.contract !== undefined)
|
|
75
|
+
document.contract = found.contract;
|
|
73
76
|
if (found.urgent)
|
|
74
77
|
document.urgent = { role: clean(found.urgent.role, 40), reason: found.urgent.reason.slice(0, REASON_MAX), tag: found.urgent.tag ?? null };
|
|
75
78
|
},
|
|
@@ -93,6 +96,10 @@ export async function openAttempt(ctx, config, store, meta) {
|
|
|
93
96
|
}
|
|
94
97
|
},
|
|
95
98
|
snapshot(moment, services) { document.observed[moment] = services; },
|
|
99
|
+
summary: () => ({
|
|
100
|
+
attempt: id, kind: document.kind, stack: document.stack, env: document.env, outcome: document.outcome, trigger: document.trigger,
|
|
101
|
+
sourceSha: document.sourceSha, tag: document.tag, releaseId: document.releaseId, ciUrl: document.ci?.url ?? null, errorCode: document.error?.code ?? null,
|
|
102
|
+
}),
|
|
96
103
|
async finish(outcome, error = null) {
|
|
97
104
|
document.outcome = outcome;
|
|
98
105
|
document.endedAt = isoOf(ctx);
|
|
@@ -165,10 +172,12 @@ export async function recordedAttempt(ctx, config, store, meta, wait, body) {
|
|
|
165
172
|
});
|
|
166
173
|
const result = await body(recorder);
|
|
167
174
|
await recorder.finish(outcomeOf(result, meta.kind), result.refused ? { code: result.refused.code, message: result.refused.message.slice(0, 300) } : null);
|
|
168
|
-
|
|
175
|
+
const notice = await notifyOutcome(ctx, config, recorder.summary());
|
|
176
|
+
return { ...result, lines: [...result.lines, ...recorder.warnings, ...notice], data: { ...result.data, attempt: recorder.id } };
|
|
169
177
|
}
|
|
170
178
|
catch (error) {
|
|
171
179
|
await recorder.finish('refused', errorOf(error));
|
|
180
|
+
await notifyOutcome(ctx, config, recorder.summary());
|
|
172
181
|
throw error;
|
|
173
182
|
}
|
|
174
183
|
finally {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { DeployConfig } from './config.mjs';
|
|
2
|
+
import type { StackContext } from './context.mjs';
|
|
3
|
+
import type { AttemptSummary } from './journal.mjs';
|
|
4
|
+
/** The one-line text a chat webhook shows. */
|
|
5
|
+
export declare function notifyText(summary: AttemptSummary): string;
|
|
6
|
+
/** The JSON body: `text` (Slack style) and `content` (Discord style) carry the line, the rest is the same facts as fields. */
|
|
7
|
+
export declare function notifyBody(summary: AttemptSummary): Record<string, unknown>;
|
|
8
|
+
/** Sends the message for a finished attempt when `deploy.notify` is declared and lists its outcome; returns the lines to print (empty when nothing was sent or it went well). */
|
|
9
|
+
export declare function notifyOutcome(ctx: StackContext, config: Pick<DeployConfig, 'notify' | 'secretsDir'>, summary: AttemptSummary): Promise<string[]>;
|
package/stack/notify.mjs
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { openMember } from './sealed.mjs';
|
|
2
|
+
const TIMEOUT_MS = 10_000;
|
|
3
|
+
const HTTPS_URL = /^https:\/\/[^\s]+$/u;
|
|
4
|
+
/** The one-line text a chat webhook shows. */
|
|
5
|
+
export function notifyText(summary) {
|
|
6
|
+
const commit = summary.sourceSha ? ` of ${summary.sourceSha.slice(0, 10)}` : '';
|
|
7
|
+
const tag = summary.tag ? ` (${summary.tag})` : '';
|
|
8
|
+
const release = summary.releaseId ? `, release ${summary.releaseId}` : '';
|
|
9
|
+
const failure = summary.errorCode ? `, ${summary.errorCode}` : '';
|
|
10
|
+
return `[${summary.stack}/${summary.env}] ${summary.kind} ${summary.outcome}${commit}${tag}${release}${failure}`;
|
|
11
|
+
}
|
|
12
|
+
/** The JSON body: `text` (Slack style) and `content` (Discord style) carry the line, the rest is the same facts as fields. */
|
|
13
|
+
export function notifyBody(summary) {
|
|
14
|
+
const text = notifyText(summary);
|
|
15
|
+
return { text, content: text, ...summary };
|
|
16
|
+
}
|
|
17
|
+
/** Sends the message for a finished attempt when `deploy.notify` is declared and lists its outcome; returns the lines to print (empty when nothing was sent or it went well). */
|
|
18
|
+
export async function notifyOutcome(ctx, config, summary) {
|
|
19
|
+
const declared = config.notify;
|
|
20
|
+
if (!declared || !declared.on.includes(summary.outcome))
|
|
21
|
+
return [];
|
|
22
|
+
try {
|
|
23
|
+
const url = openMember(ctx, config, declared.webhook.member)[declared.webhook.key];
|
|
24
|
+
if (typeof url !== 'string' || !HTTPS_URL.test(url))
|
|
25
|
+
return [`notify: the sealed ${declared.webhook.member} holds no https URL under ${declared.webhook.key}; nothing was sent`];
|
|
26
|
+
const response = await ctx.fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(notifyBody(summary)), redirect: 'error', signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
27
|
+
return response.ok ? [] : [`notify: the webhook answered HTTP ${response.status}; the deploy outcome stands`];
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
return [`notify: the message was not sent (${error.name === 'TimeoutError' ? 'timed out' : 'the webhook could not be reached'}); the deploy outcome stands`];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -48,20 +48,22 @@ export interface ReleaseStore {
|
|
|
48
48
|
unlock(token: string): Promise<boolean>;
|
|
49
49
|
/** The journal of one attempt under /srv/<stack>/deploys. */
|
|
50
50
|
journal(attempt: string): AttemptJournal;
|
|
51
|
+
/** The newest attempt documents (at most 500), oldest first; a document that is not a JSON object is left out. */
|
|
52
|
+
journals(): Promise<Record<string, unknown>[]>;
|
|
51
53
|
}
|
|
52
54
|
/** A lock nobody refreshed for this long (its holder died without releasing it) is taken over by the next try. Phases refresh it. */
|
|
53
55
|
export declare const LOCK_TTL_SECONDS = 3600;
|
|
54
56
|
/** An attempt id: UTC compact time and 8 random hex. */
|
|
55
57
|
export declare const ATTEMPT_ID: RegExp;
|
|
56
58
|
/**
|
|
57
|
-
* The remote
|
|
58
|
-
* unrefreshed for `ttl` seconds is moved aside (one rename wins) and claimed anew. Prints `acquired` or `busy <trigger> <age>`.
|
|
59
|
-
* a validated base, token and trigger.
|
|
59
|
+
* The remote command of one try at the deploy lock (ssh-gate.mts holds the body): `mkdir` is the atomic claim, the owner file (token, trigger, epoch) is
|
|
60
|
+
* replaced by rename, a lock unrefreshed for `ttl` seconds is moved aside (one rename wins) and claimed anew. Prints `acquired` or `busy <trigger> <age>`.
|
|
61
|
+
* Composed only from a validated base, token and trigger.
|
|
60
62
|
*/
|
|
61
63
|
export declare const lockAcquireScript: (base: string, token: string, trigger: string, ttl?: number) => string;
|
|
62
|
-
/** The remote
|
|
64
|
+
/** The remote command that marks the lock alive when `token` owns it: prints `held` or `lost`. */
|
|
63
65
|
export declare const lockRefreshScript: (base: string, token: string) => string;
|
|
64
|
-
/** The remote
|
|
66
|
+
/** The remote command that removes the lock when `token` owns it: prints `released` or `lost`. */
|
|
65
67
|
export declare const lockReleaseScript: (base: string, token: string) => string;
|
|
66
68
|
/** Removes every carriage return: a snippet from a Windows checkout must never reach a remote shell with CRLF. */
|
|
67
69
|
export declare const lfOnly: (text: unknown) => string;
|
|
@@ -69,4 +71,6 @@ export declare const lfOnly: (text: unknown) => string;
|
|
|
69
71
|
* The release store of one stack. `ssh` is the runner; the returned API is async:
|
|
70
72
|
* store(dir, id) accept(id) current() previous() fetch(id, targetDir) list()
|
|
71
73
|
*/
|
|
72
|
-
export declare function createReleaseStore(config: Pick<DeployConfig, 'remote' | 'stack'>, ssh: StoreSsh
|
|
74
|
+
export declare function createReleaseStore(config: Pick<DeployConfig, 'remote' | 'stack'>, ssh: StoreSsh, options?: {
|
|
75
|
+
gated?: boolean | undefined;
|
|
76
|
+
}): ReleaseStore;
|
package/stack/release-store.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import path from 'node:path';
|
|
|
8
8
|
import { firstLine, lines as splitLines } from './context.mjs';
|
|
9
9
|
import { isReleaseId } from './digest.mjs';
|
|
10
10
|
import { failure, refusal } from './errors.mjs';
|
|
11
|
-
import {
|
|
11
|
+
import { gatedCommand, inlineCommand } from './ssh-gate.mjs';
|
|
12
12
|
/** A lock nobody refreshed for this long (its holder died without releasing it) is taken over by the next try. Phases refresh it. */
|
|
13
13
|
export const LOCK_TTL_SECONDS = 3600;
|
|
14
14
|
const TOKEN = /^[0-9a-f]{32}$/u;
|
|
@@ -30,27 +30,16 @@ const checkedAttempt = (attempt) => {
|
|
|
30
30
|
throw refusal('STACK_ATTEMPT_ID', 'an attempt id is a UTC compact time and 8 lowercase hex characters');
|
|
31
31
|
return attempt;
|
|
32
32
|
};
|
|
33
|
-
const OWNER_READ = (lock) => `if [ -r "${lock}/owner" ]; then read -r held_token held_trigger held_stamp < "${lock}/owner"; else held_token=none; held_trigger=unknown; held_stamp=$(stat -c %Y "${lock}" 2>/dev/null) || held_stamp=0; fi; case "$held_stamp" in ''|*[!0-9]*) held_stamp=0;; esac`;
|
|
34
33
|
/**
|
|
35
|
-
* The remote
|
|
36
|
-
* unrefreshed for `ttl` seconds is moved aside (one rename wins) and claimed anew. Prints `acquired` or `busy <trigger> <age>`.
|
|
37
|
-
* a validated base, token and trigger.
|
|
34
|
+
* The remote command of one try at the deploy lock (ssh-gate.mts holds the body): `mkdir` is the atomic claim, the owner file (token, trigger, epoch) is
|
|
35
|
+
* replaced by rename, a lock unrefreshed for `ttl` seconds is moved aside (one rename wins) and claimed anew. Prints `acquired` or `busy <trigger> <age>`.
|
|
36
|
+
* Composed only from a validated base, token and trigger.
|
|
38
37
|
*/
|
|
39
|
-
export const lockAcquireScript = (base, token, trigger, ttl = LOCK_TTL_SECONDS) =>
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
'if mkdir "$l" 2>/dev/null; then claim; fi',
|
|
45
|
-
OWNER_READ('$l'),
|
|
46
|
-
`age=$((now - held_stamp)); if [ "$age" -gt ${Math.trunc(ttl)} ]; then if mv "$l" "$l.stale.${token}" 2>/dev/null; then rm -rf "$l.stale.${token}"; if mkdir "$l" 2>/dev/null; then claim; fi; fi; fi`,
|
|
47
|
-
'case "$held_trigger" in ci|dispatch|urgent) ;; *) held_trigger=unknown;; esac; echo "busy $held_trigger $age"',
|
|
48
|
-
].join('; ');
|
|
49
|
-
};
|
|
50
|
-
/** The remote script that marks the lock alive when `token` owns it: prints `held` or `lost`. */
|
|
51
|
-
export const lockRefreshScript = (base, token) => `l="${base}/deploy.lock"; now=$(date +%s); if [ -r "$l/owner" ]; then read -r t g s < "$l/owner"; if [ "$t" = '${checkedToken(token)}' ]; then printf '%s %s %s\\n' "$t" "$g" "$now" > "$l/owner.new" && mv -f "$l/owner.new" "$l/owner" && echo held; else echo lost; fi; else echo lost; fi`;
|
|
52
|
-
/** The remote script that removes the lock when `token` owns it: prints `released` or `lost`. */
|
|
53
|
-
export const lockReleaseScript = (base, token) => `l="${base}/deploy.lock"; if [ -r "$l/owner" ]; then read -r t g s < "$l/owner"; if [ "$t" = '${checkedToken(token)}' ]; then rm -rf "$l" && echo released; else echo lost; fi; else echo lost; fi`;
|
|
38
|
+
export const lockAcquireScript = (base, token, trigger, ttl = LOCK_TTL_SECONDS) => inlineCommand('lock-acquire', [base, checkedToken(token), checkedTrigger(trigger), String(Math.trunc(ttl))]);
|
|
39
|
+
/** The remote command that marks the lock alive when `token` owns it: prints `held` or `lost`. */
|
|
40
|
+
export const lockRefreshScript = (base, token) => inlineCommand('lock-refresh', [base, checkedToken(token)]);
|
|
41
|
+
/** The remote command that removes the lock when `token` owns it: prints `released` or `lost`. */
|
|
42
|
+
export const lockReleaseScript = (base, token) => inlineCommand('lock-release', [base, checkedToken(token)]);
|
|
54
43
|
const BASE = /^\/[A-Za-z0-9._/-]+$/u;
|
|
55
44
|
const FILES = ['stack.yaml', 'release.json'];
|
|
56
45
|
/** Removes every carriage return: a snippet from a Windows checkout must never reach a remote shell with CRLF. */
|
|
@@ -62,7 +51,7 @@ const checkedId = (id) => {
|
|
|
62
51
|
};
|
|
63
52
|
const checkedBase = (config) => {
|
|
64
53
|
const base = config.remote?.releasesBase ?? `/srv/${config.stack}`;
|
|
65
|
-
if (!BASE.test(base) || base.
|
|
54
|
+
if (!BASE.test(base) || base.includes('..'))
|
|
66
55
|
throw refusal('STACK_RELEASE_BASE', `the releases base ${base} must be an absolute path without ..`);
|
|
67
56
|
return base;
|
|
68
57
|
};
|
|
@@ -70,10 +59,11 @@ const checkedBase = (config) => {
|
|
|
70
59
|
* The release store of one stack. `ssh` is the runner; the returned API is async:
|
|
71
60
|
* store(dir, id) accept(id) current() previous() fetch(id, targetDir) list()
|
|
72
61
|
*/
|
|
73
|
-
export function createReleaseStore(config, ssh) {
|
|
62
|
+
export function createReleaseStore(config, ssh, options = {}) {
|
|
74
63
|
const base = checkedBase(config);
|
|
75
|
-
|
|
76
|
-
async function run(
|
|
64
|
+
/** Runs the named operation: through the forced-command gate (`starci-op <name> <args>`) when the key has one, else as the same body under `sh -c`. */
|
|
65
|
+
async function run(name, args, input) {
|
|
66
|
+
const command = options.gated === true ? gatedCommand(name, args) : inlineCommand(name, args);
|
|
77
67
|
const answer = await ssh(lfOnly(command), { input });
|
|
78
68
|
if (answer.status !== 0)
|
|
79
69
|
throw failure('STACK_RELEASE_SSH', `remote command failed: ${firstLine(answer.stderr)}`);
|
|
@@ -88,14 +78,13 @@ export function createReleaseStore(config, ssh) {
|
|
|
88
78
|
}
|
|
89
79
|
}
|
|
90
80
|
async function pointer(name) {
|
|
91
|
-
const out = await run(
|
|
81
|
+
const out = await run('pointer', [base, name]);
|
|
92
82
|
const id = out.trim();
|
|
93
83
|
return isReleaseId(id) ? id : '';
|
|
94
84
|
}
|
|
95
85
|
/** Both files of a stored release as [name, text] pairs, framed as base64 lines. */
|
|
96
86
|
async function readFiles(id) {
|
|
97
|
-
const
|
|
98
|
-
const out = await run(`set -e; for f in stack.yaml release.json; do test -r "${source}/$f"; base64 < "${source}/$f" | tr -d '\\n'; printf '\\n'; done`);
|
|
87
|
+
const out = await run('read', [base, checkedId(id)]);
|
|
99
88
|
const frames = out.split('\n');
|
|
100
89
|
if (frames.length !== 3 || frames[2] !== '' || frames.slice(0, 2).some((frame) => !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(frame)))
|
|
101
90
|
throw failure('STACK_RELEASE_SSH', 'invalid release response framing');
|
|
@@ -104,15 +93,15 @@ export function createReleaseStore(config, ssh) {
|
|
|
104
93
|
return {
|
|
105
94
|
/** Uploads stack.yaml and release.json of a rendered release as text; read-only afterwards. */
|
|
106
95
|
async store(dir, id) {
|
|
107
|
-
|
|
96
|
+
checkedId(id);
|
|
108
97
|
const contents = FILES.map((name) => [name, readLocal(dir, name)]);
|
|
109
98
|
const input = contents.map(([, text]) => Buffer.from(text, 'utf8').toString('base64')).join('\n') + '\n';
|
|
110
|
-
await run(
|
|
99
|
+
await run('store', [base, checkedId(id)], input);
|
|
111
100
|
},
|
|
112
101
|
/** The release was verified: previous := current, current := id. */
|
|
113
102
|
async accept(id) {
|
|
114
103
|
checkedId(id);
|
|
115
|
-
await run(
|
|
104
|
+
await run('accept', [base, id]);
|
|
116
105
|
},
|
|
117
106
|
/** The accepted release id, '' when none. */
|
|
118
107
|
current: () => pointer('current'),
|
|
@@ -139,7 +128,7 @@ export function createReleaseStore(config, ssh) {
|
|
|
139
128
|
return { stackYaml: stack[1], release: record !== null && typeof record === 'object' && !Array.isArray(record) ? record : {} };
|
|
140
129
|
},
|
|
141
130
|
async tryLock(token, trigger) {
|
|
142
|
-
const answer = (await run(
|
|
131
|
+
const answer = (await run('lock-acquire', [base, checkedToken(token), checkedTrigger(trigger), String(LOCK_TTL_SECONDS)])).trim();
|
|
143
132
|
if (answer === 'acquired')
|
|
144
133
|
return { acquired: true };
|
|
145
134
|
const busy = /^busy ([a-z]+) (-?[0-9]+)$/u.exec(answer);
|
|
@@ -147,25 +136,39 @@ export function createReleaseStore(config, ssh) {
|
|
|
147
136
|
throw failure('STACK_RELEASE_SSH', 'the deploy lock script answered something else than acquired or busy');
|
|
148
137
|
return { acquired: false, holder: busy[1], ageSeconds: Number(busy[2]) };
|
|
149
138
|
},
|
|
150
|
-
async refreshLock(token) { return (await run(
|
|
151
|
-
async unlock(token) { return (await run(
|
|
139
|
+
async refreshLock(token) { return (await run('lock-refresh', [base, checkedToken(token)])).trim() === 'held'; },
|
|
140
|
+
async unlock(token) { return (await run('lock-release', [base, checkedToken(token)])).trim() === 'released'; },
|
|
152
141
|
journal(attempt) {
|
|
153
142
|
const id = checkedAttempt(attempt);
|
|
154
|
-
const file = `${base}/deploys/${id}.json`;
|
|
155
143
|
const encoded = (value) => `${Buffer.from(JSON.stringify(value), 'utf8').toString('base64')}
|
|
156
144
|
`;
|
|
157
145
|
return {
|
|
158
146
|
async write(document) {
|
|
159
|
-
await run(
|
|
147
|
+
await run('journal-write', [base, id], encoded(document));
|
|
160
148
|
},
|
|
161
149
|
async index(entry) {
|
|
162
|
-
await run(
|
|
150
|
+
await run('journal-index', [base], encoded(entry));
|
|
163
151
|
},
|
|
164
152
|
};
|
|
165
153
|
},
|
|
154
|
+
/** The attempt documents the journal keeps, oldest first. */
|
|
155
|
+
async journals() {
|
|
156
|
+
const documents = [];
|
|
157
|
+
for (const frame of (await run('journal-read', [base])).split('\n')) {
|
|
158
|
+
if (frame === '')
|
|
159
|
+
continue;
|
|
160
|
+
try {
|
|
161
|
+
const parsed = JSON.parse(Buffer.from(frame, 'base64').toString('utf8'));
|
|
162
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
163
|
+
documents.push(parsed);
|
|
164
|
+
}
|
|
165
|
+
catch { /* a document that cannot be read is no evidence either way */ }
|
|
166
|
+
}
|
|
167
|
+
return documents;
|
|
168
|
+
},
|
|
166
169
|
/** The stored releases, newest first: [{ id, current, previous }]. */
|
|
167
170
|
async list() {
|
|
168
|
-
const response = await run(
|
|
171
|
+
const response = await run('list', [base]);
|
|
169
172
|
const [current, previous, ...entries] = response.split('\n');
|
|
170
173
|
const now = isReleaseId(current) ? current : '';
|
|
171
174
|
const before = isReleaseId(previous) ? previous : '';
|