@ankhorage/infra 4.1.0 → 4.1.2

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.
@@ -4,6 +4,7 @@ import os from 'os';
4
4
  import path from 'path';
5
5
 
6
6
  import { generateInfrastructure } from '../../../index.js';
7
+ import { ensureProjectInfrastructureRuntime } from '../../../project/index.js';
7
8
  import { createAppManifest } from '../../../testSupport.js';
8
9
  import type { InfraManifestInput } from '../../../types.js';
9
10
 
@@ -42,7 +43,11 @@ describe('generated Minikube runtime port-forward lifecycle', () => {
42
43
  expect(await readForwardPid(fixture, 'app')).toBe(appPid);
43
44
  await expectForwardPid(fixture, 'supabase-gateway', false);
44
45
 
45
- const recovery = await runPortForward(fixture, 'start', 'runtime');
46
+ const recovery = await ensureProjectInfrastructureRuntime({
47
+ projectId: fixture.profile,
48
+ projectPath: fixture.rootPath,
49
+ target: 'minikube',
50
+ });
46
51
  expect(recovery.stdout).toContain(`app: running (pid ${appPid})`);
47
52
  expect(recovery.stdout).toContain('supabase-gateway: started');
48
53
  expect(await readForwardPid(fixture, 'app')).toBe(appPid);
@@ -76,24 +81,230 @@ describe('generated Minikube runtime port-forward lifecycle', () => {
76
81
  },
77
82
  { timeout: 15_000 },
78
83
  );
84
+
85
+ test(
86
+ 'restores only the Supabase gateway for a native-only app without an app service',
87
+ async () => {
88
+ const appManifest = {
89
+ ...createAppManifest('native-runtime'),
90
+ deploy: {
91
+ targets: {
92
+ android: { enabled: true, package: 'com.ankh.nativeruntime' },
93
+ },
94
+ },
95
+ };
96
+ const fixture = await createPortForwardFixture(
97
+ createSupabaseManifest(),
98
+ 'native-runtime',
99
+ appManifest,
100
+ 'service/app-runtime',
101
+ );
102
+
103
+ const firstEnsure = await ensureProjectInfrastructureRuntime({
104
+ projectId: fixture.profile,
105
+ projectPath: fixture.rootPath,
106
+ target: 'minikube',
107
+ });
108
+ expect(firstEnsure.stdout).toContain('supabase-gateway: started');
109
+ expect(firstEnsure.stdout).not.toContain('app:');
110
+ const gatewayPid = await readForwardPid(fixture, 'supabase-gateway');
111
+
112
+ const repeatedEnsure = await ensureProjectInfrastructureRuntime({
113
+ projectId: fixture.profile,
114
+ projectPath: fixture.rootPath,
115
+ target: 'minikube',
116
+ });
117
+ expect(repeatedEnsure.stdout).toContain(`supabase-gateway: running (pid ${gatewayPid})`);
118
+ expect(await readForwardPid(fixture, 'supabase-gateway')).toBe(gatewayPid);
119
+ await expectForwardPid(fixture, 'app', false);
120
+ },
121
+ { timeout: 15_000 },
122
+ );
123
+
124
+ test(
125
+ 'retries a transient forward that becomes reachable and then loses its stale pod',
126
+ async () => {
127
+ const appManifest = {
128
+ ...createAppManifest('native-runtime-retry'),
129
+ deploy: {
130
+ targets: {
131
+ android: { enabled: true, package: 'com.ankh.nativeruntimeretry' },
132
+ },
133
+ },
134
+ };
135
+ const fixture = await createPortForwardFixture(
136
+ createSupabaseManifest(),
137
+ 'native-runtime-retry',
138
+ appManifest,
139
+ undefined,
140
+ 1,
141
+ 750,
142
+ );
143
+
144
+ const recovery = await ensureProjectInfrastructureRuntime({
145
+ projectId: fixture.profile,
146
+ projectPath: fixture.rootPath,
147
+ target: 'minikube',
148
+ });
149
+
150
+ expect(recovery.stdout).toContain(
151
+ 'supabase-gateway: port-forward attempt 1 exited before readiness; retrying',
152
+ );
153
+ expect(recovery.stdout).toContain('supabase-gateway: started');
154
+ expect(Number.parseInt(await fs.readFile(fixture.forwardAttemptPath, 'utf8'), 10)).toBe(2);
155
+ await expectForwardPid(fixture, 'supabase-gateway', true);
156
+ await expectForwardPid(fixture, 'app', false);
157
+ },
158
+ { timeout: 15_000 },
159
+ );
160
+
161
+ test(
162
+ 'cleans an owned app forward removed from the native-only active topology',
163
+ async () => {
164
+ const fixture = await createPortForwardFixture(
165
+ createSupabaseManifest(),
166
+ 'native-runtime-upgrade',
167
+ createNativeAppManifest('native-runtime-upgrade'),
168
+ );
169
+ const appPort = Number.parseInt(fixture.env.APP_PORT_FORWARD_LOCAL_PORT ?? '', 10);
170
+ const oldAppForward = Bun.spawn(
171
+ [
172
+ 'kubectl',
173
+ '--context',
174
+ fixture.profile,
175
+ '-n',
176
+ 'app',
177
+ 'port-forward',
178
+ 'service/app-runtime',
179
+ `${appPort}:80`,
180
+ ],
181
+ { env: fixture.env, stderr: 'ignore', stdout: 'ignore' },
182
+ );
183
+ await waitForHttpPort(appPort);
184
+ await writeForwardPid(fixture, 'app', oldAppForward.pid);
185
+
186
+ const cleanup = await runPortForward(fixture, 'stop', 'all');
187
+
188
+ expect(cleanup.stdout).toContain('app: stopped');
189
+ await oldAppForward.exited;
190
+ expect(isProcessRunning(oldAppForward.pid)).toBe(false);
191
+ await expectForwardPid(fixture, 'app', false);
192
+
193
+ const recovery = await runPortForward(fixture, 'start', 'runtime');
194
+ expect(recovery.stdout).toContain('supabase-gateway: started');
195
+ expect(recovery.stdout).not.toContain('app: started');
196
+ await expectForwardPid(fixture, 'app', false);
197
+ await expectForwardPid(fixture, 'supabase-gateway', true);
198
+ },
199
+ { timeout: 15_000 },
200
+ );
201
+
202
+ test(
203
+ 'refuses to terminate a non-owned PID recorded as an obsolete app forward',
204
+ async () => {
205
+ const fixture = await createPortForwardFixture(
206
+ createSupabaseManifest(),
207
+ 'native-runtime-non-owned',
208
+ createNativeAppManifest('native-runtime-non-owned'),
209
+ );
210
+ const appPort = Number.parseInt(fixture.env.APP_PORT_FORWARD_LOCAL_PORT ?? '', 10);
211
+ const nonOwnedProcess = Bun.spawn(['bun', fixture.serverScript, String(appPort)], {
212
+ env: fixture.env,
213
+ stderr: 'ignore',
214
+ stdout: 'ignore',
215
+ });
216
+
217
+ try {
218
+ await waitForHttpPort(appPort);
219
+ await writeForwardPid(fixture, 'app', nonOwnedProcess.pid);
220
+
221
+ const cleanup = await executePortForward(fixture, 'stop', 'all');
222
+
223
+ expect(cleanup.exitCode).not.toBe(0);
224
+ expect(cleanup.stdout).toContain(
225
+ `app: stale pid file pointed at non-owned live pid ${nonOwnedProcess.pid}`,
226
+ );
227
+ expect(cleanup.stdout).toContain(
228
+ `app: port occupied by non-owned process on local port ${appPort}`,
229
+ );
230
+ expect(isProcessRunning(nonOwnedProcess.pid)).toBe(true);
231
+ await expectForwardPid(fixture, 'app', false);
232
+ } finally {
233
+ nonOwnedProcess.kill();
234
+ await nonOwnedProcess.exited;
235
+ }
236
+ },
237
+ { timeout: 15_000 },
238
+ );
239
+
240
+ test('fails actionably when a generated Web app target is genuinely missing', async () => {
241
+ const fixture = await createPortForwardFixture(
242
+ createSupabaseManifest(),
243
+ 'missing-web-runtime',
244
+ createAppManifest('missing-web-runtime'),
245
+ 'service/app-runtime',
246
+ );
247
+
248
+ await expectFailureMessage(
249
+ ensureProjectInfrastructureRuntime({
250
+ projectId: fixture.profile,
251
+ projectPath: fixture.rootPath,
252
+ target: 'minikube',
253
+ }),
254
+ 'app: target app/service/app-runtime not found',
255
+ );
256
+ await expectForwardPid(fixture, 'supabase-gateway', false);
257
+ });
258
+
259
+ test('fails actionably when a generated provider target is genuinely missing', async () => {
260
+ const appManifest = {
261
+ ...createAppManifest('missing-provider-runtime'),
262
+ deploy: {
263
+ targets: {
264
+ android: { enabled: true, package: 'com.ankh.missingprovider' },
265
+ },
266
+ },
267
+ };
268
+ const fixture = await createPortForwardFixture(
269
+ createSupabaseManifest(),
270
+ 'missing-provider-runtime',
271
+ appManifest,
272
+ 'service/gateway',
273
+ );
274
+
275
+ await expectFailureMessage(
276
+ ensureProjectInfrastructureRuntime({
277
+ projectId: fixture.profile,
278
+ projectPath: fixture.rootPath,
279
+ target: 'minikube',
280
+ }),
281
+ 'supabase-gateway: target supabase/service/gateway not found',
282
+ );
283
+ });
79
284
  });
