oks-cli 1.16__tar.gz → 1.18__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.
Files changed (26) hide show
  1. {oks_cli-1.16 → oks_cli-1.18}/PKG-INFO +1 -1
  2. {oks_cli-1.16 → oks_cli-1.18}/oks_cli/cache.py +3 -3
  3. {oks_cli-1.16 → oks_cli-1.18}/oks_cli/cluster.py +86 -55
  4. {oks_cli-1.16 → oks_cli-1.18}/oks_cli/profile.py +1 -1
  5. {oks_cli-1.16 → oks_cli-1.18}/oks_cli/project.py +37 -37
  6. {oks_cli-1.16 → oks_cli-1.18}/oks_cli/utils.py +65 -6
  7. {oks_cli-1.16 → oks_cli-1.18}/oks_cli.egg-info/PKG-INFO +1 -1
  8. {oks_cli-1.16 → oks_cli-1.18}/setup.py +1 -1
  9. {oks_cli-1.16 → oks_cli-1.18}/tests/test_cluster.py +120 -0
  10. {oks_cli-1.16 → oks_cli-1.18}/tests/test_profile.py +27 -1
  11. {oks_cli-1.16 → oks_cli-1.18}/tests/test_project.py +183 -1
  12. {oks_cli-1.16 → oks_cli-1.18}/LICENSE +0 -0
  13. {oks_cli-1.16 → oks_cli-1.18}/README.md +0 -0
  14. {oks_cli-1.16 → oks_cli-1.18}/oks_cli/__init__.py +0 -0
  15. {oks_cli-1.16 → oks_cli-1.18}/oks_cli/main.py +0 -0
  16. {oks_cli-1.16 → oks_cli-1.18}/oks_cli/quotas.py +0 -0
  17. {oks_cli-1.16 → oks_cli-1.18}/oks_cli.egg-info/SOURCES.txt +0 -0
  18. {oks_cli-1.16 → oks_cli-1.18}/oks_cli.egg-info/dependency_links.txt +0 -0
  19. {oks_cli-1.16 → oks_cli-1.18}/oks_cli.egg-info/entry_points.txt +0 -0
  20. {oks_cli-1.16 → oks_cli-1.18}/oks_cli.egg-info/requires.txt +0 -0
  21. {oks_cli-1.16 → oks_cli-1.18}/oks_cli.egg-info/top_level.txt +0 -0
  22. {oks_cli-1.16 → oks_cli-1.18}/setup.cfg +0 -0
  23. {oks_cli-1.16 → oks_cli-1.18}/tests/test_cache.py +0 -0
  24. {oks_cli-1.16 → oks_cli-1.18}/tests/test_nodepool.py +0 -0
  25. {oks_cli-1.16 → oks_cli-1.18}/tests/test_quota.py +0 -0
  26. {oks_cli-1.16 → oks_cli-1.18}/tests/test_shell_completion.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oks-cli
3
- Version: 1.16
3
+ Version: 1.18
4
4
  Author: Outscale SAS
5
5
  Author-email: opensource@outscale.com
6
6
  License: BSD
@@ -2,7 +2,7 @@ import click
2
2
  from .utils import clear_cache, find_project_id_by_name, find_cluster_id_by_name, get_all_cache, get_expiration_date, \
3
3
  ctx_update, login_profile, profile_completer, cluster_completer, project_completer, print_table
4
4
 
5
- import prettytable
5
+ from prettytable import TableStyle
6
6
 
7
7
  # DEFINE THE CACHE COMMAND GROUP
8
8
  @click.group(help="Cache related commands.")
@@ -57,8 +57,8 @@ def list_kubeconfigs(ctx, project_name, cluster_name, plain, msword, profile):
57
57
 
58
58
  style = None
59
59
  if plain:
60
- style = prettytable.PLAIN_COLUMNS
60
+ style = TableStyle.PLAIN_COLUMNS
61
61
  if msword:
62
- style = prettytable.MSWORD_FRIENDLY
62
+ style = TableStyle.MSWORD_FRIENDLY
63
63
 
64
64
  print_table(data, fields, style=style)
@@ -14,6 +14,7 @@ import prettytable
14
14
  import logging
15
15
  import yaml
16
16
 
17
+ from prettytable import TableStyle
17
18
  from .utils import cluster_completer, do_request, print_output, \
18
19
  find_project_id_by_name, find_cluster_id_by_name, \
19
20
  get_cache, save_cache, detect_and_parse_input, \
@@ -22,7 +23,7 @@ from .utils import cluster_completer, do_request, print_output,
22
23
  ctx_update, set_cluster_id, get_cluster_id, get_project_id, \
23
24
  get_template, get_cluster_name, format_changed_row, \
24
25
  is_interesting_status, profile_completer, project_completer, \
25
- kubeconfig_parse_fields, print_table, get_expiration_date
26
+ kubeconfig_parse_fields, print_table, format_row
26
27
 
27
28
  from .profile import add_profile
28
29
  from .project import project_create, project_login
@@ -86,19 +87,22 @@ def cluster_logout(ctx, profile):
86
87
  @click.option('--watch', '-w', is_flag=True, help="Watch the changes")
87
88
  @click.option('--output', '-o', type=click.Choice(["json", "yaml", "wide"]), help="Specify output format")
