summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJohn Estabrook <jestabro@vyos.io>2023-11-16 13:45:43 -0600
committerGitHub <noreply@github.com>2023-11-16 13:45:43 -0600
commitd4f9d6ce726ea4d4fff6eecc16678d7a45a0b555 (patch)
treebad8b2fbfd9e325d9e6338bb8abd6608a2537c04
parentc8ba5dccfa9b02533c6536903ecacd3ddb04351e (diff)
parente036f783bc85e4d2bad5f5cbfd688a03a352223e (diff)
downloadvyos-1x-d4f9d6ce726ea4d4fff6eecc16678d7a45a0b555.tar.gz
vyos-1x-d4f9d6ce726ea4d4fff6eecc16678d7a45a0b555.zip
Merge pull request #1768 from zdc/T4516-sagitta
image: T4516: Added system image tools
-rw-r--r--Makefile1
-rw-r--r--data/templates/grub/grub_common.j223
-rw-r--r--data/templates/grub/grub_compat.j263
-rw-r--r--data/templates/grub/grub_main.j27
-rw-r--r--data/templates/grub/grub_menu.j25
-rw-r--r--data/templates/grub/grub_modules.j23
-rw-r--r--data/templates/grub/grub_options.j252
-rw-r--r--data/templates/grub/grub_vars.j24
-rw-r--r--data/templates/grub/grub_vyos_version.j222
-rw-r--r--debian/vyos-1x.links1
-rw-r--r--op-mode-definitions/add-system-image.xml.in62
-rw-r--r--op-mode-definitions/system-image.xml.in201
-rw-r--r--python/vyos/system/__init__.py18
-rw-r--r--python/vyos/system/compat.py316
-rw-r--r--python/vyos/system/disk.py217
-rw-r--r--python/vyos/system/grub.py340
-rw-r--r--python/vyos/system/image.py263
-rw-r--r--python/vyos/system/raid.py115
-rw-r--r--python/vyos/utils/convert.py5
-rw-r--r--python/vyos/utils/file.py6
-rw-r--r--python/vyos/utils/io.py25
-rwxr-xr-xsrc/op_mode/image_info.py109
-rwxr-xr-xsrc/op_mode/image_installer.py759
-rwxr-xr-xsrc/op_mode/image_manager.py207
-rw-r--r--src/system/grub_update.py104
-rwxr-xr-xsrc/system/standalone_root_pw_reset178
-rw-r--r--src/systemd/vyos-grub-update.service14
27 files changed, 3056 insertions, 64 deletions
diff --git a/Makefile b/Makefile
index 6adb57930..23e060125 100644
--- a/Makefile
+++ b/Makefile
@@ -60,7 +60,6 @@ op_mode_definitions: $(op_xml_obj)
rm -f $(OP_TMPL_DIR)/clear/interfaces/node.def
rm -f $(OP_TMPL_DIR)/clear/node.def
rm -f $(OP_TMPL_DIR)/delete/node.def
- rm -f $(OP_TMPL_DIR)/set/node.def
# XXX: ping, traceroute and mtr must be able to recursivly call themselves as the
# options are provided from the scripts themselves
diff --git a/data/templates/grub/grub_common.j2 b/data/templates/grub/grub_common.j2
new file mode 100644
index 000000000..278ffbf2c
--- /dev/null
+++ b/data/templates/grub/grub_common.j2
@@ -0,0 +1,23 @@
+# load EFI video modules
+if [ "${grub_platform}" == "efi" ]; then
+ insmod efi_gop
+ insmod efi_uga
+fi
+
+# create and activate serial console
+function setup_serial {
+ # initialize the first serial port by default
+ if [ "${console_type}" == "ttyS" ]; then
+ serial --unit=${console_num}
+ else
+ serial --unit=0
+ fi
+ terminal_output --append serial console
+ terminal_input --append serial console
+}
+
+setup_serial
+
+{% if search_root %}
+{{ search_root }}
+{% endif %}
diff --git a/data/templates/grub/grub_compat.j2 b/data/templates/grub/grub_compat.j2
new file mode 100644
index 000000000..887d5d0bd
--- /dev/null
+++ b/data/templates/grub/grub_compat.j2
@@ -0,0 +1,63 @@
+{# j2lint: disable=S6 #}
+### Generated by VyOS image-tools v.{{ tools_version }} ###
+{% macro menu_name(mode) -%}
+{% if mode == 'normal' -%}
+ VyOS
+{%- elif mode == 'pw_reset' -%}
+ Lost password change
+{%- else -%}
+ Unknown
+{%- endif %}
+{%- endmacro %}
+{% macro console_name(type) -%}
+{% if type == 'tty' -%}
+ KVM
+{%- elif type == 'ttyS' -%}
+ Serial
+{%- elif type == 'ttyUSB' -%}
+ USB
+{%- else -%}
+ Unknown
+{%- endif %}
+{%- endmacro %}
+{% macro console_opts(type) -%}
+{% if type == 'tty' -%}
+ console=ttyS0,115200 console=tty0
+{%- elif type == 'ttyS' -%}
+ console=tty0 console=ttyS0,115200
+{%- elif type == 'ttyUSB' -%}
+ console=tty0 console=ttyUSB0,115200
+{%- else -%}
+ console=tty0 console=ttyS0,115200
+{%- endif %}
+{%- endmacro %}
+{% macro passwd_opts(mode) -%}
+{% if mode == 'pw_reset' -%}
+ init=/opt/vyatta/sbin/standalone_root_pw_reset
+{%- endif %}
+{%- endmacro %}
+set default={{ default }}
+set timeout={{ timeout }}
+{% if console_type == 'ttyS' %}
+serial --unit={{ console_num }} --speed=115200
+{% else %}
+serial --unit=0 --speed=115200
+{% endif %}
+terminal_output --append serial
+terminal_input serial console
+{% for mod in modules %}
+insmod {{ mod }}
+{% endfor %}
+{% if root %}
+set root={{ root }}
+{% endif %}
+{% if search_root %}
+{{ search_root }}
+{% endif %}
+
+{% for v in versions %}
+menuentry "{{ menu_name(v.bootmode) }} {{ v.version }} ({{ console_name(v.console_type) }} console)" {
+ linux /boot/{{ v.version }}/vmlinuz {{ v.boot_opts }} {{ console_opts(v.console_type) }} {{ passwd_opts(v.bootmode) }}
+ initrd /boot/{{ v.version }}/initrd.img
+}
+{% endfor %}
diff --git a/data/templates/grub/grub_main.j2 b/data/templates/grub/grub_main.j2
new file mode 100644
index 000000000..0c7ea0202
--- /dev/null
+++ b/data/templates/grub/grub_main.j2
@@ -0,0 +1,7 @@
+load_env
+insmod regexp
+
+for cfgfile in ${prefix}/grub.cfg.d/*-autoload.cfg
+do
+ source ${cfgfile}
+done
diff --git a/data/templates/grub/grub_menu.j2 b/data/templates/grub/grub_menu.j2
new file mode 100644
index 000000000..e73005f5d
--- /dev/null
+++ b/data/templates/grub/grub_menu.j2
@@ -0,0 +1,5 @@
+for cfgfile in ${config_directory}/vyos-versions/*.cfg
+do
+ source "${cfgfile}"
+done
+source ${config_directory}/50-vyos-options.cfg
diff --git a/data/templates/grub/grub_modules.j2 b/data/templates/grub/grub_modules.j2
new file mode 100644
index 000000000..24b540c9d
--- /dev/null
+++ b/data/templates/grub/grub_modules.j2
@@ -0,0 +1,3 @@
+{% for mod_name in mods_list %}
+insmod {{ mod_name | e }}
+{% endfor %}
diff --git a/data/templates/grub/grub_options.j2 b/data/templates/grub/grub_options.j2
new file mode 100644
index 000000000..c8a1472e1
--- /dev/null
+++ b/data/templates/grub/grub_options.j2
@@ -0,0 +1,52 @@
+submenu "Boot options" {
+ submenu "Select boot mode" {
+ menuentry "Normal" {
+ set bootmode="normal"
+ export bootmode
+ configfile ${prefix}/grub.cfg.d/*vyos-menu*.cfg
+ }
+ menuentry "Password reset" {
+ set bootmode="pw_reset"
+ export bootmode
+ configfile ${prefix}/grub.cfg.d/*vyos-menu*.cfg
+ }
+ menuentry "System recovery" {
+ set bootmode="recovery"
+ export bootmode
+ configfile ${prefix}/grub.cfg.d/*vyos-menu*.cfg
+ }
+ menuentry "Load the whole root filesystem to RAM" {
+ set boot_toram="yes"
+ export boot_toram
+ configfile ${prefix}/grub.cfg.d/*vyos-menu*.cfg
+ }
+ }
+ submenu "Select console type" {
+ menuentry "tty (graphical)" {
+ set console_type="tty"
+ export console_type
+ configfile ${prefix}/grub.cfg.d/*vyos-menu*.cfg
+ }
+ menuentry "ttyS (serial)" {
+ set console_type="ttyS"
+ export console_type
+ setup_serial
+ configfile ${prefix}/grub.cfg.d/*vyos-menu*.cfg
+ }
+ menuentry "ttyUSB (USB serial)" {
+ set console_type="ttyUSB"
+ export console_type
+ setup_serial
+ configfile ${prefix}/grub.cfg.d/*vyos-menu*.cfg
+ }
+ }
+ menuentry "Enter console number" {
+ read console_num
+ export console_num
+ setup_serial
+ configfile ${prefix}/grub.cfg.d/*vyos-menu*.cfg
+ }
+ menuentry "Current: boot mode: ${bootmode}, console: ${console_type}${console_num}" {
+ echo
+ }
+}
diff --git a/data/templates/grub/grub_vars.j2 b/data/templates/grub/grub_vars.j2
new file mode 100644
index 000000000..e0002e8d8
--- /dev/null
+++ b/data/templates/grub/grub_vars.j2
@@ -0,0 +1,4 @@
+{% for var_name, var_value in vars.items() %}
+set {{ var_name | e }}="{{ var_value | e }}"
+export {{ var_name | e }}
+{% endfor %}
diff --git a/data/templates/grub/grub_vyos_version.j2 b/data/templates/grub/grub_vyos_version.j2
new file mode 100644
index 000000000..97fbe8473
--- /dev/null
+++ b/data/templates/grub/grub_vyos_version.j2
@@ -0,0 +1,22 @@
+{% set boot_opts_default = "boot=live rootdelay=5 noautologin net.ifnames=0 biosdevname=0 vyos-union=/boot/" + version_name %}
+{% if boot_opts != '' %}
+{% set boot_opts_rendered = boot_opts %}
+{% else %}
+{% set boot_opts_rendered = boot_opts_default %}
+{% endif %}
+menuentry "{{ version_name }}" --id {{ version_uuid }} {
+ set boot_opts="{{ boot_opts_rendered }}"
+ # load rootfs to RAM
+ if [ "${boot_toram}" == "yes" ]; then
+ set boot_opts="${boot_opts} toram"
+ fi
+ if [ "${bootmode}" == "pw_reset" ]; then
+ set boot_opts="${boot_opts} console=${console_type}${console_num} init=/usr/libexec/vyos/system/standalone_root_pw_reset"
+ elif [ "${bootmode}" == "recovery" ]; then
+ set boot_opts="${boot_opts} console=${console_type}${console_num} init=/usr/bin/busybox init"
+ else
+ set boot_opts="${boot_opts} console=${console_type}${console_num}"
+ fi
+ linux "/boot/{{ version_name }}/vmlinuz" ${boot_opts}
+ initrd "/boot/{{ version_name }}/initrd.img"
+}
diff --git a/debian/vyos-1x.links b/debian/vyos-1x.links
index 0e2d1b841..402c91306 100644
--- a/debian/vyos-1x.links
+++ b/debian/vyos-1x.links
@@ -1 +1,2 @@
/etc/netplug/linkup.d/vyos-python-helper /etc/netplug/linkdown.d/vyos-python-helper
+/usr/libexec/vyos/system/standalone_root_pw_reset /opt/vyatta/sbin/standalone_root_pw_reset
diff --git a/op-mode-definitions/add-system-image.xml.in b/op-mode-definitions/add-system-image.xml.in
deleted file mode 100644
index 67d8aa3b4..000000000
--- a/op-mode-definitions/add-system-image.xml.in
+++ /dev/null
@@ -1,62 +0,0 @@
-<?xml version="1.0"?>
-<interfaceDefinition>
- <node name="add">
- <children>
- <node name="system">
- <properties>
- <help>Add item to a system facility</help>
- </properties>
- <children>
- <tagNode name="image">
- <properties>
- <help>Add a new image to the system</help>
- <completionHelp>
- <list>/path/to/vyos-image.iso "http://example.com/vyos-image.iso"</list>
- </completionHelp>
- </properties>
- <command>sudo ${vyatta_sbindir}/install-image --url "${4}"</command>
- <children>
- <tagNode name="vrf">
- <properties>
- <help>Download image via specified VRF</help>
- <completionHelp>
- <path>vrf name</path>
- </completionHelp>
- </properties>
- <command>sudo ${vyatta_sbindir}/install-image --url "${4}" --vrf "${6}"</command>
- <children>
- <tagNode name="username">
- <properties>
- <help>Username for authentication</help>
- </properties>
- <children>
- <tagNode name="password">
- <properties>
- <help>Password to use with authentication</help>
- </properties>
- <command>sudo ${vyatta_sbindir}/install-image --url "${4}" --vrf "${6}" --username "${8}" --password "${10}"</command>
- </tagNode>
- </children>
- </tagNode>
- </children>
- </tagNode>
- <tagNode name="username">
- <properties>
- <help>Username for authentication</help>
- </properties>
- <children>
- <tagNode name="password">
- <properties>
- <help>Password to use with authentication</help>
- </properties>
- <command>sudo ${vyatta_sbindir}/install-image --url "${4}" --username "${6}" --password "${8}"</command>
- </tagNode>
- </children>
- </tagNode>
- </children>
- </tagNode>
- </children>
- </node>
- </children>
- </node>
-</interfaceDefinition>
diff --git a/op-mode-definitions/system-image.xml.in b/op-mode-definitions/system-image.xml.in
new file mode 100644
index 000000000..463b985d6
--- /dev/null
+++ b/op-mode-definitions/system-image.xml.in
@@ -0,0 +1,201 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interfaceDefinition>
+ <node name="add">
+ <properties>
+ <help>Add an object</help>
+ </properties>
+ <children>
+ <node name="system">
+ <properties>
+ <help>Add item to a system facility</help>
+ </properties>
+ <children>
+ <tagNode name="image">
+ <properties>
+ <help>Add a new image to the system</help>
+ <completionHelp>
+ <list>/path/to/vyos-image.iso "http://example.com/vyos-image.iso"</list>
+ </completionHelp>
+ </properties>
+ <command>sudo ${vyos_op_scripts_dir}/image_installer.py --action add --image_path "${4}"</command>
+ <children>
+ <tagNode name="vrf">
+ <properties>
+ <help>Download image via specified VRF</help>
+ <completionHelp>
+ <path>vrf name</path>
+ </completionHelp>
+ </properties>
+ <command>sudo ${vyos_op_scripts_dir}/image_installer.py --action add --image_path "${4}" --vrf "${6}"</command>
+ <children>
+ <tagNode name="username">
+ <properties>
+ <help>Username for authentication</help>
+ </properties>
+ <children>
+ <tagNode name="password">
+ <properties>
+ <help>Password to use with authentication</help>
+ </properties>
+ <command>sudo ${vyos_op_scripts_dir}/image_installer.py --action add --image_path "${4}" --vrf "${6}" --username "${8}" --password "${10}"</command>
+ </tagNode>
+ </children>
+ </tagNode>
+ </children>
+ </tagNode>
+ <tagNode name="username">
+ <properties>
+ <help>Username for authentication</help>
+ </properties>
+ <children>
+ <tagNode name="password">
+ <properties>
+ <help>Password to use with authentication</help>
+ </properties>
+ <command>sudo ${vyos_op_scripts_dir}/image_installer.py --action add --image_path "${4}" --username "${6}" --password "${8}"</command>
+ </tagNode>
+ </children>
+ </tagNode>
+ </children>
+ </tagNode>
+ </children>
+ </node>
+ </children>
+ </node>
+ <node name="set">
+ <properties>
+ <help>Install a new system</help>
+ </properties>
+ <children>
+ <node name="system">
+ <properties>
+ <help>Set system operational parameters</help>
+ </properties>
+ <children>
+ <node name="image">
+ <properties>
+ <help>Set system image parameters</help>
+ </properties>
+ <children>
+ <node name="default-boot">
+ <properties>
+ <help>Set default image to boot.</help>
+ </properties>
+ <command>sudo ${vyos_op_scripts_dir}/image_manager.py --action set</command>
+ </node>
+ <tagNode name="default-boot">
+ <properties>
+ <help>Set default image to boot.</help>
+ <completionHelp>
+ <script>sudo ${vyos_op_scripts_dir}/image_manager.py --action list</script>
+ </completionHelp>
+ </properties>
+ <command>sudo ${vyos_op_scripts_dir}/image_manager.py --action set --image_name "${5}"</command>
+ </tagNode>
+ </children>
+ </node>
+ </children>
+ </node>
+ </children>
+ </node>
+ <node name="install">
+ <properties>
+ <help>Install a new system</help>
+ </properties>
+ <children>
+ <node name="image">
+ <properties>
+ <help>Install new system image to hard drive</help>
+ </properties>
+ <command>sudo ${vyos_op_scripts_dir}/image_installer.py --action install</command>
+ </node>
+ </children>
+ </node>
+ <node name="delete">
+ <properties>
+ <help>Delete an object</help>
+ </properties>
+ <children>
+ <node name="system">
+ <properties>
+ <help>Delete system objects</help>
+ </properties>
+ <children>
+ <node name="image">
+ <properties>
+ <help>Remove an installed image from the system</help>
+ </properties>
+ <command>sudo ${vyos_op_scripts_dir}/image_manager.py --action delete</command>
+ </node>
+ <tagNode name="image">
+ <properties>
+ <help>Remove an installed image from the system</help>
+ <completionHelp>
+ <script>sudo ${vyos_op_scripts_dir}/image_manager.py --action list</script>
+ </completionHelp>
+ </properties>
+ <command>sudo ${vyos_op_scripts_dir}/image_manager.py --action delete --image_name "${4}"</command>
+ </tagNode>
+ </children>
+ </node>
+ </children>
+ </node>
+ <node name="rename">
+ <properties>
+ <help>Rename an object</help>
+ </properties>
+ <children>
+ <node name="system">
+ <properties>
+ <help>Rename a system object</help>
+ </properties>
+ <children>
+ <tagNode name="image">
+ <properties>
+ <help>System image to rename</help>
+ <completionHelp>
+ <script>sudo ${vyos_op_scripts_dir}/image_manager.py --action list</script>
+ </completionHelp>
+ </properties>
+ <children>
+ <tagNode name="to">
+ <properties>
+ <help>A new name for an image</help>
+ </properties>
+ <command>sudo ${vyos_op_scripts_dir}/image_manager.py --action rename --image_name "${4}" --image_new_name "${6}"</command>
+ </tagNode>
+ </children>
+ </tagNode>
+ </children>
+ </node>
+ </children>
+ </node>
+ <node name="show">
+ <properties>
+ <help>Rename an object</help>
+ </properties>
+ <children>
+ <node name="system">
+ <properties>
+ <help>Show system information</help>
+ </properties>
+ <children>
+ <node name="image">
+ <properties>
+ <help>Show installed VyOS images</help>
+ </properties>
+ <command>sudo ${vyos_op_scripts_dir}/image_info.py show_images_summary</command>
+ <children>
+ <node name="details">
+ <properties>
+ <help>Show details about installed VyOS images</help>
+ </properties>
+ <command>sudo ${vyos_op_scripts_dir}/image_info.py show_images_details</command>
+ </node>
+ </children>
+ </node>
+ </children>
+ </node>
+ </children>
+ </node>
+</interfaceDefinition>
diff --git a/python/vyos/system/__init__.py b/python/vyos/system/__init__.py
new file mode 100644
index 000000000..0c91330ba
--- /dev/null
+++ b/python/vyos/system/__init__.py
@@ -0,0 +1,18 @@
+# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+__all_: list[str] = ['disk', 'grub', 'image']
+# define image-tools version
+SYSTEM_CFG_VER = 1
diff --git a/python/vyos/system/compat.py b/python/vyos/system/compat.py
new file mode 100644
index 000000000..319c3dabf
--- /dev/null
+++ b/python/vyos/system/compat.py
@@ -0,0 +1,316 @@
+# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from pathlib import Path
+from re import compile, MULTILINE, DOTALL
+from functools import wraps
+from copy import deepcopy
+from typing import Union
+
+from vyos.system import disk, grub, image, SYSTEM_CFG_VER
+from vyos.template import render
+
+TMPL_GRUB_COMPAT: str = 'grub/grub_compat.j2'
+
+# define regexes and variables
+REGEX_VERSION = r'^menuentry "[^\n]*{\n[^}]*\s+linux /boot/(?P<version>\S+)/[^}]*}'
+REGEX_MENUENTRY = r'^menuentry "[^\n]*{\n[^}]*\s+linux /boot/(?P<version>\S+)/vmlinuz (?P<options>[^\n]+)\n[^}]*}'
+REGEX_CONSOLE = r'^.*console=(?P<console_type>[^\s\d]+)(?P<console_num>[\d]+).*$'
+REGEX_SANIT_CONSOLE = r'\ ?console=[^\s\d]+[\d]+(,\d+)?\ ?'
+REGEX_SANIT_INIT = r'\ ?init=\S*\ ?'
+REGEX_SANIT_QUIET = r'\ ?quiet\ ?'
+PW_RESET_OPTION = 'init=/opt/vyatta/sbin/standalone_root_pw_reset'
+
+
+class DowngradingImageTools(Exception):
+ """Raised when attempting to add an image with an earlier version
+ of image-tools than the current system, as indicated by the value
+ of SYSTEM_CFG_VER or absence thereof."""
+ pass
+
+
+def mode():
+ if grub.get_cfg_ver() >= SYSTEM_CFG_VER:
+ return False
+
+ return True
+
+
+def find_versions(menu_entries: list) -> list:
+ """Find unique VyOS versions from menu entries
+
+ Args:
+ menu_entries (list): a list with menu entries
+
+ Returns:
+ list: List of installed versions
+ """
+ versions = []
+ for vyos_ver in menu_entries:
+ versions.append(vyos_ver.get('version'))
+ # remove duplicates
+ versions = list(set(versions))
+ return versions
+
+
+def filter_unparsed(grub_path: str) -> str:
+ """Find currently installed VyOS version
+
+ Args:
+ grub_path (str): a path to the grub.cfg file
+
+ Returns:
+ str: unparsed grub.cfg items
+ """
+ config_text = Path(grub_path).read_text()
+ regex_filter = compile(REGEX_VERSION, MULTILINE | DOTALL)
+ filtered = regex_filter.sub('', config_text)
+ regex_filter = compile(grub.REGEX_GRUB_VARS, MULTILINE)
+ filtered = regex_filter.sub('', filtered)
+ regex_filter = compile(grub.REGEX_GRUB_MODULES, MULTILINE)
+ filtered = regex_filter.sub('', filtered)
+ # strip extra new lines
+ filtered = filtered.strip()
+ return filtered
+
+
+def get_search_root(unparsed: str) -> str:
+ unparsed_lines = unparsed.splitlines()
+ search_root = next((x for x in unparsed_lines if 'search' in x), '')
+ return search_root
+
+
+def sanitize_boot_opts(boot_opts: str) -> str:
+ """Sanitize boot options from console and init
+
+ Args:
+ boot_opts (str): boot options
+
+ Returns:
+ str: sanitized boot options
+ """
+ regex_filter = compile(REGEX_SANIT_CONSOLE)
+ boot_opts = regex_filter.sub('', boot_opts)
+ regex_filter = compile(REGEX_SANIT_INIT)
+ boot_opts = regex_filter.sub('', boot_opts)
+ # legacy tools add 'quiet' on add system image; this is not desired
+ regex_filter = compile(REGEX_SANIT_QUIET)
+ boot_opts = regex_filter.sub(' ', boot_opts)
+
+ return boot_opts
+
+
+def parse_entry(entry: tuple) -> dict:
+ """Parse GRUB menuentry
+
+ Args:
+ entry (tuple): tuple of (version, options)
+
+ Returns:
+ dict: dictionary with parsed options
+ """
+ # save version to dict
+ entry_dict = {'version': entry[0]}
+ # detect boot mode type
+ if PW_RESET_OPTION in entry[1]:
+ entry_dict['bootmode'] = 'pw_reset'
+ else:
+ entry_dict['bootmode'] = 'normal'
+ # find console type and number
+ regex_filter = compile(REGEX_CONSOLE)
+ entry_dict.update(regex_filter.match(entry[1]).groupdict())
+ entry_dict['boot_opts'] = sanitize_boot_opts(entry[1])
+
+ return entry_dict
+
+
+def parse_menuentries(grub_path: str) -> list:
+ """Parse all GRUB menuentries
+
+ Args:
+ grub_path (str): a path to GRUB config file
+
+ Returns:
+ list: list with menu items (each item is a dict)
+ """
+ menuentries = []
+ # read configuration file
+ config_text = Path(grub_path).read_text()
+ # parse menuentries to tuples (version, options)
+ regex_filter = compile(REGEX_MENUENTRY, MULTILINE)
+ filter_results = regex_filter.findall(config_text)
+ # parse each entry
+ for entry in filter_results:
+ menuentries.append(parse_entry(entry))
+
+ return menuentries
+
+
+def prune_vyos_versions(root_dir: str = '') -> None:
+ """Delete vyos-versions files of registered images subsequently deleted
+ or renamed by legacy image-tools
+
+ Args:
+ root_dir (str): an optional path to the root directory
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ for version in grub.version_list():
+ if not Path(f'{root_dir}/boot/{version}').is_dir():
+ grub.version_del(version)
+
+
+def update_cfg_ver(root_dir:str = '') -> int:
+ """Get minumum version of image-tools across all installed images
+
+ Args:
+ root_dir (str): an optional path to the root directory
+
+ Returns:
+ int: minimum version of image-tools
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ prune_vyos_versions(root_dir)
+
+ images_details = image.get_images_details()
+ cfg_version = min(d['tools_version'] for d in images_details)
+
+ return cfg_version
+
+
+def get_default(menu_entries: list, root_dir: str = '') -> Union[int, None]:
+ """Translate default version to menuentry index
+
+ Args:
+ menu_entries (list): list of dicts of installed version boot data
+ root_dir (str): an optional path to the root directory
+
+ Returns:
+ int: index of default version in menu_entries or None
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ grub_cfg_main = f'{root_dir}/{grub.GRUB_CFG_MAIN}'
+
+ image_name = image.get_default_image()
+
+ sublist = list(filter(lambda x: x.get('version') == image_name,
+ menu_entries))
+ if sublist:
+ return menu_entries.index(sublist[0])
+
+ return None
+
+
+def update_version_list(root_dir: str = '') -> list[dict]:
+ """Update list of dicts of installed version boot data
+
+ Args:
+ root_dir (str): an optional path to the root directory
+
+ Returns:
+ list: list of dicts of installed version boot data
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ grub_cfg_main = f'{root_dir}/{grub.GRUB_CFG_MAIN}'
+
+ # get list of versions in menuentries
+ menu_entries = parse_menuentries(grub_cfg_main)
+ menu_versions = find_versions(menu_entries)
+
+ # get list of versions added/removed by image-tools
+ current_versions = grub.version_list(root_dir)
+
+ remove = list(set(menu_versions) - set(current_versions))
+ for ver in remove:
+ menu_entries = list(filter(lambda x: x.get('version') != ver,
+ menu_entries))
+
+ add = list(set(current_versions) - set(menu_versions))
+ for ver in add:
+ last = menu_entries[0].get('version')
+ new = deepcopy(list(filter(lambda x: x.get('version') == last,
+ menu_entries)))
+ for e in new:
+ boot_opts = e.get('boot_opts').replace(last, ver)
+ e.update({'version': ver, 'boot_opts': boot_opts})
+
+ menu_entries = new + menu_entries
+
+ return menu_entries
+
+
+def grub_cfg_fields(root_dir: str = '') -> dict:
+ """Gather fields for rendering grub.cfg
+
+ Args:
+ root_dir (str): an optional path to the root directory
+
+ Returns:
+ dict: dictionary for rendering TMPL_GRUB_COMPAT
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ grub_cfg_main = f'{root_dir}/{grub.GRUB_CFG_MAIN}'
+
+ fields = {'default': 0, 'timeout': 5}
+ # 'default' and 'timeout' from legacy grub.cfg
+ fields |= grub.vars_read(grub_cfg_main)
+
+ fields['tools_version'] = SYSTEM_CFG_VER
+ menu_entries = update_version_list(root_dir)
+ fields['versions'] = menu_entries
+
+ default = get_default(menu_entries, root_dir)
+ if default is not None:
+ fields['default'] = default
+
+ modules = grub.modules_read(grub_cfg_main)
+ fields['modules'] = modules
+
+ unparsed = filter_unparsed(grub_cfg_main).splitlines()
+ search_root = next((x for x in unparsed if 'search' in x), '')
+ fields['search_root'] = search_root
+
+ return fields
+
+
+def render_grub_cfg(root_dir: str = '') -> None:
+ """Render grub.cfg for legacy compatibility"""
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ grub_cfg_main = f'{root_dir}/{grub.GRUB_CFG_MAIN}'
+
+ fields = grub_cfg_fields(root_dir)
+ render(grub_cfg_main, TMPL_GRUB_COMPAT, fields)
+
+
+def grub_cfg_update(func):
+ """Decorator to update grub.cfg after function call"""
+ @wraps(func)
+ def wrapper(*args, **kwargs):
+ ret = func(*args, **kwargs)
+ if mode():
+ render_grub_cfg()
+ return ret
+ return wrapper
diff --git a/python/vyos/system/disk.py b/python/vyos/system/disk.py
new file mode 100644
index 000000000..49e6b5c5e
--- /dev/null
+++ b/python/vyos/system/disk.py
@@ -0,0 +1,217 @@
+# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from json import loads as json_loads
+from os import sync
+from dataclasses import dataclass
+
+from psutil import disk_partitions
+
+from vyos.utils.process import run, cmd
+
+
+@dataclass
+class DiskDetails:
+ """Disk details"""
+ name: str
+ partition: dict[str, str]
+
+
+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(f'sgdisk -Z {drive_path}')
+
+
+def find_persistence() -> str:
+ """Find a mountpoint for persistence storage
+
+ Returns:
+ str: Path where 'persistance' pertition is mounted, Empty if not found
+ """
+ mounted_partitions = disk_partitions()
+ for partition in mounted_partitions:
+ if partition.mountpoint.endswith('/persistence'):
+ return partition.mountpoint
+ return ''
+
+
+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 = '+100%'
+ else:
+ root_size_text: str = str(root_size)
+ command = f'sgdisk -a1 -n1:2048:4095 -t1:EF02 -n2:4096:+256M -t2:EF00 \
+ -n3:0:+{root_size_text}K -t3:8300 {drive_path}'
+
+ run(command)
+ # update partitons in kernel
+ sync()
+ run(f'partprobe {drive_path}')
+
+ partitions: list[str] = partition_list(drive_path)
+
+ disk: DiskDetails = DiskDetails(
+ name = drive_path,
+ partition = {
+ 'efi': next(x for x in partitions if x.endswith('2')),
+ 'root': next(x for x in partitions if x.endswith('3'))
+ }
+ )
+
+ return disk
+
+
+def partition_list(drive_path: str) -> list[str]:
+ """Get a list of partitions on a drive
+
+ Args:
+ drive_path (str): path to a drive
+
+ Returns:
+ list[str]: a list of partition paths
+ """
+ lsblk: str = cmd(f'lsblk -Jp {drive_path}')
+ drive_info: dict = json_loads(lsblk)
+ device: list = drive_info.get('blockdevices')
+ children: list[str] = device[0].get('children', []) if device else []
+ partitions: list[str] = [child.get('name') for child in children]
+ return partitions
+
+
+def partition_parent(partition_path: str) -> str:
+ """Get a parent device for a partition
+
+ Args:
+ partition (str): path to a partition
+
+ Returns:
+ str: path to a parent device
+ """
+ parent: str = cmd(f'lsblk -ndpo pkname {partition_path}')
+ return parent
+
+
+def from_partition(partition_path: str) -> DiskDetails:
+ drive_path: str = partition_parent(partition_path)
+ partitions: list[str] = partition_list(drive_path)
+
+ disk: DiskDetails = DiskDetails(
+ name = drive_path,
+ partition = {
+ 'efi': next(x for x in partitions if x.endswith('2')),
+ 'root': next(x for x in partitions if x.endswith('3'))
+ }
+ )
+
+ return disk
+
+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(f'{command} {partition}')
+ if fstype == 'ext4':
+ command = 'mkfs -t ext4 -L persistence'
+ run(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(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 = f'umount {partition}'
+ run(command)
+ if path:
+ command = f'umount {path}'
+ run(command)
+
+
+def find_device(mountpoint: str) -> str:
+ """Find a device by mountpoint
+
+ Returns:
+ str: Path to device, Empty if not found
+ """
+ mounted_partitions = disk_partitions()
+ for partition in mounted_partitions:
+ if partition.mountpoint == mountpoint:
+ return partition.mountpoint
+ return ''
+
+
+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 = cmd('lsblk -Jbp')
+ 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
diff --git a/python/vyos/system/grub.py b/python/vyos/system/grub.py
new file mode 100644
index 000000000..0ac16af9a
--- /dev/null
+++ b/python/vyos/system/grub.py
@@ -0,0 +1,340 @@
+# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from pathlib import Path
+from re import MULTILINE, compile as re_compile
+from typing import Union
+from uuid import uuid5, NAMESPACE_URL, UUID
+
+from vyos.template import render
+from vyos.utils.process import cmd
+from vyos.system import disk
+
+# Define variables
+GRUB_DIR_MAIN: str = '/boot/grub'
+GRUB_CFG_MAIN: str = f'{GRUB_DIR_MAIN}/grub.cfg'
+GRUB_DIR_VYOS: str = f'{GRUB_DIR_MAIN}/grub.cfg.d'
+CFG_VYOS_HEADER: str = f'{GRUB_DIR_VYOS}/00-vyos-header.cfg'
+CFG_VYOS_MODULES: str = f'{GRUB_DIR_VYOS}/10-vyos-modules-autoload.cfg'
+CFG_VYOS_VARS: str = f'{GRUB_DIR_VYOS}/20-vyos-defaults-autoload.cfg'
+CFG_VYOS_COMMON: str = f'{GRUB_DIR_VYOS}/25-vyos-common-autoload.cfg'
+CFG_VYOS_PLATFORM: str = f'{GRUB_DIR_VYOS}/30-vyos-platform-autoload.cfg'
+CFG_VYOS_MENU: str = f'{GRUB_DIR_VYOS}/40-vyos-menu-autoload.cfg'
+CFG_VYOS_OPTIONS: str = f'{GRUB_DIR_VYOS}/50-vyos-options.cfg'
+GRUB_DIR_VYOS_VERS: str = f'{GRUB_DIR_VYOS}/vyos-versions'
+
+TMPL_VYOS_VERSION: str = 'grub/grub_vyos_version.j2'
+TMPL_GRUB_VARS: str = 'grub/grub_vars.j2'
+TMPL_GRUB_MAIN: str = 'grub/grub_main.j2'
+TMPL_GRUB_MENU: str = 'grub/grub_menu.j2'
+TMPL_GRUB_MODULES: str = 'grub/grub_modules.j2'
+TMPL_GRUB_OPTS: str = 'grub/grub_options.j2'
+TMPL_GRUB_COMMON: str = 'grub/grub_common.j2'
+
+# prepare regexes
+REGEX_GRUB_VARS: str = r'^set (?P<variable_name>.+)=[\'"]?(?P<variable_value>.*)(?<![\'"])[\'"]?$'
+REGEX_GRUB_MODULES: str = r'^insmod (?P<module_name>.+)$'
+REGEX_KERNEL_CMDLINE: str = r'^BOOT_IMAGE=/(?P<boot_type>boot|live)/((?P<image_version>.+)/)?vmlinuz.*$'
+
+
+def install(drive_path: str, boot_dir: str, efi_dir: str, id: str = 'VyOS') -> 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="{id}" \
+ --no-uefi-secure-boot'
+ ]
+ for command in commands:
+ cmd(command)
+
+
+def gen_version_uuid(version_name: str) -> str:
+ """Generate unique ID from version name
+
+ Use UUID5 / NAMESPACE_URL with prefix `uuid5-`
+
+ Args:
+ version_name (str): version name
+
+ Returns:
+ str: generated unique ID
+ """
+ ver_uuid: UUID = uuid5(NAMESPACE_URL, version_name)
+ ver_id: str = f'uuid5-{ver_uuid}'
+ return ver_id
+
+
+def version_add(version_name: str,
+ root_dir: str = '',
+ boot_opts: str = '') -> None:
+ """Add a new VyOS version to GRUB loader configuration
+
+ Args:
+ vyos_version (str): VyOS version name
+ root_dir (str): an optional path to the root directory.
+ Defaults to empty.
+ boot_opts (str): an optional boot options for Linux kernel.
+ Defaults to empty.
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+ version_config: str = f'{root_dir}/{GRUB_DIR_VYOS_VERS}/{version_name}.cfg'
+ render(
+ version_config, TMPL_VYOS_VERSION, {
+ 'version_name': version_name,
+ 'version_uuid': gen_version_uuid(version_name),
+ 'boot_opts': boot_opts
+ })
+
+
+def version_del(vyos_version: str, root_dir: str = '') -> None:
+ """Delete a VyOS version from GRUB loader configuration
+
+ Args:
+ vyos_version (str): VyOS version name
+ root_dir (str): an optional path to the root directory.
+ Defaults to empty.
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+ version_config: str = f'{root_dir}/{GRUB_DIR_VYOS_VERS}/{vyos_version}.cfg'
+ Path(version_config).unlink(missing_ok=True)
+
+
+def version_list(root_dir: str = '') -> list[str]:
+ """Generate a list with installed VyOS versions
+
+ Args:
+ root_dir (str): an optional path to the root directory.
+ Defaults to empty.
+
+ Returns:
+ list: A list with versions names
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+ versions_files = Path(f'{root_dir}/{GRUB_DIR_VYOS_VERS}').glob('*.cfg')
+ versions_list: list[str] = []
+ for file in versions_files:
+ versions_list.append(file.stem)
+ return versions_list
+
+
+def read_env(env_file: str = '') -> dict[str, str]:
+ """Read GRUB environment
+
+ Args:
+ env_file (str, optional): a path to grub environment file.
+ Defaults to empty.
+
+ Returns:
+ dict: dictionary with GRUB environment
+ """
+ if not env_file:
+ root_dir: str = disk.find_persistence()
+ env_file = f'{root_dir}/{GRUB_DIR_MAIN}/grubenv'
+
+ env_content: str = cmd(f'grub-editenv {env_file} list').splitlines()
+ regex_filter = re_compile(r'^(?P<variable_name>.*)=(?P<variable_value>.*)$')
+ env_dict: dict[str, str] = {}
+ for env_item in env_content:
+ search_result = regex_filter.fullmatch(env_item)
+ if search_result:
+ search_result_dict: dict[str, str] = search_result.groupdict()
+ variable_name: str = search_result_dict.get('variable_name', '')
+ variable_value: str = search_result_dict.get('variable_value', '')
+ if variable_name and variable_value:
+ env_dict.update({variable_name: variable_value})
+ return env_dict
+
+
+def get_cfg_ver(root_dir: str = '') -> int:
+ """Get current version of GRUB configuration
+
+ Args:
+ root_dir (str, optional): an optional path to the root directory.
+ Defaults to empty.
+
+ Returns:
+ int: a configuration version
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ cfg_ver: str = vars_read(f'{root_dir}/{CFG_VYOS_HEADER}').get(
+ 'VYOS_CFG_VER')
+ if cfg_ver:
+ cfg_ver_int: int = int(cfg_ver)
+ else:
+ cfg_ver_int: int = 0
+ return cfg_ver_int
+
+
+def write_cfg_ver(cfg_ver: int, root_dir: str = '') -> None:
+ """Write version number of GRUB configuration
+
+ Args:
+ cfg_ver (int): a version number to write
+ root_dir (str, optional): an optional path to the root directory.
+ Defaults to empty.
+
+ Returns:
+ int: a configuration version
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ vars_file: str = f'{root_dir}/{CFG_VYOS_HEADER}'
+ vars_current: dict[str, str] = vars_read(vars_file)
+ vars_current['VYOS_CFG_VER'] = str(cfg_ver)
+ vars_write(vars_file, vars_current)
+
+
+def vars_read(grub_cfg: str) -> dict[str, str]:
+ """Read variables from a GRUB configuration file
+
+ Args:
+ grub_cfg (str): a path to the GRUB config file
+
+ Returns:
+ dict: a dictionary with variables and values
+ """
+ vars_dict: dict[str, str] = {}
+ regex_filter = re_compile(REGEX_GRUB_VARS)
+ try:
+ config_text: list[str] = Path(grub_cfg).read_text().splitlines()
+ except FileNotFoundError:
+ return vars_dict
+ for line in config_text:
+ search_result = regex_filter.fullmatch(line)
+ if search_result:
+ search_dict = search_result.groupdict()
+ variable_name: str = search_dict.get('variable_name', '')
+ variable_value: str = search_dict.get('variable_value', '')
+ if variable_name and variable_value:
+ vars_dict.update({variable_name: variable_value})
+ return vars_dict
+
+
+def modules_read(grub_cfg: str) -> list[str]:
+ """Read modules list from a GRUB configuration file
+
+ Args:
+ grub_cfg (str): a path to the GRUB config file
+
+ Returns:
+ list: a list with modules to load
+ """
+ mods_list: list[str] = []
+ regex_filter = re_compile(REGEX_GRUB_MODULES, MULTILINE)
+ try:
+ config_text = Path(grub_cfg).read_text()
+ except FileNotFoundError:
+ return mods_list
+ mods_list = regex_filter.findall(config_text)
+
+ return mods_list
+
+
+def modules_write(grub_cfg: str, mods_list: list[str]) -> None:
+ """Write modules list to a GRUB configuration file (overwrite everything)
+
+ Args:
+ grub_cfg (str): a path to GRUB configuration file
+ mods_list (list): a list with modules to load
+ """
+ render(grub_cfg, TMPL_GRUB_MODULES, {'mods_list': mods_list})
+
+
+def vars_write(grub_cfg: str, grub_vars: dict[str, str]) -> None:
+ """Write variables to a GRUB configuration file (overwrite everything)
+
+ Args:
+ grub_cfg (str): a path to GRUB configuration file
+ grub_vars (dict): a dictionary with new variables
+ """
+ render(grub_cfg, TMPL_GRUB_VARS, {'vars': grub_vars})
+
+
+def set_default(version_name: str, root_dir: str = '') -> None:
+ """Set version as default boot entry
+
+ Args:
+ version_name (str): versio name
+ root_dir (str, optional): an optional path to the root directory.
+ Defaults to empty.
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ vars_file = f'{root_dir}/{CFG_VYOS_VARS}'
+ vars_current = vars_read(vars_file)
+ vars_current['default'] = gen_version_uuid(version_name)
+ vars_write(vars_file, vars_current)
+
+
+def common_write(root_dir: str = '', grub_common: dict[str, str] = {}) -> None:
+ """Write common GRUB configuration file (overwrite everything)
+
+ Args:
+ root_dir (str, optional): an optional path to the root directory.
+ Defaults to empty.
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+ common_config = f'{root_dir}/{CFG_VYOS_COMMON}'
+ render(common_config, TMPL_GRUB_COMMON, grub_common)
+
+
+def create_structure(root_dir: str = '') -> None:
+ """Create GRUB directories structure
+
+ Args:
+ root_dir (str, optional): an optional path to the root directory.
+ Defaults to ''.
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ Path(f'{root_dir}/GRUB_DIR_VYOS_VERS').mkdir(parents=True, exist_ok=True)
+
+
+def set_console_type(console_type: str, root_dir: str = '') -> None:
+ """Write default console type to GRUB configuration
+
+ Args:
+ console_type (str): a default console type
+ root_dir (str, optional): an optional path to the root directory.
+ Defaults to empty.
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ vars_file: str = f'{root_dir}/{CFG_VYOS_VARS}'
+ vars_current: dict[str, str] = vars_read(vars_file)
+ vars_current['console_type'] = str(console_type)
+ vars_write(vars_file, vars_current)
+
+def set_raid(root_dir: str = '') -> None:
+ pass
diff --git a/python/vyos/system/image.py b/python/vyos/system/image.py
new file mode 100644
index 000000000..6c4e3bba5
--- /dev/null
+++ b/python/vyos/system/image.py
@@ -0,0 +1,263 @@
+# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+from pathlib import Path
+from re import compile as re_compile
+from tempfile import TemporaryDirectory
+from typing import TypedDict
+
+from vyos import version
+from vyos.system import disk, grub
+
+# Define variables
+GRUB_DIR_MAIN: str = '/boot/grub'
+GRUB_DIR_VYOS: str = f'{GRUB_DIR_MAIN}/grub.cfg.d'
+CFG_VYOS_VARS: str = f'{GRUB_DIR_VYOS}/20-vyos-defaults-autoload.cfg'
+GRUB_DIR_VYOS_VERS: str = f'{GRUB_DIR_VYOS}/vyos-versions'
+# prepare regexes
+REGEX_KERNEL_CMDLINE: str = r'^BOOT_IMAGE=/(?P<boot_type>boot|live)/((?P<image_version>.+)/)?vmlinuz.*$'
+REGEX_SYSTEM_CFG_VER: str = r'(\r\n|\r|\n)SYSTEM_CFG_VER\s*=\s*(?P<cfg_ver>\d+)(\r\n|\r|\n)'
+
+
+# structures definitions
+class ImageDetails(TypedDict):
+ name: str
+ version: str
+ tools_version: int
+ disk_ro: int
+ disk_rw: int
+ disk_total: int
+
+
+class BootDetails(TypedDict):
+ image_default: str
+ image_running: str
+ images_available: list[str]
+ console_type: str
+ console_num: int
+
+
+def bootmode_detect() -> str:
+ """Detect system boot mode
+
+ Returns:
+ str: 'bios' or 'efi'
+ """
+ if Path('/sys/firmware/efi/').exists():
+ return 'efi'
+ else:
+ return 'bios'
+
+
+def get_image_version(mount_path: str) -> str:
+ """Extract version name from rootfs mounted at mount_path
+
+ Args:
+ mount_path (str): mount path of rootfs
+
+ Returns:
+ str: version name
+ """
+ version_file: str = Path(
+ f'{mount_path}/opt/vyatta/etc/version').read_text()
+ version_name: str = version_file.lstrip('Version: ').strip()
+
+ return version_name
+
+
+def get_image_tools_version(mount_path: str) -> int:
+ """Extract image-tools version from rootfs mounted at mount_path
+
+ Args:
+ mount_path (str): mount path of rootfs
+
+ Returns:
+ str: image-tools version
+ """
+ try:
+ version_file: str = Path(
+ f'{mount_path}/usr/lib/python3/dist-packages/vyos/system/__init__.py').read_text()
+ except FileNotFoundError:
+ system_cfg_ver: int = 0
+ else:
+ res = re_compile(REGEX_SYSTEM_CFG_VER).search(version_file)
+ system_cfg_ver: int = int(res.groupdict().get('cfg_ver', 0))
+
+ return system_cfg_ver
+
+
+def get_versions(image_name: str, root_dir: str = '') -> dict[str, str]:
+ """Return versions of image and image-tools
+
+ Args:
+ image_name (str): a name of an image
+ root_dir (str, optional): an optional path to the root directory.
+ Defaults to ''.
+
+ Returns:
+ dict[str, int]: a dictionary with versions of image and image-tools
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ squashfs_file: str = next(
+ Path(f'{root_dir}/boot/{image_name}').glob('*.squashfs')).as_posix()
+ with TemporaryDirectory() as squashfs_mounted:
+ disk.partition_mount(squashfs_file, squashfs_mounted, 'squashfs')
+
+ image_version: str = get_image_version(squashfs_mounted)
+ image_tools_version: int = get_image_tools_version(squashfs_mounted)
+
+ disk.partition_umount(squashfs_file)
+
+ versions: dict[str, int] = {
+ 'image': image_version,
+ 'image-tools': image_tools_version
+ }
+
+ return versions
+
+
+def get_details(image_name: str, root_dir: str = '') -> ImageDetails:
+ """Return information about image
+
+ Args:
+ image_name (str): a name of an image
+ root_dir (str, optional): an optional path to the root directory.
+ Defaults to ''.
+
+ Returns:
+ ImageDetails: a dictionary with details about an image (name, size)
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ versions = get_versions(image_name, root_dir)
+ image_version: str = versions.get('image', '')
+ image_tools_version: int = versions.get('image-tools', 0)
+
+ image_path: Path = Path(f'{root_dir}/boot/{image_name}')
+ image_path_rw: Path = Path(f'{root_dir}/boot/{image_name}/rw')
+
+ image_disk_ro: int = int()
+ for item in image_path.iterdir():
+ if not item.is_symlink():
+ image_disk_ro += item.stat().st_size
+
+ image_disk_rw: int = int()
+ for item in image_path_rw.rglob('*'):
+ if not item.is_symlink():
+ image_disk_rw += item.stat().st_size
+
+ image_details: ImageDetails = {
+ 'name': image_name,
+ 'version': image_version,
+ 'tools_version': image_tools_version,
+ 'disk_ro': image_disk_ro,
+ 'disk_rw': image_disk_rw,
+ 'disk_total': image_disk_ro + image_disk_rw
+ }
+
+ return image_details
+
+
+def get_images_details() -> list[ImageDetails]:
+ """Return information about all images
+
+ Returns:
+ list[ImageDetails]: a list of dictionaries with details about images
+ """
+ images: list[str] = grub.version_list()
+ images_details: list[ImageDetails] = list()
+ for image_name in images:
+ images_details.append(get_details(image_name))
+
+ return images_details
+
+
+def get_running_image() -> str:
+ """Find currently running image name
+
+ Returns:
+ str: image name
+ """
+ running_image: str = ''
+ regex_filter = re_compile(REGEX_KERNEL_CMDLINE)
+ cmdline: str = Path('/proc/cmdline').read_text()
+ running_image_result = regex_filter.match(cmdline)
+ if running_image_result:
+ running_image: str = running_image_result.groupdict().get(
+ 'image_version', '')
+ # we need to have a fallback for live systems
+ if not running_image:
+ running_image: str = version.get_version()
+
+ return running_image
+
+
+def get_default_image(root_dir: str = '') -> str:
+ """Get default boot entry
+
+ Args:
+ root_dir (str, optional): an optional path to the root directory.
+ Defaults to empty.
+ Returns:
+ str: a version name
+ """
+ if not root_dir:
+ root_dir = disk.find_persistence()
+
+ vars_file: str = f'{root_dir}/{CFG_VYOS_VARS}'
+ vars_current: dict[str, str] = grub.vars_read(vars_file)
+ default_uuid: str = vars_current.get('default', '')
+ if default_uuid:
+ images_list: list[str] = grub.version_list(root_dir)
+ for image_name in images_list:
+ if default_uuid == grub.gen_version_uuid(image_name):
+ return image_name
+ return ''
+ else:
+ return ''
+
+
+def validate_name(image_name: str) -> bool:
+ """Validate image name
+
+ Args:
+ image_name (str): suggested image name
+
+ Returns:
+ bool: validation result
+ """
+ regex_filter = re_compile(r'^[\w\.+-]{1,32}$')
+ if regex_filter.match(image_name):
+ return True
+ return False
+
+
+def is_live_boot() -> bool:
+ """Detect live booted system
+
+ Returns:
+ bool: True if the system currently booted in live mode
+ """
+ regex_filter = re_compile(REGEX_KERNEL_CMDLINE)
+ cmdline: str = Path('/proc/cmdline').read_text()
+ running_image_result = regex_filter.match(cmdline)
+ if running_image_result:
+ boot_type: str = running_image_result.groupdict().get('boot_type', '')
+ if boot_type == 'live':
+ return True
+ return False
diff --git a/python/vyos/system/raid.py b/python/vyos/system/raid.py
new file mode 100644
index 000000000..13b99fa69
--- /dev/null
+++ b/python/vyos/system/raid.py
@@ -0,0 +1,115 @@
+# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library 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
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this library. If not, see <http://www.gnu.org/licenses/>.
+
+"""RAID related functions"""
+
+from pathlib import Path
+from shutil import copy
+from dataclasses import dataclass
+
+from vyos.utils.process import cmd
+from vyos.system import disk
+
+
+@dataclass
+class RaidDetails:
+ """RAID type"""
+ name: str
+ level: str
+ members: list[str]
+ disks: list[disk.DiskDetails]
+
+
+def raid_create(raid_members: list[str],
+ raid_name: str = 'md0',
+ raid_level: str = 'raid1') -> None:
+ """Create a RAID array
+
+ Args:
+ raid_name (str): a name of array (data, backup, test, etc.)
+ raid_members (list[str]): a list of array members
+ raid_level (str, optional): an array level. Defaults to 'raid1'.
+ """
+ raid_devices_num: int = len(raid_members)
+ raid_members_str: str = ' '.join(raid_members)
+ if Path('/sys/firmware/efi').exists():
+ for part in raid_members:
+ drive: str = disk.partition_parent(part)
+ command: str = f'sgdisk --typecode=3:A19D880F-05FC-4D3B-A006-743F0F84911E {drive}'
+ cmd(command)
+ else:
+ for part in raid_members:
+ drive: str = disk.partition_parent(part)
+ command: str = f'sgdisk --typecode=3:A19D880F-05FC-4D3B-A006-743F0F84911E {drive}'
+ cmd(command)
+ for part in raid_members:
+ command: str = f'mdadm --zero-superblock {part}'
+ cmd(command)
+ command: str = f'mdadm --create /dev/{raid_name} -R --metadata=1.0 \
+ --raid-devices={raid_devices_num} --level={raid_level} \
+ {raid_members_str}'
+
+ cmd(command)
+
+ raid = RaidDetails(
+ name = f'/dev/{raid_name}',
+ level = raid_level,
+ members = raid_members,
+ disks = [disk.from_partition(m) for m in raid_members]
+ )
+
+ return raid
+
+def update_initramfs() -> None:
+ """Update initramfs"""
+ mdadm_script = '/etc/initramfs-tools/scripts/local-top/mdadm'
+ copy('/usr/share/initramfs-tools/scripts/local-block/mdadm', mdadm_script)
+ p = Path(mdadm_script)
+ p.write_text(p.read_text().replace('$((COUNT + 1))', '20'))
+ command: str = 'update-initramfs -u'
+ cmd(command)
+
+def update_default(target_dir: str) -> None:
+ """Update /etc/default/mdadm to start MD monitoring daemon at boot
+ """
+ source_mdadm_config = '/etc/default/mdadm'
+ target_mdadm_config = Path(target_dir).joinpath('/etc/default/mdadm')
+ target_mdadm_config_dir = Path(target_mdadm_config).parent
+ Path.mkdir(target_mdadm_config_dir, parents=True, exist_ok=True)
+ s = Path(source_mdadm_config).read_text().replace('START_DAEMON=false',
+ 'START_DAEMON=true')
+ Path(target_mdadm_config).write_text(s)
+
+def get_uuid(device: str) -> str:
+ """Get UUID of a device"""
+ command: str = f'tune2fs -l {device}'
+ l = cmd(command).splitlines()
+ uuid = next((x for x in l if x.startswith('Filesystem UUID')), '')
+ return uuid.split(':')[1].strip() if uuid else ''
+
+def get_uuids(raid_details: RaidDetails) -> tuple[str]:
+ """Get UUIDs of RAID members
+
+ Args:
+ raid_name (str): a name of array (data, backup, test, etc.)
+
+ Returns:
+ tuple[str]: root_disk uuid, root_md uuid
+ """
+ raid_name: str = raid_details.name
+ root_partition: str = raid_details.members[0]
+ uuid_root_disk: str = get_uuid(root_partition)
+ uuid_root_md: str = get_uuid(raid_name)
+ return uuid_root_disk, uuid_root_md
diff --git a/python/vyos/utils/convert.py b/python/vyos/utils/convert.py
index 9a8a1ff7d..c02f0071e 100644
--- a/python/vyos/utils/convert.py
+++ b/python/vyos/utils/convert.py
@@ -52,7 +52,8 @@ def seconds_to_human(s, separator=""):
return result
-def bytes_to_human(bytes, initial_exponent=0, precision=2):
+def bytes_to_human(bytes, initial_exponent=0, precision=2,
+ int_below_exponent=0):
""" Converts a value in bytes to a human-readable size string like 640 KB
The initial_exponent parameter is the exponent of 2,
@@ -68,6 +69,8 @@ def bytes_to_human(bytes, initial_exponent=0, precision=2):
# log2 is a float, while range checking requires an int
exponent = int(log2(bytes))
+ if exponent < int_below_exponent:
+ precision = 0
if exponent < 10:
value = bytes
diff --git a/python/vyos/utils/file.py b/python/vyos/utils/file.py
index 667a2464b..9f27a7fb9 100644
--- a/python/vyos/utils/file.py
+++ b/python/vyos/utils/file.py
@@ -134,6 +134,12 @@ def chmod_755(path):
S_IROTH | S_IXOTH
chmod(path, bitmask)
+def chmod_2775(path):
+ """ user/group permissions with set-group-id bit set """
+ from stat import S_ISGID, S_IRWXU, S_IRWXG, S_IROTH, S_IXOTH
+
+ bitmask = S_ISGID | S_IRWXU | S_IRWXG | S_IROTH | S_IXOTH
+ chmod(path, bitmask)
def makedir(path, user=None, group=None):
if os.path.exists(path):
diff --git a/python/vyos/utils/io.py b/python/vyos/utils/io.py
index 8790cbaac..74099b502 100644
--- a/python/vyos/utils/io.py
+++ b/python/vyos/utils/io.py
@@ -13,6 +13,8 @@
# You should have received a copy of the GNU Lesser General Public
# License along with this library. If not, see <http://www.gnu.org/licenses/>.
+from typing import Callable
+
def print_error(str='', end='\n'):
"""
Print `str` to stderr, terminated with `end`.
@@ -72,3 +74,26 @@ def is_dumb_terminal():
"""Check if the current TTY is dumb, so that we can disable advanced terminal features."""
import os
return os.getenv('TERM') in ['vt100', 'dumb']
+
+def select_entry(l: list, list_msg: str = '', prompt_msg: str = '',
+ list_format: Callable = None,) -> str:
+ """Select an entry from a list
+
+ Args:
+ l (list): a list of entries
+ list_msg (str): a message to print before listing the entries
+ prompt_msg (str): a message to print as prompt for selection
+
+ Returns:
+ str: a selected entry
+ """
+ en = list(enumerate(l, 1))
+ print(list_msg)
+ for i, e in en:
+ if list_format:
+ print(f'\t{i}: {list_format(e)}')
+ else:
+ print(f'\t{i}: {e}')
+ select = ask_input(prompt_msg, numeric_only=True,
+ valid_responses=range(1, len(l)+1))
+ return next(filter(lambda x: x[0] == select, en))[1]
diff --git a/src/op_mode/image_info.py b/src/op_mode/image_info.py
new file mode 100755
index 000000000..791001e00
--- /dev/null
+++ b/src/op_mode/image_info.py
@@ -0,0 +1,109 @@
+#!/usr/bin/env python3
+#
+# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This file is part of VyOS.
+#
+# VyOS 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 3 of the License, or (at your option) any later
+# version.
+#
+# VyOS 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
+# VyOS. If not, see <https://www.gnu.org/licenses/>.
+
+import sys
+from typing import List, Union
+
+from tabulate import tabulate
+
+from vyos import opmode
+from vyos.system import disk, grub, image
+from vyos.utils.convert import bytes_to_human
+
+
+def _format_show_images_summary(images_summary: image.BootDetails) -> str:
+ headers: list[str] = ['Name', 'Default boot', 'Running']
+ table_data: list[list[str]] = list()
+ for image_item in images_summary.get('images_available', []):
+ name: str = image_item
+ if images_summary.get('image_default') == name:
+ default: str = 'Yes'
+ else:
+ default: str = ''
+
+ if images_summary.get('image_running') == name:
+ running: str = 'Yes'
+ else:
+ running: str = ''
+
+ table_data.append([name, default, running])
+ tabulated: str = tabulate(table_data, headers)
+
+ return tabulated
+
+
+def _format_show_images_details(
+ images_details: list[image.ImageDetails]) -> str:
+ headers: list[str] = [
+ 'Name', 'Version', 'Storage Read-Only', 'Storage Read-Write',
+ 'Storage Total'
+ ]
+ table_data: list[list[Union[str, int]]] = list()
+ for image_item in images_details:
+ name: str = image_item.get('name')
+ version: str = image_item.get('version')
+ disk_ro: str = bytes_to_human(image_item.get('disk_ro'),
+ precision=1, int_below_exponent=30)
+ disk_rw: str = bytes_to_human(image_item.get('disk_rw'),
+ precision=1, int_below_exponent=30)
+ disk_total: str = bytes_to_human(image_item.get('disk_total'),
+ precision=1, int_below_exponent=30)
+ table_data.append([name, version, disk_ro, disk_rw, disk_total])
+ tabulated: str = tabulate(table_data, headers,
+ colalign=('left', 'left', 'right', 'right', 'right'))
+
+ return tabulated
+
+
+def show_images_summary(raw: bool) -> Union[image.BootDetails, str]:
+ images_available: list[str] = grub.version_list()
+ root_dir: str = disk.find_persistence()
+ boot_vars: dict = grub.vars_read(f'{root_dir}/{image.CFG_VYOS_VARS}')
+
+ images_summary: image.BootDetails = dict()
+
+ images_summary['image_default'] = image.get_default_image()
+ images_summary['image_running'] = image.get_running_image()
+ images_summary['images_available'] = images_available
+ images_summary['console_type'] = boot_vars.get('console_type')
+ images_summary['console_num'] = boot_vars.get('console_num')
+
+ if raw:
+ return images_summary
+ else:
+ return _format_show_images_summary(images_summary)
+
+
+def show_images_details(raw: bool) -> Union[list[image.ImageDetails], str]:
+ images_details = image.get_images_details()
+
+ if raw:
+ return images_details
+ else:
+ return _format_show_images_details(images_details)
+
+
+if __name__ == '__main__':
+ try:
+ res = opmode.run(sys.modules[__name__])
+ if res:
+ print(res)
+ except (ValueError, opmode.Error) as e:
+ print(e)
+ sys.exit(1)
diff --git a/src/op_mode/image_installer.py b/src/op_mode/image_installer.py
new file mode 100755
index 000000000..aa4cf301b
--- /dev/null
+++ b/src/op_mode/image_installer.py
@@ -0,0 +1,759 @@
+#!/usr/bin/env python3
+#
+# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This file is part of VyOS.
+#
+# VyOS 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 3 of the License, or (at your option) any later
+# version.
+#
+# VyOS 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
+# VyOS. If not, see <https://www.gnu.org/licenses/>.
+
+from argparse import ArgumentParser, Namespace
+from pathlib import Path
+from shutil import copy, chown, rmtree, copytree
+from sys import exit
+from time import sleep
+from typing import Union
+from urllib.parse import urlparse
+from passlib.hosts import linux_context
+
+from psutil import disk_partitions
+
+from vyos.configtree import ConfigTree
+from vyos.remote import download
+from vyos.system import disk, grub, image, compat, raid, SYSTEM_CFG_VER
+from vyos.template import render
+from vyos.utils.io import ask_input, ask_yes_no, select_entry
+from vyos.utils.file import chmod_2775
+from vyos.utils.process import cmd, run
+
+# define text messages
+MSG_ERR_NOT_LIVE: str = 'The system is already installed. Please use "add system image" instead.'
+MSG_ERR_LIVE: str = 'The system is in live-boot mode. Please use "install image" instead.'
+MSG_ERR_NO_DISK: str = 'No suitable disk was found. There must be at least one disk of 2GB or greater size.'
+MSG_ERR_IMPROPER_IMAGE: str = 'Missing sha256sum.txt.\nEither this image is corrupted, or of era 1.2.x (md5sum) and would downgrade image tools;\ndisallowed in either case.'
+MSG_INFO_INSTALL_WELCOME: str = 'Welcome to VyOS installation!\nThis command will install VyOS to your permanent storage.'
+MSG_INFO_INSTALL_EXIT: str = 'Exiting from VyOS installation'
+MSG_INFO_INSTALL_SUCCESS: str = 'The image installed successfully; please reboot now.'
+MSG_INFO_INSTALL_DISKS_LIST: str = 'The following disks were found:'
+MSG_INFO_INSTALL_DISK_SELECT: str = 'Which one should be used for installation?'
+MSG_INFO_INSTALL_RAID_CONFIGURE: str = 'Would you like to configure RAID-1 mirroring?'
+MSG_INFO_INSTALL_RAID_FOUND_DISKS: str = 'Would you like to configure RAID-1 mirroring on them?'
+MSG_INFO_INSTALL_RAID_CHOOSE_DISKS: str = 'Would you like to choose two disks for RAID-1 mirroring?'
+MSG_INFO_INSTALL_DISK_CONFIRM: str = 'Installation will delete all data on the drive. Continue?'
+MSG_INFO_INSTALL_RAID_CONFIRM: str = 'Installation will delete all data on both drives. Continue?'
+MSG_INFO_INSTALL_PARTITONING: str = 'Creating partition table...'
+MSG_INPUT_CONFIG_FOUND: str = 'An active configuration was found. Would you like to copy it to the new image?'
+MSG_INPUT_IMAGE_NAME: str = 'What would you like to name this image?'
+MSG_INPUT_IMAGE_DEFAULT: str = 'Would you like to set the new image as the default one for boot?'
+MSG_INPUT_PASSWORD: str = 'Please enter a password for the "vyos" user'
+MSG_INPUT_ROOT_SIZE_ALL: str = 'Would you like to use all the free space on the drive?'
+MSG_INPUT_ROOT_SIZE_SET: str = 'Please specify the size (in GB) of the root partition (min is 1.5 GB)?'
+MSG_INPUT_CONSOLE_TYPE: str = 'What console should be used by default? (K: KVM, S: Serial, U: USB-Serial)?'
+MSG_WARN_ISO_SIGN_INVALID: str = 'Signature is not valid. Do you want to continue with installation?'
+MSG_WARN_ISO_SIGN_UNAVAL: str = 'Signature is not available. Do you want to continue with installation?'
+MSG_WARN_ROOT_SIZE_TOOBIG: str = 'The size is too big. Try again.'
+MSG_WARN_ROOT_SIZE_TOOSMALL: str = 'The size is too small. Try again'
+MSG_WARN_IMAGE_NAME_WRONG: str = 'The suggested name is unsupported!\n'
+'It must be between 1 and 32 characters long and contains only the next characters: .+-_ a-z A-Z 0-9'
+CONST_MIN_DISK_SIZE: int = 2147483648 # 2 GB
+CONST_MIN_ROOT_SIZE: int = 1610612736 # 1.5 GB
+# 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_ROOTFS_SRC: str = f'{DIR_INSTALLATION}/root_src'
+DIR_ROOTFS_DST: str = f'{DIR_INSTALLATION}/root_dst'
+DIR_ISO_MOUNT: str = f'{DIR_INSTALLATION}/iso_src'
+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'
+ISO_DOWNLOAD_PATH: str = '/tmp/vyos_installation.iso'
+
+# default boot variables
+DEFAULT_BOOT_VARS: dict[str, str] = {
+ 'timeout': '5',
+ 'console_type': 'tty',
+ 'console_num': '0',
+ 'bootmode': 'normal'
+}
+
+
+def bytes_to_gb(size: int) -> float:
+ """Convert Bytes to GBytes, rounded to 1 decimal number
+
+ Args:
+ size (int): input size in bytes
+
+ Returns:
+ float: size in GB
+ """
+ return round(size / 1024**3, 1)
+
+
+def gb_to_bytes(size: float) -> int:
+ """Convert GBytes to Bytes
+
+ Args:
+ size (float): input size in GBytes
+
+ Returns:
+ int: size in bytes
+ """
+ return int(size * 1024**3)
+
+
+def find_disks() -> dict[str, int]:
+ """Find a target disk for installation
+
+ Returns:
+ dict[str, int]: a list of available disks by name and size
+ """
+ # check for available disks
+ print('Probing disks')
+ disks_available: dict[str, int] = disk.disks_size()
+ for disk_name, disk_size in disks_available.copy().items():
+ if disk_size < CONST_MIN_DISK_SIZE:
+ del disks_available[disk_name]
+ if not disks_available:
+ print(MSG_ERR_NO_DISK)
+ exit(MSG_INFO_INSTALL_EXIT)
+
+ num_disks: int = len(disks_available)
+ print(f'{num_disks} disk(s) found')
+
+ return disks_available
+
+
+def ask_root_size(available_space: int) -> int:
+ """Define a size of root partition
+
+ Args:
+ available_space (int): available space in bytes for a root partition
+
+ Returns:
+ int: defined size
+ """
+ if ask_yes_no(MSG_INPUT_ROOT_SIZE_ALL, default=True):
+ return available_space
+
+ while True:
+ root_size_gb: str = ask_input(MSG_INPUT_ROOT_SIZE_SET)
+ root_size_kbytes: int = (gb_to_bytes(float(root_size_gb))) // 1024
+
+ if root_size_kbytes > available_space:
+ print(MSG_WARN_ROOT_SIZE_TOOBIG)
+ continue
+ if root_size_kbytes < CONST_MIN_ROOT_SIZE / 1024:
+ print(MSG_WARN_ROOT_SIZE_TOOSMALL)
+ continue
+
+ return root_size_kbytes
+
+def create_partitions(target_disk: str, target_size: int,
+ prompt: bool = True) -> None:
+ """Create partitions on a target disk
+
+ Args:
+ target_disk (str): a target disk
+ target_size (int): size of disk in bytes
+ """
+ # define target rootfs size in KB (smallest unit acceptable by sgdisk)
+ available_size: int = (target_size - CONST_RESERVED_SPACE) // 1024
+ if prompt:
+ rootfs_size: int = ask_root_size(available_size)
+ else:
+ rootfs_size: int = available_size
+
+ print(MSG_INFO_INSTALL_PARTITONING)
+ disk.disk_cleanup(target_disk)
+ disk_details: disk.DiskDetails = disk.parttable_create(target_disk,
+ rootfs_size)
+
+ return disk_details
+
+
+def ask_single_disk(disks_available: dict[str, int]) -> str:
+ """Ask user to select a disk for installation
+
+ Args:
+ disks_available (dict[str, int]): a list of available disks
+ """
+ print(MSG_INFO_INSTALL_DISKS_LIST)
+ default_disk: str = list(disks_available)[0]
+ for disk_name, disk_size in disks_available.items():
+ disk_size_human: str = bytes_to_gb(disk_size)
+ print(f'Drive: {disk_name} ({disk_size_human} GB)')
+ disk_selected: str = ask_input(MSG_INFO_INSTALL_DISK_SELECT,
+ default=default_disk,
+ valid_responses=list(disks_available))
+
+ # create partitions
+ if not ask_yes_no(MSG_INFO_INSTALL_DISK_CONFIRM):
+ print(MSG_INFO_INSTALL_EXIT)
+ exit()
+
+ disk_details: disk.DiskDetails = create_partitions(disk_selected,
+ disks_available[disk_selected])
+
+ disk.filesystem_create(disk_details.partition['efi'], 'efi')
+ disk.filesystem_create(disk_details.partition['root'], 'ext4')
+
+ return disk_details
+
+
+def check_raid_install(disks_available: dict[str, int]) -> Union[str, None]:
+ """Ask user to select disks for RAID installation
+
+ Args:
+ disks_available (dict[str, int]): a list of available disks
+ """
+ if len(disks_available) < 2:
+ return None
+
+ if not ask_yes_no(MSG_INFO_INSTALL_RAID_CONFIGURE, default=True):
+ return None
+
+ def format_selection(disk_name: str) -> str:
+ return f'{disk_name}\t({bytes_to_gb(disks_available[disk_name])} GB)'
+
+ disk0, disk1 = list(disks_available)[0], list(disks_available)[1]
+ disks_selected: dict[str, int] = { disk0: disks_available[disk0],
+ disk1: disks_available[disk1] }
+
+ target_size: int = min(disks_selected[disk0], disks_selected[disk1])
+
+ print(MSG_INFO_INSTALL_DISKS_LIST)
+ for disk_name, disk_size in disks_selected.items():
+ disk_size_human: str = bytes_to_gb(disk_size)
+ print(f'\t{disk_name} ({disk_size_human} GB)')
+ if not ask_yes_no(MSG_INFO_INSTALL_RAID_FOUND_DISKS, default=True):
+ if not ask_yes_no(MSG_INFO_INSTALL_RAID_CHOOSE_DISKS, default=True):
+ return None
+ else:
+ disks_selected = {}
+ disk0 = select_entry(list(disks_available), 'Disks available:',
+ 'Select first disk:', format_selection)
+
+ disks_selected[disk0] = disks_available[disk0]
+ del disks_available[disk0]
+ disk1 = select_entry(list(disks_available), 'Remaining disks:',
+ 'Select second disk:', format_selection)
+ disks_selected[disk1] = disks_available[disk1]
+
+ target_size: int = min(disks_selected[disk0],
+ disks_selected[disk1])
+
+ # create partitions
+ if not ask_yes_no(MSG_INFO_INSTALL_RAID_CONFIRM):
+ print(MSG_INFO_INSTALL_EXIT)
+ exit()
+
+ disks: list[disk.DiskDetails] = []
+ for disk_selected in list(disks_selected):
+ print(f'Creating partitions on {disk_selected}')
+ disk_details = create_partitions(disk_selected, target_size,
+ prompt=False)
+ disk.filesystem_create(disk_details.partition['efi'], 'efi')
+
+ disks.append(disk_details)
+
+ print('Creating RAID array')
+ members = [disk.partition['root'] for disk in disks]
+ raid_details: raid.RaidDetails = raid.raid_create(members)
+ # raid init stuff
+ print('Updating initramfs')
+ raid.update_initramfs()
+ # end init
+ print('Creating filesystem on RAID array')
+ disk.filesystem_create(raid_details.name, 'ext4')
+
+ return raid_details
+
+
+def prepare_tmp_disr() -> None:
+ """Create temporary directories for installation
+ """
+ print('Creating temporary directories')
+ for dir in [DIR_ROOTFS_SRC, DIR_ROOTFS_DST, DIR_DST_ROOT]:
+ dirpath = Path(dir)
+ dirpath.mkdir(mode=0o755, parents=True)
+
+
+def setup_grub(root_dir: str) -> None:
+ """Install GRUB configurations
+
+ Args:
+ root_dir (str): a path to the root of target filesystem
+ """
+ print('Installing GRUB configuration files')
+ grub_cfg_main = f'{root_dir}/{grub.GRUB_DIR_MAIN}/grub.cfg'
+ grub_cfg_vars = f'{root_dir}/{grub.CFG_VYOS_VARS}'
+ grub_cfg_modules = f'{root_dir}/{grub.CFG_VYOS_MODULES}'
+ grub_cfg_menu = f'{root_dir}/{grub.CFG_VYOS_MENU}'
+ grub_cfg_options = f'{root_dir}/{grub.CFG_VYOS_OPTIONS}'
+
+ # create new files
+ render(grub_cfg_main, grub.TMPL_GRUB_MAIN, {})
+ grub.common_write(root_dir)
+ grub.vars_write(grub_cfg_vars, DEFAULT_BOOT_VARS)
+ grub.modules_write(grub_cfg_modules, [])
+ grub.write_cfg_ver(1, root_dir)
+ render(grub_cfg_menu, grub.TMPL_GRUB_MENU, {})
+ render(grub_cfg_options, grub.TMPL_GRUB_OPTS, {})
+
+
+def configure_authentication(config_file: str, password: str) -> None:
+ """Write encrypted password to config file
+
+ Args:
+ config_file (str): path of target config file
+ password (str): plaintext password
+
+ N.B. this can not be deferred by simply setting the plaintext password
+ and relying on the config mode script to process at boot, as the config
+ will not automatically be saved in that case, thus leaving the
+ plaintext exposed
+ """
+ encrypted_password = linux_context.hash(password)
+
+ with open(config_file) as f:
+ config_string = f.read()
+
+ config = ConfigTree(config_string)
+ config.set([
+ 'system', 'login', 'user', 'vyos', 'authentication',
+ 'encrypted-password'
+ ],
+ value=encrypted_password,
+ replace=True)
+ config.set_tag(['system', 'login', 'user'])
+
+ with open(config_file, 'w') as f:
+ f.write(config.to_string())
+
+def validate_signature(file_path: str, sign_type: str) -> None:
+ """Validate a file by signature and delete a signature file
+
+ Args:
+ file_path (str): a path to file
+ sign_type (str): a signature type
+ """
+ print('Validating signature')
+ signature_valid: bool = False
+ # validate with minisig
+ if sign_type == 'minisig':
+ for pubkey in [
+ '/usr/share/vyos/keys/vyos-release.minisign.pub',
+ '/usr/share/vyos/keys/vyos-backup.minisign.pub'
+ ]:
+ if run(f'minisign -V -q -p {pubkey} -m {file_path} -x {file_path}.minisig'
+ ) == 0:
+ signature_valid = True
+ break
+ Path(f'{file_path}.minisig').unlink()
+ # validate with GPG
+ if sign_type == 'asc':
+ if run(f'gpg --verify ${file_path}.asc ${file_path}') == 0:
+ signature_valid = True
+ Path(f'{file_path}.asc').unlink()
+
+ # warn or pass
+ if not signature_valid:
+ if not ask_yes_no(MSG_WARN_ISO_SIGN_INVALID, default=False):
+ exit(MSG_INFO_INSTALL_EXIT)
+ else:
+ print('Signature is valid')
+
+
+def image_fetch(image_path: str) -> Path:
+ """Fetch an ISO image
+
+ Args:
+ image_path (str): a path, remote or local
+
+ Returns:
+ Path: a path to a local file
+ """
+ try:
+ # check a type of path
+ if urlparse(image_path).scheme:
+ # download an image
+ download(ISO_DOWNLOAD_PATH, image_path, True, True)
+ # download a signature
+ sign_file = (False, '')
+ for sign_type in ['minisig', 'asc']:
+ try:
+ download(f'{ISO_DOWNLOAD_PATH}.{sign_type}',
+ f'{image_path}.{sign_type}')
+ sign_file = (True, sign_type)
+ break
+ except Exception:
+ print(f'{sign_type} signature is not available')
+ # validate a signature if it is available
+ if sign_file[0]:
+ validate_signature(ISO_DOWNLOAD_PATH, sign_file[1])
+ else:
+ if not ask_yes_no(MSG_WARN_ISO_SIGN_UNAVAL, default=False):
+ cleanup()
+ exit(MSG_INFO_INSTALL_EXIT)
+
+ return Path(ISO_DOWNLOAD_PATH)
+ else:
+ local_path: Path = Path(image_path)
+ if local_path.is_file():
+ return local_path
+ else:
+ raise FileNotFoundError
+ except Exception:
+ print(f'The image cannot be fetched from: {image_path}')
+ exit(1)
+
+
+def migrate_config() -> bool:
+ """Check for active config and ask user for migration
+
+ Returns:
+ bool: user's decision
+ """
+ active_config_path: Path = Path('/opt/vyatta/etc/config/config.boot')
+ if active_config_path.exists():
+ if ask_yes_no(MSG_INPUT_CONFIG_FOUND, default=True):
+ return True
+ return False
+
+
+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 [].
+ """
+ print('Cleaning up')
+ # 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)
+ # also delete an ISO file
+ if Path(ISO_DOWNLOAD_PATH).exists(
+ ) and ISO_DOWNLOAD_PATH not in remove_items:
+ remove_items.append(ISO_DOWNLOAD_PATH)
+
+ if mounts:
+ print('Unmounting target filesystems')
+ for mountpoint in mounts:
+ disk.partition_umount(mountpoint)
+ if remove_items:
+ print('Removing temporary files')
+ 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 cleanup_raid(details: raid.RaidDetails) -> None:
+ efiparts = []
+ for raid_disk in details.disks:
+ efiparts.append(raid_disk.partition['efi'])
+ cleanup([details.name, *efiparts],
+ ['/mnt/installation'])
+
+
+def is_raid_install(install_object: Union[disk.DiskDetails, raid.RaidDetails]) -> bool:
+ """Check if installation target is a RAID array
+
+ Args:
+ install_object (Union[disk.DiskDetails, raid.RaidDetails]): a target disk
+
+ Returns:
+ bool: True if it is a RAID array
+ """
+ if isinstance(install_object, raid.RaidDetails):
+ return True
+ return False
+
+
+def install_image() -> None:
+ """Install an image to a disk
+ """
+ if not image.is_live_boot():
+ exit(MSG_ERR_NOT_LIVE)
+
+ print(MSG_INFO_INSTALL_WELCOME)
+ if not ask_yes_no('Would you like to continue?'):
+ print(MSG_INFO_INSTALL_EXIT)
+ exit()
+
+ # configure image name
+ running_image_name: str = image.get_running_image()
+ while True:
+ image_name: str = ask_input(MSG_INPUT_IMAGE_NAME,
+ running_image_name)
+ if image.validate_name(image_name):
+ break
+ print(MSG_WARN_IMAGE_NAME_WRONG)
+
+ # ask for password
+ user_password: str = ask_input(MSG_INPUT_PASSWORD, default='vyos')
+
+ # ask for default console
+ console_type: str = ask_input(MSG_INPUT_CONSOLE_TYPE,
+ default='K',
+ valid_responses=['K', 'S', 'U'])
+ console_dict: dict[str, str] = {'K': 'tty', 'S': 'ttyS', 'U': 'ttyUSB'}
+
+ disks: dict[str, int] = find_disks()
+
+ install_target: Union[disk.DiskDetails, raid.RaidDetails, None] = None
+ try:
+ install_target = check_raid_install(disks)
+ if install_target is None:
+ install_target = ask_single_disk(disks)
+
+ # create directories for installation media
+ prepare_tmp_disr()
+
+ # mount target filesystem and create required dirs inside
+ print('Mounting new partitions')
+ if is_raid_install(install_target):
+ disk.partition_mount(install_target.name, DIR_DST_ROOT)
+ Path(f'{DIR_DST_ROOT}/boot/efi').mkdir(parents=True)
+ else:
+ disk.partition_mount(install_target.partition['root'], DIR_DST_ROOT)
+ Path(f'{DIR_DST_ROOT}/boot/efi').mkdir(parents=True)
+ disk.partition_mount(install_target.partition['efi'], f'{DIR_DST_ROOT}/boot/efi')
+
+ # a config dir. It is the deepest one, so the comand will
+ # create all the rest in a single step
+ print('Creating a configuration file')
+ target_config_dir: str = f'{DIR_DST_ROOT}/boot/{image_name}/rw/opt/vyatta/etc/config/'
+ Path(target_config_dir).mkdir(parents=True)
+ chown(target_config_dir, group='vyattacfg')
+ chmod_2775(target_config_dir)
+ # copy config
+ copy('/opt/vyatta/etc/config/config.boot', target_config_dir)
+ configure_authentication(f'{target_config_dir}/config.boot',
+ user_password)
+ Path(f'{target_config_dir}/.vyatta_config').touch()
+
+ # create a persistence.conf
+ Path(f'{DIR_DST_ROOT}/persistence.conf').write_text('/ union\n')
+
+ # copy system image and kernel files
+ print('Copying system image files')
+ for file in Path(DIR_KERNEL_SRC).iterdir():
+ if file.is_file():
+ copy(file, f'{DIR_DST_ROOT}/boot/{image_name}/')
+ copy(FILE_ROOTFS_SRC,
+ f'{DIR_DST_ROOT}/boot/{image_name}/{image_name}.squashfs')
+
+ if is_raid_install(install_target):
+ write_dir: str = f'{DIR_DST_ROOT}/boot/{image_name}/rw'
+ raid.update_default(write_dir)
+
+ setup_grub(DIR_DST_ROOT)
+ # add information about version
+ grub.create_structure()
+ grub.version_add(image_name, DIR_DST_ROOT)
+ grub.set_default(image_name, DIR_DST_ROOT)
+ grub.set_console_type(console_dict[console_type], DIR_DST_ROOT)
+
+ if is_raid_install(install_target):
+ # add RAID specific modules
+ grub.modules_write(f'{DIR_DST_ROOT}/{grub.CFG_VYOS_MODULES}',
+ ['part_msdos', 'part_gpt', 'diskfilter',
+ 'ext2','mdraid1x'])
+ # install GRUB
+ if is_raid_install(install_target):
+ print('Installing GRUB to the drives')
+ l = install_target.disks
+ for disk_target in l:
+ disk.partition_mount(disk_target.partition['efi'], f'{DIR_DST_ROOT}/boot/efi')
+ grub.install(disk_target.name, f'{DIR_DST_ROOT}/boot/',
+ f'{DIR_DST_ROOT}/boot/efi',
+ id=f'VyOS (RAID disk {l.index(disk_target) + 1})')
+ disk.partition_umount(disk_target.partition['efi'])
+ else:
+ print('Installing GRUB to the drive')
+ grub.install(install_target.name, f'{DIR_DST_ROOT}/boot/',
+ f'{DIR_DST_ROOT}/boot/efi')
+
+ # umount filesystems and remove temporary files
+ if is_raid_install(install_target):
+ cleanup([install_target.name],
+ ['/mnt/installation'])
+ else:
+ cleanup([install_target.partition['efi'],
+ install_target.partition['root']],
+ ['/mnt/installation'])
+
+ # we are done
+ print(MSG_INFO_INSTALL_SUCCESS)
+ exit()
+
+ except Exception as err:
+ print(f'Unable to install VyOS: {err}')
+ # unmount filesystems and clenup
+ try:
+ if install_target is not None:
+ if is_raid_install(install_target):
+ cleanup_raid(install_target)
+ else:
+ cleanup([install_target.partition['efi'],
+ install_target.partition['root']],
+ ['/mnt/installation'])
+ except Exception as err:
+ print(f'Cleanup failed: {err}')
+
+ exit(1)
+
+
+@compat.grub_cfg_update
+def add_image(image_path: str) -> None:
+ """Add a new image
+
+ Args:
+ image_path (str): a path to an ISO image
+ """
+ if image.is_live_boot():
+ exit(MSG_ERR_LIVE)
+
+ # fetch an image
+ iso_path: Path = image_fetch(image_path)
+ try:
+ # mount an ISO
+ Path(DIR_ISO_MOUNT).mkdir(mode=0o755, parents=True)
+ disk.partition_mount(iso_path, DIR_ISO_MOUNT, 'iso9660')
+
+ # check sums
+ print('Validating image checksums')
+ if not Path(DIR_ISO_MOUNT).joinpath('sha256sum.txt').exists():
+ cleanup()
+ exit(MSG_ERR_IMPROPER_IMAGE)
+ if run(f'cd {DIR_ISO_MOUNT} && sha256sum --status -c sha256sum.txt'):
+ cleanup()
+ exit('Image checksum verification failed.')
+
+ # mount rootfs (to get a system version)
+ Path(DIR_ROOTFS_SRC).mkdir(mode=0o755, parents=True)
+ disk.partition_mount(f'{DIR_ISO_MOUNT}/live/filesystem.squashfs',
+ DIR_ROOTFS_SRC, 'squashfs')
+
+ cfg_ver: str = image.get_image_tools_version(DIR_ROOTFS_SRC)
+ version_name: str = image.get_image_version(DIR_ROOTFS_SRC)
+
+ disk.partition_umount(f'{DIR_ISO_MOUNT}/live/filesystem.squashfs')
+
+ if cfg_ver < SYSTEM_CFG_VER:
+ raise compat.DowngradingImageTools(
+ f'Adding image would downgrade image tools to v.{cfg_ver}; disallowed')
+
+ image_name: str = ask_input(MSG_INPUT_IMAGE_NAME, version_name)
+ set_as_default: bool = ask_yes_no(MSG_INPUT_IMAGE_DEFAULT, default=True)
+
+ # find target directory
+ root_dir: str = disk.find_persistence()
+
+ # 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'{root_dir}/boot/{image_name}/rw/opt/vyatta/etc/config/'
+ # copy config
+ if migrate_config():
+ print('Copying configuration directory')
+ # copytree preserves perms but not ownership:
+ Path(target_config_dir).mkdir(parents=True)
+ chown(target_config_dir, group='vyattacfg')
+ chmod_2775(target_config_dir)
+ copytree('/opt/vyatta/etc/config/', target_config_dir,
+ dirs_exist_ok=True)
+ else:
+ Path(target_config_dir).mkdir(parents=True)
+ chown(target_config_dir, group='vyattacfg')
+ chmod_2775(target_config_dir)
+ Path(f'{target_config_dir}/.vyatta_config').touch()
+
+ # copy system image and kernel files
+ print('Copying system image files')
+ for file in Path(f'{DIR_ISO_MOUNT}/live').iterdir():
+ if file.is_file() and (file.match('initrd*') or
+ file.match('vmlinuz*')):
+ copy(file, f'{root_dir}/boot/{image_name}/')
+ copy(f'{DIR_ISO_MOUNT}/live/filesystem.squashfs',
+ f'{root_dir}/boot/{image_name}/{image_name}.squashfs')
+
+ # unmount an ISO and cleanup
+ cleanup([str(iso_path)])
+
+ # add information about version
+ grub.version_add(image_name, root_dir)
+ if set_as_default:
+ grub.set_default(image_name, root_dir)
+
+ except Exception as err:
+ # unmount an ISO and cleanup
+ cleanup([str(iso_path)])
+ exit(f'Whooops: {err}')
+
+
+def parse_arguments() -> Namespace:
+ """Parse arguments
+
+ Returns:
+ Namespace: a namespace with parsed arguments
+ """
+ parser: ArgumentParser = ArgumentParser(
+ description='Install new system images')
+ parser.add_argument('--action',
+ choices=['install', 'add'],
+ required=True,
+ help='action to perform with an image')
+ parser.add_argument(
+ '--image_path',
+ help='a path (HTTP or local file) to an image that needs to be installed'
+ )
+ # parser.add_argument('--image_new_name', help='a new name for image')
+ args: Namespace = parser.parse_args()
+ # Validate arguments
+ if args.action == 'add' and not args.image_path:
+ exit('A path to image is required for add action')
+
+ return args
+
+
+if __name__ == '__main__':
+ try:
+ args: Namespace = parse_arguments()
+ if args.action == 'install':
+ install_image()
+ if args.action == 'add':
+ add_image(args.image_path)
+
+ exit()
+
+ except KeyboardInterrupt:
+ print('Stopped by Ctrl+C')
+ cleanup()
+ exit()
+
+ except Exception as err:
+ exit(f'{err}')
diff --git a/src/op_mode/image_manager.py b/src/op_mode/image_manager.py
new file mode 100755
index 000000000..e4b2f4833
--- /dev/null
+++ b/src/op_mode/image_manager.py
@@ -0,0 +1,207 @@
+#!/usr/bin/env python3
+#
+# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This file is part of VyOS.
+#
+# VyOS 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 3 of the License, or (at your option) any later
+# version.
+#
+# VyOS 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
+# VyOS. If not, see <https://www.gnu.org/licenses/>.
+
+from argparse import ArgumentParser, Namespace
+from pathlib import Path
+from shutil import rmtree
+from sys import exit
+from typing import Optional
+
+from vyos.system import disk, grub, image, compat
+from vyos.utils.io import ask_yes_no, select_entry
+
+SET_IMAGE_LIST_MSG: str = 'The following images are available:'
+SET_IMAGE_PROMPT_MSG: str = 'Select an image to set as default:'
+DELETE_IMAGE_LIST_MSG: str = 'The following images are installed:'
+DELETE_IMAGE_PROMPT_MSG: str = 'Select an image to delete:'
+MSG_DELETE_IMAGE_RUNNING: str = 'Currently running image cannot be deleted; reboot into another image first'
+MSG_DELETE_IMAGE_DEFAULT: str = 'Default image cannot be deleted; set another image as default first'
+
+
+@compat.grub_cfg_update
+def delete_image(image_name: Optional[str] = None,
+ prompt: bool = True) -> None:
+ """Remove installed image files and boot entry
+
+ Args:
+ image_name (str): a name of image to delete
+ """
+ available_images: list[str] = grub.version_list()
+ if image_name is None:
+ if not prompt:
+ exit('An image name is required for delete action')
+ else:
+ image_name = select_entry(available_images,
+ DELETE_IMAGE_LIST_MSG,
+ DELETE_IMAGE_PROMPT_MSG)
+ if image_name == image.get_running_image():
+ exit(MSG_DELETE_IMAGE_RUNNING)
+ if image_name == image.get_default_image():
+ exit(MSG_DELETE_IMAGE_DEFAULT)
+ if image_name not in available_images:
+ exit(f'The image "{image_name}" cannot be found')
+ persistence_storage: str = disk.find_persistence()
+ if not persistence_storage:
+ exit('Persistence storage cannot be found')
+
+ if not ask_yes_no(f'Do you really want to delete the image {image_name}?',
+ default=False):
+ exit()
+
+ # remove files and menu entry
+ version_path: Path = Path(f'{persistence_storage}/boot/{image_name}')
+ try:
+ rmtree(version_path)
+ grub.version_del(image_name, persistence_storage)
+ print(f'The image "{image_name}" was successfully deleted')
+ except Exception as err:
+ exit(f'Unable to remove the image "{image_name}": {err}')
+
+
+@compat.grub_cfg_update
+def set_image(image_name: Optional[str] = None,
+ prompt: bool = True) -> None:
+ """Set default boot image
+
+ Args:
+ image_name (str): an image name
+ """
+ available_images: list[str] = grub.version_list()
+ if image_name is None:
+ if not prompt:
+ exit('An image name is required for set action')
+ else:
+ image_name = select_entry(available_images,
+ SET_IMAGE_LIST_MSG,
+ SET_IMAGE_PROMPT_MSG)
+ if image_name == image.get_default_image():
+ exit(f'The image "{image_name}" already configured as default')
+ if image_name not in available_images:
+ exit(f'The image "{image_name}" cannot be found')
+ persistence_storage: str = disk.find_persistence()
+ if not persistence_storage:
+ exit('Persistence storage cannot be found')
+
+ # set default boot image
+ try:
+ grub.set_default(image_name, persistence_storage)
+ print(f'The image "{image_name}" is now default boot image')
+ except Exception as err:
+ exit(f'Unable to set default image "{image_name}": {err}')
+
+
+@compat.grub_cfg_update
+def rename_image(name_old: str, name_new: str) -> None:
+ """Rename installed image
+
+ Args:
+ name_old (str): old name
+ name_new (str): new name
+ """
+ if name_old == image.get_running_image():
+ exit('Currently running image cannot be renamed')
+ available_images: list[str] = grub.version_list()
+ if name_old not in available_images:
+ exit(f'The image "{name_old}" cannot be found')
+ if name_new in available_images:
+ exit(f'The image "{name_new}" already exists')
+ if not image.validate_name(name_new):
+ exit(f'The image name "{name_new}" is not allowed')
+
+ persistence_storage: str = disk.find_persistence()
+ if not persistence_storage:
+ exit('Persistence storage cannot be found')
+
+ if not ask_yes_no(
+ f'Do you really want to rename the image {name_old} '
+ f'to the {name_new}?',
+ default=False):
+ exit()
+
+ try:
+ # replace default boot item
+ if name_old == image.get_default_image():
+ grub.set_default(name_new, persistence_storage)
+
+ # rename files and dirs
+ old_path: Path = Path(f'{persistence_storage}/boot/{name_old}')
+ new_path: Path = Path(f'{persistence_storage}/boot/{name_new}')
+ old_path.rename(new_path)
+
+ # replace boot item
+ grub.version_del(name_old, persistence_storage)
+ grub.version_add(name_new, persistence_storage)
+
+ print(f'The image "{name_old}" was renamed to "{name_new}"')
+ except Exception as err:
+ exit(f'Unable to rename image "{name_old}" to "{name_new}": {err}')
+
+
+def list_images() -> None:
+ """Print list of available images for CLI hints"""
+ images_list: list[str] = grub.version_list()
+ for image_name in images_list:
+ print(image_name)
+
+
+def parse_arguments() -> Namespace:
+ """Parse arguments
+
+ Returns:
+ Namespace: a namespace with parsed arguments
+ """
+ parser: ArgumentParser = ArgumentParser(description='Manage system images')
+ parser.add_argument('--action',
+ choices=['delete', 'set', 'rename', 'list'],
+ required=True,
+ help='action to perform with an image')
+ parser.add_argument(
+ '--image_name',
+ help=
+ 'a name of an image to add, delete, install, rename, or set as default')
+ parser.add_argument('--image_new_name', help='a new name for image')
+ args: Namespace = parser.parse_args()
+ # Validate arguments
+ if args.action == 'rename' and (not args.image_name or
+ not args.image_new_name):
+ exit('Both old and new image names are required for rename action')
+
+ return args
+
+
+if __name__ == '__main__':
+ try:
+ args: Namespace = parse_arguments()
+ if args.action == 'delete':
+ delete_image(args.image_name)
+ if args.action == 'set':
+ set_image(args.image_name)
+ if args.action == 'rename':
+ rename_image(args.image_name, args.image_new_name)
+ if args.action == 'list':
+ list_images()
+
+ exit()
+
+ except KeyboardInterrupt:
+ print('Stopped by Ctrl+C')
+ exit()
+
+ except Exception as err:
+ exit(f'{err}')
diff --git a/src/system/grub_update.py b/src/system/grub_update.py
new file mode 100644
index 000000000..366a85344
--- /dev/null
+++ b/src/system/grub_update.py
@@ -0,0 +1,104 @@
+#!/usr/bin/env python3
+#
+# Copyright 2023 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This file is part of VyOS.
+#
+# VyOS 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 3 of the License, or (at your option) any later
+# version.
+#
+# VyOS 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
+# VyOS. If not, see <https://www.gnu.org/licenses/>.
+
+from pathlib import Path
+from sys import exit
+
+from vyos.system import disk, grub, image, compat, SYSTEM_CFG_VER
+from vyos.template import render
+
+
+def cfg_check_update() -> bool:
+ """Check if GRUB structure update is required
+
+ Returns:
+ bool: False if not required, True if required
+ """
+ current_ver = grub.get_cfg_ver()
+ if current_ver and current_ver >= SYSTEM_CFG_VER:
+ return False
+
+ return True
+
+
+if __name__ == '__main__':
+ if image.is_live_boot():
+ exit(0)
+
+ # Skip everything if update is not required
+ if not cfg_check_update():
+ exit(0)
+
+ # find root directory of persistent storage
+ root_dir = disk.find_persistence()
+
+ # read current GRUB config
+ grub_cfg_main = f'{root_dir}/{grub.GRUB_CFG_MAIN}'
+ vars = grub.vars_read(grub_cfg_main)
+ modules = grub.modules_read(grub_cfg_main)
+ vyos_menuentries = compat.parse_menuentries(grub_cfg_main)
+ vyos_versions = compat.find_versions(vyos_menuentries)
+ unparsed_items = compat.filter_unparsed(grub_cfg_main)
+ # compatibilty for raid installs
+ search_root = compat.get_search_root(unparsed_items)
+ common_dict = {}
+ common_dict['search_root'] = search_root
+ # find default values
+ default_entry = vyos_menuentries[int(vars['default'])]
+ default_settings = {
+ 'default': grub.gen_version_uuid(default_entry['version']),
+ 'bootmode': default_entry['bootmode'],
+ 'console_type': default_entry['console_type'],
+ 'console_num': default_entry['console_num']
+ }
+ vars.update(default_settings)
+
+ # create new files
+ grub_cfg_vars = f'{root_dir}/{grub.CFG_VYOS_VARS}'
+ grub_cfg_modules = f'{root_dir}/{grub.CFG_VYOS_MODULES}'
+ grub_cfg_platform = f'{root_dir}/{grub.CFG_VYOS_PLATFORM}'
+ grub_cfg_menu = f'{root_dir}/{grub.CFG_VYOS_MENU}'
+ grub_cfg_options = f'{root_dir}/{grub.CFG_VYOS_OPTIONS}'
+
+ Path(image.GRUB_DIR_VYOS).mkdir(exist_ok=True)
+ grub.vars_write(grub_cfg_vars, vars)
+ grub.modules_write(grub_cfg_modules, modules)
+ grub.common_write(grub_common=common_dict)
+ render(grub_cfg_menu, grub.TMPL_GRUB_MENU, {})
+ render(grub_cfg_options, grub.TMPL_GRUB_OPTS, {})
+
+ # create menu entries
+ for vyos_ver in vyos_versions:
+ boot_opts = None
+ for entry in vyos_menuentries:
+ if entry.get('version') == vyos_ver and entry.get(
+ 'bootmode') == 'normal':
+ boot_opts = entry.get('boot_opts')
+ grub.version_add(vyos_ver, root_dir, boot_opts)
+
+ # update structure version
+ cfg_ver = compat.update_cfg_ver(root_dir)
+ grub.write_cfg_ver(cfg_ver, root_dir)
+
+ if compat.mode():
+ compat.render_grub_cfg(root_dir)
+ else:
+ render(grub_cfg_main, grub.TMPL_GRUB_MAIN, {})
+
+ exit(0)
diff --git a/src/system/standalone_root_pw_reset b/src/system/standalone_root_pw_reset
new file mode 100755
index 000000000..c82cea321
--- /dev/null
+++ b/src/system/standalone_root_pw_reset
@@ -0,0 +1,178 @@
+#!/bin/bash
+# **** License ****
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# 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.
+#
+# This code was originally developed by Vyatta, Inc.
+# Portions created by Vyatta are Copyright (C) 2007 Vyatta, Inc.
+# All Rights Reserved.
+#
+# Author: Bob Gilligan <gilligan@vyatta.com>
+# Description: Standalone script to set the admin passwd to new value
+# value. Note: This script can ONLY be run as a standalone
+# init program by grub.
+#
+# **** End License ****
+
+# The Vyatta config file:
+CF=/opt/vyatta/etc/config/config.boot
+
+# Admin user name
+ADMIN=vyos
+
+set_encrypted_password() {
+ sed -i \
+ -e "/ user $1 {/,/encrypted-password/s/encrypted-password .*\$/encrypted-password \"$2\"/" $3
+}
+
+
+# How long to wait for user to respond, in seconds
+TIME_TO_WAIT=30
+
+change_password() {
+ local user=$1
+ local pwd1="1"
+ local pwd2="2"
+
+ until [ "$pwd1" == "$pwd2" ]
+ do
+ read -p "Enter $user password: " -r -s pwd1
+ echo
+ read -p "Retype $user password: " -r -s pwd2
+ echo
+
+ if [ "$pwd1" != "$pwd2" ]
+ then echo "Passwords do not match"
+ fi
+ done
+
+ # set the password for the user then store it in the config
+ # so the user is recreated on the next full system boot.
+ local epwd=$(mkpasswd --method=sha-512 "$pwd1")
+ # escape any slashes in resulting password
+ local eepwd=$(sed 's:/:\\/:g' <<< $epwd)
+ set_encrypted_password $user $eepwd $CF
+}
+
+# System is so messed up that doing anything would be a mistake
+dead() {
+ echo $*
+ echo
+ echo "This tool can only recover missing admininistrator password."
+ echo "It is not a full system restore"
+ echo
+ echo -n "Hit return to reboot system: "
+ read
+ /sbin/reboot -f
+}
+
+echo "Standalone root password recovery tool."
+echo
+#
+# Check to see if we are running in standalone mode. We'll
+# know that we are if our pid is 1.
+#
+if [ "$$" != "1" ]; then
+ echo "This tool can only be run in standalone mode."
+ exit 1
+fi
+
+#
+# OK, now we know we are running in standalone mode. Talk to the
+# user.
+#
+echo -n "Do you wish to reset the admin password? (y or n) "
+read -t $TIME_TO_WAIT response
+if [ "$?" != "0" ]; then
+ echo
+ echo "Response not received in time."
+ echo "The admin password will not be reset."
+ echo "Rebooting in 5 seconds..."
+ sleep 5
+ echo
+ /sbin/reboot -f
+fi
+
+response=${response:0:1}
+if [ "$response" != "y" -a "$response" != "Y" ]; then
+ echo "OK, the admin password will not be reset."
+ echo -n "Rebooting in 5 seconds..."
+ sleep 5
+ echo
+ /sbin/reboot -f
+fi
+
+echo -en "Which admin account do you want to reset? [$ADMIN] "
+read admin_user
+ADMIN=${admin_user:-$ADMIN}
+
+echo "Starting process to reset the admin password..."
+
+echo "Re-mounting root filesystem read/write..."
+mount -o remount,rw /
+
+if [ ! -f /etc/passwd ]
+then dead "Missing password file"
+fi
+
+if [ ! -d /opt/vyatta/etc/config ]
+then dead "Missing VyOS config directory /opt/vyatta/etc/config"
+fi
+
+# Leftover from V3.0
+if grep -q /opt/vyatta/etc/config /etc/fstab
+then
+ echo "Mounting the config filesystem..."
+ mount /opt/vyatta/etc/config/
+fi
+
+if [ ! -f $CF ]
+then dead "$CF file not found"
+fi
+
+if ! grep -q 'system {' $CF
+then dead "$CF file does not contain system settings"
+fi
+
+if ! grep -q ' login {' $CF
+then
+ # Recreate login section of system
+ sed -i -e '/system {/a\
+ login {\
+ }' $CF
+fi
+
+if ! grep -q " user $ADMIN " $CF
+then
+ echo "Recreating administrator $ADMIN in $CF..."
+ sed -i -e "/ login {/a\\
+ user $ADMIN {\\
+ authentication {\\
+ encrypted-password \$6$IhbXHdwgYkLnt/$VRIsIN5c2f2v4L2l4F9WPDrRDEtWXzH75yBswmWGERAdX7oBxmq6m.sWON6pO6mi6mrVgYBxdVrFcCP5bI.nt.\\
+ plaintext-password \"\"\\
+ }\\
+ level admin\\
+ }" $CF
+fi
+
+echo "Saving backup copy of config.boot..."
+cp $CF ${CF}.before_pwrecovery
+sync
+
+echo "Setting the administrator ($ADMIN) password..."
+change_password $ADMIN
+
+echo $(date "+%b%e %T") $(hostname) "Admin password changed" \
+ | tee -a /var/log/auth.log >>/var/log/messages
+
+sync
+
+echo "System will reboot in 10 seconds..."
+sleep 10
+/sbin/reboot -f
diff --git a/src/systemd/vyos-grub-update.service b/src/systemd/vyos-grub-update.service
new file mode 100644
index 000000000..522b13a33
--- /dev/null
+++ b/src/systemd/vyos-grub-update.service
@@ -0,0 +1,14 @@
+[Unit]
+Description=Update GRUB loader configuration structure
+After=local-fs.target
+Before=vyos-router.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/libexec/vyos/system/grub_update.py
+TimeoutSec=5
+KillMode=process
+StandardOutput=journal+console
+
+[Install]
+WantedBy=vyos-router.service \ No newline at end of file