diff --git a/Makefile b/Makefile
index 6adb57930..23e060125 100644
--- a/Makefile
+++ b/Makefile
@@ -1,129 +1,128 @@
 TMPL_DIR := templates-cfg
 OP_TMPL_DIR := templates-op
 BUILD_DIR := build
 DATA_DIR := data
 SHIM_DIR := src/shim
 LIBS := -lzmq
 CFLAGS :=
 BUILD_ARCH := $(shell dpkg-architecture -q DEB_BUILD_ARCH)
 
 J2LINT := $(shell command -v j2lint 2> /dev/null)
 
 config_xml_src = $(wildcard interface-definitions/*.xml.in)
 config_xml_obj = $(config_xml_src:.xml.in=.xml)
 op_xml_src = $(wildcard op-mode-definitions/*.xml.in)
 op_xml_obj = $(op_xml_src:.xml.in=.xml)
 
 %.xml: %.xml.in
 	@echo Generating $(BUILD_DIR)/$@ from $<
 	mkdir -p $(BUILD_DIR)/$(dir $@)
 	$(CURDIR)/scripts/transclude-template $< > $(BUILD_DIR)/$@
 
 .PHONY: interface_definitions
 .ONESHELL:
 interface_definitions: $(config_xml_obj)
 	mkdir -p $(TMPL_DIR)
 
 	$(CURDIR)/scripts/override-default $(BUILD_DIR)/interface-definitions
 
 	find $(BUILD_DIR)/interface-definitions -type f -name "*.xml" | xargs -I {} $(CURDIR)/scripts/build-command-templates {} $(CURDIR)/schema/interface_definition.rng $(TMPL_DIR) || exit 1
 
 	$(CURDIR)/python/vyos/xml_ref/generate_cache.py --xml-dir $(BUILD_DIR)/interface-definitions || exit 1
 
 	# XXX: delete top level node.def's that now live in other packages
 	# IPSec VPN EAP-RADIUS does not support source-address
 	rm -rf $(TMPL_DIR)/vpn/ipsec/remote-access/radius/source-address
 
 	# T2472 - EIGRP support
 	rm -rf $(TMPL_DIR)/protocols/eigrp
 	# T2773 - EIGRP support for VRF
 	rm -rf $(TMPL_DIR)/vrf/name/node.tag/protocols/eigrp
 
 	# XXX: test if there are empty node.def files - this is not allowed as these
 	# could mask help strings or mandatory priority statements
 	find $(TMPL_DIR) -name node.def -type f -empty -exec false {} + || sh -c 'echo "There are empty node.def files! Check your interface definitions." && exit 1'
 
 ifeq ($(BUILD_ARCH),arm64)
 	# There is currently no telegraf support in VyOS for ARM64, remove CLI definitions
 	rm -rf $(TMPL_DIR)/service/monitoring/telegraf
 endif
 
 .PHONY: op_mode_definitions
 .ONESHELL:
 op_mode_definitions: $(op_xml_obj)
 	mkdir -p $(OP_TMPL_DIR)
 
 	find $(BUILD_DIR)/op-mode-definitions/ -type f -name "*.xml" | xargs -I {} $(CURDIR)/scripts/build-command-op-templates {} $(CURDIR)/schema/op-mode-definition.rng $(OP_TMPL_DIR) || exit 1
 
 	# XXX: delete top level op mode node.def's that now live in other packages
 	rm -f $(OP_TMPL_DIR)/add/node.def
 	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
 	ln -s ../node.tag $(OP_TMPL_DIR)/ping/node.tag/node.tag/
 	ln -s ../node.tag $(OP_TMPL_DIR)/traceroute/node.tag/node.tag/
 	ln -s ../node.tag $(OP_TMPL_DIR)/mtr/node.tag/node.tag/
 
 	# XXX: test if there are empty node.def files - this is not allowed as these
 	# could mask help strings or mandatory priority statements
 	find $(OP_TMPL_DIR) -name node.def -type f -empty -exec false {} + || sh -c 'echo "There are empty node.def files! Check your interface definitions." && exit 1'
 
 .PHONY: vyshim
 vyshim:
 	$(MAKE) -C $(SHIM_DIR)
 
 .PHONY: all
 all: clean interface_definitions op_mode_definitions check test j2lint vyshim
 
 .PHONY: check
 .ONESHELL:
 check:
 	@echo "Checking which CLI scripts are not enabled to work with vyos-configd..."
 	@for file in `ls src/conf_mode -I__pycache__`
 	do
 		if ! grep -q $$file data/configd-include.json; then
 			echo "* $$file"
 		fi
 	done
 
 .PHONY: clean
 clean:
 	rm -rf $(BUILD_DIR)
 	rm -rf $(TMPL_DIR)
 	rm -rf $(OP_TMPL_DIR)
 	$(MAKE) -C $(SHIM_DIR) clean
 
 .PHONY: test
 test:
 	set -e; python3 -m compileall -q -x '/vmware-tools/scripts/, /ppp/' .
 	PYTHONPATH=python/ python3 -m "nose" --with-xunit src --with-coverage --cover-erase --cover-xml --cover-package src/conf_mode,src/op_mode,src/completion,src/helpers,src/validators,src/tests --verbose
 
 .PHONY: j2lint
 j2lint:
 ifndef J2LINT
 	$(error "j2lint binary not found, consider installing: pip install git+https://github.com/aristanetworks/j2lint.git@341b5d5db86")
 endif
 	$(J2LINT) data/
 
 .PHONY: sonar
 sonar:
 	sonar-scanner -X -Dsonar.login=${SONAR_TOKEN}
 
 .PHONY: docs
 .ONESHELL:
 docs:
 	sphinx-apidoc -o sphinx/source/  python/
 	cd sphinx/
 	PYTHONPATH=../python make html
 
 deb:
 	dpkg-buildpackage -uc -us -tc -b
 
 .PHONY: schema
 schema:
 	trang -I rnc -O rng schema/interface_definition.rnc schema/interface_definition.rng
 	trang -I rnc -O rng schema/op-mode-definition.rnc schema/op-mode-definition.rng
diff --git a/data/templates/grub/grub_common.j2 b/data/templates/grub/grub_common.j2
new file mode 100644
index 000000000..78df3f48c
--- /dev/null
+++ b/data/templates/grub/grub_common.j2
@@ -0,0 +1,22 @@
+# 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
+
+# find root device
+#search --no-floppy --fs-uuid --set=root ${root_uuid}
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..6e8f45210
--- /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=/opt/vyatta/sbin/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/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..57aeb7bb4
--- /dev/null
+++ b/op-mode-definitions/system-image.xml.in
@@ -0,0 +1,189 @@
+<?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>
+              <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>
+          <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/image.py b/python/vyos/image.py
new file mode 100644
index 000000000..cae25b891
--- /dev/null
+++ b/python/vyos/image.py
@@ -0,0 +1,918 @@
+#!/usr/bin/env python3
+#
+# Copyright 2022 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 json import loads as json_loads
+from os import sync
+from pathlib import Path
+from re import MULTILINE, compile as re_compile
+from tempfile import TemporaryDirectory
+from typing import TypedDict, Union
+from uuid import uuid5, NAMESPACE_URL
+
+from psutil import disk_partitions
+
+from vyos.template import render
+from vyos.util import run, cmd
+from vyos import version
+
+# Define variables
+GRUB_DIR_MAIN: str = '/boot/grub'
+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.*$'
+
+
+# structures definitions
+class ImageDetails(TypedDict):
+    name: str
+    version: str
+    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
+
+
+class Grub:
+
+    def install(self, drive_path: str, boot_dir: str,
+                     efi_dir: str) -> None:
+        """Install GRUB for both BIOS and EFI modes (hybrid boot)
+
+        Args:
+            drive_path (str): path to a drive where GRUB must be installed
+            boot_dir (str): a path to '/boot' directory
+            efi_dir (str): a path to '/boot/efi' directory
+        """
+        commands: list[str] = [
+            f'grub-install --no-floppy --target=i386-pc --boot-directory={boot_dir} \
+                {drive_path} --force',
+            f'grub-install --no-floppy --recheck --target=x86_64-efi \
+                --force-extra-removable --boot-directory={boot_dir} \
+                --efi-directory={efi_dir} --bootloader-id="VyOS" \
+                --no-uefi-secure-boot'
+        ]
+        for command in commands:
+            run(command)
+
+    def gen_version_uuid(self, 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 = uuid5(NAMESPACE_URL, version_name)
+        ver_id = f'uuid5-{ver_uuid}'
+        return ver_id
+
+    def version_add(self,
+                         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 = find_presistence()
+        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': self.gen_version_uuid(version_name),
+                'boot_opts': boot_opts
+            })
+
+    def version_del(self, 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 = find_presistence()
+        version_config = f'{root_dir}/{GRUB_DIR_VYOS_VERS}/{vyos_version}.cfg'
+        Path(version_config).unlink(missing_ok=True)
+
+    def grub_version_list(self, 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 = find_presistence()
+        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 grub_read_env(self, 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 = find_presistence()
+            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 grub_get_cfg_ver(self, 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 = find_presistence()
+
+        cfg_ver: Union[str, None] = grub_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 grub_write_cfg_ver(self, 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 = find_presistence()
+
+        vars_file: str = f'{root_dir}/{CFG_VYOS_HEADER}'
+        vars_current: dict[str, str] = grub_vars_read(vars_file)
+        vars_current['VYOS_CFG_VER'] = str(cfg_ver)
+        grub_vars_write(vars_file, vars_current)
+
+    def grub_vars_read(self, 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 grub_modules_read(self, 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 grub_modules_write(self, 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 grub_vars_write(self, 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 grub_set_default(self, 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 = find_presistence()
+
+        vars_file = f'{root_dir}/{CFG_VYOS_VARS}'
+        vars_current = grub_vars_read(vars_file)
+        vars_current['default'] = self.gen_version_uuid(version_name)
+        grub_vars_write(vars_file, vars_current)
+
+    def grub_common_write(self, root_dir: 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 = find_presistence()
+        common_config = f'{root_dir}/{CFG_VYOS_COMMON}'
+        render(common_config, TMPL_GRUB_COMMON, {})
+
+    def create_grub_structure(self, 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 = find_presistence()
+
+        Path(f'{root_dir}/GRUB_DIR_VYOS_VERS').mkdir(parents=True,
+                                                     exist_ok=True)
+
+
+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
+    """
+    # with open(drive_path, 'w+b') as drive:
+    #     drive.seek(0)
+    #     drive.write(b'0' * 17408)
+    #     drive.seek(-17408, 2)
+    #     drive.write(b'0' * 17408)
+    # # update partitons in kernel
+    # sync()
+    # run(f'partprobe {drive_path}')
+    run(f'sgdisk -Z {drive_path}')
+
+
+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 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}')
+
+
+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 grub_install(drive_path: str, boot_dir: str, efi_dir: str) -> None:
+    """Install GRUB for both BIOS and EFI modes (hybrid boot)
+
+    Args:
+        drive_path (str): path to a drive where GRUB must be installed
+        boot_dir (str): a path to '/boot' directory
+        efi_dir (str): a path to '/boot/efi' directory
+    """
+    commands: list[str] = [
+        f'grub-install --no-floppy --target=i386-pc --boot-directory={boot_dir} \
+            {drive_path} --force'                                 ,
+        f'grub-install --no-floppy --recheck --target=x86_64-efi \
+            --force-extra-removable --boot-directory={boot_dir} \
+            --efi-directory={efi_dir} --bootloader-id="VyOS" \
+            --no-uefi-secure-boot'
+    ]
+    for command in commands:
+        run(command)
+
+
+def find_presistence() -> 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 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 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 = uuid5(NAMESPACE_URL, version_name)
+    ver_id = f'uuid5-{ver_uuid}'
+    return ver_id
+
+
+def grub_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 = find_presistence()
+    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 grub_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 = find_presistence()
+    version_config = f'{root_dir}/{GRUB_DIR_VYOS_VERS}/{vyos_version}.cfg'
+    Path(version_config).unlink(missing_ok=True)
+
+
+def grub_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 = find_presistence()
+    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 grub_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 = find_presistence()
+        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 grub_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 = find_presistence()
+
+    cfg_ver: Union[str, None] = grub_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 grub_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 = find_presistence()
+
+    vars_file: str = f'{root_dir}/{CFG_VYOS_HEADER}'
+    vars_current: dict[str, str] = grub_vars_read(vars_file)
+    vars_current['VYOS_CFG_VER'] = str(cfg_ver)
+    grub_vars_write(vars_file, vars_current)
+
+
+def grub_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 grub_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 grub_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 grub_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 grub_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 = find_presistence()
+
+    vars_file = f'{root_dir}/{CFG_VYOS_VARS}'
+    vars_current = grub_vars_read(vars_file)
+    vars_current['default'] = gen_version_uuid(version_name)
+    grub_vars_write(vars_file, vars_current)
+
+
+def grub_common_write(root_dir: 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 = find_presistence()
+    common_config = f'{root_dir}/{CFG_VYOS_COMMON}'
+    render(common_config, TMPL_GRUB_COMMON, {})
+
+
+def raid_create(raid_name: str,
+                raid_members: list[str],
+                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)
+    command: str = f'mdadm --create /dev/md/{raid_name} --metadata=1.2 \
+        --raid-devices={raid_devices_num} --level={raid_level} \
+        {raid_members_str}'
+
+    run(command)
+
+
+def disks_size() -> dict[str, int]:
+    """Get a dictionary with physical disks and their sizes
+
+    Returns:
+        dict[str, int]: a dictionary with name: size mapping
+    """
+    disks_size: dict[str, int] = {}
+    lsblk: str = 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
+
+
+def image_get_version(image_name: str, root_dir: str) -> str:
+    """Extract version name from rootfs based on image name
+
+    Args:
+        image_name (str): a name of image (from boot menu)
+        root_dir (str): a root directory of persistence storage
+
+    Returns:
+        str: version name
+    """
+    squashfs_file: str = next(
+        Path(f'{root_dir}/boot/{image_name}').glob('*.squashfs')).as_posix()
+    with TemporaryDirectory() as squashfs_mounted:
+        partition_mount(squashfs_file, squashfs_mounted, 'squashfs')
+        version_file: str = Path(
+            f'{squashfs_mounted}/opt/vyatta/etc/version').read_text()
+        partition_umount(squashfs_file)
+        version_name: str = version_file.lstrip('Version: ').strip()
+
+    return version_name
+
+
+def image_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 = find_presistence()
+
+    image_version: str = image_get_version(image_name, root_dir)
+
+    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,
+        'disk_ro': image_disk_ro,
+        'disk_rw': image_disk_rw,
+        'disk_total': image_disk_ro + image_disk_rw
+    }
+
+    return image_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 fallbak 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 = find_presistence()
+
+    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.grub_version_list(root_dir)
+        for image_name in images_list:
+            if default_uuid == gen_version_uuid(image_name):
+                return image_name
+        return ''
+    else:
+        return ''
+
+
+def image_name_validate(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
+
+
+def create_grub_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 = find_presistence()
+
+    Path(f'{root_dir}/GRUB_DIR_VYOS_VERS').mkdir(parents=True, exist_ok=True)
diff --git a/python/vyos/system/__init__.py b/python/vyos/system/__init__.py
new file mode 100644
index 000000000..403738e20
--- /dev/null
+++ b/python/vyos/system/__init__.py
@@ -0,0 +1,16 @@
+# Copyright 2022 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']
diff --git a/python/vyos/system/disk.py b/python/vyos/system/disk.py
new file mode 100644
index 000000000..e20cf32be
--- /dev/null
+++ b/python/vyos/system/disk.py
@@ -0,0 +1,172 @@
+# Copyright 2022 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 psutil import disk_partitions
+
+from vyos.util import run, cmd
+
+
+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}')
+
+
+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 raid_create(raid_name: str,
+                raid_members: list[str],
+                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)
+    command: str = f'mdadm --create /dev/md/{raid_name} --metadata=1.2 \
+        --raid-devices={raid_devices_num} --level={raid_level} \
+        {raid_members_str}'
+
+    run(command)
+
+
+def disks_size() -> dict[str, int]:
+    """Get a dictionary with physical disks and their sizes
+
+    Returns:
+        dict[str, int]: a dictionary with name: size mapping
+    """
+    disks_size: dict[str, int] = {}
+    lsblk: str = 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..11c214675
--- /dev/null
+++ b/python/vyos/system/grub.py
@@ -0,0 +1,336 @@
+# Copyright 2022 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.util import run, cmd
+from vyos.system import disk
+
+# Define variables
+GRUB_DIR_MAIN: str = '/boot/grub'
+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) -> None:
+    """Install GRUB for both BIOS and EFI modes (hybrid boot)
+
+    Args:
+        drive_path (str): path to a drive where GRUB must be installed
+        boot_dir (str): a path to '/boot' directory
+        efi_dir (str): a path to '/boot/efi' directory
+    """
+    commands: list[str] = [
+        f'grub-install --no-floppy --target=i386-pc --boot-directory={boot_dir} \
+            {drive_path} --force',
+        f'grub-install --no-floppy --recheck --target=x86_64-efi \
+            --force-extra-removable --boot-directory={boot_dir} \
+            --efi-directory={efi_dir} --bootloader-id="VyOS" \
+            --no-uefi-secure-boot'
+    ]
+    for command in commands:
+        run(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: Union[str, None] = 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 = '') -> 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, {})
+
+
+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)
diff --git a/python/vyos/system/image.py b/python/vyos/system/image.py
new file mode 100644
index 000000000..b77c3563f
--- /dev/null
+++ b/python/vyos/system/image.py
@@ -0,0 +1,197 @@
+# Copyright 2022 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.*$'
+
+
+# structures definitions
+class ImageDetails(TypedDict):
+    name: str
+    version: str
+    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_version(image_name: str, root_dir: str) -> str:
+    """Extract version name from rootfs based on image name
+
+    Args:
+        image_name (str): a name of image (from boot menu)
+        root_dir (str): a root directory of persistence storage
+
+    Returns:
+        str: version name
+    """
+    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')
+        version_file: str = Path(
+            f'{squashfs_mounted}/opt/vyatta/etc/version').read_text()
+        disk.partition_umount(squashfs_file)
+        version_name: str = version_file.lstrip('Version: ').strip()
+
+    return version_name
+
+
+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()
+
+    image_version: str = get_version(image_name, root_dir)
+
+    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,
+        'disk_ro': image_disk_ro,
+        'disk_rw': image_disk_rw,
+        'disk_total': image_disk_ro + image_disk_rw
+    }
+
+    return image_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 fallbak 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/src/op_mode/image_info.py b/src/op_mode/image_info.py
new file mode 100644
index 000000000..ae0677196
--- /dev/null
+++ b/src/op_mode/image_info.py
@@ -0,0 +1,108 @@
+#!/usr/bin/env python3
+#
+# Copyright 2022 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 hurry.filesize import size
+from tabulate import tabulate
+
+from vyos import opmode
+from vyos.system import disk, grub, image
+
+
+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: int = size(image_item.get('disk_ro'))
+        disk_rw: int = size(image_item.get('disk_rw'))
+        disk_total: int = size(image_item.get('disk_total'))
+        table_data.append([name, version, disk_ro, disk_rw, disk_total])
+    tabulated: str = tabulate(table_data, headers)
+
+    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: list[str] = grub.version_list()
+    images_details: list[image.ImageDetails] = list()
+    for image_name in images:
+        images_details.append(image.get_details(image_name))
+
+    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 100644
index 000000000..6ebb38e46
--- /dev/null
+++ b/src/op_mode/image_installer.py
@@ -0,0 +1,557 @@
+#!/usr/bin/env python3
+#
+# Copyright 2022 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, rmtree, copytree
+from sys import exit
+from urllib.parse import urlparse
+
+from psutil import disk_partitions
+
+from vyos.configtree import ConfigTree
+from vyos.remote import download
+from vyos.system import disk, grub, image
+from vyos.template import render
+from vyos.util import ask_input, ask_yes_no, 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_INFO_INSTALL_WELCOME: str = 'Welcome to VyOS installation!\nThis command will install the VyOS to your permanent storage.'
+MSG_INFO_INSTALL_EXIT: str = 'Exitting from VyOS installation'
+MSG_INFO_INSTALL_SUCCESS: str = 'The image installed successfully'
+MSG_INFO_INSTALL_DISKS_LIST: str = 'Were found the next disks:'
+MSG_INFO_INSTALL_DISK_SELECT: str = 'Which one should be used for installation?'
+MSG_INFO_INSTALL_DISK_CONFIRM: str = 'Installation will delete all data on the drive. 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 a new image as 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 free space on the drive?'
+MSG_INPUT_ROOT_SIZE_SET: str = 'What should be a 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_disk() -> tuple[str, int]:
+    """Find a target disk for installation
+
+    Returns:
+        tuple[str, int]: disk name and size in bytes
+    """
+    # check for available disks
+    disks_available: dict[str, int] = 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)
+
+    # select one as a target
+    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))
+
+    return disk_selected, disks_available[disk_selected]
+
+
+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 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:
+    config = ConfigTree(config_file)
+    config.set([
+        'system', 'login', 'user', 'vyos', 'authentication',
+        'plaintext-password'
+    ],
+               value=password,
+               replace=True)
+    config.set_tag(['system', 'login', 'user'])
+
+
+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
+    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 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()
+
+    try:
+        # 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)
+
+        # define target drive
+        install_target, target_size = find_disk()
+
+        # define target rootfs size in KB (smallest unit acceptable by sgdisk)
+        availabe_size: int = (target_size - CONST_RESERVED_SPACE) // 1024
+        rootfs_size: int = ask_root_size(availabe_size)
+
+        # 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'}
+
+        # create partitions
+        if not ask_yes_no(MSG_INFO_INSTALL_DISK_CONFIRM):
+            print(MSG_INFO_INSTALL_EXIT)
+            exit()
+        print(MSG_INFO_INSTALL_PARTITONING)
+        disk.disk_cleanup(install_target)
+        disk.parttable_create(install_target, rootfs_size)
+        disk.filesystem_create(f'{install_target}2', 'efi')
+        disk.filesystem_create(f'{install_target}3', 'ext4')
+
+        # create directiroes for installation media
+        prepare_tmp_disr()
+
+        # mount target filesystem and create required dirs inside
+        print('Mounting new partitions')
+        disk.partition_mount(f'{install_target}3', DIR_DST_ROOT)
+        Path(f'{DIR_DST_ROOT}/boot/efi').mkdir(parents=True)
+        disk.partition_mount(f'{install_target}2', 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)
+        # copy config
+        if migrate_config():
+            copy('/opt/vyatta/etc/config/config.boot', target_config_dir)
+        else:
+            copy('/opt/vyatta/etc/config.boot.default',
+                 f'{target_config_dir}/config.boot')
+        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')
+
+        # install GRUB
+        print('Installing GRUB to the drive')
+        grub.install(install_target, f'{DIR_DST_ROOT}/boot/',
+                     f'{DIR_DST_ROOT}/boot/efi')
+        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)
+
+        # umount filesystems and remove temporary files
+        cleanup([f'{install_target}2', f'{install_target}3'],
+                ['/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:
+            cleanup([f'{install_target}2', f'{install_target}3'],
+                    ['/mnt/installation'])
+        except Exception as err:
+            print(f'Cleanup failed: {err}')
+
+        exit(1)
+
+
+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 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')
+        version_file: str = Path(
+            f'{DIR_ROOTFS_SRC}/opt/vyatta/etc/version').read_text()
+        disk.partition_umount(f'{DIR_ISO_MOUNT}/live/filesystem.squashfs')
+        version_name: str = version_file.lstrip('Version: ').strip()
+        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('/opt/vyatta/etc/config/', target_config_dir)
+        else:
+            Path(target_config_dir).mkdir(parents=True)
+            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 100644
index 000000000..ac889da38
--- /dev/null
+++ b/src/op_mode/image_manager.py
@@ -0,0 +1,190 @@
+#!/usr/bin/env python3
+#
+# Copyright 2022 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 vyos.system import disk, grub, image
+from vyos.util import ask_yes_no
+
+
+def delete_image(image_name: str) -> None:
+    """Remove installed image files and boot entry
+
+    Args:
+        image_name (str): a name of image to delete
+    """
+    if image_name == image.get_running_image():
+        exit('Currently running image cannot be deleted')
+    if image_name == image.get_default_image():
+        exit('Default image cannot be deleted')
+    available_images: list[str] = grub.version_list()
+    if image_name not in available_images:
+        exit(f'The image "{image_name}" cannot be found')
+    presistence_storage: str = disk.find_persistence()
+    if not presistence_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'{presistence_storage}/boot/{image_name}')
+    try:
+        rmtree(version_path)
+        grub.version_del(image_name, presistence_storage)
+        print(f'The image "{image_name}" was successfully deleted')
+    except Exception as err:
+        exit(f'Unable to remove the image "{image_name}": {err}')
+
+
+def set_image(image_name: str) -> None:
+    """Set default boot image
+
+    Args:
+        image_name (str): an image name
+    """
+    if image_name == image.get_default_image():
+        exit(f'The image "{image_name}" already configured as default')
+    available_images: list[str] = grub.version_list()
+    if image_name not in available_images:
+        exit(f'The image "{image_name}" cannot be found')
+    presistence_storage: str = disk.find_persistence()
+    if not presistence_storage:
+        exit('Persistence storage cannot be found')
+
+    if not ask_yes_no(
+            f'Do you really want to set the image {image_name} '
+            'as default boot image?',
+            default=False):
+        exit()
+
+    # set default boot image
+    try:
+        grub.set_default(image_name, presistence_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}')
+
+
+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')
+
+    presistence_storage: str = disk.find_persistence()
+    if not presistence_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, presistence_storage)
+
+        # rename files and dirs
+        old_path: Path = Path(f'{presistence_storage}/boot/{name_old}')
+        new_path: Path = Path(f'{presistence_storage}/boot/{name_new}')
+        old_path.rename(new_path)
+
+        # replace boot item
+        grub.version_del(name_old, presistence_storage)
+        grub.version_add(name_new, presistence_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 == 'delete' and not args.image_name:
+        exit('An image name is required for delete action')
+    if args.action == 'set' and not args.image_name:
+        exit('An image name is required for set action')
+    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..ebdc73af0
--- /dev/null
+++ b/src/system/grub_update.py
@@ -0,0 +1,211 @@
+#!/usr/bin/env python3
+#
+# Copyright 2022 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 re import compile, MULTILINE, DOTALL
+from sys import exit
+
+from vyos.system import disk, grub, image
+from vyos.template import render
+
+# define configuration version
+CFG_VER = 1
+
+# 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*\ ?'
+PW_RESET_OPTION = 'init=/opt/vyatta/sbin/standalone_root_pw_reset'
+
+
+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 >= CFG_VER:
+        return False
+    else:
+        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 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)
+
+    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_menuntries(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
+
+
+if __name__ == '__main__':
+    # 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}/{image.GRUB_DIR_MAIN}/grub.cfg'
+    vars = grub.vars_read(grub_cfg_main)
+    modules = grub.modules_read(grub_cfg_main)
+    vyos_menuentries = parse_menuntries(grub_cfg_main)
+    vyos_versions = find_versions(vyos_menuentries)
+    unparsed_items = filter_unparsed(grub_cfg_main)
+
+    # 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)
+
+    # print(f'vars: {vars}')
+    # print(f'modules: {modules}')
+    # print(f'vyos_menuentries: {vyos_menuentries}')
+    # print(f'unparsed_items: {unparsed_items}')
+
+    # create new files
+    grub_cfg_vars = f'{root_dir}/{image.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}'
+
+    render(grub_cfg_main, grub.TMPL_GRUB_MAIN, {})
+    Path(image.GRUB_DIR_VYOS).mkdir(exist_ok=True)
+    grub.vars_write(grub_cfg_vars, vars)
+    grub.modules_write(grub_cfg_modules, modules)
+    # Path(grub_cfg_platform).write_text(unparsed_items)
+    grub.common_write()
+    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
+    grub.write_cfg_ver(CFG_VER, root_dir)
+    exit(0)
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