@ankhorage/infra 5.1.2 → 5.1.3

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,11 @@
1
1
  # @ankhorage/infra
2
2
 
3
+ ## 5.1.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 246e468: Retain compute targets and compute ownership when runtime-owned resources survive `infra destroy`, so production persistence is not orphaned by underlying host deletion. Production composition validation now runs against k3s 0.3.1+ and Kubernetes 0.7.3+, including current workload-template materialization.
8
+
3
9
  ## 5.1.2
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  # INFRA
5
5
 
6
- ![license: MIT](././paradox/badges/license.svg) ![npm: v5.0.0](././paradox/badges/npm.svg) ![runtime: bun](././paradox/badges/runtime.svg) ![typescript: strict](././paradox/badges/typescript.svg) ![eslint: checked](././paradox/badges/eslint.svg) ![prettier: checked](././paradox/badges/prettier.svg) ![build: checked](././paradox/badges/build.svg) ![tests: checked](././paradox/badges/tests.svg) ![docs: paradox](././paradox/badges/docs.svg)
6
+ ![license: MIT](././paradox/badges/license.svg) ![npm: v5.1.2](././paradox/badges/npm.svg) ![runtime: bun](././paradox/badges/runtime.svg) ![typescript: strict](././paradox/badges/typescript.svg) ![eslint: checked](././paradox/badges/eslint.svg) ![prettier: checked](././paradox/badges/prettier.svg) ![build: checked](././paradox/badges/build.svg) ![tests: checked](././paradox/badges/tests.svg) ![docs: paradox](././paradox/badges/docs.svg)
7
7
 
8
8
  Executable infra provider and standalone CLI for Ankhorage project workflows.
9
9
 
@@ -1 +1 @@
1
- {"version":3,"file":"destroyInfraEnvironmentAsync.d.ts","sourceRoot":"","sources":["../../../../../src/features/environment-lifecycle/application/use-cases/destroyInfraEnvironmentAsync.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EACV,4BAA4B,EAC5B,2BAA2B,EAC3B,8BAA8B,EAC/B,MAAM,yCAAyC,CAAC;AAQjD,qGAAqG;AACrG,wBAAsB,4BAA4B,CAChD,OAAO,EAAE,4BAA4B,EACrC,YAAY,EAAE,8BAA8B,GAC3C,OAAO,CAAC,2BAA2B,CAAC,CAyDtC"}
1
+ {"version":3,"file":"destroyInfraEnvironmentAsync.d.ts","sourceRoot":"","sources":["../../../../../src/features/environment-lifecycle/application/use-cases/destroyInfraEnvironmentAsync.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EACV,4BAA4B,EAC5B,2BAA2B,EAC3B,8BAA8B,EAC/B,MAAM,yCAAyC,CAAC;AAQjD,qGAAqG;AACrG,wBAAsB,4BAA4B,CAChD,OAAO,EAAE,4BAA4B,EACrC,YAAY,EAAE,8BAA8B,GAC3C,OAAO,CAAC,2BAA2B,CAAC,CAqEtC"}
@@ -29,20 +29,33 @@ export async function destroyInfraEnvironmentAsync(request, dependencies) {
29
29
  outputs.push(...result.value.outputs);
30
30
  diagnostics.push(...result.diagnostics);
31
31
  }
32
+ let runtimeRetained = false;
32
33
  if (prepared.value.runtimeDesired.targets.length > 0) {
33
34
  const runtime = await prepared.value.adapters.runtime.destroyAsync(prepared.value.context, prepared.value.runtimeDesired, destroyRequest);
34
35
  if (!runtime.ok)
35
36
  return appendInfraFailure(diagnostics, runtime);
37
+ runtimeRetained = runtime.value.resources.length > 0;
36
38
  resources.push(...runtime.value.resources);
37
39
  outputs.push(...runtime.value.outputs);
38
40
  diagnostics.push(...runtime.diagnostics);
39
41
  }
40
- const compute = await prepared.value.adapters.compute.destroyAsync(prepared.value.context, destroyRequest);
41
- if (!compute.ok)
42
- return appendInfraFailure(diagnostics, compute);
43
- resources.push(...compute.value.resources);
44
- outputs.push(...compute.value.outputs);
45
- diagnostics.push(...compute.diagnostics);
42
+ if (runtimeRetained) {
43
+ resources.push(...prepared.value.compute.resources);
44
+ outputs.push(...prepared.value.compute.outputs);
45
+ diagnostics.push({
46
+ severity: 'info',
47
+ code: 'infra-compute-retained-for-runtime',
48
+ message: 'Compute remains active because runtime-owned resources survived destruction.',
49
+ });
50
+ }
51
+ else {
52
+ const compute = await prepared.value.adapters.compute.destroyAsync(prepared.value.context, destroyRequest);
53
+ if (!compute.ok)
54
+ return appendInfraFailure(diagnostics, compute);
55
+ resources.push(...compute.value.resources);
56
+ outputs.push(...compute.value.outputs);
57
+ diagnostics.push(...compute.diagnostics);
58
+ }
46
59
  const remainingResources = mergeInfraResources(resources);
47
60
  const remainingOutputs = mergeInfraOutputs(outputs);
48
61
  const ledger = remainingResources.length === 0 && remainingOutputs.length === 0
@@ -50,7 +63,7 @@ export async function destroyInfraEnvironmentAsync(request, dependencies) {
50
63
  : createInfraLedger({
51
64
  projectId: request.projectId,
52
65
  environment: prepared.value.environment.id,
53
- targets: [],
66
+ targets: runtimeRetained ? prepared.value.compute.targets : [],
54
67
  resources: remainingResources,
55
68
  outputs: remainingOutputs,
56
69
  });
@@ -1 +1 @@
1
- {"version":3,"file":"destroyInfraEnvironmentAsync.js","sourceRoot":"","sources":["../../../../../src/features/environment-lifecycle/application/use-cases/destroyInfraEnvironmentAsync.ts"],"names":[],"mappings":"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","sourcesContent":["import type {\n InfraDestroyRequest,\n InfraDiagnostic,\n InfraOutput,\n InfraOwnedResource,\n InfraResult,\n} from '@ankhorage/contracts/infra';\n\nimport type {\n InfraDestroyOperationRequest,\n InfraDestroyOperationResult,\n InfraOrchestrationDependencies,\n} from '../../../../types/infraOrchestration.js';\nimport { appendInfraFailure } from '../../utils/appendInfraFailure.js';\nimport { createInfraLedger } from '../../utils/createInfraLedger.js';\nimport { infraIdentityKey } from '../../utils/infraIdentityKey.js';\nimport { mergeInfraOutputs } from '../../utils/mergeInfraOutputs.js';\nimport { mergeInfraResources } from '../../utils/mergeInfraResources.js';\nimport { prepareInfraOperationAsync } from './prepareInfraOperationAsync.js';\n\n/*** Destroy exact owned resources in reverse dependency order behind explicit scope confirmation. */\nexport async function destroyInfraEnvironmentAsync(\n request: InfraDestroyOperationRequest,\n dependencies: InfraOrchestrationDependencies,\n): Promise<InfraDestroyOperationResult> {\n const prepared = await prepareInfraOperationAsync(request, 'destroy', dependencies);\n if (!prepared.ok) return prepared;\n const destroyRequest: InfraDestroyRequest = {\n projectId: request.projectId,\n environment: prepared.value.environment.id,\n confirmation: request.confirmation,\n persistence: request.persistence,\n };\n const safety = validateDestroyRequest(destroyRequest, request.previous?.resources ?? []);\n if (!safety.ok) return safety;\n const diagnostics: InfraDiagnostic[] = [...prepared.diagnostics];\n const resources: InfraOwnedResource[] = [];\n const outputs: InfraOutput[] = [];\n for (const service of [...prepared.value.adapters.services].reverse()) {\n const result = await service.destroyAsync(prepared.value.context, destroyRequest);\n if (!result.ok) return appendInfraFailure(diagnostics, result);\n resources.push(...result.value.resources);\n outputs.push(...result.value.outputs);\n diagnostics.push(...result.diagnostics);\n }\n if (prepared.value.runtimeDesired.targets.length > 0) {\n const runtime = await prepared.value.adapters.runtime.destroyAsync(\n prepared.value.context,\n prepared.value.runtimeDesired,\n destroyRequest,\n );\n if (!runtime.ok) return appendInfraFailure(diagnostics, runtime);\n resources.push(...runtime.value.resources);\n outputs.push(...runtime.value.outputs);\n diagnostics.push(...runtime.diagnostics);\n }\n const compute = await prepared.value.adapters.compute.destroyAsync(\n prepared.value.context,\n destroyRequest,\n );\n if (!compute.ok) return appendInfraFailure(diagnostics, compute);\n resources.push(...compute.value.resources);\n outputs.push(...compute.value.outputs);\n diagnostics.push(...compute.diagnostics);\n const remainingResources = mergeInfraResources(resources);\n const remainingOutputs = mergeInfraOutputs(outputs);\n const ledger =\n remainingResources.length === 0 && remainingOutputs.length === 0\n ? null\n : createInfraLedger({\n projectId: request.projectId,\n environment: prepared.value.environment.id,\n targets: [],\n resources: remainingResources,\n outputs: remainingOutputs,\n });\n return {\n ok: true,\n value: { environment: prepared.value.environment.id, ledger },\n diagnostics,\n };\n}\n\n/*** Refuse cross-project confirmation and unowned persistent deletion before any mutation. */\nfunction validateDestroyRequest(\n request: InfraDestroyRequest,\n previous: readonly InfraOwnedResource[],\n): InfraResult<null> {\n if (\n request.confirmation.projectId !== request.projectId ||\n request.confirmation.environment !== request.environment\n ) {\n return destroyFailure(\n 'infra-destroy-confirmation-mismatch',\n 'Infra destroy confirmation must exactly match the selected project and environment.',\n );\n }\n if (request.persistence.policy === 'retain') {\n return { ok: true, value: null, diagnostics: [] };\n }\n const owned = new Set(previous.map(({ identity }) => infraIdentityKey(identity)));\n const invalid = request.persistence.confirmedResources.some(\n (identity) =>\n identity.projectId !== request.projectId ||\n identity.environment !== request.environment ||\n !owned.has(infraIdentityKey(identity)),\n );\n return invalid\n ? destroyFailure(\n 'infra-destroy-resource-unowned',\n 'Persistent deletion confirmation contains a resource outside the stored Infra scope.',\n )\n : { ok: true, value: null, diagnostics: [] };\n}\n\n/*** Create one stable non-provider-specific destroy safety failure. */\nfunction destroyFailure(code: string, message: string): InfraResult<never> {\n return { ok: false, diagnostics: [{ severity: 'error', code, message }] };\n}\n"]}
1
+ {"version":3,"file":"destroyInfraEnvironmentAsync.js","sourceRoot":"","sources":["../../../../../src/features/environment-lifecycle/application/use-cases/destroyInfraEnvironmentAsync.ts"],"names":[],"mappings":"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","sourcesContent":["import type {\n InfraDestroyRequest,\n InfraDiagnostic,\n InfraOutput,\n InfraOwnedResource,\n InfraResult,\n} from '@ankhorage/contracts/infra';\n\nimport type {\n InfraDestroyOperationRequest,\n InfraDestroyOperationResult,\n InfraOrchestrationDependencies,\n} from '../../../../types/infraOrchestration.js';\nimport { appendInfraFailure } from '../../utils/appendInfraFailure.js';\nimport { createInfraLedger } from '../../utils/createInfraLedger.js';\nimport { infraIdentityKey } from '../../utils/infraIdentityKey.js';\nimport { mergeInfraOutputs } from '../../utils/mergeInfraOutputs.js';\nimport { mergeInfraResources } from '../../utils/mergeInfraResources.js';\nimport { prepareInfraOperationAsync } from './prepareInfraOperationAsync.js';\n\n/*** Destroy exact owned resources in reverse dependency order behind explicit scope confirmation. */\nexport async function destroyInfraEnvironmentAsync(\n request: InfraDestroyOperationRequest,\n dependencies: InfraOrchestrationDependencies,\n): Promise<InfraDestroyOperationResult> {\n const prepared = await prepareInfraOperationAsync(request, 'destroy', dependencies);\n if (!prepared.ok) return prepared;\n const destroyRequest: InfraDestroyRequest = {\n projectId: request.projectId,\n environment: prepared.value.environment.id,\n confirmation: request.confirmation,\n persistence: request.persistence,\n };\n const safety = validateDestroyRequest(destroyRequest, request.previous?.resources ?? []);\n if (!safety.ok) return safety;\n const diagnostics: InfraDiagnostic[] = [...prepared.diagnostics];\n const resources: InfraOwnedResource[] = [];\n const outputs: InfraOutput[] = [];\n for (const service of [...prepared.value.adapters.services].reverse()) {\n const result = await service.destroyAsync(prepared.value.context, destroyRequest);\n if (!result.ok) return appendInfraFailure(diagnostics, result);\n resources.push(...result.value.resources);\n outputs.push(...result.value.outputs);\n diagnostics.push(...result.diagnostics);\n }\n let runtimeRetained = false;\n if (prepared.value.runtimeDesired.targets.length > 0) {\n const runtime = await prepared.value.adapters.runtime.destroyAsync(\n prepared.value.context,\n prepared.value.runtimeDesired,\n destroyRequest,\n );\n if (!runtime.ok) return appendInfraFailure(diagnostics, runtime);\n runtimeRetained = runtime.value.resources.length > 0;\n resources.push(...runtime.value.resources);\n outputs.push(...runtime.value.outputs);\n diagnostics.push(...runtime.diagnostics);\n }\n if (runtimeRetained) {\n resources.push(...prepared.value.compute.resources);\n outputs.push(...prepared.value.compute.outputs);\n diagnostics.push({\n severity: 'info',\n code: 'infra-compute-retained-for-runtime',\n message: 'Compute remains active because runtime-owned resources survived destruction.',\n });\n } else {\n const compute = await prepared.value.adapters.compute.destroyAsync(\n prepared.value.context,\n destroyRequest,\n );\n if (!compute.ok) return appendInfraFailure(diagnostics, compute);\n resources.push(...compute.value.resources);\n outputs.push(...compute.value.outputs);\n diagnostics.push(...compute.diagnostics);\n }\n const remainingResources = mergeInfraResources(resources);\n const remainingOutputs = mergeInfraOutputs(outputs);\n const ledger =\n remainingResources.length === 0 && remainingOutputs.length === 0\n ? null\n : createInfraLedger({\n projectId: request.projectId,\n environment: prepared.value.environment.id,\n targets: runtimeRetained ? prepared.value.compute.targets : [],\n resources: remainingResources,\n outputs: remainingOutputs,\n });\n return {\n ok: true,\n value: { environment: prepared.value.environment.id, ledger },\n diagnostics,\n };\n}\n\n/*** Refuse cross-project confirmation and unowned persistent deletion before any mutation. */\nfunction validateDestroyRequest(\n request: InfraDestroyRequest,\n previous: readonly InfraOwnedResource[],\n): InfraResult<null> {\n if (\n request.confirmation.projectId !== request.projectId ||\n request.confirmation.environment !== request.environment\n ) {\n return destroyFailure(\n 'infra-destroy-confirmation-mismatch',\n 'Infra destroy confirmation must exactly match the selected project and environment.',\n );\n }\n if (request.persistence.policy === 'retain') {\n return { ok: true, value: null, diagnostics: [] };\n }\n const owned = new Set(previous.map(({ identity }) => infraIdentityKey(identity)));\n const invalid = request.persistence.confirmedResources.some(\n (identity) =>\n identity.projectId !== request.projectId ||\n identity.environment !== request.environment ||\n !owned.has(infraIdentityKey(identity)),\n );\n return invalid\n ? destroyFailure(\n 'infra-destroy-resource-unowned',\n 'Persistent deletion confirmation contains a resource outside the stored Infra scope.',\n )\n : { ok: true, value: null, diagnostics: [] };\n}\n\n/*** Create one stable non-provider-specific destroy safety failure. */\nfunction destroyFailure(code: string, message: string): InfraResult<never> {\n return { ok: false, diagnostics: [{ severity: 'error', code, message }] };\n}\n"]}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ankhorage/infra",
3
3
  "type": "module",
4
- "version": "5.1.2",
4
+ "version": "5.1.3",
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": {
@@ -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:docker-compose-providers": "ANKH_INFRA_DOCKER_COMPOSE_PROVIDERS_E2E=1 bun test src/localDockerComposeProviders.integration.test.ts",
103
104
  "test:e2e:minikube-supabase": "ANKH_INFRA_MINIKUBE_SUPABASE_E2E=1 bun test src/localMinikubeSupabase.integration.test.ts",
104
105
  "typecheck": "bun x tsc --noEmit -p tsconfig.json",
105
106
  "check-types": "bun run typecheck",
@@ -111,14 +112,14 @@
111
112
  "@ankhorage/ankh": "^0.8.11",
112
113
  "@ankhorage/cerbos": "^0.2.0",
113
114
  "@ankhorage/devtools": "^1.14.0",
114
- "@ankhorage/docker-compose": "^0.4.0",
115
+ "@ankhorage/docker-compose": "^0.5.3",
115
116
  "@ankhorage/hetzner": "^0.3.0",
116
- "@ankhorage/k3s": "^0.3.0",
117
+ "@ankhorage/k3s": "^0.3.1",
117
118
  "@ankhorage/local": "^0.3.0",
118
119
  "@ankhorage/minikube": "^0.4.1",
119
120
  "@ankhorage/paradox": "^0.1.24",
120
121
  "@ankhorage/r2": "^0.2.0",
121
- "@ankhorage/supabase": "^0.4.5",
122
+ "@ankhorage/supabase": "^0.4.7",
122
123
  "@ankhorage/supabase-vault": "^0.3.1",
123
124
  "@types/bun": "^1.4.1",
124
125
  "@types/node": "^25.6.0",
@@ -43,6 +43,7 @@ export async function destroyInfraEnvironmentAsync(
43
43
  outputs.push(...result.value.outputs);
44
44
  diagnostics.push(...result.diagnostics);
45
45
  }
46
+ let runtimeRetained = false;
46
47
  if (prepared.value.runtimeDesired.targets.length > 0) {
47
48
  const runtime = await prepared.value.adapters.runtime.destroyAsync(
48
49
  prepared.value.context,
@@ -50,18 +51,29 @@ export async function destroyInfraEnvironmentAsync(
50
51
  destroyRequest,
51
52
  );
52
53
  if (!runtime.ok) return appendInfraFailure(diagnostics, runtime);
54
+ runtimeRetained = runtime.value.resources.length > 0;
53
55
  resources.push(...runtime.value.resources);
54
56
  outputs.push(...runtime.value.outputs);
55
57
  diagnostics.push(...runtime.diagnostics);
56
58
  }
57
- const compute = await prepared.value.adapters.compute.destroyAsync(
58
- prepared.value.context,
59
- destroyRequest,
60
- );
61
- if (!compute.ok) return appendInfraFailure(diagnostics, compute);
62
- resources.push(...compute.value.resources);
63
- outputs.push(...compute.value.outputs);
64
- diagnostics.push(...compute.diagnostics);
59
+ if (runtimeRetained) {
60
+ resources.push(...prepared.value.compute.resources);
61
+ outputs.push(...prepared.value.compute.outputs);
62
+ diagnostics.push({
63
+ severity: 'info',
64
+ code: 'infra-compute-retained-for-runtime',
65
+ message: 'Compute remains active because runtime-owned resources survived destruction.',
66
+ });
67
+ } else {
68
+ const compute = await prepared.value.adapters.compute.destroyAsync(
69
+ prepared.value.context,
70
+ destroyRequest,
71
+ );
72
+ if (!compute.ok) return appendInfraFailure(diagnostics, compute);
73
+ resources.push(...compute.value.resources);
74
+ outputs.push(...compute.value.outputs);
75
+ diagnostics.push(...compute.diagnostics);
76
+ }
65
77
  const remainingResources = mergeInfraResources(resources);
66
78
  const remainingOutputs = mergeInfraOutputs(outputs);
67
79
  const ledger =
@@ -70,7 +82,7 @@ export async function destroyInfraEnvironmentAsync(
70
82
  : createInfraLedger({
71
83
  projectId: request.projectId,
72
84
  environment: prepared.value.environment.id,
73
- targets: [],
85
+ targets: runtimeRetained ? prepared.value.compute.targets : [],
74
86
  resources: remainingResources,
75
87
  outputs: remainingOutputs,
76
88
  });
@@ -0,0 +1,485 @@
1
+ import type {
2
+ InfraLedger,
3
+ InfraManifest,
4
+ InfraResourceIdentity,
5
+ InfraResult,
6
+ } from '@ankhorage/contracts/infra';
7
+ import {
8
+ createInfraAdapter as createHetznerInfraAdapter,
9
+ type HetznerCloudApi,
10
+ type HetznerCloudObservation,
11
+ type HetznerDesiredCompute,
12
+ type HetznerHostKeyProbe,
13
+ type HetznerProjectIdentity,
14
+ type HetznerResourceId,
15
+ infraAdapterDescriptor as hetznerInfraAdapterDescriptor,
16
+ } from '@ankhorage/hetzner';
17
+ import {
18
+ createInfraAdapter as createK3sInfraAdapter,
19
+ infraAdapterDescriptor as k3sInfraAdapterDescriptor,
20
+ type K3sClusterObservation,
21
+ type K3sClusterSpec,
22
+ type K3sControlPlane,
23
+ type K3sNodeAccess,
24
+ } from '@ankhorage/k3s';
25
+ import {
26
+ createInfraAdapter as createSupabaseInfraAdapter,
27
+ infraAdapterDescriptor as supabaseInfraAdapterDescriptor,
28
+ type SupabaseControlPlane,
29
+ type SupabaseControlPlaneRequest,
30
+ } from '@ankhorage/supabase';
31
+ import { expect, test } from 'bun:test';
32
+
33
+ import { destroyInfraEnvironmentAsync } from './features/environment-lifecycle/application/use-cases/destroyInfraEnvironmentAsync.js';
34
+ import { downInfraEnvironmentAsync } from './features/environment-lifecycle/application/use-cases/downInfraEnvironmentAsync.js';
35
+ import { planInfraEnvironmentAsync } from './features/environment-lifecycle/application/use-cases/planInfraEnvironmentAsync.js';
36
+ import { statusInfraEnvironmentAsync } from './features/environment-lifecycle/application/use-cases/statusInfraEnvironmentAsync.js';
37
+ import { upInfraEnvironmentAsync } from './features/environment-lifecycle/application/use-cases/upInfraEnvironmentAsync.js';
38
+ import type { InfraOrchestrationDependencies } from './types/infraOrchestration.js';
39
+
40
+ const projectId = 'infra145-production';
41
+ const publicBaseUrl = 'https://api.production.example.test';
42
+ const domain = 'api.production.example.test';
43
+
44
+ const manifest = {
45
+ environments: {
46
+ local: {
47
+ deployment: {
48
+ compute: { provider: 'local' },
49
+ runtime: { provider: 'docker-compose' },
50
+ },
51
+ },
52
+ production: {
53
+ deployment: {
54
+ compute: { provider: 'hetzner', location: 'fsn1', serverType: 'cx23' },
55
+ runtime: { provider: 'k3s', topology: { servers: 1, agents: 0 } },
56
+ },
57
+ database: { provider: 'supabase', tier: 'prod' },
58
+ auth: { provider: 'supabase' },
59
+ networking: { domain, publicBaseUrl },
60
+ },
61
+ },
62
+ modules: [],
63
+ } as const satisfies InfraManifest;
64
+
65
+ test('composes Hetzner, k3s, Kubernetes and Supabase while retaining compute for production data', async () => {
66
+ const fixture = new ProductionFixture();
67
+ const dependencies = createDependencies(fixture);
68
+
69
+ const initialPlan = requireSuccess(
70
+ await planInfraEnvironmentAsync(
71
+ { projectId, manifest, environment: 'production' },
72
+ dependencies,
73
+ ),
74
+ );
75
+ expect(initialPlan.actions.some(({ operation }) => operation === 'create')).toBe(true);
76
+
77
+ const firstUp = requireSuccess(
78
+ await upInfraEnvironmentAsync({ projectId, manifest, environment: 'production' }, dependencies),
79
+ );
80
+ const firstLedger: InfraLedger = firstUp.ledger;
81
+ expect(firstUp.targets).toEqual([
82
+ {
83
+ id: 'server',
84
+ kind: 'ssh-host',
85
+ os: 'linux',
86
+ architecture: 'amd64',
87
+ host: '203.0.113.10',
88
+ port: 22,
89
+ user: 'root',
90
+ credential: { source: 'control-plane', name: 'HETZNER_SSH' },
91
+ hostKeyFingerprint: 'SHA256:phase10-host',
92
+ },
93
+ ]);
94
+ expect(fixture.k3s.lastAccess).toHaveLength(1);
95
+ const [access] = fixture.k3s.lastAccess;
96
+ expect(access?.transport.kind).toBe('ssh');
97
+ expect(access?.transport.kind === 'ssh' && access.transport.host).toBe('203.0.113.10');
98
+ expect(JSON.stringify(firstUp)).not.toContain('phase10-private-key');
99
+ expect(JSON.stringify(firstUp)).not.toContain('phase10-service-role-key');
100
+ expect(fixture.kubernetes.serializedResources()).toContain(`"host":"${domain}"`);
101
+ expect(fixture.kubernetes.serializedResources()).toContain('PersistentVolumeClaim');
102
+ expect(firstUp.resources.some(({ persistent }) => persistent)).toBe(true);
103
+ const urlOutput = firstUp.outputs.find(({ name }) => name === 'url');
104
+ expect(urlOutput?.value).toBe(publicBaseUrl);
105
+ expect(urlOutput?.visibility).toBe('public');
106
+
107
+ const status = requireSuccess(
108
+ await statusInfraEnvironmentAsync(
109
+ { projectId, manifest, environment: 'production', previous: firstLedger },
110
+ dependencies,
111
+ ),
112
+ );
113
+ expect(status.state).toBe('ready');
114
+
115
+ const converged = requireSuccess(
116
+ await planInfraEnvironmentAsync(
117
+ { projectId, manifest, environment: 'production', previous: firstLedger },
118
+ dependencies,
119
+ ),
120
+ );
121
+ expect(converged.actions.every(({ operation }) => operation === 'noop')).toBe(true);
122
+
123
+ const identitiesBeforeRestart = resourceIdentities(firstUp.resources);
124
+ const down = requireSuccess(
125
+ await downInfraEnvironmentAsync(
126
+ { projectId, manifest, environment: 'production', previous: firstLedger },
127
+ dependencies,
128
+ ),
129
+ );
130
+ const { ledger: downLedger } = down;
131
+ expect(fixture.k3s.state).toBe('stopped');
132
+
133
+ const resumed = requireSuccess(
134
+ await upInfraEnvironmentAsync(
135
+ { projectId, manifest, environment: 'production', previous: downLedger },
136
+ dependencies,
137
+ ),
138
+ );
139
+ const { ledger: resumedLedger } = resumed;
140
+ expect(fixture.k3s.state).toBe('ready');
141
+ expect(resourceIdentities(resumed.resources)).toEqual(identitiesBeforeRestart);
142
+
143
+ const retained = requireSuccess(
144
+ await destroyInfraEnvironmentAsync(
145
+ {
146
+ projectId,
147
+ manifest,
148
+ environment: 'production',
149
+ previous: resumedLedger,
150
+ confirmation: { projectId, environment: 'production' },
151
+ persistence: { policy: 'retain' },
152
+ },
153
+ dependencies,
154
+ ),
155
+ );
156
+ expect(retained.ledger).not.toBeNull();
157
+ expect(retained.ledger?.targets).toEqual(firstUp.targets);
158
+ expect(retained.ledger?.resources.some(({ persistent }) => persistent)).toBe(true);
159
+ expect(fixture.hetzner.destroyed).toEqual([]);
160
+ expect(fixture.k3s.calls).not.toContain('destroy');
161
+ });
162
+
163
+ class ProductionFixture {
164
+ readonly hetzner = new FakeHetznerCloudApi();
165
+ readonly hostKeyProbe = new FakeHostKeyProbe();
166
+ readonly k3s = new FakeK3sControlPlane();
167
+ readonly kubernetes = this.k3s.api;
168
+ readonly supabase = new FakeSupabaseControlPlane();
169
+ }
170
+
171
+ function createDependencies(fixture: ProductionFixture): InfraOrchestrationDependencies {
172
+ return {
173
+ adapterResolver: {
174
+ loadAsync: (packageName) => {
175
+ switch (packageName) {
176
+ case hetznerInfraAdapterDescriptor.package:
177
+ return Promise.resolve({
178
+ infraAdapterDescriptor: hetznerInfraAdapterDescriptor,
179
+ createInfraAdapter: () =>
180
+ createHetznerInfraAdapter({
181
+ api: fixture.hetzner,
182
+ hostKeyProbe: fixture.hostKeyProbe,
183
+ }),
184
+ });
185
+ case k3sInfraAdapterDescriptor.package:
186
+ return Promise.resolve({
187
+ infraAdapterDescriptor: k3sInfraAdapterDescriptor,
188
+ createInfraAdapter: () => createK3sInfraAdapter({ controlPlane: fixture.k3s }),
189
+ });
190
+ case supabaseInfraAdapterDescriptor.package:
191
+ return Promise.resolve({
192
+ infraAdapterDescriptor: supabaseInfraAdapterDescriptor,
193
+ createInfraAdapter: () =>
194
+ createSupabaseInfraAdapter({ controlPlane: fixture.supabase }),
195
+ });
196
+ default:
197
+ return Promise.reject(new Error(`Unexpected adapter package: ${packageName}`));
198
+ }
199
+ },
200
+ },
201
+ credentials: {
202
+ resolveAsync: ({ name }) => Promise.resolve(resolveCredential(name)),
203
+ },
204
+ secrets: {
205
+ resolveAsync: () =>
206
+ Promise.resolve({
207
+ ok: false,
208
+ diagnostics: [
209
+ { severity: 'error', code: 'unexpected-secret', message: 'No secret is expected.' },
210
+ ],
211
+ }),
212
+ },
213
+ };
214
+ }
215
+
216
+ function resolveCredential(name: string): InfraResult<Readonly<Record<string, string>>> {
217
+ if (name === 'HCLOUD_TOKEN') return success({ token: 'phase10-hcloud-token' });
218
+ if (name === 'HETZNER_SSH') {
219
+ return success({
220
+ publicKey: 'ssh-ed25519 AAAA phase10',
221
+ privateKey: 'phase10-private-key',
222
+ });
223
+ }
224
+ if (name === 'SUPABASE_BOOTSTRAP') {
225
+ return success({
226
+ postgresPassword: 'phase10-postgres-password',
227
+ jwtSecret: 'phase10-jwt-secret-0123456789abcdef',
228
+ anonKey: 'phase10-anon-key',
229
+ serviceRoleKey: 'phase10-service-role-key',
230
+ realtimeSecretKeyBase:
231
+ 'phase10-realtime-secret-key-base-0123456789abcdefghijklmnopqrstuvwxyz',
232
+ realtimeDatabaseEncryptionKey: '1234567890abcdef',
233
+ pgMetaCryptoKey: 'phase10-pg-meta-crypto-key-0123456789abcdef',
234
+ });
235
+ }
236
+ return {
237
+ ok: false,
238
+ diagnostics: [
239
+ {
240
+ severity: 'error',
241
+ code: 'unexpected-credential',
242
+ message: `Unexpected credential reference: ${name}`,
243
+ },
244
+ ],
245
+ };
246
+ }
247
+
248
+ class FakeHetznerCloudApi implements HetznerCloudApi {
249
+ observation: HetznerCloudObservation = { resources: [] };
250
+ readonly destroyed: HetznerResourceId[] = [];
251
+
252
+ validateAsync(): Promise<InfraResult<null>> {
253
+ return Promise.resolve(success(null));
254
+ }
255
+
256
+ inspectAsync(): Promise<InfraResult<HetznerCloudObservation>> {
257
+ return Promise.resolve(success(this.observation));
258
+ }
259
+
260
+ reconcileAsync(
261
+ _token: string,
262
+ desired: HetznerDesiredCompute,
263
+ ): Promise<InfraResult<HetznerCloudObservation>> {
264
+ this.observation = {
265
+ resources: [desired.network, desired.firewall, desired.sshKey, desired.server].map(
266
+ (resource, index) => ({
267
+ resourceId: resource.owner.identity.resourceId as HetznerResourceId,
268
+ externalId: String(index + 1),
269
+ configurationHash: resource.configurationHash,
270
+ state: 'ready' as const,
271
+ ...(resource.owner.identity.resourceId === 'server'
272
+ ? { publicIpv4: '203.0.113.10', architecture: 'amd64' as const }
273
+ : {}),
274
+ }),
275
+ ),
276
+ };
277
+ return Promise.resolve(success(this.observation));
278
+ }
279
+
280
+ destroyAsync(
281
+ _token: string,
282
+ _identity: HetznerProjectIdentity,
283
+ resourceIds: readonly HetznerResourceId[],
284
+ ): Promise<InfraResult<HetznerCloudObservation>> {
285
+ this.destroyed.push(...resourceIds);
286
+ this.observation = {
287
+ resources: this.observation.resources.filter(
288
+ ({ resourceId }) => !resourceIds.includes(resourceId),
289
+ ),
290
+ };
291
+ return Promise.resolve(success(this.observation));
292
+ }
293
+ }
294
+
295
+ class FakeHostKeyProbe implements HetznerHostKeyProbe {
296
+ fingerprintAsync(): Promise<InfraResult<string>> {
297
+ return Promise.resolve(success('SHA256:phase10-host'));
298
+ }
299
+ }
300
+
301
+ type KubernetesApi = NonNullable<K3sClusterObservation['api']>;
302
+ type KubernetesOwnershipQuery = Parameters<KubernetesApi['listOwnedAsync']>[0];
303
+ type KubernetesResource = Awaited<ReturnType<KubernetesApi['listOwnedAsync']>>[number];
304
+ type KubernetesResourceReference = Parameters<KubernetesApi['deleteAsync']>[0];
305
+ type KubernetesResourceObservation = Awaited<ReturnType<KubernetesApi['observeAsync']>>;
306
+
307
+ class FakeK3sControlPlane implements K3sControlPlane {
308
+ readonly api = new FakeKubernetesApi();
309
+ readonly calls: string[] = [];
310
+ lastAccess: readonly K3sNodeAccess[] = [];
311
+ state: K3sClusterObservation['state'] = 'absent';
312
+ nodes: K3sClusterObservation['nodes'] = [];
313
+
314
+ validateAsync(
315
+ _spec: K3sClusterSpec,
316
+ access: readonly K3sNodeAccess[],
317
+ ): Promise<InfraResult<null>> {
318
+ this.calls.push('validate');
319
+ this.lastAccess = access;
320
+ return Promise.resolve(success(null));
321
+ }
322
+
323
+ inspectAsync(): Promise<InfraResult<K3sClusterObservation>> {
324
+ this.calls.push('inspect');
325
+ return Promise.resolve(success(this.observation()));
326
+ }
327
+
328
+ ensureAsync(
329
+ spec: K3sClusterSpec,
330
+ access: readonly K3sNodeAccess[],
331
+ ): Promise<InfraResult<K3sClusterObservation>> {
332
+ this.calls.push('ensure');
333
+ this.lastAccess = access;
334
+ this.state = 'ready';
335
+ this.nodes = spec.nodes.map(({ id }) => ({
336
+ id,
337
+ state: 'ready',
338
+ configurationMatches: true,
339
+ }));
340
+ return Promise.resolve(success(this.observation()));
341
+ }
342
+
343
+ waitUntilReadyAsync(): Promise<InfraResult<K3sClusterObservation>> {
344
+ this.calls.push('wait');
345
+ return Promise.resolve(success(this.observation()));
346
+ }
347
+
348
+ loadImagesAsync(
349
+ _spec: K3sClusterSpec,
350
+ _access: readonly K3sNodeAccess[],
351
+ images: readonly string[],
352
+ ): Promise<InfraResult<null>> {
353
+ this.calls.push(`images:${images.join(',')}`);
354
+ return Promise.resolve(success(null));
355
+ }
356
+
357
+ suspendAsync(): Promise<InfraResult<null>> {
358
+ this.calls.push('suspend');
359
+ this.state = 'stopped';
360
+ this.nodes = this.nodes.map((node) => ({ ...node, state: 'stopped' }));
361
+ return Promise.resolve(success(null));
362
+ }
363
+
364
+ destroyAsync(): Promise<InfraResult<null>> {
365
+ this.calls.push('destroy');
366
+ this.state = 'absent';
367
+ this.nodes = [];
368
+ return Promise.resolve(success(null));
369
+ }
370
+
371
+ private observation(): K3sClusterObservation {
372
+ return {
373
+ state: this.state,
374
+ configurationMatches: true,
375
+ nodes: this.nodes,
376
+ ...(this.state === 'ready' ? { api: this.api } : {}),
377
+ };
378
+ }
379
+ }
380
+
381
+ class FakeKubernetesApi implements KubernetesApi {
382
+ readonly resources: KubernetesResource[] = [];
383
+
384
+ listOwnedAsync(query: KubernetesOwnershipQuery): Promise<readonly KubernetesResource[]> {
385
+ return Promise.resolve(
386
+ this.resources.filter(({ metadata }) => includesLabels(metadata.labels, query.labels)),
387
+ );
388
+ }
389
+
390
+ applyAsync(resource: KubernetesResource): Promise<void> {
391
+ const index = this.resources.findIndex((candidate) => sameResource(candidate, resource));
392
+ if (index === -1) this.resources.push(resource);
393
+ else this.resources.splice(index, 1, resource);
394
+ return Promise.resolve();
395
+ }
396
+
397
+ deleteAsync(reference: KubernetesResourceReference): Promise<void> {
398
+ const index = this.resources.findIndex((resource) => sameResource(resource, reference));
399
+ if (index >= 0) this.resources.splice(index, 1);
400
+ return Promise.resolve();
401
+ }
402
+
403
+ observeAsync(reference: KubernetesResourceReference): Promise<KubernetesResourceObservation> {
404
+ return Promise.resolve({
405
+ state: 'ready',
406
+ ...(reference.kind === 'Service' ? { publicOutputs: { endpoint: publicBaseUrl } } : {}),
407
+ });
408
+ }
409
+
410
+ waitUntilReadyAsync(): Promise<KubernetesResourceObservation> {
411
+ return Promise.resolve({ state: 'ready' });
412
+ }
413
+
414
+ serializedResources(): string {
415
+ return JSON.stringify(this.resources);
416
+ }
417
+ }
418
+
419
+ class FakeSupabaseControlPlane implements SupabaseControlPlane {
420
+ readonly buckets = new Set<string>();
421
+
422
+ healthAsync(_request: SupabaseControlPlaneRequest): Promise<InfraResult<null>> {
423
+ return Promise.resolve(success(null));
424
+ }
425
+
426
+ listBucketsAsync(_request: SupabaseControlPlaneRequest): Promise<InfraResult<readonly string[]>> {
427
+ return Promise.resolve(success([...this.buckets]));
428
+ }
429
+
430
+ createBucketAsync(
431
+ request: SupabaseControlPlaneRequest & { readonly bucket: string },
432
+ ): Promise<InfraResult<null>> {
433
+ this.buckets.add(request.bucket);
434
+ return Promise.resolve(success(null));
435
+ }
436
+
437
+ deleteBucketAsync(
438
+ request: SupabaseControlPlaneRequest & { readonly bucket: string },
439
+ ): Promise<InfraResult<null>> {
440
+ this.buckets.delete(request.bucket);
441
+ return Promise.resolve(success(null));
442
+ }
443
+ }
444
+
445
+ function resourceIdentities(
446
+ resources: readonly { readonly identity: InfraResourceIdentity }[],
447
+ ): readonly string[] {
448
+ return resources
449
+ .map(({ identity }) =>
450
+ [identity.projectId, identity.environment, identity.adapter, identity.resourceId].join(':'),
451
+ )
452
+ .sort();
453
+ }
454
+
455
+ function includesLabels(
456
+ actual: Readonly<Record<string, string>>,
457
+ expected: Readonly<Record<string, string>>,
458
+ ): boolean {
459
+ return Object.entries(expected).every(([expectedKey, expectedValue]) =>
460
+ Object.entries(actual).some(
461
+ ([actualKey, actualValue]) => actualKey === expectedKey && actualValue === expectedValue,
462
+ ),
463
+ );
464
+ }
465
+
466
+ function sameResource(
467
+ left: KubernetesResource,
468
+ right: KubernetesResource | KubernetesResourceReference,
469
+ ): boolean {
470
+ return (
471
+ left.apiVersion === right.apiVersion &&
472
+ left.kind === right.kind &&
473
+ left.metadata.name === ('metadata' in right ? right.metadata.name : right.name) &&
474
+ left.metadata.namespace === ('metadata' in right ? right.metadata.namespace : right.namespace)
475
+ );
476
+ }
477
+
478
+ function requireSuccess<T>(result: InfraResult<T>): T {
479
+ if (!result.ok) throw new Error(result.diagnostics.map(({ message }) => message).join(' '));
480
+ return result.value;
481
+ }
482
+
483
+ function success<T>(value: T): InfraResult<T> {
484
+ return { ok: true, value, diagnostics: [] };
485
+ }
@@ -0,0 +1,240 @@
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-compose-providers';
20
+ const baseUrl = 'http://127.0.0.1:54322';
21
+ const bucket = 'phase9-acceptance';
22
+ const jwtSecret = 'phase9-jwt-secret-that-is-at-least-thirty-two-characters';
23
+ const credentials = {
24
+ postgresPassword: 'phase9-postgres-password',
25
+ jwtSecret,
26
+ anonKey: createJwt('anon'),
27
+ serviceRoleKey: createJwt('service_role'),
28
+ realtimeSecretKeyBase: 'r'.repeat(64),
29
+ realtimeDatabaseEncryptionKey: '0123456789abcdef',
30
+ pgMetaCryptoKey: 'phase9-meta-crypto-key-that-is-at-least-32-characters',
31
+ } as const;
32
+ const manifest = {
33
+ environments: {
34
+ local: {
35
+ deployment: {
36
+ compute: { provider: 'local' },
37
+ runtime: { provider: 'docker-compose', projectName: projectId },
38
+ },
39
+ database: { provider: 'supabase', tier: 'dev' },
40
+ auth: { provider: 'supabase' },
41
+ objectStorage: { provider: 'supabase', buckets: [bucket] },
42
+ authz: {
43
+ provider: 'cerbos',
44
+ kind: 'ABAC',
45
+ policies: [
46
+ {
47
+ path: 'phase9.yaml',
48
+ content:
49
+ 'apiVersion: api.cerbos.dev/v1\nresourcePolicy:\n version: default\n resource: phase9\n rules: []\n',
50
+ },
51
+ ],
52
+ },
53
+ networking: { publicBaseUrl: baseUrl },
54
+ },
55
+ },
56
+ modules: [],
57
+ } as const satisfies InfraManifest;
58
+
59
+ test.skipIf(process.env.ANKH_INFRA_DOCKER_COMPOSE_PROVIDERS_E2E !== '1')(
60
+ 'runs Supabase and Cerbos through the provider-neutral Docker Compose lifecycle',
61
+ async () => {
62
+ const dependencies = createDependencies();
63
+ let ledger: InfraLedger | undefined;
64
+
65
+ try {
66
+ const initialPlan = requireSuccess(
67
+ await planInfraEnvironmentAsync({ projectId, manifest }, dependencies),
68
+ );
69
+ expect(initialPlan.actions.some(({ operation }) => operation === 'create')).toBe(true);
70
+
71
+ const { ledger: firstLedger, outputs: firstOutputs } = requireSuccess(
72
+ await upInfraEnvironmentAsync({ projectId, manifest }, dependencies),
73
+ );
74
+ ledger = firstLedger;
75
+ assertProviderNeutralOutputs(firstOutputs);
76
+
77
+ const status = requireSuccess(
78
+ await statusInfraEnvironmentAsync({ projectId, manifest, previous: ledger }, dependencies),
79
+ );
80
+ expect(status.state).toBe('ready');
81
+ expect(
82
+ status.resources
83
+ .filter(({ owner }) => ['docker-compose', 'supabase', 'cerbos'].includes(owner.adapter))
84
+ .every(({ state }) => state === 'ready'),
85
+ ).toBe(true);
86
+
87
+ const convergedPlan = requireSuccess(
88
+ await planInfraEnvironmentAsync({ projectId, manifest, previous: ledger }, dependencies),
89
+ );
90
+ expect(convergedPlan.actions.every(({ operation }) => operation === 'noop')).toBe(true);
91
+
92
+ const { ledger: downLedger } = requireSuccess(
93
+ await downInfraEnvironmentAsync({ projectId, manifest, previous: ledger }, dependencies),
94
+ );
95
+ ledger = downLedger;
96
+
97
+ const { ledger: resumedLedger, outputs: resumedOutputs } = requireSuccess(
98
+ await upInfraEnvironmentAsync({ projectId, manifest, previous: ledger }, dependencies),
99
+ );
100
+ ledger = resumedLedger;
101
+ assertProviderNeutralOutputs(resumedOutputs);
102
+
103
+ const health = await fetch(`${baseUrl}/auth/v1/health`);
104
+ expect(health.ok).toBe(true);
105
+
106
+ const destroyed = requireSuccess(
107
+ await destroyInfraEnvironmentAsync(
108
+ createDestroyRequest(ledger, persistentIdentities(ledger)),
109
+ dependencies,
110
+ ),
111
+ );
112
+ ledger = destroyed.ledger ?? undefined;
113
+ expect(destroyed.ledger).toBeNull();
114
+ } finally {
115
+ if (ledger !== undefined) {
116
+ await destroyInfraEnvironmentAsync(
117
+ createDestroyRequest(ledger, persistentIdentities(ledger)),
118
+ dependencies,
119
+ );
120
+ }
121
+ }
122
+ },
123
+ 900_000,
124
+ );
125
+
126
+ function createDependencies(): InfraOrchestrationDependencies {
127
+ return {
128
+ adapterResolver: createNodeInfraAdapterPackageResolver(),
129
+ credentials: {
130
+ resolveAsync: (reference) =>
131
+ Promise.resolve(
132
+ reference.name === 'SUPABASE_BOOTSTRAP'
133
+ ? success(credentials)
134
+ : failure(
135
+ 'unexpected-control-plane-credential',
136
+ `Unexpected control-plane credential ${reference.name}.`,
137
+ ),
138
+ ),
139
+ },
140
+ secrets: {
141
+ resolveAsync: (reference) =>
142
+ Promise.resolve(
143
+ failure(
144
+ 'unexpected-managed-secret',
145
+ `Unexpected managed secret ${reference.ref}/${reference.key}.`,
146
+ ),
147
+ ),
148
+ },
149
+ };
150
+ }
151
+
152
+ function createDestroyRequest(
153
+ previous: InfraLedger,
154
+ confirmedResources: readonly InfraResourceIdentity[],
155
+ ) {
156
+ return {
157
+ projectId,
158
+ manifest,
159
+ environment: 'local' as const,
160
+ previous,
161
+ confirmation: { projectId, environment: 'local' as const },
162
+ persistence: { policy: 'delete' as const, confirmedResources },
163
+ };
164
+ }
165
+
166
+ function persistentIdentities(ledger: InfraLedger): readonly InfraResourceIdentity[] {
167
+ return ledger.resources.filter(({ persistent }) => persistent).map(({ identity }) => identity);
168
+ }
169
+
170
+ function assertProviderNeutralOutputs(outputs: InfraLedger['outputs']): void {
171
+ const serialized = JSON.stringify(outputs);
172
+ expect(serialized).not.toContain(credentials.serviceRoleKey);
173
+ expect(serialized).not.toContain(credentials.postgresPassword);
174
+ expect(serialized).not.toContain(credentials.jwtSecret);
175
+ expect(serialized).not.toContain(credentials.realtimeSecretKeyBase);
176
+ expect(serialized).not.toContain(credentials.realtimeDatabaseEncryptionKey);
177
+ expect(serialized).not.toContain(credentials.pgMetaCryptoKey);
178
+ expect(serialized.toLowerCase()).not.toContain('kubernetes');
179
+ expect(serialized.toLowerCase()).not.toContain('minikube');
180
+ expect(
181
+ outputs.some(
182
+ ({ owner, environmentVariable, value }) =>
183
+ owner.adapter === 'supabase' &&
184
+ environmentVariable === 'EXPO_PUBLIC_SUPABASE_URL' &&
185
+ value === baseUrl,
186
+ ),
187
+ ).toBe(true);
188
+ expect(
189
+ outputs.some(
190
+ ({ owner, environmentVariable, value }) =>
191
+ owner.adapter === 'supabase' &&
192
+ environmentVariable === 'EXPO_PUBLIC_SUPABASE_ANON_KEY' &&
193
+ value === credentials.anonKey,
194
+ ),
195
+ ).toBe(true);
196
+ expect(
197
+ outputs.some(
198
+ ({ owner, name, value }) =>
199
+ owner.adapter === 'supabase' && name === 'bucket' && value === bucket,
200
+ ),
201
+ ).toBe(true);
202
+ expect(
203
+ outputs.some(
204
+ ({ owner, environmentVariable, value }) =>
205
+ owner.adapter === 'cerbos' &&
206
+ environmentVariable === 'CERBOS_URL' &&
207
+ value === 'http://cerbos:3592',
208
+ ),
209
+ ).toBe(true);
210
+ }
211
+
212
+ function createJwt(role: 'anon' | 'service_role'): string {
213
+ const encodedHeader = encodeJwtPart({ alg: 'HS256', typ: 'JWT' });
214
+ const encodedPayload = encodeJwtPart({
215
+ role,
216
+ iss: 'supabase',
217
+ iat: 1_700_000_000,
218
+ exp: 4_102_444_800,
219
+ });
220
+ const unsigned = `${encodedHeader}.${encodedPayload}`;
221
+ const signature = createHmac('sha256', jwtSecret).update(unsigned).digest('base64url');
222
+ return `${unsigned}.${signature}`;
223
+ }
224
+
225
+ function encodeJwtPart(value: Readonly<Record<string, string | number>>): string {
226
+ return Buffer.from(JSON.stringify(value)).toString('base64url');
227
+ }
228
+
229
+ function requireSuccess<T>(result: InfraResult<T>): T {
230
+ if (!result.ok) throw new Error(result.diagnostics.map(({ message }) => message).join(' '));
231
+ return result.value;
232
+ }
233
+
234
+ function success<T>(value: T): InfraResult<T> {
235
+ return { ok: true, value, diagnostics: [] };
236
+ }
237
+
238
+ function failure(code: string, message: string): InfraResult<never> {
239
+ return { ok: false, diagnostics: [{ severity: 'error', code, message }] };
240
+ }