kanibako-cli 1.5.0.dev14__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.
Files changed (85) hide show
  1. kanibako/__init__.py +3 -0
  2. kanibako/__main__.py +6 -0
  3. kanibako/auth_browser.py +296 -0
  4. kanibako/auth_parser.py +51 -0
  5. kanibako/browser_sidecar.py +183 -0
  6. kanibako/browser_state.py +103 -0
  7. kanibako/bun_sea.py +144 -0
  8. kanibako/cli.py +344 -0
  9. kanibako/commands/__init__.py +0 -0
  10. kanibako/commands/archive.py +228 -0
  11. kanibako/commands/box/__init__.py +22 -0
  12. kanibako/commands/box/_duplicate.py +395 -0
  13. kanibako/commands/box/_migrate.py +574 -0
  14. kanibako/commands/box/_parser.py +1178 -0
  15. kanibako/commands/clean.py +166 -0
  16. kanibako/commands/crab_cmd.py +480 -0
  17. kanibako/commands/diagnose.py +239 -0
  18. kanibako/commands/fork_cmd.py +51 -0
  19. kanibako/commands/helper_cmd.py +669 -0
  20. kanibako/commands/image.py +1300 -0
  21. kanibako/commands/install.py +152 -0
  22. kanibako/commands/refresh_credentials.py +67 -0
  23. kanibako/commands/restore.py +298 -0
  24. kanibako/commands/setup_cmd.py +89 -0
  25. kanibako/commands/start.py +1600 -0
  26. kanibako/commands/stop.py +116 -0
  27. kanibako/commands/system_cmd.py +224 -0
  28. kanibako/commands/upgrade.py +161 -0
  29. kanibako/commands/vault_cmd.py +199 -0
  30. kanibako/commands/workset_cmd.py +552 -0
  31. kanibako/config.py +514 -0
  32. kanibako/config_interface.py +573 -0
  33. kanibako/config_io.py +36 -0
  34. kanibako/container.py +607 -0
  35. kanibako/containerfiles.py +58 -0
  36. kanibako/containers/Containerfile.kanibako +99 -0
  37. kanibako/containers/Containerfile.template-android +55 -0
  38. kanibako/containers/Containerfile.template-dotnet +29 -0
  39. kanibako/containers/Containerfile.template-js +43 -0
  40. kanibako/containers/Containerfile.template-jvm +27 -0
  41. kanibako/containers/Containerfile.template-systems +46 -0
  42. kanibako/containers/__init__.py +0 -0
  43. kanibako/crabs.py +89 -0
  44. kanibako/errors.py +33 -0
  45. kanibako/freshness.py +67 -0
  46. kanibako/git.py +114 -0
  47. kanibako/helper_client.py +132 -0
  48. kanibako/helper_listener.py +538 -0
  49. kanibako/helpers.py +339 -0
  50. kanibako/hygiene.py +296 -0
  51. kanibako/image_sharing.py +133 -0
  52. kanibako/instructions.py +160 -0
  53. kanibako/log.py +31 -0
  54. kanibako/names.py +248 -0
  55. kanibako/paths.py +1483 -0
  56. kanibako/plugins/__init__.py +10 -0
  57. kanibako/registry.py +71 -0
  58. kanibako/rig_bundle.py +121 -0
  59. kanibako/rig_meta.py +92 -0
  60. kanibako/rig_registry.py +132 -0
  61. kanibako/rig_resolve.py +182 -0
  62. kanibako/rig_source.py +245 -0
  63. kanibako/scripts/__init__.py +0 -0
  64. kanibako/scripts/helper-init.sh +45 -0
  65. kanibako/scripts/kanibako-entry +12 -0
  66. kanibako/settings_resolve.py +312 -0
  67. kanibako/settings_seeds.py +154 -0
  68. kanibako/settings_shares.py +154 -0
  69. kanibako/shellenv.py +75 -0
  70. kanibako/snapshots.py +281 -0
  71. kanibako/targets/__init__.py +173 -0
  72. kanibako/targets/base.py +243 -0
  73. kanibako/targets/no_agent.py +58 -0
  74. kanibako/templates.py +60 -0
  75. kanibako/templates_image.py +224 -0
  76. kanibako/tweakcc.py +140 -0
  77. kanibako/tweakcc_cache.py +171 -0
  78. kanibako/utils.py +136 -0
  79. kanibako/workset.py +347 -0
  80. kanibako_cli-1.5.0.dev14.dist-info/METADATA +15 -0
  81. kanibako_cli-1.5.0.dev14.dist-info/RECORD +85 -0
  82. kanibako_cli-1.5.0.dev14.dist-info/WHEEL +5 -0
  83. kanibako_cli-1.5.0.dev14.dist-info/entry_points.txt +5 -0
  84. kanibako_cli-1.5.0.dev14.dist-info/licenses/LICENSE.md +594 -0
  85. kanibako_cli-1.5.0.dev14.dist-info/top_level.txt +1 -0
