summaryrefslogtreecommitdiff
path: root/cloudinit/config/cc_vyos_install.py
blob: 2c3629f83a8d0357645cd2790ddf140d26a0fe80 (plain)
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
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
# VyOS unattended installation module
# Copyright (C) 2023 VyOS Inc.

# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.

# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
# MA 02110-1301, USA.
"""VyOS Installation: Install VyOS unattendedly"""

from logging import Logger
from json import loads as json_loads
from pathlib import Path
from shutil import copy, rmtree
from shlex import split as shlex_split
from subprocess import run
from textwrap import dedent
from os import sync

from psutil import disk_partitions

from cloudinit import log as logging
from cloudinit.cloud import Cloud
from cloudinit.util import get_cfg_by_path

MODULE_DESCRIPTION = """\
This module installs VyOS unattendedly.
"""

LOG = logging.getLogger(__name__)

# VyOS definitions
VERSION_FILE = '/usr/share/vyos/version.json'
# a reserved space: 2MB for header, 1 MB for BIOS partition, 256 MB for EFI
CONST_RESERVED_SPACE: int = (2 + 1 + 256) * 1024**2

# define directories and paths
DIR_INSTALLATION: str = '/mnt/installation'
DIR_DST_ROOT: str = f'{DIR_INSTALLATION}/disk_dst'
DIR_KERNEL_SRC: str = '/boot/'
FILE_ROOTFS_SRC: str = '/usr/lib/live/mount/medium/live/filesystem.squashfs'


def get_version() -> str:
    """Get running VyOS version id
    Returns:
        str: version id
    """
    version_file: str = Path(VERSION_FILE).read_text()
    version_data = json_loads(version_file)

    return version_data.get('version', 'version_unknown')


def disk_cleanup(drive_path: str) -> None:
    """Clean up disk partition table (MBR and GPT)
    Zeroize primary and secondary headers - first and last 17408 bytes
    (512 bytes * 34 LBA) on a drive
    Args:
        drive_path (str): path to a drive that needs to be cleaned
    """
    run(shlex_split(f'sgdisk -Z {drive_path}'))


def parttable_create(drive_path: str, root_size: int) -> None:
    """Create a hybrid MBR/GPT partition table
    0-2047 first sectors are free
    2048-4095 sectors - BIOS Boot Partition
    4096 + 256 MB - EFI system partition
    Everything else till the end of a drive - Linux partition
    Args:
        drive_path (str): path to a drive
    """
    if not root_size:
        root_size_text: str = '0'
    else:
        root_size_text: str = f'+{str(root_size)}K'
    command: str = f'sgdisk -a1 -n1:2048:4095 -t1:EF02 -n2:4096:+256M \
        -t2:EF00 -n3:0:{root_size_text} -t3:8300 {drive_path}'

    run(shlex_split(command))
    # update partitons in kernel
    run(shlex_split(f'partx -u {drive_path}'))
    sync()


def filesystem_create(partition: str, fstype: str) -> None:
    """Create a filesystem on a partition
    Args:
        partition (str): path to a partition (for example: '/dev/sda1')
        fstype (str): filesystem type ('efi' or 'ext4')
    """
    if fstype == 'efi':
        command = 'mkfs -t fat -n EFI'
        run(shlex_split(f'{command} {partition}'))
    if fstype == 'ext4':
        command = 'mkfs -t ext4 -L persistence'
        run(shlex_split(f'{command} {partition}'))


def partition_mount(partition: str,
                    path: str,
                    fsype: str = '',
                    overlay_params: 'dict[str, str]' = {}) -> None:
    """Mount a partition into a path
    Args:
        partition (str): path to a partition (for example: '/dev/sda1')
        path (str): a path where to mount
        fsype (str): optionally, set fstype ('squashfs', 'overlay', 'iso9660')
        overlay_params (dict): optionally, set overlay parameters.
        Defaults to None.
    """
    if fsype in ['squashfs', 'iso9660']:
        command: str = f'mount -o loop,ro -t {fsype} {partition} {path}'
    if fsype == 'overlay' and overlay_params:
        command: str = f'mount -t overlay -o noatime,\
            upperdir={overlay_params["upperdir"]},\
            lowerdir={overlay_params["lowerdir"]},\
            workdir={overlay_params["workdir"]} overlay {path}'

    else:
        command = f'mount {partition} {path}'

    run(shlex_split(command))


