@ankhorage/infra 3.3.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +6 -7
  3. package/dist/adapters/minikube/base/index.js +26 -10
  4. package/dist/adapters/minikube/base/index.js.map +1 -1
  5. package/dist/adapters/minikube/index.d.ts.map +1 -1
  6. package/dist/adapters/minikube/index.js +2 -16
  7. package/dist/adapters/minikube/index.js.map +1 -1
  8. package/dist/index.d.ts +0 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +0 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/infraValidation.d.ts +1 -0
  13. package/dist/infraValidation.d.ts.map +1 -1
  14. package/dist/infraValidation.js +6 -1
  15. package/dist/infraValidation.js.map +1 -1
  16. package/dist/project/index.d.ts +1 -1
  17. package/dist/project/index.d.ts.map +1 -1
  18. package/dist/project/index.js +1 -1
  19. package/dist/project/index.js.map +1 -1
  20. package/dist/readme-usage.js +5 -0
  21. package/dist/readme-usage.js.map +1 -1
  22. package/dist/runtime.d.ts +6 -0
  23. package/dist/runtime.d.ts.map +1 -1
  24. package/dist/runtime.js +24 -0
  25. package/dist/runtime.js.map +1 -1
  26. package/dist/types.d.ts +1 -1
  27. package/dist/types.d.ts.map +1 -1
  28. package/dist/types.js.map +1 -1
  29. package/package.json +7 -7
  30. package/src/adapters/minikube/README.md +9 -1
  31. package/src/adapters/minikube/base/index.test.ts +33 -0
  32. package/src/adapters/minikube/base/index.ts +28 -10
  33. package/src/adapters/minikube/base/portForwardRuntime.test.ts +288 -0
  34. package/src/adapters/minikube/base/twoAppIsolation.integration.test.ts +272 -9
  35. package/src/adapters/minikube/index.ts +2 -21
  36. package/src/apiInfrastructure.test.ts +69 -0
  37. package/src/generatedApiLedgerCleanup.test.ts +72 -0
  38. package/src/index.ts +0 -6
  39. package/src/infraApiValidation.test.ts +42 -0
  40. package/src/infraValidation.ts +10 -1
  41. package/src/project/index.test.ts +46 -0
  42. package/src/project/index.ts +1 -0
  43. package/src/readme-usage.ts +5 -0
  44. package/src/runtime.ts +31 -0
  45. package/src/types.ts +1 -1
  46. package/dist/generatedApiDatabase.d.ts +0 -20
  47. package/dist/generatedApiDatabase.d.ts.map +0 -1
  48. package/dist/generatedApiDatabase.js +0 -343
  49. package/dist/generatedApiDatabase.js.map +0 -1
  50. package/src/generatedApiDatabase.test.ts +0 -355
  51. package/src/generatedApiDatabase.ts +0 -558
