@ankhorage/infra 7.0.0 → 7.1.1

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/cli/commands/destroy.d.ts.map +1 -1
  3. package/dist/cli/commands/destroy.js +1 -0
  4. package/dist/cli/commands/destroy.js.map +1 -1
  5. package/dist/cli/utils/prepareInfraCommandOperationAsync.d.ts.map +1 -1
  6. package/dist/cli/utils/prepareInfraCommandOperationAsync.js +4 -1
  7. package/dist/cli/utils/prepareInfraCommandOperationAsync.js.map +1 -1
  8. package/dist/features/environment-lifecycle/adapters/outbound/createProjectInfraCredentialPort.d.ts +11 -0
  9. package/dist/features/environment-lifecycle/adapters/outbound/createProjectInfraCredentialPort.d.ts.map +1 -0
  10. package/dist/features/environment-lifecycle/adapters/outbound/createProjectInfraCredentialPort.js +125 -0
  11. package/dist/features/environment-lifecycle/adapters/outbound/createProjectInfraCredentialPort.js.map +1 -0
  12. package/dist/features/environment-lifecycle/adapters/outbound/removeStoredInfraCredentialsAsync.d.ts +4 -0
  13. package/dist/features/environment-lifecycle/adapters/outbound/removeStoredInfraCredentialsAsync.d.ts.map +1 -0
  14. package/dist/features/environment-lifecycle/adapters/outbound/removeStoredInfraCredentialsAsync.js +9 -0
  15. package/dist/features/environment-lifecycle/adapters/outbound/removeStoredInfraCredentialsAsync.js.map +1 -0
  16. package/dist/features/environment-lifecycle/composition/createInfraCommandServices.d.ts.map +1 -1
  17. package/dist/features/environment-lifecycle/composition/createInfraCommandServices.js +9 -3
  18. package/dist/features/environment-lifecycle/composition/createInfraCommandServices.js.map +1 -1
  19. package/dist/features/environment-lifecycle/composition/createProjectInfraLifecycle.d.ts.map +1 -1
  20. package/dist/features/environment-lifecycle/composition/createProjectInfraLifecycle.js +28 -2
  21. package/dist/features/environment-lifecycle/composition/createProjectInfraLifecycle.js.map +1 -1
  22. package/dist/features/environment-lifecycle/utils/resolveInfraEnvironmentStateDirectory.d.ts +4 -0
  23. package/dist/features/environment-lifecycle/utils/resolveInfraEnvironmentStateDirectory.d.ts.map +1 -0
  24. package/dist/features/environment-lifecycle/utils/resolveInfraEnvironmentStateDirectory.js +11 -0
  25. package/dist/features/environment-lifecycle/utils/resolveInfraEnvironmentStateDirectory.js.map +1 -0
  26. package/dist/features/environment-lifecycle/utils/resolveInfraStatePath.d.ts +1 -1
  27. package/dist/features/environment-lifecycle/utils/resolveInfraStatePath.d.ts.map +1 -1
  28. package/dist/features/environment-lifecycle/utils/resolveInfraStatePath.js +2 -6
  29. package/dist/features/environment-lifecycle/utils/resolveInfraStatePath.js.map +1 -1
  30. package/dist/project/index.d.ts +1 -0
  31. package/dist/project/index.d.ts.map +1 -1
  32. package/dist/project/index.js +1 -0
  33. package/dist/project/index.js.map +1 -1
  34. package/dist/types/infraCli.d.ts +7 -1
  35. package/dist/types/infraCli.d.ts.map +1 -1
  36. package/dist/types/infraCli.js.map +1 -1
  37. package/package.json +4 -3
  38. package/src/cli/commands/destroy.ts +1 -0
  39. package/src/cli/utils/prepareInfraCommandOperationAsync.ts +4 -1
  40. package/src/features/environment-lifecycle/adapters/outbound/createProjectInfraCredentialPort.ts +182 -0
  41. package/src/features/environment-lifecycle/adapters/outbound/removeStoredInfraCredentialsAsync.ts +18 -0
  42. package/src/features/environment-lifecycle/composition/createInfraCommandServices.ts +9 -3
  43. package/src/features/environment-lifecycle/composition/createProjectInfraLifecycle.ts +33 -4
  44. package/src/features/environment-lifecycle/utils/resolveInfraEnvironmentStateDirectory.ts +16 -0
  45. package/src/features/environment-lifecycle/utils/resolveInfraStatePath.ts +3 -6
  46. package/src/hetznerK3sProduction.test.ts +29 -3
  47. package/src/hetznerK3sSupabaseR2.test.ts +33 -4
  48. package/src/localDockerComposeProviders.integration.test.ts +90 -51
  49. package/src/localK3sSupabase.test.ts +85 -27
  50. package/src/localMinikubeSupabase.integration.test.ts +175 -122
  51. package/src/localMinikubeSupabaseRecovery.integration.test.ts +37 -10
  52. package/src/project/index.test.ts +3 -1
  53. package/src/project/index.ts +1 -0
  54. package/src/projectCredentialLifecycle.test.ts +161 -0
  55. package/src/projectCredentialStore.test.ts +151 -0
  56. package/src/types/infraCli.ts +11 -1