def partition_umount(partition: str = '', path: str = '') -> None:
    """Umount a partition by a partition name or a path
    Args:
        partition (str): path to a partition (for example: '/dev/sda1')
        path (str): a path where a partition is mounted
    """
    if partition:
        command: str = f'umount {partition}'
        run(shlex_split(command))
    if path:
        command = f'umount {path}'
        run(shlex_split(command))


def disks_size() -> 'dict[str, int]':
    """Get a dictionary with physical disks and their sizes
    Returns:
        dict[str, int]: a dictionary with name: size mapping
    """
    disks_size: dict[str, int] = {}
    lsblk: str = run(shlex_split('lsblk -Jbp'),
                     capture_output=True).stdout.decode()
    blk_list = json_loads(lsblk)
    for device in blk_list.get('blockdevices'):
        if device['type'] == 'disk':
            disks_size.update({device['name']: device['size']})
    return disks_size


def find_disk() -> 'tuple[str, int]':
    """Find a target disk for installation
    Returns:
        tuple[str, int]: disk name and size in bytes
    """
    # check for available disks
    disks_available: dict[str, int] = disks_size()
    if not disks_available:
        return '', 0

    for disk_name, disk_size in disks_available.copy().items():
        # minimum 2 GB
        if disk_size > 2147483648:
            return disk_name, disk_size

    return '', 0


def prepare_tmp_disr() -> None:
    """Create temporary directories for installation
    """
    dirpath = Path(DIR_DST_ROOT)
    dirpath.mkdir(mode=0o755, parents=True)


def cleanup(mounts: 'list[str]' = [], remove_items: 'list[str]' = []) -> None:
    """Clean up after installation
    Args:
        mounts (list[str], optional): List of mounts to unmount.
        Defaults to [].
        remove_items (list[str], optional): List of files or directories
        to remove. Defaults to [].
    """
    # clean up installation directory by default
    mounts_all = disk_partitions(all=True)
    for mounted_device in mounts_all:
        if mounted_device.mountpoint.startswith(DIR_INSTALLATION) and not (
                mounted_device.device in mounts or
                mounted_device.mountpoint in mounts):
            mounts.append(mounted_device.mountpoint)
    # add installation dir to cleanup list
    if DIR_INSTALLATION not in remove_items:
        remove_items.append(DIR_INSTALLATION)

    if mounts:
        for mountpoint in mounts:
            partition_umount(mountpoint)
    if remove_items:
        for remove_item in remove_items:
            if Path(remove_item).exists():
                if Path(remove_item).is_file():
                    Path(remove_item).unlink()
                if Path(remove_item).is_dir():
                    rmtree(remove_item)


def grub_install(drive_path: str, boot_dir: str, efi_dir: str) -> None:
    """Install GRUB for both BIOS and EFI modes (hybrid boot)
    Args:
        drive_path (str): path to a drive where GRUB must be installed
        boot_dir (str): a path to '/boot' directory
        efi_dir (str): a path to '/boot/efi' directory
    """
    commands: list[str] = [
        f'grub-install --no-floppy --target=i386-pc \
            --boot-directory={boot_dir} {drive_path} --force',
        f'grub-install --no-floppy --recheck --target=x86_64-efi \
            --force-extra-removable --boot-directory={boot_dir} \
            --efi-directory={efi_dir} --bootloader-id="VyOS" \
            --no-uefi-secure-boot'
    ]
    for command in commands:
        run(shlex_split(command))