@@ -0,0 +1,288 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { promises as fs } from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+
6
+ import { generateInfrastructure } from '../../../index.js';
7
+ import { createAppManifest } from '../../../testSupport.js';
8
+ import type { InfraManifestInput } from '../../../types.js';
9
+
10
+ const temporaryPaths = new Set<string>();
11
+
12
+ afterEach(async () => {
13
+ for (const rootPath of temporaryPaths) {
14
+ await stopFixtureProcesses(rootPath);
15
+ await fs.rm(rootPath, { force: true, recursive: true });
16
+ }
17
+ temporaryPaths.clear();
18
+ });
19
+
20
+ describe('generated Minikube runtime port-forward lifecycle', () => {
21
+ test(
22
+ 'keeps healthy app forwarding, repairs a stopped Supabase gateway, and isolates operational forwards',
23
+ async () => {
24
+ const fixture = await createPortForwardFixture(createSupabaseManifest(), 'runtime-recovery');
25
+
26
+ const firstStart = await runPortForward(fixture, 'start', 'runtime');
27
+ expect(firstStart.stdout).toContain('app: started');
28
+ expect(firstStart.stdout).toContain('supabase-gateway: started');
29
+
30
+ const appPid = await readForwardPid(fixture, 'app');
31
+ const firstGatewayPid = await readForwardPid(fixture, 'supabase-gateway');
32
+ await expectForwardPid(fixture, 'studio', false);
33
+ await expectForwardPid(fixture, 'db-migration', false);
34
+
35
+ const repeatedStart = await runPortForward(fixture, 'start', 'runtime');
36
+ expect(repeatedStart.stdout).toContain(`app: running (pid ${appPid})`);
37
+ expect(repeatedStart.stdout).toContain(`supabase-gateway: running (pid ${firstGatewayPid})`);
38
+ expect(await readForwardPid(fixture, 'app')).toBe(appPid);
39
+ expect(await readForwardPid(fixture, 'supabase-gateway')).toBe(firstGatewayPid);
40
+
41
+ await runPortForward(fixture, 'stop', 'supabase-gateway');
42
+ expect(await readForwardPid(fixture, 'app')).toBe(appPid);
43
+ await expectForwardPid(fixture, 'supabase-gateway', false);
44
+
45
+ const recovery = await runPortForward(fixture, 'start', 'runtime');
46
+ expect(recovery.stdout).toContain(`app: running (pid ${appPid})`);
47
+ expect(recovery.stdout).toContain('supabase-gateway: started');
48
+ expect(await readForwardPid(fixture, 'app')).toBe(appPid);
49
+ expect(await readForwardPid(fixture, 'supabase-gateway')).not.toBe(firstGatewayPid);
50
+ await expectForwardPid(fixture, 'studio', false);
51
+ await expectForwardPid(fixture, 'db-migration', false);
52
+
53
+ await runPortForward(fixture, 'stop', 'runtime');
54
+ },
55
+ { timeout: 30_000 },
56
+ );
57
+
58
+ test(
59
+ 'starts only the app forward when no provider owns another runtime endpoint',
60
+ async () => {
61
+ const fixture = await createPortForwardFixture(
62
+ { deployment: { target: 'minikube', monitoring: false }, modules: [] },
63
+ 'runtime-app-only',
64
+ );
65
+
66
+ const result = await runPortForward(fixture, 'start', 'runtime');
67
+
68
+ expect(result.stdout).toContain('app: started');
69
+ expect(result.stdout).not.toContain('supabase-gateway');
70
+ await expectForwardPid(fixture, 'app', true);
71
+ await expectForwardPid(fixture, 'supabase-gateway', false);
72
+ await expectForwardPid(fixture, 'studio', false);
73
+ await expectForwardPid(fixture, 'db-migration', false);
74
+
75
+ await runPortForward(fixture, 'stop', 'runtime');
76
+ },
77
+ { timeout: 15_000 },
78
+ );
79
+ });
80
+
81
+ interface PortForwardFixture {
82
+ readonly env: Record<string, string>;
83
+ readonly processDirectory: string;
84
+ readonly profile: string;
85
+ readonly rootPath: string;
86
+ readonly scriptPath: string;
87
+ }
88
+
89
+ async function createPortForwardFixture(
90
+ manifest: InfraManifestInput,
91
+ profile: string,
92
+ ): Promise<PortForwardFixture> {
93
+ const rootPath = await fs.mkdtemp(path.join(os.tmpdir(), 'infra-port-forward-runtime-'));
94
+ temporaryPaths.add(rootPath);
95
+ const generated = generateInfrastructure(manifest, {
96
+ appManifest: createAppManifest(profile),
97
+ });
98
+ const generatedScript = generated.files.find(
99
+ (file) => file.path === 'infra/minikube/scripts/port-forward.sh',
100
+ );
101
+ if (generatedScript === undefined) {
102
+ throw new Error('Generated port-forward script was missing.');
103
+ }
104
+
105
+ const scriptPath = path.join(rootPath, generatedScript.path);
106
+ const fakeBin = path.join(rootPath, 'fake-bin');
107
+ const processDirectory = path.join(rootPath, 'fake-processes');
108
+ const serverScript = path.join(rootPath, 'forward-server.ts');
109
+ await fs.mkdir(path.dirname(scriptPath), { recursive: true });
110
+ await fs.mkdir(fakeBin, { recursive: true });
111
+ await fs.mkdir(processDirectory, { recursive: true });
112
+ await fs.writeFile(scriptPath, generatedScript.content, 'utf8');
113
+ await fs.chmod(scriptPath, 0o755);
114
+ await fs.writeFile(
115
+ serverScript,
116
+ [
117
+ "const port = Number.parseInt(Bun.argv[2] ?? '', 10);",
118
+ "Bun.serve({ hostname: '127.0.0.1', port, fetch: () => new Response('ok') });",
119
+ 'await new Promise(() => undefined);',
120
+ '',
121
+ ].join('\n'),
122
+ 'utf8',
123
+ );
124
+ await writeExecutable(
125
+ path.join(fakeBin, 'kubectl'),
126
+ `#!/usr/bin/env bash
127
+ set -euo pipefail
128
+ if [[ " $* " == *" get "* ]]; then
129
+ exit 0
130
+ fi
131
+ if [[ " $* " != *" port-forward "* ]]; then
132
+ exit 1
133
+ fi
134
+ endpoint="\${!#}"
135
+ local_port="\${endpoint%%:*}"
136
+ printf 'kubectl %s\n' "$*" > "\${FAKE_PROCESS_DIRECTORY}/$$.command"
137
+ exec bun "\${FAKE_FORWARD_SERVER_SCRIPT}" "\${local_port}"
138
+ `,
139
+ );
140
+ await writeExecutable(
141
+ path.join(fakeBin, 'ps'),
142
+ `#!/usr/bin/env bash
143
+ set -euo pipefail
144
+ pid=""
145
+ format=""
146
+ while [[ $# -gt 0 ]]; do
147
+ case "$1" in
148
+ -p) pid="$2"; shift 2 ;;
149
+ -o) format="$2"; shift 2 ;;
150
+ *) shift ;;
151
+ esac
152
+ done
153
+ command_file="\${FAKE_PROCESS_DIRECTORY}/\${pid}.command"
154
+ if [[ -f "\${command_file}" && "\${format}" == "comm=" ]]; then
155
+ echo kubectl
156
+ elif [[ -f "\${command_file}" && "\${format}" == "command=" ]]; then
157
+ cat "\${command_file}"
158
+ else
159
+ /bin/ps -p "\${pid}" -o "\${format}"
160
+ fi
161
+ `,
162
+ );
163
+
164
+ const ports = reserveAvailablePorts(4);
165
+ return {
166
+ env: {
167
+ ...process.env,
168
+ ANKH_APP_SLUG: profile,
169
+ APP_PORT_FORWARD_LOCAL_PORT: String(ports[0]),
170
+ SUPABASE_GATEWAY_FORWARD_LOCAL_PORT: String(ports[1]),
171
+ SUPABASE_STUDIO_FORWARD_LOCAL_PORT: String(ports[2]),
172
+ SUPABASE_DB_FORWARD_LOCAL_PORT: String(ports[3]),
173
+ FAKE_FORWARD_SERVER_SCRIPT: serverScript,
174
+ FAKE_PROCESS_DIRECTORY: processDirectory,
175
+ PATH: `${fakeBin}:${process.env.PATH ?? ''}`,
176
+ },
177
+ processDirectory,
178
+ profile,
179
+ rootPath,
180
+ scriptPath,
181
+ };
182
+ }
183
+
184
+ async function runPortForward(
185
+ fixture: PortForwardFixture,
186
+ action: string,
187
+ name: string,
188
+ ): Promise<{ readonly exitCode: number; readonly stderr: string; readonly stdout: string }> {
189
+ const process = Bun.spawn(['bash', fixture.scriptPath, action, name], {
190
+ env: fixture.env,
191
+ stderr: 'pipe',
192
+ stdout: 'pipe',
193
+ });
194
+ const [exitCode, stdout, stderr] = await Promise.all([
195
+ process.exited,
196
+ new Response(process.stdout).text(),
197
+ new Response(process.stderr).text(),
198
+ ]);
199
+ if (exitCode !== 0) {
200
+ throw new Error(`port-forward ${action} ${name} failed (${exitCode}): ${stdout}${stderr}`);
201
+ }
202
+ return { exitCode, stderr, stdout };
203
+ }
204
+
205
+ async function readForwardPid(fixture: PortForwardFixture, name: string): Promise<number> {
206
+ const value = await fs.readFile(forwardPidPath(fixture, name), 'utf8');
207
+ return Number.parseInt(value.trim(), 10);
208
+ }
209
+
210
+ async function expectForwardPid(
211
+ fixture: PortForwardFixture,
212
+ name: string,
213
+ exists: boolean,
214
+ ): Promise<void> {
215
+ expect(await pathExists(forwardPidPath(fixture, name))).toBe(exists);
216
+ }
217
+
218
+ function forwardPidPath(fixture: PortForwardFixture, name: string): string {
219
+ return path.join(
220
+ fixture.rootPath,
221
+ 'infra/minikube/.state/forwards',
222
+ `${fixture.profile}-${name}.pid`,
223
+ );
224
+ }
225
+
226
+ function reserveAvailablePorts(count: number): number[] {
227
+ const listeners = Array.from({ length: count }, () =>
228
+ Bun.listen({
229
+ hostname: '127.0.0.1',
230
+ port: 0,
231
+ socket: { data: () => undefined },
232
+ }),
233
+ );
234
+ const ports = listeners.map((listener) => listener.port);
235
+ listeners.forEach((listener) => listener.stop(true));
236
+ return ports;
237
+ }
238
+
239
+ async function writeExecutable(filePath: string, content: string): Promise<void> {
240
+ await fs.writeFile(filePath, content, 'utf8');
241
+ await fs.chmod(filePath, 0o755);
242
+ }
243
+
244
+ async function pathExists(filePath: string): Promise<boolean> {
245
+ try {
246
+ await fs.access(filePath);
247
+ return true;
248
+ } catch {
249
+ return false;
250
+ }
251
+ }
252
+
253
+ async function stopFixtureProcesses(rootPath: string): Promise<void> {
254
+ const stateDirectory = path.join(rootPath, 'infra/minikube/.state/forwards');
255
+ let entries: string[];
256
+ try {
257
+ entries = await fs.readdir(stateDirectory);
258
+ } catch {
259
+ return;
260
+ }
261
+
262
+ await Promise.all(
263
+ entries
264
+ .filter((entry) => entry.endsWith('.pid'))
265
+ .map(async (entry) => {
266
+ const pid = Number.parseInt(
267
+ await fs.readFile(path.join(stateDirectory, entry), 'utf8'),
268
+ 10,
269
+ );
270
+ if (Number.isInteger(pid)) {
271
+ try {
272
+ process.kill(pid, 'SIGKILL');
273
+ } catch {
274
+ // The generated lifecycle may already have stopped the process.
275
+ }
276
+ }
277
+ }),
278
+ );
279
+ }
280
+
281
+ function createSupabaseManifest(): InfraManifestInput {
282
+ return {
283
+ deployment: { target: 'minikube', monitoring: false },
284
+ auth: { scope: 'global', provider: 'supabase' },
285
+ database: { provider: 'supabase', tier: 'dev' },
286
+ modules: [],
287
+ };
288
+ }
@@ -7,6 +7,7 @@ import { promisify } from 'node:util';
7
7
  import { describe, expect, test } from 'bun:test';
