jailbee 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.
Files changed (92) hide show
  1. jailbee/__init__.py +12 -0
  2. jailbee/__main__.py +8 -0
  3. jailbee/apply.py +400 -0
  4. jailbee/autostart.py +324 -0
  5. jailbee/background.py +299 -0
  6. jailbee/branch_config.py +425 -0
  7. jailbee/chrome_pool.py +336 -0
  8. jailbee/claude_skills.py +63 -0
  9. jailbee/cli.py +6170 -0
  10. jailbee/completion.py +268 -0
  11. jailbee/config.py +2146 -0
  12. jailbee/config_init.py +357 -0
  13. jailbee/dashboard.py +740 -0
  14. jailbee/db/__init__.py +130 -0
  15. jailbee/db/gui_state.py +55 -0
  16. jailbee/db/models.py +143 -0
  17. jailbee/destroy_guard.py +189 -0
  18. jailbee/device_groups.py +86 -0
  19. jailbee/docker_daemon.py +131 -0
  20. jailbee/doctor.py +648 -0
  21. jailbee/egress.py +160 -0
  22. jailbee/egress_pool.py +533 -0
  23. jailbee/entry.py +22 -0
  24. jailbee/git.py +938 -0
  25. jailbee/git_status.py +494 -0
  26. jailbee/global_config.py +178 -0
  27. jailbee/golden.py +393 -0
  28. jailbee/gui.py +166 -0
  29. jailbee/hosts.py +177 -0
  30. jailbee/incus.py +597 -0
  31. jailbee/init_command.py +365 -0
  32. jailbee/jobs.py +169 -0
  33. jailbee/lifecycle.py +2091 -0
  34. jailbee/loose_revert.py +148 -0
  35. jailbee/macos.py +191 -0
  36. jailbee/maintenance.py +155 -0
  37. jailbee/migrate.py +818 -0
  38. jailbee/mounts.py +29 -0
  39. jailbee/network.py +199 -0
  40. jailbee/paths.py +88 -0
  41. jailbee/pr.py +474 -0
  42. jailbee/pr_ai.py +267 -0
  43. jailbee/profiles.py +353 -0
  44. jailbee/provision/__init__.py +0 -0
  45. jailbee/provision/ensure-claude.sh +65 -0
  46. jailbee/provision/install.d/05-extra-apt.sh +14 -0
  47. jailbee/provision/install.d/10-locale.sh +11 -0
  48. jailbee/provision/install.d/15-prompt.sh +39 -0
  49. jailbee/provision/install.d/60-gui-libs.sh +25 -0
  50. jailbee/provision/install.d/75-github-cli.sh +23 -0
  51. jailbee/provision/install.d.available/20-corretto.sh +16 -0
  52. jailbee/provision/install.d.available/20-openjdk.sh +11 -0
  53. jailbee/provision/install.d.available/30-nodejs.sh +38 -0
  54. jailbee/provision/install.d.available/40-python.sh +13 -0
  55. jailbee/provision/install.d.available/50-docker.sh +47 -0
  56. jailbee/provision/install.d.available/80-ecr-helper.sh +10 -0
  57. jailbee/provision/install.d.available/90-registry-mirror-ca.sh +16 -0
  58. jailbee/provision/install.sh +179 -0
  59. jailbee/provision/registry-mirror/install.sh +43 -0
  60. jailbee/provision/registry-mirror/jailbee-registry-proxy.container +18 -0
  61. jailbee/qtui/__init__.py +1 -0
  62. jailbee/qtui/actions.py +96 -0
  63. jailbee/qtui/app.py +404 -0
  64. jailbee/qtui/cards.py +469 -0
  65. jailbee/qtui/flow_layout.py +89 -0
  66. jailbee/qtui/model.py +167 -0
  67. jailbee/qtui/refresh.py +129 -0
  68. jailbee/qtui/terminal.py +71 -0
  69. jailbee/qtui/window.py +299 -0
  70. jailbee/registry.py +602 -0
  71. jailbee/retry.py +113 -0
  72. jailbee/runtime_mounts.py +195 -0
  73. jailbee/skills/jailbee-repo-setup/SKILL.md +309 -0
  74. jailbee/skills/jailbee-repo-setup/references/config-schema.md +626 -0
  75. jailbee/skills/jailbee-usage/SKILL.md +463 -0
  76. jailbee/skills/jailbee-usage/references/commands.md +483 -0
  77. jailbee/snapshots.py +37 -0
  78. jailbee/ssh_seed.py +52 -0
  79. jailbee/submodules.py +851 -0
  80. jailbee/sync.py +2513 -0
  81. jailbee/table_format.py +196 -0
  82. jailbee/templates/__init__.py +1 -0
  83. jailbee/templates/systemd/__init__.py +1 -0
  84. jailbee/templates/systemd/jailbee-net-refresh.service +10 -0
  85. jailbee/templates/systemd/jailbee-net-refresh.timer +12 -0
  86. jailbee/tmux.py +242 -0
  87. jailbee/tui.py +573 -0
  88. jailbee-1.0.0.dist-info/METADATA +179 -0
  89. jailbee-1.0.0.dist-info/RECORD +92 -0
  90. jailbee-1.0.0.dist-info/WHEEL +4 -0
  91. jailbee-1.0.0.dist-info/entry_points.txt +4 -0
  92. jailbee-1.0.0.dist-info/licenses/LICENSE +674 -0
