mystack-rig 1.0.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.
- cli/__init__.py +6 -0
- cli/__main__.py +8 -0
- cli/agents.py +575 -0
- cli/config.py +264 -0
- cli/encrypt.py +1182 -0
- cli/git.py +317 -0
- cli/last_env.py +64 -0
- cli/last_session.py +171 -0
- cli/log.py +105 -0
- cli/main.py +1234 -0
- cli/remote_dev.py +5547 -0
- cli/requirements.txt +4 -0
- cli/snapshots.py +627 -0
- cli/storage.py +348 -0
- cli/vault_keys.py +451 -0
- cli/vpn.py +323 -0
- mystack_rig-1.0.0.dist-info/METADATA +285 -0
- mystack_rig-1.0.0.dist-info/RECORD +22 -0
- mystack_rig-1.0.0.dist-info/WHEEL +5 -0
- mystack_rig-1.0.0.dist-info/entry_points.txt +2 -0
- mystack_rig-1.0.0.dist-info/licenses/LICENSE +21 -0
- mystack_rig-1.0.0.dist-info/top_level.txt +1 -0
cli/requirements.txt
ADDED
cli/snapshots.py
ADDED
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
"""Hetzner VM snapshot naming and content hashing for rig cache."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
from cli import DISTRIBUTION_NAME
|
|
12
|
+
|
|
13
|
+
SYSTEM_LABEL = "system"
|
|
14
|
+
WORKSPACE_LABEL = "workspace"
|
|
15
|
+
RIG_LABEL_KEY = "rig"
|
|
16
|
+
RIG_WORKSPACE_LABEL_KEY = "rig_workspace"
|
|
17
|
+
RIG_VERSION_LABEL_KEY = "rig_version"
|
|
18
|
+
RIG_HASH_LABEL_KEY = "rig_hash"
|
|
19
|
+
# Optional metadata labels (not used for matching; hash is authoritative).
|
|
20
|
+
RIG_AGENT_LABEL_KEY = "rig_agent"
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Variable maps: only these project/vars keys influence snapshot identity.
|
|
24
|
+
# Secrets (PATs, API keys, vault blobs) are applied by authorize.yml and must
|
|
25
|
+
# NOT appear here — changing secrets must not invalidate system snapshots;
|
|
26
|
+
# re-run authorize instead.
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
# Values that change what system.yml installs (agent CLIs, gh/glab, code-server).
|
|
30
|
+
SYSTEM_SNAPSHOT_VAR_KEYS: Tuple[str, ...] = (
|
|
31
|
+
"coding_agent_type",
|
|
32
|
+
"coding_agent_types",
|
|
33
|
+
"git_providers",
|
|
34
|
+
"code_server_port",
|
|
35
|
+
"code_server_user",
|
|
36
|
+
"workspace_root",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Values that change what workspace.yml configures (clone layout, root path).
|
|
40
|
+
# ``git`` is the clone_repos structure (repo URLs / dests / password_var *names*
|
|
41
|
+
# only — not secret values).
|
|
42
|
+
WORKSPACE_SNAPSHOT_VAR_KEYS: Tuple[str, ...] = (
|
|
43
|
+
"workspace_root",
|
|
44
|
+
"git",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def package_version() -> str:
|
|
49
|
+
"""Installed package version (fallback ``0.0.0``)."""
|
|
50
|
+
try:
|
|
51
|
+
from importlib.metadata import version as pkg_version
|
|
52
|
+
return pkg_version(DISTRIBUTION_NAME)
|
|
53
|
+
except Exception:
|
|
54
|
+
try:
|
|
55
|
+
from importlib_metadata import version as pkg_version_fallback
|
|
56
|
+
return pkg_version_fallback(DISTRIBUTION_NAME)
|
|
57
|
+
except Exception:
|
|
58
|
+
return "0.0.0"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def sanitize_snapshot_token(value: str, *, max_len: int = 48) -> str:
|
|
62
|
+
"""Keep Hetzner-friendly description tokens (alnum, dash, dot, underscore)."""
|
|
63
|
+
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", (value or "").strip())
|
|
64
|
+
cleaned = re.sub(r"-{2,}", "-", cleaned).strip("-._")
|
|
65
|
+
return (cleaned or "unnamed")[:max_len]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _canonical_json(value: Any) -> str:
|
|
69
|
+
return json.dumps(value, sort_keys=True, default=str, separators=(",", ":"))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _project_merged_vars(project) -> Dict[str, Any]:
|
|
73
|
+
"""Merge rig.yml + vars for snapshot map extraction (no personal vault files)."""
|
|
74
|
+
cfg = dict(getattr(project, "config", None) or {})
|
|
75
|
+
vars_cfg = dict(getattr(project, "vars_config", None) or {})
|
|
76
|
+
merged = {**vars_cfg, **{k: v for k, v in cfg.items() if k not in ("playbooks",)}}
|
|
77
|
+
# Prefer agent.default / agent.enabled over the vars copies when both exist.
|
|
78
|
+
agent = cfg.get("agent") or {}
|
|
79
|
+
if isinstance(agent, dict):
|
|
80
|
+
default_agent = agent.get("default")
|
|
81
|
+
if default_agent in (None, "") or isinstance(default_agent, (list, tuple)):
|
|
82
|
+
default_agent = agent.get("type")
|
|
83
|
+
if default_agent and not isinstance(default_agent, (list, tuple)):
|
|
84
|
+
merged["coding_agent_type"] = default_agent
|
|
85
|
+
enabled_agents = agent.get("enabled") or agent.get("types")
|
|
86
|
+
if enabled_agents:
|
|
87
|
+
merged["coding_agent_types"] = list(enabled_agents)
|
|
88
|
+
if "git_providers" not in merged and cfg.get("git_providers"):
|
|
89
|
+
merged["git_providers"] = cfg["git_providers"]
|
|
90
|
+
return merged
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def extract_snapshot_var_map(
|
|
94
|
+
project,
|
|
95
|
+
keys: Iterable[str],
|
|
96
|
+
) -> Dict[str, Any]:
|
|
97
|
+
"""Pull the declared snapshot keys from a loaded project."""
|
|
98
|
+
merged = _project_merged_vars(project)
|
|
99
|
+
out: Dict[str, Any] = {}
|
|
100
|
+
for key in keys:
|
|
101
|
+
if key not in merged:
|
|
102
|
+
continue
|
|
103
|
+
value = merged[key]
|
|
104
|
+
# Stabilize list order for providers / similar bags of strings.
|
|
105
|
+
if key in ("git_providers", "coding_agent_types") and isinstance(value, list):
|
|
106
|
+
value = sorted({str(p).strip().lower() for p in value if str(p).strip()})
|
|
107
|
+
out[key] = value
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def system_hash_inputs_from_project(project) -> Dict[str, Any]:
|
|
112
|
+
"""Backward-compatible alias: system var map (+ agent for labels)."""
|
|
113
|
+
var_map = extract_snapshot_var_map(project, SYSTEM_SNAPSHOT_VAR_KEYS)
|
|
114
|
+
types = var_map.get("coding_agent_types")
|
|
115
|
+
if isinstance(types, list) and len(types) > 1:
|
|
116
|
+
agent_label = "+".join(str(item) for item in types)
|
|
117
|
+
else:
|
|
118
|
+
agent_label = str(var_map.get("coding_agent_type") or "opencode")
|
|
119
|
+
return {
|
|
120
|
+
"var_map": var_map,
|
|
121
|
+
"agent_type": agent_label,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _hash_bytes(*parts: bytes) -> str:
|
|
126
|
+
digest = hashlib.sha256()
|
|
127
|
+
for part in parts:
|
|
128
|
+
digest.update(part)
|
|
129
|
+
digest.update(b"\0")
|
|
130
|
+
return digest.hexdigest()[:12]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _local_roles_bytes(project_dir: Optional[str]) -> bytes:
|
|
134
|
+
"""Stable digest of committed roles. Galaxy installs are not included."""
|
|
135
|
+
if not project_dir:
|
|
136
|
+
return b""
|
|
137
|
+
root = os.path.join(project_dir, ".rig", "local_roles")
|
|
138
|
+
if not os.path.isdir(root):
|
|
139
|
+
return b""
|
|
140
|
+
chunks: List[bytes] = []
|
|
141
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
142
|
+
dirnames.sort()
|
|
143
|
+
for name in sorted(filenames):
|
|
144
|
+
if name == ".gitkeep":
|
|
145
|
+
continue
|
|
146
|
+
path = os.path.join(dirpath, name)
|
|
147
|
+
rel = os.path.relpath(path, root).replace(os.sep, "/")
|
|
148
|
+
chunks.append(rel.encode("utf-8"))
|
|
149
|
+
chunks.append(_read_text(path).encode("utf-8"))
|
|
150
|
+
return b"\0".join(chunks)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _read_text(path: str) -> str:
|
|
154
|
+
try:
|
|
155
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
156
|
+
return handle.read()
|
|
157
|
+
except OSError:
|
|
158
|
+
return ""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def system_content_hash(
|
|
162
|
+
project=None,
|
|
163
|
+
*,
|
|
164
|
+
var_map: Optional[Dict[str, Any]] = None,
|
|
165
|
+
playbook_body: Optional[str] = None,
|
|
166
|
+
project_dir: Optional[str] = None,
|
|
167
|
+
# Legacy kwargs kept for older call sites / tests:
|
|
168
|
+
agent_type: Optional[str] = None,
|
|
169
|
+
editor: Optional[str] = None,
|
|
170
|
+
git_providers: Optional[Iterable[str]] = None,
|
|
171
|
+
) -> str:
|
|
172
|
+
"""
|
|
173
|
+
Hash for system snapshots: var map + ``provision.yml`` + ``configure.yml``.
|
|
174
|
+
|
|
175
|
+
Committed roles under ``.rig/local_roles`` are included when present.
|
|
176
|
+
``.gitkeep`` alone does not change the hash. ``editor`` is ignored
|
|
177
|
+
(local IDE only; not installed by system.yml).
|
|
178
|
+
"""
|
|
179
|
+
if var_map is None and project is not None:
|
|
180
|
+
var_map = extract_snapshot_var_map(project, SYSTEM_SNAPSHOT_VAR_KEYS)
|
|
181
|
+
if var_map is None:
|
|
182
|
+
var_map = {}
|
|
183
|
+
if agent_type:
|
|
184
|
+
var_map["coding_agent_type"] = agent_type
|
|
185
|
+
if git_providers is not None:
|
|
186
|
+
var_map["git_providers"] = list(git_providers)
|
|
187
|
+
|
|
188
|
+
body = playbook_body
|
|
189
|
+
configure_body = ""
|
|
190
|
+
root = project_dir
|
|
191
|
+
if root is None and project is not None:
|
|
192
|
+
root = getattr(project, "project_dir", None)
|
|
193
|
+
if body is None:
|
|
194
|
+
if root:
|
|
195
|
+
body = _read_text(os.path.join(root, ".rig", "provision.yml"))
|
|
196
|
+
if not body:
|
|
197
|
+
body = _read_text(os.path.join(root, ".rig", "system.yml"))
|
|
198
|
+
configure_body = _read_text(os.path.join(root, ".rig", "configure.yml"))
|
|
199
|
+
if not configure_body:
|
|
200
|
+
configure_body = _read_text(os.path.join(root, ".rig", "services.yml"))
|
|
201
|
+
if not body:
|
|
202
|
+
try:
|
|
203
|
+
from cli.remote_dev import PROVISION_PLAYBOOK
|
|
204
|
+
body = PROVISION_PLAYBOOK
|
|
205
|
+
except Exception:
|
|
206
|
+
body = ""
|
|
207
|
+
if not configure_body:
|
|
208
|
+
try:
|
|
209
|
+
from cli.remote_dev import CONFIGURE_PLAYBOOK
|
|
210
|
+
configure_body = CONFIGURE_PLAYBOOK
|
|
211
|
+
except Exception:
|
|
212
|
+
configure_body = ""
|
|
213
|
+
|
|
214
|
+
local_roles = _local_roles_bytes(root)
|
|
215
|
+
parts = [
|
|
216
|
+
b"system-vars",
|
|
217
|
+
_canonical_json(var_map).encode("utf-8"),
|
|
218
|
+
b"provision-playbook",
|
|
219
|
+
(body or "").encode("utf-8"),
|
|
220
|
+
b"configure-playbook",
|
|
221
|
+
(configure_body or "").encode("utf-8"),
|
|
222
|
+
]
|
|
223
|
+
if local_roles:
|
|
224
|
+
parts.extend([b"local-roles", local_roles])
|
|
225
|
+
return _hash_bytes(*parts)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def iter_workspace_hash_paths(project_dir: str) -> List[str]:
|
|
229
|
+
"""
|
|
230
|
+
Files that feed the workspace snapshot hash (for tests / debugging).
|
|
231
|
+
|
|
232
|
+
Includes ``materialize.yml`` and shared ``vars.yml`` only (legacy helper;
|
|
233
|
+
materialize-tier snapshots are no longer created). Personal identity vars
|
|
234
|
+
and ``authorize.yml`` / ``provision.yml`` are excluded.
|
|
235
|
+
"""
|
|
236
|
+
project_dir = os.path.abspath(project_dir)
|
|
237
|
+
paths: List[str] = []
|
|
238
|
+
for rel in (".rig/materialize.yml", ".rig/vars.yml"):
|
|
239
|
+
path = os.path.join(project_dir, rel)
|
|
240
|
+
if os.path.isfile(path):
|
|
241
|
+
paths.append(path)
|
|
242
|
+
return paths
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def workspace_content_hash(project_dir: str, project=None) -> str:
|
|
246
|
+
"""
|
|
247
|
+
Legacy hash helper for materialize content + shared vars.
|
|
248
|
+
|
|
249
|
+
New creates no longer mint workspace-tier snapshots; kept for cache listing
|
|
250
|
+
of older ``workspace-*`` images.
|
|
251
|
+
"""
|
|
252
|
+
project_dir = os.path.abspath(project_dir)
|
|
253
|
+
if project is None:
|
|
254
|
+
try:
|
|
255
|
+
from cli.remote_dev import ProjectConfig
|
|
256
|
+
project = ProjectConfig(project_dir)
|
|
257
|
+
project.load()
|
|
258
|
+
except Exception:
|
|
259
|
+
project = None
|
|
260
|
+
|
|
261
|
+
var_map: Dict[str, Any] = {}
|
|
262
|
+
if project is not None:
|
|
263
|
+
var_map = extract_snapshot_var_map(project, WORKSPACE_SNAPSHOT_VAR_KEYS)
|
|
264
|
+
# Name from rig.yml is part of workspace identity for clones/layout.
|
|
265
|
+
cfg = getattr(project, "config", None) or {}
|
|
266
|
+
if cfg.get("name"):
|
|
267
|
+
var_map = {**var_map, "name": cfg["name"]}
|
|
268
|
+
if cfg.get("repos") is not None:
|
|
269
|
+
var_map = {**var_map, "repos": cfg["repos"]}
|
|
270
|
+
|
|
271
|
+
materialize_yml = _read_text(os.path.join(project_dir, ".rig", "materialize.yml"))
|
|
272
|
+
if not materialize_yml:
|
|
273
|
+
materialize_yml = _read_text(os.path.join(project_dir, ".rig", "workspace.yml"))
|
|
274
|
+
vars_yml = _read_text(os.path.join(project_dir, ".rig", "vars.yml"))
|
|
275
|
+
|
|
276
|
+
return _hash_bytes(
|
|
277
|
+
b"workspace-vars",
|
|
278
|
+
_canonical_json(var_map).encode("utf-8"),
|
|
279
|
+
b"materialize-playbook",
|
|
280
|
+
materialize_yml.encode("utf-8"),
|
|
281
|
+
b"shared-vars",
|
|
282
|
+
vars_yml.encode("utf-8"),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def system_snapshot_name(
|
|
287
|
+
version: Optional[str] = None,
|
|
288
|
+
content_hash: Optional[str] = None,
|
|
289
|
+
**kwargs: Any,
|
|
290
|
+
) -> str:
|
|
291
|
+
"""Snapshot description after configure (build layer): ``rig-{version}-{hash}``."""
|
|
292
|
+
ver = sanitize_snapshot_token(version or package_version(), max_len=32)
|
|
293
|
+
digest = content_hash or system_content_hash(**kwargs)
|
|
294
|
+
digest = sanitize_snapshot_token(digest, max_len=16)
|
|
295
|
+
return f"rig-{ver}-{digest}"
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def workspace_snapshot_name(workspace_name: str, content_hash: str) -> str:
|
|
299
|
+
"""Snapshot description after workspace.yml: ``workspace-{name}-{hash}``."""
|
|
300
|
+
name = sanitize_snapshot_token(workspace_name, max_len=40)
|
|
301
|
+
digest = sanitize_snapshot_token(content_hash, max_len=16)
|
|
302
|
+
return f"workspace-{name}-{digest}"
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def system_snapshot_labels(
|
|
306
|
+
version: Optional[str] = None,
|
|
307
|
+
content_hash: Optional[str] = None,
|
|
308
|
+
*,
|
|
309
|
+
agent_type: Optional[str] = None,
|
|
310
|
+
**kwargs: Any,
|
|
311
|
+
) -> Dict[str, str]:
|
|
312
|
+
ver = sanitize_snapshot_token(version or package_version(), max_len=32)
|
|
313
|
+
digest = content_hash or system_content_hash(
|
|
314
|
+
agent_type=agent_type, **kwargs
|
|
315
|
+
)
|
|
316
|
+
labels = {
|
|
317
|
+
RIG_LABEL_KEY: SYSTEM_LABEL,
|
|
318
|
+
RIG_VERSION_LABEL_KEY: ver,
|
|
319
|
+
RIG_HASH_LABEL_KEY: sanitize_snapshot_token(digest, max_len=16),
|
|
320
|
+
}
|
|
321
|
+
if agent_type:
|
|
322
|
+
labels[RIG_AGENT_LABEL_KEY] = sanitize_snapshot_token(
|
|
323
|
+
str(agent_type), max_len=16
|
|
324
|
+
)
|
|
325
|
+
return labels
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def workspace_snapshot_labels(workspace_name: str, content_hash: str) -> Dict[str, str]:
|
|
329
|
+
return {
|
|
330
|
+
RIG_LABEL_KEY: WORKSPACE_LABEL,
|
|
331
|
+
RIG_WORKSPACE_LABEL_KEY: sanitize_snapshot_token(workspace_name, max_len=40),
|
|
332
|
+
RIG_HASH_LABEL_KEY: sanitize_snapshot_token(content_hash, max_len=16),
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def is_system_snapshot(
|
|
337
|
+
image: Dict[str, Any],
|
|
338
|
+
version: Optional[str] = None,
|
|
339
|
+
content_hash: Optional[str] = None,
|
|
340
|
+
) -> bool:
|
|
341
|
+
labels = image.get("labels") or {}
|
|
342
|
+
description = image.get("description") or ""
|
|
343
|
+
if labels.get(RIG_LABEL_KEY) == SYSTEM_LABEL:
|
|
344
|
+
if version is None and content_hash is None:
|
|
345
|
+
return True
|
|
346
|
+
if version is not None and labels.get(
|
|
347
|
+
RIG_VERSION_LABEL_KEY
|
|
348
|
+
) != sanitize_snapshot_token(version, max_len=32):
|
|
349
|
+
return False
|
|
350
|
+
if content_hash is not None and labels.get(
|
|
351
|
+
RIG_HASH_LABEL_KEY
|
|
352
|
+
) != sanitize_snapshot_token(content_hash, max_len=16):
|
|
353
|
+
return False
|
|
354
|
+
return True
|
|
355
|
+
expected = (
|
|
356
|
+
system_snapshot_name(version, content_hash=content_hash)
|
|
357
|
+
if version and content_hash
|
|
358
|
+
else None
|
|
359
|
+
)
|
|
360
|
+
if expected:
|
|
361
|
+
return description == expected
|
|
362
|
+
return bool(re.match(r"^rig-[A-Za-z0-9._-]+$", description))
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def is_workspace_snapshot(
|
|
366
|
+
image: Dict[str, Any],
|
|
367
|
+
workspace_name: Optional[str] = None,
|
|
368
|
+
) -> bool:
|
|
369
|
+
labels = image.get("labels") or {}
|
|
370
|
+
description = image.get("description") or ""
|
|
371
|
+
if labels.get(RIG_LABEL_KEY) == WORKSPACE_LABEL:
|
|
372
|
+
if workspace_name is None:
|
|
373
|
+
return True
|
|
374
|
+
return labels.get(RIG_WORKSPACE_LABEL_KEY) == sanitize_snapshot_token(
|
|
375
|
+
workspace_name, max_len=40
|
|
376
|
+
)
|
|
377
|
+
if workspace_name:
|
|
378
|
+
prefix = f"workspace-{sanitize_snapshot_token(workspace_name, max_len=40)}-"
|
|
379
|
+
return description.startswith(prefix)
|
|
380
|
+
return description.startswith("workspace-")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def filter_snapshots(
|
|
384
|
+
images: Iterable[Dict[str, Any]],
|
|
385
|
+
*,
|
|
386
|
+
system: bool = False,
|
|
387
|
+
workspace_name: Optional[str] = None,
|
|
388
|
+
version: Optional[str] = None,
|
|
389
|
+
) -> List[Dict[str, Any]]:
|
|
390
|
+
"""Filter snapshot images by kind (legacy helper for broad selects)."""
|
|
391
|
+
selected: List[Dict[str, Any]] = []
|
|
392
|
+
for image in images:
|
|
393
|
+
if image.get("type") and image.get("type") != "snapshot":
|
|
394
|
+
continue
|
|
395
|
+
if system:
|
|
396
|
+
if is_system_snapshot(image, version=None):
|
|
397
|
+
selected.append(image)
|
|
398
|
+
elif workspace_name is not None:
|
|
399
|
+
if is_workspace_snapshot(image, workspace_name=workspace_name):
|
|
400
|
+
selected.append(image)
|
|
401
|
+
return selected
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
_TRAILING_HASH_RE = re.compile(r"-([A-Za-z0-9]{8,16})$")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _trailing_hash(description: str) -> Optional[str]:
|
|
408
|
+
match = _TRAILING_HASH_RE.search(description or "")
|
|
409
|
+
return match.group(1) if match else None
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _parse_system_description(description: str) -> Tuple[Optional[str], Optional[str]]:
|
|
413
|
+
"""Return ``(version, hash)`` from ``rig-{version}`` or ``rig-{version}-{hash}``."""
|
|
414
|
+
if not (description or "").startswith("rig-"):
|
|
415
|
+
return None, None
|
|
416
|
+
rest = description[4:]
|
|
417
|
+
digest = _trailing_hash(description)
|
|
418
|
+
if digest and rest.endswith(f"-{digest}"):
|
|
419
|
+
return rest[: -(len(digest) + 1)] or None, digest
|
|
420
|
+
return rest or None, None
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _parse_workspace_description(
|
|
424
|
+
description: str,
|
|
425
|
+
) -> Tuple[Optional[str], Optional[str]]:
|
|
426
|
+
"""Return ``(workspace_name, hash)`` from ``workspace-{name}-{hash}``."""
|
|
427
|
+
if not (description or "").startswith("workspace-"):
|
|
428
|
+
return None, None
|
|
429
|
+
rest = description.removeprefix("workspace-")
|
|
430
|
+
digest = _trailing_hash(description)
|
|
431
|
+
if digest and rest.endswith(f"-{digest}"):
|
|
432
|
+
return rest[: -(len(digest) + 1)] or None, digest
|
|
433
|
+
return rest or None, None
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def describe_snapshot(image: Dict[str, Any]) -> Dict[str, Any]:
|
|
437
|
+
"""
|
|
438
|
+
Normalize a Hetzner image into ``kind`` / identity fields for cache logic.
|
|
439
|
+
|
|
440
|
+
Returns keys: ``kind`` (``system``|``workspace``|``other``), ``version``,
|
|
441
|
+
``workspace``, ``hash``, ``description``, ``id``.
|
|
442
|
+
"""
|
|
443
|
+
labels = image.get("labels") or {}
|
|
444
|
+
description = str(image.get("description") or "")
|
|
445
|
+
kind = "other"
|
|
446
|
+
version = labels.get(RIG_VERSION_LABEL_KEY)
|
|
447
|
+
workspace = labels.get(RIG_WORKSPACE_LABEL_KEY)
|
|
448
|
+
digest = labels.get(RIG_HASH_LABEL_KEY)
|
|
449
|
+
|
|
450
|
+
if labels.get(RIG_LABEL_KEY) == SYSTEM_LABEL or description.startswith("rig-"):
|
|
451
|
+
kind = "system"
|
|
452
|
+
parsed_ver, parsed_hash = _parse_system_description(description)
|
|
453
|
+
version = version or parsed_ver
|
|
454
|
+
digest = digest or parsed_hash
|
|
455
|
+
elif labels.get(RIG_LABEL_KEY) == WORKSPACE_LABEL or description.startswith(
|
|
456
|
+
"workspace-"
|
|
457
|
+
):
|
|
458
|
+
kind = "workspace"
|
|
459
|
+
parsed_ws, parsed_hash = _parse_workspace_description(description)
|
|
460
|
+
workspace = workspace or parsed_ws
|
|
461
|
+
digest = digest or parsed_hash
|
|
462
|
+
|
|
463
|
+
return {
|
|
464
|
+
"kind": kind,
|
|
465
|
+
"version": sanitize_snapshot_token(version, max_len=32) if version else None,
|
|
466
|
+
"workspace": (
|
|
467
|
+
sanitize_snapshot_token(workspace, max_len=40) if workspace else None
|
|
468
|
+
),
|
|
469
|
+
"hash": sanitize_snapshot_token(digest, max_len=16) if digest else None,
|
|
470
|
+
"description": description or str(image.get("id") or ""),
|
|
471
|
+
"id": image.get("id"),
|
|
472
|
+
"image": image,
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def find_unused_snapshots(
|
|
477
|
+
images: Iterable[Dict[str, Any]],
|
|
478
|
+
*,
|
|
479
|
+
current_version: Optional[str] = None,
|
|
480
|
+
current_system_hash: Optional[str] = None,
|
|
481
|
+
current_workspace_name: Optional[str] = None,
|
|
482
|
+
current_workspace_hash: Optional[str] = None,
|
|
483
|
+
include_system: bool = True,
|
|
484
|
+
include_workspace: bool = True,
|
|
485
|
+
) -> Dict[str, List[Dict[str, Any]]]:
|
|
486
|
+
"""
|
|
487
|
+
Split rig snapshots into ``keep`` (current) and ``unused`` (stale).
|
|
488
|
+
|
|
489
|
+
Unused means a different version and/or hash than the current project would
|
|
490
|
+
boot from. Workspace unused are limited to ``current_workspace_name`` when
|
|
491
|
+
set; system unused cover any older version or non-matching system hash.
|
|
492
|
+
"""
|
|
493
|
+
keep: List[Dict[str, Any]] = []
|
|
494
|
+
unused: List[Dict[str, Any]] = []
|
|
495
|
+
cur_ver = (
|
|
496
|
+
sanitize_snapshot_token(current_version, max_len=32) if current_version else None
|
|
497
|
+
)
|
|
498
|
+
cur_sys = (
|
|
499
|
+
sanitize_snapshot_token(current_system_hash, max_len=16)
|
|
500
|
+
if current_system_hash
|
|
501
|
+
else None
|
|
502
|
+
)
|
|
503
|
+
cur_ws = (
|
|
504
|
+
sanitize_snapshot_token(current_workspace_name, max_len=40)
|
|
505
|
+
if current_workspace_name
|
|
506
|
+
else None
|
|
507
|
+
)
|
|
508
|
+
cur_ws_hash = (
|
|
509
|
+
sanitize_snapshot_token(current_workspace_hash, max_len=16)
|
|
510
|
+
if current_workspace_hash
|
|
511
|
+
else None
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
for image in images:
|
|
515
|
+
if image.get("type") and image.get("type") != "snapshot":
|
|
516
|
+
continue
|
|
517
|
+
meta = describe_snapshot(image)
|
|
518
|
+
kind = meta["kind"]
|
|
519
|
+
if kind == "system" and include_system:
|
|
520
|
+
is_current = bool(
|
|
521
|
+
cur_ver
|
|
522
|
+
and cur_sys
|
|
523
|
+
and meta.get("version") == cur_ver
|
|
524
|
+
and meta.get("hash") == cur_sys
|
|
525
|
+
)
|
|
526
|
+
if is_current:
|
|
527
|
+
meta["reason"] = "current system snapshot"
|
|
528
|
+
keep.append(meta)
|
|
529
|
+
else:
|
|
530
|
+
reasons = []
|
|
531
|
+
if cur_ver and meta.get("version") and meta["version"] != cur_ver:
|
|
532
|
+
reasons.append(f"version {meta['version']} != {cur_ver}")
|
|
533
|
+
elif not meta.get("version"):
|
|
534
|
+
reasons.append("legacy system snapshot")
|
|
535
|
+
if cur_sys and meta.get("hash") and meta["hash"] != cur_sys:
|
|
536
|
+
reasons.append(f"hash {meta['hash']} != {cur_sys}")
|
|
537
|
+
elif cur_sys and not meta.get("hash"):
|
|
538
|
+
reasons.append("missing system hash")
|
|
539
|
+
elif not cur_sys:
|
|
540
|
+
reasons.append("system snapshot")
|
|
541
|
+
meta["reason"] = "; ".join(reasons) or "not current"
|
|
542
|
+
unused.append(meta)
|
|
543
|
+
elif kind == "workspace" and include_workspace:
|
|
544
|
+
if cur_ws and meta.get("workspace") != cur_ws:
|
|
545
|
+
continue
|
|
546
|
+
is_current = bool(
|
|
547
|
+
cur_ws
|
|
548
|
+
and cur_ws_hash
|
|
549
|
+
and meta.get("workspace") == cur_ws
|
|
550
|
+
and meta.get("hash") == cur_ws_hash
|
|
551
|
+
)
|
|
552
|
+
if is_current:
|
|
553
|
+
meta["reason"] = "current workspace snapshot"
|
|
554
|
+
keep.append(meta)
|
|
555
|
+
else:
|
|
556
|
+
reasons = []
|
|
557
|
+
if cur_ws_hash and meta.get("hash") and meta["hash"] != cur_ws_hash:
|
|
558
|
+
reasons.append(f"hash {meta['hash']} != {cur_ws_hash}")
|
|
559
|
+
elif cur_ws_hash and not meta.get("hash"):
|
|
560
|
+
reasons.append("missing workspace hash")
|
|
561
|
+
else:
|
|
562
|
+
reasons.append("not current workspace snapshot")
|
|
563
|
+
meta["reason"] = "; ".join(reasons)
|
|
564
|
+
unused.append(meta)
|
|
565
|
+
|
|
566
|
+
return {"keep": keep, "unused": unused}
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _snapshot_usable(image: Dict[str, Any]) -> bool:
|
|
570
|
+
status = (image.get("status") or "available").lower()
|
|
571
|
+
return status == "available"
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def find_snapshot_by_description(
|
|
575
|
+
images: Iterable[Dict[str, Any]],
|
|
576
|
+
description: str,
|
|
577
|
+
) -> Optional[Dict[str, Any]]:
|
|
578
|
+
for image in images:
|
|
579
|
+
if image.get("type") and image.get("type") != "snapshot":
|
|
580
|
+
continue
|
|
581
|
+
if image.get("description") != description:
|
|
582
|
+
continue
|
|
583
|
+
if not _snapshot_usable(image):
|
|
584
|
+
continue
|
|
585
|
+
return image
|
|
586
|
+
return None
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def resolve_boot_snapshot(
|
|
590
|
+
images: Iterable[Dict[str, Any]],
|
|
591
|
+
*,
|
|
592
|
+
workspace_name: str,
|
|
593
|
+
content_hash: str,
|
|
594
|
+
version: Optional[str] = None,
|
|
595
|
+
system_hash: Optional[str] = None,
|
|
596
|
+
) -> Dict[str, Any]:
|
|
597
|
+
"""
|
|
598
|
+
Pick a boot snapshot for faster create.
|
|
599
|
+
|
|
600
|
+
Preference:
|
|
601
|
+
1. System snapshot ``rig-{version}-{system_hash}`` → skip provision+configure
|
|
602
|
+
2. Neither → boot from the configured base image
|
|
603
|
+
|
|
604
|
+
Legacy workspace snapshots are ignored for boot (secrets-safe model).
|
|
605
|
+
``authorize`` is never skipped (credentials always re-applied after boot).
|
|
606
|
+
"""
|
|
607
|
+
images = list(images)
|
|
608
|
+
# content_hash / workspace_name kept for call-site compatibility.
|
|
609
|
+
_ = (workspace_name, content_hash)
|
|
610
|
+
|
|
611
|
+
if system_hash:
|
|
612
|
+
system_desc = system_snapshot_name(version, content_hash=system_hash)
|
|
613
|
+
system_hit = find_snapshot_by_description(images, system_desc)
|
|
614
|
+
if system_hit:
|
|
615
|
+
return {
|
|
616
|
+
"source": "system",
|
|
617
|
+
"image": system_hit,
|
|
618
|
+
"description": system_desc,
|
|
619
|
+
"skip_playbooks": ("provision", "configure"),
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
return {
|
|
623
|
+
"source": "base",
|
|
624
|
+
"image": None,
|
|
625
|
+
"description": None,
|
|
626
|
+
"skip_playbooks": (),
|
|
627
|
+
}
|