8
8
 
9
9
  import { generateInfrastructure } from '../../../index';
10
+ import { ensureProjectInfrastructureRuntime } from '../../../project/index';
10
11
  import { createAppManifest } from '../../../testSupport';
11
12
  import type { GeneratedInfrastructureFile, InfraManifestInput } from '../../../types';
12
13
 
@@ -16,14 +17,22 @@ const supabaseIsolationTest =
16
17
  process.env.ANKH_MINIKUBE_SUPABASE_ISOLATION === '1' ? test : test.skip;
17
18
  const TEST_TIMEOUT_MS = 1_800_000;
18
19
  const HTTP_TIMEOUT_MS = 60_000;
20
+ const DIAGNOSTIC_TIMEOUT_MS = 60_000;
21
+ const DIAGNOSTIC_OUTPUT_LIMIT = 20_000;
22
+ const APP_ONLY_TEST_PROFILES = ['ankh-isolation-a', 'ankh-isolation-b'] as const;
23
+ const SUPABASE_TEST_PROFILES = ['ankh-isolation-supa-a', 'ankh-isolation-supa-b'] as const;
24
+ type TestOwnedMinikubeProfile =
25
+ | (typeof APP_ONLY_TEST_PROFILES)[number]
26
+ | (typeof SUPABASE_TEST_PROFILES)[number];
19
27
 
