ref: 66e10d6f296fcd66e497a754515d13fc76662178 fanboi2/fanboi2/cmd/deploy.py -rw-r--r-- 9.0 KiB View raw
                                                                                        
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
import argparse
import datetime
import getpass
import os
import shlex
import sys
import uuid

try:
    from colorama import init, Fore, Style
    from fabric import Connection
    from invoke import run
    from invoke.exceptions import UnexpectedExit
except ImportError:
    sys.stderr.write("Please install deployment dependencies to use deploy.\n")
    sys.exit(1)

from ..version import __VERSION__


TS = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S")


REQUIRED_BINS = (
    ("python3", ("python3.6", "python-3.6")),
    ("virtualenv", ("virtualenv3.6", "virtualenv-3.6", "virtualenv")),
)


#
# Utils
#


def echo(str):
    sys.stderr.write(str)
    sys.stderr.flush()


def echo_h1(str):
    echo(Fore.YELLOW + Style.BRIGHT + "%s" % str)
    echo("\n")


def echo_h2(str):
    echo(Style.BRIGHT + "%s" % str)
    echo("\n")


def echo_body(str):
    echo("%s" % str)
    echo("\n")


def echo_error(str):
    echo(Fore.RED + "%s" % str)
    echo("\n")


def fail(e, desc=None):
    if not desc:
        desc = "perform the operation"
    echo_error("Could not %s. The error given was:" % desc)
    echo_error("%s" % "\n".join(e.result.stderr.splitlines()))
    sys.exit(1)


def normalize_cmd(cmd):
    if not isinstance(cmd, str):
        cmd = " ".join((shlex.quote(c) for c in cmd))
    return cmd


def run_as(conn, sudo_user, cmd, **kwargs):
    if "shell" not in kwargs:
        kwargs["shell"] = "/bin/sh"
    if sudo_user:
        kwargs["user"] = sudo_user
        return conn.sudo(normalize_cmd(cmd), **kwargs)
    return conn.run(normalize_cmd(cmd), **kwargs)


def run_local(cmd, **kwargs):
    if "shell" not in kwargs:
        kwargs["shell"] = "/bin/sh"
    return run(normalize_cmd(cmd), **kwargs)


#
# Step: readiness check
#


def _check_bin(conn, required_bins, sudo_user=None):
    metadata = {}
    ok = True
    for k, v in required_bins:
        echo("Checking for %s ... " % k)
        detected_bin = None
        for bin in v:
            try:
                run_as(conn, sudo_user, ["hash", bin], hide=True)
                detected_bin = bin
            except UnexpectedExit:
                continue
        if detected_bin:
            echo("found %s\n" % detected_bin)
            metadata[k] = detected_bin
        else:
            echo("not found\n")
            ok = False
    return ok, metadata


def _check_path(conn, path, sudo_user=None):
    echo("Checking if %s is writable " % path)
    if sudo_user:
        echo("by %s " % sudo_user)
    echo("... ")

    tmp_path = os.path.join(path, uuid.uuid4().hex)
    try:
        run_as(conn, sudo_user, ["mkdir", "-p", path], hide=True)
        run_as(conn, sudo_user, ["touch", tmp_path], hide=True)
        run_as(conn, sudo_user, ["rm", tmp_path], hide=True)
    except UnexpectedExit:
        echo("not writable\n")
        return False
    echo("ok\n")
    return True


def check_readiness(args, sudo_user=None):
    echo_h1("Checking readiness")
    success = True
    metadata = {}
    for host in args.host:
        with Connection(host, user=args.user) as conn:
            echo_h2(host)
            ok1, m = _check_bin(conn, REQUIRED_BINS, sudo_user)
            ok2 = _check_path(conn, args.path, sudo_user)
            success = success and ok1 and ok2
            metadata[host] = m
            echo("\n")
    if success:
        return metadata
    echo_error("The system failed readiness check. You may fix this by making sure")
    echo_error("packages are installed and the deploy path is writable by the user.")
    sys.exit(1)


#
# Step: pack
#


def pack_app(args, sudo_user=None):
    echo_h1("Preparing distribution")

    echo("Compiling assets ... ")
    try:
        run_local(["npm", "run", "gulp"], hide=True)
    except UnexpectedExit as e:
        echo("failed\n\n")
        echo_error("Failed to compile assets. The error given was:")
        echo_error("%s" % "\n".join(e.result.stderr.splitlines()))
        sys.exit(1)
    echo("done\n")

    echo("Creating distribution ... ")
    try:
        run_local(["poetry", "build", "--format=sdist"], hide=True)
    except UnexpectedExit as e:
        echo("failed\n\n")
        fail(e, "create distribution")
    echo("done\n\n")


#
# Step: setup
#


def _upload_artifact(conn, local, remote):
    echo("Uploading distribution ... ")
    conn.put(local, remote)
    echo("done\n")


