@ankhorage/infra 6.0.0 → 7.1.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/cli/commands/destroy.d.ts.map +1 -1
- package/dist/cli/commands/destroy.js +1 -0
- package/dist/cli/commands/destroy.js.map +1 -1
- package/dist/cli/utils/prepareInfraCommandOperationAsync.d.ts.map +1 -1
- package/dist/cli/utils/prepareInfraCommandOperationAsync.js +4 -1
- package/dist/cli/utils/prepareInfraCommandOperationAsync.js.map +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/adapters/outbound/createProjectInfraCredentialPort.d.ts +11 -0
- package/dist/features/environment-lifecycle/adapters/outbound/createProjectInfraCredentialPort.d.ts.map +1 -0
- package/dist/features/environment-lifecycle/adapters/outbound/createProjectInfraCredentialPort.js +125 -0
- package/dist/features/environment-lifecycle/adapters/outbound/createProjectInfraCredentialPort.js.map +1 -0
- package/dist/features/environment-lifecycle/adapters/outbound/removeStoredInfraCredentialsAsync.d.ts +4 -0
- package/dist/features/environment-lifecycle/adapters/outbound/removeStoredInfraCredentialsAsync.d.ts.map +1 -0
- package/dist/features/environment-lifecycle/adapters/outbound/removeStoredInfraCredentialsAsync.js +9 -0
- package/dist/features/environment-lifecycle/adapters/outbound/removeStoredInfraCredentialsAsync.js.map +1 -0
- 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/dist/features/environment-lifecycle/composition/createInfraCommandServices.d.ts.map +1 -1
- package/dist/features/environment-lifecycle/composition/createInfraCommandServices.js +9 -3
- package/dist/features/environment-lifecycle/composition/createInfraCommandServices.js.map +1 -1
- package/dist/features/environment-lifecycle/composition/createProjectInfraLifecycle.d.ts.map +1 -1
- package/dist/features/environment-lifecycle/composition/createProjectInfraLifecycle.js +28 -2
- package/dist/features/environment-lifecycle/composition/createProjectInfraLifecycle.js.map +1 -1
- package/dist/features/environment-lifecycle/utils/resolveInfraEnvironmentStateDirectory.d.ts +4 -0
- package/dist/features/environment-lifecycle/utils/resolveInfraEnvironmentStateDirectory.d.ts.map +1 -0
- package/dist/features/environment-lifecycle/utils/resolveInfraEnvironmentStateDirectory.js +11 -0
- package/dist/features/environment-lifecycle/utils/resolveInfraEnvironmentStateDirectory.js.map +1 -0
- package/dist/features/environment-lifecycle/utils/resolveInfraStatePath.d.ts +1 -1
- package/dist/features/environment-lifecycle/utils/resolveInfraStatePath.d.ts.map +1 -1
- package/dist/features/environment-lifecycle/utils/resolveInfraStatePath.js +2 -6
- package/dist/features/environment-lifecycle/utils/resolveInfraStatePath.js.map +1 -1
- package/dist/types/infraCli.d.ts +7 -1
- package/dist/types/infraCli.d.ts.map +1 -1
- package/dist/types/infraCli.js.map +1 -1
- package/package.json +3 -2
- package/src/cli/commands/destroy.ts +1 -0
- package/src/cli/utils/prepareInfraCommandOperationAsync.ts +4 -1
- package/src/environmentCredentialPort.test.ts +62 -0
- package/src/features/environment-lifecycle/adapters/outbound/createEnvironmentInfraCredentialPort.ts +55 -18
- package/src/features/environment-lifecycle/adapters/outbound/createProjectInfraCredentialPort.ts +182 -0
- package/src/features/environment-lifecycle/adapters/outbound/removeStoredInfraCredentialsAsync.ts +18 -0
- 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/features/environment-lifecycle/composition/createInfraCommandServices.ts +9 -3
- package/src/features/environment-lifecycle/composition/createProjectInfraLifecycle.ts +33 -4
- package/src/features/environment-lifecycle/utils/resolveInfraEnvironmentStateDirectory.ts +16 -0
- package/src/features/environment-lifecycle/utils/resolveInfraStatePath.ts +3 -6
- package/src/infraUp.test.ts +31 -3
- package/src/prepareInfraServices.test.ts +50 -0
- package/src/projectCredentialLifecycle.test.ts +161 -0
- package/src/projectCredentialStore.test.ts +151 -0
- package/src/types/infraCli.ts +11 -1
package/src/features/environment-lifecycle/adapters/outbound/createProjectInfraCredentialPort.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import type { AppEnvironmentId } from '@ankhorage/contracts/environments';
|
|
6
|
+
import type {
|
|
7
|
+
InfraControlPlaneCredentialRef,
|
|
8
|
+
InfraCredentialPort,
|
|
9
|
+
InfraResult,
|
|
10
|
+
} from '@ankhorage/contracts/infra';
|
|
11
|
+
import { isRecord, isRecordOf } from '@ankhorage/utility/object';
|
|
12
|
+
|
|
13
|
+
import { createInfraFailure } from '../../utils/createInfraFailure.js';
|
|
14
|
+
import { normalizeInfraEnvironmentKey } from '../../utils/normalizeInfraEnvironmentKey.js';
|
|
15
|
+
import { resolveInfraEnvironmentStateDirectory } from '../../utils/resolveInfraEnvironmentStateDirectory.js';
|
|
16
|
+
import { createEnvironmentInfraCredentialPort } from './createEnvironmentInfraCredentialPort.js';
|
|
17
|
+
|
|
18
|
+
/*** Resolve explicit environment overrides before durable project-local bootstrap credentials. */
|
|
19
|
+
export function createProjectInfraCredentialPort(
|
|
20
|
+
options: ProjectInfraCredentialPortOptions,
|
|
21
|
+
): InfraCredentialPort {
|
|
22
|
+
const environmentPort = createEnvironmentInfraCredentialPort(options.processEnvironment);
|
|
23
|
+
return {
|
|
24
|
+
findAsync: (reference) => findProjectCredentialAsync(options, environmentPort, reference),
|
|
25
|
+
async resolveAsync(reference) {
|
|
26
|
+
const found = await findProjectCredentialAsync(options, environmentPort, reference);
|
|
27
|
+
if (!found.ok) return found;
|
|
28
|
+
if (found.value !== null) {
|
|
29
|
+
return { ok: true, value: found.value, diagnostics: found.diagnostics };
|
|
30
|
+
}
|
|
31
|
+
return missingCredential(reference.name);
|
|
32
|
+
},
|
|
33
|
+
async persistAsync(reference, values) {
|
|
34
|
+
if (!isStringRecord(values)) {
|
|
35
|
+
return createInfraFailure(
|
|
36
|
+
'infra-control-plane-credential-invalid',
|
|
37
|
+
`Control-plane credential ${reference.name} must contain only string fields.`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return writeStoredCredentialAsync(options, reference, values);
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ProjectInfraCredentialPortOptions {
|
|
46
|
+
readonly projectPath: string;
|
|
47
|
+
readonly environment: AppEnvironmentId;
|
|
48
|
+
readonly processEnvironment: Readonly<Record<string, string | undefined>>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface StoredCredentialBundle {
|
|
52
|
+
readonly schemaVersion: 1;
|
|
53
|
+
readonly name: string;
|
|
54
|
+
readonly values: Readonly<Record<string, string>>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/*** Resolve environment precedence before falling through to project-local state. */
|
|
58
|
+
async function findProjectCredentialAsync(
|
|
59
|
+
options: ProjectInfraCredentialPortOptions,
|
|
60
|
+
environmentPort: InfraCredentialPort,
|
|
61
|
+
reference: InfraControlPlaneCredentialRef,
|
|
62
|
+
): Promise<InfraResult<Readonly<Record<string, string>> | null>> {
|
|
63
|
+
const overridden = await environmentPort.findAsync(reference);
|
|
64
|
+
if (!overridden.ok || overridden.value !== null) return overridden;
|
|
65
|
+
return readStoredCredentialAsync(options, reference);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/*** Read one exact credential bundle without enumerating or exposing sibling credentials. */
|
|
69
|
+
async function readStoredCredentialAsync(
|
|
70
|
+
options: ProjectInfraCredentialPortOptions,
|
|
71
|
+
reference: InfraControlPlaneCredentialRef,
|
|
72
|
+
): Promise<InfraResult<Readonly<Record<string, string>> | null>> {
|
|
73
|
+
const credentialPath = resolveCredentialPath(options, reference.name);
|
|
74
|
+
try {
|
|
75
|
+
const parsed: unknown = JSON.parse(await fs.readFile(credentialPath, 'utf8'));
|
|
76
|
+
if (!isStoredCredentialBundle(parsed) || parsed.name !== reference.name) {
|
|
77
|
+
return invalidCredentialStore(reference.name);
|
|
78
|
+
}
|
|
79
|
+
return { ok: true, value: parsed.values, diagnostics: [] };
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (isMissingFile(error)) return { ok: true, value: null, diagnostics: [] };
|
|
82
|
+
if (error instanceof SyntaxError) return invalidCredentialStore(reference.name);
|
|
83
|
+
return createInfraFailure(
|
|
84
|
+
'infra-control-plane-credential-store-read-failed',
|
|
85
|
+
`Control-plane credential ${reference.name} could not be read from project state.`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/*** Persist one opaque bundle atomically with restrictive local-host permissions. */
|
|
91
|
+
async function writeStoredCredentialAsync(
|
|
92
|
+
options: ProjectInfraCredentialPortOptions,
|
|
93
|
+
reference: InfraControlPlaneCredentialRef,
|
|
94
|
+
values: Readonly<Record<string, string>>,
|
|
95
|
+
): Promise<InfraResult<null>> {
|
|
96
|
+
const credentialPath = resolveCredentialPath(options, reference.name);
|
|
97
|
+
const directory = path.dirname(credentialPath);
|
|
98
|
+
const temporaryPath = `${credentialPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
99
|
+
const stored: StoredCredentialBundle = { schemaVersion: 1, name: reference.name, values };
|
|
100
|
+
try {
|
|
101
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
102
|
+
await restrictPermissionsAsync(directory, 0o700);
|
|
103
|
+
await fs.writeFile(temporaryPath, `${JSON.stringify(stored, null, 2)}\n`, {
|
|
104
|
+
encoding: 'utf8',
|
|
105
|
+
mode: 0o600,
|
|
106
|
+
flag: 'wx',
|
|
107
|
+
});
|
|
108
|
+
await fs.rename(temporaryPath, credentialPath);
|
|
109
|
+
await restrictPermissionsAsync(credentialPath, 0o600);
|
|
110
|
+
return { ok: true, value: null, diagnostics: [] };
|
|
111
|
+
} catch {
|
|
112
|
+
await removeTemporaryCredentialAsync(temporaryPath);
|
|
113
|
+
return createInfraFailure(
|
|
114
|
+
'infra-control-plane-credential-store-write-failed',
|
|
115
|
+
`Control-plane credential ${reference.name} could not be persisted to project state.`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/*** Remove an abandoned unique temporary credential file without masking the original write failure. */
|
|
121
|
+
async function removeTemporaryCredentialAsync(temporaryPath: string): Promise<void> {
|
|
122
|
+
try {
|
|
123
|
+
await fs.rm(temporaryPath, { force: true });
|
|
124
|
+
} catch {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/*** Resolve one credential to an opaque hash-named file inside its project/environment scope. */
|
|
130
|
+
function resolveCredentialPath(options: ProjectInfraCredentialPortOptions, name: string): string {
|
|
131
|
+
const digest = createHash('sha256').update(name).digest('hex');
|
|
132
|
+
return path.join(
|
|
133
|
+
resolveInfraEnvironmentStateDirectory(options.projectPath, options.environment),
|
|
134
|
+
'credentials',
|
|
135
|
+
`${digest}.json`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/*** Apply owner-only permissions where the host platform supports POSIX file modes. */
|
|
140
|
+
async function restrictPermissionsAsync(targetPath: string, mode: number): Promise<void> {
|
|
141
|
+
if (process.platform !== 'win32') await fs.chmod(targetPath, mode);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/*** Create the canonical missing-credential diagnostic after every trusted source was checked. */
|
|
145
|
+
function missingCredential(name: string): InfraResult<never> {
|
|
146
|
+
const prefixedName = `ANKH_INFRA_CREDENTIAL_${normalizeInfraEnvironmentKey(name)}`;
|
|
147
|
+
return createInfraFailure(
|
|
148
|
+
'infra-control-plane-credential-missing',
|
|
149
|
+
`Control-plane credential ${name} is unavailable. Set ${name} or ${prefixedName}.`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/*** Reject malformed persisted state without exposing or replacing any stored value. */
|
|
154
|
+
function invalidCredentialStore(name: string): InfraResult<never> {
|
|
155
|
+
return createInfraFailure(
|
|
156
|
+
'infra-control-plane-credential-store-invalid',
|
|
157
|
+
`Stored control-plane credential ${name} has an invalid project-state shape.`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/*** Recognize the private stored credential record. */
|
|
162
|
+
function isStoredCredentialBundle(value: unknown): value is StoredCredentialBundle {
|
|
163
|
+
if (!isRecord(value)) return false;
|
|
164
|
+
return (
|
|
165
|
+
value.schemaVersion === 1 && typeof value.name === 'string' && isStringRecord(value.values)
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/*** Recognize string-only opaque provider credential bundles. */
|
|
170
|
+
function isStringRecord(value: unknown): value is Readonly<Record<string, string>> {
|
|
171
|
+
return isRecordOf(value, isString);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/*** Narrow one unknown value to a string. */
|
|
175
|
+
function isString(value: unknown): value is string {
|
|
176
|
+
return typeof value === 'string';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/*** Recognize only the filesystem's absent-file failure. */
|
|
180
|
+
function isMissingFile(error: unknown): boolean {
|
|
181
|
+
return isRecord(error) && error.code === 'ENOENT';
|
|
182
|
+
}
|
package/src/features/environment-lifecycle/adapters/outbound/removeStoredInfraCredentialsAsync.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import type { AppEnvironmentId } from '@ankhorage/contracts/environments';
|
|
5
|
+
|
|
6
|
+
import { resolveInfraEnvironmentStateDirectory } from '../../utils/resolveInfraEnvironmentStateDirectory.js';
|
|
7
|
+
|
|
8
|
+
/*** Remove only persisted generated credential state for one explicit project environment. */
|
|
9
|
+
export async function removeStoredInfraCredentialsAsync(
|
|
10
|
+
projectPath: string,
|
|
11
|
+
environment: AppEnvironmentId,
|
|
12
|
+
): Promise<void> {
|
|
13
|
+
const credentialDirectory = path.join(
|
|
14
|
+
resolveInfraEnvironmentStateDirectory(projectPath, environment),
|
|
15
|
+
'credentials',
|
|
16
|
+
);
|
|
17
|
+
await fs.rm(credentialDirectory, { recursive: true, force: true });
|
|
18
|
+
}
|
|
@@ -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,
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { InfraCommandServices } from '../../../types/infraCli.js';
|
|
2
|
-
import { createEnvironmentInfraCredentialPort } from '../adapters/outbound/createEnvironmentInfraCredentialPort.js';
|
|
3
2
|
import { createEnvironmentInfraSecretPort } from '../adapters/outbound/createEnvironmentInfraSecretPort.js';
|
|
4
3
|
import { createNodeInfraAdapterPackageResolver } from '../adapters/outbound/createNodeInfraAdapterPackageResolver.js';
|
|
4
|
+
import { createProjectInfraCredentialPort } from '../adapters/outbound/createProjectInfraCredentialPort.js';
|
|
5
5
|
import { readStoredInfraStateAsync } from '../adapters/outbound/readStoredInfraStateAsync.js';
|
|
6
|
+
import { removeStoredInfraCredentialsAsync } from '../adapters/outbound/removeStoredInfraCredentialsAsync.js';
|
|
6
7
|
import { removeStoredInfraStateAsync } from '../adapters/outbound/removeStoredInfraStateAsync.js';
|
|
7
8
|
import { resolveInfraProjectAsync } from '../adapters/outbound/resolveInfraProjectAsync.js';
|
|
8
9
|
import { writeInfraGeneratedArtifactsAsync } from '../adapters/outbound/writeInfraGeneratedArtifactsAsync.js';
|
|
@@ -25,6 +26,7 @@ export function createInfraCommandServices(
|
|
|
25
26
|
readState: overrides.readState ?? readStoredInfraStateAsync,
|
|
26
27
|
writeState: overrides.writeState ?? writeStoredInfraStateAsync,
|
|
27
28
|
removeState: overrides.removeState ?? removeStoredInfraStateAsync,
|
|
29
|
+
removeCredentials: overrides.removeCredentials ?? removeStoredInfraCredentialsAsync,
|
|
28
30
|
writeArtifacts: overrides.writeArtifacts ?? writeInfraGeneratedArtifactsAsync,
|
|
29
31
|
operations: overrides.operations ?? {
|
|
30
32
|
validate: validateInfraEnvironmentAsync,
|
|
@@ -38,9 +40,13 @@ export function createInfraCommandServices(
|
|
|
38
40
|
},
|
|
39
41
|
createDependencies:
|
|
40
42
|
overrides.createDependencies ??
|
|
41
|
-
((context) => ({
|
|
43
|
+
((context, scope) => ({
|
|
42
44
|
adapterResolver: createNodeInfraAdapterPackageResolver(),
|
|
43
|
-
credentials:
|
|
45
|
+
credentials: createProjectInfraCredentialPort({
|
|
46
|
+
projectPath: scope.projectPath,
|
|
47
|
+
environment: scope.environment,
|
|
48
|
+
processEnvironment: context.env,
|
|
49
|
+
}),
|
|
44
50
|
secrets: createEnvironmentInfraSecretPort(context.env),
|
|
45
51
|
})),
|
|
46
52
|
};
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import type { InfraEnvironmentSpec, InfraManifest } from '@ankhorage/contracts/infra';
|
|
1
|
+
import type { InfraEnvironmentSpec, InfraManifest, InfraResult } from '@ankhorage/contracts/infra';
|
|
2
2
|
|
|
3
3
|
import { INFRA_PACKAGE_VERSION } from '../../../constants.js';
|
|
4
4
|
import type { InfraOperationRequest, InfraStoredState } from '../../../types/infraOrchestration.js';
|
|
5
5
|
import type {
|
|
6
6
|
CreateProjectInfraLifecycleOptions,
|
|
7
|
+
ProjectInfraDestroyRequest,
|
|
7
8
|
ProjectInfraLifecycle,
|
|
8
9
|
ProjectInfraOperationRequest,
|
|
9
10
|
} from '../../../types/infraProject.js';
|
|
@@ -72,6 +73,9 @@ export function createProjectInfraLifecycle(
|
|
|
72
73
|
async destroyAsync(request) {
|
|
73
74
|
const state = await services.readState(request.projectPath, request.environment);
|
|
74
75
|
if (state === null) {
|
|
76
|
+
const safety = validateCredentialStateDestroy(request);
|
|
77
|
+
if (!safety.ok) return safety;
|
|
78
|
+
await services.removeCredentials(request.projectPath, request.environment);
|
|
75
79
|
return {
|
|
76
80
|
ok: true,
|
|
77
81
|
value: { environment: request.environment, ledger: null },
|
|
@@ -89,9 +93,10 @@ export function createProjectInfraLifecycle(
|
|
|
89
93
|
);
|
|
90
94
|
if (!result.ok) return result;
|
|
91
95
|
await services.writeArtifacts(request.projectPath, [], prepared.state?.ledger);
|
|
92
|
-
if (result.value.ledger === null)
|
|
96
|
+
if (result.value.ledger === null) {
|
|
93
97
|
await services.removeState(request.projectPath, request.environment);
|
|
94
|
-
|
|
98
|
+
await services.removeCredentials(request.projectPath, request.environment);
|
|
99
|
+
} else {
|
|
95
100
|
await services.writeState(request.projectPath, {
|
|
96
101
|
schemaVersion: 1,
|
|
97
102
|
desired: prepared.desired,
|
|
@@ -142,10 +147,34 @@ async function prepareProjectOperationAsync(
|
|
|
142
147
|
...(state === null ? {} : { previous: state.ledger, previousDesired: state.desired }),
|
|
143
148
|
...(request.signal === undefined ? {} : { signal: request.signal }),
|
|
144
149
|
},
|
|
145
|
-
dependencies: services.createDependencies(context
|
|
150
|
+
dependencies: services.createDependencies(context, {
|
|
151
|
+
projectPath: request.projectPath,
|
|
152
|
+
environment: request.environment,
|
|
153
|
+
}),
|
|
146
154
|
};
|
|
147
155
|
}
|
|
148
156
|
|
|
157
|
+
/*** Require exact scope confirmation before deleting orphaned project credential state. */
|
|
158
|
+
function validateCredentialStateDestroy(request: ProjectInfraDestroyRequest): InfraResult<null> {
|
|
159
|
+
if (
|
|
160
|
+
request.confirmation.projectId !== request.projectId ||
|
|
161
|
+
request.confirmation.environment !== request.environment
|
|
162
|
+
) {
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
diagnostics: [
|
|
166
|
+
{
|
|
167
|
+
severity: 'error',
|
|
168
|
+
code: 'infra-destroy-confirmation-mismatch',
|
|
169
|
+
message:
|
|
170
|
+
'Infra destroy confirmation must exactly match the selected project and environment.',
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
return { ok: true, value: null, diagnostics: [] };
|
|
176
|
+
}
|
|
177
|
+
|
|
149
178
|
/*** Read one exact environment without inventing a fallback. */
|
|
150
179
|
function readEnvironment(
|
|
151
180
|
manifest: InfraManifest,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { AppEnvironmentId } from '@ankhorage/contracts/environments';
|
|
2
|
+
import { APP_ENVIRONMENT_IDS } from '@ankhorage/contracts/environments';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
/*** Build the canonical project-local state directory for one closed Infra environment. */
|
|
6
|
+
export function resolveInfraEnvironmentStateDirectory(
|
|
7
|
+
projectPath: string,
|
|
8
|
+
environment: AppEnvironmentId,
|
|
9
|
+
): string {
|
|
10
|
+
if (!APP_ENVIRONMENT_IDS.includes(environment)) {
|
|
11
|
+
throw new Error(`Invalid Infra state environment: ${environment}`);
|
|
12
|
+
}
|
|
13
|
+
return path.join(projectPath, INFRA_STATE_ROOT, environment);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const INFRA_STATE_ROOT = '.ankh/infra';
|
|
@@ -1,12 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { AppEnvironmentId } from '@ankhorage/contracts/environments';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
import { resolveInfraEnvironmentStateDirectory } from './resolveInfraEnvironmentStateDirectory.js';
|
|
5
5
|
|
|
6
6
|
/*** Build an exact state-file path from a closed canonical environment identifier. */
|
|
7
7
|
export function resolveInfraStatePath(projectPath: string, environment: AppEnvironmentId): string {
|
|
8
|
-
|
|
9
|
-
throw new Error(`Invalid Infra state environment: ${environment}`);
|
|
10
|
-
}
|
|
11
|
-
return path.join(projectPath, INFRA_STATE_ROOT, environment, 'state.json');
|
|
8
|
+
return path.join(resolveInfraEnvironmentStateDirectory(projectPath, environment), 'state.json');
|
|
12
9
|
}
|
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,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
|
+
}
|