jumpstarter-kubernetes 0.6.0__tar.gz

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.

Potentially problematic release.


This version of jumpstarter-kubernetes might be problematic. Click here for more details.

@@ -0,0 +1,166 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py,cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/build/
73
+ docs/build_all/
74
+
75
+ # PyBuilder
76
+ .pybuilder/
77
+ target/
78
+
79
+ # Jupyter Notebook
80
+ .ipynb_checkpoints
81
+
82
+ # IPython
83
+ profile_default/
84
+ ipython_config.py
85
+
86
+ # pyenv
87
+ # For a library or package, you might want to ignore these files since the code is
88
+ # intended to run in multiple environments; otherwise, check them in:
89
+ # .python-version
90
+
91
+ # pipenv
92
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
93
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
94
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
95
+ # install all needed dependencies.
96
+ #Pipfile.lock
97
+
98
+ # poetry
99
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
100
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
101
+ # commonly ignored for libraries.
102
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
103
+ #poetry.lock
104
+
105
+ # pdm
106
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
107
+ #pdm.lock
108
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
109
+ # in version control.
110
+ # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
111
+ .pdm.toml
112
+ .pdm-python
113
+ .pdm-build/
114
+
115
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
116
+ __pypackages__/
117
+
118
+ # Celery stuff
119
+ celerybeat-schedule
120
+ celerybeat.pid
121
+
122
+ # SageMath parsed files
123
+ *.sage.py
124
+
125
+ # Environments
126
+ .env
127
+ .venv
128
+ env/
129
+ venv/
130
+ ENV/
131
+ env.bak/
132
+ venv.bak/
133
+
134
+ # Spyder project settings
135
+ .spyderproject
136
+ .spyproject
137
+
138
+ # Rope project settings
139
+ .ropeproject
140
+
141
+ # mkdocs documentation
142
+ /site
143
+
144
+ # mypy
145
+ .mypy_cache/
146
+ .dmypy.json
147
+ dmypy.json
148
+
149
+ # Pyre type checker
150
+ .pyre/
151
+
152
+ # pytype static type analyzer
153
+ .pytype/
154
+
155
+ # Cython debug symbols
156
+ cython_debug/
157
+
158
+ # PyCharm
159
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
160
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
161
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
162
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
163
+ #.idea/
164
+
165
+ # Ruff cache
166
+ .ruff_cache/
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: jumpstarter-kubernetes
3
+ Version: 0.6.0
4
+ Project-URL: Homepage, https://jumpstarter.dev
5
+ Project-URL: source_archive, https://github.com/jumpstarter-dev/repo/archive/c2927a2abac82d224c7bd28f9ed83c57b5222e65.zip
6
+ Author-email: Kirk Brauer <kbrauer@hatci.com>
7
+ License-Expression: Apache-2.0
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: jumpstarter
10
+ Requires-Dist: kubernetes-asyncio>=31.1.0
11
+ Requires-Dist: kubernetes>=31.0.0
12
+ Requires-Dist: pydantic>=2.8.2
13
+ Description-Content-Type: text/markdown
14
+
15
+ # Jumpstarter Kubernetes Library
@@ -0,0 +1 @@
1
+ # Jumpstarter Kubernetes Library
@@ -0,0 +1,39 @@
1
+ from .clients import ClientsV1Alpha1Api, V1Alpha1Client, V1Alpha1ClientList, V1Alpha1ClientStatus
2
+ from .exporters import (
3
+ ExportersV1Alpha1Api,
4
+ V1Alpha1Exporter,
5
+ V1Alpha1ExporterDevice,
6
+ V1Alpha1ExporterList,
7
+ V1Alpha1ExporterStatus,
8
+ )
9
+ from .install import helm_installed, install_helm_chart
10
+ from .leases import (
11
+ LeasesV1Alpha1Api,
12
+ V1Alpha1Lease,
13
+ V1Alpha1LeaseList,
14
+ V1Alpha1LeaseSelector,
15
+ V1Alpha1LeaseSpec,
16
+ V1Alpha1LeaseStatus,
17
+ )
18
+ from .list import V1Alpha1List
19
+
20
+ __all__ = [
21
+ "ClientsV1Alpha1Api",
22
+ "V1Alpha1Client",
23
+ "V1Alpha1ClientList",
24
+ "V1Alpha1ClientStatus",
25
+ "ExportersV1Alpha1Api",
26
+ "V1Alpha1Exporter",
27
+ "V1Alpha1ExporterList",
28
+ "V1Alpha1ExporterStatus",
29
+ "V1Alpha1ExporterDevice",
30
+ "LeasesV1Alpha1Api",
31
+ "V1Alpha1Lease",
32
+ "V1Alpha1LeaseStatus",
33
+ "V1Alpha1LeaseList",
34
+ "V1Alpha1LeaseSelector",
35
+ "V1Alpha1LeaseSpec",
36
+ "V1Alpha1List",
37
+ "helm_installed",
38
+ "install_helm_chart",
39
+ ]
@@ -0,0 +1,138 @@
1
+ import asyncio
2
+ import base64
3
+ import logging
4
+ from typing import Literal, Optional
5
+
6
+ from kubernetes_asyncio.client.models import V1ObjectMeta, V1ObjectReference
7
+ from pydantic import Field
8
+
9
+ from .json import JsonBaseModel
10
+ from .list import V1Alpha1List
11
+ from .serialize import SerializeV1ObjectMeta, SerializeV1ObjectReference
12
+ from .util import AbstractAsyncCustomObjectApi
13
+ from jumpstarter.config.client import ClientConfigV1Alpha1, ClientConfigV1Alpha1Drivers
14
+ from jumpstarter.config.common import ObjectMeta
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ CREATE_CLIENT_DELAY = 1
19
+ CREATE_CLIENT_COUNT = 10
20
+
21
+
22
+ class V1Alpha1ClientStatus(JsonBaseModel):
23
+ credential: Optional[SerializeV1ObjectReference] = None
24
+ endpoint: str
25
+
26
+
27
+ class V1Alpha1Client(JsonBaseModel):
28
+ api_version: Literal["jumpstarter.dev/v1alpha1"] = Field(alias="apiVersion", default="jumpstarter.dev/v1alpha1")
29
+ kind: Literal["Client"] = Field(default="Client")
30
+ metadata: SerializeV1ObjectMeta
31
+ status: Optional[V1Alpha1ClientStatus]
32
+
33
+ @staticmethod
34
+ def from_dict(dict: dict):
35
+ return V1Alpha1Client(
36
+ api_version=dict["apiVersion"],
37
+ kind=dict["kind"],
38
+ metadata=V1ObjectMeta(
39
+ creation_timestamp=dict["metadata"]["creationTimestamp"],
40
+ generation=dict["metadata"]["generation"],
41
+ name=dict["metadata"]["name"],
42
+ namespace=dict["metadata"]["namespace"],
43
+ resource_version=dict["metadata"]["resourceVersion"],
44
+ uid=dict["metadata"]["uid"],
45
+ ),
46
+ status=V1Alpha1ClientStatus(
47
+ credential=V1ObjectReference(name=dict["status"]["credential"]["name"])
48
+ if "credential" in dict["status"]
49
+ else None,
50
+ endpoint=dict["status"].get("endpoint", ""),
51
+ )
52
+ if "status" in dict
53
+ else None,
54
+ )
55
+
56
+
57
+ class V1Alpha1ClientList(V1Alpha1List[V1Alpha1Client]):
58
+ kind: Literal["ClientList"] = Field(default="ClientList")
59
+
60
+ @staticmethod
61
+ def from_dict(dict: dict):
62
+ return V1Alpha1ClientList(items=[V1Alpha1Client.from_dict(c) for c in dict.get("items", [])])
63
+
64
+
65
+ class ClientsV1Alpha1Api(AbstractAsyncCustomObjectApi):
66
+ """Interact with the clients custom resource API"""
67
+
68
+ async def create_client(
69
+ self, name: str, labels: dict[str, str] | None = None, oidc_username: str | None = None
70
+ ) -> V1Alpha1Client:
71
+ """Create a client object in the cluster async"""
72
+ # Create the namespaced client object
73
+ await self.api.create_namespaced_custom_object(
74
+ namespace=self.namespace,
75
+ group="jumpstarter.dev",
76
+ plural="clients",
77
+ version="v1alpha1",
78
+ body={
79
+ "apiVersion": "jumpstarter.dev/v1alpha1",
80
+ "kind": "Client",
81
+ "metadata": {"name": name} | {"labels": labels} if labels is not None else {},
82
+ "spec": {"username": oidc_username} if oidc_username is not None else {},
83
+ },
84
+ )
85
+ # Wait for the credentials to become available
86
+ # NOTE: Watch is not working here with the Python kubernetes library
87
+ count = 0
88
+ updated_client = {}
89
+ # Retry for a maximum of 10s
90
+ while count < CREATE_CLIENT_COUNT:
91
+ # Try to get the updated client resource
92
+ updated_client = await self.api.get_namespaced_custom_object(
93
+ namespace=self.namespace, group="jumpstarter.dev", plural="clients", version="v1alpha1", name=name
94
+ )
95
+ # check if the client status is updated with the credentials
96
+ if "status" in updated_client:
97
+ if "credential" in updated_client["status"]:
98
+ return V1Alpha1Client.from_dict(updated_client)
99
+ count += 1
100
+ await asyncio.sleep(CREATE_CLIENT_DELAY)
101
+ raise Exception("Timeout waiting for client credentials")
102
+
103
+ async def list_clients(self) -> V1Alpha1List[V1Alpha1Client]:
104
+ """List the client objects in the cluster async"""
105
+ res = await self.api.list_namespaced_custom_object(
106
+ namespace=self.namespace, group="jumpstarter.dev", plural="clients", version="v1alpha1"
107
+ )
108
+ return V1Alpha1ClientList.from_dict(res)
109
+
110
+ async def get_client(self, name: str) -> V1Alpha1Client:
111
+ """Get a single client object from the cluster async"""
112
+ result = await self.api.get_namespaced_custom_object(
113
+ namespace=self.namespace, group="jumpstarter.dev", plural="clients", version="v1alpha1", name=name
114
+ )
115
+ return V1Alpha1Client.from_dict(result)
116
+
117
+ async def get_client_config(self, name: str, allow: list[str], unsafe=False) -> ClientConfigV1Alpha1:
118
+ """Get a client config for a specified client name"""
119
+ client = await self.get_client(name)
120
+ secret = await self.core_api.read_namespaced_secret(client.status.credential.name, self.namespace)
121
+ endpoint = client.status.endpoint
122
+ token = base64.b64decode(secret.data["token"]).decode("utf8")
123
+ return ClientConfigV1Alpha1(
124
+ alias=name,
125
+ metadata=ObjectMeta(
126
+ namespace=client.metadata.namespace,
127
+ name=client.metadata.name,
128
+ ),
129
+ endpoint=endpoint,
130
+ token=token,
131
+ drivers=ClientConfigV1Alpha1Drivers(allow=allow, unsafe=unsafe),
132
+ )
133
+
134
+ async def delete_client(self, name: str):
135
+ """Delete a client object"""
136
+ await self.api.delete_namespaced_custom_object(
137
+ namespace=self.namespace, group="jumpstarter.dev", plural="clients", version="v1alpha1", name=name
138
+ )
@@ -0,0 +1,58 @@
1
+ from kubernetes_asyncio.client.models import V1ObjectMeta
2
+
3
+ from jumpstarter_kubernetes import V1Alpha1Client, V1Alpha1ClientStatus
4
+
5
+ TEST_CLIENT = V1Alpha1Client(
6
+ api_version="jumpstarter.dev/v1alpha1",
7
+ kind="Client",
8
+ metadata=V1ObjectMeta(
9
+ creation_timestamp="2021-10-01T00:00:00Z",
10
+ generation=1,
11
+ name="test-client",
12
+ namespace="default",
13
+ resource_version="1",
14
+ uid="7a25eb81-6443-47ec-a62f-50165bffede8",
15
+ ),
16
+ status=V1Alpha1ClientStatus(credential=None, endpoint="https://test-client"),
17
+ )
18
+
19
+
20
+ def test_client_dump_json():
21
+ assert (
22
+ TEST_CLIENT.dump_json()
23
+ == """{
24
+ "apiVersion": "jumpstarter.dev/v1alpha1",
25
+ "kind": "Client",
26
+ "metadata": {
27
+ "creationTimestamp": "2021-10-01T00:00:00Z",
28
+ "generation": 1,
29
+ "name": "test-client",
30
+ "namespace": "default",
31
+ "resourceVersion": "1",
32
+ "uid": "7a25eb81-6443-47ec-a62f-50165bffede8"
33
+ },
34
+ "status": {
35
+ "credential": null,
36
+ "endpoint": "https://test-client"
37
+ }
38
+ }"""
39
+ )
40
+
41
+
42
+ def test_client_dump_yaml():
43
+ assert (
44
+ TEST_CLIENT.dump_yaml()
45
+ == """apiVersion: jumpstarter.dev/v1alpha1
46
+ kind: Client
47
+ metadata:
48
+ creationTimestamp: '2021-10-01T00:00:00Z'
49
+ generation: 1
50
+ name: test-client
51
+ namespace: default
52
+ resourceVersion: '1'
53
+ uid: 7a25eb81-6443-47ec-a62f-50165bffede8
54
+ status:
55
+ credential: null
56
+ endpoint: https://test-client
57
+ """
58
+ )
@@ -0,0 +1,146 @@
1
+ import asyncio
2
+ import base64
3
+ from typing import Literal
4
+
5
+ from kubernetes_asyncio.client.models import V1ObjectMeta, V1ObjectReference
6
+ from pydantic import Field
7
+
8
+ from .json import JsonBaseModel
9
+ from .list import V1Alpha1List
10
+ from .serialize import SerializeV1ObjectMeta, SerializeV1ObjectReference
11
+ from .util import AbstractAsyncCustomObjectApi
12
+ from jumpstarter.config.common import ObjectMeta
13
+ from jumpstarter.config.exporter import ExporterConfigV1Alpha1
14
+
15
+ CREATE_EXPORTER_DELAY = 1
16
+ CREATE_EXPORTER_COUNT = 10
17
+
18
+
19
+ class V1Alpha1ExporterDevice(JsonBaseModel):
20
+ labels: dict[str, str]
21
+ uuid: str
22
+
23
+
24
+ class V1Alpha1ExporterStatus(JsonBaseModel):
25
+ credential: SerializeV1ObjectReference
26
+ devices: list[V1Alpha1ExporterDevice]
27
+ endpoint: str
28
+
29
+
30
+ class V1Alpha1Exporter(JsonBaseModel):
31
+ api_version: Literal["jumpstarter.dev/v1alpha1"] = Field(alias="apiVersion", default="jumpstarter.dev/v1alpha1")
32
+ kind: Literal["Exporter"] = Field(default="Exporter")
33
+ metadata: SerializeV1ObjectMeta
34
+ status: V1Alpha1ExporterStatus
35
+
36
+ @staticmethod
37
+ def from_dict(dict: dict):
38
+ return V1Alpha1Exporter(
39
+ api_version=dict["apiVersion"],
40
+ kind=dict["kind"],
41
+ metadata=V1ObjectMeta(
42
+ creation_timestamp=dict["metadata"]["creationTimestamp"],
43
+ generation=dict["metadata"]["generation"],
44
+ name=dict["metadata"]["name"],
45
+ namespace=dict["metadata"]["namespace"],
46
+ resource_version=dict["metadata"]["resourceVersion"],
47
+ uid=dict["metadata"]["uid"],
48
+ ),
49
+ status=V1Alpha1ExporterStatus(
50
+ credential=V1ObjectReference(name=dict["status"]["credential"]["name"])
51
+ if "credential" in dict["status"]
52
+ else None,
53
+ endpoint=dict["status"]["endpoint"],
54
+ devices=[V1Alpha1ExporterDevice(labels=d["labels"], uuid=d["uuid"]) for d in dict["status"]["devices"]]
55
+ if "devices" in dict["status"]
56
+ else [],
57
+ ),
58
+ )
59
+
60
+
61
+ class V1Alpha1ExporterList(V1Alpha1List[V1Alpha1Exporter]):
62
+ kind: Literal["ExporterList"] = Field(default="ExporterList")
63
+
64
+ @staticmethod
65
+ def from_dict(dict: dict):
66
+ return V1Alpha1ExporterList(items=[V1Alpha1Exporter.from_dict(c) for c in dict["items"]])
67
+
68
+
69
+ class ExportersV1Alpha1Api(AbstractAsyncCustomObjectApi):
70
+ """Interact with the exporters custom resource API"""
71
+
72
+ async def list_exporters(self) -> V1Alpha1List[V1Alpha1Exporter]:
73
+ """List the exporter objects in the cluster"""
74
+ res = await self.api.list_namespaced_custom_object(
75
+ namespace=self.namespace, group="jumpstarter.dev", plural="exporters", version="v1alpha1"
76
+ )
77
+ return V1Alpha1ExporterList.from_dict(res)
78
+
79
+ async def get_exporter(self, name: str) -> V1Alpha1Exporter:
80
+ """Get a single exporter object from the cluster"""
81
+ result = await self.api.get_namespaced_custom_object(
82
+ namespace=self.namespace, group="jumpstarter.dev", plural="exporters", version="v1alpha1", name=name
83
+ )
84
+ return V1Alpha1Exporter.from_dict(result)
85
+
86
+ async def create_exporter(
87
+ self, name: str, labels: dict[str, str] | None = None, oidc_username: str | None = None
88
+ ) -> V1Alpha1Exporter:
89
+ """Create an exporter in the cluster"""
90
+ # Create the namespaced exporter object
91
+ await self.api.create_namespaced_custom_object(
92
+ namespace=self.namespace,
93
+ group="jumpstarter.dev",
94
+ plural="exporters",
95
+ version="v1alpha1",
96
+ body={
97
+ "apiVersion": "jumpstarter.dev/v1alpha1",
98
+ "kind": "Exporter",
99
+ "metadata": {"name": name} | {"labels": labels} if labels is not None else {},
100
+ "spec": {"username": oidc_username} if oidc_username is not None else {},
101
+ },
102
+ )
103
+ # Wait for the credentials to become available
104
+ # NOTE: Watch is not working here with the Python kubernetes library
105
+ count = 0
106
+ updated_exporter = {}
107
+ # Retry for a maximum of 10s
108
+ while count < CREATE_EXPORTER_COUNT:
109
+ # Try to get the updated client resource
110
+ updated_exporter = await self.api.get_namespaced_custom_object(
111
+ namespace=self.namespace, group="jumpstarter.dev", plural="exporters", version="v1alpha1", name=name
112
+ )
113
+ # check if the client status is updated with the credentials
114
+ if "status" in updated_exporter:
115
+ if "credential" in updated_exporter["status"]:
116
+ return V1Alpha1Exporter.from_dict(updated_exporter)
117
+ count += 1
118
+ await asyncio.sleep(CREATE_EXPORTER_DELAY)
119
+ raise Exception("Timeout waiting for exporter credentials")
120
+
121
+ async def get_exporter_config(self, name: str) -> ExporterConfigV1Alpha1:
122
+ """Get an exporter config for a specified exporter name"""
123
+ exporter = await self.get_exporter(name)
124
+ secret = await self.core_api.read_namespaced_secret(exporter.status.credential.name, self.namespace)
125
+ endpoint = exporter.status.endpoint
126
+ token = base64.b64decode(secret.data["token"]).decode("utf8")
127
+ return ExporterConfigV1Alpha1(
128
+ alias=name,
129
+ metadata=ObjectMeta(
130
+ namespace=exporter.metadata.namespace,
131
+ name=exporter.metadata.name,
132
+ ),
133
+ endpoint=endpoint,
134
+ token=token,
135
+ export={},
136
+ )
137
+
138
+ async def delete_exporter(self, name: str):
139
+ """Delete an exporter object"""
140
+ await self.api.delete_namespaced_custom_object(
141
+ namespace=self.namespace,
142
+ name=name,
143
+ group="jumpstarter.dev",
144
+ plural="exporters",
145
+ version="v1alpha1",
146
+ )
@@ -0,0 +1,77 @@
1
+ from kubernetes_asyncio.client.models import V1ObjectMeta, V1ObjectReference
2
+
3
+ from jumpstarter_kubernetes.exporters import V1Alpha1Exporter, V1Alpha1ExporterDevice, V1Alpha1ExporterStatus
4
+
5
+ TEST_EXPORTER = V1Alpha1Exporter(
6
+ api_version="jumpstarter.dev/v1alpha1",
7
+ kind="Exporter",
8
+ metadata=V1ObjectMeta(
9
+ creation_timestamp="2021-10-01T00:00:00Z",
10
+ generation=1,
11
+ name="test-exporter",
12
+ namespace="default",
13
+ resource_version="1",
14
+ uid="7a25eb81-6443-47ec-a62f-50165bffede8",
15
+ ),
16
+ status=V1Alpha1ExporterStatus(
17
+ credential=V1ObjectReference(name="test-credential"),
18
+ devices=[V1Alpha1ExporterDevice(labels={"test": "label"}, uuid="f4cf49ab-fc64-46c6-94e7-a40502eb77b1")],
19
+ endpoint="https://test-exporter",
20
+ ),
21
+ )
22
+
23
+
24
+ def test_exporter_dump_json():
25
+ assert (
26
+ TEST_EXPORTER.dump_json()
27
+ == """{
28
+ "apiVersion": "jumpstarter.dev/v1alpha1",
29
+ "kind": "Exporter",
30
+ "metadata": {
31
+ "creationTimestamp": "2021-10-01T00:00:00Z",
32
+ "generation": 1,
33
+ "name": "test-exporter",
34
+ "namespace": "default",
35
+ "resourceVersion": "1",
36
+ "uid": "7a25eb81-6443-47ec-a62f-50165bffede8"
37
+ },
38
+ "status": {
39
+ "credential": {
40
+ "name": "test-credential"
41
+ },
42
+ "devices": [
43
+ {
44
+ "labels": {
45
+ "test": "label"
46
+ },
47
+ "uuid": "f4cf49ab-fc64-46c6-94e7-a40502eb77b1"
48
+ }
49
+ ],
50
+ "endpoint": "https://test-exporter"
51
+ }
52
+ }"""
53
+ )
54
+
55
+
56
+ def test_exporter_dump_yaml():
57
+ assert (
58
+ TEST_EXPORTER.dump_yaml()
59
+ == """apiVersion: jumpstarter.dev/v1alpha1
60
+ kind: Exporter
61
+ metadata:
62
+ creationTimestamp: '2021-10-01T00:00:00Z'
63
+ generation: 1
64
+ name: test-exporter
65
+ namespace: default
66
+ resourceVersion: '1'
67
+ uid: 7a25eb81-6443-47ec-a62f-50165bffede8
68
+ status:
69
+ credential:
70
+ name: test-credential
71
+ devices:
72
+ - labels:
73
+ test: label
74
+ uuid: f4cf49ab-fc64-46c6-94e7-a40502eb77b1
75
+ endpoint: https://test-exporter
76
+ """
77
+ )
@@ -0,0 +1,65 @@
1
+ import asyncio
2
+ import shutil
3
+ from typing import Literal, Optional
4
+
5
+
6
+ def helm_installed(name: str) -> bool:
7
+ return shutil.which(name) is not None
8
+
9
+
10
+ async def install_helm_chart(
11
+ chart: str,
12
+ name: str,
13
+ namespace: str,
14
+ basedomain: str,
15
+ grpc_endpoint: str,
16
+ router_endpoint: str,
17
+ mode: Literal["nodeport"] | Literal["ingress"] | Literal["route"],
18
+ version: str,
19
+ kubeconfig: Optional[str],
20
+ context: Optional[str],
21
+ helm: Optional[str] = "helm",
22
+ ):
23
+ grpc_port = grpc_endpoint.split(":")[1]
24
+ router_port = router_endpoint.split(":")[1]
25
+ args = [
26
+ helm,
27
+ "upgrade",
28
+ name,
29
+ "--install",
30
+ chart,
31
+ "--create-namespace",
32
+ "--namespace",
33
+ namespace,
34
+ "--set",
35
+ f"global.baseDomain={basedomain}",
36
+ "--set",
37
+ f"jumpstarter-controller.grpc.endpoint={grpc_endpoint}",
38
+ "--set",
39
+ f"jumpstarter-controller.grpc.routerEndpoint={router_endpoint}",
40
+ "--set",
41
+ "global.metrics.enabled=false",
42
+ "--set",
43
+ f"jumpstarter-controller.grpc.nodeport.enabled={'true' if mode == 'nodeport' else 'false'}",
44
+ "--set",
45
+ f"jumpstarter-controller.grpc.nodeport.port={grpc_port}",
46
+ "--set",
47
+ f"jumpstarter-controller.grpc.nodeport.routerPort={router_port}",
48
+ "--set",
49
+ f"jumpstarter-controller.grpc.mode={mode}",
50
+ "--version",
51
+ version,
52
+ "--wait",
53
+ ]
54
+
55
+ if kubeconfig is not None:
56
+ args.append("--kubeconfig")
57
+ args.append(kubeconfig)
58
+
59
+ if context is not None:
60
+ args.append("--kube-context")
61
+ args.append(context)
62
+
63
+ # Attempt to install Jumpstarter using Helm
64
+ process = await asyncio.create_subprocess_exec(*args)
65
+ await process.wait()
@@ -0,0 +1,14 @@
1
+ import yaml
2
+ from pydantic import BaseModel, ConfigDict
3
+
4
+
5
+ class JsonBaseModel(BaseModel):
6
+ """A Pydantic BaseModel with additional Jumpstarter JSON options applied."""
7
+
8
+ def dump_json(self):
9
+ return self.model_dump_json(indent=4, by_alias=True)
10
+
11
+ def dump_yaml(self):
12
+ return yaml.safe_dump(self.model_dump(mode="json", by_alias=True), indent=2)
13
+
14
+ model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True)
@@ -0,0 +1,103 @@
1
+ from typing import Literal, Optional
2
+
3
+ from kubernetes_asyncio.client.models import V1Condition, V1ObjectMeta, V1ObjectReference
4
+ from pydantic import Field
5
+
6
+ from .json import JsonBaseModel
7
+ from .list import V1Alpha1List
8
+ from .serialize import SerializeV1Condition, SerializeV1ObjectMeta, SerializeV1ObjectReference
9
+ from .util import AbstractAsyncCustomObjectApi
10
+
11
+
12
+ class V1Alpha1LeaseStatus(JsonBaseModel):
13
+ begin_time: Optional[str] = Field(alias="beginTime")
14
+ conditions: list[SerializeV1Condition]
15
+ end_time: Optional[str] = Field(alias="endTime")
16
+ ended: bool
17
+ exporter: Optional[SerializeV1ObjectReference]
18
+
19
+
20
+ class V1Alpha1LeaseSelector(JsonBaseModel):
21
+ match_labels: dict[str, str] = Field(alias="matchLabels")
22
+
23
+
24
+ class V1Alpha1LeaseSpec(JsonBaseModel):
25
+ client: SerializeV1ObjectReference
26
+ duration: Optional[str]
27
+ selector: V1Alpha1LeaseSelector
28
+
29
+
30
+ class V1Alpha1Lease(JsonBaseModel):
31
+ api_version: Literal["jumpstarter.dev/v1alpha1"] = Field(alias="apiVersion", default="jumpstarter.dev/v1alpha1")
32
+ kind: Literal["Lease"] = Field(default="Lease")
33
+ metadata: SerializeV1ObjectMeta
34
+ spec: V1Alpha1LeaseSpec
35
+ status: V1Alpha1LeaseStatus
36
+
37
+ @staticmethod
38
+ def from_dict(dict: dict):
39
+ return V1Alpha1Lease(
40
+ api_version=dict["apiVersion"],
41
+ kind=dict["kind"],
42
+ metadata=V1ObjectMeta(
43
+ creation_timestamp=dict["metadata"]["creationTimestamp"],
44
+ generation=dict["metadata"]["generation"],
45
+ managed_fields=dict["metadata"]["managedFields"],
46
+ name=dict["metadata"]["name"],
47
+ namespace=dict["metadata"]["namespace"],
48
+ resource_version=dict["metadata"]["resourceVersion"],
49
+ uid=dict["metadata"]["uid"],
50
+ ),
51
+ status=V1Alpha1LeaseStatus(
52
+ begin_time=dict["status"]["beginTime"] if "beginTime" in dict["status"] else None,
53
+ end_time=dict["status"]["endTime"] if "endTime" in dict["status"] else None,
54
+ ended=dict["status"]["ended"],
55
+ exporter=V1ObjectReference(name=dict["status"]["exporterRef"]["name"])
56
+ if "exporterRef" in dict["status"]
57
+ else None,
58
+ conditions=[
59
+ V1Condition(
60
+ last_transition_time=cond["lastTransitionTime"],
61
+ message=cond["message"],
62
+ observed_generation=cond["observedGeneration"],
63
+ reason=cond["reason"],
64
+ status=cond["status"],
65
+ type=cond["type"],
66
+ )
67
+ for cond in dict["status"]["conditions"]
68
+ ],
69
+ ),
70
+ spec=V1Alpha1LeaseSpec(
71
+ client=V1ObjectReference(name=dict["spec"]["clientRef"]["name"])
72
+ if "clientRef" in dict["spec"]
73
+ else None,
74
+ duration=dict["spec"]["duration"] if "duration" in dict["spec"] else None,
75
+ selector=V1Alpha1LeaseSelector(match_labels=dict["spec"]["selector"]["matchLabels"]),
76
+ ),
77
+ )
78
+
79
+
80
+ class V1Alpha1LeaseList(V1Alpha1List[V1Alpha1Lease]):
81
+ kind: Literal["LeaseList"] = Field(default="LeaseList")
82
+
83
+ @staticmethod
84
+ def from_dict(dict: dict):
85
+ return V1Alpha1LeaseList(items=[V1Alpha1Lease.from_dict(c) for c in dict["items"]])
86
+
87
+
88
+ class LeasesV1Alpha1Api(AbstractAsyncCustomObjectApi):
89
+ """Interact with the leases custom resource API"""
90
+
91
+ async def list_leases(self) -> V1Alpha1List[V1Alpha1Lease]:
92
+ """List the lease objects in the cluster async"""
93
+ result = await self.api.list_namespaced_custom_object(
94
+ namespace=self.namespace, group="jumpstarter.dev", plural="leases", version="v1alpha1"
95
+ )
96
+ return V1Alpha1LeaseList.from_dict(result)
97
+
98
+ async def get_lease(self, name: str) -> V1Alpha1Lease:
99
+ """Get a single lease object from the cluster async"""
100
+ result = await self.api.get_namespaced_custom_object(
101
+ namespace=self.namespace, group="jumpstarter.dev", plural="leases", version="v1alpha1", name=name
102
+ )
103
+ return V1Alpha1Lease.from_dict(result)
@@ -0,0 +1,15 @@
1
+ from typing import Generic, Literal, TypeVar
2
+
3
+ from pydantic import Field
4
+
5
+ from .json import JsonBaseModel
6
+
7
+ T = TypeVar("T")
8
+
9
+
10
+ class V1Alpha1List(JsonBaseModel, Generic[T]):
11
+ """A generic list result type."""
12
+
13
+ api_version: Literal["jumpstarter.dev/v1alpha1"] = Field(alias="apiVersion", default="jumpstarter.dev/v1alpha1")
14
+ items: list[T]
15
+ kind: Literal["List"] = Field(default="List")
@@ -0,0 +1,14 @@
1
+ from typing import Annotated, Any, Dict
2
+
3
+ from kubernetes_asyncio.client.models import V1Condition, V1ObjectMeta, V1ObjectReference
4
+ from pydantic import WrapSerializer
5
+
6
+
7
+ def k8s_obj_to_dict(value: Any, handler, info) -> Dict[str, Any]:
8
+ result = value.to_dict(serialize=True)
9
+ return {k: v for k, v in result.items() if v is not None}
10
+
11
+
12
+ SerializeV1Condition = Annotated[V1Condition, WrapSerializer(k8s_obj_to_dict)]
13
+ SerializeV1ObjectMeta = Annotated[V1ObjectMeta, WrapSerializer(k8s_obj_to_dict)]
14
+ SerializeV1ObjectReference = Annotated[V1ObjectReference, WrapSerializer(k8s_obj_to_dict)]
@@ -0,0 +1,117 @@
1
+ from kubernetes_asyncio.client.models import V1Condition, V1ObjectMeta, V1ObjectReference
2
+
3
+ from jumpstarter_kubernetes import V1Alpha1Lease, V1Alpha1LeaseSelector, V1Alpha1LeaseSpec, V1Alpha1LeaseStatus
4
+
5
+ TEST_LEASE = V1Alpha1Lease(
6
+ api_version="jumpstarter.dev/v1alpha1",
7
+ kind="Lease",
8
+ metadata=V1ObjectMeta(
9
+ creation_timestamp="2021-10-01T00:00:00Z",
10
+ generation=1,
11
+ name="test-lease",
12
+ namespace="default",
13
+ resource_version="1",
14
+ uid="7a25eb81-6443-47ec-a62f-50165bffede8",
15
+ ),
16
+ spec=V1Alpha1LeaseSpec(
17
+ client=V1ObjectReference(name="test-client"),
18
+ duration="1h",
19
+ selector=V1Alpha1LeaseSelector(match_labels={"test": "label", "another": "something"}),
20
+ ),
21
+ status=V1Alpha1LeaseStatus(
22
+ begin_time="2021-10-01T00:00:00Z",
23
+ conditions=[
24
+ V1Condition(
25
+ last_transition_time="2021-10-01T00:00:00Z", status="True", type="Active", message="", reason=""
26
+ )
27
+ ],
28
+ end_time="2021-10-01T01:00:00Z",
29
+ ended=False,
30
+ exporter=V1ObjectReference(name="test-exporter"),
31
+ ),
32
+ )
33
+
34
+
35
+ def test_lease_dump_json():
36
+ print(TEST_LEASE.dump_json())
37
+ assert (
38
+ TEST_LEASE.dump_json()
39
+ == """{
40
+ "apiVersion": "jumpstarter.dev/v1alpha1",
41
+ "kind": "Lease",
42
+ "metadata": {
43
+ "creationTimestamp": "2021-10-01T00:00:00Z",
44
+ "generation": 1,
45
+ "name": "test-lease",
46
+ "namespace": "default",
47
+ "resourceVersion": "1",
48
+ "uid": "7a25eb81-6443-47ec-a62f-50165bffede8"
49
+ },
50
+ "spec": {
51
+ "client": {
52
+ "name": "test-client"
53
+ },
54
+ "duration": "1h",
55
+ "selector": {
56
+ "matchLabels": {
57
+ "test": "label",
58
+ "another": "something"
59
+ }
60
+ }
61
+ },
62
+ "status": {
63
+ "beginTime": "2021-10-01T00:00:00Z",
64
+ "conditions": [
65
+ {
66
+ "lastTransitionTime": "2021-10-01T00:00:00Z",
67
+ "message": "",
68
+ "reason": "",
69
+ "status": "True",
70
+ "type": "Active"
71
+ }
72
+ ],
73
+ "endTime": "2021-10-01T01:00:00Z",
74
+ "ended": false,
75
+ "exporter": {
76
+ "name": "test-exporter"
77
+ }
78
+ }
79
+ }"""
80
+ )
81
+
82
+
83
+ def test_lease_dump_yaml():
84
+ print(TEST_LEASE.dump_yaml())
85
+ assert (
86
+ TEST_LEASE.dump_yaml()
87
+ == """apiVersion: jumpstarter.dev/v1alpha1
88
+ kind: Lease
89
+ metadata:
90
+ creationTimestamp: '2021-10-01T00:00:00Z'
91
+ generation: 1
92
+ name: test-lease
93
+ namespace: default
94
+ resourceVersion: '1'
95
+ uid: 7a25eb81-6443-47ec-a62f-50165bffede8
96
+ spec:
97
+ client:
98
+ name: test-client
99
+ duration: 1h
100
+ selector:
101
+ matchLabels:
102
+ another: something
103
+ test: label
104
+ status:
105
+ beginTime: '2021-10-01T00:00:00Z'
106
+ conditions:
107
+ - lastTransitionTime: '2021-10-01T00:00:00Z'
108
+ message: ''
109
+ reason: ''
110
+ status: 'True'
111
+ type: Active
112
+ endTime: '2021-10-01T01:00:00Z'
113
+ ended: false
114
+ exporter:
115
+ name: test-exporter
116
+ """
117
+ )
@@ -0,0 +1,3 @@
1
+ from .async_custom_object_api import AbstractAsyncCustomObjectApi
2
+
3
+ __all__ = ["AbstractAsyncCustomObjectApi"]
@@ -0,0 +1,43 @@
1
+ from contextlib import AbstractAsyncContextManager
2
+ from typing import Optional, Self
3
+
4
+ from kubernetes_asyncio import config
5
+ from kubernetes_asyncio.client.api import CoreV1Api, CustomObjectsApi
6
+ from kubernetes_asyncio.client.api_client import ApiClient
7
+
8
+
9
+ class AbstractAsyncCustomObjectApi(AbstractAsyncContextManager):
10
+ """An abstract async custom object API client"""
11
+
12
+ _client: ApiClient
13
+ config_file: Optional[str]
14
+ context: Optional[str]
15
+ namespace: str
16
+ api: CustomObjectsApi
17
+ core_api: CoreV1Api
18
+
19
+ def __init__(self, namespace: str, config_file: Optional[str] = None, context: Optional[str] = None):
20
+ self.config_file = config_file
21
+ self.context = context
22
+ self.namespace = namespace
23
+
24
+ async def __aenter__(self) -> Self:
25
+ # Load the kubeconfig
26
+ await self._load_kube_config()
27
+ # Construct the API client and enter context
28
+ self._client = ApiClient()
29
+ await self._client.__aenter__()
30
+ # Construct the custom objects API client
31
+ self.api = CustomObjectsApi(self._client)
32
+ self.core_api = CoreV1Api(self._client)
33
+ return self
34
+
35
+ async def _load_kube_config(self):
36
+ await config.load_kube_config(self.config_file, self.context)
37
+
38
+ async def __aexit__(self, exc_type, exc_value, traceback):
39
+ await self._client.__aexit__(exc_type, exc_value, traceback)
40
+ self._client = None
41
+ self.api = None
42
+ self.core_api = None
43
+ return None
@@ -0,0 +1,48 @@
1
+ [project]
2
+ name = "jumpstarter-kubernetes"
3
+ dynamic = [
4
+ "version",
5
+ "urls",
6
+ ]
7
+ description = ""
8
+ authors = [
9
+ { name = "Kirk Brauer", email = "kbrauer@hatci.com" },
10
+ ]
11
+ readme = "README.md"
12
+ license = "Apache-2.0"
13
+ requires-python = ">=3.11"
14
+ dependencies = [
15
+ "jumpstarter==0.6.0",
16
+ "pydantic>=2.8.2",
17
+ "kubernetes>=31.0.0",
18
+ "kubernetes-asyncio>=31.1.0",
19
+ ]
20
+
21
+ [dependency-groups]
22
+ dev = [
23
+ "pytest>=8.3.2",
24
+ "pytest-anyio>=0.0.0",
25
+ "pytest-asyncio>=0.0.0",
26
+ "pytest-cov>=5.0.0",
27
+ ]
28
+
29
+ [tool.hatch.metadata.hooks.vcs.urls]
30
+ Homepage = "https://jumpstarter.dev"
31
+ source_archive = "https://github.com/jumpstarter-dev/repo/archive/{commit_hash}.zip"
32
+
33
+ [tool.hatch.version]
34
+ source = "vcs"
35
+
36
+ [tool.hatch.version.raw-options]
37
+ root = "../../"
38
+
39
+ [tool.hatch.build.hooks.pin_jumpstarter]
40
+ name = "pin_jumpstarter"
41
+
42
+ [build-system]
43
+ requires = [
44
+ "hatchling",
45
+ "hatch-vcs",
46
+ "hatch-pin-jumpstarter",
47
+ ]
48
+ build-backend = "hatchling.build"