@ankhorage/infra 5.1.6 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +1 -1
- package/dist/features/environment-lifecycle/adapters/outbound/createEnvironmentInfraCredentialPort.d.ts.map +1 -1
- package/dist/features/environment-lifecycle/adapters/outbound/createEnvironmentInfraCredentialPort.js +33 -10
- package/dist/features/environment-lifecycle/adapters/outbound/createEnvironmentInfraCredentialPort.js.map +1 -1
- package/dist/features/environment-lifecycle/application/use-cases/prepareInfraServicesAsync.d.ts +4 -0
- package/dist/features/environment-lifecycle/application/use-cases/prepareInfraServicesAsync.d.ts.map +1 -0
- package/dist/features/environment-lifecycle/application/use-cases/prepareInfraServicesAsync.js +15 -0
- package/dist/features/environment-lifecycle/application/use-cases/prepareInfraServicesAsync.js.map +1 -0
- package/dist/features/environment-lifecycle/application/use-cases/upInfraEnvironmentAsync.d.ts +1 -1
- package/dist/features/environment-lifecycle/application/use-cases/upInfraEnvironmentAsync.d.ts.map +1 -1
- package/dist/features/environment-lifecycle/application/use-cases/upInfraEnvironmentAsync.js +10 -5
- package/dist/features/environment-lifecycle/application/use-cases/upInfraEnvironmentAsync.js.map +1 -1
- package/package.json +5 -4
- package/src/environmentCredentialPort.test.ts +62 -0
- package/src/features/environment-lifecycle/adapters/outbound/createEnvironmentInfraCredentialPort.ts +55 -18
- package/src/features/environment-lifecycle/application/use-cases/prepareInfraServicesAsync.ts +23 -0
- package/src/features/environment-lifecycle/application/use-cases/upInfraEnvironmentAsync.ts +8 -4
- package/src/hetznerK3sProduction.test.ts +25 -1
- package/src/infraUp.test.ts +31 -3
- package/src/localMinikubeSupabaseRecovery.integration.test.ts +527 -0
- package/src/prepareInfraServices.test.ts +50 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @ankhorage/infra
|
|
2
2
|
|
|
3
|
+
## 7.0.0
|
|
4
|
+
|
|
5
|
+
### Major Changes
|
|
6
|
+
|
|
7
|
+
- 1778b96: Add provider-neutral service preparation before workload materialization and extend trusted control-plane credential hosts with optional lookup and explicit persistence so providers can prepare bootstrap state without runtime-specific coupling. Read-only lifecycle operations remain side-effect free.
|
|
8
|
+
|
|
9
|
+
## 6.0.0
|
|
10
|
+
|
|
11
|
+
### Major Changes
|
|
12
|
+
|
|
13
|
+
- 99a0dbd: Align Infra with Contracts 20 and add production S3 backup/recovery acceptance for the Supabase production slice.
|
|
14
|
+
|
|
3
15
|
## 5.1.6
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
# INFRA
|
|
5
5
|
|
|
6
|
-
         
