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,892 @@
1
+ /**
2
+ * Enhanced Temporary Container Registry Manager - TypeScript Version
3
+ *
4
+ * This builds on the excellent logic from registry-manager.js but adapts it
5
+ * for the new registry-aware SDL generation flow.
6
+ */
7
+
8
+ import type { AkashProfile } from './schemas/profiles.js';
9
+ import type { IKadiLogger } from './types/external.js';
10
+ import path from 'path';
11
+ import fs from 'fs';
12
+ import os from 'os';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { execSync } from 'child_process';
15
+ import debug from 'debug';
16
+
17
+ import { TunneledContainerRegistry } from '@kadi.build/container-registry-ability';
18
+
19
+ // @ts-ignore - config resolver from tunnel-services (JavaScript)
20
+ import { resolveTunnelConfig } from '@kadi.build/tunnel-services';
21
+
22
+ /**
23
+ * Debug logger for registry operations
24
+ */
25
+ const log = debug('kadi:registry');
26
+
27
+ /**
28
+ * Container mapping that tracks original image to registry URL transformation
29
+ */
30
+ interface ContainerMapping {
31
+ originalImage: string; // e.g., "my-app" or "localhost/my-app"
32
+ serviceName: string; // e.g., "frontend"
33
+ registryUrl: string; // e.g., "temp-registry.serveo.net/my-app:latest"
34
+ repoName: string; // e.g., "my-app"
35
+ imageTag: string; // e.g., "latest"
36
+ actualAlias: string; // e.g., "my-app" (sanitized for registry)
37
+ }
38
+
39
+ /**
40
+ * Registry credentials for SDL generation
41
+ */
42
+ interface RegistryCredentials {
43
+ host: string; // e.g., "temp-registry.serveo.net"
44
+ username: string; // e.g., access key
45
+ password: string; // e.g., secret key
46
+ }
47
+
48
+ /**
49
+ * Registry information for container registry instance
50
+ *
51
+ * Aligned with @kadi.build/container-registry-ability RegistryInfo type.
52
+ */
53
+ import type {
54
+ RegistryInfo as CRARegistryInfo,
55
+ ContainerInfo as CRAContainerInfo,
56
+ } from '@kadi.build/container-registry-ability';
57
+
58
+ type RegistryInfo = CRARegistryInfo;
59
+
60
+ /**
61
+ * Registry URL components including local and tunnel endpoints
62
+ */
63
+ interface RegistryUrls {
64
+ localUrl: string; // e.g., "http://localhost:3000"
65
+ localDomain: string; // e.g., "localhost:3000"
66
+ tunnelUrl: string | null; // e.g., "https://abc123.serveo.net" or null
67
+ tunnelDomain: string | null; // e.g., "abc123.serveo.net" or null
68
+ }
69
+
70
+ /**
71
+ * Configuration options for starting the temporary registry
72
+ *
73
+ * Controls registry server, tunnel service, and lifecycle behavior.
74
+ */
75
+ interface RegistryOptions {
76
+ /** Port number for the local registry server (default: 3000) */
77
+ port?: number;
78
+
79
+ /** Tunnel service to expose registry publicly ('kadi', 'ngrok', 'serveo', or 'localtunnel') */
80
+ tunnelService?: 'kadi' | 'ngrok' | 'serveo' | 'localtunnel';
81
+
82
+ /** Container engine to use for loading images ('docker' or 'podman') */
83
+ containerEngine?: 'docker' | 'podman';
84
+
85
+ /** Duration in milliseconds before auto-shutdown (default: 600000 = 10 minutes) */
86
+ durationMs?: number;
87
+
88
+ /** Enable automatic shutdown after downloads complete (default: true) */
89
+ autoShutdown?: boolean;
90
+
91
+ /** Authentication token for tunnel service (required for some services like ngrok) */
92
+ tunnelAuthToken?: string;
93
+
94
+ /** Region for tunnel service (e.g., 'us', 'eu', 'ap') */
95
+ tunnelRegion?: string;
96
+
97
+ /** Protocol for tunnel service ('http' or 'https') */
98
+ tunnelProtocol?: string;
99
+
100
+ /** Custom subdomain for tunnel (may require paid plan) */
101
+ tunnelSubdomain?: string;
102
+ }
103
+
104
+ /**
105
+ * Container information returned by the registry
106
+ *
107
+ * Represents a container that has been added to the temporary registry.
108
+ * The alias is the sanitized name used to reference the container in the registry.
109
+ */
110
+ interface ContainerInfo {
111
+ /** Sanitized container alias used in the registry (e.g., "my-app") */
112
+ alias: string;
113
+ }
114
+
115
+ export class TemporaryContainerRegistryManager {
116
+ private logger: IKadiLogger;
117
+ private registry: TunneledContainerRegistry | null = null;
118
+ private registryInfo: RegistryInfo | null = null;
119
+
120
+ // Core data: maps original image names to their registry URLs
121
+ private containerMappings = new Map<string, ContainerMapping>();
122
+
123
+ constructor(logger: IKadiLogger) {
124
+ this.logger = logger;
125
+ }
126
+
127
+ /**
128
+ * Start temporary registry using TunneledContainerRegistry
129
+ *
130
+ * Creates a local container registry on the specified port and exposes it publicly
131
+ * via a tunnel service (kadi, ngrok, serveo, or localtunnel). The registry is used to make
132
+ * local Docker images accessible to Akash providers during deployment.
133
+ *
134
+ * @param options - Configuration options for registry and tunnel
135
+ * @returns Promise that resolves when registry is running and accessible
136
+ * @throws Error if registry fails to start or tunnel cannot be established
137
+ *
138
+ * @example
139
+ * ```typescript
140
+ * await manager.startTemporaryRegistry({
141
+ * port: 3000,
142
+ * tunnelService: 'serveo',
143
+ * containerEngine: 'docker',
144
+ * durationMs: 600000,
145
+ * autoShutdown: false
146
+ * });
147
+ * ```
148
+ */
149
+ async startTemporaryRegistry(options: RegistryOptions): Promise<void> {
150
+ if (this.registry) {
151
+ log('Temporary registry already running');
152
+ return;
153
+ }
154
+
155
+ log('Starting temporary container registry...');
156
+
157
+ try {
158
+ // ----------------------------------------------------------------
159
+ // Resolve tunnel configuration using config.yml walk-up + vault
160
+ // + .env fallback + process.env overrides.
161
+ //
162
+ // This replaces the previous dotenv-based loading. The tunnel
163
+ // config resolver (from @kadi.build/tunnel-services) handles:
164
+ // 1. config.yml "tunnel" section (non-secret settings)
165
+ // 2. secrets.toml vault "tunnel" for tokens (via secret-ability)
166
+ // 3. .env file walk-up (fallback when no vault found)
167
+ // 4. process.env overrides (always win)
168
+ // ----------------------------------------------------------------
169
+ const tunnelResolved = await resolveTunnelConfig();
170
+ const { config: tunnelCfg, secrets: tunnelSecrets } = tunnelResolved;
171
+
172
+ const resolvedTunnelService = options.tunnelService || tunnelCfg.default_service || 'kadi';
173
+
174
+ // Determine tunnel auth token: explicit option > vault > process.env fallback
175
+ const resolvedAuthToken =
176
+ options.tunnelAuthToken ||
177
+ tunnelSecrets.kadi_token ||
178
+ tunnelSecrets.ngrok_token;
179
+
180
+ const envNgrokRegion = tunnelCfg.ngrok_region || tunnelCfg.region || process.env.NGROK_REGION || undefined;
181
+ const envNgrokProtocol = tunnelCfg.ngrok_protocol || process.env.NGROK_PROTOCOL || undefined;
182
+
183
+ // Warn if kadi tunnel is selected but no token found
184
+ if (resolvedTunnelService === 'kadi' && !resolvedAuthToken) {
185
+ this.logger.log(` ⚠ KADI_TUNNEL_TOKEN not set — kadi tunnel (frpc) requires a token`);
186
+ this.logger.log(` Set via: kadi secret set -v tunnel KADI_TUNNEL_TOKEN`);
187
+ }
188
+
189
+ // ----------------------------------------------------------------
190
+ // CRITICAL: Force-write resolved tunnel values into process.env.
191
+ //
192
+ // Some downstream packages (CRA → S3HttpServer → FileSharingServer)
193
+ // may still reference process.env for tunnel config during the
194
+ // transition period. We propagate resolved values to ensure
195
+ // compatibility.
196
+ //
197
+ // INFRASTRUCTURE DEFAULTS:
198
+ // The kadi tunnel architecture is WSS-only (no direct TCP on port
199
+ // 7000). We ensure transport/wssControlHost defaults are always set.
200
+ // ----------------------------------------------------------------
201
+ const KADI_DEFAULTS = {
202
+ KADI_TUNNEL_SERVER: 'broker.kadi.build',
203
+ KADI_TUNNEL_DOMAIN: 'tunnel.kadi.build',
204
+ KADI_TUNNEL_MODE: 'frpc',
205
+ KADI_TUNNEL_TRANSPORT: 'wss',
206
+ KADI_TUNNEL_WSS_HOST: 'tunnel-control.kadi.build',
207
+ KADI_TUNNEL_PORT: '7000',
208
+ KADI_TUNNEL_SSH_PORT: '2200',
209
+ KADI_AGENT_ID: 'kadi',
210
+ };
211
+
212
+ const envVarsToPropagate: Record<string, string | undefined> = {
213
+ KADI_TUNNEL_TOKEN: resolvedAuthToken,
214
+ KADI_TUNNEL_SERVER: tunnelCfg.server_addr || process.env.KADI_TUNNEL_SERVER || KADI_DEFAULTS.KADI_TUNNEL_SERVER,
215
+ KADI_TUNNEL_DOMAIN: tunnelCfg.tunnel_domain || process.env.KADI_TUNNEL_DOMAIN || KADI_DEFAULTS.KADI_TUNNEL_DOMAIN,
216
+ KADI_TUNNEL_MODE: tunnelCfg.mode || process.env.KADI_TUNNEL_MODE || KADI_DEFAULTS.KADI_TUNNEL_MODE,
217
+ KADI_TUNNEL_TRANSPORT: tunnelCfg.transport || process.env.KADI_TUNNEL_TRANSPORT || KADI_DEFAULTS.KADI_TUNNEL_TRANSPORT,
218
+ KADI_TUNNEL_WSS_HOST: tunnelCfg.wss_control_host || process.env.KADI_TUNNEL_WSS_HOST || KADI_DEFAULTS.KADI_TUNNEL_WSS_HOST,
219
+ KADI_TUNNEL_PORT: tunnelCfg.server_port?.toString() || process.env.KADI_TUNNEL_PORT || KADI_DEFAULTS.KADI_TUNNEL_PORT,
220
+ KADI_TUNNEL_SSH_PORT: tunnelCfg.ssh_port?.toString() || process.env.KADI_TUNNEL_SSH_PORT || KADI_DEFAULTS.KADI_TUNNEL_SSH_PORT,
221
+ KADI_AGENT_ID: tunnelCfg.agent_id || process.env.KADI_AGENT_ID || KADI_DEFAULTS.KADI_AGENT_ID,
222
+ NGROK_AUTH_TOKEN: tunnelSecrets.ngrok_token,
223
+ NGROK_REGION: envNgrokRegion as string | undefined,
224
+ NGROK_PROTOCOL: envNgrokProtocol as string | undefined,
225
+ };
226
+
227
+ for (const [key, value] of Object.entries(envVarsToPropagate)) {
228
+ if (value) {
229
+ process.env[key] = value;
230
+ log('Env set: %s=%s', key, key.includes('TOKEN') ? '***' : value);
231
+ }
232
+ }
233
+
234
+ log('Tunnel env: server=%s domain=%s mode=%s transport=%s wssHost=%s',
235
+ process.env.KADI_TUNNEL_SERVER, process.env.KADI_TUNNEL_DOMAIN,
236
+ process.env.KADI_TUNNEL_MODE, process.env.KADI_TUNNEL_TRANSPORT,
237
+ process.env.KADI_TUNNEL_WSS_HOST);
238
+
239
+ // Create TunneledContainerRegistry instance
240
+ this.registry = new TunneledContainerRegistry({
241
+ port: options.port || 3000,
242
+ tunnelService: resolvedTunnelService,
243
+ tunnelOptions: {
244
+ // Allow CLI options to override env if provided
245
+ authToken: resolvedAuthToken,
246
+ region: options.tunnelRegion || envNgrokRegion,
247
+ protocol: options.tunnelProtocol || envNgrokProtocol,
248
+ subdomain: options.tunnelSubdomain,
249
+ // Control fallback order: kadi (primary) → ngrok → localtunnel → pinggy → localhost.run → serveo
250
+ managerOptions: {
251
+ fallbackServices: process.env.TUNNEL_FALLBACK_SERVICES || 'ngrok,localtunnel,pinggy,localhost.run,serveo'
252
+ }
253
+ },
254
+ enableMonitoring: false,
255
+ // Disable verbose logging - only show errors
256
+ // User can enable with DEBUG=kadi:* environment variable if needed
257
+ enableLogging: false,
258
+ logLevel: 'error',
259
+ // Enable auto-shutdown by default to cleanup resources after deployment completes
260
+ // Can be disabled by passing options.autoShutdown = false
261
+ autoShutdown: options.autoShutdown ?? true,
262
+ containerType: options.containerEngine,
263
+ duration: options.durationMs
264
+ });
265
+
266
+ // Start the registry (includes tunnel establishment)
267
+ await this.registry.start();
268
+ this.registryInfo = this.registry.getRegistryInfo();
269
+
270
+ // Ensure we got valid registry info
271
+ if (!this.registryInfo) {
272
+ throw new Error(
273
+ 'Failed to get registry information after starting registry'
274
+ );
275
+ }
276
+
277
+ // Ensure at minimum we have a local URL (tunnel URL is optional)
278
+ if (!this.registryInfo.localUrl) {
279
+ throw new Error('Registry started but no local URL available');
280
+ }
281
+
282
+ // Report tunnel status to user
283
+ if (this.registryInfo.tunnelUrl) {
284
+ this.logger.log(` ✓ Tunnel connected: ${this.registryInfo.tunnelUrl}`);
285
+
286
+ // Detect if kadi tunnel was requested but a fallback service was used
287
+ const tunnelUrl = this.registryInfo.tunnelUrl;
288
+ const isKadiFallback = resolvedTunnelService === 'kadi' && (
289
+ tunnelUrl.includes('serveo') ||
290
+ tunnelUrl.includes('ngrok') ||
291
+ tunnelUrl.includes('localtunnel') ||
292
+ tunnelUrl.includes('pinggy')
293
+ );
294
+ if (isKadiFallback) {
295
+ this.logger.log(` ⚠ KADI tunnel failed — fell back to alternate service`);
296
+ this.logger.log(` This usually means frpc is not installed or couldn't connect.`);
297
+ this.logger.log(` Install frpc: brew install frpc (macOS) or see https://github.com/fatedier/frp/releases`);
298
+ this.logger.log(` Run with DEBUG=kadi:* for full tunnel diagnostics.`);
299
+ log('KADI tunnel fallback detected. Requested: %s, actual URL: %s', resolvedTunnelService, tunnelUrl);
300
+ }
301
+ } else {
302
+ this.logger.log(` ⚠ No tunnel URL — registry only accessible locally at ${this.registryInfo.localUrl}`);
303
+ this.logger.log(` Akash providers will NOT be able to pull images!`);
304
+ }
305
+
306
+ // Determine the primary registry URL (prefer tunnel over local)
307
+ const primaryRegistryUrl =
308
+ this.registryInfo.tunnelUrl || this.registryInfo.localUrl;
309
+ log('Registry started at: %s', primaryRegistryUrl);
310
+
311
+ await this.displayRegistryAccessInformation();
312
+ } catch (error) {
313
+ await this.stopTemporaryRegistry();
314
+ throw new Error(`Failed to start temporary registry: ${error}`);
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Display registry access information including domain and credentials
320
+ *
321
+ * Reused from temporary-container-registry.ts with the same fallback logic
322
+ */
323
+ private async displayRegistryAccessInformation(): Promise<void> {
324
+ if (!this.registryInfo) {
325
+ return;
326
+ }
327
+
328
+ try {
329
+ // Try to get and display command help (same as temporary-container-registry.ts)
330
+ const commandHelp = await this.registry!.generateCommandHelp();
331
+
332
+ const registryDomain =
333
+ commandHelp?.registry?.registryDomain ||
334
+ (this.registryInfo.tunnelUrl || this.registryInfo.localUrl).replace(
335
+ /^https?:\/\//,
336
+ ''
337
+ );
338
+
339
+ log('Registry domain: %s', registryDomain);
340
+
341
+ if (this.registryInfo.credentials) {
342
+ log('Access key: %s', this.registryInfo.credentials.accessKey);
343
+ log('Secret key: [hidden]');
344
+ }
345
+ } catch (error) {
346
+ // Fallback display if command generation fails (same as temporary-container-registry.ts)
347
+ if (this.registryInfo.credentials) {
348
+ log('Registry credentials available');
349
+ log('Username: %s', this.registryInfo.credentials.accessKey);
350
+ log('Password: [hidden]');
351
+ }
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Check if a container image exists locally
357
+ *
358
+ * Uses `docker images -q` or `podman images -q` to check if an image exists
359
+ * in the local container engine.
360
+ *
361
+ * - `docker images -q <image>` returns the image hash if it exists
362
+ * - Returns empty string if image doesn't exist
363
+ * - Works for both tagged and untagged images
364
+ * - Handles shorthand names correctly (e.g., "nginx" vs "docker.io/library/nginx")
365
+ *
366
+ * @param imageName - Full image name with tag (e.g., "my-app:latest")
367
+ * @param engine - Container engine to use
368
+ * @returns True if image exists locally, false otherwise
369
+ */
370
+ private checkImageExistsLocally(
371
+ imageName: string,
372
+ engine: 'docker' | 'podman'
373
+ ): boolean {
374
+ try {
375
+ const result = execSync(`${engine} images -q ${imageName}`, {
376
+ encoding: 'utf8',
377
+ stdio: ['pipe', 'pipe', 'pipe'] // Suppress stderr
378
+ });
379
+ return result.trim().length > 0;
380
+ } catch (error) {
381
+ // Command failed (engine not available or other error)
382
+ return false;
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Add local images from AkashProfile to the temporary registry
388
+ *
389
+ * **Simplified Logic (Reality-Based Detection):**
390
+ *
391
+ * Instead of guessing if an image is "local" based on name patterns
392
+ * (like checking if it has "/" in the name), we simply check if the
393
+ * image actually exists locally using `docker images -q <image>`.
394
+ *
395
+ * This approach:
396
+ * 1. Is more accurate (checks reality, not heuristics)
397
+ * 2. Is simpler (one check instead of multiple conditions)
398
+ * 3. Handles edge cases automatically:
399
+ * - Docker Hub shorthand ("nginx" → exists remotely, not locally)
400
+ * - Custom registries with default namespaces
401
+ * - Images that "look local" but are actually remote
402
+ *
403
+ * **Decision Flow:**
404
+ * - Image exists locally → Add to temporary registry and make publicly accessible
405
+ * - Image doesn't exist locally → Treat as remote reference (don't add to registry)
406
+ *
407
+ * **Why this works:**
408
+ * The temporary registry is ONLY needed for images that exist locally but need
409
+ * to be made publicly accessible to Akash providers. If an image doesn't exist
410
+ * locally, either:
411
+ * - It's a remote image (providers can pull it directly)
412
+ * - It doesn't exist anywhere (deployment will fail later with clear error)
413
+ *
414
+ * This prevents us from starting unnecessary infrastructure (tunnel, S3 server)
415
+ * and provides better error messages.
416
+ */
417
+ async addLocalImagesToTemporaryRegistry(
418
+ loadedProfile: AkashProfile,
419
+ containerEngine: 'docker' | 'podman'
420
+ ): Promise<void> {
421
+ log('Checking which service images exist locally...');
422
+
423
+ const serviceEntries = Object.entries(loadedProfile.services);
424
+ let localCount = 0;
425
+
426
+ for (const [serviceName, serviceConfig] of serviceEntries) {
427
+ // Type assertion: Zod validates this is AkashService
428
+ const typedService = serviceConfig as { image: string };
429
+ const imageName = typedService.image;
430
+
431
+ // Check if image exists locally (reality-based, not name-based)
432
+ if (this.checkImageExistsLocally(imageName, containerEngine)) {
433
+ localCount++;
434
+ // Image exists locally → add to temporary registry
435
+ this.logger.log(` 📦 ${serviceName}: ${imageName} (local → pushing to registry)`);
436
+ log('Image found locally for service %s: %s', serviceName, imageName);
437
+
438
+ try {
439
+ // Use the intelligent fallback strategy from registry-manager.js
440
+ const mapping = await this.addContainerIntelligently(
441
+ imageName,
442
+ serviceName,
443
+ containerEngine
444
+ );
445
+
446
+ // Store the mapping for later SDL generation
447
+ this.containerMappings.set(imageName, mapping);
448
+ } catch (error) {
449
+ this.logger.error(
450
+ `❌ Failed to add local image '${imageName}' for service '${serviceName}': ${(error as Error).message}`
451
+ );
452
+ throw error; // Stop deployment if we can't host a required local image
453
+ }
454
+ } else {
455
+ // Image doesn't exist locally → treat as remote
456
+ log(`Skipping remote image: ${serviceName}: ${imageName}`);
457
+ log('Image not found locally, treating as remote for service %s: %s', serviceName, imageName);
458
+ }
459
+ }
460
+
461
+ if (localCount > 0) {
462
+ this.logger.log(` ✓ ${localCount} local image(s) pushed to registry`);
463
+ }
464
+ log('Successfully processed %d local images', this.containerMappings.size);
465
+ }
466
+
467
+ /**
468
+ * Intelligent container addition with fallback strategies
469
+ *
470
+ * Adapted from registry-manager.js addContainerIntelligently but enhanced
471
+ * for TypeScript and our new flow.
472
+ */
473
+ private async addContainerIntelligently(
474
+ imageName: string,
475
+ serviceName: string,
476
+ containerEngine: 'docker' | 'podman'
477
+ ): Promise<ContainerMapping> {
478
+ // Parse image name (reuse logic from registry-manager.js)
479
+ const repoName = imageName.includes(':')
480
+ ? imageName.split(':')[0]
481
+ : imageName;
482
+ const imageTag = imageName.includes(':')
483
+ ? imageName.split(':')[1]
484
+ : 'latest';
485
+
486
+ log('Attempting to add container: %s', imageName);
487
+
488
+ // Strategy 1: Try to find and use tar file from kadi-build
489
+ // (Reuse findKadiBuildTarFile logic from registry-manager.js)
490
+ const tarPath = this.findKadiBuildTarFile(imageName);
491
+ if (tarPath) {
492
+ try {
493
+ log('Loading container from tar file: %s', tarPath);
494
+
495
+ const containerInfo = await this.registry!.addContainer({
496
+ type: 'tar',
497
+ name: repoName,
498
+ path: tarPath
499
+ });
500
+
501
+ log('Container loaded from tar file with alias: %s', containerInfo.alias);
502
+
503
+ return this.createContainerMapping(
504
+ imageName,
505
+ serviceName,
506
+ containerInfo,
507
+ repoName,
508
+ imageTag
509
+ );
510
+ } catch (error) {
511
+ log('Failed to load from tar file: %s', (error as Error).message);
512
+ log('Falling back to container engine...');
513
+ }
514
+ }
515
+
516
+ // Strategy 2: Try to add from container engine (docker/podman)
517
+ // (Reuse logic from registry-manager.js)
518
+ try {
519
+ log('Attempting to add from %s engine: %s', containerEngine, imageName);
520
+
521
+ const containerInfo = await this.registry!.addContainer({
522
+ type: containerEngine,
523
+ name: repoName,
524
+ image: imageName
525
+ });
526
+
527
+ log('Container loaded from %s with alias: %s', containerEngine, containerInfo.alias);
528
+
529
+ return this.createContainerMapping(
530
+ imageName,
531
+ serviceName,
532
+ containerInfo,
533
+ repoName,
534
+ imageTag
535
+ );
536
+ } catch (error) {
537
+ log('Failed to load from %s: %s', containerEngine, (error as Error).message);
538
+ }
539
+
540
+ // Strategy 3: Show helpful error message (reuse from registry-manager.js)
541
+ this.showKadiBuildSuggestion(imageName, containerEngine);
542
+ throw new Error(
543
+ `Could not add container ${imageName}. See suggestions above.`
544
+ );
545
+ }
546
+
547
+ /**
548
+ * Create container mapping for registry URLs
549
+ *
550
+ * Transforms a local image reference into a complete registry URL mapping
551
+ * that can be used in SDL generation. Includes service name tracking for
552
+ * better debugging and error messages.
553
+ *
554
+ * @param originalImage - Original image name from agent.json (e.g., "my-app")
555
+ * @param serviceName - Service name from profile (e.g., "frontend")
556
+ * @param containerInfo - Container info from registry with alias
557
+ * @param repoName - Repository name extracted from image
558
+ * @param imageTag - Image tag (e.g., "latest")
559
+ * @returns Complete container mapping with registry URL
560
+ */
561
+ private async createContainerMapping(
562
+ originalImage: string,
563
+ serviceName: string,
564
+ containerInfo: ContainerInfo,
565
+ repoName: string,
566
+ imageTag: string
567
+ ): Promise<ContainerMapping> {
568
+ const actualAlias = containerInfo.alias;
569
+ const registryUrls = await this.registry!.getRegistryUrls();
570
+ const registryDomain = this.getPreferredDomain();
571
+ const registryImageUrl = `${registryDomain}/${actualAlias}:${imageTag}`;
572
+
573
+ const mapping: ContainerMapping = {
574
+ originalImage,
575
+ serviceName,
576
+ registryUrl: registryImageUrl,
577
+ repoName,
578
+ imageTag,
579
+ actualAlias
580
+ };
581
+
582
+ log('Container available at: %s', registryImageUrl);
583
+
584
+ // Verify container is accessible (reuse verification logic)
585
+ this.verifyContainerInRegistry(actualAlias);
586
+
587
+ return mapping;
588
+ }
589
+
590
+ /**
591
+ * NEW METHOD: Get public image URL for a specific service
592
+ *
593
+ * This is the key method that SDL generator will call:
594
+ * "What's the registry URL I should use for this image?"
595
+ */
596
+ getPublicImageUrl(serviceName: string, originalImage: string): string | null {
597
+ // Look for mapping by original image name
598
+ const mapping = this.containerMappings.get(originalImage);
599
+
600
+ if (mapping && mapping.serviceName === serviceName) {
601
+ // Debug logging - could make this conditional in the future
602
+ // this.logger.log(`🔍 Found registry URL for ${serviceName}:${originalImage} → ${mapping.registryUrl}`);
603
+ return mapping.registryUrl;
604
+ }
605
+
606
+ // Debug logging - could make this conditional in the future
607
+ // this.logger.log(`🔍 No registry URL found for ${serviceName}:${originalImage} (not a local image or not processed)`);
608
+ return null;
609
+ }
610
+
611
+ /**
612
+ * Get registry credentials for SDL generation
613
+ *
614
+ * The SDL generator calls this to get credentials for local images
615
+ */
616
+ getRegistryCredentials(): RegistryCredentials | null {
617
+ if (!this.registryInfo?.credentials) {
618
+ log('No registry credentials available');
619
+ return null;
620
+ }
621
+
622
+ log('Registry info - tunnel: %s, local: %s',
623
+ this.registryInfo.tunnelUrl,
624
+ this.registryInfo.localUrl
625
+ );
626
+
627
+ const host = this.getRegistryDomain();
628
+
629
+ // Convert to the format expected by SDL generation
630
+ const credentials = {
631
+ host: host,
632
+ username: this.registryInfo.credentials.accessKey,
633
+ password: this.registryInfo.credentials.secretKey
634
+ };
635
+
636
+ log('Returning registry credentials - host: %s, username: %s',
637
+ credentials.host,
638
+ credentials.username
639
+ );
640
+
641
+ return credentials;
642
+ }
643
+
644
+ /**
645
+ * Check if an image is local (reuse from existing code)
646
+ */
647
+ private isLocalImage(image: string): boolean {
648
+ if (!image) return false;
649
+
650
+ // Local images don't contain registry domains or start with localhost
651
+ return (
652
+ !image.includes('/') ||
653
+ image.startsWith('localhost/') ||
654
+ image.startsWith('127.0.0.1/')
655
+ );
656
+ }
657
+
658
+ /**
659
+ * Find tar file from kadi-build export directory
660
+ *
661
+ * Reused from temporary-container-registry.ts - excellent implementation
662
+ * that searches for container tar files exported by kadi-build in common locations.
663
+ */
664
+ private findKadiBuildTarFile(imageName: string): string | null {
665
+ // Generate the expected filename pattern
666
+ // Example: "agent-a:0.0.1" -> "agent-a-0.0.1.tar"
667
+ const expectedFilename = `${imageName.replace(/[^a-zA-Z0-9.-]/g, '-')}.tar`;
668
+
669
+ // Common locations where kadi-build saves tar files
670
+ const possiblePaths: string[] = [
671
+ // User's home directory .kadi cache (primary location)
672
+ path.join(
673
+ os.homedir(),
674
+ '.kadi',
675
+ 'tmp',
676
+ 'container-registry-exports',
677
+ 'containers'
678
+ ),
679
+ // Current working directory exports (backup location)
680
+ path.join(process.cwd(), 'container-exports'),
681
+ // Temporary directory exports (fallback location)
682
+ path.join(os.tmpdir(), 'container-registry-exports', 'containers')
683
+ ];
684
+
685
+ for (const basePath of possiblePaths) {
686
+ const fullPath = path.join(basePath, expectedFilename);
687
+
688
+ if (fs.existsSync(fullPath)) {
689
+ log('Found tar file for %s: %s', imageName, fullPath);
690
+ return fullPath;
691
+ }
692
+
693
+ log('Checked: %s - not found', fullPath);
694
+ }
695
+
696
+ // Debug: this.logger.log(`❌ No tar file found for ${imageName}`);
697
+ // Debug: this.logger.log(` Expected filename: ${expectedFilename}`);
698
+ // Debug: this.logger.log(` Searched in: ${possiblePaths.join(', ')}`);
699
+ return null;
700
+ }
701
+
702
+ /**
703
+ * Show helpful suggestion to run kadi-build
704
+ *
705
+ * Adapted from registry-manager.js showKadiBuildSuggestion method
706
+ */
707
+ private showKadiBuildSuggestion(
708
+ imageName: string,
709
+ containerType: string
710
+ ): void {
711
+ this.logger.log(`\n🔧 CONTAINER NOT FOUND: ${imageName}`);
712
+ this.logger.log(`\nThis could mean:`);
713
+ this.logger.log(` • The container hasn't been built yet`);
714
+ this.logger.log(` • The container name doesn't match what's available`);
715
+ this.logger.log(` • The container was built with a different tool`);
716
+
717
+ this.logger.log(`\n💡 SUGGESTED SOLUTIONS:`);
718
+ this.logger.log(`\n1. Build the container first:`);
719
+ this.logger.log(
720
+ ` kadi build --tag ${imageName} --engine ${containerType}`
721
+ );
722
+
723
+ this.logger.log(`\n2. Check available containers:`);
724
+ this.logger.log(
725
+ ` ${containerType} images | grep ${imageName.split(':')[0]}`
726
+ );
727
+
728
+ this.logger.log(
729
+ `\n3. Verify the image name in your agent.json matches the built container`
730
+ );
731
+
732
+ this.logger.log(`\n4. If using a different tag, ensure it exists:`);
733
+ this.logger.log(` ${containerType} images ${imageName.split(':')[0]}`);
734
+ }
735
+
736
+ /**
737
+ * Verify container is accessible in the registry
738
+ *
739
+ * Checks that a container with the given alias is present in the registry.
740
+ * Logs a warning if not found and shows available containers for debugging.
741
+ *
742
+ * @param actualAlias - Container alias to verify
743
+ */
744
+ private verifyContainerInRegistry(actualAlias: string): void {
745
+ try {
746
+ const containers = this.registry!.listContainers();
747
+ const foundContainer = containers.find(
748
+ (c) => c.alias === actualAlias
749
+ );
750
+
751
+ if (foundContainer) {
752
+ log('Verified container in registry with alias: %s', foundContainer.alias);
753
+ } else {
754
+ this.logger.warn(
755
+ ` ⚠️ Warning: Container not found in registry with alias: ${actualAlias}`
756
+ );
757
+ this.logger.log(
758
+ ` Available containers: ${containers.map((c) => c.alias ?? c.name).join(', ')}`
759
+ );
760
+ }
761
+ } catch (error) {
762
+ // Silently ignore verification errors - registry might not support listing
763
+ }
764
+ }
765
+
766
+ /**
767
+ * Get the registry domain (without protocol)
768
+ *
769
+ * Reused from registry-manager.js with fallback logic
770
+ */
771
+ private getRegistryDomain(): string {
772
+ // First try to get from our own getRegistryUrls() method
773
+ try {
774
+ const preferredDomain = this.getPreferredDomain();
775
+ if (preferredDomain) {
776
+ log('Registry domain from getRegistryUrls: %s', preferredDomain);
777
+ return preferredDomain;
778
+ }
779
+ } catch (error) {
780
+ log('Could not get registry URLs: %s', error);
781
+ }
782
+
783
+ // Fallback to parsing from registry info
784
+ if (this.registryInfo) {
785
+ const url = this.registryInfo.tunnelUrl || this.registryInfo.localUrl;
786
+ if (url) {
787
+ const domain = url.replace(/^https?:\/\//, '');
788
+ log('Registry domain from registryInfo: %s', domain);
789
+ return domain;
790
+ }
791
+ }
792
+
793
+ this.logger.error('❌ Could not determine registry domain!');
794
+ return '';
795
+ }
796
+
797
+ /**
798
+ * Get registry URL components including local and tunnel endpoints
799
+ */
800
+ private getRegistryUrls(): RegistryUrls {
801
+ if (!this.registryInfo) {
802
+ throw new Error('Registry not started');
803
+ }
804
+
805
+ const localUrl = this.registryInfo.localUrl;
806
+ const tunnelUrl = this.registryInfo.tunnelUrl || null;
807
+
808
+ // Extract domains (remove protocol)
809
+ const localDomain = localUrl.replace(/^https?:\/\//, '');
810
+ const tunnelDomain = tunnelUrl
811
+ ? tunnelUrl.replace(/^https?:\/\//, '')
812
+ : null;
813
+
814
+ return {
815
+ localUrl,
816
+ localDomain,
817
+ tunnelUrl,
818
+ tunnelDomain
819
+ };
820
+ }
821
+
822
+ /**
823
+ * Get the preferred URL (tunnel if available, otherwise local)
824
+ */
825
+ private getPreferredUrl(): string {
826
+ const urls = this.getRegistryUrls();
827
+ return urls.tunnelUrl || urls.localUrl;
828
+ }
829
+
830
+ /**
831
+ * Get the preferred domain (tunnel if available, otherwise local)
832
+ */
833
+ private getPreferredDomain(): string {
834
+ const urls = this.getRegistryUrls();
835
+ return urls.tunnelDomain || urls.localDomain;
836
+ }
837
+
838
+ /**
839
+ * Check if registry is running
840
+ */
841
+ isRunning(): boolean {
842
+ return this.registry !== null && this.registryInfo !== null;
843
+ }
844
+
845
+ /**
846
+ * Display container mappings for debugging
847
+ *
848
+ * Enhanced version that shows the new mapping structure
849
+ */
850
+ async displayContainerMappings(): Promise<void> {
851
+ this.logger.log('\n🔍 Container image mappings:');
852
+
853
+ if (this.containerMappings.size === 0) {
854
+ this.logger.log(' No local images processed');
855
+ return;
856
+ }
857
+
858
+ for (const [originalImage, mapping] of this.containerMappings) {
859
+ this.logger.log(` ${mapping.serviceName}:`);
860
+ this.logger.log(` Original: ${originalImage}`);
861
+ this.logger.log(` Registry: ${mapping.registryUrl}`);
862
+ this.logger.log(` Alias: ${mapping.actualAlias}`);
863
+ }
864
+ }
865
+
866
+ /**
867
+ * Stop the temporary registry and cleanup resources
868
+ *
869
+ * Reused from temporary-container-registry.ts with proper cleanup
870
+ */
871
+ async stopTemporaryRegistry(): Promise<void> {
872
+ if (!this.registry) {
873
+ return;
874
+ }
875
+
876
+ log('Stopping temporary registry...');
877
+
878
+ try {
879
+ // Stop the registry instance
880
+ await this.registry.stop();
881
+
882
+ // Clear state
883
+ this.registry = null;
884
+ this.registryInfo = null;
885
+ this.containerMappings.clear();
886
+
887
+ log('Temporary registry stopped and cleaned up');
888
+ } catch (error) {
889
+ this.logger.warn(`⚠️ Error during registry cleanup: ${error}`);
890
+ }
891
+ }
892
+ }