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/panel.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""What we did in the panel: tokens for a new node, and making a release URL allocatable on it.
|
|
2
|
+
|
|
3
|
+
Every step looks first and acts only when what it needs is missing, so running
|
|
4
|
+
it again is harmless. What it creates shows up in the catalog after a few
|
|
5
|
+
seconds (ERP5 indexes in the background): each creation waits for it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from .config import Config, Software
|
|
11
|
+
from .dev import wait_for
|
|
12
|
+
from .master import Api, ComputeNode, MasterError, Request, Slap, nodes_titled, quoted
|
|
13
|
+
from .status import ALLOCABLE, matrix_cells, ticked_cells
|
|
14
|
+
|
|
15
|
+
RE6ST_POLL_SECONDS = 30
|
|
16
|
+
RE6ST_TIMEOUT_SECONDS = 45 * 60 # the registry takes 10+ minutes
|
|
17
|
+
COLUMNS = ["relative_url", "uid", "validation_state"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _project(api: Api, config: Config) -> dict:
|
|
21
|
+
rows = api.search(
|
|
22
|
+
f'portal_type:"Project" AND reference:={quoted(config.project.reference)}',
|
|
23
|
+
COLUMNS,
|
|
24
|
+
)
|
|
25
|
+
if not rows:
|
|
26
|
+
raise MasterError(
|
|
27
|
+
f"no project {config.project.reference} visible with this certificate"
|
|
28
|
+
)
|
|
29
|
+
return rows[0]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def node_certificate(api: Api, config: Config) -> dict[str, str]:
|
|
33
|
+
"""Our compute node's reference, certificate and key, to configure it with.
|
|
34
|
+
|
|
35
|
+
What `slapos node register` gets with a token, without the token: the
|
|
36
|
+
panel's "Add Compute Node Token" works only through panel.rapid.space,
|
|
37
|
+
which doesn't take our certificate. Creates the node (panel: "Add Compute
|
|
38
|
+
Node") if none has `[node] title`. Otherwise revokes its certificate and
|
|
39
|
+
requests a new one, since the master gives a certificate only once: a node
|
|
40
|
+
still configured with the old one loses access.
|
|
41
|
+
"""
|
|
42
|
+
nodes = nodes_titled(api, config.project.reference, config.node.title)
|
|
43
|
+
if len(nodes) > 1:
|
|
44
|
+
raise MasterError(f"compute node {config.node.title!r}: {len(nodes)} of them")
|
|
45
|
+
if nodes:
|
|
46
|
+
api.act(nodes[0].relative_url, "revoke_slapos_certificate")
|
|
47
|
+
answer = api.act(nodes[0].relative_url, "request_slapos_certificate_action")
|
|
48
|
+
else:
|
|
49
|
+
answer = api.act(
|
|
50
|
+
_project(api, config)["relative_url"],
|
|
51
|
+
"add_slapos_compute_node",
|
|
52
|
+
{"field_your_title": config.node.title},
|
|
53
|
+
)
|
|
54
|
+
if answer is None:
|
|
55
|
+
raise MasterError(
|
|
56
|
+
"the master answered with a redirect, not with the node's certificate"
|
|
57
|
+
)
|
|
58
|
+
# The script answers with a form whose fields hold its results.
|
|
59
|
+
names = {
|
|
60
|
+
"reference": "your_compute_node_reference",
|
|
61
|
+
"certificate": "your_compute_node_certificate",
|
|
62
|
+
"key": "your_compute_node_key",
|
|
63
|
+
}
|
|
64
|
+
missing = [n for n in names.values() if n not in answer]
|
|
65
|
+
if missing:
|
|
66
|
+
raise MasterError(
|
|
67
|
+
f"no {', '.join(missing)} in the master's answer; it has: {', '.join(answer)}"
|
|
68
|
+
)
|
|
69
|
+
return {name: answer[field]["default"] for name, field in names.items()}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def re6st_token(
|
|
73
|
+
api: Api, slap: Slap, config: Config, out=print, sleep=time.sleep
|
|
74
|
+
) -> str:
|
|
75
|
+
"""The node's re6st token: a shared instance of the re6st registry, in its own project.
|
|
76
|
+
|
|
77
|
+
Requesting it again returns the same instance, hence the same token.
|
|
78
|
+
"""
|
|
79
|
+
title = f"re6st token for {config.node.title}"
|
|
80
|
+
slap.request(
|
|
81
|
+
Request(
|
|
82
|
+
config.node.re6st_release,
|
|
83
|
+
title,
|
|
84
|
+
"default",
|
|
85
|
+
{},
|
|
86
|
+
shared=True,
|
|
87
|
+
project=config.node.re6st_project,
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def find() -> list[str]:
|
|
92
|
+
rows = api.search(
|
|
93
|
+
f'portal_type:"Slave Instance" AND validation_state:validated AND title:={quoted(title)}',
|
|
94
|
+
["getConnectionXmlAsDict"],
|
|
95
|
+
)
|
|
96
|
+
if (
|
|
97
|
+
not rows or not rows[0]["getConnectionXmlAsDict"]
|
|
98
|
+
): # None until the registry publishes
|
|
99
|
+
return []
|
|
100
|
+
token = rows[0]["getConnectionXmlAsDict"].get("token")
|
|
101
|
+
return [token] if token else []
|
|
102
|
+
|
|
103
|
+
out(f"{title}: requested, waiting for the registry to publish it (10+ minutes)")
|
|
104
|
+
return wait_for(title, find, sleep, RE6ST_POLL_SECONDS, RE6ST_TIMEOUT_SECONDS)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def open_for_allocation(api: Api, node: ComputeNode, out=print) -> None:
|
|
108
|
+
"""A new node's allocation scope is closed: nothing is allocated on it until it is open."""
|
|
109
|
+
|
|
110
|
+
def scope() -> str:
|
|
111
|
+
return api.get(node.relative_url, "slapos_panel_view")["_embedded"]["_view"][
|
|
112
|
+
"my_allocation_scope"
|
|
113
|
+
]["default"]
|
|
114
|
+
|
|
115
|
+
if scope() == "open":
|
|
116
|
+
out("node allocation scope: open")
|
|
117
|
+
return
|
|
118
|
+
api.edit(
|
|
119
|
+
node.relative_url,
|
|
120
|
+
{"field_my_allocation_scope": "open"},
|
|
121
|
+
view="slapos_panel_view",
|
|
122
|
+
)
|
|
123
|
+
if scope() != "open":
|
|
124
|
+
raise MasterError(
|
|
125
|
+
f"{node.relative_url}: allocation scope is still {scope()!r} after the edit"
|
|
126
|
+
)
|
|
127
|
+
out("node allocation scope: open now")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def make_allocatable(
|
|
131
|
+
api: Api,
|
|
132
|
+
config: Config,
|
|
133
|
+
node: ComputeNode,
|
|
134
|
+
sw: Software,
|
|
135
|
+
url: str,
|
|
136
|
+
out=print,
|
|
137
|
+
sleep=time.sleep,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Node open, product, software type, release, Allocation Supply with the (url, type) cell ticked, validated."""
|
|
140
|
+
|
|
141
|
+
def ensure(what: str, find, create) -> dict:
|
|
142
|
+
if rows := find():
|
|
143
|
+
out(f"{what}: exists")
|
|
144
|
+
return rows[0]
|
|
145
|
+
create()
|
|
146
|
+
row = wait_for(what, find, sleep)
|
|
147
|
+
out(f"{what}: created")
|
|
148
|
+
return row
|
|
149
|
+
|
|
150
|
+
open_for_allocation(api, node, out)
|
|
151
|
+
project_ref, node_ref = quoted(config.project.reference), quoted(node.reference)
|
|
152
|
+
project = _project(api, config)
|
|
153
|
+
supply_title = f"{sw.product} on {config.node.title}"
|
|
154
|
+
|
|
155
|
+
product = ensure(
|
|
156
|
+
f"Software Product {sw.product!r}",
|
|
157
|
+
lambda: api.search(
|
|
158
|
+
f'portal_type:"Software Product" AND follow_up_reference:={project_ref}'
|
|
159
|
+
f' AND title:={quoted(sw.product)} AND validation_state:!="invalidated"',
|
|
160
|
+
COLUMNS,
|
|
161
|
+
),
|
|
162
|
+
lambda: api.act(
|
|
163
|
+
project["relative_url"],
|
|
164
|
+
"add_slapos_software_product",
|
|
165
|
+
{"field_your_title": sw.product},
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
ensure(
|
|
169
|
+
f"software type {sw.software_type!r}",
|
|
170
|
+
lambda: api.search(
|
|
171
|
+
f'portal_type:"Software Product Type Variation" AND parent_uid:{product["uid"]}'
|
|
172
|
+
f" AND title:={quoted(sw.software_type)}",
|
|
173
|
+
COLUMNS,
|
|
174
|
+
),
|
|
175
|
+
lambda: api.act(
|
|
176
|
+
product["relative_url"],
|
|
177
|
+
"add_slapos_software_type",
|
|
178
|
+
{"field_your_software_type": sw.software_type},
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
ensure(
|
|
182
|
+
f"release {url}",
|
|
183
|
+
lambda: api.search(
|
|
184
|
+
f'portal_type:"Software Product Release Variation" AND parent_uid:{product["uid"]}'
|
|
185
|
+
f" AND url_string:={quoted(url)}",
|
|
186
|
+
COLUMNS,
|
|
187
|
+
),
|
|
188
|
+
lambda: api.act(
|
|
189
|
+
product["relative_url"],
|
|
190
|
+
"add_slapos_software_release",
|
|
191
|
+
{"field_your_software_release": url},
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def find_supplies(title: str | None = supply_title) -> list[dict]:
|
|
196
|
+
rows = api.search(
|
|
197
|
+
f'portal_type:"Allocation Supply" AND destination_project_reference:={project_ref}'
|
|
198
|
+
f" AND aggregate_reference:={node_ref}"
|
|
199
|
+
+ (f" AND title:={quoted(title)}" if title else ""),
|
|
200
|
+
COLUMNS,
|
|
201
|
+
)
|
|
202
|
+
return sorted(rows, key=lambda r: r["validation_state"] != "validated")
|
|
203
|
+
|
|
204
|
+
def create_supply() -> None:
|
|
205
|
+
# The panel's "Add Allocation Supply" makes an invalidated supply on all the
|
|
206
|
+
# project's open nodes, titled "All compute nodes <date>", with one line per product.
|
|
207
|
+
before = {r["relative_url"] for r in find_supplies(None)}
|
|
208
|
+
api.act(project["relative_url"], "add_slapos_allocation_supply")
|
|
209
|
+
new = wait_for(
|
|
210
|
+
"new Allocation Supply",
|
|
211
|
+
lambda: [r for r in find_supplies(None) if r["relative_url"] not in before],
|
|
212
|
+
sleep,
|
|
213
|
+
)
|
|
214
|
+
api.edit(new["relative_url"], {"field_my_title": supply_title})
|
|
215
|
+
|
|
216
|
+
supply = ensure(f"Allocation Supply {supply_title!r}", find_supplies, create_supply)
|
|
217
|
+
line = ensure(
|
|
218
|
+
f"its line for {sw.product!r}",
|
|
219
|
+
lambda: api.search(
|
|
220
|
+
f'portal_type:"Allocation Supply Line" AND parent_uid:{supply["uid"]}'
|
|
221
|
+
f" AND resource_title:={quoted(sw.product)}",
|
|
222
|
+
COLUMNS,
|
|
223
|
+
),
|
|
224
|
+
# Invalidating a supply adds the lines of the products it lacks; validated again below.
|
|
225
|
+
lambda: api.act(
|
|
226
|
+
supply["relative_url"], "slapos_panel_workflow_fast_invalidate"
|
|
227
|
+
),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
cell = (url, sw.software_type)
|
|
231
|
+
if cell in ticked_cells(api.get(line["relative_url"], "view")):
|
|
232
|
+
out(f"cell {sw.software_type!r} x release: ticked")
|
|
233
|
+
else:
|
|
234
|
+
api.edit(
|
|
235
|
+
line["relative_url"],
|
|
236
|
+
matrix_fields(api.get(line["relative_url"], "view"), cell),
|
|
237
|
+
)
|
|
238
|
+
if cell not in ticked_cells(api.get(line["relative_url"], "view")):
|
|
239
|
+
raise MasterError(
|
|
240
|
+
f"{line['relative_url']}: the cell for {cell} is still not ticked after the edit"
|
|
241
|
+
)
|
|
242
|
+
out(f"cell {sw.software_type!r} x release: ticked now")
|
|
243
|
+
|
|
244
|
+
if find_supplies()[0]["validation_state"] == "validated":
|
|
245
|
+
out("supply: validated")
|
|
246
|
+
else:
|
|
247
|
+
api.act(supply["relative_url"], "validate_action")
|
|
248
|
+
wait_for(
|
|
249
|
+
"supply validation",
|
|
250
|
+
lambda: [
|
|
251
|
+
r for r in find_supplies() if r["validation_state"] == "validated"
|
|
252
|
+
],
|
|
253
|
+
sleep,
|
|
254
|
+
)
|
|
255
|
+
out("supply: validated now")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def matrix_fields(line_view: dict, cell: tuple[str, str]) -> dict[str, str]:
|
|
259
|
+
"""Every field of the line's matrix as Base_edit expects them back, with `cell` ticked.
|
|
260
|
+
|
|
261
|
+
The matrix is validated as a whole: if one of its fields is missing from the
|
|
262
|
+
POST, Base_edit skips all of them.
|
|
263
|
+
"""
|
|
264
|
+
cells = list(matrix_cells(line_view))
|
|
265
|
+
if not any((r, t) == cell for r, t, _ in cells):
|
|
266
|
+
raise MasterError(f"the supply line's matrix has no cell for {cell}")
|
|
267
|
+
fields = {}
|
|
268
|
+
for release, software_type, field in cells:
|
|
269
|
+
if field["field_id"] == ALLOCABLE:
|
|
270
|
+
fields["field_" + field["key"]] = (
|
|
271
|
+
"1" if field["value"] or (release, software_type) == cell else "0"
|
|
272
|
+
)
|
|
273
|
+
else:
|
|
274
|
+
fields["field_" + field["key"]] = "\n".join(field["value"])
|
|
275
|
+
return fields
|