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/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"| {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)
|