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.
Files changed (52) hide show
  1. package/.env.example +6 -0
  2. package/.prettierrc +6 -0
  3. package/README.md +589 -0
  4. package/agent.json +23 -0
  5. package/index.js +11 -0
  6. package/package.json +42 -0
  7. package/quick-command.txt +92 -0
  8. package/scripts/preflight.js +458 -0
  9. package/scripts/preflight.sh +300 -0
  10. package/src/cli/bid-selector.ts +222 -0
  11. package/src/cli/colors.ts +216 -0
  12. package/src/cli/index.ts +11 -0
  13. package/src/cli/prompts.ts +190 -0
  14. package/src/cli/spinners.ts +165 -0
  15. package/src/commands/deploy-local.ts +475 -0
  16. package/src/commands/deploy.ts +1342 -0
  17. package/src/commands/down.ts +679 -0
  18. package/src/commands/index.ts +10 -0
  19. package/src/commands/lock.ts +571 -0
  20. package/src/config/agent-loader.ts +177 -0
  21. package/src/config/index.ts +9 -0
  22. package/src/display/deployment-info.ts +220 -0
  23. package/src/display/pricing.ts +137 -0
  24. package/src/display/resources.ts +234 -0
  25. package/src/enhanced-registry-manager.ts +892 -0
  26. package/src/index.ts +307 -0
  27. package/src/infrastructure/registry.ts +269 -0
  28. package/src/schemas/profiles.ts +529 -0
  29. package/src/secrets/broker-urls.ts +109 -0
  30. package/src/secrets/handshake.ts +407 -0
  31. package/src/secrets/index.ts +69 -0
  32. package/src/secrets/inject-env.ts +171 -0
  33. package/src/secrets/nonce.ts +31 -0
  34. package/src/secrets/normalize.ts +204 -0
  35. package/src/secrets/prepare.ts +152 -0
  36. package/src/secrets/validate.ts +243 -0
  37. package/src/secrets/vault.ts +80 -0
  38. package/src/types/akash.ts +116 -0
  39. package/src/types/container-registry-ability.d.ts +158 -0
  40. package/src/types/external.ts +49 -0
  41. package/src/types.ts +211 -0
  42. package/src/utils/akt-price.ts +74 -0
  43. package/tests/agent-loader.test.ts +239 -0
  44. package/tests/autonomous.test.ts +244 -0
  45. package/tests/down.test.ts +1143 -0
  46. package/tests/lock.test.ts +1148 -0
  47. package/tests/nonce.test.ts +34 -0
  48. package/tests/normalize.test.ts +270 -0
  49. package/tests/secrets-schema.test.ts +301 -0
  50. package/tests/types.test.ts +198 -0
  51. package/tsconfig.json +18 -0
  52. package/vitest.config.ts +9 -0
