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"}