def _extract_artifact(conn, dist, srcdir, sudo_user=None):
    echo("Extracting distribution ... ")
    try:
        run_as(conn, sudo_user, ["mkdir", "-p", srcdir], hide=True)
        run_as(
            conn,
            sudo_user,
            ["tar", "-xvzf", dist, "--strip-components=1", "-C", srcdir],
            hide=True,
        )
        # Unfortunately putting file as deployuser isn't very straightforward.
        # We have to put as the logged in user, and must use the same user to
        # perform the cleanup.
        conn.run(normalize_cmd(["rm", dist]), hide=True)
    except UnexpectedExit as e:
        echo("failed\n\n")
        fail(e, "extract distribution")
    echo("done\n")


def _setup_app(conn, srcdir, hostmeta, sudo_user=None):
    echo("Setting up application ... ")
    try:
        with conn.cd(srcdir):
            run_as(
                conn,
                sudo_user,
                [
                    hostmeta["virtualenv"],
                    "-p",
                    hostmeta["python3"],
                    "--always-copy",
                    "venv",
                ],
                hide=True,
            )
            run_as(conn, sudo_user, ["venv/bin/pip3", "install", "-e", "."], hide=True)
    except UnexpectedExit as e:
        echo("failed\n\n")
        fail(e, "setup application")
    echo("done\n")


def setup_app(args, metadata, sudo_user=None):
    echo_h1("Setting up the application")

    dist = "fanboi2-%s.tar.gz" % __VERSION__
    dist_local = "dist/%s" % dist
    dist_remote = "/tmp/%s-v%s.tar.gz" % (TS, __VERSION__)
    srcdir_remote = "%s/versions/%s-v%s" % (args.path, TS, __VERSION__)
    for host in args.host:
        with Connection(host, user=args.user) as conn:
            hostmeta = metadata[host]
            echo_h2(host)
            _upload_artifact(conn, dist_local, dist_remote)
            _extract_artifact(conn, dist_remote, srcdir_remote, sudo_user)
            _setup_app(conn, srcdir_remote, hostmeta, sudo_user)
            echo("\n")
    return srcdir_remote


#
# Step: committing
#


def _commit_app(conn, srcdir, current, sudo_user=None):
    echo("Committing changes ... ")
    try:
        run_as(conn, sudo_user, ["ln", "-sfF", srcdir, current], hide=True)
    except UnexpectedExit as e:
        echo("failed\n\n")
        fail(e, "commit changes")
    echo("done\n")


def _commit_postcmd(conn, postcmd, sudo_user=None):
    if postcmd is not None:
        echo("Running post-commit command ... ")
        try:
            run_as(conn, sudo_user, shlex.split(postcmd), hide=True)
        except UnexpectedExit as e:
            echo("failed\n\n")
            fail(e, "perform post-commit command")
        echo("done\n")


def _cleanup_versions(conn, versions, keep, sudo_user=None):
    echo("Cleaning up older versions ... ")

    # Always keep the latest version
    keep += 1
    all_versions = conn.sftp().listdir(versions)
    normalized_versions = [os.path.join(versions, d) for d in sorted(all_versions)]
    deletable_versions = normalized_versions[:-keep]
    try:
        # Using rm -rf since versions may be using different user than
        # the one logged in (when --deployuser is given).
        run_as(conn, sudo_user, ["rm", "-rf", *deletable_versions], hide=True)
    except UnexpectedExit as e:
        echo("failed\n\n")
        fail(e, "cleanup older versions")
    echo("done\n")


def commit_app(args, srcdir, sudo_user=None):
    echo_h1("Committing changes")
    current = "%s/current" % args.path

    versions = os.path.abspath(os.path.join(srcdir, ".."))

    for host in args.host:
        with Connection(host, user=args.user) as conn:
            echo_h2(host)
            _commit_app(conn, srcdir, current, sudo_user)
            _commit_postcmd(conn, args.postcmd, sudo_user)
            _cleanup_versions(conn, versions, args.keep, sudo_user)
        echo("\n")


def deploy(args):
    sudo_user = None
    if args.user != args.deployuser:
        sudo_user = args.deployuser

    metadata = check_readiness(args, sudo_user)
    pack_app(args, sudo_user)
    srcdir = setup_app(args, metadata, sudo_user)
    commit_app(args, srcdir, sudo_user)


def main():
    """Parse the command line arguments."""
    parser = argparse.ArgumentParser()
    parser.add_argument("host", type=str, nargs="+")
    parser.add_argument("--user", type=str, default=getpass.getuser())
    parser.add_argument("--path", type=str, required=True)
    parser.add_argument("--keep", type=int, default=3)
    parser.add_argument("--deployuser", type=str, default=None)
    parser.add_argument("--postcmd", type=str, default=None)

    args = parser.parse_args()
    if args.host is None:
        parser.print_usage()
        sys.exit(1)

    if args.deployuser is None:
        args.deployuser = args.user

    init(autoreset=True)
    deploy(args)