cli.py 13 KB
Newer Older
1
2
3
4
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# vim: ai ts=4 sts=4 et sw=4 nu

thiolliere's avatar
GUI    
thiolliere committed
5
6
import os
import sys
rgaudin's avatar
rgaudin committed
7
import json
thiolliere's avatar
GUI    
thiolliere committed
8
import yaml
9
import time
10
import argparse
11
import tempfile
12

thiolliere's avatar
thiolliere committed
13
import data
14
15
16
17
18
19
20
from backend.content import (
    get_collection,
    get_required_building_space,
    get_required_image_size,
    get_content,
    isremote,
)
thiolliere's avatar
thiolliere committed
21
from util import CancelEvent
22
from util import check_user_inputs
rgaudin's avatar
rgaudin committed
23
from version import get_version_str
24
25
from util import CLILogger, b64decode
from util import get_free_space_in_dir
rgaudin's avatar
rgaudin committed
26
from util import get_adjusted_image_size
27
from backend.catalog import get_catalogs
28
29
from run_installation import run_installation
from util import human_readable_size, get_cache
30
from backend.util import sd_has_single_partition, is_admin
31

32
import tzlocal
33
34
import humanfriendly

35
CANCEL_TIMEOUT = 5
rgaudin's avatar
rgaudin committed
36
logger = CLILogger()
rgaudin's avatar
rgaudin committed
37
logger.std("Kiwix Hotspot {v}".format(v=get_version_str()))
thiolliere's avatar
thiolliere committed
38

rgaudin's avatar
rgaudin committed
39
40

def set_config(config, args):
41
42
43
44
45
46
47
    def get_choices(option):
        return [x for x in parser._actions if x.dest == option][-1].choices

    def setif(key, value):
        if getattr(args, key, None) is None:
            setattr(args, key, value)

rgaudin's avatar
rgaudin committed
48
    if not isinstance(config, dict):
49
        return
rgaudin's avatar
rgaudin committed
50
51

    # direct arguments
52
53
54
55
56
57
    for key, arg_key in {
        "project_name": "name",
        "timezone": "timezone",
        "language": "language",
        "size": "size",
    }.items():
rgaudin's avatar
rgaudin committed
58
        if key in config.keys() and config.get(key) is not None:
59
            setif(arg_key, str(config.get(key)))
rgaudin's avatar
rgaudin committed
60
61

    # branding files
rgaudin's avatar
rgaudin committed
62
    if "branding" in config.keys() and isinstance(config["branding"], dict):
63
        for key in ("logo", "favicon", "css"):
rgaudin's avatar
rgaudin committed
64
            if config["branding"].get(key) is not None:
65
                try:
66
67
68
                    fpath = b64decode(
                        fname=config["branding"][key]["fname"],
                        data=config["branding"][key]["data"],
69
                        to=tempfile.mkdtemp(),
70
                    )
71
72
73
                except Exception:
                    pass
                else:
74
                    setif(key, os.path.abspath(fpath))
rgaudin's avatar
rgaudin committed
75

76
    # wifi (previous format)
rgaudin's avatar
rgaudin committed
77
78
79
80
    if "wifi" in config.keys() and isinstance(config["wifi"], dict):
        if "password" in config["wifi"].keys() and config["wifi"].get(
            "protected", True
        ):
81
            setif("wifi_pwd", config["wifi"]["password"])
82
    # wifi (new format)
rgaudin's avatar
rgaudin committed
83
    if "wifi_password" in config.keys():
84
        setif("wifi_pwd", config["wifi_password"])
rgaudin's avatar
rgaudin committed
85
86

    # admin account
rgaudin's avatar
rgaudin committed
87
    if "admin_account" in config.keys() and isinstance(config["admin_account"], dict):
rgaudin's avatar
rgaudin committed
88

89
        # we need both login and password
90
91
92
93
94
95
96
97
        if (
            config["admin_account"].get("login") is not None
            and config["admin_account"].get("password") is not None
        ):
            setif(
                "admin_account",
                [config["admin_account"]["login"], config["admin_account"]["password"]],
            )
rgaudin's avatar
rgaudin committed
98
99
100

    # build_dir
    if config.get("build_dir") is not None:
101
        setif("build_dir", os.path.abspath(config["build_dir"]))
rgaudin's avatar
rgaudin committed
102
103

    # content