@@ -1,6 +1,8 @@
1
- import { createHmac } from 'node:crypto';
1
+ import { createHash } from 'node:crypto';
2
2
 
3
3
  import type {
4
+ InfraControlPlaneCredentialRef,
5
+ InfraCredentialPort,
4
6
  InfraLedger,
5
7
  InfraManifest,
6
8
  InfraResourceIdentity,
@@ -19,16 +21,7 @@ import type { InfraOrchestrationDependencies } from './types/infraOrchestration.
19
21
  const projectId = 'infra145-compose-providers';
20
22
  const baseUrl = 'http://127.0.0.1:54322';
21
23
  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;
24
+ const bootstrapCredential = { source: 'control-plane', name: 'SUPABASE_BOOTSTRAP' } as const;
32
25
  const manifest = {
33
26
  environments: {
34
27
  local: {
@@ -59,7 +52,8 @@ const manifest = {
59
52
  test.skipIf(process.env.ANKH_INFRA_DOCKER_COMPOSE_PROVIDERS_E2E !== '1')(
60
53
  'runs Supabase and Cerbos through the provider-neutral Docker Compose lifecycle',
61
54
  async () => {
62
- const dependencies = createDependencies();
55
+ const credentialPort = new MemoryCredentialPort();
56
+ const dependencies = createDependencies(credentialPort);
63
57
  let ledger: InfraLedger | undefined;
64
58
 
65
59
  try {
@@ -72,7 +66,10 @@ test.skipIf(process.env.ANKH_INFRA_DOCKER_COMPOSE_PROVIDERS_E2E !== '1')(
72
66
  await upInfraEnvironmentAsync({ projectId, manifest }, dependencies),
73
67
  );
74
68
  ledger = firstLedger;
75
- assertProviderNeutralOutputs(firstOutputs);
69
+ expect(credentialPort.persistCount).toBe(1);
70
+ const firstCredentials = credentialPort.readRequired();
71
+ const firstCredentialFingerprint = credentialFingerprint(firstCredentials);
72
+ assertProviderNeutralOutputs(firstOutputs, firstCredentials);
76
73
 
77
74
  const status = requireSuccess(
78
75
  await statusInfraEnvironmentAsync({ projectId, manifest, previous: ledger }, dependencies),
@@ -98,7 +95,9 @@ test.skipIf(process.env.ANKH_INFRA_DOCKER_COMPOSE_PROVIDERS_E2E !== '1')(
98
95
  await upInfraEnvironmentAsync({ projectId, manifest, previous: ledger }, dependencies),
99
96
  );
100
97
  ledger = resumedLedger;
101
- assertProviderNeutralOutputs(resumedOutputs);
98
+ expect(credentialPort.persistCount).toBe(1);
99
+ expect(credentialFingerprint(credentialPort.readRequired())).toBe(firstCredentialFingerprint);
100
+ assertProviderNeutralOutputs(resumedOutputs, firstCredentials);
102
101
 
103
102
  const health = await fetch(`${baseUrl}/auth/v1/health`);
104
103
  expect(health.ok).toBe(true);
@@ -123,20 +122,10 @@ test.skipIf(process.env.ANKH_INFRA_DOCKER_COMPOSE_PROVIDERS_E2E !== '1')(
123
122
  900_000,
124
123
  );
125
124
 
126
- function createDependencies(): InfraOrchestrationDependencies {
125
+ function createDependencies(credentials: InfraCredentialPort): InfraOrchestrationDependencies {
127
126
  return {
128
127
  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
- },
128
+ credentials,
140
129
  secrets: {
141
130
  resolveAsync: (reference) =>
142
131
  Promise.resolve(
@@ -149,6 +138,73 @@ function createDependencies(): InfraOrchestrationDependencies {
149
138
  };
150
139
  }
151
140
 
141
+ class MemoryCredentialPort implements InfraCredentialPort {
142
+ private values: Readonly<Record<string, string>> | null = null;
143
+ persistCount = 0;
144
+
145
+ findAsync(
146
+ reference: InfraControlPlaneCredentialRef,
147
+ ): Promise<InfraResult<Readonly<Record<string, string>> | null>> {
148
+ if (reference.name !== bootstrapCredential.name) return Promise.resolve(success(null));
149
+ return Promise.resolve(success(this.values === null ? null : { ...this.values }));
150
+ }
151
+
152
+ resolveAsync(
153
+ reference: InfraControlPlaneCredentialRef,
154
+ ): Promise<InfraResult<Readonly<Record<string, string>>>> {
155
+ if (reference.name === bootstrapCredential.name && this.values !== null) {
156
+ return Promise.resolve(success({ ...this.values }));
157
+ }
158
+ return Promise.resolve(
159
+ failure(
160
+ 'unexpected-control-plane-credential',
161
+ `Unexpected or missing control-plane credential ${reference.name}.`,
162
+ ),
163
+ );
164
+ }
165
+
166
+ persistAsync(
167
+ reference: InfraControlPlaneCredentialRef,
168
+ values: Readonly<Record<string, string>>,
169
+ ): Promise<InfraResult<null>> {
170
+ if (reference.name !== bootstrapCredential.name) {
171
+ return Promise.resolve(
172
+ failure(
173
+ 'unexpected-control-plane-credential',
174
+ `Unexpected control-plane credential ${reference.name}.`,
175
+ ),
176
+ );
177
+ }
178
+ this.values = { ...values };
179
+ this.persistCount += 1;
180
+ return Promise.resolve(success(null));
181
+ }
182
+
183
+ readRequired(): Readonly<Record<string, string>> {
184
+ if (this.values === null) throw new Error('Expected generated Supabase bootstrap credentials.');
185
+ return { ...this.values };
186
+ }
187
+ }
188
+
189
+ function credentialFingerprint(values: Readonly<Record<string, string>>): string {
190
+ const canonical = Object.entries(values)
191
+ .sort(([left], [right]) => left.localeCompare(right))
192
+ .map(([key, value]) => `${key}\u0000${value}`)
193
+ .join('\u0001');
194
+ return createHash('sha256').update(canonical).digest('hex');
195
+ }
196
+
197
+ function privilegedCredentialValues(values: Readonly<Record<string, string>>): readonly string[] {
198
+ const { anonKey: _anonKey, ...privileged } = values;
199
+ return Object.values(privileged);
200
+ }
201
+
202
+ function readAnonKey(values: Readonly<Record<string, string>>): string {
203
+ const { anonKey } = values;
204
+ if (anonKey === undefined) throw new Error('Expected generated Supabase anonKey.');
205
+ return anonKey;
206
+ }
207
+
152
208
  function createDestroyRequest(
153
209
  previous: InfraLedger,
154
210
  confirmedResources: readonly InfraResourceIdentity[],
@@ -167,14 +223,14 @@ function persistentIdentities(ledger: InfraLedger): readonly InfraResourceIdenti
167
223
  return ledger.resources.filter(({ persistent }) => persistent).map(({ identity }) => identity);
168
224
  }
169
225
 
170
- function assertProviderNeutralOutputs(outputs: InfraLedger['outputs']): void {
226
+ function assertProviderNeutralOutputs(
227
+ outputs: InfraLedger['outputs'],
228
+ credentials: Readonly<Record<string, string>>,
229
+ ): void {
171
230
  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);
231
+ for (const secret of privilegedCredentialValues(credentials)) {
232
+ expect(serialized).not.toContain(secret);
233
+ }
178
234
  expect(serialized.toLowerCase()).not.toContain('kubernetes');
179
235
  expect(serialized.toLowerCase()).not.toContain('minikube');
180
236
  expect(
@@ -190,7 +246,7 @@ function assertProviderNeutralOutputs(outputs: InfraLedger['outputs']): void {
190
246
  ({ owner, environmentVariable, value }) =>
191
247
  owner.adapter === 'supabase' &&
192
248
  environmentVariable === 'EXPO_PUBLIC_SUPABASE_ANON_KEY' &&
193
- value === credentials.anonKey,
249
+ value === readAnonKey(credentials),
194
250
  ),
195
251
  ).toBe(true);
196
252
  expect(
@@ -209,23 +265,6 @@ function assertProviderNeutralOutputs(outputs: InfraLedger['outputs']): void {
209
265
  ).toBe(true);
210
266
  }
211
267
 
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
268
  function requireSuccess<T>(result: InfraResult<T>): T {
230
269
  if (!result.ok) throw new Error(result.diagnostics.map(({ message }) => message).join(' '));
231
270
  return result.value;
@@ -1,5 +1,9 @@
1
+ import { createHash } from 'node:crypto';
2
+
1
3
  import type {
2
4
  InfraComputeTarget,
5
+ InfraControlPlaneCredentialRef,
6
+ InfraCredentialPort,
3
7
  InfraLedger,
4
8
  InfraManifest,
5
9
  InfraResourceIdentity,
@@ -38,6 +42,7 @@ import type { InfraOrchestrationDependencies } from './types/infraOrchestration.
38
42
  const projectId = 'infra164-local-k3s-supabase';
39
43
  const publicBaseUrl = 'http://127.0.0.1:54321';
40
44
  const bucket = 'infra164-bucket';
45
+ const bootstrapCredential = { source: 'control-plane', name: 'SUPABASE_BOOTSTRAP' } as const;
41
46
  const target: Extract<InfraComputeTarget, { kind: 'local-host' }> = {
42
47
  id: 'local',
43
48
  kind: 'local-host',
@@ -73,6 +78,10 @@ test('runs local k3s and Supabase through the portable compute lifecycle', async
73
78
  await upInfraEnvironmentAsync({ projectId, manifest }, dependencies),
74
79
  );
75
80
  const firstLedger: InfraLedger = firstUp.ledger;
81
+ expect(fixture.credentials.persistCount).toBe(1);
82
+ const firstCredentials = fixture.credentials.readRequired();
83
+ const firstCredentialFingerprint = credentialFingerprint(firstCredentials);
84
+ assertPrivilegedValuesAbsent(firstUp, firstCredentials);
76
85
  expect(firstUp.targets).toEqual([target]);
77
86
  expect([...new Set(fixture.loadedPackages)].sort()).toEqual(
78
87
  [
@@ -88,8 +97,6 @@ test('runs local k3s and Supabase through the portable compute lifecycle', async
88
97
  expect(fixture.kubernetes.serializedResources()).toContain('/etc/postgresql-custom');
89
98
  expect(fixture.kubernetes.serializedResources()).toContain('.ankhorage-image-seeded');
90
99
  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
100
 
94
101
  const outputs = requireSuccess(
95
102
  getInfraEnvironmentOutputs({
@@ -144,6 +151,11 @@ test('runs local k3s and Supabase through the portable compute lifecycle', async
144
151
  expect(fixture.k3s.state).toBe('ready');
145
152
  expect(resumed.targets).toEqual([target]);
146
153
  expect(resourceIdentities(resumed.resources)).toEqual(identitiesBeforeRestart);
154
+ expect(fixture.credentials.persistCount).toBe(1);
155
+ expect(credentialFingerprint(fixture.credentials.readRequired())).toBe(
156
+ firstCredentialFingerprint,
157
+ );
158
+ assertPrivilegedValuesAbsent(resumed, firstCredentials);
147
159
 
148
160
  const destroyed = requireSuccess(
149
161
  await destroyInfraEnvironmentAsync(
@@ -171,6 +183,7 @@ class LocalK3sFixture {
171
183
  readonly k3s = new FakeK3sControlPlane();
172
184
  readonly kubernetes = this.k3s.api;
173
185
  readonly supabase = new FakeSupabaseControlPlane();
186
+ readonly credentials = new MemoryCredentialPort();
174
187
  readonly loadedPackages: string[] = [];
175
188
  }
176
189
 
@@ -201,9 +214,7 @@ function createDependencies(fixture: LocalK3sFixture): InfraOrchestrationDepende
201
214
  }
202
215
  },
203
216
  },
204
- credentials: {
205
- resolveAsync: ({ name }) => Promise.resolve(resolveCredential(name)),
206
- },
217
+ credentials: fixture.credentials,
207
218
  secrets: {
208
219
  resolveAsync: () =>
209
220
  Promise.resolve({
@@ -216,29 +227,72 @@ function createDependencies(fixture: LocalK3sFixture): InfraOrchestrationDepende
216
227
  };
217
228
  }
218
229
 
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
- });
230
+ class MemoryCredentialPort implements InfraCredentialPort {
231
+ private values: Readonly<Record<string, string>> | null = null;
232
+ persistCount = 0;
233
+
234
+ findAsync(
235
+ reference: InfraControlPlaneCredentialRef,
236
+ ): Promise<InfraResult<Readonly<Record<string, string>> | null>> {
237
+ if (reference.name !== bootstrapCredential.name) return Promise.resolve(success(null));
238
+ return Promise.resolve(success(this.values === null ? null : { ...this.values }));
239
+ }
240
+
241
+ resolveAsync(
242
+ reference: InfraControlPlaneCredentialRef,
243
+ ): Promise<InfraResult<Readonly<Record<string, string>>>> {
244
+ if (reference.name === bootstrapCredential.name && this.values !== null) {
245
+ return Promise.resolve(success({ ...this.values }));
246
+ }
247
+ return Promise.resolve(
248
+ failure(
249
+ 'unexpected-credential',
250
+ `Unexpected or missing credential reference: ${reference.name}`,
251
+ ),
252
+ );
253
+ }
254
+
255
+ persistAsync(
256
+ reference: InfraControlPlaneCredentialRef,
257
+ values: Readonly<Record<string, string>>,
258
+ ): Promise<InfraResult<null>> {
259
+ if (reference.name !== bootstrapCredential.name) {
260
+ return Promise.resolve(
261
+ failure('unexpected-credential', `Unexpected credential reference: ${reference.name}`),
262
+ );
263
+ }
264
+ this.values = { ...values };
265
+ this.persistCount += 1;
266
+ return Promise.resolve(success(null));
267
+ }
268
+
269
+ readRequired(): Readonly<Record<string, string>> {
270
+ if (this.values === null) throw new Error('Expected generated Supabase bootstrap credentials.');
271
+ return { ...this.values };
272
+ }
273
+ }
274
+
275
+ function credentialFingerprint(values: Readonly<Record<string, string>>): string {
276
+ const canonical = Object.entries(values)
277
+ .sort(([left], [right]) => left.localeCompare(right))
278
+ .map(([key, value]) => `${key}\u0000${value}`)
279
+ .join('\u0001');
280
+ return createHash('sha256').update(canonical).digest('hex');
281
+ }
282
+
283
+ function privilegedCredentialValues(values: Readonly<Record<string, string>>): readonly string[] {
284
+ const { anonKey: _anonKey, ...privileged } = values;
285
+ return Object.values(privileged);
286
+ }
287
+
288
+ function assertPrivilegedValuesAbsent(
289
+ value: unknown,
290
+ credentials: Readonly<Record<string, string>>,
291
+ ): void {
292
+ const serialized = JSON.stringify(value);
293
+ for (const secret of privilegedCredentialValues(credentials)) {
294
+ expect(serialized).not.toContain(secret);
231
295
  }
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
296
  }
243
297
 
244
298
  class FakeLocalHostProbe implements LocalHostProbe {
@@ -446,3 +500,7 @@ function requireSuccess<T>(result: InfraResult<T>): T {
446
500
  function success<T>(value: T): InfraResult<T> {
447
501
  return { ok: true, value, diagnostics: [] };
448
502
  }
503
+
504
+ function failure(code: string, message: string): InfraResult<never> {
505
+ return { ok: false, diagnostics: [{ severity: 'error', code, message }] };
506
+ }