@ankhorage/infra 0.4.2 → 1.0.1

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 (45) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/dist/adapters/minikube/auth/supabase/index.d.ts.map +1 -1
  4. package/dist/adapters/minikube/auth/supabase/index.js +14 -39
  5. package/dist/adapters/minikube/auth/supabase/index.js.map +1 -1
  6. package/dist/adapters/minikube/auth/supabase/profile.d.ts +0 -1
  7. package/dist/adapters/minikube/auth/supabase/profile.d.ts.map +1 -1
  8. package/dist/adapters/minikube/auth/supabase/profile.js +1 -1
  9. package/dist/adapters/minikube/auth/supabase/profile.js.map +1 -1
  10. package/dist/adapters/minikube/authz/cerbos/index.d.ts.map +1 -1
  11. package/dist/adapters/minikube/authz/cerbos/index.js +22 -2
  12. package/dist/adapters/minikube/authz/cerbos/index.js.map +1 -1
  13. package/dist/adapters/minikube/base/index.d.ts +5 -2
  14. package/dist/adapters/minikube/base/index.d.ts.map +1 -1
  15. package/dist/adapters/minikube/base/index.js +2764 -1841
  16. package/dist/adapters/minikube/base/index.js.map +1 -1
  17. package/dist/adapters/minikube/contracts.d.ts +25 -0
  18. package/dist/adapters/minikube/contracts.d.ts.map +1 -1
  19. package/dist/adapters/minikube/contracts.js +1 -0
  20. package/dist/adapters/minikube/contracts.js.map +1 -1
  21. package/dist/adapters/minikube/index.d.ts.map +1 -1
  22. package/dist/adapters/minikube/index.js +18 -33
  23. package/dist/adapters/minikube/index.js.map +1 -1
  24. package/dist/adapters/minikube/secrets/supabase-vault/index.d.ts.map +1 -1
  25. package/dist/adapters/minikube/secrets/supabase-vault/index.js +49 -6
  26. package/dist/adapters/minikube/secrets/supabase-vault/index.js.map +1 -1
  27. package/dist/adapters/minikube/storage/supabase/index.d.ts.map +1 -1
  28. package/dist/adapters/minikube/storage/supabase/index.js +2 -1
  29. package/dist/adapters/minikube/storage/supabase/index.js.map +1 -1
  30. package/package.json +4 -1
  31. package/src/adapters/minikube/README.md +46 -38
  32. package/src/adapters/minikube/auth/supabase/index.ts +14 -40
  33. package/src/adapters/minikube/auth/supabase/profile.ts +1 -1
  34. package/src/adapters/minikube/authz/cerbos/index.ts +25 -3
  35. package/src/adapters/minikube/base/index.test.ts +399 -673
  36. package/src/adapters/minikube/base/index.ts +2969 -1874
  37. package/src/adapters/minikube/base/twoAppIsolation.integration.test.ts +636 -0
  38. package/src/adapters/minikube/contracts.ts +29 -0
  39. package/src/adapters/minikube/index.ts +18 -44
  40. package/src/adapters/minikube/secrets/index.test.ts +15 -7
  41. package/src/adapters/minikube/secrets/supabase-vault/index.ts +53 -6
  42. package/src/adapters/minikube/storage/supabase/index.ts +1 -5
  43. package/src/apis.test.ts +2 -1
  44. package/src/index.test.ts +64 -577
  45. package/src/minikubeImageLifecycle.test.ts +4 -3
