210 lines
5.7 KiB
Python
210 lines
5.7 KiB
Python
|
#!/usr/bin/env python3
|
||
|
|
||
|
"""Build a composefs dump from a Json config
|
||
|
|
||
|
See the man page of composefs-dump for details about the format:
|
||
|
https://github.com/containers/composefs/blob/main/man/composefs-dump.md
|
||
|
|
||
|
Ensure to check the file with the check script when you make changes to it:
|
||
|
|
||
|
./check-build-composefs-dump.sh ./build-composefs_dump.py
|
||
|
"""
|
||
|
|
||
|
import glob
|
||
|
import json
|
||
|
import os
|
||
|
import sys
|
||
|
from enum import Enum
|
||
|
from pathlib import Path
|
||
|
from typing import Any
|
||
|
|
||
|
Attrs = dict[str, Any]
|
||
|
|
||
|
|
||
|
class FileType(Enum):
|
||
|
"""The filetype as defined by the `st_mode` stat field in octal
|
||
|
|
||
|
You can check the st_mode stat field of a path in Python with
|
||
|
`oct(os.stat("/path/").st_mode)`
|
||
|
"""
|
||
|
|
||
|
directory = "4"
|
||
|
file = "10"
|
||
|
symlink = "12"
|
||
|
|
||
|
|
||
|
class ComposefsPath:
|
||
|
path: str
|
||
|
size: int
|
||
|
filetype: FileType
|
||
|
mode: str
|
||
|
uid: str
|
||
|
gid: str
|
||
|
payload: str
|
||
|
rdev: str = "0"
|
||
|
nlink: int = 1
|
||
|
mtime: str = "1.0"
|
||
|
content: str = "-"
|
||
|
digest: str = "-"
|
||
|
|
||
|
def __init__(
|
||
|
self,
|
||
|
attrs: Attrs,
|
||
|
size: int,
|
||
|
filetype: FileType,
|
||
|
mode: str,
|
||
|
payload: str,
|
||
|
path: str | None = None,
|
||
|
):
|
||
|
if path is None:
|
||
|
path = attrs["target"]
|
||
|
self.path = "/" + path
|
||
|
self.size = size
|
||
|
self.filetype = filetype
|
||
|
self.mode = mode
|
||
|
self.uid = attrs["uid"]
|
||
|
self.gid = attrs["gid"]
|
||
|
self.payload = payload
|
||
|
|
||
|
def write_line(self) -> str:
|
||
|
line_list = [
|
||
|
str(self.path),
|
||
|
str(self.size),
|
||
|
f"{self.filetype.value}{self.mode}",
|
||
|
str(self.nlink),
|
||
|
str(self.uid),
|
||
|
str(self.gid),
|
||
|
str(self.rdev),
|
||
|
str(self.mtime),
|
||
|
str(self.payload),
|
||
|
str(self.content),
|
||
|
str(self.digest),
|
||
|
]
|
||
|
return " ".join(line_list)
|
||
|
|
||
|
|
||
|
def eprint(*args, **kwargs) -> None:
|
||
|
print(args, **kwargs, file=sys.stderr)
|
||
|
|
||
|
|
||
|
def leading_directories(path: str) -> list[str]:
|
||
|
"""Return the leading directories of path
|
||
|
|
||
|
Given the path "alsa/conf.d/50-pipewire.conf", for example, this function
|
||
|
returns `[ "alsa", "alsa/conf.d" ]`.
|
||
|
"""
|
||
|
parents = list(Path(path).parents)
|
||
|
parents.reverse()
|
||
|
# remove the implicit `.` from the start of a relative path or `/` from an
|
||
|
# absolute path
|
||
|
del parents[0]
|
||
|
return [str(i) for i in parents]
|
||
|
|
||
|
|
||
|
def add_leading_directories(
|
||
|
target: str, attrs: Attrs, paths: dict[str, ComposefsPath]
|
||
|
) -> None:
|
||
|
"""Add the leading directories of a target path to the composefs paths
|
||
|
|
||
|
mkcomposefs expects that all leading directories are explicitly listed in
|
||
|
the dump file. Given the path "alsa/conf.d/50-pipewire.conf", for example,
|
||
|
this function adds "alsa" and "alsa/conf.d" to the composefs paths.
|
||
|
"""
|
||
|
path_components = leading_directories(target)
|
||
|
for component in path_components:
|
||
|
composefs_path = ComposefsPath(
|
||
|
attrs,
|
||
|
path=component,
|
||
|
size=4096,
|
||
|
filetype=FileType.directory,
|
||
|
mode="0755",
|
||
|
payload="-",
|
||
|
)
|
||
|
paths[component] = composefs_path
|
||
|
|
||
|
|
||
|
def main() -> None:
|
||
|
"""Build a composefs dump from a Json config
|
||
|
|
||
|
This config describes the files that the final composefs image is supposed
|
||
|
to contain.
|
||
|
"""
|
||
|
config_file = sys.argv[1]
|
||
|
if not config_file:
|
||
|
eprint("No config file was supplied.")
|
||
|
sys.exit(1)
|
||
|
|
||
|
with open(config_file, "rb") as f:
|
||
|
config = json.load(f)
|
||
|
|
||
|
if not config:
|
||
|
eprint("Config is empty.")
|
||
|
sys.exit(1)
|
||
|
|
||
|
eprint("Building composefs dump...")
|
||
|
|
||
|
paths: dict[str, ComposefsPath] = {}
|
||
|
for attrs in config:
|
||
|
target = attrs["target"]
|
||
|
source = attrs["source"]
|
||
|
mode = attrs["mode"]
|
||
|
|
||
|
if "*" in source: # Path with globbing
|
||
|
glob_sources = glob.glob(source)
|
||
|
for glob_source in glob_sources:
|
||
|
basename = os.path.basename(glob_source)
|
||
|
glob_target = f"{target}/{basename}"
|
||
|
|
||
|
composefs_path = ComposefsPath(
|
||
|
attrs,
|
||
|
path=glob_target,
|
||
|
size=100,
|
||
|
filetype=FileType.symlink,
|
||
|
mode="0777",
|
||
|
payload=glob_source,
|
||
|
)
|
||
|
|
||
|
paths[glob_target] = composefs_path
|
||
|
add_leading_directories(glob_target, attrs, paths)
|
||
|
else: # Without globbing
|
||
|
if mode == "symlink":
|
||
|
composefs_path = ComposefsPath(
|
||
|
attrs,
|
||
|
# A high approximation of the size of a symlink
|
||
|
size=100,
|
||
|
filetype=FileType.symlink,
|
||
|
mode="0777",
|
||
|
payload=source,
|
||
|
)
|
||
|
else:
|
||
|
if os.path.isdir(source):
|
||
|
composefs_path = ComposefsPath(
|
||
|
attrs,
|
||
|
size=4096,
|
||
|
filetype=FileType.directory,
|
||
|
mode=mode,
|
||
|
payload=source,
|
||
|
)
|
||
|
else:
|
||
|
composefs_path = ComposefsPath(
|
||
|
attrs,
|
||
|
size=os.stat(source).st_size,
|
||
|
filetype=FileType.file,
|
||
|
mode=mode,
|
||
|
payload=target,
|
||
|
)
|
||
|
paths[target] = composefs_path
|
||
|
add_leading_directories(target, attrs, paths)
|
||
|
|
||
|
composefs_dump = ["/ 4096 40755 1 0 0 0 0.0 - - -"] # Root directory
|
||
|
for key in sorted(paths):
|
||
|
composefs_path = paths[key]
|
||
|
eprint(composefs_path.path)
|
||
|
composefs_dump.append(composefs_path.write_line())
|
||
|
|
||
|
print("\n".join(composefs_dump))
|
||
|
|
||
|
|
||
|
if __name__ == "__main__":
|
||
|
main()
|