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.
- kanibako/__init__.py +3 -0
- kanibako/__main__.py +6 -0
- kanibako/auth_browser.py +296 -0
- kanibako/auth_parser.py +51 -0
- kanibako/browser_sidecar.py +183 -0
- kanibako/browser_state.py +103 -0
- kanibako/bun_sea.py +144 -0
- kanibako/cli.py +344 -0
- kanibako/commands/__init__.py +0 -0
- kanibako/commands/archive.py +228 -0
- kanibako/commands/box/__init__.py +22 -0
- kanibako/commands/box/_duplicate.py +395 -0
- kanibako/commands/box/_migrate.py +574 -0
- kanibako/commands/box/_parser.py +1178 -0
- kanibako/commands/clean.py +166 -0
- kanibako/commands/crab_cmd.py +480 -0
- kanibako/commands/diagnose.py +239 -0
- kanibako/commands/fork_cmd.py +51 -0
- kanibako/commands/helper_cmd.py +669 -0
- kanibako/commands/image.py +1300 -0
- kanibako/commands/install.py +152 -0
- kanibako/commands/refresh_credentials.py +67 -0
- kanibako/commands/restore.py +298 -0
- kanibako/commands/setup_cmd.py +89 -0
- kanibako/commands/start.py +1600 -0
- kanibako/commands/stop.py +116 -0
- kanibako/commands/system_cmd.py +224 -0
- kanibako/commands/upgrade.py +161 -0
- kanibako/commands/vault_cmd.py +199 -0
- kanibako/commands/workset_cmd.py +552 -0
- kanibako/config.py +514 -0
- kanibako/config_interface.py +573 -0
- kanibako/config_io.py +36 -0
- kanibako/container.py +607 -0
- kanibako/containerfiles.py +58 -0
- kanibako/containers/Containerfile.kanibako +99 -0
- kanibako/containers/Containerfile.template-android +55 -0
- kanibako/containers/Containerfile.template-dotnet +29 -0
- kanibako/containers/Containerfile.template-js +43 -0
- kanibako/containers/Containerfile.template-jvm +27 -0
- kanibako/containers/Containerfile.template-systems +46 -0
- kanibako/containers/__init__.py +0 -0
- kanibako/crabs.py +89 -0
- kanibako/errors.py +33 -0
- kanibako/freshness.py +67 -0
- kanibako/git.py +114 -0
- kanibako/helper_client.py +132 -0
- kanibako/helper_listener.py +538 -0
- kanibako/helpers.py +339 -0
- kanibako/hygiene.py +296 -0
- kanibako/image_sharing.py +133 -0
- kanibako/instructions.py +160 -0
- kanibako/log.py +31 -0
- kanibako/names.py +248 -0
- kanibako/paths.py +1483 -0
- kanibako/plugins/__init__.py +10 -0
- kanibako/registry.py +71 -0
- kanibako/rig_bundle.py +121 -0
- kanibako/rig_meta.py +92 -0
- kanibako/rig_registry.py +132 -0
- kanibako/rig_resolve.py +182 -0
- kanibako/rig_source.py +245 -0
- kanibako/scripts/__init__.py +0 -0
- kanibako/scripts/helper-init.sh +45 -0
- kanibako/scripts/kanibako-entry +12 -0
- kanibako/settings_resolve.py +312 -0
- kanibako/settings_seeds.py +154 -0
- kanibako/settings_shares.py +154 -0
- kanibako/shellenv.py +75 -0
- kanibako/snapshots.py +281 -0
- kanibako/targets/__init__.py +173 -0
- kanibako/targets/base.py +243 -0
- kanibako/targets/no_agent.py +58 -0
- kanibako/templates.py +60 -0
- kanibako/templates_image.py +224 -0
- kanibako/tweakcc.py +140 -0
- kanibako/tweakcc_cache.py +171 -0
- kanibako/utils.py +136 -0
- kanibako/workset.py +347 -0
- kanibako_cli-1.5.0.dev14.dist-info/METADATA +15 -0
- kanibako_cli-1.5.0.dev14.dist-info/RECORD +85 -0
- kanibako_cli-1.5.0.dev14.dist-info/WHEEL +5 -0
- kanibako_cli-1.5.0.dev14.dist-info/entry_points.txt +5 -0
- kanibako_cli-1.5.0.dev14.dist-info/licenses/LICENSE.md +594 -0
- 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
|