@ankhorage/infra 5.1.5 → 6.0.0

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.
@@ -0,0 +1,448 @@
1
+ import type {
2
+ InfraComputeTarget,
3
+ InfraLedger,
4
+ InfraManifest,
5
+ InfraResourceIdentity,
6
+ InfraResult,
7
+ } from '@ankhorage/contracts/infra';
8
+ import {
9
+ createInfraAdapter as createK3sInfraAdapter,
10
+ infraAdapterDescriptor as k3sInfraAdapterDescriptor,
11
+ type K3sClusterObservation,
12
+ type K3sClusterSpec,
13
+ type K3sControlPlane,
14
+ type K3sNodeAccess,
15
+ } from '@ankhorage/k3s';
16
+ import {
17
+ createInfraAdapter as createLocalInfraAdapter,
18
+ infraAdapterDescriptor as localInfraAdapterDescriptor,
19
+ type LocalHostObservation,
20
+ type LocalHostProbe,
21
+ } from '@ankhorage/local';
22
+ import {
23
+ createInfraAdapter as createSupabaseInfraAdapter,
24
+ infraAdapterDescriptor as supabaseInfraAdapterDescriptor,
25
+ type SupabaseControlPlane,
26
+ type SupabaseControlPlaneRequest,
27
+ } from '@ankhorage/supabase';
28
+ import { expect, test } from 'bun:test';
29
+
30
+ import { destroyInfraEnvironmentAsync } from './features/environment-lifecycle/application/use-cases/destroyInfraEnvironmentAsync.js';
31
+ import { downInfraEnvironmentAsync } from './features/environment-lifecycle/application/use-cases/downInfraEnvironmentAsync.js';
32
+ import { getInfraEnvironmentOutputs } from './features/environment-lifecycle/application/use-cases/getInfraEnvironmentOutputs.js';
33
+ import { planInfraEnvironmentAsync } from './features/environment-lifecycle/application/use-cases/planInfraEnvironmentAsync.js';
34
+ import { statusInfraEnvironmentAsync } from './features/environment-lifecycle/application/use-cases/statusInfraEnvironmentAsync.js';
35
+ import { upInfraEnvironmentAsync } from './features/environment-lifecycle/application/use-cases/upInfraEnvironmentAsync.js';
36
+ import type { InfraOrchestrationDependencies } from './types/infraOrchestration.js';
37
+
38
+ const projectId = 'infra164-local-k3s-supabase';
39
+ const publicBaseUrl = 'http://127.0.0.1:54321';
40
+ const bucket = 'infra164-bucket';
41
+ const target: Extract<InfraComputeTarget, { kind: 'local-host' }> = {
42
+ id: 'local',
43
+ kind: 'local-host',
44
+ os: 'linux',
45
+ architecture: 'amd64',
46
+ };
47
+ const manifest = {
48
+ environments: {
49
+ local: {
50
+ deployment: {
51
+ compute: { provider: 'local' },
52
+ runtime: { provider: 'k3s', topology: { servers: 1, agents: 0 } },
53
+ },
54
+ database: { provider: 'supabase', tier: 'dev' },
55
+ auth: { provider: 'supabase' },
56
+ objectStorage: { provider: 'supabase', buckets: [bucket] },
57
+ networking: { publicBaseUrl },
58
+ },
59
+ },
60
+ modules: [],
61
+ } as const satisfies InfraManifest;
62
+
63
+ test('runs local k3s and Supabase through the portable compute lifecycle', async () => {
64
+ const fixture = new LocalK3sFixture();
65
+ const dependencies = createDependencies(fixture);
66
+
67
+ const initialPlan = requireSuccess(
68
+ await planInfraEnvironmentAsync({ projectId, manifest }, dependencies),
69
+ );
70
+ expect(initialPlan.actions.some(({ operation }) => operation === 'create')).toBe(true);
71
+
72
+ const firstUp = requireSuccess(
73
+ await upInfraEnvironmentAsync({ projectId, manifest }, dependencies),
74
+ );
75
+ const firstLedger: InfraLedger = firstUp.ledger;
76
+ expect(firstUp.targets).toEqual([target]);
77
+ expect([...new Set(fixture.loadedPackages)].sort()).toEqual(
78
+ [
79
+ localInfraAdapterDescriptor.package,
80
+ k3sInfraAdapterDescriptor.package,
81
+ supabaseInfraAdapterDescriptor.package,
82
+ ].sort(),
83
+ );
84
+ expect(fixture.k3s.lastAccess).toHaveLength(1);
85
+ expect(fixture.k3s.lastAccess[0]?.transport).toEqual({ kind: 'local' });
86
+ expect(fixture.k3s.lastSpec?.nodes[0]?.target).toEqual(target);
87
+ expect(fixture.kubernetes.serializedResources()).toContain('PersistentVolumeClaim');
88
+ expect(fixture.kubernetes.serializedResources()).toContain('/etc/postgresql-custom');
89
+ expect(fixture.kubernetes.serializedResources()).toContain('.ankhorage-image-seeded');
90
+ expect(fixture.supabase.buckets.has(bucket)).toBe(true);
91
+ expect(JSON.stringify(firstUp)).not.toContain('infra164-service-role-key');
92
+ expect(JSON.stringify(firstUp)).not.toContain('infra164-postgres-password');
93
+
94
+ const outputs = requireSuccess(
95
+ getInfraEnvironmentOutputs({
96
+ projectId,
97
+ manifest,
98
+ environment: 'local',
99
+ previous: firstLedger,
100
+ }),
101
+ );
102
+ expect(outputs.outputs).toEqual(firstUp.outputs);
103
+ expect(
104
+ outputs.outputs.some(
105
+ ({ environmentVariable, value }) =>
106
+ environmentVariable === 'EXPO_PUBLIC_SUPABASE_URL' && value === publicBaseUrl,
107
+ ),
108
+ ).toBe(true);
109
+ expect(outputs.outputs.some(({ name, value }) => name === 'bucket' && value === bucket)).toBe(
110
+ true,
111
+ );
112
+
113
+ const status = requireSuccess(
114
+ await statusInfraEnvironmentAsync(
115
+ { projectId, manifest, environment: 'local', previous: firstLedger },
116
+ dependencies,
117
+ ),
118
+ );
119
+ expect(status.state).toBe('ready');
120
+
121
+ const converged = requireSuccess(
122
+ await planInfraEnvironmentAsync(
123
+ { projectId, manifest, environment: 'local', previous: firstLedger },
124
+ dependencies,
125
+ ),
126
+ );
127
+ expect(converged.actions.every(({ operation }) => operation === 'noop')).toBe(true);
128
+
129
+ const identitiesBeforeRestart = resourceIdentities(firstUp.resources);
130
+ const down = requireSuccess(
131
+ await downInfraEnvironmentAsync(
132
+ { projectId, manifest, environment: 'local', previous: firstLedger },
133
+ dependencies,
134
+ ),
135
+ );
136
+ expect(fixture.k3s.state).toBe('stopped');
137
+
138
+ const resumed = requireSuccess(
139
+ await upInfraEnvironmentAsync(
140
+ { projectId, manifest, environment: 'local', previous: down.ledger },
141
+ dependencies,
142
+ ),
143
+ );
144
+ expect(fixture.k3s.state).toBe('ready');
145
+ expect(resumed.targets).toEqual([target]);
146
+ expect(resourceIdentities(resumed.resources)).toEqual(identitiesBeforeRestart);
147
+
148
+ const destroyed = requireSuccess(
149
+ await destroyInfraEnvironmentAsync(
150
+ {
151
+ projectId,
152
+ manifest,
153
+ environment: 'local',
154
+ previous: resumed.ledger,
155
+ confirmation: { projectId, environment: 'local' },
156
+ persistence: {
157
+ policy: 'delete',
158
+ confirmedResources: persistentIdentities(resumed.ledger),
159
+ },
160
+ },
161
+ dependencies,
162
+ ),
163
+ );
164
+ expect(destroyed.ledger).toBeNull();
165
+ expect(fixture.k3s.state).toBe('absent');
166
+ expect(fixture.k3s.calls).toContain('destroy');
167
+ });
168
+
169
+ class LocalK3sFixture {
170
+ readonly local = new FakeLocalHostProbe();
171
+ readonly k3s = new FakeK3sControlPlane();
172
+ readonly kubernetes = this.k3s.api;
173
+ readonly supabase = new FakeSupabaseControlPlane();
174
+ readonly loadedPackages: string[] = [];
175
+ }
176
+
177
+ function createDependencies(fixture: LocalK3sFixture): InfraOrchestrationDependencies {
178
+ return {
179
+ adapterResolver: {
180
+ loadAsync: (packageName) => {
181
+ fixture.loadedPackages.push(packageName);
182
+ switch (packageName) {
183
+ case localInfraAdapterDescriptor.package:
184
+ return Promise.resolve({
185
+ infraAdapterDescriptor: localInfraAdapterDescriptor,
186
+ createInfraAdapter: () => createLocalInfraAdapter({ probe: fixture.local }),
187
+ });
188
+ case k3sInfraAdapterDescriptor.package:
189
+ return Promise.resolve({
190
+ infraAdapterDescriptor: k3sInfraAdapterDescriptor,
191
+ createInfraAdapter: () => createK3sInfraAdapter({ controlPlane: fixture.k3s }),
192
+ });
193
+ case supabaseInfraAdapterDescriptor.package:
194
+ return Promise.resolve({
195
+ infraAdapterDescriptor: supabaseInfraAdapterDescriptor,
196
+ createInfraAdapter: () =>
197
+ createSupabaseInfraAdapter({ controlPlane: fixture.supabase }),
198
+ });
199
+ default:
200
+ return Promise.reject(new Error(`Unexpected adapter package: ${packageName}`));
201
+ }
202
+ },
203
+ },
204
+ credentials: {
205
+ resolveAsync: ({ name }) => Promise.resolve(resolveCredential(name)),
206
+ },
207
+ secrets: {
208
+ resolveAsync: () =>
209
+ Promise.resolve({
210
+ ok: false,
211
+ diagnostics: [
212
+ { severity: 'error', code: 'unexpected-secret', message: 'No secret is expected.' },
213
+ ],
214
+ }),
215
+ },
216
+ };
217
+ }
218
+
219
+ function resolveCredential(name: string): InfraResult<Readonly<Record<string, string>>> {
220
+ if (name === 'SUPABASE_BOOTSTRAP') {
221
+ return success({
222
+ postgresPassword: 'infra164-postgres-password',
223
+ jwtSecret: 'infra164-jwt-secret-0123456789abcdef',
224
+ anonKey: 'infra164-anon-key',
225
+ serviceRoleKey: 'infra164-service-role-key',
226
+ realtimeSecretKeyBase:
227
+ 'infra164-realtime-secret-key-base-0123456789abcdefghijklmnopqrstuvwxyz',
228
+ realtimeDatabaseEncryptionKey: '1234567890abcdef',
229
+ pgMetaCryptoKey: 'infra164-pg-meta-crypto-key-0123456789abcdef',
230
+ });
231
+ }
232
+ return {
233
+ ok: false,
234
+ diagnostics: [
235
+ {
236
+ severity: 'error',
237
+ code: 'unexpected-credential',
238
+ message: `Unexpected credential reference: ${name}`,
239
+ },
240
+ ],
241
+ };
242
+ }
243
+
244
+ class FakeLocalHostProbe implements LocalHostProbe {
245
+ inspectAsync(workingDirectory: string): Promise<InfraResult<LocalHostObservation>> {
246
+ return Promise.resolve(
247
+ success({
248
+ target,
249
+ workingDirectory,
250
+ exists: true,
251
+ readable: true,
252
+ writable: true,
253
+ }),
254
+ );
255
+ }
256
+ }
257
+
258
+ type KubernetesApi = NonNullable<K3sClusterObservation['api']>;
259
+ type KubernetesOwnershipQuery = Parameters<KubernetesApi['listOwnedAsync']>[0];
260
+ type KubernetesResource = Awaited<ReturnType<KubernetesApi['listOwnedAsync']>>[number];
261
+ type KubernetesResourceReference = Parameters<KubernetesApi['deleteAsync']>[0];
262
+ type KubernetesResourceObservation = Awaited<ReturnType<KubernetesApi['observeAsync']>>;
263
+
264
+ class FakeK3sControlPlane implements K3sControlPlane {
265
+ readonly api = new FakeKubernetesApi();
266
+ readonly calls: string[] = [];
267
+ lastAccess: readonly K3sNodeAccess[] = [];
268
+ lastSpec?: K3sClusterSpec;
269
+ state: K3sClusterObservation['state'] = 'absent';
270
+ nodes: K3sClusterObservation['nodes'] = [];
271
+
272
+ validateAsync(
273
+ _spec: K3sClusterSpec,
274
+ access: readonly K3sNodeAccess[],
275
+ ): Promise<InfraResult<null>> {
276
+ this.calls.push('validate');
277
+ this.lastAccess = access;
278
+ return Promise.resolve(success(null));
279
+ }
280
+
281
+ inspectAsync(): Promise<InfraResult<K3sClusterObservation>> {
282
+ this.calls.push('inspect');
283
+ return Promise.resolve(success(this.observation()));
284
+ }
285
+
286
+ ensureAsync(
287
+ spec: K3sClusterSpec,
288
+ access: readonly K3sNodeAccess[],
289
+ ): Promise<InfraResult<K3sClusterObservation>> {
290
+ this.calls.push('ensure');
291
+ this.lastAccess = access;
292
+ this.lastSpec = spec;
293
+ this.state = 'ready';
294
+ this.nodes = spec.nodes.map(({ id }) => ({
295
+ id,
296
+ state: 'ready',
297
+ configurationMatches: true,
298
+ }));
299
+ return Promise.resolve(success(this.observation()));
300
+ }
301
+
302
+ waitUntilReadyAsync(): Promise<InfraResult<K3sClusterObservation>> {
303
+ this.calls.push('wait');
304
+ return Promise.resolve(success(this.observation()));
305
+ }
306
+
307
+ loadImagesAsync(
308
+ _spec: K3sClusterSpec,
309
+ _access: readonly K3sNodeAccess[],
310
+ images: readonly string[],
311
+ ): Promise<InfraResult<null>> {
312
+ this.calls.push(`images:${images.join(',')}`);
313
+ return Promise.resolve(success(null));
314
+ }
315
+
316
+ suspendAsync(): Promise<InfraResult<null>> {
317
+ this.calls.push('suspend');
318
+ this.state = 'stopped';
319
+ this.nodes = this.nodes.map((node) => ({ ...node, state: 'stopped' }));
320
+ return Promise.resolve(success(null));
321
+ }
322
+
323
+ destroyAsync(): Promise<InfraResult<null>> {
324
+ this.calls.push('destroy');
325
+ this.state = 'absent';
326
+ this.nodes = [];
327
+ return Promise.resolve(success(null));
328
+ }
329
+
330
+ private observation(): K3sClusterObservation {
331
+ return {
332
+ state: this.state,
333
+ configurationMatches: true,
334
+ nodes: this.nodes,
335
+ ...(this.state === 'ready' ? { api: this.api } : {}),
336
+ };
337
+ }
338
+ }
339
+
340
+ class FakeKubernetesApi implements KubernetesApi {
341
+ readonly resources: KubernetesResource[] = [];
342
+
343
+ listOwnedAsync(query: KubernetesOwnershipQuery): Promise<readonly KubernetesResource[]> {
344
+ return Promise.resolve(
345
+ this.resources.filter(({ metadata }) => includesLabels(metadata.labels, query.labels)),
346
+ );
347
+ }
348
+
349
+ applyAsync(resource: KubernetesResource): Promise<void> {
350
+ const index = this.resources.findIndex((candidate) => sameResource(candidate, resource));
351
+ if (index === -1) this.resources.push(resource);
352
+ else this.resources.splice(index, 1, resource);
353
+ return Promise.resolve();
354
+ }
355
+
356
+ deleteAsync(reference: KubernetesResourceReference): Promise<void> {
357
+ const index = this.resources.findIndex((resource) => sameResource(resource, reference));
358
+ if (index >= 0) this.resources.splice(index, 1);
359
+ return Promise.resolve();
360
+ }
361
+
362
+ observeAsync(reference: KubernetesResourceReference): Promise<KubernetesResourceObservation> {
363
+ return Promise.resolve({
364
+ state: 'ready',
365
+ ...(reference.kind === 'Service' ? { publicOutputs: { endpoint: publicBaseUrl } } : {}),
366
+ });
367
+ }
368
+
369
+ waitUntilReadyAsync(): Promise<KubernetesResourceObservation> {
370
+ return Promise.resolve({ state: 'ready' });
371
+ }
372
+
373
+ serializedResources(): string {
374
+ return JSON.stringify(this.resources);
375
+ }
376
+ }
377
+
378
+ class FakeSupabaseControlPlane implements SupabaseControlPlane {
379
+ readonly buckets = new Set<string>();
380
+
381
+ healthAsync(_request: SupabaseControlPlaneRequest): Promise<InfraResult<null>> {
382
+ return Promise.resolve(success(null));
383
+ }
384
+
385
+ listBucketsAsync(_request: SupabaseControlPlaneRequest): Promise<InfraResult<readonly string[]>> {
386
+ return Promise.resolve(success([...this.buckets]));
387
+ }
388
+
389
+ createBucketAsync(
390
+ request: SupabaseControlPlaneRequest & { readonly bucket: string },
391
+ ): Promise<InfraResult<null>> {
392
+ this.buckets.add(request.bucket);
393
+ return Promise.resolve(success(null));
394
+ }
395
+
396
+ deleteBucketAsync(
397
+ request: SupabaseControlPlaneRequest & { readonly bucket: string },
398
+ ): Promise<InfraResult<null>> {
399
+ this.buckets.delete(request.bucket);
400
+ return Promise.resolve(success(null));
401
+ }
402
+ }
403
+
404
+ function persistentIdentities(ledger: InfraLedger): readonly InfraResourceIdentity[] {
405
+ return ledger.resources.filter(({ persistent }) => persistent).map(({ identity }) => identity);
406
+ }
407
+
408
+ function resourceIdentities(
409
+ resources: readonly { readonly identity: InfraResourceIdentity }[],
410
+ ): readonly string[] {
411
+ return resources
412
+ .map(({ identity }) =>
413
+ [identity.projectId, identity.environment, identity.adapter, identity.resourceId].join(':'),
414
+ )
415
+ .sort();
416
+ }
417
+
418
+ function includesLabels(
419
+ actual: Readonly<Record<string, string>>,
420
+ expected: Readonly<Record<string, string>>,
421
+ ): boolean {
422
+ return Object.entries(expected).every(([expectedKey, expectedValue]) =>
423
+ Object.entries(actual).some(
424
+ ([actualKey, actualValue]) => actualKey === expectedKey && actualValue === expectedValue,
425
+ ),
426
+ );
427
+ }
428
+
429
+ function sameResource(
430
+ left: KubernetesResource,
431
+ right: KubernetesResource | KubernetesResourceReference,
432
+ ): boolean {
433
+ return (
434
+ left.apiVersion === right.apiVersion &&
435
+ left.kind === right.kind &&
436
+ left.metadata.name === ('metadata' in right ? right.metadata.name : right.name) &&
437
+ left.metadata.namespace === ('metadata' in right ? right.metadata.namespace : right.namespace)
438
+ );
439
+ }
440
+
441
+ function requireSuccess<T>(result: InfraResult<T>): T {
442
+ if (!result.ok) throw new Error(result.diagnostics.map(({ message }) => message).join(' '));
443
+ return result.value;
444
+ }
445
+
446
+ function success<T>(value: T): InfraResult<T> {
447
+ return { ok: true, value, diagnostics: [] };
448
+ }