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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ openshift_templates
@@ -0,0 +1,5 @@
1
+ """Small OpenShift Template client; see README.md for usage and limitations."""
2
+
3
+ from ._client import OperationError, connect, create, delete, get, process
4
+
5
+ __all__ = ["OperationError", "connect", "create", "delete", "get", "process"]
@@ -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