Repository: systemd/mkosi

Issue:
Offer persist flag for `Drives=`
Currently the `Drives=` option is very similar to a scratch disk as they are removed after each run. It would be useful to add a new flag `persist`, which, when set, suppresses removal of the respective file. This flag should require that `file-id` is set for it to function.

This is useful when having an otherwise ephemeral setup but wanting to persist some other stuff across starts/stops that cannot be covered by runtime trees/virtiofsd.

Discussion:

Solution patch:
diff --git a/mkosi/config.py b/mkosi/config.py
index 002b3e41..7ebb3daa 100644
--- a/mkosi/config.py
+++ b/mkosi/config.py
@@ -156,6 +156,7 @@ class Drive:
     directory: Optional[Path]
     options: Optional[str]
     file_id: str
+    persist: bool
 
 
 # We use negative numbers for specifying special constants
@@ -1366,27 +1367,29 @@ def parse_profile(value: str) -> str:
 
 
 def parse_drive(value: str) -> Drive:
-    parts = value.split(":", maxsplit=4)
-    if not parts or not parts[0]:
+    parts = value.split(":")
+
+    if len(parts) > 6:
+        die(f"Too many components in drive '{value}")
+
+    if len(parts) < 1:
         die(f"No ID specified for drive '{value}'")
 
     if len(parts) < 2:
         die(f"Missing size in drive '{value}")
 
-    if len(parts) > 5:
-        die(f"Too many components in drive '{value}")
-
     id = parts[0]
     if not is_valid_filename(id):
         die(f"Unsupported path character in drive id '{id}'")
 
-    size = parse_bytes(parts[1])
-
-    directory = parse_path(parts[2]) if len(parts) > 2 and parts[2] else None
-    options = parts[3] if len(parts) > 3 and parts[3] else None
-    file_id = parts[4] if len(parts) > 4 and parts[4] else id
-
-    return Drive(id=id, size=size, directory=directory, options=options, file_id=file_id)
+    return Drive(
+        id=id,
+        size=parse_bytes(parts[1]),
+        directory=parse_path(p) if len(parts) > 2 and (p := parts[2]) else None,
+        options=p if len(parts) > 3 and (p := parts[3]) else None,
+        file_id=p if len(parts) > 4 and (p := parts[4]) else id,
+        persist=parse_boolean(p) if len(parts) > 5 and (p := parts[5]) else False,
+    )
 
 
 def config_parse_sector_size(value: Optional[str], old: Optional[int]) -> Optional[int]:
@@ -5288,6 +5291,7 @@ def json_type_transformer(refcls: Union[type[Args], type[Config]]) -> Callable[[
                     directory=Path(d["Directory"]) if d.get("Directory") else None,
                     options=d.get("Options"),
                     file_id=d.get("FileId", d["Id"]),
+                    persist=d.get("Persist", False),
                 )
             )
 
diff --git a/mkosi/qemu.py b/mkosi/qemu.py
index 1c2c7857..ab214417 100644
--- a/mkosi/qemu.py
+++ b/mkosi/qemu.py
@@ -24,7 +24,7 @@ import textwrap
 import uuid
 from collections.abc import Iterator, Sequence
 from pathlib import Path
