system: add snapshots (boot environments) GUI support #7749

This pull request introduces a new feature to the OPNsense web interface,
allowing users to manage FreeBSD boot environments directly within OPNsense.

This integration provides an intuitive and seamless way for users to create,
manage, and switch between boot environments, enhancing system management
and recovery options.

Renamed the menu item to "Snapshots" in an attempt to explain the feature
to non-FreeBSD users.
This commit is contained in:
Sam Sheridan 2024-07-26 03:08:33 +01:00 committed by Franco Fichtner
parent 346e913323
commit 7118a82a05
8 changed files with 534 additions and 0 deletions

View File

@ -0,0 +1,240 @@
<?php
/*
* Copyright (C) 2024 Deciso B.V.
* Copyright (C) 2024 Sheridan Computers Limited
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
* AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
* AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
namespace OPNsense\Core\Api;
use OPNsense\Base\ApiControllerBase;
use OPNsense\Core\Backend;
use OPNsense\Base\UserException;
class SnapshotsController extends ApiControllerBase
{
private array $environments = [];
/**
* @param string $fieldname property to search
* @param string $value value the property should have
* @return array|null
*/
private function find($fieldname, $value)
{
if (empty($this->environments)) {
$this->environments = json_decode(trim((new Backend())->configdRun('zfs snapshot list')), true) ?? [];
}
foreach ($this->environments as $record) {
if (isset($record[$fieldname]) && $record[$fieldname] == $value) {
return $record;
}
}
return null;
}
/**
* @param string $uuid generated uuid to search (calculated by name)
* @return array|null
*/
private function findByUuid($uuid)
{
return $this->find('uuid', $uuid);
}
/**
* @param string $name snapshot name, the actual key of the record
* @return array|null
*/
private function findByName($name)
{
return $this->find('name', $name);
}
/**
* allow all but whitespaces for now
* @param string $name snapshot name
*/
private function isValidName($name)
{
return !preg_match('/\s/', $name);
}
/**
* @return boolean is this a supported feature (ZFS enabled)
*/
public function isSupportedAction()
{
$result = json_decode((new Backend())->configdRun('zfs snapshot supported'), true) ?? [];
return ['supported' => !empty($result) && $result['status'] == 'OK'];
}
/**
* search snapshots
* @return array
*/
public function searchAction()
{
$records = json_decode((new Backend())->configdRun('zfs snapshot list'), true) ?? [];
return $this->searchRecordsetBase($records);
}
/**
* fetch an environment by uuid, return new when not found or $uuid equals null
* @param string $uuid
* @return array
*/
public function getAction($uuid = null)
{
if (!empty($uuid)) {
$result = $this->findByUuid($uuid);
if (!empty($result)) {
return $result;
}
}
// new or not found
return ['name' => 'BE'.date("YmdHis"), 'uuid' => ''];
}
/**
* create a new snapshot
* @param string $uuid uuid to save
* @return array status
*/
public function setAction($uuid)
{
if ($this->request->isPost() && $this->request->hasPost('name')) {
$name = $this->request->getPost('name', 'string', null);
$be = $this->findByUuid($uuid);
$new_be = $this->findByName($name);
if (!empty($be) && $be['name'] == $name) {
/* skip, unchanged */
return ['status' => 'ok'];
} elseif (!empty($be) && empty($new_be) && $this->isValidName($name)) {
return json_decode(
(new Backend())->configdpRun("zfs snapshot rename", [$be['name'], $name]),
true
);
} else {
if (!empty($new_be)) {
$msg = gettext('A snapshot already exists by this name');
} elseif (!$this->isValidName($name)){
$msg = gettext('Invalid name specified');
} else {
$msg = gettext('Snapshot not found');
}
return [
'status' => 'failed',
'validations' => [
'name' => $msg
]
];
}
}
return ['status' => 'failed'];
}
/**
* add or clone a snapshot
* @return array status
*/
public function addAction()
{
if ($this->request->isPost()) {
$uuid = $this->request->getPost('uuid', 'string', '');
$name = $this->request->getPost('name', 'string', '');
$msg = null;
if ($this->findByName($name)) {
$msg = gettext('A snapshot already exists by this name');
} elseif (!$this->isValidName($name)){
$msg = gettext('Invalid name specified');
}
if (!empty($uuid) && empty($msg)) {
/* clone environment */
$be = $this->findByUuid($uuid);
if (empty($be)) {
$msg = gettext('Snapshot not found');
} else {
return json_decode(
(new Backend())->configdpRun("zfs snapshot clone", [$name, $be['name']]), true
);
}
} elseif (empty($msg)) {
return (new Backend())->configdpRun("zfs snapshot create", [$name]);
}
if ($msg) {
return [
'status' => 'failed',
'validations' => [
'name' => $msg
]
];
}
}
return ['status' => 'failed'];
}
/**
* delete an environment by uuid
* @param string $uuid
* @return array
* @throws UserException when not found (or possible)
*/
public function delAction($uuid)
{
if ($this->request->isPost()) {
$be = $this->findByUuid($uuid);
if (empty($be)) {
throw new UserException(gettext("Snapshot not found"), gettext("Snapshots"));
}
if ($be['active'] != '-') {
throw new UserException(gettext("Cannot delete active snapshot"), gettext("Snapshots"));
}
return (json_decode((new Backend())->configdpRun("zfs snapshot destroy", [$be['name']]), true));
}
return ['status' => 'failed'];
}
/**
* activate a snapshot by uuid
* @param string $uuid
* @return array
* @throws UserException when not found (or possible)
*/
public function activateAction($uuid)
{
if ($this->request->isPost()) {
$be = $this->findByUuid($uuid);
if (empty($be)) {
throw new UserException(gettext("Snapshot not found"), gettext("Snapshots"));
}
return json_decode((new Backend())->configdpRun("zfs snapshot activate", [$be['name']]), true);
}
return ['status' => 'failed'];
}
}

