@ankhorage/infra 5.1.4 → 5.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,322 @@
1
+ import type {
2
+ InfraComputeAdapter,
3
+ InfraComputeSnapshot,
4
+ InfraManifest,
5
+ InfraPlanAction,
6
+ InfraReconcileResult,
7
+ InfraResourceStatus,
8
+ InfraResult,
9
+ } from '@ankhorage/contracts/infra';
10
+ import { infraAdapterDescriptor as hetznerInfraAdapterDescriptor } from '@ankhorage/hetzner';
11
+ import {
12
+ createInfraAdapter as createK3sInfraAdapter,
13
+ infraAdapterDescriptor as k3sInfraAdapterDescriptor,
14
+ type K3sClusterObservation,
15
+ type K3sClusterSpec,
16
+ type K3sControlPlane,
17
+ type K3sNodeAccess,
18
+ } from '@ankhorage/k3s';
19
+ import { expect, test } from 'bun:test';
20
+
21
+ import { type InfraOrchestrationDependencies, upInfraEnvironmentAsync } from './index.js';
22
+
23
+ const projectId = 'infra162-server-runtime-environment';
24
+ const serverImage = 'example/server:production';
25
+
26
+ const manifest = {
27
+ environments: {
28
+ local: {
29
+ deployment: {
30
+ compute: { provider: 'local' },
31
+ runtime: { provider: 'docker-compose' },
32
+ },
33
+ },
34
+ preview: {
35
+ deployment: {
36
+ compute: { provider: 'hetzner', location: 'fsn1' },
37
+ runtime: { provider: 'k3s', topology: { servers: 1, agents: 0 } },
38
+ },
39
+ workloads: [serverWorkload('development')],
40
+ },
41
+ production: {
42
+ deployment: {
43
+ compute: { provider: 'hetzner', location: 'fsn1' },
44
+ runtime: { provider: 'k3s', topology: { servers: 1, agents: 0 } },
45
+ },
46
+ workloads: [serverWorkload('production')],
47
+ },
48
+ },
49
+ modules: [],
50
+ } as const satisfies InfraManifest;
51
+
52
+ test('projects explicit production server environment through the generic k3s workload path', async () => {
53
+ const fixture = new ProductionServerFixture();
54
+ const dependencies = createDependencies(fixture);
55
+
56
+ const first = requireSuccess(
57
+ await upInfraEnvironmentAsync({ projectId, manifest, environment: 'production' }, dependencies),
58
+ );
59
+ const firstProjection = fixture.kubernetes.serializedResources();
60
+
61
+ expect(first.environment).toBe('production');
62
+ expect(firstProjection).toContain('"name":"NODE_ENV","value":"production"');
63
+ expect(fixture.k3s.loadedImages).toEqual([serverImage]);
64
+
65
+ requireSuccess(
66
+ await upInfraEnvironmentAsync(
67
+ {
68
+ projectId,
69
+ manifest,
70
+ environment: 'production',
71
+ previous: first.ledger,
72
+ },
73
+ dependencies,
74
+ ),
75
+ );
76
+
77
+ expect(fixture.kubernetes.serializedResources()).toBe(firstProjection);
78
+ });
79
+
80
+ test('preserves an explicitly non-production server environment without rewriting it', async () => {
81
+ const fixture = new ProductionServerFixture();
82
+
83
+ const result = requireSuccess(
84
+ await upInfraEnvironmentAsync(
85
+ { projectId, manifest, environment: 'preview' },
86
+ createDependencies(fixture),
87
+ ),
88
+ );
89
+ const projection = fixture.kubernetes.serializedResources();
90
+
91
+ expect(result.environment).toBe('preview');
92
+ expect(projection).toContain('"name":"NODE_ENV","value":"development"');
93
+ expect(projection).not.toContain('"name":"NODE_ENV","value":"production"');
94
+ });
95
+
96
+ function serverWorkload(nodeEnvironment: 'development' | 'production') {
97
+ return {
98
+ id: 'server',
99
+ artifact: { kind: 'image' as const, image: serverImage },
100
+ environment: {
101
+ NODE_ENV: { kind: 'literal' as const, value: nodeEnvironment },
102
+ },
103
+ };
104
+ }
105
+
106
+ class ProductionServerFixture {
107
+ readonly compute = new FakeHetznerComputeAdapter();
108
+ readonly k3s = new FakeK3sControlPlane();
109
+ readonly kubernetes = this.k3s.api;
110
+ }
111
+
112
+ function createDependencies(fixture: ProductionServerFixture): InfraOrchestrationDependencies {
113
+ return {
114
+ adapterResolver: {
115
+ loadAsync: (packageName) => {
116
+ switch (packageName) {
117
+ case hetznerInfraAdapterDescriptor.package:
118
+ return Promise.resolve({
119
+ infraAdapterDescriptor: hetznerInfraAdapterDescriptor,
120
+ createInfraAdapter: () => fixture.compute,
121
+ });
122
+ case k3sInfraAdapterDescriptor.package:
123
+ return Promise.resolve({
124
+ infraAdapterDescriptor: k3sInfraAdapterDescriptor,
125
+ createInfraAdapter: () => createK3sInfraAdapter({ controlPlane: fixture.k3s }),
126
+ });
127
+ default:
128
+ return Promise.reject(new Error(`Unexpected adapter package: ${packageName}`));
129
+ }
130
+ },
131
+ },
132
+ credentials: {
133
+ resolveAsync: () => Promise.resolve(success({ token: 'fixture-token' })),
134
+ },
135
+ secrets: {
136
+ resolveAsync: () =>
137
+ Promise.resolve({
138
+ ok: false,
139
+ diagnostics: [
140
+ { severity: 'error', code: 'unexpected-secret', message: 'No secret is expected.' },
141
+ ],
142
+ }),
143
+ },
144
+ };
145
+ }
146
+
147
+ class FakeHetznerComputeAdapter implements InfraComputeAdapter<'hetzner'> {
148
+ readonly descriptor = hetznerInfraAdapterDescriptor;
149
+ readonly snapshot: InfraComputeSnapshot = {
150
+ targets: [
151
+ {
152
+ id: 'server',
153
+ kind: 'ssh-host',
154
+ os: 'linux',
155
+ architecture: 'amd64',
156
+ host: '203.0.113.162',
157
+ port: 22,
158
+ user: 'root',
159
+ credential: { source: 'control-plane', name: 'SERVER_SSH' },
160
+ hostKeyFingerprint: 'SHA256:infra162-server',
161
+ },
162
+ ],
163
+ resources: [],
164
+ outputs: [],
165
+ };
166
+
167
+ validateAsync(): Promise<InfraResult<null>> {
168
+ return Promise.resolve(success(null));
169
+ }
170
+
171
+ inspectAsync(): Promise<InfraResult<InfraComputeSnapshot>> {
172
+ return Promise.resolve(success(this.snapshot));
173
+ }
174
+
175
+ planAsync(): Promise<InfraResult<readonly InfraPlanAction[]>> {
176
+ return Promise.resolve(success([]));
177
+ }
178
+
179
+ ensureAsync(): Promise<InfraResult<InfraComputeSnapshot>> {
180
+ return Promise.resolve(success(this.snapshot));
181
+ }
182
+
183
+ statusAsync(): Promise<InfraResult<readonly InfraResourceStatus[]>> {
184
+ return Promise.resolve(success([]));
185
+ }
186
+
187
+ destroyAsync(): Promise<InfraResult<InfraReconcileResult>> {
188
+ return Promise.resolve(success({ resources: [], outputs: [] }));
189
+ }
190
+ }
191
+
192
+ type KubernetesApi = NonNullable<K3sClusterObservation['api']>;
193
+ type KubernetesOwnershipQuery = Parameters<KubernetesApi['listOwnedAsync']>[0];
194
+ type KubernetesResource = Awaited<ReturnType<KubernetesApi['listOwnedAsync']>>[number];
195
+ type KubernetesResourceReference = Parameters<KubernetesApi['deleteAsync']>[0];
196
+ type KubernetesResourceObservation = Awaited<ReturnType<KubernetesApi['observeAsync']>>;
197
+
198
+ class FakeK3sControlPlane implements K3sControlPlane {
199
+ readonly api = new FakeKubernetesApi();
200
+ loadedImages: readonly string[] = [];
201
+ state: K3sClusterObservation['state'] = 'absent';
202
+ nodes: K3sClusterObservation['nodes'] = [];
203
+
204
+ validateAsync(): Promise<InfraResult<null>> {
205
+ return Promise.resolve(success(null));
206
+ }
207
+
208
+ inspectAsync(): Promise<InfraResult<K3sClusterObservation>> {
209
+ return Promise.resolve(success(this.observation()));
210
+ }
211
+
212
+ ensureAsync(spec: K3sClusterSpec): Promise<InfraResult<K3sClusterObservation>> {
213
+ this.state = 'ready';
214
+ this.nodes = spec.nodes.map(({ id }) => ({
215
+ id,
216
+ state: 'ready',
217
+ configurationMatches: true,
218
+ }));
219
+ return Promise.resolve(success(this.observation()));
220
+ }
221
+
222
+ waitUntilReadyAsync(): Promise<InfraResult<K3sClusterObservation>> {
223
+ return Promise.resolve(success(this.observation()));
224
+ }
225
+
226
+ loadImagesAsync(
227
+ _spec: K3sClusterSpec,
228
+ _access: readonly K3sNodeAccess[],
229
+ images: readonly string[],
230
+ ): Promise<InfraResult<null>> {
231
+ this.loadedImages = [...images];
232
+ return Promise.resolve(success(null));
233
+ }
234
+
235
+ suspendAsync(): Promise<InfraResult<null>> {
236
+ this.state = 'stopped';
237
+ this.nodes = this.nodes.map((node) => ({ ...node, state: 'stopped' }));
238
+ return Promise.resolve(success(null));
239
+ }
240
+
241
+ destroyAsync(): Promise<InfraResult<null>> {
242
+ this.state = 'absent';
243
+ this.nodes = [];
244
+ return Promise.resolve(success(null));
245
+ }
246
+
247
+ private observation(): K3sClusterObservation {
248
+ return {
249
+ state: this.state,
250
+ configurationMatches: true,
251
+ nodes: this.nodes,
252
+ ...(this.state === 'ready' ? { api: this.api } : {}),
253
+ };
254
+ }
255
+ }
256
+
257
+ class FakeKubernetesApi implements KubernetesApi {
258
+ readonly resources: KubernetesResource[] = [];
259
+
260
+ listOwnedAsync(query: KubernetesOwnershipQuery): Promise<readonly KubernetesResource[]> {
261
+ return Promise.resolve(
262
+ this.resources.filter(({ metadata }) => includesLabels(metadata.labels, query.labels)),
263
+ );
264
+ }
265
+
266
+ applyAsync(resource: KubernetesResource): Promise<void> {
267
+ const index = this.resources.findIndex((candidate) => sameResource(candidate, resource));
268
+ if (index === -1) this.resources.push(resource);
269
+ else this.resources.splice(index, 1, resource);
270
+ return Promise.resolve();
271
+ }
272
+
273
+ deleteAsync(reference: KubernetesResourceReference): Promise<void> {
274
+ const index = this.resources.findIndex((resource) => sameResource(resource, reference));
275
+ if (index >= 0) this.resources.splice(index, 1);
276
+ return Promise.resolve();
277
+ }
278
+
279
+ observeAsync(): Promise<KubernetesResourceObservation> {
280
+ return Promise.resolve({ state: 'ready' });
281
+ }
282
+
283
+ waitUntilReadyAsync(): Promise<KubernetesResourceObservation> {
284
+ return Promise.resolve({ state: 'ready' });
285
+ }
286
+
287
+ serializedResources(): string {
288
+ return JSON.stringify(this.resources);
289
+ }
290
+ }
291
+
292
+ function includesLabels(
293
+ actual: Readonly<Record<string, string>>,
294
+ expected: Readonly<Record<string, string>>,
295
+ ): boolean {
296
+ return Object.entries(expected).every(([expectedKey, expectedValue]) =>
297
+ Object.entries(actual).some(
298
+ ([actualKey, actualValue]) => actualKey === expectedKey && actualValue === expectedValue,
299
+ ),
300
+ );
301
+ }
302
+
303
+ function sameResource(
304
+ left: KubernetesResource,
305
+ right: KubernetesResource | KubernetesResourceReference,
306
+ ): boolean {
307
+ return (
308
+ left.apiVersion === right.apiVersion &&
309
+ left.kind === right.kind &&
310
+ left.metadata.name === ('metadata' in right ? right.metadata.name : right.name) &&
311
+ left.metadata.namespace === ('metadata' in right ? right.metadata.namespace : right.namespace)
312
+ );
313
+ }
314
+
315
+ function requireSuccess<T>(result: InfraResult<T>): T {
316
+ if (!result.ok) throw new Error(result.diagnostics.map(({ message }) => message).join(' '));
317
+ return result.value;
318
+ }
319
+
320
+ function success<T>(value: T): InfraResult<T> {
321
+ return { ok: true, value, diagnostics: [] };
322
+ }
@@ -18,6 +18,35 @@ afterEach(async () => {
18
18
  temporaryPaths.clear();
19
19
  });
