hackerrun 0.1.9 → 0.1.11
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/settings.local.json +2 -1
- package/dist/index.js +1007 -1017
- package/package.json +1 -1
- package/src/commands/scale.ts +158 -153
- package/src/lib/cluster.ts +15 -20
package/package.json
CHANGED
package/src/commands/scale.ts
CHANGED
|
@@ -1,35 +1,59 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import ora from 'ora';
|
|
4
|
-
import { confirm } from '@inquirer/prompts';
|
|
4
|
+
import { select, confirm } from '@inquirer/prompts';
|
|
5
5
|
import { getPlatformToken } from '../lib/platform-auth.js';
|
|
6
6
|
import { PlatformClient } from '../lib/platform-client.js';
|
|
7
7
|
import { ClusterManager } from '../lib/cluster.js';
|
|
8
8
|
import { getAppName } from '../lib/app-config.js';
|
|
9
9
|
import { UncloudRunner } from '../lib/uncloud-runner.js';
|
|
10
10
|
|
|
11
|
-
const DEFAULT_VM_SIZE = '
|
|
11
|
+
const DEFAULT_VM_SIZE = 'burstable-1';
|
|
12
12
|
const DEFAULT_BOOT_IMAGE = 'ubuntu-noble';
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Parse service list output from uncloud
|
|
16
|
+
*/
|
|
17
|
+
function parseServiceList(output: string): string[] {
|
|
18
|
+
const lines = output.trim().split('\n');
|
|
19
|
+
const services: string[] = [];
|
|
20
|
+
|
|
21
|
+
for (const line of lines) {
|
|
22
|
+
const trimmed = line.trim();
|
|
23
|
+
// Skip header and connection messages
|
|
24
|
+
if (!trimmed || trimmed.startsWith('NAME') || trimmed.startsWith('Connecting') || trimmed.startsWith('Connected')) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
// Extract first column (service name)
|
|
28
|
+
const svcName = trimmed.split(/\s+/)[0];
|
|
29
|
+
if (svcName) {
|
|
30
|
+
services.push(svcName);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return services;
|
|
35
|
+
}
|
|
36
|
+
|
|
14
37
|
export function createScaleCommand() {
|
|
15
38
|
const cmd = new Command('scale');
|
|
16
39
|
cmd
|
|
17
|
-
.description('Scale
|
|
18
|
-
.argument('
|
|
19
|
-
.
|
|
20
|
-
.
|
|
21
|
-
.option('--
|
|
22
|
-
.
|
|
40
|
+
.description('Scale a service in your app')
|
|
41
|
+
.argument('<app>', 'App name')
|
|
42
|
+
.argument('[service]', 'Service name (optional - will prompt if not provided)')
|
|
43
|
+
.argument('[replicas]', 'Number of replicas')
|
|
44
|
+
.option('--add-machines', 'Automatically add machines if needed')
|
|
45
|
+
.option('--no-add-machine', 'Scale on existing machines only')
|
|
46
|
+
.option('--size <size>', `VM size for new machines (default: ${DEFAULT_VM_SIZE})`)
|
|
47
|
+
.action(async (appName: string, serviceArg: string | undefined, replicasArg: string | undefined, options) => {
|
|
23
48
|
let uncloudRunner: UncloudRunner | null = null;
|
|
24
49
|
|
|
25
50
|
try {
|
|
26
|
-
const appName = options.app || getAppName();
|
|
27
51
|
const platformToken = getPlatformToken();
|
|
28
52
|
const platformClient = new PlatformClient(platformToken);
|
|
29
|
-
const clusterManager = new ClusterManager(platformClient);
|
|
30
53
|
uncloudRunner = new UncloudRunner(platformClient);
|
|
54
|
+
const clusterManager = new ClusterManager(platformClient, uncloudRunner);
|
|
31
55
|
|
|
32
|
-
// Get
|
|
56
|
+
// Get app info
|
|
33
57
|
const app = await platformClient.getApp(appName);
|
|
34
58
|
if (!app) {
|
|
35
59
|
console.log(chalk.red(`\nApp '${appName}' not found\n`));
|
|
@@ -37,175 +61,139 @@ export function createScaleCommand() {
|
|
|
37
61
|
process.exit(1);
|
|
38
62
|
}
|
|
39
63
|
|
|
40
|
-
|
|
64
|
+
// Get list of services
|
|
65
|
+
let services: string[];
|
|
66
|
+
const spinner = ora('Fetching services...').start();
|
|
67
|
+
try {
|
|
68
|
+
const output = await uncloudRunner.serviceList(appName);
|
|
69
|
+
services = parseServiceList(output);
|
|
70
|
+
spinner.stop();
|
|
71
|
+
} catch (error) {
|
|
72
|
+
spinner.fail('Failed to fetch services');
|
|
73
|
+
console.log(chalk.yellow('\nMake sure the app is deployed and running.\n'));
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
41
76
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
console.log(chalk.
|
|
45
|
-
|
|
46
|
-
|
|
77
|
+
if (services.length === 0) {
|
|
78
|
+
console.log(chalk.yellow(`\nNo services found in app '${appName}'`));
|
|
79
|
+
console.log(`Run ${chalk.bold('hackerrun deploy')} to deploy your app first.\n`);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
47
82
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
console.log(` ${index + 1}. ${chalk.bold(node.name)}${primaryLabel}`);
|
|
52
|
-
console.log(` IPv6: ${node.ipv6 || 'pending'}`);
|
|
53
|
-
console.log();
|
|
54
|
-
});
|
|
83
|
+
// Determine service name
|
|
84
|
+
let serviceName: string;
|
|
85
|
+
let replicas: number;
|
|
55
86
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
console.log(chalk.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
87
|
+
if (!serviceArg) {
|
|
88
|
+
// No service provided - list and prompt
|
|
89
|
+
console.log(chalk.cyan(`\nServices in '${appName}':\n`));
|
|
90
|
+
|
|
91
|
+
serviceName = await select({
|
|
92
|
+
message: 'Which service would you like to scale?',
|
|
93
|
+
choices: services.map(s => ({ name: s, value: s })),
|
|
94
|
+
});
|
|
63
95
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const delta = parseInt(countArg.slice(1), 10);
|
|
69
|
-
if (isNaN(delta) || delta <= 0) {
|
|
70
|
-
console.log(chalk.red(`\nInvalid count: ${countArg}\n`));
|
|
96
|
+
// replicas must be provided as second arg in this case
|
|
97
|
+
if (!replicasArg) {
|
|
98
|
+
console.log(chalk.red('\nPlease specify the number of replicas.\n'));
|
|
99
|
+
console.log(chalk.dim(`Usage: hackerrun scale ${appName} ${serviceName} <replicas>\n`));
|
|
71
100
|
process.exit(1);
|
|
72
101
|
}
|
|
73
|
-
|
|
74
|
-
} else if (
|
|
75
|
-
//
|
|
76
|
-
const
|
|
77
|
-
if (isNaN(
|
|
78
|
-
|
|
102
|
+
replicas = parseInt(replicasArg, 10);
|
|
103
|
+
} else if (!replicasArg) {
|
|
104
|
+
// Service provided but no replicas - check if serviceArg is actually a number
|
|
105
|
+
const maybeReplicas = parseInt(serviceArg, 10);
|
|
106
|
+
if (!isNaN(maybeReplicas)) {
|
|
107
|
+
// serviceArg is actually the replica count, prompt for service
|
|
108
|
+
replicas = maybeReplicas;
|
|
109
|
+
|
|
110
|
+
console.log(chalk.cyan(`\nServices in '${appName}':\n`));
|
|
111
|
+
|
|
112
|
+
serviceName = await select({
|
|
113
|
+
message: 'Which service would you like to scale?',
|
|
114
|
+
choices: services.map(s => ({ name: s, value: s })),
|
|
115
|
+
});
|
|
116
|
+
} else {
|
|
117
|
+
// serviceArg is the service name, but no replicas provided
|
|
118
|
+
console.log(chalk.red('\nPlease specify the number of replicas.\n'));
|
|
119
|
+
console.log(chalk.dim(`Usage: hackerrun scale ${appName} ${serviceArg} <replicas>\n`));
|
|
79
120
|
process.exit(1);
|
|
80
121
|
}
|
|
81
|
-
targetCount = currentNodeCount - delta;
|
|
82
122
|
} else {
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
123
|
+
// Both service and replicas provided
|
|
124
|
+
serviceName = serviceArg;
|
|
125
|
+
replicas = parseInt(replicasArg, 10);
|
|
126
|
+
|
|
127
|
+
// Validate service exists
|
|
128
|
+
if (!services.includes(serviceName)) {
|
|
129
|
+
console.log(chalk.red(`\nService '${serviceName}' not found in app '${appName}'.\n`));
|
|
130
|
+
console.log(chalk.cyan('Available services:'));
|
|
131
|
+
services.forEach(s => console.log(` - ${s}`));
|
|
132
|
+
console.log();
|
|
87
133
|
process.exit(1);
|
|
88
134
|
}
|
|
89
135
|
}
|
|
90
136
|
|
|
91
|
-
// Validate
|
|
92
|
-
if (
|
|
93
|
-
console.log(chalk.red(
|
|
137
|
+
// Validate replicas
|
|
138
|
+
if (isNaN(replicas) || replicas < 1) {
|
|
139
|
+
console.log(chalk.red('\nReplicas must be a positive number.\n'));
|
|
94
140
|
process.exit(1);
|
|
95
141
|
}
|
|
96
142
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
console.log(chalk.
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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?',
|
|
143
|
+
const currentMachineCount = app.nodes.length;
|
|
144
|
+
|
|
145
|
+
console.log(chalk.cyan(`\nScaling '${serviceName}' to ${replicas} replica(s)...`));
|
|
146
|
+
console.log(chalk.dim(`Current machines: ${currentMachineCount}\n`));
|
|
147
|
+
|
|
148
|
+
// Check if we need more machines
|
|
149
|
+
let shouldAddMachines = false;
|
|
150
|
+
|
|
151
|
+
if (replicas > currentMachineCount) {
|
|
152
|
+
if (options.addMachines) {
|
|
153
|
+
// --add-machines flag: automatically add
|
|
154
|
+
shouldAddMachines = true;
|
|
155
|
+
} else if (options.addMachine === false) {
|
|
156
|
+
// --no-add-machine flag: don't add
|
|
157
|
+
shouldAddMachines = false;
|
|
158
|
+
console.log(chalk.yellow(`Scaling ${replicas} replicas across ${currentMachineCount} machine(s).\n`));
|
|
159
|
+
} else {
|
|
160
|
+
// Interactive: ask the user
|
|
161
|
+
console.log(chalk.yellow(`You want ${replicas} replicas but only have ${currentMachineCount} machine(s).`));
|
|
162
|
+
console.log(chalk.dim(`You can either:`));
|
|
163
|
+
console.log(chalk.dim(` • Add ${replicas - currentMachineCount} new machine(s) for dedicated capacity`));
|
|
164
|
+
console.log(chalk.dim(` • Run ${replicas} replicas on your existing ${currentMachineCount} machine(s)\n`));
|
|
165
|
+
|
|
166
|
+
shouldAddMachines = await confirm({
|
|
167
|
+
message: `Add ${replicas - currentMachineCount} new machine(s)?`,
|
|
167
168
|
default: false,
|
|
168
169
|
});
|
|
169
170
|
|
|
170
|
-
if (!
|
|
171
|
-
console.log(chalk.dim(
|
|
172
|
-
return;
|
|
171
|
+
if (!shouldAddMachines) {
|
|
172
|
+
console.log(chalk.dim(`\nScaling ${replicas} replicas across ${currentMachineCount} existing machine(s).\n`));
|
|
173
173
|
}
|
|
174
174
|
}
|
|
175
|
+
}
|
|
175
176
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
177
|
+
// Add machines if needed
|
|
178
|
+
if (shouldAddMachines && replicas > currentMachineCount) {
|
|
179
|
+
const machinesToAdd = replicas - currentMachineCount;
|
|
180
|
+
const vmSize = options.size || DEFAULT_VM_SIZE;
|
|
179
181
|
|
|
182
|
+
console.log(chalk.cyan(`\nAdding ${machinesToAdd} machine(s)...\n`));
|
|
183
|
+
|
|
184
|
+
for (let i = 0; i < machinesToAdd; i++) {
|
|
185
|
+
console.log(chalk.cyan(`Adding machine ${i + 1} of ${machinesToAdd}...`));
|
|
180
186
|
try {
|
|
181
|
-
|
|
182
|
-
|
|
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}`));
|
|
187
|
+
const newNode = await clusterManager.addNode(appName, vmSize, DEFAULT_BOOT_IMAGE);
|
|
188
|
+
console.log(chalk.green(` Added: ${newNode.name} (${newNode.ipv6})`));
|
|
199
189
|
} catch (error) {
|
|
200
|
-
console.log(chalk.red(` Failed to
|
|
201
|
-
|
|
190
|
+
console.log(chalk.red(` Failed to add machine: ${(error as Error).message}`));
|
|
191
|
+
console.log(chalk.yellow(' Continuing with available machines...\n'));
|
|
192
|
+
break;
|
|
202
193
|
}
|
|
203
194
|
}
|
|
204
195
|
|
|
205
|
-
//
|
|
206
|
-
const updatedApp = await platformClient.getApp(appName);
|
|
207
|
-
|
|
208
|
-
// Sync gateway to update Caddyfile with remaining node IPs
|
|
196
|
+
// Sync gateway routes after adding machines
|
|
209
197
|
const syncSpinner = ora('Syncing gateway routes...').start();
|
|
210
198
|
try {
|
|
211
199
|
await platformClient.syncGatewayRoutes(app.location);
|
|
@@ -213,10 +201,27 @@ export function createScaleCommand() {
|
|
|
213
201
|
} catch (error) {
|
|
214
202
|
syncSpinner.warn(`Gateway sync failed: ${(error as Error).message}`);
|
|
215
203
|
}
|
|
204
|
+
}
|
|
216
205
|
|
|
217
|
-
|
|
206
|
+
// Scale the service using uc scale
|
|
207
|
+
const scaleSpinner = ora(`Scaling ${serviceName} to ${replicas} replicas...`).start();
|
|
208
|
+
try {
|
|
209
|
+
await uncloudRunner.run(appName, 'scale', [serviceName, String(replicas)], { stdio: 'pipe' });
|
|
210
|
+
scaleSpinner.succeed(chalk.green(`Scaled '${serviceName}' to ${replicas} replica(s)`));
|
|
211
|
+
} catch (error) {
|
|
212
|
+
scaleSpinner.fail(`Failed to scale service: ${(error as Error).message}`);
|
|
213
|
+
process.exit(1);
|
|
218
214
|
}
|
|
219
215
|
|
|
216
|
+
// Show summary
|
|
217
|
+
const updatedApp = await platformClient.getApp(appName);
|
|
218
|
+
console.log(chalk.cyan('\nScale Summary:\n'));
|
|
219
|
+
console.log(` App: ${chalk.bold(appName)}`);
|
|
220
|
+
console.log(` Service: ${chalk.bold(serviceName)}`);
|
|
221
|
+
console.log(` Replicas: ${chalk.bold(String(replicas))}`);
|
|
222
|
+
console.log(` Machines: ${chalk.bold(String(updatedApp?.nodes.length || currentMachineCount))}`);
|
|
223
|
+
console.log();
|
|
224
|
+
|
|
220
225
|
} catch (error) {
|
|
221
226
|
console.error(chalk.red(`\nError: ${(error as Error).message}\n`));
|
|
222
227
|
process.exit(1);
|
package/src/lib/cluster.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AppCluster, VMNode, PlatformClient } from './platform-client.js';
|
|
2
2
|
import { SSHCertManager, testIPv6Connectivity } from './ssh-cert.js';
|
|
3
3
|
import { createTunnel, killTunnel, TunnelInfo } from './gateway-tunnel.js';
|
|
4
|
+
import { UncloudRunner } from './uncloud-runner.js';
|
|
4
5
|
import { execSync } from 'child_process';
|
|
5
6
|
import ora from 'ora';
|
|
6
7
|
import chalk from 'chalk';
|
|
@@ -18,11 +19,14 @@ export interface ClusterInitOptions {
|
|
|
18
19
|
|
|
19
20
|
export class ClusterManager {
|
|
20
21
|
private sshCertManager: SSHCertManager;
|
|
22
|
+
private uncloudRunner: UncloudRunner;
|
|
21
23
|
|
|
22
24
|
constructor(
|
|
23
|
-
private platformClient: PlatformClient
|
|
25
|
+
private platformClient: PlatformClient,
|
|
26
|
+
uncloudRunner?: UncloudRunner
|
|
24
27
|
) {
|
|
25
28
|
this.sshCertManager = new SSHCertManager(platformClient);
|
|
29
|
+
this.uncloudRunner = uncloudRunner || new UncloudRunner(platformClient);
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
/**
|
|
@@ -207,7 +211,7 @@ export class ClusterManager {
|
|
|
207
211
|
spinner.text = 'Joining uncloud cluster...';
|
|
208
212
|
spinner.stop();
|
|
209
213
|
console.log(chalk.cyan('\nJoining uncloud cluster...'));
|
|
210
|
-
await this.joinUncloudCluster(vmWithIp.ip6!,
|
|
214
|
+
await this.joinUncloudCluster(vmWithIp.ip6!, appName);
|
|
211
215
|
|
|
212
216
|
spinner = ora('Configuring Docker for NAT64...').start();
|
|
213
217
|
|
|
@@ -321,26 +325,17 @@ export class ClusterManager {
|
|
|
321
325
|
|
|
322
326
|
/**
|
|
323
327
|
* Join a new node to an existing uncloud cluster
|
|
324
|
-
* Uses
|
|
328
|
+
* Uses `uc machine add` which handles:
|
|
329
|
+
* - SSH to the new machine
|
|
330
|
+
* - Installing uncloudd if needed
|
|
331
|
+
* - Getting token from new machine via gRPC
|
|
332
|
+
* - Registering machine in cluster
|
|
325
333
|
*/
|
|
326
|
-
private async joinUncloudCluster(newVmIp: string,
|
|
334
|
+
private async joinUncloudCluster(newVmIp: string, appName: string): Promise<void> {
|
|
327
335
|
try {
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
await this.
|
|
331
|
-
|
|
332
|
-
// Get join token from primary node using --connect ssh://
|
|
333
|
-
const token = execSync(`uc --connect ssh://root@${primaryVmIp} machine token`, {
|
|
334
|
-
encoding: 'utf-8',
|
|
335
|
-
timeout: 30000,
|
|
336
|
-
}).trim();
|
|
337
|
-
|
|
338
|
-
if (!token) {
|
|
339
|
-
throw new Error('Failed to get join token from primary node');
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// Join the new node to the cluster using --connect ssh://
|
|
343
|
-
execSync(`uc --connect ssh://root@${primaryVmIp} machine add root@${newVmIp} --token "${token}"`, {
|
|
336
|
+
// Use uc machine add which SSHes to the new VM and joins it to the cluster
|
|
337
|
+
// The --no-caddy flag skips Caddy deployment (we use the gateway for routing)
|
|
338
|
+
await this.uncloudRunner.run(appName, 'machine', ['add', `root@${newVmIp}`, '--no-caddy'], {
|
|
344
339
|
stdio: 'inherit',
|
|
345
340
|
timeout: 600000, // 10 min timeout
|
|
346
341
|
});
|