@rehearsal-db/core 0.1.0-beta.1 → 0.1.0-beta.11
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 +271 -1
- package/COMPATIBILITY.md +26 -0
- package/README.md +150 -346
- package/SECURITY.md +2 -1
- package/SUPPORT.md +5 -0
- package/docs/README.md +38 -0
- package/docs/adapters.md +20 -6
- package/docs/architecture.md +58 -0
- package/docs/baselines.md +102 -22
- package/docs/commands.md +202 -33
- package/docs/configuration.md +288 -13
- package/docs/getting-started.md +114 -181
- package/docs/glossary.md +11 -8
- package/docs/index.md +47 -0
- package/docs/production-source.md +176 -33
- package/docs/releasing.md +28 -38
- package/docs/roadmap.md +41 -0
- package/docs/runtime-policies.md +253 -0
- package/docs/sanitization.md +144 -8
- package/docs/security-model.md +34 -9
- package/docs/standalone-workflow.md +109 -0
- package/docs/troubleshooting.md +127 -2
- package/docs/tutorial.md +70 -59
- package/package.json +39 -23
- package/scripts/install/create_root_config.mjs +36 -0
- package/scripts/runtime/manage_database.mjs +18 -0
- package/src/README.md +17 -0
- package/src/application/session.mjs +435 -0
- package/{scripts/lib/rehearsal/baseline_artifact.mjs → src/baseline/artifact.mjs} +142 -17
- package/{scripts/lib/rehearsal/baseline_builder.mjs → src/baseline/builder.mjs} +19 -10
- package/src/baseline/input_discovery.mjs +155 -0
- package/src/baseline/policy_review.mjs +92 -0
- package/src/baseline/preparation.mjs +323 -0
- package/src/baseline/privacy_engine.mjs +741 -0
- package/{scripts/lib/rehearsal → src/baseline}/sanitization_policy.mjs +133 -3
- package/{scripts/lib/rehearsal → src/baseline}/schema_snapshot.mjs +1 -1
- package/src/cli/arguments.mjs +123 -0
- package/src/cli/guided.mjs +812 -0
- package/src/cli/init_command.mjs +77 -0
- package/src/cli/rehearsal.mjs +952 -0
- package/src/cli/renderers.mjs +589 -0
- package/src/cli/runtime_commands.mjs +624 -0
- package/src/cli/source_commands.mjs +326 -0
- package/src/cli/terminal.mjs +275 -0
- package/src/identity/claim.mjs +1163 -0
- package/src/identity/storage.mjs +165 -0
- package/{scripts/lib/rehearsal → src/project}/configuration.d.mts +72 -4
- package/src/project/configuration.mjs +1201 -0
- package/src/project/install_scaffold.mjs +291 -0
- package/src/project/setup.mjs +484 -0
- package/src/project/support_report.mjs +105 -0
- package/src/runtime/cleanup.mjs +381 -0
- package/{scripts/lib/rehearsal → src/runtime}/plan.mjs +118 -20
- package/src/runtime/policy.mjs +438 -0
- package/{scripts/lib/rehearsal/runtime_restore.mjs → src/runtime/restore.mjs} +46 -27
- package/src/runtime/topology.mjs +177 -0
- package/{scripts/lib/rehearsal → src/shared}/diagnostics.mjs +10 -3
- package/src/shared/human_output.mjs +4 -0
- package/src/shared/operation_guard.mjs +162 -0
- package/{scripts/lib/rehearsal → src/shared}/process_environment.mjs +5 -1
- package/src/source/access.mjs +578 -0
- package/src/source/asset_transfer.mjs +177 -0
- package/src/source/baseline.mjs +446 -0
- package/src/source/postgresql_access.mjs +480 -0
- package/src/targets/postgresql.mjs +808 -0
- package/{scripts/operations/database/manage_rehearsal_database.mjs → src/targets/supabase.mjs} +66 -20
- package/{scripts/lib/environment/local_supabase.mjs → src/targets/supabase_environment.mjs} +60 -28
- package/src/targets/target.mjs +66 -0
- package/scripts/lib/rehearsal/configuration.mjs +0 -559
- package/scripts/operations/rehearsal/rehearsal_cli.mjs +0 -565
- /package/{scripts/lib/rehearsal → src/runtime}/migration_history.mjs +0 -0
- /package/{scripts/lib/rehearsal → src/runtime}/service_environment.mjs +0 -0
|
@@ -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 "../project/configuration.mjs";
|
|
14
|
+
import { createCleanProcessEnvironment } from "../shared/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
|
+
};
|
|
@@ -10,15 +10,19 @@ import { createConnection } from "node:net";
|
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { pathToFileURL } from "node:url";
|
|
12
12
|
import { performance } from "node:perf_hooks";
|
|
13
|
-
import { verifyActiveBaseline } from "
|
|
13
|
+
import { verifyActiveBaseline } from "../baseline/artifact.mjs";
|
|
14
14
|
import {
|
|
15
15
|
inspectDetectedProject,
|
|
16
16
|
loadRehearsalConfig,
|
|
17
|
-
} from "
|
|
18
|
-
import { createCandidateMigrationReceipt } from "./
|
|
17
|
+
} from "../project/configuration.mjs";
|
|
18
|
+
import { createCandidateMigrationReceipt } from "./restore.mjs";
|
|
19
19
|
import { readMigrationFileInventory } from "./migration_history.mjs";
|
|
20
|
-
import { REHEARSAL_VERSION } from "
|
|
20
|
+
import { REHEARSAL_VERSION } from "../shared/diagnostics.mjs";
|
|
21
21
|
import { readRehearsalServiceEnvironment } from "./service_environment.mjs";
|
|
22
|
+
import {
|
|
23
|
+
readBoundRuntimeSanitizationPolicy,
|
|
24
|
+
validateRuntimeSanitizationPolicy,
|
|
25
|
+
} from "../baseline/sanitization_policy.mjs";
|
|
22
26
|
|
|
23
27
|
const commandAvailable = (command, args = ["--version"]) => {
|
|
24
28
|
const result = spawnSync(command, args, {
|
|
@@ -101,6 +105,14 @@ const resultCheck = async ({ id, label, run, remediation }) => {
|
|
|
101
105
|
};
|
|
102
106
|
|
|
103
107
|
const assertRuntimeConfigMatches = async ({ config, paths }) => {
|
|
108
|
+
if (config.runtime.target === "postgresql") {
|
|
109
|
+
if (paths.rehearsalConfig !== null || paths.supabaseWorkdir !== null) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
"The PostgreSQL target unexpectedly resolved Supabase paths.",
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return `isolated PostgreSQL image ${config.postgresql.image} and a loopback database port are configured`;
|
|
115
|
+
}
|
|
104
116
|
const source = await readFile(paths.rehearsalConfig, "utf8");
|
|
105
117
|
const expected = [
|
|
106
118
|
["project_id", config.runtime.projectId],
|
|
@@ -155,8 +167,15 @@ const assertConfiguredCommand = async ({ command, projectRoot }) => {
|
|
|
155
167
|
|
|
156
168
|
const loadPlanInputs = async (options = {}) => {
|
|
157
169
|
const loaded = await loadRehearsalConfig(options);
|
|
158
|
-
const baseline = await
|
|
159
|
-
|
|
170
|
+
const [baseline, policyBytes] = await Promise.all([
|
|
171
|
+
verifyActiveBaseline({
|
|
172
|
+
artifactRoot: loaded.paths.artifactDirectory,
|
|
173
|
+
}),
|
|
174
|
+
readFile(loaded.paths.sanitizationPolicy),
|
|
175
|
+
]);
|
|
176
|
+
readBoundRuntimeSanitizationPolicy({
|
|
177
|
+
bytes: policyBytes,
|
|
178
|
+
expectedSha256: baseline.sanitizationPolicySha256,
|
|
160
179
|
});
|
|
161
180
|
const currentFiles = await readMigrationFileInventory(
|
|
162
181
|
new URL("./", pathToFileURL(`${loaded.paths.migrationDirectory}/`)),
|
|
@@ -206,7 +225,8 @@ export const buildRehearsalPlan = async (options = {}) => {
|
|
|
206
225
|
project: config.project.name,
|
|
207
226
|
},
|
|
208
227
|
environment: {
|
|
209
|
-
|
|
228
|
+
target: config.runtime.target,
|
|
229
|
+
kind: `isolated_local_${config.runtime.target}`,
|
|
210
230
|
applicationUrl: config.runtime.applicationUrl,
|
|
211
231
|
projectId: config.runtime.projectId,
|
|
212
232
|
ports: config.runtime.ports,
|
|
@@ -215,9 +235,9 @@ export const buildRehearsalPlan = async (options = {}) => {
|
|
|
215
235
|
authenticationProviders: config.safety.authenticationProviders,
|
|
216
236
|
barriers: [
|
|
217
237
|
"versioned configuration accepts loopback hosts only",
|
|
218
|
-
|
|
238
|
+
`runtime uses a dedicated local ${config.runtime.target === "supabase" ? "Supabase workdir" : "PostgreSQL container and volume"} and project id`,
|
|
219
239
|
"child processes receive an allowlisted environment",
|
|
220
|
-
"
|
|
240
|
+
"configuration accepts only loopback runtime URLs and quarantines common hosted credentials",
|
|
221
241
|
...(config.safety.authenticationProviders.length
|
|
222
242
|
? [
|
|
223
243
|
`external identity is limited to: ${config.safety.authenticationProviders.join(", ")}`,
|
|
@@ -371,14 +391,37 @@ export const runRehearsalDoctor = async (options = {}) => {
|
|
|
371
391
|
remediation: "Use npm for the initial Rehearsal contract.",
|
|
372
392
|
},
|
|
373
393
|
{
|
|
374
|
-
id:
|
|
375
|
-
|
|
394
|
+
id:
|
|
395
|
+
config.runtime.target === "supabase"
|
|
396
|
+
? "supabase-cli"
|
|
397
|
+
: "postgresql-image",
|
|
398
|
+
label:
|
|
399
|
+
config.runtime.target === "supabase"
|
|
400
|
+
? "Supabase CLI"
|
|
401
|
+
: "PostgreSQL image",
|
|
376
402
|
run: async () => {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
403
|
+
if (config.runtime.target === "supabase") {
|
|
404
|
+
const result = commandAvailable("supabase");
|
|
405
|
+
if (!result.available)
|
|
406
|
+
throw new Error("Supabase CLI is unavailable.");
|
|
407
|
+
return result.version;
|
|
408
|
+
}
|
|
409
|
+
const result = commandAvailable("docker", [
|
|
410
|
+
"image",
|
|
411
|
+
"inspect",
|
|
412
|
+
config.postgresql.image,
|
|
413
|
+
]);
|
|
414
|
+
if (!result.available) {
|
|
415
|
+
throw new Error(
|
|
416
|
+
`PostgreSQL image ${config.postgresql.image} is unavailable locally.`,
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
return config.postgresql.image;
|
|
380
420
|
},
|
|
381
|
-
remediation:
|
|
421
|
+
remediation:
|
|
422
|
+
config.runtime.target === "supabase"
|
|
423
|
+
? "Install the Supabase CLI in the project and retry."
|
|
424
|
+
: `Pull and review ${config.postgresql.image}, then retry.`,
|
|
382
425
|
},
|
|
383
426
|
{
|
|
384
427
|
id: "docker",
|
|
@@ -401,17 +444,33 @@ export const runRehearsalDoctor = async (options = {}) => {
|
|
|
401
444
|
run: async () => {
|
|
402
445
|
await Promise.all([
|
|
403
446
|
verifyPath(paths.migrationDirectory, "directory"),
|
|
404
|
-
|
|
447
|
+
...(paths.rehearsalConfig
|
|
448
|
+
? [verifyPath(paths.rehearsalConfig, "file")]
|
|
449
|
+
: []),
|
|
405
450
|
verifyPath(paths.sanitizationPolicy, "file"),
|
|
406
451
|
...(paths.runtimeAdapter
|
|
407
452
|
? [verifyPath(paths.runtimeAdapter, "file")]
|
|
408
453
|
: []),
|
|
409
454
|
]);
|
|
410
|
-
return
|
|
455
|
+
return config.runtime.target === "supabase"
|
|
456
|
+
? "migrations, local runtime config, and sanitization policy exist"
|
|
457
|
+
: "migrations and sanitization policy exist";
|
|
411
458
|
},
|
|
412
459
|
remediation:
|
|
413
460
|
"Correct the missing project path in the Rehearsal configuration.",
|
|
414
461
|
},
|
|
462
|
+
{
|
|
463
|
+
id: "sanitization-policy",
|
|
464
|
+
label: "Reviewed sanitization policy",
|
|
465
|
+
run: async () => {
|
|
466
|
+
const policy = validateRuntimeSanitizationPolicy(
|
|
467
|
+
JSON.parse(await readFile(paths.sanitizationPolicy, "utf8")),
|
|
468
|
+
);
|
|
469
|
+
return `${policy.tables.length} table classifications reviewed through ${policy.migrationCutoff}`;
|
|
470
|
+
},
|
|
471
|
+
remediation:
|
|
472
|
+
"Complete every REVIEW REQUIRED decision, remove draft only after review, and retry.",
|
|
473
|
+
},
|
|
415
474
|
{
|
|
416
475
|
id: "runtime-isolation",
|
|
417
476
|
label: "Runtime target isolation",
|
|
@@ -423,6 +482,9 @@ export const runRehearsalDoctor = async (options = {}) => {
|
|
|
423
482
|
id: "service-environment",
|
|
424
483
|
label: "Local service credentials",
|
|
425
484
|
run: async () => {
|
|
485
|
+
if (config.runtime.target === "postgresql") {
|
|
486
|
+
return "no local service credentials required";
|
|
487
|
+
}
|
|
426
488
|
const environment = await readRehearsalServiceEnvironment({
|
|
427
489
|
path: paths.serviceEnvironment,
|
|
428
490
|
keys: config.supabase.serviceEnvironmentVariables,
|
|
@@ -449,6 +511,42 @@ export const runRehearsalDoctor = async (options = {}) => {
|
|
|
449
511
|
);
|
|
450
512
|
const occupied = availability.filter((entry) => !entry.available);
|
|
451
513
|
if (occupied.length) {
|
|
514
|
+
if (config.runtime.target === "postgresql") {
|
|
515
|
+
const status = spawnSync(
|
|
516
|
+
"docker",
|
|
517
|
+
[
|
|
518
|
+
"ps",
|
|
519
|
+
"--filter",
|
|
520
|
+
`label=com.rehearsal-db.project=${config.runtime.projectId}`,
|
|
521
|
+
"--format",
|
|
522
|
+
"{{.Ports}}",
|
|
523
|
+
],
|
|
524
|
+
{
|
|
525
|
+
cwd: projectRoot,
|
|
526
|
+
encoding: "utf8",
|
|
527
|
+
env: Object.fromEntries(
|
|
528
|
+
["HOME", "LANG", "LC_ALL", "PATH", "SHELL", "TMPDIR"].flatMap(
|
|
529
|
+
(key) =>
|
|
530
|
+
process.env[key] === undefined
|
|
531
|
+
? []
|
|
532
|
+
: [[key, process.env[key]]],
|
|
533
|
+
),
|
|
534
|
+
),
|
|
535
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
536
|
+
},
|
|
537
|
+
);
|
|
538
|
+
if (
|
|
539
|
+
status.status !== 0 ||
|
|
540
|
+
!String(status.stdout).includes(
|
|
541
|
+
`127.0.0.1:${config.runtime.ports.database}->5432/tcp`,
|
|
542
|
+
)
|
|
543
|
+
) {
|
|
544
|
+
throw new Error(
|
|
545
|
+
`Configured database port is occupied by an unverified process: database=${config.runtime.ports.database}.`,
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
return "occupied only by the configured local Rehearsal runtime";
|
|
549
|
+
}
|
|
452
550
|
const serviceEnvironment = await readRehearsalServiceEnvironment({
|
|
453
551
|
path: paths.serviceEnvironment,
|
|
454
552
|
keys: config.supabase.serviceEnvironmentVariables,
|
|
@@ -553,7 +651,7 @@ export const runRehearsalDoctor = async (options = {}) => {
|
|
|
553
651
|
},
|
|
554
652
|
{
|
|
555
653
|
id: "safety-policy",
|
|
556
|
-
label: "Hosted
|
|
654
|
+
label: "Hosted target configuration",
|
|
557
655
|
run: async () => {
|
|
558
656
|
if (
|
|
559
657
|
config.safety.hostedAccess !== "disabled" ||
|
|
@@ -562,8 +660,8 @@ export const runRehearsalDoctor = async (options = {}) => {
|
|
|
562
660
|
throw new Error("The safety policy is not fail closed.");
|
|
563
661
|
}
|
|
564
662
|
return config.safety.authenticationProviders.length
|
|
565
|
-
? `hosted
|
|
566
|
-
: "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";
|
|
567
665
|
},
|
|
568
666
|
remediation: "Restore the version-1 fail-closed safety policy.",
|
|
569
667
|
},
|