mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
Expose native storage retention for VM and CT backups
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const ts = require('typescript');
|
||||
const source = fs.readFileSync('AppImage/lib/backup-retention.ts', 'utf8');
|
||||
const context = { exports: {} };
|
||||
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, context);
|
||||
const { parseRetention, buildRetention } = context.exports;
|
||||
for (const policy of ['keep-all=1', 'keep-last=3', 'keep-last=3,keep-weekly=2', 'keep-hourly=24,keep-daily=7,keep-monthly=12,keep-yearly=2']) {
|
||||
const { fields, keepAll } = parseRetention(policy);
|
||||
assert.equal(buildRetention(fields, keepAll), policy);
|
||||
}
|
||||
assert.equal(buildRetention({}, false), '', 'empty policy remains explicit');
|
||||
assert.equal(buildRetention({}, true), 'keep-all=1');
|
||||
assert.equal(buildRetention({ daily: '3', hourly: '0', last: '' }, false), 'keep-daily=3');
|
||||
assert.equal(buildRetention({ last: '3' }, true), 'keep-last=3', 'existing job editor precedence preserved');
|
||||
const component = fs.readFileSync('AppImage/components/backup-storage-retention.tsx', 'utf8');
|
||||
const english = JSON.parse(fs.readFileSync('AppImage/messages/en/common.json')).backup.storageRetention;
|
||||
for (const [, key] of component.matchAll(/t\("backup\.storageRetention\.([A-Za-z]+)"/g)) {
|
||||
assert.ok(typeof english[key] === 'string' && english[key], `translation: ${key}`);
|
||||
}
|
||||
assert.ok(component.includes('digest: editing.digest'));
|
||||
assert.ok(component.includes('method: "PUT"'));
|
||||
assert.ok(!component.includes('/prune'));
|
||||
assert.ok(fs.readFileSync('AppImage/components/vm-backup-jobs.tsx', 'utf8').includes('from "../lib/backup-retention"'));
|
||||
assert.ok(fs.readFileSync('AppImage/components/backup-tab.tsx', 'utf8').includes('<BackupStorageRetention />'));
|
||||
console.log('PASS: shared native retention properties, keep-all/count round trips, explicit empty policy, translations and digest wiring');
|
||||
@@ -1,5 +1,6 @@
|
||||
"""VM/CT backup jobs API: what reaches pvesh, with pvesh itself replaced."""
|
||||
import importlib
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
@@ -23,6 +24,34 @@ GUESTS = [{"vmid": 100, "name": "web", "type": "qemu", "node": "pve1"},
|
||||
{"vmid": 200, "name": "other", "type": "qemu", "node": "pve2"}]
|
||||
|
||||
|
||||
@unittest.skipIf(Flask is None, "Flask runtime required")
|
||||
class StorageRetentionAuthorizationTests(unittest.TestCase):
|
||||
def test_real_authentication_guard_blocks_read_only_writes_before_pvesh(self):
|
||||
scripts = Path(__file__).resolve().parents[1] / "AppImage/scripts"
|
||||
auth = types.ModuleType("auth_manager")
|
||||
auth.load_auth_config = lambda: {"enabled": True}
|
||||
auth.verify_token = lambda token: "tester" if token in ("admin", "reader") else None
|
||||
auth.verify_token_full = lambda token: ("tester", "full_admin" if token == "admin" else "read_only")
|
||||
spec = importlib.util.spec_from_file_location("storage_retention_middleware", scripts / "jwt_middleware.py")
|
||||
middleware = importlib.util.module_from_spec(spec)
|
||||
with patch.dict(sys.modules, auth_manager=auth):
|
||||
spec.loader.exec_module(middleware)
|
||||
spec = importlib.util.spec_from_file_location("storage_retention_auth_routes", scripts / "flask_vm_backup_routes.py")
|
||||
routes = importlib.util.module_from_spec(spec)
|
||||
with patch.dict(sys.modules, jwt_middleware=middleware):
|
||||
spec.loader.exec_module(routes)
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(routes.vm_backup_bp)
|
||||
client = app.test_client()
|
||||
data = {"digest": "a" * 40, "prune_backups": "keep-last=3"}
|
||||
with patch.object(routes, "_pvesh") as run:
|
||||
self.assertEqual(client.get("/api/vm-backup-storages").status_code, 401)
|
||||
self.assertEqual(client.put("/api/vm-backup-storages/local", json=data).status_code, 401)
|
||||
self.assertEqual(client.put("/api/vm-backup-storages/local", json=data,
|
||||
headers={"Authorization": "Bearer reader"}).status_code, 403)
|
||||
run.assert_not_called()
|
||||
|
||||
|
||||
@unittest.skipIf(Flask is None, "Flask runtime required")
|
||||
class VmBackupJobsApiTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
@@ -73,9 +102,59 @@ class VmBackupJobsApiTests(unittest.TestCase):
|
||||
|
||||
def test_reading_needs_a_session_and_changing_needs_an_administrator(self):
|
||||
guards = dict((name, kind) for kind, name in self.guards)
|
||||
self.assertEqual({name for name, kind in guards.items() if kind == "auth"}, {"list_jobs", "options", "get_job"})
|
||||
self.assertEqual({name for name, kind in guards.items() if kind == "auth"}, {"list_jobs", "options", "get_job", "backup_storage_retention"})
|
||||
self.assertEqual({name for name, kind in guards.items() if kind == "admin"},
|
||||
{"create_job", "update_job", "delete_job", "toggle_job", "run_job"})
|
||||
{"create_job", "update_job", "delete_job", "toggle_job", "run_job", "update_backup_storage_retention"})
|
||||
|
||||
def test_storage_retention_lists_only_backup_storages_and_no_secrets(self):
|
||||
with patch.object(self.routes, "_pvesh_json", return_value=(True, [
|
||||
{"storage": "local", "type": "dir", "content": "backup,iso", "digest": "a" * 40,
|
||||
"prune-backups": {"keep-last": 3}, "password": "private"},
|
||||
{"storage": "images", "type": "dir", "content": "images", "digest": "a" * 40},
|
||||
])):
|
||||
answer = self.client.get("/api/vm-backup-storages").get_json()
|
||||
self.assertEqual(answer, {"storages": [{"id": "local", "type": "dir", "digest": "a" * 40, "prune_backups": "keep-last=3"}]})
|
||||
|
||||
def storage_policy(self, prune="keep-last=3,keep-weekly=2", digest="a" * 40, current=None):
|
||||
if current is None:
|
||||
current = {"storage": "local", "content": "backup,iso", "digest": "a" * 40}
|
||||
with patch.object(self.routes, "_pvesh_json", return_value=(True, current)), self.pvesh():
|
||||
return self.client.put("/api/vm-backup-storages/local", json={"digest": digest, "prune_backups": prune})
|
||||
|
||||
def test_saving_native_storage_retention_never_runs_pruning_or_changes_other_properties(self):
|
||||
response = self.storage_policy()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(self.changes(), [["set", "/storage/local", "--digest", "a" * 40,
|
||||
"--prune-backups", "keep-last=3,keep-weekly=2"]])
|
||||
self.calls.clear()
|
||||
self.assertEqual(self.storage_policy("keep-all=1").status_code, 200)
|
||||
self.assertEqual(self.changes()[0][-1], "keep-all=1")
|
||||
|
||||
def test_invalid_storage_retention_is_rejected_before_commands(self):
|
||||
invalid = [None, [], {}, {"digest": "a" * 40, "prune_backups": []}]
|
||||
for prune in ("", "keep-last=-1", "keep-all=0", "keep-all=2", "keep-last=0", "keep-last=1,keep-last=2",
|
||||
"keep-all=1,keep-last=2", "keep-last=2 --delete path", "keep-last=1\n", "keep-other=1"):
|
||||
invalid.append({"digest": "a" * 40, "prune_backups": prune})
|
||||
invalid += [{"digest": "invalid", "prune_backups": "keep-last=1"}]
|
||||
with patch.object(self.routes, "_pvesh") as run:
|
||||
for data in invalid:
|
||||
with self.subTest(data=data):
|
||||
self.assertEqual(self.client.put("/api/vm-backup-storages/local", json=data).status_code, 400)
|
||||
self.assertEqual(self.client.put("/api/vm-backup-storages/-bad", json=invalid[-1]).status_code, 400)
|
||||
run.assert_not_called()
|
||||
|
||||
def test_stale_storage_configuration_or_non_backup_storage_is_not_overwritten(self):
|
||||
for current in ({"content": "backup", "digest": "b" * 40},
|
||||
{"content": "images", "digest": "a" * 40}):
|
||||
self.assertEqual(self.storage_policy(current=current).status_code, 409)
|
||||
self.assertEqual(self.changes(), [])
|
||||
|
||||
def test_native_storage_update_failure_is_visible(self):
|
||||
with patch.object(self.routes, "_pvesh_json", return_value=(True, {"content": "backup", "digest": "a" * 40})), \
|
||||
patch.object(self.routes, "_pvesh", return_value=(False, "configuration changed under lock")):
|
||||
response = self.client.put("/api/vm-backup-storages/local", json={"digest": "a" * 40, "prune_backups": "keep-last=3"})
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertIn("changed", response.get_json()["error"])
|
||||
|
||||
def test_the_list_gives_text_properties_and_the_host_backups_that_hang_from_a_job(self):
|
||||
with self.pvesh(), patch.object(self.routes, "_host_backups_attached",
|
||||
|
||||
Reference in New Issue
Block a user