Skip to content

Commit 0cedc14

Browse files
BrunoQuaresmabpmct
andauthored
Add deploy cli [WIP] (#59)
* Add deploy cli * add development instructions * Fix async runner and add extra token info * Add prettier * Move prettier config * Add Prettier and pre-commit linting * Remove package.json * Move Prettier config to cli * Pull version from package.json * Get description from package.json * Update package info * Update package name * Add cli to coder org Co-authored-by: Ben Potter <[email protected]>
1 parent 8fcc596 commit 0cedc14

14 files changed

+1518
-0
lines changed

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
node_modules
2+
bin

.husky/.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
_

.husky/pre-commit

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
#!/bin/sh
2+
. "$(dirname "$0")/_/husky.sh"
3+
4+
npx lint-staged

cli/.prettierignore

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
node_modules
2+
bin
3+
yarn.lock

cli/.prettierrc

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"tabWidth": 2,
3+
"useTabs": false
4+
}

cli/README.md

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# dcs-cli
2+
3+
Provision a code-server instance from your terminal.
4+
5+
## Development
6+
7+
```console
8+
git clone [email protected]:cdr/deploy-code-server.git
9+
cd deploy-code-server/cli
10+
npm install && npm run build:watch
11+
12+
# in another session:
13+
node bin/index.js
14+
```

cli/package.json

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"name": "@coder/deploy-code-server",
3+
"version": "0.1.0",
4+
"repository": "cdr/deploy-code-server",
5+
"homepage": "https://github.com/cdr/deploy-code-server",
6+
"description": "CLI to deploy code-server",
7+
"main": "bin/index.js",
8+
"bin": "bin/index.js",
9+
"scripts": {
10+
"build": "tsc",
11+
"build:watch": "tsc -w",
12+
"prepare": "yarn build"
13+
},
14+
"keywords": ["code-server", "coder"],
15+
"author": "coder",
16+
"publishConfig": {
17+
"access": "public"
18+
},
19+
"license": "ISC",
20+
"devDependencies": {
21+
"@types/inquirer": "^7.3.3",
22+
"@types/node": "^14.14.20",
23+
"typescript": "^4.1.3"
24+
},
25+
"dependencies": {
26+
"async-wait-until": "^2.0.7",
27+
"chalk": "^4.1.2",
28+
"commander": "^8.1.0",
29+
"got": "^11.8.2",
30+
"inquirer": "^8.1.2",
31+
"ora": "^5.4.1"
32+
}
33+
}

cli/src/deploys/deployDigitalOcean.ts

+138
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import inquirer from "inquirer";
2+
import got from "got";
3+
import ora from "ora";
4+
import chalk from "chalk";
5+
import {
6+
createDroplet,
7+
Droplet,
8+
DropletV4Network,
9+
getDroplet,
10+
} from "../lib/digitalOcean";
11+
import waitUntil from "async-wait-until";
12+
13+
const getUserDataScript = async () =>
14+
got(
15+
"https://raw.githubusercontent.com/cdr/deploy-code-server/main/deploy-vm/launch-code-server.sh"
16+
).text();
17+
18+
const isPermissionError = (error: unknown) => {
19+
return error instanceof got.HTTPError && error.response.statusCode === 401;
20+
};
21+
22+
const getPublicIp = (droplet: Droplet) => {
23+
const network = droplet.networks.v4.find(
24+
(network) => network.type === "public"
25+
);
26+
return network?.ip_address;
27+
};
28+
29+
const isCodeServerLive = async (droplet: Droplet) => {
30+
try {
31+
const response = await got(`http://${getPublicIp(droplet)}`, { retry: 0 });
32+
return response.statusCode === 200;
33+
} catch {
34+
return false;
35+
}
36+
};
37+
38+
const handleErrorLog = (error: unknown) => {
39+
if (isPermissionError(error)) {
40+
console.log(
41+
chalk.red(
42+
chalk.bold("Invalid token."),
43+
"Please, verify your token and try again."
44+
)
45+
);
46+
} else {
47+
console.log(chalk.red.bold("Something wrong happened"));
48+
console.log(
49+
chalk.red(
50+
"You may have to delete the droplet manually on your Digital Ocean dashboard."
51+
)
52+
);
53+
}
54+
};
55+
56+
const oneMinute = 1000 * 60;
57+
const fiveMinutes = oneMinute * 5;
58+
59+
const waitUntilBeActive = (droplet: Droplet, token: string) => {
60+
return waitUntil(
61+
async () => {
62+
const dropletInfo = await getDroplet({ token, id: droplet.id });
63+
return dropletInfo.status === "active";
64+
},
65+
{ timeout: fiveMinutes, intervalBetweenAttempts: oneMinute / 2 }
66+
);
67+
};
68+
69+
const waitUntilHasPublicIp = (droplet: Droplet, token: string) => {
70+
return waitUntil(
71+
async () => {
72+
const dropletInfo = await getDroplet({ token, id: droplet.id });
73+
const ip = getPublicIp(dropletInfo);
74+
return ip !== undefined;
75+
},
76+
{ timeout: fiveMinutes, intervalBetweenAttempts: oneMinute / 2 }
77+
);
78+
};
79+
80+
const waitUntilCodeServerIsLive = (droplet: Droplet, token: string) => {
81+
return waitUntil(
82+
async () => {
83+
const dropletInfo = await getDroplet({ token, id: droplet.id });
84+
return isCodeServerLive(dropletInfo);
85+
},
86+
{ timeout: fiveMinutes * 2, intervalBetweenAttempts: oneMinute / 2 }
87+
);
88+
};
89+
90+
export const deployDigitalOcean = async () => {
91+
let spinner: ora.Ora;
92+
93+
console.log(
94+
chalk.blue(
95+
"You can create a token on",
96+
chalk.bold("https://cloud.digitalocean.com/account/api/tokens")
97+
)
98+
);
99+
const { token } = await inquirer.prompt([
100+
{ name: "token", message: "Your Digital Ocean token:", type: "password" },
101+
]);
102+
103+
try {
104+
let spinner = ora("Creating droplet and installing code-server").start();
105+
let droplet = await createDroplet({
106+
userData: await getUserDataScript(),
107+
token,
108+
});
109+
spinner.stop();
110+
console.log(chalk.green("✅ Droplet created"));
111+
112+
spinner = ora("Waiting droplet to be active").start();
113+
await waitUntilBeActive(droplet, token);
114+
spinner.stop();
115+
console.log(chalk.green("✅ Droplet active"));
116+
117+
spinner = ora("Waiting droplet to have a public IP").start();
118+
await waitUntilHasPublicIp(droplet, token);
119+
spinner.stop();
120+
console.log(chalk.green("✅ Public IP is available"));
121+
122+
spinner = ora(
123+
"Waiting code-server to be live. It can take up to 5 minutes."
124+
).start();
125+
await waitUntilCodeServerIsLive(droplet, token);
126+
droplet = await getDroplet({ token, id: droplet.id });
127+
spinner.stop();
128+
console.log(
129+
chalk.green(
130+
`🚀 Your code-server is live. You can access it on`,
131+
chalk.bold(`http://${getPublicIp(droplet)}`)
132+
)
133+
);
134+
} catch (error) {
135+
spinner.stop();
136+
handleErrorLog(error);
137+
}
138+
};