def grub_configure(grub_dir: str, vyos_version: str,
                   boot_params: 'dict[str, str]') -> None:
    """Configure GRUB

    Args:
        grub_dir (str): path to GRUB folder
        vyos_version (str): VyOS version id
        boot_params (dict[str, str]): boot parameters
    """
    if boot_params['console_type'] == 'kvm':
        default_boot = 0
    elif boot_params['console_type'] == 'serial':
        default_boot = 1
    if boot_params['cmdline_extra']:
        cmdline_extra = f' {boot_params["cmdline_extra"]}'
    grub_cfg_content: str = dedent(f'''
    # load EFI video modules
    if [ "${{grub_platform}}" == "efi" ]; then
    insmod efi_gop
    insmod efi_uga
    fi

    set default={default_boot}
    set timeout=5
    serial --unit={boot_params['serial_console_num']} --speed={boot_params['serial_console_speed']}
    terminal_output --append serial console
    terminal_input --append serial console

    menuentry "VyOS { vyos_version } (KVM console)" {{
        linux /boot/{ vyos_version }/vmlinuz boot=live rootdelay=5 noautologin net.ifnames=0 biosdevname=0 vyos-union=/boot/{ vyos_version } console=tty0{cmdline_extra}
        initrd /boot/{ vyos_version }/initrd.img
    }}

    menuentry "VyOS { vyos_version } (Serial console)" {{
        linux /boot/{ vyos_version }/vmlinuz boot=live rootdelay=5 noautologin net.ifnames=0 biosdevname=0 vyos-union=/boot/{ vyos_version } console=ttyS{boot_params['serial_console_num']},{boot_params['serial_console_speed']}{cmdline_extra}
        initrd /boot/{ vyos_version }/initrd.img
    }}

    menuentry "VyOS { vyos_version } - password reset (KVM console)" {{
        linux /boot/{ vyos_version }/vmlinuz boot=live rootdelay=5 noautologin net.ifnames=0 biosdevname=0 vyos-union=/boot/{ vyos_version } console=tty0 init=/opt/vyatta/sbin/standalone_root_pw_reset{cmdline_extra}
        initrd /boot/{ vyos_version }/initrd.img
    }}

    menuentry "VyOS { vyos_version } - password reset (Serial console)" {{
        linux /boot/{ vyos_version }/vmlinuz boot=live rootdelay=5 noautologin net.ifnames=0 biosdevname=0 vyos-union=/boot/{ vyos_version } console=ttyS{boot_params['serial_console_num']},{boot_params['serial_console_speed']} init=/opt/vyatta/sbin/standalone_root_pw_reset{cmdline_extra}
        initrd /boot/{ vyos_version }/initrd.img
    }}
    ''')

    grub_cfg_file = Path(f'{grub_dir}/grub.cfg')
    grub_cfg_file.write_text(grub_cfg_content)


