hoist-cli 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.
- hoist/__init__.py +4 -0
- hoist/__main__.py +4 -0
- hoist/cli.py +1672 -0
- hoist/config.py +131 -0
- hoist/containers.py +781 -0
- hoist/dash/__init__.py +0 -0
- hoist/dash/collect.py +497 -0
- hoist/dash/fleet.py +140 -0
- hoist/dash/jobs.py +111 -0
- hoist/dash/server.py +979 -0
- hoist/dash/static/app.css +595 -0
- hoist/dash/static/app.js +437 -0
- hoist/dash/static/app.ui.js +501 -0
- hoist/dash/static/app.views.js +1057 -0
- hoist/dash/static/favicon.svg +1 -0
- hoist/dash/static/fonts/IBMPlexMono-400.woff2 +0 -0
- hoist/dash/static/fonts/IBMPlexMono-500.woff2 +0 -0
- hoist/dash/static/fonts/IBMPlexSans.woff2 +0 -0
- hoist/dash/static/fonts/OFL.txt +93 -0
- hoist/dash/static/fonts/fonts.css +3 -0
- hoist/dash/static/index.html +41 -0
- hoist/dash/static/login.html +41 -0
- hoist/dash/static/vendor/XTERM-LICENSE +21 -0
- hoist/dash/static/vendor/addon-fit.js +2 -0
- hoist/dash/static/vendor/xterm.css +218 -0
- hoist/dash/static/vendor/xterm.js +2 -0
- hoist/dash/team.py +241 -0
- hoist/dash/terminal.py +164 -0
- hoist/detect.py +159 -0
- hoist/front.py +457 -0
- hoist/notify.py +111 -0
- hoist/qr.py +417 -0
- hoist/service.py +210 -0
- hoist/source.py +410 -0
- hoist/tunnel.py +376 -0
- hoist/ui.py +101 -0
- hoist_cli-0.1.0.dist-info/METADATA +524 -0
- hoist_cli-0.1.0.dist-info/RECORD +42 -0
- hoist_cli-0.1.0.dist-info/WHEEL +5 -0
- hoist_cli-0.1.0.dist-info/entry_points.txt +2 -0
- hoist_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- hoist_cli-0.1.0.dist-info/top_level.txt +1 -0
hoist/config.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Persistent state for hoisted apps.
|
|
2
|
+
|
|
3
|
+
Several hoist processes can touch this file at once: one sync timer per app
|
|
4
|
+
(two apps on the same repo fire on the same push), the dashboard, and the CLI.
|
|
5
|
+
So every write goes to a private temp file and is renamed into place, the
|
|
6
|
+
previous good copy is kept as a backup, and read-modify-write sections take an
|
|
7
|
+
exclusive lock via `transaction()`. A file that fails to parse is never read
|
|
8
|
+
as "no apps": that would let the next save wipe every app.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import contextlib
|
|
14
|
+
import fcntl
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import threading
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Iterator
|
|
22
|
+
|
|
23
|
+
STATE_DIR = Path(os.environ.get("HOIST_HOME", Path.home() / ".config" / "hoist"))
|
|
24
|
+
STATE_FILE = STATE_DIR / "apps.json"
|
|
25
|
+
|
|
26
|
+
_DEFAULT: dict[str, Any] = {"version": 1, "domain": None, "apps": {}}
|
|
27
|
+
_local = threading.local()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class StateError(Exception):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _fresh() -> dict[str, Any]:
|
|
35
|
+
return {"version": 1, "domain": None, "apps": {}}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _backup() -> Path:
|
|
39
|
+
return STATE_FILE.with_name(STATE_FILE.name + ".bak")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _parse(path: Path) -> dict[str, Any]:
|
|
43
|
+
data = json.loads(path.read_text())
|
|
44
|
+
if not isinstance(data, dict):
|
|
45
|
+
raise json.JSONDecodeError("state is not an object", "", 0)
|
|
46
|
+
for key, value in _DEFAULT.items():
|
|
47
|
+
data.setdefault(key, value if not isinstance(value, dict) else dict(value))
|
|
48
|
+
return data
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load() -> dict[str, Any]:
|
|
52
|
+
if not STATE_FILE.exists():
|
|
53
|
+
return _fresh()
|
|
54
|
+
try:
|
|
55
|
+
return _parse(STATE_FILE)
|
|
56
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
57
|
+
backup = _backup()
|
|
58
|
+
if backup.exists():
|
|
59
|
+
try:
|
|
60
|
+
return _parse(backup)
|
|
61
|
+
except (json.JSONDecodeError, OSError):
|
|
62
|
+
pass
|
|
63
|
+
raise StateError(
|
|
64
|
+
f"{STATE_FILE} is unreadable ({exc}) and there is no good backup; "
|
|
65
|
+
"fix or move it aside before running hoist again"
|
|
66
|
+
) from exc
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def save(state: dict[str, Any]) -> None:
|
|
70
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
tmp = STATE_FILE.with_name(f".{STATE_FILE.name}.{os.getpid()}.{threading.get_ident()}.tmp")
|
|
72
|
+
with open(tmp, "w") as fh:
|
|
73
|
+
fh.write(json.dumps(state, indent=2, sort_keys=True) + "\n")
|
|
74
|
+
fh.flush()
|
|
75
|
+
os.fsync(fh.fileno())
|
|
76
|
+
with _lock():
|
|
77
|
+
if STATE_FILE.exists():
|
|
78
|
+
try:
|
|
79
|
+
_parse(STATE_FILE) # only a good copy becomes the backup
|
|
80
|
+
shutil.copy2(STATE_FILE, _backup())
|
|
81
|
+
except (json.JSONDecodeError, OSError):
|
|
82
|
+
pass
|
|
83
|
+
os.replace(tmp, STATE_FILE)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@contextlib.contextmanager
|
|
87
|
+
def _lock() -> Iterator[None]:
|
|
88
|
+
"""An exclusive, re-entrant (per thread) lock on the state directory."""
|
|
89
|
+
depth = getattr(_local, "depth", 0)
|
|
90
|
+
if depth:
|
|
91
|
+
_local.depth = depth + 1
|
|
92
|
+
try:
|
|
93
|
+
yield
|
|
94
|
+
finally:
|
|
95
|
+
_local.depth -= 1
|
|
96
|
+
return
|
|
97
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
fh = open(STATE_DIR / ".lock", "a+")
|
|
99
|
+
fcntl.flock(fh, fcntl.LOCK_EX)
|
|
100
|
+
_local.depth = 1
|
|
101
|
+
try:
|
|
102
|
+
yield
|
|
103
|
+
finally:
|
|
104
|
+
_local.depth = 0
|
|
105
|
+
fcntl.flock(fh, fcntl.LOCK_UN)
|
|
106
|
+
fh.close()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@contextlib.contextmanager
|
|
110
|
+
def transaction() -> Iterator[dict[str, Any]]:
|
|
111
|
+
"""Load, let the caller change, and save -- with no other writer in between."""
|
|
112
|
+
with _lock():
|
|
113
|
+
state = load()
|
|
114
|
+
yield state
|
|
115
|
+
save(state)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def now() -> str:
|
|
119
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def get_app(state: dict[str, Any], name: str) -> dict[str, Any] | None:
|
|
123
|
+
return state["apps"].get(name)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def put_app(state: dict[str, Any], app: dict[str, Any]) -> None:
|
|
127
|
+
state["apps"][app["name"]] = app
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def drop_app(state: dict[str, Any], name: str) -> None:
|
|
131
|
+
state["apps"].pop(name, None)
|