tailctl 0.1.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.
- tailctl/__init__.py +1 -0
- tailctl/cli.py +674 -0
- tailctl/config.py +275 -0
- tailctl/forwarder.py +123 -0
- tailctl/instance_manager.py +863 -0
- tailctl/liveness.py +137 -0
- tailctl/log.py +43 -0
- tailctl/paths.py +79 -0
- tailctl/profiles.py +71 -0
- tailctl/registry.py +180 -0
- tailctl/signals.py +52 -0
- tailctl/tailscale.py +365 -0
- tailctl-0.1.0.dist-info/METADATA +143 -0
- tailctl-0.1.0.dist-info/RECORD +18 -0
- tailctl-0.1.0.dist-info/WHEEL +5 -0
- tailctl-0.1.0.dist-info/entry_points.txt +2 -0
- tailctl-0.1.0.dist-info/licenses/LICENSE +21 -0
- tailctl-0.1.0.dist-info/top_level.txt +1 -0
tailctl/liveness.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Two-factor process liveness for tailctl holders and waiters.
|
|
2
|
+
|
|
3
|
+
A holder is alive iff its PID is still running AND that process's create-time
|
|
4
|
+
matches the value recorded when the holder was registered. The create-time
|
|
5
|
+
check is what defeats PID reuse: macOS recycles PIDs aggressively, so a bare
|
|
6
|
+
``os.kill(pid, 0)`` can wrongly report a dead session as alive.
|
|
7
|
+
|
|
8
|
+
The module is structured around a ``ProcessTable`` protocol so the production
|
|
9
|
+
code uses ``psutil`` and tests use ``FakeProcessTable`` — simulating PID reuse
|
|
10
|
+
in real OS processes is unreliable on macOS.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Protocol
|
|
17
|
+
|
|
18
|
+
import psutil
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ProcessTable(Protocol):
|
|
22
|
+
"""Minimal interface needed to capture and verify a process identity.
|
|
23
|
+
|
|
24
|
+
``create_time`` is float seconds since epoch (locale-independent,
|
|
25
|
+
sub-second resolution). ``is_alive`` does the (pid, create_time) two-factor
|
|
26
|
+
check that PID reuse cannot fool.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def create_time(self, pid: int) -> float: ...
|
|
30
|
+
|
|
31
|
+
def is_alive(self, pid: int, expected_create_time: float) -> bool: ...
|
|
32
|
+
|
|
33
|
+
def pids_for_socket(self, socket_path: str) -> list[int]: ...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ProcessIdentity:
|
|
38
|
+
"""A captured (pid, create_time) pair.
|
|
39
|
+
|
|
40
|
+
Pass the resulting identity into ``ProcessTable.is_alive`` to test
|
|
41
|
+
whether the same process is still running.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
pid: int
|
|
45
|
+
create_time: float
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def capture(cls, table: ProcessTable, pid: int) -> ProcessIdentity:
|
|
49
|
+
return cls(pid=pid, create_time=table.create_time(pid))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class PsutilProcessTable:
|
|
53
|
+
"""Production implementation of ``ProcessTable`` backed by psutil."""
|
|
54
|
+
|
|
55
|
+
def create_time(self, pid: int) -> float:
|
|
56
|
+
return psutil.Process(pid).create_time()
|
|
57
|
+
|
|
58
|
+
def is_alive(self, pid: int, expected_create_time: float) -> bool:
|
|
59
|
+
try:
|
|
60
|
+
proc = psutil.Process(pid)
|
|
61
|
+
except psutil.NoSuchProcess:
|
|
62
|
+
return False
|
|
63
|
+
try:
|
|
64
|
+
actual = proc.create_time()
|
|
65
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
66
|
+
return False
|
|
67
|
+
# Allow a small tolerance since create_time is float, but PID reuse on
|
|
68
|
+
# macOS happens on second-ish boundaries — a tight epsilon is enough.
|
|
69
|
+
return abs(actual - expected_create_time) < 0.001
|
|
70
|
+
|
|
71
|
+
def pids_for_socket(self, socket_path: str) -> list[int]:
|
|
72
|
+
"""Pids of running tailscaled daemons bound to ``socket_path``.
|
|
73
|
+
|
|
74
|
+
Matched by an exact ``--socket=<path>`` argv token. Used to find a
|
|
75
|
+
leaked daemon that still holds an instance's UDS but has no live
|
|
76
|
+
registry row (spawn race, a row dropped without killing the pid, or
|
|
77
|
+
create_time drift hiding it from the two-factor check) — it would
|
|
78
|
+
otherwise wedge the next spawn with ``address already in use``.
|
|
79
|
+
"""
|
|
80
|
+
needle = f"--socket={socket_path}"
|
|
81
|
+
found: list[int] = []
|
|
82
|
+
for proc in psutil.process_iter(["pid", "cmdline"]):
|
|
83
|
+
try:
|
|
84
|
+
cmdline = proc.info.get("cmdline") or []
|
|
85
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
86
|
+
continue
|
|
87
|
+
if needle in cmdline:
|
|
88
|
+
found.append(proc.info["pid"])
|
|
89
|
+
return found
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class FakeProcessTable:
|
|
93
|
+
"""Test double for ``ProcessTable``.
|
|
94
|
+
|
|
95
|
+
Construct with a dict mapping pid → create_time. Use ``kill(pid)`` to
|
|
96
|
+
remove a process, and ``reuse(pid, new_create_time)`` to simulate a new
|
|
97
|
+
process being assigned the same PID — the key case real OS APIs make
|
|
98
|
+
hard to test deterministically.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(self, processes: dict[int, float] | None = None) -> None:
|
|
102
|
+
self._procs: dict[int, float] = dict(processes or {})
|
|
103
|
+
self._sockets: dict[int, str] = {}
|
|
104
|
+
|
|
105
|
+
def create_time(self, pid: int) -> float:
|
|
106
|
+
if pid not in self._procs:
|
|
107
|
+
raise LookupError(f"pid {pid} not in fake process table")
|
|
108
|
+
return self._procs[pid]
|
|
109
|
+
|
|
110
|
+
def is_alive(self, pid: int, expected_create_time: float) -> bool:
|
|
111
|
+
if pid not in self._procs:
|
|
112
|
+
return False
|
|
113
|
+
return abs(self._procs[pid] - expected_create_time) < 0.001
|
|
114
|
+
|
|
115
|
+
def pids_for_socket(self, socket_path: str) -> list[int]:
|
|
116
|
+
return [
|
|
117
|
+
pid
|
|
118
|
+
for pid, sock in self._sockets.items()
|
|
119
|
+
if sock == socket_path and pid in self._procs
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
# --- test helpers ---
|
|
123
|
+
|
|
124
|
+
def kill(self, pid: int) -> None:
|
|
125
|
+
self._procs.pop(pid, None)
|
|
126
|
+
self._sockets.pop(pid, None)
|
|
127
|
+
|
|
128
|
+
def attach_socket(self, pid: int, socket_path: str) -> None:
|
|
129
|
+
"""Record that ``pid`` is bound to ``socket_path`` (for orphan tests)."""
|
|
130
|
+
self._sockets[pid] = socket_path
|
|
131
|
+
|
|
132
|
+
def reuse(self, pid: int, new_create_time: float) -> None:
|
|
133
|
+
"""Simulate PID reuse: same pid, different create_time."""
|
|
134
|
+
self._procs[pid] = new_create_time
|
|
135
|
+
|
|
136
|
+
def spawn(self, pid: int, create_time: float) -> None:
|
|
137
|
+
self._procs[pid] = create_time
|
tailctl/log.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Append-only JSON log with a single-generation 10 MB rotation.
|
|
2
|
+
|
|
3
|
+
Every coordinator action (acquire, release, switch, reconciliation, reap,
|
|
4
|
+
rollback escalation) writes one JSON object per line. Format is intentionally
|
|
5
|
+
simple: a single object, no nesting beyond the immediate keys.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from tailctl import paths
|
|
16
|
+
|
|
17
|
+
ROTATE_AT_BYTES = 10 * 1024 * 1024 # 10 MB
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def append(record: dict[str, Any], *, log_path: Path | None = None) -> None:
|
|
21
|
+
"""Append one record. Rotates if the file would exceed ``ROTATE_AT_BYTES``."""
|
|
22
|
+
target = log_path or paths.log_jsonl()
|
|
23
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
_rotate_if_needed(target)
|
|
25
|
+
line = json.dumps(record, separators=(",", ":"), sort_keys=True) + "\n"
|
|
26
|
+
with open(target, "a", encoding="utf-8") as fh:
|
|
27
|
+
fh.write(line)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _rotate_if_needed(target: Path) -> None:
|
|
31
|
+
try:
|
|
32
|
+
size = target.stat().st_size
|
|
33
|
+
except FileNotFoundError:
|
|
34
|
+
return
|
|
35
|
+
if size < ROTATE_AT_BYTES:
|
|
36
|
+
return
|
|
37
|
+
rotated = paths.log_jsonl_rotated() if target == paths.log_jsonl() else target.with_suffix(
|
|
38
|
+
target.suffix + ".1"
|
|
39
|
+
)
|
|
40
|
+
# Overwrite any prior rotation generation; we keep exactly one.
|
|
41
|
+
if rotated.exists():
|
|
42
|
+
rotated.unlink()
|
|
43
|
+
os.replace(target, rotated)
|
tailctl/paths.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Filesystem path resolution for tailctl runtime state.
|
|
2
|
+
|
|
3
|
+
All on-disk state lives under a single root directory, defaulting to
|
|
4
|
+
``~/.tailctl`` and overridable via the ``TAILCTL_HOME`` environment variable.
|
|
5
|
+
The override is what makes tests hermetic: a test sets ``TAILCTL_HOME`` to a
|
|
6
|
+
tmp dir and every path-returning function in this module follows it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
_ENV_VAR = "TAILCTL_HOME"
|
|
15
|
+
_DEFAULT_HOME = "~/.tailctl"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def home() -> Path:
|
|
19
|
+
"""Return the tailctl state root directory.
|
|
20
|
+
|
|
21
|
+
Honors ``$TAILCTL_HOME`` if set, falling back to ``~/.tailctl``.
|
|
22
|
+
"""
|
|
23
|
+
return Path(os.environ.get(_ENV_VAR, _DEFAULT_HOME)).expanduser()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def profiles_yaml() -> Path:
|
|
27
|
+
return home() / "profiles.yaml"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def state_json() -> Path:
|
|
31
|
+
return home() / "state.json"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def state_lock() -> Path:
|
|
35
|
+
"""Separate lock file; never atomic-renamed.
|
|
36
|
+
|
|
37
|
+
The flock is held on this file's inode so that an atomic rename of
|
|
38
|
+
``state.json`` itself does not orphan the lock.
|
|
39
|
+
"""
|
|
40
|
+
return home() / "state.json.lock"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def instances_json() -> Path:
|
|
44
|
+
"""Registry of running per-profile userspace tailscaled instances."""
|
|
45
|
+
return home() / "instances.json"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def instances_lock() -> Path:
|
|
49
|
+
"""Separate lock file for the instances registry (never atomic-renamed)."""
|
|
50
|
+
return home() / "instances.json.lock"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def instances_dir() -> Path:
|
|
54
|
+
"""Root for per-instance runtime dirs (socket + statedir + logs)."""
|
|
55
|
+
return home() / "instances"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def instance_dir(profile: str) -> Path:
|
|
59
|
+
"""Per-profile runtime dir holding its tailscaled socket, statedir, logs."""
|
|
60
|
+
return instances_dir() / profile
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def log_jsonl() -> Path:
|
|
64
|
+
return home() / "log.jsonl"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def log_jsonl_rotated() -> Path:
|
|
68
|
+
return home() / "log.1.jsonl"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def fixtures_dir() -> Path:
|
|
72
|
+
return home() / "fixtures"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def ensure_home() -> Path:
|
|
76
|
+
"""Create the state root if it doesn't exist; return its path."""
|
|
77
|
+
h = home()
|
|
78
|
+
h.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
return h
|
tailctl/profiles.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Profile data type and lookup table.
|
|
2
|
+
|
|
3
|
+
Profiles are the user's logical name → ``(account_id, tailnet, exit_node)``
|
|
4
|
+
mapping. ``config.py`` (next PR up) constructs ``Profiles`` from
|
|
5
|
+
``profiles.yaml``; the coordinator only depends on this lightweight type.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class PortForward:
|
|
15
|
+
"""A native-TCP forward for a profile.
|
|
16
|
+
|
|
17
|
+
Under the userspace-per-identity model, a profile's tailnet may host
|
|
18
|
+
services (ClickHouse, Postgres, …) that native clients reach by connecting
|
|
19
|
+
to a stable ``127.0.0.1:<local_port>``. The forwarder relays that local
|
|
20
|
+
port through the profile's userspace tailscaled to ``remote_host:remote_port``.
|
|
21
|
+
``local_port`` is optional; when 0/None the instance allocates a free port.
|
|
22
|
+
The resolved port is exported to ``tailctl run`` subprocesses as
|
|
23
|
+
``<SERVICE>_ADDR=127.0.0.1:<local_port>`` (service upper-cased).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
service: str
|
|
27
|
+
remote_host: str
|
|
28
|
+
remote_port: int
|
|
29
|
+
local_port: int = 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Profile:
|
|
34
|
+
name: str
|
|
35
|
+
account_id: str
|
|
36
|
+
tailnet: str | None = None
|
|
37
|
+
exit_node: str | None = None
|
|
38
|
+
expected_egress_ip: str | None = None
|
|
39
|
+
# Userspace-model fields:
|
|
40
|
+
accept_routes: bool = True
|
|
41
|
+
port_forwards: tuple[PortForward, ...] = field(default_factory=tuple)
|
|
42
|
+
# Name of an env var holding a Tailscale auth key for this profile's
|
|
43
|
+
# tailnet (sourced from BWS). When set and present in the environment,
|
|
44
|
+
# first login is non-interactive (no browser URL). Absent → fall back to
|
|
45
|
+
# the interactive one-time login.
|
|
46
|
+
auth_key_env: str | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class UnknownProfileError(KeyError):
|
|
50
|
+
"""Profile name does not exist in profiles.yaml."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Profiles:
|
|
55
|
+
"""Read-only profile table with a configured default."""
|
|
56
|
+
|
|
57
|
+
default_name: str
|
|
58
|
+
by_name: dict[str, Profile]
|
|
59
|
+
|
|
60
|
+
def get(self, name: str) -> Profile:
|
|
61
|
+
try:
|
|
62
|
+
return self.by_name[name]
|
|
63
|
+
except KeyError as exc:
|
|
64
|
+
raise UnknownProfileError(name) from exc
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def default(self) -> Profile:
|
|
68
|
+
return self.get(self.default_name)
|
|
69
|
+
|
|
70
|
+
def names(self) -> list[str]:
|
|
71
|
+
return sorted(self.by_name.keys())
|
tailctl/registry.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""On-disk registry of running userspace tailscaled instances.
|
|
2
|
+
|
|
3
|
+
Replaces the global ``state.json`` coordination model. There is no shared
|
|
4
|
+
network to coordinate anymore: each profile that a session needs gets its own
|
|
5
|
+
userspace ``tailscaled`` (own socket, statedir, SOCKS/HTTP proxy ports), and
|
|
6
|
+
several run simultaneously. This registry is a lightweight table — NOT a state
|
|
7
|
+
machine — recording which profiles have a live instance, the ports they expose,
|
|
8
|
+
their port-forwards, and a refcount so multiple sessions can share one instance.
|
|
9
|
+
|
|
10
|
+
The locking discipline mirrors ``state.py``: an exclusive ``fcntl.flock`` on a
|
|
11
|
+
SEPARATE ``instances.json.lock`` file, with the registry itself read/mutated/
|
|
12
|
+
written atomically via ``tmp + os.replace``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import fcntl
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
from collections.abc import Iterator
|
|
21
|
+
from contextlib import contextmanager
|
|
22
|
+
from dataclasses import asdict, dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from tailctl import paths
|
|
27
|
+
|
|
28
|
+
REGISTRY_SCHEMA_VERSION = 1
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Forward:
|
|
33
|
+
"""One active port-forward for an instance."""
|
|
34
|
+
|
|
35
|
+
service: str
|
|
36
|
+
local_port: int
|
|
37
|
+
remote_host: str
|
|
38
|
+
remote_port: int
|
|
39
|
+
pid: int | None = None # relay subprocess pid (None until started)
|
|
40
|
+
create_time: float | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Holder:
|
|
45
|
+
"""A live claim on an instance by some owning process.
|
|
46
|
+
|
|
47
|
+
The refcount is ``len(holders)``; each holder is identified by a two-factor
|
|
48
|
+
``(owner_pid, owner_create_time)`` so a crashed owner's claim can be reaped
|
|
49
|
+
(and PID reuse can't resurrect it). ``up`` adds a holder; ``down`` removes
|
|
50
|
+
the caller's; ``reap`` drops dead-owner holders and stops orphaned daemons.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
owner_pid: int
|
|
54
|
+
owner_create_time: float
|
|
55
|
+
since: str = ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class Instance:
|
|
60
|
+
"""A running per-profile userspace tailscaled, plus its forwards/holders."""
|
|
61
|
+
|
|
62
|
+
profile: str
|
|
63
|
+
pid: int
|
|
64
|
+
create_time: float
|
|
65
|
+
socket: str
|
|
66
|
+
statedir: str
|
|
67
|
+
socks_port: int
|
|
68
|
+
http_port: int
|
|
69
|
+
created_at: str = ""
|
|
70
|
+
forwards: list[Forward] = field(default_factory=list)
|
|
71
|
+
holders: list[Holder] = field(default_factory=list)
|
|
72
|
+
# False while the daemon is registered but awaiting first login; flips True
|
|
73
|
+
# once exit node + forwards are applied. Distinguishes a pending instance
|
|
74
|
+
# (which already has a holder) from a finalized one.
|
|
75
|
+
finalized: bool = True
|
|
76
|
+
# Epoch seconds when the last holder released gracefully — starts the linger
|
|
77
|
+
# window during which the warm daemon is kept for fast reuse. None while held
|
|
78
|
+
# (or when the owner crashed: then reap stops it immediately, no grace).
|
|
79
|
+
released_at: float | None = None
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def refcount(self) -> int:
|
|
83
|
+
return len(self.holders)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class Registry:
|
|
88
|
+
schema_version: int = REGISTRY_SCHEMA_VERSION
|
|
89
|
+
generation: int = 0
|
|
90
|
+
instances: dict[str, Instance] = field(default_factory=dict)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class RegistryCorruptError(RuntimeError):
|
|
94
|
+
"""instances.json could not be parsed."""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _registry_from_dict(data: dict[str, Any]) -> Registry:
|
|
98
|
+
if data.get("schema_version") not in (None, REGISTRY_SCHEMA_VERSION):
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f"unrecognized registry schema_version: {data.get('schema_version')!r}; "
|
|
101
|
+
f"expected {REGISTRY_SCHEMA_VERSION}"
|
|
102
|
+
)
|
|
103
|
+
instances: dict[str, Instance] = {}
|
|
104
|
+
_fields = (
|
|
105
|
+
"profile", "pid", "create_time", "socket", "statedir",
|
|
106
|
+
"socks_port", "http_port", "created_at", "finalized", "released_at",
|
|
107
|
+
)
|
|
108
|
+
for name, body in (data.get("instances") or {}).items():
|
|
109
|
+
forwards = [Forward(**f) for f in body.get("forwards", [])]
|
|
110
|
+
holders = [Holder(**h) for h in body.get("holders", [])]
|
|
111
|
+
# Only pass known scalar fields; drop anything else (e.g. a legacy
|
|
112
|
+
# ``refcount`` key, now a derived property).
|
|
113
|
+
known = {k: body[k] for k in _fields if k in body}
|
|
114
|
+
instances[name] = Instance(forwards=forwards, holders=holders, **known)
|
|
115
|
+
return Registry(
|
|
116
|
+
schema_version=REGISTRY_SCHEMA_VERSION,
|
|
117
|
+
generation=int(data.get("generation", 0)),
|
|
118
|
+
instances=instances,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class RegistryStore:
|
|
123
|
+
"""Atomic, flock-coordinated access to instances.json.
|
|
124
|
+
|
|
125
|
+
Same contract as ``state.StateStore``: ``transaction()`` yields a mutable
|
|
126
|
+
``Registry`` written on clean exit; ``read()`` is a lock-free snapshot.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(
|
|
130
|
+
self, *, registry_path: Path | None = None, lock_path: Path | None = None
|
|
131
|
+
) -> None:
|
|
132
|
+
self._path = registry_path or paths.instances_json()
|
|
133
|
+
self._lock_path = lock_path or paths.instances_lock()
|
|
134
|
+
|
|
135
|
+
@contextmanager
|
|
136
|
+
def transaction(self) -> Iterator[Registry]:
|
|
137
|
+
self._lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
fd = os.open(self._lock_path, os.O_RDWR | os.O_CREAT, 0o600)
|
|
139
|
+
try:
|
|
140
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
141
|
+
reg = self._read_unlocked()
|
|
142
|
+
yield reg
|
|
143
|
+
reg.generation += 1
|
|
144
|
+
self._write_unlocked(reg)
|
|
145
|
+
finally:
|
|
146
|
+
try:
|
|
147
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
148
|
+
finally:
|
|
149
|
+
os.close(fd)
|
|
150
|
+
|
|
151
|
+
def read(self) -> Registry:
|
|
152
|
+
return self._read_unlocked()
|
|
153
|
+
|
|
154
|
+
def _read_unlocked(self) -> Registry:
|
|
155
|
+
if not self._path.exists():
|
|
156
|
+
return Registry()
|
|
157
|
+
try:
|
|
158
|
+
data = json.loads(self._path.read_text())
|
|
159
|
+
except json.JSONDecodeError as exc:
|
|
160
|
+
raise RegistryCorruptError(
|
|
161
|
+
f"registry at {self._path} is not valid JSON: {exc}"
|
|
162
|
+
) from exc
|
|
163
|
+
if not isinstance(data, dict):
|
|
164
|
+
raise RegistryCorruptError(f"registry at {self._path} is not a JSON object")
|
|
165
|
+
return _registry_from_dict(data)
|
|
166
|
+
|
|
167
|
+
def _write_unlocked(self, reg: Registry) -> None:
|
|
168
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
|
|
170
|
+
payload = json.dumps(
|
|
171
|
+
{
|
|
172
|
+
"schema_version": reg.schema_version,
|
|
173
|
+
"generation": reg.generation,
|
|
174
|
+
"instances": {n: asdict(i) for n, i in reg.instances.items()},
|
|
175
|
+
},
|
|
176
|
+
indent=2,
|
|
177
|
+
sort_keys=True,
|
|
178
|
+
)
|
|
179
|
+
tmp.write_text(payload)
|
|
180
|
+
os.replace(tmp, self._path)
|
tailctl/signals.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Signal handling for the wait loop.
|
|
2
|
+
|
|
3
|
+
When a session sitting in ``coord.acquire`` receives SIGINT or SIGTERM (e.g.
|
|
4
|
+
the user hits Ctrl-C, or the parent shell kills the tailctl process), we
|
|
5
|
+
remove the session from the waiters list under the lock and re-elect the
|
|
6
|
+
named handoff if we were the chosen successor. Then we re-raise the default
|
|
7
|
+
disposition so the process actually exits.
|
|
8
|
+
|
|
9
|
+
This module installs handlers on import-time only when ``install_handlers`` is
|
|
10
|
+
called — leaving import side-effect free for tests that don't need them.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import signal
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
# Callbacks registered by Coordinator.install_signal_handlers (one per wait).
|
|
20
|
+
_cleanups: list[Callable[[], None]] = []
|
|
21
|
+
_previous_handlers: dict[int, Any] = {}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def push_cleanup(fn: Callable[[], None]) -> None:
|
|
25
|
+
"""Register a cleanup to run on SIGINT/SIGTERM. Pops back automatically
|
|
26
|
+
when ``pop_cleanup`` is called."""
|
|
27
|
+
_cleanups.append(fn)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def pop_cleanup() -> None:
|
|
31
|
+
if _cleanups:
|
|
32
|
+
_cleanups.pop()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _handler(signum: int, frame: Any) -> None:
|
|
36
|
+
while _cleanups:
|
|
37
|
+
try:
|
|
38
|
+
_cleanups.pop()()
|
|
39
|
+
except Exception:
|
|
40
|
+
# Best-effort: keep popping even on individual cleanup failure.
|
|
41
|
+
continue
|
|
42
|
+
# Re-raise default disposition by re-installing the previous handler
|
|
43
|
+
# and re-sending the signal.
|
|
44
|
+
prev = _previous_handlers.get(signum, signal.SIG_DFL)
|
|
45
|
+
signal.signal(signum, prev)
|
|
46
|
+
signal.raise_signal(signum)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def install_handlers() -> None:
|
|
50
|
+
"""Install SIGINT + SIGTERM handlers. Safe to call repeatedly."""
|
|
51
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
52
|
+
_previous_handlers[sig] = signal.signal(sig, _handler)
|