|
|
7
7
|
|
|
8
8
|
Executable infra provider and standalone CLI for Ankhorage project workflows.
|
|
9
9
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"createEnvironmentInfraCredentialPort.d.ts","sourceRoot":"","sources":["../../../../../src/features/environment-lifecycle/adapters/outbound/createEnvironmentInfraCredentialPort.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"createEnvironmentInfraCredentialPort.d.ts","sourceRoot":"","sources":["../../../../../src/features/environment-lifecycle/adapters/outbound/createEnvironmentInfraCredentialPort.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAEV,qBAAqB,EAEtB,MAAM,4BAA4B,CAAC;AAKpC,sGAAsG;AACtG,wBAAgB,oCAAoC,CAClD,WAAW,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,GACxD,qBAAqB,CAAC,aAAa,CAAC,CA4BtC"}
|
|
@@ -3,23 +3,43 @@ import { normalizeInfraEnvironmentKey } from '../../utils/normalizeInfraEnvironm
|
|
|
3
3
|
/*** Resolve execution-only control-plane credentials from exact-name or prefixed environment data. */
|
|
4
4
|
export function createEnvironmentInfraCredentialPort(environment) {
|
|
5
5
|
return {
|
|
6
|
+
findAsync(reference) {
|
|
7
|
+
return Promise.resolve(findCredential(environment, reference));
|
|
8
|
+
},
|
|
6
9
|
resolveAsync(reference) {
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
const found = findCredential(environment, reference);
|
|
11
|
+
if (!found.ok)
|
|
12
|
+
return Promise.resolve(found);
|
|
13
|
+
if (found.value !== null) {
|
|
14
|
+
return Promise.resolve({ ok: true, value: found.value, diagnostics: found.diagnostics });
|
|
11
15
|
}
|
|
12
|
-
|
|
16
|
+
const prefixedName = prefixedCredentialName(reference.name);
|
|
17
|
+
return Promise.resolve(createInfraFailure('infra-control-plane-credential-missing', `Control-plane credential ${reference.name} is unavailable. Set ${reference.name} or ${prefixedName}.`));
|
|
18
|
+
},
|
|
19
|
+
persistAsync(reference) {
|
|
20
|
+
return Promise.resolve(createInfraFailure('infra-control-plane-credential-persistence-unavailable', `Control-plane credential ${reference.name} cannot be persisted by the environment-only credential source.`));
|
|
13
21
|
},
|
|
14
22
|
};
|
|
15
23
|
}
|
|
24
|
+
/*** Find and parse an optional credential bundle from the environment without treating absence as failure. */
|
|
25
|
+
function findCredential(environment, reference) {
|
|
26
|
+
const prefixedName = prefixedCredentialName(reference.name);
|
|
27
|
+
const rawValue = Reflect.get(environment, reference.name) ?? Reflect.get(environment, prefixedName);
|
|
28
|
+
if (rawValue === undefined || rawValue.length === 0) {
|
|
29
|
+
return { ok: true, value: null, diagnostics: [] };
|
|
30
|
+
}
|
|
31
|
+
return parseCredentialBundle(reference.name, rawValue);
|
|
32
|
+
}
|
|
33
|
+
/*** Build the canonical prefixed environment variable name for one credential reference. */
|
|
34
|
+
function prefixedCredentialName(name) {
|
|
35
|
+
return `ANKH_INFRA_CREDENTIAL_${normalizeInfraEnvironmentKey(name)}`;
|
|
36
|
+
}
|
|
16
37
|
/*** Parse JSON credential bundles; token variables also support their conventional scalar form. */
|
|
17
38
|
function parseCredentialBundle(name, rawValue) {
|
|
18
39
|
try {
|
|
19
40
|
const parsed = JSON.parse(rawValue);
|
|
20
|
-
if (
|
|
41
|
+
if (isStringRecord(parsed))
|
|
21
42
|
return { ok: true, value: parsed, diagnostics: [] };
|
|
22
|
-
}
|
|
23
43
|
}
|
|
24
44
|
catch {
|
|
25
45
|
if (name.endsWith('_TOKEN')) {
|
|
@@ -28,8 +48,11 @@ function parseCredentialBundle(name, rawValue) {
|
|
|
28
48
|
}
|
|
29
49
|
return createInfraFailure('infra-control-plane-credential-invalid', `Control-plane credential ${name} must be a JSON object containing string fields.`);
|
|
30
50
|
}
|
|
31
|
-
/*** Narrow
|
|
32
|
-
function
|
|
33
|
-
return typeof value === 'object' &&
|
|
51
|
+
/*** Narrow credential bundle values to a string-only record. */
|
|
52
|
+
function isStringRecord(value) {
|
|
53
|
+
return (typeof value === 'object' &&
|
|
54
|
+
value !== null &&
|
|
55
|
+
!Array.isArray(value) &&
|
|
56
|
+
Object.values(value).every((entry) => typeof entry === 'string'));
|
|
34
57
|
}
|
|
35
58
|
//# sourceMappingURL=createEnvironmentInfraCredentialPort.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"createEnvironmentInfraCredentialPort.js","sourceRoot":"","sources":["../../../../../src/features/environment-lifecycle/adapters/outbound/createEnvironmentInfraCredentialPort.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"createEnvironmentInfraCredentialPort.js","sourceRoot":"","sources":["../../../../../src/features/environment-lifecycle/adapters/outbound/createEnvironmentInfraCredentialPort.ts"],"names":[],"mappings":"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","sourcesContent":["import type {\n InfraControlPlaneCredentialRef,\n InfraExecutionContext,\n InfraResult,\n} from '@ankhorage/contracts/infra';\n\nimport { createInfraFailure } from '../../utils/createInfraFailure.js';\nimport { normalizeInfraEnvironmentKey } from '../../utils/normalizeInfraEnvironmentKey.js';\n\n/*** Resolve execution-only control-plane credentials from exact-name or prefixed environment data. */\nexport function createEnvironmentInfraCredentialPort(\n environment: Readonly<Record<string, string | undefined>>,\n): InfraExecutionContext['credentials'] {\n return {\n findAsync(reference) {\n return Promise.resolve(findCredential(environment, reference));\n },\n resolveAsync(reference) {\n const found = findCredential(environment, reference);\n if (!found.ok) return Promise.resolve(found);\n if (found.value !== null) {\n return Promise.resolve({ ok: true, value: found.value, diagnostics: found.diagnostics });\n }\n const prefixedName = prefixedCredentialName(reference.name);\n return Promise.resolve(\n createInfraFailure(\n 'infra-control-plane-credential-missing',\n `Control-plane credential ${reference.name} is unavailable. Set ${reference.name} or ${prefixedName}.`,\n ),\n );\n },\n persistAsync(reference) {\n return Promise.resolve(\n createInfraFailure(\n 'infra-control-plane-credential-persistence-unavailable',\n `Control-plane credential ${reference.name} cannot be persisted by the environment-only credential source.`,\n ),\n );\n },\n };\n}\n\n/*** Find and parse an optional credential bundle from the environment without treating absence as failure. */\nfunction findCredential(\n environment: Readonly<Record<string, string | undefined>>,\n reference: InfraControlPlaneCredentialRef,\n): InfraResult<Readonly<Record<string, string>> | null> {\n const prefixedName = prefixedCredentialName(reference.name);\n const rawValue =\n Reflect.get(environment, reference.name) ?? Reflect.get(environment, prefixedName);\n if (rawValue === undefined || rawValue.length === 0) {\n return { ok: true, value: null, diagnostics: [] };\n }\n return parseCredentialBundle(reference.name, rawValue);\n}\n\n/*** Build the canonical prefixed environment variable name for one credential reference. */\nfunction prefixedCredentialName(name: string): string {\n return `ANKH_INFRA_CREDENTIAL_${normalizeInfraEnvironmentKey(name)}`;\n}\n\n/*** Parse JSON credential bundles; token variables also support their conventional scalar form. */\nfunction parseCredentialBundle(\n name: string,\n rawValue: string,\n): InfraResult<Readonly<Record<string, string>>> {\n try {\n const parsed: unknown = JSON.parse(rawValue);\n if (isStringRecord(parsed)) return { ok: true, value: parsed, diagnostics: [] };\n } catch {\n if (name.endsWith('_TOKEN')) {\n return { ok: true, value: { token: rawValue, apiToken: rawValue }, diagnostics: [] };\n }\n }\n return createInfraFailure(\n 'infra-control-plane-credential-invalid',\n `Control-plane credential ${name} must be a JSON object containing string fields.`,\n );\n}\n\n/*** Narrow credential bundle values to a string-only record. */\nfunction isStringRecord(value: unknown): value is Readonly<Record<string, string>> {\n return (\n typeof value === 'object' &&\n value !== null &&\n !Array.isArray(value) &&\n Object.values(value).every((entry) => typeof entry === 'string')\n );\n}\n"]}
|
package/dist/features/environment-lifecycle/application/use-cases/prepareInfraServicesAsync.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { InfraExecutionContext, InfraResult, InfraServiceAdapter } from '@ankhorage/contracts/infra';
|
|
2
|
+
/*** Run optional mutating service preparation before runtime workload materialization. */
|
|
3
|
+
export declare function prepareInfraServicesAsync(context: InfraExecutionContext, services: readonly InfraServiceAdapter[]): Promise<InfraResult<null>>;
|
|
4
|
+
//# sourceMappingURL=prepareInfraServicesAsync.d.ts.map
|
package/dist/features/environment-lifecycle/application/use-cases/prepareInfraServicesAsync.d.ts.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"prepareInfraServicesAsync.d.ts","sourceRoot":"","sources":["../../../../../src/features/environment-lifecycle/application/use-cases/prepareInfraServicesAsync.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAEV,qBAAqB,EACrB,WAAW,EACX,mBAAmB,EACpB,MAAM,4BAA4B,CAAC;AAIpC,0FAA0F;AAC1F,wBAAsB,yBAAyB,CAC7C,OAAO,EAAE,qBAAqB,EAC9B,QAAQ,EAAE,SAAS,mBAAmB,EAAE,GACvC,OAAO,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC,CAS5B"}
|
package/dist/features/environment-lifecycle/application/use-cases/prepareInfraServicesAsync.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { appendInfraFailure } from '../../utils/appendInfraFailure.js';
|
|
2
|
+
/*** Run optional mutating service preparation before runtime workload materialization. */
|
|
3
|
+
export async function prepareInfraServicesAsync(context, services) {
|
|
4
|
+
const diagnostics = [];
|
|
5
|
+
for (const service of services) {
|
|
6
|
+
if (service.prepareAsync === undefined)
|
|
7
|
+
continue;
|
|
8
|
+
const prepared = await service.prepareAsync(context);
|
|
9
|
+
if (!prepared.ok)
|
|
10
|
+
return appendInfraFailure(diagnostics, prepared);
|
|
11
|
+
diagnostics.push(...prepared.diagnostics);
|
|
12
|
+
}
|
|
13
|
+
return { ok: true, value: null, diagnostics };
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=prepareInfraServicesAsync.js.map
|
package/dist/features/environment-lifecycle/application/use-cases/prepareInfraServicesAsync.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"prepareInfraServicesAsync.js","sourceRoot":"","sources":["../../../../../src/features/environment-lifecycle/application/use-cases/prepareInfraServicesAsync.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,kBAAkB,EAAE,MAAM,mCAAmC,CAAC;AAEvE,0FAA0F;AAC1F,MAAM,CAAC,KAAK,UAAU,yBAAyB,CAC7C,OAA8B,EAC9B,QAAwC;IAExC,MAAM,WAAW,GAAsB,EAAE,CAAC;IAC1C,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;QAC/B,IAAI,OAAO,CAAC,YAAY,KAAK,SAAS;YAAE,SAAS;QACjD,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC;QACrD,IAAI,CAAC,QAAQ,CAAC,EAAE;YAAE,OAAO,kBAAkB,CAAC,WAAW,EAAE,QAAQ,CAAC,CAAC;QACnE,WAAW,CAAC,IAAI,CAAC,GAAG,QAAQ,CAAC,WAAW,CAAC,CAAC;IAC5C,CAAC;IACD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,WAAW,EAAE,CAAC;AAChD,CAAC","sourcesContent":["import type {\n InfraDiagnostic,\n InfraExecutionContext,\n InfraResult,\n InfraServiceAdapter,\n} from '@ankhorage/contracts/infra';\n\nimport { appendInfraFailure } from '../../utils/appendInfraFailure.js';\n\n/*** Run optional mutating service preparation before runtime workload materialization. */\nexport async function prepareInfraServicesAsync(\n context: InfraExecutionContext,\n services: readonly InfraServiceAdapter[],\n): Promise<InfraResult<null>> {\n const diagnostics: InfraDiagnostic[] = [];\n for (const service of services) {\n if (service.prepareAsync === undefined) continue;\n const prepared = await service.prepareAsync(context);\n if (!prepared.ok) return appendInfraFailure(diagnostics, prepared);\n diagnostics.push(...prepared.diagnostics);\n }\n return { ok: true, value: null, diagnostics };\n}\n"]}
|
package/dist/features/environment-lifecycle/application/use-cases/upInfraEnvironmentAsync.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { InfraOrchestrationDependencies, InfraUpOperationResult, InfraUpRequest } from '../../../../types/infraOrchestration.js';
|
|
2
|
-
/*** Validate and reconcile compute,
|
|
2
|
+
/*** Validate and reconcile compute, service preparation, workloads, runtime and services in order. */
|
|
3
3
|
export declare function upInfraEnvironmentAsync(request: InfraUpRequest, dependencies: InfraOrchestrationDependencies): Promise<InfraUpOperationResult>;
|
|
4
4
|
//# sourceMappingURL=upInfraEnvironmentAsync.d.ts.map
|
package/dist/features/environment-lifecycle/application/use-cases/upInfraEnvironmentAsync.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"upInfraEnvironmentAsync.d.ts","sourceRoot":"","sources":["../../../../../src/features/environment-lifecycle/application/use-cases/upInfraEnvironmentAsync.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EACV,8BAA8B,EAC9B,sBAAsB,EACtB,cAAc,EACf,MAAM,yCAAyC,CAAC;
|
|
1
|
+
{"version":3,"file":"upInfraEnvironmentAsync.d.ts","sourceRoot":"","sources":["../../../../../src/features/environment-lifecycle/application/use-cases/upInfraEnvironmentAsync.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EACV,8BAA8B,EAC9B,sBAAsB,EACtB,cAAc,EACf,MAAM,yCAAyC,CAAC;AAWjD,sGAAsG;AACtG,wBAAsB,uBAAuB,CAC3C,OAAO,EAAE,cAAc,EACvB,YAAY,EAAE,8BAA8B,GAC3C,OAAO,CAAC,sBAAsB,CAAC,CA6EjC"}
|
package/dist/features/environment-lifecycle/application/use-cases/upInfraEnvironmentAsync.js
CHANGED
|
@@ -4,9 +4,10 @@ import { createInfraExecutionContext } from '../../utils/createInfraExecutionCon
|
|
|
4
4
|
import { createInfraLedger } from '../../utils/createInfraLedger.js';
|
|
5
5
|
import { mergeInfraOutputs } from '../../utils/mergeInfraOutputs.js';
|
|
6
6
|
import { collectInfraWorkloadsAsync } from './collectInfraWorkloadsAsync.js';
|
|
7
|
+
import { prepareInfraServicesAsync } from './prepareInfraServicesAsync.js';
|
|
7
8
|
import { resolveInfraAdaptersAsync } from './resolveInfraAdaptersAsync.js';
|
|
8
9
|
import { validateInfraAdaptersAsync } from './validateInfraAdaptersAsync.js';
|
|
9
|
-
/*** Validate and reconcile compute,
|
|
10
|
+
/*** Validate and reconcile compute, service preparation, workloads, runtime and services in order. */
|
|
10
11
|
export async function upInfraEnvironmentAsync(request, dependencies) {
|
|
11
12
|
const environment = resolveInfraEnvironment({
|
|
12
13
|
manifest: request.manifest,
|
|
@@ -25,14 +26,18 @@ export async function upInfraEnvironmentAsync(request, dependencies) {
|
|
|
25
26
|
if (!validation.ok)
|
|
26
27
|
return appendInfraFailure(diagnostics, validation);
|
|
27
28
|
diagnostics.push(...validation.diagnostics);
|
|
28
|
-
const workloads = await collectInfraWorkloadsAsync(context, adapters.value.services);
|
|
29
|
-
if (!workloads.ok)
|
|
30
|
-
return appendInfraFailure(diagnostics, workloads);
|
|
31
|
-
diagnostics.push(...workloads.diagnostics);
|
|
32
29
|
const compute = await adapters.value.compute.ensureAsync(context, environment.value.desired.deployment.compute);
|
|
33
30
|
if (!compute.ok)
|
|
34
31
|
return appendInfraFailure(diagnostics, compute);
|
|
35
32
|
diagnostics.push(...compute.diagnostics);
|
|
33
|
+
const preparation = await prepareInfraServicesAsync(context, adapters.value.services);
|
|
34
|
+
if (!preparation.ok)
|
|
35
|
+
return appendInfraFailure(diagnostics, preparation);
|
|
36
|
+
diagnostics.push(...preparation.diagnostics);
|
|
37
|
+
const workloads = await collectInfraWorkloadsAsync(context, adapters.value.services);
|
|
38
|
+
if (!workloads.ok)
|
|
39
|
+
return appendInfraFailure(diagnostics, workloads);
|
|
40
|
+
diagnostics.push(...workloads.diagnostics);
|
|
36
41
|
const runtimeDesired = {
|
|
37
42
|
selection: environment.value.desired.deployment.runtime,
|
|
38
43
|
targets: compute.value.targets,
|
package/dist/features/environment-lifecycle/application/use-cases/upInfraEnvironmentAsync.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"upInfraEnvironmentAsync.js","sourceRoot":"","sources":["../../../../../src/features/environment-lifecycle/application/use-cases/upInfraEnvironmentAsync.ts"],"names":[],"mappings":"AAcA,OAAO,EAAE,uBAAuB,EAAE,MAAM,yCAAyC,CAAC;AAClF,OAAO,EAAE,kBAAkB,EAAE,MAAM,mCAAmC,CAAC;AACvE,OAAO,EAAE,2BAA2B,EAAE,MAAM,4CAA4C,CAAC;AACzF,OAAO,EAAE,iBAAiB,EAAE,MAAM,kCAAkC,CAAC;AACrE,OAAO,EAAE,iBAAiB,EAAE,MAAM,kCAAkC,CAAC;AACrE,OAAO,EAAE,0BAA0B,EAAE,MAAM,iCAAiC,CAAC;AAC7E,OAAO,EAAE,yBAAyB,EAAE,MAAM,gCAAgC,CAAC;AAC3E,OAAO,EAAE,0BAA0B,EAAE,MAAM,iCAAiC,CAAC;AAE7E,
|
|
1
|
+
{"version":3,"file":"upInfraEnvironmentAsync.js","sourceRoot":"","sources":["../../../../../src/features/environment-lifecycle/application/use-cases/upInfraEnvironmentAsync.ts"],"names":[],"mappings":"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","sourcesContent":["import type {\n InfraDiagnostic,\n InfraExecutionContext,\n InfraOutput,\n InfraOwnedResource,\n InfraResult,\n InfraServiceAdapter,\n} from '@ankhorage/contracts/infra';\n\nimport type {\n InfraOrchestrationDependencies,\n InfraUpOperationResult,\n InfraUpRequest,\n} from '../../../../types/infraOrchestration.js';\nimport { resolveInfraEnvironment } from '../../domain/resolveInfraEnvironment.js';\nimport { appendInfraFailure } from '../../utils/appendInfraFailure.js';\nimport { createInfraExecutionContext } from '../../utils/createInfraExecutionContext.js';\nimport { createInfraLedger } from '../../utils/createInfraLedger.js';\nimport { mergeInfraOutputs } from '../../utils/mergeInfraOutputs.js';\nimport { collectInfraWorkloadsAsync } from './collectInfraWorkloadsAsync.js';\nimport { prepareInfraServicesAsync } from './prepareInfraServicesAsync.js';\nimport { resolveInfraAdaptersAsync } from './resolveInfraAdaptersAsync.js';\nimport { validateInfraAdaptersAsync } from './validateInfraAdaptersAsync.js';\n\n/*** Validate and reconcile compute, service preparation, workloads, runtime and services in order. */\nexport async function upInfraEnvironmentAsync(\n request: InfraUpRequest,\n dependencies: InfraOrchestrationDependencies,\n): Promise<InfraUpOperationResult> {\n const environment = resolveInfraEnvironment({\n manifest: request.manifest,\n environment: request.environment,\n operation: 'up',\n });\n if (!environment.ok) return environment;\n const diagnostics: InfraDiagnostic[] = [...environment.diagnostics];\n const adapters = await resolveInfraAdaptersAsync(\n environment.value.desired,\n dependencies.adapterResolver,\n );\n if (!adapters.ok) return appendInfraFailure(diagnostics, adapters);\n diagnostics.push(...adapters.diagnostics);\n const context = createInfraExecutionContext(request, environment.value, dependencies);\n const validation = await validateInfraAdaptersAsync(context, adapters.value);\n if (!validation.ok) return appendInfraFailure(diagnostics, validation);\n diagnostics.push(...validation.diagnostics);\n const compute = await adapters.value.compute.ensureAsync(\n context,\n environment.value.desired.deployment.compute,\n );\n if (!compute.ok) return appendInfraFailure(diagnostics, compute);\n diagnostics.push(...compute.diagnostics);\n const preparation = await prepareInfraServicesAsync(context, adapters.value.services);\n if (!preparation.ok) return appendInfraFailure(diagnostics, preparation);\n diagnostics.push(...preparation.diagnostics);\n const workloads = await collectInfraWorkloadsAsync(context, adapters.value.services);\n if (!workloads.ok) return appendInfraFailure(diagnostics, workloads);\n diagnostics.push(...workloads.diagnostics);\n const runtimeDesired = {\n selection: environment.value.desired.deployment.runtime,\n targets: compute.value.targets,\n workloads: [...(environment.value.desired.workloads ?? []), ...workloads.value],\n availableOutputs: mergeInfraOutputs(request.previous?.outputs ?? [], compute.value.outputs),\n };\n const runtimeValidation = await adapters.value.runtime.validateAsync(context, runtimeDesired);\n if (!runtimeValidation.ok) return appendInfraFailure(diagnostics, runtimeValidation);\n diagnostics.push(...runtimeValidation.diagnostics);\n const runtime = await adapters.value.runtime.ensureAsync(context, runtimeDesired);\n if (!runtime.ok) return appendInfraFailure(diagnostics, runtime);\n diagnostics.push(...runtime.diagnostics);\n const services = await reconcileServicesAsync(context, adapters.value.services, [\n ...compute.value.outputs,\n ...runtime.value.outputs,\n ]);\n if (!services.ok) return appendInfraFailure(diagnostics, services);\n diagnostics.push(...services.diagnostics);\n const resources = [\n ...compute.value.resources,\n ...runtime.value.resources,\n ...services.value.resources,\n ];\n const outputs = mergeInfraOutputs(\n compute.value.outputs,\n runtime.value.outputs,\n services.value.outputs,\n );\n const ledger = createInfraLedger({\n projectId: request.projectId,\n environment: environment.value.id,\n targets: compute.value.targets,\n resources,\n outputs,\n previous: request.previous,\n });\n return {\n ok: true,\n value: {\n environment: environment.value.id,\n targets: compute.value.targets,\n resources,\n outputs,\n ledger,\n },\n diagnostics,\n };\n}\n\n/*** Reconcile service-owned external state after its runtime workloads expose outputs. */\nasync function reconcileServicesAsync(\n context: InfraExecutionContext,\n services: readonly InfraServiceAdapter[],\n runtimeOutputs: readonly InfraOutput[],\n): Promise<\n InfraResult<{\n readonly resources: readonly InfraOwnedResource[];\n readonly outputs: readonly InfraOutput[];\n }>\n> {\n const resources: InfraOwnedResource[] = [];\n const outputs: InfraOutput[] = [];\n const diagnostics: InfraDiagnostic[] = [];\n for (const service of services) {\n const result = await service.reconcileAsync(context, runtimeOutputs);\n if (!result.ok) return appendInfraFailure(diagnostics, result);\n resources.push(...result.value.resources);\n outputs.push(...result.value.outputs);\n diagnostics.push(...result.diagnostics);\n }\n return { ok: true, value: { resources, outputs }, diagnostics };\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ankhorage/infra",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "7.0.0",
|
|
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": "^
|
|
38
|
+
"@ankhorage/contracts": "^21.0.0"
|
|
39
39
|
},
|
|
40
40
|
"files": [
|
|
41
41
|
"dist",
|
|
@@ -102,6 +102,7 @@
|
|
|
102
102
|
"test:e2e": "ANKH_INFRA_COMPOSITION_E2E=1 bun test src/infraComposition.integration.test.ts",
|
|
103
103
|
"test:e2e:docker-compose-providers": "ANKH_INFRA_DOCKER_COMPOSE_PROVIDERS_E2E=1 bun test src/localDockerComposeProviders.integration.test.ts",
|
|
104
104
|
"test:e2e:minikube-supabase": "ANKH_INFRA_MINIKUBE_SUPABASE_E2E=1 bun test src/localMinikubeSupabase.integration.test.ts",
|
|
105
|
+
"test:e2e:minikube-supabase-recovery": "ANKH_INFRA_MINIKUBE_SUPABASE_RECOVERY_E2E=1 bun test src/localMinikubeSupabaseRecovery.integration.test.ts",
|
|
105
106
|
"typecheck": "bun x tsc --noEmit -p tsconfig.json",
|
|
106
107
|
"check-types": "bun run typecheck",
|
|
107
108
|
"version-packages": "ankhorage-changeset version",
|
|
@@ -116,10 +117,10 @@
|
|
|
116
117
|
"@ankhorage/hetzner": "^0.3.0",
|
|
117
118
|
"@ankhorage/k3s": "^0.4.1",
|
|
118
119
|
"@ankhorage/local": "^0.3.0",
|
|
119
|
-
"@ankhorage/minikube": "^0.4.
|
|
120
|
+
"@ankhorage/minikube": "^0.4.4",
|
|
120
121
|
"@ankhorage/paradox": "^0.1.24",
|
|
121
122
|
"@ankhorage/r2": "^0.2.0",
|
|
122
|
-
"@ankhorage/supabase": "^0.5.
|
|
123
|
+
"@ankhorage/supabase": "^0.5.9",
|
|
123
124
|
"@ankhorage/supabase-vault": "^0.3.1",
|
|
124
125
|
"@types/bun": "^1.4.1",
|
|
125
126
|
"@types/node": "^25.6.0",
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { InfraControlPlaneCredentialRef } from '@ankhorage/contracts/infra';
|
|
2
|
+
import { expect, it } from 'bun:test';
|
|
3
|
+
|
|
4
|
+
import { createEnvironmentInfraCredentialPort } from './features/environment-lifecycle/adapters/outbound/createEnvironmentInfraCredentialPort';
|
|
5
|
+
|
|
6
|
+
const bundleReference = {
|
|
7
|
+
source: 'control-plane',
|
|
8
|
+
name: 'FIXTURE_BOOTSTRAP',
|
|
9
|
+
} as const satisfies InfraControlPlaneCredentialRef;
|
|
10
|
+
const tokenReference = {
|
|
11
|
+
source: 'control-plane',
|
|
12
|
+
name: 'FIXTURE_TOKEN',
|
|
13
|
+
} as const satisfies InfraControlPlaneCredentialRef;
|
|
14
|
+
|
|
15
|
+
it('finds exact and prefixed environment credentials without mutating state', async () => {
|
|
16
|
+
const port = createEnvironmentInfraCredentialPort({
|
|
17
|
+
FIXTURE_BOOTSTRAP: JSON.stringify({ username: 'fixture', password: 'secret' }),
|
|
18
|
+
ANKH_INFRA_CREDENTIAL_FIXTURE_TOKEN: 'token-value',
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
expect(await port.findAsync(bundleReference)).toEqual({
|
|
22
|
+
ok: true,
|
|
23
|
+
value: { username: 'fixture', password: 'secret' },
|
|
24
|
+
diagnostics: [],
|
|
25
|
+
});
|
|
26
|
+
expect(await port.findAsync(tokenReference)).toEqual({
|
|
27
|
+
ok: true,
|
|
28
|
+
value: { token: 'token-value', apiToken: 'token-value' },
|
|
29
|
+
diagnostics: [],
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('distinguishes optional lookup from strict resolution', async () => {
|
|
34
|
+
const port = createEnvironmentInfraCredentialPort({});
|
|
35
|
+
|
|
36
|
+
expect(await port.findAsync(bundleReference)).toEqual({ ok: true, value: null, diagnostics: [] });
|
|
37
|
+
const resolved = await port.resolveAsync(bundleReference);
|
|
38
|
+
expect(resolved.ok).toBe(false);
|
|
39
|
+
expect(resolved.diagnostics).toEqual([
|
|
40
|
+
{
|
|
41
|
+
severity: 'error',
|
|
42
|
+
code: 'infra-control-plane-credential-missing',
|
|
43
|
+
message:
|
|
44
|
+
'Control-plane credential FIXTURE_BOOTSTRAP is unavailable. Set FIXTURE_BOOTSTRAP or ANKH_INFRA_CREDENTIAL_FIXTURE_BOOTSTRAP.',
|
|
45
|
+
},
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('fails closed when an environment-only source is asked to persist credentials', async () => {
|
|
50
|
+
const port = createEnvironmentInfraCredentialPort({});
|
|
51
|
+
const persisted = await port.persistAsync(bundleReference, { value: 'secret' });
|
|
52
|
+
|
|
53
|
+
expect(persisted.ok).toBe(false);
|
|
54
|
+
expect(persisted.diagnostics).toEqual([
|
|
55
|
+
{
|
|
56
|
+
severity: 'error',
|
|
57
|
+
code: 'infra-control-plane-credential-persistence-unavailable',
|
|
58
|
+
message:
|
|
59
|
+
'Control-plane credential FIXTURE_BOOTSTRAP cannot be persisted by the environment-only credential source.',
|
|
60
|
+
},
|
|
61
|
+
]);
|
|
62
|
+
});
|
package/src/features/environment-lifecycle/adapters/outbound/createEnvironmentInfraCredentialPort.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
InfraControlPlaneCredentialRef,
|
|
3
|
+
InfraExecutionContext,
|
|
4
|
+
InfraResult,
|
|
5
|
+
} from '@ankhorage/contracts/infra';
|
|
2
6
|
|
|
3
7
|
import { createInfraFailure } from '../../utils/createInfraFailure.js';
|
|
4
8
|
import { normalizeInfraEnvironmentKey } from '../../utils/normalizeInfraEnvironmentKey.js';
|
|
@@ -8,23 +12,53 @@ export function createEnvironmentInfraCredentialPort(
|
|
|
8
12
|
environment: Readonly<Record<string, string | undefined>>,
|
|
9
13
|
): InfraExecutionContext['credentials'] {
|
|
10
14
|
return {
|
|
15
|
+
findAsync(reference) {
|
|
16
|
+
return Promise.resolve(findCredential(environment, reference));
|
|
17
|
+
},
|
|
11
18
|
resolveAsync(reference) {
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
return Promise.resolve(
|
|
17
|
-
createInfraFailure(
|
|
18
|
-
'infra-control-plane-credential-missing',
|
|
19
|
-
`Control-plane credential ${reference.name} is unavailable. Set ${reference.name} or ${prefixedName}.`,
|
|
20
|
-
),
|
|
21
|
-
);
|
|
19
|
+
const found = findCredential(environment, reference);
|
|
20
|
+
if (!found.ok) return Promise.resolve(found);
|
|
21
|
+
if (found.value !== null) {
|
|
22
|
+
return Promise.resolve({ ok: true, value: found.value, diagnostics: found.diagnostics });
|
|
22
23
|
}
|
|
23
|
-
|
|
24
|
+
const prefixedName = prefixedCredentialName(reference.name);
|
|
25
|
+
return Promise.resolve(
|
|
26
|
+
createInfraFailure(
|
|
27
|
+
'infra-control-plane-credential-missing',
|
|
28
|
+
`Control-plane credential ${reference.name} is unavailable. Set ${reference.name} or ${prefixedName}.`,
|
|
29
|
+
),
|
|
30
|
+
);
|
|
31
|
+
},
|
|
32
|
+
persistAsync(reference) {
|
|
33
|
+
return Promise.resolve(
|
|
34
|
+
createInfraFailure(
|
|
35
|
+
'infra-control-plane-credential-persistence-unavailable',
|
|
36
|
+
`Control-plane credential ${reference.name} cannot be persisted by the environment-only credential source.`,
|
|
37
|
+
),
|
|
38
|
+
);
|
|
24
39
|
},
|
|
25
40
|
};
|
|
26
41
|
}
|
|
27
42
|
|
|
43
|
+
/*** Find and parse an optional credential bundle from the environment without treating absence as failure. */
|
|
44
|
+
function findCredential(
|
|
45
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
46
|
+
reference: InfraControlPlaneCredentialRef,
|
|
47
|
+
): InfraResult<Readonly<Record<string, string>> | null> {
|
|
48
|
+
const prefixedName = prefixedCredentialName(reference.name);
|
|
49
|
+
const rawValue =
|
|
50
|
+
Reflect.get(environment, reference.name) ?? Reflect.get(environment, prefixedName);
|
|
51
|
+
if (rawValue === undefined || rawValue.length === 0) {
|
|
52
|
+
return { ok: true, value: null, diagnostics: [] };
|
|
53
|
+
}
|
|
54
|
+
return parseCredentialBundle(reference.name, rawValue);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/*** Build the canonical prefixed environment variable name for one credential reference. */
|
|
58
|
+
function prefixedCredentialName(name: string): string {
|
|
59
|
+
return `ANKH_INFRA_CREDENTIAL_${normalizeInfraEnvironmentKey(name)}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
28
62
|
/*** Parse JSON credential bundles; token variables also support their conventional scalar form. */
|
|
29
63
|
function parseCredentialBundle(
|
|
30
64
|
name: string,
|
|
@@ -32,9 +66,7 @@ function parseCredentialBundle(
|
|
|
32
66
|
): InfraResult<Readonly<Record<string, string>>> {
|
|
33
67
|
try {
|
|
34
68
|
const parsed: unknown = JSON.parse(rawValue);
|
|
35
|
-
if (
|
|
36
|
-
return { ok: true, value: parsed as Readonly<Record<string, string>>, diagnostics: [] };
|
|
37
|
-
}
|
|
69
|
+
if (isStringRecord(parsed)) return { ok: true, value: parsed, diagnostics: [] };
|
|
38
70
|
} catch {
|
|
39
71
|
if (name.endsWith('_TOKEN')) {
|
|
40
72
|
return { ok: true, value: { token: rawValue, apiToken: rawValue }, diagnostics: [] };
|
|
@@ -46,7 +78,12 @@ function parseCredentialBundle(
|
|
|
46
78
|
);
|
|
47
79
|
}
|
|
48
80
|
|
|
49
|
-
/*** Narrow
|
|
50
|
-
function
|
|
51
|
-
return
|
|
81
|
+
/*** Narrow credential bundle values to a string-only record. */
|
|
82
|
+
function isStringRecord(value: unknown): value is Readonly<Record<string, string>> {
|
|
83
|
+
return (
|
|
84
|
+
typeof value === 'object' &&
|
|
85
|
+
value !== null &&
|
|
86
|
+
!Array.isArray(value) &&
|
|
87
|
+
Object.values(value).every((entry) => typeof entry === 'string')
|
|
88
|
+
);
|
|
52
89
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
InfraDiagnostic,
|
|
3
|
+
InfraExecutionContext,
|
|
4
|
+
InfraResult,
|
|
5
|
+
InfraServiceAdapter,
|
|
6
|
+
} from '@ankhorage/contracts/infra';
|
|
7
|
+
|
|
8
|
+
import { appendInfraFailure } from '../../utils/appendInfraFailure.js';
|
|
9
|
+
|
|
10
|
+
/*** Run optional mutating service preparation before runtime workload materialization. */
|
|
11
|
+
export async function prepareInfraServicesAsync(
|
|
12
|
+
context: InfraExecutionContext,
|
|
13
|
+
services: readonly InfraServiceAdapter[],
|
|
14
|
+
): Promise<InfraResult<null>> {
|
|
15
|
+
const diagnostics: InfraDiagnostic[] = [];
|
|
16
|
+
for (const service of services) {
|
|
17
|
+
if (service.prepareAsync === undefined) continue;
|
|
18
|
+
const prepared = await service.prepareAsync(context);
|
|
19
|
+
if (!prepared.ok) return appendInfraFailure(diagnostics, prepared);
|
|
20
|
+
diagnostics.push(...prepared.diagnostics);
|
|
21
|
+
}
|
|
22
|
+
return { ok: true, value: null, diagnostics };
|
|
23
|
+
}
|
|
@@ -18,10 +18,11 @@ import { createInfraExecutionContext } from '../../utils/createInfraExecutionCon
|
|
|
18
18
|
import { createInfraLedger } from '../../utils/createInfraLedger.js';
|
|
19
19
|
import { mergeInfraOutputs } from '../../utils/mergeInfraOutputs.js';
|
|
20
20
|
import { collectInfraWorkloadsAsync } from './collectInfraWorkloadsAsync.js';
|
|
21
|
+
import { prepareInfraServicesAsync } from './prepareInfraServicesAsync.js';
|
|
21
22
|
import { resolveInfraAdaptersAsync } from './resolveInfraAdaptersAsync.js';
|
|
22
23
|
import { validateInfraAdaptersAsync } from './validateInfraAdaptersAsync.js';
|
|
23
24
|
|
|
24
|
-
/*** Validate and reconcile compute,
|
|
25
|
+
/*** Validate and reconcile compute, service preparation, workloads, runtime and services in order. */
|
|
25
26
|
export async function upInfraEnvironmentAsync(
|
|
26
27
|
request: InfraUpRequest,
|
|
27
28
|
dependencies: InfraOrchestrationDependencies,
|
|
@@ -43,15 +44,18 @@ export async function upInfraEnvironmentAsync(
|
|
|
43
44
|
const validation = await validateInfraAdaptersAsync(context, adapters.value);
|
|
44
45
|
if (!validation.ok) return appendInfraFailure(diagnostics, validation);
|
|
45
46
|
diagnostics.push(...validation.diagnostics);
|
|
46
|
-
const workloads = await collectInfraWorkloadsAsync(context, adapters.value.services);
|
|
47
|
-
if (!workloads.ok) return appendInfraFailure(diagnostics, workloads);
|
|
48
|
-
diagnostics.push(...workloads.diagnostics);
|
|
49
47
|
const compute = await adapters.value.compute.ensureAsync(
|
|
50
48
|
context,
|
|
51
49
|
environment.value.desired.deployment.compute,
|
|
52
50
|
);
|
|
53
51
|
if (!compute.ok) return appendInfraFailure(diagnostics, compute);
|
|
54
52
|
diagnostics.push(...compute.diagnostics);
|
|
53
|
+
const preparation = await prepareInfraServicesAsync(context, adapters.value.services);
|
|
54
|
+
if (!preparation.ok) return appendInfraFailure(diagnostics, preparation);
|
|
55
|
+
diagnostics.push(...preparation.diagnostics);
|
|
56
|
+
const workloads = await collectInfraWorkloadsAsync(context, adapters.value.services);
|
|
57
|
+
if (!workloads.ok) return appendInfraFailure(diagnostics, workloads);
|
|
58
|
+
diagnostics.push(...workloads.diagnostics);
|
|
55
59
|
const runtimeDesired = {
|
|
56
60
|
selection: environment.value.desired.deployment.runtime,
|
|
57
61
|
targets: compute.value.targets,
|
|
@@ -40,6 +40,13 @@ import type { InfraOrchestrationDependencies } from './types/infraOrchestration.
|
|
|
40
40
|
const projectId = 'infra145-production';
|
|
41
41
|
const publicBaseUrl = 'https://api.production.example.test';
|
|
42
42
|
const domain = 'api.production.example.test';
|
|
43
|
+
const persistenceTarget = {
|
|
44
|
+
endpoint: 'https://s3.production.example.test',
|
|
45
|
+
region: 'eu-central-1',
|
|
46
|
+
bucket: 'infra145-database-backups',
|
|
47
|
+
credentials: { source: 'control-plane', name: 'S3_PERSISTENCE' },
|
|
48
|
+
forcePathStyle: true,
|
|
49
|
+
} as const;
|
|
43
50
|
|
|
44
51
|
const manifest = {
|
|
45
52
|
environments: {
|
|
@@ -54,8 +61,17 @@ const manifest = {
|
|
|
54
61
|
compute: { provider: 'hetzner', location: 'fsn1', serverType: 'cx23' },
|
|
55
62
|
runtime: { provider: 'k3s', topology: { servers: 1, agents: 0 } },
|
|
56
63
|
},
|
|
57
|
-
database: {
|
|
64
|
+
database: {
|
|
65
|
+
provider: 'supabase',
|
|
66
|
+
tier: 'prod',
|
|
67
|
+
backup: { mode: 'scheduled', target: persistenceTarget, intervalHours: 24 },
|
|
68
|
+
},
|
|
58
69
|
auth: { provider: 'supabase' },
|
|
70
|
+
objectStorage: {
|
|
71
|
+
provider: 'supabase',
|
|
72
|
+
buckets: ['media'],
|
|
73
|
+
backend: { ...persistenceTarget, bucket: 'infra145-storage' },
|
|
74
|
+
},
|
|
59
75
|
networking: {
|
|
60
76
|
domain,
|
|
61
77
|
publicBaseUrl,
|
|
@@ -106,10 +122,15 @@ test('composes Hetzner, k3s, Kubernetes and Supabase while retaining compute for
|
|
|
106
122
|
expect(access?.transport.kind === 'ssh' && access.transport.host).toBe('203.0.113.10');
|
|
107
123
|
expect(JSON.stringify(firstUp)).not.toContain('phase10-private-key');
|
|
108
124
|
expect(JSON.stringify(firstUp)).not.toContain('phase10-service-role-key');
|
|
125
|
+
expect(JSON.stringify(firstUp)).not.toContain('phase10-s3-secret-key');
|
|
109
126
|
expect(fixture.kubernetes.serializedResources()).toContain(`"host":"${domain}"`);
|
|
110
127
|
expect(fixture.kubernetes.serializedResources()).toContain('PersistentVolumeClaim');
|
|
111
128
|
expect(fixture.kubernetes.serializedResources()).toContain('/etc/postgresql-custom');
|
|
112
129
|
expect(fixture.kubernetes.serializedResources()).toContain('.ankhorage-image-seeded');
|
|
130
|
+
expect(fixture.kubernetes.serializedResources()).toContain('supabase-db-backup');
|
|
131
|
+
expect(
|
|
132
|
+
fixture.kubernetes.resources.filter(({ kind }) => kind === 'PersistentVolumeClaim'),
|
|
133
|
+
).toHaveLength(2);
|
|
113
134
|
expect(firstUp.resources.some(({ persistent }) => persistent)).toBe(true);
|
|
114
135
|
const urlOutput = firstUp.outputs.find(({ name }) => name === 'url');
|
|
115
136
|
expect(urlOutput?.value).toBe(publicBaseUrl);
|
|
@@ -232,6 +253,9 @@ function resolveCredential(name: string): InfraResult<Readonly<Record<string, st
|
|
|
232
253
|
privateKey: 'phase10-private-key',
|
|
233
254
|
});
|
|
234
255
|
}
|
|
256
|
+
if (name === 'S3_PERSISTENCE') {
|
|
257
|
+
return success({ accessKeyId: 'phase10-s3-access', secretAccessKey: 'phase10-s3-secret-key' });
|
|
258
|
+
}
|
|
235
259
|
if (name === 'SUPABASE_BOOTSTRAP') {
|
|
236
260
|
return success({
|
|
237
261
|
postgresPassword: 'phase10-postgres-password',
|
package/src/infraUp.test.ts
CHANGED
|
@@ -27,7 +27,7 @@ const environment = {
|
|
|
27
27
|
const manifest = { environments: { local: environment }, modules: [] } as const;
|
|
28
28
|
|
|
29
29
|
describe('environment up orchestration', () => {
|
|
30
|
-
it('validates before mutation and
|
|
30
|
+
it('validates before mutation and prepares services before workload materialization', async () => {
|
|
31
31
|
const calls: string[] = [];
|
|
32
32
|
const observedRuntimeDesired: InfraRuntimeDesiredState[] = [];
|
|
33
33
|
const observedServiceOutputs: InfraOutput[][] = [];
|
|
@@ -39,9 +39,11 @@ describe('environment up orchestration', () => {
|
|
|
39
39
|
'local.validate',
|
|
40
40
|
'supabase.validate',
|
|
41
41
|
'cerbos.validate',
|
|
42
|
+
'local.ensure',
|
|
43
|
+
'supabase.prepare',
|
|
44
|
+
'cerbos.prepare',
|
|
42
45
|
'supabase.workloads',
|
|
43
46
|
'cerbos.workloads',
|
|
44
|
-
'local.ensure',
|
|
45
47
|
'docker-compose.validate',
|
|
46
48
|
'docker-compose.ensure',
|
|
47
49
|
'supabase.reconcile',
|
|
@@ -81,6 +83,27 @@ describe('environment up orchestration', () => {
|
|
|
81
83
|
expect(calls).toEqual(['local.validate', 'supabase.validate']);
|
|
82
84
|
});
|
|
83
85
|
|
|
86
|
+
it('stops before workload materialization when service preparation fails', async () => {
|
|
87
|
+
const calls: string[] = [];
|
|
88
|
+
const dependencies = createDependencies(calls, [], [], 'supabase.prepare');
|
|
89
|
+
const result = await upInfraEnvironmentAsync({ projectId: 'sample', manifest }, dependencies);
|
|
90
|
+
|
|
91
|
+
expect(result.ok).toBe(false);
|
|
92
|
+
expect(result.diagnostics.map(({ code }) => code)).toEqual([
|
|
93
|
+
'compute-warning',
|
|
94
|
+
'fixture-failure',
|
|
95
|
+
]);
|
|
96
|
+
expect(calls).toEqual([
|
|
97
|
+
'local.validate',
|
|
98
|
+
'supabase.validate',
|
|
99
|
+
'cerbos.validate',
|
|
100
|
+
'local.ensure',
|
|
101
|
+
'supabase.prepare',
|
|
102
|
+
]);
|
|
103
|
+
expect(calls).not.toContain('supabase.workloads');
|
|
104
|
+
expect(calls).not.toContain('docker-compose.ensure');
|
|
105
|
+
});
|
|
106
|
+
|
|
84
107
|
it('does not reconcile services after a runtime failure', async () => {
|
|
85
108
|
const calls: string[] = [];
|
|
86
109
|
const dependencies = createDependencies(calls, [], [], 'docker-compose.ensure');
|
|
@@ -100,7 +123,11 @@ function createDependencies(
|
|
|
100
123
|
): InfraOrchestrationDependencies {
|
|
101
124
|
return {
|
|
102
125
|
adapterResolver: createResolver(calls, runtimeDesired, serviceOutputs, failure),
|
|
103
|
-
credentials: {
|
|
126
|
+
credentials: {
|
|
127
|
+
findAsync: () => Promise.resolve(success(null)),
|
|
128
|
+
resolveAsync: () => Promise.resolve(success({})),
|
|
129
|
+
persistAsync: () => Promise.resolve(success(null)),
|
|
130
|
+
},
|
|
104
131
|
secrets: { resolveAsync: () => Promise.resolve(success('secret')) },
|
|
105
132
|
};
|
|
106
133
|
}
|
|
@@ -199,6 +226,7 @@ function createService(
|
|
|
199
226
|
descriptor: descriptor as InfraServiceAdapter['descriptor'],
|
|
200
227
|
validateAsync: () => recordAsync(calls, `${descriptor.id}.validate`, failure, null),
|
|
201
228
|
planAsync: () => Promise.resolve(success([])),
|
|
229
|
+
prepareAsync: () => recordAsync(calls, `${descriptor.id}.prepare`, failure, null),
|
|
202
230
|
desiredWorkloadsAsync: () =>
|
|
203
231
|
recordAsync(calls, `${descriptor.id}.workloads`, failure, [workload(descriptor.id)]),
|
|
204
232
|
reconcileAsync: (_context, outputs) => {
|
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
InfraLedger,
|
|
5
|
+
InfraManifest,
|
|
6
|
+
InfraResourceIdentity,
|
|
7
|
+
InfraResult,
|
|
8
|
+
} from '@ankhorage/contracts/infra';
|
|
9
|
+
import { expect, test } from 'bun:test';
|
|
10
|
+
|
|
11
|
+
import { createNodeInfraAdapterPackageResolver } from './features/environment-lifecycle/adapters/outbound/createNodeInfraAdapterPackageResolver.js';
|
|
12
|
+
import { destroyInfraEnvironmentAsync } from './features/environment-lifecycle/application/use-cases/destroyInfraEnvironmentAsync.js';
|
|
13
|
+
import { upInfraEnvironmentAsync } from './features/environment-lifecycle/application/use-cases/upInfraEnvironmentAsync.js';
|
|
14
|
+
import type { InfraOrchestrationDependencies } from './types/infraOrchestration.js';
|
|
15
|
+
|
|
16
|
+
const projectId = 'infra145-minikube-supabase-recovery';
|
|
17
|
+
const profile = projectId;
|
|
18
|
+
const namespace = `${projectId}-local`;
|
|
19
|
+
const baseUrl = 'http://127.0.0.1:54322';
|
|
20
|
+
const logicalBucket = 'phase10-objects';
|
|
21
|
+
const physicalBucket = 'phase10-persistence';
|
|
22
|
+
const minioContainer = 'infra145-minio-recovery';
|
|
23
|
+
const minioHostEndpoint = 'http://127.0.0.1:19000';
|
|
24
|
+
const minioPodEndpoint = 'http://host.minikube.internal:19000';
|
|
25
|
+
const minioImage = 'quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z';
|
|
26
|
+
const s3AccessKey = 'phase10-access';
|
|
27
|
+
const s3SecretKey = 'phase10-secret-key';
|
|
28
|
+
const databaseMarker = 'database-survived-from-s3-backup';
|
|
29
|
+
const objectMarker = 'storage-object-survived-off-cluster';
|
|
30
|
+
const jwtSecret = 'phase10-jwt-secret-that-is-at-least-thirty-two-characters';
|
|
31
|
+
const serviceRoleKey = createJwt('service_role');
|
|
32
|
+
const persistenceTarget = {
|
|
33
|
+
endpoint: minioPodEndpoint,
|
|
34
|
+
region: 'us-east-1',
|
|
35
|
+
bucket: physicalBucket,
|
|
36
|
+
credentials: { source: 'control-plane', name: 'S3_PERSISTENCE' },
|
|
37
|
+
forcePathStyle: true,
|
|
38
|
+
} as const;
|
|
39
|
+
const manifest = {
|
|
40
|
+
environments: {
|
|
41
|
+
local: {
|
|
42
|
+
deployment: {
|
|
43
|
+
compute: { provider: 'local' },
|
|
44
|
+
runtime: {
|
|
45
|
+
provider: 'minikube',
|
|
46
|
+
profile,
|
|
47
|
+
driver: 'docker',
|
|
48
|
+
cpus: 2,
|
|
49
|
+
memoryMiB: 5_120,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
database: {
|
|
53
|
+
provider: 'supabase',
|
|
54
|
+
tier: 'prod',
|
|
55
|
+
backup: { mode: 'scheduled', target: persistenceTarget, intervalHours: 24 },
|
|
56
|
+
},
|
|
57
|
+
auth: { provider: 'supabase' },
|
|
58
|
+
objectStorage: {
|
|
59
|
+
provider: 'supabase',
|
|
60
|
+
buckets: [logicalBucket],
|
|
61
|
+
backend: persistenceTarget,
|
|
62
|
+
},
|
|
63
|
+
networking: { publicBaseUrl: baseUrl },
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
modules: [],
|
|
67
|
+
} as const satisfies InfraManifest;
|
|
68
|
+
|
|
69
|
+
test.skipIf(process.env.ANKH_INFRA_MINIKUBE_SUPABASE_RECOVERY_E2E !== '1')(
|
|
70
|
+
'recovers a fresh Minikube Supabase database while S3-backed Storage remains off-cluster',
|
|
71
|
+
async () => {
|
|
72
|
+
await stopMinioAsync();
|
|
73
|
+
await startMinioAsync();
|
|
74
|
+
try {
|
|
75
|
+
const dependencies = createDependencies();
|
|
76
|
+
const firstUp = requireSuccess(
|
|
77
|
+
await upInfraEnvironmentAsync({ projectId, manifest }, dependencies),
|
|
78
|
+
);
|
|
79
|
+
await writeDatabaseMarkerAsync();
|
|
80
|
+
await uploadStorageObjectAsync();
|
|
81
|
+
await forceFreshBackupAsync();
|
|
82
|
+
expect(await readLatestBackupPointerAsync()).toMatch(/^database\/\d{8}T\d{6}Z$/);
|
|
83
|
+
|
|
84
|
+
const destroyed = requireSuccess(
|
|
85
|
+
await destroyInfraEnvironmentAsync(
|
|
86
|
+
createDestroyRequest(firstUp.ledger, clusterPersistentIdentities(firstUp.ledger)),
|
|
87
|
+
dependencies,
|
|
88
|
+
),
|
|
89
|
+
);
|
|
90
|
+
expectRetainedStorageBucket(destroyed.ledger);
|
|
91
|
+
|
|
92
|
+
const recovered = requireSuccess(
|
|
93
|
+
await upInfraEnvironmentAsync({ projectId, manifest }, dependencies),
|
|
94
|
+
);
|
|
95
|
+
expect(await readDatabaseMarkerAsync()).toBe(databaseMarker);
|
|
96
|
+
expect(await downloadStorageObjectAsync()).toBe(objectMarker);
|
|
97
|
+
|
|
98
|
+
const finalDestroy = requireSuccess(
|
|
99
|
+
await destroyInfraEnvironmentAsync(
|
|
100
|
+
createDestroyRequest(recovered.ledger, clusterPersistentIdentities(recovered.ledger)),
|
|
101
|
+
dependencies,
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
expectRetainedStorageBucket(finalDestroy.ledger);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
const diagnostics = await collectFailureDiagnosticsAsync();
|
|
107
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
108
|
+
throw new Error(`${message}\nRecovery diagnostics: ${diagnostics}`, { cause: error });
|
|
109
|
+
} finally {
|
|
110
|
+
await runAllowFailureAsync(['minikube', 'delete', '-p', profile]);
|
|
111
|
+
await stopMinioAsync();
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
1_200_000,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
async function startMinioAsync(): Promise<void> {
|
|
118
|
+
await runAsync(
|
|
119
|
+
[
|
|
120
|
+
'docker',
|
|
121
|
+
'run',
|
|
122
|
+
'--detach',
|
|
123
|
+
'--rm',
|
|
124
|
+
'--name',
|
|
125
|
+
minioContainer,
|
|
126
|
+
'--publish',
|
|
127
|
+
'19000:9000',
|
|
128
|
+
'--env',
|
|
129
|
+
`MINIO_ROOT_USER=${s3AccessKey}`,
|
|
130
|
+
'--env',
|
|
131
|
+
`MINIO_ROOT_PASSWORD=${s3SecretKey}`,
|
|
132
|
+
minioImage,
|
|
133
|
+
'server',
|
|
134
|
+
'/data',
|
|
135
|
+
'--address',
|
|
136
|
+
':9000',
|
|
137
|
+
],
|
|
138
|
+
'start MinIO',
|
|
139
|
+
);
|
|
140
|
+
await waitForMinioAsync();
|
|
141
|
+
await runAsync(
|
|
142
|
+
[
|
|
143
|
+
'curl',
|
|
144
|
+
'--fail',
|
|
145
|
+
'--silent',
|
|
146
|
+
'--show-error',
|
|
147
|
+
'--request',
|
|
148
|
+
'PUT',
|
|
149
|
+
'--aws-sigv4',
|
|
150
|
+
'aws:amz:us-east-1:s3',
|
|
151
|
+
'--user',
|
|
152
|
+
`${s3AccessKey}:${s3SecretKey}`,
|
|
153
|
+
`${minioHostEndpoint}/${physicalBucket}`,
|
|
154
|
+
],
|
|
155
|
+
'create MinIO bucket',
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function waitForMinioAsync(): Promise<void> {
|
|
160
|
+
for (const _attempt of Array.from({ length: 60 }, (_, index) => index)) {
|
|
161
|
+
const response = await fetch(`${minioHostEndpoint}/minio/health/live`).catch(() => undefined);
|
|
162
|
+
if (response?.ok === true) return;
|
|
163
|
+
await Bun.sleep(500);
|
|
164
|
+
}
|
|
165
|
+
throw new Error('MinIO did not become ready.');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function stopMinioAsync(): Promise<void> {
|
|
169
|
+
await runAllowFailureAsync(['docker', 'rm', '--force', minioContainer]);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function writeDatabaseMarkerAsync(): Promise<void> {
|
|
173
|
+
const pod = await findPodAsync('supabase-db-', 'supabase-db-backup-');
|
|
174
|
+
await runAsync(
|
|
175
|
+
[
|
|
176
|
+
'kubectl',
|
|
177
|
+
'exec',
|
|
178
|
+
'--namespace',
|
|
179
|
+
namespace,
|
|
180
|
+
pod,
|
|
181
|
+
'--',
|
|
182
|
+
'psql',
|
|
183
|
+
'-U',
|
|
184
|
+
'postgres',
|
|
185
|
+
'-d',
|
|
186
|
+
'postgres',
|
|
187
|
+
'-v',
|
|
188
|
+
'ON_ERROR_STOP=1',
|
|
189
|
+
'-c',
|
|
190
|
+
`CREATE TABLE public.phase10_recovery_marker(value text NOT NULL); INSERT INTO public.phase10_recovery_marker(value) VALUES ('${databaseMarker}');`,
|
|
191
|
+
],
|
|
192
|
+
'write database recovery marker',
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function readDatabaseMarkerAsync(): Promise<string> {
|
|
197
|
+
const pod = await findPodAsync('supabase-db-', 'supabase-db-backup-');
|
|
198
|
+
return runAsync(
|
|
199
|
+
[
|
|
200
|
+
'kubectl',
|
|
201
|
+
'exec',
|
|
202
|
+
'--namespace',
|
|
203
|
+
namespace,
|
|
204
|
+
pod,
|
|
205
|
+
'--',
|
|
206
|
+
'psql',
|
|
207
|
+
'-U',
|
|
208
|
+
'postgres',
|
|
209
|
+
'-d',
|
|
210
|
+
'postgres',
|
|
211
|
+
'-Atc',
|
|
212
|
+
'SELECT value FROM public.phase10_recovery_marker LIMIT 1;',
|
|
213
|
+
],
|
|
214
|
+
'read database recovery marker',
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function uploadStorageObjectAsync(): Promise<void> {
|
|
219
|
+
const response = await fetch(`${baseUrl}/storage/v1/object/${logicalBucket}/recovery.txt`, {
|
|
220
|
+
method: 'POST',
|
|
221
|
+
headers: {
|
|
222
|
+
Authorization: `Bearer ${serviceRoleKey}`,
|
|
223
|
+
apikey: serviceRoleKey,
|
|
224
|
+
'Content-Type': 'text/plain',
|
|
225
|
+
},
|
|
226
|
+
body: objectMarker,
|
|
227
|
+
});
|
|
228
|
+
if (!response.ok) throw new Error(`Storage upload failed with HTTP ${response.status}.`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function downloadStorageObjectAsync(): Promise<string> {
|
|
232
|
+
const response = await fetch(`${baseUrl}/storage/v1/object/${logicalBucket}/recovery.txt`, {
|
|
233
|
+
headers: { Authorization: `Bearer ${serviceRoleKey}`, apikey: serviceRoleKey },
|
|
234
|
+
});
|
|
235
|
+
if (!response.ok) throw new Error(`Storage download failed with HTTP ${response.status}.`);
|
|
236
|
+
return response.text();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function forceFreshBackupAsync(): Promise<void> {
|
|
240
|
+
await runAsync(
|
|
241
|
+
['kubectl', 'rollout', 'restart', 'deployment/supabase-db-backup', '--namespace', namespace],
|
|
242
|
+
'restart database backup workload',
|
|
243
|
+
);
|
|
244
|
+
await runAsync(
|
|
245
|
+
[
|
|
246
|
+
'kubectl',
|
|
247
|
+
'rollout',
|
|
248
|
+
'status',
|
|
249
|
+
'deployment/supabase-db-backup',
|
|
250
|
+
'--namespace',
|
|
251
|
+
namespace,
|
|
252
|
+
'--timeout=180s',
|
|
253
|
+
],
|
|
254
|
+
'wait for database backup workload',
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function readLatestBackupPointerAsync(): Promise<string> {
|
|
259
|
+
return runAsync(
|
|
260
|
+
[
|
|
261
|
+
'curl',
|
|
262
|
+
'--fail',
|
|
263
|
+
'--silent',
|
|
264
|
+
'--show-error',
|
|
265
|
+
'--aws-sigv4',
|
|
266
|
+
'aws:amz:us-east-1:s3',
|
|
267
|
+
'--user',
|
|
268
|
+
`${s3AccessKey}:${s3SecretKey}`,
|
|
269
|
+
`${minioHostEndpoint}/${physicalBucket}/database/latest`,
|
|
270
|
+
],
|
|
271
|
+
'read latest database backup pointer',
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function findPodAsync(prefix: string, excludedPrefix?: string): Promise<string> {
|
|
276
|
+
const names = (
|
|
277
|
+
await runAsync(
|
|
278
|
+
[
|
|
279
|
+
'kubectl',
|
|
280
|
+
'get',
|
|
281
|
+
'pods',
|
|
282
|
+
'--namespace',
|
|
283
|
+
namespace,
|
|
284
|
+
'-o',
|
|
285
|
+
'jsonpath={.items[*].metadata.name}',
|
|
286
|
+
],
|
|
287
|
+
'list Supabase pods',
|
|
288
|
+
)
|
|
289
|
+
).split(' ');
|
|
290
|
+
const name = names.find(
|
|
291
|
+
(candidate) => candidate.startsWith(prefix) && !candidate.startsWith(excludedPrefix ?? '\0'),
|
|
292
|
+
);
|
|
293
|
+
if (name === undefined) throw new Error(`No pod matched ${prefix}.`);
|
|
294
|
+
return name;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function collectFailureDiagnosticsAsync(): Promise<string> {
|
|
298
|
+
const [
|
|
299
|
+
auth,
|
|
300
|
+
storageStatus,
|
|
301
|
+
buckets,
|
|
302
|
+
databaseState,
|
|
303
|
+
databaseLogs,
|
|
304
|
+
previousDatabaseLogs,
|
|
305
|
+
dataRestoreState,
|
|
306
|
+
dataRestoreLogs,
|
|
307
|
+
previousDataRestoreLogs,
|
|
308
|
+
] = await Promise.all([
|
|
309
|
+
readEndpointDiagnosticAsync('/auth/v1/health', false),
|
|
310
|
+
readEndpointDiagnosticAsync('/storage/v1/status', false),
|
|
311
|
+
readEndpointDiagnosticAsync('/storage/v1/bucket', true),
|
|
312
|
+
readDatabaseStateAsync(),
|
|
313
|
+
readDatabaseLogsAsync(false),
|
|
314
|
+
readDatabaseLogsAsync(true),
|
|
315
|
+
readDataRestoreStateAsync(),
|
|
316
|
+
readDataRestoreLogsAsync(false),
|
|
317
|
+
readDataRestoreLogsAsync(true),
|
|
318
|
+
]);
|
|
319
|
+
return [
|
|
320
|
+
auth,
|
|
321
|
+
storageStatus,
|
|
322
|
+
buckets,
|
|
323
|
+
databaseState,
|
|
324
|
+
databaseLogs,
|
|
325
|
+
previousDatabaseLogs,
|
|
326
|
+
dataRestoreState,
|
|
327
|
+
dataRestoreLogs,
|
|
328
|
+
previousDataRestoreLogs,
|
|
329
|
+
].join(' | ');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function readDatabaseStateAsync(): Promise<string> {
|
|
333
|
+
const pod = await findPodAsync('supabase-db-', 'supabase-db-backup-').catch(() => undefined);
|
|
334
|
+
if (pod === undefined) return 'database-state=unavailable';
|
|
335
|
+
const state = await runAsync(
|
|
336
|
+
[
|
|
337
|
+
'kubectl',
|
|
338
|
+
'get',
|
|
339
|
+
'pod',
|
|
340
|
+
pod,
|
|
341
|
+
'--namespace',
|
|
342
|
+
namespace,
|
|
343
|
+
'-o',
|
|
344
|
+
'jsonpath={.status.containerStatuses[?(@.name=="supabase-db")].restartCount}',
|
|
345
|
+
],
|
|
346
|
+
'read database restart state',
|
|
347
|
+
).catch((error: unknown) => (error instanceof Error ? error.message : String(error)));
|
|
348
|
+
return `database-restarts=${redactDiagnosticText(state)}`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function readDatabaseLogsAsync(previous: boolean): Promise<string> {
|
|
352
|
+
const pod = await findPodAsync('supabase-db-', 'supabase-db-backup-').catch(() => undefined);
|
|
353
|
+
const label = previous ? 'database-previous-logs' : 'database-logs';
|
|
354
|
+
if (pod === undefined) return `${label}=unavailable`;
|
|
355
|
+
const logs = await runAsync(
|
|
356
|
+
[
|
|
357
|
+
'kubectl',
|
|
358
|
+
'logs',
|
|
359
|
+
'--namespace',
|
|
360
|
+
namespace,
|
|
361
|
+
pod,
|
|
362
|
+
'--container',
|
|
363
|
+
'supabase-db',
|
|
364
|
+
...(previous ? ['--previous'] : []),
|
|
365
|
+
'--tail=240',
|
|
366
|
+
],
|
|
367
|
+
`read ${label}`,
|
|
368
|
+
).catch((error: unknown) => (error instanceof Error ? error.message : String(error)));
|
|
369
|
+
return `${label}=${redactDiagnosticText(logs).slice(-6_000)}`;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function readDataRestoreStateAsync(): Promise<string> {
|
|
373
|
+
const pod = await findPodAsync('supabase-db-data-restore-').catch(() => undefined);
|
|
374
|
+
if (pod === undefined) return 'data-restore-state=unavailable';
|
|
375
|
+
const state = await runAsync(
|
|
376
|
+
[
|
|
377
|
+
'kubectl',
|
|
378
|
+
'get',
|
|
379
|
+
'pod',
|
|
380
|
+
pod,
|
|
381
|
+
'--namespace',
|
|
382
|
+
namespace,
|
|
383
|
+
'-o',
|
|
384
|
+
'jsonpath={.status.containerStatuses[?(@.name=="supabase-db-data-restore")].restartCount}',
|
|
385
|
+
],
|
|
386
|
+
'read data restore restart state',
|
|
387
|
+
).catch((error: unknown) => (error instanceof Error ? error.message : String(error)));
|
|
388
|
+
return `data-restore-restarts=${redactDiagnosticText(state)}`;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function readDataRestoreLogsAsync(previous: boolean): Promise<string> {
|
|
392
|
+
const pod = await findPodAsync('supabase-db-data-restore-').catch(() => undefined);
|
|
393
|
+
const label = previous ? 'data-restore-previous-logs' : 'data-restore-logs';
|
|
394
|
+
if (pod === undefined) return `${label}=unavailable`;
|
|
395
|
+
const logs = await runAsync(
|
|
396
|
+
[
|
|
397
|
+
'kubectl',
|
|
398
|
+
'logs',
|
|
399
|
+
'--namespace',
|
|
400
|
+
namespace,
|
|
401
|
+
pod,
|
|
402
|
+
'--container',
|
|
403
|
+
'supabase-db-data-restore',
|
|
404
|
+
...(previous ? ['--previous'] : []),
|
|
405
|
+
'--tail=240',
|
|
406
|
+
],
|
|
407
|
+
`read ${label}`,
|
|
408
|
+
).catch((error: unknown) => (error instanceof Error ? error.message : String(error)));
|
|
409
|
+
return `${label}=${redactDiagnosticText(logs).slice(-6_000)}`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function readEndpointDiagnosticAsync(path: string, authenticated: boolean): Promise<string> {
|
|
413
|
+
const response = await fetch(`${baseUrl}${path}`, {
|
|
414
|
+
headers: authenticated
|
|
415
|
+
? { Authorization: `Bearer ${serviceRoleKey}`, apikey: serviceRoleKey }
|
|
416
|
+
: undefined,
|
|
417
|
+
}).catch(() => undefined);
|
|
418
|
+
if (response === undefined) return `${path}=unreachable`;
|
|
419
|
+
const body = redactDiagnosticText((await response.text()).slice(0, 500));
|
|
420
|
+
return `${path}=${response.status}:${body}`;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function redactDiagnosticText(value: string): string {
|
|
424
|
+
return value
|
|
425
|
+
.replaceAll(s3SecretKey, '[redacted]')
|
|
426
|
+
.replaceAll(serviceRoleKey, '[redacted]')
|
|
427
|
+
.replaceAll(jwtSecret, '[redacted]');
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function createDependencies(): InfraOrchestrationDependencies {
|
|
431
|
+
return {
|
|
432
|
+
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
|
+
},
|
|
443
|
+
secrets: {
|
|
444
|
+
resolveAsync: ({ ref, key }) =>
|
|
445
|
+
Promise.resolve(failure('unexpected-secret', `Unexpected managed secret ${ref}/${key}.`)),
|
|
446
|
+
},
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function createDestroyRequest(
|
|
451
|
+
previous: InfraLedger,
|
|
452
|
+
confirmedResources: readonly InfraResourceIdentity[],
|
|
453
|
+
) {
|
|
454
|
+
return {
|
|
455
|
+
projectId,
|
|
456
|
+
manifest,
|
|
457
|
+
environment: 'local' as const,
|
|
458
|
+
previous,
|
|
459
|
+
confirmation: { projectId, environment: 'local' as const },
|
|
460
|
+
persistence: { policy: 'delete' as const, confirmedResources },
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function clusterPersistentIdentities(ledger: InfraLedger): readonly InfraResourceIdentity[] {
|
|
465
|
+
return ledger.resources
|
|
466
|
+
.filter(({ identity, persistent }) => persistent && identity.adapter !== 'supabase')
|
|
467
|
+
.map(({ identity }) => identity);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function expectRetainedStorageBucket(ledger: InfraLedger | null): void {
|
|
471
|
+
expect(
|
|
472
|
+
ledger?.resources.map(({ identity }) => `${identity.adapter}:${identity.resourceId}`),
|
|
473
|
+
).toEqual([`supabase:bucket/${logicalBucket}`]);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function supabaseCredentials() {
|
|
477
|
+
return {
|
|
478
|
+
postgresPassword: 'phase10-postgres-password',
|
|
479
|
+
jwtSecret,
|
|
480
|
+
anonKey: createJwt('anon'),
|
|
481
|
+
serviceRoleKey,
|
|
482
|
+
realtimeSecretKeyBase: 'r'.repeat(64),
|
|
483
|
+
realtimeDatabaseEncryptionKey: '0123456789abcdef',
|
|
484
|
+
pgMetaCryptoKey: 'phase10-meta-crypto-key-that-is-at-least-32-characters',
|
|
485
|
+
} as const;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function createJwt(role: 'anon' | 'service_role'): string {
|
|
489
|
+
const header = encodeJwtPart({ alg: 'HS256', typ: 'JWT' });
|
|
490
|
+
const payload = encodeJwtPart({ role, iss: 'supabase', iat: 1_700_000_000, exp: 4_102_444_800 });
|
|
491
|
+
const unsigned = `${header}.${payload}`;
|
|
492
|
+
const signature = createHmac('sha256', jwtSecret).update(unsigned).digest('base64url');
|
|
493
|
+
return `${unsigned}.${signature}`;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function encodeJwtPart(value: Readonly<Record<string, string | number>>): string {
|
|
497
|
+
return Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function runAsync(command: readonly string[], label: string): Promise<string> {
|
|
501
|
+
const process = Bun.spawn(command, { stdout: 'pipe', stderr: 'pipe' });
|
|
502
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
503
|
+
process.exited,
|
|
504
|
+
new Response(process.stdout).text(),
|
|
505
|
+
new Response(process.stderr).text(),
|
|
506
|
+
]);
|
|
507
|
+
if (exitCode !== 0) throw new Error(`${label} failed: ${stderr.trim()}`);
|
|
508
|
+
return stdout.trim();
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async function runAllowFailureAsync(command: readonly string[]): Promise<void> {
|
|
512
|
+
const process = Bun.spawn(command, { stdout: 'ignore', stderr: 'ignore' });
|
|
513
|
+
await process.exited;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function requireSuccess<T>(result: InfraResult<T>): T {
|
|
517
|
+
if (!result.ok) throw new Error(result.diagnostics.map(({ message }) => message).join(' '));
|
|
518
|
+
return result.value;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function success<T>(value: T): InfraResult<T> {
|
|
522
|
+
return { ok: true, value, diagnostics: [] };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function failure(code: string, message: string): InfraResult<never> {
|
|
526
|
+
return { ok: false, diagnostics: [{ severity: 'error', code, message }] };
|
|
527
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
InfraExecutionContext,
|
|
3
|
+
InfraResult,
|
|
4
|
+
InfraServiceAdapter,
|
|
5
|
+
} from '@ankhorage/contracts/infra';
|
|
6
|
+
import { INFRA_ADAPTER_CATALOG } from '@ankhorage/contracts/infra';
|
|
7
|
+
import { expect, it } from 'bun:test';
|
|
8
|
+
|
|
9
|
+
import { prepareInfraServicesAsync } from './features/environment-lifecycle/application/use-cases/prepareInfraServicesAsync';
|
|
10
|
+
|
|
11
|
+
it('leaves services without preparation unchanged', async () => {
|
|
12
|
+
const service: InfraServiceAdapter = {
|
|
13
|
+
descriptor: INFRA_ADAPTER_CATALOG.cerbos,
|
|
14
|
+
validateAsync: () => success(null),
|
|
15
|
+
planAsync: () => success([]),
|
|
16
|
+
desiredWorkloadsAsync: () => success([]),
|
|
17
|
+
reconcileAsync: () => success({ resources: [], outputs: [] }),
|
|
18
|
+
statusAsync: () => success([]),
|
|
19
|
+
destroyAsync: () => success({ resources: [], outputs: [] }),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
expect(await prepareInfraServicesAsync(createContext(), [service])).toEqual({
|
|
23
|
+
ok: true,
|
|
24
|
+
value: null,
|
|
25
|
+
diagnostics: [],
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function createContext(): InfraExecutionContext {
|
|
30
|
+
return {
|
|
31
|
+
projectId: 'fixture',
|
|
32
|
+
environment: 'local',
|
|
33
|
+
desired: {
|
|
34
|
+
deployment: {
|
|
35
|
+
compute: { provider: 'local' },
|
|
36
|
+
runtime: { provider: 'docker-compose' },
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
credentials: {
|
|
40
|
+
findAsync: () => success(null),
|
|
41
|
+
resolveAsync: () => success({}),
|
|
42
|
+
persistAsync: () => success(null),
|
|
43
|
+
},
|
|
44
|
+
secrets: { resolveAsync: () => success('secret') },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function success<T>(value: T): Promise<InfraResult<T>> {
|
|
49
|
+
return Promise.resolve({ ok: true, value, diagnostics: [] });
|
|
50
|
+
}
|