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