kadi-deploy 0.19.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/.env.example +6 -0
- package/.prettierrc +6 -0
- package/README.md +589 -0
- package/agent.json +23 -0
- package/index.js +11 -0
- package/package.json +42 -0
- package/quick-command.txt +92 -0
- package/scripts/preflight.js +458 -0
- package/scripts/preflight.sh +300 -0
- package/src/cli/bid-selector.ts +222 -0
- package/src/cli/colors.ts +216 -0
- package/src/cli/index.ts +11 -0
- package/src/cli/prompts.ts +190 -0
- package/src/cli/spinners.ts +165 -0
- package/src/commands/deploy-local.ts +475 -0
- package/src/commands/deploy.ts +1342 -0
- package/src/commands/down.ts +679 -0
- package/src/commands/index.ts +10 -0
- package/src/commands/lock.ts +571 -0
- package/src/config/agent-loader.ts +177 -0
- package/src/config/index.ts +9 -0
- package/src/display/deployment-info.ts +220 -0
- package/src/display/pricing.ts +137 -0
- package/src/display/resources.ts +234 -0
- package/src/enhanced-registry-manager.ts +892 -0
- package/src/index.ts +307 -0
- package/src/infrastructure/registry.ts +269 -0
- package/src/schemas/profiles.ts +529 -0
- package/src/secrets/broker-urls.ts +109 -0
- package/src/secrets/handshake.ts +407 -0
- package/src/secrets/index.ts +69 -0
- package/src/secrets/inject-env.ts +171 -0
- package/src/secrets/nonce.ts +31 -0
- package/src/secrets/normalize.ts +204 -0
- package/src/secrets/prepare.ts +152 -0
- package/src/secrets/validate.ts +243 -0
- package/src/secrets/vault.ts +80 -0
- package/src/types/akash.ts +116 -0
- package/src/types/container-registry-ability.d.ts +158 -0
- package/src/types/external.ts +49 -0
- package/src/types.ts +211 -0
- package/src/utils/akt-price.ts +74 -0
- package/tests/agent-loader.test.ts +239 -0
- package/tests/autonomous.test.ts +244 -0
- package/tests/down.test.ts +1143 -0
- package/tests/lock.test.ts +1148 -0
- package/tests/nonce.test.ts +34 -0
- package/tests/normalize.test.ts +270 -0
- package/tests/secrets-schema.test.ts +301 -0
- package/tests/types.test.ts +198 -0
- package/tsconfig.json +18 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,1148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deployment Lock File Tests (v3 — multi-instance)
|
|
3
|
+
*
|
|
4
|
+
* Tests for the `.kadi-deploy.lock` file management:
|
|
5
|
+
* - writeLockFile / readLockFile round-trip (v3 format)
|
|
6
|
+
* - Multi-instance per profile behavior
|
|
7
|
+
* - removeDeployment / getDeployment / getDeploymentByInstance / getDeploymentsByProfile / listDeployments
|
|
8
|
+
* - v1 → v3 migration
|
|
9
|
+
* - v2 → v3 migration
|
|
10
|
+
* - buildLocalLock / buildAkashLock convenience builders
|
|
11
|
+
* - instanceId generation and collision avoidance
|
|
12
|
+
* - Validation of malformed lock files
|
|
13
|
+
* - Missing file handling
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
17
|
+
import fs from 'node:fs/promises';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
LOCK_VERSION,
|
|
23
|
+
LOCK_FILENAME,
|
|
24
|
+
lockFilePath,
|
|
25
|
+
writeLockFile,
|
|
26
|
+
readLockFile,
|
|
27
|
+
deleteLockFile,
|
|
28
|
+
removeDeployment,
|
|
29
|
+
getDeployment,
|
|
30
|
+
getDeploymentByInstance,
|
|
31
|
+
getDeploymentsByProfile,
|
|
32
|
+
listDeployments,
|
|
33
|
+
migrateV1toV2,
|
|
34
|
+
migrateV1toV3,
|
|
35
|
+
migrateV2toV3,
|
|
36
|
+
generateInstanceId,
|
|
37
|
+
deploymentKey,
|
|
38
|
+
parseDeploymentKey,
|
|
39
|
+
buildLocalLock,
|
|
40
|
+
buildAkashLock,
|
|
41
|
+
type DeploymentLock,
|
|
42
|
+
type LockFile,
|
|
43
|
+
type LocalDeploymentLock,
|
|
44
|
+
type AkashDeploymentLock,
|
|
45
|
+
} from '../src/commands/lock.js';
|
|
46
|
+
|
|
47
|
+
// ─────────────────────────────────────────────────────────
|
|
48
|
+
// Fixtures
|
|
49
|
+
// ─────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
const LOCAL_ENTRY: DeploymentLock = {
|
|
52
|
+
instanceId: 'a1b2',
|
|
53
|
+
target: 'local',
|
|
54
|
+
profile: 'dev',
|
|
55
|
+
deployedAt: '2026-02-25T12:00:00.000Z',
|
|
56
|
+
local: {
|
|
57
|
+
composePath: '/tmp/test-project/docker-compose.yml',
|
|
58
|
+
engine: 'docker',
|
|
59
|
+
network: 'kadi-net',
|
|
60
|
+
services: ['web', 'api'],
|
|
61
|
+
containers: { web: 'abc123', api: 'def456' },
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const AKASH_ENTRY: DeploymentLock = {
|
|
66
|
+
instanceId: 'c3d4',
|
|
67
|
+
target: 'akash',
|
|
68
|
+
profile: 'production',
|
|
69
|
+
deployedAt: '2026-02-25T12:00:00.000Z',
|
|
70
|
+
akash: {
|
|
71
|
+
dseq: 12345678,
|
|
72
|
+
owner: 'akash1abc123def456',
|
|
73
|
+
provider: 'akash1provider789',
|
|
74
|
+
providerUri: 'https://provider.example.com',
|
|
75
|
+
network: 'mainnet',
|
|
76
|
+
gseq: 1,
|
|
77
|
+
oseq: 1,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// ─────────────────────────────────────────────────────────
|
|
82
|
+
// Test helpers
|
|
83
|
+
// ─────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
let tmpDir: string;
|
|
86
|
+
|
|
87
|
+
beforeEach(async () => {
|
|
88
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'kadi-deploy-lock-test-'));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
afterEach(async () => {
|
|
92
|
+
try {
|
|
93
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
94
|
+
} catch {
|
|
95
|
+
// ignore cleanup errors
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ─────────────────────────────────────────────────────────
|
|
100
|
+
// Constants
|
|
101
|
+
// ─────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
describe('constants', () => {
|
|
104
|
+
it('LOCK_VERSION is 3', () => {
|
|
105
|
+
expect(LOCK_VERSION).toBe(3);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('LOCK_FILENAME is .kadi-deploy.lock', () => {
|
|
109
|
+
expect(LOCK_FILENAME).toBe('.kadi-deploy.lock');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ─────────────────────────────────────────────────────────
|
|
114
|
+
// lockFilePath
|
|
115
|
+
// ─────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
describe('lockFilePath', () => {
|
|
118
|
+
it('returns the correct path', () => {
|
|
119
|
+
expect(lockFilePath('/my/project')).toBe(
|
|
120
|
+
path.join('/my/project', LOCK_FILENAME)
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ─────────────────────────────────────────────────────────
|
|
126
|
+
// writeLockFile + readLockFile round-trip (v2)
|
|
127
|
+
// ─────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
describe('writeLockFile + readLockFile', () => {
|
|
130
|
+
it('round-trips a local deployment entry', async () => {
|
|
131
|
+
await writeLockFile(tmpDir, LOCAL_ENTRY);
|
|
132
|
+
const lockFile = await readLockFile(tmpDir);
|
|
133
|
+
|
|
134
|
+
expect(lockFile).not.toBeNull();
|
|
135
|
+
expect(lockFile!.version).toBe(LOCK_VERSION);
|
|
136
|
+
|
|
137
|
+
const entries = Object.values(lockFile!.deployments);
|
|
138
|
+
expect(entries).toHaveLength(1);
|
|
139
|
+
|
|
140
|
+
const entry = entries[0];
|
|
141
|
+
expect(entry.target).toBe('local');
|
|
142
|
+
expect(entry.profile).toBe('dev');
|
|
143
|
+
expect(entry.instanceId).toBe('a1b2');
|
|
144
|
+
expect(entry.local).toBeDefined();
|
|
145
|
+
expect(entry.local!.engine).toBe('docker');
|
|
146
|
+
expect(entry.local!.services).toEqual(['web', 'api']);
|
|
147
|
+
expect(entry.local!.containers).toEqual({ web: 'abc123', api: 'def456' });
|
|
148
|
+
expect(entry.local!.composePath).toBe('/tmp/test-project/docker-compose.yml');
|
|
149
|
+
expect(entry.local!.network).toBe('kadi-net');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('round-trips an Akash deployment entry', async () => {
|
|
153
|
+
await writeLockFile(tmpDir, AKASH_ENTRY);
|
|
154
|
+
const lockFile = await readLockFile(tmpDir);
|
|
155
|
+
|
|
156
|
+
expect(lockFile).not.toBeNull();
|
|
157
|
+
expect(lockFile!.version).toBe(LOCK_VERSION);
|
|
158
|
+
|
|
159
|
+
const entries = Object.values(lockFile!.deployments);
|
|
160
|
+
expect(entries).toHaveLength(1);
|
|
161
|
+
|
|
162
|
+
const entry = entries[0];
|
|
163
|
+
expect(entry.target).toBe('akash');
|
|
164
|
+
expect(entry.profile).toBe('production');
|
|
165
|
+
expect(entry.instanceId).toBe('c3d4');
|
|
166
|
+
expect(entry.akash).toBeDefined();
|
|
167
|
+
expect(entry.akash!.dseq).toBe(12345678);
|
|
168
|
+
expect(entry.akash!.owner).toBe('akash1abc123def456');
|
|
169
|
+
expect(entry.akash!.provider).toBe('akash1provider789');
|
|
170
|
+
expect(entry.akash!.providerUri).toBe('https://provider.example.com');
|
|
171
|
+
expect(entry.akash!.network).toBe('mainnet');
|
|
172
|
+
expect(entry.akash!.gseq).toBe(1);
|
|
173
|
+
expect(entry.akash!.oseq).toBe(1);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('preserves deployedAt as ISO string', async () => {
|
|
177
|
+
await writeLockFile(tmpDir, AKASH_ENTRY);
|
|
178
|
+
const lockFile = await readLockFile(tmpDir);
|
|
179
|
+
const entry = Object.values(lockFile!.deployments)[0];
|
|
180
|
+
expect(entry.deployedAt).toBe('2026-02-25T12:00:00.000Z');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('writes valid JSON with trailing newline', async () => {
|
|
184
|
+
await writeLockFile(tmpDir, LOCAL_ENTRY);
|
|
185
|
+
const raw = await fs.readFile(lockFilePath(tmpDir), 'utf-8');
|
|
186
|
+
expect(raw.endsWith('\n')).toBe(true);
|
|
187
|
+
expect(() => JSON.parse(raw)).not.toThrow();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('writes v3 format with version and deployments map', async () => {
|
|
191
|
+
await writeLockFile(tmpDir, LOCAL_ENTRY);
|
|
192
|
+
const raw = await fs.readFile(lockFilePath(tmpDir), 'utf-8');
|
|
193
|
+
const data = JSON.parse(raw);
|
|
194
|
+
expect(data.version).toBe(3);
|
|
195
|
+
expect(data.deployments).toBeDefined();
|
|
196
|
+
expect(typeof data.deployments).toBe('object');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('uses composite key {profile}:{instanceId}', async () => {
|
|
200
|
+
await writeLockFile(tmpDir, LOCAL_ENTRY);
|
|
201
|
+
const raw = await fs.readFile(lockFilePath(tmpDir), 'utf-8');
|
|
202
|
+
const data = JSON.parse(raw);
|
|
203
|
+
const keys = Object.keys(data.deployments);
|
|
204
|
+
expect(keys).toEqual(['dev:a1b2']);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('returns instanceId from writeLockFile', async () => {
|
|
208
|
+
const instanceId = await writeLockFile(tmpDir, LOCAL_ENTRY);
|
|
209
|
+
expect(instanceId).toBe('a1b2');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('generates instanceId when not provided', async () => {
|
|
213
|
+
const entry: DeploymentLock = {
|
|
214
|
+
instanceId: '', // empty — should be auto-generated
|
|
215
|
+
target: 'local',
|
|
216
|
+
profile: 'test',
|
|
217
|
+
deployedAt: '2026-02-25T12:00:00.000Z',
|
|
218
|
+
local: LOCAL_ENTRY.local,
|
|
219
|
+
};
|
|
220
|
+
const instanceId = await writeLockFile(tmpDir, entry);
|
|
221
|
+
expect(instanceId).toHaveLength(4);
|
|
222
|
+
expect(/^[0-9a-f]{4}$/.test(instanceId)).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// ─────────────────────────────────────────────────────────
|
|
227
|
+
// Multi-deployment merge behavior
|
|
228
|
+
// ─────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
describe('multi-deployment merge', () => {
|
|
231
|
+
it('merges multiple deployments into the same lock file', async () => {
|
|
232
|
+
await writeLockFile(tmpDir, LOCAL_ENTRY);
|
|
233
|
+
await writeLockFile(tmpDir, AKASH_ENTRY);
|
|
234
|
+
|
|
235
|
+
const lockFile = await readLockFile(tmpDir);
|
|
236
|
+
expect(lockFile).not.toBeNull();
|
|
237
|
+
|
|
238
|
+
const entries = Object.values(lockFile!.deployments);
|
|
239
|
+
expect(entries).toHaveLength(2);
|
|
240
|
+
|
|
241
|
+
const profiles = entries.map(e => e.profile).sort();
|
|
242
|
+
expect(profiles).toEqual(['dev', 'production']);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('does NOT overwrite same profile — creates a new instance', async () => {
|
|
246
|
+
await writeLockFile(tmpDir, LOCAL_ENTRY);
|
|
247
|
+
|
|
248
|
+
const updated: DeploymentLock = {
|
|
249
|
+
instanceId: 'e5f6',
|
|
250
|
+
target: 'local',
|
|
251
|
+
profile: 'dev',
|
|
252
|
+
deployedAt: '2026-03-01T00:00:00.000Z',
|
|
253
|
+
local: LOCAL_ENTRY.local,
|
|
254
|
+
};
|
|
255
|
+
await writeLockFile(tmpDir, updated);
|
|
256
|
+
|
|
257
|
+
const lockFile = await readLockFile(tmpDir);
|
|
258
|
+
const entries = Object.values(lockFile!.deployments);
|
|
259
|
+
expect(entries).toHaveLength(2);
|
|
260
|
+
|
|
261
|
+
const instanceIds = entries.map(e => e.instanceId).sort();
|
|
262
|
+
expect(instanceIds).toEqual(['a1b2', 'e5f6']);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('supports three simultaneous deployments', async () => {
|
|
266
|
+
await writeLockFile(tmpDir, LOCAL_ENTRY);
|
|
267
|
+
await writeLockFile(tmpDir, AKASH_ENTRY);
|
|
268
|
+
|
|
269
|
+
const staging: DeploymentLock = {
|
|
270
|
+
instanceId: 'f7f8',
|
|
271
|
+
target: 'akash',
|
|
272
|
+
profile: 'staging',
|
|
273
|
+
deployedAt: '2026-02-25T15:00:00.000Z',
|
|
274
|
+
akash: { ...AKASH_ENTRY.akash!, dseq: 99999 },
|
|
275
|
+
};
|
|
276
|
+
await writeLockFile(tmpDir, staging);
|
|
277
|
+
|
|
278
|
+
const lockFile = await readLockFile(tmpDir);
|
|
279
|
+
const entries = Object.values(lockFile!.deployments);
|
|
280
|
+
expect(entries).toHaveLength(3);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('allows multiple instances of the same profile', async () => {
|
|
284
|
+
// Deploy "local" profile twice with different instanceIds
|
|
285
|
+
const instance1: DeploymentLock = {
|
|
286
|
+
instanceId: 'aa11',
|
|
287
|
+
target: 'local',
|
|
288
|
+
profile: 'local',
|
|
289
|
+
deployedAt: '2026-03-01T10:00:00.000Z',
|
|
290
|
+
local: LOCAL_ENTRY.local,
|
|
291
|
+
};
|
|
292
|
+
const instance2: DeploymentLock = {
|
|
293
|
+
instanceId: 'bb22',
|
|
294
|
+
target: 'local',
|
|
295
|
+
profile: 'local',
|
|
296
|
+
deployedAt: '2026-03-01T11:00:00.000Z',
|
|
297
|
+
local: { ...LOCAL_ENTRY.local!, network: 'kadi-net-2' },
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
await writeLockFile(tmpDir, instance1);
|
|
301
|
+
await writeLockFile(tmpDir, instance2);
|
|
302
|
+
|
|
303
|
+
const lockFile = await readLockFile(tmpDir);
|
|
304
|
+
expect(Object.keys(lockFile!.deployments).sort()).toEqual(['local:aa11', 'local:bb22']);
|
|
305
|
+
|
|
306
|
+
// Both entries should exist with different data
|
|
307
|
+
const entries = Object.values(lockFile!.deployments);
|
|
308
|
+
expect(entries).toHaveLength(2);
|
|
309
|
+
expect(entries[0].profile).toBe('local');
|
|
310
|
+
expect(entries[1].profile).toBe('local');
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// ─────────────────────────────────────────────────────────
|
|
315
|
+
// readLockFile — edge cases
|
|
316
|
+
// ─────────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
describe('readLockFile edge cases', () => {
|
|
319
|
+
it('returns null when file does not exist', async () => {
|
|
320
|
+
const result = await readLockFile(tmpDir);
|
|
321
|
+
expect(result).toBeNull();
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('throws on invalid JSON', async () => {
|
|
325
|
+
await fs.writeFile(lockFilePath(tmpDir), 'not json!', 'utf-8');
|
|
326
|
+
await expect(readLockFile(tmpDir)).rejects.toThrow();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('throws when v3 is missing version', async () => {
|
|
330
|
+
await fs.writeFile(
|
|
331
|
+
lockFilePath(tmpDir),
|
|
332
|
+
JSON.stringify({ deployments: {} }),
|
|
333
|
+
'utf-8'
|
|
334
|
+
);
|
|
335
|
+
await expect(readLockFile(tmpDir)).rejects.toThrow(/missing required fields/);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('throws when v3 is missing deployments', async () => {
|
|
339
|
+
await fs.writeFile(
|
|
340
|
+
lockFilePath(tmpDir),
|
|
341
|
+
JSON.stringify({ version: 3 }),
|
|
342
|
+
'utf-8'
|
|
343
|
+
);
|
|
344
|
+
await expect(readLockFile(tmpDir)).rejects.toThrow(/missing required fields/);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('throws when entry target=local but local section missing', async () => {
|
|
348
|
+
const data: LockFile = {
|
|
349
|
+
version: 3,
|
|
350
|
+
deployments: {
|
|
351
|
+
'dev:ab12': { instanceId: 'ab12', target: 'local', profile: 'dev', deployedAt: new Date().toISOString() },
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
await fs.writeFile(lockFilePath(tmpDir), JSON.stringify(data), 'utf-8');
|
|
355
|
+
await expect(readLockFile(tmpDir)).rejects.toThrow(/"local" section is missing/);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('throws when entry target=akash but akash section missing', async () => {
|
|
359
|
+
const data: LockFile = {
|
|
360
|
+
version: 3,
|
|
361
|
+
deployments: {
|
|
362
|
+
'prod:cd34': { instanceId: 'cd34', target: 'akash', profile: 'prod', deployedAt: new Date().toISOString() },
|
|
363
|
+
},
|
|
364
|
+
};
|
|
365
|
+
await fs.writeFile(lockFilePath(tmpDir), JSON.stringify(data), 'utf-8');
|
|
366
|
+
await expect(readLockFile(tmpDir)).rejects.toThrow(/"akash" section is missing/);
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// ─────────────────────────────────────────────────────────
|
|
371
|
+
// v1 → v2 migration
|
|
372
|
+
// ─────────────────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
describe('v1 → v3 migration', () => {
|
|
375
|
+
it('migrateV1toV3 converts a v1 local lock', () => {
|
|
376
|
+
const v1 = {
|
|
377
|
+
version: 1,
|
|
378
|
+
target: 'local' as const,
|
|
379
|
+
profile: 'dev',
|
|
380
|
+
deployedAt: '2026-01-01T00:00:00.000Z',
|
|
381
|
+
local: LOCAL_ENTRY.local,
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const v3 = migrateV1toV3(v1);
|
|
385
|
+
expect(v3.version).toBe(LOCK_VERSION);
|
|
386
|
+
|
|
387
|
+
const entries = Object.values(v3.deployments);
|
|
388
|
+
expect(entries).toHaveLength(1);
|
|
389
|
+
expect(entries[0].target).toBe('local');
|
|
390
|
+
expect(entries[0].profile).toBe('dev');
|
|
391
|
+
expect(entries[0].instanceId).toHaveLength(4);
|
|
392
|
+
expect(entries[0].local).toEqual(LOCAL_ENTRY.local);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('migrateV1toV3 converts a v1 Akash lock', () => {
|
|
396
|
+
const v1 = {
|
|
397
|
+
version: 1,
|
|
398
|
+
target: 'akash' as const,
|
|
399
|
+
profile: 'production',
|
|
400
|
+
deployedAt: '2026-01-01T00:00:00.000Z',
|
|
401
|
+
akash: AKASH_ENTRY.akash,
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const v3 = migrateV1toV3(v1);
|
|
405
|
+
expect(v3.version).toBe(LOCK_VERSION);
|
|
406
|
+
|
|
407
|
+
const entries = Object.values(v3.deployments);
|
|
408
|
+
expect(entries).toHaveLength(1);
|
|
409
|
+
expect(entries[0].target).toBe('akash');
|
|
410
|
+
expect(entries[0].profile).toBe('production');
|
|
411
|
+
expect(entries[0].instanceId).toHaveLength(4);
|
|
412
|
+
expect(entries[0].akash).toEqual(AKASH_ENTRY.akash);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('migrateV1toV2 is a backward-compat alias for migrateV1toV3', () => {
|
|
416
|
+
const v1 = {
|
|
417
|
+
version: 1,
|
|
418
|
+
target: 'local' as const,
|
|
419
|
+
profile: 'dev',
|
|
420
|
+
deployedAt: '2026-01-01T00:00:00.000Z',
|
|
421
|
+
local: LOCAL_ENTRY.local,
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const result = migrateV1toV2(v1);
|
|
425
|
+
expect(result.version).toBe(LOCK_VERSION);
|
|
426
|
+
const entries = Object.values(result.deployments);
|
|
427
|
+
expect(entries).toHaveLength(1);
|
|
428
|
+
expect(entries[0].instanceId).toHaveLength(4);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('readLockFile transparently reads v1 files as v3', async () => {
|
|
432
|
+
// Write a raw v1-format lock file
|
|
433
|
+
const v1 = {
|
|
434
|
+
version: 1,
|
|
435
|
+
target: 'local',
|
|
436
|
+
profile: 'dev',
|
|
437
|
+
deployedAt: '2026-01-15T08:00:00.000Z',
|
|
438
|
+
local: LOCAL_ENTRY.local,
|
|
439
|
+
};
|
|
440
|
+
await fs.writeFile(lockFilePath(tmpDir), JSON.stringify(v1, null, 2), 'utf-8');
|
|
441
|
+
|
|
442
|
+
const lockFile = await readLockFile(tmpDir);
|
|
443
|
+
expect(lockFile).not.toBeNull();
|
|
444
|
+
expect(lockFile!.version).toBe(LOCK_VERSION);
|
|
445
|
+
|
|
446
|
+
const entries = Object.values(lockFile!.deployments);
|
|
447
|
+
expect(entries).toHaveLength(1);
|
|
448
|
+
expect(entries[0].target).toBe('local');
|
|
449
|
+
expect(entries[0].profile).toBe('dev');
|
|
450
|
+
expect(entries[0].instanceId).toHaveLength(4);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('readLockFile validates v1 entries during migration', async () => {
|
|
454
|
+
const v1 = {
|
|
455
|
+
version: 1,
|
|
456
|
+
target: 'local',
|
|
457
|
+
profile: 'dev',
|
|
458
|
+
deployedAt: '2026-01-15T08:00:00.000Z',
|
|
459
|
+
// missing local section
|
|
460
|
+
};
|
|
461
|
+
await fs.writeFile(lockFilePath(tmpDir), JSON.stringify(v1), 'utf-8');
|
|
462
|
+
await expect(readLockFile(tmpDir)).rejects.toThrow(/"local" section is missing/);
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// ─────────────────────────────────────────────────────────
|
|
467
|
+
// v2 → v3 migration
|
|
468
|
+
// ─────────────────────────────────────────────────────────
|
|
469
|
+
|
|
470
|
+
describe('v2 → v3 migration', () => {
|
|
471
|
+
it('migrates v2 lock file entries and assigns instanceIds', async () => {
|
|
472
|
+
// Write a raw v2-format lock file (keyed by profile name, no instanceId)
|
|
473
|
+
const v2 = {
|
|
474
|
+
version: 2,
|
|
475
|
+
deployments: {
|
|
476
|
+
dev: {
|
|
477
|
+
target: 'local',
|
|
478
|
+
profile: 'dev',
|
|
479
|
+
deployedAt: '2026-02-25T12:00:00.000Z',
|
|
480
|
+
local: LOCAL_ENTRY.local,
|
|
481
|
+
},
|
|
482
|
+
production: {
|
|
483
|
+
target: 'akash',
|
|
484
|
+
profile: 'production',
|
|
485
|
+
deployedAt: '2026-02-25T12:00:00.000Z',
|
|
486
|
+
akash: AKASH_ENTRY.akash,
|
|
487
|
+
},
|
|
488
|
+
},
|
|
489
|
+
};
|
|
490
|
+
await fs.writeFile(lockFilePath(tmpDir), JSON.stringify(v2, null, 2), 'utf-8');
|
|
491
|
+
|
|
492
|
+
const lockFile = await readLockFile(tmpDir);
|
|
493
|
+
expect(lockFile).not.toBeNull();
|
|
494
|
+
expect(lockFile!.version).toBe(LOCK_VERSION);
|
|
495
|
+
|
|
496
|
+
const entries = Object.values(lockFile!.deployments);
|
|
497
|
+
expect(entries).toHaveLength(2);
|
|
498
|
+
|
|
499
|
+
// Each entry should have a generated instanceId
|
|
500
|
+
for (const entry of entries) {
|
|
501
|
+
expect(entry.instanceId).toHaveLength(4);
|
|
502
|
+
expect(/^[0-9a-f]{4}$/.test(entry.instanceId)).toBe(true);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Keys should be composite
|
|
506
|
+
const keys = Object.keys(lockFile!.deployments);
|
|
507
|
+
for (const key of keys) {
|
|
508
|
+
expect(key).toContain(':');
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it('preserves deployment data during v2 → v3 migration', async () => {
|
|
513
|
+
const v2 = {
|
|
514
|
+
version: 2,
|
|
515
|
+
deployments: {
|
|
516
|
+
production: {
|
|
517
|
+
target: 'akash',
|
|
518
|
+
profile: 'production',
|
|
519
|
+
deployedAt: '2026-02-25T12:00:00.000Z',
|
|
520
|
+
akash: AKASH_ENTRY.akash,
|
|
521
|
+
},
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
await fs.writeFile(lockFilePath(tmpDir), JSON.stringify(v2, null, 2), 'utf-8');
|
|
525
|
+
|
|
526
|
+
const lockFile = await readLockFile(tmpDir);
|
|
527
|
+
const entry = Object.values(lockFile!.deployments)[0];
|
|
528
|
+
expect(entry.akash!.dseq).toBe(12345678);
|
|
529
|
+
expect(entry.akash!.owner).toBe('akash1abc123def456');
|
|
530
|
+
expect(entry.profile).toBe('production');
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
// ─────────────────────────────────────────────────────────
|
|
535
|
+
// removeDeployment
|
|
536
|
+
// ─────────────────────────────────────────────────────────
|
|
537
|
+
|
|
538
|
+
describe('removeDeployment', () => {
|
|
539
|
+
it('removes a deployment by profile+instanceId and keeps others', async () => {
|
|
540
|
+
const id1 = await writeLockFile(tmpDir, LOCAL_ENTRY);
|
|
541
|
+
const id2 = await writeLockFile(tmpDir, AKASH_ENTRY);
|
|
542
|
+
|
|
543
|
+
const removed = await removeDeployment(tmpDir, 'dev', id1);
|
|
544
|
+
expect(removed).toBe(true);
|
|
545
|
+
|
|
546
|
+
const lockFile = await readLockFile(tmpDir);
|
|
547
|
+
expect(lockFile).not.toBeNull();
|
|
548
|
+
const remaining = Object.values(lockFile!.deployments);
|
|
549
|
+
expect(remaining).toHaveLength(1);
|
|
550
|
+
expect(remaining[0].profile).toBe('production');
|
|
551
|
+
expect(remaining[0].instanceId).toBe(id2);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it('removes by profile alone when only one instance exists', async () => {
|
|
555
|
+
await writeLockFile(tmpDir, LOCAL_ENTRY);
|
|
556
|
+
await writeLockFile(tmpDir, AKASH_ENTRY);
|
|
557
|
+
|
|
558
|
+
const removed = await removeDeployment(tmpDir, 'dev');
|
|
559
|
+
expect(removed).toBe(true);
|
|
560
|
+
|
|
561
|
+
const lockFile = await readLockFile(tmpDir);
|
|
562
|
+
expect(lockFile).not.toBeNull();
|
|
563
|
+
const remaining = Object.values(lockFile!.deployments);
|
|
564
|
+
expect(remaining).toHaveLength(1);
|
|
565
|
+
expect(remaining[0].profile).toBe('production');
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it('deletes the lock file when last deployment is removed', async () => {
|
|
569
|
+
const id1 = await writeLockFile(tmpDir, LOCAL_ENTRY);
|
|
570
|
+
|
|
571
|
+
const removed = await removeDeployment(tmpDir, 'dev', id1);
|
|
572
|
+
expect(removed).toBe(true);
|
|
573
|
+
|
|
574
|
+
const lockFile = await readLockFile(tmpDir);
|
|
575
|
+
expect(lockFile).toBeNull();
|
|
576
|
+
|
|
577
|
+
// File should not exist on disk
|
|
578
|
+
const exists = await fs.stat(lockFilePath(tmpDir)).then(() => true).catch(() => false);
|
|
579
|
+
expect(exists).toBe(false);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it('returns false for a non-existent profile', async () => {
|
|
583
|
+
await writeLockFile(tmpDir, LOCAL_ENTRY);
|
|
584
|
+
const removed = await removeDeployment(tmpDir, 'nonexistent');
|
|
585
|
+
expect(removed).toBe(false);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it('returns false when no lock file exists', async () => {
|
|
589
|
+
const removed = await removeDeployment(tmpDir, 'anything');
|
|
590
|
+
expect(removed).toBe(false);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it('removes correct instance when multiple instances share a profile', async () => {
|
|
594
|
+
const id1 = await writeLockFile(tmpDir, { ...LOCAL_ENTRY, instanceId: '' });
|
|
595
|
+
const id2 = await writeLockFile(tmpDir, { ...LOCAL_ENTRY, instanceId: '' });
|
|
596
|
+
expect(id1).not.toBe(id2);
|
|
597
|
+
|
|
598
|
+
const removed = await removeDeployment(tmpDir, 'dev', id1);
|
|
599
|
+
expect(removed).toBe(true);
|
|
600
|
+
|
|
601
|
+
const lockFile = await readLockFile(tmpDir);
|
|
602
|
+
const remaining = Object.values(lockFile!.deployments);
|
|
603
|
+
expect(remaining).toHaveLength(1);
|
|
604
|
+
expect(remaining[0].instanceId).toBe(id2);
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
// ─────────────────────────────────────────────────────────
|
|
609
|
+
// getDeployment / getDeploymentByInstance / getDeploymentsByProfile
|
|
610
|
+
// ─────────────────────────────────────────────────────────
|
|
611
|
+
|
|
612
|
+
describe('getDeployment', () => {
|
|
613
|
+
it('returns a deployment by profile name', async () => {
|
|
614
|
+
await writeLockFile(tmpDir, LOCAL_ENTRY);
|
|
615
|
+
await writeLockFile(tmpDir, AKASH_ENTRY);
|
|
616
|
+
|
|
617
|
+
const entry = await getDeployment(tmpDir, 'dev');
|
|
618
|
+
expect(entry).not.toBeNull();
|
|
619
|
+
expect(entry!.target).toBe('local');
|
|
620
|
+
expect(entry!.profile).toBe('dev');
|
|
621
|
+
expect(entry!.instanceId).toHaveLength(4);
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it('returns null for non-existent profile', async () => {
|
|
625
|
+
await writeLockFile(tmpDir, LOCAL_ENTRY);
|
|
626
|
+
const entry = await getDeployment(tmpDir, 'nonexistent');
|
|
627
|
+
expect(entry).toBeNull();
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it('returns null when no lock file exists', async () => {
|
|
631
|
+
const entry = await getDeployment(tmpDir, 'dev');
|
|
632
|
+
expect(entry).toBeNull();
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
describe('getDeploymentByInstance', () => {
|
|
637
|
+
it('returns a deployment by instanceId', async () => {
|
|
638
|
+
const id = await writeLockFile(tmpDir, LOCAL_ENTRY);
|
|
639
|
+
const entry = await getDeploymentByInstance(tmpDir, id);
|
|
640
|
+
expect(entry).not.toBeNull();
|
|
641
|
+
expect(entry!.instanceId).toBe(id);
|
|
642
|
+
expect(entry!.profile).toBe('dev');
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it('returns null for non-existent instanceId', async () => {
|
|
646
|
+
await writeLockFile(tmpDir, LOCAL_ENTRY);
|
|
647
|
+
const entry = await getDeploymentByInstance(tmpDir, 'ffff');
|
|
648
|
+
expect(entry).toBeNull();
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it('returns null when no lock file exists', async () => {
|
|
652
|
+
const entry = await getDeploymentByInstance(tmpDir, 'abcd');
|
|
653
|
+
expect(entry).toBeNull();
|
|
654
|
+
});
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
describe('getDeploymentsByProfile', () => {
|
|
658
|
+
it('returns all instances for a given profile', async () => {
|
|
659
|
+
const id1 = await writeLockFile(tmpDir, { ...LOCAL_ENTRY, instanceId: '' });
|
|
660
|
+
const id2 = await writeLockFile(tmpDir, { ...LOCAL_ENTRY, instanceId: '' });
|
|
661
|
+
await writeLockFile(tmpDir, AKASH_ENTRY);
|
|
662
|
+
|
|
663
|
+
const results = await getDeploymentsByProfile(tmpDir, 'dev');
|
|
664
|
+
expect(results).toHaveLength(2);
|
|
665
|
+
const ids = results.map(([_, e]) => e.instanceId).sort();
|
|
666
|
+
expect(ids).toContain(id1);
|
|
667
|
+
expect(ids).toContain(id2);
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
it('returns empty array for non-existent profile', async () => {
|
|
671
|
+
await writeLockFile(tmpDir, LOCAL_ENTRY);
|
|
672
|
+
const results = await getDeploymentsByProfile(tmpDir, 'nonexistent');
|
|
673
|
+
expect(results).toEqual([]);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it('returns empty array when no lock file exists', async () => {
|
|
677
|
+
const results = await getDeploymentsByProfile(tmpDir, 'dev');
|
|
678
|
+
expect(results).toEqual([]);
|
|
679
|
+
});
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
// ─────────────────────────────────────────────────────────
|
|
683
|
+
// listDeployments
|
|
684
|
+
// ─────────────────────────────────────────────────────────
|
|
685
|
+
|
|
686
|
+
describe('listDeployments', () => {
|
|
687
|
+
it('lists all active deployments as tuples with composite keys', async () => {
|
|
688
|
+
const id1 = await writeLockFile(tmpDir, LOCAL_ENTRY);
|
|
689
|
+
const id2 = await writeLockFile(tmpDir, AKASH_ENTRY);
|
|
690
|
+
|
|
691
|
+
const list = await listDeployments(tmpDir);
|
|
692
|
+
expect(list).toHaveLength(2);
|
|
693
|
+
|
|
694
|
+
const keys = list.map(([key]) => key).sort();
|
|
695
|
+
expect(keys).toEqual([`dev:${id1}`, `production:${id2}`].sort());
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it('returns empty array when no lock file exists', async () => {
|
|
699
|
+
const list = await listDeployments(tmpDir);
|
|
700
|
+
expect(list).toEqual([]);
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it('returns single entry for one deployment', async () => {
|
|
704
|
+
const id = await writeLockFile(tmpDir, LOCAL_ENTRY);
|
|
705
|
+
const list = await listDeployments(tmpDir);
|
|
706
|
+
expect(list).toHaveLength(1);
|
|
707
|
+
expect(list[0][0]).toBe(`dev:${id}`);
|
|
708
|
+
expect(list[0][1].target).toBe('local');
|
|
709
|
+
expect(list[0][1].instanceId).toBe(id);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it('lists multiple instances of the same profile', async () => {
|
|
713
|
+
const id1 = await writeLockFile(tmpDir, { ...LOCAL_ENTRY, instanceId: '' });
|
|
714
|
+
const id2 = await writeLockFile(tmpDir, { ...LOCAL_ENTRY, instanceId: '' });
|
|
715
|
+
|
|
716
|
+
const list = await listDeployments(tmpDir);
|
|
717
|
+
expect(list).toHaveLength(2);
|
|
718
|
+
const ids = list.map(([_, e]) => e.instanceId).sort();
|
|
719
|
+
expect(ids).toContain(id1);
|
|
720
|
+
expect(ids).toContain(id2);
|
|
721
|
+
});
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
// ─────────────────────────────────────────────────────────
|
|
725
|
+
// deleteLockFile
|
|
726
|
+
// ─────────────────────────────────────────────────────────
|
|
727
|
+
|
|
728
|
+
describe('deleteLockFile', () => {
|
|
729
|
+
it('deletes existing lock file and returns true', async () => {
|
|
730
|
+
await writeLockFile(tmpDir, LOCAL_ENTRY);
|
|
731
|
+
|
|
732
|
+
const exists = await fs.stat(lockFilePath(tmpDir)).then(() => true).catch(() => false);
|
|
733
|
+
expect(exists).toBe(true);
|
|
734
|
+
|
|
735
|
+
const deleted = await deleteLockFile(tmpDir);
|
|
736
|
+
expect(deleted).toBe(true);
|
|
737
|
+
|
|
738
|
+
const existsAfter = await fs.stat(lockFilePath(tmpDir)).then(() => true).catch(() => false);
|
|
739
|
+
expect(existsAfter).toBe(false);
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
it('returns false when file does not exist', async () => {
|
|
743
|
+
const deleted = await deleteLockFile(tmpDir);
|
|
744
|
+
expect(deleted).toBe(false);
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
it('is idempotent', async () => {
|
|
748
|
+
await writeLockFile(tmpDir, LOCAL_ENTRY);
|
|
749
|
+
expect(await deleteLockFile(tmpDir)).toBe(true);
|
|
750
|
+
expect(await deleteLockFile(tmpDir)).toBe(false);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it('removes the file so readLockFile returns null', async () => {
|
|
754
|
+
await writeLockFile(tmpDir, AKASH_ENTRY);
|
|
755
|
+
expect(await readLockFile(tmpDir)).not.toBeNull();
|
|
756
|
+
|
|
757
|
+
await deleteLockFile(tmpDir);
|
|
758
|
+
expect(await readLockFile(tmpDir)).toBeNull();
|
|
759
|
+
});
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
// ─────────────────────────────────────────────────────────
|
|
763
|
+
// buildLocalLock
|
|
764
|
+
// ─────────────────────────────────────────────────────────
|
|
765
|
+
|
|
766
|
+
describe('buildLocalLock', () => {
|
|
767
|
+
it('builds a valid local lock from deployment data', () => {
|
|
768
|
+
const lock = buildLocalLock('dev', {
|
|
769
|
+
engine: 'docker',
|
|
770
|
+
network: 'kadi-net',
|
|
771
|
+
services: ['web', 'api'],
|
|
772
|
+
containers: { web: 'abc', api: 'def' },
|
|
773
|
+
composePath: '/project/docker-compose.yml',
|
|
774
|
+
deployedAt: new Date('2026-02-25T12:00:00Z'),
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
expect(lock.target).toBe('local');
|
|
778
|
+
expect(lock.profile).toBe('dev');
|
|
779
|
+
expect(lock.instanceId).toBe(''); // assigned by writeLockFile
|
|
780
|
+
expect(lock.deployedAt).toBe('2026-02-25T12:00:00.000Z');
|
|
781
|
+
expect(lock.local).toBeDefined();
|
|
782
|
+
expect(lock.local!.composePath).toBe('/project/docker-compose.yml');
|
|
783
|
+
expect(lock.local!.engine).toBe('docker');
|
|
784
|
+
expect(lock.local!.services).toEqual(['web', 'api']);
|
|
785
|
+
expect(lock.akash).toBeUndefined();
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it('does not include a version field on the entry', () => {
|
|
789
|
+
const lock = buildLocalLock('dev', {
|
|
790
|
+
engine: 'docker',
|
|
791
|
+
network: 'kadi-net',
|
|
792
|
+
services: ['app'],
|
|
793
|
+
containers: { app: '123' },
|
|
794
|
+
composePath: '/project/compose.yml',
|
|
795
|
+
deployedAt: new Date(),
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
expect('version' in lock).toBe(false);
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
it('supports podman engine', () => {
|
|
802
|
+
const lock = buildLocalLock('test', {
|
|
803
|
+
engine: 'podman',
|
|
804
|
+
network: 'kadi-net',
|
|
805
|
+
services: ['app'],
|
|
806
|
+
containers: { app: '123' },
|
|
807
|
+
composePath: '/project/docker-compose.yml',
|
|
808
|
+
deployedAt: new Date(),
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
expect(lock.local!.engine).toBe('podman');
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
it('converts deployedAt Date to ISO string', () => {
|
|
815
|
+
const now = new Date();
|
|
816
|
+
const lock = buildLocalLock('test', {
|
|
817
|
+
engine: 'docker',
|
|
818
|
+
network: 'test-net',
|
|
819
|
+
services: ['app'],
|
|
820
|
+
containers: { app: '123' },
|
|
821
|
+
composePath: '/project/compose.yml',
|
|
822
|
+
deployedAt: now,
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
expect(lock.deployedAt).toBe(now.toISOString());
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
it('includes label when provided', () => {
|
|
829
|
+
const lock = buildLocalLock('dev', {
|
|
830
|
+
engine: 'docker',
|
|
831
|
+
network: 'kadi-net',
|
|
832
|
+
services: ['app'],
|
|
833
|
+
containers: { app: '123' },
|
|
834
|
+
composePath: '/project/compose.yml',
|
|
835
|
+
deployedAt: new Date(),
|
|
836
|
+
}, 'broker-east');
|
|
837
|
+
|
|
838
|
+
expect(lock.label).toBe('broker-east');
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
it('omits label when not provided', () => {
|
|
842
|
+
const lock = buildLocalLock('dev', {
|
|
843
|
+
engine: 'docker',
|
|
844
|
+
network: 'kadi-net',
|
|
845
|
+
services: ['app'],
|
|
846
|
+
containers: { app: '123' },
|
|
847
|
+
composePath: '/project/compose.yml',
|
|
848
|
+
deployedAt: new Date(),
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
expect(lock.label).toBeUndefined();
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
it('writeLockFile assigns instanceId to a buildLocalLock result', async () => {
|
|
855
|
+
const lock = buildLocalLock('dev', {
|
|
856
|
+
engine: 'docker',
|
|
857
|
+
network: 'kadi-net',
|
|
858
|
+
services: ['svc'],
|
|
859
|
+
containers: { svc: '123' },
|
|
860
|
+
composePath: '/project/compose.yml',
|
|
861
|
+
deployedAt: new Date(),
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
expect(lock.instanceId).toBe('');
|
|
865
|
+
const id = await writeLockFile(tmpDir, lock);
|
|
866
|
+
expect(id).toHaveLength(4);
|
|
867
|
+
expect(lock.instanceId).toBe(id);
|
|
868
|
+
});
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
// ─────────────────────────────────────────────────────────
|
|
872
|
+
// buildAkashLock
|
|
873
|
+
// ─────────────────────────────────────────────────────────
|
|
874
|
+
|
|
875
|
+
describe('buildAkashLock', () => {
|
|
876
|
+
it('builds a valid Akash lock from deployment data', () => {
|
|
877
|
+
const lock = buildAkashLock('production', {
|
|
878
|
+
dseq: 99999,
|
|
879
|
+
owner: 'akash1owner',
|
|
880
|
+
provider: 'akash1prov',
|
|
881
|
+
providerUri: 'https://prov.example.com',
|
|
882
|
+
network: 'mainnet',
|
|
883
|
+
gseq: 1,
|
|
884
|
+
oseq: 1,
|
|
885
|
+
deployedAt: new Date('2026-02-25T14:30:00Z'),
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
expect(lock.target).toBe('akash');
|
|
889
|
+
expect(lock.profile).toBe('production');
|
|
890
|
+
expect(lock.instanceId).toBe(''); // assigned by writeLockFile
|
|
891
|
+
expect(lock.deployedAt).toBe('2026-02-25T14:30:00.000Z');
|
|
892
|
+
expect(lock.akash).toBeDefined();
|
|
893
|
+
expect(lock.akash!.dseq).toBe(99999);
|
|
894
|
+
expect(lock.akash!.owner).toBe('akash1owner');
|
|
895
|
+
expect(lock.akash!.network).toBe('mainnet');
|
|
896
|
+
expect(lock.local).toBeUndefined();
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
it('does not include a version field on the entry', () => {
|
|
900
|
+
const lock = buildAkashLock('prod', {
|
|
901
|
+
dseq: 42,
|
|
902
|
+
owner: 'akash1owner',
|
|
903
|
+
provider: 'akash1prov',
|
|
904
|
+
providerUri: 'https://uri.com',
|
|
905
|
+
network: 'mainnet',
|
|
906
|
+
gseq: 1,
|
|
907
|
+
oseq: 1,
|
|
908
|
+
deployedAt: new Date(),
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
expect('version' in lock).toBe(false);
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
it('supports testnet network', () => {
|
|
915
|
+
const lock = buildAkashLock('staging', {
|
|
916
|
+
dseq: 11111,
|
|
917
|
+
owner: 'akash1test',
|
|
918
|
+
provider: 'akash1testprov',
|
|
919
|
+
providerUri: 'https://testprov.example.com',
|
|
920
|
+
network: 'testnet',
|
|
921
|
+
gseq: 1,
|
|
922
|
+
oseq: 1,
|
|
923
|
+
deployedAt: new Date(),
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
expect(lock.akash!.network).toBe('testnet');
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
it('preserves all deployment identifiers', () => {
|
|
930
|
+
const lock = buildAkashLock('prod', {
|
|
931
|
+
dseq: 42,
|
|
932
|
+
owner: 'akash1owner',
|
|
933
|
+
provider: 'akash1prov',
|
|
934
|
+
providerUri: 'https://uri.com',
|
|
935
|
+
network: 'mainnet',
|
|
936
|
+
gseq: 3,
|
|
937
|
+
oseq: 5,
|
|
938
|
+
deployedAt: new Date(),
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
expect(lock.akash!.gseq).toBe(3);
|
|
942
|
+
expect(lock.akash!.oseq).toBe(5);
|
|
943
|
+
expect(lock.akash!.providerUri).toBe('https://uri.com');
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
it('includes label when provided', () => {
|
|
947
|
+
const lock = buildAkashLock('production', {
|
|
948
|
+
dseq: 42,
|
|
949
|
+
owner: 'akash1owner',
|
|
950
|
+
provider: 'akash1prov',
|
|
951
|
+
providerUri: 'https://uri.com',
|
|
952
|
+
network: 'mainnet',
|
|
953
|
+
gseq: 1,
|
|
954
|
+
oseq: 1,
|
|
955
|
+
deployedAt: new Date(),
|
|
956
|
+
}, 'primary-gpu');
|
|
957
|
+
|
|
958
|
+
expect(lock.label).toBe('primary-gpu');
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
it('omits label when not provided', () => {
|
|
962
|
+
const lock = buildAkashLock('production', {
|
|
963
|
+
dseq: 42,
|
|
964
|
+
owner: 'akash1owner',
|
|
965
|
+
provider: 'akash1prov',
|
|
966
|
+
providerUri: 'https://uri.com',
|
|
967
|
+
network: 'mainnet',
|
|
968
|
+
gseq: 1,
|
|
969
|
+
oseq: 1,
|
|
970
|
+
deployedAt: new Date(),
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
expect(lock.label).toBeUndefined();
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
it('writeLockFile assigns instanceId to a buildAkashLock result', async () => {
|
|
977
|
+
const lock = buildAkashLock('production', {
|
|
978
|
+
dseq: 42,
|
|
979
|
+
owner: 'akash1owner',
|
|
980
|
+
provider: 'akash1prov',
|
|
981
|
+
providerUri: 'https://uri.com',
|
|
982
|
+
network: 'mainnet',
|
|
983
|
+
gseq: 1,
|
|
984
|
+
oseq: 1,
|
|
985
|
+
deployedAt: new Date(),
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
expect(lock.instanceId).toBe('');
|
|
989
|
+
const id = await writeLockFile(tmpDir, lock);
|
|
990
|
+
expect(id).toHaveLength(4);
|
|
991
|
+
expect(lock.instanceId).toBe(id);
|
|
992
|
+
});
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
// ─────────────────────────────────────────────────────────
|
|
996
|
+
// Full lifecycle: write → read → remove → delete
|
|
997
|
+
// ─────────────────────────────────────────────────────────
|
|
998
|
+
|
|
999
|
+
describe('full lock lifecycle', () => {
|
|
1000
|
+
it('write multiple → remove one → verify remainder → remove last → null', async () => {
|
|
1001
|
+
// Write two deployments
|
|
1002
|
+
const id1 = await writeLockFile(tmpDir, LOCAL_ENTRY);
|
|
1003
|
+
const id2 = await writeLockFile(tmpDir, AKASH_ENTRY);
|
|
1004
|
+
|
|
1005
|
+
let lockFile = await readLockFile(tmpDir);
|
|
1006
|
+
const profiles = Object.values(lockFile!.deployments).map(e => e.profile).sort();
|
|
1007
|
+
expect(profiles).toEqual(['dev', 'production']);
|
|
1008
|
+
|
|
1009
|
+
// Remove one by profile+instanceId
|
|
1010
|
+
await removeDeployment(tmpDir, 'dev', id1);
|
|
1011
|
+
lockFile = await readLockFile(tmpDir);
|
|
1012
|
+
const remaining = Object.values(lockFile!.deployments);
|
|
1013
|
+
expect(remaining).toHaveLength(1);
|
|
1014
|
+
expect(remaining[0].profile).toBe('production');
|
|
1015
|
+
|
|
1016
|
+
// Remove last — file should be deleted
|
|
1017
|
+
await removeDeployment(tmpDir, 'production', id2);
|
|
1018
|
+
lockFile = await readLockFile(tmpDir);
|
|
1019
|
+
expect(lockFile).toBeNull();
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
it('buildLocalLock → writeLockFile → getDeployment round-trip', async () => {
|
|
1023
|
+
const lock = buildLocalLock('test-profile', {
|
|
1024
|
+
engine: 'podman',
|
|
1025
|
+
network: 'test-net',
|
|
1026
|
+
services: ['svc1', 'svc2', 'svc3'],
|
|
1027
|
+
containers: { svc1: 'c1', svc2: 'c2', svc3: 'c3' },
|
|
1028
|
+
composePath: path.join(tmpDir, 'docker-compose.yml'),
|
|
1029
|
+
deployedAt: new Date('2026-01-01T00:00:00Z'),
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
const id = await writeLockFile(tmpDir, lock);
|
|
1033
|
+
const entry = await getDeployment(tmpDir, 'test-profile');
|
|
1034
|
+
expect(entry).not.toBeNull();
|
|
1035
|
+
expect(entry!.instanceId).toBe(id);
|
|
1036
|
+
expect(entry!.target).toBe('local');
|
|
1037
|
+
expect(entry!.profile).toBe('test-profile');
|
|
1038
|
+
expect(entry!.local!.engine).toBe('podman');
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
it('buildAkashLock → writeLockFile → getDeployment round-trip', async () => {
|
|
1042
|
+
const lock = buildAkashLock('staging', {
|
|
1043
|
+
dseq: 777,
|
|
1044
|
+
owner: 'akash1abc',
|
|
1045
|
+
provider: 'akash1xyz',
|
|
1046
|
+
providerUri: 'https://example.com',
|
|
1047
|
+
network: 'testnet',
|
|
1048
|
+
gseq: 2,
|
|
1049
|
+
oseq: 3,
|
|
1050
|
+
deployedAt: new Date('2026-06-15T10:30:00Z'),
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
const id = await writeLockFile(tmpDir, lock);
|
|
1054
|
+
const entry = await getDeployment(tmpDir, 'staging');
|
|
1055
|
+
expect(entry).not.toBeNull();
|
|
1056
|
+
expect(entry!.instanceId).toBe(id);
|
|
1057
|
+
expect(entry!.target).toBe('akash');
|
|
1058
|
+
expect(entry!.akash!.dseq).toBe(777);
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
it('multiple instances of same profile coexist', async () => {
|
|
1062
|
+
const lock1 = buildLocalLock('dev', {
|
|
1063
|
+
engine: 'docker',
|
|
1064
|
+
network: 'net-1',
|
|
1065
|
+
services: ['svc'],
|
|
1066
|
+
containers: { svc: 'c1' },
|
|
1067
|
+
composePath: '/project/compose.yml',
|
|
1068
|
+
deployedAt: new Date(),
|
|
1069
|
+
});
|
|
1070
|
+
const lock2 = buildLocalLock('dev', {
|
|
1071
|
+
engine: 'docker',
|
|
1072
|
+
network: 'net-2',
|
|
1073
|
+
services: ['svc'],
|
|
1074
|
+
containers: { svc: 'c2' },
|
|
1075
|
+
composePath: '/project/compose.yml',
|
|
1076
|
+
deployedAt: new Date(),
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
const id1 = await writeLockFile(tmpDir, lock1);
|
|
1080
|
+
const id2 = await writeLockFile(tmpDir, lock2);
|
|
1081
|
+
expect(id1).not.toBe(id2);
|
|
1082
|
+
|
|
1083
|
+
const all = await getDeploymentsByProfile(tmpDir, 'dev');
|
|
1084
|
+
expect(all).toHaveLength(2);
|
|
1085
|
+
|
|
1086
|
+
const list = await listDeployments(tmpDir);
|
|
1087
|
+
expect(list).toHaveLength(2);
|
|
1088
|
+
|
|
1089
|
+
// Each can be retrieved by instance
|
|
1090
|
+
const e1 = await getDeploymentByInstance(tmpDir, id1);
|
|
1091
|
+
const e2 = await getDeploymentByInstance(tmpDir, id2);
|
|
1092
|
+
expect(e1!.local!.network).toBe('net-1');
|
|
1093
|
+
expect(e2!.local!.network).toBe('net-2');
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
it('labels are preserved through write → read cycle', async () => {
|
|
1097
|
+
const lock = buildLocalLock('dev', {
|
|
1098
|
+
engine: 'docker',
|
|
1099
|
+
network: 'kadi-net',
|
|
1100
|
+
services: ['svc'],
|
|
1101
|
+
containers: { svc: 'c1' },
|
|
1102
|
+
composePath: '/project/compose.yml',
|
|
1103
|
+
deployedAt: new Date(),
|
|
1104
|
+
}, 'broker-west');
|
|
1105
|
+
|
|
1106
|
+
const id = await writeLockFile(tmpDir, lock);
|
|
1107
|
+
const entry = await getDeploymentByInstance(tmpDir, id);
|
|
1108
|
+
expect(entry!.label).toBe('broker-west');
|
|
1109
|
+
});
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
// ─────────────────────────────────────────────────────────
|
|
1113
|
+
// Utility functions: generateInstanceId, deploymentKey, parseDeploymentKey
|
|
1114
|
+
// ─────────────────────────────────────────────────────────
|
|
1115
|
+
|
|
1116
|
+
describe('generateInstanceId', () => {
|
|
1117
|
+
it('returns a 4-char hex string', () => {
|
|
1118
|
+
const id = generateInstanceId();
|
|
1119
|
+
expect(id).toHaveLength(4);
|
|
1120
|
+
expect(/^[0-9a-f]{4}$/.test(id)).toBe(true);
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
it('generates unique IDs on successive calls', () => {
|
|
1124
|
+
const ids = new Set(Array.from({ length: 20 }, () => generateInstanceId()));
|
|
1125
|
+
// With 65536 possible values, 20 should almost certainly be unique
|
|
1126
|
+
expect(ids.size).toBe(20);
|
|
1127
|
+
});
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
describe('deploymentKey', () => {
|
|
1131
|
+
it('creates a composite key', () => {
|
|
1132
|
+
expect(deploymentKey('dev', 'ab12')).toBe('dev:ab12');
|
|
1133
|
+
});
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
describe('parseDeploymentKey', () => {
|
|
1137
|
+
it('parses a composite key into profile and instanceId', () => {
|
|
1138
|
+
const parsed = parseDeploymentKey('dev:ab12');
|
|
1139
|
+
expect(parsed.profile).toBe('dev');
|
|
1140
|
+
expect(parsed.instanceId).toBe('ab12');
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
it('handles profiles with colons', () => {
|
|
1144
|
+
const parsed = parseDeploymentKey('my:complex:profile:ab12');
|
|
1145
|
+
expect(parsed.profile).toBe('my:complex:profile');
|
|
1146
|
+
expect(parsed.instanceId).toBe('ab12');
|
|
1147
|
+
});
|
|
1148
|
+
});
|