@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.
- package/CHANGELOG.md +12 -0
- package/dist/adapters/minikube/base/index.d.ts +1 -0
- package/dist/adapters/minikube/base/index.d.ts.map +1 -1
- package/dist/adapters/minikube/base/index.js +166 -84
- package/dist/adapters/minikube/base/index.js.map +1 -1
- package/dist/adapters/minikube/index.d.ts.map +1 -1
- package/dist/adapters/minikube/index.js +3 -0
- package/dist/adapters/minikube/index.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/minikube/README.md +18 -8
- package/src/adapters/minikube/base/index.test.ts +143 -6
- package/src/adapters/minikube/base/index.ts +176 -82
- package/src/adapters/minikube/base/portForwardRuntime.test.ts +355 -19
- package/src/adapters/minikube/base/upgradeReconciliation.test.ts +135 -0
- package/src/adapters/minikube/index.ts +4 -0
|
@@ -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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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([
|