@rehearsal-db/core 0.1.0-beta.5 → 0.1.0-beta.7
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 +56 -1
- package/COMPATIBILITY.md +5 -0
- package/README.md +112 -377
- package/SECURITY.md +2 -1
- package/docs/adapters.md +5 -0
- package/docs/baselines.md +81 -22
- package/docs/commands.md +105 -75
- package/docs/configuration.md +109 -8
- package/docs/getting-started.md +88 -227
- package/docs/glossary.md +7 -8
- package/docs/releasing.md +10 -1
- package/docs/roadmap.md +39 -0
- package/docs/security-model.md +5 -1
- package/docs/troubleshooting.md +69 -1
- package/docs/tutorial.md +70 -59
- package/package.json +4 -2
- package/scripts/lib/environment/local_supabase.mjs +8 -8
- package/scripts/lib/rehearsal/baseline_artifact.mjs +32 -3
- package/scripts/lib/rehearsal/cleanup.mjs +381 -0
- package/scripts/lib/rehearsal/configuration.d.mts +24 -3
- package/scripts/lib/rehearsal/configuration.mjs +337 -59
- package/scripts/lib/rehearsal/diagnostics.mjs +4 -2
- package/scripts/lib/rehearsal/plan.mjs +89 -14
- package/scripts/lib/rehearsal/runtime_restore.mjs +6 -2
- package/scripts/lib/rehearsal/setup.mjs +60 -28
- package/scripts/lib/runtime/postgresql_runtime.mjs +772 -0
- package/scripts/lib/runtime/runtime_target.mjs +41 -0
- package/scripts/lib/runtime/supabase_runtime.mjs +792 -0
- package/scripts/operations/database/manage_rehearsal_database.mjs +6 -785
- package/scripts/operations/rehearsal/rehearsal_cli.mjs +330 -31
package/docs/tutorial.md
CHANGED
|
@@ -1,96 +1,107 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Safe hands-on tutorial
|
|
2
2
|
|
|
3
|
-
This
|
|
4
|
-
|
|
3
|
+
This tutorial runs a complete PostgreSQL rehearsal in a temporary fictional project. It
|
|
4
|
+
does not use your application, production data, or a hosted database.
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
Allow about five minutes. You need Node.js 24 and a running Docker-compatible engine.
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
## 1. Prepare Rehearsal
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
If you do not already have this repository, clone it:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
git clone https://github.com/Ddupasquier/rehearsal-db.git
|
|
14
|
+
cd rehearsal-db
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
candidate migration:
|
|
17
|
+
Then run:
|
|
19
18
|
|
|
20
|
-
```
|
|
21
|
-
|
|
19
|
+
```bash
|
|
20
|
+
npm ci --ignore-scripts
|
|
21
|
+
docker pull postgres:17-alpine
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
If you already have this repository open, use that checkout.
|
|
25
25
|
|
|
26
|
-
##
|
|
26
|
+
## 2. Make a temporary project
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
ports and a unique project ID. Its `rehearsalConfig` must point at a dedicated, unlinked
|
|
30
|
-
Supabase config. Never reuse a hosted project reference or production environment file.
|
|
28
|
+
From the Rehearsal repository root, run this block exactly:
|
|
31
29
|
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
```bash
|
|
31
|
+
rehearsal_repo=$PWD
|
|
32
|
+
tutorial_dir=$(mktemp -d)
|
|
33
|
+
cp -R tests/fixtures/postgresql-project "$tutorial_dir/app"
|
|
34
|
+
cd "$tutorial_dir/app"
|
|
35
|
+
npm install --ignore-scripts --no-save "$rehearsal_repo"
|
|
36
|
+
```
|
|
34
37
|
|
|
35
|
-
|
|
38
|
+
The temporary project contains:
|
|
36
39
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
- one historical migration that creates a `widgets` table;
|
|
41
|
+
- one candidate migration that adds a `description` column;
|
|
42
|
+
- one synthetic row;
|
|
43
|
+
- a reviewed sanitization policy;
|
|
44
|
+
- an application proof that checks the migrated database.
|
|
41
45
|
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
## 3. Create the baseline
|
|
47
|
+
|
|
48
|
+
Open the guide:
|
|
44
49
|
|
|
45
50
|
```bash
|
|
46
|
-
npx rehearsal
|
|
47
|
-
--records=rehearsal/synthetic-data.ndjson \
|
|
48
|
-
--ledger=rehearsal/migration-ledger.json
|
|
51
|
+
npx rehearsal
|
|
49
52
|
```
|
|
50
53
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
and never contacts a hosted service.
|
|
54
|
+
The project begins at Stage 3 of 4. Choose **Create the baseline**. Accept the detected
|
|
55
|
+
record and ledger files, review the summary, then confirm creation.
|
|
54
56
|
|
|
55
|
-
|
|
57
|
+
The project should advance to Stage 4 of 4 with every item checked.
|
|
56
58
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
## 4. Run the migration
|
|
60
|
+
|
|
61
|
+
Choose **Run a rehearsal**. The guide should show exactly one candidate:
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
20260101000100_add_widget_description.sql
|
|
62
65
|
```
|
|
63
66
|
|
|
64
|
-
|
|
65
|
-
|
|
67
|
+
Confirm it. Rehearsal creates a loopback-only PostgreSQL container, restores the baseline,
|
|
68
|
+
applies the candidate, and runs the fixture proof.
|
|
66
69
|
|
|
67
|
-
|
|
70
|
+
Success ends with an application-proof message and suggests exercising the local
|
|
71
|
+
application before verification.
|
|
68
72
|
|
|
69
|
-
|
|
73
|
+
## 5. Try the runtime commands
|
|
74
|
+
|
|
75
|
+
Exit the guide, then run:
|
|
70
76
|
|
|
71
77
|
```bash
|
|
72
|
-
npx rehearsal run --confirm-candidates=<sha256>
|
|
73
|
-
npx rehearsal inspect migrations
|
|
74
78
|
npx rehearsal status
|
|
79
|
+
npx rehearsal verify
|
|
80
|
+
npx rehearsal reset
|
|
81
|
+
npx rehearsal stop
|
|
82
|
+
npx rehearsal discard
|
|
75
83
|
```
|
|
76
84
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
85
|
+
`discard` removes only the labeled tutorial container and volume. The temporary project
|
|
86
|
+
directory remains on disk and can be deleted when you no longer need it.
|
|
87
|
+
|
|
88
|
+
## What you proved
|
|
80
89
|
|
|
81
|
-
|
|
90
|
+
You used the same packaged CLI a normal project installs. Rehearsal verified the baseline,
|
|
91
|
+
approved an exact migration, ran it in a disposable database, tested the result, and
|
|
92
|
+
cleaned up only its own runtime.
|
|
82
93
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
runtime, and never produce a successful receipt.
|
|
94
|
+
Next, follow [Getting started](getting-started.md) in your own project. Start with synthetic
|
|
95
|
+
rows until the workflow and application proof are reliable.
|
|
86
96
|
|
|
87
|
-
|
|
97
|
+
## Supabase check
|
|
98
|
+
|
|
99
|
+
If Supabase CLI 2.117.0 is installed, the repository also has a fully automated Supabase
|
|
100
|
+
proof:
|
|
88
101
|
|
|
89
102
|
```bash
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
npx rehearsal stop
|
|
103
|
+
cd "$rehearsal_repo"
|
|
104
|
+
npm run test:fixture
|
|
93
105
|
```
|
|
94
106
|
|
|
95
|
-
|
|
96
|
-
workflow.
|
|
107
|
+
The PostgreSQL equivalent is `npm run test:fixture:postgresql`.
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rehearsal-db/core",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.7",
|
|
4
4
|
"private": false,
|
|
5
|
-
"description": "Safely rehearse Supabase migrations against sanitized, production-shaped
|
|
5
|
+
"description": "Safely rehearse PostgreSQL and Supabase migrations against sanitized, production-shaped data.",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/Ddupasquier/rehearsal-db.git"
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"files": [
|
|
40
40
|
"scripts/lib/environment/local_supabase.mjs",
|
|
41
41
|
"scripts/lib/rehearsal",
|
|
42
|
+
"scripts/lib/runtime",
|
|
42
43
|
"scripts/operations/database/manage_rehearsal_database.mjs",
|
|
43
44
|
"scripts/operations/rehearsal/rehearsal_cli.mjs",
|
|
44
45
|
"docs/*.md",
|
|
@@ -55,6 +56,7 @@
|
|
|
55
56
|
"test:unit": "vitest run tests/scripts",
|
|
56
57
|
"test:docs": "vitest run tests/contracts/documentationCommands.test.mjs",
|
|
57
58
|
"test:fixture": "node scripts/operations/rehearsal/prove_independent_fixture.mjs",
|
|
59
|
+
"test:fixture:postgresql": "node scripts/operations/rehearsal/prove_postgresql_fixture.mjs",
|
|
58
60
|
"check": "npm run check:syntax && npm run test && npm run format:check && npm run package:audit",
|
|
59
61
|
"check:syntax": "find scripts tests -type f -name '*.mjs' -exec node --check {} +",
|
|
60
62
|
"format": "prettier --write .",
|
|
@@ -62,14 +62,13 @@ export const localCommandSucceeds = (
|
|
|
62
62
|
stdio: "ignore",
|
|
63
63
|
}).status === 0;
|
|
64
64
|
|
|
65
|
-
export const ensureLocalContainerRuntime = ({
|
|
65
|
+
export const ensureLocalContainerRuntime = ({
|
|
66
|
+
cwd = process.cwd(),
|
|
67
|
+
autoStartColima = true,
|
|
68
|
+
} = {}) => {
|
|
66
69
|
if (localCommandSucceeds("docker", ["info"], { cwd })) return;
|
|
67
|
-
if (localCommandSucceeds("colima", ["version"], { cwd })) {
|
|
68
|
-
runLocalCommand(
|
|
69
|
-
"colima",
|
|
70
|
-
["start", "--cpu", "4", "--memory", "4", "--disk", "40"],
|
|
71
|
-
{ cwd },
|
|
72
|
-
);
|
|
70
|
+
if (autoStartColima && localCommandSucceeds("colima", ["version"], { cwd })) {
|
|
71
|
+
runLocalCommand("colima", ["start"], { cwd });
|
|
73
72
|
}
|
|
74
73
|
if (!localCommandSucceeds("docker", ["info"], { cwd })) {
|
|
75
74
|
throw new Error(
|
|
@@ -118,8 +117,9 @@ export const startLocalSupabase = ({
|
|
|
118
117
|
exclude = [],
|
|
119
118
|
environment = {},
|
|
120
119
|
applyMigrations = true,
|
|
120
|
+
autoStartColima = true,
|
|
121
121
|
} = {}) => {
|
|
122
|
-
ensureLocalContainerRuntime({ cwd });
|
|
122
|
+
ensureLocalContainerRuntime({ cwd, autoStartColima });
|
|
123
123
|
const startArguments = withWorkdir(
|
|
124
124
|
["start", ...(exclude.length ? ["--exclude", exclude.join(",")] : [])],
|
|
125
125
|
workdir,
|
|
@@ -478,7 +478,7 @@ export const listIncompleteBaselineBuilds = async ({ artifactRoot }) => {
|
|
|
478
478
|
}
|
|
479
479
|
};
|
|
480
480
|
|
|
481
|
-
export const
|
|
481
|
+
export const planBaselineGenerationPrune = async ({
|
|
482
482
|
artifactRoot,
|
|
483
483
|
retain = 2,
|
|
484
484
|
}) => {
|
|
@@ -516,7 +516,36 @@ export const pruneBaselineGenerations = async ({
|
|
|
516
516
|
const removed = entries
|
|
517
517
|
.map((entry) => entry.name)
|
|
518
518
|
.filter((name) => !retained.has(name));
|
|
519
|
-
|
|
519
|
+
return { retained: [...retained], removed };
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
export const pruneBaselineGenerations = async ({
|
|
523
|
+
artifactRoot,
|
|
524
|
+
retain = 2,
|
|
525
|
+
expectedRemoved,
|
|
526
|
+
}) => {
|
|
527
|
+
const root = assertArtifactRoot(artifactRoot);
|
|
528
|
+
const plan = await planBaselineGenerationPrune({
|
|
529
|
+
artifactRoot: root,
|
|
530
|
+
retain,
|
|
531
|
+
});
|
|
532
|
+
const generationsRoot = join(root, generationsDirectoryName);
|
|
533
|
+
if (
|
|
534
|
+
expectedRemoved &&
|
|
535
|
+
(expectedRemoved.length !== plan.removed.length ||
|
|
536
|
+
expectedRemoved.some((name, index) => name !== plan.removed[index]))
|
|
537
|
+
) {
|
|
538
|
+
throw new Error(
|
|
539
|
+
"Rehearsal baseline generations changed after the cleanup preview.",
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
for (const name of plan.removed) {
|
|
543
|
+
const active = await resolveActiveBaselinePaths({ artifactRoot: root });
|
|
544
|
+
if (basename(active.generationDirectory) === name) {
|
|
545
|
+
throw new Error(
|
|
546
|
+
"Refusing to prune the active Rehearsal baseline generation.",
|
|
547
|
+
);
|
|
548
|
+
}
|
|
520
549
|
const target = assertInsideRoot(
|
|
521
550
|
generationsRoot,
|
|
522
551
|
join(generationsRoot, name),
|
|
@@ -525,7 +554,7 @@ export const pruneBaselineGenerations = async ({
|
|
|
525
554
|
await makeArtifactTreeWritable(target);
|
|
526
555
|
await rm(target, { recursive: true, force: true });
|
|
527
556
|
}
|
|
528
|
-
return
|
|
557
|
+
return plan;
|
|
529
558
|
};
|
|
530
559
|
|
|
531
560
|
export const removeBaselineArtifactRoot = async ({ artifactRoot }) => {
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purpose: Preview and apply narrowly scoped Rehearsal cleanup without global
|
|
3
|
+
* Docker pruning or deletion of unowned database resources.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { stat } from "node:fs/promises";
|
|
9
|
+
import {
|
|
10
|
+
planBaselineGenerationPrune,
|
|
11
|
+
pruneBaselineGenerations,
|
|
12
|
+
} from "./baseline_artifact.mjs";
|
|
13
|
+
import { loadRehearsalConfig } from "./configuration.mjs";
|
|
14
|
+
import { createCleanProcessEnvironment } from "./process_environment.mjs";
|
|
15
|
+
|
|
16
|
+
const SUPABASE_IMAGE_PREFIX = "public.ecr.aws/supabase/";
|
|
17
|
+
|
|
18
|
+
const commandResult = (command, args, { projectRoot }) =>
|
|
19
|
+
spawnSync(command, args, {
|
|
20
|
+
cwd: projectRoot,
|
|
21
|
+
encoding: "utf8",
|
|
22
|
+
env: createCleanProcessEnvironment(),
|
|
23
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
24
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const runDocker = (args, { projectRoot, allowFailure = false }) => {
|
|
28
|
+
const result = commandResult("docker", args, { projectRoot });
|
|
29
|
+
if (!allowFailure && (result.error || result.status !== 0)) {
|
|
30
|
+
const detail = [result.stdout, result.stderr]
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.join("\n")
|
|
33
|
+
.trim();
|
|
34
|
+
throw new Error(
|
|
35
|
+
`docker ${args.join(" ")} failed${detail ? `:\n${detail}` : "."}`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
return result;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const nonEmptyLines = (value) =>
|
|
42
|
+
String(value ?? "")
|
|
43
|
+
.split("\n")
|
|
44
|
+
.map((line) => line.trim())
|
|
45
|
+
.filter(Boolean);
|
|
46
|
+
|
|
47
|
+
const dockerAvailable = (projectRoot) =>
|
|
48
|
+
runDocker(["info"], { projectRoot, allowFailure: true }).status === 0;
|
|
49
|
+
|
|
50
|
+
export const parsePosixDiskUsage = (output) => {
|
|
51
|
+
const columns = nonEmptyLines(output).at(-1)?.split(/\s+/u) ?? [];
|
|
52
|
+
if (columns.length < 6) return null;
|
|
53
|
+
const totalKiB = Number(columns[1]);
|
|
54
|
+
const usedKiB = Number(columns[2]);
|
|
55
|
+
const availableKiB = Number(columns[3]);
|
|
56
|
+
const usedPercent = Number.parseInt(columns[4], 10);
|
|
57
|
+
if (![totalKiB, usedKiB, availableKiB, usedPercent].every(Number.isFinite)) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
totalBytes: totalKiB * 1024,
|
|
62
|
+
usedBytes: usedKiB * 1024,
|
|
63
|
+
availableBytes: availableKiB * 1024,
|
|
64
|
+
usedPercent,
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const inspectContainerDisk = (projectRoot) => {
|
|
69
|
+
const context = commandResult("docker", ["context", "show"], {
|
|
70
|
+
projectRoot,
|
|
71
|
+
});
|
|
72
|
+
if (context.status !== 0 || context.stdout.trim() !== "colima") return null;
|
|
73
|
+
const result = commandResult(
|
|
74
|
+
"colima",
|
|
75
|
+
["ssh", "--", "df", "-Pk", "/var/lib/docker"],
|
|
76
|
+
{ projectRoot },
|
|
77
|
+
);
|
|
78
|
+
const usage = result.status === 0 ? parsePosixDiskUsage(result.stdout) : null;
|
|
79
|
+
return usage ? { backend: "colima", ...usage } : null;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const inspectDockerUsage = (projectRoot) => {
|
|
83
|
+
if (!dockerAvailable(projectRoot)) {
|
|
84
|
+
return { available: false, resources: [], disk: null };
|
|
85
|
+
}
|
|
86
|
+
const result = runDocker(["system", "df", "--format", "{{json .}}"], {
|
|
87
|
+
projectRoot,
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
available: true,
|
|
91
|
+
resources: nonEmptyLines(result.stdout).map((line) => JSON.parse(line)),
|
|
92
|
+
disk: inspectContainerDisk(projectRoot),
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const inspectUsedImageIds = (projectRoot) => {
|
|
97
|
+
const containers = nonEmptyLines(
|
|
98
|
+
runDocker(["container", "ls", "--all", "--quiet"], { projectRoot }).stdout,
|
|
99
|
+
);
|
|
100
|
+
if (containers.length === 0) return new Set();
|
|
101
|
+
return new Set(
|
|
102
|
+
nonEmptyLines(
|
|
103
|
+
runDocker(
|
|
104
|
+
["container", "inspect", "--format", "{{.Image}}", ...containers],
|
|
105
|
+
{ projectRoot },
|
|
106
|
+
).stdout,
|
|
107
|
+
),
|
|
108
|
+
);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const splitImageTag = (value) => {
|
|
112
|
+
const separator = value.lastIndexOf(":");
|
|
113
|
+
if (separator <= value.lastIndexOf("/")) return null;
|
|
114
|
+
return {
|
|
115
|
+
repository: value.slice(0, separator),
|
|
116
|
+
tag: value.slice(separator + 1),
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const selectOlderUnusedSupabaseImages = ({ images, usedImageIds }) => {
|
|
121
|
+
const byRepository = new Map();
|
|
122
|
+
for (const image of images) {
|
|
123
|
+
for (const fullTag of image.RepoTags ?? []) {
|
|
124
|
+
const parsed = splitImageTag(fullTag);
|
|
125
|
+
if (!parsed?.repository.startsWith(SUPABASE_IMAGE_PREFIX)) continue;
|
|
126
|
+
const entries = byRepository.get(parsed.repository) ?? [];
|
|
127
|
+
entries.push({
|
|
128
|
+
id: image.Id,
|
|
129
|
+
createdAt: image.Created,
|
|
130
|
+
bytes: image.Size,
|
|
131
|
+
fullTag,
|
|
132
|
+
});
|
|
133
|
+
byRepository.set(parsed.repository, entries);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const candidatesById = new Map();
|
|
138
|
+
for (const entries of byRepository.values()) {
|
|
139
|
+
const newestId = [...entries].sort(
|
|
140
|
+
(left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt),
|
|
141
|
+
)[0]?.id;
|
|
142
|
+
for (const entry of entries) {
|
|
143
|
+
if (entry.id === newestId || usedImageIds.has(entry.id)) continue;
|
|
144
|
+
const existing = candidatesById.get(entry.id) ?? {
|
|
145
|
+
id: entry.id,
|
|
146
|
+
createdAt: entry.createdAt,
|
|
147
|
+
bytes: entry.bytes,
|
|
148
|
+
tags: [],
|
|
149
|
+
};
|
|
150
|
+
existing.tags.push(entry.fullTag);
|
|
151
|
+
candidatesById.set(entry.id, existing);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return [...candidatesById.values()]
|
|
155
|
+
.map((entry) => ({ ...entry, tags: [...new Set(entry.tags)].sort() }))
|
|
156
|
+
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const inspectUnusedImages = (projectRoot) => {
|
|
160
|
+
const imageIds = [
|
|
161
|
+
...new Set(
|
|
162
|
+
nonEmptyLines(
|
|
163
|
+
runDocker(["image", "ls", "--quiet", "--no-trunc"], {
|
|
164
|
+
projectRoot,
|
|
165
|
+
}).stdout,
|
|
166
|
+
),
|
|
167
|
+
),
|
|
168
|
+
];
|
|
169
|
+
if (imageIds.length === 0) return [];
|
|
170
|
+
const images = nonEmptyLines(
|
|
171
|
+
runDocker(["image", "inspect", "--format", "{{json .}}", ...imageIds], {
|
|
172
|
+
projectRoot,
|
|
173
|
+
}).stdout,
|
|
174
|
+
).map((line) => JSON.parse(line));
|
|
175
|
+
return selectOlderUnusedSupabaseImages({
|
|
176
|
+
images,
|
|
177
|
+
usedImageIds: inspectUsedImageIds(projectRoot),
|
|
178
|
+
});
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const runtimeDetected = async ({ config, paths, projectRoot }) => {
|
|
182
|
+
try {
|
|
183
|
+
await stat(paths.runtimeWorkdir);
|
|
184
|
+
return true;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (error?.code !== "ENOENT") throw error;
|
|
187
|
+
}
|
|
188
|
+
if (!dockerAvailable(projectRoot)) return false;
|
|
189
|
+
if (config.runtime.target === "postgresql") {
|
|
190
|
+
const containers = runDocker(
|
|
191
|
+
[
|
|
192
|
+
"container",
|
|
193
|
+
"ls",
|
|
194
|
+
"--all",
|
|
195
|
+
"--filter",
|
|
196
|
+
`label=com.rehearsal-db.project=${config.runtime.projectId}`,
|
|
197
|
+
"--quiet",
|
|
198
|
+
],
|
|
199
|
+
{ projectRoot },
|
|
200
|
+
);
|
|
201
|
+
const volumes = runDocker(
|
|
202
|
+
[
|
|
203
|
+
"volume",
|
|
204
|
+
"ls",
|
|
205
|
+
"--filter",
|
|
206
|
+
`label=com.rehearsal-db.project=${config.runtime.projectId}`,
|
|
207
|
+
"--quiet",
|
|
208
|
+
],
|
|
209
|
+
{ projectRoot },
|
|
210
|
+
);
|
|
211
|
+
return (
|
|
212
|
+
nonEmptyLines(containers.stdout).length > 0 ||
|
|
213
|
+
nonEmptyLines(volumes.stdout).length > 0
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
const containers = runDocker(
|
|
217
|
+
[
|
|
218
|
+
"container",
|
|
219
|
+
"ls",
|
|
220
|
+
"--all",
|
|
221
|
+
"--filter",
|
|
222
|
+
`label=com.supabase.cli.project=${config.runtime.projectId}`,
|
|
223
|
+
"--quiet",
|
|
224
|
+
],
|
|
225
|
+
{ projectRoot },
|
|
226
|
+
);
|
|
227
|
+
const volumes = nonEmptyLines(
|
|
228
|
+
runDocker(["volume", "ls", "--format", "{{.Name}}"], { projectRoot })
|
|
229
|
+
.stdout,
|
|
230
|
+
);
|
|
231
|
+
return (
|
|
232
|
+
nonEmptyLines(containers.stdout).length > 0 ||
|
|
233
|
+
volumes.some((name) => name.endsWith(`_${config.runtime.projectId}`))
|
|
234
|
+
);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const inspectBaselineCleanup = async ({ artifactRoot, retain }) => {
|
|
238
|
+
try {
|
|
239
|
+
return {
|
|
240
|
+
available: true,
|
|
241
|
+
...(await planBaselineGenerationPrune({ artifactRoot, retain })),
|
|
242
|
+
};
|
|
243
|
+
} catch (error) {
|
|
244
|
+
if (error?.code === "ENOENT") {
|
|
245
|
+
return { available: false, retained: [], removed: [] };
|
|
246
|
+
}
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const cleanupIdentity = (plan) => ({
|
|
252
|
+
projectId: plan.projectId,
|
|
253
|
+
retainBaselineGenerations: plan.retainBaselineGenerations,
|
|
254
|
+
baselineGenerations: plan.baselines.removed,
|
|
255
|
+
runtime: plan.runtime.included && plan.runtime.detected,
|
|
256
|
+
images: plan.images.candidates.map(({ id, tags }) => ({ id, tags })),
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const cleanupDigest = (plan) =>
|
|
260
|
+
createHash("sha256")
|
|
261
|
+
.update(JSON.stringify(cleanupIdentity(plan)))
|
|
262
|
+
.digest("hex");
|
|
263
|
+
|
|
264
|
+
export const planRehearsalCleanup = async ({
|
|
265
|
+
projectRoot = process.cwd(),
|
|
266
|
+
configPath,
|
|
267
|
+
includeRuntime = false,
|
|
268
|
+
includeImages = false,
|
|
269
|
+
inspection = { inspectDockerUsage, runtimeDetected },
|
|
270
|
+
} = {}) => {
|
|
271
|
+
const loaded = await loadRehearsalConfig({ projectRoot, configPath });
|
|
272
|
+
const docker = inspection.inspectDockerUsage(loaded.projectRoot);
|
|
273
|
+
const baselines = await inspectBaselineCleanup({
|
|
274
|
+
artifactRoot: loaded.paths.artifactDirectory,
|
|
275
|
+
retain: loaded.config.cleanup.retainBaselineGenerations,
|
|
276
|
+
});
|
|
277
|
+
const images = {
|
|
278
|
+
requested: includeImages,
|
|
279
|
+
inspected: includeImages && docker.available,
|
|
280
|
+
candidates:
|
|
281
|
+
includeImages && docker.available
|
|
282
|
+
? inspectUnusedImages(loaded.projectRoot)
|
|
283
|
+
: [],
|
|
284
|
+
};
|
|
285
|
+
const plan = {
|
|
286
|
+
project: loaded.config.project.name,
|
|
287
|
+
projectId: loaded.config.runtime.projectId,
|
|
288
|
+
target: loaded.config.runtime.target,
|
|
289
|
+
retainBaselineGenerations: loaded.config.cleanup.retainBaselineGenerations,
|
|
290
|
+
baselines,
|
|
291
|
+
runtime: {
|
|
292
|
+
included: includeRuntime,
|
|
293
|
+
detected: await inspection.runtimeDetected({ ...loaded }),
|
|
294
|
+
},
|
|
295
|
+
images,
|
|
296
|
+
docker,
|
|
297
|
+
};
|
|
298
|
+
return Object.freeze({ ...plan, digest: cleanupDigest(plan) });
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const removePlannedImages = ({ candidates, projectRoot }) => {
|
|
302
|
+
if (candidates.length === 0) return [];
|
|
303
|
+
const usedImageIds = inspectUsedImageIds(projectRoot);
|
|
304
|
+
const removed = [];
|
|
305
|
+
for (const candidate of candidates) {
|
|
306
|
+
if (usedImageIds.has(candidate.id)) {
|
|
307
|
+
throw new Error(
|
|
308
|
+
`Cleanup stopped because a container began using ${candidate.id}. Preview the cleanup again.`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
for (const tag of candidate.tags) {
|
|
312
|
+
const inspection = runDocker(
|
|
313
|
+
["image", "inspect", "--format", "{{.Id}}", tag],
|
|
314
|
+
{ projectRoot, allowFailure: true },
|
|
315
|
+
);
|
|
316
|
+
if (inspection.status !== 0) continue;
|
|
317
|
+
if (inspection.stdout.trim() !== candidate.id) {
|
|
318
|
+
throw new Error(
|
|
319
|
+
`Cleanup stopped because ${tag} changed after the preview. Preview the cleanup again.`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
runDocker(["image", "rm", tag], { projectRoot });
|
|
323
|
+
removed.push(tag);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return removed;
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
export const applyRehearsalCleanup = async ({
|
|
330
|
+
plan,
|
|
331
|
+
confirmation,
|
|
332
|
+
projectRoot = process.cwd(),
|
|
333
|
+
configPath,
|
|
334
|
+
removeRuntime = async () => undefined,
|
|
335
|
+
inspection,
|
|
336
|
+
} = {}) => {
|
|
337
|
+
if (confirmation !== plan?.digest) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
"Cleanup requires the exact --confirm-cleanup digest from a fresh preview. Nothing was removed.",
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
const refreshed = await planRehearsalCleanup({
|
|
343
|
+
projectRoot,
|
|
344
|
+
configPath,
|
|
345
|
+
includeRuntime: plan.runtime.included,
|
|
346
|
+
includeImages: plan.images.inspected,
|
|
347
|
+
...(inspection ? { inspection } : {}),
|
|
348
|
+
});
|
|
349
|
+
if (refreshed.digest !== plan.digest) {
|
|
350
|
+
throw new Error(
|
|
351
|
+
"Cleanup targets changed after the preview. Nothing was removed; preview the cleanup again.",
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
if (plan.images.requested && !refreshed.images.inspected) {
|
|
355
|
+
throw new Error(
|
|
356
|
+
"Docker is unavailable, so Rehearsal cannot verify or remove unused images.",
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
if (refreshed.runtime.included && refreshed.runtime.detected) {
|
|
360
|
+
await removeRuntime();
|
|
361
|
+
}
|
|
362
|
+
const loaded = await loadRehearsalConfig({ projectRoot, configPath });
|
|
363
|
+
const baselines = refreshed.baselines.available
|
|
364
|
+
? await pruneBaselineGenerations({
|
|
365
|
+
artifactRoot: loaded.paths.artifactDirectory,
|
|
366
|
+
retain: refreshed.retainBaselineGenerations,
|
|
367
|
+
expectedRemoved: refreshed.baselines.removed,
|
|
368
|
+
})
|
|
369
|
+
: { retained: [], removed: [] };
|
|
370
|
+
const images = removePlannedImages({
|
|
371
|
+
candidates: refreshed.images.candidates,
|
|
372
|
+
projectRoot: loaded.projectRoot,
|
|
373
|
+
});
|
|
374
|
+
return {
|
|
375
|
+
project: refreshed.project,
|
|
376
|
+
projectId: refreshed.projectId,
|
|
377
|
+
baselines,
|
|
378
|
+
runtimeRemoved: refreshed.runtime.included && refreshed.runtime.detected,
|
|
379
|
+
imagesRemoved: images,
|
|
380
|
+
};
|
|
381
|
+
};
|