@@ -0,0 +1,475 @@
1
+ /**
2
+ * Deploy Command - Local Docker/Podman Integration
3
+ *
4
+ * Main deployment command that uses deploy-ability library for local deployments.
5
+ * This replaces the old targets/local/local.ts with a clean interface to deploy-ability.
6
+ *
7
+ * @module commands/deploy-local
8
+ */
9
+
10
+ import { deployToLocal } from '@kadi.build/deploy-ability/local';
11
+ import type {
12
+ LocalDeploymentOptions,
13
+ LocalDeploymentData,
14
+ LocalDryRunData
15
+ } from '@kadi.build/deploy-ability/types';
16
+
17
+ import { loadAgentConfig, getProfile, getFirstProfile } from '../config/agent-loader.js';
18
+ import {
19
+ waitForSecretRequest,
20
+ shareSecrets,
21
+ sendRejection,
22
+ readSecretsFromCli,
23
+ prepareSecretsForDeployment,
24
+ type WaitForSecretsResult,
25
+ } from '../secrets/index.js';
26
+ import { normalizeSecrets, hasAnySecrets, type NormalizedVaultSource } from '../secrets/normalize.js';
27
+ import { startSpinner, succeedSpinner, failSpinner } from '../cli/spinners.js';
28
+ import { confirmPrompt } from '../cli/prompts.js';
29
+ import { error as errorColor, warning, success as successColor, formatKeyValue, formatHeader, dim, bold } from '../cli/colors.js';
30
+
31
+ import type { DeploymentContext } from '../types.js';
32
+ import type { Ora } from 'ora';
33
+ import { buildLocalLock, writeLockFile } from './lock.js';
34
+
35
+ /**
36
+ * Executes local Docker/Podman deployment using deploy-ability
37
+ *
38
+ * This function orchestrates the complete local deployment workflow:
39
+ * 1. Load agent configuration
40
+ * 2. Resolve profile
41
+ * 3. Build deployment options
42
+ * 4. Deploy using deploy-ability
43
+ * 5. Display results
44
+ *
45
+ * @param ctx - Deployment context from CLI
46
+ */
47
+ export async function executeLocalDeployment(ctx: DeploymentContext): Promise<void> {
48
+ const { logger, projectRoot, flags } = ctx;
49
+
50
+ // ----------------------------------------------------------------
51
+ // 1. Load agent configuration
52
+ // ----------------------------------------------------------------
53
+ const spinner = startSpinner('Loading agent configuration...');
54
+
55
+ let agentConfig;
56
+ try {
57
+ agentConfig = await loadAgentConfig(projectRoot);
58
+ succeedSpinner(spinner, `Loaded agent: ${agentConfig.name}`);
59
+ } catch (err) {
60
+ failSpinner(spinner, 'Failed to load agent.json');
61
+ logger.error(errorColor((err as Error).message));
62
+ return;
63
+ }
64
+
65
+ // ----------------------------------------------------------------
66
+ // 2. Resolve profile
67
+ // ----------------------------------------------------------------
68
+ const profileName = flags.profile || getFirstProfile(agentConfig);
69
+
70
+ if (!profileName) {
71
+ logger.error('No deploy profiles found in agent.json');
72
+ logger.log('Add a deploy profile under the "deploy" field in agent.json');
73
+ return;
74
+ }
75
+
76
+ const profile = getProfile(agentConfig, profileName);
77
+ if (!profile) {
78
+ logger.error(`Profile "${profileName}" not found in agent.json`);
79
+ return;
80
+ }
81
+
82
+ // Validate it's a local profile
83
+ if (profile.target !== 'local') {
84
+ logger.error(`Profile "${profileName}" is not a local profile (target: ${profile.target})`);
85
+ return;
86
+ }
87
+
88
+ logger.log(successColor(`✓ Using profile: ${profileName}`));
89
+
90
+ // ----------------------------------------------------------------
91
+ // 2.5. SECRETS INJECTION
92
+ // If profile declares secrets, inject KADI_* env vars into all services
93
+ // Supports both legacy single-vault and multi-vault configurations.
94
+ // ----------------------------------------------------------------
95
+ const normalizedSecrets = normalizeSecrets(profile.secrets as any);
96
+ const hasSecrets = hasAnySecrets(normalizedSecrets);
97
+
98
+ let deployNonce: string | undefined;
99
+ let brokerUrl: string | null = null;
100
+ let secretsVault: string | undefined;
101
+ let secretsSources: NormalizedVaultSource[] = [];
102
+
103
+ if (hasSecrets) {
104
+ const result = prepareSecretsForDeployment({
105
+ profile,
106
+ agentConfig,
107
+ projectRoot,
108
+ logger,
109
+ colors: { dim, success: successColor, error: errorColor },
110
+ });
111
+
112
+ if (!result) return;
113
+
114
+ deployNonce = result.deployNonce;
115
+ brokerUrl = result.brokerUrl;
116
+ secretsVault = result.secretsVault;
117
+ secretsSources = result.secretsSources;
118
+ }
119
+
120
+ // ----------------------------------------------------------------
121
+ // 3. Build deployment options for deploy-ability
122
+ // ----------------------------------------------------------------
123
+
124
+ // Spinner reference for progress updates
125
+ let progressSpinner: Ora | null = null;
126
+
127
+ const deployOptions: LocalDeploymentOptions = {
128
+ projectRoot,
129
+ profile: profileName,
130
+ // Pass the transformed profile with injected env vars
131
+ loadedProfile: {
132
+ name: profileName,
133
+ profile,
134
+ agent: agentConfig,
135
+ projectRoot,
136
+ },
137
+ engine: profile.engine || flags.engine || 'docker',
138
+ dryRun: flags.dryRun || false,
139
+ verbose: flags.verbose || false,
140
+
141
+ // Progress callback - updates spinner with structured events
142
+ onProgress: (event) => {
143
+ if (progressSpinner && event.message) {
144
+ progressSpinner.text = event.message;
145
+ }
146
+ },
147
+
148
+ // Logger for deploy-ability to use (errors only, no progress messages)
149
+ logger: {
150
+ log: (msg: string) => logger.log(msg),
151
+ error: (msg: string) => logger.error(msg),
152
+ warn: (msg: string) => logger.log(warning(msg)),
153
+ debug: flags.verbose ? (msg: string) => logger.log(msg) : () => {}
154
+ }
155
+ };
156
+
157
+ // ----------------------------------------------------------------
158
+ // 4. Execute deployment using deploy-ability
159
+ // ----------------------------------------------------------------
160
+ if (flags.dryRun) {
161
+ logger.log('\n' + warning('⚠️ DRY RUN MODE - No deployment will be created'));
162
+ } else if (!flags.yes) {
163
+ // Confirm deployment
164
+ const proceed = await confirmPrompt(
165
+ `Deploy locally using ${deployOptions.engine}?`,
166
+ true
167
+ );
168
+
169
+ if (!proceed) {
170
+ logger.log('Deployment cancelled');
171
+ return;
172
+ }
173
+ }
174
+
175
+ // ----------------------------------------------------------------
176
+ // 4. Start listening for secrets BEFORE deploying (to avoid race condition)
177
+ // The agent may request secrets immediately after starting, so we need
178
+ // to be subscribed before the container starts.
179
+ // ----------------------------------------------------------------
180
+ let secretsPromise: Promise<WaitForSecretsResult> | null = null;
181
+
182
+ if (hasSecrets && deployNonce && brokerUrl && !flags.dryRun) {
183
+ logger.log(dim(`\nConnecting to broker to listen for secret requests...`));
184
+
185
+ // Start listening NOW (don't await - let it run in background)
186
+ secretsPromise = waitForSecretRequest({
187
+ brokerUrl,
188
+ expectedNonce: deployNonce,
189
+ timeout: 5 * 60 * 1000, // 5 minutes
190
+ logger: {
191
+ log: (msg: string) => logger.log(dim(msg)),
192
+ error: (msg: string) => logger.error(errorColor(msg)),
193
+ },
194
+ });
195
+
196
+ // Wait for broker subscription before starting containers
197
+ await new Promise(resolve => setTimeout(resolve, 1000));
198
+ }
199
+
200
+ // ----------------------------------------------------------------
201
+ // 5. Execute deployment using deploy-ability
202
+ // ----------------------------------------------------------------
203
+ progressSpinner = startSpinner(`Deploying locally with ${deployOptions.engine}...`);
204
+
205
+ try {
206
+ const result = await deployToLocal(deployOptions);
207
+
208
+ if (!result.success) {
209
+ failSpinner(progressSpinner, 'Deployment failed');
210
+ logger.error(errorColor(result.error.message));
211
+
212
+ // Show context details (command, exit code, stderr)
213
+ const err = result.error as unknown as { context?: Record<string, unknown> };
214
+ if (err.context) {
215
+ if (err.context.error) logger.error(`Error: ${err.context.error}`);
216
+ if (err.context.stderr) logger.error(`Stderr: ${err.context.stderr}`);
217
+ if (err.context.exitCode !== undefined) logger.error(`Exit code: ${err.context.exitCode}`);
218
+ }
219
+
220
+ if (flags.verbose && result.error.cause) {
221
+ logger.error('\nCause:', result.error.cause);
222
+ }
223
+
224
+ return;
225
+ }
226
+
227
+ succeedSpinner(progressSpinner, 'Deployment complete!');
228
+
229
+ // ----------------------------------------------------------------
230
+ // 6. Display results
231
+ // ----------------------------------------------------------------
232
+ const data = result.data;
233
+
234
+ if ('dryRun' in data && data.dryRun) {
235
+ // Dry run result
236
+ displayLocalDryRunInfo(profileName, data);
237
+ } else {
238
+ // Actual deployment result
239
+ const deploymentData = data as LocalDeploymentData;
240
+
241
+ if (flags.verbose) {
242
+ displayLocalDeploymentInfo(deploymentData);
243
+ } else {
244
+ displayLocalDeploymentSummary(deploymentData);
245
+ }
246
+
247
+ // ----------------------------------------------------------------
248
+ // 6.5. Write deployment lock file for `kadi deploy down`
249
+ // ----------------------------------------------------------------
250
+ try {
251
+ const lock = buildLocalLock(profileName, deploymentData, flags.label);
252
+ const instanceId = await writeLockFile(projectRoot, lock);
253
+ logger.log(dim(` Instance: ${instanceId} (profile: ${profileName}${flags.label ? `, label: ${flags.label}` : ''})`));
254
+ logger.log(dim(` Use \`kadi deploy down --instance ${instanceId}\` to tear down.`));
255
+ if (flags.verbose) {
256
+ logger.log(dim('Lock file written: .kadi-deploy.lock'));
257
+ }
258
+ } catch (lockErr) {
259
+ // Non-fatal: deployment succeeded even if lock write fails
260
+ logger.log(warning(`Could not write lock file: ${(lockErr as Error).message}`));
261
+ }
262
+
263
+ // ----------------------------------------------------------------
264
+ // 7. Wait for secret request result (we started listening earlier)
265
+ // ----------------------------------------------------------------
266
+ if (secretsPromise && brokerUrl && secretsVault) {
267
+ logger.log(dim('\n─────────────────────────────────────────────────────────────────'));
268
+ logger.log(bold('Waiting for agent to request secrets...'));
269
+ logger.log(dim(`Broker: ${brokerUrl}`));
270
+ logger.log(dim('Press Ctrl+C to skip (agent will not receive secrets)'));
271
+ logger.log(dim('─────────────────────────────────────────────────────────────────\n'));
272
+
273
+ // Handle Ctrl+C during secret wait - force exit cleanly
274
+ const sigintHandler = () => {
275
+ logger.log(warning('\n\nSkipping secret sharing (Ctrl+C)'));
276
+ process.exit(0);
277
+ };
278
+ process.on('SIGINT', sigintHandler);
279
+
280
+ const waitResult = await secretsPromise;
281
+
282
+ // Remove handler after secrets are done
283
+ process.off('SIGINT', sigintHandler);
284
+
285
+ if (waitResult.success && waitResult.request) {
286
+ const request = waitResult.request;
287
+
288
+ // Display request info
289
+ logger.log('');
290
+ logger.log(successColor('Agent connected and requesting secrets!'));
291
+ logger.log('');
292
+ logger.log(` Agent ID: ${request.agentId}`);
293
+ logger.log(` Required: ${request.required.join(', ')}`);
294
+ if (request.optional?.length) {
295
+ logger.log(` Optional: ${request.optional.join(', ')}`);
296
+ }
297
+ logger.log('');
298
+
299
+ // Prompt for approval (auto-approve with --yes flag)
300
+ const approved = flags.yes || await confirmPrompt('Share these secrets with the agent?', true);
301
+
302
+ if (!approved) {
303
+ logger.log(warning('\nSecret sharing declined.'));
304
+ // Notify agent so it can fail immediately instead of timing out
305
+ await sendRejection({
306
+ brokerUrl,
307
+ agentId: request.agentId,
308
+ reason: 'User declined to share secrets',
309
+ logger: {
310
+ log: (msg: string) => logger.log(dim(msg)),
311
+ error: (msg: string) => logger.error(errorColor(msg)),
312
+ },
313
+ });
314
+ } else {
315
+ const allRequestedKeys = [
316
+ ...request.required,
317
+ ...(request.optional || []),
318
+ ];
319
+
320
+ // Multi-vault: read from each vault source
321
+ const secrets: Record<string, string> = {};
322
+ for (const source of secretsSources) {
323
+ const sourceKeys = [...source.required, ...source.optional]
324
+ .filter(k => allRequestedKeys.includes(k));
325
+ if (sourceKeys.length === 0) continue;
326
+
327
+ logger.log(dim(`\nReading secrets from vault '${source.vault}'...`));
328
+ const vaultSecrets = readSecretsFromCli({
329
+ keys: sourceKeys,
330
+ vault: source.vault,
331
+ cwd: projectRoot,
332
+ onError: (key, error) => {
333
+ logger.log(dim(` Failed to read '${key}' from vault '${source.vault}': ${error}`));
334
+ },
335
+ });
336
+ Object.assign(secrets, vaultSecrets);
337
+ }
338
+ const foundKeys = Object.keys(secrets);
339
+
340
+ const missingRequired = request.required.filter(
341
+ (key) => !secrets[key]
342
+ );
343
+
344
+ if (missingRequired.length > 0) {
345
+ const vaultNames = secretsSources.map(s => s.vault).join(', ');
346
+ logger.log(warning(`\nMissing required secrets in vault(s) '${vaultNames}': ${missingRequired.join(', ')}`));
347
+ logger.log(dim(`Use "kadi secret set <key> <value> -v <vault>" to add them.`));
348
+ } else if (foundKeys.length === 0) {
349
+ const vaultNames = secretsSources.map(s => s.vault).join(', ');
350
+ logger.log(warning(`\nNo secrets found in vault(s) '${vaultNames}'.`));
351
+ } else if (!request.publicKey) {
352
+ logger.log(warning('\nAgent did not provide public key for encryption.'));
353
+ } else {
354
+ logger.log(successColor(`\nSharing ${foundKeys.length} secret(s) with agent (encrypted)...`));
355
+
356
+ try {
357
+ await shareSecrets({
358
+ brokerUrl,
359
+ agentId: request.agentId,
360
+ agentPublicKey: request.publicKey,
361
+ secrets,
362
+ logger: {
363
+ log: (msg: string) => logger.log(dim(msg)),
364
+ error: (msg: string) => logger.error(errorColor(msg)),
365
+ },
366
+ });
367
+
368
+ logger.log(successColor('Secrets shared successfully!'));
369
+ logger.log(dim(` Shared: ${foundKeys.join(', ')}`));
370
+ } catch (shareErr) {
371
+ logger.log(warning(`\nFailed to share secrets: ${(shareErr as Error).message}`));
372
+ }
373
+ }
374
+ }
375
+ } else if (waitResult.timedOut) {
376
+ logger.log(warning('\nTimeout waiting for agent to request secrets.'));
377
+ } else if (waitResult.error) {
378
+ logger.log(warning(`\nSecret handshake failed: ${waitResult.error}`));
379
+ }
380
+
381
+ logger.log('');
382
+ }
383
+ }
384
+
385
+ } catch (err) {
386
+ failSpinner(progressSpinner, 'Deployment failed');
387
+ logger.error(errorColor((err as Error).message));
388
+
389
+ if (flags.verbose) {
390
+ console.error(err);
391
+ }
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Displays local deployment information
397
+ *
398
+ * @param data - Deployment data from deploy-ability
399
+ */
400
+ function displayLocalDeploymentInfo(data: LocalDeploymentData): void {
401
+ const separator = '─'.repeat(70);
402
+
403
+ console.log('');
404
+ console.log(successColor('✓ Local Deployment Successful!'));
405
+ console.log('');
406
+ console.log(separator);
407
+ console.log(formatHeader('Deployment Information'));
408
+ console.log(separator);
409
+ console.log('');
410
+
411
+ console.log(formatKeyValue('Profile', data.profile));
412
+ console.log(formatKeyValue('Engine', data.engine));
413
+ console.log(formatKeyValue('Network', data.network));
414
+ console.log(formatKeyValue('Compose Path', data.composePath));
415
+ console.log(formatKeyValue('Deployed At', data.deployedAt.toLocaleString()));
416
+ console.log('');
417
+
418
+ console.log(formatHeader('Services'));
419
+ console.log(separator);
420
+ console.log('');
421
+
422
+ for (const serviceName of data.services) {
423
+ const containerId = data.containers[serviceName];
424
+ const endpoint = data.endpoints[serviceName];
425
+
426
+ console.log(formatKeyValue(serviceName, ''));
427
+ console.log(` Container: ${containerId}`);
428
+ if (endpoint) {
429
+ console.log(` Endpoint: ${endpoint}`);
430
+ }
431
+ }
432
+
433
+ console.log('');
434
+ }
435
+
436
+ /**
437
+ * Displays local deployment summary
438
+ *
439
+ * @param data - Deployment data from deploy-ability
440
+ */
441
+ function displayLocalDeploymentSummary(data: LocalDeploymentData): void {
442
+ console.log('');
443
+ console.log(successColor(`✓ Deployed ${data.services.length} service(s) locally`));
444
+
445
+ for (const serviceName of data.services) {
446
+ const endpoint = data.endpoints[serviceName];
447
+ if (endpoint) {
448
+ console.log(` ${serviceName}: ${endpoint}`);
449
+ }
450
+ }
451
+
452
+ console.log('');
453
+ }
454
+
455
+ /**
456
+ * Displays dry-run information for local deployment
457
+ *
458
+ * @param profileName - Profile name
459
+ * @param data - Dry-run data from deploy-ability
460
+ */
461
+ function displayLocalDryRunInfo(profileName: string, data: LocalDryRunData): void {
462
+ console.log('');
463
+ console.log(formatHeader('Dry Run - Local Deployment Preview'));
464
+ console.log(` ${formatKeyValue('Profile', profileName)}`);
465
+ console.log(` ${formatKeyValue('Engine', data.engine)}`);
466
+ console.log(` ${formatKeyValue('Services', data.services.join(', '))}`);
467
+ console.log('');
468
+ console.log('Generated docker-compose.yml:');
469
+ console.log('─'.repeat(70));
470
+ console.log(data.composeFile);
471
+ console.log('─'.repeat(70));
472
+ console.log('');
473
+ console.log(warning('ℹ️ This is a dry run - no containers were started'));
474
+ console.log('');
475
+ }