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/master.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""The SlapOS master: its API (hateoas) for reads and forms, and the slap protocol."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from urllib.parse import quote, urljoin, urlsplit
|
|
7
|
+
from xml.etree import ElementTree
|
|
8
|
+
from xml.sax.saxutils import escape
|
|
9
|
+
|
|
10
|
+
import httpx2
|
|
11
|
+
|
|
12
|
+
from .config import Master
|
|
13
|
+
|
|
14
|
+
TIMEOUT = 60
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MasterError(Exception):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def expand(template: str, params: Mapping[str, str | int | list]) -> str:
|
|
22
|
+
"""RFC 6570 form-style query expansion ({&a,b*}), the only kind the API uses."""
|
|
23
|
+
|
|
24
|
+
def replace(match: re.Match) -> str:
|
|
25
|
+
operator, names = match.group(1)[:1], match.group(1)[1:].split(",")
|
|
26
|
+
if operator not in ("&", "?"):
|
|
27
|
+
raise MasterError(f"unsupported URI template expression: {match.group(0)}")
|
|
28
|
+
pairs = []
|
|
29
|
+
for name in names:
|
|
30
|
+
explode = name.endswith("*")
|
|
31
|
+
name = name.rstrip("*")
|
|
32
|
+
value = params.get(name)
|
|
33
|
+
if value is None:
|
|
34
|
+
continue
|
|
35
|
+
values = value if isinstance(value, list) else [value]
|
|
36
|
+
if not explode and isinstance(value, list):
|
|
37
|
+
values = [",".join(map(str, value))]
|
|
38
|
+
pairs += [f"{name}={quote(str(v), safe='')}" for v in values]
|
|
39
|
+
if not pairs:
|
|
40
|
+
return ""
|
|
41
|
+
return operator + "&".join(pairs)
|
|
42
|
+
|
|
43
|
+
return re.sub(r"\{([^}]+)\}", replace, template)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Api:
|
|
47
|
+
def __init__(self, client: httpx2.Client, root: dict, tls_name: str | None = None):
|
|
48
|
+
self._client = client
|
|
49
|
+
self._extensions = {"sni_hostname": tls_name} if tls_name else {}
|
|
50
|
+
self._root = root
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def connect(
|
|
54
|
+
cls, master: Master, transport: httpx2.BaseTransport | None = None
|
|
55
|
+
) -> "Api":
|
|
56
|
+
client = httpx2.Client(
|
|
57
|
+
cert=(str(master.cert), str(master.key)),
|
|
58
|
+
timeout=TIMEOUT,
|
|
59
|
+
transport=transport,
|
|
60
|
+
)
|
|
61
|
+
url = _get(client, urljoin(master.url, "getHateoasUrl")).text.strip()
|
|
62
|
+
if urlsplit(url).scheme != "https":
|
|
63
|
+
raise MasterError(f"getHateoasUrl returned {url!r}")
|
|
64
|
+
extensions = (
|
|
65
|
+
{"sni_hostname": master.api_tls_name} if master.api_tls_name else {}
|
|
66
|
+
)
|
|
67
|
+
root = _get(client, url, extensions).json()
|
|
68
|
+
return cls(client, root, master.api_tls_name)
|
|
69
|
+
|
|
70
|
+
def _get_json(self, url: str) -> dict:
|
|
71
|
+
return _get(self._client, url, self._extensions).json()
|
|
72
|
+
|
|
73
|
+
def _template(self, name: str) -> str:
|
|
74
|
+
try:
|
|
75
|
+
return self._root["_links"][name]["href"]
|
|
76
|
+
except KeyError:
|
|
77
|
+
raise MasterError(f"API root has no {name!r} link") from None
|
|
78
|
+
|
|
79
|
+
def search(self, query: str, select: list[str], limit: int = 100) -> list[dict]:
|
|
80
|
+
url = expand(
|
|
81
|
+
self._template("raw_search"),
|
|
82
|
+
{"query": query, "select_list": select, "limit": limit},
|
|
83
|
+
)
|
|
84
|
+
rows = self._get_json(url)["_embedded"]["contents"]
|
|
85
|
+
for row in rows:
|
|
86
|
+
row.pop("_links", None)
|
|
87
|
+
return rows
|
|
88
|
+
|
|
89
|
+
def get(self, relative_url: str, view: str | None = None) -> dict:
|
|
90
|
+
params = {"relative_url": relative_url}
|
|
91
|
+
if view:
|
|
92
|
+
params["view"] = view
|
|
93
|
+
return self._get_json(expand(self._template("traverse"), params))
|
|
94
|
+
|
|
95
|
+
def act(
|
|
96
|
+
self, relative_url: str, action: str, fields: dict[str, str] | None = None
|
|
97
|
+
) -> dict | None:
|
|
98
|
+
"""Submit one of a document's actions as the panel does: a fast input dialog
|
|
99
|
+
(add_slapos_software_type), a workflow transition (validate_action) or a script.
|
|
100
|
+
|
|
101
|
+
`fields` are keyed as the form keys them (field_your_software_type); every
|
|
102
|
+
other field of the form is sent with its default. Returns the master's
|
|
103
|
+
answer when it is JSON (a script may answer with a form of its results).
|
|
104
|
+
"""
|
|
105
|
+
links = [
|
|
106
|
+
link
|
|
107
|
+
for name, value in self.get(relative_url, "view")["_links"].items()
|
|
108
|
+
if name.startswith("action_")
|
|
109
|
+
for link in (value if isinstance(value, list) else [value])
|
|
110
|
+
]
|
|
111
|
+
href = next(
|
|
112
|
+
(link["href"] for link in links if link.get("name") == action), None
|
|
113
|
+
)
|
|
114
|
+
if href is None:
|
|
115
|
+
names = sorted({link.get("name") for link in links})
|
|
116
|
+
raise MasterError(
|
|
117
|
+
f"{relative_url} has no action {action!r}; it has: {', '.join(names)}"
|
|
118
|
+
)
|
|
119
|
+
form = self._get_json(href)["_embedded"]["_view"]
|
|
120
|
+
actions = form["_actions"]
|
|
121
|
+
data = {
|
|
122
|
+
f["key"]: f["default"] or ""
|
|
123
|
+
for f in form.values()
|
|
124
|
+
if isinstance(f, dict) and "key" in f
|
|
125
|
+
}
|
|
126
|
+
# The method is in _actions.put for dialogs, next to it for scripts.
|
|
127
|
+
data["dialog_method"] = actions["put"].get("action") or actions["action"]
|
|
128
|
+
return self._post(actions["put"]["href"], data | (fields or {}))
|
|
129
|
+
|
|
130
|
+
def edit(
|
|
131
|
+
self, relative_url: str, fields: dict[str, str], view: str = "view"
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Change fields of one of a document's views, keyed as the form keys them (field_my_title).
|
|
134
|
+
|
|
135
|
+
Base_edit skips the fields absent from the POST: the others keep their values.
|
|
136
|
+
"""
|
|
137
|
+
form = self.get(relative_url, view)["_embedded"]["_view"]
|
|
138
|
+
self._post(
|
|
139
|
+
form["_actions"]["put"]["href"],
|
|
140
|
+
{"form_id": form["form_id"]["default"], **fields},
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def _post(self, url: str, data: dict) -> dict | None:
|
|
144
|
+
try:
|
|
145
|
+
response = self._client.post(
|
|
146
|
+
url,
|
|
147
|
+
data=data,
|
|
148
|
+
extensions=self._extensions,
|
|
149
|
+
headers={"Accept": "application/hal+json"},
|
|
150
|
+
)
|
|
151
|
+
except httpx2.TransportError as e:
|
|
152
|
+
raise MasterError(f"POST {url}: {e}") from e
|
|
153
|
+
if response.is_error:
|
|
154
|
+
raise MasterError(
|
|
155
|
+
f"POST {url}: HTTP {response.status_code} {response.text[:200]}"
|
|
156
|
+
)
|
|
157
|
+
if "json" not in response.headers.get(
|
|
158
|
+
"content-type", ""
|
|
159
|
+
): # application/json or application/hal+json
|
|
160
|
+
return None # a redirect
|
|
161
|
+
body = response.json()
|
|
162
|
+
if body.get("portal_status_level") == "error":
|
|
163
|
+
raise MasterError(f"POST {url}: {body.get('portal_status_message')}")
|
|
164
|
+
return body
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _get(
|
|
168
|
+
client: httpx2.Client, url: str, extensions: dict | None = None
|
|
169
|
+
) -> httpx2.Response:
|
|
170
|
+
try:
|
|
171
|
+
response = client.get(url, extensions=extensions or {})
|
|
172
|
+
except httpx2.TransportError as e:
|
|
173
|
+
raise MasterError(f"GET {url}: {e}") from e
|
|
174
|
+
if response.is_error:
|
|
175
|
+
raise MasterError(
|
|
176
|
+
f"{response.request.method} {response.url}: HTTP {response.status_code} {response.text[:200]}"
|
|
177
|
+
)
|
|
178
|
+
return response
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def quoted(value: str) -> str:
|
|
182
|
+
"""A value for an ERP5 catalog query (title:="...")."""
|
|
183
|
+
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# The slap protocol: form POSTs to master.url, some fields marshalled as
|
|
187
|
+
# slapos.core does (slapos.util.dumps, golden values in tests/test_master.py).
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def marshal(value: dict[str, str] | str | bool) -> str:
|
|
191
|
+
def text(s: str) -> str:
|
|
192
|
+
# slapos.core escapes twice (xml_marshaller, then serialization); the master expects it.
|
|
193
|
+
return f"<string>{escape(escape(s))}</string>"
|
|
194
|
+
|
|
195
|
+
if isinstance(value, bool):
|
|
196
|
+
body = f"<bool>{int(value)}</bool>"
|
|
197
|
+
elif isinstance(value, str):
|
|
198
|
+
body = text(value)
|
|
199
|
+
elif not value:
|
|
200
|
+
body = '<dictionary id="i2"/>'
|
|
201
|
+
else:
|
|
202
|
+
body = (
|
|
203
|
+
'<dictionary id="i2">'
|
|
204
|
+
+ "".join(text(k) + text(v) for k, v in value.items())
|
|
205
|
+
+ "</dictionary>"
|
|
206
|
+
)
|
|
207
|
+
return f"<marshal>{body}</marshal>"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
_STATES = {
|
|
211
|
+
"start_requested": "started",
|
|
212
|
+
"stop_requested": "stopped",
|
|
213
|
+
"destroy_requested": "destroyed",
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass(frozen=True)
|
|
218
|
+
class Request:
|
|
219
|
+
"""An instance request, as sent to requestComputerPartition."""
|
|
220
|
+
|
|
221
|
+
software_release: str
|
|
222
|
+
reference: str
|
|
223
|
+
software_type: str
|
|
224
|
+
filter: dict[str, str]
|
|
225
|
+
parameters: dict[str, str] = field(default_factory=dict)
|
|
226
|
+
shared: bool = False
|
|
227
|
+
state: str = "started"
|
|
228
|
+
project: str | None = (
|
|
229
|
+
None # without it, the master guesses (from the filter or the release)
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def form(self) -> dict[str, str]:
|
|
233
|
+
project = {"project_reference": self.project} if self.project else {}
|
|
234
|
+
return project | {
|
|
235
|
+
"software_release": self.software_release,
|
|
236
|
+
"partition_reference": self.reference,
|
|
237
|
+
"software_type": self.software_type,
|
|
238
|
+
"partition_parameter_xml": marshal(self.parameters),
|
|
239
|
+
"filter_xml": marshal(self.filter),
|
|
240
|
+
"state": marshal(self.state),
|
|
241
|
+
"shared_xml": marshal(self.shared),
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@dataclass(frozen=True)
|
|
246
|
+
class ComputeNode:
|
|
247
|
+
reference: str
|
|
248
|
+
relative_url: str
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def nodes_titled(api: Api, project: str, title: str) -> list[ComputeNode]:
|
|
252
|
+
rows = api.search(
|
|
253
|
+
f'portal_type:"Compute Node" AND validation_state:validated AND follow_up_reference:={quoted(project)}'
|
|
254
|
+
f" AND title:={quoted(title)}",
|
|
255
|
+
["reference", "relative_url"],
|
|
256
|
+
)
|
|
257
|
+
return [ComputeNode(r["reference"], r["relative_url"]) for r in rows]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def find_node(api: Api, project: str, title: str) -> ComputeNode:
|
|
261
|
+
"""Our compute node, by its title in the project (its reference changes with each registration)."""
|
|
262
|
+
nodes = nodes_titled(api, project, title)
|
|
263
|
+
if len(nodes) != 1:
|
|
264
|
+
found = f"{len(nodes)} of them" if nodes else "none (`hox node-certificate`)"
|
|
265
|
+
raise MasterError(f"compute node {title!r} in {project}: {found}")
|
|
266
|
+
return nodes[0]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def stored_request(api: Api, reference: str) -> Request | None:
|
|
270
|
+
"""The request an instance tree was created with, read back from the master.
|
|
271
|
+
|
|
272
|
+
The master answers 500 to a request whose URL, type or filter differ from
|
|
273
|
+
the original: re-sending this one (with another state) is what works.
|
|
274
|
+
"""
|
|
275
|
+
trees = api.search(
|
|
276
|
+
f'portal_type:"Instance Tree" AND validation_state:validated AND title:={quoted(reference)}',
|
|
277
|
+
[
|
|
278
|
+
"url_string",
|
|
279
|
+
"source_reference",
|
|
280
|
+
"sla_xml",
|
|
281
|
+
"text_content",
|
|
282
|
+
"root_slave",
|
|
283
|
+
"slap_state",
|
|
284
|
+
"follow_up_reference",
|
|
285
|
+
],
|
|
286
|
+
)
|
|
287
|
+
if not trees:
|
|
288
|
+
return None
|
|
289
|
+
tree = trees[0]
|
|
290
|
+
return Request(
|
|
291
|
+
software_release=tree["url_string"],
|
|
292
|
+
reference=reference,
|
|
293
|
+
software_type=tree["source_reference"],
|
|
294
|
+
filter=instance_xml(tree["sla_xml"]),
|
|
295
|
+
parameters=instance_xml(tree["text_content"]),
|
|
296
|
+
shared=bool(tree["root_slave"]),
|
|
297
|
+
state=_STATES[tree["slap_state"]],
|
|
298
|
+
project=tree["follow_up_reference"],
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def instance_xml(xml: str | None) -> dict[str, str]:
|
|
303
|
+
"""<instance><parameter id="k">v</parameter>…</instance> -> {k: v}."""
|
|
304
|
+
if not xml:
|
|
305
|
+
return {}
|
|
306
|
+
params = {}
|
|
307
|
+
for p in ElementTree.fromstring(xml.encode()).iter("parameter"):
|
|
308
|
+
key = p.get("id")
|
|
309
|
+
if key is None:
|
|
310
|
+
raise MasterError(f"instance XML: a parameter has no id: {xml[:200]}")
|
|
311
|
+
params[key] = p.text or ""
|
|
312
|
+
return params
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class Slap:
|
|
316
|
+
def __init__(self, client: httpx2.Client, url: str):
|
|
317
|
+
self._client = client
|
|
318
|
+
self._url = url
|
|
319
|
+
|
|
320
|
+
@classmethod
|
|
321
|
+
def connect(
|
|
322
|
+
cls, master: Master, transport: httpx2.BaseTransport | None = None
|
|
323
|
+
) -> "Slap":
|
|
324
|
+
client = httpx2.Client(
|
|
325
|
+
cert=(str(master.cert), str(master.key)),
|
|
326
|
+
timeout=TIMEOUT,
|
|
327
|
+
transport=transport,
|
|
328
|
+
)
|
|
329
|
+
return cls(client, master.url)
|
|
330
|
+
|
|
331
|
+
def _post(self, path: str, data: dict[str, str]) -> httpx2.Response:
|
|
332
|
+
url = urljoin(self._url, path)
|
|
333
|
+
try:
|
|
334
|
+
return self._client.post(url, data=data)
|
|
335
|
+
except httpx2.TransportError as e:
|
|
336
|
+
raise MasterError(f"POST {url}: {e}") from e
|
|
337
|
+
|
|
338
|
+
def supply(self, url: str, node: str, state: str = "available") -> None:
|
|
339
|
+
response = self._post(
|
|
340
|
+
"supplySupply", {"url": url, "computer_id": node, "state": state}
|
|
341
|
+
)
|
|
342
|
+
if response.is_error:
|
|
343
|
+
raise MasterError(
|
|
344
|
+
f"supply {url} on {node}: HTTP {response.status_code} {response.text[:200]}"
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
def request(self, request: Request) -> bool:
|
|
348
|
+
"""Send an instance request. True: the master processed it; False: still provisioning (HTTP 408)."""
|
|
349
|
+
response = self._post("requestComputerPartition", request.form())
|
|
350
|
+
if (
|
|
351
|
+
response.status_code == 408
|
|
352
|
+
): # what the master answers while it provisions, not a timeout
|
|
353
|
+
return False
|
|
354
|
+
if response.status_code == 500:
|
|
355
|
+
raise MasterError(
|
|
356
|
+
f"request {request.reference}: HTTP 500. The master answers that to a request whose URL,"
|
|
357
|
+
" software type or filter differ from the instance's original one."
|
|
358
|
+
)
|
|
359
|
+
if response.is_error:
|
|
360
|
+
raise MasterError(
|
|
361
|
+
f"request {request.reference}: HTTP {response.status_code} {response.text[:200]}"
|
|
362
|
+
)
|
|
363
|
+
return True
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def next_name(reference: str) -> str:
|
|
367
|
+
"""mynij-proxy-3 -> mynij-proxy-4; mynij-proxy -> mynij-proxy-2."""
|
|
368
|
+
base, _, number = reference.rpartition("-")
|
|
369
|
+
return (
|
|
370
|
+
f"{base}-{int(number) + 1}" if base and number.isdigit() else f"{reference}-2"
|
|
371
|
+
)
|
hox_cli/node.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""The compute node, over SSH, and the local git checkouts."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import re
|
|
5
|
+
import shlex
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
from collections import deque
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
SOFTWARE_ROOT = "/opt/slapgrid"
|
|
13
|
+
SOFTWARE_LOG = "/opt/slapos/log/slapos-node-software.log"
|
|
14
|
+
INSTANCE_LOG = "/opt/slapos/log/slapos-node-instance.log"
|
|
15
|
+
LOG_LINES = 2000
|
|
16
|
+
SEPARATOR = "----hox----"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NodeError(Exception):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def software_dir(url: str) -> str:
|
|
24
|
+
return f"{SOFTWARE_ROOT}/{hashlib.md5(url.encode()).hexdigest()}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class SoftwareState:
|
|
29
|
+
url: str
|
|
30
|
+
state: str # "completed", "building" (dir present, not completed) or "absent"
|
|
31
|
+
last_error: (
|
|
32
|
+
str | None
|
|
33
|
+
) # last buildout error for this dir, if the last run of it failed
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class NodeFacts:
|
|
38
|
+
software: list[SoftwareState]
|
|
39
|
+
failing_promises: list[str] # from the last `slapos node instance` run
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Exit codes of `slapos node software|instance`.
|
|
43
|
+
SLAPGRID_OK, SLAPGRID_FAIL, SLAPGRID_PROMISE_FAIL = 0, 1, 2
|
|
44
|
+
SLAPGRID_BUSY = 10 # another run (the node's cron) holds the pidfile
|
|
45
|
+
BUSY_RETRY_SECONDS = 15
|
|
46
|
+
BUSY_TIMEOUT_SECONDS = 15 * 60
|
|
47
|
+
SSH_FAILED = 255 # ssh's own failure, not the remote command's
|
|
48
|
+
TAIL_LINES = 10 # of a failed run, in the error
|
|
49
|
+
|
|
50
|
+
# What `hox rebuild` shows of a slapgrid run: parts that change (buildout says
|
|
51
|
+
# "Updating x." for every unchanged one), errors and the end of each run.
|
|
52
|
+
_SHOWN = re.compile(
|
|
53
|
+
r"(Installing|Uninstalling) \S+\.$|Error|While:|Failed to run buildout"
|
|
54
|
+
r"|Finished (software releases|computer partitions)|slappart\d+\["
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def shown(line: str) -> bool:
|
|
59
|
+
return bool(_SHOWN.search(line))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class Partition:
|
|
64
|
+
name: str
|
|
65
|
+
memory_mb: int # the resident size of its user's processes
|
|
66
|
+
running: int
|
|
67
|
+
down: list[
|
|
68
|
+
str
|
|
69
|
+
] # services that should run and don't, as `service_name` shortens them
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class System:
|
|
74
|
+
uptime_seconds: int
|
|
75
|
+
partitions: list[Partition]
|
|
76
|
+
memory_available_mb: int
|
|
77
|
+
memory_total_mb: int
|
|
78
|
+
disk_free_mb: int
|
|
79
|
+
disk_total_mb: int
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Node:
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
ssh: tuple[str, ...],
|
|
86
|
+
run=subprocess.run,
|
|
87
|
+
popen=subprocess.Popen,
|
|
88
|
+
sleep=time.sleep,
|
|
89
|
+
):
|
|
90
|
+
self._ssh = ssh
|
|
91
|
+
self._run = run
|
|
92
|
+
self._popen = popen
|
|
93
|
+
self._sleep = sleep
|
|
94
|
+
|
|
95
|
+
def run(self, command: str) -> str:
|
|
96
|
+
result = self._run([*self._ssh, command], capture_output=True, text=True)
|
|
97
|
+
if result.returncode == SSH_FAILED:
|
|
98
|
+
raise NodeError(
|
|
99
|
+
f"{self._ssh[0]} {self._ssh[-1]}: {result.stderr.strip()[-500:].replace(chr(10), '; ')}"
|
|
100
|
+
)
|
|
101
|
+
if result.returncode != 0:
|
|
102
|
+
raise NodeError(
|
|
103
|
+
f"{shlex.join(self._ssh)} {command!r}: exit {result.returncode}: {result.stderr.strip()[-500:]}"
|
|
104
|
+
)
|
|
105
|
+
return result.stdout
|
|
106
|
+
|
|
107
|
+
def stream(self, command: str, keep, out=print) -> tuple[int, list[str]]:
|
|
108
|
+
"""Run command, pass the lines that `keep` accepts to `out`.
|
|
109
|
+
|
|
110
|
+
Returns its exit code and its last lines, shown or not (ssh's own errors included).
|
|
111
|
+
"""
|
|
112
|
+
last: deque[str] = deque(maxlen=TAIL_LINES)
|
|
113
|
+
with self._popen(
|
|
114
|
+
[*self._ssh, command],
|
|
115
|
+
stdout=subprocess.PIPE,
|
|
116
|
+
stderr=subprocess.STDOUT,
|
|
117
|
+
text=True,
|
|
118
|
+
) as proc:
|
|
119
|
+
assert proc.stdout is not None # stdout=PIPE
|
|
120
|
+
for line in proc.stdout:
|
|
121
|
+
line = line.rstrip("\n")
|
|
122
|
+
last.append(line)
|
|
123
|
+
if keep(line):
|
|
124
|
+
out(line)
|
|
125
|
+
return proc.returncode, list(last)
|
|
126
|
+
|
|
127
|
+
def interactive(self, command: str) -> int:
|
|
128
|
+
"""Run command with the terminal attached (tail -F), return its exit code."""
|
|
129
|
+
return self._run([*self._ssh, command]).returncode
|
|
130
|
+
|
|
131
|
+
def slapgrid(self, args: str, out=print) -> int:
|
|
132
|
+
"""`slapos node <args>`, waiting while the node's own run holds the lock.
|
|
133
|
+
|
|
134
|
+
Returns SLAPGRID_OK or SLAPGRID_PROMISE_FAIL; raises on anything else.
|
|
135
|
+
"""
|
|
136
|
+
waited = 0
|
|
137
|
+
while True:
|
|
138
|
+
code, last = self.stream(f"slapos node {args}", shown, out)
|
|
139
|
+
if code != SLAPGRID_BUSY:
|
|
140
|
+
break
|
|
141
|
+
if waited >= BUSY_TIMEOUT_SECONDS:
|
|
142
|
+
raise NodeError(
|
|
143
|
+
f"slapos node {args}: another slapos run kept the lock for {waited // 60} min"
|
|
144
|
+
)
|
|
145
|
+
out(f"another slapos run is going on, retrying in {BUSY_RETRY_SECONDS}s")
|
|
146
|
+
self._sleep(BUSY_RETRY_SECONDS)
|
|
147
|
+
waited += BUSY_RETRY_SECONDS
|
|
148
|
+
if code not in (SLAPGRID_OK, SLAPGRID_PROMISE_FAIL):
|
|
149
|
+
what = "ssh failed" if code == SSH_FAILED else f"exit {code}"
|
|
150
|
+
raise NodeError(
|
|
151
|
+
f"slapos node {args}: {what}; its last lines:\n" + "\n".join(last)
|
|
152
|
+
)
|
|
153
|
+
return code
|
|
154
|
+
|
|
155
|
+
def facts(self, urls: list[str]) -> NodeFacts:
|
|
156
|
+
dirs = [software_dir(u) for u in urls]
|
|
157
|
+
script = "; ".join([
|
|
158
|
+
f"for d in {' '.join(dirs)}; do"
|
|
159
|
+
" if [ -f $d/.completed ]; then echo $d completed;"
|
|
160
|
+
" elif [ -d $d ]; then echo $d building; else echo $d absent; fi; done"
|
|
161
|
+
if dirs
|
|
162
|
+
else "true",
|
|
163
|
+
f"echo {SEPARATOR}",
|
|
164
|
+
f"tail -n {LOG_LINES} {SOFTWARE_LOG}",
|
|
165
|
+
f"echo {SEPARATOR}",
|
|
166
|
+
f"tail -n {LOG_LINES} {INSTANCE_LOG}",
|
|
167
|
+
])
|
|
168
|
+
return parse_facts(urls, self.run(script))
|
|
169
|
+
|
|
170
|
+
def system(self) -> System:
|
|
171
|
+
return parse_system(self.run(_SYSTEM_SCRIPT))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
_SYSTEM_SCRIPT = f"; echo {SEPARATOR}; ".join([
|
|
175
|
+
"cut -d' ' -f1 /proc/uptime",
|
|
176
|
+
"ls -d /srv/slapgrid/slappart*",
|
|
177
|
+
"slapos node status 2>/dev/null",
|
|
178
|
+
"ps -eo user:32,rss --no-headers",
|
|
179
|
+
"free -m",
|
|
180
|
+
"df -m /srv/slapgrid",
|
|
181
|
+
])
|
|
182
|
+
_SERVICE = re.compile(r"(slappart\d+):(\S+)\s+([A-Z]+)")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def service_name(program: str) -> str:
|
|
186
|
+
"""web-2aa681dd2c1eb6c7422d9a9becca20d9-on-watch -> web."""
|
|
187
|
+
return re.sub(r"(-[0-9a-f]{32})?(-on-watch)?$", "", program)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def parse_system(output: str) -> System:
|
|
191
|
+
uptime, names, services, processes, free, df = output.split(SEPARATOR + "\n")
|
|
192
|
+
running: dict[str, int] = {}
|
|
193
|
+
down: dict[str, list[str]] = {}
|
|
194
|
+
for partition, program, state in _SERVICE.findall(services):
|
|
195
|
+
# Supervisor restarts nothing here and shows no exit code, so a crash also reads
|
|
196
|
+
# EXITED: only -on-watch services must stay up, the others run once or wait their turn.
|
|
197
|
+
if state == "RUNNING":
|
|
198
|
+
running[partition] = running.get(partition, 0) + 1
|
|
199
|
+
elif program.endswith("-on-watch") or state not in ("EXITED", "STOPPED"):
|
|
200
|
+
down.setdefault(partition, []).append(service_name(program))
|
|
201
|
+
memory_kb: dict[str, int] = {}
|
|
202
|
+
for user, rss in (line.split() for line in processes.splitlines()):
|
|
203
|
+
if user.startswith("slapuser"):
|
|
204
|
+
partition = user.replace("slapuser", "slappart")
|
|
205
|
+
memory_kb[partition] = memory_kb.get(partition, 0) + int(rss)
|
|
206
|
+
# free -m: total used free shared buff/cache available; df -m: fs total used available.
|
|
207
|
+
memory = free.splitlines()[1].split()
|
|
208
|
+
disk = df.splitlines()[1].split()
|
|
209
|
+
partitions = sorted(
|
|
210
|
+
(name.rsplit("/", 1)[1] for name in names.split()),
|
|
211
|
+
key=lambda name: int(name.removeprefix("slappart")),
|
|
212
|
+
)
|
|
213
|
+
return System(
|
|
214
|
+
uptime_seconds=int(float(uptime)),
|
|
215
|
+
partitions=[
|
|
216
|
+
Partition(
|
|
217
|
+
name,
|
|
218
|
+
memory_kb.get(name, 0) // 1024,
|
|
219
|
+
running.get(name, 0),
|
|
220
|
+
down.get(name, []),
|
|
221
|
+
)
|
|
222
|
+
for name in partitions
|
|
223
|
+
],
|
|
224
|
+
memory_available_mb=int(memory[6]),
|
|
225
|
+
memory_total_mb=int(memory[1]),
|
|
226
|
+
disk_free_mb=int(disk[3]),
|
|
227
|
+
disk_total_mb=int(disk[1]),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def parse_facts(urls: list[str], output: str) -> NodeFacts:
|
|
232
|
+
states, software_log, instance_log = output.split(SEPARATOR + "\n")
|
|
233
|
+
by_dir = dict(line.split(" ", 1) for line in states.splitlines())
|
|
234
|
+
errors = last_buildout_errors(software_log)
|
|
235
|
+
software = []
|
|
236
|
+
for url in urls:
|
|
237
|
+
d = software_dir(url)
|
|
238
|
+
state = by_dir.get(d, "absent")
|
|
239
|
+
software.append(
|
|
240
|
+
SoftwareState(url, state, errors.get(d) if state != "completed" else None)
|
|
241
|
+
)
|
|
242
|
+
return NodeFacts(software, failing_promises(instance_log))
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
_FAILED = re.compile(r"Failed to run buildout profile in directory '([^']+)'")
|
|
246
|
+
_ERROR = re.compile(r"INFO\s+(Error: .*)")
|
|
247
|
+
_RUN_START = re.compile(r"Processing software releases\.\.\.")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def last_buildout_errors(log: str) -> dict[str, str]:
|
|
251
|
+
"""Software dir -> the 'Error:' line of its last failed buildout run.
|
|
252
|
+
|
|
253
|
+
A later successful run in the same log clears nothing here: callers only
|
|
254
|
+
look at dirs that are not completed.
|
|
255
|
+
"""
|
|
256
|
+
errors: dict[str, str] = {}
|
|
257
|
+
last_error = None
|
|
258
|
+
for line in log.splitlines():
|
|
259
|
+
if _RUN_START.search(line):
|
|
260
|
+
last_error = None
|
|
261
|
+
elif m := _ERROR.search(line):
|
|
262
|
+
last_error = m.group(1)
|
|
263
|
+
elif m := _FAILED.search(line):
|
|
264
|
+
errors[m.group(1)] = (
|
|
265
|
+
last_error or "buildout failed (no 'Error:' line in the log tail)"
|
|
266
|
+
)
|
|
267
|
+
return errors
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def failing_promises(log: str) -> list[str]:
|
|
271
|
+
"""Promise failures of the last finished `slapos node instance` run.
|
|
272
|
+
|
|
273
|
+
A run logs "Finished computer partitions.", then, if promises failed,
|
|
274
|
+
"Error with promises for the following partitions:" and one line each.
|
|
275
|
+
"""
|
|
276
|
+
lines = log.splitlines()
|
|
277
|
+
finished = [
|
|
278
|
+
i for i, line in enumerate(lines) if "Finished computer partitions." in line
|
|
279
|
+
]
|
|
280
|
+
if not finished:
|
|
281
|
+
return []
|
|
282
|
+
result = []
|
|
283
|
+
in_block = False
|
|
284
|
+
for line in lines[finished[-1] + 1 :]:
|
|
285
|
+
if re.search(r"INFO\s+=+$", line):
|
|
286
|
+
continue
|
|
287
|
+
if "Error with promises for the following partitions:" in line:
|
|
288
|
+
in_block = True
|
|
289
|
+
elif in_block and (m := re.search(r"INFO\s{3,}(slappart\S+: .*)", line)):
|
|
290
|
+
result.append(m.group(1))
|
|
291
|
+
else:
|
|
292
|
+
break
|
|
293
|
+
return result
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def git(checkout: Path, *args: str) -> str:
|
|
297
|
+
result = subprocess.run(
|
|
298
|
+
["git", "-C", str(checkout), *args], capture_output=True, text=True
|
|
299
|
+
)
|
|
300
|
+
if result.returncode != 0:
|
|
301
|
+
raise NodeError(f"git -C {checkout} {' '.join(args)}: {result.stderr.strip()}")
|
|
302
|
+
return result.stdout
|