@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
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type { DbTarget as PostgresTarget } from '../db/postgres.mjs';
|
|
2
|
+
import type { DatabaseDecl, DatabaseTarget, MigrationPhase } from '../db/migrate.mjs';
|
|
3
|
+
import type { DeployConfig } from './config.mjs';
|
|
4
|
+
import type { StackContext } from './context.mjs';
|
|
5
|
+
import type { ArchiveFacts, ManagerBackup } from './manager-backup.mjs';
|
|
6
|
+
import type { ActionResult, Runner } from './remote.mjs';
|
|
7
|
+
/** The receipt of a database backup (db/backup-receipt.mjs). */
|
|
8
|
+
export interface BackupReceipt {
|
|
9
|
+
schemaOnly: boolean;
|
|
10
|
+
at: string;
|
|
11
|
+
file: string;
|
|
12
|
+
bytes: number;
|
|
13
|
+
sha256: string;
|
|
14
|
+
parts: string[];
|
|
15
|
+
}
|
|
16
|
+
/** What a backup hands back: where the receipt is and what it says. */
|
|
17
|
+
export interface BackupData {
|
|
18
|
+
receiptPath: string;
|
|
19
|
+
receipt?: BackupReceipt | undefined;
|
|
20
|
+
}
|
|
21
|
+
/** The database a floor step reaches (db/postgres.mjs): `{ urlEnv }`, `{}` or `{ target }`, spread into the migrate options. */
|
|
22
|
+
export type DbTarget = Record<string, unknown>;
|
|
23
|
+
/** The verdict of one migrate pass; `data` is migrate's (`done`, `push`, `pushFiles`, `pushed`). */
|
|
24
|
+
export interface MigrateResult {
|
|
25
|
+
ok: boolean;
|
|
26
|
+
lines?: string[] | undefined;
|
|
27
|
+
data?: Record<string, unknown> | undefined;
|
|
28
|
+
}
|
|
29
|
+
/** Whether the database is empty, and why not. */
|
|
30
|
+
export interface Emptiness {
|
|
31
|
+
empty: boolean;
|
|
32
|
+
history: boolean;
|
|
33
|
+
publicTables: number;
|
|
34
|
+
}
|
|
35
|
+
/** The outcome of the `first-start` step. */
|
|
36
|
+
export interface FirstStartStepResult {
|
|
37
|
+
ok: boolean;
|
|
38
|
+
code?: string;
|
|
39
|
+
lines: string[];
|
|
40
|
+
}
|
|
41
|
+
/** The collaborators of the database steps, each defaulting to the app's own db verbs; a spec injects stubs. */
|
|
42
|
+
export interface FloorDeps {
|
|
43
|
+
backup: (ctx: StackContext, args: {
|
|
44
|
+
target: DbTarget;
|
|
45
|
+
out: string;
|
|
46
|
+
}) => Promise<BackupData>;
|
|
47
|
+
decrypt: (ctx: StackContext, args: {
|
|
48
|
+
archive: string;
|
|
49
|
+
identity: string | null;
|
|
50
|
+
parts: string[];
|
|
51
|
+
}) => Promise<{
|
|
52
|
+
parts: string[];
|
|
53
|
+
}>;
|
|
54
|
+
migrate: (ctx: StackContext, args: {
|
|
55
|
+
target: DbTarget;
|
|
56
|
+
execute: boolean;
|
|
57
|
+
phase?: MigrationPhase | undefined;
|
|
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
|
+
/** The database of a running container, reached with the credentials that container holds. */
|
|
65
|
+
containerTarget: (ctx: StackContext, container: string) => PostgresTarget;
|
|
66
|
+
/** The encrypted dump of a `stack` database, made and kept on the manager. */
|
|
67
|
+
managerBackup: (ctx: StackContext, config: DeployConfig, args: {
|
|
68
|
+
run: Runner;
|
|
69
|
+
container: string;
|
|
70
|
+
url: string;
|
|
71
|
+
recipients: string[];
|
|
72
|
+
at: string;
|
|
73
|
+
}) => Promise<ManagerBackup>;
|
|
74
|
+
/** The test decrypt of that dump on the manager with its own identity: the parts it lists. */
|
|
75
|
+
managerDecrypt: (ctx: StackContext, args: {
|
|
76
|
+
run: Runner;
|
|
77
|
+
archive: string;
|
|
78
|
+
}) => Promise<string[]>;
|
|
79
|
+
}
|
|
80
|
+
/** The code or message of a thrown value, for a refusal line. */
|
|
81
|
+
export declare const reasonOf: (error: unknown) => string;
|
|
82
|
+
/** The message of a thrown value. */
|
|
83
|
+
export declare const messageOf: (error: unknown) => string;
|
|
84
|
+
/** A refused step: the lines so far and the code; the CLI prints both and exits 2. */
|
|
85
|
+
export declare const refused: (lines: string[], code: string, message: string, data?: Record<string, unknown>) => ActionResult;
|
|
86
|
+
export declare const AGE_HEADER = "age-encryption.org/v1\n";
|
|
87
|
+
/** Problems of a backup receipt against the facts measured on its archive: a full backup written by this run, size, age header and sha256 equal. */
|
|
88
|
+
export declare function backupProblems(receipt: BackupReceipt | undefined, facts: ArchiveFacts, startedMs: number): string[];
|
|
89
|
+
/** How long a first start waits for the database service's container to accept connections. */
|
|
90
|
+
export declare const FIRST_START_WAIT_SECONDS = 180;
|
|
91
|
+
/** How long a deploy of a running stack waits for the database service's container to accept connections. */
|
|
92
|
+
export declare const DB_WAIT_SECONDS = 60;
|
|
93
|
+
export declare const FLOOR_DEPS: Readonly<FloorDeps>;
|
|
94
|
+
/**
|
|
95
|
+
* The `first-start` step of a first start's deploy (between the hold deploy and the real one): the database container, the proof it is empty,
|
|
96
|
+
* migrate preview then execute (every migration, contract ones included: no release runs yet) inside it. `targetFor` reaches the container's
|
|
97
|
+
* database; `hint` ends the not-empty message with what the operator does next. Returns { ok, lines, code } where code names the failure.
|
|
98
|
+
*/
|
|
99
|
+
export declare function firstStartStep(ctx: StackContext, config: DeployConfig, input: {
|
|
100
|
+
service: string;
|
|
101
|
+
targetFor: (container: string) => PostgresTarget;
|
|
102
|
+
hint: string;
|
|
103
|
+
}, f: FloorDeps): Promise<FirstStartStepResult>;
|
|
104
|
+
/** The database a deploy migrates: the `database:` block of application-stacks.yaml. */
|
|
105
|
+
export interface DeployDatabase {
|
|
106
|
+
decl: DatabaseDecl;
|
|
107
|
+
target: DatabaseTarget;
|
|
108
|
+
service: string;
|
|
109
|
+
}
|
|
110
|
+
/** The `database:` block of the app, or null when it declares none (then a deploy has no database phase at all). */
|
|
111
|
+
export declare function declaredDatabase(ctx: Pick<StackContext, 'cwd'>, config: Pick<DeployConfig, 'repoRoot'>): Promise<DeployDatabase | null>;
|
|
112
|
+
/** What the phases of one deploy are given. */
|
|
113
|
+
export interface PhaseInput {
|
|
114
|
+
ctx: StackContext;
|
|
115
|
+
config: DeployConfig;
|
|
116
|
+
database: DeployDatabase;
|
|
117
|
+
f: FloorDeps;
|
|
118
|
+
/** The manager's runner (a `stack` database's backup is made and kept there). */
|
|
119
|
+
run: Runner | null;
|
|
120
|
+
dbUrlEnv?: string | undefined;
|
|
121
|
+
backupOut?: string | undefined;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* A `stack` database must be a service of the stack the deploy renders: an app whose `database:` block exists but whose database is not one of its
|
|
125
|
+
* services declares `target: external`. Returns the refusal, or null when the service is there (or the stack's services are not known).
|
|
126
|
+
*/
|
|
127
|
+
export declare function unknownDatabaseService(config: Pick<DeployConfig, 'stack' | 'services'>, database: DeployDatabase): {
|
|
128
|
+
code: string;
|
|
129
|
+
message: string;
|
|
130
|
+
} | null;
|
|
131
|
+
/** One recorded step of a deploy. */
|
|
132
|
+
export interface PhaseStep {
|
|
133
|
+
step: string;
|
|
134
|
+
ok: boolean;
|
|
135
|
+
}
|
|
136
|
+
/** What the deploy keeps of its migrations (the deploy result's `data.migrations`; WP1's journal records it). */
|
|
137
|
+
export interface MigrationRecord {
|
|
138
|
+
/** Something was pending before the deploy. */
|
|
139
|
+
pending: boolean;
|
|
140
|
+
/** The file names of the expand and of the contract migrations that were pending. */
|
|
141
|
+
expand: string[];
|
|
142
|
+
contract: string[];
|
|
143
|
+
/** The verified backup taken because something was pending, else null (nothing pending: no backup). */
|
|
144
|
+
backup: {
|
|
145
|
+
receiptPath: string;
|
|
146
|
+
file: string;
|
|
147
|
+
bytes: number;
|
|
148
|
+
sha256: string;
|
|
149
|
+
where: 'manager' | 'local';
|
|
150
|
+
} | null;
|
|
151
|
+
/** The versions the expand pass and the contract pass applied. */
|
|
152
|
+
applied: string[];
|
|
153
|
+
contractApplied: string[];
|
|
154
|
+
}
|
|
155
|
+
/** The result of a phase: ok, or the refusal that stopped it; the steps it ran and the lines it printed either way. */
|
|
156
|
+
export interface PhaseResult {
|
|
157
|
+
ok: boolean;
|
|
158
|
+
refused?: {
|
|
159
|
+
code: string;
|
|
160
|
+
message: string;
|
|
161
|
+
} | undefined;
|
|
162
|
+
lines: string[];
|
|
163
|
+
steps: PhaseStep[];
|
|
164
|
+
record: MigrationRecord;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Before the stack deploy: preview, backup only when something is pending, then the expand migrations. Any refusal leaves the services untouched
|
|
168
|
+
* and carries its code; the migrations an execute applied before failing stay (the backup is their way back).
|
|
169
|
+
*/
|
|
170
|
+
export declare function expandPhase(p: PhaseInput): Promise<PhaseResult>;
|
|
171
|
+
/**
|
|
172
|
+
* After the accept: the `*_contract_*` migrations. A red contract leaves the release accepted (it is serving and verified); the caller reports a
|
|
173
|
+
* red job and the owner decides. Nothing pending is a green no-op without a database call beyond the preview.
|
|
174
|
+
*/
|
|
175
|
+
export declare function contractPhase(p: PhaseInput): Promise<{
|
|
176
|
+
ok: boolean;
|
|
177
|
+
lines: string[];
|
|
178
|
+
applied: string[];
|
|
179
|
+
skipped: boolean;
|
|
180
|
+
}>;
|
|
181
|
+
/** The first-start hooks of a `stack` database on an environment with no accepted release (the deploy's `deploy-hold` and `first-start` steps). */
|
|
182
|
+
export declare function firstStartHooks(p: PhaseInput): {
|
|
183
|
+
step: () => Promise<FirstStartStepResult>;
|
|
184
|
+
service: string;
|
|
185
|
+
};
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// db-phases.mjs - the database steps a deploy takes from the urgent safety floor (urgent-floor.mjs) into every deploy
|
|
2
|
+
// (docs: contexts/project/cicd.md, Database migrations; owner approval 2026-10-31). One module owns the steps, so the urgent path and the
|
|
3
|
+
// automatic path run the same backup verification, test decrypt, migrate preview and first start, under the same codes:
|
|
4
|
+
// STACK_DEPLOY_BACKUP_FAILED, STACK_DEPLOY_BACKUP_UNVERIFIED, STACK_DEPLOY_BACKUP_DECRYPT_UNAVAILABLE, STACK_DEPLOY_BACKUP_DECRYPT_FAILED,
|
|
5
|
+
// STACK_DEPLOY_MIGRATE_PREVIEW_RED, STACK_DEPLOY_MIGRATE_FAILED (urgent: formerly STACK_URGENT_BACKUP_*, _DECRYPT_*, _MIGRATE_*).
|
|
6
|
+
// The automatic path of a deploy (an app that declares the `database:` block of application-stacks.yaml):
|
|
7
|
+
// before the stack deploy db-preview migrate preview of every pending migration; red refuses (STACK_DEPLOY_MIGRATE_PREVIEW_RED)
|
|
8
|
+
// backup ONLY when something is pending: a fresh age-encrypted dump on the manager, receipt verified, test decrypt
|
|
9
|
+
// with the manager's own identity (STACK_DEPLOY_BACKUP_*); nothing pending means no backup and the record says so
|
|
10
|
+
// migrate the EXPAND migrations (everything but `*_contract_*`), inside the database container (STACK_DEPLOY_MIGRATE_FAILED)
|
|
11
|
+
// after accept contract the `*_contract_*` migrations; red leaves the release accepted (STACK_DEPLOY_CONTRACT_FAILED)
|
|
12
|
+
// no accepted release first start hold deploy with only the database running, prove it empty, migrate everything inside the container,
|
|
13
|
+
// then the real deploy; a database that is not empty is refused (STACK_FIRST_START_NOT_EMPTY)
|
|
14
|
+
// `database.target: stack` (default) reaches the database inside its own container with the credentials that container holds
|
|
15
|
+
// (db/postgres.mjs ownContainerTarget), so the deploy never holds a DATABASE_URL. `database.target: external` keeps `--db-url-env`.
|
|
16
|
+
// A refusal before the stack deploy leaves the services untouched. Every collaborator is a FloorDeps member, replaceable by a spec.
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { backupRecipients } from '../db/backup-receipt.mjs';
|
|
21
|
+
import { readDeclaration } from '../release/index.mjs';
|
|
22
|
+
import { DEFAULT_DB_URL_ENV, dbTarget, ownContainerTarget, queryRows } from '../db/postgres.mjs';
|
|
23
|
+
import { isContractMigration } from '../db/migrate.mjs';
|
|
24
|
+
import { lines as splitLines, tool } from './context.mjs';
|
|
25
|
+
import { refusal } from './errors.mjs';
|
|
26
|
+
/** The code or message of a thrown value, for a refusal line. */
|
|
27
|
+
export const reasonOf = (error) => String(error?.code ?? error?.message ?? error);
|
|
28
|
+
/** The message of a thrown value. */
|
|
29
|
+
export const messageOf = (error) => String(error?.message ?? error);
|
|
30
|
+
/** A refused step: the lines so far and the code; the CLI prints both and exits 2. */
|
|
31
|
+
export const refused = (lines, code, message, data = {}) => ({ ok: false, refused: { code, message }, lines, data: { ...data, refused: code } });
|
|
32
|
+
export const AGE_HEADER = 'age-encryption.org/v1\n';
|
|
33
|
+
const SHA = /^[a-f0-9]{64}$/u;
|
|
34
|
+
/** Problems of a backup receipt against the facts measured on its archive: a full backup written by this run, size, age header and sha256 equal. */
|
|
35
|
+
export function backupProblems(receipt, facts, startedMs) {
|
|
36
|
+
const problems = [];
|
|
37
|
+
if (!receipt || typeof receipt !== 'object')
|
|
38
|
+
return ['no receipt'];
|
|
39
|
+
if (receipt.schemaOnly !== false)
|
|
40
|
+
problems.push('the backup is not a full (data) backup');
|
|
41
|
+
if (!Number.isFinite(Date.parse(receipt.at)) || Date.parse(receipt.at) < startedMs - 1000)
|
|
42
|
+
problems.push('the receipt is not from this run');
|
|
43
|
+
if (!facts.exists)
|
|
44
|
+
return [...problems, 'the archive is missing'];
|
|
45
|
+
if (!facts.header)
|
|
46
|
+
problems.push('the archive has no age header');
|
|
47
|
+
if (facts.size !== receipt.bytes)
|
|
48
|
+
problems.push(`the archive is ${facts.size} bytes, the receipt says ${receipt.bytes}`);
|
|
49
|
+
if (!SHA.test(String(receipt.sha256 ?? '')) || facts.sha256 !== receipt.sha256)
|
|
50
|
+
problems.push('the archive sha256 differs from the receipt');
|
|
51
|
+
return problems;
|
|
52
|
+
}
|
|
53
|
+
async function defaultBackup(ctx, { target, out }) {
|
|
54
|
+
const { dumpDatabase } = await import('../db/dump.mjs');
|
|
55
|
+
return dumpDatabase(ctx, { ...target, out }).data;
|
|
56
|
+
}
|
|
57
|
+
async function defaultDecrypt(ctx, { archive, identity, parts }) {
|
|
58
|
+
const { backupTool } = await import('../db/backup-receipt.mjs');
|
|
59
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'starci-decrypt-'));
|
|
60
|
+
try {
|
|
61
|
+
backupTool(ctx, 'age', ['-d', '-i', identity, '-o', path.join(scratch, 'backup.tar'), archive]);
|
|
62
|
+
const listed = String(backupTool(ctx, 'tar', ['-tf', 'backup.tar'], { cwd: scratch }).stdout ?? '').split(/\r?\n/u).map((line) => line.replace(/^\.\//u, '').trim());
|
|
63
|
+
const missing = parts.filter((part) => !listed.includes(part));
|
|
64
|
+
if (missing.length > 0)
|
|
65
|
+
throw new Error(`the decrypted archive lacks ${missing.join(', ')}`);
|
|
66
|
+
return { parts: listed.filter(Boolean) };
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function defaultMigrate(ctx, { target, execute, phase }) {
|
|
73
|
+
const { loadDatabase, migrateDatabase } = await import('../db/migrate.mjs');
|
|
74
|
+
// The deploy's --execute is the arming of this one migration: the step runs it, it never waits for a second switch.
|
|
75
|
+
const armed = Object.create(ctx, { env: { value: { ...ctx.env, STARCI_DB_ARMED: '1' } } });
|
|
76
|
+
return migrateDatabase(armed, loadDatabase(ctx.cwd), { ...target, execute, ...(phase ? { phase } : {}) });
|
|
77
|
+
}
|
|
78
|
+
/** How long a first start waits for the database service's container to accept connections. */
|
|
79
|
+
export const FIRST_START_WAIT_SECONDS = 180;
|
|
80
|
+
/** How long a deploy of a running stack waits for the database service's container to accept connections. */
|
|
81
|
+
export const DB_WAIT_SECONDS = 60;
|
|
82
|
+
/** The running container of `service` whose server accepts connections (pg_isready), polled until `seconds` pass; undefined when none did. */
|
|
83
|
+
async function defaultDbContainer(ctx, config, { service, seconds }) {
|
|
84
|
+
const deadline = ctx.now() + seconds * 1000;
|
|
85
|
+
for (;;) {
|
|
86
|
+
const found = tool(ctx, 'docker', ['ps', '-q', '--filter', `label=com.docker.swarm.service.name=${config.stack}_${service}`, '--filter', 'status=running']);
|
|
87
|
+
const id = splitLines(found.stdout)[0];
|
|
88
|
+
if (id && tool(ctx, 'docker', ['exec', id, 'pg_isready', '-q']).status === 0)
|
|
89
|
+
return id;
|
|
90
|
+
if (ctx.now() >= deadline)
|
|
91
|
+
return undefined;
|
|
92
|
+
await new Promise((resolve) => { setTimeout(resolve, 3000); });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Whether the target database is empty: no migration history table and no table in public. */
|
|
96
|
+
async function defaultEmptiness(ctx, target) {
|
|
97
|
+
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 });
|
|
98
|
+
return { empty: row?.history === false && row?.public_tables === 0, history: row?.history === true, publicTables: Number(row?.public_tables ?? -1) };
|
|
99
|
+
}
|
|
100
|
+
const DEFAULT_FLOOR = {
|
|
101
|
+
backup: defaultBackup, decrypt: defaultDecrypt, migrate: defaultMigrate, dbContainer: defaultDbContainer, emptiness: defaultEmptiness,
|
|
102
|
+
containerTarget: ownContainerTarget,
|
|
103
|
+
managerBackup: async (ctx, config, args) => (await import('./manager-backup.mjs')).managerBackup(ctx, config, args),
|
|
104
|
+
managerDecrypt: async (ctx, args) => (await import('./manager-backup.mjs')).managerDecrypt(ctx, args),
|
|
105
|
+
};
|
|
106
|
+
export const FLOOR_DEPS = Object.freeze(DEFAULT_FLOOR);
|
|
107
|
+
/**
|
|
108
|
+
* The `first-start` step of a first start's deploy (between the hold deploy and the real one): the database container, the proof it is empty,
|
|
109
|
+
* migrate preview then execute (every migration, contract ones included: no release runs yet) inside it. `targetFor` reaches the container's
|
|
110
|
+
* database; `hint` ends the not-empty message with what the operator does next. Returns { ok, lines, code } where code names the failure.
|
|
111
|
+
*/
|
|
112
|
+
export async function firstStartStep(ctx, config, input, f) {
|
|
113
|
+
const service = `${config.stack}_${input.service}`;
|
|
114
|
+
const container = await f.dbContainer(ctx, config, { service: input.service, seconds: FIRST_START_WAIT_SECONDS });
|
|
115
|
+
if (!container)
|
|
116
|
+
return { ok: false, code: 'STACK_FIRST_START_NO_DATABASE', lines: [`first start: no running container of ${service} accepted connections within ${FIRST_START_WAIT_SECONDS}s`] };
|
|
117
|
+
const target = input.targetFor(container);
|
|
118
|
+
const state = await f.emptiness(ctx, target);
|
|
119
|
+
if (!state.empty) {
|
|
120
|
+
const found = state.history ? 'a migration history' : `${state.publicTables < 0 ? 'an unreadable count of' : state.publicTables} table(s) in public`;
|
|
121
|
+
return { ok: false, code: 'STACK_FIRST_START_NOT_EMPTY', lines: [
|
|
122
|
+
`first start refused: the database of ${service} holds ${found}; ${input.hint}`,
|
|
123
|
+
// A first start that migrated and then rolled back leaves exactly this: a migrated volume and no serving release.
|
|
124
|
+
`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`,
|
|
125
|
+
] };
|
|
126
|
+
}
|
|
127
|
+
const lines = [`first start: the database of ${service} is empty (no migration history, no table in public): nothing to back up`];
|
|
128
|
+
for (const execute of [false, true]) {
|
|
129
|
+
let result;
|
|
130
|
+
try {
|
|
131
|
+
result = await f.migrate(ctx, { target: { target }, execute });
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
result = { ok: false, lines: [messageOf(error)] };
|
|
135
|
+
}
|
|
136
|
+
lines.push(...(result.lines ?? []).map((line) => `first start: migrate${execute ? '' : ' preview'}: ${line}`));
|
|
137
|
+
if (!result.ok)
|
|
138
|
+
return { ok: false, code: 'STACK_FIRST_START_MIGRATE_RED', lines: [...lines, `first start: the migration${execute ? '' : ' preview'} is red`] };
|
|
139
|
+
}
|
|
140
|
+
return { ok: true, lines };
|
|
141
|
+
}
|
|
142
|
+
/** The `database:` block of the app, or null when it declares none (then a deploy has no database phase at all). */
|
|
143
|
+
export async function declaredDatabase(ctx, config) {
|
|
144
|
+
const root = config.repoRoot ?? ctx.cwd;
|
|
145
|
+
if (typeof root !== 'string' || !readDeclaration(root).database)
|
|
146
|
+
return null;
|
|
147
|
+
const { loadDatabase } = await import('../db/migrate.mjs');
|
|
148
|
+
const decl = loadDatabase(root);
|
|
149
|
+
return { decl, target: decl.target, service: decl.service };
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* A `stack` database must be a service of the stack the deploy renders: an app whose `database:` block exists but whose database is not one of its
|
|
153
|
+
* services declares `target: external`. Returns the refusal, or null when the service is there (or the stack's services are not known).
|
|
154
|
+
*/
|
|
155
|
+
export function unknownDatabaseService(config, database) {
|
|
156
|
+
if (database.target !== 'stack' || !(config.services instanceof Set) || config.services.has(database.service))
|
|
157
|
+
return null;
|
|
158
|
+
return { code: 'STACK_DEPLOY_DB_SERVICE_UNKNOWN', message: `database.service ${database.service} is not a service of the stack ${config.stack}: name the stack service that runs the database, or declare database.target: external for a database outside the stack; nothing was migrated or deployed` };
|
|
159
|
+
}
|
|
160
|
+
const emptyRecord = () => ({ pending: false, expand: [], contract: [], backup: null, applied: [], contractApplied: [] });
|
|
161
|
+
const pending = (result) => result.data?.done !== true;
|
|
162
|
+
const filesOf = (result) => (Array.isArray(result.data?.pushFiles) ? result.data.pushFiles.map(String) : []);
|
|
163
|
+
const versionsOf = (result) => (Array.isArray(result.data?.pushed) ? result.data.pushed.map(String) : []);
|
|
164
|
+
/** Reaches the database: a `stack` one inside its own container, an `external` one by the URL `--db-url-env` names. */
|
|
165
|
+
async function reach(p, seconds) {
|
|
166
|
+
const { ctx, config, database, f } = p;
|
|
167
|
+
if (database.target === 'external') {
|
|
168
|
+
const urlEnv = p.dbUrlEnv ?? DEFAULT_DB_URL_ENV;
|
|
169
|
+
return { pg: dbTarget(ctx, urlEnv), migrate: { urlEnv }, backupTarget: { urlEnv }, container: null };
|
|
170
|
+
}
|
|
171
|
+
if (p.dbUrlEnv)
|
|
172
|
+
throw refusal('STACK_DEPLOY_DB_URL_UNEXPECTED', `database.target is stack: migrations run inside the database container and hold no URL; --db-url-env belongs to database.target: external`);
|
|
173
|
+
const container = await f.dbContainer(ctx, config, { service: database.service, seconds });
|
|
174
|
+
if (!container)
|
|
175
|
+
throw refusal('STACK_DEPLOY_DB_UNAVAILABLE', `no running container of ${config.stack}_${database.service} accepts connections; nothing was migrated or deployed`);
|
|
176
|
+
const pg = f.containerTarget(ctx, container);
|
|
177
|
+
return { pg, migrate: { target: pg }, backupTarget: {}, container };
|
|
178
|
+
}
|
|
179
|
+
/** The backup of a `stack` database: made and kept on the manager, verified, test-decrypted. Returns the backup or the refusal. */
|
|
180
|
+
async function stackBackup(p, reached, lines, started) {
|
|
181
|
+
const { ctx, config, f } = p;
|
|
182
|
+
if (!p.run || reached.container === null)
|
|
183
|
+
return { code: 'STACK_DEPLOY_BACKUP_FAILED', message: 'the backup is made on the manager and no connection to it is open; nothing was migrated or deployed' };
|
|
184
|
+
const recipients = [...new Set([...backupRecipients(config.repoRoot), ...(config.remote.serverRecipient ? [config.remote.serverRecipient] : [])])];
|
|
185
|
+
let made;
|
|
186
|
+
try {
|
|
187
|
+
made = await f.managerBackup(ctx, config, { run: p.run, container: reached.container, url: reached.pg.url, recipients, at: new Date(ctx.now()).toISOString() });
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
return { code: 'STACK_DEPLOY_BACKUP_FAILED', message: `the fresh database backup failed (${reasonOf(error)}); nothing was migrated or deployed` };
|
|
191
|
+
}
|
|
192
|
+
const problems = backupProblems(made.receipt, made.facts, started);
|
|
193
|
+
if (problems.length > 0)
|
|
194
|
+
return { code: 'STACK_DEPLOY_BACKUP_UNVERIFIED', message: `${problems.join('; ')}; nothing was migrated or deployed` };
|
|
195
|
+
lines.push(`db: backup ${path.posix.basename(made.archive)} on the manager (${made.receipt.bytes} bytes, sha256 ${made.receipt.sha256.slice(0, 12)}) receipt ${made.receiptPath}`);
|
|
196
|
+
try {
|
|
197
|
+
const listed = await f.managerDecrypt(ctx, { run: p.run, archive: made.archive });
|
|
198
|
+
const missing = made.receipt.parts.filter((part) => !listed.includes(part));
|
|
199
|
+
if (missing.length > 0)
|
|
200
|
+
throw new Error(`the decrypted archive lacks ${missing.join(', ')}`);
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
return { code: 'STACK_DEPLOY_BACKUP_DECRYPT_FAILED', message: `the test decrypt of the backup on the manager failed (${reasonOf(error)}); nothing was migrated or deployed` };
|
|
204
|
+
}
|
|
205
|
+
lines.push(`db: test decrypt on the manager listed ${made.receipt.parts.length} part(s)`);
|
|
206
|
+
return { backup: { receiptPath: made.receiptPath, file: made.receipt.file, bytes: made.receipt.bytes, sha256: made.receipt.sha256, where: 'manager' } };
|
|
207
|
+
}
|
|
208
|
+
/** The backup of an `external` database: dumped where the deploy runs (`--backup-out`, else ~/.starci-backups/<project>), verified, test-decrypted. */
|
|
209
|
+
async function externalBackup(p, reached, lines, started) {
|
|
210
|
+
const { ctx, config, f } = p;
|
|
211
|
+
const identity = ctx.env.SOPS_AGE_KEY_FILE;
|
|
212
|
+
if (!identity || !fs.existsSync(identity))
|
|
213
|
+
return { code: 'STACK_DEPLOY_BACKUP_DECRYPT_UNAVAILABLE', message: 'the test decrypt of the backup reads the age identity named by SOPS_AGE_KEY_FILE, which is not set or not a file' };
|
|
214
|
+
let backup;
|
|
215
|
+
try {
|
|
216
|
+
backup = await f.backup(ctx, { target: reached.backupTarget, out: p.backupOut ?? path.join(os.homedir(), '.starci-backups', config.project) });
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
return { code: 'STACK_DEPLOY_BACKUP_FAILED', message: `the fresh database backup failed (${reasonOf(error)}); nothing was migrated or deployed` };
|
|
220
|
+
}
|
|
221
|
+
const receipt = backup.receipt;
|
|
222
|
+
const archive = path.join(path.dirname(backup.receiptPath), receipt?.file ?? '');
|
|
223
|
+
const { localFacts } = await import('./manager-backup.mjs');
|
|
224
|
+
const problems = backupProblems(receipt, localFacts(archive), started);
|
|
225
|
+
if (problems.length > 0 || !receipt)
|
|
226
|
+
return { code: 'STACK_DEPLOY_BACKUP_UNVERIFIED', message: `${problems.join('; ')}; nothing was migrated or deployed` };
|
|
227
|
+
lines.push(`db: backup ${path.basename(archive)} (${receipt.bytes} bytes, sha256 ${receipt.sha256.slice(0, 12)}) receipt ${backup.receiptPath}`);
|
|
228
|
+
try {
|
|
229
|
+
await f.decrypt(ctx, { archive, identity, parts: receipt.parts });
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
return { code: 'STACK_DEPLOY_BACKUP_DECRYPT_FAILED', message: `the test decrypt of the backup failed (${reasonOf(error)}); nothing was migrated or deployed` };
|
|
233
|
+
}
|
|
234
|
+
lines.push(`db: test decrypt listed ${receipt.parts.length} part(s)`);
|
|
235
|
+
return { backup: { receiptPath: backup.receiptPath, file: receipt.file, bytes: receipt.bytes, sha256: receipt.sha256, where: 'local' } };
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Before the stack deploy: preview, backup only when something is pending, then the expand migrations. Any refusal leaves the services untouched
|
|
239
|
+
* and carries its code; the migrations an execute applied before failing stay (the backup is their way back).
|
|
240
|
+
*/
|
|
241
|
+
export async function expandPhase(p) {
|
|
242
|
+
const { ctx, f } = p;
|
|
243
|
+
const lines = [];
|
|
244
|
+
const steps = [];
|
|
245
|
+
const record = emptyRecord();
|
|
246
|
+
const stop = (step, code, message) => { steps.push({ step, ok: false }); return { ok: false, refused: { code, message }, lines, steps, record }; };
|
|
247
|
+
const started = ctx.now();
|
|
248
|
+
let reached;
|
|
249
|
+
try {
|
|
250
|
+
reached = await reach(p, DB_WAIT_SECONDS);
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
return stop('db-preview', error.code ?? 'STACK_DEPLOY_DB_UNAVAILABLE', messageOf(error).replace(/^[A-Z_]+: /u, ''));
|
|
254
|
+
}
|
|
255
|
+
let preview;
|
|
256
|
+
try {
|
|
257
|
+
preview = await f.migrate(ctx, { target: reached.migrate, execute: false, phase: 'all' });
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
preview = { ok: false, lines: [messageOf(error)] };
|
|
261
|
+
}
|
|
262
|
+
lines.push(...(preview.lines ?? []).map((line) => `db: migrate preview: ${line}`));
|
|
263
|
+
if (!preview.ok)
|
|
264
|
+
return stop('db-preview', 'STACK_DEPLOY_MIGRATE_PREVIEW_RED', 'the migration preview is red; nothing was backed up, migrated or deployed');
|
|
265
|
+
steps.push({ step: 'db-preview', ok: true });
|
|
266
|
+
record.pending = pending(preview);
|
|
267
|
+
const files = filesOf(preview);
|
|
268
|
+
record.contract = files.filter(isContractMigration);
|
|
269
|
+
record.expand = files.filter((file) => !record.contract.includes(file));
|
|
270
|
+
if (!record.pending) {
|
|
271
|
+
lines.push('db: no migration is pending: no backup was taken and nothing is migrated');
|
|
272
|
+
return { ok: true, lines, steps, record };
|
|
273
|
+
}
|
|
274
|
+
// The contract migrations count: they destroy data after accept, so a release that only carries them still gets its backup first.
|
|
275
|
+
const made = await (p.database.target === 'external' ? externalBackup(p, reached, lines, started) : stackBackup(p, reached, lines, started));
|
|
276
|
+
if ('code' in made)
|
|
277
|
+
return stop('backup', made.code, made.message);
|
|
278
|
+
record.backup = made.backup;
|
|
279
|
+
steps.push({ step: 'backup', ok: true });
|
|
280
|
+
let applied;
|
|
281
|
+
try {
|
|
282
|
+
applied = await f.migrate(ctx, { target: reached.migrate, execute: true, phase: 'expand' });
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
applied = { ok: false, lines: [messageOf(error)] };
|
|
286
|
+
}
|
|
287
|
+
lines.push(...(applied.lines ?? []).map((line) => `db: migrate: ${line}`));
|
|
288
|
+
if (!applied.ok)
|
|
289
|
+
return stop('migrate', 'STACK_DEPLOY_MIGRATE_FAILED', `the migration failed; nothing was deployed (what it applied stays; restore from ${record.backup?.receiptPath ?? 'the backup'} if the database is half migrated)`);
|
|
290
|
+
record.applied = versionsOf(applied);
|
|
291
|
+
steps.push({ step: 'migrate', ok: true });
|
|
292
|
+
if (record.contract.length > 0)
|
|
293
|
+
lines.push(`db: ${record.contract.length} contract migration(s) wait for the accept: ${record.contract.join(', ')}`);
|
|
294
|
+
return { ok: true, lines, steps, record };
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* After the accept: the `*_contract_*` migrations. A red contract leaves the release accepted (it is serving and verified); the caller reports a
|
|
298
|
+
* red job and the owner decides. Nothing pending is a green no-op without a database call beyond the preview.
|
|
299
|
+
*/
|
|
300
|
+
export async function contractPhase(p) {
|
|
301
|
+
const { ctx, f } = p;
|
|
302
|
+
const lines = [];
|
|
303
|
+
const fail = (text) => ({ ok: false, lines: [...lines, text], applied: [], skipped: false });
|
|
304
|
+
let reached;
|
|
305
|
+
try {
|
|
306
|
+
reached = await reach(p, DB_WAIT_SECONDS);
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
return fail(`contract: ${messageOf(error)}; the release stays accepted`);
|
|
310
|
+
}
|
|
311
|
+
let preview;
|
|
312
|
+
try {
|
|
313
|
+
preview = await f.migrate(ctx, { target: reached.migrate, execute: false, phase: 'contract' });
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
preview = { ok: false, lines: [messageOf(error)] };
|
|
317
|
+
}
|
|
318
|
+
lines.push(...(preview.lines ?? []).map((line) => `db: contract preview: ${line}`));
|
|
319
|
+
if (!preview.ok)
|
|
320
|
+
return fail('contract: the preview is red; nothing was applied and the release stays accepted');
|
|
321
|
+
if (!pending(preview))
|
|
322
|
+
return { ok: true, lines: [...lines, 'db: no contract migration is pending'], applied: [], skipped: true };
|
|
323
|
+
let applied;
|
|
324
|
+
try {
|
|
325
|
+
applied = await f.migrate(ctx, { target: reached.migrate, execute: true, phase: 'contract' });
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
applied = { ok: false, lines: [messageOf(error)] };
|
|
329
|
+
}
|
|
330
|
+
lines.push(...(applied.lines ?? []).map((line) => `db: contract: ${line}`));
|
|
331
|
+
if (!applied.ok)
|
|
332
|
+
return fail('contract: the migration failed; the release stays accepted and serving; the owner decides (the backup taken before this deploy is the way back for the data)');
|
|
333
|
+
return { ok: true, lines: [...lines, `db: ${versionsOf(applied).length} contract migration(s) applied after the accept`], applied: versionsOf(applied), skipped: false };
|
|
334
|
+
}
|
|
335
|
+
/** The first-start hooks of a `stack` database on an environment with no accepted release (the deploy's `deploy-hold` and `first-start` steps). */
|
|
336
|
+
export function firstStartHooks(p) {
|
|
337
|
+
const { ctx, config, f, database } = p;
|
|
338
|
+
return {
|
|
339
|
+
service: database.service,
|
|
340
|
+
step: () => firstStartStep(ctx, config, {
|
|
341
|
+
service: database.service,
|
|
342
|
+
targetFor: (container) => f.containerTarget(ctx, container),
|
|
343
|
+
hint: 'no release was ever accepted here, so a database that already holds data needs the owner: accept or restore its state deliberately, then deploy again',
|
|
344
|
+
}, f),
|
|
345
|
+
};
|
|
346
|
+
}
|
package/stack/deploy.d.mts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { DeployConfig } from './config.mjs';
|
|
2
2
|
import type { StackContext } from './context.mjs';
|
|
3
3
|
import type { ProvisionOptions } from './provision.mjs';
|
|
4
|
+
import type { ObservedService } from './status.mjs';
|
|
4
5
|
import type { ActionResult, Runner, SshOptions } from './remote.mjs';
|
|
5
6
|
import type { RenderOptions } from './render.mjs';
|
|
6
7
|
import type { ReleaseStore } from './release-store.mjs';
|
|
@@ -22,6 +23,12 @@ export interface DeployOptions {
|
|
|
22
23
|
dbService?: string | undefined;
|
|
23
24
|
reason?: string | undefined;
|
|
24
25
|
remote?: string | undefined;
|
|
26
|
+
/** The id of the CI run that is deploying (digits): the attempt's trigger is `ci` and its journal names the run. The evidence check is the CLI's (`--ci-run`). */
|
|
27
|
+
ciRun?: string | undefined;
|
|
28
|
+
/** Deploys even when the accepted release already runs this commit (the dispatch `redeploy`). */
|
|
29
|
+
redeploy?: boolean | undefined;
|
|
30
|
+
/** How long to wait for a held deploy lock, in seconds (default 600). */
|
|
31
|
+
lockWaitSeconds?: number | undefined;
|
|
25
32
|
/** The `first-start` step the urgent floor adds between edge and converge. */
|
|
26
33
|
firstStartStep?: (() => Promise<FirstStartStepResult>) | undefined;
|
|
27
34
|
/** The stack service of the database a first start bootstraps: the one service its hold deploy leaves running. */
|
|
@@ -33,6 +40,7 @@ export interface RollbackOptions {
|
|
|
33
40
|
execute?: boolean | undefined;
|
|
34
41
|
timeoutSeconds?: number | undefined;
|
|
35
42
|
sshCmd?: string | null | undefined;
|
|
43
|
+
lockWaitSeconds?: number | undefined;
|
|
36
44
|
}
|
|
37
45
|
/** What an accept takes. */
|
|
38
46
|
export interface AcceptOptions {
|
|
@@ -40,6 +48,8 @@ export interface AcceptOptions {
|
|
|
40
48
|
execute?: boolean | undefined;
|
|
41
49
|
retrySeconds?: number | undefined;
|
|
42
50
|
sshCmd?: string | null | undefined;
|
|
51
|
+
lockWaitSeconds?: number | undefined;
|
|
52
|
+
dbUrlEnv?: string | undefined;
|
|
43
53
|
}
|
|
44
54
|
/** The collaborators of a deploy (see the header); the specs inject stubs. */
|
|
45
55
|
export interface DeployDeps {
|
|
@@ -61,6 +71,7 @@ export interface DeployDeps {
|
|
|
61
71
|
execute: boolean;
|
|
62
72
|
}) => Promise<ActionResult>;
|
|
63
73
|
releaseStore: (config: DeployConfig, ssh: Runner) => ReleaseStore;
|
|
74
|
+
observedServices: (ctx: StackContext, config: DeployConfig) => ObservedService[];
|
|
64
75
|
ssh: (ctx: StackContext, config: DeployConfig, options: SshOptions) => Runner | Promise<Runner>;
|
|
65
76
|
floor: Partial<FloorDeps>;
|
|
66
77
|
}
|
|
@@ -70,19 +81,22 @@ export declare const importModule: (file: string) => Promise<any>;
|
|
|
70
81
|
export declare const lazy: <Args extends unknown[], Result>(file: string, name: string) => (...args: Args) => Promise<Result>;
|
|
71
82
|
export declare const DEFAULT_DEPS: Readonly<DeployDeps>;
|
|
72
83
|
/**
|
|
73
|
-
* Deploys a release: `options` = { images, sourceSha, execute, out, timeoutSeconds, retrySeconds, skipPublic, sshCmd, urgent, dbUrlEnv, backupOut }.
|
|
84
|
+
* Deploys a release: `options` = { images, sourceSha, execute, out, timeoutSeconds, retrySeconds, skipPublic, sshCmd, urgent, dbUrlEnv, backupOut, ciRun, redeploy }.
|
|
74
85
|
* skipPublic (the first deploy into a shared ingress, whose public routes exist only after `ingress publish`): the verification checks only what runs
|
|
75
86
|
* inside the stack, and the release stays PENDING (stored and deployed, pointer not moved) until `acceptRelease` has verified it in full.
|
|
76
|
-
*
|
|
87
|
+
* With `execute` the run is one recorded attempt under the deploy lock (journal.mjs); the accepted release that already runs this commit ends it green
|
|
88
|
+
* as `already-current` unless `redeploy` is set.
|
|
89
|
+
* Returns { ok, lines, data: { releaseId, previous, steps: [{ step, ok }], rolledBack, attempt } }.
|
|
77
90
|
*/
|
|
78
91
|
export declare function deployRelease(ctx: StackContext, config: DeployConfig, options?: DeployOptions, deps?: Partial<DeployDeps>): Promise<ActionResult>;
|
|
79
92
|
/**
|
|
80
93
|
* Redeploys a stored release (default: the `previous` pointer): `options` = { release, execute, timeoutSeconds, sshCmd }.
|
|
81
|
-
* `release` is read from `options` only and must be null or 16 lowercase hex.
|
|
94
|
+
* `release` is read from `options` only and must be null or 16 lowercase hex. With `execute` it is one recorded attempt under the deploy lock.
|
|
82
95
|
*/
|
|
83
96
|
export declare function rollbackRelease(ctx: StackContext, config: DeployConfig, options?: RollbackOptions, deps?: Partial<DeployDeps>): Promise<ActionResult>;
|
|
84
97
|
/**
|
|
85
98
|
* Accepts a release that a `skipPublic` deploy left pending: the FULL verification (public hosts and checks inside the stack) must pass first, then the
|
|
86
|
-
* pointer moves and the superseded secrets and configs are pruned. `options` = { release, execute, retrySeconds, sshCmd }.
|
|
99
|
+
* pointer moves and the superseded secrets and configs are pruned. `options` = { release, execute, retrySeconds, sshCmd }. With `execute` it is one recorded
|
|
100
|
+
* attempt under the deploy lock.
|
|
87
101
|
*/
|
|
88
102
|
export declare function acceptRelease(ctx: StackContext, config: DeployConfig, options?: AcceptOptions, deps?: Partial<DeployDeps>): Promise<ActionResult>;
|