Skip to content

Commit c8dc2f6

Browse files
Node management, registry addon
1 parent b49e3a1 commit c8dc2f6

File tree

8 files changed

+404
-71
lines changed

8 files changed

+404
-71
lines changed

src/commands/init.ts

Lines changed: 31 additions & 11 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,21 @@ 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+
commands.push(
335+
`DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \
336+
docker-ce=$(apt-cache madison docker-ce | grep --fixed-strings ${dockerVersion} | head -1 | awk '{print $3}') \
337+
docker-ce-cli=$(apt-cache madison docker-ce-cli | grep --fixed-strings ${dockerVersion} | head -1 | awk '{print $3}') \
338+
containerd.io docker-buildx-plugin docker-compose-plugin`,
339+
)
340+
}
341+
322342
for await (const command of commands) {
323343
await runSshCommand({ssh, command, verbose, progressBar})
324344
}
@@ -362,7 +382,7 @@ async function initDisco({
362382
return apiKey
363383
}
364384

365-
function extractApiKey(output: string): string {
385+
export function extractApiKey(output: string): string {
366386
const match = output.match(/Created API key: ([a-z0-9]{32})/)
367387
if (!match) {
368388
throw new Error('could not extract API key')
@@ -371,7 +391,7 @@ function extractApiKey(output: string): string {
371391
return match[1]
372392
}
373393

374-
async function runSshCommand({
394+
export async function runSshCommand({
375395
ssh,
376396
command,
377397
stdin,
@@ -422,7 +442,7 @@ async function runSshCommand({
422442
return stdout
423443
}
424444

425-
async function userCanSudoWitoutPassword({ssh, verbose}: {ssh: NodeSSH; verbose: boolean}): Promise<boolean> {
445+
export async function userCanSudoWitoutPassword({ssh, verbose}: {ssh: NodeSSH; verbose: boolean}): Promise<boolean> {
426446
try {
427447
await runSshCommand({ssh, command: 'sudo -n true', verbose, progressBar: undefined})
428448
if (verbose) {
@@ -439,7 +459,7 @@ async function userCanSudoWitoutPassword({ssh, verbose}: {ssh: NodeSSH; verbose:
439459
}
440460
}
441461

442-
async function setupRootSshAccess({
462+
export async function setupRootSshAccess({
443463
ssh,
444464
verbose,
445465
password,

src/commands/nodes/add.ts

Lines changed: 103 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,100 +1,144 @@
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+
this.log("You can install the addon by using the command disco registry:addon:install. For example:")
52+
this.log(`disco registry:addon:install --domain registry.example.com --disco ${discoConfig.name}`)
53+
return;
54+
}
4655

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

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-
})
58+
let username = argUsername
5959

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

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

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

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

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

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')
105+
106+
if (verbose) {
107+
this.log('Joining Swarm')
96108
}
97109

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

src/commands/nodes/list.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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 = 'show node list'
7+
8+
static examples = [
9+
'<%= config.bin %> <%= command.id %>',
10+
]
11+
12+
static flags = {
13+
disco: Flags.string({required: false}),
14+
}
15+
16+
public async run(): Promise<void> {
17+
const {flags} = await this.parse(NodesList)
18+
const {disco} = flags
19+
20+
const discoConfig = getDisco(disco || null)
21+
22+
const url = `https://${discoConfig.host}/api/disco/swarm/nodes`
23+
const res = await request({
24+
method: 'GET',
25+
url,
26+
discoConfig,
27+
})
28+
const {nodes} = (await res.json()) as {
29+
nodes: {
30+
created: string,
31+
name: string,
32+
state: string,
33+
address: string,
34+
isLeader: boolean,
35+
}[]
36+
}
37+
38+
for (const node of nodes) {
39+
this.log(`${node.name}${node.isLeader ? ' (main)' : ''}`);
40+
}
41+
}
42+
}

0 commit comments

Comments
 (0)