88
89
  @click.option('--profile', help="Configuration profile to use")
90
+ @click.option('--all', '-A', is_flag=True, help="List clusters from all projects")
89
91
  @click.pass_context
90
- def cluster_list(ctx, project_name, cluster_name, deleted, plain, msword, watch, output, profile):
92
+ def cluster_list(ctx, project_name, cluster_name, deleted, plain, msword, watch, output, profile, all):
91
93
  """Display clusters with optional filtering and real-time monitoring."""
92
94
  project_name, cluster_name, profile = ctx_update(ctx, project_name, cluster_name, profile)
93
95
  login_profile(profile)
94
96
 
95
97
  profile_name = os.getenv('OKS_PROFILE')
96
98
  region_name = os.getenv('OKS_REGION')
97
- project_id = find_project_id_by_name(project_name)
98
- cluster_id = get_cluster_id()
99
-
100
99
  params = {}
101
- params['project_id'] = project_id
100
+
101
+ if not all:
102
+ project_id = find_project_id_by_name(project_name)
103
+ params['project_id'] = project_id
104
+
105
+ cluster_id = get_cluster_id()
102
106
 
103
107
  if cluster_name:
104
108
  params['name'] = cluster_name
@@ -107,7 +111,17 @@ def cluster_list(ctx, project_name, cluster_name, deleted, plain, msword, watch,
107
111
 
108
112
  field_names = ["CLUSTER", "PROFILE", "REGION", "CREATED", "UPDATED", "STATUS", "DEFAULT"]
109
113
 
110
- data = do_request("GET", 'clusters', params=params)
114
+ if all:
115
+ field_names.insert(0, "PROJECT")
116
+
117
+ projects = {project["id"]: project for project in do_request("GET", "projects")}
118
+ data = do_request("GET", "clusters/all", params=params)
119
+
120
+ for cluster in data:
121
+ project = projects.get(cluster.get("project_id"))
122
+ cluster["project_name"] = project.get("name")
123
+ else:
124
+ data = do_request("GET", "clusters", params=params)
111
125
 
112
126
  if output == "wide":
113
127
  field_names.insert(0, "ID")
@@ -123,49 +137,27 @@ def cluster_list(ctx, project_name, cluster_name, deleted, plain, msword, watch,
123
137
  table._min_width = {"CREATED": 13, "UPDATED": 13, "STATUS": 10}
124
138
 
125
139
  if plain or watch:
126
- table.set_style(prettytable.PLAIN_COLUMNS)
140
+ table.set_style(TableStyle.PLAIN_COLUMNS)
127
141
 
128
142
  if msword:
129
143
  table.set_style(prettytable.MSWORD_FRIENDLY)
130
144
 
131
- def format_row(cluster):
132
- status = cluster['statuses']['status']
133
-
134
- is_default = True if cluster.get('id') == cluster_id else False
135
-
136
- if status == 'ready':
137
- msg = click.style(status, fg='green')
138
- elif status == 'failed' or status == 'deleted':
139
- msg = click.style(status, fg='red')
140
- elif status == 'deploying':
141
- msg = click.style(status, fg='yellow')
142
- else:
143
- msg = status
144
-
145
- name = click.style(cluster['name'], bold=True)
146
- if is_default:
147
- default = "*"
148
- else:
149
- default = ""
150
-
151
- created_at = dateutil.parser.parse(cluster['statuses']['created_at'])
152
- updated_at = dateutil.parser.parse(cluster['statuses']['updated_at'])
153
- now = datetime.now(tz = created_at.tzinfo)
154
-
155
- row = [name, profile_name, region_name, human_readable.date_time(now - created_at), human_readable.date_time(now - updated_at), msg, default]
145
+ initial_clusters = {}
156
146
 
147
+ for cluster in data:
148
+ row, _, name = format_row(cluster.get('statuses'), cluster.get('name'), cluster_id == cluster.get('id'))
149
+ row.insert(1, profile_name)
150
+ row.insert(2, region_name)
151
+ if all:
152
+ project_name = click.style(cluster.get("project_name"), bold=True)
153
+ row.insert(0, project_name)
157
154
  if output == "wide":
158
- row.insert(0, cluster['id'])
159
- row.append(cluster['version'])
160
- row.append(cluster['control_planes'])
161
-
162
- return row, status, cluster['name']
155
+ row.insert(0, cluster.get('id'))
156
+ row.append(cluster.get('version'))
157
+ row.append(cluster.get('control_planes'))
163
158
 
164
- initial_clusters = {}
165
- for cluster in data:
166
- row, _, name = format_row(cluster)
167
159
  table.add_row(row)
168
- initial_clusters[name] = cluster
160
+ initial_clusters[cluster.get("id")] = cluster
169
161
 
170
162
  click.echo(table)
171
163
 
@@ -177,46 +169,66 @@ def cluster_list(ctx, project_name, cluster_name, deleted, plain, msword, watch,
177
169
  total_sleep += 2
178
170
 
179
171
  try:
180
- data = do_request("GET", 'clusters', params=params)
172
+ if all:
173
+ projects = {project["id"]: project for project in do_request("GET", "projects")}
174
+ data = do_request("GET", "clusters/all", params=params)
175
+
176
+ for cluster in data:
177
+ project = projects.get(cluster.get("project_id"))
178
+ cluster["project_name"] = project.get("name")
179
+ else:
180
+ data = do_request("GET", 'clusters', params=params)
181
181
  except click.ClickException as err:
182
182
  click.echo(f"Error during watch: {err}")
183
183
  continue
184
184
 
185
- current_cluster_names = {cluster['name'] for cluster in data}
185
+ current_cluster_ids = {cluster.get('id') for cluster in data}
186
186
 
187
- for name, cluster in list(initial_clusters.items()):
188
- if name not in current_cluster_names:
187
+ for id, cluster in list(initial_clusters.items()):
188
+ if id not in current_cluster_ids:
189
189
  deleted_cluster = cluster.copy()
190
190
  deleted_cluster['statuses']['status'] = 'deleted'
191
191
 
192
- row, current_status, _ = format_row(deleted_cluster)
192
+ row, current_status, _ = format_row(deleted_cluster.get('statuses'), deleted_cluster.get('name'), cluster_id == deleted_cluster.get('id'))
193
+ row.insert(1, profile_name)
194
+ row.insert(2, region_name)
195
+ if all:
196
+ project_name = click.style(cluster.get("project_name"), bold=True)
197
+ row.insert(0, project_name)
193
198
 
194
199
  new_table = format_changed_row(table, row)
195
200
  click.echo(new_table)
196
201
 
197
- del initial_clusters[name]
202
+ del initial_clusters[id]
198
203
 
199
204
  for cluster in data:
200
- row, current_status, name = format_row(cluster)
205
+ row, current_status, name = format_row(cluster.get('statuses'), cluster.get('name'), cluster_id == cluster.get('id'))
206
+ row.insert(1, profile_name)
207
+ row.insert(2, region_name)
208
+ if all:
209
+ project_name = click.style(cluster.get("project_name"), bold=True)
210
+ row.insert(0, project_name)
211
+
212
+ cl_id = cluster.get('id')
201
213
 
202
- if name not in initial_clusters:
214
+ if cl_id not in initial_clusters:
203
215
  new_table = format_changed_row(table, row)
204
216
  click.echo(new_table)
205
- initial_clusters[name] = cluster
217
+ initial_clusters[cl_id] = cluster
206
218
  continue
207
219
 
208
- stored_cluster = initial_clusters[name]
220
+ stored_cluster = initial_clusters[cl_id]
209
221
  cluster_status = stored_cluster.get('statuses').get('status')
210
222
  if cluster_status != current_status:
211
223
  new_table = format_changed_row(table, row)
212
224
  click.echo(new_table)
213
- initial_clusters[name] = cluster
225
+ initial_clusters[cl_id] = cluster
214
226
  continue
215
227
 
216
228
  if total_sleep % 10 == 0 and is_interesting_status(current_status):
217
229
  new_table = format_changed_row(table, row)
218
230
  click.echo(new_table)
219
- initial_clusters[name] = cluster
231
+ initial_clusters[cl_id] = cluster
220
232
 
221
233
  except KeyboardInterrupt:
222
234
  click.echo("\nWatch stopped.")
@@ -245,6 +257,9 @@ def cluster_get_command(ctx, project_name, cluster_name, output, profile):
245
257
  def prepare_cluster_template(cluster_config):
246
258
  cluster_template = get_template("cluster")
247
259
 
260
+ if cluster_template.get("project_id") == "":
261
+ cluster_template.pop("project_id", None)
262
+
248
263
  admin_whitelist = cluster_config.get("admin_whitelist") or []
249
264
  if isinstance(admin_whitelist, str):
250
265
  admin_whitelist = [admin_whitelist]
@@ -473,7 +488,23 @@ def cluster_update_command(ctx, project_name, cluster_name, description, admin,
473
488
  if len(admin) == 0:
474
489
  cluster_config['admin_whitelist'] = []
475
490
  else:
476
- cluster_config['admin_whitelist'] = admin.split(',')
491
+ admin_list = admin.split(',')
492
+ resolved_ips = []
493
+ for ip in admin_list:
494
+ ip = ip.strip()
495
+ if ip == "my-ip":
496
+ try:
497
+ data = do_request("GET", "myip")
498
+ if isinstance(data, dict) and "x_real_ip" in data:
499
+ resolved_ip = data["x_real_ip"]
500
+ resolved_ips.append(f"{resolved_ip}/32")
501
+ else:
502
+ raise click.ClickException(f"Unexpected response format from 'myip': {data}")
503
+ except Exception as e:
504
+ raise click.ClickException(f"Unable to resolve 'my-ip': {e}")
505
+ else:
506
+ resolved_ips.append(ip)
507
+ cluster_config['admin_whitelist'] = resolved_ips
477
508
 
478
509
  if version is not None:
479
510
  cluster_config['version'] = version
@@ -18,7 +18,7 @@ def profile():
18
18
  @click.option('--password', required=False, help="Password", type=click.STRING)
19
19
  @click.option('--region', required=True, help="Region name", type=click.Choice(['eu-west-2', 'cloudgouv-eu-west-1']))
20
20
  @click.option('--endpoint', required=False, help="API endpoint", type=click.STRING)
21
- @click.option('--jwt', help="Enable JWT, by default is false")
21
+ @click.option('--jwt', help="Enable JWT, by default is false", type=click.BOOL)
22
22
  def add_profile(profile_name, access_key, secret_key, username, password, region, endpoint, jwt):
23
23
  """Add a new profile with AK/SK or username/password authentication."""
24
24
  if not profile_name:
@@ -5,8 +5,12 @@ import dateutil.parser
5
5
  import human_readable
6
6
  import prettytable
7
7
  import os
8
+ from prettytable import TableStyle
8
9
 
9
- from .utils import do_request, print_output, print_table, find_project_id_by_name, get_project_id, set_project_id, detect_and_parse_input, transform_tuple, ctx_update, set_cluster_id, get_template, get_project_name, format_changed_row, is_interesting_status, login_profile, profile_completer, project_completer
10
+ from .utils import do_request, print_output, print_table, find_project_id_by_name, get_project_id, set_project_id, \
11
+ detect_and_parse_input, transform_tuple, ctx_update, set_cluster_id, get_template, get_project_name, \
12
+ format_changed_row, is_interesting_status, login_profile, profile_completer, project_completer, \
13
+ format_row
10
14
 
11
15
  # DEIFNE THE PROJECT COMMAND GROUP
12
16
  @click.group(help="Project related commands.")
@@ -73,6 +77,7 @@ def project_list(ctx, project_name, deleted, plain, msword, uuid, watch, output,
73
77
  login_profile(profile)
74
78
 
75
79
  profile_name = os.getenv('OKS_PROFILE')
80
+ region_name = os.getenv('OKS_REGION')
76
81
  project_id = get_project_id()
77
82
 
78
83
  params = {}
@@ -98,51 +103,24 @@ def project_list(ctx, project_name, deleted, plain, msword, uuid, watch, output,
98
103
  table._min_width = {"CREATED": 13, "UPDATED": 13, "STATUS": 10}
99
104
 
100
105
  if plain or watch:
101
- table.set_style(prettytable.PLAIN_COLUMNS)
106
+ table.set_style(TableStyle.PLAIN_COLUMNS)
102
107
 
103
108
  if msword:
104
109
  table.set_style(prettytable.MSWORD_FRIENDLY)
105
110
 
106
- def format_row(project):
107
- status = project.get('status')
108
- is_default = True if project.get('id') == project_id else False
109
-
110
- if status == 'ready':
111
- msg = click.style(status, fg='green')
112
- elif status == 'failed' or status == 'deleted':
113
- msg = click.style(status, fg='red')
114
- elif status == 'deploying':
115
- msg = click.style(status, fg='yellow')
116
- else:
117
- msg = status
118
-
119
- name = click.style(project['name'], bold=True)
120
- if is_default:
121
- default = "*"
122
- else:
123
- default = ""
124
-
125
- region_name = project.get('region')
126
- created_at = dateutil.parser.parse(project['created_at'])
127
- updated_at = dateutil.parser.parse(project['updated_at'])
128
- now = datetime.datetime.now(tz=created_at.tzinfo)
129
-
130
- row = [name, profile_name, region_name, human_readable.date_time(now - created_at), human_readable.date_time(now - updated_at), msg, default]
131
- if uuid:
132
- row.append(project['id'])
133
-
134
- return row, status, project['name']
135
-
136
111
  initial_projects = {}
137
112
 
138
113
  for project in data:
139
- row, _, name = format_row(project)
114
+ row, _, name = format_row(project, project.get('name'), project_id == project.get('id'))
115
+ row.insert(1, profile_name)
116
+ row.insert(2, region_name)
117
+ if uuid:
118
+ row.append(project.get('id'))
140
119
  table.add_row(row)
141
120
  initial_projects[name] = project
142
121
 
143
122
  click.echo(table)
144
123
 
145
-
146
124
  if watch:
147
125
  total_sleep = 0
148
126
  try:
@@ -162,15 +140,19 @@ def project_list(ctx, project_name, deleted, plain, msword, uuid, watch, output,
162
140
  deleted_project = project.copy()
163
141
  deleted_project['status'] = 'deleted'
164
142
 
165
- row, current_status, _ = format_row(deleted_project)
166
-
143
+ row, current_status, _ = format_row(deleted_project, deleted_project.get('name'), project_id == deleted_project.get('id'))
144
+ row.insert(1, profile_name)
145
+ row.insert(2, region_name)
167
146
  new_table = format_changed_row(table, row)
147
+
168
148
  click.echo(new_table)
169
149
 
170
150
  del initial_projects[name]
171
151
 
172
152
  for project in data:
173
- row, current_status, name = format_row(project)
153
+ row, current_status, name = format_row(project, project.get('name'), project_id == project.get('id'))
154
+ row.insert(1, profile_name)
155
+ row.insert(2, region_name)
174
156
 
175
157
  if name not in initial_projects:
176
158
  new_table = format_changed_row(table, row)
@@ -409,4 +391,22 @@ def project_get_public_ips(ctx, project_name, output, profile):
409
391
  project_id = find_project_id_by_name(project_name)
410
392
 
411
393
  data = do_request("GET", f'projects/{project_id}/public_ips')
394
+ print_output(data, output)
395
+
396
+
397
+
398
+ # GET NETS BY PROJECT NAME
399
+ @project.command('nets', help="Get project nets")
400
+ @click.option('--project-name', '-p', help="Name of the project", shell_complete=project_completer)
401
+ @click.option('--output', '-o', type=click.Choice(["json", "yaml"]), help="Specify output format, by default is json")
402
+ @click.option('--profile',help="Configuration profile to use")
403
+ @click.pass_context
404
+ def project_get_public_ips(ctx, project_name, output, profile):
405
+ """Retrieve the list of Nets associated with the specified project."""
406
+ project_name, _, profile = ctx_update(ctx, project_name, None, profile)
407
+ login_profile(profile)
408
+
409
+ project_id = find_project_id_by_name(project_name)
410
+
411
+ data = do_request("GET", f'projects/{project_id}/nets')
412
412
  print_output(data, output)
@@ -14,7 +14,8 @@ from datetime import datetime
14
14
  import OpenSSL
15
15
  import shutil
16
16
  import prettytable
17
-
17
+ import dateutil.parser
18
+ import human_readable
18
19
  import base64
19
20
  import sys
20
21
 
@@ -35,6 +36,9 @@ class JSONClickException(click.ClickException):
35
36
  def show(self, file=None):
36
37
  click.echo(self.message, file=file)
37
38
 
39
+ class _LiteralStr(str): pass
40
+
41
+ yaml.add_representer(_LiteralStr, lambda dumper, data: dumper.represent_scalar('tag:yaml.org,2002:str', data, style='|'))
38
42
 
39
43
  def find_response_object(data):
40
44
  """Extract the main object from the API response payload."""
@@ -71,6 +75,10 @@ def find_response_object(data):
71
75
  return response["Snapshots"]
72
76
  elif key == "PublicIps":
73
77
  return response["PublicIps"]
78
+ elif key == "IP":
79
+ return response["IP"]
80
+ elif key == "Nets":
81
+ return response["Nets"]
74
82
 
75
83
  raise click.ClickException("The API response format is incorrect.")
76
84
 
@@ -102,10 +110,13 @@ def do_request(method, path, *args, **kwargs):
102
110
  obj = find_response_object(data)
103
111
  return obj
104
112
  except requests.exceptions.HTTPError as err:
105
- otp_response = handle_otp_error(err, lambda: do_request(method, path, *args, **kwargs))
113
+ otp_response = handle_otp_error(err, method, path, args, kwargs)
106
114
  if otp_response is not None:
107
115
  return otp_response
108
116
 
117
+ if os.environ.get("OKS_OTP_CODE") is not None:
118
+ raise JSONClickException(err.response.text)
119
+
109
120
  jwt_response = handle_jwt_error(err, method, path, args, kwargs)
110
121
  if jwt_response is not None:
111
122
  return jwt_response
@@ -158,12 +169,22 @@ def build_headers():
158
169
 
159
170
  return headers
160
171
 
172
+ def _convert_multiline(obj):
173
+ """Recursively convert multiline strings"""
174
+ if isinstance(obj, dict):
175
+ return {k: _convert_multiline(v) for k, v in obj.items()}
176
+ if isinstance(obj, list):
177
+ return [_convert_multiline(i) for i in obj]
178
+ if isinstance(obj, str) and '\n' in obj:
179
+ return _LiteralStr(obj)
180
+ return obj
181
+
161
182
  def print_output(data, output_fromat):
162
183
  """Print data in the specified format: JSON, YAML, or silent."""
163
184
  output_data = json.dumps(data, indent=4)
164
185
 
165
186
  if output_fromat == "yaml":
166
- output_data = yaml.dump(data, sort_keys=False)
187
+ output_data = yaml.dump(_convert_multiline(data), sort_keys=False)
167
188
 
168
189
  elif output_fromat == "silent":
169
190
  return
@@ -194,7 +215,7 @@ def print_table(data, table_fields, align="l", style=None):
194
215
  table.add_row([d[v] if v in d else "" for v in values])
195
216
  click.echo(table)
196
217
 
197
- def handle_otp_error(err, callback):
218
+ def handle_otp_error(err, method, path, args, kwargs):
198
219
  """Handle OTP authentication error by prompting the user and retrying the request."""
199
220
  try:
200
221
  response_body = json.loads(err.response.text)
@@ -202,7 +223,11 @@ def handle_otp_error(err, callback):
202
223
  otp_code = click.prompt('Enter your OTP code', type=int)
203
224
  os.environ["OKS_OTP_CODE"] = str(otp_code)
204
225
 
205
- return callback()
226
+ logging.info("Retrying request with user-provided OTP...")
227
+ return do_request(method, path, *args, **kwargs)
228
+
229
+ except JSONClickException:
230
+ raise
206
231
  except Exception:
207
232
  return None
208
233
 
@@ -241,8 +266,14 @@ def find_cluster_id_by_name(project_id, cluster_name):
241
266
  """Retrieve the cluster ID by name within a given project, or use the default cluster if none is provided."""
242
267
  if not cluster_name:
243
268
  cluster_id = get_cluster_id()
269
+ errors = {"Error": "--cluster-name must be specified, or a default cluster must be set"}
270
+
244
271
  if not cluster_id:
245
- raise click.BadParameter("--cluster-name must be specified, or a default cluster must be set")
272
+ raise JSONClickException(json.dumps(errors))
273
+
274
+ cluster = do_request("GET", f'clusters/{cluster_id}')
275
+ if cluster['project_id'] != project_id:
276
+ raise JSONClickException(json.dumps(errors))
246
277
  else:
247
278
  data = do_request("GET", 'clusters', params={"project_id": project_id, "name": cluster_name})
248
279
  if len(data) != 1:
@@ -365,6 +396,34 @@ def login_profile(name):
365
396
 
366
397
  return {}
367
398
 
399
+ def format_row(data: dict, name: str, is_default: bool):
400
+ """Parse status and dates from a cluster of project object and returns elements"""
401
+
402
+ if not data.get('status'):
403
+ raise click.ClickException(f"Can't find 'status' in project/cluster data")
404
+
405
+ status = data.get('status')
406
+ if status == 'ready':
407
+ msg = click.style(status, fg='green')
408
+ elif status in ['failed', 'deleted']:
409
+ msg = click.style(status, fg='red')
410
+ elif status in ['deploying', 'deleting', 'pending']:
411
+ msg = click.style(status, fg='yellow')
412
+ else:
413
+ msg = status
414
+
415
+ if is_default:
416
+ default = "*"
417
+ else:
418
+ default = ""
419
+
420
+ created_at = dateutil.parser.parse(data['created_at'])
421
+ updated_at = dateutil.parser.parse(data['updated_at'])
422
+ now = datetime.now(tz=created_at.tzinfo)
423
+
424
+ row = [click.style(name, bold=True), human_readable.date_time(now - created_at), human_readable.date_time(now - updated_at), msg, default]
425
+ return row, status, name
426
+
368
427
  def profile_list():
369
428
  """Return all profiles as a dict, or empty if none."""
370
429
  _, PROFILE_FILE = get_config_path()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oks-cli
3
- Version: 1.16
3
+ Version: 1.18
4
4
  Author: Outscale SAS
5
5
  Author-email: opensource@outscale.com
6
6
  License: BSD
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="oks-cli",
5
- version="1.16",
5
+ version="1.18",
6
6
  packages=['oks_cli'],
7
7
  author="Outscale SAS",
8
8
  author_email="opensource@outscale.com",
@@ -66,6 +66,37 @@ def test_cluster_list_all_args(mock_request, add_default_profile):
66
66
  assert result.exit_code == 0
67
67
  assert "test-cluster" in result.output
68
68
 
69
+ # Test the "cluster list" command with --all(-A) flag
70
+ @patch("oks_cli.utils.requests.request")
71
+ def test_cluster_list_all(mock_request, add_default_profile):
72
+ mock_request.side_effect = [
73
+ MagicMock(status_code=200, headers={}, json=lambda: {"ResponseContext": {}, "Projects": [{"id": "12345", "name": "test-project"}]}),
74
+ MagicMock(status_code=200, headers={}, json=lambda: {
75
+ "ResponseContext": {},
76
+ "Clusters": [{
77
+ "id": "67890",
78
+ "project_id": "12345",
79
+ "name": "test-cluster",
80
+ "statuses": {
81
+ "status": "ready",
82
+ "created_at": "2019-08-24T14:15:22Z",
83
+ "updated_at": "2019-08-24T14:15:22Z"
84
+ }
85
+ }]
86
+ }),
87
+ ]
88
+
89
+ runner = CliRunner()
90
+ result = runner.invoke(cli, [
91
+ "cluster", "list",
92
+ "-A",
93
+ "--profile", "default"
94
+ ])
95
+
96
+ assert result.exit_code == 0
97
+ assert "test-project" in result.output
98
+ assert "test-cluster" in result.output
99
+
69
100
  # Test the "cluster get" command: verifies fetching details of a specific cluster
70
101
  @patch("oks_cli.utils.requests.request")
71
102
  def test_cluster_get_command(mock_request, add_default_profile):
@@ -438,3 +469,92 @@ def test_cluster_create_by_one_click_command(mock_request, mock_sleep, mock_for
438
469
  result = runner.invoke(cli, ["cluster", "create", "-p", "default", "-c", "test"], input=input_data)
439
470
  assert result.exit_code == 0
440
471
 
472
+ @patch("oks_cli.utils.requests.request")
473
+ @patch("time.sleep")
474
+ def test_cluster_list_watch_command(mock_sleep, mock_request, add_default_profile):
475
+ """Test the cluster list command with --watch option"""
476
+
477
+ # Simulate successive API responses
478
+ initial_response = MagicMock(
479
+ status_code=200,
480
+ headers={},
481
+ json=lambda: {
482
+ "ResponseContext": {},
483
+ "Projects": [{"id": "12345"}]
484
+ }
485
+ )
486
+
487
+ # First query for clusters (initial state)
488
+ first_cluster_response = MagicMock(
489
+ status_code=200,
490
+ headers={},
491
+ json=lambda: {
492
+ "ResponseContext": {},
493
+ "Clusters": [{
494
+ "id": "12345",
495
+ "name": "test-cluster",
496
+ "statuses": {
497
+ "status": "deploying",
498
+ "created_at": "2023-01-01T00:00:00Z",
499
+ "updated_at": "2023-01-01T00:00:00Z"
500
+ },
501
+ "version": "1.0",
502
+ "control_planes": 3
503
+ }]
504
+ }
505
+ )
506
+
507
+ # Second query for clusters (status change)
508
+ second_cluster_response = MagicMock(
509
+ status_code=200,
510
+ headers={},
511
+ json=lambda: {
512
+ "ResponseContext": {},
513
+ "Clusters": [{
514
+ "id": "12345",
515
+ "name": "test-cluster",
516
+ "statuses": {
517
+ "status": "ready",
518
+ "created_at": "2023-01-01T00:00:00Z",
519
+ "updated_at": "2023-01-01T00:01:00Z"
520
+ },
521
+ "version": "1.0",
522
+ "control_planes": 3
523
+ }]
524
+ }
525
+ )
526
+
527
+ # Mock call configuration
528
+ mock_request.side_effect = [
529
+ initial_response,
530
+ first_cluster_response,
531
+ second_cluster_response,
532
+ second_cluster_response,
533
+ ]
534
+
535
+ # Simulate KeyboardInterrupt after a few iterations
536
+ def side_effect_sleep(duration):
537
+ if mock_sleep.call_count >= 3:
538
+ raise KeyboardInterrupt()
539
+ return None
540
+
541
+ mock_sleep.side_effect = side_effect_sleep
542
+
543
+ runner = CliRunner()
544
+
545
+ result = runner.invoke(cli, [
546
+ "cluster", "list",
547
+ "-p", "test",
548
+ "-c", "test-cluster",
549
+ "--watch"
550
+ ])
551
+
552
+ # Checks
553
+ assert result.exit_code == 0
554
+ assert "test-cluster" in result.output
555
+ assert "Watch stopped." in result.output
556
+
557
+ assert mock_sleep.called
558
+
559
+ assert mock_request.call_count >= 3
560
+
@@ -1,6 +1,6 @@
1
1
  from click.testing import CliRunner
2
2
  from oks_cli.main import cli
3
-
3
+ from unittest.mock import patch
4
4
 
5
5
  def test_profile_list_command():
6
6
  runner = CliRunner()
@@ -38,6 +38,32 @@ def test_profile_add_command():
38
38
  assert result.exit_code == 0
39
39
  assert "Profile default has been successfully added" in result.output
40
40
 
41
+ def test_profile_add_jwt_boolean():
42
+ runner = CliRunner()
43
+
44
+ with patch("oks_cli.profile.set_profile") as mock_set_profile:
45
+ result = runner.invoke(
46
+ cli,
47
+ [
48
+ "profile", "add",
49
+ "--region", "eu-west-2",
50
+ "--access-key", "AK",
51
+ "--secret-key", "SK",
52
+ "--jwt", "true"
53
+ ],
54
+ input="y\n"
55
+ )
56
+
57
+ assert result.exit_code == 0
58
+
59
+ mock_set_profile.assert_called_once()
60
+ profile_name, obj = mock_set_profile.call_args[0]
61
+
62
+ assert profile_name == "default"
63
+
64
+ assert isinstance(obj["jwt"], bool)
65
+ assert obj["jwt"] is True
66
+
41
67
  def test_profile_update_command(add_default_profile):
42
68
  runner = CliRunner()
43
69
  result = runner.invoke(cli, ["profile", "update", "--profile-name", "default", "--region", "cloudgouv-eu-west-1"])
@@ -473,4 +473,186 @@ def test_project_publicips_yaml(mock_request, add_default_profile):
473
473
  data = yaml.safe_load(result.output)
474
474
  assert isinstance(data, list)
475
475
  assert data == []
476
- # END PROJECT PUBLICIPS COMMAND
476
+ # END PROJECT PUBLICIPS COMMAND
477
+
478
+
479
+ # START PROJECT NETS COMMAND
480
+ # Test the "project nets" command: verifies fetching project nets
481
+ nets = [{
482
+ "DhcpOptionsSetId": "dopt-12345678",
483
+ "IpRange": "10.50.0.0/16",
484
+ "NetId": "vpc-12345678",
485
+ "State": "available",
486
+ "Tags": [
487
+ {
488
+ "Key": "Name",
489
+ "Value": "default"
490
+ }
491
+ ]
492
+ }]
493
+
494
+ @patch("oks_cli.utils.requests.request")
495
+ def test_project_nets_command(mock_request, add_default_profile):
496
+ mock_request.side_effect = [
497
+ MagicMock(status_code=200, headers = {}, json=lambda: {"ResponseContext": {}, "Projects": [{"id": "12345"}]}),
498
+ MagicMock(status_code=200, headers = {}, json=lambda: {"ResponseContext": {}, "Nets": nets })
499
+ ]
500
+
501
+ runner = CliRunner()
502
+ result = runner.invoke(cli, ["project", "nets", "-p", "test"])
503
+ assert result.exit_code == 0
504
+
505
+ data = json.loads(result.output)
506
+ assert isinstance(data, list)
507
+ assert data == nets
508
+
509
+ @patch("oks_cli.utils.requests.request")
510
+ def test_project_nets_json(mock_request, add_default_profile):
511
+ mock_request.side_effect = [
512
+ MagicMock(status_code=200, headers={}, json=lambda: {"ResponseContext": {}, "Projects": [{"id": "12345"}]}),
513
+ MagicMock(status_code=200, headers = {}, json=lambda: {"ResponseContext": {}, "Nets": nets })
514
+ ]
515
+
516
+ runner = CliRunner()
517
+ result = runner.invoke(cli, [
518
+ "project", "nets",
519
+ "-p", "test-project",
520
+ "-o", "json",
521
+ "--profile", "default"
522
+ ])
523
+
524
+ assert result.exit_code == 0
525
+
526
+ data = json.loads(result.output)
527
+ assert isinstance(data, list)
528
+ assert data == nets
529
+
530
+ @patch("oks_cli.utils.requests.request")
531
+ def test_project_nets_yaml(mock_request, add_default_profile):
532
+ mock_request.side_effect = [
533
+ MagicMock(status_code=200, headers={}, json=lambda: {"ResponseContext": {}, "Projects": [{"id": "12345"}]}),
534
+ MagicMock(status_code=200, headers = {}, json=lambda: {"ResponseContext": {}, "Nets": nets })
535
+ ]
536
+
537
+ runner = CliRunner()
538
+ result = runner.invoke(cli, [
539
+ "project", "nets",
540
+ "-p", "test-project",
541
+ "-o", "yaml",
542
+ "--profile", "default"
543
+ ])
544
+
545
+ assert result.exit_code == 0
546
+
547
+ data = yaml.safe_load(result.output)
548
+ assert isinstance(data, list)
549
+ assert data == nets
550
+ # END PROJECT NETS COMMAND
551
+
552
+
553
+ # Test the "project list" command with --watch option
554
+ @patch("oks_cli.utils.requests.request")
555
+ @patch("time.sleep")
556
+ def test_project_list_watch_command(mock_sleep, mock_request, add_default_profile):
557
+ """Test the project list command with --watch option"""
558
+
559
+ # First query for projects (initial state)
560
+ first_project_response = MagicMock(
561
+ status_code=200,
562
+ headers={},
563
+ json=lambda: {
564
+ "ResponseContext": {},
565
+ "Projects": [{
566
+ "id": "12345",
567
+ "name": "test-project",
568
+ "created_at": "2023-01-01T00:00:00Z",
569
+ "updated_at": "2023-01-01T00:00:00Z",
570
+ "status": "active"
571
+ }]
572
+ }
573
+ )
574
+
575
+ # Second query for projects (new project appears)
576
+ second_project_response = MagicMock(
577
+ status_code=200,
578
+ headers={},
579
+ json=lambda: {
580
+ "ResponseContext": {},
581
+ "Projects": [
582
+ {
583
+ "id": "12345",
584
+ "name": "test-project",
585
+ "created_at": "2023-01-01T00:00:00Z",
586
+ "updated_at": "2023-01-01T00:00:00Z",
587
+ "status": "active"
588
+ },
589
+ {
590
+ "id": "67890",
591
+ "name": "new-project",
592
+ "created_at": "2023-01-01T00:01:00Z",
593
+ "updated_at": "2023-01-01T00:01:00Z",
594
+ "status": "creating"
595
+ }
596
+ ]
597
+ }
598
+ )
599
+
600
+ # Third query for projects (status change)
601
+ third_project_response = MagicMock(
602
+ status_code=200,
603
+ headers={},
604
+ json=lambda: {
605
+ "ResponseContext": {},
606
+ "Projects": [
607
+ {
608
+ "id": "12345",
609
+ "name": "test-project",
610
+ "created_at": "2023-01-01T00:00:00Z",
611
+ "updated_at": "2023-01-01T00:00:00Z",
612
+ "status": "active"
613
+ },
614
+ {
615
+ "id": "67890",
616
+ "name": "new-project",
617
+ "created_at": "2023-01-01T00:01:00Z",
618
+ "updated_at": "2023-01-01T00:02:00Z",
619
+ "status": "active"
620
+ }
621
+ ]
622
+ }
623
+ )
624
+
625
+ # Mock call configuration
626
+ mock_request.side_effect = [
627
+ first_project_response,
628
+ second_project_response,
629
+ third_project_response,
630
+ third_project_response,
631
+ ]
632
+
633
+ # Simulate KeyboardInterrupt after a few iterations
634
+ def side_effect_sleep(duration):
635
+ if mock_sleep.call_count >= 3:
636
+ raise KeyboardInterrupt()
637
+ return None
638
+
639
+ mock_sleep.side_effect = side_effect_sleep
640
+
641
+ runner = CliRunner()
642
+
643
+ # Launch command with --watch
644
+ result = runner.invoke(cli, [
645
+ "project", "list",
646
+ "--watch"
647
+ ])
648
+
649
+ # Checks
650
+ assert result.exit_code == 0
651
+ assert "test-project" in result.output
652
+ assert "Watch stopped." in result.output
653
+
654
+ # Verify that sleep was called (indicates watch is working)
655
+ assert mock_sleep.called
656
+
657
+ # Verify multiple API calls were made (at least 3 for watching)
658
+ assert mock_request.call_count >= 3
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes