@ankhorage/infra 5.1.4 → 5.1.6

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.6
4
+
5
+ ### Patch Changes
6
+
7
+ - 57da4cf: Prove the released Hetzner, k3s, Supabase and R2 composition with independent object-storage ownership and privileged output isolation.
8
+
9
+ ## 5.1.5
10
+
11
+ ### Patch Changes
12
+
13
+ - 7da18cd: Make project Infra destroy idempotent when no stored ownership state exists, avoiding provider resolution for never-provisioned or already-destroyed environments.
14
+
3
15
  ## 5.1.4
4
16
 
5
17
  ### 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.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)
6
+ ![license: MIT](././paradox/badges/license.svg) ![npm: v5.1.4](././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":"createProjectInfraLifecycle.d.ts","sourceRoot":"","sources":["../../../../src/features/environment-lifecycle/composition/createProjectInfraLifecycle.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,kCAAkC,EAClC,qBAAqB,EAEtB,MAAM,gCAAgC,CAAC;AAGxC,0GAA0G;AAC1G,wBAAgB,2BAA2B,CACzC,OAAO,GAAE,kCAAuC,GAC/C,qBAAqB,CAiFvB"}
1
+ {"version":3,"file":"createProjectInfraLifecycle.d.ts","sourceRoot":"","sources":["../../../../src/features/environment-lifecycle/composition/createProjectInfraLifecycle.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,kCAAkC,EAClC,qBAAqB,EAEtB,MAAM,gCAAgC,CAAC;AAGxC,0GAA0G;AAC1G,wBAAgB,2BAA2B,CACzC,OAAO,GAAE,kCAAuC,GAC/C,qBAAqB,CAyFvB"}
@@ -58,7 +58,15 @@ export function createProjectInfraLifecycle(options = {}) {
58
58
  return result;
59
59
  },
60
60
  async destroyAsync(request) {
61
- const prepared = await prepareProjectOperationAsync(request, services, true);
61
+ const state = await services.readState(request.projectPath, request.environment);
62
+ if (state === null) {
63
+ return {
64
+ ok: true,
65
+ value: { environment: request.environment, ledger: null },
66
+ diagnostics: [],
67
+ };
68
+ }
69
+ const prepared = await prepareProjectOperationAsync(request, services, true, state);
62
70
  const result = await services.operations.destroy({
63
71
  ...prepared.operation,
64
72
  confirmation: request.confirmation,
@@ -81,8 +89,8 @@ export function createProjectInfraLifecycle(options = {}) {
81
89
  };
82
90
  }
83
91
  /*** Resolve one explicit project/environment request with its prior ownership state and dependencies. */
84
- async function prepareProjectOperationAsync(request, services, useStoredDesired = false) {
85
- const state = await services.readState(request.projectPath, request.environment);
92
+ async function prepareProjectOperationAsync(request, services, useStoredDesired = false, knownState) {
93
+ const state = knownState ?? (await services.readState(request.projectPath, request.environment));
86
94
  const currentDesired = readEnvironment(request.manifest, request.environment);
87
95
  const desired = useStoredDesired && state !== null ? state.desired : currentDesired;
88
96
  if (desired === undefined) {
@@ -1 +1 @@
1
- {"version":3,"file":"createProjectInfraLifecycle.js","sourceRoot":"","sources":["../../../../src/features/environment-lifecycle/composition/createProjectInfraLifecycle.ts"],"names":[],"mappings":"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","sourcesContent":["import type { InfraEnvironmentSpec, InfraManifest } from '@ankhorage/contracts/infra';\n\nimport { INFRA_PACKAGE_VERSION } from '../../../constants.js';\nimport type { InfraOperationRequest, InfraStoredState } from '../../../types/infraOrchestration.js';\nimport type {\n CreateProjectInfraLifecycleOptions,\n ProjectInfraLifecycle,\n ProjectInfraOperationRequest,\n} from '../../../types/infraProject.js';\nimport { createInfraCommandServices } from './createInfraCommandServices.js';\n\n/*** Create the typed project-facing Infra lifecycle backed by canonical use cases and ownership state. */\nexport function createProjectInfraLifecycle(\n options: CreateProjectInfraLifecycleOptions = {},\n): ProjectInfraLifecycle {\n const services = createInfraCommandServices(options.services);\n return {\n async validateAsync(request) {\n const prepared = await prepareProjectOperationAsync(request, services);\n return services.operations.validate(prepared.operation, prepared.dependencies);\n },\n async planAsync(request) {\n const prepared = await prepareProjectOperationAsync(request, services);\n return services.operations.plan(prepared.operation, prepared.dependencies);\n },\n async generateAsync(request) {\n const prepared = await prepareProjectOperationAsync(request, services);\n const result = await services.operations.generate(prepared.operation, prepared.dependencies);\n if (!result.ok) return result;\n await services.writeArtifacts(\n request.projectPath,\n result.value.artifacts,\n prepared.state?.ledger,\n );\n await services.writeState(request.projectPath, {\n schemaVersion: 1,\n desired: prepared.desired,\n ledger: result.value.ledger,\n });\n return result;\n },\n async upAsync(request) {\n const prepared = await prepareProjectOperationAsync(request, services);\n const result = await services.operations.up(prepared.operation, prepared.dependencies);\n if (!result.ok) return result;\n await services.writeState(request.projectPath, {\n schemaVersion: 1,\n desired: prepared.desired,\n ledger: result.value.ledger,\n });\n return result;\n },\n async statusAsync(request) {\n const prepared = await prepareProjectOperationAsync(request, services);\n return services.operations.status(prepared.operation, prepared.dependencies);\n },\n async outputsAsync(request) {\n const prepared = await prepareProjectOperationAsync(request, services);\n return services.operations.outputs(prepared.operation);\n },\n async downAsync(request) {\n const prepared = await prepareProjectOperationAsync(request, services);\n const result = await services.operations.down(prepared.operation, prepared.dependencies);\n if (!result.ok) return result;\n await services.writeState(request.projectPath, {\n schemaVersion: 1,\n desired: prepared.desired,\n ledger: result.value.ledger,\n });\n return result;\n },\n async destroyAsync(request) {\n const prepared = await prepareProjectOperationAsync(request, services, true);\n const result = await services.operations.destroy(\n {\n ...prepared.operation,\n confirmation: request.confirmation,\n persistence: request.persistence,\n },\n prepared.dependencies,\n );\n if (!result.ok) return result;\n await services.writeArtifacts(request.projectPath, [], prepared.state?.ledger);\n if (result.value.ledger === null)\n await services.removeState(request.projectPath, request.environment);\n else {\n await services.writeState(request.projectPath, {\n schemaVersion: 1,\n desired: prepared.desired,\n ledger: result.value.ledger,\n });\n }\n return result;\n },\n };\n}\n\ninterface PreparedProjectOperation {\n readonly operation: InfraOperationRequest;\n readonly desired: InfraEnvironmentSpec;\n readonly state: InfraStoredState | null;\n readonly dependencies: ReturnType<\n ReturnType<typeof createInfraCommandServices>['createDependencies']\n >;\n}\n\n/*** Resolve one explicit project/environment request with its prior ownership state and dependencies. */\nasync function prepareProjectOperationAsync(\n request: ProjectInfraOperationRequest,\n services: ReturnType<typeof createInfraCommandServices>,\n useStoredDesired = false,\n): Promise<PreparedProjectOperation> {\n const state = await services.readState(request.projectPath, request.environment);\n const currentDesired = readEnvironment(request.manifest, request.environment);\n const desired = useStoredDesired && state !== null ? state.desired : currentDesired;\n if (desired === undefined) {\n throw new Error(`Infra environment \"${request.environment}\" is not configured.`);\n }\n const context = {\n cwd: request.projectPath,\n env: request.executionEnvironment ?? process.env,\n version: INFRA_PACKAGE_VERSION,\n writeStdout: () => undefined,\n writeStderr: () => undefined,\n };\n return {\n state,\n desired,\n operation: {\n projectId: request.projectId,\n manifest: replaceEnvironment(request.manifest, request.environment, desired),\n environment: request.environment,\n ...(state === null ? {} : { previous: state.ledger, previousDesired: state.desired }),\n ...(request.signal === undefined ? {} : { signal: request.signal }),\n },\n dependencies: services.createDependencies(context),\n };\n}\n\n/*** Read one exact environment without inventing a fallback. */\nfunction readEnvironment(\n manifest: InfraManifest,\n environment: ProjectInfraOperationRequest['environment'],\n): InfraEnvironmentSpec | undefined {\n if (environment === 'local') return manifest.environments.local;\n if (environment === 'preview') return manifest.environments.preview;\n return manifest.environments.production;\n}\n\n/*** Replace one selected environment while preserving all other manifest state. */\nfunction replaceEnvironment(\n manifest: InfraManifest,\n environment: ProjectInfraOperationRequest['environment'],\n desired: InfraEnvironmentSpec,\n): InfraManifest {\n return { ...manifest, environments: { ...manifest.environments, [environment]: desired } };\n}\n"]}
1
+ {"version":3,"file":"createProjectInfraLifecycle.js","sourceRoot":"","sources":["../../../../src/features/environment-lifecycle/composition/createProjectInfraLifecycle.ts"],"names":[],"mappings":"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","sourcesContent":["import type { InfraEnvironmentSpec, InfraManifest } from '@ankhorage/contracts/infra';\n\nimport { INFRA_PACKAGE_VERSION } from '../../../constants.js';\nimport type { InfraOperationRequest, InfraStoredState } from '../../../types/infraOrchestration.js';\nimport type {\n CreateProjectInfraLifecycleOptions,\n ProjectInfraLifecycle,\n ProjectInfraOperationRequest,\n} from '../../../types/infraProject.js';\nimport { createInfraCommandServices } from './createInfraCommandServices.js';\n\n/*** Create the typed project-facing Infra lifecycle backed by canonical use cases and ownership state. */\nexport function createProjectInfraLifecycle(\n options: CreateProjectInfraLifecycleOptions = {},\n): ProjectInfraLifecycle {\n const services = createInfraCommandServices(options.services);\n return {\n async validateAsync(request) {\n const prepared = await prepareProjectOperationAsync(request, services);\n return services.operations.validate(prepared.operation, prepared.dependencies);\n },\n async planAsync(request) {\n const prepared = await prepareProjectOperationAsync(request, services);\n return services.operations.plan(prepared.operation, prepared.dependencies);\n },\n async generateAsync(request) {\n const prepared = await prepareProjectOperationAsync(request, services);\n const result = await services.operations.generate(prepared.operation, prepared.dependencies);\n if (!result.ok) return result;\n await services.writeArtifacts(\n request.projectPath,\n result.value.artifacts,\n prepared.state?.ledger,\n );\n await services.writeState(request.projectPath, {\n schemaVersion: 1,\n desired: prepared.desired,\n ledger: result.value.ledger,\n });\n return result;\n },\n async upAsync(request) {\n const prepared = await prepareProjectOperationAsync(request, services);\n const result = await services.operations.up(prepared.operation, prepared.dependencies);\n if (!result.ok) return result;\n await services.writeState(request.projectPath, {\n schemaVersion: 1,\n desired: prepared.desired,\n ledger: result.value.ledger,\n });\n return result;\n },\n async statusAsync(request) {\n const prepared = await prepareProjectOperationAsync(request, services);\n return services.operations.status(prepared.operation, prepared.dependencies);\n },\n async outputsAsync(request) {\n const prepared = await prepareProjectOperationAsync(request, services);\n return services.operations.outputs(prepared.operation);\n },\n async downAsync(request) {\n const prepared = await prepareProjectOperationAsync(request, services);\n const result = await services.operations.down(prepared.operation, prepared.dependencies);\n if (!result.ok) return result;\n await services.writeState(request.projectPath, {\n schemaVersion: 1,\n desired: prepared.desired,\n ledger: result.value.ledger,\n });\n return result;\n },\n async destroyAsync(request) {\n const state = await services.readState(request.projectPath, request.environment);\n if (state === null) {\n return {\n ok: true,\n value: { environment: request.environment, ledger: null },\n diagnostics: [],\n };\n }\n const prepared = await prepareProjectOperationAsync(request, services, true, state);\n const result = await services.operations.destroy(\n {\n ...prepared.operation,\n confirmation: request.confirmation,\n persistence: request.persistence,\n },\n prepared.dependencies,\n );\n if (!result.ok) return result;\n await services.writeArtifacts(request.projectPath, [], prepared.state?.ledger);\n if (result.value.ledger === null)\n await services.removeState(request.projectPath, request.environment);\n else {\n await services.writeState(request.projectPath, {\n schemaVersion: 1,\n desired: prepared.desired,\n ledger: result.value.ledger,\n });\n }\n return result;\n },\n };\n}\n\ninterface PreparedProjectOperation {\n readonly operation: InfraOperationRequest;\n readonly desired: InfraEnvironmentSpec;\n readonly state: InfraStoredState | null;\n readonly dependencies: ReturnType<\n ReturnType<typeof createInfraCommandServices>['createDependencies']\n >;\n}\n\n/*** Resolve one explicit project/environment request with its prior ownership state and dependencies. */\nasync function prepareProjectOperationAsync(\n request: ProjectInfraOperationRequest,\n services: ReturnType<typeof createInfraCommandServices>,\n useStoredDesired = false,\n knownState?: InfraStoredState,\n): Promise<PreparedProjectOperation> {\n const state = knownState ?? (await services.readState(request.projectPath, request.environment));\n const currentDesired = readEnvironment(request.manifest, request.environment);\n const desired = useStoredDesired && state !== null ? state.desired : currentDesired;\n if (desired === undefined) {\n throw new Error(`Infra environment \"${request.environment}\" is not configured.`);\n }\n const context = {\n cwd: request.projectPath,\n env: request.executionEnvironment ?? process.env,\n version: INFRA_PACKAGE_VERSION,\n writeStdout: () => undefined,\n writeStderr: () => undefined,\n };\n return {\n state,\n desired,\n operation: {\n projectId: request.projectId,\n manifest: replaceEnvironment(request.manifest, request.environment, desired),\n environment: request.environment,\n ...(state === null ? {} : { previous: state.ledger, previousDesired: state.desired }),\n ...(request.signal === undefined ? {} : { signal: request.signal }),\n },\n dependencies: services.createDependencies(context),\n };\n}\n\n/*** Read one exact environment without inventing a fallback. */\nfunction readEnvironment(\n manifest: InfraManifest,\n environment: ProjectInfraOperationRequest['environment'],\n): InfraEnvironmentSpec | undefined {\n if (environment === 'local') return manifest.environments.local;\n if (environment === 'preview') return manifest.environments.preview;\n return manifest.environments.production;\n}\n\n/*** Replace one selected environment while preserving all other manifest state. */\nfunction replaceEnvironment(\n manifest: InfraManifest,\n environment: ProjectInfraOperationRequest['environment'],\n desired: InfraEnvironmentSpec,\n): InfraManifest {\n return { ...manifest, environments: { ...manifest.environments, [environment]: desired } };\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.4",
4
+ "version": "5.1.6",
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.3.0"
38
+ "@ankhorage/contracts": "^19.4.0"
39
39
  },
40
40
  "files": [
41
41
  "dist",
@@ -114,12 +114,12 @@
114
114
  "@ankhorage/devtools": "^1.14.0",
115
115
  "@ankhorage/docker-compose": "^0.5.3",
116
116
  "@ankhorage/hetzner": "^0.3.0",
117
- "@ankhorage/k3s": "^0.4.0",
117
+ "@ankhorage/k3s": "^0.4.1",
118
118
  "@ankhorage/local": "^0.3.0",
119
- "@ankhorage/minikube": "^0.4.1",
119
+ "@ankhorage/minikube": "^0.4.2",
120
120
  "@ankhorage/paradox": "^0.1.24",
121
121
  "@ankhorage/r2": "^0.2.0",
122
- "@ankhorage/supabase": "^0.4.7",
122
+ "@ankhorage/supabase": "^0.5.5",
123
123
  "@ankhorage/supabase-vault": "^0.3.1",
124
124
  "@types/bun": "^1.4.1",
125
125
  "@types/node": "^25.6.0",
@@ -70,7 +70,15 @@ export function createProjectInfraLifecycle(
70
70
  return result;
71
71
  },
72
72
  async destroyAsync(request) {
73
- const prepared = await prepareProjectOperationAsync(request, services, true);
73
+ const state = await services.readState(request.projectPath, request.environment);
74
+ if (state === null) {
75
+ return {
76
+ ok: true,
77
+ value: { environment: request.environment, ledger: null },
78
+ diagnostics: [],
79
+ };
80
+ }
81
+ const prepared = await prepareProjectOperationAsync(request, services, true, state);
74
82
  const result = await services.operations.destroy(
75
83
  {
76
84
  ...prepared.operation,
@@ -109,8 +117,9 @@ async function prepareProjectOperationAsync(
109
117
  request: ProjectInfraOperationRequest,
110
118
  services: ReturnType<typeof createInfraCommandServices>,
111
119
  useStoredDesired = false,
120
+ knownState?: InfraStoredState,
112
121
  ): Promise<PreparedProjectOperation> {
113
- const state = await services.readState(request.projectPath, request.environment);
122
+ const state = knownState ?? (await services.readState(request.projectPath, request.environment));
114
123
  const currentDesired = readEnvironment(request.manifest, request.environment);
115
124
  const desired = useStoredDesired && state !== null ? state.desired : currentDesired;
116
125
  if (desired === undefined) {
@@ -108,6 +108,8 @@ test('composes Hetzner, k3s, Kubernetes and Supabase while retaining compute for
108
108
  expect(JSON.stringify(firstUp)).not.toContain('phase10-service-role-key');
109
109
  expect(fixture.kubernetes.serializedResources()).toContain(`"host":"${domain}"`);
110
110
  expect(fixture.kubernetes.serializedResources()).toContain('PersistentVolumeClaim');
111
+ expect(fixture.kubernetes.serializedResources()).toContain('/etc/postgresql-custom');
112
+ expect(fixture.kubernetes.serializedResources()).toContain('.ankhorage-image-seeded');
111
113
  expect(firstUp.resources.some(({ persistent }) => persistent)).toBe(true);
112
114
  const urlOutput = firstUp.outputs.find(({ name }) => name === 'url');
113
115
  expect(urlOutput?.value).toBe(publicBaseUrl);
@@ -0,0 +1,482 @@
1
+ import type { InfraManifest, InfraResult } from '@ankhorage/contracts/infra';
2
+ import {
3
+ createInfraAdapter as createHetznerInfraAdapter,
4
+ type HetznerCloudApi,
5
+ type HetznerCloudObservation,
6
+ type HetznerDesiredCompute,
7
+ type HetznerHostKeyProbe,
8
+ type HetznerProjectIdentity,
9
+ type HetznerResourceId,
10
+ infraAdapterDescriptor as hetznerInfraAdapterDescriptor,
11
+ } from '@ankhorage/hetzner';
12
+ import {
13
+ createInfraAdapter as createK3sInfraAdapter,
14
+ infraAdapterDescriptor as k3sInfraAdapterDescriptor,
15
+ type K3sClusterObservation,
16
+ type K3sClusterSpec,
17
+ type K3sControlPlane,
18
+ type K3sNodeAccess,
19
+ } from '@ankhorage/k3s';
20
+ import {
21
+ createInfraAdapter as createR2InfraAdapter,
22
+ infraAdapterDescriptor as r2InfraAdapterDescriptor,
23
+ type R2BucketControlPlane,
24
+ type R2BucketObservation,
25
+ type R2CloudRequest,
26
+ } from '@ankhorage/r2';
27
+ import {
28
+ createInfraAdapter as createSupabaseInfraAdapter,
29
+ infraAdapterDescriptor as supabaseInfraAdapterDescriptor,
30
+ type SupabaseControlPlane,
31
+ type SupabaseControlPlaneRequest,
32
+ } from '@ankhorage/supabase';
33
+ import { expect, test } from 'bun:test';
34
+
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 = 'infra163-production-r2';
41
+ const publicBaseUrl = 'https://api.r2-production.example.test';
42
+ const domain = 'api.r2-production.example.test';
43
+ const r2AccountId = 'phase10-r2-account';
44
+ const r2Bucket = 'media';
45
+
46
+ const manifest = {
47
+ environments: {
48
+ production: {
49
+ deployment: {
50
+ compute: { provider: 'hetzner', location: 'fsn1', serverType: 'cx23' },
51
+ runtime: { provider: 'k3s', topology: { servers: 1, agents: 0 } },
52
+ },
53
+ database: { provider: 'supabase', tier: 'prod' },
54
+ auth: { provider: 'supabase' },
55
+ objectStorage: { provider: 'r2', accountId: r2AccountId, buckets: [r2Bucket] },
56
+ networking: {
57
+ domain,
58
+ publicBaseUrl,
59
+ tls: { mode: 'acme-http-01', contactEmail: 'infra@example.test' },
60
+ },
61
+ },
62
+ },
63
+ modules: [],
64
+ } as const satisfies InfraManifest;
65
+
66
+ test('composes Hetzner, k3s, Supabase database/auth and independent R2 object storage', async () => {
67
+ const fixture = new ProductionR2Fixture();
68
+ const dependencies = createDependencies(fixture);
69
+
70
+ const initialPlan = requireSuccess(
71
+ await planInfraEnvironmentAsync(
72
+ { projectId, manifest, environment: 'production' },
73
+ dependencies,
74
+ ),
75
+ );
76
+ const initialOwners = new Set(initialPlan.actions.map(({ owner }) => owner.adapter));
77
+ expect(initialOwners.has('supabase')).toBe(true);
78
+ expect(initialOwners.has('r2')).toBe(true);
79
+ expect(initialPlan.actions.some(({ operation }) => operation === 'create')).toBe(true);
80
+
81
+ const firstUp = requireSuccess(
82
+ await upInfraEnvironmentAsync({ projectId, manifest, environment: 'production' }, dependencies),
83
+ );
84
+ const serializedWorkloads = fixture.kubernetes.serializedResources();
85
+ expect(serializedWorkloads).toContain('supabase-db');
86
+ expect(serializedWorkloads).toContain('supabase-auth');
87
+ expect(serializedWorkloads).toContain('supabase-gateway');
88
+ expect(serializedWorkloads).not.toContain('supabase-storage');
89
+ expect(serializedWorkloads).not.toContain('supabase-imgproxy');
90
+ expect(serializedWorkloads).not.toContain('/storage/v1/');
91
+ expect(fixture.supabase.created).toEqual([]);
92
+ expect(fixture.r2.created).toEqual([r2Bucket]);
93
+
94
+ expect(
95
+ firstUp.resources.some(
96
+ ({ identity }) => identity.adapter === 'r2' && identity.resourceId === `bucket/${r2Bucket}`,
97
+ ),
98
+ ).toBe(true);
99
+ expect(
100
+ firstUp.resources.some(
101
+ ({ identity }) =>
102
+ identity.adapter === 'supabase' && identity.resourceId.startsWith('bucket/'),
103
+ ),
104
+ ).toBe(false);
105
+
106
+ const r2Outputs = firstUp.outputs.filter(({ owner }) => owner.adapter === 'r2');
107
+ expect(r2Outputs.map(({ name }) => name)).toEqual(['bucket', 'accountId', 's3Endpoint']);
108
+ expect(r2Outputs.every(({ visibility }) => visibility === 'public')).toBe(true);
109
+ expect(r2Outputs.map(({ value }) => value)).toEqual([
110
+ r2Bucket,
111
+ r2AccountId,
112
+ `https://${r2AccountId}.r2.cloudflarestorage.com`,
113
+ ]);
114
+
115
+ const serializedOutputs = JSON.stringify(firstUp.outputs);
116
+ expect(serializedOutputs).not.toContain('phase10-r2-api-token');
117
+ expect(serializedOutputs).not.toContain('phase10-private-key');
118
+ expect(serializedOutputs).not.toContain('phase10-postgres-password');
119
+ expect(serializedOutputs).not.toContain('phase10-jwt-secret');
120
+ expect(serializedOutputs).not.toContain('phase10-service-role-key');
121
+
122
+ const status = requireSuccess(
123
+ await statusInfraEnvironmentAsync(
124
+ { projectId, manifest, environment: 'production', previous: firstUp.ledger },
125
+ dependencies,
126
+ ),
127
+ );
128
+ expect(status.state).toBe('ready');
129
+
130
+ const converged = requireSuccess(
131
+ await planInfraEnvironmentAsync(
132
+ { projectId, manifest, environment: 'production', previous: firstUp.ledger },
133
+ dependencies,
134
+ ),
135
+ );
136
+ expect(converged.actions.every(({ operation }) => operation === 'noop')).toBe(true);
137
+
138
+ const secondUp = requireSuccess(
139
+ await upInfraEnvironmentAsync(
140
+ { projectId, manifest, environment: 'production', previous: firstUp.ledger },
141
+ dependencies,
142
+ ),
143
+ );
144
+ expect(fixture.r2.created).toEqual([r2Bucket]);
145
+ expect(secondUp.outputs.filter(({ owner }) => owner.adapter === 'r2')).toEqual(r2Outputs);
146
+ });
147
+
148
+ class ProductionR2Fixture {
149
+ readonly hetzner = new FakeHetznerCloudApi();
150
+ readonly hostKeyProbe = new FakeHostKeyProbe();
151
+ readonly k3s = new FakeK3sControlPlane();
152
+ readonly kubernetes = this.k3s.api;
153
+ readonly supabase = new FakeSupabaseControlPlane();
154
+ readonly r2 = new FakeR2ControlPlane();
155
+ }
156
+
157
+ function createDependencies(fixture: ProductionR2Fixture): InfraOrchestrationDependencies {
158
+ return {
159
+ adapterResolver: {
160
+ loadAsync: (packageName) => {
161
+ switch (packageName) {
162
+ case hetznerInfraAdapterDescriptor.package:
163
+ return Promise.resolve({
164
+ infraAdapterDescriptor: hetznerInfraAdapterDescriptor,
165
+ createInfraAdapter: () =>
166
+ createHetznerInfraAdapter({
167
+ api: fixture.hetzner,
168
+ hostKeyProbe: fixture.hostKeyProbe,
169
+ }),
170
+ });
171
+ case k3sInfraAdapterDescriptor.package:
172
+ return Promise.resolve({
173
+ infraAdapterDescriptor: k3sInfraAdapterDescriptor,
174
+ createInfraAdapter: () => createK3sInfraAdapter({ controlPlane: fixture.k3s }),
175
+ });
176
+ case supabaseInfraAdapterDescriptor.package:
177
+ return Promise.resolve({
178
+ infraAdapterDescriptor: supabaseInfraAdapterDescriptor,
179
+ createInfraAdapter: () =>
180
+ createSupabaseInfraAdapter({ controlPlane: fixture.supabase }),
181
+ });
182
+ case r2InfraAdapterDescriptor.package:
183
+ return Promise.resolve({
184
+ infraAdapterDescriptor: r2InfraAdapterDescriptor,
185
+ createInfraAdapter: () => createR2InfraAdapter({ cloud: fixture.r2 }),
186
+ });
187
+ default:
188
+ return Promise.reject(new Error(`Unexpected adapter package: ${packageName}`));
189
+ }
190
+ },
191
+ },
192
+ credentials: {
193
+ resolveAsync: ({ name }) => Promise.resolve(resolveCredential(name)),
194
+ },
195
+ secrets: {
196
+ resolveAsync: () =>
197
+ Promise.resolve({
198
+ ok: false,
199
+ diagnostics: [
200
+ { severity: 'error', code: 'unexpected-secret', message: 'No secret is expected.' },
201
+ ],
202
+ }),
203
+ },
204
+ };
205
+ }
206
+
207
+ function resolveCredential(name: string): InfraResult<Readonly<Record<string, string>>> {
208
+ if (name === 'HCLOUD_TOKEN') return success({ token: 'phase10-hcloud-token' });
209
+ if (name === 'HETZNER_SSH') {
210
+ return success({
211
+ publicKey: 'ssh-ed25519 AAAA phase10',
212
+ privateKey: 'phase10-private-key',
213
+ });
214
+ }
215
+ if (name === 'SUPABASE_BOOTSTRAP') {
216
+ return success({
217
+ postgresPassword: 'phase10-postgres-password',
218
+ jwtSecret: 'phase10-jwt-secret-0123456789abcdef',
219
+ anonKey: 'phase10-anon-key',
220
+ serviceRoleKey: 'phase10-service-role-key',
221
+ realtimeSecretKeyBase:
222
+ 'phase10-realtime-secret-key-base-0123456789abcdefghijklmnopqrstuvwxyz',
223
+ realtimeDatabaseEncryptionKey: '1234567890abcdef',
224
+ pgMetaCryptoKey: 'phase10-pg-meta-crypto-key-0123456789abcdef',
225
+ });
226
+ }
227
+ if (name === 'CLOUDFLARE_R2') return success({ apiToken: 'phase10-r2-api-token' });
228
+ return {
229
+ ok: false,
230
+ diagnostics: [
231
+ {
232
+ severity: 'error',
233
+ code: 'unexpected-credential',
234
+ message: `Unexpected credential reference: ${name}`,
235
+ },
236
+ ],
237
+ };
238
+ }
239
+
240
+ class FakeHetznerCloudApi implements HetznerCloudApi {
241
+ observation: HetznerCloudObservation = { resources: [] };
242
+
243
+ validateAsync(): Promise<InfraResult<null>> {
244
+ return Promise.resolve(success(null));
245
+ }
246
+
247
+ inspectAsync(): Promise<InfraResult<HetznerCloudObservation>> {
248
+ return Promise.resolve(success(this.observation));
249
+ }
250
+
251
+ reconcileAsync(
252
+ _token: string,
253
+ desired: HetznerDesiredCompute,
254
+ ): Promise<InfraResult<HetznerCloudObservation>> {
255
+ this.observation = {
256
+ resources: [desired.network, desired.firewall, desired.sshKey, desired.server].map(
257
+ (resource, index) => ({
258
+ resourceId: resource.owner.identity.resourceId as HetznerResourceId,
259
+ externalId: String(index + 1),
260
+ configurationHash: resource.configurationHash,
261
+ state: 'ready' as const,
262
+ ...(resource.owner.identity.resourceId === 'server'
263
+ ? { publicIpv4: '203.0.113.10', architecture: 'amd64' as const }
264
+ : {}),
265
+ }),
266
+ ),
267
+ };
268
+ return Promise.resolve(success(this.observation));
269
+ }
270
+
271
+ destroyAsync(
272
+ _token: string,
273
+ _identity: HetznerProjectIdentity,
274
+ resourceIds: readonly HetznerResourceId[],
275
+ ): Promise<InfraResult<HetznerCloudObservation>> {
276
+ this.observation = {
277
+ resources: this.observation.resources.filter(
278
+ ({ resourceId }) => !resourceIds.includes(resourceId),
279
+ ),
280
+ };
281
+ return Promise.resolve(success(this.observation));
282
+ }
283
+ }
284
+
285
+ class FakeHostKeyProbe implements HetznerHostKeyProbe {
286
+ fingerprintAsync(): Promise<InfraResult<string>> {
287
+ return Promise.resolve(success('SHA256:phase10-host'));
288
+ }
289
+ }
290
+
291
+ type KubernetesApi = NonNullable<K3sClusterObservation['api']>;
292
+ type KubernetesOwnershipQuery = Parameters<KubernetesApi['listOwnedAsync']>[0];
293
+ type KubernetesResource = Awaited<ReturnType<KubernetesApi['listOwnedAsync']>>[number];
294
+ type KubernetesResourceReference = Parameters<KubernetesApi['deleteAsync']>[0];
295
+ type KubernetesResourceObservation = Awaited<ReturnType<KubernetesApi['observeAsync']>>;
296
+
297
+ class FakeK3sControlPlane implements K3sControlPlane {
298
+ readonly api = new FakeKubernetesApi();
299
+ lastAccess: readonly K3sNodeAccess[] = [];
300
+ lastSpec?: K3sClusterSpec;
301
+ state: K3sClusterObservation['state'] = 'absent';
302
+ nodes: K3sClusterObservation['nodes'] = [];
303
+
304
+ validateAsync(
305
+ _spec: K3sClusterSpec,
306
+ access: readonly K3sNodeAccess[],
307
+ ): Promise<InfraResult<null>> {
308
+ this.lastAccess = access;
309
+ return Promise.resolve(success(null));
310
+ }
311
+
312
+ inspectAsync(): Promise<InfraResult<K3sClusterObservation>> {
313
+ return Promise.resolve(success(this.observation()));
314
+ }
315
+
316
+ ensureAsync(
317
+ spec: K3sClusterSpec,
318
+ access: readonly K3sNodeAccess[],
319
+ ): Promise<InfraResult<K3sClusterObservation>> {
320
+ this.lastAccess = access;
321
+ this.lastSpec = spec;
322
+ this.state = 'ready';
323
+ this.nodes = spec.nodes.map(({ id }) => ({
324
+ id,
325
+ state: 'ready',
326
+ configurationMatches: true,
327
+ }));
328
+ return Promise.resolve(success(this.observation()));
329
+ }
330
+
331
+ waitUntilReadyAsync(): Promise<InfraResult<K3sClusterObservation>> {
332
+ return Promise.resolve(success(this.observation()));
333
+ }
334
+
335
+ loadImagesAsync(
336
+ _spec: K3sClusterSpec,
337
+ _access: readonly K3sNodeAccess[],
338
+ _images: readonly string[],
339
+ ): Promise<InfraResult<null>> {
340
+ return Promise.resolve(success(null));
341
+ }
342
+
343
+ suspendAsync(): Promise<InfraResult<null>> {
344
+ this.state = 'stopped';
345
+ this.nodes = this.nodes.map((node) => ({ ...node, state: 'stopped' }));
346
+ return Promise.resolve(success(null));
347
+ }
348
+
349
+ destroyAsync(): Promise<InfraResult<null>> {
350
+ this.state = 'absent';
351
+ this.nodes = [];
352
+ return Promise.resolve(success(null));
353
+ }
354
+
355
+ private observation(): K3sClusterObservation {
356
+ return {
357
+ state: this.state,
358
+ configurationMatches: true,
359
+ nodes: this.nodes,
360
+ ...(this.state === 'ready' ? { api: this.api } : {}),
361
+ };
362
+ }
363
+ }
364
+
365
+ class FakeKubernetesApi implements KubernetesApi {
366
+ readonly resources: KubernetesResource[] = [];
367
+
368
+ listOwnedAsync(query: KubernetesOwnershipQuery): Promise<readonly KubernetesResource[]> {
369
+ return Promise.resolve(
370
+ this.resources.filter(({ metadata }) => includesLabels(metadata.labels, query.labels)),
371
+ );
372
+ }
373
+
374
+ applyAsync(resource: KubernetesResource): Promise<void> {
375
+ const index = this.resources.findIndex((candidate) => sameResource(candidate, resource));
376
+ if (index === -1) this.resources.push(resource);
377
+ else this.resources.splice(index, 1, resource);
378
+ return Promise.resolve();
379
+ }
380
+
381
+ deleteAsync(reference: KubernetesResourceReference): Promise<void> {
382
+ const index = this.resources.findIndex((resource) => sameResource(resource, reference));
383
+ if (index >= 0) this.resources.splice(index, 1);
384
+ return Promise.resolve();
385
+ }
386
+
387
+ observeAsync(reference: KubernetesResourceReference): Promise<KubernetesResourceObservation> {
388
+ return Promise.resolve({
389
+ state: 'ready',
390
+ ...(reference.kind === 'Service' ? { publicOutputs: { endpoint: publicBaseUrl } } : {}),
391
+ });
392
+ }
393
+
394
+ waitUntilReadyAsync(): Promise<KubernetesResourceObservation> {
395
+ return Promise.resolve({ state: 'ready' });
396
+ }
397
+
398
+ serializedResources(): string {
399
+ return JSON.stringify(this.resources);
400
+ }
401
+ }
402
+
403
+ class FakeSupabaseControlPlane implements SupabaseControlPlane {
404
+ readonly created: string[] = [];
405
+
406
+ healthAsync(_request: SupabaseControlPlaneRequest): Promise<InfraResult<null>> {
407
+ return Promise.resolve(success(null));
408
+ }
409
+
410
+ listBucketsAsync(_request: SupabaseControlPlaneRequest): Promise<InfraResult<readonly string[]>> {
411
+ return Promise.resolve(success([]));
412
+ }
413
+
414
+ createBucketAsync(
415
+ request: SupabaseControlPlaneRequest & { readonly bucket: string },
416
+ ): Promise<InfraResult<null>> {
417
+ this.created.push(request.bucket);
418
+ return Promise.resolve(success(null));
419
+ }
420
+
421
+ deleteBucketAsync(
422
+ _request: SupabaseControlPlaneRequest & { readonly bucket: string },
423
+ ): Promise<InfraResult<null>> {
424
+ return Promise.resolve(success(null));
425
+ }
426
+ }
427
+
428
+ class FakeR2ControlPlane implements R2BucketControlPlane {
429
+ readonly created: string[] = [];
430
+ private readonly buckets = new Set<string>();
431
+
432
+ listBucketsAsync(): Promise<InfraResult<readonly R2BucketObservation[]>> {
433
+ return Promise.resolve(success([...this.buckets].map((name) => ({ name }))));
434
+ }
435
+
436
+ createBucketAsync(
437
+ request: R2CloudRequest & { readonly bucket: string },
438
+ ): Promise<InfraResult<R2BucketObservation>> {
439
+ this.created.push(request.bucket);
440
+ this.buckets.add(request.bucket);
441
+ return Promise.resolve(success({ name: request.bucket }));
442
+ }
443
+
444
+ deleteBucketAsync(
445
+ request: R2CloudRequest & { readonly bucket: string },
446
+ ): Promise<InfraResult<null>> {
447
+ this.buckets.delete(request.bucket);
448
+ return Promise.resolve(success(null));
449
+ }
450
+ }
451
+
452
+ function includesLabels(
453
+ actual: Readonly<Record<string, string>>,
454
+ expected: Readonly<Record<string, string>>,
455
+ ): boolean {
456
+ return Object.entries(expected).every(([expectedKey, expectedValue]) =>
457
+ Object.entries(actual).some(
458
+ ([actualKey, actualValue]) => actualKey === expectedKey && actualValue === expectedValue,
459
+ ),
460
+ );
461
+ }
462
+
463
+ function sameResource(
464
+ left: KubernetesResource,
465
+ right: KubernetesResource | KubernetesResourceReference,
466
+ ): boolean {
467
+ return (
468
+ left.apiVersion === right.apiVersion &&
469
+ left.kind === right.kind &&
470
+ left.metadata.name === ('metadata' in right ? right.metadata.name : right.name) &&
471
+ left.metadata.namespace === ('metadata' in right ? right.metadata.namespace : right.namespace)
472
+ );
473
+ }
474
+
475
+ function requireSuccess<T>(result: InfraResult<T>): T {
476
+ if (!result.ok) throw new Error(result.diagnostics.map(({ message }) => message).join(' '));
477
+ return result.value;
478
+ }
479
+
480
+ function success<T>(value: T): InfraResult<T> {
481
+ return { ok: true, value, diagnostics: [] };
482
+ }