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/requirements.txt ADDED
@@ -0,0 +1,4 @@
1
+ requests>=2.25.1
2
+ pyyaml>=5.4.1
3
+ ansible>=6.0.0
4
+ cryptography>=41.0.0
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
+ }