View File

@ -0,0 +1,48 @@
<?php
/*
* Copyright (C) 2024 Deciso B.V.
* Copyright (C) 2024 Sheridan Computers Limited
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
* AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
* AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
namespace OPNsense\Core;
class SnapshotsController extends \OPNsense\Base\IndexController
{
/**
* {@inheritdoc}
*/
protected function templateJSIncludes()
{
return array_merge(parent::templateJSIncludes(), [
'/ui/js/moment-with-locales.min.js'
]);
}
public function indexAction()
{
$this->view->pick('OPNsense/Core/snapshot');
$this->view->SnapshotForm = $this->getForm('snapshot');
}
}

View File

@ -0,0 +1,12 @@
<form>
<field>
<id>uuid</id>
<label>UUID</label>
<type>hidden</type>
</field>
<field>
<id>name</id>
<label>Name</label>
<type>text</type>
</field>
</form>

View File

@ -652,6 +652,13 @@
<pattern>api/trust/crl/*</pattern>
</patterns>
</page-system-crlmanager>
<page-snapshots>
<name>System: Snapshots</name>
<patterns>
<pattern>ui/snapshots/*</pattern>
<pattern>api/snapshots/*</pattern>
</patterns>
</page-snapshots>
<page-system-firmware-manualupdate>
<name>System: Firmware</name>
<patterns>

View File

@ -72,6 +72,7 @@
<Edit url="/system_advanced_sysctl.php*" visibility="hidden"/>
</Tunables>
</Settings>
<Snapshots cssClass="fa fa-hdd-o fa-fw" url="/ui/core/snapshots/"/>
<Trust cssClass="fa fa-certificate fa-fw">
<Authorities url="/ui/trust/ca"/>
<Certificates url="/ui/trust/cert"/>

View File

@ -0,0 +1,84 @@
<script>
$(document).ready(function() {
/* always hide "uuid" row in edit form */
$("#row_uuid").hide();
/* before rendering anything, check if ZFS is supported */
ajaxGet('/api/core/snapshots/is_supported/', {}, function(data, status) {
if (data && data.supported) {
$("#grid-env").UIBootgrid({
get: '/api/core/snapshots/get/',
set: '/api/core/snapshots/set/',
add: '/api/core/snapshots/add/',
del: '/api/core/snapshots/del/',
search: '/api/core/snapshots/search',
commands: {
activate_be: {
method: function(event) {
let uuid = $(this).data("row-id") !== undefined ? $(this).data("row-id") : '';
ajaxCall('/api/core/snapshots/activate/' + uuid, {}, function(data, status){
if (data.result) {
$('#grid-env').bootgrid('reload');
}
});
},
classname: 'fa fa-fw fa-check',
title: "{{ lang._('Activate') }}",
sequence: 10
},
},
options: {
selection: false,
multiSelect: false,
rowSelect: false,
formatters: {
"timestamp": function (column, row) {
return moment.unix(row[column.id]).local().format('YYYY-MM-DD HH:mm');
}
}
}
});
$("#supported_block").show();
} else {
$("#unsupported_block").show();
}
});
});
</script>
<div id="supported_block" class="content-box" style="display: none;">
<table id="grid-env" class="table table-condensed table-hover table-striped table-responsive" data-editDialog="frmSnapshot">
<thead>
<tr>
<th data-column-id="uuid" data-type="string" data-visible="false" data-identifier="true" data-sortable="false">{{ lang._('uuid') }}</th>
<th data-column-id="name" data-type="string" data-visible="true" data-identifier="false">{{ lang._('Name') }}</th>
<th data-column-id="active" data-type="string" data-visible="true" data-sortable="false">{{ lang._('Active') }}</th>
<th data-column-id="mountpoint" data-type="string" data-visible="true">{{ lang._('Mountpoint') }}</th>
<th data-column-id="size" data-type="string" data-visible="true" data-sortable="false">{{ lang._('Size') }}</th>
<th data-column-id="created" data-type="string" data-formatter="timestamp">{{ lang._('Created') }}</th>
<th data-column-id="commands" data-width="9em" data-formatter="commands" data-sortable="false">{{ lang._('Commands') }}</th>
</tr>
</thead>
<tbody>
</tbody>
<tfoot>
<tr>
<td colspan="6"></td>
<td>
<button data-action="add" type="button" class="btn btn-xs btn-primary"><span class="fa fa-fw fa-plus"></span></button>
</td>
</tr>
</tfoot>
</table>
</div>
<div id="unsupported_block" style="padding: 10px; display: none;">
<div class="alert alert-warning" role="alert">
<i class="fa fa-fw fa-warning"></i>
{{ lang._('Snapshots are only available when a ZFS file system is used.') }}
<br/>
{{ lang._('For more information on how to migrate to ZFS, please refer to our documentation or support resources.') }}
</div>
</div>
{{ partial("layout_partials/base_dialog",['fields':SnapshotForm,'id':'frmSnapshot', 'label':lang._('Edit snapshot')])}}