def handle(name: str, cfg: dict, cloud: Cloud, _: Logger, args: list) -> None:
    # check if installation is activated in config
    install_activated: bool = get_cfg_by_path(cfg, 'vyos_install/activated',
                                              False)
    if not install_activated:
        LOG.info('Installation is not activated in configuration')
        return

    # Find a version name to use later
    image_name: str = get_version()
    LOG.debug(f'version to be installed: {image_name}')

    # define target drive
    install_target, target_size = find_disk()
    # add prefix to partitions
    part_prefix: str = ''
    for dev_type in ['nvme', 'mmcblk']:
        if dev_type in install_target:
            part_prefix = 'p'
    LOG.info(
        f'system will be installed to {install_target} ({target_size} bytes)')

    # define target rootfs size in KB (smallest unit acceptable by sgdisk)
    rootfs_size: int = (target_size - CONST_RESERVED_SPACE) // 1024
    LOG.info(f'rootfs size: {rootfs_size} bytes')

    # create partitions
    disk_cleanup(install_target)
    LOG.info('disk cleaned')
    parttable_create(install_target, rootfs_size)
    LOG.info('partitin table created')
    filesystem_create(f'{install_target}{part_prefix}2', 'efi')
    LOG.info('efi filesystem created')
    filesystem_create(f'{install_target}{part_prefix}3', 'ext4')
    LOG.info('ext4 filesystem created')

    # create directiroes for installation media
    prepare_tmp_disr()
    LOG.info('prepared temporary folders for installation')

    # mount target filesystem and create required dirs inside
    partition_mount(f'{install_target}{part_prefix}3', DIR_DST_ROOT)
    LOG.info(
        f'partiton {install_target}{part_prefix}3 mouted to {DIR_DST_ROOT}')
    Path(f'{DIR_DST_ROOT}/boot/efi').mkdir(parents=True)
    partition_mount(f'{install_target}{part_prefix}2',
                    f'{DIR_DST_ROOT}/boot/efi')
    LOG.info(
        f'partiton {install_target}{part_prefix}2 mouted to {DIR_DST_ROOT}/boot/efi'
    )

    # copy config
    # a config dir. It is the deepest one, so the comand will
    # create all the rest in a single step
    target_config_dir: str = f'{DIR_DST_ROOT}/boot/{image_name}/rw/opt/vyatta/etc/'
    Path(target_config_dir).mkdir(parents=True)
    # we must use Linux cp command, because Python cannot preserve ownership
    run(['cp', '-pr', '/opt/vyatta/etc/config', target_config_dir])
    LOG.info('configuration copied from running system')

    # create a persistence.conf
    Path(f'{DIR_DST_ROOT}/persistence.conf').write_text('/ union\n')
    LOG.info('root filesystem marked as persistent')

    # copy system image and kernel files
    for file in Path(DIR_KERNEL_SRC).iterdir():
        if file.is_file():
            copy(file, f'{DIR_DST_ROOT}/boot/{image_name}/')
            LOG.info(f'{file} installed into {DIR_DST_ROOT}/boot/{image_name}/')
    copy(FILE_ROOTFS_SRC,
         f'{DIR_DST_ROOT}/boot/{image_name}/{image_name}.squashfs')
    LOG.info(
        f'{FILE_ROOTFS_SRC} installed into {DIR_DST_ROOT}/boot/{image_name}/{image_name}.squashfs'
    )

    # install GRUB
    grub_install(install_target, f'{DIR_DST_ROOT}/boot/',
                 f'{DIR_DST_ROOT}/boot/efi')
    LOG.info('GRUB installed')

    # configure GRUB
    boot_params: dict[str, str] = {
        'console_type':
            get_cfg_by_path(cfg, 'vyos_install/boot_params/console_type',
                            'kvm'),
        'serial_console_num':
            get_cfg_by_path(cfg, 'vyos_install/boot_params/serial_console_num',
                            '0'),
        'serial_console_speed':
            get_cfg_by_path(cfg,
                            'vyos_install/boot_params/serial_console_speed',
                            '9600'),
        'cmdline_extra':
            get_cfg_by_path(cfg, 'vyos_install/boot_params/cmdline_extra', '')
    }
    grub_configure(f'{DIR_DST_ROOT}/boot/grub', image_name, boot_params)
    LOG.info('GRUB configured')

    # check if we need to disable Cloud-init
    if get_cfg_by_path(cfg, 'vyos_install/ci_disable', False):
        LOG.info('Disabling Cloud-init')
        Path(f'{DIR_DST_ROOT}/boot/{image_name}/rw/etc/cloud').mkdir(
            parents=True)
        Path(
            f'{DIR_DST_ROOT}/boot/{image_name}/rw/etc/cloud/cloud-init.disabled'
        ).touch()

    # umount filesystems and remove temporary files
    cleanup(
        [f'{install_target}{part_prefix}2', f'{install_target}{part_prefix}3'],
        ['/mnt/installation'])
    LOG.info('temporary resources freed up')

    # check if we need to reboot
    if get_cfg_by_path(cfg, 'vyos_install/post_reboot', False):
        LOG.warn('Adding reboot trigger to postconfig script')
        script_file = Path(
            '/opt/vyatta/etc/config/scripts/vyos-postconfig-bootup.script')
        script_file_data: str = script_file.read_text() + '\nsystemctl reboot\n'
        script_file.write_text(script_file_data)

    # sync just in case
    sync()