openshift-template-client 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.
- openshift_template_client-0.1.0.dist-info/METADATA +415 -0
- openshift_template_client-0.1.0.dist-info/RECORD +7 -0
- openshift_template_client-0.1.0.dist-info/WHEEL +5 -0
- openshift_template_client-0.1.0.dist-info/top_level.txt +1 -0
- openshift_templates/__init__.py +5 -0
- openshift_templates/_client.py +506 -0
- openshift_templates/py.typed +0 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: openshift-template-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Small Python client for OpenShift templates and their resources
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: kubernetes<35,>=30
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest<9,>=8; extra == "dev"
|
|
10
|
+
Requires-Dist: ruff<1,>=0.9; extra == "dev"
|
|
11
|
+
Requires-Dist: build<2,>=1; extra == "dev"
|
|
12
|
+
|
|
13
|
+
# openshift-template-client
|
|
14
|
+
|
|
15
|
+
A minimal synchronous Python library for a separate mini scheduler to process
|
|
16
|
+
existing OpenShift Templates and create, inspect, or delete their namespaced resources.
|
|
17
|
+
Python 3.9+; `kubernetes>=30,<35` is the only direct runtime dependency.
|
|
18
|
+
|
|
19
|
+
It uses Kubernetes `DynamicClient` discovery, including installed APIs such as
|
|
20
|
+
KubeVirt `VirtualMachine`. OpenShift performs parameter substitution; no local
|
|
21
|
+
template engine or `oc`/`kubectl` subprocess is used. Scheduling stays in the
|
|
22
|
+
caller. Importing the package does not authenticate or contact a cluster.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
After the first successful PyPI release, install remotely without cloning:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
python3 -m venv .venv
|
|
30
|
+
source .venv/bin/activate
|
|
31
|
+
python -m pip install openshift-template-client
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
To select a particular published release, use
|
|
35
|
+
`python -m pip install 'openshift-template-client==0.1.0'` (once `0.1.0` is
|
|
36
|
+
published). To upgrade later, use `python -m pip install --upgrade
|
|
37
|
+
openshift-template-client`. Import the installed package as `openshift_templates`.
|
|
38
|
+
|
|
39
|
+
Before publishing to PyPI, or to use the GitHub source, install directly from
|
|
40
|
+
the repository after pushing the code there. This requires Git:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
python -m pip install 'openshift-template-client @ git+https://github.com/Ravid-Amar/openshift-template-client.git@main'
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Replace `@main` with a pushed release tag such as `@v0.1.0` to select that
|
|
47
|
+
revision. For development from a local clone:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
git clone https://github.com/Ravid-Amar/openshift-template-client.git
|
|
51
|
+
cd openshift-template-client
|
|
52
|
+
python -m pip install -e '.[dev]'
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Both pip/venv and the existing `src/` layout are supported. No uv migration is
|
|
56
|
+
required. See [START_HERE.md](START_HERE.md) for wheelhouse installation offline.
|
|
57
|
+
|
|
58
|
+
Pushing a `v*` tag runs the [PyPI publishing workflow](.github/workflows/publish.yml).
|
|
59
|
+
The tag must equal `v` plus the version in `pyproject.toml`. See
|
|
60
|
+
[Publishing releases](docs/publishing.md) for the one-time GitHub/PyPI setup and
|
|
61
|
+
release commands. PyPI installation becomes available after a successful upload;
|
|
62
|
+
creating this workflow alone does not publish a package.
|
|
63
|
+
|
|
64
|
+
## Local kubeconfig: create, then delete after restart
|
|
65
|
+
|
|
66
|
+
The `worker-template` Template must already exist in `infra`. The target
|
|
67
|
+
namespace `workers` and required namespaced APIs must also exist. Adapt the
|
|
68
|
+
parameter names to your Template, including explicit values for every unresolved
|
|
69
|
+
generator. This example assumes `VM_NAME` and `IMAGE_TAG` are its parameters.
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
import openshift_templates as ocp
|
|
73
|
+
|
|
74
|
+
api = ocp.connect(kubeconfig="/path/to/kubeconfig", context="dev", request_timeout=(5, 30))
|
|
75
|
+
try:
|
|
76
|
+
refs = ocp.create(
|
|
77
|
+
"worker-template",
|
|
78
|
+
{"VM_NAME": "worker-001", "IMAGE_TAG": "1.2.3"},
|
|
79
|
+
namespace="workers",
|
|
80
|
+
template_namespace="infra",
|
|
81
|
+
api=api,
|
|
82
|
+
request_timeout=(5, 30),
|
|
83
|
+
)
|
|
84
|
+
finally:
|
|
85
|
+
api.client.close()
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Later, in a fresh process, use the same template and inputs. No create result,
|
|
89
|
+
resource inventory, or discovery cache needs to be retained:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
import openshift_templates as ocp
|
|
93
|
+
|
|
94
|
+
api = ocp.connect(kubeconfig="/path/to/kubeconfig", context="dev")
|
|
95
|
+
try:
|
|
96
|
+
deleted = ocp.delete(
|
|
97
|
+
"worker-template",
|
|
98
|
+
{"VM_NAME": "worker-001", "IMAGE_TAG": "1.2.3"},
|
|
99
|
+
namespace="workers",
|
|
100
|
+
template_namespace="infra",
|
|
101
|
+
api=api,
|
|
102
|
+
)
|
|
103
|
+
finally:
|
|
104
|
+
api.client.close()
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`connect()` without kubeconfig/context uses the Kubernetes client's usual local
|
|
108
|
+
kubeconfig search and current context. Omitting `api` from an operation opens a
|
|
109
|
+
local client and closes it on success or failure. Injected clients remain open
|
|
110
|
+
for reuse; their owner must close `api.client`. `connect()` closes its transport
|
|
111
|
+
if discovery fails. TLS certificate verification defaults to `True`; configure
|
|
112
|
+
the cluster CA in kubeconfig. To disable verification explicitly, use
|
|
113
|
+
`connect(verify_ssl=False)` and pass that client as `api` to operations. This
|
|
114
|
+
setting applies to discovery and all resource requests, with either auth mode
|
|
115
|
+
(including `connect(in_cluster=True, verify_ssl=False)`):
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
params = {"VM_NAME": "worker-001", "IMAGE_TAG": "1.2.3"}
|
|
119
|
+
api = ocp.connect(kubeconfig="/path/to/kubeconfig", context="dev", verify_ssl=False)
|
|
120
|
+
try:
|
|
121
|
+
objects = ocp.get(
|
|
122
|
+
"worker-template", params, namespace="workers", template_namespace="infra", api=api
|
|
123
|
+
)
|
|
124
|
+
finally:
|
|
125
|
+
api.client.close()
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
With verification disabled, the server certificate is not authenticated.
|
|
129
|
+
Kubeconfig updates and HTTP debug output are disabled by `connect()`.
|
|
130
|
+
|
|
131
|
+
## In-cluster ServiceAccount
|
|
132
|
+
|
|
133
|
+
Inside a pod with ServiceAccount credentials and cluster CA mounted, use:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
import openshift_templates as ocp
|
|
137
|
+
|
|
138
|
+
params = {"VM_NAME": "worker-002", "IMAGE_TAG": "1.2.3"}
|
|
139
|
+
api = ocp.connect(in_cluster=True)
|
|
140
|
+
try:
|
|
141
|
+
ocp.create("worker-template", params, namespace="workers", template_namespace="infra", api=api)
|
|
142
|
+
finally:
|
|
143
|
+
api.client.close()
|
|
144
|
+
|
|
145
|
+
# Later, including in a new pod/process with the same stored job inputs:
|
|
146
|
+
api = ocp.connect(in_cluster=True)
|
|
147
|
+
try:
|
|
148
|
+
ocp.delete("worker-template", params, namespace="workers", template_namespace="infra", api=api)
|
|
149
|
+
finally:
|
|
150
|
+
api.client.close()
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Do not combine `in_cluster=True` with kubeconfig/context. Grant this
|
|
154
|
+
ServiceAccount the permissions below in the source and target namespaces.
|
|
155
|
+
|
|
156
|
+
## Public API
|
|
157
|
+
|
|
158
|
+
Import as `openshift_templates`. All operations are synchronous and have type
|
|
159
|
+
hints and docstrings with parameters, returns, errors, and examples.
|
|
160
|
+
|
|
161
|
+
| Function | What it does | Returns |
|
|
162
|
+
| --- | --- | --- |
|
|
163
|
+
| `connect()` | Loads local kubeconfig/context or in-cluster credentials and discovers APIs. Close its transport with `api.client.close()`. | A reusable `DynamicClient`. |
|
|
164
|
+
| `process()` | Fetches a stored Template, overrides parameters in a copy, and asks OpenShift to render it. No resources are deployed. | Rendered manifest dictionaries. |
|
|
165
|
+
| `create()` | Calls `process()`, validates every target, then applies them in order with server-side apply. `field_manager` defaults to `mini-scheduler`; conflicts are preserved. | Identity references, including returned UIDs. |
|
|
166
|
+
| `get()` | Reconstructs template identities and reads the currently deployed objects in order. It takes the same template arguments as `create()`, or `resources=refs`. | Full live dictionaries, including each kind's `status` when present. |
|
|
167
|
+
| `delete()` | Reconstructs template identities and deletes them in reverse order. Missing objects are already absent. It also accepts `resources=refs`. | Successful identity references. |
|
|
168
|
+
|
|
169
|
+
`process`, `create`, `get`, and `delete` accept `template_name`, optional
|
|
170
|
+
`parameters`, required target `namespace`, optional `template_namespace`,
|
|
171
|
+
optional injected `api`, and `request_timeout`. `get`/`delete` accept either
|
|
172
|
+
template inputs or `resources`, never both. Their `template_name` defaults to
|
|
173
|
+
`None` to allow the reference mode; it is required when `resources` is omitted.
|
|
174
|
+
Supplying neither mode is invalid. All five functions have examples
|
|
175
|
+
and explanations in their docstrings; internal helper functions explain their
|
|
176
|
+
single responsibility where they are defined.
|
|
177
|
+
|
|
178
|
+
`template_namespace` defaults to the target `namespace` only when omitted.
|
|
179
|
+
Explicitly invalid/empty namespaces fail before authentication or API requests.
|
|
180
|
+
Parameters are string-keyed mappings of strings, booleans, integers, or finite
|
|
181
|
+
floats; booleans become `true`/`false`, other scalar values become strings.
|
|
182
|
+
Unknown names and `None` values are errors. Unspecified Template defaults remain
|
|
183
|
+
intact. Overrides remove the parameter's `generate` and `from` fields in a copy;
|
|
184
|
+
the stored Template is never changed.
|
|
185
|
+
|
|
186
|
+
Each operation accepts positive seconds or a `(connect, read)` seconds tuple;
|
|
187
|
+
the default is `(10, 60)`. This is a per-request timeout, not a whole-operation
|
|
188
|
+
deadline. `connect(request_timeout=...)` configures discovery and direct client
|
|
189
|
+
calls. Pass the operation's `request_timeout` separately for its Template GET,
|
|
190
|
+
processing POST, and resource mutations. With an injected client, discovery uses
|
|
191
|
+
that client's configuration. Clients constructed by `connect()` disable
|
|
192
|
+
transport retries; injected clients should also disable mutation retries.
|
|
193
|
+
|
|
194
|
+
`process(..., for_delete=True)` remains accepted for compatibility. Deterministic
|
|
195
|
+
parameter checks now apply regardless of that flag, including processing only.
|
|
196
|
+
To inspect rendering:
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
objects = ocp.process(
|
|
200
|
+
"worker-template", params, namespace="workers", template_namespace="infra", api=api
|
|
201
|
+
)
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Resource UIDs and optional deletion protection
|
|
205
|
+
|
|
206
|
+
OpenShift assigns each resource a UID and stores it in the live object's
|
|
207
|
+
`metadata.uid`. `create()` copies that UID from the server-side apply response
|
|
208
|
+
into its returned references. The package does not save those references or UIDs
|
|
209
|
+
to disk; retaining them is optional.
|
|
210
|
+
|
|
211
|
+
Template-based deletion works after a pod/process restart using the same job
|
|
212
|
+
inputs. It reprocesses the Template and deletes by name and namespace, without
|
|
213
|
+
fetching the live resources or their UIDs. No saved `create()` result is needed.
|
|
214
|
+
|
|
215
|
+
The optional reference mode skips Template processing and uses any supplied UID
|
|
216
|
+
as a deletion precondition:
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
import openshift_templates as ocp
|
|
220
|
+
|
|
221
|
+
params = {"VM_NAME": "worker-001", "IMAGE_TAG": "1.2.3"}
|
|
222
|
+
api = ocp.connect() # Or connect(in_cluster=True) in a pod.
|
|
223
|
+
try:
|
|
224
|
+
refs = ocp.create(
|
|
225
|
+
"worker-template", params, namespace="workers", template_namespace="infra", api=api
|
|
226
|
+
)
|
|
227
|
+
# template_name defaults to None here because resources supplies the identities.
|
|
228
|
+
# A saved UID protects against deleting a replacement object with the same name.
|
|
229
|
+
ocp.delete(namespace="workers", resources=refs, api=api)
|
|
230
|
+
finally:
|
|
231
|
+
api.client.close()
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
If a live object's UID differs from the supplied UID, the server rejects that
|
|
235
|
+
deletion with 409, exposed through `OperationError`. References without UIDs are
|
|
236
|
+
also accepted and delete by name/namespace. Omit `template_name`, `parameters`,
|
|
237
|
+
and `template_namespace` when passing `resources`. An empty reference list is a
|
|
238
|
+
valid no-op. A UID embedded in a rendered Template is never used as a deletion
|
|
239
|
+
precondition.
|
|
240
|
+
|
|
241
|
+
`get()` returns current live UIDs, but reading one before deletion cannot prove
|
|
242
|
+
that it belongs to the original object: a replacement may already exist. Only
|
|
243
|
+
retaining the original UID provides that additional identity protection.
|
|
244
|
+
|
|
245
|
+
## Inspect deployed resources
|
|
246
|
+
|
|
247
|
+
Use the same job inputs after restart, or pass `resources=refs` from create.
|
|
248
|
+
`get()` reads the main resource endpoints, including KubeVirt VirtualMachines;
|
|
249
|
+
it does not poll, wait, or decide whether a workload is ready.
|
|
250
|
+
|
|
251
|
+
```python
|
|
252
|
+
import openshift_templates as ocp
|
|
253
|
+
|
|
254
|
+
api = ocp.connect() # Or connect(in_cluster=True) in a pod.
|
|
255
|
+
try:
|
|
256
|
+
objects = ocp.get(
|
|
257
|
+
"worker-template",
|
|
258
|
+
{"VM_NAME": "worker-001", "IMAGE_TAG": "1.2.3"},
|
|
259
|
+
namespace="workers",
|
|
260
|
+
template_namespace="infra",
|
|
261
|
+
api=api,
|
|
262
|
+
)
|
|
263
|
+
for obj in objects:
|
|
264
|
+
print(obj["kind"], obj["metadata"]["name"], obj.get("status", {}))
|
|
265
|
+
finally:
|
|
266
|
+
api.client.close()
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
These are live responses, unlike the desired manifests from `process()`. A kind
|
|
270
|
+
such as ConfigMap may have no `status`. Read errors, including 404 for a missing
|
|
271
|
+
resource, 403, and timeouts, propagate immediately; a failed call returns no
|
|
272
|
+
partial list. Responses include the full object, so avoid logging Secret objects.
|
|
273
|
+
Each object is read sequentially; the list is not an atomic cluster snapshot.
|
|
274
|
+
|
|
275
|
+
## Stateless replay and namespace boundaries
|
|
276
|
+
|
|
277
|
+
- Both create and template-based delete reject unresolved parameter generators,
|
|
278
|
+
even if a generator appears unrelated to an object's name. Supply explicit
|
|
279
|
+
values, or use a nonempty fixed default already in the Template. No random
|
|
280
|
+
value is regenerated by these operations.
|
|
281
|
+
- Every rendered object needs a stable `metadata.name`. `metadata.generateName`
|
|
282
|
+
is rejected even when a name is also supplied.
|
|
283
|
+
- All rendered targets are discovered and validated before any resource apply
|
|
284
|
+
or delete. Only namespaced resources in the requested target namespace are
|
|
285
|
+
accepted. An omitted/empty manifest namespace becomes the target namespace;
|
|
286
|
+
another namespace, or any cluster-scoped kind, is rejected. Namespace and CRD
|
|
287
|
+
creation are outside this library.
|
|
288
|
+
- Stateless replay depends on compatible Template revisions and the same
|
|
289
|
+
parameters reproducing identical API resource kinds, names, and namespaces.
|
|
290
|
+
Keep that revision available. Renaming/removing an object can leave the old
|
|
291
|
+
object undiscoverable by replay. Apply does not prune removed objects.
|
|
292
|
+
- Use unique names per scheduler job. Manage shared resources separately.
|
|
293
|
+
Template-based deletion is by name: it can delete a replacement with the same
|
|
294
|
+
identity and provides no original-UID protection after restart.
|
|
295
|
+
|
|
296
|
+
For core `v1` Secrets, create converts UTF-8 `stringData` to base64 `data` before
|
|
297
|
+
server-side apply, preserving existing keys and giving `stringData` precedence
|
|
298
|
+
on collisions. This follows the API's merge behavior and avoids the documented
|
|
299
|
+
[Secret stringData limitations with server-side apply](https://kubernetes.io/docs/concepts/configuration/secret/#working-with-secrets).
|
|
300
|
+
Normalization uses only the standard library, works on copies, and does not
|
|
301
|
+
change `process()` output. Base64 is not encryption.
|
|
302
|
+
|
|
303
|
+
## Failures and completion
|
|
304
|
+
|
|
305
|
+
Invalid arguments and unsafe rendered identities raise `ValueError`. Failures
|
|
306
|
+
before resource mutations (authentication, discovery, Template GET/POST,
|
|
307
|
+
processing timeouts) propagate directly from the Kubernetes client/transport.
|
|
308
|
+
Other schema validation remains the server's responsibility.
|
|
309
|
+
|
|
310
|
+
Mutation failures raise `OperationError`. Its `.operation` is `Apply` or `Delete`,
|
|
311
|
+
`.resources` contains successful identity references, and `.failures` contains
|
|
312
|
+
`(reference, original_exception)` pairs. Apply stops at its first failure;
|
|
313
|
+
delete attempts every validated target in reverse order. Only an individual
|
|
314
|
+
delete's 404 is a successful no-op. Permission errors (403), field/UID conflicts
|
|
315
|
+
(409), validation errors, and timeouts remain visible. No rollback or automatic
|
|
316
|
+
mutation retry is performed.
|
|
317
|
+
|
|
318
|
+
```python
|
|
319
|
+
try:
|
|
320
|
+
ocp.create("worker-template", params, namespace="workers", template_namespace="infra", api=api)
|
|
321
|
+
except ocp.OperationError as error:
|
|
322
|
+
accepted = error.resources
|
|
323
|
+
failed_references = [ref for ref, exception in error.failures]
|
|
324
|
+
# Let your scheduler decide how to reconcile the outcome.
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
Multi-object operations are not transactional. A mutation timeout has an unknown
|
|
328
|
+
outcome: the server may have accepted it. Success means API acceptance, not VM
|
|
329
|
+
readiness or completed background deletion. The package does not log tokens,
|
|
330
|
+
parameter values, or rendered Secrets. References and the `OperationError`
|
|
331
|
+
message contain identities only; raw exceptions/tracebacks may include server
|
|
332
|
+
response bodies, so avoid logging them wholesale.
|
|
333
|
+
|
|
334
|
+
## Namespace-scoped permissions
|
|
335
|
+
|
|
336
|
+
Grant only the API groups/kinds actually used by your Templates. Ordinary API
|
|
337
|
+
discovery access is needed; it grants no cluster-scoped mutation permission.
|
|
338
|
+
`get()` additionally needs target resource `get` permission. No operation needs
|
|
339
|
+
target resource `list` or `watch` permission.
|
|
340
|
+
|
|
341
|
+
| Namespace | API group / resource | Verbs |
|
|
342
|
+
| --- | --- | --- |
|
|
343
|
+
| Source (`infra`) | `template.openshift.io` / `templates` | `get` (can restrict to the Template's name) |
|
|
344
|
+
| Target (`workers`) | `template.openshift.io` / `processedtemplates` | `create` |
|
|
345
|
+
| Target (`workers`) | Each rendered resource, e.g. core `configmaps`, `secrets`; `kubevirt.io` / `virtualmachines` | `create`, `patch`, `delete`; add `get` for inspection |
|
|
346
|
+
|
|
347
|
+
Example Roles for a Template producing ConfigMaps, Secrets, and VirtualMachines:
|
|
348
|
+
|
|
349
|
+
```yaml
|
|
350
|
+
apiVersion: rbac.authorization.k8s.io/v1
|
|
351
|
+
kind: Role
|
|
352
|
+
metadata:
|
|
353
|
+
name: worker-template-reader
|
|
354
|
+
namespace: infra
|
|
355
|
+
rules:
|
|
356
|
+
- apiGroups: [template.openshift.io]
|
|
357
|
+
resources: [templates]
|
|
358
|
+
resourceNames: [worker-template]
|
|
359
|
+
verbs: [get]
|
|
360
|
+
---
|
|
361
|
+
apiVersion: rbac.authorization.k8s.io/v1
|
|
362
|
+
kind: Role
|
|
363
|
+
metadata:
|
|
364
|
+
name: worker-template-manager
|
|
365
|
+
namespace: workers
|
|
366
|
+
rules:
|
|
367
|
+
- apiGroups: [template.openshift.io]
|
|
368
|
+
resources: [processedtemplates]
|
|
369
|
+
verbs: [create]
|
|
370
|
+
- apiGroups: [""]
|
|
371
|
+
resources: [configmaps, secrets]
|
|
372
|
+
verbs: [create, patch, delete, get]
|
|
373
|
+
- apiGroups: [kubevirt.io]
|
|
374
|
+
resources: [virtualmachines]
|
|
375
|
+
verbs: [create, patch, delete, get]
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
Have namespace owners bind each Role with a RoleBinding in its namespace to
|
|
379
|
+
your user or the pod's ServiceAccount (specify the ServiceAccount's actual
|
|
380
|
+
namespace, even for a binding in another namespace). The library does not create
|
|
381
|
+
these Roles/bindings. No ClusterRole, CRD installation, or cluster-admin grant is
|
|
382
|
+
required by this workflow. KubeVirt must already be installed if used.
|
|
383
|
+
|
|
384
|
+
## Development and validation
|
|
385
|
+
|
|
386
|
+
```bash
|
|
387
|
+
python -m pytest
|
|
388
|
+
python -m ruff check .
|
|
389
|
+
python -m ruff format --check .
|
|
390
|
+
python -m build
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Tests block real credential loading/network access and mock transport while
|
|
394
|
+
exercising the real DynamicClient's HTTP construction. They cover replay with a
|
|
395
|
+
fresh client, optional UID preconditions, live status reads, namespace/scope
|
|
396
|
+
boundaries, generators, reverse/repeated deletion, SSA conflicts, partial
|
|
397
|
+
failures, timeouts (including discovery), Secret
|
|
398
|
+
normalization, and client ownership. Builds use `pyproject.toml` and produce a
|
|
399
|
+
wheel and sdist; isolated builds may fetch build dependencies.
|
|
400
|
+
|
|
401
|
+
A clean-environment wheel import can be checked without cluster credentials:
|
|
402
|
+
|
|
403
|
+
```bash
|
|
404
|
+
python -m venv /tmp/openshift-template-wheel-check
|
|
405
|
+
/tmp/openshift-template-wheel-check/bin/python -m pip install dist/*.whl
|
|
406
|
+
/tmp/openshift-template-wheel-check/bin/python -I -c 'import openshift_templates'
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
For a fully offline install/build, prepare a wheelhouse as described in
|
|
410
|
+
[START_HERE.md](START_HERE.md). Python 3.9 syntax compatibility is maintained;
|
|
411
|
+
runtime checks here use Python 3.12 and kubernetes 34.1.0. Actual Python 3.9
|
|
412
|
+
execution and live OpenShift validation remain target-environment checks.
|
|
413
|
+
Live checks should confirm deployed-version processing, RBAC, SSA/Secret and
|
|
414
|
+
KubeVirt behavior, and admission/finalizer effects. See
|
|
415
|
+
[docs/api-notes.md](docs/api-notes.md) for endpoint details and official sources.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
openshift_templates/__init__.py,sha256=ZK0J32APVDcjVvipkZqutMTS4HWyo7hSxDnAXKQg3O0,235
|
|
2
|
+
openshift_templates/_client.py,sha256=GIyV5TuHRs9sgpIXpMy48k3DWMyKqOcyApAtzs9x3rQ,23410
|
|
3
|
+
openshift_templates/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
openshift_template_client-0.1.0.dist-info/METADATA,sha256=aBHd1TUYqG3rUnFyEqL7TNW3v1q_N1Syf_iIGINFAUE,18578
|
|
5
|
+
openshift_template_client-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
6
|
+
openshift_template_client-0.1.0.dist-info/top_level.txt,sha256=vPgu4Pf-f8OuqodogZgz9fFRMe7pi5veZVGGi5FoUvE,20
|
|
7
|
+
openshift_template_client-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
openshift_templates
|
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
"""Synchronous OpenShift Template processing and stateless resource lifecycle."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from base64 import b64encode
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
from math import isfinite
|
|
9
|
+
from typing import Any, Dict, Iterator, List, Optional, Tuple, Union
|
|
10
|
+
|
|
11
|
+
from kubernetes import client, config
|
|
12
|
+
from kubernetes.dynamic import DynamicClient
|
|
13
|
+
from kubernetes.dynamic.exceptions import NotFoundError
|
|
14
|
+
from kubernetes.dynamic.resource import Resource
|
|
15
|
+
|
|
16
|
+
API_VERSION = "template.openshift.io/v1"
|
|
17
|
+
RequestTimeout = Union[float, Tuple[float, float]]
|
|
18
|
+
Parameters = Mapping[str, Union[str, bool, int, float]]
|
|
19
|
+
Manifest = Dict[str, Any]
|
|
20
|
+
REQUEST_TIMEOUT: RequestTimeout = (10, 60)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OperationError(RuntimeError):
|
|
24
|
+
"""Mutation failures: operation name, successful references, and (reference, error) pairs.
|
|
25
|
+
|
|
26
|
+
Messages contain identities only; original exceptions can include response bodies.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
operation: str,
|
|
32
|
+
resources: List[Manifest],
|
|
33
|
+
failures: List[Tuple[Manifest, Exception]],
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Keep successful references and original failures; format an identity-only message."""
|
|
36
|
+
self.operation = operation
|
|
37
|
+
self.resources = resources
|
|
38
|
+
self.failures = failures
|
|
39
|
+
labels = [f"{ref['kind']}/{ref['metadata']['name']}" for ref, _ in failures]
|
|
40
|
+
super().__init__(f"{operation} failed for {', '.join(labels)}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _validate_timeout(request_timeout: RequestTimeout) -> None:
|
|
44
|
+
"""Reject invalid or unbounded timeout settings before opening a connection."""
|
|
45
|
+
values = request_timeout if isinstance(request_timeout, tuple) else (request_timeout,)
|
|
46
|
+
if (isinstance(request_timeout, tuple) and len(values) != 2) or any(
|
|
47
|
+
isinstance(value, bool)
|
|
48
|
+
or not isinstance(value, (int, float))
|
|
49
|
+
or not isfinite(value)
|
|
50
|
+
or value <= 0
|
|
51
|
+
for value in values
|
|
52
|
+
):
|
|
53
|
+
raise ValueError("request_timeout must be positive seconds or a (connect, read) pair")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _validate_inputs(
|
|
57
|
+
template_name: Optional[str],
|
|
58
|
+
parameters: Optional[Parameters],
|
|
59
|
+
namespace: str,
|
|
60
|
+
template_namespace: Optional[str],
|
|
61
|
+
request_timeout: RequestTimeout,
|
|
62
|
+
resources: Optional[List[Manifest]] = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
"""
|
|
65
|
+
Check namespaces, timeout, and either template inputs or references before connecting.
|
|
66
|
+
“DNS subdomain” describes the name’s format—it doesn’t require a real domain or DNS record.
|
|
67
|
+
"""
|
|
68
|
+
namespaces = {"namespace": namespace}
|
|
69
|
+
if template_namespace is not None:
|
|
70
|
+
namespaces["template_namespace"] = template_namespace
|
|
71
|
+
for argument, value in namespaces.items():
|
|
72
|
+
if (
|
|
73
|
+
not isinstance(value, str)
|
|
74
|
+
or len(value) > 63
|
|
75
|
+
or not re.fullmatch(r"[a-z0-9](?:[-a-z0-9]*[a-z0-9])?", value)
|
|
76
|
+
):
|
|
77
|
+
raise ValueError(f"{argument} must be a nonempty DNS label of at most 63 characters")
|
|
78
|
+
_validate_timeout(request_timeout)
|
|
79
|
+
# get/delete have two exclusive input modes. An empty reference list is valid;
|
|
80
|
+
# None selects template replay, which requires a template_name below.
|
|
81
|
+
if resources is not None:
|
|
82
|
+
if template_name is not None or parameters is not None or template_namespace is not None:
|
|
83
|
+
raise ValueError("Provide resources OR template inputs, not both")
|
|
84
|
+
if not isinstance(resources, list) or any(not isinstance(obj, dict) for obj in resources):
|
|
85
|
+
raise ValueError("resources must be a list of manifest dictionaries")
|
|
86
|
+
return
|
|
87
|
+
if (
|
|
88
|
+
not isinstance(template_name, str)
|
|
89
|
+
or len(template_name) > 253
|
|
90
|
+
or not re.fullmatch(
|
|
91
|
+
r"[a-z0-9](?:[-a-z0-9]*[a-z0-9])?(?:\.[a-z0-9](?:[-a-z0-9]*[a-z0-9])?)*", template_name
|
|
92
|
+
)
|
|
93
|
+
):
|
|
94
|
+
raise ValueError("template_name must be a nonempty DNS subdomain")
|
|
95
|
+
if parameters is not None:
|
|
96
|
+
if not isinstance(parameters, Mapping) or any(
|
|
97
|
+
not isinstance(name, str) or not name for name in parameters
|
|
98
|
+
):
|
|
99
|
+
raise ValueError("parameters must be a mapping with nonempty string names")
|
|
100
|
+
for name, value in parameters.items():
|
|
101
|
+
if value is None:
|
|
102
|
+
raise ValueError(f"Parameter {name} cannot be None")
|
|
103
|
+
if not isinstance(value, (str, bool, int, float)) or (
|
|
104
|
+
isinstance(value, float) and not isfinite(value)
|
|
105
|
+
):
|
|
106
|
+
raise ValueError(
|
|
107
|
+
f"Parameter {name} must be a string, boolean, integer, or finite float."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def connect(
|
|
112
|
+
*,
|
|
113
|
+
in_cluster: bool = False,
|
|
114
|
+
kubeconfig: Optional[str] = None,
|
|
115
|
+
context: Optional[str] = None,
|
|
116
|
+
verify_ssl: bool = True,
|
|
117
|
+
request_timeout: RequestTimeout = REQUEST_TIMEOUT,
|
|
118
|
+
) -> DynamicClient:
|
|
119
|
+
"""Return a reusable DynamicClient using local kubeconfig or pod credentials.
|
|
120
|
+
|
|
121
|
+
kubeconfig/context select local credentials; in_cluster selects the ServiceAccount.
|
|
122
|
+
request_timeout is seconds or (connect, read) seconds for discovery/direct calls.
|
|
123
|
+
verify_ssl enables certificate verification by default; False disables it for
|
|
124
|
+
discovery and all requests through this client. Transport retries are disabled. The caller
|
|
125
|
+
must close api.client. Invalid settings raise ValueError; authentication and
|
|
126
|
+
API errors propagate. Discovery failures close the transport before raising.
|
|
127
|
+
|
|
128
|
+
Example: api = connect(kubeconfig="/path/to/config", context="dev")"""
|
|
129
|
+
_validate_timeout(request_timeout)
|
|
130
|
+
if in_cluster and (kubeconfig is not None or context is not None):
|
|
131
|
+
raise ValueError("in_cluster cannot be combined with kubeconfig or context")
|
|
132
|
+
configuration = client.Configuration()
|
|
133
|
+
if in_cluster:
|
|
134
|
+
config.load_incluster_config(client_configuration=configuration)
|
|
135
|
+
else:
|
|
136
|
+
config.load_kube_config(
|
|
137
|
+
config_file=kubeconfig,
|
|
138
|
+
context=context,
|
|
139
|
+
client_configuration=configuration,
|
|
140
|
+
persist_config=False,
|
|
141
|
+
)
|
|
142
|
+
configuration.verify_ssl = verify_ssl
|
|
143
|
+
configuration.debug = False
|
|
144
|
+
# A lost mutation response does not prove failure; retrying could mutate again.
|
|
145
|
+
configuration.retries = 0
|
|
146
|
+
transport = client.ApiClient(configuration)
|
|
147
|
+
# DynamicClient's discovery calls do not accept a timeout argument. Set a
|
|
148
|
+
# transport default, while allowing explicit per-operation overrides.
|
|
149
|
+
original_call = transport.call_api
|
|
150
|
+
|
|
151
|
+
def call_api(*args: Any, **kwargs: Any) -> Any:
|
|
152
|
+
"""Give discovery requests a timeout when DynamicClient passes None."""
|
|
153
|
+
if kwargs.get("_request_timeout") is None:
|
|
154
|
+
kwargs["_request_timeout"] = request_timeout
|
|
155
|
+
return original_call(*args, **kwargs)
|
|
156
|
+
|
|
157
|
+
transport.call_api = call_api
|
|
158
|
+
try:
|
|
159
|
+
return DynamicClient(transport)
|
|
160
|
+
except Exception:
|
|
161
|
+
transport.close()
|
|
162
|
+
raise
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@contextmanager
|
|
166
|
+
def _using_api(
|
|
167
|
+
api: Optional[DynamicClient], request_timeout: RequestTimeout
|
|
168
|
+
) -> Iterator[DynamicClient]:
|
|
169
|
+
"""Reuse an injected client; open and always close a local client when omitted."""
|
|
170
|
+
owned = api is None
|
|
171
|
+
active = connect(request_timeout=request_timeout) if owned else api
|
|
172
|
+
try:
|
|
173
|
+
yield active
|
|
174
|
+
finally:
|
|
175
|
+
if owned:
|
|
176
|
+
active.client.close()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def process(
|
|
180
|
+
template_name: str,
|
|
181
|
+
parameters: Optional[Parameters] = None,
|
|
182
|
+
*,
|
|
183
|
+
namespace: str,
|
|
184
|
+
template_namespace: Optional[str] = None,
|
|
185
|
+
api: Optional[DynamicClient] = None,
|
|
186
|
+
for_delete: bool = False,
|
|
187
|
+
request_timeout: RequestTimeout = REQUEST_TIMEOUT,
|
|
188
|
+
) -> List[Manifest]:
|
|
189
|
+
"""Return manifests rendered by OpenShift, without applying them.
|
|
190
|
+
|
|
191
|
+
template_name is fetched from template_namespace (defaults to namespace).
|
|
192
|
+
parameters override matching defaults; unresolved generators are rejected.
|
|
193
|
+
namespace selects the processing endpoint. api is caller-owned; if omitted,
|
|
194
|
+
a local client is opened/closed. request_timeout is seconds or (connect, read).
|
|
195
|
+
for_delete is retained for compatibility; deterministic checks always apply.
|
|
196
|
+
Raises ValueError for invalid inputs/parameters; API and transport errors
|
|
197
|
+
propagate. Returned dictionaries can contain Secret data.
|
|
198
|
+
|
|
199
|
+
Example: process("worker", {"VM_NAME": "job-001"}, namespace="workers")"""
|
|
200
|
+
_validate_inputs(template_name, parameters, namespace, template_namespace, request_timeout)
|
|
201
|
+
with _using_api(api, request_timeout) as active:
|
|
202
|
+
templates = active.resources.get(api_version=API_VERSION, kind="Template", name="templates")
|
|
203
|
+
# Read from the source namespace, but process into the target namespace.
|
|
204
|
+
# All overrides affect this copy; the stored Template stays unchanged.
|
|
205
|
+
template = deepcopy(
|
|
206
|
+
templates.get(
|
|
207
|
+
name=template_name,
|
|
208
|
+
namespace=template_namespace if template_namespace is not None else namespace,
|
|
209
|
+
_request_timeout=request_timeout,
|
|
210
|
+
).to_dict()
|
|
211
|
+
)
|
|
212
|
+
# Source UID, namespace, and resourceVersion do not belong on a processing POST.
|
|
213
|
+
template["metadata"] = {"name": template_name}
|
|
214
|
+
template.pop("status", None)
|
|
215
|
+
definitions = {p["name"]: p for p in template.get("parameters", [])}
|
|
216
|
+
unknown = set(parameters or {}) - set(definitions)
|
|
217
|
+
if unknown:
|
|
218
|
+
raise ValueError(f"Unknown template parameters: {', '.join(sorted(unknown))}")
|
|
219
|
+
for name, value in (parameters or {}).items():
|
|
220
|
+
parameter = definitions[name]
|
|
221
|
+
parameter["value"] = str(value).lower() if isinstance(value, bool) else str(value)
|
|
222
|
+
# Even an explicit empty string is deterministic and disables generation.
|
|
223
|
+
parameter.pop("generate", None)
|
|
224
|
+
parameter.pop("from", None)
|
|
225
|
+
# Apply the same rule to every operation so replay after restart cannot
|
|
226
|
+
# silently generate different names or values. OpenShift does substitution.
|
|
227
|
+
generated = [
|
|
228
|
+
p["name"] for p in definitions.values() if p.get("generate") and not p.get("value")
|
|
229
|
+
]
|
|
230
|
+
if generated:
|
|
231
|
+
raise ValueError(
|
|
232
|
+
"Unresolved parameter generators: "
|
|
233
|
+
+ ", ".join(generated)
|
|
234
|
+
+ ". Supply explicit values for deterministic replay."
|
|
235
|
+
)
|
|
236
|
+
processor = active.resources.get(
|
|
237
|
+
api_version=API_VERSION, kind="Template", name="processedtemplates"
|
|
238
|
+
)
|
|
239
|
+
objects = (
|
|
240
|
+
processor.create(body=template, namespace=namespace, _request_timeout=request_timeout)
|
|
241
|
+
.to_dict()
|
|
242
|
+
.get("objects", [])
|
|
243
|
+
)
|
|
244
|
+
if not isinstance(objects, list):
|
|
245
|
+
raise ValueError("Processed Template objects must be a list")
|
|
246
|
+
return objects
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _targets(
|
|
250
|
+
api: DynamicClient, objects: List[Manifest], namespace: str
|
|
251
|
+
) -> List[Tuple[Resource, Manifest]]:
|
|
252
|
+
"""Return discovered resources paired with copied, namespace-normalized manifests.
|
|
253
|
+
|
|
254
|
+
Validate every identity and reject generated names, cross-namespace targets,
|
|
255
|
+
and cluster-scoped kinds before any target resource is read or mutated.
|
|
256
|
+
API discovery may contact the server during validation."""
|
|
257
|
+
if not isinstance(objects, list):
|
|
258
|
+
raise ValueError("resources must be a list of manifest dictionaries")
|
|
259
|
+
targets = []
|
|
260
|
+
for original in objects:
|
|
261
|
+
obj = deepcopy(original)
|
|
262
|
+
if not isinstance(obj, dict) or not isinstance(obj.get("metadata"), dict):
|
|
263
|
+
raise ValueError("Every resource must be a manifest with a metadata dictionary")
|
|
264
|
+
metadata = obj["metadata"]
|
|
265
|
+
if "generateName" in metadata:
|
|
266
|
+
raise ValueError("metadata.generateName is unsupported; require stable metadata.name")
|
|
267
|
+
for field in ("apiVersion", "kind"):
|
|
268
|
+
if not isinstance(obj.get(field), str) or not obj[field]:
|
|
269
|
+
raise ValueError(f"Every resource must have a nonempty {field}")
|
|
270
|
+
if not isinstance(metadata.get("name"), str) or not metadata["name"].strip():
|
|
271
|
+
raise ValueError("Every resource must have a nonempty metadata.name")
|
|
272
|
+
if metadata["name"] in (".", "..") or any(c in metadata["name"] for c in "/%"):
|
|
273
|
+
raise ValueError("metadata.name must be a safe API path segment")
|
|
274
|
+
if metadata.get("namespace") not in (None, "", namespace):
|
|
275
|
+
raise ValueError(f"Resource {metadata['name']} targets a different namespace")
|
|
276
|
+
resource = api.resources.get(api_version=obj["apiVersion"], kind=obj["kind"])
|
|
277
|
+
if not resource.namespaced:
|
|
278
|
+
raise ValueError(f"Cluster-scoped resource is unsupported: {obj['kind']}")
|
|
279
|
+
# An omitted/empty namespace inherits the target; an explicit other one fails.
|
|
280
|
+
metadata["namespace"] = namespace
|
|
281
|
+
targets.append((resource, obj))
|
|
282
|
+
return targets
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _reference(obj: Manifest) -> Manifest:
|
|
286
|
+
"""Return only API version, kind, name, namespace, and any UID; exclude payload data."""
|
|
287
|
+
metadata = obj["metadata"]
|
|
288
|
+
return {
|
|
289
|
+
"apiVersion": obj["apiVersion"],
|
|
290
|
+
"kind": obj["kind"],
|
|
291
|
+
"metadata": {key: metadata[key] for key in ("name", "namespace", "uid") if key in metadata},
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _prepare_apply(obj: Manifest) -> None:
|
|
296
|
+
"""Remove server-managed fields and normalize core Secret stringData in a copy.
|
|
297
|
+
|
|
298
|
+
stringData values become UTF-8/base64 data and override matching data keys.
|
|
299
|
+
Invalid Secret maps raise ValueError before any resource is applied."""
|
|
300
|
+
obj.pop("status", None)
|
|
301
|
+
# A Template may contain exported objects. Their old server-assigned fields
|
|
302
|
+
# must not be submitted as the identity/version of the object being applied.
|
|
303
|
+
fields = (
|
|
304
|
+
"uid resourceVersion managedFields creationTimestamp generation "
|
|
305
|
+
"deletionTimestamp deletionGracePeriodSeconds"
|
|
306
|
+
)
|
|
307
|
+
for key in fields.split():
|
|
308
|
+
obj["metadata"].pop(key, None)
|
|
309
|
+
# stringData does not work well with SSA; normalize it to the persisted data
|
|
310
|
+
# representation, with stringData winning collisions as in the Secret API.
|
|
311
|
+
if obj["apiVersion"] == "v1" and obj["kind"] == "Secret" and "stringData" in obj:
|
|
312
|
+
strings, data = obj.pop("stringData"), obj.setdefault("data", {})
|
|
313
|
+
if not isinstance(strings, dict) or not isinstance(data, dict):
|
|
314
|
+
raise ValueError("Secret stringData and data must be dictionaries")
|
|
315
|
+
for key, value in strings.items():
|
|
316
|
+
if not isinstance(key, str) or not isinstance(value, str):
|
|
317
|
+
raise ValueError("Secret stringData must map string keys to string values")
|
|
318
|
+
data[key] = b64encode(value.encode("utf-8")).decode("ascii")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def create(
|
|
322
|
+
template_name: str,
|
|
323
|
+
parameters: Optional[Parameters] = None,
|
|
324
|
+
*,
|
|
325
|
+
namespace: str,
|
|
326
|
+
template_namespace: Optional[str] = None,
|
|
327
|
+
api: Optional[DynamicClient] = None,
|
|
328
|
+
field_manager: str = "mini-scheduler",
|
|
329
|
+
request_timeout: RequestTimeout = REQUEST_TIMEOUT,
|
|
330
|
+
) -> List[Manifest]:
|
|
331
|
+
"""Process and server-side apply a Template; return identity references in order.
|
|
332
|
+
|
|
333
|
+
References include the UID returned by the server. Retaining these references
|
|
334
|
+
is optional; template-based delete needs only the original job inputs.
|
|
335
|
+
|
|
336
|
+
template_name/parameters identify the Template and scalar overrides; namespace
|
|
337
|
+
bounds all targets; template_namespace defaults to namespace. api is caller-owned
|
|
338
|
+
(or a local client is opened/closed). request_timeout is seconds or (connect, read).
|
|
339
|
+
field_manager names the SSA owner; conflicts are never forced. All identities
|
|
340
|
+
are checked before applying. Invalid targets raise ValueError; processing errors
|
|
341
|
+
propagate. First apply failure raises OperationError with earlier successes.
|
|
342
|
+
There is no rollback or readiness wait.
|
|
343
|
+
|
|
344
|
+
Example: create("worker", {"VM_NAME": "job-001"}, namespace="workers")"""
|
|
345
|
+
_validate_inputs(template_name, parameters, namespace, template_namespace, request_timeout)
|
|
346
|
+
if not isinstance(field_manager, str) or not field_manager.strip() or len(field_manager) > 128:
|
|
347
|
+
raise ValueError("field_manager must be a nonempty string of at most 128 characters")
|
|
348
|
+
with _using_api(api, request_timeout) as active:
|
|
349
|
+
objects = process(
|
|
350
|
+
template_name,
|
|
351
|
+
parameters,
|
|
352
|
+
namespace=namespace,
|
|
353
|
+
template_namespace=template_namespace,
|
|
354
|
+
api=active,
|
|
355
|
+
request_timeout=request_timeout,
|
|
356
|
+
)
|
|
357
|
+
# Check every target and normalize every Secret before the first mutation.
|
|
358
|
+
targets = _targets(active, objects, namespace)
|
|
359
|
+
for _, obj in targets:
|
|
360
|
+
_prepare_apply(obj)
|
|
361
|
+
applied = []
|
|
362
|
+
for resource, obj in targets:
|
|
363
|
+
try:
|
|
364
|
+
result = active.server_side_apply(
|
|
365
|
+
resource,
|
|
366
|
+
name=obj["metadata"]["name"],
|
|
367
|
+
body=obj,
|
|
368
|
+
namespace=namespace,
|
|
369
|
+
field_manager=field_manager,
|
|
370
|
+
force_conflicts=False,
|
|
371
|
+
_request_timeout=request_timeout,
|
|
372
|
+
).to_dict()
|
|
373
|
+
# The apply response supplies the live UID, including when updating
|
|
374
|
+
# an existing object. No separate GET or local UID storage is needed.
|
|
375
|
+
applied.append(_reference(result))
|
|
376
|
+
except Exception as error:
|
|
377
|
+
raise OperationError("Apply", applied, [(_reference(obj), error)]) from error
|
|
378
|
+
return applied
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def delete(
|
|
382
|
+
template_name: Optional[str] = None,
|
|
383
|
+
parameters: Optional[Parameters] = None,
|
|
384
|
+
*,
|
|
385
|
+
namespace: str,
|
|
386
|
+
template_namespace: Optional[str] = None,
|
|
387
|
+
resources: Optional[List[Manifest]] = None,
|
|
388
|
+
api: Optional[DynamicClient] = None,
|
|
389
|
+
request_timeout: RequestTimeout = REQUEST_TIMEOUT,
|
|
390
|
+
) -> List[Manifest]:
|
|
391
|
+
"""Reprocess a Template and delete its identities in reverse order, without inventory.
|
|
392
|
+
|
|
393
|
+
Use the same template_name/parameters and compatible revision as create.
|
|
394
|
+
namespace bounds deletion; template_namespace defaults to namespace. Alternatively,
|
|
395
|
+
resources accepts references instead: omit template_name (its default is None)
|
|
396
|
+
and all other template inputs. One input mode is required; do not mix them.
|
|
397
|
+
Supplied UIDs become deletion preconditions; references without UIDs are valid.
|
|
398
|
+
Template replay deletes by name/namespace, without fetching live resource UIDs.
|
|
399
|
+
api is caller-owned (or a local client is opened/closed). request_timeout is
|
|
400
|
+
seconds or (connect, read) per request.
|
|
401
|
+
Returns successful references, including 404s. Invalid targets raise ValueError;
|
|
402
|
+
processing errors propagate. Other delete failures are collected in OperationError
|
|
403
|
+
after attempting all targets. Name-based replay has no original-UID protection.
|
|
404
|
+
|
|
405
|
+
Examples:
|
|
406
|
+
delete("worker", {"VM_NAME": "job-001"}, namespace="workers")
|
|
407
|
+
delete(namespace="workers", resources=refs) # Optional references from create.
|
|
408
|
+
"""
|
|
409
|
+
_validate_inputs(
|
|
410
|
+
template_name, parameters, namespace, template_namespace, request_timeout, resources
|
|
411
|
+
)
|
|
412
|
+
with _using_api(api, request_timeout) as active:
|
|
413
|
+
if resources is None:
|
|
414
|
+
# Reconstruct identities from the stored Template and job inputs. This
|
|
415
|
+
# works in a fresh process without a create result, inventory, or UID.
|
|
416
|
+
resources = process(
|
|
417
|
+
template_name,
|
|
418
|
+
parameters,
|
|
419
|
+
namespace=namespace,
|
|
420
|
+
template_namespace=template_namespace,
|
|
421
|
+
api=active,
|
|
422
|
+
request_timeout=request_timeout,
|
|
423
|
+
)
|
|
424
|
+
# Validate the entire set before issuing the first DELETE.
|
|
425
|
+
targets = _targets(active, resources, namespace)
|
|
426
|
+
deleted, failures = [], []
|
|
427
|
+
for resource, obj in reversed(targets):
|
|
428
|
+
ref = _reference(obj)
|
|
429
|
+
options = {
|
|
430
|
+
"apiVersion": "v1",
|
|
431
|
+
"kind": "DeleteOptions",
|
|
432
|
+
"propagationPolicy": "Background",
|
|
433
|
+
}
|
|
434
|
+
# Validation permits template_name=None only in resources mode.
|
|
435
|
+
# A caller-retained UID optionally protects against deleting a replacement
|
|
436
|
+
# with the same name (the server rejects a mismatch with 409). Template
|
|
437
|
+
# replay must not trust a UID embedded in a rendered/exported manifest.
|
|
438
|
+
# No live UID is fetched: it could belong to a replacement object.
|
|
439
|
+
if template_name is None and ref["metadata"].get("uid"):
|
|
440
|
+
options["preconditions"] = {"uid": ref["metadata"]["uid"]}
|
|
441
|
+
try:
|
|
442
|
+
resource.delete(
|
|
443
|
+
name=ref["metadata"]["name"],
|
|
444
|
+
namespace=namespace,
|
|
445
|
+
body=options,
|
|
446
|
+
_request_timeout=request_timeout,
|
|
447
|
+
)
|
|
448
|
+
# API acceptance does not mean finalizers/background deletion finished.
|
|
449
|
+
deleted.append(ref)
|
|
450
|
+
except NotFoundError:
|
|
451
|
+
# An absent target already satisfies deletion, including on replay.
|
|
452
|
+
deleted.append(ref)
|
|
453
|
+
except Exception as error:
|
|
454
|
+
# Continue cleanup, then expose every failure and earlier success.
|
|
455
|
+
failures.append((ref, error))
|
|
456
|
+
if failures:
|
|
457
|
+
raise OperationError("Delete", deleted, failures) from failures[0][1]
|
|
458
|
+
return deleted
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def get(
|
|
462
|
+
template_name: Optional[str] = None,
|
|
463
|
+
parameters: Optional[Parameters] = None,
|
|
464
|
+
*,
|
|
465
|
+
namespace: str,
|
|
466
|
+
template_namespace: Optional[str] = None,
|
|
467
|
+
resources: Optional[List[Manifest]] = None,
|
|
468
|
+
api: Optional[DynamicClient] = None,
|
|
469
|
+
request_timeout: RequestTimeout = REQUEST_TIMEOUT,
|
|
470
|
+
) -> List[Manifest]:
|
|
471
|
+
"""Read deployed resources and return their full live dictionaries.
|
|
472
|
+
|
|
473
|
+
Supply template_name/parameters, or resources containing identity references.
|
|
474
|
+
Omit template_name (default None) and other template inputs when using resources.
|
|
475
|
+
Reads use name/namespace and return the current object; supplied UIDs are not
|
|
476
|
+
checked, so get() cannot establish an original object's UID after replacement.
|
|
477
|
+
namespace bounds all reads; template_namespace locates the source Template.
|
|
478
|
+
api is caller-owned; without it a local client is opened and closed.
|
|
479
|
+
request_timeout is seconds or (connect, read) seconds per request.
|
|
480
|
+
Results follow rendered/reference order and include each kind's own status,
|
|
481
|
+
if present. There is no readiness interpretation or polling. Results can
|
|
482
|
+
contain Secret data. ValueError reports invalid inputs/targets; API errors
|
|
483
|
+
(including 404/403) and timeouts propagate immediately, without partial results.
|
|
484
|
+
|
|
485
|
+
Example: get("worker", {"VM_NAME": "job-001"}, namespace="workers")
|
|
486
|
+
"""
|
|
487
|
+
_validate_inputs(
|
|
488
|
+
template_name, parameters, namespace, template_namespace, request_timeout, resources
|
|
489
|
+
)
|
|
490
|
+
with _using_api(api, request_timeout) as active:
|
|
491
|
+
if resources is None:
|
|
492
|
+
resources = process(
|
|
493
|
+
template_name,
|
|
494
|
+
parameters,
|
|
495
|
+
namespace=namespace,
|
|
496
|
+
template_namespace=template_namespace,
|
|
497
|
+
api=active,
|
|
498
|
+
request_timeout=request_timeout,
|
|
499
|
+
)
|
|
500
|
+
targets = _targets(active, resources, namespace)
|
|
501
|
+
return [
|
|
502
|
+
resource.get(
|
|
503
|
+
name=obj["metadata"]["name"], namespace=namespace, _request_timeout=request_timeout
|
|
504
|
+
).to_dict()
|
|
505
|
+
for resource, obj in targets
|
|
506
|
+
]
|
|
File without changes
|