80
285
 
81
286
  interface PortForwardFixture {
82
287
  readonly env: Record<string, string>;
288
+ readonly forwardAttemptPath: string;
83
289
  readonly processDirectory: string;
84
290
  readonly profile: string;
85
291
  readonly rootPath: string;
292
+ readonly serverScript: string;
86
293
  readonly scriptPath: string;
87
294
  }
88
295
 
89
296
  async function createPortForwardFixture(
90
297
  manifest: InfraManifestInput,
91
298
  profile: string,
299
+ appManifest = createAppManifest(profile),
300
+ missingResource?: string,
301
+ transientPortForwardFailures = 0,
302
+ transientPortForwardLifetimeMs = 0,
92
303
  ): Promise<PortForwardFixture> {
93
304
  const rootPath = await fs.mkdtemp(path.join(os.tmpdir(), 'infra-port-forward-runtime-'));
94
305
  temporaryPaths.add(rootPath);
95
306
  const generated = generateInfrastructure(manifest, {
96
- appManifest: createAppManifest(profile),
307
+ appManifest,
97
308
  });
98
309
  const generatedScript = generated.files.find(
99
310
  (file) => file.path === 'infra/minikube/scripts/port-forward.sh',
@@ -104,18 +315,23 @@ async function createPortForwardFixture(
104
315
 
105
316
  const scriptPath = path.join(rootPath, generatedScript.path);
106
317
  const fakeBin = path.join(rootPath, 'fake-bin');
318
+ const forwardAttemptPath = path.join(rootPath, 'forward-attempts');
107
319
  const processDirectory = path.join(rootPath, 'fake-processes');
108
320
  const serverScript = path.join(rootPath, 'forward-server.ts');
109
321
  await fs.mkdir(path.dirname(scriptPath), { recursive: true });
110
322
  await fs.mkdir(fakeBin, { recursive: true });
111
323
  await fs.mkdir(processDirectory, { recursive: true });
112
- await fs.writeFile(scriptPath, generatedScript.content, 'utf8');
113
- await fs.chmod(scriptPath, 0o755);
114
324
  await fs.writeFile(
115
325
  serverScript,
116
326
  [
117
327
  "const port = Number.parseInt(Bun.argv[2] ?? '', 10);",
118
- "Bun.serve({ hostname: '127.0.0.1', port, fetch: () => new Response('ok') });",
328
+ "const lifetimeMs = Number.parseInt(Bun.argv[3] ?? '0', 10);",
329
+ "const server = Bun.serve({ hostname: '127.0.0.1', port, fetch: () => new Response('ok') });",
330
+ 'if (lifetimeMs > 0) {',
331
+ ' await Bun.sleep(lifetimeMs);',
332
+ ' server.stop(true);',
333
+ ' process.exit(1);',
334
+ '}',
119
335
  'await new Promise(() => undefined);',
120
336
  '',
121
337
  ].join('\n'),
@@ -126,6 +342,9 @@ async function createPortForwardFixture(
126
342
  `#!/usr/bin/env bash
127
343
  set -euo pipefail
128
344
  if [[ " $* " == *" get "* ]]; then
345
+ if [[ -n "\${FAKE_MISSING_RESOURCE:-}" && " $* " == *" \${FAKE_MISSING_RESOURCE} "* ]]; then
346
+ exit 1
347
+ fi
129
348
  exit 0
130
349
  fi
131
350
  if [[ " $* " != *" port-forward "* ]]; then
@@ -133,6 +352,19 @@ if [[ " $* " != *" port-forward "* ]]; then
133
352
  fi
134
353
  endpoint="\${!#}"
135
354
  local_port="\${endpoint%%:*}"
355
+ forward_attempt=0
356
+ if [[ -f "\${FAKE_FORWARD_ATTEMPT_FILE}" ]]; then
357
+ forward_attempt="$(cat "\${FAKE_FORWARD_ATTEMPT_FILE}")"
358
+ fi
359
+ forward_attempt="$((forward_attempt + 1))"
360
+ printf '%s\n' "\${forward_attempt}" > "\${FAKE_FORWARD_ATTEMPT_FILE}"
361
+ if [[ "\${forward_attempt}" -le "\${FAKE_TRANSIENT_FORWARD_FAILURES}" ]]; then
362
+ if [[ "\${FAKE_TRANSIENT_FORWARD_LIFETIME_MS}" -gt 0 ]]; then
363
+ bun "\${FAKE_FORWARD_SERVER_SCRIPT}" "\${local_port}" "\${FAKE_TRANSIENT_FORWARD_LIFETIME_MS}" || true
364
+ fi
365
+ echo 'error: error upgrading connection: unable to upgrade connection: pod does not exist' >&2
366
+ exit 1
367
+ fi
136
368
  printf 'kubectl %s\n' "$*" > "\${FAKE_PROCESS_DIRECTORY}/$$.command"
137
369
  exec bun "\${FAKE_FORWARD_SERVER_SCRIPT}" "\${local_port}"
138
370
  `,
@@ -162,29 +394,91 @@ fi
162
394
  );
163
395
 
164
396
  const ports = reserveAvailablePorts(4);
397
+ const env = {
398
+ ...process.env,
399
+ ANKH_APP_SLUG: profile,
400
+ APP_PORT_FORWARD_LOCAL_PORT: String(ports[0]),
401
+ SUPABASE_GATEWAY_FORWARD_LOCAL_PORT: String(ports[1]),
402
+ SUPABASE_STUDIO_FORWARD_LOCAL_PORT: String(ports[2]),
403
+ SUPABASE_DB_FORWARD_LOCAL_PORT: String(ports[3]),
404
+ FAKE_FORWARD_ATTEMPT_FILE: forwardAttemptPath,
405
+ FAKE_FORWARD_SERVER_SCRIPT: serverScript,
406
+ FAKE_PROCESS_DIRECTORY: processDirectory,
407
+ FAKE_TRANSIENT_FORWARD_FAILURES: String(transientPortForwardFailures),
408
+ FAKE_TRANSIENT_FORWARD_LIFETIME_MS: String(transientPortForwardLifetimeMs),
409
+ ...(missingResource ? { FAKE_MISSING_RESOURCE: missingResource } : {}),
410
+ PATH: `${fakeBin}:${process.env.PATH ?? ''}`,
411
+ };
412
+ const injectedEnvironment = Object.entries(env)
413
+ .filter(([key]) =>
414
+ [
415
+ 'ANKH_APP_SLUG',
416
+ 'APP_PORT_FORWARD_LOCAL_PORT',
417
+ 'SUPABASE_GATEWAY_FORWARD_LOCAL_PORT',
418
+ 'SUPABASE_STUDIO_FORWARD_LOCAL_PORT',
419
+ 'SUPABASE_DB_FORWARD_LOCAL_PORT',
420
+ 'FAKE_FORWARD_ATTEMPT_FILE',
421
+ 'FAKE_FORWARD_SERVER_SCRIPT',
422
+ 'FAKE_PROCESS_DIRECTORY',
423
+ 'FAKE_TRANSIENT_FORWARD_FAILURES',
424
+ 'FAKE_TRANSIENT_FORWARD_LIFETIME_MS',
425
+ 'FAKE_MISSING_RESOURCE',
426
+ 'PATH',
427
+ ].includes(key),
428
+ )
429
+ .map(([key, value]) => `export ${key}=${shellQuote(value)}`)
430
+ .join('\n');
431
+ await fs.writeFile(
432
+ scriptPath,
433
+ generatedScript.content.replace(
434
+ '#!/usr/bin/env bash\n',
435
+ `#!/usr/bin/env bash\n${injectedEnvironment}\n`,
436
+ ),
437
+ 'utf8',
438
+ );
439
+ await fs.chmod(scriptPath, 0o755);
165
440
  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
- },
441
+ env,
442
+ forwardAttemptPath,
177
443
  processDirectory,
178
444
  profile,
179
445
  rootPath,
446
+ serverScript,
180
447
  scriptPath,
181
448
  };
182
449
  }
183
450
 
451
+ function shellQuote(value: string): string {
452
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
453
+ }
454
+
455
+ async function expectFailureMessage(promise: Promise<unknown>, expected: string): Promise<void> {
456
+ try {
457
+ await promise;
458
+ throw new Error('Expected runtime ensure to fail.');
459
+ } catch (error) {
460
+ expect(error instanceof Error ? error.message : String(error)).toContain(expected);
461
+ }
462
+ }
463
+
184
464
  async function runPortForward(
185
465
  fixture: PortForwardFixture,
186
466
  action: string,
187
467
  name: string,
468
+ ): Promise<{ readonly exitCode: number; readonly stderr: string; readonly stdout: string }> {
469
+ const result = await executePortForward(fixture, action, name);
470
+ if (result.exitCode !== 0) {
471
+ throw new Error(
472
+ `port-forward ${action} ${name} failed (${result.exitCode}): ${result.stdout}${result.stderr}`,
473
+ );
474
+ }
475
+ return result;
476
+ }
477
+
478
+ async function executePortForward(
479
+ fixture: PortForwardFixture,
480
+ action: string,
481
+ name: string,
188
482
  ): Promise<{ readonly exitCode: number; readonly stderr: string; readonly stdout: string }> {
189
483
  const process = Bun.spawn(['bash', fixture.scriptPath, action, name], {
190
484
  env: fixture.env,
@@ -196,12 +490,43 @@ async function runPortForward(
196
490
  new Response(process.stdout).text(),
197
491
  new Response(process.stderr).text(),
198
492
  ]);
199
- if (exitCode !== 0) {
200
- throw new Error(`port-forward ${action} ${name} failed (${exitCode}): ${stdout}${stderr}`);
201
- }
202
493
  return { exitCode, stderr, stdout };
203
494
  }
204
495
 
496
+ async function writeForwardPid(
497
+ fixture: PortForwardFixture,
498
+ name: string,
499
+ pid: number,
500
+ ): Promise<void> {
501
+ const pidPath = forwardPidPath(fixture, name);
502
+ await fs.mkdir(path.dirname(pidPath), { recursive: true });
503
+ await fs.writeFile(pidPath, `${pid}\n`, 'utf8');
504
+ }
505
+
506
+ async function waitForHttpPort(port: number): Promise<void> {
507
+ for (let attempt = 0; attempt < 40; attempt += 1) {
508
+ try {
509
+ const response = await fetch(`http://127.0.0.1:${port}`, {
510
+ signal: AbortSignal.timeout(250),
511
+ });
512
+ if (response.ok) return;
513
+ } catch {
514
+ // The fixture process may still be binding its port.
515
+ }
516
+ await Bun.sleep(50);
517
+ }
518
+ throw new Error(`Timed out waiting for fixture port ${port}.`);
519
+ }
520
+
521
+ function isProcessRunning(pid: number): boolean {
522
+ try {
523
+ process.kill(pid, 0);
524
+ return true;
525
+ } catch {
526
+ return false;
527
+ }
528
+ }
529
+
205
530
  async function readForwardPid(fixture: PortForwardFixture, name: string): Promise<number> {
206
531
  const value = await fs.readFile(forwardPidPath(fixture, name), 'utf8');
207
532
  return Number.parseInt(value.trim(), 10);
@@ -286,3 +611,14 @@ function createSupabaseManifest(): InfraManifestInput {
286
611
  modules: [],
287
612
  };
288
613
  }
614
+
615
+ function createNativeAppManifest(profile: string) {
616
+ return {
617
+ ...createAppManifest(profile),
618
+ deploy: {
619
+ targets: {
620
+ android: { enabled: true, package: `com.ankh.${profile.replaceAll('-', '')}` },
621
+ },
622
+ },
623
+ };
624
+ }
@@ -0,0 +1,135 @@
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
+
9
+ const temporaryPaths = new Set<string>();
10
+
11
+ afterEach(async () => {
12
+ await Promise.all(
13
+ [...temporaryPaths].map((rootPath) => fs.rm(rootPath, { force: true, recursive: true })),
14
+ );
15
+ temporaryPaths.clear();
16
+ });
17
+
18
+ describe('generated native-only Minikube upgrade reconciliation', () => {
19
+ test('deletes exact stale app resources idempotently after applying desired topology', async () => {
20
+ const profile = 'native-upgrade-reconcile';
21
+ const rootPath = await fs.mkdtemp(path.join(os.tmpdir(), 'infra-native-upgrade-'));
22
+ temporaryPaths.add(rootPath);
23
+ const generated = generateInfrastructure(
24
+ { deployment: { target: 'minikube', monitoring: false }, modules: [] },
25
+ {
26
+ appManifest: {
27
+ ...createAppManifest(profile),
28
+ deploy: {
29
+ targets: {
30
+ android: { enabled: true, package: 'com.ankh.nativeupgradereconcile' },
31
+ },
32
+ },
33
+ },
34
+ },
35
+ );
36
+
37
+ for (const file of generated.files) {
38
+ const filePath = path.join(rootPath, file.path);
39
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
40
+ await fs.writeFile(filePath, file.content, 'utf8');
41
+ if (file.executable === true) await fs.chmod(filePath, 0o755);
42
+ }
43
+
44
+ const fakeBin = path.join(rootPath, 'fake-bin');
45
+ const kubectlLog = path.join(rootPath, 'kubectl.log');
46
+ const resourceState = path.join(rootPath, 'app-runtime-resources');
47
+ await fs.mkdir(fakeBin, { recursive: true });
48
+ await fs.writeFile(
49
+ resourceState,
50
+ ['deployment/app-runtime', 'service/app-runtime', 'configmap/app-infra-config', ''].join(
51
+ '\n',
52
+ ),
53
+ 'utf8',
54
+ );
55
+ await writeExecutable(
56
+ path.join(fakeBin, 'minikube'),
57
+ `#!/usr/bin/env bash
58
+ set -euo pipefail
59
+ if [[ " $* " == *" status "* ]]; then
60
+ echo Running
61
+ exit 0
62
+ fi
63
+ exit 1
64
+ `,
65
+ );
66
+ await writeExecutable(
67
+ path.join(fakeBin, 'kubectl'),
68
+ `#!/usr/bin/env bash
69
+ set -euo pipefail
70
+ printf '%s\n' "$*" >> "\${FAKE_KUBECTL_LOG}"
71
+ if [[ " $* " == *" apply -k "* ]]; then
72
+ exit 0
73
+ fi
74
+ if [[ " $* " == *" delete "* ]]; then
75
+ expected='delete deployment/app-runtime service/app-runtime configmap/app-infra-config --ignore-not-found'
76
+ if [[ " $* " != *" \${expected} "* ]]; then
77
+ echo "unexpected cleanup identity: $*" >&2
78
+ exit 1
79
+ fi
80
+ : > "\${FAKE_RESOURCE_STATE}"
81
+ exit 0
82
+ fi
83
+ exit 1
84
+ `,
85
+ );
86
+
87
+ const upScript = path.join(rootPath, 'infra/minikube/scripts/up.sh');
88
+ const env = {
89
+ ...process.env,
90
+ ANKH_APP_SLUG: profile,
91
+ FAKE_KUBECTL_LOG: kubectlLog,
92
+ FAKE_RESOURCE_STATE: resourceState,
93
+ PATH: `${fakeBin}:${process.env.PATH ?? ''}`,
94
+ };
95
+
96
+ const first = await runScript(upScript, env);
97
+ const second = await runScript(upScript, env);
98
+ const commands = (await fs.readFile(kubectlLog, 'utf8')).trim().split('\n');
99
+ const exactDelete = `--context ${profile} -n app delete deployment/app-runtime service/app-runtime configmap/app-infra-config --ignore-not-found`;
100
+
101
+ expect(first.stdout).toContain(`Minikube infrastructure for '${profile}' is running.`);
102
+ expect(second.stdout).toContain(`Minikube infrastructure for '${profile}' is running.`);
103
+ expect(commands).toHaveLength(4);
104
+ expect(commands[0]).toContain(`--context ${profile} apply -k `);
105
+ expect(commands[1]).toBe(exactDelete);
106
+ expect(commands[2]).toContain(`--context ${profile} apply -k `);
107
+ expect(commands[3]).toBe(exactDelete);
108
+ expect(await fs.readFile(resourceState, 'utf8')).toBe('');
109
+ });
110
+ });
111
+
112
+ async function runScript(
113
+ scriptPath: string,
114
+ env: Record<string, string | undefined>,
115
+ ): Promise<{ readonly stderr: string; readonly stdout: string }> {
116
+ const process = Bun.spawn(['bash', scriptPath], {
117
+ env,
118
+ stderr: 'pipe',
119
+ stdout: 'pipe',
120
+ });
121
+ const [exitCode, stdout, stderr] = await Promise.all([
122
+ process.exited,
123
+ new Response(process.stdout).text(),
124
+ new Response(process.stderr).text(),
125
+ ]);
126
+ if (exitCode !== 0) {
127
+ throw new Error(`Generated up.sh failed (${exitCode}): ${stdout}${stderr}`);
128
+ }
129
+ return { stderr, stdout };
130
+ }
131
+
132
+ async function writeExecutable(filePath: string, content: string): Promise<void> {
133
+ await fs.writeFile(filePath, content, 'utf8');
134
+ await fs.chmod(filePath, 0o755);
135
+ }
@@ -31,6 +31,9 @@ export function generateMinikubeInfra(
31
31
  }
32
32
 
33
33
  const appSlug = validateCanonicalAppSlug(options.appManifest?.metadata.slug);
34
+ const appRuntimeEnabled =
35
+ options.appManifest?.deploy === undefined ||
36
+ options.appManifest.deploy.targets.web?.enabled === true;
34
37
  const namespace = APP_NAMESPACE;
35
38
 
36
39
  const authArtifacts = generateAuthProviderArtifacts({
@@ -75,6 +78,7 @@ export function generateMinikubeInfra(
75
78
  providerNamespaces,
76
79
  providerLifecycle,
77
80
  extraEnvEntries,
81
+ appRuntimeEnabled,
78
82
  });
79
83
 
80
84
  const warnings = unique([