jailbee/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """jailbee — manage isolated dev environments using Incus.
2
+
3
+ Originally written for the GISGRO codebase; now generic — every repo
4
+ provides its own ``.jailbee/config.yaml``.
5
+ """
6
+
7
+ from importlib.metadata import PackageNotFoundError, version
8
+
9
+ try:
10
+ __version__ = version("jailbee")
11
+ except PackageNotFoundError: # running from a source tree without an install
12
+ __version__ = "0.0.0+unknown"
jailbee/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Enable `python -m jailbee` so the background worker can re-exec
2
+ the CLI identically under `uv run` and an installed `jailbee`.
3
+ """
4
+
5
+ from jailbee.cli import app
6
+
7
+ if __name__ == "__main__":
8
+ app()
jailbee/apply.py ADDED
@@ -0,0 +1,400 @@
1
+ """`jailbee apply` — re-apply config to profiles, ACL, and live container state.
2
+
3
+ Orchestrates the steps that take a repo from "config edited" to "every
4
+ container reflects the new config", or makes clear which manual step is
5
+ needed. Replaces `jailbee init --reapply` and `jailbee net refresh`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from datetime import UTC, datetime
12
+ from typing import TYPE_CHECKING
13
+
14
+ import yaml
15
+ from sqlmodel import Session
16
+
17
+ from jailbee.config import Config
18
+ from jailbee.db import get_engine
19
+ from jailbee.egress_pool import refresh_pool, register_repo
20
+ from jailbee.global_config import GlobalConfig
21
+ from jailbee.incus import Incus
22
+ from jailbee.profiles import (
23
+ base_profile_yaml,
24
+ binds_profile_yaml,
25
+ net_profile_yaml,
26
+ profile_names,
27
+ )
28
+ from jailbee.tui import ConfirmFn, default_confirm
29
+
30
+ if TYPE_CHECKING:
31
+ from jailbee.lifecycle import ContainerInfo
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class ApplyResult:
36
+ profiles_changed: list[str]
37
+ profiles_unchanged: list[str]
38
+ acl_changed: bool
39
+ hosts_repinned: list[str]
40
+ docker_proxy_reapplied: list[str]
41
+ restarted: list[str]
42
+ restart_failures: list[tuple[str, str]]
43
+ # Containers moved off the removed `<prefix>-net-offline` profile by
44
+ # this run. Empty on every apply after the first one.
45
+ offline_migrated: list[str] = field(default_factory=list)
46
+
47
+ @property
48
+ def fully_successful(self) -> bool:
49
+ return not self.restart_failures
50
+
51
+
52
+ def _profile_differs(incus: Incus, name: str, new_yaml: str) -> bool:
53
+ """True if the rendered YAML differs semantically from Incus's stored profile.
54
+
55
+ Compares ``config`` and ``devices`` keys as parsed dicts. Ignores any
56
+ other keys Incus surfaces in `profile show` (e.g. project, used_by).
57
+ """
58
+ return _yaml_subset_differs(incus.profile_show(name), new_yaml, keys=("config", "devices"))
59
+
60
+
61
+ def _acl_differs(incus: Incus, name: str, new_yaml: str) -> bool:
62
+ """True if the rendered ACL YAML differs from Incus's stored ACL."""
63
+ return _yaml_subset_differs(
64
+ incus.network_acl_show(name),
65
+ new_yaml,
66
+ keys=("egress", "ingress", "config"),
67
+ )
68
+
69
+
70
+ def _yaml_subset_differs(existing: str, candidate: str, *, keys: tuple[str, ...]) -> bool:
71
+ existing_parsed = yaml.safe_load(existing) or {}
72
+ candidate_parsed = yaml.safe_load(candidate) or {}
73
+ for key in keys:
74
+ if existing_parsed.get(key) != candidate_parsed.get(key):
75
+ return True
76
+ return False
77
+
78
+
79
+ # The `offline` network mode was removed. Containers created before that
80
+ # still carry `<prefix>-net-offline`; `jailbee apply` moves them to strict and
81
+ # deletes the profile. Spelled as a suffix rather than read from
82
+ # `ProfileNames` because the attribute no longer exists.
83
+ _STALE_NET_OFFLINE_SUFFIX = "-net-offline"
84
+
85
+
86
+ def _drop_offline_net_profile(cfg: Config, incus: Incus) -> list[str]:
87
+ """Move any container off the removed offline net profile, then delete it.
88
+
89
+ Returns the migrated container names.
90
+
91
+ Deliberately not via ``switch_network``: that helper requires a
92
+ *recognised* net profile to be attached, and `<prefix>-net-offline` is no
93
+ longer in ``net_by_mode`` — it would raise "has no network profile
94
+ attached — cannot switch" on exactly the containers being migrated.
95
+
96
+ ``/etc/hosts`` is not re-pinned here either. The migrated containers then
97
+ report ``network == "strict"``, so ``run_apply``'s running-container sweep
98
+ re-pins them a few lines further down.
99
+ """
100
+ from jailbee.incus import IncusError
101
+ from jailbee.tui import info, warn
102
+
103
+ names = profile_names(cfg)
104
+ stale = f"{cfg.container_prefix}{_STALE_NET_OFFLINE_SUFFIX}"
105
+
106
+ migrated: list[str] = []
107
+ for raw in incus.list_containers():
108
+ # A container mid-destroy can be reported with "profiles": null,
109
+ # which bypasses a `.get(..., [])` default (key present, value None).
110
+ profiles = raw.get("profiles") or []
111
+ if stale not in profiles:
112
+ continue
113
+ info(f" Migrating {raw['name']} off {stale} → {names.net_strict}...")
114
+ incus.profile_assign(
115
+ raw["name"],
116
+ [names.net_strict if p == stale else p for p in profiles],
117
+ )
118
+ migrated.append(raw["name"])
119
+
120
+ if incus.profile_exists(stale):
121
+ try:
122
+ incus.profile_delete(stale)
123
+ info(f" Deleted stale profile {stale}")
124
+ except IncusError as e:
125
+ warn(f"Could not delete stale profile {stale}: {e}")
126
+
127
+ return migrated
128
+
129
+
130
+ def run_apply(
131
+ cfg: Config,
132
+ incus: Incus,
133
+ gcfg: GlobalConfig,
134
+ *,
135
+ assume_yes: bool = False,
136
+ no_restart: bool = False,
137
+ confirm_fn: ConfirmFn | None = None,
138
+ ) -> ApplyResult:
139
+ """Apply current config to profiles, ACL, and live container state."""
140
+ from jailbee.lifecycle import short_name
141
+ from jailbee.tui import info
142
+
143
+ info("Applying configuration...")
144
+
145
+ # Resolve everything that could fail outside Incus before we mutate
146
+ # anything. Either of these raises and apply aborts cleanly with no
147
+ # partial profile / ACL update.
148
+ info("Refreshing egress pool + ACL + /etc/hosts...")
149
+ mirror_endpoint = _compute_mirror_endpoint_or_abort(incus, gcfg)
150
+ mirror_ca_pem = _read_mirror_ca_or_warn(gcfg) if mirror_endpoint else None
151
+
152
+ with Session(get_engine()) as session:
153
+ register_repo(session, cfg)
154
+ refresh_result = refresh_pool(
155
+ cfg,
156
+ gcfg,
157
+ incus,
158
+ session,
159
+ now=datetime.now(UTC),
160
+ )
161
+
162
+ if refresh_result.status == "dns_error":
163
+ from jailbee.egress import NetworkResolveError
164
+
165
+ raise NetworkResolveError(
166
+ "egress refresh",
167
+ Exception(refresh_result.error or "DNS failure"),
168
+ )
169
+ if refresh_result.status == "acl_error":
170
+ from jailbee.incus import IncusError
171
+
172
+ raise IncusError(refresh_result.error or "ACL write failed")
173
+ if refresh_result.status == "partial":
174
+ from jailbee.tui import warn
175
+
176
+ warn(f"Some hostnames failed to resolve: {refresh_result.error}")
177
+
178
+ if refresh_result.added or refresh_result.removed:
179
+ info(f" Pool: +{len(refresh_result.added)} added, -{len(refresh_result.removed)} evicted")
180
+
181
+ # refresh_pool wrote the ACL whenever there was anything to resolve.
182
+ # The added/removed counters are the closest "did anything change"
183
+ # signal we have from the new pipeline.
184
+ acl_changed_flag = bool(refresh_result.added or refresh_result.removed)
185
+
186
+ # Ensure shared-dir tree exists. `jailbee init` would have created it,
187
+ # but a user enabling claude/jetbrains after the initial init (or a repo
188
+ # initialised before a given integration's mounts were added) needs the
189
+ # integration subdirs to exist before the binds profile mounts them —
190
+ # otherwise Incus rejects the profile edit/assign with "Missing source
191
+ # path ...". `_ensure_integration_shared_dirs` is the shared source of
192
+ # truth with `jailbee init` so the two can't drift. Idempotent — cheap to re-run.
193
+ from jailbee.init_command import (
194
+ _ensure_integration_shared_dirs,
195
+ _ensure_shared_dirs,
196
+ _ensure_user_shared_dirs,
197
+ )
198
+
199
+ assert cfg.shared_dir is not None # set by load_config
200
+ _ensure_shared_dirs(cfg.shared_dir)
201
+ _ensure_user_shared_dirs(cfg)
202
+ _ensure_integration_shared_dirs(cfg)
203
+
204
+ # Refresh jailbee's bundled skills in the shared ~/.claude/skills so existing
205
+ # containers pick up a newer jailbee without recreation. Non-fatal.
206
+ from jailbee.claude_skills import sync_jailbee_skills
207
+ from jailbee.tui import warn
208
+
209
+ try:
210
+ sync_jailbee_skills(cfg)
211
+ except Exception as e: # non-fatal
212
+ warn(f"jailbee-skills sync failed (continuing): {e}")
213
+
214
+ names = profile_names(cfg)
215
+ profile_yamls = {
216
+ names.base: base_profile_yaml(cfg),
217
+ names.binds: binds_profile_yaml(cfg),
218
+ names.net_strict: net_profile_yaml(cfg, "strict"),
219
+ names.net_loose: net_profile_yaml(cfg, "loose"),
220
+ }
221
+ offline_migrated = _drop_offline_net_profile(cfg, incus)
222
+
223
+ info("Checking profiles...")
224
+ profiles_changed: list[str] = []
225
+ profiles_unchanged: list[str] = []
226
+ for name, new_yaml in profile_yamls.items():
227
+ if _profile_differs(incus, name, new_yaml):
228
+ info(f" Updating profile {name}...")
229
+ incus.profile_set_yaml(name, new_yaml)
230
+ profiles_changed.append(name)
231
+ else:
232
+ profiles_unchanged.append(name)
233
+
234
+ _ensure_acl_attached_to_bridge(cfg, incus)
235
+
236
+ # Push the repo's extra upstream registries into the mirror once,
237
+ # before re-applying per-container dockerd proxy. apply_mirror_registries
238
+ # is idempotent and a no-op when the list is empty or already covered.
239
+ if mirror_endpoint is not None and cfg.docker_registry_mirror.extra_registries:
240
+ info("Syncing extra registries into mirror...")
241
+ from jailbee.registry import apply_mirror_registries
242
+
243
+ apply_mirror_registries(incus, cfg.docker_registry_mirror.extra_registries)
244
+
245
+ info("Listing running containers...")
246
+ hosts_repinned: list[str] = []
247
+ docker_proxy_reapplied: list[str] = []
248
+ mirror_port = mirror_endpoint[1] if mirror_endpoint else None
249
+
250
+ running_names: list[str] = []
251
+ for ci in _list_containers(cfg, incus):
252
+ if ci.state != "Running":
253
+ continue
254
+ running_names.append(ci.name)
255
+ short = short_name(cfg, ci.name)
256
+ if cfg.host_devices:
257
+ # Re-ensure dev is in each host_devices group (e.g. kvm). Idempotent;
258
+ # picks up entries added since the container was created. New shells
259
+ # see the group; an already-open `jailbee shell` must be reopened.
260
+ from jailbee.device_groups import ensure_device_groups
261
+
262
+ ensure_device_groups(cfg, incus, ci.name)
263
+ if ci.network == "strict":
264
+ info(f" Re-pinning /etc/hosts on {short}...")
265
+ from jailbee.hosts import apply_hosts
266
+
267
+ apply_hosts(cfg, incus, ci.name, mirror_endpoint=mirror_endpoint)
268
+ hosts_repinned.append(ci.name)
269
+ if mirror_endpoint is not None and mirror_ca_pem is not None and mirror_port is not None:
270
+ info(f" Re-applying dockerd HTTPS_PROXY on {short}...")
271
+ from jailbee.docker_daemon import apply_docker_proxy
272
+
273
+ # apply_docker_proxy reads the mirror endpoint via MIRROR_DNS_NAME
274
+ # internally; we only need to pass CA + port.
275
+ apply_docker_proxy(incus, ci.name, mirror_ca_pem, mirror_port)
276
+ docker_proxy_reapplied.append(ci.name)
277
+
278
+ restarted: list[str] = []
279
+ restart_failures: list[tuple[str, str]] = []
280
+ should_restart = bool(profiles_changed) and bool(running_names) and not no_restart
281
+ if should_restart and not assume_yes:
282
+ prompt = (
283
+ f"\n{len(running_names)} running container(s) need restart "
284
+ f"to pick up profile changes:\n "
285
+ f"{', '.join(short_name(cfg, n) for n in running_names)}\nRestart now?"
286
+ )
287
+ fn = confirm_fn or default_confirm
288
+ if not fn(prompt):
289
+ should_restart = False
290
+
291
+ if should_restart:
292
+ for name in running_names:
293
+ info(f" Restarting {short_name(cfg, name)}...")
294
+ try:
295
+ _restart_one(cfg, incus, name, mirror_endpoint=mirror_endpoint)
296
+ restarted.append(name)
297
+ except Exception as e:
298
+ restart_failures.append((name, str(e)))
299
+
300
+ return ApplyResult(
301
+ profiles_changed=profiles_changed,
302
+ profiles_unchanged=profiles_unchanged,
303
+ acl_changed=acl_changed_flag,
304
+ hosts_repinned=hosts_repinned,
305
+ docker_proxy_reapplied=docker_proxy_reapplied,
306
+ restarted=restarted,
307
+ restart_failures=restart_failures,
308
+ offline_migrated=offline_migrated,
309
+ )
310
+
311
+
312
+ def _apply_acl_with_nft_quirk(incus: Incus, name: str, acl_yaml: str) -> None:
313
+ """Thin delegator to egress_pool._apply_acl_with_nft_quirk.
314
+
315
+ Logic was moved to egress_pool so the refresh timer and `jailbee apply`
316
+ share one nft-quirk-aware ACL writer. This wrapper stays for the
317
+ apply.py code path's clarity (and for tests that may want to patch
318
+ on apply rather than egress_pool).
319
+ """
320
+ from jailbee.egress_pool import _apply_acl_with_nft_quirk as impl
321
+
322
+ impl(incus, name, acl_yaml)
323
+
324
+
325
+ def _ensure_acl_attached_to_bridge(cfg: Config, incus: Incus) -> None:
326
+ """Idempotent. Wraps ``init_command.ensure_acl_attached_to_bridge``."""
327
+ from jailbee.init_command import ensure_acl_attached_to_bridge
328
+
329
+ ensure_acl_attached_to_bridge(cfg, incus)
330
+
331
+
332
+ def _compute_mirror_endpoint_or_abort(incus: Incus, gcfg: GlobalConfig) -> tuple[str, int] | None:
333
+ """Resolve mirror endpoint, return None if disabled. Aborts on ValueError."""
334
+ if not gcfg.docker_registry_mirror.enabled:
335
+ return None
336
+ from jailbee.docker_daemon import compute_mirror_endpoint
337
+
338
+ return compute_mirror_endpoint(incus, gcfg)
339
+
340
+
341
+ def _read_mirror_ca_or_warn(gcfg: GlobalConfig) -> str | None:
342
+ """Return CA cert PEM string, or None if not present (with a warning)."""
343
+ from jailbee.tui import warn
344
+
345
+ ca_path = gcfg.docker_registry_mirror.data_dir / "ca" / "ca.crt"
346
+ if ca_path.is_file():
347
+ return ca_path.read_text()
348
+ warn(f"Mirror CA cert not found at {ca_path}; skipping per-container docker-proxy reapply.")
349
+ return None
350
+
351
+
352
+ def _list_containers(cfg: Config, incus: Incus) -> list[ContainerInfo]:
353
+ """Wrap ``lifecycle.list_containers`` so tests can patch one symbol."""
354
+ from jailbee.lifecycle import list_containers
355
+
356
+ return list_containers(cfg, incus)
357
+
358
+
359
+ def _restart_one(
360
+ cfg: Config,
361
+ incus: Incus,
362
+ name: str,
363
+ *,
364
+ mirror_endpoint: tuple[str, int] | None = None,
365
+ ) -> None:
366
+ """Restart one container, re-pin /etc/hosts, and run on_start autostart.
367
+
368
+ Wrapper called only by ``run_apply``. The GUI auto-launch part of
369
+ ``cli._post_start_actions`` is intentionally skipped — `jailbee apply`
370
+ should not pop up IDE/Chrome on every container — but the autostart
371
+ shell steps (docker daemon, services, etc.) MUST run, otherwise the
372
+ container comes back from restart with no services and is unusable.
373
+ """
374
+ from jailbee.autostart import (
375
+ AutostartTrigger,
376
+ inject_github_token,
377
+ run_autostart,
378
+ )
379
+ from jailbee.hosts import apply_hosts
380
+ from jailbee.lifecycle import (
381
+ container_repo_dir,
382
+ current_network_mode,
383
+ restart_container,
384
+ )
385
+
386
+ restart_container(cfg, incus, name)
387
+ if current_network_mode(cfg, incus, name) == "strict":
388
+ apply_hosts(cfg, incus, name, mirror_endpoint=mirror_endpoint)
389
+ repo_dir = container_repo_dir(cfg, incus, name)
390
+ # Re-inject GH_TOKEN (infrastructure, not a user autostart step) so a
391
+ # rotated PAT is picked up on `jailbee apply`.
392
+ inject_github_token(cfg, incus, name, repo_dir, mirror_endpoint=mirror_endpoint)
393
+ run_autostart(
394
+ cfg,
395
+ incus,
396
+ name,
397
+ AutostartTrigger.ON_START,
398
+ repo_dir=repo_dir,
399
+ mirror_endpoint=mirror_endpoint,
400
+ )