nutanix-shim-server 0.0.1__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.
@@ -0,0 +1,177 @@
1
+ # Env files
2
+ **/*.env.*
3
+
4
+ # Byte-compiled / optimized / DLL files
5
+ __pycache__/
6
+ *.py[cod]
7
+ *$py.class
8
+
9
+ # C extensions
10
+ *.so
11
+
12
+ # Distribution / packaging
13
+ .Python
14
+ build/
15
+ develop-eggs/
16
+ dist/
17
+ downloads/
18
+ eggs/
19
+ .eggs/
20
+ lib/
21
+ lib64/
22
+ parts/
23
+ sdist/
24
+ var/
25
+ wheels/
26
+ share/python-wheels/
27
+ *.egg-info/
28
+ .installed.cfg
29
+ *.egg
30
+ MANIFEST
31
+
32
+ # PyInstaller
33
+ # Usually these files are written by a python script from a template
34
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
35
+ *.manifest
36
+ *.spec
37
+
38
+ # Installer logs
39
+ pip-log.txt
40
+ pip-delete-this-directory.txt
41
+
42
+ # Unit test / coverage reports
43
+ htmlcov/
44
+ .tox/
45
+ .nox/
46
+ .coverage
47
+ .coverage.*
48
+ .cache
49
+ nosetests.xml
50
+ coverage.xml
51
+ *.cover
52
+ *.py,cover
53
+ .hypothesis/
54
+ .pytest_cache/
55
+ cover/
56
+
57
+ # Translations
58
+ *.mo
59
+ *.pot
60
+
61
+ # Django stuff:
62
+ *.log
63
+ local_settings.py
64
+ db.sqlite3
65
+ db.sqlite3-journal
66
+
67
+ # Flask stuff:
68
+ instance/
69
+ .webassets-cache
70
+
71
+ # Scrapy stuff:
72
+ .scrapy
73
+
74
+ # Sphinx documentation
75
+ docs/_build/
76
+
77
+ # PyBuilder
78
+ .pybuilder/
79
+ target/
80
+
81
+ # Jupyter Notebook
82
+ .ipynb_checkpoints
83
+
84
+ # IPython
85
+ profile_default/
86
+ ipython_config.py
87
+
88
+ # pyenv
89
+ # For a library or package, you might want to ignore these files since the code is
90
+ # intended to run in multiple environments; otherwise, check them in:
91
+ # .python-version
92
+
93
+ # pipenv
94
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
95
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
96
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
97
+ # install all needed dependencies.
98
+ #Pipfile.lock
99
+
100
+ # poetry
101
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
102
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
103
+ # commonly ignored for libraries.
104
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
105
+ #poetry.lock
106
+
107
+ # pdm
108
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
109
+ #pdm.lock
110
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
111
+ # in version control.
112
+ # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
113
+ .pdm.toml
114
+ .pdm-python
115
+ .pdm-build/
116
+
117
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
118
+ __pypackages__/
119
+
120
+ # Celery stuff
121
+ celerybeat-schedule
122
+ celerybeat.pid
123
+
124
+ # SageMath parsed files
125
+ *.sage.py
126
+
127
+ # Environments
128
+ .env
129
+ .venv
130
+ env/
131
+ venv/
132
+ ENV/
133
+ env.bak/
134
+ venv.bak/
135
+
136
+ # Spyder project settings
137
+ .spyderproject
138
+ .spyproject
139
+
140
+ # vscode settings
141
+ .vscode
142
+
143
+ # local mlflow tracking
144
+ mlruns/
145
+ mlartifacts/
146
+
147
+ # Rope project settings
148
+ .ropeproject
149
+
150
+ # mkdocs documentation
151
+ /site
152
+
153
+ # mypy
154
+ .mypy_cache/
155
+ .dmypy.json
156
+ dmypy.json
157
+
158
+ # Pyre type checker
159
+ .pyre/
160
+
161
+ # pytype static type analyzer
162
+ .pytype/
163
+
164
+ # Cython debug symbols
165
+ cython_debug/
166
+
167
+ # PyCharm
168
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
169
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
170
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
171
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
172
+ #.idea/
173
+
174
+ NORCE_CAChain.crt
175
+
176
+ # argus files
177
+ .argus
@@ -0,0 +1,12 @@
1
+ repos:
2
+ - repo: https://github.com/astral-sh/ruff-pre-commit
3
+ rev: v0.11.8
4
+ hooks:
5
+ - id: ruff
6
+ name: lint
7
+ args: [ --fix ]
8
+ - id: ruff-format
9
+ name: format
10
+ - id: ruff
11
+ name: sort imports
12
+ args: [ --select, I, --fix]
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 NORCE Research
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: nutanix-shim-server
3
+ Version: 0.0.1
4
+ Summary: Server for the Foreman Nutanix shim plugin
5
+ Author-email: Miles Granger <mgra@norceresearch.no>
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.12
8
+ Requires-Dist: fastapi[standard]>=0.118.0
9
+ Requires-Dist: ntnx-clustermgmt-py-client~=4.0.0
10
+ Requires-Dist: ntnx-networking-py-client~=4.0.0
11
+ Requires-Dist: ntnx-prism-py-client~=4.0.0
12
+ Requires-Dist: ntnx-vmm-py-client~=4.0.0
File without changes
@@ -0,0 +1,36 @@
1
+ [project]
2
+ name = "nutanix-shim-server"
3
+ version = "0.0.1"
4
+ description = "Server for the Foreman Nutanix shim plugin"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Miles Granger", email = "mgra@norceresearch.no" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "fastapi[standard]>=0.118.0",
12
+ "ntnx-clustermgmt-py-client~=4.0.0",
13
+ "ntnx-networking-py-client~=4.0.0",
14
+ "ntnx-prism-py-client~=4.0.0",
15
+ "ntnx-vmm-py-client~=4.0.0",
16
+ ]
17
+
18
+ [project.scripts]
19
+ nutanix-shim-server = "nutanix_shim_server:main"
20
+
21
+ [build-system]
22
+ requires = ["hatchling"]
23
+ build-backend = "hatchling.build"
24
+
25
+ [dependency-groups]
26
+ dev = [
27
+ "ipython>=8.34.0",
28
+ "pre-commit>=4.1.0",
29
+ "pyright>=1.1.396",
30
+ "pytest-mock>=3.14.0",
31
+ "pytest>=8.3.5",
32
+ "ruff>=0.13.2",
33
+ ]
34
+
35
+ [tool.pytest.init_options]
36
+ addopts = "-v"
@@ -0,0 +1,15 @@
1
+ import sys
2
+
3
+ from fastapi_cli.cli import main as fastapi_main
4
+
5
+
6
+ def main() -> None:
7
+ """Entry point that wraps fastapi-cli with the app path pre-configured."""
8
+ # Insert 'run' command and app path, then append any user-provided args
9
+ sys.argv = [
10
+ "fastapi",
11
+ "run",
12
+ "nutanix_shim_server.server:app",
13
+ *sys.argv[1:],
14
+ ]
15
+ fastapi_main()
@@ -0,0 +1,283 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import dataclasses
5
+ from typing import Self, cast
6
+ import ntnx_clustermgmt_py_client as cm
7
+
8
+ try:
9
+ from IPython.terminal.embed import embed
10
+ except ImportError:
11
+
12
+ def embed():
13
+ pass
14
+
15
+
16
+ class ClusterMgmt:
17
+ config: cm.Configuration
18
+
19
+ def __init__(self):
20
+ self.config = cm.Configuration()
21
+ self.config.host = os.environ["NUTANIX_HOST"]
22
+ self.config.scheme = "https"
23
+ self.config.set_api_key(os.environ["NUTANIX_API_KEY"])
24
+ self.config.max_retry_attempts = 3
25
+ self.config.backoff_factor = 3
26
+ self.config.verify_ssl = False # TODO: True
27
+ self.config.port = os.environ.get("NUTANIX_PORT", 9440)
28
+
29
+ @property
30
+ def client(self) -> cm.ApiClient:
31
+ if not hasattr(self, "_client"):
32
+ self._client = cm.ApiClient(self.config)
33
+ self._client.add_default_header(
34
+ header_name="Accept-Encoding", header_value="gzip, deflate, br"
35
+ )
36
+ return self._client
37
+
38
+ @property
39
+ def storage_containers_api(self) -> cm.StorageContainersApi:
40
+ if not hasattr(self, "_storage_containers_api"):
41
+ self._storage_containers_api = cm.StorageContainersApi(
42
+ api_client=self.client
43
+ )
44
+ return self._storage_containers_api
45
+
46
+ def list_storage_containers(self) -> list[StorageContainerMetadata]:
47
+ """Return list of storage containers"""
48
+ resp: cm.ListStorageContainersApiResponse = self.storage_containers_api.list_storage_containers()
49
+ containers: None | list[cm.StorageContainer] = resp.data
50
+ if containers:
51
+ return [
52
+ StorageContainerMetadata.from_nutanix_storage_container(container)
53
+ for container in containers
54
+ ]
55
+ return []
56
+
57
+ @property
58
+ def clusters_api(self) -> cm.ClustersApi:
59
+ if not hasattr(self, "_clusters_api"):
60
+ self._clusters_api = cm.ClustersApi(api_client=self.client)
61
+ return self._clusters_api
62
+
63
+ def list_clusters(self) -> list[ClusterMetadata]:
64
+ """Return list of clusters"""
65
+ resp: cm.ListClustersApiResponse = self.clusters_api.list_clusters()
66
+ clusters: None | list[cm.Cluster] = resp.data
67
+ if clusters:
68
+ return [
69
+ ClusterMetadata.from_nutanix_cluster(cluster) for cluster in clusters
70
+ ]
71
+ return []
72
+
73
+ def get_cluster_stats(self, cluster_ext_id: str) -> ClusterResourceStats:
74
+ """Get resource usage statistics for a specific cluster
75
+
76
+ Gets CPU/memory capacity by aggregating from cluster hosts, and usage
77
+ stats from the cluster stats API.
78
+ """
79
+ import datetime
80
+
81
+ # Get cluster stats for usage metrics
82
+ end_time = datetime.datetime.now(datetime.timezone.utc)
83
+ start_time = end_time - datetime.timedelta(hours=1)
84
+
85
+ stats_resp: cm.ClusterStatsApiResponse = self.clusters_api.get_cluster_stats(
86
+ extId=cluster_ext_id,
87
+ _startTime=start_time,
88
+ _endTime=end_time,
89
+ )
90
+ stats: cm.ClusterStats = stats_resp.data # type: ignore
91
+
92
+ # Get hosts to aggregate CPU and memory capacity
93
+ hosts_resp = self.clusters_api.list_hosts_by_cluster_id(clusterExtId=cluster_ext_id)
94
+ hosts: None | list[cm.Host] = hosts_resp.data # type: ignore
95
+
96
+ # Aggregate capacity from all hosts in the cluster
97
+ total_cpu_capacity_hz = 0
98
+ total_memory_capacity_bytes = 0
99
+ total_cpu_cores = 0
100
+
101
+ if hosts:
102
+ for host in hosts:
103
+ # Try cpu_capacity_hz first, fall back to calculating from frequency and cores
104
+ if host.cpu_capacity_hz:
105
+ total_cpu_capacity_hz += host.cpu_capacity_hz
106
+ elif host.cpu_frequency_hz and host.number_of_cpu_cores:
107
+ # Calculate capacity: frequency * number of cores
108
+ total_cpu_capacity_hz += host.cpu_frequency_hz * host.number_of_cpu_cores
109
+
110
+ if host.memory_size_bytes:
111
+ total_memory_capacity_bytes += host.memory_size_bytes
112
+
113
+ if host.number_of_cpu_cores:
114
+ total_cpu_cores += host.number_of_cpu_cores
115
+
116
+ return ClusterResourceStats.from_nutanix_cluster_stats(
117
+ stats, total_cpu_capacity_hz, total_memory_capacity_bytes, total_cpu_cores
118
+ )
119
+
120
+
121
+ @dataclasses.dataclass(frozen=True)
122
+ class ClusterMetadata:
123
+ name: str
124
+ ext_id: str
125
+ n_nodes: int
126
+ arch: str
127
+ vm_count: int
128
+ is_available: bool
129
+
130
+ @classmethod
131
+ def from_nutanix_cluster(cls, cluster: cm.Cluster) -> Self:
132
+ # nutanix typing is almost always "Unknown | None" - hence the casting
133
+ nodes = cast(cm.NodeReference, cluster.nodes)
134
+ config = cast(cm.ClusterConfigReference, cluster.config)
135
+ return cls(
136
+ name=cast(str, cluster.name),
137
+ n_nodes=cast(int, nodes.number_of_nodes),
138
+ ext_id=cast(str, cluster.ext_id),
139
+ arch=cast(str, config.cluster_arch),
140
+ vm_count=cast(int, cluster.vm_count),
141
+ is_available=cast(bool, config.is_available),
142
+ )
143
+
144
+
145
+ @dataclasses.dataclass(frozen=True)
146
+ class StorageContainerMetadata:
147
+ """
148
+ Metadata about a storage container.
149
+
150
+ Includes container ID, name, capacity information, and storage features.
151
+ """
152
+
153
+ ext_id: str
154
+ name: str
155
+ cluster_name: None | str
156
+ cluster_ext_id: None | str
157
+ max_capacity_bytes: None | int
158
+ logical_advertised_capacity_bytes: None | int
159
+ replication_factor: None | int
160
+ is_compression_enabled: None | bool
161
+ is_encrypted: None | bool
162
+ is_marked_for_removal: None | bool
163
+
164
+ @classmethod
165
+ def from_nutanix_storage_container(cls, container: cm.StorageContainer) -> Self:
166
+ """Convert Nutanix SDK StorageContainer to our response model"""
167
+ return cls(
168
+ ext_id=cast(str, container.container_ext_id), # Note: SDK uses container_ext_id, not ext_id
169
+ name=cast(str, container.name),
170
+ cluster_name=container.cluster_name,
171
+ cluster_ext_id=container.cluster_ext_id,
172
+ max_capacity_bytes=container.max_capacity_bytes,
173
+ logical_advertised_capacity_bytes=container.logical_advertised_capacity_bytes,
174
+ replication_factor=container.replication_factor,
175
+ is_compression_enabled=container.is_compression_enabled,
176
+ is_encrypted=container.is_encrypted,
177
+ is_marked_for_removal=container.is_marked_for_removal,
178
+ )
179
+
180
+
181
+ @dataclasses.dataclass(frozen=True)
182
+ class ClusterResourceStats:
183
+ """
184
+ Resource usage statistics for a cluster.
185
+
186
+ Includes CPU, memory, and storage capacity and usage information.
187
+ """
188
+
189
+ ext_id: str
190
+ # CPU stats
191
+ cpu_capacity_hz: int
192
+ cpu_usage_hz: int
193
+ cpu_usage_percent: float
194
+ cpu_cores_total: int
195
+ cpu_cores_usage: int
196
+ # Memory stats
197
+ memory_capacity_bytes: int
198
+ memory_usage_bytes: int
199
+ memory_usage_percent: float
200
+ # Storage stats
201
+ storage_capacity_bytes: int
202
+ storage_usage_bytes: int
203
+ storage_usage_percent: float
204
+
205
+ @classmethod
206
+ def from_nutanix_cluster_stats(
207
+ cls,
208
+ stats: cm.ClusterStats,
209
+ cpu_capacity_hz: int = 0,
210
+ memory_capacity_bytes: int = 0,
211
+ cpu_cores_total: int = 0,
212
+ ) -> Self:
213
+ """Convert Nutanix SDK ClusterStats to our response model
214
+
215
+ Note: Stats from Nutanix API can be time-series (lists) or scalar values.
216
+ We extract the latest/last value from lists if needed.
217
+
218
+ CPU and memory capacity, and CPU cores are passed in separately since
219
+ they must be aggregated from cluster hosts (not available in ClusterStats).
220
+ """
221
+
222
+ def extract_value(val, field_name="unknown"):
223
+ """Extract scalar value from either a list or scalar
224
+
225
+ Handles:
226
+ - None -> 0
227
+ - Scalar values -> value
228
+ - Lists of TimeValuePair -> extract value from last pair
229
+ - Empty lists -> 0
230
+ """
231
+ if val is None:
232
+ return 0
233
+ if isinstance(val, list):
234
+ if not val:
235
+ return 0
236
+ # Take the last value from time series
237
+ last_item = val[-1]
238
+ # If it's a TimeValuePair object, extract the value
239
+ if hasattr(last_item, 'value'):
240
+ return last_item.value if last_item.value is not None else 0
241
+ return last_item
242
+ # Handle TimeValuePair objects directly
243
+ if hasattr(val, 'value'):
244
+ return val.value if val.value is not None else 0
245
+ return val
246
+
247
+ # Extract usage values from stats (storage includes capacity too)
248
+ # CPU: Use hypervisor_cpu_usage_ppm to calculate usage from capacity
249
+ cpu_usage_ppm = extract_value(stats.hypervisor_cpu_usage_ppm, "hypervisor_cpu_usage_ppm")
250
+ cpu_usage_hz = int(cpu_capacity_hz * (cpu_usage_ppm / 1_000_000)) if cpu_capacity_hz else 0
251
+ cpu_usage_pct = (cpu_usage_hz / cpu_capacity_hz * 100) if cpu_capacity_hz else 0
252
+
253
+ # Estimate cores in use based on usage percentage
254
+ cpu_cores_usage = int(cpu_cores_total * (cpu_usage_pct / 100)) if cpu_cores_total else 0
255
+
256
+ # Memory: overall_memory_usage_bytes from stats, capacity from aggregated hosts
257
+ memory_usage = extract_value(stats.overall_memory_usage_bytes, "overall_memory_usage_bytes")
258
+ memory_usage_pct = (memory_usage / memory_capacity_bytes * 100) if memory_capacity_bytes else 0
259
+
260
+ # Storage: both capacity and usage from stats
261
+ storage_capacity = extract_value(stats.storage_capacity_bytes, "storage_capacity_bytes")
262
+ storage_usage = extract_value(stats.storage_usage_bytes, "storage_usage_bytes")
263
+ storage_usage_pct = (storage_usage / storage_capacity * 100) if storage_capacity else 0
264
+
265
+ return cls(
266
+ ext_id=cast(str, stats.ext_id),
267
+ cpu_capacity_hz=int(cpu_capacity_hz),
268
+ cpu_usage_hz=int(cpu_usage_hz),
269
+ cpu_usage_percent=round(cpu_usage_pct, 2),
270
+ cpu_cores_total=int(cpu_cores_total),
271
+ cpu_cores_usage=int(cpu_cores_usage),
272
+ memory_capacity_bytes=int(memory_capacity_bytes),
273
+ memory_usage_bytes=int(memory_usage),
274
+ memory_usage_percent=round(memory_usage_pct, 2),
275
+ storage_capacity_bytes=int(storage_capacity),
276
+ storage_usage_bytes=int(storage_usage),
277
+ storage_usage_percent=round(storage_usage_pct, 2),
278
+ )
279
+
280
+
281
+ if __name__ == "__main__":
282
+ mgmt = ClusterMgmt()
283
+ mgmt.list_clusters()