@rehearsal-db/core 0.1.0-beta.6 → 0.1.0-beta.8
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 -1
- package/README.md +10 -3
- package/docs/commands.md +35 -1
- package/docs/configuration.md +65 -7
- package/docs/releasing.md +10 -1
- package/docs/roadmap.md +39 -0
- package/docs/troubleshooting.md +23 -0
- package/package.json +1 -1
- 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 +6 -0
- package/scripts/lib/rehearsal/configuration.mjs +113 -6
- package/scripts/lib/rehearsal/diagnostics.mjs +1 -1
- package/scripts/lib/rehearsal/plan.mjs +4 -4
- package/scripts/lib/runtime/postgresql_runtime.mjs +10 -9
- package/scripts/lib/runtime/runtime_target.mjs +25 -0
- package/scripts/lib/runtime/supabase_runtime.mjs +7 -4
- package/scripts/operations/database/manage_rehearsal_database.mjs +9 -2
- package/scripts/operations/rehearsal/rehearsal_cli.mjs +199 -5
|
@@ -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
|
+
};
|
|
@@ -22,6 +22,12 @@ export interface RehearsalConfig {
|
|
|
22
22
|
artifactDirectory?: string;
|
|
23
23
|
sanitizationPolicy: string;
|
|
24
24
|
};
|
|
25
|
+
containerRuntime?: {
|
|
26
|
+
autoStartColima?: boolean;
|
|
27
|
+
};
|
|
28
|
+
cleanup?: {
|
|
29
|
+
retainBaselineGenerations?: number;
|
|
30
|
+
};
|
|
25
31
|
application: {
|
|
26
32
|
startCommand: string;
|
|
27
33
|
proofCommand: string;
|
|
@@ -23,6 +23,8 @@ export const REHEARSAL_CONFIG_VERSION = 1;
|
|
|
23
23
|
|
|
24
24
|
export const REHEARSAL_DEFAULTS = Object.freeze({
|
|
25
25
|
baseline: Object.freeze({ artifactDirectory: ".rehearsal" }),
|
|
26
|
+
containerRuntime: Object.freeze({ autoStartColima: true }),
|
|
27
|
+
cleanup: Object.freeze({ retainBaselineGenerations: 2 }),
|
|
26
28
|
runtime: Object.freeze({
|
|
27
29
|
applicationUrl: "http://localhost:5175",
|
|
28
30
|
projectId: "rehearsal-local",
|
|
@@ -111,6 +113,20 @@ const assertPort = (value, path) => {
|
|
|
111
113
|
return value;
|
|
112
114
|
};
|
|
113
115
|
|
|
116
|
+
const assertBoolean = (value, path) => {
|
|
117
|
+
if (typeof value !== "boolean") {
|
|
118
|
+
throw new Error(`${path} must be true or false.`);
|
|
119
|
+
}
|
|
120
|
+
return value;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const assertPositiveInteger = (value, path) => {
|
|
124
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
125
|
+
throw new Error(`${path} must be a positive integer.`);
|
|
126
|
+
}
|
|
127
|
+
return value;
|
|
128
|
+
};
|
|
129
|
+
|
|
114
130
|
const assertStringArray = (value, path) => {
|
|
115
131
|
if (
|
|
116
132
|
!Array.isArray(value) ||
|
|
@@ -151,6 +167,8 @@ const normalizeConfig = (input) => {
|
|
|
151
167
|
"supabase",
|
|
152
168
|
"postgresql",
|
|
153
169
|
"baseline",
|
|
170
|
+
"containerRuntime",
|
|
171
|
+
"cleanup",
|
|
154
172
|
"application",
|
|
155
173
|
"runtime",
|
|
156
174
|
"safety",
|
|
@@ -219,6 +237,17 @@ const normalizeConfig = (input) => {
|
|
|
219
237
|
["artifactDirectory", "sanitizationPolicy"],
|
|
220
238
|
"config.baseline",
|
|
221
239
|
);
|
|
240
|
+
const containerRuntime = assertPlainObject(
|
|
241
|
+
root.containerRuntime ?? {},
|
|
242
|
+
"config.containerRuntime",
|
|
243
|
+
);
|
|
244
|
+
assertKnownKeys(
|
|
245
|
+
containerRuntime,
|
|
246
|
+
["autoStartColima"],
|
|
247
|
+
"config.containerRuntime",
|
|
248
|
+
);
|
|
249
|
+
const cleanup = assertPlainObject(root.cleanup ?? {}, "config.cleanup");
|
|
250
|
+
assertKnownKeys(cleanup, ["retainBaselineGenerations"], "config.cleanup");
|
|
222
251
|
const application = assertPlainObject(root.application, "config.application");
|
|
223
252
|
assertKnownKeys(
|
|
224
253
|
application,
|
|
@@ -426,6 +455,20 @@ const normalizeConfig = (input) => {
|
|
|
426
455
|
"config.baseline.sanitizationPolicy",
|
|
427
456
|
),
|
|
428
457
|
}),
|
|
458
|
+
containerRuntime: Object.freeze({
|
|
459
|
+
autoStartColima: assertBoolean(
|
|
460
|
+
containerRuntime.autoStartColima ??
|
|
461
|
+
REHEARSAL_DEFAULTS.containerRuntime.autoStartColima,
|
|
462
|
+
"config.containerRuntime.autoStartColima",
|
|
463
|
+
),
|
|
464
|
+
}),
|
|
465
|
+
cleanup: Object.freeze({
|
|
466
|
+
retainBaselineGenerations: assertPositiveInteger(
|
|
467
|
+
cleanup.retainBaselineGenerations ??
|
|
468
|
+
REHEARSAL_DEFAULTS.cleanup.retainBaselineGenerations,
|
|
469
|
+
"config.cleanup.retainBaselineGenerations",
|
|
470
|
+
),
|
|
471
|
+
}),
|
|
429
472
|
application: Object.freeze({
|
|
430
473
|
startCommand: assertCommand(
|
|
431
474
|
application.startCommand,
|
|
@@ -649,27 +692,60 @@ export const renderDetectedConfig = (
|
|
|
649
692
|
applicationUrl = "http://localhost:5175",
|
|
650
693
|
ports = { api: 58321, database: 58322, studio: 58323 },
|
|
651
694
|
} = {},
|
|
652
|
-
) =>
|
|
695
|
+
) =>
|
|
696
|
+
`// @ts-check
|
|
697
|
+
/**
|
|
698
|
+
* Rehearsal configuration for ${detected.projectName}.
|
|
699
|
+
* Generated from this project by \`npx rehearsal\` and safe to commit.
|
|
700
|
+
* Review the CHECK comments. Keep passwords, tokens, and production URLs out of this file.
|
|
701
|
+
* Docs: https://github.com/Ddupasquier/rehearsal-db/blob/main/docs/configuration.md
|
|
702
|
+
*/
|
|
653
703
|
import { defineRehearsalConfig } from "@rehearsal-db/core";
|
|
654
704
|
|
|
655
705
|
export default defineRehearsalConfig({
|
|
706
|
+
// Configuration format. Rehearsal will explain if an upgrade is ever needed.
|
|
656
707
|
schemaVersion: 1,
|
|
708
|
+
// Stable local name used in Rehearsal labels and reports.
|
|
657
709
|
project: { name: ${JSON.stringify(detected.projectName)} },
|
|
710
|
+
|
|
711
|
+
// Project files Rehearsal reads. Every path stays inside this repository.
|
|
658
712
|
supabase: {
|
|
659
713
|
workdir: ".",
|
|
660
714
|
migrationDirectory: "supabase/migrations",
|
|
661
715
|
rehearsalConfig: "infrastructure/rehearsal/supabase/config.toml",
|
|
662
716
|
runtimeWorkdir: ".rehearsal/runtime",
|
|
717
|
+
|
|
718
|
+
// Optional local-only identity-provider credentials. Enable both keys together.
|
|
719
|
+
// serviceEnvironmentFile: ".env.rehearsal-service.local",
|
|
720
|
+
// serviceEnvironmentVariables: ["LOCAL_IDP_CLIENT_ID", "LOCAL_IDP_SECRET"],
|
|
663
721
|
},
|
|
722
|
+
|
|
723
|
+
// Immutable local baseline files. Rehearsal never reads production on your behalf.
|
|
664
724
|
baseline: {
|
|
665
725
|
artifactDirectory: ".rehearsal",
|
|
666
726
|
sanitizationPolicy: "infrastructure/rehearsal/sanitization-policy.json",
|
|
667
727
|
},
|
|
728
|
+
|
|
729
|
+
// Reuse any running Docker-compatible engine; start Colima only when needed.
|
|
730
|
+
containerRuntime: {
|
|
731
|
+
autoStartColima: true,
|
|
732
|
+
},
|
|
733
|
+
|
|
734
|
+
// Keep this many baseline generations, always including the active one.
|
|
735
|
+
cleanup: {
|
|
736
|
+
retainBaselineGenerations: 2,
|
|
737
|
+
},
|
|
738
|
+
|
|
668
739
|
application: {
|
|
740
|
+
// CHECK: commands detected from package.json. Change them if they are not correct.
|
|
669
741
|
startCommand: ${JSON.stringify(detected.applicationCommand)},
|
|
670
742
|
proofCommand: ${JSON.stringify(detected.verificationCommand)},
|
|
671
743
|
environmentFile: ".rehearsal/runtime.env",
|
|
744
|
+
// Optional project-specific restore hook:
|
|
745
|
+
// runtimeAdapter: "infrastructure/rehearsal/runtime-adapter.mjs",
|
|
672
746
|
},
|
|
747
|
+
|
|
748
|
+
// Disposable local runtime identity, URL, and dedicated ports.
|
|
673
749
|
runtime: {
|
|
674
750
|
target: "supabase",
|
|
675
751
|
applicationUrl: ${JSON.stringify(applicationUrl)},
|
|
@@ -678,6 +754,8 @@ export default defineRehearsalConfig({
|
|
|
678
754
|
databasePort: ${ports.database},
|
|
679
755
|
studioPort: ${ports.studio},
|
|
680
756
|
},
|
|
757
|
+
|
|
758
|
+
// Only local runtime URLs are accepted; common hosted credentials are quarantined.
|
|
681
759
|
safety: {
|
|
682
760
|
allowedHosts: ["127.0.0.1", "::1", "localhost"],
|
|
683
761
|
blockedEnvironmentVariables: [
|
|
@@ -688,19 +766,29 @@ export default defineRehearsalConfig({
|
|
|
688
766
|
hostedAccess: "disabled",
|
|
689
767
|
outboundNetwork: "deny",
|
|
690
768
|
},
|
|
691
|
-
verification: { commands: [${JSON.stringify(detected.verificationCommand)}] },
|
|
692
769
|
});
|
|
693
|
-
|
|
770
|
+
`.replaceAll("\t", " ");
|
|
694
771
|
|
|
695
772
|
export const renderDetectedPostgresqlConfig = (
|
|
696
773
|
detected,
|
|
697
774
|
{ applicationUrl = "http://localhost:5175", databasePort = 58322 } = {},
|
|
698
|
-
) =>
|
|
775
|
+
) =>
|
|
776
|
+
`// @ts-check
|
|
777
|
+
/**
|
|
778
|
+
* Rehearsal configuration for ${detected.projectName}.
|
|
779
|
+
* Generated from this project by \`npx rehearsal\` and safe to commit.
|
|
780
|
+
* Review the CHECK comments. Keep passwords, tokens, and production URLs out of this file.
|
|
781
|
+
* Docs: https://github.com/Ddupasquier/rehearsal-db/blob/main/docs/configuration.md
|
|
782
|
+
*/
|
|
699
783
|
import { defineRehearsalConfig } from "@rehearsal-db/core";
|
|
700
784
|
|
|
701
785
|
export default defineRehearsalConfig({
|
|
786
|
+
// Configuration format. Rehearsal will explain if an upgrade is ever needed.
|
|
702
787
|
schemaVersion: 1,
|
|
788
|
+
// Stable local name used in Rehearsal labels and reports.
|
|
703
789
|
project: { name: ${JSON.stringify(detected.projectName)} },
|
|
790
|
+
|
|
791
|
+
// Disposable PostgreSQL settings. The image must already exist locally.
|
|
704
792
|
postgresql: {
|
|
705
793
|
migrationDirectory: ${JSON.stringify(detected.postgresqlMigrationDirectory)},
|
|
706
794
|
runtimeWorkdir: ".rehearsal/runtime",
|
|
@@ -708,21 +796,41 @@ export default defineRehearsalConfig({
|
|
|
708
796
|
database: "postgres",
|
|
709
797
|
user: "postgres",
|
|
710
798
|
},
|
|
799
|
+
|
|
800
|
+
// Immutable local baseline files. Rehearsal never reads production on your behalf.
|
|
711
801
|
baseline: {
|
|
712
802
|
artifactDirectory: ".rehearsal",
|
|
713
803
|
sanitizationPolicy: "infrastructure/rehearsal/sanitization-policy.json",
|
|
714
804
|
},
|
|
805
|
+
|
|
806
|
+
// Reuse any running Docker-compatible engine; start Colima only when needed.
|
|
807
|
+
containerRuntime: {
|
|
808
|
+
autoStartColima: true,
|
|
809
|
+
},
|
|
810
|
+
|
|
811
|
+
// Keep this many baseline generations, always including the active one.
|
|
812
|
+
cleanup: {
|
|
813
|
+
retainBaselineGenerations: 2,
|
|
814
|
+
},
|
|
815
|
+
|
|
715
816
|
application: {
|
|
817
|
+
// CHECK: commands detected from package.json. Change them if they are not correct.
|
|
716
818
|
startCommand: ${JSON.stringify(detected.applicationCommand)},
|
|
717
819
|
proofCommand: ${JSON.stringify(detected.verificationCommand)},
|
|
718
820
|
environmentFile: ".rehearsal/runtime.env",
|
|
821
|
+
// Optional project-specific restore hook:
|
|
822
|
+
// runtimeAdapter: "infrastructure/rehearsal/runtime-adapter.mjs",
|
|
719
823
|
},
|
|
824
|
+
|
|
825
|
+
// Disposable local runtime identity, URL, and dedicated database port.
|
|
720
826
|
runtime: {
|
|
721
827
|
target: "postgresql",
|
|
722
828
|
applicationUrl: ${JSON.stringify(applicationUrl)},
|
|
723
829
|
projectId: "${detected.projectName}-rehearsal",
|
|
724
830
|
databasePort: ${databasePort},
|
|
725
831
|
},
|
|
832
|
+
|
|
833
|
+
// Only local runtime URLs are accepted; common hosted credentials are quarantined.
|
|
726
834
|
safety: {
|
|
727
835
|
allowedHosts: ["127.0.0.1", "::1", "localhost"],
|
|
728
836
|
blockedEnvironmentVariables: [
|
|
@@ -735,6 +843,5 @@ export default defineRehearsalConfig({
|
|
|
735
843
|
hostedAccess: "disabled",
|
|
736
844
|
outboundNetwork: "deny",
|
|
737
845
|
},
|
|
738
|
-
verification: { commands: [${JSON.stringify(detected.verificationCommand)}] },
|
|
739
846
|
});
|
|
740
|
-
|
|
847
|
+
`.replaceAll("\t", " ");
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
8
|
|
|
9
9
|
export const REHEARSAL_RESULT_VERSION = 1;
|
|
10
|
-
export const REHEARSAL_VERSION = "0.1.0-beta.
|
|
10
|
+
export const REHEARSAL_VERSION = "0.1.0-beta.8";
|
|
11
11
|
|
|
12
12
|
export const REHEARSAL_EXIT_CODES = Object.freeze({
|
|
13
13
|
success: 0,
|
|
@@ -237,7 +237,7 @@ export const buildRehearsalPlan = async (options = {}) => {
|
|
|
237
237
|
"versioned configuration accepts loopback hosts only",
|
|
238
238
|
`runtime uses a dedicated local ${config.runtime.target === "supabase" ? "Supabase workdir" : "PostgreSQL container and volume"} and project id`,
|
|
239
239
|
"child processes receive an allowlisted environment",
|
|
240
|
-
"
|
|
240
|
+
"configuration accepts only loopback runtime URLs and quarantines common hosted credentials",
|
|
241
241
|
...(config.safety.authenticationProviders.length
|
|
242
242
|
? [
|
|
243
243
|
`external identity is limited to: ${config.safety.authenticationProviders.join(", ")}`,
|
|
@@ -651,7 +651,7 @@ export const runRehearsalDoctor = async (options = {}) => {
|
|
|
651
651
|
},
|
|
652
652
|
{
|
|
653
653
|
id: "safety-policy",
|
|
654
|
-
label: "Hosted
|
|
654
|
+
label: "Hosted target configuration",
|
|
655
655
|
run: async () => {
|
|
656
656
|
if (
|
|
657
657
|
config.safety.hostedAccess !== "disabled" ||
|
|
@@ -660,8 +660,8 @@ export const runRehearsalDoctor = async (options = {}) => {
|
|
|
660
660
|
throw new Error("The safety policy is not fail closed.");
|
|
661
661
|
}
|
|
662
662
|
return config.safety.authenticationProviders.length
|
|
663
|
-
? `hosted
|
|
664
|
-
: "hosted
|
|
663
|
+
? `hosted database targets disabled; hosted credentials quarantined; local identity providers: ${config.safety.authenticationProviders.join(", ")}`
|
|
664
|
+
: "hosted database targets disabled; common hosted credentials quarantined";
|
|
665
665
|
},
|
|
666
666
|
remediation: "Restore the version-1 fail-closed safety policy.",
|
|
667
667
|
},
|