cli/src/index.ts

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/usr/bin/env node
2+
3+
import { program } from "commander";
4+
import { deployDigitalOcean } from "./deploys/deployDigitalOcean";
5+
import packageJson from "../package.json";
6+
7+
const main = async () => {
8+
program.version(packageJson.version).description(packageJson.description);
9+
program.parse();
10+
await deployDigitalOcean();
11+
process.exit(0);
12+
};
13+
14+
main();

cli/src/lib/digitalOcean.ts

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import got from "got";
2+
3+
const DIGITALOCEAN_API_URL = "https://api.digitalocean.com/v2";
4+
5+
export type DropletV4Network = {
6+
ip_address: string;
7+
type: "private" | "public";
8+
};
9+
export type Droplet = {
10+
id: string;
11+
name: string;
12+
networks: { v4: DropletV4Network[] };
13+
status: "new" | "active";
14+
};
15+
16+
type CreateDropletOptions = {
17+
userData: string;
18+
token: string;
19+
};
20+
21+
export const createDroplet = async ({
22+
token,
23+
userData,
24+
}: CreateDropletOptions) => {
25+
return got
26+
.post(`${DIGITALOCEAN_API_URL}/droplets`, {
27+
json: {
28+
name: "code-server",
29+
region: "nyc3",
30+
size: "s-1vcpu-1gb",
31+
image: "ubuntu-20-10-x64",
32+
user_data: userData,
33+
},
34+
headers: {
35+
Authorization: `Bearer ${token}`,
36+
},
37+
})
38+
.json<{ droplet: Droplet }>()
39+
.then((data) => data.droplet);
40+
};
41+
42+
type GetDropletOptions = {
43+
id: string;
44+
token: string;
45+
};
46+
47+
export const getDroplet = async ({ token, id }: GetDropletOptions) => {
48+
return got(`${DIGITALOCEAN_API_URL}/droplets/${id}`, {
49+
headers: {
50+
Authorization: `Bearer ${token}`,
51+
},
52+
})
53+
.json<{ droplet: Droplet }>()
54+
.then((data) => data.droplet);
55+
};

cli/tsconfig.json

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"compilerOptions": {
3+
"module": "commonjs",
4+
"target": "es2017",
5+
"lib": ["es2015"],
6+
"moduleResolution": "node",
7+
"sourceMap": true,
8+
"outDir": "bin",
9+
"baseUrl": ".",
10+
"paths": {
11+
"*": ["node_modules/*", "src/types/*"]
12+
},
13+
"esModuleInterop": true,
14+
"resolveJsonModule": true
15+
},
16+
"include": ["src/**/*"]
17+
}

0 commit comments

Comments
 (0)