Skip to content

Commit fff7299

Browse files
Add commands to add/remove nodes
1 parent b49e3a1 commit fff7299

File tree

7 files changed

+407
-69
lines changed

7 files changed

+407
-69
lines changed

src/commands/init.ts

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -191,24 +191,30 @@ async function uploadLocalImage({image, ssh, verbose}: {image: string; ssh: Node
191191
}
192192
}
193193

194-
async function installDockerIfNeeded({
194+
export async function installDockerIfNeeded({
195195
dockerAlreadyInstalled,
196+
dockerVersion,
196197
verbose,
197198
ssh,
198199
progressBar,
199200
}: {
200201
dockerAlreadyInstalled: boolean
202+
dockerVersion?: string,
201203
verbose: boolean
202204
ssh: NodeSSH
203205
progressBar: SingleBar | undefined
204206
}): Promise<void> {
205207
if (!dockerAlreadyInstalled) {
206208
if (verbose) {
207-
process.stdout.write('Installing Docker\n')
209+
if (dockerVersion === undefined) {
210+
process.stdout.write('Installing Docker\n')
211+
} else {
212+
process.stdout.write(`Installing Docker ${dockerVersion}\n`)
213+
}
208214
}
209215

210216
try {
211-
await installDocker({ssh, verbose, progressBar})
217+
await installDocker({ssh, dockerVersion, verbose, progressBar})
212218
} catch (error) {
213219
if ((error as Error).toString().includes('Could not get lock')) {
214220
throw new Error(`Package manager already busy. Try again in a few minutes.`)
@@ -247,7 +253,7 @@ async function getSshPrivateKeyPaths(): Promise<string[]> {
247253
return privKeyPaths
248254
}
249255

250-
async function connectSsh({
256+
export async function connectSsh({
251257
host,
252258
username,
253259
password,
@@ -291,17 +297,19 @@ async function connectSsh({
291297
throw new Error('Failed to connect with SSH')
292298
}
293299

294-
async function checkDockerInstalled(ssh: NodeSSH): Promise<boolean> {
300+
export async function checkDockerInstalled(ssh: NodeSSH): Promise<boolean> {
295301
const {code} = await ssh.execCommand('command -v docker >/dev/null 2>&1')
296302
return code === 0
297303
}
298304

299305
async function installDocker({
300306
ssh,
307+
dockerVersion,
301308
verbose,
302309
progressBar,
303310
}: {
304311
ssh: NodeSSH
312+
dockerVersion?: string
305313
verbose: boolean
306314
progressBar: SingleBar | undefined
307315
}): Promise<void> {
@@ -316,9 +324,26 @@ async function installDocker({
316324
'$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | ' +
317325
'sudo tee /etc/apt/sources.list.d/docker.list > /dev/null',
318326
'sudo apt-get update',
319-
'DEBIAN_FRONTEND=noninteractive sudo apt-get install -y docker-ce docker-ce-cli ' +
320-
'containerd.io docker-buildx-plugin docker-compose-plugin',
321327
]
328+
if (dockerVersion === undefined) {
329+
commands.push(
330+
'DEBIAN_FRONTEND=noninteractive sudo apt-get install -y docker-ce docker-ce-cli ' +
331+
'containerd.io docker-buildx-plugin docker-compose-plugin',
332+
)
333+
} else {
334+
process.stdout.write( `DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \
335+
docker-ce=$(apt-cache madison docker-ce | grep --fixed-strings ${dockerVersion} | head -1 | awk '{print $3}') \
336+
docker-ce-cli=$(apt-cache madison docker-ce-cli | grep --fixed-strings ${dockerVersion} | head -1 | awk '{print $3}') \
337+
containerd.io docker-buildx-plugin docker-compose-plugin\n`,
338+
)
339+
commands.push(
340+
`DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \
341+
docker-ce=$(apt-cache madison docker-ce | grep --fixed-strings ${dockerVersion} | head -1 | awk '{print $3}') \
342+
docker-ce-cli=$(apt-cache madison docker-ce-cli | grep --fixed-strings ${dockerVersion} | head -1 | awk '{print $3}') \
343+
containerd.io docker-buildx-plugin docker-compose-plugin`,
344+
)
345+
}
346+
322347
for await (const command of commands) {
323348
await runSshCommand({ssh, command, verbose, progressBar})
324349
}
@@ -371,7 +396,7 @@ function extractApiKey(output: string): string {
371396
return match[1]
372397
}
373398

374-
async function runSshCommand({
399+
export async function runSshCommand({
375400
ssh,
376401
command,
377402
stdin,
@@ -422,7 +447,7 @@ async function runSshCommand({
422447
return stdout
423448
}
424449

425-
async function userCanSudoWitoutPassword({ssh, verbose}: {ssh: NodeSSH; verbose: boolean}): Promise<boolean> {
450+
export async function userCanSudoWitoutPassword({ssh, verbose}: {ssh: NodeSSH; verbose: boolean}): Promise<boolean> {
426451
try {
427452
await runSshCommand({ssh, command: 'sudo -n true', verbose, progressBar: undefined})
428453
if (verbose) {
@@ -439,7 +464,7 @@ async function userCanSudoWitoutPassword({ssh, verbose}: {ssh: NodeSSH; verbose:
439464
}
440465
}
441466

442-
async function setupRootSshAccess({
467+
export async function setupRootSshAccess({
443468
ssh,
444469
verbose,
445470
password,

src/commands/nodes/add.ts

Lines changed: 101 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,100 +1,142 @@
11
import {Args, Command, Flags} from '@oclif/core'
2-
2+
import {NodeSSH} from 'node-ssh'
3+
import inquirerPassword from '@inquirer/password'
4+
import {SingleBar} from 'cli-progress'
35
import {getDisco} from '../../config.js'
46
import {request} from '../../auth-request.js'
5-
6-
import * as fs from 'node:fs'
7-
import * as os from 'node:os'
8-
import * as path from 'node:path'
9-
import {NodeSSH} from 'node-ssh'
10-
11-
const GET_NODE_SCRIPT_URL = (version: string) => `https://downloads.letsdisco.dev/${version}/node`
7+
import { checkDockerInstalled, connectSsh, installDockerIfNeeded, runSshCommand, setupRootSshAccess, userCanSudoWitoutPassword } from '../init.js'
128

139
export default class NodesAdd extends Command {
14-
static override args = {
15-
sshString: Args.string({description: 'ssh user@IP to connect to new machine', required: true}),
10+
static args = {
11+
sshString: Args.string({required: true}),
1612
}
1713

18-
static override description = 'add a new server to your deployment'
14+
static description = 'initializes a new server'
1915

20-
static override examples = ['<%= config.bin %> <%= command.id %> [email protected]']
16+
static examples = [
17+
'<%= config.bin %> <%= command.id %> [email protected]',
18+
'<%= config.bin %> <%= command.id %> [email protected] --version 0.4.0',
19+
]
2120

22-
static override flags = {
21+
static flags = {
22+
verbose: Flags.boolean({default: false, description: 'show extra output'}),
23+
'identity-file': Flags.string({
24+
char: 'i',
25+
description: 'SSH key to use for authentication',
26+
}),
2327
disco: Flags.string({required: false}),
24-
version: Flags.string({required: false, default: 'latest'}),
2528
}
2629

2730
public async run(): Promise<void> {
2831
const {args, flags} = await this.parse(NodesAdd)
29-
const discoConfig = getDisco(flags.disco || null)
30-
// eslint-disable-next-line new-cap
31-
const nodeScriptUrl = GET_NODE_SCRIPT_URL(flags.version)
32+
const {verbose, 'identity-file': identityFile, disco} = flags
33+
34+
const discoConfig = getDisco(disco || null)
3235

3336
const url = `https://${discoConfig.host}/api/disco/swarm/join-token`
3437
const res = await request({
3538
method: 'GET',
3639
url,
3740
discoConfig,
3841
})
39-
const data = (await res.json()) as any
40-
41-
const token = data.joinToken
42-
const {ip} = data
43-
const command = `curl ${nodeScriptUrl} | sudo IP=${ip} TOKEN=${token} sh`
42+
const {joinToken, ip: leaderIp, dockerVersion, registryHost} = (await res.json()) as {
43+
joinToken: string
44+
ip: string
45+
dockerVersion: string
46+
registryHost: null | string
47+
}
4448

45-
// TODO centralize this code which is identical to code in init.ts
49+
if (registryHost === null) {
50+
this.log("Image registry not configured")
51+
return;
52+
}
4653

47-
const [username, host] = args.sshString.split('@')
54+
const [argUsername, host] = args.sshString.split('@')
4855

49-
const sshKeyPaths = [
50-
path.join(os.homedir(), '.ssh', 'id_ed25519'),
51-
path.join(os.homedir(), '.ssh', 'id_rsa'),
52-
].filter((p) => {
53-
try {
54-
return fs.statSync(p).isFile()
55-
} catch {
56-
return false
57-
}
58-
})
56+
let username = argUsername
5957

60-
if (sshKeyPaths.length === 0) {
61-
this.error('could not find an SSH key in ~/.ssh')
58+
let ssh
59+
let password
60+
try {
61+
;({ssh, password} = await connectSsh({host, username, identityFile}))
62+
} catch {
63+
this.error('could not connect to SSH')
6264
}
6365

64-
const ssh = new NodeSSH()
66+
if (username !== 'root') {
67+
const canSudoWithoutPassword = await userCanSudoWitoutPassword({ssh, verbose})
68+
// use password if provided, or ask for one if needed
69+
const passwordToUse =
70+
password === undefined
71+
? canSudoWithoutPassword
72+
? undefined
73+
: await inquirerPassword({message: `${username}@${host}'s password:`})
74+
: password
75+
if (verbose) {
76+
if (passwordToUse === undefined) {
77+
process.stdout.write('Will not use password\n')
78+
} else {
79+
process.stdout.write('Will use password\n')
80+
}
81+
}
6582

66-
let connected = false
67-
for await (const sshKeyPath of sshKeyPaths) {
83+
await setupRootSshAccess({ssh, password: passwordToUse, verbose})
84+
username = 'root'
6885
try {
69-
await ssh.connect({
70-
host,
71-
privateKeyPath: sshKeyPath,
72-
username,
73-
})
74-
connected = true
75-
break
86+
;({ssh, password} = await connectSsh({host, username, identityFile}))
7687
} catch {
77-
// skip error
88+
this.error('could not connect to SSH as root')
7889
}
7990
}
8091

81-
if (!connected) {
82-
this.error('could not connect to server')
92+
const dockerAlreadyInstalled = await checkDockerInstalled(ssh)
93+
let progressBar
94+
if (!verbose) {
95+
const dockerInstallOutputCount = 309
96+
const count = dockerAlreadyInstalled ? 0 : dockerInstallOutputCount;
97+
progressBar = new SingleBar({format: '[{bar}] {percentage}%', clearOnComplete: true})
98+
progressBar.start(count, 0)
8399
}
84100

85-
this.log('connected')
101+
await installDockerIfNeeded({dockerAlreadyInstalled, verbose, ssh, dockerVersion, progressBar})
86102

87-
// do something with stderr output?
88-
const {code} = await ssh.execCommand(command, {
89-
onStdout(chunk) {
90-
const str = chunk.toString('utf8')
91-
process.stdout.write(str)
92-
},
93-
})
94-
if (code !== 0) {
95-
this.error('failed to run ssh script')
103+
104+
if (verbose) {
105+
this.log('Joining Swarm')
96106
}
97107

108+
await joinSwarm({
109+
ssh,
110+
joinToken,
111+
leaderIp,
112+
verbose,
113+
progressBar,
114+
})
115+
98116
ssh.dispose()
117+
if (progressBar !== undefined) {
118+
progressBar.stop()
119+
}
120+
121+
this.log('Done')
99122
}
100123
}
124+
125+
126+
async function joinSwarm({
127+
ssh,
128+
joinToken,
129+
leaderIp,
130+
verbose,
131+
progressBar,
132+
}: {
133+
ssh: NodeSSH
134+
joinToken: string,
135+
leaderIp: string,
136+
verbose: boolean
137+
progressBar: SingleBar | undefined
138+
}): Promise<void> {
139+
const command = `docker swarm join --token ${joinToken} ${leaderIp}:2377`;
140+
await runSshCommand({ssh, command, verbose, progressBar})
141+
}
142+

src/commands/nodes/list.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import {Command, Flags} from '@oclif/core'
2+
import {getDisco} from '../../config.js'
3+
import {request} from '../../auth-request.js'
4+
5+
export default class NodesList extends Command {
6+
static description = 'initializes a new server'
7+
8+
static examples = [
9+
'<%= config.bin %> <%= command.id %> [email protected]',
10+
'<%= config.bin %> <%= command.id %> [email protected] --version 0.4.0',
11+
]
12+
13+
static flags = {
14+
disco: Flags.string({required: false}),
15+
}
16+
17+
public async run(): Promise<void> {
18+
const {flags} = await this.parse(NodesList)
19+
const {disco} = flags
20+
21+
const discoConfig = getDisco(disco || null)
22+
23+
const url = `https://${discoConfig.host}/api/disco/swarm/nodes`
24+
const res = await request({
25+
method: 'GET',
26+
url,
27+
discoConfig,
28+
})
29+
const {nodes} = (await res.json()) as {
30+
nodes: {
31+
created: string,
32+
name: string,
33+
state: string,
34+
address: string,
35+
isLeader: boolean,
36+
}[]
37+
}
38+
39+
for (const node of nodes) {
40+
this.log(`${node.name}${node.isLeader ? ' (main)' : ''}, state: ${node.state}`);
41+
}
42+
}
43+
}

0 commit comments

Comments
 (0)