@ankhorage/infra 5.1.0 → 5.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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # @ankhorage/infra
2
2
 
3
+ ## 5.1.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 343eefe: Prove in the real local Minikube acceptance that reconciliation prunes stale Infra-owned resources without deleting unrelated Kubernetes resources.
8
+
9
+ ## 5.1.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 0771f29: Add reproducible local Minikube + Supabase Phase 8 parity acceptance against the released provider-neutral adapters and runtime fixes.
14
+
3
15
  ## 5.1.0
4
16
 
5
17
  ### Minor Changes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ankhorage/infra",
3
3
  "type": "module",
4
- "version": "5.1.0",
4
+ "version": "5.1.2",
5
5
  "description": "Project-aware infrastructure generation library and executable Ankh provider for Ankhorage.",
6
6
  "homepage": "https://github.com/ankhorage/infra#readme",
7
7
  "bugs": {
@@ -35,7 +35,7 @@
35
35
  "openapi"
36
36
  ],
37
37
  "dependencies": {
38
- "@ankhorage/contracts": "^19.0.0"
38
+ "@ankhorage/contracts": "^19.1.0"
39
39
  },
40
40
  "files": [
41
41
  "dist",
@@ -100,6 +100,7 @@
100
100
  "prepack": "bun run build",
101
101
  "test": "bun test src",
102
102
  "test:e2e": "ANKH_INFRA_COMPOSITION_E2E=1 bun test src/infraComposition.integration.test.ts",
103
+ "test:e2e:minikube-supabase": "ANKH_INFRA_MINIKUBE_SUPABASE_E2E=1 bun test src/localMinikubeSupabase.integration.test.ts",
103
104
  "typecheck": "bun x tsc --noEmit -p tsconfig.json",
104
105
  "check-types": "bun run typecheck",
105
106
  "version-packages": "ankhorage-changeset version",
@@ -114,10 +115,10 @@
114
115
  "@ankhorage/hetzner": "^0.3.0",
115
116
  "@ankhorage/k3s": "^0.3.0",
116
117
  "@ankhorage/local": "^0.3.0",
117
- "@ankhorage/minikube": "^0.3.0",
118
+ "@ankhorage/minikube": "^0.4.1",
118
119
  "@ankhorage/paradox": "^0.1.24",
119
120
  "@ankhorage/r2": "^0.2.0",
120
- "@ankhorage/supabase": "^0.2.0",
121
+ "@ankhorage/supabase": "^0.4.5",
121
122
  "@ankhorage/supabase-vault": "^0.3.1",
122
123
  "@types/bun": "^1.4.1",
123
124
  "@types/node": "^25.6.0",
@@ -0,0 +1,318 @@
1
+ import { createHmac } from 'node:crypto';
2
+
3
+ import type {
4
+ InfraLedger,
5
+ InfraManifest,
6
+ InfraResourceIdentity,
7
+ InfraResult,
8
+ } from '@ankhorage/contracts/infra';
9
+ import { expect, test } from 'bun:test';
10
+
11
+ import { createNodeInfraAdapterPackageResolver } from './features/environment-lifecycle/adapters/outbound/createNodeInfraAdapterPackageResolver.js';
12
+ import { destroyInfraEnvironmentAsync } from './features/environment-lifecycle/application/use-cases/destroyInfraEnvironmentAsync.js';
13
+ import { downInfraEnvironmentAsync } from './features/environment-lifecycle/application/use-cases/downInfraEnvironmentAsync.js';
14
+ import { planInfraEnvironmentAsync } from './features/environment-lifecycle/application/use-cases/planInfraEnvironmentAsync.js';
15
+ import { statusInfraEnvironmentAsync } from './features/environment-lifecycle/application/use-cases/statusInfraEnvironmentAsync.js';
16
+ import { upInfraEnvironmentAsync } from './features/environment-lifecycle/application/use-cases/upInfraEnvironmentAsync.js';
17
+ import type { InfraOrchestrationDependencies } from './types/infraOrchestration.js';
18
+
19
+ const projectId = 'infra145-minikube-supabase';
20
+ const acceptanceNamespace = `${projectId}-local`;
21
+ const baseUrl = 'http://127.0.0.1:54321';
22
+ const bucket = 'phase8-acceptance';
23
+ const staleOwnedConfigMap = 'phase8-stale-owned';
24
+ const unrelatedConfigMap = 'phase8-unrelated';
25
+ const staleOwnedResourceId = 'stale:phase8-owned';
26
+ const jwtSecret = 'phase8-jwt-secret-that-is-at-least-thirty-two-characters';
27
+ const credentials = {
28
+ postgresPassword: 'phase8-postgres-password',
29
+ jwtSecret,
30
+ anonKey: createJwt('anon'),
31
+ serviceRoleKey: createJwt('service_role'),
32
+ realtimeSecretKeyBase: 'r'.repeat(64),
33
+ realtimeDatabaseEncryptionKey: '0123456789abcdef',
34
+ pgMetaCryptoKey: 'phase8-meta-crypto-key-that-is-at-least-32-characters',
35
+ } as const;
36
+ const manifest = {
37
+ environments: {
38
+ local: {
39
+ deployment: {
40
+ compute: { provider: 'local' },
41
+ runtime: {
42
+ provider: 'minikube',
43
+ profile: projectId,
44
+ driver: 'docker',
45
+ cpus: 2,
46
+ memoryMiB: 5_120,
47
+ },
48
+ },
49
+ database: { provider: 'supabase', tier: 'dev' },
50
+ auth: { provider: 'supabase' },
51
+ objectStorage: { provider: 'supabase', buckets: [bucket] },
52
+ networking: { publicBaseUrl: baseUrl },
53
+ },
54
+ },
55
+ modules: [],
56
+ } as const satisfies InfraManifest;
57
+
58
+ test.skipIf(process.env.ANKH_INFRA_MINIKUBE_SUPABASE_E2E !== '1')(
59
+ 'runs local Minikube and Supabase through the provider-neutral lifecycle',
60
+ async () => {
61
+ const dependencies = createDependencies();
62
+ let ledger: InfraLedger | undefined;
63
+
64
+ try {
65
+ const initialPlan = requireSuccess(
66
+ await planInfraEnvironmentAsync({ projectId, manifest }, dependencies),
67
+ );
68
+ expect(initialPlan.actions.some(({ operation }) => operation === 'create')).toBe(true);
69
+
70
+ const { ledger: firstLedger, outputs: firstOutputs } = requireSuccess(
71
+ await upInfraEnvironmentAsync({ projectId, manifest }, dependencies),
72
+ );
73
+ ledger = firstLedger;
74
+ assertSafePublicOutputs(firstOutputs);
75
+ expect(
76
+ firstOutputs.some(
77
+ ({ owner, name, value }) =>
78
+ owner.adapter === 'minikube' && name === 'localUrl' && value === baseUrl,
79
+ ),
80
+ ).toBe(true);
81
+ expect(
82
+ firstOutputs.some(
83
+ ({ environmentVariable, value }) =>
84
+ environmentVariable === 'EXPO_PUBLIC_SUPABASE_URL' && value === baseUrl,
85
+ ),
86
+ ).toBe(true);
87
+ expect(firstOutputs.some(({ name, value }) => name === 'bucket' && value === bucket)).toBe(
88
+ true,
89
+ );
90
+
91
+ const status = requireSuccess(
92
+ await statusInfraEnvironmentAsync({ projectId, manifest, previous: ledger }, dependencies),
93
+ );
94
+ expect(status.state).toBe('ready');
95
+
96
+ const convergedPlan = requireSuccess(
97
+ await planInfraEnvironmentAsync({ projectId, manifest, previous: ledger }, dependencies),
98
+ );
99
+ expect(convergedPlan.actions.every(({ operation }) => operation === 'noop')).toBe(true);
100
+
101
+ await createStaleOwnershipFixturesAsync();
102
+ const stalePlan = requireSuccess(
103
+ await planInfraEnvironmentAsync({ projectId, manifest, previous: ledger }, dependencies),
104
+ );
105
+ expect(
106
+ stalePlan.actions.some(
107
+ ({ operation, owner }) =>
108
+ operation === 'delete' && owner.resourceId === staleOwnedResourceId,
109
+ ),
110
+ ).toBe(true);
111
+
112
+ const { ledger: prunedLedger } = requireSuccess(
113
+ await upInfraEnvironmentAsync({ projectId, manifest, previous: ledger }, dependencies),
114
+ );
115
+ ledger = prunedLedger;
116
+ expect(await configMapExistsAsync(staleOwnedConfigMap)).toBe(false);
117
+ expect(await configMapExistsAsync(unrelatedConfigMap)).toBe(true);
118
+ await runKubectlAsync([
119
+ 'delete',
120
+ 'configmap',
121
+ unrelatedConfigMap,
122
+ '--namespace',
123
+ acceptanceNamespace,
124
+ '--ignore-not-found=true',
125
+ ]);
126
+
127
+ const { ledger: downLedger } = requireSuccess(
128
+ await downInfraEnvironmentAsync({ projectId, manifest, previous: ledger }, dependencies),
129
+ );
130
+ ledger = downLedger;
131
+
132
+ const { ledger: resumedLedger, outputs: resumedOutputs } = requireSuccess(
133
+ await upInfraEnvironmentAsync({ projectId, manifest, previous: ledger }, dependencies),
134
+ );
135
+ ledger = resumedLedger;
136
+ assertSafePublicOutputs(resumedOutputs);
137
+
138
+ const health = await fetch(`${baseUrl}/auth/v1/health`);
139
+ expect(health.ok).toBe(true);
140
+
141
+ const destroyed = requireSuccess(
142
+ await destroyInfraEnvironmentAsync(
143
+ createDestroyRequest(ledger, persistentIdentities(ledger)),
144
+ dependencies,
145
+ ),
146
+ );
147
+ ledger = destroyed.ledger ?? undefined;
148
+ expect(destroyed.ledger).toBeNull();
149
+ } finally {
150
+ if (ledger !== undefined) {
151
+ await destroyInfraEnvironmentAsync(
152
+ createDestroyRequest(ledger, persistentIdentities(ledger)),
153
+ dependencies,
154
+ );
155
+ }
156
+ }
157
+ },
158
+ 900_000,
159
+ );
160
+
161
+ async function createStaleOwnershipFixturesAsync(): Promise<void> {
162
+ await runKubectlAsync([
163
+ 'create',
164
+ 'configmap',
165
+ staleOwnedConfigMap,
166
+ '--namespace',
167
+ acceptanceNamespace,
168
+ '--from-literal=marker=stale',
169
+ ]);
170
+ await runKubectlAsync([
171
+ 'label',
172
+ 'configmap',
173
+ staleOwnedConfigMap,
174
+ '--namespace',
175
+ acceptanceNamespace,
176
+ 'app.kubernetes.io/managed-by=ankhorage-infra',
177
+ `infra.ankhorage.dev/project=${projectId}`,
178
+ 'infra.ankhorage.dev/environment=local',
179
+ ]);
180
+ await runKubectlAsync([
181
+ 'annotate',
182
+ 'configmap',
183
+ staleOwnedConfigMap,
184
+ '--namespace',
185
+ acceptanceNamespace,
186
+ `infra.ankhorage.dev/project-id=${projectId}`,
187
+ 'infra.ankhorage.dev/environment=local',
188
+ 'infra.ankhorage.dev/adapter=minikube',
189
+ `infra.ankhorage.dev/resource-id=${staleOwnedResourceId}`,
190
+ 'infra.ankhorage.dev/persistent=false',
191
+ 'infra.ankhorage.dev/retention=retain',
192
+ 'infra.ankhorage.dev/depends-on=[]',
193
+ ]);
194
+ await runKubectlAsync([
195
+ 'create',
196
+ 'configmap',
197
+ unrelatedConfigMap,
198
+ '--namespace',
199
+ acceptanceNamespace,
200
+ '--from-literal=marker=unrelated',
201
+ ]);
202
+ }
203
+
204
+ async function configMapExistsAsync(name: string): Promise<boolean> {
205
+ const output = await runKubectlAsync([
206
+ 'get',
207
+ 'configmap',
208
+ name,
209
+ '--namespace',
210
+ acceptanceNamespace,
211
+ '--ignore-not-found=true',
212
+ '--output=name',
213
+ ]);
214
+ return output === `configmap/${name}`;
215
+ }
216
+
217
+ async function runKubectlAsync(args: readonly string[]): Promise<string> {
218
+ const process = Bun.spawn(['kubectl', ...args], { stdout: 'pipe', stderr: 'pipe' });
219
+ const [exitCode, stdout, stderr] = await Promise.all([
220
+ process.exited,
221
+ new Response(process.stdout).text(),
222
+ new Response(process.stderr).text(),
223
+ ]);
224
+ if (exitCode !== 0) {
225
+ throw new Error(`kubectl ${args.join(' ')} failed: ${stderr.trim()}`);
226
+ }
227
+ return stdout.trim();
228
+ }
229
+
230
+ function createDependencies(): InfraOrchestrationDependencies {
231
+ return {
232
+ adapterResolver: createNodeInfraAdapterPackageResolver(),
233
+ credentials: {
234
+ resolveAsync: (reference) =>
235
+ Promise.resolve(
236
+ reference.name === 'SUPABASE_BOOTSTRAP'
237
+ ? success(credentials)
238
+ : failure(
239
+ 'unexpected-control-plane-credential',
240
+ `Unexpected control-plane credential ${reference.name}.`,
241
+ ),
242
+ ),
243
+ },
244
+ secrets: {
245
+ resolveAsync: (reference) =>
246
+ Promise.resolve(
247
+ failure(
248
+ 'unexpected-managed-secret',
249
+ `Unexpected managed secret ${reference.ref}/${reference.key}.`,
250
+ ),
251
+ ),
252
+ },
253
+ };
254
+ }
255
+
256
+ function createDestroyRequest(
257
+ previous: InfraLedger,
258
+ confirmedResources: readonly InfraResourceIdentity[],
259
+ ) {
260
+ return {
261
+ projectId,
262
+ manifest,
263
+ environment: 'local' as const,
264
+ previous,
265
+ confirmation: { projectId, environment: 'local' as const },
266
+ persistence: { policy: 'delete' as const, confirmedResources },
267
+ };
268
+ }
269
+
270
+ function persistentIdentities(ledger: InfraLedger): readonly InfraResourceIdentity[] {
271
+ return ledger.resources.filter(({ persistent }) => persistent).map(({ identity }) => identity);
272
+ }
273
+
274
+ function assertSafePublicOutputs(outputs: InfraLedger['outputs']): void {
275
+ const serialized = JSON.stringify(outputs);
276
+ expect(serialized).not.toContain(credentials.serviceRoleKey);
277
+ expect(serialized).not.toContain(credentials.postgresPassword);
278
+ expect(serialized).not.toContain(credentials.jwtSecret);
279
+ expect(serialized).not.toContain(credentials.realtimeSecretKeyBase);
280
+ expect(serialized).not.toContain(credentials.realtimeDatabaseEncryptionKey);
281
+ expect(serialized).not.toContain(credentials.pgMetaCryptoKey);
282
+ expect(
283
+ outputs.some(
284
+ ({ environmentVariable, value }) =>
285
+ environmentVariable === 'EXPO_PUBLIC_SUPABASE_ANON_KEY' && value === credentials.anonKey,
286
+ ),
287
+ ).toBe(true);
288
+ }
289
+
290
+ function createJwt(role: 'anon' | 'service_role'): string {
291
+ const encodedHeader = encodeJwtPart({ alg: 'HS256', typ: 'JWT' });
292
+ const encodedPayload = encodeJwtPart({
293
+ role,
294
+ iss: 'supabase',
295
+ iat: 1_700_000_000,
296
+ exp: 4_102_444_800,
297
+ });
298
+ const unsigned = `${encodedHeader}.${encodedPayload}`;
299
+ const signature = createHmac('sha256', jwtSecret).update(unsigned).digest('base64url');
300
+ return `${unsigned}.${signature}`;
301
+ }
302
+
303
+ function encodeJwtPart(value: Readonly<Record<string, string | number>>): string {
304
+ return Buffer.from(JSON.stringify(value)).toString('base64url');
305
+ }
306
+
307
+ function requireSuccess<T>(result: InfraResult<T>): T {
308
+ if (!result.ok) throw new Error(result.diagnostics.map(({ message }) => message).join(' '));
309
+ return result.value;
310
+ }
311
+
312
+ function success<T>(value: T): InfraResult<T> {
313
+ return { ok: true, value, diagnostics: [] };
314
+ }
315
+
316
+ function failure(code: string, message: string): InfraResult<never> {
317
+ return { ok: false, diagnostics: [{ severity: 'error', code, message }] };
318
+ }