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
package/src/index.ts
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KADI Deploy - Main Entry Point
|
|
3
|
+
*
|
|
4
|
+
* Profile-based deployment system for KADI agents using deploy-ability library.
|
|
5
|
+
* Supports deployment to Akash Network and local Docker/Podman.
|
|
6
|
+
*
|
|
7
|
+
* This implementation uses deploy-ability as a library for all deployment operations.
|
|
8
|
+
*
|
|
9
|
+
* @module index
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import type { IKadiContext, DeployOptions, ResolvedDeployOptions, DeploymentContext } from './types.js';
|
|
14
|
+
import { loadAgentConfig, getFirstProfile, hasProfile, getProfile } from './config/agent-loader.js';
|
|
15
|
+
import { executeAkashDeployment } from './commands/deploy.js';
|
|
16
|
+
import { executeLocalDeployment } from './commands/deploy-local.js';
|
|
17
|
+
import { executeDown } from './commands/down.js';
|
|
18
|
+
import { validateSecretsOrFail } from './secrets/index.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Register the "deploy" command with the Kadi CLI
|
|
22
|
+
*
|
|
23
|
+
* Uses Commander subcommands:
|
|
24
|
+
* kadi deploy [up] — Deploy agents (default, backward compatible)
|
|
25
|
+
* kadi deploy down — Tear down an active deployment
|
|
26
|
+
*
|
|
27
|
+
* @param ctx - Plugin context injected by the Kadi CLI
|
|
28
|
+
*/
|
|
29
|
+
export default function registerDeploy(ctx: IKadiContext) {
|
|
30
|
+
const { commander, logger } = ctx;
|
|
31
|
+
|
|
32
|
+
// Parent command — `kadi deploy`
|
|
33
|
+
const deploy = commander
|
|
34
|
+
.command('deploy')
|
|
35
|
+
.alias('d')
|
|
36
|
+
.description(
|
|
37
|
+
'Deploy agents to Akash Network or locally, or tear them down'
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// ─────────────────────────────────────────────────────────────────
|
|
41
|
+
// Subcommand: deploy up (default — runs when bare `kadi deploy` is used)
|
|
42
|
+
// ─────────────────────────────────────────────────────────────────
|
|
43
|
+
deploy
|
|
44
|
+
.command('up', { isDefault: true })
|
|
45
|
+
.description(
|
|
46
|
+
'Deploy agents to Akash Network or locally using profiles from agent.json'
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
// Profile selection
|
|
50
|
+
.option(
|
|
51
|
+
'--profile <profile>',
|
|
52
|
+
'Deploy profile to use from agent.json (if not specified, uses first available profile)'
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
// Project configuration
|
|
56
|
+
.option(
|
|
57
|
+
'-p, --project <path>',
|
|
58
|
+
'Path to folder containing agent.json'
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
// Deployment options
|
|
62
|
+
.option('--dry-run', "Preview deployment without executing")
|
|
63
|
+
.option('--yes, -y', 'Skip confirmation prompts')
|
|
64
|
+
.option('--verbose, -v', 'Verbose logging')
|
|
65
|
+
|
|
66
|
+
// Network/engine overrides
|
|
67
|
+
.option('--network <network>', 'Akash network (mainnet|testnet)')
|
|
68
|
+
.option('--engine <engine>', 'Container engine (docker|podman)')
|
|
69
|
+
|
|
70
|
+
// Certificate (Akash only)
|
|
71
|
+
.option('--cert <path>', 'Path to existing TLS certificate')
|
|
72
|
+
|
|
73
|
+
// Autonomous deployment (agent-controlled, no human interaction)
|
|
74
|
+
.option('--autonomous', 'Fully autonomous deployment with no human interaction (uses secrets vault for wallet)')
|
|
75
|
+
.option('--bid-strategy <strategy>', 'Bid selection strategy for autonomous mode (cheapest|most-reliable|balanced)', 'cheapest')
|
|
76
|
+
.option('--bid-max-price <uakt>', 'Maximum bid price per block in uAKT (autonomous mode)')
|
|
77
|
+
.option('--require-audited', 'Only accept bids from audited providers (autonomous mode)')
|
|
78
|
+
.option('--secrets-vault <vault>', 'Vault for wallet mnemonic (default: "global"). Deployment secrets always use the profile\'s secrets.vault')
|
|
79
|
+
.option('--auto-approve-secrets', 'Auto-approve secret sharing without prompting')
|
|
80
|
+
.option('--secret-timeout <ms>', 'Timeout (ms) for waiting for agent to request secrets')
|
|
81
|
+
.option('--label <label>', 'Human-readable label for this deployment instance (e.g. "broker-east")')
|
|
82
|
+
|
|
83
|
+
// Help text
|
|
84
|
+
.addHelpText(
|
|
85
|
+
'after',
|
|
86
|
+
`
|
|
87
|
+
Profile Examples:
|
|
88
|
+
kadi deploy # Use first available profile
|
|
89
|
+
kadi deploy --profile production # Use production profile
|
|
90
|
+
kadi deploy --profile akash-mainnet # Deploy to Akash mainnet
|
|
91
|
+
kadi deploy --profile local-dev # Deploy locally
|
|
92
|
+
|
|
93
|
+
Autonomous Deployment (no human interaction):
|
|
94
|
+
kadi deploy --autonomous # Auto-deploy using secrets vault
|
|
95
|
+
kadi deploy --autonomous --bid-strategy most-reliable
|
|
96
|
+
kadi deploy --autonomous --bid-max-price 1000 --require-audited
|
|
97
|
+
kadi deploy --autonomous --auto-approve-secrets
|
|
98
|
+
|
|
99
|
+
Teardown:
|
|
100
|
+
kadi deploy down # Tear down the active deployment
|
|
101
|
+
kadi deploy down --autonomous # Fully non-interactive (skips confirmation + QR)
|
|
102
|
+
kadi deploy down --autonomous --profile prod # Required when multiple deployments active
|
|
103
|
+
kadi deploy down --yes # Skip confirmation (interactive mode)
|
|
104
|
+
|
|
105
|
+
Configuration:
|
|
106
|
+
Profiles are defined in agent.json under the "deploy" field.
|
|
107
|
+
CLI flags override profile settings.
|
|
108
|
+
|
|
109
|
+
For autonomous mode, store your wallet mnemonic in the secrets vault:
|
|
110
|
+
kadi secrets set --vault global --key AKASH_WALLET --value "your mnemonic"
|
|
111
|
+
|
|
112
|
+
Supported Targets:
|
|
113
|
+
• akash - Deploy to Akash Network (decentralized cloud)
|
|
114
|
+
• local - Deploy to local Docker/Podman
|
|
115
|
+
`
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
.action(async (...args: unknown[]) => {
|
|
119
|
+
// Commander passes flags as first argument
|
|
120
|
+
const flags = args[0] as Record<string, unknown>;
|
|
121
|
+
|
|
122
|
+
// Set default project path if not provided
|
|
123
|
+
const deployFlags = {
|
|
124
|
+
...flags,
|
|
125
|
+
project: (flags.project as string) || process.cwd(),
|
|
126
|
+
bidMaxPrice: flags.bidMaxPrice ? parseInt(flags.bidMaxPrice as string, 10) : undefined,
|
|
127
|
+
secretTimeout: flags.secretTimeout ? parseInt(flags.secretTimeout as string, 10) : undefined,
|
|
128
|
+
} as DeployOptions;
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
await deployWithDeployAbility(ctx, deployFlags);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
const errorMessage =
|
|
134
|
+
error instanceof Error ? error.message : String(error);
|
|
135
|
+
logger.error(`Deploy failed: ${errorMessage}`);
|
|
136
|
+
|
|
137
|
+
if (deployFlags.verbose) {
|
|
138
|
+
logger.error('Full error details:');
|
|
139
|
+
console.error(error);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
process.exitCode = 1;
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ─────────────────────────────────────────────────────────────────
|
|
147
|
+
// Subcommand: deploy down — tear down an active deployment
|
|
148
|
+
// ─────────────────────────────────────────────────────────────────
|
|
149
|
+
deploy
|
|
150
|
+
.command('down')
|
|
151
|
+
.description(
|
|
152
|
+
'Tear down an active deployment (reads .kadi-deploy.lock)'
|
|
153
|
+
)
|
|
154
|
+
.option(
|
|
155
|
+
'-p, --project <path>',
|
|
156
|
+
'Path to project with agent.json / .kadi-deploy.lock'
|
|
157
|
+
)
|
|
158
|
+
.option('--profile <profile>', 'Profile name to tear down (prompts if multiple active)')
|
|
159
|
+
.option('--instance <id>', 'Instance ID to tear down (4-char hex from deploy output)')
|
|
160
|
+
.option('--all', 'Tear down all active deployments')
|
|
161
|
+
.option('--engine <engine>', 'Override container engine (docker|podman)')
|
|
162
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
163
|
+
.option('--verbose, -v', 'Verbose output')
|
|
164
|
+
.option('--autonomous', 'Autonomous Akash teardown (wallet from secrets vault, no QR)')
|
|
165
|
+
.option('--secrets-vault <vault>', 'Vault name for wallet mnemonic (default: "global")')
|
|
166
|
+
.option('--network <network>', 'Override Akash network (mainnet|testnet)')
|
|
167
|
+
.action(async (...args: unknown[]) => {
|
|
168
|
+
const flags = args[0] as Record<string, unknown>;
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
await executeDown(ctx, {
|
|
172
|
+
project: (flags.project as string) || process.cwd(),
|
|
173
|
+
profile: flags.profile as string | undefined,
|
|
174
|
+
instance: flags.instance as string | undefined,
|
|
175
|
+
all: flags.all as boolean | undefined,
|
|
176
|
+
engine: flags.engine as string | undefined,
|
|
177
|
+
yes: flags.yes as boolean | undefined,
|
|
178
|
+
verbose: flags.verbose as boolean | undefined,
|
|
179
|
+
autonomous: flags.autonomous as boolean | undefined,
|
|
180
|
+
secretsVault: flags.secretsVault as string | undefined,
|
|
181
|
+
network: flags.network as string | undefined,
|
|
182
|
+
});
|
|
183
|
+
} catch (error) {
|
|
184
|
+
const errorMessage =
|
|
185
|
+
error instanceof Error ? error.message : String(error);
|
|
186
|
+
logger.error(`Teardown failed: ${errorMessage}`);
|
|
187
|
+
|
|
188
|
+
if (flags.verbose) {
|
|
189
|
+
logger.error('Full error details:');
|
|
190
|
+
console.error(error);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
process.exitCode = 1;
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Main deployment logic using deploy-ability library
|
|
200
|
+
*
|
|
201
|
+
* This function:
|
|
202
|
+
* 1. Loads agent.json
|
|
203
|
+
* 2. Resolves profile
|
|
204
|
+
* 3. Routes to appropriate command (Akash or local)
|
|
205
|
+
*
|
|
206
|
+
* @param ctx - KADI CLI context
|
|
207
|
+
* @param options - Raw CLI options
|
|
208
|
+
*/
|
|
209
|
+
async function deployWithDeployAbility(ctx: IKadiContext, options: DeployOptions) {
|
|
210
|
+
const { logger } = ctx;
|
|
211
|
+
const projectRoot = path.resolve(options.project || process.cwd());
|
|
212
|
+
|
|
213
|
+
// ----------------------------------------------------------------
|
|
214
|
+
// 1. Load agent.json
|
|
215
|
+
// ----------------------------------------------------------------
|
|
216
|
+
let agentConfig;
|
|
217
|
+
try {
|
|
218
|
+
agentConfig = await loadAgentConfig(projectRoot);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
221
|
+
logger.error(
|
|
222
|
+
`Unable to load agent.json from ${projectRoot}: ${errorMessage}`
|
|
223
|
+
);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ----------------------------------------------------------------
|
|
228
|
+
// 2. Resolve profile
|
|
229
|
+
// ----------------------------------------------------------------
|
|
230
|
+
let selectedProfile = options.profile;
|
|
231
|
+
|
|
232
|
+
if (options.profile) {
|
|
233
|
+
// User explicitly requested a profile - validate it exists
|
|
234
|
+
if (!hasProfile(agentConfig, options.profile)) {
|
|
235
|
+
logger.error(
|
|
236
|
+
`Deploy profile '${options.profile}' not found in agent.json`
|
|
237
|
+
);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
} else {
|
|
241
|
+
// No profile specified - use first one
|
|
242
|
+
selectedProfile = getFirstProfile(agentConfig);
|
|
243
|
+
|
|
244
|
+
if (!selectedProfile) {
|
|
245
|
+
logger.error('No deploy profiles found in agent.json');
|
|
246
|
+
logger.log('Add a deploy profile under the "deploy" field in agent.json');
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Get profile configuration
|
|
252
|
+
if (!selectedProfile) {
|
|
253
|
+
logger.error('No deploy profiles available');
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Get and validate profile structure (Zod validation)
|
|
258
|
+
const profile = getProfile(agentConfig, selectedProfile);
|
|
259
|
+
if (!profile) {
|
|
260
|
+
logger.error(`Profile "${selectedProfile}" not found in agent.json`);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ----------------------------------------------------------------
|
|
265
|
+
// 2.5. Validate secrets exist before deployment
|
|
266
|
+
// ----------------------------------------------------------------
|
|
267
|
+
// Check that all required secrets are available in the local vault.
|
|
268
|
+
// This shells out to `kadi secret list --json` and compares against
|
|
269
|
+
// the profile's secrets.required array. Fails fast with helpful error
|
|
270
|
+
// if any required secrets are missing.
|
|
271
|
+
try {
|
|
272
|
+
validateSecretsOrFail(profile, projectRoot, logger);
|
|
273
|
+
} catch (err) {
|
|
274
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
275
|
+
logger.error(errorMessage);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const target = profile.target;
|
|
280
|
+
|
|
281
|
+
// ----------------------------------------------------------------
|
|
282
|
+
// 3. Build deployment context
|
|
283
|
+
// ----------------------------------------------------------------
|
|
284
|
+
const deployCtx: DeploymentContext = {
|
|
285
|
+
...ctx,
|
|
286
|
+
projectRoot,
|
|
287
|
+
agent: agentConfig,
|
|
288
|
+
flags: {
|
|
289
|
+
...options,
|
|
290
|
+
profile: selectedProfile,
|
|
291
|
+
target,
|
|
292
|
+
project: projectRoot
|
|
293
|
+
} as ResolvedDeployOptions
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
// ----------------------------------------------------------------
|
|
297
|
+
// 4. Route to appropriate deployment command
|
|
298
|
+
// ----------------------------------------------------------------
|
|
299
|
+
if (target === 'akash') {
|
|
300
|
+
await executeAkashDeployment(deployCtx);
|
|
301
|
+
} else if (target === 'local') {
|
|
302
|
+
await executeLocalDeployment(deployCtx);
|
|
303
|
+
} else {
|
|
304
|
+
logger.error(`Unknown deployment target: ${target}`);
|
|
305
|
+
logger.log('Supported targets: akash, local');
|
|
306
|
+
}
|
|
307
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry Infrastructure Module
|
|
3
|
+
*
|
|
4
|
+
* Handles temporary container registry for local images.
|
|
5
|
+
*
|
|
6
|
+
* **Problem**: Akash providers can't pull local images like "my-app"
|
|
7
|
+
* **Solution**:
|
|
8
|
+
* 1. Start temporary registry on localhost:3000
|
|
9
|
+
* 2. Push local images to it
|
|
10
|
+
* 3. Expose publicly via tunnel (kadi/ngrok/serveo/localtunnel)
|
|
11
|
+
* 4. Rewrite profile to use public registry URLs
|
|
12
|
+
* 5. Providers can now pull images during deployment
|
|
13
|
+
*
|
|
14
|
+
* @module infrastructure/registry
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { TemporaryContainerRegistryManager } from '../enhanced-registry-manager.js';
|
|
18
|
+
import type { IKadiLogger } from '../types/external.js';
|
|
19
|
+
import type { AkashDeploymentData } from '@kadi.build/deploy-ability/types';
|
|
20
|
+
import type { AkashProfile } from '../schemas/profiles.js';
|
|
21
|
+
import { execSync } from 'node:child_process';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Registry context returned by setupRegistryIfNeeded
|
|
25
|
+
*/
|
|
26
|
+
export interface RegistryContext {
|
|
27
|
+
/**
|
|
28
|
+
* Profile ready for deployment to Akash
|
|
29
|
+
*
|
|
30
|
+
* Local images have been replaced with public registry URLs.
|
|
31
|
+
* For example:
|
|
32
|
+
* - Original: { image: "my-app" }
|
|
33
|
+
* - Deployable: { image: "abc123.serveo.net/my-app:latest", credentials: {...} }
|
|
34
|
+
*/
|
|
35
|
+
deployableProfile: AkashProfile;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Cleanup function to call after deployment
|
|
39
|
+
*
|
|
40
|
+
* Waits for containers to be running on provider, then shuts down
|
|
41
|
+
* the temporary registry (no longer needed once containers pulled images).
|
|
42
|
+
*/
|
|
43
|
+
cleanup: () => Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Sets up temporary registry infrastructure if profile uses local images
|
|
48
|
+
*
|
|
49
|
+
* @param profile - Original profile from agent.json
|
|
50
|
+
* @param logger - Logger instance
|
|
51
|
+
* @param options - Registry configuration options
|
|
52
|
+
* @returns Registry context with deployable profile and cleanup function
|
|
53
|
+
*
|
|
54
|
+
* @example No local images
|
|
55
|
+
* ```typescript
|
|
56
|
+
* // Profile uses remote images only (docker.io/nginx)
|
|
57
|
+
* const ctx = await setupRegistryIfNeeded(profile, logger)
|
|
58
|
+
* // ctx.deployableProfile === profile (unchanged)
|
|
59
|
+
* // ctx.cleanup() is a no-op
|
|
60
|
+
* ```
|
|
61
|
+
*
|
|
62
|
+
* @example Local images present
|
|
63
|
+
* ```typescript
|
|
64
|
+
* // Profile uses local images (my-app, my-api)
|
|
65
|
+
* const ctx = await setupRegistryIfNeeded(profile, logger)
|
|
66
|
+
* // ctx.deployableProfile has images rewritten to public URLs
|
|
67
|
+
* // ctx.cleanup() waits for containers and shuts down registry
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export async function setupRegistryIfNeeded(
|
|
71
|
+
profile: AkashProfile,
|
|
72
|
+
logger: IKadiLogger,
|
|
73
|
+
options: {
|
|
74
|
+
useRemoteRegistry?: boolean;
|
|
75
|
+
registryDuration?: number;
|
|
76
|
+
tunnelService?: 'kadi' | 'ngrok' | 'serveo' | 'localtunnel';
|
|
77
|
+
containerEngine?: 'docker' | 'podman';
|
|
78
|
+
} = {}
|
|
79
|
+
): Promise<RegistryContext> {
|
|
80
|
+
// User explicitly wants to use remote registry
|
|
81
|
+
if (options.useRemoteRegistry) {
|
|
82
|
+
logger.log('📡 Using remote registry (local registry disabled)');
|
|
83
|
+
logger.log('⚠️ Ensure all images are pushed to a remote registry!');
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
deployableProfile: profile,
|
|
87
|
+
cleanup: async () => {} // No cleanup needed
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Check if any services use local images
|
|
92
|
+
if (!hasLocalImages(profile)) {
|
|
93
|
+
// All images are remote (docker.io/nginx, ghcr.io/owner/repo, etc.)
|
|
94
|
+
// No registry needed - providers can pull directly
|
|
95
|
+
return {
|
|
96
|
+
deployableProfile: profile,
|
|
97
|
+
cleanup: async () => {} // No cleanup needed
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Profile has local images - start registry infrastructure
|
|
102
|
+
const resolvedTunnel = options.tunnelService || 'kadi';
|
|
103
|
+
const resolvedEngine = options.containerEngine || 'docker';
|
|
104
|
+
logger.log(`🔧 Local images detected — setting up temporary registry (${resolvedTunnel} tunnel)...`);
|
|
105
|
+
|
|
106
|
+
const manager = new TemporaryContainerRegistryManager(logger);
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
await manager.startTemporaryRegistry({
|
|
110
|
+
port: 3000,
|
|
111
|
+
tunnelService: resolvedTunnel,
|
|
112
|
+
containerEngine: resolvedEngine,
|
|
113
|
+
durationMs: options.registryDuration || 600000, // 10 minutes default
|
|
114
|
+
autoShutdown: false // We'll shutdown manually after containers are running
|
|
115
|
+
});
|
|
116
|
+
} catch (err) {
|
|
117
|
+
const msg = (err as Error).message || String(err);
|
|
118
|
+
logger.error(`❌ Registry startup failed: ${msg}`);
|
|
119
|
+
if (resolvedTunnel === 'kadi' && (msg.includes('token') || msg.includes('auth') || msg.includes('KADI_TUNNEL'))) {
|
|
120
|
+
logger.error(``);
|
|
121
|
+
logger.error(` The kadi tunnel (frpc) requires a token.`);
|
|
122
|
+
logger.error(` Ensure your .env file contains KADI_TUNNEL_TOKEN and is`);
|
|
123
|
+
logger.error(` placed in the kadi-deploy plugin root (next to package.json).`);
|
|
124
|
+
}
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Push local images to registry
|
|
129
|
+
logger.log(`📦 Pushing local images to temporary registry...`);
|
|
130
|
+
await manager.addLocalImagesToTemporaryRegistry(
|
|
131
|
+
profile,
|
|
132
|
+
resolvedEngine
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// Transform profile: "my-app" → "abc123.serveo.net/my-app:latest"
|
|
136
|
+
const deployableProfile = transformProfileWithRegistry(profile, manager);
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
deployableProfile,
|
|
140
|
+
cleanup: async () => {
|
|
141
|
+
if (manager.isRunning()) {
|
|
142
|
+
logger.log('🛑 Shutting down temporary registry...');
|
|
143
|
+
await manager.stopTemporaryRegistry();
|
|
144
|
+
logger.log('✅ Temporary registry shut down');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Checks if profile contains any local container images
|
|
152
|
+
*
|
|
153
|
+
* **Reality-Based Detection:**
|
|
154
|
+
* Instead of guessing based on image name patterns, we check if images
|
|
155
|
+
* actually exist locally using `docker images -q <image>`.
|
|
156
|
+
*
|
|
157
|
+
* This is more accurate because:
|
|
158
|
+
* - "nginx" might look local but exists only remotely (Docker Hub)
|
|
159
|
+
* - "my-app" might be on local machine and needs our temporary registry
|
|
160
|
+
* - We avoid false positives from name heuristics
|
|
161
|
+
*
|
|
162
|
+
* **Why this matters:**
|
|
163
|
+
* If this returns true, we start the temporary registry infrastructure
|
|
164
|
+
* (tunnel, S3 server). We should only do this when there are ACTUAL local
|
|
165
|
+
* images that need to be made publicly accessible.
|
|
166
|
+
*
|
|
167
|
+
* @param profile - Profile to check
|
|
168
|
+
* @param engine - Container engine to use for checking (default: 'docker')
|
|
169
|
+
* @returns True if profile has any images that exist locally
|
|
170
|
+
*/
|
|
171
|
+
export function hasLocalImages(profile: AkashProfile, engine: 'docker' | 'podman' = 'docker'): boolean {
|
|
172
|
+
if (!profile.services) return false;
|
|
173
|
+
|
|
174
|
+
return Object.values(profile.services).some((service) => {
|
|
175
|
+
const image = service.image;
|
|
176
|
+
if (!image) return false;
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
// Check if image exists locally
|
|
180
|
+
const result = execSync(`${engine} images -q ${image}`, {
|
|
181
|
+
encoding: 'utf8',
|
|
182
|
+
stdio: ['pipe', 'pipe', 'pipe'] // Suppress stderr
|
|
183
|
+
});
|
|
184
|
+
return result.trim().length > 0;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
// Image doesn't exist locally or engine unavailable
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Transforms profile to use registry URLs for local images
|
|
194
|
+
*
|
|
195
|
+
* Creates a new profile object with:
|
|
196
|
+
* - Local images replaced with public registry URLs
|
|
197
|
+
* - Registry credentials added to each service
|
|
198
|
+
*
|
|
199
|
+
* @param profile - Original profile
|
|
200
|
+
* @param manager - Running registry manager
|
|
201
|
+
* @returns Transformed profile ready for deployment
|
|
202
|
+
*/
|
|
203
|
+
function transformProfileWithRegistry(
|
|
204
|
+
profile: AkashProfile,
|
|
205
|
+
manager: TemporaryContainerRegistryManager
|
|
206
|
+
): AkashProfile {
|
|
207
|
+
// Deep clone to avoid mutating original
|
|
208
|
+
const transformed = JSON.parse(JSON.stringify(profile)) as AkashProfile;
|
|
209
|
+
|
|
210
|
+
// Get registry credentials
|
|
211
|
+
const credentials = manager.getRegistryCredentials();
|
|
212
|
+
|
|
213
|
+
// Transform each service
|
|
214
|
+
for (const [serviceName, service] of Object.entries(transformed.services)) {
|
|
215
|
+
// Service is typed from Zod schema
|
|
216
|
+
const originalImage = service.image;
|
|
217
|
+
|
|
218
|
+
// Check if this is a local image
|
|
219
|
+
const isLocal =
|
|
220
|
+
!originalImage.includes('/') ||
|
|
221
|
+
originalImage.startsWith('localhost/') ||
|
|
222
|
+
originalImage.startsWith('127.0.0.1/');
|
|
223
|
+
|
|
224
|
+
if (isLocal) {
|
|
225
|
+
// Get public registry URL for this image
|
|
226
|
+
const registryUrl = manager.getPublicImageUrl(serviceName, originalImage);
|
|
227
|
+
|
|
228
|
+
if (registryUrl) {
|
|
229
|
+
// Replace image with registry URL
|
|
230
|
+
service.image = registryUrl;
|
|
231
|
+
|
|
232
|
+
// Add registry credentials
|
|
233
|
+
if (credentials) {
|
|
234
|
+
service.credentials = {
|
|
235
|
+
host: credentials.host,
|
|
236
|
+
username: credentials.username,
|
|
237
|
+
password: credentials.password
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return transformed;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Wait for containers to be running on Akash provider before shutting down registry
|
|
249
|
+
*
|
|
250
|
+
* This is called by the cleanup function to ensure providers have pulled images
|
|
251
|
+
* before we shut down the temporary registry.
|
|
252
|
+
*
|
|
253
|
+
* @param deploymentData - Deployment result from deploy-ability
|
|
254
|
+
* @param logger - Logger instance
|
|
255
|
+
* @param timeoutMs - How long to wait (default: 5 minutes)
|
|
256
|
+
*/
|
|
257
|
+
export async function waitForContainersRunning(
|
|
258
|
+
deploymentData: AkashDeploymentData,
|
|
259
|
+
logger: IKadiLogger,
|
|
260
|
+
timeoutMs: number = 300000
|
|
261
|
+
): Promise<void> {
|
|
262
|
+
logger.log('⏳ Waiting for containers to be running on provider...');
|
|
263
|
+
|
|
264
|
+
// TODO: Use deploy-ability's waitForContainersRunning if available
|
|
265
|
+
// For now, just wait a bit to let providers pull images
|
|
266
|
+
await new Promise(resolve => setTimeout(resolve, 30000)); // 30 seconds
|
|
267
|
+
|
|
268
|
+
logger.log('✅ Containers should be running (or pulling images)');
|
|
269
|
+
}
|