rgaudin's avatar
rgaudin committed
104
    if "content" in config.keys() and isinstance(config["content"], dict):
rgaudin's avatar
rgaudin committed
105
106

        # list contents (langs)
107
108
109
110
111
        for key, arg_key in {
            "kalite": "kalite",
            "wikifundi": "wikifundi",
            "zims": "zim_install",
        }.items():
rgaudin's avatar
rgaudin committed
112
113
114
            if key in config["content"].keys() and isinstance(
                config["content"][key], list
            ):
115
116
117
118
                value = config["content"][key]
                # check that all elements are valid choices
                wrong = [x for x in value if x not in get_choices(arg_key)]
                if len(wrong):
119
120
121
122
123
                    raise ValueError(
                        "Incorrect values for `{key}`: {val}".format(
                            key=arg_key, val=" ".join(wrong)
                        )
                    )
124
125
                else:
                    setif(arg_key, value)
rgaudin's avatar
rgaudin committed
126
127

        # bool contents (switch)
128
        for key in ("edupi", "aflatoun", "nomad", "mathews"):
rgaudin's avatar
rgaudin committed
129
            if config["content"].get(key) is not None:
130
131
                vl = "yes" if config["content"][key] in ("yes", True) else "no"
                setif(key, vl)
rgaudin's avatar
rgaudin committed
132

rgaudin's avatar
rgaudin committed
133
134
135
        # edupi resources
        if config["content"].get("edupi_resources") is not None:
            rsc = config["content"]["edupi_resources"]
136
            setif("edupi_resources", rsc if isremote(rsc) else os.path.abspath(rsc))
rgaudin's avatar
rgaudin committed
137

138

139
if get_catalogs(logger) is None:
140
    print("Catalog downloads failed, you may check your internet connection")
rgaudin's avatar
rgaudin committed
141
    sys.exit(2)
142

thiolliere's avatar
thiolliere committed
143
zim_choices = []
144
for catalog in get_catalogs(logger):
thiolliere's avatar
thiolliere committed
145
146
147
    for (key, value) in catalog["all"].items():
        zim_choices.append(key)

rgaudin's avatar
rgaudin committed
148
languages = [code for code, language in data.hotspot_languages]
thiolliere's avatar
thiolliere committed
149

150
defaults = {
rgaudin's avatar
rgaudin committed
151
    "name": "Kiwix",
152
153
154
155
156
157
    "timezone": str(tzlocal.get_localzone()),
    "language": "en",
    "size": "8GB",
    "build_dir": ".",
    "catalog": False,
    "edupi": "no",
158
    "nomad": "no",
159
    "mathews": "no",
160
161
162
163
    "aflatoun": "no",
    "kalite": [],
    "wikifundi": [],
    "zim_install": [],
164
    "shrink": "yes",
165
166
}

rgaudin's avatar
rgaudin committed
167
parser = argparse.ArgumentParser(description="kiwix-hotspot installer for raspberrypi.")
168
169
170
171
172
parser.add_argument("--name", help="name of the box ({})".format(defaults["name"]))
parser.add_argument("--timezone", help="timezone ({})".format(defaults["timezone"]))
parser.add_argument(
    "--language", help="language ({})".format(defaults["language"]), choices=languages
)
173
parser.add_argument("--wifi-pwd", help="wifi password (None, Network is Open)")
174
175
176
177
178
179
180
parser.add_argument(
    "--kalite", help="install kalite", choices=["fr", "en", "es"], nargs="+"
)
parser.add_argument("--aflatoun", help="install aflatoun", choices=["yes", "no"])
parser.add_argument(
    "--wikifundi", help="install wikifundi", choices=["fr", "en"], nargs="+"
)
181
parser.add_argument("--edupi", help="install edupi", choices=["yes", "no"])
182
parser.add_argument("--nomad", help="install Nomad Education", choices=["yes", "no"])
183
parser.add_argument("--mathews", help="install Math Mathews", choices=["yes", "no"])
184
185
186
187
188
parser.add_argument(
    "--edupi-resources", help="Zipped folder of resources to init EduPi with"
)
parser.add_argument("--zim-install", help="install zim", choices=zim_choices, nargs="+")
parser.add_argument("--size", help="resize image ({})".format(defaults["size"]))
thiolliere's avatar
thiolliere committed
189
190
parser.add_argument("--favicon", help="set favicon")
parser.add_argument("--logo", help="set logo")
thiolliere's avatar
rebrand    
thiolliere committed
191
parser.add_argument("--css", help="set css style")
192
193
194
195
196
197
198
199
200
201
202
parser.add_argument(
    "--build-dir", help="set build directory ({})".format(defaults["build_dir"])
)
parser.add_argument("--catalog", help="show catalog and exit", action="store_true")
parser.add_argument(
    "--admin-account", help="create admin account [LOGIN, PWD]", nargs=2
)
parser.add_argument(
    "--config",
    help="use a JSON config file to set parameters (superseeds cli parameters)",
)
203
parser.add_argument("--filename", help="Output file name (without suffix)")
204
parser.add_argument("--shrink", help="Shrink image file", choices=["yes", "no"])
rgaudin's avatar
rgaudin committed
205
parser.add_argument("--ram", help="Max RAM for QEMU", default="2G")
206
parser.add_argument("--sdcard", help="Device to copy image to")
207
208
209
210
211
parser.add_argument(
    "--root",
    action="store_true",
    help="Don't use udisks2 (linux-only, must be ran as root)",
)
thiolliere's avatar
GUI    
thiolliere committed
212
213
214

