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/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