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/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