1#!/usr/bin/env python3
2
3"""Build a composefs dump from a Json config
4
5See the man page of composefs-dump for details about the format:
6https://github.com/containers/composefs/blob/main/man/composefs-dump.md
7
8Ensure to check the file with the check script when you make changes to it:
9
10./check-build-composefs-dump.sh ./build-composefs_dump.py
11"""
12
13import glob
14import json
15import os
16import sys
17from enum import Enum
18from pathlib import Path
19from typing import Any
20
21Attrs = dict[str, Any]
22
23
24class FileType(Enum):
25 """The filetype as defined by the `st_mode` stat field in octal
26
27 You can check the st_mode stat field of a path in Python with
28 `oct(os.stat("/path/").st_mode)`
29 """
30
31 directory = "4"
32 file = "10"
33 symlink = "12"
34
35
36class ComposefsPath:
37 path: str
38 size: int
39 filetype: FileType
40 mode: str
41 uid: str
42 gid: str
43 payload: str
44 rdev: str = "0"
45 nlink: int = 1
46 mtime: str = "1.0"
47 content: str = "-"
48 digest: str = "-"
49
50 def __init__(
51 self,
52 attrs: Attrs,
53 size: int,
54 filetype: FileType,
55 mode: str,
56 payload: str,
57 path: str | None = None,
58 ):
59 if path is None:
60 path = attrs["target"]
61 self.path = path
62 self.size = size
63 self.filetype = filetype
64 self.mode = mode
65 self.uid = attrs["uid"]
66 self.gid = attrs["gid"]
67 self.payload = payload
68
69 def write_line(self) -> str:
70 line_list = [
71 str(self.path),
72 str(self.size),
73 f"{self.filetype.value}{self.mode}",
74 str(self.nlink),
75 str(self.uid),
76 str(self.gid),
77 str(self.rdev),
78 str(self.mtime),
79 str(self.payload),
80 str(self.content),
81 str(self.digest),
82 ]
83 return " ".join(line_list)
84
85
86def eprint(*args: Any, **kwargs: Any) -> None:
87 print(*args, **kwargs, file=sys.stderr)
88
89
90def normalize_path(path: str) -> str:
91 return str("/" + os.path.normpath(path).lstrip("/"))
92
93
94def leading_directories(path: str) -> list[str]:
95 """Return the leading directories of path
96
97 Given the path "alsa/conf.d/50-pipewire.conf", for example, this function
98 returns `[ "alsa", "alsa/conf.d" ]`.
99 """
100 parents = list(Path(path).parents)
101 parents.reverse()
102 # remove the implicit `.` from the start of a relative path or `/` from an
103 # absolute path
104 del parents[0]
105 return [str(i) for i in parents]
106
107
108def add_leading_directories(
109 target: str, attrs: Attrs, paths: dict[str, ComposefsPath]
110) -> None:
111 """Add the leading directories of a target path to the composefs paths
112
113 mkcomposefs expects that all leading directories are explicitly listed in
114 the dump file. Given the path "alsa/conf.d/50-pipewire.conf", for example,
115 this function adds "alsa" and "alsa/conf.d" to the composefs paths.
116 """
117 path_components = leading_directories(target)
118 for component in path_components:
119 composefs_path = ComposefsPath(
120 attrs,
121 path=component,
122 size=4096,
123 filetype=FileType.directory,
124 mode="0755",
125 payload="-",
126 )
127 paths[component] = composefs_path
128
129
130def main() -> None:
131 """Build a composefs dump from a Json config
132
133 This config describes the files that the final composefs image is supposed
134 to contain.
135 """
136 config_file = sys.argv[1]
137 if not config_file:
138 eprint("No config file was supplied.")
139 sys.exit(1)
140
141 with open(config_file, "rb") as f:
142 config = json.load(f)
143
144 if not config:
145 eprint("Config is empty.")
146 sys.exit(1)
147
148 eprint("Building composefs dump...")
149
150 paths: dict[str, ComposefsPath] = {}
151 for attrs in config:
152 # Normalize the target path to work around issues in how targets are
153 # declared in `environment.etc`.
154 attrs["target"] = normalize_path(attrs["target"])
155
156 target = attrs["target"]
157 source = attrs["source"]
158 mode = attrs["mode"]
159
160 if "*" in source: # Path with globbing
161 glob_sources = glob.glob(source)
162 for glob_source in glob_sources:
163 basename = os.path.basename(glob_source)
164 glob_target = f"{target}/{basename}"
165
166 composefs_path = ComposefsPath(
167 attrs,
168 path=glob_target,
169 size=100,
170 filetype=FileType.symlink,
171 mode="0777",
172 payload=glob_source,
173 )
174
175 paths[glob_target] = composefs_path
176 add_leading_directories(glob_target, attrs, paths)
177 else: # Without globbing
178 if mode == "symlink":
179 composefs_path = ComposefsPath(
180 attrs,
181 # A high approximation of the size of a symlink
182 size=100,
183 filetype=FileType.symlink,
184 mode="0777",
185 payload=source,
186 )
187 else:
188 if os.path.isdir(source):
189 composefs_path = ComposefsPath(
190 attrs,
191 size=4096,
192 filetype=FileType.directory,
193 mode=mode,
194 payload=source,
195 )
196 else:
197 composefs_path = ComposefsPath(
198 attrs,
199 size=os.stat(source).st_size,
200 filetype=FileType.file,
201 mode=mode,
202 # payload needs to be relative path in this case
203 payload=target.lstrip("/"),
204 )
205 paths[target] = composefs_path
206 add_leading_directories(target, attrs, paths)
207
208 composefs_dump = ["/ 4096 40755 1 0 0 0 0.0 - - -"] # Root directory
209 for key in sorted(paths):
210 composefs_path = paths[key]
211 eprint(composefs_path.path)
212 composefs_dump.append(composefs_path.write_line())
213
214 print("\n".join(composefs_dump))
215
216
217if __name__ == "__main__":
218 main()