aetherion 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aetherion/__init__.py +3 -0
- aetherion/__main__.py +10 -0
- aetherion/cli.py +410 -0
- aetherion/data/Dockerfile +656 -0
- aetherion/data/scripts/install-treesitter.lua +35 -0
- aetherion/data/skeleton/etc/apt/apt.conf.d/99-aetherion-minimal +4 -0
- aetherion/data/skeleton/etc/containers/containers.conf +5 -0
- aetherion/data/skeleton/etc/containers/storage.conf +5 -0
- aetherion/data/skeleton/home/aetherion/.bashrc +37 -0
- aetherion/data/skeleton/home/aetherion/.config/nvim/.gitignore +1 -0
- aetherion/data/skeleton/home/aetherion/.config/nvim/README.md +216 -0
- aetherion/data/skeleton/home/aetherion/.config/nvim/ascii_header.txt +7 -0
- aetherion/data/skeleton/home/aetherion/.config/nvim/init.lua +54 -0
- aetherion/data/skeleton/home/aetherion/.config/nvim/lazy-lock.json +35 -0
- aetherion/data/skeleton/home/aetherion/.config/nvim/lua/commands/git.lua +14 -0
- aetherion/data/skeleton/home/aetherion/.config/nvim/lua/options.lua +338 -0
- aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/alpha.lua +247 -0
- aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/catppuccin.lua +8 -0
- aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/cinnamon.lua +14 -0
- aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/debug.lua +185 -0
- aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/git-utils.lua +109 -0
- aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/lsp-config.lua +265 -0
- aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/lualine.lua +28 -0
- aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/none-ls.lua +50 -0
- aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/nvim-cmp.lua +107 -0
- aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/nvim-tree.lua +55 -0
- aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/nvim-treesitter.lua +32 -0
- aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/smear-cursor.lua +4 -0
- aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/telescope.lua +110 -0
- aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/vim-godot.lua +5 -0
- aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/vim-visual-multi.lua +20 -0
- aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/which-key.lua +49 -0
- aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins.lua +1 -0
- aetherion/data/skeleton/home/aetherion/.config/nvim/stylua.toml +8 -0
- aetherion/data/skeleton/home/aetherion/.config/starship.toml +210 -0
- aetherion-0.1.0.dist-info/METADATA +99 -0
- aetherion-0.1.0.dist-info/RECORD +40 -0
- aetherion-0.1.0.dist-info/WHEEL +4 -0
- aetherion-0.1.0.dist-info/entry_points.txt +2 -0
- aetherion-0.1.0.dist-info/licenses/LICENSE +21 -0
aetherion/__init__.py
ADDED
aetherion/__main__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Entry point for `python -m aetherion`. The console-script entry point in
|
|
2
|
+
pyproject.toml points at the same function, so both invocation paths share
|
|
3
|
+
a single implementation."""
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from aetherion.cli import main
|
|
8
|
+
|
|
9
|
+
if __name__ == "__main__":
|
|
10
|
+
sys.exit(main())
|
aetherion/cli.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import os
|
|
6
|
+
import secrets
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
DEFAULT_IMAGE = "localhost/aetherion:dev"
|
|
14
|
+
CONTAINER_HOME = "/home/aetherion"
|
|
15
|
+
|
|
16
|
+
# Files shipped alongside the launcher that together form the docker build
|
|
17
|
+
# context. Order is purely cosmetic (used in log output).
|
|
18
|
+
BUNDLED_ASSETS: tuple[str, ...] = ("Dockerfile", "skeleton", "scripts")
|
|
19
|
+
|
|
20
|
+
# Per-agent state we preserve on the host so that login or first-run setup
|
|
21
|
+
# done inside one container session survives into the next. Each tuple lists
|
|
22
|
+
# the paths (relative to CONTAINER_HOME, mirrored under the host data dir)
|
|
23
|
+
# owned by that agent — keep new paths grouped under the agent that owns
|
|
24
|
+
# them so `--agents <name>` slicing keeps working with no extra plumbing.
|
|
25
|
+
AGENT_PATHS: dict[str, tuple[str, ...]] = {
|
|
26
|
+
"claude": (".claude", ".claude.json"),
|
|
27
|
+
"cursor": (".cursor", ".config/cursor"),
|
|
28
|
+
"copilot": (".copilot",),
|
|
29
|
+
"gemini": (".gemini",),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _detect_runtime() -> str:
|
|
34
|
+
override = os.environ.get("AETHERION_CONTAINER_RUNTIME")
|
|
35
|
+
if override:
|
|
36
|
+
return override
|
|
37
|
+
for candidate in ("podman", "docker"):
|
|
38
|
+
if shutil.which(candidate):
|
|
39
|
+
return candidate
|
|
40
|
+
sys.stderr.write(
|
|
41
|
+
"aetherion: container runtime detection failed "
|
|
42
|
+
"(tried podman and docker, neither found in PATH).\n"
|
|
43
|
+
"Set AETHERION_CONTAINER_RUNTIME to override.\n"
|
|
44
|
+
)
|
|
45
|
+
raise SystemExit(2)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Container runtime: env var overrides; auto-detect prefers podman over docker.
|
|
49
|
+
CONTAINER_RUNTIME = _detect_runtime()
|
|
50
|
+
# Match against the basename so a full path (e.g. /usr/bin/docker) still works.
|
|
51
|
+
_RUNTIME_IS_DOCKER = Path(CONTAINER_RUNTIME).name == "docker"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def user_ns_args() -> list[str]:
|
|
55
|
+
# Podman and Docker diverge on user-namespace handling. Podman's
|
|
56
|
+
# `keep-id` maps the host UID/GID into the container so bind-mounted
|
|
57
|
+
# config stays owned by the caller. Docker has no `keep-id`; running as
|
|
58
|
+
# an explicit uid:gid achieves the equivalent ownership on the mounts.
|
|
59
|
+
if _RUNTIME_IS_DOCKER:
|
|
60
|
+
return ["--user", "1000:1000"]
|
|
61
|
+
return ["--userns=keep-id:uid=1000,gid=1000"]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _bundled_assets_dir() -> Path:
|
|
65
|
+
# Dockerfile + skeleton/ + scripts/ ship inside the package itself, in a
|
|
66
|
+
# sibling data/ directory. This resolves to the same real path whether
|
|
67
|
+
# the launcher runs from a source checkout, an editable install, or a
|
|
68
|
+
# pip-installed wheel — no importlib.resources dance required, because
|
|
69
|
+
# we always need real filesystem paths anyway (docker build + shutil
|
|
70
|
+
# both want them).
|
|
71
|
+
return Path(__file__).resolve().parent / "data"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
|
75
|
+
parser = argparse.ArgumentParser(
|
|
76
|
+
prog="aetherion",
|
|
77
|
+
description="Launch the aetherion dev container.",
|
|
78
|
+
)
|
|
79
|
+
known = ", ".join(AGENT_PATHS)
|
|
80
|
+
parser.add_argument(
|
|
81
|
+
"--agents",
|
|
82
|
+
metavar="LIST",
|
|
83
|
+
type=lambda s: [a.strip() for a in s.split(",") if a.strip()],
|
|
84
|
+
default=list(AGENT_PATHS),
|
|
85
|
+
help=(
|
|
86
|
+
"Comma-separated subset of agent toolchains whose login/setup state "
|
|
87
|
+
"to expose into the container. Anything not listed is neither "
|
|
88
|
+
f"mounted in nor preserved on exit. Default: all. Known: {known}. "
|
|
89
|
+
"Pass an empty value (--agents '') to expose nothing."
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
parser.add_argument(
|
|
93
|
+
"--image",
|
|
94
|
+
metavar="REF",
|
|
95
|
+
default=DEFAULT_IMAGE,
|
|
96
|
+
help=f"Container image to run (and to tag when building). Default: {DEFAULT_IMAGE}.",
|
|
97
|
+
)
|
|
98
|
+
parser.add_argument(
|
|
99
|
+
"--build-image",
|
|
100
|
+
action="store_true",
|
|
101
|
+
help=(
|
|
102
|
+
"Build the image and exit (does not launch the container). Uses "
|
|
103
|
+
"--build-dir as context if given, otherwise the Dockerfile + "
|
|
104
|
+
"skeleton bundled with this script. Chain with && to launch after."
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
parser.add_argument(
|
|
108
|
+
"--build-dir",
|
|
109
|
+
metavar="PATH",
|
|
110
|
+
default=None,
|
|
111
|
+
help=(
|
|
112
|
+
"Directory to use as the build context. Must contain a Dockerfile. "
|
|
113
|
+
"Combine with --build-image to build from a customized copy "
|
|
114
|
+
"(see --extract)."
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
parser.add_argument(
|
|
118
|
+
"--extract",
|
|
119
|
+
metavar="PATH",
|
|
120
|
+
default=None,
|
|
121
|
+
help=(
|
|
122
|
+
"Copy the bundled Dockerfile, skeleton/, and scripts/ into PATH "
|
|
123
|
+
"and exit without launching. Use this to customize the image: "
|
|
124
|
+
"edit, then `aetherion --build-image --build-dir PATH`."
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
parser.add_argument(
|
|
128
|
+
"-e", "--env",
|
|
129
|
+
action="append",
|
|
130
|
+
default=[],
|
|
131
|
+
metavar="NAME=VALUE",
|
|
132
|
+
help=(
|
|
133
|
+
"Set an environment variable inside the container. Repeat for "
|
|
134
|
+
"multiple (e.g. -e ONE=1 --env TWO=2). Values may contain spaces "
|
|
135
|
+
"if quoted at the shell: --env 'NAME=has spaces'. A bare name "
|
|
136
|
+
"with no `=` inherits the value from the host environment."
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
return parser.parse_args(argv)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def main(argv: list[str] | None = None) -> int:
|
|
143
|
+
# argv=None lets the console-script entry point (pyproject.toml) call
|
|
144
|
+
# main() with no args while keeping the parameter explicit for tests
|
|
145
|
+
# and for the `python -m aetherion` wrapper.
|
|
146
|
+
if argv is None:
|
|
147
|
+
argv = sys.argv[1:]
|
|
148
|
+
args = _parse_args(argv)
|
|
149
|
+
|
|
150
|
+
# --extract is terminal: it never launches the container.
|
|
151
|
+
if args.extract is not None:
|
|
152
|
+
return _extract_bundle(Path(args.extract).expanduser().resolve())
|
|
153
|
+
|
|
154
|
+
unknown = [a for a in args.agents if a not in AGENT_PATHS]
|
|
155
|
+
if unknown:
|
|
156
|
+
sys.stderr.write(
|
|
157
|
+
f"aetherion: unknown agent(s): {', '.join(unknown)}\n"
|
|
158
|
+
f"aetherion: known agents: {', '.join(AGENT_PATHS)}\n"
|
|
159
|
+
)
|
|
160
|
+
return 2
|
|
161
|
+
|
|
162
|
+
selected: list[str] = args.agents
|
|
163
|
+
if set(selected) != set(AGENT_PATHS):
|
|
164
|
+
scope = ", ".join(selected) if selected else "(none)"
|
|
165
|
+
sys.stderr.write(f"aetherion: agent scope limited to: {scope}\n")
|
|
166
|
+
|
|
167
|
+
image: str = args.image
|
|
168
|
+
|
|
169
|
+
# --build-image is terminal: it never launches the container, regardless
|
|
170
|
+
# of build success or failure. The build's exit code propagates as-is.
|
|
171
|
+
if args.build_image:
|
|
172
|
+
context = (
|
|
173
|
+
Path(args.build_dir).expanduser().resolve()
|
|
174
|
+
if args.build_dir is not None
|
|
175
|
+
else _bundled_assets_dir()
|
|
176
|
+
)
|
|
177
|
+
return _build_image(image, context)
|
|
178
|
+
|
|
179
|
+
if not _image_exists(image):
|
|
180
|
+
sys.stderr.write(
|
|
181
|
+
f"aetherion: image '{image}' is not present locally.\n"
|
|
182
|
+
"aetherion: this launcher does not pull images — build it locally:\n"
|
|
183
|
+
f" aetherion --build-image"
|
|
184
|
+
+ (f" --image {image}" if image != DEFAULT_IMAGE else "")
|
|
185
|
+
+ "\n"
|
|
186
|
+
"aetherion: or extract a copy of the build files first to customize:\n"
|
|
187
|
+
" aetherion --extract <path>\n"
|
|
188
|
+
)
|
|
189
|
+
return 1
|
|
190
|
+
|
|
191
|
+
home = Path.home()
|
|
192
|
+
pwd = Path.cwd()
|
|
193
|
+
|
|
194
|
+
data_dir = home / ".aetherion" / "data"
|
|
195
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
|
|
197
|
+
# Rewrite host home → container home so a host path of ~/foo lands at ~/foo
|
|
198
|
+
# inside the container too. Anything outside $HOME is mounted at its real
|
|
199
|
+
# path, since there's no portable home-relative form for it.
|
|
200
|
+
if pwd == home:
|
|
201
|
+
container_workdir = CONTAINER_HOME
|
|
202
|
+
elif home in pwd.parents:
|
|
203
|
+
container_workdir = f"{CONTAINER_HOME}/{pwd.relative_to(home)}"
|
|
204
|
+
else:
|
|
205
|
+
container_workdir = str(pwd)
|
|
206
|
+
|
|
207
|
+
mounts: list[str] = []
|
|
208
|
+
# (agent, rel) for each deferred path so the first-run notice and the
|
|
209
|
+
# post-exit "preserved <agent>" log can name the agent that owns it.
|
|
210
|
+
deferred: list[tuple[str, str]] = []
|
|
211
|
+
|
|
212
|
+
for agent in selected:
|
|
213
|
+
for rel in AGENT_PATHS[agent]:
|
|
214
|
+
host_path = data_dir / rel
|
|
215
|
+
container_path = f"{CONTAINER_HOME}/{rel}"
|
|
216
|
+
if host_path.exists():
|
|
217
|
+
mounts += ["-v", f"{host_path}:{container_path}:z"]
|
|
218
|
+
else:
|
|
219
|
+
deferred.append((agent, rel))
|
|
220
|
+
|
|
221
|
+
if deferred:
|
|
222
|
+
sys.stderr.write(
|
|
223
|
+
"aetherion: these agent paths are not yet preserved on the host;\n"
|
|
224
|
+
"any that get created during the session will be extracted on clean exit:\n"
|
|
225
|
+
)
|
|
226
|
+
last_agent: str | None = None
|
|
227
|
+
for agent, rel in deferred:
|
|
228
|
+
if agent != last_agent:
|
|
229
|
+
sys.stderr.write(f" [{agent}]\n")
|
|
230
|
+
last_agent = agent
|
|
231
|
+
sys.stderr.write(f" - {data_dir / rel}\n")
|
|
232
|
+
sys.stderr.write("aetherion: let the container exit cleanly, do not SIGKILL the launcher.\n\n")
|
|
233
|
+
|
|
234
|
+
# --cidfile instead of --rm: we need the container to outlive the shell so
|
|
235
|
+
# we can diff and `cp` config out before removing it.
|
|
236
|
+
instance_id = secrets.token_hex(4)
|
|
237
|
+
instance_name = f"aetherion-{instance_id}"
|
|
238
|
+
|
|
239
|
+
with tempfile.TemporaryDirectory(prefix="aetherion-cid-") as td:
|
|
240
|
+
cidfile = Path(td) / "cid"
|
|
241
|
+
|
|
242
|
+
# Passed through subprocess as separate argv entries, so values with
|
|
243
|
+
# spaces or shell-special characters are safe — no shell evaluation.
|
|
244
|
+
env_args: list[str] = []
|
|
245
|
+
for kv in args.env:
|
|
246
|
+
env_args += ["-e", kv]
|
|
247
|
+
|
|
248
|
+
run_argv = [
|
|
249
|
+
CONTAINER_RUNTIME, "run",
|
|
250
|
+
*user_ns_args(),
|
|
251
|
+
"--name", instance_name,
|
|
252
|
+
"--hostname", instance_id,
|
|
253
|
+
"--cidfile", str(cidfile),
|
|
254
|
+
*env_args,
|
|
255
|
+
"-v", f"{pwd}:{container_workdir}:z",
|
|
256
|
+
"-w", container_workdir,
|
|
257
|
+
*mounts,
|
|
258
|
+
"-it",
|
|
259
|
+
image,
|
|
260
|
+
]
|
|
261
|
+
|
|
262
|
+
rc = subprocess.run(run_argv).returncode
|
|
263
|
+
|
|
264
|
+
if not cidfile.exists():
|
|
265
|
+
return rc
|
|
266
|
+
|
|
267
|
+
cid = cidfile.read_text().strip()
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
if deferred:
|
|
271
|
+
preserve_agent_state(cid, deferred, data_dir)
|
|
272
|
+
finally:
|
|
273
|
+
subprocess.run(
|
|
274
|
+
[CONTAINER_RUNTIME, "rm", "-f", cid],
|
|
275
|
+
stdout=subprocess.DEVNULL,
|
|
276
|
+
stderr=subprocess.DEVNULL,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
return rc
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _image_exists(image: str) -> bool:
|
|
283
|
+
return subprocess.run(
|
|
284
|
+
[CONTAINER_RUNTIME, "image", "inspect", image],
|
|
285
|
+
stdout=subprocess.DEVNULL,
|
|
286
|
+
stderr=subprocess.DEVNULL,
|
|
287
|
+
).returncode == 0
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _build_image(image: str, context: Path) -> int:
|
|
291
|
+
if not context.is_dir():
|
|
292
|
+
sys.stderr.write(f"aetherion: build context does not exist: {context}\n")
|
|
293
|
+
return 1
|
|
294
|
+
if not (context / "Dockerfile").is_file():
|
|
295
|
+
sys.stderr.write(
|
|
296
|
+
f"aetherion: no Dockerfile found in build context: {context}\n"
|
|
297
|
+
"aetherion: run `aetherion --extract <path>` to populate one.\n"
|
|
298
|
+
)
|
|
299
|
+
return 1
|
|
300
|
+
sys.stderr.write(f"aetherion: building {image} from {context}\n")
|
|
301
|
+
return subprocess.run(
|
|
302
|
+
[CONTAINER_RUNTIME, "build", "-t", image, str(context)],
|
|
303
|
+
).returncode
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _extract_bundle(dest: Path) -> int:
|
|
307
|
+
src = _bundled_assets_dir()
|
|
308
|
+
missing = [name for name in BUNDLED_ASSETS if not (src / name).exists()]
|
|
309
|
+
if missing:
|
|
310
|
+
sys.stderr.write(
|
|
311
|
+
f"aetherion: bundled asset(s) missing from {src}: {', '.join(missing)}\n"
|
|
312
|
+
"aetherion: this launcher must be run from a complete source tree.\n"
|
|
313
|
+
)
|
|
314
|
+
return 1
|
|
315
|
+
|
|
316
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
317
|
+
for name in BUNDLED_ASSETS:
|
|
318
|
+
s = src / name
|
|
319
|
+
d = dest / name
|
|
320
|
+
if s.is_dir():
|
|
321
|
+
# dirs_exist_ok overlays into an existing tree rather than failing,
|
|
322
|
+
# but it still only touches files that exist in the source — so any
|
|
323
|
+
# extra files the user added under dest/<name>/ stay put.
|
|
324
|
+
shutil.copytree(s, d, dirs_exist_ok=True)
|
|
325
|
+
else:
|
|
326
|
+
shutil.copy2(s, d)
|
|
327
|
+
|
|
328
|
+
sys.stderr.write(
|
|
329
|
+
f"aetherion: extracted {', '.join(BUNDLED_ASSETS)} to {dest}\n"
|
|
330
|
+
f"aetherion: build with: aetherion --build-image --build-dir {dest}\n"
|
|
331
|
+
)
|
|
332
|
+
return 0
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def preserve_agent_state(cid: str, deferred: list[tuple[str, str]], data_dir: Path) -> None:
|
|
336
|
+
# Use `<runtime> diff` to find which deferred paths the container actually
|
|
337
|
+
# touched. Skipping `cp` on untouched paths avoids spurious errors and
|
|
338
|
+
# keeps the host clean of empty agent dirs from sessions where the user
|
|
339
|
+
# never logged in.
|
|
340
|
+
touched = _diff_paths(cid)
|
|
341
|
+
for agent, rel in deferred:
|
|
342
|
+
container_path = f"{CONTAINER_HOME}/{rel}"
|
|
343
|
+
if not _was_touched(container_path, touched):
|
|
344
|
+
continue
|
|
345
|
+
host_path = data_dir / rel
|
|
346
|
+
if extract(cid, container_path, host_path):
|
|
347
|
+
sys.stderr.write(f"aetherion: preserved {agent} state at {host_path}\n")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _diff_paths(cid: str) -> set[str]:
|
|
351
|
+
"""Return the set of container-fs paths reported as Added or Changed by
|
|
352
|
+
`<runtime> diff`. Deletes are ignored — nothing to extract."""
|
|
353
|
+
result = subprocess.run(
|
|
354
|
+
[CONTAINER_RUNTIME, "diff", cid],
|
|
355
|
+
capture_output=True,
|
|
356
|
+
text=True,
|
|
357
|
+
)
|
|
358
|
+
if result.returncode != 0:
|
|
359
|
+
return set()
|
|
360
|
+
paths: set[str] = set()
|
|
361
|
+
for line in result.stdout.splitlines():
|
|
362
|
+
kind, _, path = line.partition(" ")
|
|
363
|
+
if kind in ("A", "C") and path:
|
|
364
|
+
paths.add(path)
|
|
365
|
+
return paths
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _was_touched(target: str, touched: set[str]) -> bool:
|
|
369
|
+
if target in touched:
|
|
370
|
+
return True
|
|
371
|
+
prefix = target + "/"
|
|
372
|
+
return any(p.startswith(prefix) for p in touched)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def extract(cid: str, src_in_container: str, dst_on_host: Path) -> bool:
|
|
376
|
+
dst_on_host.parent.mkdir(parents=True, exist_ok=True)
|
|
377
|
+
|
|
378
|
+
# Stage to a sibling tmp path so the final move into place is an atomic
|
|
379
|
+
# rename on the same filesystem — no half-written config visible to a
|
|
380
|
+
# future run.
|
|
381
|
+
staging = dst_on_host.with_name(dst_on_host.name + ".tmp-extract")
|
|
382
|
+
_remove(staging)
|
|
383
|
+
|
|
384
|
+
cp = subprocess.run(
|
|
385
|
+
[CONTAINER_RUNTIME, "cp", f"{cid}:{src_in_container}", str(staging)],
|
|
386
|
+
stdout=subprocess.DEVNULL,
|
|
387
|
+
stderr=subprocess.PIPE,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
if cp.returncode != 0:
|
|
391
|
+
_remove(staging)
|
|
392
|
+
return False
|
|
393
|
+
|
|
394
|
+
# Defensive: dst shouldn't exist (deferred = not on host at launch), but
|
|
395
|
+
# if a concurrent run raced us, clear it so os.replace can land cleanly
|
|
396
|
+
# even when staging is a directory.
|
|
397
|
+
_remove(dst_on_host)
|
|
398
|
+
os.replace(staging, dst_on_host)
|
|
399
|
+
return True
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _remove(path: Path) -> None:
|
|
403
|
+
if path.is_dir() and not path.is_symlink():
|
|
404
|
+
shutil.rmtree(path)
|
|
405
|
+
elif path.exists() or path.is_symlink():
|
|
406
|
+
path.unlink()
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
if __name__ == "__main__":
|
|
410
|
+
sys.exit(main())
|