@starci/hfs 4.12.2 → 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 +35 -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 +74 -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/remote.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import path from 'node:path';
|
|
|
15
15
|
import { tool } from './context.mjs';
|
|
16
16
|
import { restrictToUser, writePrivate } from './private-file.mjs';
|
|
17
17
|
import { refusal } from './errors.mjs';
|
|
18
|
+
import { openMember as openSealedMember } from './sealed.mjs';
|
|
18
19
|
import { createSshSession, sessionDirectory } from './ssh-session.mjs';
|
|
19
20
|
import { assertStackAccount } from './target-binding.mjs';
|
|
20
21
|
const ACCOUNT = /^[A-Za-z0-9._@-]+$/u;
|
|
@@ -215,22 +216,36 @@ function ciHome(ctx) {
|
|
|
215
216
|
throw refusal('STACK_CONNECT_NO_HOME', 'HOME is not set');
|
|
216
217
|
return path.join(ctx.env.HOME, '.ssh');
|
|
217
218
|
}
|
|
218
|
-
|
|
219
|
+
/** The sealed member that holds the CI transport of an environment: VPS_SSH_KEY (the CI-only deploy key), VPS_KNOWN_HOSTS, VPS_HOST and VPS_USER. */
|
|
220
|
+
export const CI_MEMBER = 'vps-ci';
|
|
221
|
+
/**
|
|
222
|
+
* The CI transport values: the sealed member `vps-ci` of the environment (opened with the server identity the job holds) when it exists, else the
|
|
223
|
+
* variables of the job (the transition, and the owner's own runs). The member keeps the host, the account, the key and the pinned host keys out of
|
|
224
|
+
* GitHub and of a public log; the pinned host keys are reviewed in git as ciphertext.
|
|
225
|
+
*/
|
|
226
|
+
function ciValues(ctx, config) {
|
|
227
|
+
const present = typeof config.secretsDir === 'string' && fs.existsSync(memberFile(config.secretsDir, CI_MEMBER));
|
|
228
|
+
if (!present)
|
|
229
|
+
return { values: ctx.env, sealed: false };
|
|
230
|
+
const map = openSealedMember(ctx, config, CI_MEMBER);
|
|
231
|
+
return { values: Object.fromEntries(CI_VARIABLES.map((name) => [name, typeof map[name] === 'string' ? map[name] : undefined])), sealed: true };
|
|
232
|
+
}
|
|
233
|
+
function ciInputs(ctx, values, sealed) {
|
|
219
234
|
for (const name of CI_VARIABLES)
|
|
220
|
-
if (!
|
|
221
|
-
throw refusal('STACK_CONNECT_MISSING', `${name} is not set`);
|
|
235
|
+
if (!values[name])
|
|
236
|
+
throw refusal('STACK_CONNECT_MISSING', sealed ? `the sealed member ${CI_MEMBER} holds no ${name}` : `${name} is not set and the environment has no sealed member ${CI_MEMBER}`);
|
|
222
237
|
const [host, user] = ['VPS_HOST', 'VPS_USER'].map((name) => {
|
|
223
|
-
const value =
|
|
238
|
+
const value = values[name]; // set: every CI variable was checked above
|
|
224
239
|
if (!ACCOUNT.test(value) || value.startsWith('-'))
|
|
225
240
|
throw refusal('STACK_CONNECT_INVALID', `${name} must match ${ACCOUNT}`);
|
|
226
241
|
return value;
|
|
227
242
|
});
|
|
228
|
-
const key = lfOnly(
|
|
243
|
+
const key = lfOnly(values.VPS_SSH_KEY);
|
|
229
244
|
if (!key.includes('PRIVATE KEY'))
|
|
230
245
|
throw refusal('STACK_CONNECT_INVALID', 'VPS_SSH_KEY is not a PEM private key');
|
|
231
246
|
if (!ctx.env.GITHUB_ENV)
|
|
232
247
|
throw refusal('STACK_CONNECT_NO_GITHUB_ENV', 'GITHUB_ENV is not set');
|
|
233
|
-
return { key: withNewline(key), known: withNewline(lfOnly(
|
|
248
|
+
return { key: withNewline(key), known: withNewline(lfOnly(values.VPS_KNOWN_HOSTS)), host, user };
|
|
234
249
|
}
|
|
235
250
|
const sshBlock = (alias, inputs) => [
|
|
236
251
|
'', `Host ${alias}`, ` HostName ${inputs.host}`, ` User ${inputs.user}`, ' IdentityFile ~/.ssh/deploy_key',
|
|
@@ -240,18 +255,23 @@ const sshBlock = (alias, inputs) => [
|
|
|
240
255
|
/** CI transport: pinned key, known_hosts, ssh config block and DOCKER_HOST=ssh://<alias> in $GITHUB_ENV. */
|
|
241
256
|
export async function connectCi(ctx, config, { execute = false } = {}) {
|
|
242
257
|
const dir = ciHome(ctx);
|
|
243
|
-
const
|
|
258
|
+
const { values, sealed } = ciValues(ctx, config);
|
|
259
|
+
const inputs = ciInputs(ctx, values, sealed);
|
|
244
260
|
const alias = config.remote.host;
|
|
245
|
-
const
|
|
246
|
-
const
|
|
261
|
+
const origin = sealed ? `the sealed member ${CI_MEMBER}` : 'VPS_SSH_KEY';
|
|
262
|
+
const plan = [`${dir}/deploy_key (0600) from ${origin}`, `${dir}/known_hosts (0644) from ${sealed ? CI_MEMBER : 'VPS_KNOWN_HOSTS'}`, `a Host ${alias} block in ${dir}/config`, `DOCKER_HOST=ssh://${alias} in GITHUB_ENV`];
|
|
263
|
+
const data = { host: alias, files: ['deploy_key', 'known_hosts', 'config'], sealed };
|
|
247
264
|
if (!execute)
|
|
248
265
|
return { ok: true, lines: plan.map((item) => `dry run: would write ${item}`), data };
|
|
249
266
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
250
267
|
writeWithMode(path.join(dir, 'deploy_key'), inputs.key, 0o600);
|
|
251
268
|
writeWithMode(path.join(dir, 'known_hosts'), inputs.known, 0o644);
|
|
252
269
|
fs.appendFileSync(path.join(dir, 'config'), sshBlock(alias, inputs), { mode: 0o600 });
|
|
253
|
-
fs.appendFileSync(ctx.env.GITHUB_ENV, `DOCKER_HOST=ssh://${alias}
|
|
254
|
-
|
|
270
|
+
fs.appendFileSync(ctx.env.GITHUB_ENV, `DOCKER_HOST=ssh://${alias}
|
|
271
|
+
`);
|
|
272
|
+
// The log of a public repository is public: mask the host, the account and the releases root before any later line can carry them.
|
|
273
|
+
const masks = sealed ? [inputs.host, inputs.user, config.remote.releasesBase].filter((value) => typeof value === 'string' && value.length >= 4).map((value) => `::add-mask::${value}`) : [];
|
|
274
|
+
return { ok: true, lines: [...masks, ...plan.map((item) => `wrote ${item}`)], data };
|
|
255
275
|
}
|
|
256
276
|
/** CI cleanup: closes the ssh control master (failure ignored) and removes the deploy key. */
|
|
257
277
|
export async function disconnectCi(ctx, config, { execute = false } = {}) {
|
package/stack/server-key.d.mts
CHANGED
|
@@ -28,6 +28,8 @@ export interface ServerKeyOptions {
|
|
|
28
28
|
export declare const SERVER_AGE_IDENTITY = "/root/.config/sops/age/keys.txt";
|
|
29
29
|
/** The managed workflow `--restore github` dispatches (templates/app/ci-workflows*). */
|
|
30
30
|
export declare const RESTORE_WORKFLOW = "stack-key-restore.yml";
|
|
31
|
+
/** A command run as root: directly when the deploy user is root, else over passwordless sudo, the script read from stdin. */
|
|
32
|
+
export declare const AS_ROOT = "if [ \"$(id -u)\" -eq 0 ]; then sh -s; else sudo -n sh -s; fi";
|
|
31
33
|
/**
|
|
32
34
|
* The directories, after root's PATH, where age-keygen is looked for: sudo's secure_path (AlmaLinux 10, RHEL) leaves out
|
|
33
35
|
* /usr/local/bin, where the static installer puts it, so the presence check and every call resolve it the same way, as root.
|
package/stack/server-key.mjs
CHANGED
|
@@ -37,7 +37,7 @@ export const SERVER_AGE_IDENTITY = '/root/.config/sops/age/keys.txt';
|
|
|
37
37
|
export const RESTORE_WORKFLOW = 'stack-key-restore.yml';
|
|
38
38
|
const RECIPIENT = /^age1[a-z0-9]+$/u;
|
|
39
39
|
/** A command run as root: directly when the deploy user is root, else over passwordless sudo, the script read from stdin. */
|
|
40
|
-
const AS_ROOT = 'if [ "$(id -u)" -eq 0 ]; then sh -s; else sudo -n sh -s; fi';
|
|
40
|
+
export const AS_ROOT = 'if [ "$(id -u)" -eq 0 ]; then sh -s; else sudo -n sh -s; fi';
|
|
41
41
|
/**
|
|
42
42
|
* The directories, after root's PATH, where age-keygen is looked for: sudo's secure_path (AlmaLinux 10, RHEL) leaves out
|
|
43
43
|
* /usr/local/bin, where the static installer puts it, so the presence check and every call resolve it the same way, as root.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { DeployConfig } from './config.mjs';
|
|
2
|
+
import type { StackContext } from './context.mjs';
|
|
3
|
+
import type { ReleaseStore } from './release-store.mjs';
|
|
4
|
+
import type { ActionResult, Runner, SshOptions } from './remote.mjs';
|
|
5
|
+
/** One live service of the stack as `docker service inspect` reports it (names, digests and states only; no environment, no secret). */
|
|
6
|
+
export interface ObservedService {
|
|
7
|
+
/** The service name without the stack prefix (`api` for `shop_api`). */
|
|
8
|
+
service: string;
|
|
9
|
+
/** The image reference of the service spec, or null when it has none. */
|
|
10
|
+
image: string | null;
|
|
11
|
+
/** The `sha256:...` digest the image reference pins, or null when it is a tag only. */
|
|
12
|
+
digest: string | null;
|
|
13
|
+
/** The update state of the service (`completed`, `rollback_completed`, ...), null when it was never updated. */
|
|
14
|
+
updateState: string | null;
|
|
15
|
+
/** The first 16 hex of the sha256 of the canonical task template: it changes when anything a task runs with changes. */
|
|
16
|
+
taskTemplateHash: string | null;
|
|
17
|
+
}
|
|
18
|
+
/** The kinds of drift a status reports. */
|
|
19
|
+
export type DriftKind = 'rolled back by swarm' | 'updated outside a release' | 'missing' | 'not in the release';
|
|
20
|
+
/** One service that does not run what the accepted release says. */
|
|
21
|
+
export interface ServiceDrift {
|
|
22
|
+
service: string;
|
|
23
|
+
kind: DriftKind;
|
|
24
|
+
detail: string;
|
|
25
|
+
}
|
|
26
|
+
/** The `sha256:...` digest an image reference pins, or null. */
|
|
27
|
+
export declare const digestOf: (image: string | null | undefined) => string | null;
|
|
28
|
+
/** The image of each service of a stack file, by service name (the pinned references of a rendered release). */
|
|
29
|
+
export declare function stackImages(stackYaml: string): Record<string, string>;
|
|
30
|
+
/** The live services of the stack, sorted by name. Throws when docker cannot be asked. */
|
|
31
|
+
export declare function observedServices(ctx: Pick<StackContext, 'spawn'>, config: Pick<DeployConfig, 'stack'>): ObservedService[];
|
|
32
|
+
/** What a release says its services run: the stack's images and, when it was accepted by this version, the services observed at the accept. */
|
|
33
|
+
export interface ExpectedRelease {
|
|
34
|
+
stackYaml: string;
|
|
35
|
+
release: Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
/** Compares the live services with a release; an empty list means they run the release. */
|
|
38
|
+
export declare function compareWithRelease(expected: ExpectedRelease, live: readonly ObservedService[]): ServiceDrift[];
|
|
39
|
+
/** The collaborators of a status (the specs inject stubs). */
|
|
40
|
+
export interface StatusDeps {
|
|
41
|
+
ssh: (ctx: StackContext, config: DeployConfig, options: SshOptions) => Runner | Promise<Runner>;
|
|
42
|
+
releaseStore: (config: DeployConfig, ssh: Runner) => ReleaseStore;
|
|
43
|
+
observedServices: (ctx: StackContext, config: DeployConfig) => ObservedService[];
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* `stack status`: the accepted release against the live services. Returns { ok, lines, data: { release, sourceSha, drift, services } }; ok is false exactly
|
|
47
|
+
* when a service drifted. An environment with no accepted release reports that and is not a drift.
|
|
48
|
+
*/
|
|
49
|
+
export declare function statusStack(ctx: StackContext, config: DeployConfig, options?: SshOptions, deps?: Partial<StatusDeps>): Promise<ActionResult>;
|
package/stack/status.mjs
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// status.mjs - what runs against what was accepted: `starci app stack status --env <env>` (read-only) compares the live services of the stack with the
|
|
2
|
+
// accepted release and prints the drift per service. A service is drifted when
|
|
3
|
+
// - Swarm rolled it back by itself (its update state is `rollback_*`): the release still reads as the new one, the service runs the old spec;
|
|
4
|
+
// - its live image digest is not the digest the release's stack.yaml names, or its task template is not the one observed when the release was accepted
|
|
5
|
+
// (a manual `docker service update`): `updated outside a release`;
|
|
6
|
+
// - it is missing from the swarm, or runs in the stack without being in the release.
|
|
7
|
+
// `observedServices(ctx, config)` is the one reader of the live services: the deploy snapshots it into its journal (after converge, after a rollback and on
|
|
8
|
+
// accept) and into release.json (`observed`), and `verifyStack` judges the digests it reads. Every call is a docker query; nothing here changes the swarm.
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
import { parseYaml } from '../runtime/engine/yaml.mjs';
|
|
11
|
+
import { canonical } from './converge.mjs';
|
|
12
|
+
import { lines as splitLines, toolOutput } from './context.mjs';
|
|
13
|
+
import { createReleaseStore } from './release-store.mjs';
|
|
14
|
+
const DIGEST = /@(sha256:[0-9a-f]{64})$/u;
|
|
15
|
+
const IMAGE_FIELD = /^[A-Za-z0-9][A-Za-z0-9./_:@-]*$/u;
|
|
16
|
+
/** The `sha256:...` digest an image reference pins, or null. */
|
|
17
|
+
export const digestOf = (image) => (image ? (DIGEST.exec(image)?.[1] ?? null) : null);
|
|
18
|
+
/** The image of each service of a stack file, by service name (the pinned references of a rendered release). */
|
|
19
|
+
export function stackImages(stackYaml) {
|
|
20
|
+
// reason: the stack file, parsed YAML read defensively
|
|
21
|
+
const services = parseYaml(stackYaml)?.services ?? {};
|
|
22
|
+
const pairs = Object.entries(services).map(([name, entry]) => [name, entry?.image]);
|
|
23
|
+
return Object.fromEntries(pairs.filter((pair) => typeof pair[1] === 'string' && IMAGE_FIELD.test(pair[1])));
|
|
24
|
+
}
|
|
25
|
+
const shortDigest = (digest) => (digest ? digest.slice(0, 19) : 'no digest');
|
|
26
|
+
/** Reads one `docker service inspect` document into an ObservedService. */
|
|
27
|
+
function observe(stack, name, text) {
|
|
28
|
+
// reason: `docker service inspect` output, parsed JSON read defensively below
|
|
29
|
+
const inspect = JSON.parse(text);
|
|
30
|
+
const image = inspect?.Spec?.TaskTemplate?.ContainerSpec?.Image;
|
|
31
|
+
const template = inspect?.Spec?.TaskTemplate;
|
|
32
|
+
const state = inspect?.UpdateStatus?.State;
|
|
33
|
+
const reference = typeof image === 'string' && IMAGE_FIELD.test(image) ? image : null;
|
|
34
|
+
return {
|
|
35
|
+
service: name.startsWith(`${stack}_`) ? name.slice(stack.length + 1) : name,
|
|
36
|
+
image: reference,
|
|
37
|
+
digest: digestOf(reference),
|
|
38
|
+
updateState: typeof state === 'string' && /^[a-z_]+$/u.test(state) ? state : null,
|
|
39
|
+
taskTemplateHash: template === undefined ? null : createHash('sha256').update(canonical(template)).digest('hex').slice(0, 16),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** The live services of the stack, sorted by name. Throws when docker cannot be asked. */
|
|
43
|
+
export function observedServices(ctx, config) {
|
|
44
|
+
const names = splitLines(toolOutput(ctx, 'docker', ['service', 'ls', '--filter', `label=com.docker.stack.namespace=${config.stack}`, '--format', '{{.Name}}'])).sort();
|
|
45
|
+
return names.map((name) => observe(config.stack, name, toolOutput(ctx, 'docker', ['service', 'inspect', name, '--format', '{{json .}}'])));
|
|
46
|
+
}
|
|
47
|
+
/** The `observed.accept` services of a release record, or [] when it carries none (an older release). */
|
|
48
|
+
function acceptedObservation(release) {
|
|
49
|
+
const observed = release.observed;
|
|
50
|
+
const list = observed?.accept;
|
|
51
|
+
return Array.isArray(list) ? list.filter((entry) => typeof entry === 'object' && entry !== null && typeof entry.service === 'string') : [];
|
|
52
|
+
}
|
|
53
|
+
/** Compares the live services with a release; an empty list means they run the release. */
|
|
54
|
+
export function compareWithRelease(expected, live) {
|
|
55
|
+
const images = stackImages(expected.stackYaml);
|
|
56
|
+
const accepted = new Map(acceptedObservation(expected.release).map((entry) => [entry.service, entry]));
|
|
57
|
+
const running = new Map(live.map((entry) => [entry.service, entry]));
|
|
58
|
+
const drift = [];
|
|
59
|
+
for (const [service, image] of Object.entries(images)) {
|
|
60
|
+
const now = running.get(service);
|
|
61
|
+
if (!now) {
|
|
62
|
+
drift.push({ service, kind: 'missing', detail: 'the release names it and no such service runs' });
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const wanted = digestOf(image);
|
|
66
|
+
if (now.updateState?.startsWith('rollback'))
|
|
67
|
+
drift.push({ service, kind: 'rolled back by swarm', detail: `update state ${now.updateState}; runs ${shortDigest(now.digest)}, the release names ${shortDigest(wanted)}` });
|
|
68
|
+
else if (wanted && now.digest !== wanted)
|
|
69
|
+
drift.push({ service, kind: 'updated outside a release', detail: `runs ${shortDigest(now.digest)}, the release names ${shortDigest(wanted)}` });
|
|
70
|
+
else {
|
|
71
|
+
const seen = accepted.get(service)?.taskTemplateHash ?? null;
|
|
72
|
+
if (seen && now.taskTemplateHash && seen !== now.taskTemplateHash)
|
|
73
|
+
drift.push({ service, kind: 'updated outside a release', detail: 'its task template changed since the release was accepted' });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
for (const service of running.keys())
|
|
77
|
+
if (!Object.hasOwn(images, service))
|
|
78
|
+
drift.push({ service, kind: 'not in the release', detail: 'it runs in the stack and the accepted release does not name it' });
|
|
79
|
+
return drift;
|
|
80
|
+
}
|
|
81
|
+
// reason: a dynamic import by string, resolved on first use so a spec that injects `ssh` never loads remote.mjs
|
|
82
|
+
const ownerSsh = async (ctx, config, options) => (await import('./remote.mjs')).ownerSshRunner(ctx, config, options);
|
|
83
|
+
const DEFAULT_DEPS = { ssh: ownerSsh, releaseStore: createReleaseStore, observedServices };
|
|
84
|
+
/**
|
|
85
|
+
* `stack status`: the accepted release against the live services. Returns { ok, lines, data: { release, sourceSha, drift, services } }; ok is false exactly
|
|
86
|
+
* when a service drifted. An environment with no accepted release reports that and is not a drift.
|
|
87
|
+
*/
|
|
88
|
+
export async function statusStack(ctx, config, options = {}, deps = {}) {
|
|
89
|
+
const d = { ...DEFAULT_DEPS, ...deps };
|
|
90
|
+
const run = await d.ssh(ctx, config, { sshCmd: options.sshCmd ?? null });
|
|
91
|
+
try {
|
|
92
|
+
const store = d.releaseStore(config, run);
|
|
93
|
+
const current = await store.current();
|
|
94
|
+
const live = d.observedServices(ctx, config);
|
|
95
|
+
if (!current)
|
|
96
|
+
return { ok: true, lines: [`status: ${config.stack} has no accepted release${live.length > 0 ? `; ${live.length} service(s) run in the stack` : ''}`], data: { release: null, sourceSha: null, drift: [], services: live } };
|
|
97
|
+
const stored = await store.read(current);
|
|
98
|
+
const drift = compareWithRelease(stored, live);
|
|
99
|
+
const sourceSha = typeof stored.release.sourceSha === 'string' ? stored.release.sourceSha : null;
|
|
100
|
+
const drifted = new Map(drift.map((entry) => [entry.service, entry]));
|
|
101
|
+
const names = [...new Set([...Object.keys(stackImages(stored.stackYaml)), ...live.map((entry) => entry.service)])].sort();
|
|
102
|
+
const rows = names.map((name) => {
|
|
103
|
+
const found = drifted.get(name);
|
|
104
|
+
const now = live.find((entry) => entry.service === name);
|
|
105
|
+
return found ? `DRIFT ${name} ${found.kind}: ${found.detail}` : `PASS ${name} ${shortDigest(now?.digest ?? null)}`;
|
|
106
|
+
});
|
|
107
|
+
const head = `status: accepted release ${current}${sourceSha ? ` (commit ${sourceSha.slice(0, 10)})` : ''}`;
|
|
108
|
+
const tail = drift.length === 0 ? 'status: every service runs the accepted release' : `status: ${drift.length} service(s) differ from the accepted release`;
|
|
109
|
+
return { ok: drift.length === 0, lines: [head, ...rows, tail], data: { release: current, sourceSha, drift, services: live } };
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
run.cleanup?.();
|
|
113
|
+
}
|
|
114
|
+
}
|
package/stack/urgent-floor.d.mts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import { type DbTarget as PostgresTarget } from '../db/postgres.mjs';
|
|
2
1
|
import type { DeployConfig } from './config.mjs';
|
|
3
2
|
import type { StackContext } from './context.mjs';
|
|
3
|
+
import { FIRST_START_WAIT_SECONDS, FLOOR_DEPS } from './db-phases.mjs';
|
|
4
|
+
import type { BackupData, BackupReceipt, FirstStartStepResult, FloorDeps } from './db-phases.mjs';
|
|
4
5
|
import type { ActionResult } from './remote.mjs';
|
|
6
|
+
export { FIRST_START_WAIT_SECONDS, FLOOR_DEPS };
|
|
7
|
+
export type { BackupData, BackupReceipt, FirstStartStepResult, FloorDeps };
|
|
5
8
|
/** The options of an urgent deploy the floor reads. */
|
|
6
9
|
export interface FloorOptions {
|
|
7
10
|
skipPublic?: boolean | undefined;
|
|
@@ -12,56 +15,6 @@ export interface FloorOptions {
|
|
|
12
15
|
backupOut?: string | undefined;
|
|
13
16
|
execute?: boolean | undefined;
|
|
14
17
|
}
|
|
15
|
-
/** The receipt of a database backup (db/backup-receipt.mjs). */
|
|
16
|
-
export interface BackupReceipt {
|
|
17
|
-
schemaOnly: boolean;
|
|
18
|
-
at: string;
|
|
19
|
-
file: string;
|
|
20
|
-
bytes: number;
|
|
21
|
-
sha256: string;
|
|
22
|
-
parts: string[];
|
|
23
|
-
}
|
|
24
|
-
/** What a backup hands back: where the receipt is and what it says. */
|
|
25
|
-
export interface BackupData {
|
|
26
|
-
receiptPath: string;
|
|
27
|
-
receipt?: BackupReceipt | undefined;
|
|
28
|
-
}
|
|
29
|
-
/** The database a floor step reaches (db/postgres.mjs). */
|
|
30
|
-
type DbTarget = Record<string, unknown>;
|
|
31
|
-
/** The verdict of one migrate pass. */
|
|
32
|
-
interface MigrateResult {
|
|
33
|
-
ok: boolean;
|
|
34
|
-
lines?: string[] | undefined;
|
|
35
|
-
}
|
|
36
|
-
/** Whether the database is empty, and why not. */
|
|
37
|
-
interface Emptiness {
|
|
38
|
-
empty: boolean;
|
|
39
|
-
history: boolean;
|
|
40
|
-
publicTables: number;
|
|
41
|
-
}
|
|
42
|
-
/** The collaborators of the floor, each defaulting to the app's own db verbs. */
|
|
43
|
-
export interface FloorDeps {
|
|
44
|
-
backup: (ctx: StackContext, args: {
|
|
45
|
-
target: DbTarget;
|
|
46
|
-
out: string;
|
|
47
|
-
}) => Promise<BackupData>;
|
|
48
|
-
decrypt: (ctx: StackContext, args: {
|
|
49
|
-
archive: string;
|
|
50
|
-
identity: string | null;
|
|
51
|
-
parts: string[];
|
|
52
|
-
}) => Promise<{
|
|
53
|
-
parts: string[];
|
|
54
|
-
}>;
|
|
55
|
-
migrate: (ctx: StackContext, args: {
|
|
56
|
-
target: DbTarget;
|
|
57
|
-
execute: boolean;
|
|
58
|
-
}) => Promise<MigrateResult>;
|
|
59
|
-
dbContainer: (ctx: StackContext, config: DeployConfig, args: {
|
|
60
|
-
service: string;
|
|
61
|
-
seconds: number;
|
|
62
|
-
}) => Promise<string | undefined>;
|
|
63
|
-
emptiness: (ctx: StackContext, target: PostgresTarget) => Promise<Emptiness>;
|
|
64
|
-
}
|
|
65
18
|
/** The data a deploy result carries that the floor reads. */
|
|
66
19
|
export interface DeployData {
|
|
67
20
|
steps?: {
|
|
@@ -87,20 +40,16 @@ export interface UrgentRun {
|
|
|
87
40
|
}): Promise<DeployOutcome>;
|
|
88
41
|
dryRun(): Promise<ActionResult>;
|
|
89
42
|
sha256(file: string): string;
|
|
43
|
+
/** Hands the urgent record (tag, role, reason) to the deploy journal once the floor has read it. */
|
|
44
|
+
note?(record: {
|
|
45
|
+
tag: string;
|
|
46
|
+
role: string;
|
|
47
|
+
reason: string;
|
|
48
|
+
}): void;
|
|
90
49
|
}
|
|
91
|
-
/** The outcome of the `first-start` step. */
|
|
92
|
-
export interface FirstStartStepResult {
|
|
93
|
-
ok: boolean;
|
|
94
|
-
code?: string;
|
|
95
|
-
lines: string[];
|
|
96
|
-
}
|
|
97
|
-
/** How long a first start waits for the database service's container to accept connections. */
|
|
98
|
-
export declare const FIRST_START_WAIT_SECONDS = 180;
|
|
99
|
-
export declare const FLOOR_DEPS: Readonly<FloorDeps>;
|
|
100
50
|
/**
|
|
101
51
|
* An urgent deploy: the floor's preconditions, then (with execute) its guard steps, then `deployExecute` (the release, its readiness verify and the
|
|
102
52
|
* automatic rollback). `run` = { render(), openStore(), deployExecute(), dryRun(), sha256 }. Returns { ok, lines, data } or a refused result whose
|
|
103
53
|
* data names the urgent release.
|
|
104
54
|
*/
|
|
105
55
|
export declare function urgentDeploy(ctx: StackContext, config: DeployConfig, options: FloorOptions, run: UrgentRun, deps?: Partial<FloorDeps>): Promise<ActionResult>;
|
|
106
|
-
export {};
|
package/stack/urgent-floor.mjs
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// urgent-floor.mjs - the safety floor of an urgent deploy (`starci app stack deploy --urgent`, owner ruling urgent-release 2026-10-29). An urgent
|
|
2
2
|
// release skips CI and the full gate, never this floor; each step that fails is a refusal with its own code, never a warning:
|
|
3
3
|
// record the commit carries a pushed URGENT record (release/urgent.mjs) STACK_URGENT_NO_RECORD, STACK_URGENT_NOT_PUSHED
|
|
4
|
-
// backup a fresh full database backup, encrypted to the app's age recipients (db dump)
|
|
5
|
-
// verified its receipt names this run, its size, age header and sha256 match the archive
|
|
6
|
-
// decrypt a test decrypt with the operator's age identity (SOPS_AGE_KEY_FILE) lists every part
|
|
4
|
+
// backup a fresh full database backup, encrypted to the app's age recipients (db dump) STACK_DEPLOY_BACKUP_FAILED
|
|
5
|
+
// verified its receipt names this run, its size, age header and sha256 match the archive STACK_DEPLOY_BACKUP_UNVERIFIED
|
|
6
|
+
// decrypt a test decrypt with the operator's age identity (SOPS_AGE_KEY_FILE) lists every part STACK_DEPLOY_BACKUP_DECRYPT_UNAVAILABLE, STACK_DEPLOY_BACKUP_DECRYPT_FAILED
|
|
7
7
|
// render the release renders locally (deploy.mjs inspectRendered) its own STACKS_/STACK_DEPLOY_ refusal
|
|
8
8
|
// rollback an accepted previous release exists, so a red deploy has somewhere to go back to STACK_URGENT_NO_ROLLBACK_TARGET
|
|
9
|
-
// migrate db migrate previews the plan, then executes it
|
|
9
|
+
// migrate db migrate previews the plan, then executes it STACK_DEPLOY_MIGRATE_PREVIEW_RED, STACK_DEPLOY_MIGRATE_FAILED
|
|
10
10
|
// deploy the release with its readiness verify (deploy.verify); red rolls back by itself STACK_URGENT_RED_ROLLED_BACK, STACK_URGENT_ROLLBACK_FAILED,
|
|
11
11
|
// STACK_URGENT_DEPLOY_NOT_STARTED (red before the stack deploy)
|
|
12
12
|
// The public-host checks are never skipped (STACK_URGENT_SKIP_PUBLIC). A rollback cannot undo the migrations: the backup above is their way back.
|
|
@@ -20,101 +20,28 @@
|
|
|
20
20
|
// STACK_FIRST_START_USAGE (no --db-service), STACK_FIRST_START_NO_DATABASE (no container accepted connections in time),
|
|
21
21
|
// STACK_FIRST_START_NOT_EMPTY (a history or a public table: use the plain urgent floor), STACK_FIRST_START_MIGRATE_RED (preview or execute red);
|
|
22
22
|
// each rolls the release back to the previous one; the migrations a red first start applied stay in a database no release used before.
|
|
23
|
-
//
|
|
23
|
+
// The backup, decrypt, migrate and first-start steps are shared with the automatic deploy (db-phases.mjs), under the same STACK_DEPLOY_* codes; the
|
|
24
|
+
// urgent path keeps its operator-held --db-url-env and its always-on backup (the floor is never skipped). `deps` are the collaborators (backup,
|
|
25
|
+
// decrypt, migrate), each defaulting to the app's own db verbs; a spec injects stubs.
|
|
24
26
|
import fs from 'node:fs';
|
|
25
27
|
import os from 'node:os';
|
|
26
28
|
import path from 'node:path';
|
|
27
29
|
import { readDeclaration } from '../release/index.mjs';
|
|
28
30
|
import { releaseDir } from '../release/gate.mjs';
|
|
29
|
-
import { DEFAULT_DB_URL_ENV, dbTarget, inContainer
|
|
31
|
+
import { DEFAULT_DB_URL_ENV, dbTarget, inContainer } from '../db/postgres.mjs';
|
|
30
32
|
import { readUrgent } from '../release/records.mjs';
|
|
31
|
-
import { lines as splitLines, tool } from './context.mjs';
|
|
32
33
|
import { refusal } from './errors.mjs';
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const reasonOf = (error) => String(error?.code ?? error?.message ?? error);
|
|
37
|
-
/** The message of a thrown value. */
|
|
38
|
-
const messageOf = (error) => String(error?.message ?? error);
|
|
34
|
+
import { FIRST_START_WAIT_SECONDS, FLOOR_DEPS, backupProblems, firstStartStep as sharedFirstStartStep, messageOf, reasonOf, refused } from './db-phases.mjs';
|
|
35
|
+
import { localFacts } from './manager-backup.mjs';
|
|
36
|
+
export { FIRST_START_WAIT_SECONDS, FLOOR_DEPS };
|
|
39
37
|
/** The database a floor step reaches: the connection named by --db-url-env (default DATABASE_URL). */
|
|
40
38
|
const targetOf = (options) => (options.dbUrlEnv ? { urlEnv: options.dbUrlEnv } : {});
|
|
41
39
|
/** The backups of an urgent deploy live outside every repository: --backup-out, else ~/.starci-backups/<project>. */
|
|
42
40
|
const backupDir = (config, options) => options.backupOut ?? path.join(os.homedir(), '.starci-backups', config.project);
|
|
43
|
-
async function defaultBackup(ctx, { target, out }) {
|
|
44
|
-
const { dumpDatabase } = await import('../db/dump.mjs');
|
|
45
|
-
return dumpDatabase(ctx, { ...target, out }).data;
|
|
46
|
-
}
|
|
47
|
-
async function defaultDecrypt(ctx, { archive, identity, parts }) {
|
|
48
|
-
const { backupTool } = await import('../db/backup-receipt.mjs');
|
|
49
|
-
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'starci-decrypt-'));
|
|
50
|
-
try {
|
|
51
|
-
backupTool(ctx, 'age', ['-d', '-i', identity, '-o', path.join(scratch, 'backup.tar'), archive]);
|
|
52
|
-
const listed = String(backupTool(ctx, 'tar', ['-tf', 'backup.tar'], { cwd: scratch }).stdout ?? '').split(/\r?\n/u).map((line) => line.replace(/^\.\//u, '').trim());
|
|
53
|
-
const missing = parts.filter((part) => !listed.includes(part));
|
|
54
|
-
if (missing.length > 0)
|
|
55
|
-
throw new Error(`the decrypted archive lacks ${missing.join(', ')}`);
|
|
56
|
-
return { parts: listed.filter(Boolean) };
|
|
57
|
-
}
|
|
58
|
-
finally {
|
|
59
|
-
fs.rmSync(scratch, { recursive: true, force: true });
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
async function defaultMigrate(ctx, { target, execute }) {
|
|
63
|
-
const { loadDatabase, migrateDatabase } = await import('../db/migrate.mjs');
|
|
64
|
-
// The urgent deploy's --execute is the arming of this one migration: the floor runs it, it never waits for a second switch.
|
|
65
|
-
const armed = Object.create(ctx, { env: { value: { ...ctx.env, STARCI_DB_ARMED: '1' } } });
|
|
66
|
-
return migrateDatabase(armed, loadDatabase(ctx.cwd), { ...target, execute });
|
|
67
|
-
}
|
|
68
|
-
/** How long a first start waits for the database service's container to accept connections. */
|
|
69
|
-
export const FIRST_START_WAIT_SECONDS = 180;
|
|
70
|
-
/** The running container of `service` whose server accepts connections (pg_isready), polled until `seconds` pass; undefined when none did. */
|
|
71
|
-
async function defaultDbContainer(ctx, config, { service, seconds }) {
|
|
72
|
-
const deadline = ctx.now() + seconds * 1000;
|
|
73
|
-
for (;;) {
|
|
74
|
-
const found = tool(ctx, 'docker', ['ps', '-q', '--filter', `label=com.docker.swarm.service.name=${config.stack}_${service}`, '--filter', 'status=running']);
|
|
75
|
-
const id = splitLines(found.stdout)[0];
|
|
76
|
-
if (id && tool(ctx, 'docker', ['exec', id, 'pg_isready', '-q']).status === 0)
|
|
77
|
-
return id;
|
|
78
|
-
if (ctx.now() >= deadline)
|
|
79
|
-
return undefined;
|
|
80
|
-
await new Promise((resolve) => { setTimeout(resolve, 3000); });
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
/** Whether the target database is empty: no migration history table and no table in public. */
|
|
84
|
-
async function defaultEmptiness(ctx, target) {
|
|
85
|
-
const [row] = queryRows(ctx, target, "select to_regclass('supabase_migrations.schema_migrations') is not null as history, (select count(*)::int from pg_tables where schemaname = 'public') as public_tables", { withhold: true });
|
|
86
|
-
return { empty: row?.history === false && row?.public_tables === 0, history: row?.history === true, publicTables: Number(row?.public_tables ?? -1) };
|
|
87
|
-
}
|
|
88
|
-
export const FLOOR_DEPS = Object.freeze({ backup: defaultBackup, decrypt: defaultDecrypt, migrate: defaultMigrate, dbContainer: defaultDbContainer, emptiness: defaultEmptiness });
|
|
89
|
-
/** A refused floor step: the lines so far and the code; the CLI prints both and exits 2. */
|
|
90
|
-
const refused = (lines, code, message, data = {}) => ({ ok: false, refused: { code, message }, lines, data: { ...data, refused: code } });
|
|
91
41
|
/** Problems of the backup receipt against its archive: written by this run, a full backup, its size, age header and sha256 equal to the file's. */
|
|
92
42
|
function receiptProblems(receipt, archive, startedMs, sha256) {
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
return ['no receipt'];
|
|
96
|
-
if (receipt.schemaOnly !== false)
|
|
97
|
-
problems.push('the backup is not a full (data) backup');
|
|
98
|
-
if (!Number.isFinite(Date.parse(receipt.at)) || Date.parse(receipt.at) < startedMs - 1000)
|
|
99
|
-
problems.push('the receipt is not from this run');
|
|
100
|
-
if (!fs.existsSync(archive))
|
|
101
|
-
return [...problems, 'the archive is missing'];
|
|
102
|
-
const size = fs.statSync(archive).size;
|
|
103
|
-
const header = Buffer.alloc(AGE_HEADER.length);
|
|
104
|
-
const fd = fs.openSync(archive, 'r');
|
|
105
|
-
try {
|
|
106
|
-
fs.readSync(fd, header, 0, header.length, 0);
|
|
107
|
-
}
|
|
108
|
-
finally {
|
|
109
|
-
fs.closeSync(fd);
|
|
110
|
-
}
|
|
111
|
-
if (header.toString('ascii') !== AGE_HEADER)
|
|
112
|
-
problems.push('the archive has no age header');
|
|
113
|
-
if (size !== receipt.bytes)
|
|
114
|
-
problems.push(`the archive is ${size} bytes, the receipt says ${receipt.bytes}`);
|
|
115
|
-
if (!SHA.test(String(receipt.sha256 ?? '')) || sha256(archive) !== receipt.sha256)
|
|
116
|
-
problems.push('the archive sha256 differs from the receipt');
|
|
117
|
-
return problems;
|
|
43
|
+
const facts = localFacts(archive);
|
|
44
|
+
return backupProblems(receipt, facts.exists ? { ...facts, sha256: sha256(archive) } : facts, startedMs);
|
|
118
45
|
}
|
|
119
46
|
/** What the floor needs before anything runs: the pushed urgent record, a declared database, the identity of the test decrypt, the full public verify. */
|
|
120
47
|
function preconditions(ctx, config, options) {
|
|
@@ -135,7 +62,7 @@ function preconditions(ctx, config, options) {
|
|
|
135
62
|
}
|
|
136
63
|
const identity = ctx.env.SOPS_AGE_KEY_FILE;
|
|
137
64
|
if (!identity || !fs.existsSync(identity))
|
|
138
|
-
throw refusal('
|
|
65
|
+
throw refusal('STACK_DEPLOY_BACKUP_DECRYPT_UNAVAILABLE', 'the test decrypt of the backup reads the age identity named by SOPS_AGE_KEY_FILE, which is not set or not a file');
|
|
139
66
|
return { record, identity };
|
|
140
67
|
}
|
|
141
68
|
/**
|
|
@@ -150,19 +77,19 @@ async function guard(ctx, config, options, { f, run, identity, lines }) {
|
|
|
150
77
|
backup = await f.backup(ctx, { target, out: backupDir(config, options) });
|
|
151
78
|
}
|
|
152
79
|
catch (error) {
|
|
153
|
-
return refused(lines, '
|
|
80
|
+
return refused(lines, 'STACK_DEPLOY_BACKUP_FAILED', `the fresh database backup failed (${reasonOf(error)}); nothing was migrated or deployed`);
|
|
154
81
|
}
|
|
155
82
|
const archive = path.join(path.dirname(backup.receiptPath), backup.receipt?.file ?? '');
|
|
156
83
|
const problems = receiptProblems(backup.receipt, archive, started, run.sha256);
|
|
157
84
|
if (problems.length > 0)
|
|
158
|
-
return refused(lines, '
|
|
85
|
+
return refused(lines, 'STACK_DEPLOY_BACKUP_UNVERIFIED', `${problems.join('; ')}; nothing was migrated or deployed`);
|
|
159
86
|
const receipt = backup.receipt; // receiptProblems reports 'no receipt' when it is absent
|
|
160
87
|
lines.push(`floor: backup ${path.basename(archive)} (${receipt.bytes} bytes, sha256 ${receipt.sha256.slice(0, 12)}) receipt ${backup.receiptPath}`);
|
|
161
88
|
try {
|
|
162
89
|
await f.decrypt(ctx, { archive, identity, parts: receipt.parts });
|
|
163
90
|
}
|
|
164
91
|
catch (error) {
|
|
165
|
-
return refused(lines, '
|
|
92
|
+
return refused(lines, 'STACK_DEPLOY_BACKUP_DECRYPT_FAILED', `the test decrypt of the backup failed (${reasonOf(error)}); nothing was migrated or deployed`);
|
|
166
93
|
}
|
|
167
94
|
lines.push(`floor: test decrypt listed ${receipt.parts.length} part(s)`);
|
|
168
95
|
const { releaseId } = await run.render();
|
|
@@ -179,7 +106,7 @@ async function guard(ctx, config, options, { f, run, identity, lines }) {
|
|
|
179
106
|
}
|
|
180
107
|
lines.push(...(preview.lines ?? []).map((line) => `floor: migrate preview: ${line}`));
|
|
181
108
|
if (!preview.ok)
|
|
182
|
-
return refused(lines, '
|
|
109
|
+
return refused(lines, 'STACK_DEPLOY_MIGRATE_PREVIEW_RED', 'the migration preview is red; nothing was migrated or deployed');
|
|
183
110
|
let applied;
|
|
184
111
|
try {
|
|
185
112
|
applied = await f.migrate(ctx, { target, execute: true });
|
|
@@ -189,42 +116,19 @@ async function guard(ctx, config, options, { f, run, identity, lines }) {
|
|
|
189
116
|
}
|
|
190
117
|
lines.push(...(applied.lines ?? []).map((line) => `floor: migrate: ${line}`));
|
|
191
118
|
if (!applied.ok)
|
|
192
|
-
return refused(lines, '
|
|
119
|
+
return refused(lines, 'STACK_DEPLOY_MIGRATE_FAILED', `the migration failed; nothing was deployed (restore from ${backup.receiptPath} if the database is half migrated)`);
|
|
193
120
|
return null;
|
|
194
121
|
}
|
|
195
122
|
/**
|
|
196
|
-
* The `first-start` step of
|
|
197
|
-
*
|
|
123
|
+
* The `first-start` step of an urgent first start (between the hold deploy and the real one): the shared step (db-phases.mjs) against the database
|
|
124
|
+
* the operator's --db-url-env names, reached inside its container. Returns { ok, lines, code } where code names the failure.
|
|
198
125
|
*/
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const state = await f.emptiness(ctx, target);
|
|
206
|
-
if (!state.empty) {
|
|
207
|
-
const found = state.history ? 'a migration history' : `${state.publicTables < 0 ? 'an unreadable count of' : state.publicTables} table(s) in public`;
|
|
208
|
-
return { ok: false, code: 'STACK_FIRST_START_NOT_EMPTY', lines: [
|
|
209
|
-
`first start refused: the database of ${service} holds ${found}; a database with data takes the plain urgent floor (backup first)`,
|
|
210
|
-
// A first start that migrated and then rolled back leaves exactly this: a migrated volume and no serving release.
|
|
211
|
-
`if an earlier first start migrated this volume and then rolled back, and the owner confirms it holds no data, remove that volume (docker volume rm on the server) and run the first start again`,
|
|
212
|
-
] };
|
|
213
|
-
}
|
|
214
|
-
const lines = [`first start: the database of ${service} is empty (no migration history, no table in public): nothing to back up`];
|
|
215
|
-
for (const execute of [false, true]) {
|
|
216
|
-
let result;
|
|
217
|
-
try {
|
|
218
|
-
result = await f.migrate(ctx, { target: { target }, execute });
|
|
219
|
-
}
|
|
220
|
-
catch (error) {
|
|
221
|
-
result = { ok: false, lines: [messageOf(error)] };
|
|
222
|
-
}
|
|
223
|
-
lines.push(...(result.lines ?? []).map((line) => `first start: migrate${execute ? '' : ' preview'}: ${line}`));
|
|
224
|
-
if (!result.ok)
|
|
225
|
-
return { ok: false, code: 'STACK_FIRST_START_MIGRATE_RED', lines: [...lines, `first start: the migration${execute ? '' : ' preview'} is red`] };
|
|
226
|
-
}
|
|
227
|
-
return { ok: true, lines };
|
|
126
|
+
function firstStartStep(ctx, config, options, f) {
|
|
127
|
+
return sharedFirstStartStep(ctx, config, {
|
|
128
|
+
service: options.dbService, // preconditions required --db-service
|
|
129
|
+
targetFor: (container) => inContainer(dbTarget(ctx, options.dbUrlEnv ?? DEFAULT_DB_URL_ENV), container),
|
|
130
|
+
hint: 'a database with data takes the plain urgent floor (backup first)',
|
|
131
|
+
}, f);
|
|
228
132
|
}
|
|
229
133
|
/**
|
|
230
134
|
* A first start: the rollback target, then the deploy whose `first-start` step bootstraps and migrates the empty database. Returns the dry-run
|
|
@@ -262,6 +166,7 @@ async function firstStartDeploy(ctx, config, options, run, f, lines) {
|
|
|
262
166
|
export async function urgentDeploy(ctx, config, options, run, deps = {}) {
|
|
263
167
|
const f = { ...FLOOR_DEPS, ...deps };
|
|
264
168
|
const { record, identity } = preconditions(ctx, config, options);
|
|
169
|
+
run.note?.(record);
|
|
265
170
|
const lines = [`urgent ${record.tag} (${options.sourceSha.slice(0, 10)}) by ${record.role}: ${record.reason}`];
|
|
266
171
|
let deployed;
|
|
267
172
|
if (options.firstStart === true) {
|