20
28
  describe('generated Minikube two-app isolation', () => {
21
29
  isolationTest(
22
30
  'runs two generated app profiles without sharing cluster resources',
23
31
  async () => {
32
+ await deleteTestOwnedMinikubeProfiles(APP_ONLY_TEST_PROFILES);
24
33
  const root = await mkdtemp(path.join(process.cwd(), '.tmp-minikube-isolation-'));
25
- const first = await createGeneratedApp(root, 'ankh-isolation-a', 18181);
26
- const second = await createGeneratedApp(root, 'ankh-isolation-b', 18182);
34
+ const first = await createGeneratedApp(root, APP_ONLY_TEST_PROFILES[0], 18181);
35
+ const second = await createGeneratedApp(root, APP_ONLY_TEST_PROFILES[1], 18182);
27
36
 
28
37
  try {
29
38
  await runScript(first.minikubeRoot, 'up.sh');
@@ -56,16 +65,18 @@ describe('generated Minikube two-app isolation', () => {
56
65
  supabaseIsolationTest(
57
66
  'runs two generated Supabase-backed profiles without shared runtime state',
58
67
  async () => {
68
+ await deleteTestOwnedMinikubeProfiles(SUPABASE_TEST_PROFILES);
59
69
  const root = await mkdtemp(path.join(process.cwd(), '.tmp-minikube-supabase-isolation-'));
60
70
  const [firstPorts, secondPorts] = await reserveSupabaseHostPortSets(2);
61
71
  if (!firstPorts || !secondPorts) {
62
72
  throw new Error('Expected two reserved Supabase host port sets.');
63
73
  }
64
- const first = await createGeneratedSupabaseApp(root, 'ankh-isolation-supa-a', firstPorts);
65
- const second = await createGeneratedSupabaseApp(root, 'ankh-isolation-supa-b', secondPorts);
74
+ const first = await createGeneratedSupabaseApp(root, SUPABASE_TEST_PROFILES[0], firstPorts);
75
+ const second = await createGeneratedSupabaseApp(root, SUPABASE_TEST_PROFILES[1], secondPorts);
66
76
 
67
77
  try {
68
78
  await runScript(first.minikubeRoot, 'up.sh');
79
+ await prepareSecondSupabaseProfile(first.slug, second.slug, root);
69
80
  await runScript(second.minikubeRoot, 'up.sh');
70
81
 
71
82
  await expectProfileOwnsAppNamespace(first.slug);
@@ -75,6 +86,7 @@ describe('generated Minikube two-app isolation', () => {
75
86
  await expectNoHostSupabaseComposeContainersFor(first.slug, second.slug);
76
87
 
77
88
  await expectSupabaseEndToEnd(first);
89
+ await expectRuntimePortForwardRecovery(first);
78
90
  const secondSession = await expectSupabaseEndToEnd(second);
79
91
 
80
92
  await runScript(second.minikubeRoot, 'down.sh');
@@ -97,6 +109,14 @@ describe('generated Minikube two-app isolation', () => {
97
109
  );
98
110
  });
99
111
 
112
+ async function deleteTestOwnedMinikubeProfiles(
113
+ profiles: readonly TestOwnedMinikubeProfile[],
114
+ ): Promise<void> {
115
+ for (const profile of profiles) {
116
+ await execFile('minikube', ['delete', '-p', profile], { timeout: TEST_TIMEOUT_MS });
117
+ }
118
+ }
119
+
100
120
  async function createGeneratedApp(root: string, slug: string, appPort: number) {
101
121
  const appRoot = path.join(root, slug);
102
122
  const minikubeRoot = path.join(appRoot, 'infra', 'minikube');
@@ -305,11 +325,182 @@ async function writeGeneratedFiles(
305
325
  }
306
326
 
307
327
  async function runScript(minikubeRoot: string, scriptName: string) {
308
- return execFile(path.join(minikubeRoot, 'scripts', scriptName), {
309
- cwd: minikubeRoot,
310
- env: process.env,
311
- timeout: TEST_TIMEOUT_MS,
312
- });
328
+ try {
329
+ return await execFile(path.join(minikubeRoot, 'scripts', scriptName), {
330
+ cwd: minikubeRoot,
331
+ env: process.env,
332
+ timeout: TEST_TIMEOUT_MS,
333
+ });
334
+ } catch (error) {
335
+ if (scriptName !== 'up.sh') throw error;
336
+ const diagnostics = await collectFailedUpDiagnostics(minikubeRoot);
337
+ throw new Error(
338
+ `Generated ${scriptName} failed:\n${formatUnknownError(error)}\n\n${diagnostics}`,
339
+ { cause: error },
340
+ );
341
+ }
342
+ }
343
+
344
+ async function prepareSecondSupabaseProfile(
345
+ sourceProfile: string,
346
+ targetProfile: string,
347
+ scratchRoot: string,
348
+ ): Promise<void> {
349
+ const imagesResult = await execFile(
350
+ 'kubectl',
351
+ [
352
+ '--context',
353
+ sourceProfile,
354
+ '-n',
355
+ 'supabase',
356
+ 'get',
357
+ 'deployments',
358
+ '-o',
359
+ 'jsonpath={range .items[*].spec.template.spec.containers[*]}{.image}{"\\n"}{end}',
360
+ ],
361
+ { timeout: DIAGNOSTIC_TIMEOUT_MS },
362
+ );
363
+ const images = [
364
+ ...new Set(imagesResult.stdout.split(/\r?\n/u).map((image) => image.trim())),
365
+ ].filter(Boolean);
366
+ if (images.length === 0) {
367
+ throw new Error(`Expected Supabase runtime images in profile ${sourceProfile}.`);
368
+ }
369
+
370
+ await execFile(
371
+ 'minikube',
372
+ ['start', '-p', targetProfile, `--driver=${process.env.MINIKUBE_DRIVER ?? 'docker'}`],
373
+ { timeout: TEST_TIMEOUT_MS },
374
+ );
375
+
376
+ const imageArchive = path.join(scratchRoot, 'supabase-image-transfer.tar');
377
+ try {
378
+ for (const image of images) {
379
+ await execFile('minikube', ['-p', sourceProfile, 'image', 'save', image, imageArchive], {
380
+ timeout: TEST_TIMEOUT_MS,
381
+ });
382
+ await execFile('minikube', ['-p', targetProfile, 'image', 'load', imageArchive], {
383
+ timeout: TEST_TIMEOUT_MS,
384
+ });
385
+ await rm(imageArchive, { force: true });
386
+ }
387
+ } finally {
388
+ await rm(imageArchive, { force: true });
389
+ }
390
+ }
391
+
392
+ async function collectFailedUpDiagnostics(minikubeRoot: string): Promise<string> {
393
+ let profile: string;
394
+ try {
395
+ profile = readRequiredEnv(await readGeneratedEnv(minikubeRoot), 'ANKH_APP_SLUG');
396
+ } catch (error) {
397
+ return `Unable to determine the failed Minikube profile: ${formatUnknownError(error)}`;
398
+ }
399
+
400
+ const commands: readonly DiagnosticCommand[] = [
401
+ {
402
+ label: 'Minikube status',
403
+ command: 'minikube',
404
+ args: ['-p', profile, 'status'],
405
+ },
406
+ {
407
+ label: 'Supabase pods',
408
+ command: 'kubectl',
409
+ args: ['--context', profile, '-n', 'supabase', 'get', 'pods', '-o', 'wide'],
410
+ },
411
+ {
412
+ label: 'Postgres deployment',
413
+ command: 'kubectl',
414
+ args: ['--context', profile, '-n', 'supabase', 'describe', 'deployment', 'postgres'],
415
+ },
416
+ {
417
+ label: 'Postgres pod',
418
+ command: 'kubectl',
419
+ args: [
420
+ '--context',
421
+ profile,
422
+ '-n',
423
+ 'supabase',
424
+ 'describe',
425
+ 'pod',
426
+ '-l',
427
+ 'app.kubernetes.io/name=supabase-postgres',
428
+ ],
429
+ },
430
+ {
431
+ label: 'Postgres logs',
432
+ command: 'kubectl',
433
+ args: [
434
+ '--context',
435
+ profile,
436
+ '-n',
437
+ 'supabase',
438
+ 'logs',
439
+ '-l',
440
+ 'app.kubernetes.io/name=supabase-postgres',
441
+ '--all-containers=true',
442
+ '--tail=200',
443
+ ],
444
+ },
445
+ {
446
+ label: 'Supabase events',
447
+ command: 'kubectl',
448
+ args: ['--context', profile, '-n', 'supabase', 'get', 'events', '--sort-by=.lastTimestamp'],
449
+ },
450
+ {
451
+ label: 'Supabase volumes',
452
+ command: 'kubectl',
453
+ args: ['--context', profile, '-n', 'supabase', 'get', 'pvc,pv'],
454
+ },
455
+ {
456
+ label: 'Docker containers',
457
+ command: 'docker',
458
+ args: ['ps', '--format', '{{.Names}}\\t{{.Status}}\\t{{.Image}}'],
459
+ },
460
+ ];
461
+ const output: string[] = [];
462
+ for (const command of commands) {
463
+ output.push(await captureDiagnostic(command));
464
+ }
465
+ return `Bounded diagnostics for failed Minikube profile ${profile}:\n\n${output.join('\n\n')}`;
466
+ }
467
+
468
+ interface DiagnosticCommand {
469
+ readonly label: string;
470
+ readonly command: string;
471
+ readonly args: readonly string[];
472
+ }
473
+
474
+ async function captureDiagnostic(diagnostic: DiagnosticCommand): Promise<string> {
475
+ const renderedCommand = [diagnostic.command, ...diagnostic.args].join(' ');
476
+ try {
477
+ const result = await execFile(diagnostic.command, [...diagnostic.args], {
478
+ timeout: DIAGNOSTIC_TIMEOUT_MS,
479
+ });
480
+ const output = [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join('\n');
481
+ return `${diagnostic.label}\n$ ${renderedCommand}\n${truncateDiagnostic(output || '(no output)')}`;
482
+ } catch (error) {
483
+ return `${diagnostic.label}\n$ ${renderedCommand}\n${truncateDiagnostic(
484
+ formatUnknownError(error),
485
+ )}`;
486
+ }
487
+ }
488
+
489
+ function formatUnknownError(error: unknown): string {
490
+ if (!(error instanceof Error)) return String(error);
491
+ const childProcessError = error as Error & { stderr?: unknown; stdout?: unknown };
492
+ return [
493
+ error.message,
494
+ typeof childProcessError.stdout === 'string' ? childProcessError.stdout.trim() : '',
495
+ typeof childProcessError.stderr === 'string' ? childProcessError.stderr.trim() : '',
496
+ ]
497
+ .filter(Boolean)
498
+ .join('\n');
499
+ }
500
+
501
+ function truncateDiagnostic(output: string): string {
502
+ if (output.length <= DIAGNOSTIC_OUTPUT_LIMIT) return output;
503
+ return `${output.slice(0, DIAGNOSTIC_OUTPUT_LIMIT)}\n... diagnostic output truncated ...`;
313
504
  }
314
505
 
315
506
  async function removeDockerImage(image: string): Promise<void> {
@@ -369,6 +560,78 @@ async function expectNoHostSupabaseComposeContainersFor(...slugs: string[]) {
369
560
  }
370
561
  }
371
562
 
563
+ async function expectRuntimePortForwardRecovery(app: {
564
+ appRoot: string;
565
+ minikubeRoot: string;
566
+ slug: string;
567
+ }): Promise<void> {
568
+ const appPidBefore = await readForwardPid(app.minikubeRoot, app.slug, 'app');
569
+ const gatewayPidBefore = await readForwardPid(app.minikubeRoot, app.slug, 'supabase-gateway');
570
+
571
+ await runPortForward(app.minikubeRoot, 'stop', 'supabase-gateway');
572
+ await runPortForward(app.minikubeRoot, 'stop', 'studio');
573
+ await runPortForward(app.minikubeRoot, 'stop', 'db-migration');
574
+
575
+ await ensureProjectInfrastructureRuntime({
576
+ projectId: app.slug,
577
+ projectPath: app.appRoot,
578
+ target: 'minikube',
579
+ });
580
+
581
+ expect(await readForwardPid(app.minikubeRoot, app.slug, 'app')).toBe(appPidBefore);
582
+ expect(await readForwardPid(app.minikubeRoot, app.slug, 'supabase-gateway')).not.toBe(
583
+ gatewayPidBefore,
584
+ );
585
+ await expectForwardPidMissing(app.minikubeRoot, app.slug, 'studio');
586
+ await expectForwardPidMissing(app.minikubeRoot, app.slug, 'db-migration');
587
+
588
+ const env = await readGeneratedEnv(app.minikubeRoot);
589
+ const gatewayUrl = readRequiredEnv(env, 'SUPABASE_PUBLIC_URL');
590
+ const anonKey = readRequiredEnv(env, 'SUPABASE_ANON_KEY');
591
+ const response = await fetch(`${gatewayUrl}/auth/v1/health`, {
592
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
593
+ headers: {
594
+ apikey: anonKey,
595
+ },
596
+ });
597
+ expect(response.ok).toBe(true);
598
+ }
599
+
600
+ async function runPortForward(
601
+ minikubeRoot: string,
602
+ action: 'start' | 'status' | 'stop',
603
+ name: string,
604
+ ): Promise<void> {
605
+ await execFile(path.join(minikubeRoot, 'scripts', 'port-forward.sh'), [action, name], {
606
+ cwd: minikubeRoot,
607
+ env: process.env,
608
+ timeout: 60_000,
609
+ });
610
+ }
611
+
612
+ async function readForwardPid(minikubeRoot: string, slug: string, name: string): Promise<number> {
613
+ const value = await readFile(forwardPidPath(minikubeRoot, slug, name), 'utf8');
614
+ return Number.parseInt(value.trim(), 10);
615
+ }
616
+
617
+ async function expectForwardPidMissing(
618
+ minikubeRoot: string,
619
+ slug: string,
620
+ name: string,
621
+ ): Promise<void> {
622
+ let exists = true;
623
+ try {
624
+ await readFile(forwardPidPath(minikubeRoot, slug, name), 'utf8');
625
+ } catch {
626
+ exists = false;
627
+ }
628
+ expect(exists).toBe(false);
629
+ }
630
+
631
+ function forwardPidPath(minikubeRoot: string, slug: string, name: string): string {
632
+ return path.join(minikubeRoot, '.state', 'forwards', `${slug}-${name}.pid`);
633
+ }
634
+
372
635
  interface SupabaseSessionFixture {
373
636
  readonly accessToken: string;
374
637
  readonly anonKey: string;
@@ -1,4 +1,4 @@
1
- import { generateGeneratedApiDatabaseArtifacts } from '../../generatedApiDatabase';
1
+ import { validateInfraApiSupport } from '../../infraValidation';
2
2
  import type {
3
3
  GeneratedPackageDependency,
4
4
  InfraManifestInput,
@@ -45,21 +45,6 @@ export function generateMinikubeInfra(
45
45
  });
46
46
  const storageArtifacts = generateStorageArtifacts({ manifest, namespace });
47
47
  const secretStoreArtifacts = generateSecretStoreArtifacts({ manifest, namespace });
48
- const generatedApiDatabaseArtifacts = generateGeneratedApiDatabaseArtifacts({
49
- generatedApis: options.appManifest?.generatedApis,
50
- databaseProvider: manifest.database?.provider,
51
- });
52
- const generatedApiErrors = generatedApiDatabaseArtifacts.diagnostics.filter(
53
- (diagnostic) => diagnostic.severity === 'error',
54
- );
55
-
56
- if (generatedApiErrors.length > 0) {
57
- throw new Error(
58
- `Generated API database definition is invalid:\n${generatedApiErrors
59
- .map((diagnostic) => `- ${diagnostic.path ?? diagnostic.apiId}: ${diagnostic.message}`)
60
- .join('\n')}`,
61
- );
62
- }
63
48
 
64
49
  const extraResources = unique([
65
50
  ...authArtifacts.resources,
@@ -98,9 +83,6 @@ export function generateMinikubeInfra(
98
83
  ...authzArtifacts.warnings,
99
84
  ...storageArtifacts.warnings,
100
85
  ...secretStoreArtifacts.warnings,
101
- ...generatedApiDatabaseArtifacts.diagnostics
102
- .filter((diagnostic) => diagnostic.severity === 'warning')
103
- .map((diagnostic) => diagnostic.message),
104
86
  ]);
105
87
 
106
88
  return {
@@ -110,7 +92,6 @@ export function generateMinikubeInfra(
110
92
  ...authzArtifacts.files,
111
93
  ...storageArtifacts.files,
112
94
  ...secretStoreArtifacts.files,
113
- ...generatedApiDatabaseArtifacts.files,
114
95
  ],
115
96
  warnings,
116
97
  meta: {
@@ -151,7 +132,7 @@ function collectDependencies(manifest: InfraManifestInput): readonly GeneratedPa
151
132
  }
152
133
 
153
134
  function collectWarnings(manifest: InfraManifestInput): string[] {
154
- const warnings: string[] = [];
135
+ const warnings = [...validateInfraApiSupport(manifest)];
155
136
 
156
137
  if (manifest.deployment?.monitoring) {
157
138
  warnings.push(