View File

@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""
Copyright (c) 2024 Deciso B.V.
Copyright (c) 2024 Sheridan Computers Limited
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
------------------------------------------------------------------------------------------
Simple wrapper around bectl shell command
"""
import argparse
import datetime
import json
import subprocess
import sys
import time
import uuid
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument(
'action',
help='action to perform, see bectl for details',
choices=['is_supported', 'activate', 'create', 'clone', 'destroy', 'list', 'rename']
)
parser.add_argument('--beName', help='name of boot environment', type=str)
parser.add_argument('--from-source', help='boot environment to clone', type=str)
inputargs = parser.parse_args()
cmd = []
error_msg = None
if subprocess.run(['df', '-Tt', 'zfs', '/'], capture_output=True).returncode != 0:
error_msg = 'Unsupported root filesystem'
elif inputargs.action == 'is_supported':
print(json.dumps({"status": "OK", "message": "File system is ZFS"}))
elif inputargs.action == 'list':
cmd = ['bectl', 'list', '-H']
elif inputargs.action == 'activate' and inputargs.beName:
cmd = ['bectl', 'activate', inputargs.beName]
elif inputargs.action == 'create':
name = inputargs.beName if inputargs.beName else "BE-{date:%Y%m%d%H%M%S}".format(date=datetime.datetime.now())
cmd = ['bectl', 'create', name]
elif inputargs.action == 'clone' and inputargs.from_source:
name = inputargs.beName if inputargs.beName else "BE-{date:%Y%m%d%H%M%S}".format(date=datetime.datetime.now())
cmd = ['bectl', 'create', '-e', inputargs.from_source, name]
elif inputargs.action == 'destroy' and inputargs.beName:
cmd = ['bectl', 'destroy', inputargs.beName]
elif inputargs.action == 'rename' and inputargs.beName and inputargs.from_source:
cmd = ['bectl', 'rename', inputargs.from_source, inputargs.beName]
else:
print(json.dumps({"status": "failed", "result": "Incomplete argument list"}))
sys.exit(-1)
if error_msg:
print(json.dumps({"status": "failed", "result": error_msg}))
elif len(cmd) > 0:
sp = subprocess.run(cmd, capture_output=True, text=True)
if sp.returncode != 0:
print(json.dumps({"status": "failed", "result": sp.stderr.strip()}))
elif inputargs.action == 'list':
result = []
for line in sp.stdout.split("\n"):
parts = line.split("\t")
if len(parts) >= 5:
result.append({
"uuid": str(uuid.uuid3(uuid.NAMESPACE_DNS, parts[0])),
"name": parts[0],
"active": parts[1],
"mountpoint": parts[2],
"size": parts[3],
"created_str": parts[4],
"created": time.mktime(datetime.datetime.strptime(parts[4], "%Y-%m-%d %H:%M").timetuple())
})
print(json.dumps(result))
else:
print(json.dumps({
"status": "ok",
"result": 'bootenvironment executed %s successfully' % inputargs.action
}))

View File

@ -11,3 +11,45 @@ parameters:%s
type:script
message:Scrubbing ZFS Pool %s
description:ZFS pool scrub
[snapshot.list]
command:/usr/local/opnsense/scripts/system/bectl.py
parameters:list
type:script_output
message:List snapshots
[snapshot.create]
command:/usr/local/opnsense/scripts/system/bectl.py
parameters: create --beName %s
type:script_output
message:Creating snapshot %s
[snapshot.clone]
command:/usr/local/opnsense/scripts/system/bectl.py
parameters: clone --beName %s --from-source %s
type:script_output
message:Cloning snapshot %s from %s
[snapshot.activate]
command:/usr/local/opnsense/scripts/system/bectl.py
parameters: activate --beName %s
type:script_output
message:Activate snapshot %s
[snapshot.destroy]
command:/usr/local/opnsense/scripts/system/bectl.py
parameters: destroy --beName %s
type:script_output
message:Delete snapshot %s
[snapshot.rename]
command:/usr/local/opnsense/scripts/system/bectl.py
parameters: rename --from-source %s --beName %s
type:script_output
message:Rename snapshot %s to %s
[snapshot.supported]
command:/usr/local/opnsense/scripts/system/bectl.py
parameters: is_supported
type:script_output
message:Checking if ZFS is supported