-from typing import Optional
+from typing import IO, Optional
 
 from mkosi.config import (
     Args,
@@ -795,10 +795,18 @@ def apply_runtime_size(config: Config, image: Path) -> None:
 
 @contextlib.contextmanager
 def finalize_drive(config: Config, drive: Drive) -> Iterator[Path]:
-    with tempfile.NamedTemporaryFile(
-        dir=drive.directory or "/var/tmp",
-        prefix=f"mkosi-drive-{drive.id}",
-    ) as file:
+    with contextlib.ExitStack() as stack:
+        file: IO[bytes]
+        if drive.persist:
+            path = Path(drive.directory or "/var/tmp") / f"mkosi-drive-{drive.id}"
+            file = path.open("a+b")
+        else:
+            file = stack.enter_context(
+                tempfile.NamedTemporaryFile(
+                    dir=drive.directory or "/var/tmp",
+                    prefix=f"mkosi-drive-{drive.id}",
+                )
+            )
         maybe_make_nocow(Path(file.name))
         file.truncate(round_up(drive.size, resource.getpagesize()))
         yield Path(file.name)
diff --git a/mkosi/resources/man/mkosi.1.md b/mkosi/resources/man/mkosi.1.md
index 17230a24..d46d3a0a 100644
--- a/mkosi/resources/man/mkosi.1.md
+++ b/mkosi/resources/man/mkosi.1.md
@@ -1791,18 +1791,23 @@ boolean argument: either `1`, `yes`, or `true` to enable, or `0`, `no`,
 
 `Drives=`, `--drive=`
 :   Add a drive. Takes a colon-delimited string of format
-    `<id>:<size>[:<directory>[:<options>[:<file-id>]]]`. `id` specifies
+    `<id>:<size>[:<directory>[:<options>[:<file-id>[:<persist>]]]]`. `id` specifies
     the ID assigned to the drive. This can be used as the `drive=`
     property in various **qemu** devices. `size` specifies the size of the
     drive. This takes a size in bytes. Additionally, the suffixes `K`, `M`
     and `G` can be used to specify a size in kilobytes, megabytes and
     gigabytes respectively. `directory` optionally specifies the directory
-    in which to create the file backing the drive. `options` optionally
-    specifies extra comma-delimited properties which are passed verbatim
+    in which to create the file backing the drive. If unset, the file will be created under `/var/tmp`.
+    `options` optionally specifies extra comma-delimited properties which are passed verbatim
     to **qemu**'s `-blockdev` option. `file-id` specifies the ID of the file
-    backing the drive. Drives with the same file ID will share the
-    backing file. The directory and size of the file will be determined
-    from the first drive with a given file ID.
+    backing the drive. If unset, this defaults to the drive ID.
+    Drives with the same file ID will share the backing file.
+    The directory and size of the file will be determined from the first drive with a given file ID.
+    `persist` takes a boolean value and determines whether the drive will be persisted across **qemu** invocations.
+    Enabling persistence also prevents suffixing the filename with a random string.
+    The file backing the drive will always be available under `/<directory>/mkosi-drive-<file-id>`
+    You can skip values by setting them to the empty string, specifying e.g. `myfs:1G::::yes`
+    will create a persistent drive under `/var/tmp/mkosi-drive-myfs`.
 
     **Example usage:**
 


Test patch:
diff --git a/tests/test_json.py b/tests/test_json.py
index 1207459a..ff5d4bf4 100644
--- a/tests/test_json.py
+++ b/tests/test_json.py
@@ -156,6 +156,7 @@ def test_config() -> None:
                     "FileId": "red",
                     "Id": "abc",
                     "Options": "abc,qed",
+                    "Persist": false,
                     "Size": 200
                 },
                 {
@@ -163,6 +164,15 @@ def test_config() -> None:
                     "FileId": "wcd",
                     "Id": "abc",
                     "Options": "",
+                    "Persist": false,
+                    "Size": 200
+                },
+                {
+                    "Directory": null,
+                    "FileId": "bla",
+                    "Id": "abc",
+                    "Options": "",
+                    "Persist": true,
                     "Size": 200
                 }
             ],
@@ -470,7 +480,11 @@ def test_config() -> None:
         credentials={"credkey": "credval"},
         dependencies=["dep1"],
         distribution=Distribution.fedora,
-        drives=[Drive("abc", 200, Path("/foo/bar"), "abc,qed", "red"), Drive("abc", 200, None, "", "wcd")],
+        drives=[
+            Drive("abc", 200, Path("/foo/bar"), "abc,qed", "red", False),
+            Drive("abc", 200, None, "", "wcd", False),
+            Drive("abc", 200, None, "", "bla", True),
+        ],
         environment_files=[],
         environment={"foo": "foo", "BAR": "BAR", "Qux": "Qux"},
         ephemeral=True,