args = parser.parse_args()

215
216
217
218
219
220
221
222
# handle root option (disable udisks use)
if args.root:
    if not is_admin():
        print("You must be root/elevated to use --root option")
        sys.exit(1)
    else:
        os.environ["NO_UDISKS"] = "yes"

223
# apply options from config file if requested
rgaudin's avatar
rgaudin committed
224
225
if args.config:
    try:
226
        with open(args.config, "r") as fd:
rgaudin's avatar
rgaudin committed
227
228
229
            config = json.load(fd)
    except Exception:
        print("Failed to parse JSON file {}".format(args.config))
230
        sys.exit(1)
rgaudin's avatar
rgaudin committed
231
    else:
232
233
234
        try:
            set_config(config, args)
        except Exception as exp:
235
            print("Error while parsing your config file ({})".format(args.config))
236
237
238
239
240
241
242
            print(exp)
            sys.exit(1)

# apply defaults for all non-set options
for key, value in defaults.items():
    if getattr(args, key, None) is None:
        setattr(args, key, value)
rgaudin's avatar
rgaudin committed
243

thiolliere's avatar
GUI    
thiolliere committed
244
if args.catalog:
245
    for catalog in get_catalogs(logger):
246
        print(yaml.dump(catalog, default_flow_style=False, default_style=""))
rgaudin's avatar
rgaudin committed
247
    sys.exit(0)
thiolliere's avatar
GUI    
thiolliere committed
248

249
if args.admin_account:
250
    admin_account = {"login": args.admin_account[0], "pwd": args.admin_account[1]}
251
else:
252
    admin_account = {"login": "admin", "pwd": "admin-password"}
253

254
255
256
# parse requested size
try:
    args.size = humanfriendly.parse_size(args.size)
257
    args.output_size = get_adjusted_image_size(args.size)  # adjust image size for HW
258
259
260
261
except Exception:
    print("Unable to understand required size ({})".format(args.size))
    sys.exit(1)
else:
262
    args.human_size = human_readable_size(args.output_size, False)
thiolliere's avatar
thiolliere committed
263

264
265

# check arguments
266
267
268
269
270
271
272
273
(
    valid_project_name,
    valid_language,
    valid_timezone,
    valid_wifi_pwd,
    valid_admin_login,
    valid_admin_pwd,
) = check_user_inputs(
274
275
276
277
278
279
280
    project_name=args.name,
    language=args.language,
    timezone=args.timezone,
    wifi_pwd=args.wifi_pwd,
    admin_login=admin_account["login"],
    admin_pwd=admin_account["pwd"],
)
281
282

for key, is_valid in {
283
284
285
286
287
288
289
    "name": valid_project_name,
    "language": valid_language,
    "timezone": valid_timezone,
    "wifi_pwd": valid_wifi_pwd,
    "admin_login": valid_admin_login,
    "admin_password": valid_admin_pwd,
}.items():
290
291
292
293
    if not is_valid:
        print("Invalid argument for `{key}`".format(key=key))
        sys.exit(1)

294
295
296
297
if args.sdcard and not os.path.exists(args.sdcard):
    print("SD card device does not exist.")
    sys.exit(1)

