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/report.py ADDED
@@ -0,0 +1,208 @@
1
+ """`hox build-report`: how long a software's builds took on the node, and the disk they use, as Markdown."""
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+ from datetime import UTC, datetime
6
+
7
+ from .config import Config, Software
8
+ from .node import SEPARATOR, SOFTWARE_ROOT, Node, software_dir
9
+
10
+ LOG_DIR = "/opt/slapos/log"
11
+ SHARED = f"{SOFTWARE_ROOT}/shared"
12
+ CRON = "/etc/cron.d/slapos-node"
13
+ LONGEST = 15 # parts listed per run; the others are summed up
14
+ LARGEST = 10 # shared parts listed
15
+
16
+ # What a `slapos node software` log says about a build (slapgrid and buildout lines).
17
+ _EVENT = re.compile(
18
+ r"^\[(?P<time>[\d-]+ [\d:]+),\d+\] \w+ +(?:"
19
+ r"Installing software release (?P<release>\S+) \.\.\."
20
+ r"|Installing (?P<part>[\w.+-]+)\."
21
+ r"|.*\"POST /(?P<available>availableSoftwareRelease) .*"
22
+ r"|(?P<failed>Failed to run buildout profile) .*"
23
+ r"|(?P<finished>Finished software releases\.)"
24
+ r"|(?P<processing>Processing software releases\.\.\.))$"
25
+ )
26
+
27
+
28
+ def utc(text: str) -> datetime:
29
+ """A time of the node's logs or `date -u`: the node runs in UTC."""
30
+ return datetime.strptime(text, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC)
31
+
32
+
33
+ _GREP = (
34
+ r"Installing software release |INFO +Installing [A-Za-z0-9_.+-]+\.$"
35
+ r"|POST /availableSoftwareRelease|Failed to run buildout profile|(Finished|Processing) software releases"
36
+ )
37
+
38
+
39
+ @dataclass
40
+ class Run:
41
+ """One `slapos node software` attempt at a release URL."""
42
+
43
+ url: str
44
+ start: datetime
45
+ end: datetime | None = None # None: still running when the log was read
46
+ result: str = (
47
+ "building" # "completed", "failed", "stopped" (killed, no result) or "building"
48
+ )
49
+ parts: list[tuple[str, datetime]] = field(
50
+ default_factory=list
51
+ ) # (part, when its install started)
52
+
53
+ def part_times(self, now: datetime) -> list[tuple[str, float]]:
54
+ """(part, seconds), each part lasting until the next one starts or the run ends."""
55
+ ends = [t for _, t in self.parts[1:]] + [self.end or now]
56
+ return [
57
+ (name, (end - start).total_seconds())
58
+ for (name, start), end in zip(self.parts, ends)
59
+ ]
60
+
61
+
62
+ def parse_runs(log: str) -> list[Run]:
63
+ runs: list[Run] = []
64
+ current: Run | None = None
65
+ last: datetime | None = None # time of the last line read
66
+
67
+ def close(run: Run, end: datetime) -> None:
68
+ run.end = end
69
+ if run.result == "building":
70
+ run.result = "stopped"
71
+
72
+ for line in log.splitlines():
73
+ m = _EVENT.match(line)
74
+ if not m:
75
+ continue
76
+ when = utc(m["time"])
77
+ if m["processing"]: # a new slapgrid run: one still open was killed
78
+ if current:
79
+ assert last is not None # an open run has had at least its first line
80
+ close(current, last)
81
+ current = None
82
+ elif m["release"]:
83
+ if current: # the previous release of the same slapgrid run
84
+ close(current, when)
85
+ current = Run(m["release"], when)
86
+ runs.append(current)
87
+ elif current is None:
88
+ pass
89
+ elif m["part"]:
90
+ current.parts.append((m["part"], when))
91
+ elif m["available"]:
92
+ current.result = "completed"
93
+ elif m["failed"]:
94
+ current.result = "failed"
95
+ elif m["finished"]:
96
+ close(current, when)
97
+ current = None
98
+ last = when
99
+ return runs
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class NodeUsage:
104
+ now: datetime
105
+ cpus: int
106
+ memory_mib: int
107
+ makeflags: str | None
108
+ disk_kib: tuple[int, int] # (used, size) of the filesystem of /opt/slapgrid
109
+ sizes_kib: dict[str, int] # path -> size, for each release dir and each shared part
110
+
111
+
112
+ def gather(node: Node) -> tuple[list[Run], NodeUsage]:
113
+ """One SSH round trip: the build lines of the software logs (rotated ones first), then sizes."""
114
+ output = node.run(
115
+ "; ".join([
116
+ f"cd {LOG_DIR}",
117
+ f"cat $(ls slapos-node-software.log-* 2>/dev/null) slapos-node-software.log | grep -E '{_GREP}'",
118
+ f"echo {SEPARATOR}",
119
+ "date -u '+%Y-%m-%d %H:%M:%S'; nproc; free -m | awk '/^Mem/ {print $2}'",
120
+ f"grep -h '^MAKEFLAGS=' {CRON} || echo",
121
+ f"df -k --output=used,size {SOFTWARE_ROOT} | tail -1",
122
+ f"echo {SEPARATOR}",
123
+ f"du -sk {SOFTWARE_ROOT}/[0-9a-f]* {SHARED}/* 2>/dev/null || true", # release dirs: md5 hex names
124
+ ])
125
+ )
126
+ log, facts, sizes = output.split(SEPARATOR + "\n")
127
+ now, cpus, memory, makeflags, disk = facts.splitlines()
128
+ used, size = disk.split()
129
+ usage = NodeUsage(
130
+ utc(now),
131
+ int(cpus),
132
+ int(memory),
133
+ makeflags.removeprefix("MAKEFLAGS=") or None,
134
+ (int(used), int(size)),
135
+ {
136
+ path: int(kib)
137
+ for kib, path in (line.split("\t") for line in sizes.splitlines())
138
+ },
139
+ )
140
+ return parse_runs(log), usage
141
+
142
+
143
+ def duration(seconds: float) -> str:
144
+ minutes = round(seconds / 60)
145
+ hours, minutes = divmod(minutes, 60)
146
+ return f"{hours} h {minutes:02d} min" if hours else f"{minutes} min"
147
+
148
+
149
+ def size(kib: int) -> str:
150
+ return f"{kib / 1024**2:.1f} GiB" if kib >= 1024**2 else f"{kib / 1024:.0f} MiB"
151
+
152
+
153
+ def markdown(config: Config, sw: Software, runs: list[Run], usage: NodeUsage) -> str:
154
+ ours = [r for r in runs if sw.ref_of(r.url) is not None]
155
+ out = [f"# Build of {sw.name} on {config.node.title}", ""]
156
+ out.append(
157
+ f"Node: {usage.cpus} vCPU, {usage.memory_mib / 1024:.1f} GiB of memory,"
158
+ f" make jobs now: {usage.makeflags or 'one (no MAKEFLAGS)'} (a run keeps the setting it started with)."
159
+ f" Read on {usage.now:%Y-%m-%d %H:%M} UTC."
160
+ )
161
+ if not ours:
162
+ out += ["", f"No build of {sw.name} in the node's software logs."]
163
+ for url in dict.fromkeys(r.url for r in ours):
164
+ url_runs = [r for r in ours if r.url == url]
165
+ out += ["", f"## {config.url_name(url)}", "", f"<{url}>", ""]
166
+ out += [
167
+ "| Run | Started (UTC) | Ended (UTC) | Duration | Result |",
168
+ "|---|---|---|---|---|",
169
+ ]
170
+ for i, r in enumerate(url_runs, 1):
171
+ end = f"{r.end:%Y-%m-%d %H:%M}" if r.end else "running"
172
+ spent = duration(((r.end or usage.now) - r.start).total_seconds())
173
+ out.append(
174
+ f"| {i} | {r.start:%Y-%m-%d %H:%M} | {end} | {spent} | {r.result} |"
175
+ )
176
+ total = sum(((r.end or usage.now) - r.start).total_seconds() for r in url_runs)
177
+ out += ["", f"Total build time: {duration(total)} over {len(url_runs)} run(s)."]
178
+ longest = max(url_runs, key=lambda r: len(r.parts))
179
+ times = sorted(longest.part_times(usage.now), key=lambda p: -p[1])
180
+ if times:
181
+ number = url_runs.index(longest) + 1
182
+ out += [
183
+ "",
184
+ f'Longest parts of run {number} (from buildout\'s "Installing" lines):',
185
+ "",
186
+ ]
187
+ out += ["| Part | Time |", "|---|---|"]
188
+ out += [f"| {name} | {duration(t)} |" for name, t in times[:LONGEST]]
189
+ if rest := times[LONGEST:]:
190
+ out.append(
191
+ f"| {len(rest)} other parts | {duration(sum(t for _, t in rest))} |"
192
+ )
193
+ shared = {p: k for p, k in usage.sizes_kib.items() if p.startswith(SHARED + "/")}
194
+ largest = sorted(shared.items(), key=lambda p: -p[1])[:LARGEST]
195
+ out += ["", "## Disk", "", "| What | Size |", "|---|---|"]
196
+ for url in dict.fromkeys(r.url for r in ours):
197
+ if (kib := usage.sizes_kib.get(software_dir(url))) is not None:
198
+ out.append(f"| {config.url_name(url)}: release directory | {size(kib)} |")
199
+ out.append(
200
+ f"| Shared parts, for every release on the node ({len(shared)}) | {size(sum(shared.values()))} |"
201
+ )
202
+ out += [
203
+ f"| &nbsp;&nbsp;{p.removeprefix(SHARED + '/')} | {size(k)} |"
204
+ for p, k in largest
205
+ ]
206
+ used, total = usage.disk_kib
207
+ out.append(f"| Node disk used (all of it) | {size(used)} of {size(total)} |")
208
+ return "\n".join(out)
hox_cli/status.py ADDED
@@ -0,0 +1,361 @@
1
+ """`hox status`: what the master, the node and GitHub say, about one software or the whole system."""
2
+
3
+ import dataclasses
4
+ import re
5
+ from collections.abc import Iterator
6
+ from dataclasses import dataclass, field
7
+
8
+ import httpx2
9
+
10
+ from .config import Config, Software, short_ref
11
+ from .master import Api, MasterError, quoted
12
+ from .node import Node, NodeError, NodeFacts, Partition, System, git
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Frontend:
17
+ title: str
18
+ allocated: bool
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Instance:
23
+ title: str
24
+ state: str
25
+ url: str
26
+ software_type: str
27
+ partition: str | None
28
+ connection: dict[str, str]
29
+ news: str | None
30
+ frontends: list[Frontend]
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Installation:
35
+ url: str
36
+ state: str
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class GitHub:
41
+ local_head: str
42
+ pushed: bool # origin/<branch> (as last fetched) is at the local head
43
+ served: (
44
+ bool | None
45
+ ) # raw.githubusercontent.com serves software.cfg as at the local head; None: fetch failed
46
+
47
+
48
+ @dataclass
49
+ class Report:
50
+ software: str
51
+ instance: Instance | None
52
+ diagnosis: list[str] = field(default_factory=list)
53
+ installations: list[Installation] = field(default_factory=list)
54
+ node: NodeFacts | None = None
55
+ services: Partition | None = None
56
+ node_error: str | None = None
57
+ github: GitHub | None = None
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class Deployed:
62
+ """An instance tree of the project. `state` is what was requested: only the node knows what runs."""
63
+
64
+ instance: str
65
+ software: str | None # its [software.<name>] of hox.toml
66
+ release: str # the branch or commit of a release hox.toml knows, else its URL
67
+ state: str
68
+ partition: str | None
69
+ frontend: str | None
70
+ status: str = "unknown"
71
+ down: tuple[str, ...] = ()
72
+ memory_mb: int | None = None
73
+
74
+
75
+ @dataclass
76
+ class Overview:
77
+ instances: list[Deployed]
78
+ undeployed: list[str] # software of hox.toml with no instance
79
+ system: System | None = None
80
+ facts: NodeFacts | None = None
81
+ node_error: str | None = None
82
+
83
+
84
+ def overview(config: Config, api: Api, node: Node, node_reference: str) -> Overview:
85
+ rows = deployed(config, api)
86
+ result = Overview(rows, sorted(set(config.software) - {r.software for r in rows}))
87
+ wanted = [
88
+ i.url
89
+ for i in installations(api, node_reference)
90
+ if i.state == "start_requested"
91
+ ]
92
+ try:
93
+ result.system = node.system()
94
+ result.facts = node.facts(wanted)
95
+ except NodeError as e:
96
+ result.node_error = str(e)
97
+ result.instances = [
98
+ _observed(r, config.software[r.software] if r.software else None, result.system)
99
+ for r in rows
100
+ ]
101
+ return result
102
+
103
+
104
+ def _observed(row: Deployed, sw: Software | None, system: System | None) -> Deployed:
105
+ """The row, with what runs as far as we can tell: the master only knows what was requested."""
106
+ if row.state != "start_requested":
107
+ status = {"stop_requested": "stopped", "destroy_requested": "being destroyed"}[
108
+ row.state
109
+ ]
110
+ return dataclasses.replace(row, status=status)
111
+ if row.partition is None:
112
+ return dataclasses.replace(row, status="not allocated")
113
+ if system is None:
114
+ return row
115
+ partition = _services(system, row.partition, sw)
116
+ if partition is None or not (partition.running or partition.down):
117
+ return dataclasses.replace(row, status="no services")
118
+ down = tuple(partition.down)
119
+ status = (
120
+ "not running"
121
+ if not partition.running
122
+ else f"{len(down)} down"
123
+ if down
124
+ else "up"
125
+ )
126
+ return dataclasses.replace(
127
+ row, status=status, down=down, memory_mb=partition.memory_mb
128
+ )
129
+
130
+
131
+ def _services(system: System, name: str, sw: Software | None) -> Partition | None:
132
+ """The node's partition, leaving out of `down` the software's one-shot services."""
133
+ partition = next((p for p in system.partitions if p.name == name), None)
134
+ if partition is None or sw is None:
135
+ return partition
136
+ return dataclasses.replace(
137
+ partition, down=[d for d in partition.down if d not in sw.one_shot]
138
+ )
139
+
140
+
141
+ # The master keeps a destroyed instance's rows: more than the project's partitions.
142
+ LIST_LIMIT = 1000
143
+
144
+
145
+ def deployed(config: Config, api: Api) -> list[Deployed]:
146
+ project = f"validation_state:validated AND follow_up_reference:={quoted(config.project.reference)}"
147
+ trees = api.search(
148
+ f'portal_type:"Instance Tree" AND {project}',
149
+ ["title", "slap_state", "url_string"],
150
+ LIST_LIMIT,
151
+ )
152
+ # Only `url` of the connection parameters: others can hold credentials (monitor-setup-url).
153
+ roots = {
154
+ r["title"]: r
155
+ for r in api.search(
156
+ f'portal_type:"Software Instance" AND {project}',
157
+ ["title", "aggregate_title", "getConnectionXmlAsDict"],
158
+ LIST_LIMIT,
159
+ )
160
+ }
161
+ known = {sw.instance: sw for sw in config.software.values()}
162
+ rows = []
163
+ for tree in sorted(trees, key=lambda t: t["title"]):
164
+ title, sw = tree["title"], known.get(tree["title"])
165
+ root = roots.get(title, {})
166
+ rows.append(
167
+ Deployed(
168
+ instance=title,
169
+ software=sw.name if sw else None,
170
+ release=_release(sw, tree["url_string"]),
171
+ state=tree["slap_state"],
172
+ partition=root.get("aggregate_title"),
173
+ frontend=(root.get("getConnectionXmlAsDict") or {}).get("url"),
174
+ )
175
+ )
176
+ return rows
177
+
178
+
179
+ def _release(sw: Software | None, url: str) -> str:
180
+ ref = sw.ref_of(url) if sw else None
181
+ return url if ref is None else short_ref(ref)
182
+
183
+
184
+ def gather(
185
+ config: Config,
186
+ sw: Software,
187
+ api: Api,
188
+ node: Node,
189
+ http: httpx2.Client,
190
+ node_reference: str,
191
+ ) -> Report:
192
+ report = Report(sw.name, instance(api, sw.instance))
193
+ if report.instance and report.instance.partition is None:
194
+ report.diagnosis = allocation_diagnosis(api, config, sw, report.instance)
195
+ report.installations = [
196
+ i for i in installations(api, node_reference) if sw.ref_of(i.url) is not None
197
+ ]
198
+ try:
199
+ report.node = node.facts([i.url for i in report.installations])
200
+ if report.instance and (partition := report.instance.partition):
201
+ report.services = _services(node.system(), partition, sw)
202
+ promises = [
203
+ p for p in report.node.failing_promises if p.startswith(f"{partition}[")
204
+ ]
205
+ report.node = dataclasses.replace(report.node, failing_promises=promises)
206
+ except NodeError as e:
207
+ report.node_error = str(e)
208
+ report.github = github(sw, http)
209
+ return report
210
+
211
+
212
+ def instance(api: Api, title: str) -> Instance | None:
213
+ trees = api.search(
214
+ f'portal_type:"Instance Tree" AND validation_state:validated AND title:={quoted(title)}',
215
+ ["title", "relative_url", "slap_state", "url_string", "source_reference"],
216
+ )
217
+ if not trees:
218
+ return None
219
+ tree = trees[0]
220
+ rows = api.search(
221
+ 'portal_type:("Software Instance","Slave Instance") AND validation_state:validated'
222
+ f" AND default_specialise_title:={quoted(title)}",
223
+ [
224
+ "title",
225
+ "portal_type",
226
+ "slap_state",
227
+ "aggregate_title",
228
+ "getConnectionXmlAsDict",
229
+ "SoftwareInstance_getNewsDict",
230
+ ],
231
+ )
232
+ root = next(
233
+ (
234
+ r
235
+ for r in rows
236
+ if r["portal_type"] == "Software Instance" and r["title"] == title
237
+ ),
238
+ {}, # none yet: not allocated
239
+ )
240
+ frontends = [
241
+ Frontend(r["title"], r["aggregate_title"] is not None)
242
+ for r in rows
243
+ if r["portal_type"] == "Slave Instance"
244
+ ]
245
+ news: dict = root.get("SoftwareInstance_getNewsDict") or {}
246
+ return Instance(
247
+ title=title,
248
+ state=tree["slap_state"],
249
+ url=tree["url_string"],
250
+ software_type=tree["source_reference"],
251
+ partition=root.get("aggregate_title"),
252
+ connection=root.get("getConnectionXmlAsDict") or {},
253
+ news=news.get("text") if not news.get("no_data") else None,
254
+ frontends=frontends,
255
+ )
256
+
257
+
258
+ def require_instance(api: Api, sw: Software) -> Instance:
259
+ """The software's instance; an error that says what to do when the master has none."""
260
+ inst = instance(api, sw.instance)
261
+ if inst is None:
262
+ raise MasterError(f"no instance {sw.instance!r} on the master (`hox request`)")
263
+ return inst
264
+
265
+
266
+ def installations(api: Api, node_reference: str) -> list[Installation]:
267
+ rows = api.search(
268
+ 'portal_type:"Software Installation" AND validation_state:validated'
269
+ f" AND default_aggregate_reference:={quoted(node_reference)}",
270
+ ["url_string", "slap_state"],
271
+ )
272
+ return [Installation(r["url_string"], r["slap_state"]) for r in rows]
273
+
274
+
275
+ def allocation_diagnosis(
276
+ api: Api, config: Config, sw: Software, inst: Instance
277
+ ) -> list[str]:
278
+ """Why an instance has no partition: what the project lacks for its URL and type."""
279
+ if inst.software_type != sw.software_type:
280
+ return [
281
+ f"instance software type is {inst.software_type!r}, config says {sw.software_type!r}"
282
+ ]
283
+ releases = api.search(
284
+ f'portal_type:"Software Product Release Variation" AND url_string:={quoted(inst.url)}',
285
+ ["relative_url"],
286
+ )
287
+ if not releases:
288
+ return [
289
+ f"{inst.url} is not a release of any Software Product: `hox setup` or `hox release`"
290
+ ]
291
+ supplies = api.search(
292
+ 'portal_type:"Allocation Supply" AND validation_state:validated'
293
+ f" AND destination_project_reference:={quoted(config.project.reference)}",
294
+ ["relative_url", "title"],
295
+ )
296
+ if not supplies:
297
+ return [f"no validated Allocation Supply in {config.project.reference}"]
298
+ lines = api.search(
299
+ f'portal_type:"Allocation Supply Line" AND resource_title:={quoted(sw.product)}',
300
+ ["relative_url"],
301
+ )
302
+ for supply in supplies:
303
+ for line in lines:
304
+ if line["relative_url"].startswith(supply["relative_url"] + "/"):
305
+ if (inst.url, inst.software_type) in ticked_cells(
306
+ api.get(line["relative_url"], "view")
307
+ ):
308
+ return [
309
+ f"allocatable through {supply['title']!r}: wait for the master's allocation pass"
310
+ ]
311
+ return [f"no validated Allocation Supply ticks ({inst.url}, {inst.software_type})"]
312
+
313
+
314
+ _CELL = re.compile(r"_cell_\d+_(\d+)_\d+$")
315
+ ALLOCABLE = "matrixbox_allocable"
316
+
317
+
318
+ def matrix_cells(line_view: dict) -> Iterator[tuple[str, str, dict]]:
319
+ """(release URL, software type, field) for each field of an Allocation Supply Line's matrix.
320
+
321
+ The matrix is a list of tables (one per value of a third dimension, unused
322
+ here), each a header row of software types and one row per release. A cell
323
+ has three fields: the `allocable` checkbox and two hidden category lists.
324
+ """
325
+ for table in line_view["_embedded"]["_view"]["matrixbox"]["data"]:
326
+ header, rows = table[0], table[1:]
327
+ for row in rows:
328
+ for field in row[1:]:
329
+ cell = _CELL.search(field["key"])
330
+ if cell is None:
331
+ raise MasterError(
332
+ f"Allocation Supply matrix: unexpected field {field['key']!r}"
333
+ )
334
+ column = int(cell.group(1))
335
+ yield _label(row[0]), _label(header[1 + column]), field
336
+
337
+
338
+ def ticked_cells(line_view: dict) -> set[tuple[str, str]]:
339
+ """(release URL, software type) pairs ticked in an Allocation Supply Line's matrix."""
340
+ return {
341
+ (r, t)
342
+ for r, t, f in matrix_cells(line_view)
343
+ if f["field_id"] == ALLOCABLE and f["value"]
344
+ }
345
+
346
+
347
+ def _label(label: str) -> str:
348
+ # "Software Release/https://…" -> "https://…"
349
+ return label.split("/", 1)[1]
350
+
351
+
352
+ def github(sw: Software, http: httpx2.Client) -> GitHub:
353
+ head = git(sw.checkout, "rev-parse", sw.branch).strip()
354
+ remote = git(sw.checkout, "rev-parse", f"origin/{sw.branch}").strip()
355
+ expected = git(sw.checkout, "show", f"{head}:{sw.path}")
356
+ try:
357
+ response = http.get(sw.branch_url)
358
+ served = response.status_code == 200 and response.text == expected
359
+ except httpx2.TransportError:
360
+ served = None
361
+ return GitHub(head, head == remote, served)