hackerrun 0.1.0 → 0.1.2
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/CLAUDE.md +138 -0
- package/dist/index.js +1520 -392
- package/package.json +1 -1
- package/src/commands/app.ts +30 -6
- package/src/commands/connect.ts +53 -1
- package/src/commands/deploy.ts +88 -18
- package/src/commands/scale.ts +231 -0
- package/src/commands/vpn.ts +240 -0
- package/src/index.ts +8 -0
- package/src/lib/cluster.ts +175 -20
- package/src/lib/gateway-tunnel.ts +187 -0
- package/src/lib/platform-client.ts +191 -69
- package/src/lib/uncloud-runner.ts +138 -111
- package/src/lib/uncloud.ts +10 -1
- package/src/lib/vpn.ts +487 -0
package/package.json
CHANGED
package/src/commands/app.ts
CHANGED
|
@@ -238,10 +238,20 @@ export function createAppCommands() {
|
|
|
238
238
|
await platformClient.deleteVM(app.location, node.name);
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
-
// Remove app from backend
|
|
241
|
+
// Remove app from backend (cascades to delete routes)
|
|
242
242
|
spinner.text = 'Removing app from database...';
|
|
243
|
+
const appLocation = app.location; // Store before deletion
|
|
243
244
|
await platformClient.deleteApp(appName);
|
|
244
245
|
|
|
246
|
+
// Sync gateway to remove stale route from Caddyfile
|
|
247
|
+
spinner.text = 'Syncing gateway routes...';
|
|
248
|
+
try {
|
|
249
|
+
await platformClient.syncGatewayRoutes(appLocation);
|
|
250
|
+
} catch (error) {
|
|
251
|
+
// Non-fatal: gateway sync failure shouldn't block destroy
|
|
252
|
+
console.log(chalk.dim(` Warning: Could not sync gateway: ${(error as Error).message}`));
|
|
253
|
+
}
|
|
254
|
+
|
|
245
255
|
// Remove uncloud context from local config
|
|
246
256
|
spinner.text = 'Cleaning up local configuration...';
|
|
247
257
|
const contextName = app.uncloudContext || appName;
|
|
@@ -369,17 +379,31 @@ export function createAppCommands() {
|
|
|
369
379
|
const spinner = ora(`Changing domain from '${oldDomain}' to '${newDomain}'...`).start();
|
|
370
380
|
|
|
371
381
|
try {
|
|
372
|
-
|
|
373
|
-
|
|
382
|
+
let updatedApp = app;
|
|
383
|
+
|
|
384
|
+
// Only call rename if domain is actually changing
|
|
385
|
+
if (oldDomain !== newDomain) {
|
|
386
|
+
updatedApp = await platformClient.renameDomain(appName, newDomain);
|
|
387
|
+
spinner.succeed(chalk.green(`Domain changed successfully`));
|
|
388
|
+
} else {
|
|
389
|
+
spinner.succeed(chalk.green(`Domain already set to '${newDomain}'`));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Ensure route exists (create if missing, update if exists)
|
|
393
|
+
const primaryNode = app.nodes.find(n => n.isPrimary);
|
|
394
|
+
if (primaryNode?.ipv6) {
|
|
395
|
+
spinner.start('Ensuring route exists...');
|
|
396
|
+
await platformClient.registerRoute(appName, primaryNode.ipv6, 80);
|
|
397
|
+
spinner.succeed(chalk.green('Route registered'));
|
|
398
|
+
}
|
|
374
399
|
|
|
375
|
-
// Sync gateway routes
|
|
400
|
+
// Sync gateway routes
|
|
376
401
|
spinner.start('Syncing gateway routes...');
|
|
377
402
|
await platformClient.syncGatewayRoutes(app.location);
|
|
378
403
|
spinner.succeed(chalk.green('Gateway routes synced'));
|
|
379
404
|
|
|
380
405
|
console.log(`\n App: ${updatedApp.appName}`);
|
|
381
|
-
console.log(`
|
|
382
|
-
console.log(` New domain: https://${updatedApp.domainName}.hackerrun.app\n`);
|
|
406
|
+
console.log(` Domain: https://${updatedApp.domainName}.hackerrun.app\n`);
|
|
383
407
|
} catch (error) {
|
|
384
408
|
spinner.fail(chalk.red('Failed to change domain'));
|
|
385
409
|
throw error;
|
package/src/commands/connect.ts
CHANGED
|
@@ -78,6 +78,7 @@ export function createConnectCommand() {
|
|
|
78
78
|
choices: [
|
|
79
79
|
{ name: 'Change repository', value: 'change' },
|
|
80
80
|
{ name: 'Keep current connection', value: 'keep' },
|
|
81
|
+
{ name: 'Reconnect (delete and re-setup)', value: 'reconnect' },
|
|
81
82
|
],
|
|
82
83
|
});
|
|
83
84
|
|
|
@@ -85,6 +86,13 @@ export function createConnectCommand() {
|
|
|
85
86
|
console.log(chalk.green('\nConnection unchanged.\n'));
|
|
86
87
|
return;
|
|
87
88
|
}
|
|
89
|
+
|
|
90
|
+
if (action === 'reconnect') {
|
|
91
|
+
const disconnectSpinner = ora('Disconnecting existing repository...').start();
|
|
92
|
+
await platformClient.disconnectRepo(appName);
|
|
93
|
+
disconnectSpinner.succeed('Existing connection removed');
|
|
94
|
+
console.log(chalk.cyan('\nReconnecting...\n'));
|
|
95
|
+
}
|
|
88
96
|
}
|
|
89
97
|
|
|
90
98
|
// Check for GitHub App installation
|
|
@@ -118,7 +126,51 @@ export function createConnectCommand() {
|
|
|
118
126
|
|
|
119
127
|
// Fetch accessible repos
|
|
120
128
|
const repoSpinner = ora('Fetching accessible repositories...').start();
|
|
121
|
-
|
|
129
|
+
let repos: Awaited<ReturnType<typeof platformClient.listAccessibleRepos>>;
|
|
130
|
+
try {
|
|
131
|
+
repos = await platformClient.listAccessibleRepos();
|
|
132
|
+
} catch (error: any) {
|
|
133
|
+
repoSpinner.stop();
|
|
134
|
+
// Check if this is a stale installation error (GitHub App was uninstalled)
|
|
135
|
+
const isStaleInstallation =
|
|
136
|
+
error.message.includes('create-an-installation-access-token') ||
|
|
137
|
+
error.message.includes('Bad credentials') ||
|
|
138
|
+
(error.message.includes('installation') && error.message.includes('Not Found'));
|
|
139
|
+
|
|
140
|
+
if (isStaleInstallation) {
|
|
141
|
+
console.log(chalk.yellow('\nGitHub App installation is stale or was removed from GitHub.'));
|
|
142
|
+
console.log(chalk.cyan('Clearing stale record and prompting for reinstallation...\n'));
|
|
143
|
+
|
|
144
|
+
await platformClient.deleteGitHubInstallation();
|
|
145
|
+
|
|
146
|
+
// Re-run installation flow
|
|
147
|
+
console.log(chalk.cyan('Please reinstall the HackerRun GitHub App:\n'));
|
|
148
|
+
const spinner = ora('Initiating GitHub connection...').start();
|
|
149
|
+
const flow = await platformClient.initiateGitHubConnect();
|
|
150
|
+
spinner.succeed('GitHub connection initiated');
|
|
151
|
+
|
|
152
|
+
console.log(chalk.cyan('\nPlease complete the following steps:\n'));
|
|
153
|
+
console.log(` 1. Visit: ${chalk.bold.blue(flow.authUrl)}`);
|
|
154
|
+
console.log(` 2. Install the HackerRun app on your account`);
|
|
155
|
+
console.log(` 3. Select the repositories you want to access\n`);
|
|
156
|
+
|
|
157
|
+
const pollSpinner = ora('Waiting for GitHub authorization...').start();
|
|
158
|
+
try {
|
|
159
|
+
await pollForInstallation(platformClient, flow.stateToken, pollSpinner);
|
|
160
|
+
pollSpinner.succeed('GitHub App installed');
|
|
161
|
+
} catch (pollError) {
|
|
162
|
+
pollSpinner.fail((pollError as Error).message);
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Retry fetching repos
|
|
167
|
+
const retrySpinner = ora('Fetching accessible repositories...').start();
|
|
168
|
+
repos = await platformClient.listAccessibleRepos();
|
|
169
|
+
retrySpinner.stop();
|
|
170
|
+
} else {
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
122
174
|
repoSpinner.stop();
|
|
123
175
|
|
|
124
176
|
if (repos.length === 0) {
|
package/src/commands/deploy.ts
CHANGED
|
@@ -85,19 +85,23 @@ export function createDeployCommand() {
|
|
|
85
85
|
.option('-i, --image <image>', 'Boot image')
|
|
86
86
|
.option('--build-token <token>', 'Build token for CI/CD (bypasses normal auth)')
|
|
87
87
|
.action(async (options) => {
|
|
88
|
+
const deployStartTime = Date.now();
|
|
89
|
+
|
|
88
90
|
try {
|
|
89
91
|
// Check platform compatibility
|
|
90
92
|
PlatformDetector.ensureSupported();
|
|
91
93
|
|
|
92
|
-
// Check uncloud CLI is installed (offers to auto-install if missing)
|
|
93
|
-
await UncloudManager.ensureInstalled();
|
|
94
|
-
|
|
95
94
|
// Determine authentication mode
|
|
96
95
|
// CI/CD mode: use build token if provided
|
|
97
96
|
// Interactive mode: use user's platform token
|
|
98
|
-
let platformToken: string;
|
|
99
97
|
const isCIBuild = !!options.buildToken;
|
|
100
98
|
|
|
99
|
+
// Check uncloud CLI is installed
|
|
100
|
+
// In CI/CD mode, fail fast without interactive prompt (build VM should have it pre-installed)
|
|
101
|
+
// In interactive mode, offer to auto-install if missing
|
|
102
|
+
await UncloudManager.ensureInstalled({ nonInteractive: isCIBuild });
|
|
103
|
+
|
|
104
|
+
let platformToken: string;
|
|
101
105
|
if (isCIBuild) {
|
|
102
106
|
platformToken = options.buildToken;
|
|
103
107
|
} else {
|
|
@@ -123,10 +127,18 @@ export function createDeployCommand() {
|
|
|
123
127
|
let cluster = await platformClient.getApp(appName);
|
|
124
128
|
let isFirstDeploy = false;
|
|
125
129
|
|
|
126
|
-
|
|
130
|
+
// App exists but has no nodes - this happens when app was created with
|
|
131
|
+
// metadataOnly (connect-before-deploy flow). Treat it like first deploy.
|
|
132
|
+
const needsInfrastructure = !cluster || cluster.nodes.length === 0;
|
|
133
|
+
|
|
134
|
+
if (needsInfrastructure) {
|
|
127
135
|
// First deployment - create infrastructure
|
|
128
136
|
isFirstDeploy = true;
|
|
129
|
-
|
|
137
|
+
if (cluster && cluster.nodes.length === 0) {
|
|
138
|
+
console.log(chalk.yellow('App exists but has no infrastructure - creating VMs...\n'));
|
|
139
|
+
} else {
|
|
140
|
+
console.log(chalk.yellow('First deployment - creating infrastructure...\n'));
|
|
141
|
+
}
|
|
130
142
|
|
|
131
143
|
// Initialize cluster (saves to platform and returns with domainName)
|
|
132
144
|
cluster = await clusterManager.initializeCluster({
|
|
@@ -163,30 +175,87 @@ export function createDeployCommand() {
|
|
|
163
175
|
console.log(chalk.cyan('\nRunning deployment...\n'));
|
|
164
176
|
|
|
165
177
|
try {
|
|
166
|
-
// Run uc deploy
|
|
167
|
-
|
|
178
|
+
// Run uc deploy with automatic retry for connection failures
|
|
179
|
+
// This handles tunnel instability when going through gateway
|
|
180
|
+
const maxDeployAttempts = 3;
|
|
181
|
+
let lastError: Error | null = null;
|
|
182
|
+
|
|
183
|
+
for (let attempt = 1; attempt <= maxDeployAttempts; attempt++) {
|
|
184
|
+
try {
|
|
185
|
+
await uncloudRunner.deploy(appName, process.cwd());
|
|
186
|
+
lastError = null;
|
|
187
|
+
break; // Success, exit retry loop
|
|
188
|
+
} catch (error) {
|
|
189
|
+
lastError = error as Error;
|
|
190
|
+
const errorMsg = lastError.message.toLowerCase();
|
|
191
|
+
|
|
192
|
+
// Check if this is a retryable error
|
|
193
|
+
// When using gateway tunnel, connection issues manifest as "exit code 1"
|
|
194
|
+
// because spawnSync with stdio:inherit doesn't capture the actual error
|
|
195
|
+
const isRetryableError =
|
|
196
|
+
errorMsg.includes('connection reset') ||
|
|
197
|
+
errorMsg.includes('connection refused') ||
|
|
198
|
+
errorMsg.includes('broken pipe') ||
|
|
199
|
+
errorMsg.includes('network is unreachable') ||
|
|
200
|
+
errorMsg.includes('context deadline exceeded') ||
|
|
201
|
+
errorMsg.includes('exit code 1'); // Generic failure, often connection-related
|
|
202
|
+
|
|
203
|
+
if (isRetryableError && attempt < maxDeployAttempts) {
|
|
204
|
+
console.log(chalk.yellow(`\nDeploy failed, retrying (attempt ${attempt + 1}/${maxDeployAttempts})...\n`));
|
|
205
|
+
// Clean up current tunnel/session before retry
|
|
206
|
+
uncloudRunner.cleanup();
|
|
207
|
+
// Brief pause before retry
|
|
208
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
209
|
+
} else {
|
|
210
|
+
// Non-retryable error or max attempts reached
|
|
211
|
+
throw lastError;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
168
215
|
|
|
169
216
|
console.log(chalk.green('\nApp deployed successfully!'));
|
|
170
217
|
|
|
171
218
|
// Register route for this app (maps domain to VM IPv6, port 80 for HTTP)
|
|
219
|
+
// Uses retry logic since platform API may be temporarily unavailable after long deploys
|
|
172
220
|
if (cluster.domainName) {
|
|
173
221
|
const spinner = ora('Registering route...').start();
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
222
|
+
let success = false;
|
|
223
|
+
|
|
224
|
+
for (let attempt = 1; attempt <= 5; attempt++) {
|
|
225
|
+
try {
|
|
226
|
+
if (attempt > 1) {
|
|
227
|
+
spinner.text = `Registering route (attempt ${attempt}/5)...`;
|
|
228
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
229
|
+
}
|
|
230
|
+
const route = await platformClient.registerRoute(appName, primaryNode.ipv6, 80);
|
|
231
|
+
spinner.succeed(chalk.green(`Route registered: ${route.fullUrl}`));
|
|
232
|
+
|
|
233
|
+
// Sync gateway Caddy config with all routes
|
|
234
|
+
spinner.start('Syncing gateway routes...');
|
|
235
|
+
await platformClient.syncGatewayRoutes(cluster.location);
|
|
236
|
+
spinner.succeed(chalk.green('Gateway routes synced'));
|
|
237
|
+
success = true;
|
|
238
|
+
break;
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (attempt === 5) {
|
|
241
|
+
spinner.warn(chalk.yellow(`Could not register route: ${(error as Error).message}`));
|
|
242
|
+
console.log(chalk.dim(` Run 'hackerrun domain --app ${appName} ${cluster.domainName}' to retry`));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
184
245
|
}
|
|
185
246
|
}
|
|
186
247
|
|
|
187
248
|
// Update last deployed timestamp on backend
|
|
188
249
|
await platformClient.updateLastDeployed(appName);
|
|
189
250
|
|
|
251
|
+
// Calculate deploy time
|
|
252
|
+
const deployDurationMs = Date.now() - deployStartTime;
|
|
253
|
+
const deployMinutes = Math.floor(deployDurationMs / 60000);
|
|
254
|
+
const deploySeconds = Math.floor((deployDurationMs % 60000) / 1000);
|
|
255
|
+
const deployTimeStr = deployMinutes > 0
|
|
256
|
+
? `${deployMinutes}m ${deploySeconds}s`
|
|
257
|
+
: `${deploySeconds}s`;
|
|
258
|
+
|
|
190
259
|
// Show summary
|
|
191
260
|
console.log(chalk.cyan('\nDeployment Summary:\n'));
|
|
192
261
|
console.log(` App Name: ${chalk.bold(appName)}`);
|
|
@@ -194,6 +263,7 @@ export function createDeployCommand() {
|
|
|
194
263
|
console.log(` URL: ${chalk.bold(`https://${cluster.domainName}.hackerrun.app`)}`);
|
|
195
264
|
console.log(` Location: ${cluster.location}`);
|
|
196
265
|
console.log(` Nodes: ${cluster.nodes.length}`);
|
|
266
|
+
console.log(` Deploy Time: ${chalk.bold(deployTimeStr)}`);
|
|
197
267
|
|
|
198
268
|
console.log(chalk.cyan('\nInfrastructure:\n'));
|
|
199
269
|
cluster.nodes.forEach((node, index) => {
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import { confirm } from '@inquirer/prompts';
|
|
5
|
+
import { getPlatformToken } from '../lib/platform-auth.js';
|
|
6
|
+
import { PlatformClient } from '../lib/platform-client.js';
|
|
7
|
+
import { ClusterManager } from '../lib/cluster.js';
|
|
8
|
+
import { getAppName } from '../lib/app-config.js';
|
|
9
|
+
import { UncloudRunner } from '../lib/uncloud-runner.js';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_VM_SIZE = 'standard-2';
|
|
12
|
+
const DEFAULT_BOOT_IMAGE = 'ubuntu-noble';
|
|
13
|
+
|
|
14
|
+
export function createScaleCommand() {
|
|
15
|
+
const cmd = new Command('scale');
|
|
16
|
+
cmd
|
|
17
|
+
.description('Scale an app by adding or removing nodes')
|
|
18
|
+
.argument('[count]', 'Target node count, or +N/-N to add/remove nodes')
|
|
19
|
+
.option('--app <app>', 'App name (uses hackerrun.yaml if not specified)')
|
|
20
|
+
.option('--size <size>', `VM size for new nodes (default: ${DEFAULT_VM_SIZE})`)
|
|
21
|
+
.option('--force', 'Skip confirmation when removing nodes')
|
|
22
|
+
.action(async (countArg: string | undefined, options) => {
|
|
23
|
+
let uncloudRunner: UncloudRunner | null = null;
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const appName = options.app || getAppName();
|
|
27
|
+
const platformToken = getPlatformToken();
|
|
28
|
+
const platformClient = new PlatformClient(platformToken);
|
|
29
|
+
const clusterManager = new ClusterManager(platformClient);
|
|
30
|
+
uncloudRunner = new UncloudRunner(platformClient);
|
|
31
|
+
|
|
32
|
+
// Get current app state
|
|
33
|
+
const app = await platformClient.getApp(appName);
|
|
34
|
+
if (!app) {
|
|
35
|
+
console.log(chalk.red(`\nApp '${appName}' not found\n`));
|
|
36
|
+
console.log(`Run ${chalk.bold('hackerrun deploy')} to create an app first.\n`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const currentNodeCount = app.nodes.length;
|
|
41
|
+
|
|
42
|
+
// If no count provided, show current scale info
|
|
43
|
+
if (!countArg) {
|
|
44
|
+
console.log(chalk.cyan(`\nApp '${appName}' Scale Info:\n`));
|
|
45
|
+
console.log(` Current nodes: ${chalk.bold(currentNodeCount.toString())}`);
|
|
46
|
+
console.log();
|
|
47
|
+
|
|
48
|
+
// List nodes with details
|
|
49
|
+
app.nodes.forEach((node, index) => {
|
|
50
|
+
const primaryLabel = node.isPrimary ? chalk.yellow(' (primary)') : '';
|
|
51
|
+
console.log(` ${index + 1}. ${chalk.bold(node.name)}${primaryLabel}`);
|
|
52
|
+
console.log(` IPv6: ${node.ipv6 || 'pending'}`);
|
|
53
|
+
console.log();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
console.log(chalk.dim('Usage:'));
|
|
57
|
+
console.log(chalk.dim(` hackerrun scale 3 # Scale to 3 nodes`));
|
|
58
|
+
console.log(chalk.dim(` hackerrun scale +1 # Add 1 node`));
|
|
59
|
+
console.log(chalk.dim(` hackerrun scale -1 # Remove 1 node`));
|
|
60
|
+
console.log();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Parse count argument
|
|
65
|
+
let targetCount: number;
|
|
66
|
+
if (countArg.startsWith('+')) {
|
|
67
|
+
// Add nodes
|
|
68
|
+
const delta = parseInt(countArg.slice(1), 10);
|
|
69
|
+
if (isNaN(delta) || delta <= 0) {
|
|
70
|
+
console.log(chalk.red(`\nInvalid count: ${countArg}\n`));
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
targetCount = currentNodeCount + delta;
|
|
74
|
+
} else if (countArg.startsWith('-')) {
|
|
75
|
+
// Remove nodes
|
|
76
|
+
const delta = parseInt(countArg.slice(1), 10);
|
|
77
|
+
if (isNaN(delta) || delta <= 0) {
|
|
78
|
+
console.log(chalk.red(`\nInvalid count: ${countArg}\n`));
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
targetCount = currentNodeCount - delta;
|
|
82
|
+
} else {
|
|
83
|
+
// Absolute count
|
|
84
|
+
targetCount = parseInt(countArg, 10);
|
|
85
|
+
if (isNaN(targetCount)) {
|
|
86
|
+
console.log(chalk.red(`\nInvalid count: ${countArg}\n`));
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Validate target count
|
|
92
|
+
if (targetCount < 1) {
|
|
93
|
+
console.log(chalk.red(`\nCannot scale below 1 node. Use 'hackerrun destroy' to remove the app.\n`));
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (targetCount === currentNodeCount) {
|
|
98
|
+
console.log(chalk.yellow(`\nApp already has ${currentNodeCount} node(s). Nothing to do.\n`));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const vmSize = options.size || DEFAULT_VM_SIZE;
|
|
103
|
+
|
|
104
|
+
if (targetCount > currentNodeCount) {
|
|
105
|
+
// Scale up - add nodes
|
|
106
|
+
const nodesToAdd = targetCount - currentNodeCount;
|
|
107
|
+
console.log(chalk.cyan(`\nScaling '${appName}' from ${currentNodeCount} to ${targetCount} nodes (+${nodesToAdd})\n`));
|
|
108
|
+
|
|
109
|
+
for (let i = 0; i < nodesToAdd; i++) {
|
|
110
|
+
console.log(chalk.cyan(`\nAdding node ${i + 1} of ${nodesToAdd}...`));
|
|
111
|
+
const newNode = await clusterManager.addNode(appName, vmSize, DEFAULT_BOOT_IMAGE);
|
|
112
|
+
console.log(chalk.green(` Added: ${newNode.name} (${newNode.ipv6})`));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Refresh app state and sync gateway for load balancing
|
|
116
|
+
const updatedApp = await platformClient.getApp(appName);
|
|
117
|
+
|
|
118
|
+
// Sync gateway to update Caddyfile with all node IPs
|
|
119
|
+
const syncSpinner = ora('Syncing gateway routes...').start();
|
|
120
|
+
try {
|
|
121
|
+
await platformClient.syncGatewayRoutes(app.location);
|
|
122
|
+
syncSpinner.succeed('Gateway routes synced');
|
|
123
|
+
} catch (error) {
|
|
124
|
+
syncSpinner.warn(`Gateway sync failed: ${(error as Error).message}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
console.log(chalk.green(`\n✓ Scaled to ${updatedApp?.nodes.length} nodes\n`));
|
|
128
|
+
|
|
129
|
+
// Show x-machines hint
|
|
130
|
+
console.log(chalk.dim('To deploy to specific nodes, use x-machines in your compose file:'));
|
|
131
|
+
console.log(chalk.dim(' services:'));
|
|
132
|
+
console.log(chalk.dim(' web:'));
|
|
133
|
+
console.log(chalk.dim(' x-machines:'));
|
|
134
|
+
updatedApp?.nodes.forEach(node => {
|
|
135
|
+
console.log(chalk.dim(` - ${node.name}`));
|
|
136
|
+
});
|
|
137
|
+
console.log();
|
|
138
|
+
|
|
139
|
+
} else {
|
|
140
|
+
// Scale down - remove nodes
|
|
141
|
+
const nodesToRemove = currentNodeCount - targetCount;
|
|
142
|
+
|
|
143
|
+
// All nodes are equal in uncloud - select nodes to remove (most recently added first)
|
|
144
|
+
// Sort by node number descending to remove highest-numbered nodes first
|
|
145
|
+
const sortedNodes = [...app.nodes].sort((a, b) => {
|
|
146
|
+
const numA = parseInt(a.name.split('-').pop() || '0', 10);
|
|
147
|
+
const numB = parseInt(b.name.split('-').pop() || '0', 10);
|
|
148
|
+
return numB - numA; // Descending order
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const nodesToRemoveList = sortedNodes.slice(0, nodesToRemove);
|
|
152
|
+
|
|
153
|
+
console.log(chalk.yellow(`\nScaling '${appName}' from ${currentNodeCount} to ${targetCount} nodes (-${nodesToRemove})\n`));
|
|
154
|
+
console.log(chalk.yellow('The following nodes will be removed:'));
|
|
155
|
+
nodesToRemoveList.forEach(node => {
|
|
156
|
+
console.log(` - ${node.name} (${node.ipv6})`);
|
|
157
|
+
});
|
|
158
|
+
console.log();
|
|
159
|
+
|
|
160
|
+
// Confirm unless --force
|
|
161
|
+
if (!options.force) {
|
|
162
|
+
console.log(chalk.yellow('Warning: All containers on these nodes will be stopped and the VMs deleted.'));
|
|
163
|
+
console.log(chalk.yellow('Data on these nodes will be lost.\n'));
|
|
164
|
+
|
|
165
|
+
const confirmed = await confirm({
|
|
166
|
+
message: 'Are you sure you want to remove these nodes?',
|
|
167
|
+
default: false,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
if (!confirmed) {
|
|
171
|
+
console.log(chalk.dim('\nScale cancelled.\n'));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Remove nodes
|
|
177
|
+
for (const node of nodesToRemoveList) {
|
|
178
|
+
console.log(chalk.cyan(`\nRemoving node '${node.name}'...`));
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
// Step 1: Remove from uncloud cluster
|
|
182
|
+
const spinner = ora('Removing from uncloud cluster...').start();
|
|
183
|
+
await uncloudRunner.removeNode(appName, node.name);
|
|
184
|
+
spinner.succeed('Removed from cluster');
|
|
185
|
+
|
|
186
|
+
// Step 2: Delete VM from Ubicloud
|
|
187
|
+
spinner.start('Deleting VM...');
|
|
188
|
+
await platformClient.deleteVM(app.location, node.name);
|
|
189
|
+
spinner.succeed('VM deleted');
|
|
190
|
+
|
|
191
|
+
// Step 3: Update app state in platform
|
|
192
|
+
spinner.start('Updating app state...');
|
|
193
|
+
const updatedNodes = app.nodes.filter(n => n.name !== node.name);
|
|
194
|
+
app.nodes = updatedNodes;
|
|
195
|
+
await platformClient.saveApp(app);
|
|
196
|
+
spinner.succeed('App state updated');
|
|
197
|
+
|
|
198
|
+
console.log(chalk.green(` Removed: ${node.name}`));
|
|
199
|
+
} catch (error) {
|
|
200
|
+
console.log(chalk.red(` Failed to remove ${node.name}: ${(error as Error).message}`));
|
|
201
|
+
// Continue with other nodes
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Refresh app state and sync gateway
|
|
206
|
+
const updatedApp = await platformClient.getApp(appName);
|
|
207
|
+
|
|
208
|
+
// Sync gateway to update Caddyfile with remaining node IPs
|
|
209
|
+
const syncSpinner = ora('Syncing gateway routes...').start();
|
|
210
|
+
try {
|
|
211
|
+
await platformClient.syncGatewayRoutes(app.location);
|
|
212
|
+
syncSpinner.succeed('Gateway routes synced');
|
|
213
|
+
} catch (error) {
|
|
214
|
+
syncSpinner.warn(`Gateway sync failed: ${(error as Error).message}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
console.log(chalk.green(`\n✓ Scaled to ${updatedApp?.nodes.length} nodes\n`));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
} catch (error) {
|
|
221
|
+
console.error(chalk.red(`\nError: ${(error as Error).message}\n`));
|
|
222
|
+
process.exit(1);
|
|
223
|
+
} finally {
|
|
224
|
+
if (uncloudRunner) {
|
|
225
|
+
uncloudRunner.cleanup();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
return cmd;
|
|
231
|
+
}
|