rgaudin's avatar
rgaudin committed
298
299
300
301
if args.sdcard and not sd_has_single_partition(args.sdcard, logger):
    print("SD card is not clean (must have a single FAT-like partition). Please wipe.")
    sys.exit(1)

302
# display configuration and offer time to cancel
rgaudin's avatar
rgaudin committed
303
print("Configuration:")
304
305
306
keys = args.__dict__.keys()
longest = max([len(key) for key in keys])
for name in keys:
307
308
309
310
311
    print(
        "  {name}:{space} {value}".format(
            name=name, value=getattr(args, name), space=" " * (longest - len(name))
        )
    )
312
313

# check disk space
314
315
316
collection = get_collection(
    edupi=args.edupi == "yes",
    edupi_resources=args.edupi_resources,
317
    nomad=args.nomad == "yes",
318
    mathews=args.mathews == "yes",
319
320
321
322
323
    packages=args.zim_install,
    kalite_languages=args.kalite,
    wikifundi_languages=args.wikifundi,
    aflatoun_languages=["fr", "en"] if args.aflatoun == "yes" else [],
)
324
325
326
cache_folder = get_cache(args.build_dir)
# how much space is available on the build directory?
avail_space_in_build_dir = get_free_space_in_dir(args.build_dir)
327
328
329
try:
    # how much space do we need to build the image?
    space_required_to_build = get_required_building_space(
330
        collection, cache_folder, args.output_size
331
    )
332
333
334
335
336
    # how large should the image be?
    required_image_size = get_required_image_size(collection)
except FileNotFoundError as exp:
    print("Supplied File Not Found: {}".format(exp.filename), file=sys.stderr)
    sys.exit(1)
rgaudin's avatar
rgaudin committed
337
base_image_size = get_content("hotspot_master_image")["expanded_size"]
338
339

if args.size < base_image_size:
340
341
342
343
344
345
    print(
        "image size can not be under {size}".format(
            size=human_readable_size(base_image_size, False)
        ),
        file=sys.stderr,
    )
346
347
    sys.exit(3)

rgaudin's avatar
rgaudin committed
348
if args.output_size < required_image_size:
349
    print(
350
        "image size ({img}/{img2}) is not large enough for the content ({req})".format(
351
            img=human_readable_size(args.size, False),
352
            img2=human_readable_size(args.output_size, False),
353
354
355
356
            req=human_readable_size(required_image_size, False),
        ),
        file=sys.stderr,
    )
357
358
    sys.exit(3)

359
if avail_space_in_build_dir < space_required_to_build:
360
    print(
361
        "Not enough space available at {dir} ({free}) to build image ({req})".format(
362
363
            dir=args.build_dir,
            free=human_readable_size(avail_space_in_build_dir),
364
            req=human_readable_size(space_required_to_build),
365
366
367
        ),
        file=sys.stderr,
    )
368
369
    sys.exit(1)

370
371
372
print(
    "\nInstaller will start in ({}) seconds.".format(CANCEL_TIMEOUT), end="", flush=True
)
373
374
for timeout in range(CANCEL_TIMEOUT, 0, -1):
    time.sleep(1)
375
    print(" {} ".format(timeout), end="", flush=True)
376
377
print("\nStarting...")

thiolliere's avatar
thiolliere committed
378
379
380
cancel_event = CancelEvent()
try:
    error = run_installation(
381
382
383
384
385
386
387
388
389
        name=args.name,
        timezone=args.timezone,
        language=args.language,
        wifi_pwd=args.wifi_pwd,
        kalite=args.kalite,
        wikifundi=args.wikifundi,
        aflatoun=args.aflatoun == "yes",
        edupi=args.edupi == "yes",
        edupi_resources=args.edupi_resources,
390
        nomad=args.nomad == "yes",
391
        mathews=args.mathews == "yes",
392
        zim_install=args.zim_install,
393
        size=args.output_size,
394
395
396
397
398
399
400
401
        logger=logger,
        cancel_event=cancel_event,
        sd_card=args.sdcard,
        logo=args.logo,
        favicon=args.favicon,
        css=args.css,
        admin_account=admin_account,
        build_dir=args.build_dir,
402
        filename=args.filename,
403
        shrink=args.shrink == "yes",
404
405
406
        qemu_ram=args.ram,
    )
except Exception:
thiolliere's avatar
thiolliere committed
407
    cancel_event.cancel()