@@ -0,0 +1,636 @@
1
+ import { execFile as execFileCallback } from 'node:child_process';
2
+ import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { createServer, type Server } from 'node:net';
4
+ import path from 'node:path';
5
+ import { promisify } from 'node:util';
6
+
7
+ import { describe, expect, test } from 'bun:test';
8
+
9
+ import { generateInfrastructure } from '../../../index';
10
+ import { createAppManifest } from '../../../testSupport';
11
+ import type { GeneratedInfrastructureFile, InfraManifestInput } from '../../../types';
12
+
13
+ const execFile = promisify(execFileCallback);
14
+ const isolationTest = process.env.ANKH_MINIKUBE_ISOLATION === '1' ? test : test.skip;
15
+ const supabaseIsolationTest =
16
+ process.env.ANKH_MINIKUBE_SUPABASE_ISOLATION === '1' ? test : test.skip;
17
+ const TEST_TIMEOUT_MS = 1_800_000;
18
+ const HTTP_TIMEOUT_MS = 60_000;
19
+
20
+ describe('generated Minikube two-app isolation', () => {
21
+ isolationTest(
22
+ 'runs two generated app profiles without sharing cluster resources',
23
+ async () => {
24
+ 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);
27
+
28
+ try {
29
+ await runScript(first.minikubeRoot, 'up.sh');
30
+ await runScript(second.minikubeRoot, 'up.sh');
31
+
32
+ await expectProfileOwnsAppNamespace(first.slug);
33
+ await expectProfileOwnsAppNamespace(second.slug);
34
+ await expectNoSupabaseNamespace(first.slug);
35
+ await expectNoSupabaseNamespace(second.slug);
36
+
37
+ const firstStatus = await runScript(first.minikubeRoot, 'status.sh');
38
+ const secondStatus = await runScript(second.minikubeRoot, 'status.sh');
39
+ expect(firstStatus.stdout).toContain('- namespace/app: present');
40
+ expect(secondStatus.stdout).toContain('- namespace/app: present');
41
+ expect(firstStatus.stdout).toContain('app: running');
42
+ expect(secondStatus.stdout).toContain('app: running');
43
+ } finally {
44
+ await Promise.allSettled([
45
+ runScript(first.minikubeRoot, 'destroy.sh'),
46
+ runScript(second.minikubeRoot, 'destroy.sh'),
47
+ removeDockerImage(first.dockerImage),
48
+ removeDockerImage(second.dockerImage),
49
+ ]);
50
+ await rm(root, { recursive: true, force: true });
51
+ }
52
+ },
53
+ TEST_TIMEOUT_MS,
54
+ );
55
+
56
+ supabaseIsolationTest(
57
+ 'runs two generated Supabase-backed profiles without shared runtime state',
58
+ async () => {
59
+ const root = await mkdtemp(path.join(process.cwd(), '.tmp-minikube-supabase-isolation-'));
60
+ const [firstPorts, secondPorts] = await reserveSupabaseHostPortSets(2);
61
+ if (!firstPorts || !secondPorts) {
62
+ throw new Error('Expected two reserved Supabase host port sets.');
63
+ }
64
+ const first = await createGeneratedSupabaseApp(root, 'ankh-isolation-supa-a', firstPorts);
65
+ const second = await createGeneratedSupabaseApp(root, 'ankh-isolation-supa-b', secondPorts);
66
+
67
+ try {
68
+ await runScript(first.minikubeRoot, 'up.sh');
69
+ await runScript(second.minikubeRoot, 'up.sh');
70
+
71
+ await expectProfileOwnsAppNamespace(first.slug);
72
+ await expectProfileOwnsAppNamespace(second.slug);
73
+ await expectSupabaseRuntime(first.slug);
74
+ await expectSupabaseRuntime(second.slug);
75
+ await expectNoHostSupabaseComposeContainersFor(first.slug, second.slug);
76
+
77
+ await expectSupabaseEndToEnd(first);
78
+ const secondSession = await expectSupabaseEndToEnd(second);
79
+
80
+ await runScript(second.minikubeRoot, 'down.sh');
81
+ await runScript(second.minikubeRoot, 'up.sh');
82
+ await expectSupabaseSessionPersists(second, secondSession);
83
+
84
+ await runScript(first.minikubeRoot, 'destroy.sh');
85
+ await expectSupabaseEndToEnd(second);
86
+ } finally {
87
+ await Promise.allSettled([
88
+ runScript(first.minikubeRoot, 'destroy.sh'),
89
+ runScript(second.minikubeRoot, 'destroy.sh'),
90
+ removeDockerImage(first.dockerImage),
91
+ removeDockerImage(second.dockerImage),
92
+ ]);
93
+ await rm(root, { recursive: true, force: true });
94
+ }
95
+ },
96
+ TEST_TIMEOUT_MS,
97
+ );
98
+ });
99
+
100
+ async function createGeneratedApp(root: string, slug: string, appPort: number) {
101
+ const appRoot = path.join(root, slug);
102
+ const minikubeRoot = path.join(appRoot, 'infra', 'minikube');
103
+ const exportRoot = path.join(appRoot, '.ankh', 'web-export');
104
+ const dockerImage = `ankh/${slug}:isolation`;
105
+ await mkdir(appRoot, { recursive: true });
106
+ await writeGeneratedFiles(appRoot, generateAppOnlyInfrastructure(slug).files);
107
+ await mkdir(exportRoot, { recursive: true });
108
+ await writeFile(path.join(exportRoot, 'index.html'), `<h1>${slug}</h1>\n`);
109
+ await execFile(
110
+ 'docker',
111
+ [
112
+ 'build',
113
+ '-t',
114
+ dockerImage,
115
+ '-f',
116
+ path.join(minikubeRoot, 'app-image', 'Dockerfile'),
117
+ exportRoot,
118
+ ],
119
+ { timeout: 180_000 },
120
+ );
121
+ await writeFile(
122
+ path.join(minikubeRoot, '.env'),
123
+ [
124
+ `ANKH_APP_SLUG=${slug}`,
125
+ `APP_IMAGE=${dockerImage}`,
126
+ 'APP_BUILD_ENABLED=false',
127
+ 'APP_WEB_EXPORT_DIR=.ankh/web-export',
128
+ 'APP_IMAGE_SYNC_STRATEGY=docker-load',
129
+ `APP_PORT_FORWARD_LOCAL_PORT=${appPort}`,
130
+ '',
131
+ ].join('\n'),
132
+ );
133
+
134
+ return { appRoot, dockerImage, minikubeRoot, slug };
135
+ }
136
+
137
+ function generateAppOnlyInfrastructure(slug: string) {
138
+ const manifest: InfraManifestInput = {
139
+ deployment: {
140
+ target: 'minikube',
141
+ monitoring: false,
142
+ },
143
+ plugins: [],
144
+ };
145
+
146
+ return generateInfrastructure(manifest, {
147
+ appManifest: createAppManifest(slug),
148
+ });
149
+ }
150
+
151
+ interface GeneratedSupabaseHostPorts {
152
+ readonly app: number;
153
+ readonly gateway: number;
154
+ readonly studio: number;
155
+ readonly db: number;
156
+ }
157
+
158
+ async function createGeneratedSupabaseApp(
159
+ root: string,
160
+ slug: string,
161
+ hostPorts: GeneratedSupabaseHostPorts,
162
+ ) {
163
+ const appRoot = path.join(root, slug);
164
+ const minikubeRoot = path.join(appRoot, 'infra', 'minikube');
165
+ const exportRoot = path.join(appRoot, '.ankh', 'web-export');
166
+ const dockerImage = `ankh/${slug}:isolation`;
167
+ await mkdir(appRoot, { recursive: true });
168
+ await writeGeneratedFiles(appRoot, generateSupabaseInfrastructure(slug).files);
169
+ await mkdir(exportRoot, { recursive: true });
170
+ await writeFile(path.join(exportRoot, 'index.html'), `<h1>${slug}</h1>\n`);
171
+ await execFile(
172
+ 'docker',
173
+ [
174
+ 'build',
175
+ '-t',
176
+ dockerImage,
177
+ '-f',
178
+ path.join(minikubeRoot, 'app-image', 'Dockerfile'),
179
+ exportRoot,
180
+ ],
181
+ { timeout: 180_000 },
182
+ );
183
+ await writeFile(
184
+ path.join(minikubeRoot, '.env'),
185
+ [
186
+ `ANKH_APP_SLUG=${slug}`,
187
+ `APP_IMAGE=${dockerImage}`,
188
+ 'APP_BUILD_ENABLED=false',
189
+ 'APP_WEB_EXPORT_DIR=.ankh/web-export',
190
+ 'APP_IMAGE_SYNC_STRATEGY=docker-load',
191
+ `APP_PORT_FORWARD_LOCAL_PORT=${hostPorts.app}`,
192
+ `SUPABASE_GATEWAY_FORWARD_LOCAL_PORT=${hostPorts.gateway}`,
193
+ `SUPABASE_STUDIO_FORWARD_LOCAL_PORT=${hostPorts.studio}`,
194
+ `SUPABASE_DB_FORWARD_LOCAL_PORT=${hostPorts.db}`,
195
+ '',
196
+ ].join('\n'),
197
+ );
198
+
199
+ return { appRoot, dockerImage, minikubeRoot, slug };
200
+ }
201
+
202
+ async function reserveSupabaseHostPortSets(count: number): Promise<GeneratedSupabaseHostPorts[]> {
203
+ const servers = await Promise.all(
204
+ Array.from({ length: count * 4 }, () => listenOnEphemeralPort()),
205
+ );
206
+ try {
207
+ const ports = servers.map((server) => {
208
+ const address = server.address();
209
+ if (!address || typeof address === 'string') {
210
+ throw new Error('Expected TCP server address with an assigned port.');
211
+ }
212
+ return address.port;
213
+ });
214
+ if (new Set(ports).size !== ports.length) {
215
+ throw new Error('Expected every generated Supabase host port to be unique.');
216
+ }
217
+
218
+ return Array.from({ length: count }, (_, index) => {
219
+ const offset = index * 4;
220
+ const app = ports[offset];
221
+ const gateway = ports[offset + 1];
222
+ const studio = ports[offset + 2];
223
+ const db = ports[offset + 3];
224
+ if (!app || !gateway || !studio || !db) {
225
+ throw new Error('Expected a complete Supabase host port set.');
226
+ }
227
+ return { app, gateway, studio, db };
228
+ });
229
+ } finally {
230
+ await Promise.all(servers.map(closeServer));
231
+ }
232
+ }
233
+
234
+ async function listenOnEphemeralPort(): Promise<Server> {
235
+ const server = createServer();
236
+ await new Promise<void>((resolve, reject) => {
237
+ server.once('error', reject);
238
+ server.listen(0, '127.0.0.1', () => {
239
+ server.off('error', reject);
240
+ resolve();
241
+ });
242
+ });
243
+ return server;
244
+ }
245
+
246
+ async function closeServer(server: Server): Promise<void> {
247
+ await new Promise<void>((resolve, reject) => {
248
+ server.close((error) => {
249
+ if (error) {
250
+ reject(error);
251
+ return;
252
+ }
253
+ resolve();
254
+ });
255
+ });
256
+ }
257
+
258
+ function generateSupabaseInfrastructure(slug: string) {
259
+ const manifest: InfraManifestInput = {
260
+ deployment: {
261
+ target: 'minikube',
262
+ monitoring: false,
263
+ },
264
+ auth: {
265
+ scope: 'global',
266
+ provider: 'supabase',
267
+ profile: {
268
+ table: 'profiles',
269
+ fields: ['email'],
270
+ },
271
+ },
272
+ database: {
273
+ provider: 'supabase',
274
+ tier: 'dev',
275
+ },
276
+ storage: {
277
+ provider: 'supabase',
278
+ buckets: ['avatars'],
279
+ },
280
+ secretStore: {
281
+ provider: 'supabase-vault',
282
+ },
283
+ plugins: [],
284
+ };
285
+
286
+ return generateInfrastructure(manifest, {
287
+ appManifest: createAppManifest(slug),
288
+ });
289
+ }
290
+
291
+ async function writeGeneratedFiles(
292
+ appRoot: string,
293
+ files: readonly GeneratedInfrastructureFile[],
294
+ ): Promise<void> {
295
+ await Promise.all(
296
+ files.map(async (file) => {
297
+ const filePath = path.join(appRoot, file.path);
298
+ await mkdir(path.dirname(filePath), { recursive: true });
299
+ await writeFile(filePath, file.content, 'utf8');
300
+ if (file.executable === true) {
301
+ await chmod(filePath, 0o755);
302
+ }
303
+ }),
304
+ );
305
+ }
306
+
307
+ 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
+ });
313
+ }
314
+
315
+ async function removeDockerImage(image: string): Promise<void> {
316
+ await execFile('docker', ['image', 'rm', image], { timeout: 60_000 }).catch(() => {
317
+ // Best-effort cleanup for gated local integration runs.
318
+ });
319
+ }
320
+
321
+ async function expectProfileOwnsAppNamespace(profile: string) {
322
+ const result = await execFile(
323
+ 'kubectl',
324
+ ['--context', profile, '-n', 'app', 'get', 'deployment', 'app-runtime', '-o', 'name'],
325
+ { timeout: 60_000 },
326
+ );
327
+ expect(result.stdout.trim()).toBe('deployment.apps/app-runtime');
328
+ }
329
+
330
+ async function expectNoSupabaseNamespace(profile: string) {
331
+ let namespaceFound = true;
332
+ try {
333
+ await execFile('kubectl', ['--context', profile, 'get', 'namespace', 'supabase'], {
334
+ timeout: 60_000,
335
+ });
336
+ } catch {
337
+ namespaceFound = false;
338
+ }
339
+ expect(namespaceFound).toBe(false);
340
+ }
341
+
342
+ async function expectSupabaseRuntime(profile: string) {
343
+ const namespace = await execFile(
344
+ 'kubectl',
345
+ ['--context', profile, 'get', 'namespace', 'supabase', '-o', 'name'],
346
+ { timeout: 60_000 },
347
+ );
348
+ expect(namespace.stdout.trim()).toBe('namespace/supabase');
349
+
350
+ for (const deployment of ['postgres', 'auth', 'rest', 'realtime', 'storage', 'gateway']) {
351
+ const result = await execFile(
352
+ 'kubectl',
353
+ ['--context', profile, '-n', 'supabase', 'get', 'deployment', deployment, '-o', 'name'],
354
+ { timeout: 60_000 },
355
+ );
356
+ expect(result.stdout.trim()).toBe(`deployment.apps/${deployment}`);
357
+ }
358
+ }
359
+
360
+ async function expectNoHostSupabaseComposeContainersFor(...slugs: string[]) {
361
+ const result = await execFile('docker', ['ps', '--format', '{{.Names}}'], { timeout: 60_000 });
362
+ const names = result.stdout
363
+ .split(/\r?\n/u)
364
+ .map((name) => name.trim())
365
+ .filter(Boolean);
366
+
367
+ for (const slug of slugs) {
368
+ expect(names.some((name) => name.startsWith('supabase_') && name.includes(slug))).toBe(false);
369
+ }
370
+ }
371
+
372
+ interface SupabaseSessionFixture {
373
+ readonly accessToken: string;
374
+ readonly anonKey: string;
375
+ readonly email: string;
376
+ readonly gatewayUrl: string;
377
+ readonly userId: string;
378
+ }
379
+
380
+ async function expectSupabaseEndToEnd(app: {
381
+ minikubeRoot: string;
382
+ slug: string;
383
+ }): Promise<SupabaseSessionFixture> {
384
+ const env = await readGeneratedEnv(app.minikubeRoot);
385
+ const gatewayUrl = readRequiredEnv(env, 'SUPABASE_PUBLIC_URL');
386
+ const anonKey = readRequiredEnv(env, 'SUPABASE_ANON_KEY');
387
+ const serviceRoleKey = readRequiredEnv(env, 'SUPABASE_SERVICE_ROLE_KEY');
388
+ const dbUrl = readRequiredEnv(env, 'SUPABASE_DB_URL');
389
+ const email = `${app.slug}-${Date.now()}@example.test`;
390
+ const password = `pw-${app.slug}-1234567890`;
391
+
392
+ const signup = await postJson(`${gatewayUrl}/auth/v1/signup`, anonKey, anonKey, {
393
+ email,
394
+ password,
395
+ });
396
+ assertHttpOk(signup, 'auth signup');
397
+
398
+ const signIn = await postJson(
399
+ `${gatewayUrl}/auth/v1/token?grant_type=password`,
400
+ anonKey,
401
+ anonKey,
402
+ {
403
+ email,
404
+ password,
405
+ },
406
+ );
407
+ assertHttpOk(signIn, 'auth password sign-in');
408
+ const accessToken = readStringField(signIn.body, 'access_token');
409
+ const userId = readStringField(readRecord(signIn.body, 'user'), 'id');
410
+
411
+ const profile = await getJson(
412
+ `${gatewayUrl}/rest/v1/profiles?id=eq.${encodeURIComponent(userId)}&select=id,email`,
413
+ anonKey,
414
+ accessToken,
415
+ );
416
+ assertHttpOk(profile, 'profile REST read');
417
+ expect(Array.isArray(profile.body)).toBe(true);
418
+ expect(profile.body).toHaveLength(1);
419
+ expect(profile.body[0]).toMatchObject({ id: userId, email });
420
+
421
+ await ensureStorageObjectRoundTrip(gatewayUrl, serviceRoleKey, app.slug);
422
+ await ensureVaultRoundTrip(dbUrl, app.slug);
423
+
424
+ return { accessToken, anonKey, email, gatewayUrl, userId };
425
+ }
426
+
427
+ async function expectSupabaseSessionPersists(
428
+ app: { minikubeRoot: string; slug: string },
429
+ session: SupabaseSessionFixture,
430
+ ): Promise<void> {
431
+ const env = await readGeneratedEnv(app.minikubeRoot);
432
+ const gatewayUrl = readRequiredEnv(env, 'SUPABASE_PUBLIC_URL');
433
+ const anonKey = readRequiredEnv(env, 'SUPABASE_ANON_KEY');
434
+ expect(gatewayUrl).toBe(session.gatewayUrl);
435
+ expect(anonKey).toBe(session.anonKey);
436
+
437
+ const user = await getJson(`${gatewayUrl}/auth/v1/user`, anonKey, session.accessToken);
438
+ assertHttpOk(user, 'auth user read after stop/start');
439
+ expect(user.body).toMatchObject({ id: session.userId, email: session.email });
440
+
441
+ const profile = await getJson(
442
+ `${gatewayUrl}/rest/v1/profiles?id=eq.${encodeURIComponent(session.userId)}&select=id,email`,
443
+ anonKey,
444
+ session.accessToken,
445
+ );
446
+ assertHttpOk(profile, 'profile REST read after stop/start');
447
+ expect(Array.isArray(profile.body)).toBe(true);
448
+ expect(profile.body).toHaveLength(1);
449
+ expect(profile.body[0]).toMatchObject({ id: session.userId, email: session.email });
450
+ }
451
+
452
+ async function readGeneratedEnv(minikubeRoot: string): Promise<Map<string, string>> {
453
+ const content = await readFile(path.join(minikubeRoot, '.env'), 'utf8');
454
+ const env = new Map<string, string>();
455
+ for (const line of content.split(/\r?\n/u)) {
456
+ const trimmed = line.trim();
457
+ if (!trimmed || trimmed.startsWith('#')) continue;
458
+ const separator = trimmed.indexOf('=');
459
+ if (separator <= 0) continue;
460
+ env.set(trimmed.slice(0, separator), trimmed.slice(separator + 1));
461
+ }
462
+ return env;
463
+ }
464
+
465
+ function readRequiredEnv(env: Map<string, string>, key: string): string {
466
+ const value = env.get(key);
467
+ if (!value) throw new Error(`Missing generated env value ${key}.`);
468
+ return value;
469
+ }
470
+
471
+ async function postJson(
472
+ url: string,
473
+ apikey: string,
474
+ bearer: string,
475
+ body: Record<string, unknown>,
476
+ ): Promise<{ response: Response; body: unknown }> {
477
+ const response = await fetch(url, {
478
+ method: 'POST',
479
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
480
+ headers: {
481
+ apikey,
482
+ authorization: `Bearer ${bearer}`,
483
+ 'content-type': 'application/json',
484
+ },
485
+ body: JSON.stringify(body),
486
+ });
487
+ return { response, body: await response.json().catch(() => null) };
488
+ }
489
+
490
+ async function getJson(
491
+ url: string,
492
+ apikey: string,
493
+ bearer: string,
494
+ ): Promise<{ response: Response; body: unknown }> {
495
+ const response = await fetch(url, {
496
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
497
+ headers: {
498
+ apikey,
499
+ authorization: `Bearer ${bearer}`,
500
+ },
501
+ });
502
+ return { response, body: await response.json().catch(() => null) };
503
+ }
504
+
505
+ function readRecord(value: unknown, field: string): Record<string, unknown> {
506
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
507
+ throw new Error(`Expected object while reading ${field}.`);
508
+ }
509
+ const child = (value as Record<string, unknown>)[field];
510
+ if (!child || typeof child !== 'object' || Array.isArray(child)) {
511
+ throw new Error(`Expected object field ${field}.`);
512
+ }
513
+ return child as Record<string, unknown>;
514
+ }
515
+
516
+ function readStringField(value: unknown, field: string): string {
517
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
518
+ throw new Error(`Expected object while reading ${field}.`);
519
+ }
520
+ const fieldValue = (value as Record<string, unknown>)[field];
521
+ if (typeof fieldValue !== 'string' || fieldValue.length === 0) {
522
+ throw new Error(`Expected non-empty string field ${field}.`);
523
+ }
524
+ return fieldValue;
525
+ }
526
+
527
+ async function ensureStorageObjectRoundTrip(
528
+ gatewayUrl: string,
529
+ serviceRoleKey: string,
530
+ slug: string,
531
+ ): Promise<void> {
532
+ const bucket = await postJson(`${gatewayUrl}/storage/v1/bucket`, serviceRoleKey, serviceRoleKey, {
533
+ id: 'avatars',
534
+ name: 'avatars',
535
+ public: false,
536
+ });
537
+ if (!bucket.response.ok && bucket.response.status !== 409 && !isStorageDuplicate(bucket.body)) {
538
+ throw new Error(
539
+ `storage bucket create failed with HTTP ${bucket.response.status}: ${JSON.stringify(
540
+ bucket.body,
541
+ )}`,
542
+ );
543
+ }
544
+
545
+ const objectKey = `e2e/${slug}-${Date.now()}.txt`;
546
+ const upload = await fetch(`${gatewayUrl}/storage/v1/object/avatars/${objectKey}`, {
547
+ method: 'POST',
548
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
549
+ headers: {
550
+ apikey: serviceRoleKey,
551
+ authorization: `Bearer ${serviceRoleKey}`,
552
+ 'content-type': 'text/plain',
553
+ 'x-upsert': 'true',
554
+ },
555
+ body: `storage:${slug}`,
556
+ });
557
+ if (!upload.ok) {
558
+ throw new Error(`storage upload failed with HTTP ${upload.status}: ${await upload.text()}`);
559
+ }
560
+
561
+ const download = await fetch(`${gatewayUrl}/storage/v1/object/avatars/${objectKey}`, {
562
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
563
+ headers: {
564
+ apikey: serviceRoleKey,
565
+ authorization: `Bearer ${serviceRoleKey}`,
566
+ },
567
+ });
568
+ if (!download.ok) {
569
+ throw new Error(
570
+ `storage download failed with HTTP ${download.status}: ${await download.text()}`,
571
+ );
572
+ }
573
+ expect(await download.text()).toBe(`storage:${slug}`);
574
+ }
575
+
576
+ function assertHttpOk(result: { response: Response; body: unknown }, label: string): void {
577
+ if (result.response.ok) return;
578
+ throw new Error(
579
+ `${label} failed with HTTP ${result.response.status}: ${JSON.stringify(result.body)}`,
580
+ );
581
+ }
582
+
583
+ function isStorageDuplicate(body: unknown): boolean {
584
+ return (
585
+ !!body &&
586
+ typeof body === 'object' &&
587
+ !Array.isArray(body) &&
588
+ (body as Record<string, unknown>).statusCode === '409'
589
+ );
590
+ }
591
+
592
+ async function ensureVaultRoundTrip(dbUrl: string, slug: string): Promise<void> {
593
+ const secretRef = `e2e/${slug}/${Date.now()}`;
594
+ const marker = `vault:${slug}`;
595
+ const sql = `
596
+ with created as (
597
+ select vault.create_secret('${escapeSqlLiteral(JSON.stringify({ marker }))}', '${escapeSqlLiteral(
598
+ secretRef,
599
+ )}', 'Generated Supabase isolation test')::uuid as id
600
+ )
601
+ insert into ankh_secret_store.secret_metadata (
602
+ project_id, environment, secret_ref, vault_secret_id, kind, provider, configured_fields
603
+ )
604
+ select '${escapeSqlLiteral(slug)}', 'local', '${escapeSqlLiteral(
605
+ secretRef,
606
+ )}', id, 'generic', 'e2e', array['marker']::text[]
607
+ from created;
608
+ `;
609
+ await execFile('psql', [dbUrl, '-v', 'ON_ERROR_STOP=1', '-q', '-c', sql], {
610
+ timeout: 60_000,
611
+ });
612
+
613
+ const resolved = await execFile(
614
+ 'psql',
615
+ [
616
+ dbUrl,
617
+ '-v',
618
+ 'ON_ERROR_STOP=1',
619
+ '-Atq',
620
+ '-c',
621
+ `select decrypted.decrypted_secret::jsonb ->> 'marker'
622
+ from ankh_secret_store.secret_metadata metadata
623
+ join vault.decrypted_secrets decrypted on decrypted.id = metadata.vault_secret_id
624
+ where metadata.project_id = '${escapeSqlLiteral(slug)}'
625
+ and metadata.environment = 'local'
626
+ and metadata.secret_ref = '${escapeSqlLiteral(secretRef)}'
627
+ limit 1;`,
628
+ ],
629
+ { timeout: 60_000 },
630
+ );
631
+ expect(resolved.stdout.trim()).toBe(marker);
632
+ }
633
+
634
+ function escapeSqlLiteral(value: string): string {
635
+ return value.replace(/'/gu, "''");
636
+ }
@@ -1,8 +1,36 @@
1
1
  import type { GeneratedInfrastructureFile } from '../../types';
2
2
 
3
+ interface MinikubeProviderEndpoint {
4
+ name: string;
5
+ url: string;
6
+ }
7
+
8
+ interface MinikubeProviderReadinessCheck {
9
+ label: string;
10
+ namespace: string;
11
+ resource: string;
12
+ timeoutSeconds: number;
13
+ }
14
+
15
+ interface MinikubeProviderLifecycleCommand {
16
+ label: string;
17
+ command: string;
18
+ }
19
+
20
+ export interface MinikubeProviderLifecycle {
21
+ id: string;
22
+ namespace: string;
23
+ endpoints: MinikubeProviderEndpoint[];
24
+ readinessChecks: MinikubeProviderReadinessCheck[];
25
+ migrationCommands: MinikubeProviderLifecycleCommand[];
26
+ reconciliationCommands: MinikubeProviderLifecycleCommand[];
27
+ statusChecks: MinikubeProviderLifecycleCommand[];
28
+ }
29
+
3
30
  export interface MinikubeAdapterArtifacts {
4
31
  files: GeneratedInfrastructureFile[];
5
32
  resources: string[];
33
+ providerLifecycle: MinikubeProviderLifecycle[];
6
34
  envEntries: string[];
7
35
  warnings: string[];
8
36
  }
@@ -11,6 +39,7 @@ export function emptyMinikubeArtifacts(): MinikubeAdapterArtifacts {
11
39
  return {
12
40
  files: [],
13
41
  resources: [],
42
+ providerLifecycle: [],
14
43
  envEntries: [],
15
44
  warnings: [],
16
45
  };