hox-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.
- hox_cli/__init__.py +0 -0
- hox_cli/cli.py +791 -0
- hox_cli/config.py +205 -0
- hox_cli/deploy.py +95 -0
- hox_cli/dev.py +162 -0
- hox_cli/lock.py +198 -0
- hox_cli/master.py +371 -0
- hox_cli/node.py +302 -0
- hox_cli/panel.py +275 -0
- hox_cli/port.py +705 -0
- hox_cli/report.py +208 -0
- hox_cli/status.py +361 -0
- hox_cli/try_start.py +327 -0
- hox_cli-0.1.0.dist-info/METADATA +8 -0
- hox_cli-0.1.0.dist-info/RECORD +17 -0
- hox_cli-0.1.0.dist-info/WHEEL +4 -0
- hox_cli-0.1.0.dist-info/entry_points.txt +3 -0
hox_cli/config.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""hox.toml, parsed once into frozen dataclasses."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import re
|
|
5
|
+
import tomllib
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
FILENAME = "hox.toml"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConfigError(Exception):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Master:
|
|
18
|
+
url: str
|
|
19
|
+
cert: Path
|
|
20
|
+
key: Path
|
|
21
|
+
# api.vifib.com serves slap.vifib.com's certificate: verify it under that name.
|
|
22
|
+
api_tls_name: str | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Project:
|
|
27
|
+
reference: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Node:
|
|
32
|
+
title: str # on the master; hox looks its reference up there
|
|
33
|
+
ssh: tuple[str, ...]
|
|
34
|
+
# Where `hox re6st-token` requests the node's re6st token (panel: "Re6st Registries").
|
|
35
|
+
re6st_project: str
|
|
36
|
+
re6st_release: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Check:
|
|
41
|
+
name: str
|
|
42
|
+
url: str
|
|
43
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
44
|
+
expect_status: int | None = None
|
|
45
|
+
expect_header: dict[str, str] = field(default_factory=dict)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
RAW = "https://raw.githubusercontent.com"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def short_ref(ref: str) -> str:
|
|
52
|
+
"""A commit's first nine characters; a branch stays whole."""
|
|
53
|
+
return ref[:9] if re.fullmatch(r"[0-9a-f]{40}", ref) else ref
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Software:
|
|
58
|
+
name: str
|
|
59
|
+
repo: str
|
|
60
|
+
checkout: Path
|
|
61
|
+
branch: str
|
|
62
|
+
path: str
|
|
63
|
+
software_type: str
|
|
64
|
+
product: str
|
|
65
|
+
instance: str
|
|
66
|
+
checks: tuple[Check, ...] = ()
|
|
67
|
+
# The instance's parameters, sent with every request; None keeps those it has.
|
|
68
|
+
parameters: dict[str, str] | None = None
|
|
69
|
+
# Services of etc/service that exit once their work is done: their exit isn't a failure.
|
|
70
|
+
one_shot: tuple[str, ...] = ()
|
|
71
|
+
|
|
72
|
+
def raw_url(self, file: str, ref: str | None = None) -> str:
|
|
73
|
+
return f"{RAW}/{self.repo}/{ref or self.branch}/{file}"
|
|
74
|
+
|
|
75
|
+
def pinned_url(self, commit: str) -> str:
|
|
76
|
+
return self.raw_url(self.path, commit)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def branch_url(self) -> str:
|
|
80
|
+
return self.raw_url(self.path)
|
|
81
|
+
|
|
82
|
+
def ref_of(self, url: str) -> str | None:
|
|
83
|
+
"""The branch or commit of one of this software's release URLs; None for another URL."""
|
|
84
|
+
prefix, suffix = f"{RAW}/{self.repo}/", f"/{self.path}"
|
|
85
|
+
if url.startswith(prefix) and url.endswith(suffix):
|
|
86
|
+
return url[len(prefix) : -len(suffix)]
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class Config:
|
|
92
|
+
root: Path
|
|
93
|
+
master: Master
|
|
94
|
+
project: Project
|
|
95
|
+
node: Node
|
|
96
|
+
software: dict[str, Software]
|
|
97
|
+
|
|
98
|
+
def software_named(self, name: str | None) -> Software:
|
|
99
|
+
if name is None:
|
|
100
|
+
if len(self.software) != 1:
|
|
101
|
+
raise ConfigError(
|
|
102
|
+
f"several software in {FILENAME}, name one of: {', '.join(self.software)}"
|
|
103
|
+
)
|
|
104
|
+
return next(iter(self.software.values()))
|
|
105
|
+
if name in self.software:
|
|
106
|
+
return self.software[name]
|
|
107
|
+
for sw in self.software.values():
|
|
108
|
+
if sw.instance == name:
|
|
109
|
+
return sw
|
|
110
|
+
raise ConfigError(
|
|
111
|
+
f"no software or instance {name!r} in {FILENAME} (software: {', '.join(self.software)})"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def url_name(self, url: str) -> str:
|
|
115
|
+
"""A software release URL as hox.toml knows it ("sbe branch", "sbe @ 971d944c2"), else the URL."""
|
|
116
|
+
for sw in self.software.values():
|
|
117
|
+
if ref := sw.ref_of(url):
|
|
118
|
+
return (
|
|
119
|
+
f"{sw.name} branch"
|
|
120
|
+
if ref == sw.branch
|
|
121
|
+
else f"{sw.name} @ {short_ref(ref)}"
|
|
122
|
+
)
|
|
123
|
+
return url
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def find(start: Path) -> Path:
|
|
127
|
+
for directory in (start, *start.parents):
|
|
128
|
+
if (directory / FILENAME).is_file():
|
|
129
|
+
return directory / FILENAME
|
|
130
|
+
raise ConfigError(f"no {FILENAME} in {start} or its parents")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def load(path: Path | None = None) -> Config:
|
|
134
|
+
path = (path or find(Path.cwd())).resolve()
|
|
135
|
+
try:
|
|
136
|
+
data = tomllib.loads(path.read_text())
|
|
137
|
+
except (OSError, tomllib.TOMLDecodeError) as e:
|
|
138
|
+
raise ConfigError(f"{path}: {e}") from e
|
|
139
|
+
return parse(data, path.parent)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def parse(data: dict, root: Path) -> Config:
|
|
143
|
+
data = dict(data)
|
|
144
|
+
|
|
145
|
+
def resolve(value: str) -> Path:
|
|
146
|
+
return (root / Path(value).expanduser()).resolve()
|
|
147
|
+
|
|
148
|
+
def ssh_args(args: tuple[str, ...]) -> tuple[str, ...]:
|
|
149
|
+
# The files of ssh's -F (config) and -i (key) resolve against hox.toml's directory.
|
|
150
|
+
return tuple(
|
|
151
|
+
str(resolve(a)) if prev in ("-F", "-i") else a
|
|
152
|
+
for prev, a in zip(("",) + args, args)
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
master = _build(Master, _table(data, "master"), "master")
|
|
156
|
+
master = dataclasses.replace(
|
|
157
|
+
master, cert=resolve(master.cert), key=resolve(master.key)
|
|
158
|
+
)
|
|
159
|
+
project = _build(Project, _table(data, "project"), "project")
|
|
160
|
+
node = _build(Node, _table(data, "node"), "node")
|
|
161
|
+
node = dataclasses.replace(node, ssh=ssh_args(node.ssh))
|
|
162
|
+
|
|
163
|
+
software = {}
|
|
164
|
+
for name, table in _table(data, "software").items():
|
|
165
|
+
ctx = f"software.{name}"
|
|
166
|
+
table = dict(table)
|
|
167
|
+
checks = tuple(
|
|
168
|
+
_build(Check, c, f"{ctx}.checks[{i}]")
|
|
169
|
+
for i, c in enumerate(table.pop("checks", []))
|
|
170
|
+
)
|
|
171
|
+
if not all(isinstance(v, str) for v in table.get("parameters", {}).values()):
|
|
172
|
+
raise ConfigError(f"[{ctx}.parameters] values must be strings")
|
|
173
|
+
sw = _build(Software, {"name": name, **table, "checks": checks}, ctx)
|
|
174
|
+
software[name] = dataclasses.replace(sw, checkout=resolve(sw.checkout))
|
|
175
|
+
if not software:
|
|
176
|
+
raise ConfigError("[software.<name>]: at least one is required")
|
|
177
|
+
if data:
|
|
178
|
+
raise ConfigError(f"unknown sections: {', '.join(data)}")
|
|
179
|
+
return Config(root, master, project, node, software)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _table(data: dict, name: str) -> dict:
|
|
183
|
+
if name not in data:
|
|
184
|
+
raise ConfigError(f"[{name}] is required")
|
|
185
|
+
value = data.pop(name)
|
|
186
|
+
if not isinstance(value, dict):
|
|
187
|
+
raise ConfigError(f"[{name}] must be a table")
|
|
188
|
+
return value
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _build(cls, table: dict, ctx: str):
|
|
192
|
+
fields = {f.name: f for f in dataclasses.fields(cls)}
|
|
193
|
+
unknown = set(table) - set(fields)
|
|
194
|
+
if unknown:
|
|
195
|
+
raise ConfigError(f"[{ctx}] unknown keys: {', '.join(sorted(unknown))}")
|
|
196
|
+
required = {
|
|
197
|
+
n
|
|
198
|
+
for n, f in fields.items()
|
|
199
|
+
if f.default is dataclasses.MISSING and f.default_factory is dataclasses.MISSING
|
|
200
|
+
}
|
|
201
|
+
missing = required - set(table)
|
|
202
|
+
if missing:
|
|
203
|
+
raise ConfigError(f"[{ctx}] missing keys: {', '.join(sorted(missing))}")
|
|
204
|
+
values = {k: tuple(v) if isinstance(v, list) else v for k, v in table.items()}
|
|
205
|
+
return cls(**values)
|
hox_cli/deploy.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""`hox deploy`: wait for a requested instance to be allocated, built, and to pass its checks.
|
|
2
|
+
|
|
3
|
+
The node's own cron does the work (it builds what is supplied and deploys what
|
|
4
|
+
is allocated): this only waits, says what it waits for, and forces one
|
|
5
|
+
instance run once the build is there.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from . import dev, status
|
|
11
|
+
from .config import Software
|
|
12
|
+
from .master import Api
|
|
13
|
+
from .node import SLAPGRID_PROMISE_FAIL, Node
|
|
14
|
+
|
|
15
|
+
ALLOCATION_POLL_SECONDS, ALLOCATION_TIMEOUT_SECONDS = 30, 30 * 60
|
|
16
|
+
BUILD_POLL_SECONDS, BUILD_TIMEOUT_SECONDS = 60, 8 * 3600 # SBE without cache: hours
|
|
17
|
+
CHECKS_POLL_SECONDS, CHECKS_TIMEOUT_SECONDS = 60, 45 * 60 # frontends take a while
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def wait_allocated(api: Api, sw: Software, out=print, sleep=time.sleep) -> str:
|
|
21
|
+
def find() -> list[str]:
|
|
22
|
+
inst = status.instance(api, sw.instance)
|
|
23
|
+
return [inst.partition] if inst and inst.partition else []
|
|
24
|
+
|
|
25
|
+
out(f"{sw.instance}: waiting for allocation")
|
|
26
|
+
partition = dev.wait_for(
|
|
27
|
+
f"{sw.instance}'s allocation",
|
|
28
|
+
find,
|
|
29
|
+
sleep,
|
|
30
|
+
ALLOCATION_POLL_SECONDS,
|
|
31
|
+
ALLOCATION_TIMEOUT_SECONDS,
|
|
32
|
+
)
|
|
33
|
+
out(f"{sw.instance}: allocated on {partition}")
|
|
34
|
+
return partition
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def wait_built(node: Node, url: str, out=print, sleep=time.sleep) -> None:
|
|
38
|
+
last = None
|
|
39
|
+
|
|
40
|
+
def find() -> list[bool]:
|
|
41
|
+
nonlocal last
|
|
42
|
+
[state] = node.facts([url]).software
|
|
43
|
+
if (state.state, state.last_error) != last:
|
|
44
|
+
out(
|
|
45
|
+
f"build of {url}: {state.state}"
|
|
46
|
+
+ (f" (last error: {state.last_error})" if state.last_error else "")
|
|
47
|
+
)
|
|
48
|
+
last = (state.state, state.last_error)
|
|
49
|
+
return [True] if state.state == "completed" else []
|
|
50
|
+
|
|
51
|
+
dev.wait_for(
|
|
52
|
+
f"build of {url}", find, sleep, BUILD_POLL_SECONDS, BUILD_TIMEOUT_SECONDS
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def wait_checks(
|
|
57
|
+
api: Api, sw: Software, node: Node, out=print, sleep=time.sleep
|
|
58
|
+
) -> list[dev.CheckResult]:
|
|
59
|
+
last = None
|
|
60
|
+
|
|
61
|
+
def find() -> list[list[dev.CheckResult]]:
|
|
62
|
+
nonlocal last
|
|
63
|
+
results = dev.run_checks(
|
|
64
|
+
sw.checks, status.require_instance(api, sw).connection, node
|
|
65
|
+
)
|
|
66
|
+
failing = [f"{r.name}: {r.detail}" for r in results if not r.ok]
|
|
67
|
+
if failing and failing != last:
|
|
68
|
+
out("waiting for: " + "; ".join(failing))
|
|
69
|
+
last = failing
|
|
70
|
+
return [] if failing else [results]
|
|
71
|
+
|
|
72
|
+
return dev.wait_for(
|
|
73
|
+
f"{sw.instance}'s checks",
|
|
74
|
+
find,
|
|
75
|
+
sleep,
|
|
76
|
+
CHECKS_POLL_SECONDS,
|
|
77
|
+
CHECKS_TIMEOUT_SECONDS,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def deploy(
|
|
82
|
+
api: Api, sw: Software, url: str, node: Node, out=print, sleep=time.sleep
|
|
83
|
+
) -> list[dev.CheckResult]:
|
|
84
|
+
"""After `hox setup` and `hox request`: allocated, built, deployed, checks passing."""
|
|
85
|
+
partition = wait_allocated(api, sw, out, sleep)
|
|
86
|
+
wait_built(node, url, out, sleep)
|
|
87
|
+
# Only our partition: another one's failure (a release still building) isn't ours.
|
|
88
|
+
if (
|
|
89
|
+
node.slapgrid(f"instance --force --only-cp {partition}", out)
|
|
90
|
+
== SLAPGRID_PROMISE_FAIL
|
|
91
|
+
):
|
|
92
|
+
out(
|
|
93
|
+
"some promises fail (see above); they may pass once the frontends are there"
|
|
94
|
+
)
|
|
95
|
+
return wait_checks(api, sw, node, out, sleep)
|
hox_cli/dev.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""The dev loop: pushed? served by GitHub? rebuild in place, then check."""
|
|
2
|
+
|
|
3
|
+
import shlex
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import PurePosixPath
|
|
7
|
+
|
|
8
|
+
import httpx2
|
|
9
|
+
|
|
10
|
+
from .config import Check, Software
|
|
11
|
+
from .node import Node, git
|
|
12
|
+
|
|
13
|
+
GITHUB_POLL_SECONDS = 15
|
|
14
|
+
GITHUB_TIMEOUT_SECONDS = (
|
|
15
|
+
6 * 60
|
|
16
|
+
) # raw.githubusercontent.com caches branch files up to 5 minutes
|
|
17
|
+
CURL_TIMEOUT_SECONDS = 20
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DevError(Exception):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def wait_for(what: str, find, sleep=time.sleep, poll: int = 5, timeout: int = 180):
|
|
25
|
+
"""The first of find()'s results, once there is one."""
|
|
26
|
+
waited = 0
|
|
27
|
+
while not (rows := find()):
|
|
28
|
+
if waited >= timeout:
|
|
29
|
+
raise DevError(
|
|
30
|
+
f"{what}: still not there after {waited // 60} min {waited % 60} s"
|
|
31
|
+
)
|
|
32
|
+
sleep(poll)
|
|
33
|
+
waited += poll
|
|
34
|
+
return rows[0]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def require_pushed(sw: Software) -> str:
|
|
38
|
+
"""The local branch head, if origin has it too (asks the remote: no fetch, no write)."""
|
|
39
|
+
head = git(sw.checkout, "rev-parse", sw.branch).strip()
|
|
40
|
+
line = git(sw.checkout, "ls-remote", "origin", f"refs/heads/{sw.branch}").strip()
|
|
41
|
+
remote = line.split()[0] if line else None
|
|
42
|
+
if remote != head:
|
|
43
|
+
raise DevError(
|
|
44
|
+
f"{sw.branch} is at {head[:9]} locally and {remote[:9] if remote else 'absent'} on origin: push it first"
|
|
45
|
+
)
|
|
46
|
+
return head
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def pushed_commit(sw: Software, commit: str) -> str:
|
|
50
|
+
"""The full SHA of commit, if a branch of origin contains it (as last fetched).
|
|
51
|
+
|
|
52
|
+
Pinned URLs use it: GitHub also serves short SHAs, but each spelling is
|
|
53
|
+
another URL for the master and another software directory on the node.
|
|
54
|
+
"""
|
|
55
|
+
sha = git(sw.checkout, "rev-parse", "--verify", f"{commit}^{{commit}}").strip()
|
|
56
|
+
if not git(sw.checkout, "branch", "-r", "--contains", sha).strip():
|
|
57
|
+
raise DevError(
|
|
58
|
+
f"{commit} is on no branch of origin (as last fetched): push it first"
|
|
59
|
+
)
|
|
60
|
+
return sha
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def software_files(sw: Software, commit: str) -> dict[str, str]:
|
|
64
|
+
"""The files of the software's directory at commit, as the node will fetch them."""
|
|
65
|
+
directory = str(PurePosixPath(sw.path).parent)
|
|
66
|
+
names = git(sw.checkout, "ls-tree", "-r", "--name-only", commit, directory).split()
|
|
67
|
+
return {name: git(sw.checkout, "show", f"{commit}:{name}") for name in names}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def stale_files(sw: Software, files: dict[str, str], http: httpx2.Client) -> list[str]:
|
|
71
|
+
stale = []
|
|
72
|
+
for name, content in files.items():
|
|
73
|
+
response = http.get(sw.raw_url(name))
|
|
74
|
+
if response.status_code != 200 or response.text != content:
|
|
75
|
+
stale.append(name)
|
|
76
|
+
return stale
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def wait_for_github(
|
|
80
|
+
sw: Software, commit: str, http: httpx2.Client, out=print, sleep=time.sleep
|
|
81
|
+
) -> None:
|
|
82
|
+
files = software_files(sw, commit)
|
|
83
|
+
waited = 0
|
|
84
|
+
while stale := stale_files(sw, files, http):
|
|
85
|
+
if waited >= GITHUB_TIMEOUT_SECONDS:
|
|
86
|
+
raise DevError(
|
|
87
|
+
f"raw.githubusercontent.com still serves old versions of: {', '.join(stale)}"
|
|
88
|
+
)
|
|
89
|
+
out(
|
|
90
|
+
f"raw.githubusercontent.com serves old {', '.join(stale)}, retrying in {GITHUB_POLL_SECONDS}s"
|
|
91
|
+
)
|
|
92
|
+
sleep(GITHUB_POLL_SECONDS)
|
|
93
|
+
waited += GITHUB_POLL_SECONDS
|
|
94
|
+
out(f"raw.githubusercontent.com serves {commit[:9]} ({len(files)} files)")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class CheckResult:
|
|
99
|
+
name: str
|
|
100
|
+
url: str | None # None: a connection parameter it needs is not published
|
|
101
|
+
ok: bool
|
|
102
|
+
detail: str
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def run_checks(
|
|
106
|
+
checks: tuple[Check, ...], connection: dict[str, str], node: Node
|
|
107
|
+
) -> list[CheckResult]:
|
|
108
|
+
"""Run each check with curl on the node: backends are only reachable over re6st."""
|
|
109
|
+
published = {k: v for k, v in connection.items() if v not in (None, "None", "")}
|
|
110
|
+
results = []
|
|
111
|
+
for check in checks:
|
|
112
|
+
try:
|
|
113
|
+
url = check.url.format_map(published)
|
|
114
|
+
except KeyError as e:
|
|
115
|
+
results.append(
|
|
116
|
+
CheckResult(
|
|
117
|
+
check.name, None, False, f"{e.args[0]} is not published yet"
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
continue
|
|
121
|
+
results.append(evaluate(check, url, node.run(curl_command(check, url))))
|
|
122
|
+
return results
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def curl_command(check: Check, url: str) -> str:
|
|
126
|
+
args = [
|
|
127
|
+
"curl",
|
|
128
|
+
"-gsk",
|
|
129
|
+
"-o",
|
|
130
|
+
"/dev/null",
|
|
131
|
+
"-D",
|
|
132
|
+
"-",
|
|
133
|
+
"--max-time",
|
|
134
|
+
str(CURL_TIMEOUT_SECONDS),
|
|
135
|
+
]
|
|
136
|
+
for key, value in check.headers.items():
|
|
137
|
+
args += ["-H", f"{key}: {value}"]
|
|
138
|
+
# Always exit 0 so the output is ours to read; curl's own code comes last.
|
|
139
|
+
return shlex.join([*args, url]) + '; echo "curl-exit=$?"'
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def evaluate(check: Check, url: str, output: str) -> CheckResult:
|
|
143
|
+
lines = output.strip().splitlines()
|
|
144
|
+
curl_exit = int(lines[-1].removeprefix("curl-exit="))
|
|
145
|
+
if curl_exit != 0:
|
|
146
|
+
return CheckResult(check.name, url, False, f"curl exit {curl_exit}")
|
|
147
|
+
status_line = next(line for line in lines if line.startswith("HTTP/"))
|
|
148
|
+
status = int(status_line.split()[1])
|
|
149
|
+
headers = {}
|
|
150
|
+
for line in lines:
|
|
151
|
+
if ":" in line and not line.startswith("HTTP/"):
|
|
152
|
+
key, value = line.split(":", 1)
|
|
153
|
+
headers[key.strip().lower()] = value.strip()
|
|
154
|
+
problems = []
|
|
155
|
+
if check.expect_status is not None and status != check.expect_status:
|
|
156
|
+
problems.append(f"status {status}, expected {check.expect_status}")
|
|
157
|
+
for key, value in check.expect_header.items():
|
|
158
|
+
if headers.get(key.lower()) != value:
|
|
159
|
+
problems.append(f"{key}: {headers.get(key.lower())!r}, expected {value!r}")
|
|
160
|
+
return CheckResult(
|
|
161
|
+
check.name, url, not problems, "; ".join(problems) or f"status {status}"
|
|
162
|
+
)
|
hox_cli/lock.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""From a project's uv.lock to buildout's [versions]: what the project needs at run
|
|
2
|
+
time on a SlapOS node, pinned as uv resolved it, and what buildout can't install
|
|
3
|
+
as is."""
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from packaging import tags
|
|
10
|
+
from packaging.markers import Marker
|
|
11
|
+
from packaging.utils import canonicalize_name, parse_wheel_filename
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# buildout's own: their versions are SlapOS's (stack/slapos.cfg), whatever the app wants.
|
|
15
|
+
BUILDOUT_TOOLS = frozenset({"pip", "setuptools", "wheel", "zc-buildout"})
|
|
16
|
+
|
|
17
|
+
# Built by SlapOS's stack itself, from source, for slapos.cookbook (stack/slapos.cfg):
|
|
18
|
+
# a :whl pin breaks that build.
|
|
19
|
+
STACK_BUILT = frozenset({"cffi", "lxml", "pyyaml"})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LockError(Exception):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Pin:
|
|
28
|
+
name: str
|
|
29
|
+
version: str
|
|
30
|
+
sdist: bool
|
|
31
|
+
wheel: (
|
|
32
|
+
str | None
|
|
33
|
+
) # "pure", "binary" (manylinux, for the node), or none for the node
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def stack_built(self) -> bool:
|
|
37
|
+
return canonicalize_name(self.name) in STACK_BUILT
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def constraint(self) -> str:
|
|
41
|
+
# A wheel installs without a build: buildout builds sdists with the build's
|
|
42
|
+
# setuptools (74 on SlapOS 1.0), which many recent sdists are too new for.
|
|
43
|
+
return (
|
|
44
|
+
f"{self.version}:whl"
|
|
45
|
+
if self.wheel and not self.stack_built
|
|
46
|
+
else self.version
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def node_tags(python: str) -> frozenset[tags.Tag]:
|
|
51
|
+
"""The wheel tags a SlapOS node's CPython `python` installs: manylinux x86_64
|
|
52
|
+
(the node's glibc, Debian 12, is 2.36)."""
|
|
53
|
+
version = tuple(int(n) for n in python.split(".")[:2])
|
|
54
|
+
platforms = [f"manylinux_2_{minor}_x86_64" for minor in range(36, 4, -1)]
|
|
55
|
+
platforms += [
|
|
56
|
+
"manylinux2014_x86_64",
|
|
57
|
+
"manylinux2010_x86_64",
|
|
58
|
+
"manylinux1_x86_64",
|
|
59
|
+
"linux_x86_64",
|
|
60
|
+
]
|
|
61
|
+
return frozenset([
|
|
62
|
+
*tags.cpython_tags(version, platforms=platforms),
|
|
63
|
+
*tags.compatible_tags(version, platforms=platforms),
|
|
64
|
+
])
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def wheel_kind(filenames: list[str], supported: frozenset[tags.Tag]) -> str | None:
|
|
68
|
+
found = {t for f in filenames for t in parse_wheel_filename(f)[3]} & supported
|
|
69
|
+
if any(t.platform == "any" for t in found):
|
|
70
|
+
return "pure"
|
|
71
|
+
return "binary" if found else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def environment(python: str) -> dict[str, str]:
|
|
75
|
+
"""Marker values on a SlapOS node: Linux x86_64, CPython `python` (3.12)."""
|
|
76
|
+
return {
|
|
77
|
+
"python_version": ".".join(python.split(".")[:2]),
|
|
78
|
+
"python_full_version": python,
|
|
79
|
+
"implementation_name": "cpython",
|
|
80
|
+
"platform_python_implementation": "CPython",
|
|
81
|
+
"sys_platform": "linux",
|
|
82
|
+
"platform_system": "Linux",
|
|
83
|
+
"platform_machine": "x86_64",
|
|
84
|
+
"os_name": "posix",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def runtime_pins(lock: dict, python: str) -> list[Pin]:
|
|
89
|
+
"""The root project's run-time dependencies (no dependency groups), with the
|
|
90
|
+
extras they ask for, whose markers hold on the node."""
|
|
91
|
+
env = environment(python)
|
|
92
|
+
supported = node_tags(python)
|
|
93
|
+
packages: dict[str, list[dict]] = {}
|
|
94
|
+
for p in lock["package"]:
|
|
95
|
+
packages.setdefault(p["name"], []).append(p)
|
|
96
|
+
roots = [p for p in lock["package"] if {"editable", "virtual"} & p["source"].keys()]
|
|
97
|
+
if len(roots) != 1:
|
|
98
|
+
raise LockError(
|
|
99
|
+
f"expected one editable or virtual root package, found {len(roots)}"
|
|
100
|
+
)
|
|
101
|
+
root = roots[0]
|
|
102
|
+
|
|
103
|
+
def holds(dep: dict, extra: str = "") -> bool:
|
|
104
|
+
return "marker" not in dep or Marker(dep["marker"]).evaluate(
|
|
105
|
+
env | {"extra": extra}
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def package(dep: dict) -> dict:
|
|
109
|
+
found = packages[dep["name"]]
|
|
110
|
+
if len(found) > 1: # uv forked the resolution: the dependency says which
|
|
111
|
+
found = [p for p in found if p["version"] == dep["version"]]
|
|
112
|
+
return found[0]
|
|
113
|
+
|
|
114
|
+
seen: set[tuple[str, str]] = set()
|
|
115
|
+
pins: dict[str, Pin] = {}
|
|
116
|
+
todo = [(root, "")]
|
|
117
|
+
while todo:
|
|
118
|
+
p, extra = todo.pop()
|
|
119
|
+
if (p["name"], extra) in seen:
|
|
120
|
+
continue
|
|
121
|
+
seen.add((p["name"], extra))
|
|
122
|
+
deps = (
|
|
123
|
+
p.get("optional-dependencies", {}).get(extra, [])
|
|
124
|
+
if extra
|
|
125
|
+
else p.get("dependencies", [])
|
|
126
|
+
)
|
|
127
|
+
for dep in deps:
|
|
128
|
+
if not holds(dep, extra):
|
|
129
|
+
continue
|
|
130
|
+
q = package(dep)
|
|
131
|
+
todo += [(q, "")] + [(q, e) for e in dep.get("extra", [])]
|
|
132
|
+
if p is not root and not extra and p["name"] not in BUILDOUT_TOOLS:
|
|
133
|
+
wheels = [w["url"].rsplit("/", 1)[1] for w in p.get("wheels", [])]
|
|
134
|
+
pins[p["name"]] = Pin(
|
|
135
|
+
p["name"], p["version"], "sdist" in p, wheel_kind(wheels, supported)
|
|
136
|
+
)
|
|
137
|
+
return sorted(pins.values(), key=lambda pin: pin.name)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def versions_cfg(path: Path, python: str) -> str:
|
|
141
|
+
"""A [versions] section for buildout, with what needs attention as comments."""
|
|
142
|
+
return render_versions(
|
|
143
|
+
runtime_pins(tomllib.loads(path.read_text()), python),
|
|
144
|
+
f"{path.name} by `hox versions`",
|
|
145
|
+
python,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def render_versions(pins: list[Pin], source: str, python: str) -> str:
|
|
150
|
+
binary = [p.name for p in pins if p.wheel == "binary" and not p.stack_built]
|
|
151
|
+
stack = [p.name for p in pins if p.stack_built]
|
|
152
|
+
compiled = [p.name for p in pins if not p.wheel and p.sdist]
|
|
153
|
+
unbuildable = [p.name for p in pins if not p.wheel and not p.sdist]
|
|
154
|
+
lines = [
|
|
155
|
+
f"# From {source}, for CPython {python} on Linux x86_64.",
|
|
156
|
+
"# Packages with a wheel for the node are installed from it (:whl).",
|
|
157
|
+
]
|
|
158
|
+
if binary:
|
|
159
|
+
lines += [
|
|
160
|
+
"# From binary (manylinux) wheels, with their C libraries bundled, not",
|
|
161
|
+
"# SlapOS's: a security fix in one comes with a new release of the package.",
|
|
162
|
+
*_wrapped(binary),
|
|
163
|
+
]
|
|
164
|
+
if stack:
|
|
165
|
+
lines += [
|
|
166
|
+
"# Built by SlapOS's stack (slapos.cookbook), from source, with its recipe:",
|
|
167
|
+
*_wrapped(stack),
|
|
168
|
+
]
|
|
169
|
+
if compiled:
|
|
170
|
+
lines += [
|
|
171
|
+
"# Built from their sdist (no wheel for the node): those with C or Rust",
|
|
172
|
+
"# code need their libraries and build tools in the build:",
|
|
173
|
+
*_wrapped(compiled),
|
|
174
|
+
]
|
|
175
|
+
if unbuildable:
|
|
176
|
+
lines += [
|
|
177
|
+
"# No sdist and no wheel for the node, so buildout can't install them:",
|
|
178
|
+
"# replace them (another extra, or a SlapOS component):",
|
|
179
|
+
*_wrapped(unbuildable),
|
|
180
|
+
]
|
|
181
|
+
width = max(len(p.name) for p in pins)
|
|
182
|
+
return "\n".join([
|
|
183
|
+
*lines,
|
|
184
|
+
"",
|
|
185
|
+
"[versions]",
|
|
186
|
+
*(f"{p.name:<{width}} = {p.constraint}" for p in pins),
|
|
187
|
+
"",
|
|
188
|
+
])
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _wrapped(names: list[str]) -> list[str]:
|
|
192
|
+
lines, line = [], "# "
|
|
193
|
+
for name in names:
|
|
194
|
+
if len(line) + len(name) > 76:
|
|
195
|
+
lines.append(line)
|
|
196
|
+
line = "# "
|
|
197
|
+
line += f" {name}"
|
|
198
|
+
return [*lines, line]
|