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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hackerrun",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "CLI tool to create and manage VMs using Ubicloud API",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 = 'standard-2';
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 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) => {
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 current app state
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
- const currentNodeCount = app.nodes.length;
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
- // 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();
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
- // 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
- });
83
+ // Determine service name
84
+ let serviceName: string;
85
+ let replicas: number;
55
86
 
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
- }
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
- // 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`));
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
- 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`));
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
- // Absolute count
84
- targetCount = parseInt(countArg, 10);
85
- if (isNaN(targetCount)) {
86
- console.log(chalk.red(`\nInvalid count: ${countArg}\n`));
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 target count
92
- if (targetCount < 1) {
93
- console.log(chalk.red(`\nCannot scale below 1 node. Use 'hackerrun destroy' to remove the app.\n`));
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
- 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?',
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 (!confirmed) {
171
- console.log(chalk.dim('\nScale cancelled.\n'));
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
- // Remove nodes
177
- for (const node of nodesToRemoveList) {
178
- console.log(chalk.cyan(`\nRemoving node '${node.name}'...`));
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
- // 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}`));
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 remove ${node.name}: ${(error as Error).message}`));
201
- // Continue with other nodes
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
- // Refresh app state and sync gateway
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
- console.log(chalk.green(`\n✓ Scaled to ${updatedApp?.nodes.length} nodes\n`));
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);
@@ -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!, existingNode.ipv6, appName);
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 --connect ssh:// to avoid local context dependency
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, primaryVmIp: string, contextName: string): Promise<void> {
334
+ private async joinUncloudCluster(newVmIp: string, appName: string): Promise<void> {
327
335
  try {
328
- // Get SSH certificates for both nodes and add to agent
329
- await this.sshCertManager.getSession(contextName, primaryVmIp);
330
- await this.sshCertManager.getSession(contextName, newVmIp);
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
  });