oks-cli 1.14__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.
- oks_cli/__init__.py +0 -0
- oks_cli/cache.py +63 -0
- oks_cli/cluster.py +746 -0
- oks_cli/main.py +91 -0
- oks_cli/profile.py +128 -0
- oks_cli/project.py +398 -0
- oks_cli/quotas.py +14 -0
- oks_cli/utils.py +850 -0
- oks_cli-1.14.dist-info/METADATA +270 -0
- oks_cli-1.14.dist-info/RECORD +14 -0
- oks_cli-1.14.dist-info/WHEEL +5 -0
- oks_cli-1.14.dist-info/entry_points.txt +2 -0
- oks_cli-1.14.dist-info/licenses/LICENSE +29 -0
- oks_cli-1.14.dist-info/top_level.txt +1 -0
oks_cli/utils.py
ADDED
|
@@ -0,0 +1,850 @@
|
|
|
1
|
+
import click
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import logging
|
|
5
|
+
import requests
|
|
6
|
+
from urllib.parse import urljoin, urlencode
|
|
7
|
+
import re
|
|
8
|
+
import yaml
|
|
9
|
+
import json
|
|
10
|
+
import pathlib
|
|
11
|
+
import traceback
|
|
12
|
+
import time
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
import OpenSSL
|
|
15
|
+
import shutil
|
|
16
|
+
import prettytable
|
|
17
|
+
|
|
18
|
+
import base64
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
from click.shell_completion import CompletionItem
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_config_path():
|
|
25
|
+
CONFIG_FOLDER = os.path.expanduser('~/.oks_cli')
|
|
26
|
+
if not os.path.exists(CONFIG_FOLDER):
|
|
27
|
+
os.makedirs(CONFIG_FOLDER)
|
|
28
|
+
|
|
29
|
+
PROFILE_FILE = f"{CONFIG_FOLDER}/config.json"
|
|
30
|
+
return CONFIG_FOLDER, PROFILE_FILE
|
|
31
|
+
|
|
32
|
+
DEFAULT_API_URL = "https://api.{region}.oks.outscale.com/api/v2/"
|
|
33
|
+
|
|
34
|
+
class JSONClickException(click.ClickException):
|
|
35
|
+
def show(self, file=None):
|
|
36
|
+
click.echo(self.message, file=file)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def find_response_object(data):
|
|
40
|
+
"""Extract the main object from the API response payload."""
|
|
41
|
+
response = data.json()
|
|
42
|
+
|
|
43
|
+
if isinstance(response, dict):
|
|
44
|
+
keys = list(response.keys())
|
|
45
|
+
keys.remove("ResponseContext")
|
|
46
|
+
|
|
47
|
+
key = keys.pop()
|
|
48
|
+
if key == "Cluster":
|
|
49
|
+
return response["Cluster"]
|
|
50
|
+
elif key == "Clusters":
|
|
51
|
+
return response["Clusters"]
|
|
52
|
+
elif key == "Project":
|
|
53
|
+
return response["Project"]
|
|
54
|
+
elif key == "Projects":
|
|
55
|
+
return response["Projects"]
|
|
56
|
+
elif key == "detail":
|
|
57
|
+
return {"Details": response["detail"]}
|
|
58
|
+
elif key == "Details":
|
|
59
|
+
return {"Details": response["Details"]}
|
|
60
|
+
elif key == "ControlPlanes":
|
|
61
|
+
return response["ControlPlanes"]
|
|
62
|
+
elif key == "Versions":
|
|
63
|
+
return response["Versions"]
|
|
64
|
+
elif key == "CPSubregions":
|
|
65
|
+
return response["CPSubregions"]
|
|
66
|
+
elif key == "Template":
|
|
67
|
+
return response["Template"]
|
|
68
|
+
elif key == "Quotas":
|
|
69
|
+
return response["Quotas"]
|
|
70
|
+
elif key == "Snapshots":
|
|
71
|
+
return response["Snapshots"]
|
|
72
|
+
elif key == "PublicIps":
|
|
73
|
+
return response["PublicIps"]
|
|
74
|
+
|
|
75
|
+
raise click.ClickException("The API response format is incorrect.")
|
|
76
|
+
|
|
77
|
+
def do_request(method, path, *args, **kwargs):
|
|
78
|
+
"""Perform an HTTP request to the API with authentication and error handling."""
|
|
79
|
+
api_url = os.environ.get("OKS_ENDPOINT")
|
|
80
|
+
|
|
81
|
+
logging.debug("method: %s path: %s args: %s kwargs: %s", method, path, args, kwargs)
|
|
82
|
+
|
|
83
|
+
url = urljoin(api_url, path)
|
|
84
|
+
|
|
85
|
+
headers = build_headers()
|
|
86
|
+
|
|
87
|
+
kwargs.setdefault('headers', {}).update(headers)
|
|
88
|
+
|
|
89
|
+
logging.info("%s request %s?%s", method, url,
|
|
90
|
+
urlencode(kwargs.get('params', {})))
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
data = requests.request(method, url, *args, **kwargs)
|
|
94
|
+
logging.info("response %s %s %s...", data.status_code,
|
|
95
|
+
data.reason, data.text[:50])
|
|
96
|
+
data.raise_for_status()
|
|
97
|
+
save_tokens(data.headers)
|
|
98
|
+
obj = find_response_object(data)
|
|
99
|
+
return obj
|
|
100
|
+
except requests.exceptions.HTTPError as err:
|
|
101
|
+
otp_response = handle_otp_error(err, lambda: do_request(method, path, *args, **kwargs))
|
|
102
|
+
if otp_response is not None:
|
|
103
|
+
return otp_response
|
|
104
|
+
|
|
105
|
+
jwt_response = handle_jwt_error(err, method, path, args, kwargs)
|
|
106
|
+
if jwt_response is not None:
|
|
107
|
+
return jwt_response
|
|
108
|
+
|
|
109
|
+
logging.debug(traceback.format_stack(limit = 4))
|
|
110
|
+
raise JSONClickException(err.response.text)
|
|
111
|
+
|
|
112
|
+
def build_headers():
|
|
113
|
+
"""Build HTTP headers for API requests based on environment authentication settings."""
|
|
114
|
+
headers = {
|
|
115
|
+
"Content-Type": "application/json",
|
|
116
|
+
"Accept": "application/json"
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if os.getenv("OKS_OTP_CODE"):
|
|
120
|
+
headers["X-OTP-Code"] = os.getenv("OKS_OTP_CODE")
|
|
121
|
+
|
|
122
|
+
if os.getenv("OKS_DEV_HEADER"):
|
|
123
|
+
for header in os.getenv("OKS_DEV_HEADER").split(','):
|
|
124
|
+
_h, _, _n = header.partition('=')
|
|
125
|
+
if _h and _n:
|
|
126
|
+
headers[_h.strip()] = _n.strip()
|
|
127
|
+
|
|
128
|
+
if is_jwt_enabled() and is_tokens_valid():
|
|
129
|
+
headers["AccessToken"] = get_token('access_token')
|
|
130
|
+
headers["RefreshToken"] = get_token('refresh_token')
|
|
131
|
+
elif os.getenv("OKS_ACCESS_KEY") and os.getenv("OKS_ACCESS_KEY"):
|
|
132
|
+
headers["AccessKey"] = os.getenv("OKS_ACCESS_KEY")
|
|
133
|
+
headers["SecretKey"] = os.getenv("OKS_SECRET_KEY")
|
|
134
|
+
elif os.getenv("OKS_USERNAME") and os.getenv("OKS_PASSWORD"):
|
|
135
|
+
username = os.getenv("OKS_USERNAME")
|
|
136
|
+
password = os.getenv("OKS_PASSWORD")
|
|
137
|
+
user_pass = f"{username}:{password}"
|
|
138
|
+
user_pass_bytes = user_pass.encode('utf-8')
|
|
139
|
+
encoded_user_pass = base64.b64encode(user_pass_bytes).decode('utf-8')
|
|
140
|
+
|
|
141
|
+
headers["Authorization"] = f"Basic {encoded_user_pass}"
|
|
142
|
+
else:
|
|
143
|
+
raise click.ClickException("No authentication profiles were found. Please set a user profile to proceed")
|
|
144
|
+
|
|
145
|
+
return headers
|
|
146
|
+
|
|
147
|
+
def print_output(data, output_fromat):
|
|
148
|
+
"""Print data in the specified format: JSON, YAML, or silent."""
|
|
149
|
+
output_data = json.dumps(data, indent=4)
|
|
150
|
+
|
|
151
|
+
if output_fromat == "yaml":
|
|
152
|
+
output_data = yaml.dump(data, sort_keys=False)
|
|
153
|
+
|
|
154
|
+
elif output_fromat == "silent":
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
click.echo(output_data)
|
|
158
|
+
|
|
159
|
+
def handle_otp_error(err, callback):
|
|
160
|
+
"""Handle OTP authentication error by prompting the user and retrying the request."""
|
|
161
|
+
try:
|
|
162
|
+
response_body = json.loads(err.response.text)
|
|
163
|
+
if response_body.get("otp_required"):
|
|
164
|
+
otp_code = click.prompt('Enter your OTP code', type=int)
|
|
165
|
+
os.environ["OKS_OTP_CODE"] = str(otp_code)
|
|
166
|
+
|
|
167
|
+
return callback()
|
|
168
|
+
except Exception:
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
def handle_jwt_error(err, method, path, args, kwargs):
|
|
172
|
+
"""Handle invalid JWT errors by removing tokens and retrying the request."""
|
|
173
|
+
try:
|
|
174
|
+
response_body = json.loads(err.response.text)
|
|
175
|
+
if response_body.get("message") == "Unauthorized: Invalid JWT.":
|
|
176
|
+
remove_jwt_token('access_token')
|
|
177
|
+
remove_jwt_token('refresh_token')
|
|
178
|
+
|
|
179
|
+
headers = kwargs.get("headers", {})
|
|
180
|
+
headers.pop("AccessToken", None)
|
|
181
|
+
headers.pop("RefreshToken", None)
|
|
182
|
+
|
|
183
|
+
return do_request(method, path, *args, **kwargs)
|
|
184
|
+
except Exception:
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
def find_project_id_by_name(project_name):
|
|
188
|
+
"""Retrieve the project ID by name or use the default project if no name is provided."""
|
|
189
|
+
if not project_name:
|
|
190
|
+
project_id = get_project_id()
|
|
191
|
+
if not project_id:
|
|
192
|
+
raise click.BadParameter("--project-name must be specified, or a default project must be set")
|
|
193
|
+
else:
|
|
194
|
+
data = do_request("GET", 'projects', params={"name": project_name})
|
|
195
|
+
if len(data) != 1:
|
|
196
|
+
errors = {"Error": f"{len(data)} projects found by name: {project_name}"}
|
|
197
|
+
raise JSONClickException(json.dumps(errors))
|
|
198
|
+
project_id = data.pop()['id']
|
|
199
|
+
|
|
200
|
+
return project_id
|
|
201
|
+
|
|
202
|
+
def find_cluster_id_by_name(project_id, cluster_name):
|
|
203
|
+
"""Retrieve the cluster ID by name within a given project, or use the default cluster if none is provided."""
|
|
204
|
+
if not cluster_name:
|
|
205
|
+
cluster_id = get_cluster_id()
|
|
206
|
+
if not cluster_id:
|
|
207
|
+
raise click.BadParameter("--cluster-name must be specified, or a default cluster must be set")
|
|
208
|
+
else:
|
|
209
|
+
data = do_request("GET", 'clusters', params={"project_id": project_id, "name": cluster_name})
|
|
210
|
+
if len(data) != 1:
|
|
211
|
+
errors = {"Error": f"{len(data)} clusters found by name: {cluster_name}"}
|
|
212
|
+
raise JSONClickException(json.dumps(errors))
|
|
213
|
+
cluster_id = data.pop()['id']
|
|
214
|
+
|
|
215
|
+
return cluster_id
|
|
216
|
+
|
|
217
|
+
def get_project_id():
|
|
218
|
+
"""Return the default project ID from the profile configuration file, if available."""
|
|
219
|
+
project_id = None
|
|
220
|
+
|
|
221
|
+
if not os.getenv("OKS_PROFILE"):
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
CONFIG_FOLDER, _ = get_config_path()
|
|
225
|
+
|
|
226
|
+
PROJECT_ID_FILE = f"{CONFIG_FOLDER}/{os.getenv('OKS_PROFILE')}.project_id"
|
|
227
|
+
|
|
228
|
+
if os.path.exists(PROJECT_ID_FILE):
|
|
229
|
+
with open(PROJECT_ID_FILE, 'r') as file:
|
|
230
|
+
project_id = file.read()
|
|
231
|
+
|
|
232
|
+
return project_id
|
|
233
|
+
|
|
234
|
+
def get_cluster_id():
|
|
235
|
+
"""Return the default cluster ID from the profile configuration file, if available."""
|
|
236
|
+
cluster_id = None
|
|
237
|
+
|
|
238
|
+
if not os.getenv("OKS_PROFILE"):
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
CONFIG_FOLDER, _ = get_config_path()
|
|
242
|
+
|
|
243
|
+
CLUSTER_ID_FILE = f"{CONFIG_FOLDER}/{os.getenv('OKS_PROFILE')}.cluster_id"
|
|
244
|
+
|
|
245
|
+
if os.path.exists(CLUSTER_ID_FILE):
|
|
246
|
+
with open(CLUSTER_ID_FILE, 'r') as file:
|
|
247
|
+
cluster_id = file.read()
|
|
248
|
+
|
|
249
|
+
return cluster_id
|
|
250
|
+
|
|
251
|
+
def set_cluster_id(cluster_id):
|
|
252
|
+
"""Save the given cluster ID to the profile configuration file with secure permissions."""
|
|
253
|
+
CONFIG_FOLDER, _ = get_config_path()
|
|
254
|
+
|
|
255
|
+
if not os.path.exists(CONFIG_FOLDER):
|
|
256
|
+
os.makedirs(CONFIG_FOLDER)
|
|
257
|
+
|
|
258
|
+
if not os.getenv("OKS_PROFILE"):
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
CLUSTER_ID_FILE = f"{CONFIG_FOLDER}/{os.getenv('OKS_PROFILE')}.cluster_id"
|
|
262
|
+
|
|
263
|
+
with open(CLUSTER_ID_FILE, 'w') as file:
|
|
264
|
+
file.write(cluster_id)
|
|
265
|
+
|
|
266
|
+
os.chmod(CLUSTER_ID_FILE, 0o600)
|
|
267
|
+
|
|
268
|
+
def set_project_id(project_id):
|
|
269
|
+
"""Save the given project ID to the profile configuration file with secure permissions."""
|
|
270
|
+
CONFIG_FOLDER, _ = get_config_path()
|
|
271
|
+
|
|
272
|
+
if not os.path.exists(CONFIG_FOLDER):
|
|
273
|
+
os.makedirs(CONFIG_FOLDER)
|
|
274
|
+
|
|
275
|
+
if not os.getenv("OKS_PROFILE"):
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
PROJECT_ID_FILE = f"{CONFIG_FOLDER}/{os.getenv('OKS_PROFILE')}.project_id"
|
|
279
|
+
|
|
280
|
+
with open(PROJECT_ID_FILE, 'w') as file:
|
|
281
|
+
file.write(project_id)
|
|
282
|
+
|
|
283
|
+
os.chmod(PROJECT_ID_FILE, 0o600)
|
|
284
|
+
|
|
285
|
+
def login_profile(name):
|
|
286
|
+
"""
|
|
287
|
+
Load and set environment variables for the given profile name.
|
|
288
|
+
Raises an exception if the profile does not exist or lacks required info.
|
|
289
|
+
"""
|
|
290
|
+
_, PROFILE_FILE = get_config_path()
|
|
291
|
+
if name is None:
|
|
292
|
+
name = "default"
|
|
293
|
+
|
|
294
|
+
if os.path.exists(PROFILE_FILE):
|
|
295
|
+
with open(PROFILE_FILE, 'r') as file:
|
|
296
|
+
profiles = json.load(file)
|
|
297
|
+
|
|
298
|
+
if name not in profiles:
|
|
299
|
+
raise click.ClickException("Profile %s does not exist" % click.style(name, bold=True))
|
|
300
|
+
|
|
301
|
+
os.environ["OKS_PROFILE"] = name
|
|
302
|
+
|
|
303
|
+
if profiles[name]['type'] == 'username/password':
|
|
304
|
+
os.environ["OKS_USERNAME"] = profiles[name]['username']
|
|
305
|
+
os.environ["OKS_PASSWORD"] = profiles[name]['password']
|
|
306
|
+
else: # profiles[name]['type'] == 'ak/sk'
|
|
307
|
+
os.environ["OKS_ACCESS_KEY"] = profiles[name]['access_key']
|
|
308
|
+
os.environ["OKS_SECRET_KEY"] = profiles[name]['secret_key']
|
|
309
|
+
|
|
310
|
+
if not os.environ.get("OKS_ENDPOINT"):
|
|
311
|
+
if 'endpoint' in profiles[name]:
|
|
312
|
+
os.environ["OKS_ENDPOINT"] = profiles[name]['endpoint']
|
|
313
|
+
elif 'region_name' in profiles[name]:
|
|
314
|
+
os.environ["OKS_ENDPOINT"] = DEFAULT_API_URL.format(region=profiles[name]['region_name'])
|
|
315
|
+
else:
|
|
316
|
+
raise click.ClickException(f"Unable to find API endpoint for {click.style(name, bold=True)} profile")
|
|
317
|
+
|
|
318
|
+
if 'region_name' in profiles[name]:
|
|
319
|
+
os.environ["OKS_REGION"] = profiles[name]['region_name']
|
|
320
|
+
|
|
321
|
+
return profiles[name]
|
|
322
|
+
|
|
323
|
+
return {}
|
|
324
|
+
|
|
325
|
+
def profile_list():
|
|
326
|
+
"""Return all profiles as a dict, or empty if none."""
|
|
327
|
+
_, PROFILE_FILE = get_config_path()
|
|
328
|
+
|
|
329
|
+
if os.path.exists(PROFILE_FILE):
|
|
330
|
+
with open(PROFILE_FILE, 'r') as file:
|
|
331
|
+
profiles = json.load(file)
|
|
332
|
+
return profiles
|
|
333
|
+
|
|
334
|
+
return {}
|
|
335
|
+
|
|
336
|
+
def get_profiles():
|
|
337
|
+
"""Return list of profile names, or empty list."""
|
|
338
|
+
_, PROFILE_FILE = get_config_path()
|
|
339
|
+
|
|
340
|
+
if os.path.exists(PROFILE_FILE):
|
|
341
|
+
with open(PROFILE_FILE, 'r') as f:
|
|
342
|
+
data = json.load(f)
|
|
343
|
+
if isinstance(data, dict):
|
|
344
|
+
return list(data.keys())
|
|
345
|
+
return []
|
|
346
|
+
|
|
347
|
+
def profile_completer(ctx, param, incomplete):
|
|
348
|
+
"""Autocomplete profile names starting with input."""
|
|
349
|
+
profiles = get_profiles()
|
|
350
|
+
return [CompletionItem(p) for p in profiles if p.startswith(incomplete)]
|
|
351
|
+
|
|
352
|
+
def set_profile(name, obj: dict):
|
|
353
|
+
"""Add or update a profile in the profiles file."""
|
|
354
|
+
_, PROFILE_FILE = get_config_path()
|
|
355
|
+
|
|
356
|
+
if os.path.exists(PROFILE_FILE):
|
|
357
|
+
with open(PROFILE_FILE, 'r+') as file:
|
|
358
|
+
profiles = json.load(file)
|
|
359
|
+
profiles[name] = obj
|
|
360
|
+
|
|
361
|
+
profiles = json.dumps(profiles)
|
|
362
|
+
|
|
363
|
+
file.seek(0)
|
|
364
|
+
file.write(profiles)
|
|
365
|
+
file.truncate()
|
|
366
|
+
else:
|
|
367
|
+
with open(PROFILE_FILE, 'w') as file:
|
|
368
|
+
profiles = {
|
|
369
|
+
name: obj
|
|
370
|
+
}
|
|
371
|
+
profiles = json.dumps(profiles)
|
|
372
|
+
file.write(profiles)
|
|
373
|
+
|
|
374
|
+
os.chmod(PROFILE_FILE, 0o600)
|
|
375
|
+
|
|
376
|
+
def remove_profile(name):
|
|
377
|
+
"""Remove a profile by name from the profiles file."""
|
|
378
|
+
_, PROFILE_FILE = get_config_path()
|
|
379
|
+
|
|
380
|
+
if os.path.exists(PROFILE_FILE):
|
|
381
|
+
with open(PROFILE_FILE, 'r+') as file:
|
|
382
|
+
profiles = json.load(file)
|
|
383
|
+
del profiles[name]
|
|
384
|
+
|
|
385
|
+
profiles = json.dumps(profiles)
|
|
386
|
+
|
|
387
|
+
file.seek(0)
|
|
388
|
+
file.write(profiles)
|
|
389
|
+
file.truncate()
|
|
390
|
+
|
|
391
|
+
def get_cache(project, cluster, name, user, group):
|
|
392
|
+
"""Return path to cached item if it exists, else None."""
|
|
393
|
+
CONFIG_FOLDER, _ = get_config_path()
|
|
394
|
+
|
|
395
|
+
user = user or "default"
|
|
396
|
+
group = group or "default"
|
|
397
|
+
|
|
398
|
+
cluster_cache = pathlib.Path(CONFIG_FOLDER).joinpath("cache", f"{project}-{cluster}", user, group)
|
|
399
|
+
item_path = cluster_cache.joinpath(name).absolute()
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
with item_path.open():
|
|
403
|
+
logging.info("cache found at %s", item_path)
|
|
404
|
+
return item_path
|
|
405
|
+
except Exception:
|
|
406
|
+
logging.info("cache item %s %s %s not found", project, cluster, name)
|
|
407
|
+
|
|
408
|
+
def save_cache(project, cluster, name, data, user, group):
|
|
409
|
+
"""Save data to cache file and return its path."""
|
|
410
|
+
CONFIG_FOLDER, _ = get_config_path()
|
|
411
|
+
|
|
412
|
+
user = user or "default"
|
|
413
|
+
group = group or "default"
|
|
414
|
+
|
|
415
|
+
cluster_cache = pathlib.Path(CONFIG_FOLDER).joinpath("cache", f"{project}-{cluster}", user, group)
|
|
416
|
+
item_path = cluster_cache.joinpath(name).absolute()
|
|
417
|
+
|
|
418
|
+
if not cluster_cache.exists():
|
|
419
|
+
cluster_cache.mkdir(parents = True)
|
|
420
|
+
|
|
421
|
+
with item_path.open("w") as f:
|
|
422
|
+
logging.info("saving cache at %s", item_path)
|
|
423
|
+
f.write(data)
|
|
424
|
+
|
|
425
|
+
os.chmod(item_path, 0o600)
|
|
426
|
+
|
|
427
|
+
return item_path
|
|
428
|
+
|
|
429
|
+
def clear_cache():
|
|
430
|
+
"""Delete the entire cache directory."""
|
|
431
|
+
CONFIG_FOLDER, _ = get_config_path()
|
|
432
|
+
cache = pathlib.Path(CONFIG_FOLDER).joinpath("cache")
|
|
433
|
+
shutil.rmtree(cache)
|
|
434
|
+
|
|
435
|
+
def get_all_cache(project, cluster, name):
|
|
436
|
+
"""Retrieve all cache entries for a project and cluster."""
|
|
437
|
+
CONFIG_FOLDER, _ = get_config_path()
|
|
438
|
+
cluster_cache_path = pathlib.Path(CONFIG_FOLDER).joinpath("cache", f"{project}-{cluster}")
|
|
439
|
+
table = []
|
|
440
|
+
|
|
441
|
+
if not os.path.exists(cluster_cache_path):
|
|
442
|
+
logging.info(f"Cache directory '{cluster_cache_path}' does not exist.")
|
|
443
|
+
return table
|
|
444
|
+
|
|
445
|
+
for user in os.listdir(cluster_cache_path):
|
|
446
|
+
user_path = os.path.join(cluster_cache_path, user)
|
|
447
|
+
if os.path.isdir(user_path):
|
|
448
|
+
for group in os.listdir(user_path):
|
|
449
|
+
group_path = os.path.join(user_path, group)
|
|
450
|
+
if os.path.isdir(group_path):
|
|
451
|
+
cache = get_cache(project, cluster, name, user, group)
|
|
452
|
+
table.append({"user": user,"group": group, "cache_path": cache})
|
|
453
|
+
|
|
454
|
+
return table
|
|
455
|
+
|
|
456
|
+
def parse_jwt(token):
|
|
457
|
+
"""Decode and parse a JWT token payload as JSON."""
|
|
458
|
+
try:
|
|
459
|
+
payload_b64 = token.split('.')[1]
|
|
460
|
+
padding = '=' * (4 - len(payload_b64) % 4)
|
|
461
|
+
payload_json = base64.urlsafe_b64decode(payload_b64 + padding).decode('utf-8')
|
|
462
|
+
return json.loads(payload_json)
|
|
463
|
+
except (IndexError, ValueError):
|
|
464
|
+
return None
|
|
465
|
+
|
|
466
|
+
def save_tokens(headers):
|
|
467
|
+
"""Save access and refresh tokens from response headers."""
|
|
468
|
+
CONFIG_FOLDER, _ = get_config_path()
|
|
469
|
+
if not headers.get('Access-Token') or not headers['Refresh-Token']:
|
|
470
|
+
return
|
|
471
|
+
|
|
472
|
+
if not os.getenv("OKS_PROFILE"):
|
|
473
|
+
return
|
|
474
|
+
|
|
475
|
+
ACCESS_TOKEN_FILE = f"{CONFIG_FOLDER}/{os.getenv('OKS_PROFILE')}.access_token"
|
|
476
|
+
REFRESH_TOKEN_FILE = f"{CONFIG_FOLDER}/{os.getenv('OKS_PROFILE')}.refresh_token"
|
|
477
|
+
|
|
478
|
+
with open(ACCESS_TOKEN_FILE, 'w') as file:
|
|
479
|
+
file.write(headers['Access-Token'])
|
|
480
|
+
|
|
481
|
+
with open(REFRESH_TOKEN_FILE, 'w') as file:
|
|
482
|
+
file.write(headers['Refresh-Token'])
|
|
483
|
+
|
|
484
|
+
os.chmod(ACCESS_TOKEN_FILE, 0o600)
|
|
485
|
+
os.chmod(REFRESH_TOKEN_FILE, 0o600)
|
|
486
|
+
|
|
487
|
+
def is_tokens_valid():
|
|
488
|
+
"""Check if stored refresh token is still valid."""
|
|
489
|
+
CONFIG_FOLDER, _ = get_config_path()
|
|
490
|
+
if not os.getenv("OKS_PROFILE"):
|
|
491
|
+
return
|
|
492
|
+
|
|
493
|
+
REFRESH_TOKEN_FILE = f"{CONFIG_FOLDER}/{os.getenv('OKS_PROFILE')}.refresh_token"
|
|
494
|
+
|
|
495
|
+
if not os.path.exists(REFRESH_TOKEN_FILE):
|
|
496
|
+
return False
|
|
497
|
+
|
|
498
|
+
with open(REFRESH_TOKEN_FILE, 'r') as file:
|
|
499
|
+
refresh_token = file.read()
|
|
500
|
+
|
|
501
|
+
decoded_refresh_token = parse_jwt(refresh_token)
|
|
502
|
+
|
|
503
|
+
if decoded_refresh_token and decoded_refresh_token.get('exp'):
|
|
504
|
+
|
|
505
|
+
current_time = int(time.time())
|
|
506
|
+
|
|
507
|
+
if decoded_refresh_token['exp'] >= current_time + 5: # 5sec margin
|
|
508
|
+
return True
|
|
509
|
+
|
|
510
|
+
return False
|
|
511
|
+
|
|
512
|
+
def is_jwt_enabled():
|
|
513
|
+
"""Return True if JWT is enabled in the current profile."""
|
|
514
|
+
_, PROFILE_FILE = get_config_path()
|
|
515
|
+
|
|
516
|
+
if not os.getenv("OKS_PROFILE"):
|
|
517
|
+
return
|
|
518
|
+
|
|
519
|
+
name = os.getenv("OKS_PROFILE")
|
|
520
|
+
|
|
521
|
+
if os.path.exists(PROFILE_FILE):
|
|
522
|
+
with open(PROFILE_FILE, 'r') as file:
|
|
523
|
+
profiles = json.load(file)
|
|
524
|
+
|
|
525
|
+
if name not in profiles:
|
|
526
|
+
raise click.ClickException("Profile %s does not exist" % click.style(name, bold=True))
|
|
527
|
+
|
|
528
|
+
return profiles[name].get('jwt', False)
|
|
529
|
+
|
|
530
|
+
return False
|
|
531
|
+
|
|
532
|
+
def get_token(token_type):
|
|
533
|
+
"""Retrieve stored token (access or refresh) for current profile."""
|
|
534
|
+
CONFIG_FOLDER, _ = get_config_path()
|
|
535
|
+
|
|
536
|
+
if not os.getenv("OKS_PROFILE"):
|
|
537
|
+
return
|
|
538
|
+
|
|
539
|
+
TOKEN_FILE = f"{CONFIG_FOLDER}/{os.getenv('OKS_PROFILE')}.{token_type}"
|
|
540
|
+
|
|
541
|
+
if not os.path.exists(TOKEN_FILE):
|
|
542
|
+
return ""
|
|
543
|
+
|
|
544
|
+
with open(TOKEN_FILE, 'r') as file:
|
|
545
|
+
token = file.read()
|
|
546
|
+
return token
|
|
547
|
+
|
|
548
|
+
def remove_jwt_token(token_type):
|
|
549
|
+
"""Delete the specified JWT token file for current profile."""
|
|
550
|
+
CONFIG_FOLDER, _ = get_config_path()
|
|
551
|
+
|
|
552
|
+
if not os.getenv("OKS_PROFILE"):
|
|
553
|
+
return
|
|
554
|
+
|
|
555
|
+
TOKEN_FILE = f"{CONFIG_FOLDER}/{os.getenv('OKS_PROFILE')}.{token_type}"
|
|
556
|
+
|
|
557
|
+
if os.path.exists(TOKEN_FILE):
|
|
558
|
+
os.remove(TOKEN_FILE)
|
|
559
|
+
|
|
560
|
+
def detect_and_parse_input(input_data):
|
|
561
|
+
"""Parse input as JSON or YAML; raise error if invalid."""
|
|
562
|
+
try:
|
|
563
|
+
return json.loads(input_data)
|
|
564
|
+
except json.JSONDecodeError:
|
|
565
|
+
pass
|
|
566
|
+
|
|
567
|
+
try:
|
|
568
|
+
return yaml.safe_load(input_data)
|
|
569
|
+
except yaml.YAMLError:
|
|
570
|
+
pass
|
|
571
|
+
|
|
572
|
+
raise click.BadParameter("Input file is neither valid JSON nor YAML.")
|
|
573
|
+
|
|
574
|
+
def verify_certificate(kubeconfig_str):
|
|
575
|
+
"""Check if the kubeconfig client certificate is still valid."""
|
|
576
|
+
not_after_date = get_expiration_date(kubeconfig_str)
|
|
577
|
+
|
|
578
|
+
if not_after_date < datetime.now():
|
|
579
|
+
return False
|
|
580
|
+
else:
|
|
581
|
+
return True
|
|
582
|
+
|
|
583
|
+
def get_expiration_date(kubeconfig_str):
|
|
584
|
+
"""Extract and return the client certificate expiration date."""
|
|
585
|
+
kubeconfig = yaml.safe_load(kubeconfig_str)
|
|
586
|
+
|
|
587
|
+
for user_entry in kubeconfig.get('users', []):
|
|
588
|
+
user_details = user_entry['user']
|
|
589
|
+
client_cert_data = user_details.get('client-certificate-data')
|
|
590
|
+
|
|
591
|
+
if not client_cert_data:
|
|
592
|
+
logging.info("No client certificate data found for user.")
|
|
593
|
+
continue
|
|
594
|
+
|
|
595
|
+
ca_cert = base64.b64decode(client_cert_data)
|
|
596
|
+
|
|
597
|
+
try:
|
|
598
|
+
cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, ca_cert)
|
|
599
|
+
not_after = cert.get_notAfter().decode('ascii')
|
|
600
|
+
not_after_date = datetime.strptime(not_after, '%Y%m%d%H%M%SZ')
|
|
601
|
+
|
|
602
|
+
return not_after_date
|
|
603
|
+
except OpenSSL.crypto.Error as e:
|
|
604
|
+
logging.info(f"ERROR: Invalid certificate data for cluster. Error: {e}")
|
|
605
|
+
|
|
606
|
+
def retrieve_cp_sized(filepath, endpoint):
|
|
607
|
+
"""Fetch control plane sizes from API and save to file."""
|
|
608
|
+
cp_list = do_request("GET", endpoint)
|
|
609
|
+
|
|
610
|
+
with open(filepath, "w") as file:
|
|
611
|
+
json.dump(cp_list, file)
|
|
612
|
+
|
|
613
|
+
def shell_completions(ctx, param: click.core.Option, incomplete):
|
|
614
|
+
"""Provide shell autocompletions with cached API data."""
|
|
615
|
+
CONFIG_FOLDER, _ = get_config_path()
|
|
616
|
+
|
|
617
|
+
profiles = profile_list()
|
|
618
|
+
profile = ctx.params["profile"] or ctx.parent.params["profile"] or ctx.parent.parent.params["profile"] or "default"
|
|
619
|
+
|
|
620
|
+
if profile not in profiles:
|
|
621
|
+
return []
|
|
622
|
+
|
|
623
|
+
login_profile(profile)
|
|
624
|
+
|
|
625
|
+
if param.name == "version":
|
|
626
|
+
endpoint = "clusters/limits/kubernetes_versions"
|
|
627
|
+
elif param.name == "control_plane":
|
|
628
|
+
endpoint = "clusters/limits/control_plane_plans"
|
|
629
|
+
elif param.name == "zone":
|
|
630
|
+
endpoint = "clusters/limits/cp_subregions"
|
|
631
|
+
else:
|
|
632
|
+
return []
|
|
633
|
+
|
|
634
|
+
CP_SIZES_PATH = f"{CONFIG_FOLDER}/cache/{profile}.{param.name}"
|
|
635
|
+
os.makedirs(f"{CONFIG_FOLDER}/cache", exist_ok=True)
|
|
636
|
+
|
|
637
|
+
if os.path.exists(CP_SIZES_PATH):
|
|
638
|
+
file_ctime = os.path.getctime(CP_SIZES_PATH)
|
|
639
|
+
if datetime.timestamp(datetime.now()) - file_ctime > 300:
|
|
640
|
+
retrieve_cp_sized(CP_SIZES_PATH, endpoint)
|
|
641
|
+
else:
|
|
642
|
+
retrieve_cp_sized(CP_SIZES_PATH, endpoint)
|
|
643
|
+
|
|
644
|
+
if os.path.exists(CP_SIZES_PATH):
|
|
645
|
+
with open(CP_SIZES_PATH, "r") as file:
|
|
646
|
+
cp_list = json.load(file)
|
|
647
|
+
else:
|
|
648
|
+
cp_list = []
|
|
649
|
+
|
|
650
|
+
return [k for k in cp_list if k.startswith(incomplete)]
|
|
651
|
+
|
|
652
|
+
def update_shell_profile(shell_profile, filepath):
|
|
653
|
+
"""Append source command to shell profile if not present."""
|
|
654
|
+
if os.path.exists(shell_profile):
|
|
655
|
+
with open(shell_profile, 'r') as f:
|
|
656
|
+
lines = f.readlines()
|
|
657
|
+
|
|
658
|
+
line_to_add = f". {filepath}\n"
|
|
659
|
+
if line_to_add not in lines:
|
|
660
|
+
with open(shell_profile, 'a') as f:
|
|
661
|
+
f.write("\n" + line_to_add)
|
|
662
|
+
|
|
663
|
+
def find_shell_profile(home, shell_type):
|
|
664
|
+
"""Return user shell profile path or None if ambiguous."""
|
|
665
|
+
shell_profile = None
|
|
666
|
+
|
|
667
|
+
if shell_type == "bash":
|
|
668
|
+
|
|
669
|
+
bash_profile = os.path.join(home, '.bash_profile')
|
|
670
|
+
bashrc = os.path.join(home, '.bashrc')
|
|
671
|
+
|
|
672
|
+
if os.path.exists(bash_profile) and os.path.exists(bashrc):
|
|
673
|
+
return None
|
|
674
|
+
|
|
675
|
+
shell_profile = bash_profile or bashrc
|
|
676
|
+
|
|
677
|
+
elif shell_type == "zsh":
|
|
678
|
+
|
|
679
|
+
zshrc = os.path.join(home, '.zshrc')
|
|
680
|
+
profile = os.path.join(home, '.profile')
|
|
681
|
+
|
|
682
|
+
if os.path.exists(zshrc) and os.path.exists(profile):
|
|
683
|
+
return None
|
|
684
|
+
|
|
685
|
+
shell_profile = zshrc or profile
|
|
686
|
+
|
|
687
|
+
return shell_profile
|
|
688
|
+
|
|
689
|
+
def install_completions(shell_type):
|
|
690
|
+
"""Install shell completion scripts for bash or zsh."""
|
|
691
|
+
home = os.path.expanduser('~')
|
|
692
|
+
|
|
693
|
+
if shell_type is None:
|
|
694
|
+
try:
|
|
695
|
+
shell_pid = os.getppid()
|
|
696
|
+
result = subprocess.run(['ps', '-p', str(shell_pid), '-o', 'comm='], capture_output=True, text=True)
|
|
697
|
+
shell_name = result.stdout.strip()
|
|
698
|
+
|
|
699
|
+
shell_type = os.path.basename(shell_name)
|
|
700
|
+
except subprocess.SubProcessError:
|
|
701
|
+
click.echo("Failed to determine shell type, please specify it by --type")
|
|
702
|
+
|
|
703
|
+
completion_dir = os.path.join(home, ".oks_cli", "completions")
|
|
704
|
+
os.makedirs(completion_dir, exist_ok=True)
|
|
705
|
+
|
|
706
|
+
if shell_type == 'bash':
|
|
707
|
+
subprocess.run(f'_OKS_CLI_COMPLETE=bash_source oks-cli > {completion_dir}/oks-cli.sh', shell=True)
|
|
708
|
+
shell_profile = find_shell_profile(home, shell_type)
|
|
709
|
+
if shell_profile:
|
|
710
|
+
update_shell_profile(shell_profile, f"{completion_dir}/oks-cli.sh")
|
|
711
|
+
click.echo(f"Autocompletion installed for {shell_type} in {completion_dir}.\nRestart your shell or source you {click.style(shell_profile, bold=True)} to enable it.")
|
|
712
|
+
else:
|
|
713
|
+
click.echo(
|
|
714
|
+
"\nTo activate autocompletion on login please add following lines into your .bash_profile or .bashrc file:\n\n" +
|
|
715
|
+
click.style('[ -s "$HOME/.oks_cli/completions/oks-cli.sh" ] && source "$HOME/.oks_cli/completions/oks-cli.sh"\n\n', bold=True) +
|
|
716
|
+
"And to activate it now - please run:\n\n" +
|
|
717
|
+
click.style('source "$HOME/.oks_cli/completions/oks-cli.sh"\n', bold=True)
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
elif shell_type == 'zsh':
|
|
721
|
+
subprocess.run(f'_OKS_CLI_COMPLETE=zsh_source oks-cli > {completion_dir}/oks-cli.sh', shell=True)
|
|
722
|
+
shell_profile = find_shell_profile(home, shell_type)
|
|
723
|
+
if shell_profile:
|
|
724
|
+
update_shell_profile(shell_profile, f"{completion_dir}/oks-cli.sh")
|
|
725
|
+
click.echo(f"Autocompletion installed for {shell_type} in {completion_dir}.\nRestart your shell or source you {click.style(shell_profile, bold=True)} to enable it.")
|
|
726
|
+
else:
|
|
727
|
+
click.echo(
|
|
728
|
+
"\nTo activate autocompletion on login please add following lines into your .profile or .zshrc file:\n\n" +
|
|
729
|
+
click.style('[ -s "$HOME/.oks_cli/completions/oks-cli.sh" ] && source "$HOME/.oks_cli/completions/oks-cli.sh"\n\n', bold=True) +
|
|
730
|
+
"And to activate it now - please run:\n\n" +
|
|
731
|
+
click.style('source "$HOME/.oks_cli/completions/oks-cli.sh"\n', bold=True)
|
|
732
|
+
)
|
|
733
|
+
else:
|
|
734
|
+
click.echo(f"Shell completions for {shell_type} are not implemented.")
|
|
735
|
+
|
|
736
|
+
def transform_tuple(data):
|
|
737
|
+
"""Convert tuple to list unless it contains only an empty string."""
|
|
738
|
+
return [] if data == ('',) else list(data)
|
|
739
|
+
|
|
740
|
+
def cluster_create_in_background(cluster_config, text):
|
|
741
|
+
"""Forks and retries cluster creation in background until project ready or failed."""
|
|
742
|
+
pid = os.fork()
|
|
743
|
+
|
|
744
|
+
if pid == 0: # Child process
|
|
745
|
+
time.sleep(120) # Initial 2 mins pause
|
|
746
|
+
for i in range(30): # retry every 30 seconds during 15 mins
|
|
747
|
+
project = do_request("GET", f"projects/{cluster_config.get('project_id')}")
|
|
748
|
+
|
|
749
|
+
if project.get("status") == "ready":
|
|
750
|
+
do_request("POST", 'clusters', json=cluster_config)
|
|
751
|
+
return
|
|
752
|
+
elif project.get("status") == "failed":
|
|
753
|
+
return
|
|
754
|
+
|
|
755
|
+
time.sleep(30)
|
|
756
|
+
else: # Parent process
|
|
757
|
+
click.echo(f"Task for create cluster started in background. PID: {pid}" + text)
|
|
758
|
+
|
|
759
|
+
def get_template(type):
|
|
760
|
+
"""Fetch and cache template, refresh if older than 15 minutes."""
|
|
761
|
+
CONFIG_FOLDER, _ = get_config_path()
|
|
762
|
+
|
|
763
|
+
TEMPLATE_PATH = f"{CONFIG_FOLDER}/cache/{type}.template"
|
|
764
|
+
os.makedirs(f"{CONFIG_FOLDER}/cache", exist_ok=True)
|
|
765
|
+
|
|
766
|
+
if os.path.exists(TEMPLATE_PATH):
|
|
767
|
+
file_ctime = os.path.getctime(TEMPLATE_PATH)
|
|
768
|
+
if datetime.timestamp(datetime.now()) - file_ctime > 900:
|
|
769
|
+
template = do_request("GET", f"templates/{type}")
|
|
770
|
+
with open(TEMPLATE_PATH, "w") as file:
|
|
771
|
+
json.dump(template, file)
|
|
772
|
+
os.chmod(TEMPLATE_PATH, 0o600)
|
|
773
|
+
else:
|
|
774
|
+
with open(TEMPLATE_PATH, "r") as file:
|
|
775
|
+
template = json.load(file)
|
|
776
|
+
else:
|
|
777
|
+
template = do_request("GET", f"templates/{type}")
|
|
778
|
+
with open(TEMPLATE_PATH, "w") as file:
|
|
779
|
+
json.dump(template, file)
|
|
780
|
+
os.chmod(TEMPLATE_PATH, 0o600)
|
|
781
|
+
|
|
782
|
+
return template
|
|
783
|
+
|
|
784
|
+
def ctx_update(ctx, project_name=None, cluster_name=None, profile=None, overwrite=True):
|
|
785
|
+
"""Update context with project, cluster, and profile; optionally prevent overwrites."""
|
|
786
|
+
if not hasattr(ctx, 'obj') or not ctx.obj:
|
|
787
|
+
ctx.obj = dict()
|
|
788
|
+
|
|
789
|
+
if project_name is not None:
|
|
790
|
+
if ctx.obj.get('project_name') and not overwrite:
|
|
791
|
+
raise click.BadParameter("project-name already set before")
|
|
792
|
+
ctx.obj['project_name'] = project_name
|
|
793
|
+
|
|
794
|
+
if cluster_name is not None:
|
|
795
|
+
if ctx.obj.get('cluster_name') and not overwrite:
|
|
796
|
+
raise click.BadParameter("cluster-name already set before")
|
|
797
|
+
ctx.obj['cluster_name'] = cluster_name
|
|
798
|
+
|
|
799
|
+
if profile is not None:
|
|
800
|
+
if ctx.obj.get('profile') and not overwrite:
|
|
801
|
+
raise click.BadParameter("profile already set before")
|
|
802
|
+
ctx.obj['profile'] = profile
|
|
803
|
+
|
|
804
|
+
return (ctx.obj.get('project_name'), ctx.obj.get('cluster_name'), ctx.obj.get('profile'))
|
|
805
|
+
|
|
806
|
+
def get_project_name(project_name):
|
|
807
|
+
"""Return project name from ID or given name, else raise error."""
|
|
808
|
+
if not project_name:
|
|
809
|
+
project_id = get_project_id()
|
|
810
|
+
if not project_id:
|
|
811
|
+
raise click.BadParameter("--project-name must be specified, or a default project must be set")
|
|
812
|
+
|
|
813
|
+
project = do_request("GET", f'projects/{project_id}')
|
|
814
|
+
return project['name']
|
|
815
|
+
else:
|
|
816
|
+
return project_name
|
|
817
|
+
|
|
818
|
+
def get_cluster_name(cluster_name):
|
|
819
|
+
"""Return cluster name from ID or given name, else raise error."""
|
|
820
|
+
if not cluster_name:
|
|
821
|
+
cluster_id = get_cluster_id()
|
|
822
|
+
if not cluster_id:
|
|
823
|
+
raise click.BadParameter("--cluster_name must be specified, or a default cluster must be set")
|
|
824
|
+
|
|
825
|
+
cluster = do_request("GET", f'clusters/{cluster_id}')
|
|
826
|
+
return cluster['name']
|
|
827
|
+
else:
|
|
828
|
+
return cluster_name
|
|
829
|
+
|
|
830
|
+
def format_changed_row(table, row):
|
|
831
|
+
"""Format a single changed row maintaining table style."""
|
|
832
|
+
new_table = prettytable.PrettyTable()
|
|
833
|
+
new_table.field_names = table.field_names
|
|
834
|
+
if table._style:
|
|
835
|
+
new_table.set_style(table._style)
|
|
836
|
+
new_table.header = False
|
|
837
|
+
|
|
838
|
+
# Set the min width for each column
|
|
839
|
+
for i, width in enumerate(table._widths):
|
|
840
|
+
if i < len(new_table.field_names):
|
|
841
|
+
new_table.min_width[new_table.field_names[i]] = width
|
|
842
|
+
|
|
843
|
+
new_table.add_row(row)
|
|
844
|
+
|
|
845
|
+
return new_table
|
|
846
|
+
|
|
847
|
+
def is_interesting_status(status):
|
|
848
|
+
"""Check if status is in the list of interesting statuses."""
|
|
849
|
+
interesting_statuses = ["pending", "deploying", "updating", "upgrading", "deleting"]
|
|
850
|
+
return status in interesting_statuses
|