@@ -0,0 +1,154 @@
1
+ """Copy-once-at-init seed resolution (pure, additive).
2
+
3
+ This module turns settings-framework *seed* config entries into a list of
4
+ ``(host_src, guest_dest)`` copy-pairs. A seed is copied ONCE at box/crab init
5
+ (the CALLER does the copy — this module is **pure**: no file I/O, no copying,
6
+ no global mutable state). It imports only stdlib and the expression engine in
7
+ :mod:`kanibako.settings_resolve`. Wiring it into init is a separate increment.
8
+
9
+ It is the simpler sibling of :mod:`kanibako.settings_shares`: seeds have **no
10
+ ``ro``/``rw`` mode**, **no root-join** (``host_src`` is a full expression, not a
11
+ relative path joined under a scope root), and yield plain string pairs instead
12
+ of :class:`~kanibako.targets.base.Mount` objects.
13
+
14
+ The seed model
15
+ --------------
16
+ A seed is declared with a key of the shape::
17
+
18
+ {scope}.path.seeded.{name}
19
+
20
+ where ``scope`` is one of ``system``/``crab``/``workset``/``box`` and ``name``
21
+ is an arbitrary identifier (it may itself contain dots — everything after
22
+ ``seeded.`` is the name). The value is a ``host_src:guest_dest`` bind
23
+ expression: ``host_src`` resolves in HOST space, ``guest_dest`` in GUEST space
24
+ (a container ``/home/agent/...`` path).
25
+
26
+ Two orthogonal axes
27
+ ~~~~~~~~~~~~~~~~~~~~~
28
+ * **The KEY's scope** is informational (it documents the seed's *reach*).
29
+ * **The LEVEL where the key is SET** decides *precedence*. These differ on
30
+ purpose: a box may set ``system.path.seeded.foo = ""`` to **suppress** an
31
+ inherited system-scoped seed for just that box, or override it with its own
32
+ ``host_src:guest_dest``.
33
+
34
+ Suppression / disable
35
+ ~~~~~~~~~~~~~~~~~~~~~~~
36
+ A seed is SKIPPED (no copy-pair emitted) when the winning value is either an
37
+ explicit terminal ``""`` (``rv.terminal``) or the sentinel string ``"empty"``
38
+ (mirroring the existing ``resolve_template`` "empty" convention).
39
+
40
+ Accumulate / apply order
41
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~
42
+ Distinct seed names accumulate. For a SINGLE key, the most-specific level that
43
+ set it wins (standard :func:`resolve_value` precedence). The returned pairs are
44
+ ordered by scope *apply* order ``system, crab, workset, box`` — the REVERSE of
45
+ the precedence list — so a later/more-specific scope's copy overlays an earlier
46
+ one. Within a scope, pairs are ordered by ``name`` ascending, for full
47
+ determinism.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import re
53
+ from collections.abc import Callable
54
+ from dataclasses import dataclass
55
+
56
+ from kanibako.settings_resolve import (
57
+ LevelView,
58
+ ResolveCtx,
59
+ SettingsError,
60
+ _Unset,
61
+ expand_expr,
62
+ resolve_value,
63
+ split_bind,
64
+ )
65
+
66
+ # Matches a seed key: scope . path . seeded . name
67
+ # (name greedily captures the remainder, which may contain dots).
68
+ SEED_KEY_RE = re.compile(
69
+ r"^(?P<scope>system|crab|workset|box)\.path\.seeded\.(?P<name>.+)$"
70
+ )
71
+
72
+ # Sentinel value that disables a seed (parallels resolve_template's "empty").
73
+ _DISABLE_SENTINEL = "empty"
74
+
75
+
76
+ def is_seed_key(key: str) -> bool:
77
+ """True if *key* is a seed config key ({scope}.path.seeded.{name})."""
78
+ return SEED_KEY_RE.match(key) is not None
79
+
80
+
81
+ # Apply order: REVERSE of precedence (most-specific scope copies LAST so a
82
+ # later copy overlays an earlier one).
83
+ _SCOPE_APPLY_ORDER = {"system": 0, "crab": 1, "workset": 2, "box": 3}
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class SeedPair:
88
+ """A resolved copy-once-at-init seed: copy host_src -> guest_dest (guest space)."""
89
+
90
+ host_src: str # resolved host path
91
+ guest_dest: str # resolved guest/container path (e.g. /home/agent/...)
92
+ scope: str # "system" | "crab" | "workset" | "box"
93
+ name: str
94
+
95
+
96
+ def resolve_seeds(
97
+ *,
98
+ levels: list[LevelView],
99
+ ctx: ResolveCtx,
100
+ lookup: Callable[[str, tuple[str, ...]], str],
101
+ ) -> list[SeedPair]:
102
+ """Resolve seed config into a deterministic list of copy-pairs.
103
+
104
+ *levels* are ordered MOST-SPECIFIC-FIRST (``[box, workset, crab, system]``).
105
+ *lookup* resolves ``@``-refs (typically a closure over *levels*). Returns
106
+ :class:`SeedPair` objects in apply order (see module docstring). Raises
107
+ :class:`SettingsError` if a non-suppressed seed value lacks a
108
+ ``host_src:guest_dest`` colon.
109
+ """
110
+ # 1. Discover seed keys across every level's values AND defaults.
111
+ keys: set[str] = set()
112
+ for level in levels:
113
+ for key in level.values:
114
+ if SEED_KEY_RE.match(key):
115
+ keys.add(key)
116
+ for key in level.defaults:
117
+ if SEED_KEY_RE.match(key):
118
+ keys.add(key)
119
+
120
+ pairs: list[tuple[tuple[int, str], SeedPair]] = []
121
+ for key in keys:
122
+ m = SEED_KEY_RE.match(key)
123
+ assert m is not None # keys were filtered by the same regex.
124
+ scope = m.group("scope")
125
+ name = m.group("name")
126
+
127
+ rv = resolve_value(key, levels=levels, ctx=ctx, lookup=lookup)
128
+ if isinstance(rv, _Unset):
129
+ # Defensive: the key came from some level, so it must resolve.
130
+ continue
131
+ if rv.terminal:
132
+ # Explicit "" — suppressed; do not copy.
133
+ continue
134
+ if rv.value == _DISABLE_SENTINEL:
135
+ # "empty" sentinel — disabled; do not copy.
136
+ continue
137
+
138
+ host_src_raw, guest_dest_raw = split_bind(rv.value)
139
+ if guest_dest_raw is None:
140
+ raise SettingsError(
141
+ f"Seed '{key}' must specify 'host_src:guest_dest' "
142
+ f"(no unescaped ':' in value {rv.value!r})."
143
+ )
144
+
145
+ host_src = expand_expr(host_src_raw, space="host", ctx=ctx, lookup=lookup)
146
+ guest_dest = expand_expr(guest_dest_raw, space="guest", ctx=ctx, lookup=lookup)
147
+
148
+ sort_key = (_SCOPE_APPLY_ORDER[scope], name)
149
+ pairs.append(
150
+ (sort_key, SeedPair(host_src=host_src, guest_dest=guest_dest, scope=scope, name=name))
151
+ )
152
+
153
+ pairs.sort(key=lambda pair: pair[0])
154
+ return [seed for _, seed in pairs]
@@ -0,0 +1,154 @@
1
+ """Scoped-share resolution (pure, additive).
2
+
3
+ This module turns settings-framework *scoped-share* config entries into a list
4
+ of :class:`~kanibako.targets.base.Mount` objects. It is **pure**: no file I/O,
5
+ no global mutable state. It imports only stdlib, the expression engine in
6
+ :mod:`kanibako.settings_resolve`, and the :class:`Mount` dataclass. Wiring it
7
+ into container launch is a separate increment.
8
+
9
+ The share model
10
+ ---------------
11
+ A share is declared with a key of the shape::
12
+
13
+ {scope}.path.share_{ro|rw}.{name}
14
+
15
+ where ``scope`` is one of ``system``/``crab``/``workset``/``box``, the mode is
16
+ ``ro`` or ``rw``, and ``name`` is an arbitrary identifier (it may itself contain
17
+ dots — everything after ``share_{ro,rw}.`` is the name). The value is a
18
+ ``host_src:guest_dest`` bind expression.
19
+
20
+ Two orthogonal axes
21
+ ~~~~~~~~~~~~~~~~~~~~~
22
+ * **The KEY's scope** decides the *source root* the relative ``host_src`` is
23
+ joined under (via *scope_roots*) and the *mount mode* (``ro`` → ``"ro"``;
24
+ ``rw`` → ``"Z,U"``).
25
+ * **The LEVEL where the key is SET** decides *precedence*. These differ on
26
+ purpose: a box may set ``system.path.share_rw.foo = ""`` to **suppress** the
27
+ system-scoped share ``foo`` for just that box (foreign-prefix suppression —
28
+ an explicit ``""`` is terminal and produces no mount).
29
+
30
+ Accumulate / apply order
31
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~
32
+ Distinct share names accumulate. For a SINGLE key, the most-specific level that
33
+ set it wins (standard :func:`resolve_value` precedence). The returned mounts
34
+ are ordered by scope *apply* order ``system, crab, workset, box`` — the REVERSE
35
+ of the precedence list — so the most-specific scope comes LAST, letting
36
+ podman's "last ``-v`` wins" dedup honor box over system. Within a scope, mounts
37
+ are ordered by ``(mode, name)`` ascending, for full determinism.
38
+
39
+ Root-join rule
40
+ ~~~~~~~~~~~~~~~
41
+ *scope_roots* maps a GROUP PREFIX (the key up to and including
42
+ ``share_ro``/``share_rw``, e.g. ``"crab.path.share_rw"``) to a host-space root
43
+ expression (e.g. ``"@system.path.crabs/$CRAB/share"``). When a root exists for
44
+ a key's group AND the resolved ``host_src`` is NOT absolute, the source becomes
45
+ ``root / host_src``; otherwise ``host_src`` is used as-is. A group absent from
46
+ *scope_roots* (or mapped to ``None``/``""``) means no join — this is the ``box``
47
+ case, where ``host_src`` is an arbitrary host path.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import re
53
+ from collections.abc import Callable, Mapping
54
+ from pathlib import Path
55
+
56
+ from kanibako.settings_resolve import (
57
+ LevelView,
58
+ ResolveCtx,
59
+ SettingsError,
60
+ _Unset,
61
+ expand_expr,
62
+ resolve_value,
63
+ split_bind,
64
+ )
65
+ from kanibako.targets.base import Mount
66
+
67
+ # Matches a scoped-share key: scope . path . share_{ro|rw} . name
68
+ # (name greedily captures the remainder, which may contain dots).
69
+ SHARE_KEY_RE = re.compile(
70
+ r"^(?P<scope>system|crab|workset|box)\.path\.share_(?P<mode>ro|rw)\.(?P<name>.+)$"
71
+ )
72
+
73
+
74
+ def is_share_key(key: str) -> bool:
75
+ """True if *key* is a scoped-share config key ({scope}.path.share_{ro,rw}.{name})."""
76
+ return SHARE_KEY_RE.match(key) is not None
77
+
78
+
79
+ # Apply order: REVERSE of precedence (most-specific scope mounts LAST so
80
+ # podman's last-`-v`-wins dedup honors it).
81
+ _SCOPE_APPLY_ORDER = {"system": 0, "crab": 1, "workset": 2, "box": 3}
82
+
83
+
84
+ def resolve_shares(
85
+ *,
86
+ levels: list[LevelView],
87
+ ctx: ResolveCtx,
88
+ lookup: Callable[[str, tuple[str, ...]], str],
89
+ scope_roots: Mapping[str, str] | None = None,
90
+ ) -> list[Mount]:
91
+ """Resolve scoped-share config into a deterministic list of mounts.
92
+
93
+ *levels* are ordered MOST-SPECIFIC-FIRST (``[box, workset, crab, system]``).
94
+ *lookup* resolves ``@``-refs (typically a closure over *levels*).
95
+ *scope_roots* maps a group prefix (``"{scope}.path.share_{ro,rw}"``) to a
96
+ host-space root expression; absent/empty means no root join. Returns mounts
97
+ in apply order (see module docstring). Raises :class:`SettingsError` if a
98
+ non-suppressed share value lacks a ``host_src:guest_dest`` colon.
99
+ """
100
+ # 1. Discover share keys across every level's values AND defaults.
101
+ keys: set[str] = set()
102
+ for level in levels:
103
+ for key in level.values:
104
+ if SHARE_KEY_RE.match(key):
105
+ keys.add(key)
106
+ for key in level.defaults:
107
+ if SHARE_KEY_RE.match(key):
108
+ keys.add(key)
109
+
110
+ mounts: list[tuple[tuple[int, str, str], Mount]] = []
111
+ for key in keys:
112
+ m = SHARE_KEY_RE.match(key)
113
+ assert m is not None # keys were filtered by the same regex.
114
+ scope = m.group("scope")
115
+ mode = m.group("mode")
116
+ name = m.group("name")
117
+ group = f"{scope}.path.share_{mode}"
118
+
119
+ rv = resolve_value(key, levels=levels, ctx=ctx, lookup=lookup)
120
+ if isinstance(rv, _Unset):
121
+ # Defensive: the key came from some level, so it must resolve.
122
+ continue
123
+ if rv.terminal:
124
+ # Explicit "" — suppressed; do not mount.
125
+ continue
126
+
127
+ host_src_raw, guest_dest_raw = split_bind(rv.value)
128
+ if guest_dest_raw is None:
129
+ raise SettingsError(
130
+ f"Share '{key}' must specify 'host_src:guest_dest' "
131
+ f"(no unescaped ':' in value {rv.value!r})."
132
+ )
133
+
134
+ host_src = expand_expr(
135
+ host_src_raw, space="host", ctx=ctx, lookup=lookup,
136
+ )
137
+ guest_dest = expand_expr(
138
+ guest_dest_raw, space="guest", ctx=ctx, lookup=lookup,
139
+ )
140
+
141
+ # Root join: only for relative host_src under a group that has a root.
142
+ root_expr = scope_roots.get(group) if scope_roots else None
143
+ if root_expr and not host_src.startswith("/"):
144
+ root = expand_expr(root_expr, space="host", ctx=ctx, lookup=lookup)
145
+ source = Path(root) / host_src
146
+ else:
147
+ source = Path(host_src)
148
+
149
+ options = "ro" if mode == "ro" else "Z,U"
150
+ sort_key = (_SCOPE_APPLY_ORDER[scope], mode, name)
151
+ mounts.append((sort_key, Mount(source=source, destination=guest_dest, options=options)))
152
+
153
+ mounts.sort(key=lambda pair: pair[0])
154
+ return [mount for _, mount in mounts]
kanibako/shellenv.py ADDED
@@ -0,0 +1,75 @@
1
+ """Environment variable file handling for per-project and global env vars."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ _KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
9
+
10
+
11
+ def read_env_file(path: Path) -> dict[str, str]:
12
+ """Read a Docker-style .env file and return key-value pairs.
13
+
14
+ - One KEY=VALUE per line
15
+ - Lines starting with ``#`` are comments
16
+ - Empty lines are ignored
17
+ - No shell expansion (values are literal)
18
+ - Invalid lines are silently skipped
19
+ """
20
+ env: dict[str, str] = {}
21
+ if not path.is_file():
22
+ return env
23
+ for line in path.read_text().splitlines():
24
+ line = line.strip()
25
+ if not line or line.startswith("#"):
26
+ continue
27
+ if "=" not in line:
28
+ continue
29
+ key, _, value = line.partition("=")
30
+ key = key.strip()
31
+ if not _KEY_RE.match(key):
32
+ continue
33
+ env[key] = value
34
+ return env
35
+
36
+
37
+ def write_env_file(path: Path, env: dict[str, str]) -> None:
38
+ """Write a dict of env vars to a Docker-style .env file."""
39
+ path.parent.mkdir(parents=True, exist_ok=True)
40
+ lines: list[str] = []
41
+ for key, value in sorted(env.items()):
42
+ lines.append(f"{key}={value}")
43
+ path.write_text("\n".join(lines) + "\n" if lines else "")
44
+
45
+
46
+ def set_env_var(path: Path, key: str, value: str) -> None:
47
+ """Set a single env var in an env file (read-modify-write)."""
48
+ if not _KEY_RE.match(key):
49
+ raise ValueError(f"Invalid environment variable name: {key}")
50
+ env = read_env_file(path)
51
+ env[key] = value
52
+ write_env_file(path, env)
53
+
54
+
55
+ def unset_env_var(path: Path, key: str) -> bool:
56
+ """Remove an env var from an env file. Returns True if it existed."""
57
+ env = read_env_file(path)
58
+ if key not in env:
59
+ return False
60
+ del env[key]
61
+ write_env_file(path, env)
62
+ return True
63
+
64
+
65
+ def merge_env(
66
+ global_path: Path | None,
67
+ project_path: Path | None,
68
+ ) -> dict[str, str]:
69
+ """Merge global and project env files. Project wins on conflict."""
70
+ env: dict[str, str] = {}
71
+ if global_path:
72
+ env.update(read_env_file(global_path))
73
+ if project_path:
74
+ env.update(read_env_file(project_path))
75
+ return env
kanibako/snapshots.py ADDED
@@ -0,0 +1,281 @@
1
+ """Snapshot engine for vault share-rw directories.
2
+
3
+ Provides point-in-time backups of ``share-rw/`` stored in a ``.versions/``
4
+ sibling directory. Three strategies are supported:
5
+
6
+ * **reflink** -- copy-on-write clone (instant, space-efficient; requires a
7
+ COW filesystem such as Btrfs or XFS with reflink support).
8
+ * **hardlink** -- ``rsync --link-dest`` so unchanged files share inodes
9
+ (fast, moderate space; works on any POSIX filesystem).
10
+ * **tarxz** -- compressed tar archive (slow but universally portable; legacy
11
+ default).
12
+
13
+ ``detect_snapshot_strategy`` probes the filesystem and picks the best option
14
+ automatically. Automatic snapshots can be triggered before each container
15
+ launch.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import shutil
21
+ import subprocess
22
+ import tarfile
23
+ from datetime import datetime, timezone
24
+ from pathlib import Path
25
+
26
+
27
+ # Default maximum number of snapshots to retain.
28
+ _DEFAULT_MAX_SNAPSHOTS = 5
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Internal helpers
33
+ # ---------------------------------------------------------------------------
34
+
35
+
36
+ def _versions_dir(vault_rw_path: Path) -> Path:
37
+ """Return the .versions/ directory for a vault share-rw path."""
38
+ return vault_rw_path.parent / ".versions"
39
+
40
+
41
+ def _test_reflink(path: Path) -> bool:
42
+ """Test if *path*'s filesystem supports reflinks."""
43
+ if not path.is_dir():
44
+ return False
45
+ test_src = path / ".reflink-test-src"
46
+ test_dst = path / ".reflink-test-dst"
47
+ try:
48
+ test_src.write_bytes(b"test")
49
+ result = subprocess.run(
50
+ ["cp", "--reflink=always", str(test_src), str(test_dst)],
51
+ capture_output=True,
52
+ )
53
+ return result.returncode == 0
54
+ except Exception:
55
+ return False
56
+ finally:
57
+ test_src.unlink(missing_ok=True)
58
+ test_dst.unlink(missing_ok=True)
59
+
60
+
61
+ def detect_snapshot_strategy(vault_path: Path) -> str:
62
+ """Detect the best snapshot strategy for the given path.
63
+
64
+ Returns ``"reflink"``, ``"hardlink"``, or ``"tarxz"``.
65
+ """
66
+ if _test_reflink(vault_path):
67
+ return "reflink"
68
+ # hardlink is always available on POSIX
69
+ return "hardlink"
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Strategy implementations
74
+ # ---------------------------------------------------------------------------
75
+
76
+
77
+ def _snapshot_tarxz(vault_rw_path: Path, versions: Path, ts: str) -> Path:
78
+ """Create a tar.xz snapshot (original behaviour)."""
79
+ archive = versions / f"{ts}.tar.xz"
80
+ with tarfile.open(archive, "w:xz") as tar:
81
+ for item in sorted(vault_rw_path.iterdir()):
82
+ tar.add(str(item), arcname=item.name)
83
+ return archive
84
+
85
+
86
+ def _snapshot_reflink(vault_rw_path: Path, versions: Path, ts: str) -> Path:
87
+ """Create a snapshot using reflink (COW) copy."""
88
+ dest = versions / ts
89
+ subprocess.run(
90
+ ["cp", "--reflink=always", "-a", str(vault_rw_path), str(dest)],
91
+ check=True,
92
+ capture_output=True,
93
+ )
94
+ return dest
95
+
96
+
97
+ def _snapshot_hardlink(vault_rw_path: Path, versions: Path, ts: str) -> Path:
98
+ """Create a snapshot using hardlinks (fast for unchanged files)."""
99
+ dest = versions / ts
100
+ # Find the most recent directory snapshot for --link-dest.
101
+ existing = sorted(
102
+ (d for d in versions.iterdir() if d.is_dir()),
103
+ key=lambda p: p.name,
104
+ )
105
+ link_dest = existing[-1] if existing else None
106
+
107
+ cmd = ["rsync", "-a"]
108
+ if link_dest:
109
+ cmd.extend(["--link-dest", str(link_dest)])
110
+ cmd.extend([str(vault_rw_path) + "/", str(dest) + "/"])
111
+
112
+ try:
113
+ subprocess.run(cmd, check=True, capture_output=True)
114
+ except (subprocess.CalledProcessError, FileNotFoundError):
115
+ # rsync not available or failed -- fall back to regular copy.
116
+ shutil.copytree(vault_rw_path, dest)
117
+ return dest
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Public API
122
+ # ---------------------------------------------------------------------------
123
+
124
+
125
+ def create_snapshot(
126
+ vault_rw_path: Path, strategy: str = "tarxz",
127
+ ) -> Path | None:
128
+ """Create a snapshot using the given strategy.
129
+
130
+ Returns the path to the snapshot (archive or directory), or ``None`` if
131
+ the directory is empty (nothing to snapshot).
132
+ """
133
+ if not vault_rw_path.is_dir():
134
+ return None
135
+
136
+ # Don't snapshot an empty directory.
137
+ contents = list(vault_rw_path.iterdir())
138
+ if not contents:
139
+ return None
140
+
141
+ versions = _versions_dir(vault_rw_path)
142
+ versions.mkdir(parents=True, exist_ok=True)
143
+
144
+ ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
145
+
146
+ if strategy == "reflink":
147
+ return _snapshot_reflink(vault_rw_path, versions, ts)
148
+ elif strategy == "hardlink":
149
+ return _snapshot_hardlink(vault_rw_path, versions, ts)
150
+ else:
151
+ return _snapshot_tarxz(vault_rw_path, versions, ts)
152
+
153
+
154
+ def list_snapshots(vault_rw_path: Path) -> list[tuple[str, str, int]]:
155
+ """List snapshots for *vault_rw_path*.
156
+
157
+ Returns a list of ``(name, timestamp_iso, size_bytes)`` sorted by time
158
+ (oldest first). Both directory snapshots (reflink / hardlink) and
159
+ legacy tar.xz archives are included.
160
+ """
161
+ versions = _versions_dir(vault_rw_path)
162
+ if not versions.is_dir():
163
+ return []
164
+
165
+ snapshots: list[tuple[str, str, int]] = []
166
+ for entry in sorted(versions.iterdir()):
167
+ name = entry.name
168
+ if entry.is_dir():
169
+ # Directory snapshot (reflink or hardlink).
170
+ try:
171
+ dt = datetime.strptime(name, "%Y%m%dT%H%M%SZ")
172
+ ts_iso = dt.strftime("%Y-%m-%d %H:%M:%S UTC")
173
+ except ValueError:
174
+ ts_iso = name
175
+ # Approximate size.
176
+ try:
177
+ size = sum(
178
+ f.stat().st_size for f in entry.rglob("*") if f.is_file()
179
+ )
180
+ except Exception:
181
+ size = 0
182
+ snapshots.append((name, ts_iso, size))
183
+ elif entry.name.endswith(".tar.xz"):
184
+ # Legacy tar.xz snapshot.
185
+ stem = name.removesuffix(".tar.xz")
186
+ try:
187
+ dt = datetime.strptime(stem, "%Y%m%dT%H%M%SZ")
188
+ ts_iso = dt.strftime("%Y-%m-%d %H:%M:%S UTC")
189
+ except ValueError:
190
+ ts_iso = stem
191
+ size = entry.stat().st_size
192
+ snapshots.append((name, ts_iso, size))
193
+
194
+ return snapshots
195
+
196
+
197
+ def restore_snapshot(vault_rw_path: Path, snapshot_name: str) -> None:
198
+ """Restore *vault_rw_path* from the named snapshot.
199
+
200
+ Handles both directory snapshots and legacy tar.xz archives. The
201
+ current contents of share-rw are replaced with the snapshot contents.
202
+ Raises ``FileNotFoundError`` if the snapshot does not exist.
203
+ """
204
+ versions = _versions_dir(vault_rw_path)
205
+ snapshot = versions / snapshot_name
206
+
207
+ if snapshot.is_dir():
208
+ # Directory snapshot (reflink or hardlink).
209
+ if vault_rw_path.is_dir():
210
+ for item in vault_rw_path.iterdir():
211
+ if item.is_dir():
212
+ shutil.rmtree(item)
213
+ else:
214
+ item.unlink()
215
+ vault_rw_path.mkdir(parents=True, exist_ok=True)
216
+ # Copy contents.
217
+ for item in snapshot.iterdir():
218
+ dest = vault_rw_path / item.name
219
+ if item.is_dir():
220
+ shutil.copytree(item, dest)
221
+ else:
222
+ shutil.copy2(item, dest)
223
+ elif snapshot.is_file() and snapshot_name.endswith(".tar.xz"):
224
+ # Legacy tar.xz.
225
+ if vault_rw_path.is_dir():
226
+ for item in vault_rw_path.iterdir():
227
+ if item.is_dir():
228
+ shutil.rmtree(item)
229
+ else:
230
+ item.unlink()
231
+ with tarfile.open(snapshot, "r:xz") as tar:
232
+ tar.extractall(path=str(vault_rw_path), filter="data")
233
+ else:
234
+ raise FileNotFoundError(f"Snapshot not found: {snapshot_name}")
235
+
236
+
237
+ def prune_snapshots(
238
+ vault_rw_path: Path, max_keep: int = _DEFAULT_MAX_SNAPSHOTS,
239
+ ) -> int:
240
+ """Remove old snapshots, keeping at most *max_keep*.
241
+
242
+ Handles both directory snapshots and legacy tar.xz archives.
243
+ Returns the number of snapshots removed.
244
+ """
245
+ versions = _versions_dir(vault_rw_path)
246
+ if not versions.is_dir():
247
+ return 0
248
+
249
+ # Collect all snapshots (dirs and tar.xz files).
250
+ all_snapshots = sorted(
251
+ (
252
+ f
253
+ for f in versions.iterdir()
254
+ if f.is_dir() or f.name.endswith(".tar.xz")
255
+ ),
256
+ key=lambda p: p.name.removesuffix(".tar.xz"),
257
+ )
258
+ to_remove = all_snapshots[:-max_keep] if len(all_snapshots) > max_keep else []
259
+ for old in to_remove:
260
+ if old.is_dir():
261
+ shutil.rmtree(old)
262
+ else:
263
+ old.unlink()
264
+ return len(to_remove)
265
+
266
+
267
+ def auto_snapshot(
268
+ vault_rw_path: Path,
269
+ *,
270
+ strategy: str = "tarxz",
271
+ max_keep: int = _DEFAULT_MAX_SNAPSHOTS,
272
+ ) -> Path | None:
273
+ """Create a snapshot and prune old ones.
274
+
275
+ Convenience wrapper combining ``create_snapshot`` + ``prune_snapshots``.
276
+ Returns the new snapshot path, or ``None`` if share-rw was empty.
277
+ """
278
+ result = create_snapshot(vault_rw_path, strategy=strategy)
279
+ if result is not None:
280
+ prune_snapshots(vault_rw_path, max_keep=max_keep)
281
+ return result