anton-bakker-deploy-engine 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +818 -0
- package/dist/engine.d.ts +11 -1
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +164 -41
- package/dist/engine.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/pipeline.d.ts +6 -1
- package/dist/pipeline.d.ts.map +1 -0
- package/dist/pipeline.js +21 -1
- package/dist/pipeline.js.map +1 -0
- package/dist/types.d.ts +28 -3
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -1
- package/dist/types.js.map +1 -0
- package/dist/verify.d.ts +2 -17
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +3 -1
- package/dist/verify.js.map +1 -0
- package/package.json +6 -3
package/README.md
ADDED
|
@@ -0,0 +1,818 @@
|
|
|
1
|
+
# anton-bakker-deploy-engine
|
|
2
|
+
|
|
3
|
+
A self-validating AWS deployment verification library with a 4-layer verification model and a config-driven pipeline engine.
|
|
4
|
+
|
|
5
|
+
Zero runtime dependencies. TypeScript. ESM. Node.js ≥ 20.
|
|
6
|
+
|
|
7
|
+
## Table of Contents
|
|
8
|
+
|
|
9
|
+
- [Overview](#overview)
|
|
10
|
+
- [Installation](#installation)
|
|
11
|
+
- [Architecture](#architecture)
|
|
12
|
+
- [Verification Functions](#verification-functions)
|
|
13
|
+
- [verifyAmplifyOutputs](#verifyamplifyoutputs)
|
|
14
|
+
- [verifyBuildArtifact](#verifybuildartifact)
|
|
15
|
+
- [parseTableCount](#parsetablecount)
|
|
16
|
+
- [assertTableCount](#asserttablecount)
|
|
17
|
+
- [parseEcsStatus](#parseecsstatus)
|
|
18
|
+
- [assertCognitoGroups](#assertcognitogroups)
|
|
19
|
+
- [assertApiHealth](#assertapihealth)
|
|
20
|
+
- [deriveExpectations](#deriveexpectations)
|
|
21
|
+
- [summarize](#summarize)
|
|
22
|
+
- [Pipeline Engine](#pipeline-engine)
|
|
23
|
+
- [Concepts](#concepts)
|
|
24
|
+
- [Building Gates](#building-gates)
|
|
25
|
+
- [Writing Check Executors](#writing-check-executors)
|
|
26
|
+
- [Creating the Execution Context](#creating-the-execution-context)
|
|
27
|
+
- [Running the Pipeline](#running-the-pipeline)
|
|
28
|
+
- [State Persistence and Resume](#state-persistence-and-resume)
|
|
29
|
+
- [Clearing State](#clearing-state)
|
|
30
|
+
- [Default Environments](#default-environments)
|
|
31
|
+
- [Types Reference](#types-reference)
|
|
32
|
+
- [Publishing](#publishing)
|
|
33
|
+
- [Development](#development)
|
|
34
|
+
- [Licence](#licence)
|
|
35
|
+
|
|
36
|
+
## Overview
|
|
37
|
+
|
|
38
|
+
Every AWS deployment can fail silently — tables missing, ECS tasks crashing, environment variables leaking into production builds. This library provides composable verification functions and a pipeline engine that catches these problems automatically.
|
|
39
|
+
|
|
40
|
+
The 4-layer verification model:
|
|
41
|
+
|
|
42
|
+
| Layer | Question | Examples |
|
|
43
|
+
|-------|----------|---------|
|
|
44
|
+
| **Exists** | Is the resource present? | amplify_outputs.json readable, index JS bundle exists |
|
|
45
|
+
| **Configured** | Is it set up correctly? | Version is 1.4, data.url is HTTPS, no localhost leaks |
|
|
46
|
+
| **Functional** | Is it working at runtime? | ECS service healthy, API responds, DynamoDB tables present |
|
|
47
|
+
| **Project-specific** | Does it match our source of truth? | Table count matches schema, Cognito groups match auth stack |
|
|
48
|
+
|
|
49
|
+
You can use the verification functions standalone (for scripts, CI checks, post-deploy validation) or wire them into the pipeline engine for a full gated deployment workflow.
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npm install anton-bakker-deploy-engine
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Requires Node.js ≥ 20. Zero runtime dependencies — only uses Node.js built-ins (`fs`, `path`).
|
|
58
|
+
|
|
59
|
+
## Architecture
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
src/
|
|
63
|
+
├── types.ts # All type definitions (CheckResult, GateDefinition, etc.)
|
|
64
|
+
├── verify.ts # Standalone verification functions (Layer 1–4 checks)
|
|
65
|
+
├── pipeline.ts # Gate builder + default environment configs
|
|
66
|
+
├── engine.ts # PipelineEngine class (gate executor, state, resume)
|
|
67
|
+
└── index.ts # Public API — all exports
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Everything is exported from the package root:
|
|
71
|
+
|
|
72
|
+
```typescript
|
|
73
|
+
import {
|
|
74
|
+
// Verification functions
|
|
75
|
+
verifyAmplifyOutputs, verifyBuildArtifact, parseTableCount,
|
|
76
|
+
assertTableCount, parseEcsStatus, assertCognitoGroups,
|
|
77
|
+
assertApiHealth, deriveExpectations, summarize,
|
|
78
|
+
// Pipeline engine
|
|
79
|
+
PipelineEngine, buildGates, DEFAULT_ENVIRONMENTS,
|
|
80
|
+
// Types
|
|
81
|
+
type CheckResult, type GateDefinition, type GateResult,
|
|
82
|
+
type EnvironmentConfig, type PipelineState, type PipelineReport,
|
|
83
|
+
type CheckExecutor, type ExecutionContext,
|
|
84
|
+
} from 'anton-bakker-deploy-engine';
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Verification Functions
|
|
88
|
+
|
|
89
|
+
All verification functions return `CheckResult` or `CheckResult[]`. Every result has:
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
interface CheckResult {
|
|
93
|
+
name: string; // Human-readable check description
|
|
94
|
+
passed: boolean; // Did it pass?
|
|
95
|
+
expected?: string | number; // What was expected
|
|
96
|
+
actual?: string | number; // What was found
|
|
97
|
+
duration?: number; // Milliseconds (set by pipeline engine)
|
|
98
|
+
error?: string; // Error message when passed === false
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### verifyAmplifyOutputs
|
|
103
|
+
|
|
104
|
+
Validates that `amplify_outputs.json` has all required fields for a working Amplify deployment.
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
function verifyAmplifyOutputs(outputsPath: string): CheckResult[]
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**Checks performed:**
|
|
111
|
+
- `version` field is present and equals `"1.4"`
|
|
112
|
+
- `data.url` is present and starts with `https://`
|
|
113
|
+
- `auth.user_pool_id` is present
|
|
114
|
+
- `auth.user_pool_client_id` is present
|
|
115
|
+
|
|
116
|
+
**Example:**
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
const checks = verifyAmplifyOutputs('./amplify_outputs.json');
|
|
120
|
+
// Returns 6 CheckResults
|
|
121
|
+
|
|
122
|
+
// All passed?
|
|
123
|
+
if (checks.every(c => c.passed)) {
|
|
124
|
+
console.log('amplify_outputs.json is valid');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Find failures
|
|
128
|
+
const failures = checks.filter(c => !c.passed);
|
|
129
|
+
failures.forEach(f => console.error(`${f.name}: ${f.error}`));
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
**Error handling:** If the file doesn't exist or contains invalid JSON, returns a single failed `CheckResult` with the error message.
|
|
133
|
+
|
|
134
|
+
### verifyBuildArtifact
|
|
135
|
+
|
|
136
|
+
Scans the built JavaScript bundle for environment leaks that should never reach production.
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
function verifyBuildArtifact(distDir: string): CheckResult[]
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
**Parameters:**
|
|
143
|
+
- `distDir` — path to the build output directory (expects `dist/assets/index-*.js`)
|
|
144
|
+
|
|
145
|
+
**Checks performed:**
|
|
146
|
+
- No `localhost:4000` in the bundle (GraphQL sandbox leak)
|
|
147
|
+
- No `localhost:5173` in the bundle (Vite dev server leak)
|
|
148
|
+
- No `http://...elb.amazonaws.com` URLs (insecure ALB URLs)
|
|
149
|
+
|
|
150
|
+
**Example:**
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
const checks = verifyBuildArtifact('./dist');
|
|
154
|
+
const leaks = checks.filter(c => !c.passed);
|
|
155
|
+
if (leaks.length > 0) {
|
|
156
|
+
console.error('Environment leaks detected in build:');
|
|
157
|
+
leaks.forEach(l => console.error(` ${l.name}: ${l.actual}`));
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**Error handling:** Returns a failed result if `dist/assets` doesn't exist or no `index-*.js` file is found.
|
|
163
|
+
|
|
164
|
+
### parseTableCount
|
|
165
|
+
|
|
166
|
+
Parses DynamoDB table count from AWS CLI output, filtering by a table name prefix.
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
function parseTableCount(
|
|
170
|
+
awsOutput: string | null,
|
|
171
|
+
prefix: string
|
|
172
|
+
): CheckResult & { count: number }
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
**Parameters:**
|
|
176
|
+
- `awsOutput` — raw output from `aws dynamodb list-tables` (JSON array of table names) or a plain number string
|
|
177
|
+
- `prefix` — table name prefix to filter by (e.g. `"s20-dev"`)
|
|
178
|
+
|
|
179
|
+
**Example:**
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
import { execSync } from 'child_process';
|
|
183
|
+
|
|
184
|
+
const output = execSync('aws dynamodb list-tables --output json --query TableNames').toString();
|
|
185
|
+
const result = parseTableCount(output, 's20-dev');
|
|
186
|
+
console.log(`Found ${result.count} tables with prefix s20-dev`);
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
**Accepts two input formats:**
|
|
190
|
+
1. JSON array: `["s20-dev-Employee", "s20-dev-Division", "other-table"]` → filters by prefix, returns count
|
|
191
|
+
2. Plain number: `"124\n"` → parses as integer
|
|
192
|
+
|
|
193
|
+
### assertTableCount
|
|
194
|
+
|
|
195
|
+
Verifies the actual table count meets an expected minimum.
|
|
196
|
+
|
|
197
|
+
```typescript
|
|
198
|
+
function assertTableCount(actual: number, expected: number): CheckResult
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
**Example:**
|
|
202
|
+
|
|
203
|
+
```typescript
|
|
204
|
+
const { count } = parseTableCount(awsOutput, 's20-dev');
|
|
205
|
+
const check = assertTableCount(count, 124);
|
|
206
|
+
if (!check.passed) {
|
|
207
|
+
console.error(check.error); // "4 tables missing"
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### parseEcsStatus
|
|
212
|
+
|
|
213
|
+
Parses ECS service health from AWS CLI JSON output.
|
|
214
|
+
|
|
215
|
+
```typescript
|
|
216
|
+
function parseEcsStatus(awsOutput: string | null): CheckResult
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
**Parameters:**
|
|
220
|
+
- `awsOutput` — JSON string with `status`, `running`/`runningCount`, and `desired`/`desiredCount` fields
|
|
221
|
+
|
|
222
|
+
**Passes when:** `status === 'ACTIVE'` AND `running >= desired` AND `desired > 0`
|
|
223
|
+
|
|
224
|
+
**Example:**
|
|
225
|
+
|
|
226
|
+
```typescript
|
|
227
|
+
const output = execSync(`aws ecs describe-services \
|
|
228
|
+
--cluster my-cluster --services my-service \
|
|
229
|
+
--query 'services[0].{status:status,running:runningCount,desired:desiredCount}'`
|
|
230
|
+
).toString();
|
|
231
|
+
|
|
232
|
+
const check = parseEcsStatus(output);
|
|
233
|
+
if (!check.passed) {
|
|
234
|
+
console.error(check.error); // "2 tasks not running"
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
**Accepts both field name styles:** `running`/`desired` and `runningCount`/`desiredCount`.
|
|
239
|
+
|
|
240
|
+
### assertCognitoGroups
|
|
241
|
+
|
|
242
|
+
Verifies that all required Cognito user pool groups exist.
|
|
243
|
+
|
|
244
|
+
```typescript
|
|
245
|
+
function assertCognitoGroups(
|
|
246
|
+
actualGroups: string[],
|
|
247
|
+
requiredGroups: string[]
|
|
248
|
+
): CheckResult
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
**Example:**
|
|
252
|
+
|
|
253
|
+
```typescript
|
|
254
|
+
const output = execSync(`aws cognito-idp list-groups \
|
|
255
|
+
--user-pool-id eu-central-1_ABC \
|
|
256
|
+
--query 'Groups[].GroupName' --output json`
|
|
257
|
+
).toString();
|
|
258
|
+
const actual = JSON.parse(output);
|
|
259
|
+
|
|
260
|
+
const check = assertCognitoGroups(actual, ['administrators', 'managers', 'root']);
|
|
261
|
+
if (!check.passed) {
|
|
262
|
+
console.error(check.error); // "Missing: managers, root"
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### assertApiHealth
|
|
267
|
+
|
|
268
|
+
Verifies an API health check response contains `__typename` (GraphQL introspection indicator).
|
|
269
|
+
|
|
270
|
+
```typescript
|
|
271
|
+
function assertApiHealth(response: string | null): CheckResult
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
**Example:**
|
|
275
|
+
|
|
276
|
+
```typescript
|
|
277
|
+
const response = execSync('curl -s https://api.example.com/graphql -d \'{"query":"{__typename}"}\'').toString();
|
|
278
|
+
const check = assertApiHealth(response);
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
### deriveExpectations
|
|
282
|
+
|
|
283
|
+
Derives expected deployment values from project source-of-truth files. Reads a GraphQL schema to count model types (which become DynamoDB tables), parses an auth stack for Cognito group definitions, and reads infrastructure config for the API domain.
|
|
284
|
+
|
|
285
|
+
```typescript
|
|
286
|
+
function deriveExpectations(opts: {
|
|
287
|
+
schemaPath: string; // Path to GraphQL schema file
|
|
288
|
+
authStackPath?: string; // Path to CDK auth stack (optional)
|
|
289
|
+
configPath: string; // Path to infrastructure config JSON
|
|
290
|
+
}): DeployExpectations
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
**Returns:**
|
|
294
|
+
|
|
295
|
+
```typescript
|
|
296
|
+
interface DeployExpectations {
|
|
297
|
+
tableCount: number; // Number of model types (= expected DynamoDB tables)
|
|
298
|
+
tableNames: string[]; // Model type names
|
|
299
|
+
cognitoGroups: string[]; // Group names found in auth stack
|
|
300
|
+
apiDomain: string; // Constructed from config dns.apiSubdomain + dns.zoneName
|
|
301
|
+
}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
**Schema parsing rules:**
|
|
305
|
+
- Counts all `type X {` definitions in the GraphQL schema
|
|
306
|
+
- Excludes: `Query`, `Mutation`, `Subscription`, types ending in `Input`, types ending in `Connection`
|
|
307
|
+
|
|
308
|
+
**Auth stack parsing:** Finds Cognito group names via two patterns:
|
|
309
|
+
- Direct assignment: `groupName: 'administrators'`
|
|
310
|
+
- Loop pattern: `for (... of ['administrators', 'managers', 'root'])`
|
|
311
|
+
|
|
312
|
+
**Example:**
|
|
313
|
+
|
|
314
|
+
```typescript
|
|
315
|
+
const expectations = deriveExpectations({
|
|
316
|
+
schemaPath: 'server/src/schema/schema.graphql',
|
|
317
|
+
authStackPath: 'cdk/lib/auth-stack.ts',
|
|
318
|
+
configPath: 'cdk/config/infrastructure.json',
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
console.log(`Expecting ${expectations.tableCount} DynamoDB tables`);
|
|
322
|
+
console.log(`Required Cognito groups: ${expectations.cognitoGroups.join(', ')}`);
|
|
323
|
+
console.log(`API domain: ${expectations.apiDomain}`);
|
|
324
|
+
|
|
325
|
+
// Use with assertTableCount
|
|
326
|
+
const tableCheck = assertTableCount(actualCount, expectations.tableCount);
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
**Config file format expected:**
|
|
330
|
+
|
|
331
|
+
```json
|
|
332
|
+
{
|
|
333
|
+
"dns": {
|
|
334
|
+
"apiSubdomain": "api",
|
|
335
|
+
"zoneName": "example.com"
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
If `dns.apiSubdomain` or `dns.zoneName` are missing, defaults to `api.example.com`.
|
|
341
|
+
|
|
342
|
+
### summarize
|
|
343
|
+
|
|
344
|
+
Aggregates an array of check results into a single pass/fail summary.
|
|
345
|
+
|
|
346
|
+
```typescript
|
|
347
|
+
function summarize(checks: CheckResult[]): {
|
|
348
|
+
passed: boolean; // true if all checks passed
|
|
349
|
+
total: number; // total number of checks
|
|
350
|
+
failed: number; // number of failed checks
|
|
351
|
+
errors: string[]; // error messages from failed checks
|
|
352
|
+
}
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
**Example:**
|
|
356
|
+
|
|
357
|
+
```typescript
|
|
358
|
+
const allChecks = [
|
|
359
|
+
...verifyAmplifyOutputs('./amplify_outputs.json'),
|
|
360
|
+
...verifyBuildArtifact('./dist'),
|
|
361
|
+
assertTableCount(count, expected),
|
|
362
|
+
parseEcsStatus(ecsOutput),
|
|
363
|
+
];
|
|
364
|
+
|
|
365
|
+
const result = summarize(allChecks);
|
|
366
|
+
if (result.passed) {
|
|
367
|
+
console.log(`✅ All ${result.total} checks passed`);
|
|
368
|
+
} else {
|
|
369
|
+
console.error(`❌ ${result.failed}/${result.total} checks failed:`);
|
|
370
|
+
result.errors.forEach(e => console.error(` - ${e}`));
|
|
371
|
+
process.exit(1);
|
|
372
|
+
}
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
## Pipeline Engine
|
|
376
|
+
|
|
377
|
+
The pipeline engine runs a sequence of gates, where each gate contains one or more checks. Gates execute in order. When a gate fails, the engine takes the configured action (abort, rollback, or alert) and stops.
|
|
378
|
+
|
|
379
|
+
### Concepts
|
|
380
|
+
|
|
381
|
+
**Gate** — a named group of checks with a failure action. Example: "Post-Deploy Inventory" gate runs `table-count`, `ecs-status`, and `cognito-groups` checks. If any fail, the action is `rollback`.
|
|
382
|
+
|
|
383
|
+
**Check executor** — an async function that runs a single named check and returns a `CheckResult`. You register these with the engine.
|
|
384
|
+
|
|
385
|
+
**Execution context** — environment information passed to every check executor (environment name, region, project root, a shell exec helper, etc.).
|
|
386
|
+
|
|
387
|
+
**State persistence** — the engine saves its state to disk after each gate. If a run is interrupted, it resumes from the last passed gate.
|
|
388
|
+
|
|
389
|
+
### Building Gates
|
|
390
|
+
|
|
391
|
+
Use `buildGates()` to generate a gate sequence from an `EnvironmentConfig`:
|
|
392
|
+
|
|
393
|
+
```typescript
|
|
394
|
+
import { buildGates, DEFAULT_ENVIRONMENTS } from 'anton-bakker-deploy-engine';
|
|
395
|
+
|
|
396
|
+
const gates = buildGates(DEFAULT_ENVIRONMENTS.staging);
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
This produces 10 gates in order:
|
|
400
|
+
|
|
401
|
+
| # | Gate | Checks | On Fail |
|
|
402
|
+
|---|------|--------|---------|
|
|
403
|
+
| 1 | Preflight | `aws-creds`, `deploy-lock` | abort |
|
|
404
|
+
| 2 | Code Quality | From `preDeployChecks` config | abort |
|
|
405
|
+
| 3 | Infrastructure Validation | `cdk-synth`, `derive-expectations` | abort |
|
|
406
|
+
| 4 | Pre-Deploy Safety | `backup-tables` (if enabled), `prepare-migrations` (if enabled) | abort |
|
|
407
|
+
| 5 | Build | `docker-build`, `frontend-build`, `post-build-verify` | abort |
|
|
408
|
+
| 6 | Deploy | `cdk-deploy`, `ecs-update`, `frontend-deploy`, `seed-data` (if enabled) | rollback |
|
|
409
|
+
| 7 | Post-Deploy Inventory | `table-count`, `ecs-status`, `cognito-groups` | rollback |
|
|
410
|
+
| 8 | Post-Deploy Configuration | `pitr-enabled`, `billing-mode`, `https-enforced`, `s3-not-public` | alert |
|
|
411
|
+
| 9 | Post-Deploy Functional | From `postDeployProbes` config | rollback or alert |
|
|
412
|
+
| 10 | Project Assertions | `manifest-assertions`, `e2e-tests` (if enabled) | alert |
|
|
413
|
+
|
|
414
|
+
Gates 4 and 10 are conditional — they're skipped if the relevant config flags are off.
|
|
415
|
+
|
|
416
|
+
You can also build gates manually:
|
|
417
|
+
|
|
418
|
+
```typescript
|
|
419
|
+
import type { GateDefinition } from 'anton-bakker-deploy-engine';
|
|
420
|
+
|
|
421
|
+
const gates: GateDefinition[] = [
|
|
422
|
+
{
|
|
423
|
+
id: 'preflight',
|
|
424
|
+
name: 'Preflight',
|
|
425
|
+
checks: ['aws-creds'],
|
|
426
|
+
onFail: 'abort',
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
id: 'deploy',
|
|
430
|
+
name: 'Deploy',
|
|
431
|
+
checks: ['cdk-deploy'],
|
|
432
|
+
onFail: 'rollback',
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
id: 'verify',
|
|
436
|
+
name: 'Post-Deploy Verification',
|
|
437
|
+
checks: ['api-health', 'table-count'],
|
|
438
|
+
onFail: 'rollback',
|
|
439
|
+
},
|
|
440
|
+
];
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
**Failure actions:**
|
|
444
|
+
- `abort` — stop the pipeline, do nothing else
|
|
445
|
+
- `rollback` — stop the pipeline, signal that a rollback is needed (the engine sets `result: 'rolled-back'`)
|
|
446
|
+
- `alert` — stop the pipeline, signal that an alert should be sent
|
|
447
|
+
|
|
448
|
+
**Conditional gates:** Add a `condition` function that receives the `EnvironmentConfig`. The gate is skipped if it returns `false`:
|
|
449
|
+
|
|
450
|
+
```typescript
|
|
451
|
+
{
|
|
452
|
+
id: 'backup',
|
|
453
|
+
name: 'Backup',
|
|
454
|
+
checks: ['backup-tables'],
|
|
455
|
+
onFail: 'abort',
|
|
456
|
+
condition: (env) => env.backupBefore, // Only run if backups are enabled
|
|
457
|
+
}
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
### Writing Check Executors
|
|
461
|
+
|
|
462
|
+
A check executor is an async function that receives the `ExecutionContext` and returns a `CheckResult`:
|
|
463
|
+
|
|
464
|
+
```typescript
|
|
465
|
+
import type { CheckExecutor } from 'anton-bakker-deploy-engine';
|
|
466
|
+
|
|
467
|
+
const typecheckExecutor: CheckExecutor = async (ctx) => {
|
|
468
|
+
const result = ctx.exec('npx tsc --noEmit');
|
|
469
|
+
return {
|
|
470
|
+
name: 'TypeScript type check',
|
|
471
|
+
passed: result !== null,
|
|
472
|
+
error: result === null ? 'tsc --noEmit failed' : undefined,
|
|
473
|
+
};
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const tableCountExecutor: CheckExecutor = async (ctx) => {
|
|
477
|
+
const output = ctx.exec(`aws dynamodb list-tables --query TableNames --output json`);
|
|
478
|
+
const { count } = parseTableCount(output, ctx.prefix);
|
|
479
|
+
const expected = ctx.expectations?.tableCount as number ?? 0;
|
|
480
|
+
return assertTableCount(count, expected);
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
const apiHealthExecutor: CheckExecutor = async (ctx) => {
|
|
484
|
+
const response = ctx.exec(`curl -sf https://${ctx.prefix}.example.com/health`);
|
|
485
|
+
return assertApiHealth(response);
|
|
486
|
+
};
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
Register executors in a `Map<string, CheckExecutor>`:
|
|
490
|
+
|
|
491
|
+
```typescript
|
|
492
|
+
const checks = new Map<string, CheckExecutor>();
|
|
493
|
+
checks.set('typecheck', typecheckExecutor);
|
|
494
|
+
checks.set('table-count', tableCountExecutor);
|
|
495
|
+
checks.set('api-health', apiHealthExecutor);
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
The check name in the map must match the check name referenced in gate definitions.
|
|
499
|
+
|
|
500
|
+
If a check executor throws an exception, the engine catches it and records a failed `CheckResult` with the error message.
|
|
501
|
+
|
|
502
|
+
If a gate references a check name that has no registered executor, the engine records a failed result with `"No executor registered for 'name'"`.
|
|
503
|
+
|
|
504
|
+
### Creating the Execution Context
|
|
505
|
+
|
|
506
|
+
The `ExecutionContext` provides environment information to all check executors:
|
|
507
|
+
|
|
508
|
+
```typescript
|
|
509
|
+
import type { ExecutionContext } from 'anton-bakker-deploy-engine';
|
|
510
|
+
import { execSync } from 'child_process';
|
|
511
|
+
|
|
512
|
+
const ctx: ExecutionContext = {
|
|
513
|
+
environment: 'staging', // Full environment name
|
|
514
|
+
envShort: 'stg', // Short code for prefixes
|
|
515
|
+
prefix: 's20-stg', // Resource name prefix
|
|
516
|
+
region: 'eu-central-1', // AWS region
|
|
517
|
+
account: '123456789012', // AWS account ID
|
|
518
|
+
projectRoot: process.cwd(), // Project root directory
|
|
519
|
+
dryRun: false, // If true, no state is persisted
|
|
520
|
+
envConfig: DEFAULT_ENVIRONMENTS.staging,
|
|
521
|
+
expectations: { // Optional — from deriveExpectations()
|
|
522
|
+
tableCount: 124,
|
|
523
|
+
cognitoGroups: ['administrators', 'managers'],
|
|
524
|
+
},
|
|
525
|
+
exec: (cmd) => { // Shell command executor
|
|
526
|
+
try {
|
|
527
|
+
return execSync(cmd, { encoding: 'utf-8', timeout: 60_000 });
|
|
528
|
+
} catch {
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
},
|
|
532
|
+
log: (msg) => process.stdout.write(msg + '\n'),
|
|
533
|
+
};
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
**Fields:**
|
|
537
|
+
|
|
538
|
+
| Field | Type | Description |
|
|
539
|
+
|-------|------|-------------|
|
|
540
|
+
| `environment` | `string` | Full environment name (`development`, `staging`, `production`) |
|
|
541
|
+
| `envShort` | `string` | Short code used in resource prefixes (`dev`, `stg`, `prod`) |
|
|
542
|
+
| `prefix` | `string` | Resource name prefix (e.g. `s20-dev`) |
|
|
543
|
+
| `region` | `string` | AWS region |
|
|
544
|
+
| `account` | `string` | AWS account ID |
|
|
545
|
+
| `projectRoot` | `string` | Absolute path to project root |
|
|
546
|
+
| `dryRun` | `boolean` | When `true`, no state files are written to disk |
|
|
547
|
+
| `envConfig` | `EnvironmentConfig` | The environment configuration driving this run |
|
|
548
|
+
| `expectations` | `Record<string, unknown>` | Optional key-value store for derived expectations |
|
|
549
|
+
| `exec` | `(cmd: string) => string \| null` | Runs a shell command, returns stdout or `null` on failure |
|
|
550
|
+
| `log` | `(msg: string) => void` | Logging function |
|
|
551
|
+
|
|
552
|
+
### Running the Pipeline
|
|
553
|
+
|
|
554
|
+
```typescript
|
|
555
|
+
import { PipelineEngine, buildGates, DEFAULT_ENVIRONMENTS } from 'anton-bakker-deploy-engine';
|
|
556
|
+
|
|
557
|
+
const envConfig = DEFAULT_ENVIRONMENTS.staging;
|
|
558
|
+
const gates = buildGates(envConfig);
|
|
559
|
+
|
|
560
|
+
const engine = new PipelineEngine({
|
|
561
|
+
gates,
|
|
562
|
+
checks, // Map<string, CheckExecutor>
|
|
563
|
+
ctx, // ExecutionContext
|
|
564
|
+
stateDir: './orchestrator', // Optional — defaults to <projectRoot>/orchestrator
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
const report = await engine.run();
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
**Constructor options:**
|
|
571
|
+
|
|
572
|
+
| Option | Type | Required | Default | Description |
|
|
573
|
+
|--------|------|----------|---------|-------------|
|
|
574
|
+
| `gates` | `GateDefinition[]` | Yes | — | Ordered list of gates to execute |
|
|
575
|
+
| `checks` | `Map<string, CheckExecutor>` | Yes | — | Registered check executors |
|
|
576
|
+
| `ctx` | `ExecutionContext` | Yes | — | Execution context |
|
|
577
|
+
| `stateDir` | `string` | No | `<projectRoot>/orchestrator` | Directory for state and report files |
|
|
578
|
+
|
|
579
|
+
**The `run()` method returns a `PipelineReport`:**
|
|
580
|
+
|
|
581
|
+
```typescript
|
|
582
|
+
interface PipelineReport {
|
|
583
|
+
state: PipelineState;
|
|
584
|
+
summary: {
|
|
585
|
+
totalGates: number;
|
|
586
|
+
passed: number;
|
|
587
|
+
failed: number;
|
|
588
|
+
skipped: number;
|
|
589
|
+
duration: number; // Total milliseconds
|
|
590
|
+
failedGate?: string; // ID of the gate that failed
|
|
591
|
+
action?: 'abort' | 'rollback' | 'alert'; // Failure action
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
**Handling the result:**
|
|
597
|
+
|
|
598
|
+
```typescript
|
|
599
|
+
const report = await engine.run();
|
|
600
|
+
|
|
601
|
+
switch (report.state.result) {
|
|
602
|
+
case 'passed':
|
|
603
|
+
console.log(`✅ All ${report.summary.totalGates} gates passed in ${report.summary.duration}ms`);
|
|
604
|
+
break;
|
|
605
|
+
case 'failed':
|
|
606
|
+
console.error(`❌ Failed at gate: ${report.summary.failedGate} → ${report.summary.action}`);
|
|
607
|
+
break;
|
|
608
|
+
case 'rolled-back':
|
|
609
|
+
console.error(`🔄 Rolled back at gate: ${report.summary.failedGate}`);
|
|
610
|
+
// Trigger actual rollback logic here
|
|
611
|
+
break;
|
|
612
|
+
}
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
### State Persistence and Resume
|
|
616
|
+
|
|
617
|
+
The engine saves its state to `<stateDir>/deploy-state-<environment>.json` after each gate completes. If a run is interrupted (process crash, network failure, timeout), the next run automatically resumes from the last passed gate.
|
|
618
|
+
|
|
619
|
+
**State file location:** `orchestrator/deploy-state-staging.json`
|
|
620
|
+
|
|
621
|
+
**Resume behaviour:**
|
|
622
|
+
- Gates that already passed are skipped with a log message
|
|
623
|
+
- The failed gate is re-executed
|
|
624
|
+
- New gates run normally
|
|
625
|
+
|
|
626
|
+
**Reports:** On successful completion, the engine also saves a report to `<stateDir>/deploy-reports/<timestamp>-<envShort>.json`.
|
|
627
|
+
|
|
628
|
+
**Dry run mode:** When `ctx.dryRun` is `true`, no state or report files are written. Useful for testing.
|
|
629
|
+
|
|
630
|
+
### Clearing State
|
|
631
|
+
|
|
632
|
+
To force a fresh run (discard previous state), archive the state file:
|
|
633
|
+
|
|
634
|
+
```typescript
|
|
635
|
+
PipelineEngine.clearState('./orchestrator', 'staging');
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
This moves `deploy-state-staging.json` to `orchestrator/archive/state-<timestamp>.json`. The next `engine.run()` starts from gate 1.
|
|
639
|
+
|
|
640
|
+
### Default Environments
|
|
641
|
+
|
|
642
|
+
`DEFAULT_ENVIRONMENTS` provides pre-built configurations for three environments:
|
|
643
|
+
|
|
644
|
+
**development:**
|
|
645
|
+
|
|
646
|
+
```typescript
|
|
647
|
+
{
|
|
648
|
+
approval: 'never',
|
|
649
|
+
preDeployChecks: ['typecheck', 'lint', 'server-tests', 'schema-parse', 'merge-markers'],
|
|
650
|
+
migrations: true,
|
|
651
|
+
seed: { enabled: true, skipCalendar: true },
|
|
652
|
+
e2eTests: false,
|
|
653
|
+
backupBefore: false,
|
|
654
|
+
cdkNagLevel: 'warning',
|
|
655
|
+
postDeployProbes: ['api-health', 'table-count', 'ecs-status'],
|
|
656
|
+
rollbackOnProbeFailure: false,
|
|
657
|
+
}
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
**staging:**
|
|
661
|
+
|
|
662
|
+
```typescript
|
|
663
|
+
{
|
|
664
|
+
approval: 'never',
|
|
665
|
+
preDeployChecks: ['typecheck', 'lint', 'server-tests', 'web-tests', 'schema-parse', 'merge-markers'],
|
|
666
|
+
migrations: true,
|
|
667
|
+
seed: { enabled: true, skipCalendar: false },
|
|
668
|
+
e2eTests: true,
|
|
669
|
+
e2eTestSuite: 'web/e2e/migration/',
|
|
670
|
+
backupBefore: true,
|
|
671
|
+
cdkNagLevel: 'error',
|
|
672
|
+
postDeployProbes: ['api-health', 'table-count', 'ecs-status', 'cognito-groups', 'crud-probe'],
|
|
673
|
+
rollbackOnProbeFailure: true,
|
|
674
|
+
}
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
**production:**
|
|
678
|
+
|
|
679
|
+
```typescript
|
|
680
|
+
{
|
|
681
|
+
approval: 'broadening',
|
|
682
|
+
preDeployChecks: ['typecheck', 'lint', 'server-tests', 'web-tests', 'schema-parse',
|
|
683
|
+
'merge-markers', 'schema-backward-compat'],
|
|
684
|
+
migrations: true,
|
|
685
|
+
seed: { enabled: false },
|
|
686
|
+
e2eTests: true,
|
|
687
|
+
e2eTestSuite: 'web/e2e/smoke/',
|
|
688
|
+
backupBefore: true,
|
|
689
|
+
cdkNagLevel: 'error',
|
|
690
|
+
postDeployProbes: ['api-health', 'table-count', 'ecs-status', 'cognito-groups',
|
|
691
|
+
'crud-probe', 'auth-probe', 's3-probe'],
|
|
692
|
+
rollbackOnProbeFailure: true,
|
|
693
|
+
}
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
**EnvironmentConfig fields:**
|
|
697
|
+
|
|
698
|
+
| Field | Type | Description |
|
|
699
|
+
|-------|------|-------------|
|
|
700
|
+
| `approval` | `'never' \| 'broadening' \| 'always'` | When to require human approval |
|
|
701
|
+
| `preDeployChecks` | `string[]` | Check names to run in the Code Quality gate |
|
|
702
|
+
| `migrations` | `boolean` | Whether to run database migrations |
|
|
703
|
+
| `seed` | `{ enabled, skipCalendar? }` | Whether to seed data after deploy |
|
|
704
|
+
| `e2eTests` | `boolean` | Whether to run E2E tests |
|
|
705
|
+
| `e2eTestSuite` | `string` | Path to E2E test suite |
|
|
706
|
+
| `backupBefore` | `boolean` | Whether to backup DynamoDB tables before deploy |
|
|
707
|
+
| `cdkNagLevel` | `'warning' \| 'error'` | CDK Nag severity level |
|
|
708
|
+
| `postDeployProbes` | `string[]` | Check names to run in the Functional gate |
|
|
709
|
+
| `rollbackOnProbeFailure` | `boolean` | Whether functional probe failures trigger rollback |
|
|
710
|
+
|
|
711
|
+
You can extend or override defaults:
|
|
712
|
+
|
|
713
|
+
```typescript
|
|
714
|
+
const myConfig: EnvironmentConfig = {
|
|
715
|
+
...DEFAULT_ENVIRONMENTS.staging,
|
|
716
|
+
backupBefore: false, // Skip backups for faster deploys
|
|
717
|
+
postDeployProbes: [...DEFAULT_ENVIRONMENTS.staging.postDeployProbes, 'custom-probe'],
|
|
718
|
+
};
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
## Types Reference
|
|
722
|
+
|
|
723
|
+
All types are exported from the package root.
|
|
724
|
+
|
|
725
|
+
| Type | Description |
|
|
726
|
+
|------|-------------|
|
|
727
|
+
| `CheckResult` | Single pass/fail check with name, expected, actual, error, duration |
|
|
728
|
+
| `GateDefinition` | Gate configuration: id, name, check names, failure action, optional condition |
|
|
729
|
+
| `GateResult` | Gate execution result: status, checks, timing |
|
|
730
|
+
| `EnvironmentConfig` | Environment-specific deployment configuration |
|
|
731
|
+
| `PipelineState` | Full pipeline run state: runId, environment, commit, gates, result |
|
|
732
|
+
| `PipelineReport` | Pipeline state + summary statistics |
|
|
733
|
+
| `CheckExecutor` | `(ctx: ExecutionContext) => Promise<CheckResult>` — async check function |
|
|
734
|
+
| `ExecutionContext` | Runtime context passed to every check executor |
|
|
735
|
+
| `DeployExpectations` | Output of `deriveExpectations()`: table count/names, Cognito groups, API domain |
|
|
736
|
+
|
|
737
|
+
## Publishing
|
|
738
|
+
|
|
739
|
+
The package is published to npm via `scripts/publish.sh`.
|
|
740
|
+
|
|
741
|
+
```bash
|
|
742
|
+
# Publish (auto-detects tag, auto-bumps patch if version exists)
|
|
743
|
+
npm run release:publish
|
|
744
|
+
|
|
745
|
+
# Publish explicitly to latest tag
|
|
746
|
+
npm run release:publish:latest
|
|
747
|
+
|
|
748
|
+
# Publish to beta tag
|
|
749
|
+
NPM_TAG=beta npm run release:publish
|
|
750
|
+
```
|
|
751
|
+
|
|
752
|
+
**What the publish script does:**
|
|
753
|
+
|
|
754
|
+
1. Commits and pushes any uncommitted changes
|
|
755
|
+
2. Runs tests (`npm test`)
|
|
756
|
+
3. Builds (`npm run build`)
|
|
757
|
+
4. Fetches the npm automation token from AWS Secrets Manager (`npm/automation-token/anton-bakker-deploy-engine`)
|
|
758
|
+
5. If the current version is already published, auto-bumps patch, commits, and pushes
|
|
759
|
+
6. Publishes to npm with a temporary `.npmrc` (token never stored on disk)
|
|
760
|
+
|
|
761
|
+
**Environment variables:**
|
|
762
|
+
|
|
763
|
+
| Variable | Default | Description |
|
|
764
|
+
|----------|---------|-------------|
|
|
765
|
+
| `AWS_PROFILE` | `BeyondAmbition` | AWS profile for Secrets Manager access |
|
|
766
|
+
| `AWS_REGION` | `eu-west-1` | AWS region for Secrets Manager |
|
|
767
|
+
| `NPM_SECRET_NAME` | `npm/automation-token/anton-bakker-deploy-engine` | Secrets Manager secret ID |
|
|
768
|
+
| `NPM_TAG` | `latest` | npm publish tag |
|
|
769
|
+
| `REGISTRY` | `https://registry.npmjs.org/` | npm registry URL |
|
|
770
|
+
|
|
771
|
+
## Development
|
|
772
|
+
|
|
773
|
+
```bash
|
|
774
|
+
# Install dependencies
|
|
775
|
+
npm install
|
|
776
|
+
|
|
777
|
+
# Run tests
|
|
778
|
+
npm test
|
|
779
|
+
|
|
780
|
+
# Type check (no output)
|
|
781
|
+
npx tsc --noEmit
|
|
782
|
+
|
|
783
|
+
# Build (compiles to dist/)
|
|
784
|
+
npm run build
|
|
785
|
+
|
|
786
|
+
# Lint (zero warnings tolerance)
|
|
787
|
+
npm run lint
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
**Project structure:**
|
|
791
|
+
|
|
792
|
+
```
|
|
793
|
+
├── src/
|
|
794
|
+
│ ├── types.ts # Type definitions
|
|
795
|
+
│ ├── verify.ts # Verification functions
|
|
796
|
+
│ ├── engine.ts # PipelineEngine class
|
|
797
|
+
│ ├── pipeline.ts # Gate builder + default configs
|
|
798
|
+
│ └── index.ts # Public exports
|
|
799
|
+
├── __tests__/
|
|
800
|
+
│ ├── verify.test.ts # 48 tests for verification functions
|
|
801
|
+
│ ├── engine.test.ts # 13 tests for engine + pipeline
|
|
802
|
+
│ └── fixtures/ # Test fixtures (schema, config, auth stack)
|
|
803
|
+
├── scripts/
|
|
804
|
+
│ └── publish.sh # npm publish script
|
|
805
|
+
├── eslint.config.js # ESLint flat config
|
|
806
|
+
├── tsconfig.json # TypeScript config (strict, ESM, NodeNext)
|
|
807
|
+
└── package.json
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
**Test runner:** Vitest. All 61 tests run in ~200ms.
|
|
811
|
+
|
|
812
|
+
**TypeScript config:** Strict mode, ESM (`"type": "module"`), `NodeNext` module resolution, source maps and declaration maps enabled.
|
|
813
|
+
|
|
814
|
+
**ESLint rules:** `no-console: error`, `@typescript-eslint/no-unused-vars: error`.
|
|
815
|
+
|
|
816
|
+
## Licence
|
|
817
|
+
|
|
818
|
+
MIT
|