@ankhorage/infra 4.0.0 → 4.1.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.
@@ -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;
@@ -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([
@@ -5,6 +5,7 @@ import path from 'path';
5
5
 
6
6
  import { createAppManifest } from '../testSupport.js';
7
7
  import {
8
+ ensureProjectInfrastructureRuntime,
8
9
  inspectProjectInfrastructure,
9
10
  readProjectInfrastructureEnvironment,
10
11
  resolveProjectInfrastructureDatabaseUrl,
@@ -78,6 +79,39 @@ describe('@ankhorage/infra/project', () => {
78
79
  });
79
80
  });
80
81
 
82
+ test('ensures the generated runtime port-forward group through the owner API', async () => {
83
+ const projectPath = await createProjectFixture();
84
+ const scriptPath = path.join(projectPath, 'infra', 'minikube', 'scripts', 'port-forward.sh');
85
+ await fs.writeFile(scriptPath, '#!/usr/bin/env bash\nprintf \'%s\' "$*"\n', 'utf8');
86
+
87
+ const result = await ensureProjectInfrastructureRuntime({
88
+ projectId: 'example',
89
+ projectPath,
90
+ target: 'minikube',
91
+ });
92
+
93
+ expect(result).toEqual({ stdout: 'start runtime', stderr: '' });
94
+ });
95
+
96
+ test('reports actionable runtime-forward failures without reconciling infrastructure', async () => {
97
+ const projectPath = await createProjectFixture();
98
+ const scriptPath = path.join(projectPath, 'infra', 'minikube', 'scripts', 'port-forward.sh');
99
+ await fs.writeFile(
100
+ scriptPath,
101
+ '#!/usr/bin/env bash\necho "app: target app/service/app-runtime not found"\nexit 1\n',
102
+ 'utf8',
103
+ );
104
+
105
+ await expectRejectMessage(
106
+ ensureProjectInfrastructureRuntime({
107
+ projectId: 'example',
108
+ projectPath,
109
+ target: 'minikube',
110
+ }),
111
+ "Failed to ensure infrastructure runtime for project 'example': app: target app/service/app-runtime not found",
112
+ );
113
+ });
114
+
81
115
  test('reads only requested runtime environment values from the generated fallback', async () => {
82
116
  const projectPath = await createProjectFixture();
83
117
  const result = await readProjectInfrastructureEnvironment({
@@ -147,3 +181,15 @@ describe('@ankhorage/infra/project', () => {
147
181
  });
148
182
  });
149
183
  });
184
+
185
+ async function expectRejectMessage(
186
+ promise: Promise<unknown>,
187
+ expectedMessage: string,
188
+ ): Promise<void> {
189
+ try {
190
+ await promise;
191
+ throw new Error('Expected operation to reject.');
192
+ } catch (error) {
193
+ expect(error instanceof Error ? error.message : String(error)).toContain(expectedMessage);
194
+ }
195
+ }
@@ -12,6 +12,7 @@ export {
12
12
  syncProjectInfrastructure,
13
13
  } from '../projectInfrastructure.js';
14
14
  export {
15
+ ensureProjectInfrastructureRuntime,
15
16
  type InfraLifecycleScript,
16
17
  type InfraPortForwardInfo,
17
18
  InfraScriptExecutionError,
@@ -29,6 +29,11 @@ import { runCli } from './cli/bin.js';
29
29
  * operations accept an explicit project path and do not require Studio or a
30
30
  * particular workspace layout.
31
31
  *
32
+ * Running applications can repair their provider-aware host endpoints through
33
+ * `ensureProjectInfrastructureRuntime()` from `@ankhorage/infra/project`. This
34
+ * operation only ensures the generated runtime port-forward group; it does not
35
+ * deploy, bootstrap, or reconcile infrastructure.
36
+ *
32
37
  * @usage
33
38
  */
34
39
  await runCli(['--help']);
package/src/runtime.ts CHANGED
@@ -85,6 +85,37 @@ export async function resolveProjectInfrastructurePortForward(args: {
85
85
  };
86
86
  }
87
87
 
88
+ /** Ensures the generated host forwards required by a running application are active. */
89
+ export async function ensureProjectInfrastructureRuntime(args: {
90
+ readonly projectId: string;
91
+ readonly projectPath: string;
92
+ readonly target: string;
93
+ }): Promise<InfraScriptOutput> {
94
+ try {
95
+ return await runProjectInfrastructureLifecycle({
96
+ args: ['start', 'runtime'],
97
+ projectId: args.projectId,
98
+ projectPath: args.projectPath,
99
+ script: 'port-forward',
100
+ target: args.target,
101
+ });
102
+ } catch (error) {
103
+ if (!(error instanceof InfraScriptExecutionError)) {
104
+ throw error;
105
+ }
106
+
107
+ const detail = error.stderr.trim() || error.stdout.trim();
108
+ throw new InfraScriptExecutionError({
109
+ exitCode: error.exitCode,
110
+ message: `Failed to ensure infrastructure runtime for project '${args.projectId}'${
111
+ detail.length > 0 ? `: ${detail}` : '.'
112
+ }`,
113
+ stderr: error.stderr,
114
+ stdout: error.stdout,
115
+ });
116
+ }
117
+ }
118
+
88
119
  export async function runProjectInfraScript(args: {
89
120
  readonly context: InfraCommandContext;
90
121
  readonly projectId: string;