20
20
 
21
+ test('destroy is idempotent without stored ownership state or provider resolution', async () => {
22
+ const projectPath = await fs.mkdtemp(path.join(os.tmpdir(), 'infra-project-lifecycle-'));
23
+ temporaryPaths.add(projectPath);
24
+ const destroyRequests: InfraDestroyOperationRequest[] = [];
25
+ const lifecycle = createProjectInfraLifecycle({
26
+ services: {
27
+ createDependencies: () => {
28
+ throw new Error('Provider dependencies must not resolve without stored ownership state.');
29
+ },
30
+ operations: createOperations(destroyRequests),
31
+ },
32
+ });
33
+ const request = {
34
+ projectId: 'sample',
35
+ projectPath,
36
+ manifest: { environments: { local: desired }, modules: [] },
37
+ environment: 'local' as const,
38
+ };
39
+
40
+ const destroyed = await lifecycle.destroyAsync({
41
+ ...request,
42
+ confirmation: { projectId: 'sample', environment: 'local' },
43
+ persistence: { policy: 'retain' },
44
+ });
45
+
46
+ expect(destroyed).toEqual(success({ environment: 'local', ledger: null }));
47
+ expect(destroyRequests).toEqual([]);
48
+ });
49
+
21
50
  test('runs typed project lifecycle operations with canonical state and artifact ownership', async () => {
22
51
  const projectPath = await fs.mkdtemp(path.join(os.tmpdir(), 'infra-project-lifecycle-'));
23
52
  temporaryPaths.add(projectPath);