-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathontap_client.py
More file actions
313 lines (251 loc) · 10.4 KB
/
Copy pathontap_client.py
File metadata and controls
313 lines (251 loc) · 10.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
#!/usr/bin/env python3
# © 2026 NetApp, Inc. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
# See the NOTICE file in the repo root for trademark and attribution details.
"""Lightweight ONTAP REST API client for use across example scripts.
Usage::
from ontap_client import OntapClient
with OntapClient.from_env() as client:
cluster = client.get("/cluster", fields="version")
print(cluster["name"], cluster["version"]["full"])
Environment variables::
ONTAP_HOST cluster management LIF (required)
ONTAP_PASS admin password (required)
ONTAP_USER username, default admin
ONTAP_VERIFY_SSL set to 'true' to enable SSL verification, default false
ONTAP_TIMEOUT request timeout in seconds, default 90
"""
from __future__ import annotations
import logging
import os
import sys
import time
from typing import Any
import requests
import urllib3
logger = logging.getLogger("ontap_client")
__all__ = ["OntapClient", "OntapApiError", "load_env_file"]
# All examples in this repo disable SSL verification to support environments
# that use self-signed certificates. We recommend setting
# ONTAP_VERIFY_SSL=true once CA-signed certificates are in place. The
# warning suppression below keeps script output readable when verification
# is disabled.
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
_DEFAULT_TIMEOUT = 90
_DEFAULT_HEADERS = {
"Accept": "application/hal+json",
"Content-Type": "application/json",
"X-Dot-Client-App": "pace-example",
}
class OntapApiError(Exception):
"""Raised when an ONTAP REST call returns a non-success status."""
def __init__(self, response: requests.Response) -> None:
self.status_code = response.status_code
try:
detail = response.json()
except ValueError:
detail = response.text
super().__init__(f"HTTP {self.status_code}: {detail}")
self.detail = detail
class OntapClient:
"""Thin wrapper around :mod:`requests` for ONTAP REST API calls.
Parameters
----------
host:
Cluster management LIF hostname or IP.
username:
ONTAP admin user.
password:
ONTAP admin password.
verify_ssl:
Defaults to ``False`` to support self-signed certificates.
Set to ``True`` once CA-signed certificates are in place.
timeout:
Default request timeout in seconds.
"""
def __init__(
self,
host: str,
username: str,
password: str,
*,
verify_ssl: bool = False,
timeout: int = _DEFAULT_TIMEOUT,
) -> None:
self.base_url = f"https://{host}/api"
self.timeout = timeout
self._session = requests.Session()
self._session.auth = (username, password)
self._session.verify = verify_ssl
self._session.headers.update(_DEFAULT_HEADERS)
# -- Context manager ----------------------------------------------------
def __enter__(self) -> OntapClient:
return self
def __exit__(self, *exc: object) -> None:
self.close()
def close(self) -> None:
self._session.close()
def update_auth(self, username: str, password: str) -> None:
"""Replace the HTTP Basic-Auth credentials on the underlying session.
Use this when the cluster switches authentication context mid-workflow
(e.g. after ``POST /cluster`` when the node moves from pre-cluster mode
to full cluster mode and requires the new cluster admin password).
"""
self._session.auth = (username, password)
# -- Factory ------------------------------------------------------------
@classmethod
def from_env(cls) -> OntapClient:
"""Build a client from standard ``ONTAP_*`` environment variables.
Required environment variables:
``ONTAP_HOST``, ``ONTAP_PASS``
Optional (with defaults):
``ONTAP_USER`` (default ``admin``),
``ONTAP_VERIFY_SSL`` (default ``false``),
``ONTAP_TIMEOUT`` (default ``90`` seconds)
"""
host = os.environ.get("ONTAP_HOST", "")
if not host:
logger.error("ONTAP_HOST environment variable is required")
sys.exit(1)
password = os.environ.get("ONTAP_PASS", "")
if not password:
logger.error("ONTAP_PASS environment variable is required")
sys.exit(1)
_timeout_env = os.environ.get("ONTAP_TIMEOUT")
timeout = int(_timeout_env) if _timeout_env is not None else _DEFAULT_TIMEOUT
return cls(
host=host,
username=os.environ.get("ONTAP_USER", "admin"),
password=password,
verify_ssl=os.environ.get("ONTAP_VERIFY_SSL", "false").lower() == "true",
timeout=timeout,
)
# -- HTTP helpers -------------------------------------------------------
def _url(self, path: str) -> str:
if path.startswith("https://"):
return path
return f"{self.base_url}/{path.lstrip('/')}"
def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
kwargs.setdefault("timeout", self.timeout)
url = self._url(path)
logger.debug("%s %s", method, url)
try:
resp = self._session.request(method, url, **kwargs)
except requests.exceptions.Timeout as exc:
raise RuntimeError(
f"{method} {url} timed out after {kwargs['timeout']} s — "
"the cluster may be busy or unreachable. "
"Increase the timeout via OntapClient(..., timeout=<seconds>) if needed."
) from exc
if not resp.ok:
raise OntapApiError(resp)
if resp.status_code == 204 or not resp.content:
return {}
return resp.json()
def get(self, path: str, *, fields: str = "", **params: str) -> dict[str, Any]:
if fields:
params["fields"] = fields
return self._request("GET", path, params=params)
def post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
return self._request("POST", path, json=body)
def patch(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
return self._request("PATCH", path, json=body)
def delete(self, path: str) -> dict[str, Any]:
return self._request("DELETE", path)
# -- Convenience --------------------------------------------------------
def poll_job(
self,
job_uuid: str,
*,
interval: int = 5,
timeout: int = 300,
) -> dict[str, Any]:
"""Poll an async job until it leaves the ``running`` state.
Raises :class:`OntapApiError` if the job ends in ``failure``.
Retries on transient connection errors (e.g. RemoteDisconnected).
"""
url = f"/cluster/jobs/{job_uuid}"
deadline = time.monotonic() + timeout
while True:
try:
job = self.get(url, fields="state,message")
except requests.exceptions.ConnectionError as exc:
if time.monotonic() + interval > deadline:
raise TimeoutError(
f"Job {job_uuid} poll timed out after connection error: {exc}"
) from exc
logger.warning(
"Job %s — connection error during poll, retrying: %s", job_uuid, exc
)
time.sleep(interval)
continue
state = job.get("state", "unknown")
logger.info("Job %s — state: %s", job_uuid, state)
if state == "success":
return job
if state == "failure":
msg = job.get("message", "no details")
raise RuntimeError(f"Job {job_uuid} failed: {msg}")
if time.monotonic() + interval > deadline:
raise TimeoutError(f"Job {job_uuid} did not complete within {timeout}s")
time.sleep(interval)
def wait_snapmirrored(
self,
rel_uuid: str,
*,
interval: int = 15,
max_wait: int = 1800,
) -> dict[str, Any]:
"""Poll a SnapMirror relationship until its state becomes ``snapmirrored``.
Args:
rel_uuid: UUID of the SnapMirror relationship to watch.
interval: Seconds between polls (default 15).
max_wait: Maximum total seconds to wait before raising (default 1800).
Returns:
The final relationship record when state == ``snapmirrored``.
Raises:
:class:`RuntimeError` if ``max_wait`` is exceeded.
"""
elapsed = 0
while elapsed < max_wait:
result = self.get(
f"/snapmirror/relationships/{rel_uuid}",
fields="state,lag_time,healthy",
)
state = result.get("state", "unknown")
logger.info("Relationship %s — state: %s", rel_uuid, state)
if state == "snapmirrored":
return result
time.sleep(interval)
elapsed += interval
raise RuntimeError(f"Timed out waiting for relationship {rel_uuid} to reach snapmirrored")
# ---------------------------------------------------------------------------
# Shared utilities
# ---------------------------------------------------------------------------
def load_env_file(path: str) -> None:
"""Load ``KEY=VALUE`` pairs from a file into :data:`os.environ` (dotenv style).
Rules:
- Blank lines and lines starting with ``#`` are ignored.
- Values are set via :func:`os.environ.setdefault` so existing env vars
take precedence.
- Surrounding single or double quotes on values are stripped.
Args:
path: Path to the env file. The script exits with an error message if
the file does not exist or contains a malformed line.
"""
from pathlib import Path # local import to avoid top-level dependency
p = Path(path)
if not p.is_file():
logger.error("Env file not found: %s", path)
import sys
sys.exit(1)
for lineno, raw in enumerate(p.read_text(encoding="utf-8").splitlines(), start=1):
line = raw.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
logger.error("Env file %s line %d: expected KEY=VALUE, got: %s", path, lineno, line)
import sys
sys.exit(1)
key, _, value = line.partition("=")
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))