@ankhorage/infra 7.1.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.
- package/CHANGELOG.md +6 -0
- package/dist/project/index.d.ts +1 -0
- package/dist/project/index.d.ts.map +1 -1
- package/dist/project/index.js +1 -0
- package/dist/project/index.js.map +1 -1
- package/package.json +2 -2
- package/src/hetznerK3sProduction.test.ts +29 -3
- package/src/hetznerK3sSupabaseR2.test.ts +33 -4
- package/src/localDockerComposeProviders.integration.test.ts +90 -51
- package/src/localK3sSupabase.test.ts +85 -27
- package/src/localMinikubeSupabase.integration.test.ts +175 -122
- package/src/localMinikubeSupabaseRecovery.integration.test.ts +37 -10
- package/src/project/index.test.ts +3 -1
- package/src/project/index.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @ankhorage/infra
|
|
2
2
|
|
|
3
|
+
## 7.1.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 7440682: Expose the project-scoped credential port constructor from `@ankhorage/infra/project` for downstream host composition without exposing credential storage details.
|
|
8
|
+
|
|
3
9
|
## 7.1.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
package/dist/project/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { createProjectInfraCredentialPort } from '../features/environment-lifecycle/adapters/outbound/createProjectInfraCredentialPort.js';
|
|
1
2
|
export { readStoredInfraStateAsync } from '../features/environment-lifecycle/adapters/outbound/readStoredInfraStateAsync.js';
|
|
2
3
|
export { removeStoredInfraStateAsync } from '../features/environment-lifecycle/adapters/outbound/removeStoredInfraStateAsync.js';
|
|
3
4
|
export { resolveInfraProjectAsync } from '../features/environment-lifecycle/adapters/outbound/resolveInfraProjectAsync.js';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/project/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,yBAAyB,EAAE,MAAM,kFAAkF,CAAC;AAC7H,OAAO,EAAE,2BAA2B,EAAE,MAAM,oFAAoF,CAAC;AACjI,OAAO,EAAE,wBAAwB,EAAE,MAAM,iFAAiF,CAAC;AAC3H,OAAO,EAAE,iCAAiC,EAAE,MAAM,0FAA0F,CAAC;AAC7I,OAAO,EAAE,0BAA0B,EAAE,MAAM,mFAAmF,CAAC;AAC/H,OAAO,EAAE,2BAA2B,EAAE,MAAM,8EAA8E,CAAC;AAC3H,OAAO,EAAE,kBAAkB,EAAE,MAAM,+DAA+D,CAAC;AACnG,YAAY,EACV,kCAAkC,EAClC,wBAAwB,EACxB,0BAA0B,EAC1B,qBAAqB,EACrB,4BAA4B,EAC5B,oBAAoB,GACrB,MAAM,0BAA0B,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/project/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gCAAgC,EAAE,MAAM,yFAAyF,CAAC;AAC3I,OAAO,EAAE,yBAAyB,EAAE,MAAM,kFAAkF,CAAC;AAC7H,OAAO,EAAE,2BAA2B,EAAE,MAAM,oFAAoF,CAAC;AACjI,OAAO,EAAE,wBAAwB,EAAE,MAAM,iFAAiF,CAAC;AAC3H,OAAO,EAAE,iCAAiC,EAAE,MAAM,0FAA0F,CAAC;AAC7I,OAAO,EAAE,0BAA0B,EAAE,MAAM,mFAAmF,CAAC;AAC/H,OAAO,EAAE,2BAA2B,EAAE,MAAM,8EAA8E,CAAC;AAC3H,OAAO,EAAE,kBAAkB,EAAE,MAAM,+DAA+D,CAAC;AACnG,YAAY,EACV,kCAAkC,EAClC,wBAAwB,EACxB,0BAA0B,EAC1B,qBAAqB,EACrB,4BAA4B,EAC5B,oBAAoB,GACrB,MAAM,0BAA0B,CAAC"}
|
package/dist/project/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { createProjectInfraCredentialPort } from '../features/environment-lifecycle/adapters/outbound/createProjectInfraCredentialPort.js';
|
|
1
2
|
export { readStoredInfraStateAsync } from '../features/environment-lifecycle/adapters/outbound/readStoredInfraStateAsync.js';
|
|
2
3
|
export { removeStoredInfraStateAsync } from '../features/environment-lifecycle/adapters/outbound/removeStoredInfraStateAsync.js';
|
|
3
4
|
export { resolveInfraProjectAsync } from '../features/environment-lifecycle/adapters/outbound/resolveInfraProjectAsync.js';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/project/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,yBAAyB,EAAE,MAAM,kFAAkF,CAAC;AAC7H,OAAO,EAAE,2BAA2B,EAAE,MAAM,oFAAoF,CAAC;AACjI,OAAO,EAAE,wBAAwB,EAAE,MAAM,iFAAiF,CAAC;AAC3H,OAAO,EAAE,iCAAiC,EAAE,MAAM,0FAA0F,CAAC;AAC7I,OAAO,EAAE,0BAA0B,EAAE,MAAM,mFAAmF,CAAC;AAC/H,OAAO,EAAE,2BAA2B,EAAE,MAAM,8EAA8E,CAAC;AAC3H,OAAO,EAAE,kBAAkB,EAAE,MAAM,+DAA+D,CAAC","sourcesContent":["export { readStoredInfraStateAsync } from '../features/environment-lifecycle/adapters/outbound/readStoredInfraStateAsync.js';\nexport { removeStoredInfraStateAsync } from '../features/environment-lifecycle/adapters/outbound/removeStoredInfraStateAsync.js';\nexport { resolveInfraProjectAsync } from '../features/environment-lifecycle/adapters/outbound/resolveInfraProjectAsync.js';\nexport { writeInfraGeneratedArtifactsAsync } from '../features/environment-lifecycle/adapters/outbound/writeInfraGeneratedArtifactsAsync.js';\nexport { writeStoredInfraStateAsync } from '../features/environment-lifecycle/adapters/outbound/writeStoredInfraStateAsync.js';\nexport { createProjectInfraLifecycle } from '../features/environment-lifecycle/composition/createProjectInfraLifecycle.js';\nexport { resolveProjectFile } from '../features/environment-lifecycle/utils/resolveProjectFile.js';\nexport type {\n CreateProjectInfraLifecycleOptions,\n InfraArtifactWriteResult,\n ProjectInfraDestroyRequest,\n ProjectInfraLifecycle,\n ProjectInfraOperationRequest,\n ResolvedInfraProject,\n} from '../types/infraProject.js';\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/project/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gCAAgC,EAAE,MAAM,yFAAyF,CAAC;AAC3I,OAAO,EAAE,yBAAyB,EAAE,MAAM,kFAAkF,CAAC;AAC7H,OAAO,EAAE,2BAA2B,EAAE,MAAM,oFAAoF,CAAC;AACjI,OAAO,EAAE,wBAAwB,EAAE,MAAM,iFAAiF,CAAC;AAC3H,OAAO,EAAE,iCAAiC,EAAE,MAAM,0FAA0F,CAAC;AAC7I,OAAO,EAAE,0BAA0B,EAAE,MAAM,mFAAmF,CAAC;AAC/H,OAAO,EAAE,2BAA2B,EAAE,MAAM,8EAA8E,CAAC;AAC3H,OAAO,EAAE,kBAAkB,EAAE,MAAM,+DAA+D,CAAC","sourcesContent":["export { createProjectInfraCredentialPort } from '../features/environment-lifecycle/adapters/outbound/createProjectInfraCredentialPort.js';\nexport { readStoredInfraStateAsync } from '../features/environment-lifecycle/adapters/outbound/readStoredInfraStateAsync.js';\nexport { removeStoredInfraStateAsync } from '../features/environment-lifecycle/adapters/outbound/removeStoredInfraStateAsync.js';\nexport { resolveInfraProjectAsync } from '../features/environment-lifecycle/adapters/outbound/resolveInfraProjectAsync.js';\nexport { writeInfraGeneratedArtifactsAsync } from '../features/environment-lifecycle/adapters/outbound/writeInfraGeneratedArtifactsAsync.js';\nexport { writeStoredInfraStateAsync } from '../features/environment-lifecycle/adapters/outbound/writeStoredInfraStateAsync.js';\nexport { createProjectInfraLifecycle } from '../features/environment-lifecycle/composition/createProjectInfraLifecycle.js';\nexport { resolveProjectFile } from '../features/environment-lifecycle/utils/resolveProjectFile.js';\nexport type {\n CreateProjectInfraLifecycleOptions,\n InfraArtifactWriteResult,\n ProjectInfraDestroyRequest,\n ProjectInfraLifecycle,\n ProjectInfraOperationRequest,\n ResolvedInfraProject,\n} from '../types/infraProject.js';\n"]}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ankhorage/infra",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "7.1.
|
|
4
|
+
"version": "7.1.1",
|
|
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": {
|
|
@@ -121,7 +121,7 @@
|
|
|
121
121
|
"@ankhorage/minikube": "^0.4.4",
|
|
122
122
|
"@ankhorage/paradox": "^0.1.24",
|
|
123
123
|
"@ankhorage/r2": "^0.2.0",
|
|
124
|
-
"@ankhorage/supabase": "^0.5.
|
|
124
|
+
"@ankhorage/supabase": "^0.5.10",
|
|
125
125
|
"@ankhorage/supabase-vault": "^0.3.1",
|
|
126
126
|
"@types/bun": "^1.4.1",
|
|
127
127
|
"@types/node": "^25.6.0",
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
InfraControlPlaneCredentialRef,
|
|
3
|
+
InfraCredentialPort,
|
|
2
4
|
InfraLedger,
|
|
3
5
|
InfraManifest,
|
|
4
6
|
InfraResourceIdentity,
|
|
@@ -230,9 +232,7 @@ function createDependencies(fixture: ProductionFixture): InfraOrchestrationDepen
|
|
|
230
232
|
}
|
|
231
233
|
},
|
|
232
234
|
},
|
|
233
|
-
credentials:
|
|
234
|
-
resolveAsync: ({ name }) => Promise.resolve(resolveCredential(name)),
|
|
235
|
-
},
|
|
235
|
+
credentials: createCredentialPort(),
|
|
236
236
|
secrets: {
|
|
237
237
|
resolveAsync: () =>
|
|
238
238
|
Promise.resolve({
|
|
@@ -245,6 +245,32 @@ function createDependencies(fixture: ProductionFixture): InfraOrchestrationDepen
|
|
|
245
245
|
};
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
function createCredentialPort(): InfraCredentialPort {
|
|
249
|
+
return {
|
|
250
|
+
findAsync: (reference) => Promise.resolve(findCredential(reference)),
|
|
251
|
+
resolveAsync: ({ name }) => Promise.resolve(resolveCredential(name)),
|
|
252
|
+
persistAsync: (reference) =>
|
|
253
|
+
Promise.resolve({
|
|
254
|
+
ok: false,
|
|
255
|
+
diagnostics: [
|
|
256
|
+
{
|
|
257
|
+
severity: 'error',
|
|
258
|
+
code: 'unexpected-credential-persist',
|
|
259
|
+
message: `Production fixture must not persist credential ${reference.name}.`,
|
|
260
|
+
},
|
|
261
|
+
],
|
|
262
|
+
}),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function findCredential(
|
|
267
|
+
reference: InfraControlPlaneCredentialRef,
|
|
268
|
+
): InfraResult<Readonly<Record<string, string>> | null> {
|
|
269
|
+
return reference.name === 'SUPABASE_BOOTSTRAP'
|
|
270
|
+
? resolveCredential(reference.name)
|
|
271
|
+
: success(null);
|
|
272
|
+
}
|
|
273
|
+
|
|
248
274
|
function resolveCredential(name: string): InfraResult<Readonly<Record<string, string>>> {
|
|
249
275
|
if (name === 'HCLOUD_TOKEN') return success({ token: 'phase10-hcloud-token' });
|
|
250
276
|
if (name === 'HETZNER_SSH') {
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
InfraControlPlaneCredentialRef,
|
|
3
|
+
InfraCredentialPort,
|
|
4
|
+
InfraManifest,
|
|
5
|
+
InfraResult,
|
|
6
|
+
} from '@ankhorage/contracts/infra';
|
|
2
7
|
import {
|
|
3
8
|
createInfraAdapter as createHetznerInfraAdapter,
|
|
4
9
|
type HetznerCloudApi,
|
|
@@ -189,9 +194,7 @@ function createDependencies(fixture: ProductionR2Fixture): InfraOrchestrationDep
|
|
|
189
194
|
}
|
|
190
195
|
},
|
|
191
196
|
},
|
|
192
|
-
credentials:
|
|
193
|
-
resolveAsync: ({ name }) => Promise.resolve(resolveCredential(name)),
|
|
194
|
-
},
|
|
197
|
+
credentials: createCredentialPort(),
|
|
195
198
|
secrets: {
|
|
196
199
|
resolveAsync: () =>
|
|
197
200
|
Promise.resolve({
|
|
@@ -204,6 +207,32 @@ function createDependencies(fixture: ProductionR2Fixture): InfraOrchestrationDep
|
|
|
204
207
|
};
|
|
205
208
|
}
|
|
206
209
|
|
|
210
|
+
function createCredentialPort(): InfraCredentialPort {
|
|
211
|
+
return {
|
|
212
|
+
findAsync: (reference) => Promise.resolve(findCredential(reference)),
|
|
213
|
+
resolveAsync: ({ name }) => Promise.resolve(resolveCredential(name)),
|
|
214
|
+
persistAsync: (reference) =>
|
|
215
|
+
Promise.resolve({
|
|
216
|
+
ok: false,
|
|
217
|
+
diagnostics: [
|
|
218
|
+
{
|
|
219
|
+
severity: 'error',
|
|
220
|
+
code: 'unexpected-credential-persist',
|
|
221
|
+
message: `Production fixture must not persist credential ${reference.name}.`,
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
}),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function findCredential(
|
|
229
|
+
reference: InfraControlPlaneCredentialRef,
|
|
230
|
+
): InfraResult<Readonly<Record<string, string>> | null> {
|
|
231
|
+
return reference.name === 'SUPABASE_BOOTSTRAP'
|
|
232
|
+
? resolveCredential(reference.name)
|
|
233
|
+
: success(null);
|
|
234
|
+
}
|
|
235
|
+
|
|
207
236
|
function resolveCredential(name: string): InfraResult<Readonly<Record<string, string>>> {
|
|
208
237
|
if (name === 'HCLOUD_TOKEN') return success({ token: 'phase10-hcloud-token' });
|
|
209
238
|
if (name === 'HETZNER_SSH') {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
226
|
+
function assertProviderNeutralOutputs(
|
|
227
|
+
outputs: InfraLedger['outputs'],
|
|
228
|
+
credentials: Readonly<Record<string, string>>,
|
|
229
|
+
): void {
|
|
171
230
|
const serialized = JSON.stringify(outputs);
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
+
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
2
5
|
|
|
3
6
|
import type {
|
|
4
7
|
InfraLedger,
|
|
@@ -8,13 +11,8 @@ import type {
|
|
|
8
11
|
} from '@ankhorage/contracts/infra';
|
|
9
12
|
import { expect, test } from 'bun:test';
|
|
10
13
|
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
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';
|
|
14
|
+
import { createProjectInfraCredentialPort } from './features/environment-lifecycle/adapters/outbound/createProjectInfraCredentialPort.js';
|
|
15
|
+
import { createProjectInfraLifecycle } from './project/index.js';
|
|
18
16
|
|
|
19
17
|
const projectId = 'infra145-minikube-supabase';
|
|
20
18
|
const acceptanceNamespace = `${projectId}-local`;
|
|
@@ -23,15 +21,9 @@ const bucket = 'phase8-acceptance';
|
|
|
23
21
|
const staleOwnedConfigMap = 'phase8-stale-owned';
|
|
24
22
|
const unrelatedConfigMap = 'phase8-unrelated';
|
|
25
23
|
const staleOwnedResourceId = 'stale:phase8-owned';
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
jwtSecret,
|
|
30
|
-
anonKey: createJwt('anon'),
|
|
31
|
-
serviceRoleKey: createJwt('service_role'),
|
|
32
|
-
realtimeSecretKeyBase: 'r'.repeat(64),
|
|
33
|
-
realtimeDatabaseEncryptionKey: '0123456789abcdef',
|
|
34
|
-
pgMetaCryptoKey: 'phase8-meta-crypto-key-that-is-at-least-32-characters',
|
|
24
|
+
const bootstrapCredential = {
|
|
25
|
+
source: 'control-plane',
|
|
26
|
+
name: 'SUPABASE_BOOTSTRAP',
|
|
35
27
|
} as const;
|
|
36
28
|
const manifest = {
|
|
37
29
|
environments: {
|
|
@@ -56,52 +48,60 @@ const manifest = {
|
|
|
56
48
|
} as const satisfies InfraManifest;
|
|
57
49
|
|
|
58
50
|
test.skipIf(process.env.ANKH_INFRA_MINIKUBE_SUPABASE_E2E !== '1')(
|
|
59
|
-
'
|
|
51
|
+
'bootstraps fresh local Minikube and Supabase through the project lifecycle',
|
|
60
52
|
async () => {
|
|
61
|
-
const
|
|
53
|
+
const projectPath = await fs.mkdtemp(path.join(os.tmpdir(), 'ankh-infra170-minikube-'));
|
|
54
|
+
const isolatedProjectPath = await fs.mkdtemp(path.join(os.tmpdir(), 'ankh-infra170-isolated-'));
|
|
55
|
+
const request = createOperationRequest(projectPath);
|
|
62
56
|
let ledger: InfraLedger | undefined;
|
|
57
|
+
let destroyed = false;
|
|
63
58
|
|
|
64
59
|
try {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
);
|
|
60
|
+
const firstLifecycle = createProjectInfraLifecycle();
|
|
61
|
+
const initialPlan = requireSuccess(await firstLifecycle.planAsync(request));
|
|
68
62
|
expect(initialPlan.actions.some(({ operation }) => operation === 'create')).toBe(true);
|
|
69
63
|
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
);
|
|
73
|
-
|
|
74
|
-
assertSafePublicOutputs(
|
|
64
|
+
const firstUp = requireSuccess(await firstLifecycle.upAsync(request));
|
|
65
|
+
({ ledger } = firstUp);
|
|
66
|
+
const firstCredentials = await resolveBootstrapCredentialsAsync(projectPath);
|
|
67
|
+
const firstFingerprint = credentialFingerprint(firstCredentials);
|
|
68
|
+
assertSafePublicOutputs(firstUp.outputs, firstCredentials);
|
|
69
|
+
assertSecretFree(firstUp, firstCredentials);
|
|
70
|
+
await assertStoredStateSecretFreeAsync(projectPath, firstCredentials);
|
|
71
|
+
await assertCredentialFilePermissionsAsync(projectPath);
|
|
75
72
|
expect(
|
|
76
|
-
|
|
73
|
+
firstUp.outputs.some(
|
|
77
74
|
({ owner, name, value }) =>
|
|
78
75
|
owner.adapter === 'minikube' && name === 'localUrl' && value === baseUrl,
|
|
79
76
|
),
|
|
80
77
|
).toBe(true);
|
|
81
78
|
expect(
|
|
82
|
-
|
|
79
|
+
firstUp.outputs.some(
|
|
83
80
|
({ environmentVariable, value }) =>
|
|
84
81
|
environmentVariable === 'EXPO_PUBLIC_SUPABASE_URL' && value === baseUrl,
|
|
85
82
|
),
|
|
86
83
|
).toBe(true);
|
|
87
|
-
expect(
|
|
84
|
+
expect(firstUp.outputs.some(({ name, value }) => name === 'bucket' && value === bucket)).toBe(
|
|
88
85
|
true,
|
|
89
86
|
);
|
|
90
87
|
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
);
|
|
88
|
+
const secondLifecycle = createProjectInfraLifecycle();
|
|
89
|
+
const status = requireSuccess(await secondLifecycle.statusAsync(request));
|
|
94
90
|
expect(status.state).toBe('ready');
|
|
95
91
|
|
|
96
|
-
const convergedPlan = requireSuccess(
|
|
97
|
-
await planInfraEnvironmentAsync({ projectId, manifest, previous: ledger }, dependencies),
|
|
98
|
-
);
|
|
92
|
+
const convergedPlan = requireSuccess(await secondLifecycle.planAsync(request));
|
|
99
93
|
expect(convergedPlan.actions.every(({ operation }) => operation === 'noop')).toBe(true);
|
|
94
|
+
assertSecretFree(convergedPlan, firstCredentials);
|
|
100
95
|
|
|
101
|
-
await
|
|
102
|
-
|
|
103
|
-
|
|
96
|
+
const secondUp = requireSuccess(await secondLifecycle.upAsync(request));
|
|
97
|
+
({ ledger } = secondUp);
|
|
98
|
+
expect(credentialFingerprint(await resolveBootstrapCredentialsAsync(projectPath))).toBe(
|
|
99
|
+
firstFingerprint,
|
|
104
100
|
);
|
|
101
|
+
expect(resourceIdentities(secondUp.resources)).toEqual(resourceIdentities(firstUp.resources));
|
|
102
|
+
|
|
103
|
+
await createStaleOwnershipFixturesAsync();
|
|
104
|
+
const stalePlan = requireSuccess(await secondLifecycle.planAsync(request));
|
|
105
105
|
expect(
|
|
106
106
|
stalePlan.actions.some(
|
|
107
107
|
({ operation, owner }) =>
|
|
@@ -109,10 +109,8 @@ test.skipIf(process.env.ANKH_INFRA_MINIKUBE_SUPABASE_E2E !== '1')(
|
|
|
109
109
|
),
|
|
110
110
|
).toBe(true);
|
|
111
111
|
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
);
|
|
115
|
-
ledger = prunedLedger;
|
|
112
|
+
const pruned = requireSuccess(await secondLifecycle.upAsync(request));
|
|
113
|
+
({ ledger } = pruned);
|
|
116
114
|
expect(await configMapExistsAsync(staleOwnedConfigMap)).toBe(false);
|
|
117
115
|
expect(await configMapExistsAsync(unrelatedConfigMap)).toBe(true);
|
|
118
116
|
await runKubectlAsync([
|
|
@@ -124,35 +122,53 @@ test.skipIf(process.env.ANKH_INFRA_MINIKUBE_SUPABASE_E2E !== '1')(
|
|
|
124
122
|
'--ignore-not-found=true',
|
|
125
123
|
]);
|
|
126
124
|
|
|
127
|
-
const
|
|
128
|
-
|
|
125
|
+
const down = requireSuccess(await secondLifecycle.downAsync(request));
|
|
126
|
+
({ ledger } = down);
|
|
127
|
+
expect(credentialFingerprint(await resolveBootstrapCredentialsAsync(projectPath))).toBe(
|
|
128
|
+
firstFingerprint,
|
|
129
129
|
);
|
|
130
|
-
ledger = downLedger;
|
|
131
130
|
|
|
132
|
-
const
|
|
133
|
-
|
|
131
|
+
const resumedLifecycle = createProjectInfraLifecycle();
|
|
132
|
+
const resumed = requireSuccess(await resumedLifecycle.upAsync(request));
|
|
133
|
+
({ ledger } = resumed);
|
|
134
|
+
expect(credentialFingerprint(await resolveBootstrapCredentialsAsync(projectPath))).toBe(
|
|
135
|
+
firstFingerprint,
|
|
134
136
|
);
|
|
135
|
-
|
|
136
|
-
|
|
137
|
+
assertSafePublicOutputs(resumed.outputs, firstCredentials);
|
|
138
|
+
assertSecretFree(resumed, firstCredentials);
|
|
139
|
+
await assertStoredStateSecretFreeAsync(projectPath, firstCredentials);
|
|
137
140
|
|
|
138
141
|
const health = await fetch(`${baseUrl}/auth/v1/health`);
|
|
139
142
|
expect(health.ok).toBe(true);
|
|
140
143
|
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
144
|
+
const isolatedPort = createProjectInfraCredentialPort({
|
|
145
|
+
projectPath: isolatedProjectPath,
|
|
146
|
+
environment: 'local',
|
|
147
|
+
processEnvironment: {},
|
|
148
|
+
});
|
|
149
|
+
expect(await isolatedPort.persistAsync(bootstrapCredential, firstCredentials)).toMatchObject({
|
|
150
|
+
ok: true,
|
|
151
|
+
});
|
|
152
|
+
const isolatedFingerprint = credentialFingerprint(
|
|
153
|
+
requireSuccess(await isolatedPort.resolveAsync(bootstrapCredential)),
|
|
146
154
|
);
|
|
147
|
-
|
|
148
|
-
|
|
155
|
+
|
|
156
|
+
const destroyResult = requireSuccess(
|
|
157
|
+
await resumedLifecycle.destroyAsync(createDestroyRequest(projectPath, ledger)),
|
|
158
|
+
);
|
|
159
|
+
ledger = undefined;
|
|
160
|
+
destroyed = true;
|
|
161
|
+
expect(destroyResult.ledger).toBeNull();
|
|
162
|
+
expect(await findBootstrapCredentialsAsync(projectPath)).toBeNull();
|
|
163
|
+
expect(
|
|
164
|
+
credentialFingerprint(requireSuccess(await isolatedPort.resolveAsync(bootstrapCredential))),
|
|
165
|
+
).toBe(isolatedFingerprint);
|
|
149
166
|
} finally {
|
|
150
|
-
if (ledger !== undefined) {
|
|
151
|
-
await
|
|
152
|
-
createDestroyRequest(ledger, persistentIdentities(ledger)),
|
|
153
|
-
dependencies,
|
|
154
|
-
);
|
|
167
|
+
if (!destroyed && ledger !== undefined) {
|
|
168
|
+
await createProjectInfraLifecycle().destroyAsync(createDestroyRequest(projectPath, ledger));
|
|
155
169
|
}
|
|
170
|
+
await fs.rm(projectPath, { recursive: true, force: true });
|
|
171
|
+
await fs.rm(isolatedProjectPath, { recursive: true, force: true });
|
|
156
172
|
}
|
|
157
173
|
},
|
|
158
174
|
900_000,
|
|
@@ -227,43 +243,24 @@ async function runKubectlAsync(args: readonly string[]): Promise<string> {
|
|
|
227
243
|
return stdout.trim();
|
|
228
244
|
}
|
|
229
245
|
|
|
230
|
-
function
|
|
246
|
+
function createOperationRequest(projectPath: string) {
|
|
231
247
|
return {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
? success(credentials)
|
|
238
|
-
: failure(
|
|
239
|
-
'unexpected-control-plane-credential',
|
|
240
|
-
`Unexpected control-plane credential ${reference.name}.`,
|
|
241
|
-
),
|
|
242
|
-
),
|
|
243
|
-
},
|
|
244
|
-
secrets: {
|
|
245
|
-
resolveAsync: (reference) =>
|
|
246
|
-
Promise.resolve(
|
|
247
|
-
failure(
|
|
248
|
-
'unexpected-managed-secret',
|
|
249
|
-
`Unexpected managed secret ${reference.ref}/${reference.key}.`,
|
|
250
|
-
),
|
|
251
|
-
),
|
|
252
|
-
},
|
|
248
|
+
projectId,
|
|
249
|
+
projectPath,
|
|
250
|
+
manifest,
|
|
251
|
+
environment: 'local' as const,
|
|
252
|
+
executionEnvironment: {},
|
|
253
253
|
};
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
-
function createDestroyRequest(
|
|
257
|
-
previous: InfraLedger,
|
|
258
|
-
confirmedResources: readonly InfraResourceIdentity[],
|
|
259
|
-
) {
|
|
256
|
+
function createDestroyRequest(projectPath: string, previous: InfraLedger) {
|
|
260
257
|
return {
|
|
261
|
-
|
|
262
|
-
manifest,
|
|
263
|
-
environment: 'local' as const,
|
|
264
|
-
previous,
|
|
258
|
+
...createOperationRequest(projectPath),
|
|
265
259
|
confirmation: { projectId, environment: 'local' as const },
|
|
266
|
-
persistence: {
|
|
260
|
+
persistence: {
|
|
261
|
+
policy: 'delete' as const,
|
|
262
|
+
confirmedResources: persistentIdentities(previous),
|
|
263
|
+
},
|
|
267
264
|
};
|
|
268
265
|
}
|
|
269
266
|
|
|
@@ -271,48 +268,104 @@ function persistentIdentities(ledger: InfraLedger): readonly InfraResourceIdenti
|
|
|
271
268
|
return ledger.resources.filter(({ persistent }) => persistent).map(({ identity }) => identity);
|
|
272
269
|
}
|
|
273
270
|
|
|
274
|
-
function
|
|
271
|
+
function resourceIdentities(
|
|
272
|
+
resources: readonly { readonly identity: InfraResourceIdentity }[],
|
|
273
|
+
): readonly string[] {
|
|
274
|
+
return resources
|
|
275
|
+
.map(({ identity }) =>
|
|
276
|
+
[identity.projectId, identity.environment, identity.adapter, identity.resourceId].join(':'),
|
|
277
|
+
)
|
|
278
|
+
.sort();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function resolveBootstrapCredentialsAsync(
|
|
282
|
+
projectPath: string,
|
|
283
|
+
): Promise<Readonly<Record<string, string>>> {
|
|
284
|
+
const port = createProjectInfraCredentialPort({
|
|
285
|
+
projectPath,
|
|
286
|
+
environment: 'local',
|
|
287
|
+
processEnvironment: {},
|
|
288
|
+
});
|
|
289
|
+
return requireSuccess(await port.resolveAsync(bootstrapCredential));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function findBootstrapCredentialsAsync(
|
|
293
|
+
projectPath: string,
|
|
294
|
+
): Promise<Readonly<Record<string, string>> | null> {
|
|
295
|
+
const port = createProjectInfraCredentialPort({
|
|
296
|
+
projectPath,
|
|
297
|
+
environment: 'local',
|
|
298
|
+
processEnvironment: {},
|
|
299
|
+
});
|
|
300
|
+
return requireSuccess(await port.findAsync(bootstrapCredential));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function credentialFingerprint(values: Readonly<Record<string, string>>): string {
|
|
304
|
+
const canonical = Object.entries(values)
|
|
305
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
306
|
+
.map(([key, value]) => `${key}\u0000${value}`)
|
|
307
|
+
.join('\u0001');
|
|
308
|
+
return createHash('sha256').update(canonical).digest('hex');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function privilegedCredentialValues(values: Readonly<Record<string, string>>): readonly string[] {
|
|
312
|
+
const { anonKey: _anonKey, ...privileged } = values;
|
|
313
|
+
return Object.values(privileged);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function assertSafePublicOutputs(
|
|
317
|
+
outputs: InfraLedger['outputs'],
|
|
318
|
+
credentials: Readonly<Record<string, string>>,
|
|
319
|
+
): void {
|
|
275
320
|
const serialized = JSON.stringify(outputs);
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
expect(serialized).not.toContain(credentials.pgMetaCryptoKey);
|
|
321
|
+
for (const value of privilegedCredentialValues(credentials)) {
|
|
322
|
+
expect(serialized).not.toContain(value);
|
|
323
|
+
}
|
|
324
|
+
const { anonKey } = credentials;
|
|
325
|
+
if (anonKey === undefined) throw new Error('Expected generated Supabase anonKey.');
|
|
282
326
|
expect(
|
|
283
327
|
outputs.some(
|
|
284
328
|
({ environmentVariable, value }) =>
|
|
285
|
-
environmentVariable === 'EXPO_PUBLIC_SUPABASE_ANON_KEY' && value ===
|
|
329
|
+
environmentVariable === 'EXPO_PUBLIC_SUPABASE_ANON_KEY' && value === anonKey,
|
|
286
330
|
),
|
|
287
331
|
).toBe(true);
|
|
288
332
|
}
|
|
289
333
|
|
|
290
|
-
function
|
|
291
|
-
const
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
iat: 1_700_000_000,
|
|
296
|
-
exp: 4_102_444_800,
|
|
297
|
-
});
|
|
298
|
-
const unsigned = `${encodedHeader}.${encodedPayload}`;
|
|
299
|
-
const signature = createHmac('sha256', jwtSecret).update(unsigned).digest('base64url');
|
|
300
|
-
return `${unsigned}.${signature}`;
|
|
334
|
+
function assertSecretFree(value: unknown, credentials: Readonly<Record<string, string>>): void {
|
|
335
|
+
const serialized = JSON.stringify(value);
|
|
336
|
+
for (const secret of privilegedCredentialValues(credentials)) {
|
|
337
|
+
expect(serialized).not.toContain(secret);
|
|
338
|
+
}
|
|
301
339
|
}
|
|
302
340
|
|
|
303
|
-
function
|
|
304
|
-
|
|
341
|
+
async function assertStoredStateSecretFreeAsync(
|
|
342
|
+
projectPath: string,
|
|
343
|
+
credentials: Readonly<Record<string, string>>,
|
|
344
|
+
): Promise<void> {
|
|
345
|
+
const state = await fs.readFile(
|
|
346
|
+
path.join(projectPath, '.ankh', 'infra', 'local', 'state.json'),
|
|
347
|
+
'utf8',
|
|
348
|
+
);
|
|
349
|
+
for (const secret of privilegedCredentialValues(credentials)) {
|
|
350
|
+
expect(state).not.toContain(secret);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function assertCredentialFilePermissionsAsync(projectPath: string): Promise<void> {
|
|
355
|
+
if (process.platform === 'win32') return;
|
|
356
|
+
const digest = createHash('sha256').update(bootstrapCredential.name).digest('hex');
|
|
357
|
+
const credentialPath = path.join(
|
|
358
|
+
projectPath,
|
|
359
|
+
'.ankh',
|
|
360
|
+
'infra',
|
|
361
|
+
'local',
|
|
362
|
+
'credentials',
|
|
363
|
+
`${digest}.json`,
|
|
364
|
+
);
|
|
365
|
+
expect((await fs.stat(credentialPath)).mode & 0o777).toBe(0o600);
|
|
305
366
|
}
|
|
306
367
|
|
|
307
368
|
function requireSuccess<T>(result: InfraResult<T>): T {
|
|
308
369
|
if (!result.ok) throw new Error(result.diagnostics.map(({ message }) => message).join(' '));
|
|
309
370
|
return result.value;
|
|
310
371
|
}
|
|
311
|
-
|
|
312
|
-
function success<T>(value: T): InfraResult<T> {
|
|
313
|
-
return { ok: true, value, diagnostics: [] };
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
function failure(code: string, message: string): InfraResult<never> {
|
|
317
|
-
return { ok: false, diagnostics: [{ severity: 'error', code, message }] };
|
|
318
|
-
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createHmac } from 'node:crypto';
|
|
2
2
|
|
|
3
3
|
import type {
|
|
4
|
+
InfraControlPlaneCredentialRef,
|
|
5
|
+
InfraCredentialPort,
|
|
4
6
|
InfraLedger,
|
|
5
7
|
InfraManifest,
|
|
6
8
|
InfraResourceIdentity,
|
|
@@ -430,16 +432,7 @@ function redactDiagnosticText(value: string): string {
|
|
|
430
432
|
function createDependencies(): InfraOrchestrationDependencies {
|
|
431
433
|
return {
|
|
432
434
|
adapterResolver: createNodeInfraAdapterPackageResolver(),
|
|
433
|
-
credentials:
|
|
434
|
-
resolveAsync: ({ name }) =>
|
|
435
|
-
Promise.resolve(
|
|
436
|
-
name === 'SUPABASE_BOOTSTRAP'
|
|
437
|
-
? success(supabaseCredentials())
|
|
438
|
-
: name === 'S3_PERSISTENCE'
|
|
439
|
-
? success({ accessKeyId: s3AccessKey, secretAccessKey: s3SecretKey })
|
|
440
|
-
: failure('unexpected-credential', `Unexpected credential reference: ${name}`),
|
|
441
|
-
),
|
|
442
|
-
},
|
|
435
|
+
credentials: createCredentialPort(),
|
|
443
436
|
secrets: {
|
|
444
437
|
resolveAsync: ({ ref, key }) =>
|
|
445
438
|
Promise.resolve(failure('unexpected-secret', `Unexpected managed secret ${ref}/${key}.`)),
|
|
@@ -447,6 +440,40 @@ function createDependencies(): InfraOrchestrationDependencies {
|
|
|
447
440
|
};
|
|
448
441
|
}
|
|
449
442
|
|
|
443
|
+
function createCredentialPort(): InfraCredentialPort {
|
|
444
|
+
return {
|
|
445
|
+
findAsync: (reference) => Promise.resolve(findCredential(reference)),
|
|
446
|
+
resolveAsync: (reference) => Promise.resolve(resolveCredential(reference)),
|
|
447
|
+
persistAsync: (reference) =>
|
|
448
|
+
Promise.resolve(
|
|
449
|
+
failure(
|
|
450
|
+
'unexpected-credential-persist',
|
|
451
|
+
`Recovery acceptance must not persist credential ${reference.name}.`,
|
|
452
|
+
),
|
|
453
|
+
),
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function findCredential(
|
|
458
|
+
reference: InfraControlPlaneCredentialRef,
|
|
459
|
+
): InfraResult<Readonly<Record<string, string>> | null> {
|
|
460
|
+
if (reference.name === 'SUPABASE_BOOTSTRAP') return success(supabaseCredentials());
|
|
461
|
+
if (reference.name === 'S3_PERSISTENCE') {
|
|
462
|
+
return success({ accessKeyId: s3AccessKey, secretAccessKey: s3SecretKey });
|
|
463
|
+
}
|
|
464
|
+
return success(null);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function resolveCredential(
|
|
468
|
+
reference: InfraControlPlaneCredentialRef,
|
|
469
|
+
): InfraResult<Readonly<Record<string, string>>> {
|
|
470
|
+
if (reference.name === 'SUPABASE_BOOTSTRAP') return success(supabaseCredentials());
|
|
471
|
+
if (reference.name === 'S3_PERSISTENCE') {
|
|
472
|
+
return success({ accessKeyId: s3AccessKey, secretAccessKey: s3SecretKey });
|
|
473
|
+
}
|
|
474
|
+
return failure('unexpected-credential', `Unexpected credential reference: ${reference.name}`);
|
|
475
|
+
}
|
|
476
|
+
|
|
450
477
|
function createDestroyRequest(
|
|
451
478
|
previous: InfraLedger,
|
|
452
479
|
confirmedResources: readonly InfraResourceIdentity[],
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
createProjectInfraCredentialPort,
|
|
4
5
|
createProjectInfraLifecycle,
|
|
5
6
|
readStoredInfraStateAsync,
|
|
6
7
|
resolveInfraProjectAsync,
|
|
@@ -8,7 +9,8 @@ import {
|
|
|
8
9
|
} from './index.js';
|
|
9
10
|
|
|
10
11
|
describe('@ankhorage/infra/project', () => {
|
|
11
|
-
test('exports project resolution, safe state and artifact owner APIs', () => {
|
|
12
|
+
test('exports project resolution, credentials, safe state and artifact owner APIs', () => {
|
|
13
|
+
expect(createProjectInfraCredentialPort).toBeFunction();
|
|
12
14
|
expect(createProjectInfraLifecycle).toBeFunction();
|
|
13
15
|
expect(resolveInfraProjectAsync).toBeFunction();
|
|
14
16
|
expect(readStoredInfraStateAsync).toBeFunction();
|
package/src/project/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { createProjectInfraCredentialPort } from '../features/environment-lifecycle/adapters/outbound/createProjectInfraCredentialPort.js';
|
|
1
2
|
export { readStoredInfraStateAsync } from '../features/environment-lifecycle/adapters/outbound/readStoredInfraStateAsync.js';
|
|
2
3
|
export { removeStoredInfraStateAsync } from '../features/environment-lifecycle/adapters/outbound/removeStoredInfraStateAsync.js';
|
|
3
4
|
export { resolveInfraProjectAsync } from '../features/environment-lifecycle/adapters/outbound/resolveInfraProjectAsync.js';
|