From 49987288d4a892286f0e9e72c075b36e5eff4c79 Mon Sep 17 00:00:00 2001 From: Tom Lehmann Date: Mon, 10 Aug 2026 17:24:04 +0200 Subject: [PATCH] Route the LINBO image management operations LinboImageManager already implements list, delete, rename, duplicate, restore and save_extras, and linuxmusterTools covers them with tests. The API routed none of them, so an image could be uploaded and downloaded but never renamed, copied, rolled back or deleted. Nine endpoints under /v1/linbo/images, global-administrator only like the rest of the router. Four points needed handling in the router rather than the library: - The manager returns silently when a group is unknown, so each endpoint resolves the group first and answers 404 itself. - LinboImageGroup.load() returns early for an image whose .info cannot be read, leaving base as None and diff_image unassigned. Such a group is refused with 409 and the recorded reason; it stays visible in the listing, flagged, which is where it gets noticed. - Backups are keyed internally by a display date, while save_extras takes a raw timestamp. The URLs take the raw YYYYMMDDhhmm form throughout and convert where the manager expects the other. - NameChecker.check returns a bool instead of raising, so its result is tested explicitly rather than relied on through an except branch. info is required in the sidecar body: save_extras deletes whatever the body omits, and an image without .info can no longer be read by the manifest, by LINBO or by this endpoint. --- .../pytests/test_linbo_image_management.py | 415 ++++++++++++++++++ .../linuxmusterApi/routers_v1/body_schemas.py | 31 ++ .../linuxmusterApi/routers_v1/linbo.py | 336 ++++++++++++++ 3 files changed, 782 insertions(+) create mode 100644 usr/lib/python3/dist-packages/linuxmusterApi/pytests/test_linbo_image_management.py diff --git a/usr/lib/python3/dist-packages/linuxmusterApi/pytests/test_linbo_image_management.py b/usr/lib/python3/dist-packages/linuxmusterApi/pytests/test_linbo_image_management.py new file mode 100644 index 0000000..2c801ee --- /dev/null +++ b/usr/lib/python3/dist-packages/linuxmusterApi/pytests/test_linbo_image_management.py @@ -0,0 +1,415 @@ +from unittest.mock import Mock + +import pytest +from fastapi import HTTPException +from pydantic import ValidationError + +from routers_v1 import linbo +from routers_v1.body_schemas import LinboImageExtrasBody, LinboImageNameBody + + +BACKUP_TIMESTAMP = "202601271107" +BACKUP_DATE = "27/01/2026 11:07" + + +class FakeImageGroup: + """ + Stands in for LinboImageGroup with real attributes rather than a Mock, + because a Mock invents any attribute that is asked of it and would hide the + case below: LinboImageGroup.load() returns early when an image's .info + cannot be read, leaving base None, error set — and diff_image never + assigned at all. + """ + + def __init__(self, name="win11", backups=None, diff=True, usable=True): + self.name = name + self.backups = backups if backups is not None else {} + if usable: + self.base = object() + self.error = None + self.diff_image = object() if diff else None + else: + self.base = None + self.error = "missing .info" + + def to_dict(self): + if self.base is None: + return {"name": self.name, "error": self.error} + return {"name": self.name} + + +def make_group(name="win11", backups=None, diff=True, usable=True): + return FakeImageGroup(name=name, backups=backups, diff=diff, usable=usable) + + +@pytest.fixture +def manager(monkeypatch): + """ + Patch the manager class so the endpoints operate on groups we control, + and accept every name so the tests that are not about validation are not + coupled to NameChecker's pattern. + """ + + instance = Mock() + instance.groups = {} + monkeypatch.setattr(linbo, "LinboImageManager", Mock(return_value=instance)) + + checker = Mock() + checker.check_linbo_image_name.return_value = True + monkeypatch.setattr(linbo, "name_checker", checker) + + monkeypatch.setattr(linbo, "timestamp2date", lambda ts: BACKUP_DATE) + return instance + + +def test_list_images_reports_every_group(manager): + manager.groups = {"win11": make_group("win11"), "ubuntu": make_group("ubuntu")} + + result = linbo.list_images(None) + + assert result["total"] == 2 + assert {image["name"] for image in result["images"]} == {"win11", "ubuntu"} + + +def test_delete_image_delegates_to_the_manager(manager): + manager.groups = {"win11": make_group()} + + result = linbo.delete_image("win11", None) + + manager.delete.assert_called_once_with("win11") + assert result == {"image": "win11", "status": "deleted"} + + +def test_delete_image_404s_on_an_unknown_image(manager): + manager.groups = {} + + with pytest.raises(HTTPException) as error: + linbo.delete_image("nope", None) + + assert error.value.status_code == 404 + manager.delete.assert_not_called() + + +def test_delete_image_400s_on_an_invalid_name(manager, monkeypatch): + checker = Mock() + checker.check_linbo_image_name.return_value = False + monkeypatch.setattr(linbo, "name_checker", checker) + manager.groups = {"win11": make_group()} + + with pytest.raises(HTTPException) as error: + linbo.delete_image("../../etc", None) + + assert error.value.status_code == 400 + manager.delete.assert_not_called() + + +def test_delete_diff_404s_when_there_is_no_differential_image(manager): + manager.groups = {"win11": make_group(diff=False)} + + with pytest.raises(HTTPException) as error: + linbo.delete_image_diff("win11", None) + + assert error.value.status_code == 404 + manager.delete.assert_not_called() + + +def test_delete_diff_passes_the_diff_flag(manager): + manager.groups = {"win11": make_group(diff=True)} + + linbo.delete_image_diff("win11", None) + + manager.delete.assert_called_once_with("win11", diff=True) + + +def test_delete_backup_converts_the_timestamp_to_the_display_date(manager): + manager.groups = {"win11": make_group(backups={BACKUP_DATE: Mock()})} + + result = linbo.delete_image_backup("win11", BACKUP_TIMESTAMP, None) + + manager.delete.assert_called_once_with("win11", date=BACKUP_DATE) + assert result["backup"] == BACKUP_TIMESTAMP + + +def test_delete_backup_404s_on_an_unknown_backup(manager): + manager.groups = {"win11": make_group(backups={})} + + with pytest.raises(HTTPException) as error: + linbo.delete_image_backup("win11", BACKUP_TIMESTAMP, None) + + assert error.value.status_code == 404 + manager.delete.assert_not_called() + + +def test_delete_backup_400s_on_a_malformed_timestamp(manager, monkeypatch): + def reject(timestamp): + raise ValueError("bad timestamp") + + monkeypatch.setattr(linbo, "timestamp2date", reject) + manager.groups = {"win11": make_group(backups={BACKUP_DATE: Mock()})} + + with pytest.raises(HTTPException) as error: + linbo.delete_image_backup("win11", "not-a-timestamp", None) + + assert error.value.status_code == 400 + manager.delete.assert_not_called() + + +def test_restore_backup_delegates_to_the_manager(manager): + manager.groups = {"win11": make_group(backups={BACKUP_DATE: Mock()})} + + result = linbo.restore_image_backup("win11", BACKUP_TIMESTAMP, None) + + manager.restore.assert_called_once_with("win11", BACKUP_DATE) + assert result["status"] == "restored" + + +def test_rename_delegates_to_the_manager(manager): + manager.groups = {"win11": make_group()} + + result = linbo.rename_image("win11", LinboImageNameBody(new_name="win11-2026"), None) + + manager.rename.assert_called_once_with("win11", "win11-2026") + assert result == {"image": "win11-2026", "previousName": "win11", "status": "renamed"} + + +def test_rename_409s_when_the_target_name_is_taken(manager): + manager.groups = {"win11": make_group(), "ubuntu": make_group("ubuntu")} + + with pytest.raises(HTTPException) as error: + linbo.rename_image("win11", LinboImageNameBody(new_name="ubuntu"), None) + + assert error.value.status_code == 409 + manager.rename.assert_not_called() + + +def test_rename_400s_on_an_invalid_target_name(manager, monkeypatch): + checker = Mock() + checker.check_linbo_image_name.side_effect = [True, False] + monkeypatch.setattr(linbo, "name_checker", checker) + manager.groups = {"win11": make_group()} + + with pytest.raises(HTTPException) as error: + linbo.rename_image("win11", LinboImageNameBody(new_name="../evil"), None) + + assert error.value.status_code == 400 + manager.rename.assert_not_called() + + +def test_duplicate_delegates_to_the_manager(manager): + manager.groups = {"win11": make_group()} + + result = linbo.duplicate_image("win11", LinboImageNameBody(new_name="win11-copy"), None) + + manager.duplicate.assert_called_once_with("win11", "win11-copy") + assert result["sourceImage"] == "win11" + + +def test_duplicate_maps_image_exists_error_to_409(manager): + manager.groups = {"win11": make_group()} + manager.duplicate.side_effect = linbo.ImageExistsError("/srv/linbo/images/win11-copy") + + with pytest.raises(HTTPException) as error: + linbo.duplicate_image("win11", LinboImageNameBody(new_name="win11-copy"), None) + + assert error.value.status_code == 409 + + +def test_operations_map_runtime_error_to_409(manager): + manager.groups = {"win11": make_group()} + manager.delete.side_effect = RuntimeError("Cannot delete image group win11: missing .info") + + with pytest.raises(HTTPException) as error: + linbo.delete_image("win11", None) + + assert error.value.status_code == 409 + + +def test_operations_map_an_unreadable_info_to_500(manager): + """ + rename and duplicate re-read the .info they rewrote; IncompleteImageInfoError + is a ValueError and would otherwise escape every handler as an opaque 500. + """ + + manager.groups = {"win11": make_group()} + manager.rename.side_effect = linbo.IncompleteImageInfoError( + "/srv/linbo/images/win11/win11.qcow2.info", ["timestamp"] + ) + + with pytest.raises(HTTPException) as error: + linbo.rename_image("win11", LinboImageNameBody(new_name="win12"), None) + + assert error.value.status_code == 500 + assert "no longer readable" in error.value.detail + + +def test_operations_map_os_error_to_500(manager): + manager.groups = {"win11": make_group()} + manager.delete.side_effect = OSError("disk on fire") + + with pytest.raises(HTTPException) as error: + linbo.delete_image("win11", None) + + assert error.value.status_code == 500 + + +def test_save_extras_forwards_the_body_and_targets_the_base_image(manager): + manager.groups = {"win11": make_group()} + + result = linbo.save_image_extras( + "win11", + LinboImageExtrasBody(info="[OS]\ntimestamp=202601271107", desc="Windows 11", reg="[HKLM]"), + None, + False, + None, + ) + + manager.save_extras.assert_called_once() + image, data = manager.save_extras.call_args.args + assert image == "win11" + assert data["desc"] == "Windows 11" + assert data["reg"] == "[HKLM]" + assert data["postsync"] is None + assert manager.save_extras.call_args.kwargs == {"timestamp": None, "diff": False} + assert result["status"] == "saved" + + +def test_save_extras_passes_the_raw_timestamp_not_the_display_date(manager): + manager.groups = {"win11": make_group(backups={BACKUP_DATE: Mock()})} + + linbo.save_image_extras( + "win11", + LinboImageExtrasBody(info="[OS]", desc="a backup"), + BACKUP_TIMESTAMP, + False, + None, + ) + + assert manager.save_extras.call_args.kwargs["timestamp"] == BACKUP_TIMESTAMP + + +def test_save_extras_rejects_timestamp_and_diff_together(manager): + manager.groups = {"win11": make_group()} + + with pytest.raises(HTTPException) as error: + linbo.save_image_extras( + "win11", + LinboImageExtrasBody(info="[OS]", desc="x"), + BACKUP_TIMESTAMP, + True, + None, + ) + + assert error.value.status_code == 400 + manager.save_extras.assert_not_called() + + +def test_save_extras_404s_for_a_diff_that_does_not_exist(manager): + manager.groups = {"win11": make_group(diff=False)} + + with pytest.raises(HTTPException) as error: + linbo.save_image_extras("win11", LinboImageExtrasBody(info="[OS]", desc="x"), None, True, None) + + assert error.value.status_code == 404 + manager.save_extras.assert_not_called() + + +def test_list_backups_keys_by_raw_timestamp(manager): + backup = Mock() + backup.timestamp = BACKUP_TIMESTAMP + backup.to_dict.return_value = {"name": "win11", "timestamp": BACKUP_TIMESTAMP} + manager.groups = {"win11": make_group(backups={BACKUP_DATE: backup})} + + result = linbo.list_image_backups("win11", None) + + assert result["total"] == 1 + assert BACKUP_TIMESTAMP in result["backups"] + + +def test_extras_body_refuses_to_drop_the_info_sidecar(): + """ + save_extras deletes any sidecar the body leaves out, and LinboImage.load_info + raises IncompleteImageInfoError without .info — so a body without info would + make the image unreadable rather than just edit it. + """ + + with pytest.raises(ValidationError): + LinboImageExtrasBody(desc="Windows 11") + + +# An image whose .info cannot be read stays in the listing, flagged, but no +# operation on it can succeed — LinboImageGroup.load() leaves base as None and +# never assigns diff_image. Each of these would otherwise be an opaque 500. + + +@pytest.mark.parametrize( + "call", + [ + pytest.param(lambda: linbo.delete_image("win11", None), id="delete"), + pytest.param(lambda: linbo.delete_image_diff("win11", None), id="delete-diff"), + pytest.param(lambda: linbo.list_image_backups("win11", None), id="list-backups"), + pytest.param( + lambda: linbo.rename_image("win11", LinboImageNameBody(new_name="win12"), None), + id="rename", + ), + pytest.param( + lambda: linbo.save_image_extras( + "win11", LinboImageExtrasBody(info="[OS]"), None, True, None + ), + id="extras-diff", + ), + ], +) +def test_operations_on_an_unusable_image_409_with_the_reason(manager, call): + manager.groups = {"win11": make_group(usable=False)} + + with pytest.raises(HTTPException) as error: + call() + + assert error.value.status_code == 409 + assert "missing .info" in error.value.detail + + +def test_an_unusable_image_still_appears_in_the_listing(manager): + manager.groups = {"win11": make_group(usable=False)} + + result = linbo.list_images(None) + + assert result["images"] == [{"name": "win11", "error": "missing .info"}] + + +# The image management paths share the /images prefix with manifest, download +# and upload. Segment count and literal suffixes keep almost all of them +# disjoint, so registration order does not decide them — with one exception: +# DELETE /images/upload/diff matches both cancel_upload_endpoint (an image +# named "diff") and delete_image_diff (an image named "upload"). Registration +# order settles it in favour of the older route, and the last case below locks +# that in. Asserting on the resolved endpoint rather than on a position in a +# path list keeps the whole check method-aware. + +ROUTE_CASES = [ + ("GET", "/linbo/images", "list_images"), + ("GET", "/linbo/images/manifest", "get_image_manifest"), + ("GET", "/linbo/images/win11/backups", "list_image_backups"), + ("GET", "/linbo/images/download/win11/win11.qcow2", "download_image_file"), + ("GET", "/linbo/images/upload/win11/win11.qcow2/status", "upload_status_endpoint"), + ("DELETE", "/linbo/images/win11", "delete_image"), + ("DELETE", "/linbo/images/win11/diff", "delete_image_diff"), + ("DELETE", "/linbo/images/upload/win11", "cancel_upload_endpoint"), + ("DELETE", "/linbo/images/win11/backups/202601271107", "delete_image_backup"), + ("POST", "/linbo/images/win11/rename", "rename_image"), + ("POST", "/linbo/images/win11/duplicate", "duplicate_image"), + ("POST", "/linbo/images/win11/backups/202601271107/restore", "restore_image_backup"), + ("POST", "/linbo/images/upload/win11/complete", "finalize_upload_endpoint"), + ("PUT", "/linbo/images/win11/extras", "save_image_extras"), + ("PUT", "/linbo/images/upload/win11/win11.qcow2", "upload_image_file"), + ("DELETE", "/linbo/images/upload/diff", "cancel_upload_endpoint"), +] + + +@pytest.mark.parametrize("method,path,expected", ROUTE_CASES) +def test_image_paths_resolve_to_the_intended_endpoint(method, path, expected): + for route in linbo.router.routes: + if method in route.methods and route.path_regex.match(path): + assert route.endpoint.__name__ == expected + return + pytest.fail(f"{method} {path} matches no route") diff --git a/usr/lib/python3/dist-packages/linuxmusterApi/routers_v1/body_schemas.py b/usr/lib/python3/dist-packages/linuxmusterApi/routers_v1/body_schemas.py index d26118f..b721906 100644 --- a/usr/lib/python3/dist-packages/linuxmusterApi/routers_v1/body_schemas.py +++ b/usr/lib/python3/dist-packages/linuxmusterApi/routers_v1/body_schemas.py @@ -293,6 +293,37 @@ class StartConfRawBody(BaseModel): content: str +class LinboImageNameBody(BaseModel): + """ + Target name for a rename or duplicate, validated as a linbo image name by + the endpoint before it reaches LinboImageManager. + """ + + + new_name: str + +class LinboImageExtrasBody(BaseModel): + """ + Content of an image's sidecar files, as LinboImage.save_extras expects it. + + A field left out or set to null deletes that sidecar — that is + save_extras' own semantics, not something the endpoint adds. desc is the + exception: an empty string writes an empty file. + + info is required for exactly that reason. LinboImage.load_info parses it + for every image that is not a backup and raises IncompleteImageInfoError + without it, so letting a request omit it would leave the image unreadable + for the manifest, for LINBO and for any later call to this endpoint. + """ + + + info: str + desc: str | None = None + vdi: str | None = None + reg: str | None = None + postsync: str | None = None + prestart: str | None = None + class LinboRemoteRunBody(BaseModel): """ Parameters to build and run a linbo-remote command via LinboRemote. diff --git a/usr/lib/python3/dist-packages/linuxmusterApi/routers_v1/linbo.py b/usr/lib/python3/dist-packages/linuxmusterApi/routers_v1/linbo.py index ec8482f..3f07f70 100644 --- a/usr/lib/python3/dist-packages/linuxmusterApi/routers_v1/linbo.py +++ b/usr/lib/python3/dist-packages/linuxmusterApi/routers_v1/linbo.py @@ -19,6 +19,8 @@ from .body_schemas import ( LinboBatchMacs, LinboHostScanBody, + LinboImageExtrasBody, + LinboImageNameBody, LinboWolBody, StartConfRawBody, ) @@ -856,3 +858,337 @@ def cancel_upload_endpoint( return cancel_upload(IMAGES_DIR, image_name) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) + + +# ── Image Management ─────────────────────────────────────────────── + +# LinboImageManager silently does nothing when a group is unknown, so every +# endpoint below resolves the group first and 404s itself. NameChecker.check +# returns a bool rather than raising, so its result is tested explicitly. + + +def _require_image_group(manager, image_name): + if not name_checker.check_linbo_image_name(image_name): + raise HTTPException(status_code=400, detail=f"Invalid image name: {image_name}") + + group = manager.groups.get(image_name) + if group is None: + raise HTTPException(status_code=404, detail=f"No image named {image_name}") + + if group.base is None: + # LinboImageGroup.load() stops at an unreadable .info: it records the + # reason in error and returns before assigning diff_image at all. No + # operation can succeed on such a group, so it is refused here with the + # reason instead of failing later on the missing attribute. The listing + # still reports the group, flagged, which is where it gets noticed. + raise HTTPException(status_code=409, detail=f"Image {image_name} is not usable: {group.error}") + + return group + + +def _require_new_image_name(manager, new_name): + if not name_checker.check_linbo_image_name(new_name): + raise HTTPException(status_code=400, detail=f"Invalid image name: {new_name}") + + if new_name in manager.groups or Path(LINBO_PATH, new_name).exists(): + raise HTTPException(status_code=409, detail=f"An image named {new_name} already exists") + + return new_name + + +def _require_backup_date(group, timestamp): + """ + Resolve a %Y%m%d%H%M path segment to the display date LinboImageGroup keys + its backups by. The manager's delete/restore take that display form, while + save_extras takes the raw timestamp — the endpoints below always take the + raw timestamp and convert here, so the URL shape stays uniform. + """ + + try: + date = timestamp2date(timestamp) + except ValueError as error: + raise HTTPException( + status_code=400, + detail=f"Invalid backup timestamp {timestamp}, expected YYYYMMDDhhmm", + ) from error + + if date not in group.backups: + raise HTTPException(status_code=404, detail=f"No backup {timestamp} for image {group.name}") + + return date + + +def _run_image_operation(operation): + """ + Map the library's failure modes onto status codes. RuntimeError is what + LinboImageGroup raises for a group whose .info is missing or incomplete: + the image exists but cannot be operated on. + """ + + try: + return operation() + except ImageExistsError as error: + raise HTTPException(status_code=409, detail=str(error)) from error + except RuntimeError as error: + raise HTTPException(status_code=409, detail=str(error)) from error + except IncompleteImageInfoError as error: + # rename and duplicate re-read the .info they just rewrote. If that + # read fails the operation has already half-happened, so this is a + # server-side inconsistency to report, not a bad request to reject. + logger.error("LINBO image left unreadable after an operation: %r", error) + raise HTTPException(status_code=500, detail=f"Image is no longer readable: {error}") from error + except OSError as error: + logger.error("LINBO image operation failed: %r", error) + raise HTTPException(status_code=500, detail=f"Image operation failed: {error}") from error + + +@router.get("/images", name="List LINBO images with backups and sidecars") +def list_images( + who: AuthenticatedUser = Depends(RoleChecker("G")), +): + """ + ## List every LINBO image with its sidecars, backups and differential image. + + `/images/manifest` reports what the sync clients need. This reports what an + image management UI needs: the `reg`, `postsync` and `prestart` contents and + the backup list, which the manifest leaves out. + + ### Access + - global-administrators + + \f + """ + + + manager = LinboImageManager() + images = [group.to_dict() for group in manager.groups.values()] + return {"images": images, "total": len(images)} + + +@router.get("/images/{image_name}/backups", name="List an image's backups") +def list_image_backups( + image_name: str, + who: AuthenticatedUser = Depends(RoleChecker("G")), +): + """ + ## List the backups of one LINBO image. + + Keys are the `YYYYMMDDhhmm` timestamps the other backup endpoints take. + + ### Access + - global-administrators + + \f + :param image_name: Name of the LINBO image + """ + + + group = _require_image_group(LinboImageManager(), image_name) + backups = { + backup.timestamp: backup.to_dict() + for backup in group.backups.values() + } + return {"image": image_name, "backups": backups, "total": len(backups)} + + +@router.delete("/images/{image_name}", name="Delete a LINBO image") +def delete_image( + image_name: str, + who: AuthenticatedUser = Depends(RoleChecker("G")), +): + """ + ## Delete a LINBO image with its backups, differential image and sidecars. + + ### Access + - global-administrators + + \f + :param image_name: Name of the LINBO image + """ + + + manager = LinboImageManager() + _require_image_group(manager, image_name) + + _run_image_operation(lambda: manager.delete(image_name)) + return {"image": image_name, "status": "deleted"} + + +@router.delete("/images/{image_name}/diff", name="Delete an image's differential image") +def delete_image_diff( + image_name: str, + who: AuthenticatedUser = Depends(RoleChecker("G")), +): + """ + ## Delete only the differential image of a LINBO image. + + ### Access + - global-administrators + + \f + :param image_name: Name of the LINBO image + """ + + + manager = LinboImageManager() + group = _require_image_group(manager, image_name) + + if group.diff_image is None: + raise HTTPException(status_code=404, detail=f"Image {image_name} has no differential image") + + _run_image_operation(lambda: manager.delete(image_name, diff=True)) + return {"image": image_name, "status": "diff-deleted"} + + +@router.delete("/images/{image_name}/backups/{timestamp}", name="Delete one backup of an image") +def delete_image_backup( + image_name: str, + timestamp: str, + who: AuthenticatedUser = Depends(RoleChecker("G")), +): + """ + ## Delete a single backup of a LINBO image. + + ### Access + - global-administrators + + \f + :param image_name: Name of the LINBO image + :param timestamp: Backup timestamp, `YYYYMMDDhhmm` + """ + + + manager = LinboImageManager() + group = _require_image_group(manager, image_name) + date = _require_backup_date(group, timestamp) + + _run_image_operation(lambda: manager.delete(image_name, date=date)) + return {"image": image_name, "backup": timestamp, "status": "deleted"} + + +@router.post("/images/{image_name}/backups/{timestamp}/restore", name="Restore a backup of an image") +def restore_image_backup( + image_name: str, + timestamp: str, + who: AuthenticatedUser = Depends(RoleChecker("G")), +): + """ + ## Restore a backup over the base image. + + The base image is moved to a new backup first, so the operation is + reversible. + + ### Access + - global-administrators + + \f + :param image_name: Name of the LINBO image + :param timestamp: Backup timestamp to restore, `YYYYMMDDhhmm` + """ + + + manager = LinboImageManager() + group = _require_image_group(manager, image_name) + date = _require_backup_date(group, timestamp) + + _run_image_operation(lambda: manager.restore(image_name, date)) + return {"image": image_name, "backup": timestamp, "status": "restored"} + + +@router.post("/images/{image_name}/rename", name="Rename a LINBO image") +def rename_image( + image_name: str, + body: LinboImageNameBody, + who: AuthenticatedUser = Depends(RoleChecker("G")), +): + """ + ## Rename a LINBO image with its backups, differential image and sidecars. + + ### Access + - global-administrators + + \f + :param image_name: Current name of the LINBO image + :param body: New name + """ + + + manager = LinboImageManager() + _require_image_group(manager, image_name) + new_name = _require_new_image_name(manager, body.new_name) + + _run_image_operation(lambda: manager.rename(image_name, new_name)) + return {"image": new_name, "previousName": image_name, "status": "renamed"} + + +@router.post("/images/{image_name}/duplicate", name="Duplicate a LINBO image") +def duplicate_image( + image_name: str, + body: LinboImageNameBody, + who: AuthenticatedUser = Depends(RoleChecker("G")), +): + """ + ## Copy a LINBO image under a new name, without its backups. + + ### Access + - global-administrators + + \f + :param image_name: Name of the LINBO image to copy + :param body: Name for the copy + """ + + + manager = LinboImageManager() + _require_image_group(manager, image_name) + new_name = _require_new_image_name(manager, body.new_name) + + _run_image_operation(lambda: manager.duplicate(image_name, new_name)) + return {"image": new_name, "sourceImage": image_name, "status": "duplicated"} + + +@router.put("/images/{image_name}/extras", name="Write an image's sidecar files") +def save_image_extras( + image_name: str, + body: LinboImageExtrasBody, + timestamp: str | None = Query( + None, + description="Write the sidecars of this backup instead of the base image", + ), + diff: bool = Query(False, description="Write the sidecars of the differential image"), + who: AuthenticatedUser = Depends(RoleChecker("G")), +): + """ + ## Write the `info`, `desc`, `vdi`, `reg`, `postsync` and `prestart` sidecars. + + A field left out of the body deletes that sidecar, which is why `info` is + required — an image without it cannot be read back. `timestamp` and `diff` + are mutually exclusive. + + ### Access + - global-administrators + + \f + :param image_name: Name of the LINBO image + :param body: Sidecar contents + :param timestamp: Backup timestamp, `YYYYMMDDhhmm` + :param diff: Target the differential image + """ + + + if timestamp and diff: + raise HTTPException(status_code=400, detail="timestamp and diff are mutually exclusive") + + manager = LinboImageManager() + group = _require_image_group(manager, image_name) + + if timestamp: + _require_backup_date(group, timestamp) + + if diff and group.diff_image is None: + raise HTTPException(status_code=404, detail=f"Image {image_name} has no differential image") + + _run_image_operation( + lambda: manager.save_extras(image_name, body.model_dump(), timestamp=timestamp, diff=diff) + ) + return {"image": image_name, "status": "saved"}