oks-cli 1.19__tar.gz → 1.21__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 (30) hide show
  1. {oks_cli-1.19 → oks_cli-1.21}/PKG-INFO +2 -2
  2. {oks_cli-1.19 → oks_cli-1.21}/oks_cli/cache.py +1 -1
  3. {oks_cli-1.19 → oks_cli-1.21}/oks_cli/cluster.py +67 -45
  4. {oks_cli-1.19 → oks_cli-1.21}/oks_cli/main.py +3 -1
  5. {oks_cli-1.19 → oks_cli-1.21}/oks_cli/profile.py +1 -1
  6. {oks_cli-1.19 → oks_cli-1.21}/oks_cli/project.py +17 -3
  7. oks_cli-1.21/oks_cli/user.py +169 -0
  8. {oks_cli-1.19 → oks_cli-1.21}/oks_cli/utils.py +29 -26
  9. {oks_cli-1.19 → oks_cli-1.21}/oks_cli.egg-info/PKG-INFO +2 -2
  10. {oks_cli-1.19 → oks_cli-1.21}/oks_cli.egg-info/SOURCES.txt +3 -1
  11. {oks_cli-1.19 → oks_cli-1.21}/oks_cli.egg-info/requires.txt +1 -1
  12. {oks_cli-1.19 → oks_cli-1.21}/setup.py +3 -3
  13. {oks_cli-1.19 → oks_cli-1.21}/tests/test_shell_completion.py +2 -2
  14. oks_cli-1.21/tests/test_user.py +56 -0
  15. {oks_cli-1.19 → oks_cli-1.21}/LICENSE +0 -0
  16. {oks_cli-1.19 → oks_cli-1.21}/README.md +0 -0
  17. {oks_cli-1.19 → oks_cli-1.21}/oks_cli/__init__.py +0 -0
  18. {oks_cli-1.19 → oks_cli-1.21}/oks_cli/netpeering.py +0 -0
  19. {oks_cli-1.19 → oks_cli-1.21}/oks_cli/quotas.py +0 -0
  20. {oks_cli-1.19 → oks_cli-1.21}/oks_cli.egg-info/dependency_links.txt +0 -0
  21. {oks_cli-1.19 → oks_cli-1.21}/oks_cli.egg-info/entry_points.txt +0 -0
  22. {oks_cli-1.19 → oks_cli-1.21}/oks_cli.egg-info/top_level.txt +0 -0
  23. {oks_cli-1.19 → oks_cli-1.21}/setup.cfg +0 -0
  24. {oks_cli-1.19 → oks_cli-1.21}/tests/test_cache.py +0 -0
  25. {oks_cli-1.19 → oks_cli-1.21}/tests/test_cluster.py +0 -0
  26. {oks_cli-1.19 → oks_cli-1.21}/tests/test_netpeering.py +0 -0
  27. {oks_cli-1.19 → oks_cli-1.21}/tests/test_nodepool.py +0 -0
  28. {oks_cli-1.19 → oks_cli-1.21}/tests/test_profile.py +0 -0
  29. {oks_cli-1.19 → oks_cli-1.21}/tests/test_project.py +0 -0
  30. {oks_cli-1.19 → oks_cli-1.21}/tests/test_quota.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oks-cli
3
- Version: 1.19
3
+ Version: 1.21
4
4
  Author: Outscale SAS
5
5
  Author-email: opensource@outscale.com
6
6
  License: BSD
@@ -17,7 +17,7 @@ Description-Content-Type: text/markdown
17
17
  License-File: LICENSE
18
18
  Requires-Dist: certifi>=2024.8.30
19
19
  Requires-Dist: charset-normalizer>=3.3.2
20
- Requires-Dist: click<8.3.0,>=8.1.7
20
+ Requires-Dist: click<8.3.0,>=8.2.0
21
21
  Requires-Dist: colorama>=0.4.6
22
22
  Requires-Dist: idna>=3.10
23
23
  Requires-Dist: pyyaml>=6.0.2
@@ -38,7 +38,7 @@ def list_kubeconfigs(ctx, project_name, cluster_name, plain, msword, profile):
38
38
 
39
39
  result = get_all_cache(project_id, cluster_id, "kubeconfig")
40
40
 
41
- data = list()
41
+ data = []
42
42
  fields = [["user", "user"],["group", "group"], ["expiration date", "expires_at"]]
43
43
 
44
44
  for element in result:
@@ -74,6 +74,13 @@ def cluster_logout(ctx, profile):
74
74
  """Clear the current default cluster selection."""
75
75
  _, _, profile = ctx_update(ctx, None, None, profile)
76
76
  login_profile(profile)
77
+
78
+ current_cluster = get_cluster_id()
79
+
80
+ if not current_cluster:
81
+ click.echo("You are not connected to any cluster.")
82
+ return
83
+
77
84
  set_cluster_id("")
78
85
  click.echo("Logged out from the current cluster")
79
86
 
@@ -114,20 +121,20 @@ def cluster_list(ctx, project_name, cluster_name, deleted, plain, msword, watch,
114
121
  if all:
115
122
  field_names.insert(0, "PROJECT")
116
123
 
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)
125
-
126
124
  if output == "wide":
127
125
  field_names.insert(0, "ID")
128
126
  field_names.append("VERSION")
129
127
  field_names.append("CONTROL PLANE")
130
128
  elif output:
129
+ if all:
130
+ projects = {p["id"]: p for p in do_request("GET", "projects")}
131
+ data = do_request("GET", "clusters/all", params=params)
132
+ for cluster in data:
133
+ project = projects.get(cluster.get("project_id"))
134
+ cluster["project_name"] = project.get("name")
135
+ else:
136
+ data = do_request("GET", "clusters", params=params)
137
+
131
138
  print_output(data, output)
132
139
  return
133
140
 
@@ -142,10 +149,14 @@ def cluster_list(ctx, project_name, cluster_name, deleted, plain, msword, watch,
142
149
  if msword:
143
150
  table.set_style(TableStyle.MSWORD_FRIENDLY)
144
151
 
145
- initial_clusters = {}
146
152
 
147
- for cluster in data:
148
- row, _, name = format_row(cluster.get('statuses'), cluster.get('name'), cluster_id == cluster.get('id'))
153
+ def build_row(cluster):
154
+ row, current_status, _ = format_row(
155
+ cluster.get('statuses'),
156
+ cluster.get('name'),
157
+ cluster_id == cluster.get('id')
158
+ )
159
+
149
160
  row.insert(1, profile_name)
150
161
  row.insert(2, region_name)
151
162
  if all:
@@ -156,6 +167,21 @@ def cluster_list(ctx, project_name, cluster_name, deleted, plain, msword, watch,
156
167
  row.append(cluster.get('version'))
157
168
  row.append(cluster.get('control_planes'))
158
169
 
170
+ return row, current_status
171
+
172
+ if all:
173
+ projects = {p["id"]: p for p in do_request("GET", "projects")}
174
+ data = do_request("GET", "clusters/all", params=params)
175
+ for cluster in data:
176
+ project = projects.get(cluster.get("project_id"))
177
+ cluster["project_name"] = project.get("name")
178
+ else:
179
+ data = do_request("GET", "clusters", params=params)
180
+
181
+ initial_clusters = {}
182
+
183
+ for cluster in data:
184
+ row, _ = build_row(cluster)
159
185
  table.add_row(row)
160
186
  initial_clusters[cluster.get("id")] = cluster
161
187
 
@@ -170,46 +196,32 @@ def cluster_list(ctx, project_name, cluster_name, deleted, plain, msword, watch,
170
196
 
171
197
  try:
172
198
  if all:
173
- projects = {project["id"]: project for project in do_request("GET", "projects")}
199
+ projects = {p["id"]: p for p in do_request("GET", "projects")}
174
200
  data = do_request("GET", "clusters/all", params=params)
175
-
176
201
  for cluster in data:
177
202
  project = projects.get(cluster.get("project_id"))
178
203
  cluster["project_name"] = project.get("name")
179
204
  else:
180
- data = do_request("GET", 'clusters', params=params)
205
+ data = do_request("GET", "clusters", params=params)
181
206
  except click.ClickException as err:
182
207
  click.echo(f"Error during watch: {err}")
183
208
  continue
184
209
 
185
- current_cluster_ids = {cluster.get('id') for cluster in data}
210
+ current_ids = {c.get('id') for c in data}
186
211
 
187
- for id, cluster in list(initial_clusters.items()):
188
- if id not in current_cluster_ids:
189
- deleted_cluster = cluster.copy()
212
+ for cl_id, stored_cluster in list(initial_clusters.items()):
213
+ if cl_id not in current_ids:
214
+ deleted_cluster = stored_cluster.copy()
190
215
  deleted_cluster['statuses']['status'] = 'deleted'
191
216
 
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)
198
-
217
+ row, _ = build_row(deleted_cluster)
199
218
  new_table = format_changed_row(table, row)
200
219
  click.echo(new_table)
201
-
202
- del initial_clusters[id]
220
+ del initial_clusters[cl_id]
203
221
 
204
222
  for cluster in data:
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
223
  cl_id = cluster.get('id')
224
+ row, current_status = build_row(cluster)
213
225
 
214
226
  if cl_id not in initial_clusters:
215
227
  new_table = format_changed_row(table, row)
@@ -218,8 +230,8 @@ def cluster_list(ctx, project_name, cluster_name, deleted, plain, msword, watch,
218
230
  continue
219
231
 
220
232
  stored_cluster = initial_clusters[cl_id]
221
- cluster_status = stored_cluster.get('statuses').get('status')
222
- if cluster_status != current_status:
233
+ stored_status = stored_cluster.get('statuses').get('status')
234
+ if stored_status != current_status:
223
235
  new_table = format_changed_row(table, row)
224
236
  click.echo(new_table)
225
237
  initial_clusters[cl_id] = cluster
@@ -516,7 +528,7 @@ def cluster_update_command(ctx, project_name, cluster_name, description, admin,
516
528
  if tags is not None:
517
529
  parsed_tags = {}
518
530
 
519
- if not len(tags) == 0:
531
+ if len(tags) != 0:
520
532
  pairs = tags.split(',')
521
533
  for pair in pairs:
522
534
  if '=' not in pair:
@@ -729,19 +741,29 @@ def _run_kubectl(project_id, cluster_id, user, group, args, input=None, capture=
729
741
 
730
742
  if not kubeconfig_raw:
731
743
  click.echo("Cannot get kubeconfig")
732
- raise SystemExit()
744
+ raise SystemExit(1)
733
745
 
734
746
  kubeconfig_path = save_cache(project_id, cluster_id, 'kubeconfig', kubeconfig_raw, user, group)
735
747
 
736
748
  env = dict(os.environ)
737
749
  env['KUBECONFIG'] = str(kubeconfig_path)
738
- cmd = ['kubectl']
739
- cmd += list(args)
750
+
751
+ cmd = ['kubectl'] + list(args)
740
752
  logging.info("running %s", cmd)
741
- if not input:
742
- return subprocess.run(cmd, env=env, capture_output=capture)
743
- else:
744
- return subprocess.run(cmd, input=input, text=True, env=env, capture_output=capture)
753
+
754
+ try:
755
+ if not input:
756
+ return subprocess.run(cmd, env=env, capture_output=capture, check=True)
757
+ else:
758
+ return subprocess.run(cmd, input=input, text=True, env=env, capture_output=capture, check=True)
759
+
760
+ except subprocess.CalledProcessError as e:
761
+ if e.stderr:
762
+ click.echo(e.stderr, err=True)
763
+ elif e.stdout:
764
+ click.echo(e.stdout, err=True)
765
+
766
+ raise SystemExit(e.returncode)
745
767
 
746
768
 
747
769
  @cluster.command('kubectl', help='Fetch the kubeconfig for a cluster and run kubectl against it', context_settings={"ignore_unknown_options": True})
@@ -9,6 +9,7 @@ from .profile import profile
9
9
  from .cache import cache
10
10
  from .quotas import quotas
11
11
  from .netpeering import netpeering
12
+ from .user import user
12
13
 
13
14
  from .utils import ctx_update, install_completions, profile_completer, cluster_completer, project_completer
14
15
 
@@ -47,7 +48,7 @@ def cli(ctx, project_name, cluster_name, profile, verbose):
47
48
  click.echo(ctx.get_help())
48
49
 
49
50
  if not hasattr(ctx, 'obj') or not ctx.obj:
50
- ctx.obj = dict()
51
+ ctx.obj = {}
51
52
 
52
53
  if project_name != None:
53
54
  ctx.obj['project_name'] = project_name
@@ -62,6 +63,7 @@ cli.add_command(profile)
62
63
  cli.add_command(cache)
63
64
  cli.add_command(quotas)
64
65
  cli.add_command(netpeering)
66
+ cli.add_command(user)
65
67
 
66
68
  def recursive_help(cmd, parent=None):
67
69
  """Recursively prints help for all commands and subcommands."""
@@ -122,7 +122,7 @@ def list_profiles(output):
122
122
  return click.echo("There are no profiles")
123
123
 
124
124
  profiles_keys = list(profiles.keys())
125
- lines = list()
125
+ lines = []
126
126
 
127
127
  for key in profiles_keys:
128
128
  if 'endpoint' not in profiles[key]:
@@ -56,6 +56,13 @@ def project_logout(ctx, profile):
56
56
  """Unset the current default project and log out."""
57
57
  _, _, profile = ctx_update(ctx, None, None, profile)
58
58
  login_profile(profile)
59
+
60
+ current_project = get_project_id()
61
+
62
+ if not current_project:
63
+ click.echo("You are not connected to any project.")
64
+ return
65
+
59
66
  set_project_id("")
60
67
  set_cluster_id("")
61
68
  click.echo("Logged out from the current project")
@@ -321,7 +328,7 @@ def project_update_command(ctx, project_name, description, quirk, tags, disable_
321
328
  if tags is not None:
322
329
  parsed_tags = {}
323
330
 
324
- if not len(tags) == 0:
331
+ if len(tags) != 0:
325
332
  pairs = tags.split(',')
326
333
  for pair in pairs:
327
334
  if '=' not in pair:
@@ -404,7 +411,7 @@ def project_get_public_ips(ctx, project_name, output, profile):
404
411
  # GET NETS BY PROJECT NAME
405
412
  @project.command('nets', help="Get project nets")
406
413
  @click.option('--project-name', '-p', help="Name of the project", shell_complete=project_completer)
407
- @click.option('--output', '-o', type=click.Choice(["json", "yaml"]), help="Specify output format, by default is json")
414
+ @click.option('--output', '-o', type=click.Choice(["json", "yaml", "table"]), help="Specify output format, by default is json")
408
415
  @click.option('--profile',help="Configuration profile to use")
409
416
  @click.pass_context
410
417
  def project_get_public_ips(ctx, project_name, output, profile):
@@ -415,4 +422,11 @@ def project_get_public_ips(ctx, project_name, output, profile):
415
422
  project_id = find_project_id_by_name(project_name)
416
423
 
417
424
  data = do_request("GET", f'projects/{project_id}/nets')
418
- print_output(data, output)
425
+ if output == "table":
426
+ print_table(data, [["DHCP options set id", "DhcpOptionsSetId"],
427
+ ["Ip range", "IpRange"],
428
+ ["Net id", "NetId"],
429
+ ["State", "State"],
430
+ ["Tenancy", "Tenancy"]])
431
+ else:
432
+ print_output(data, output)
@@ -0,0 +1,169 @@
1
+ import click
2
+ from datetime import datetime
3
+ import dateutil.parser
4
+ import human_readable
5
+ import prettytable
6
+ import json
7
+
8
+ from nacl.public import PrivateKey, SealedBox
9
+ from nacl.encoding import Base64Encoder
10
+
11
+ from .utils import do_request, print_output, find_project_id_by_name, ctx_update, login_profile, profile_completer, project_completer, JSONClickException
12
+
13
+ # DEIFNE THE USER COMMAND GROUP
14
+ @click.group(help="EIM users related commands.")
15
+ @click.option('--project-name', '-p', required=False, help="Project Name", shell_complete=project_completer)
16
+ @click.option('--profile', help="Configuration profile to use", shell_complete=profile_completer)
17
+ @click.pass_context
18
+ def user(ctx, project_name, profile):
19
+ """Group of commands related to project management."""
20
+ ctx_update(ctx, project_name, None, profile)
21
+
22
+ # LIST USERS
23
+ @user.command('list', help="List EIM users")
24
+ @click.option('--output', '-o', type=click.Choice(["json", "yaml"]), help="Specify output format, by default is json")
25
+ @click.option('--project-name', '-p', required=False, help="Project Name", shell_complete=project_completer)
26
+ @click.option('--profile', help="Configuration profile to use")
27
+ @click.pass_context
28
+ def user_list(ctx, output, project_name, profile):
29
+ """List users"""
30
+ project_name, _, profile = ctx_update(ctx, project_name, None, profile)
31
+ login_profile(profile)
32
+
33
+ project_id = find_project_id_by_name(project_name)
34
+
35
+ data = do_request("GET", f'projects/{project_id}/eim_users')
36
+
37
+ if output:
38
+ print_output(data, output)
39
+ return
40
+
41
+ field_names = ["USER", "ACCESS KEY", "STATE", "CREATED", "EXPIRATION DATE"]
42
+ table = prettytable.PrettyTable()
43
+ table.field_names = field_names
44
+
45
+ for user in data:
46
+ access_keys = user.get("AccessKeys", [])
47
+ access_key = access_keys[0] if access_keys else {}
48
+
49
+
50
+ state = access_key.get("State", "N/A")
51
+ if state == 'ACTIVE':
52
+ state = click.style(state, fg='green')
53
+ elif state == "INACTIVE":
54
+ state = click.style(state, fg='red')
55
+
56
+
57
+ row = [
58
+ user.get("UserName"),
59
+ access_key.get("AccessKeyId", "N/A"),
60
+ state
61
+ ]
62
+
63
+ if "CreationDate" in access_key:
64
+ created_at = dateutil.parser.parse(access_key.get("CreationDate"))
65
+ now = datetime.now(tz=created_at.tzinfo)
66
+ row.append(human_readable.date_time(now - created_at))
67
+ else:
68
+ row.append("N/A")
69
+
70
+ if "ExpirationDate" in access_key:
71
+ exp_at = dateutil.parser.parse(access_key.get("ExpirationDate"))
72
+ now = datetime.now(tz=exp_at.tzinfo)
73
+ row.append(human_readable.date_time(now - exp_at))
74
+ else:
75
+ row.append("N/A")
76
+
77
+ table.add_row(row)
78
+
79
+ click.echo(table)
80
+
81
+
82
+ @user.command('create', help="Create a new EIM user")
83
+ @click.option('--project-name', '-p', help="Name of project", type=click.STRING, shell_complete=project_completer)
84
+ @click.option('--output', '-o', type=click.Choice(["json", "yaml"]), help="Specify output format, by default is json")
85
+ @click.option('--profile', help="Configuration profile to use")
86
+ @click.option('--user', '-u', required=True, help="OKS User type")
87
+ @click.option('--ttl', type=click.STRING, help="TTL in human readable format (5h, 1d, 1w), by default is 1w")
88
+ @click.option('--nacl', is_flag=True, help="Use public key encryption on wire")
89
+ @click.pass_context
90
+ def user_create(ctx, project_name, output, profile, user, ttl, nacl):
91
+ """Create a new EIM user."""
92
+ project_name, _, profile = ctx_update(ctx, project_name, None, profile)
93
+ login_profile(profile)
94
+
95
+ project_id = find_project_id_by_name(project_name)
96
+
97
+ params = {
98
+ "user": user
99
+ }
100
+ if ttl:
101
+ params["ttl"] = ttl
102
+
103
+ if nacl:
104
+ ephemeral = PrivateKey.generate()
105
+ unsealbox = SealedBox(ephemeral)
106
+
107
+ headers = {
108
+ 'x-encrypt-nacl': ephemeral.public_key.encode(Base64Encoder).decode('ascii')
109
+ }
110
+
111
+ raw_data = do_request(
112
+ "POST",
113
+ f'projects/{project_id}/eim_users',
114
+ params=params,
115
+ headers=headers
116
+ )
117
+
118
+ decrypted = unsealbox.decrypt(
119
+ raw_data.get("Data").encode('ascii'),
120
+ encoder=Base64Encoder
121
+ ).decode('ascii')
122
+
123
+ data = json.loads(decrypted)
124
+
125
+ # format decrypted errors the same way as the api errors.
126
+ if "Errors" in data:
127
+ response_context = raw_data.get("ResponseContext")
128
+ errors = []
129
+ for error in data.get("Errors", []):
130
+ error["Code"] = str(data.get("Code"))
131
+ errors.append(error)
132
+
133
+ raise JSONClickException(json.dumps({"Errors": errors,"ResponseContext": response_context}, separators=(",", ":")))
134
+
135
+ else:
136
+ data = do_request(
137
+ "POST",
138
+ f'projects/{project_id}/eim_users',
139
+ params=params
140
+ )
141
+
142
+ print_output(data, output)
143
+
144
+ # DELETE USER
145
+ @user.command('delete', help="Delete an EIM user")
146
+ @click.option('--project-name', '-p', required=False, help="Project name", shell_complete=project_completer)
147
+ @click.option('--user', '-u', required=True, help="User name")
148
+ @click.option('--output', '-o', type=click.Choice(["json", "yaml"]), help="Specify output format")
149
+ @click.option('--dry-run', is_flag=True, help="Run without any action")
150
+ @click.option('--force', is_flag=True, help="Force deletion without confirmation")
151
+ @click.option('--profile', help="Configuration profile to use", shell_complete=profile_completer)
152
+ @click.pass_context
153
+ def user_delete(ctx, project_name, user, output, dry_run, force, profile):
154
+ """CLI command to delete an EIM user."""
155
+
156
+ project_name, _, profile = ctx_update(ctx, project_name, None, profile)
157
+ login_profile(profile)
158
+
159
+ project_id = find_project_id_by_name(project_name)
160
+
161
+ if dry_run:
162
+ message = {"message": f"Dry run: The user '{user}' would be deleted."}
163
+ print_output(message, output)
164
+ return
165
+
166
+ if force or click.confirm(f"Are you sure you want to delete the user '{user}'?", abort=True):
167
+ data = do_request("DELETE", f"projects/{project_id}/eim_users/{user}")
168
+ print_output(data, output)
169
+
@@ -79,6 +79,12 @@ def find_response_object(data):
79
79
  return response["IP"]
80
80
  elif key == "Nets":
81
81
  return response["Nets"]
82
+ elif key == "EimUsers":
83
+ return response["EimUsers"]
84
+ elif key == "EimUser":
85
+ return response["EimUser"]
86
+ elif key == "Data":
87
+ return response
82
88
 
83
89
  raise click.ClickException("The API response format is incorrect.")
84
90
 
@@ -202,8 +208,8 @@ def print_table(data, table_fields, align="l", style=None):
202
208
  table.align = align
203
209
  if style and isinstance(style, prettytable.TableStyle):
204
210
  table.set_style(style)
205
- fields = list()
206
- values = list()
211
+ fields = []
212
+ values = []
207
213
 
208
214
  for d in table_fields:
209
215
  fields.append(d[0])
@@ -417,7 +423,7 @@ def format_row(data: dict, name: str, is_default: bool):
417
423
  """Parse status and dates from a cluster of project object and returns elements"""
418
424
 
419
425
  if not data.get('status'):
420
- raise click.ClickException(f"Can't find 'status' in project/cluster data")
426
+ raise click.ClickException("Can't find 'status' in project/cluster data")
421
427
 
422
428
  status = data.get('status')
423
429
  if status == 'ready':
@@ -805,14 +811,14 @@ def kubeconfig_parse_fields(kubeconfig, cluster_name, user, group):
805
811
  group: user group name of this kubeconfig (if set)
806
812
  """
807
813
  kubeconfig_str = yaml.safe_load(kubeconfig)
808
- kubedata = list()
814
+ kubedata = []
809
815
 
810
816
  # Ensure loaded YAML returnes a valid dict object
811
817
  if not isinstance(kubeconfig_str, dict):
812
818
  return kubedata
813
819
 
814
820
  for context in kubeconfig_str.get('contexts', []):
815
- data = dict()
821
+ data = {}
816
822
  ctx_cluster = context.get('context').get('cluster', None)
817
823
  ctx_user = context.get('context').get('user', None)
818
824
  ctx_name = context.get('name')
@@ -917,7 +923,11 @@ def find_shell_profile(home, shell_type):
917
923
  if os.path.exists(bash_profile) and os.path.exists(bashrc):
918
924
  return None
919
925
 
920
- shell_profile = bash_profile or bashrc
926
+ if os.path.exists(bash_profile):
927
+ shell_profile = bash_profile
928
+
929
+ if os.path.exists(bashrc):
930
+ shell_profile = bashrc
921
931
 
922
932
  elif shell_type == "zsh":
923
933
 
@@ -927,7 +937,11 @@ def find_shell_profile(home, shell_type):
927
937
  if os.path.exists(zshrc) and os.path.exists(profile):
928
938
  return None
929
939
 
930
- shell_profile = zshrc or profile
940
+ if os.path.exists(zshrc):
941
+ shell_profile = zshrc
942
+
943
+ if os.path.exists(profile):
944
+ shell_profile = profile
931
945
 
932
946
  return shell_profile
933
947
 
@@ -942,8 +956,8 @@ def install_completions(shell_type):
942
956
  shell_name = result.stdout.strip()
943
957
 
944
958
  shell_type = os.path.basename(shell_name).lstrip('-')
945
- except subprocess.SubProcessError:
946
- click.echo("Failed to determine shell type, please specify it by --type")
959
+ except (subprocess.SubprocessError, FileNotFoundError):
960
+ raise click.ClickException("Failed to determine shell type, please specify it by --type")
947
961
 
948
962
  completion_dir = os.path.join(home, ".oks_cli", "completions")
949
963
  os.makedirs(completion_dir, exist_ok=True)
@@ -1029,7 +1043,7 @@ def get_template(type):
1029
1043
  def ctx_update(ctx, project_name=None, cluster_name=None, profile=None, overwrite=True):
1030
1044
  """Update context with project, cluster, and profile; optionally prevent overwrites."""
1031
1045
  if not hasattr(ctx, 'obj') or not ctx.obj:
1032
- ctx.obj = dict()
1046
+ ctx.obj = {}
1033
1047
 
1034
1048
  if project_name is not None:
1035
1049
  if ctx.obj.get('project_name') and not overwrite:
@@ -1097,30 +1111,19 @@ def is_interesting_status(status):
1097
1111
  def normalize_key_path(key_path: str) -> str:
1098
1112
  return re.sub(r'\[(\d+)\]', r'.\1', key_path)
1099
1113
 
1100
- def parse_value(value):
1114
+ def parse_value(value: str):
1101
1115
  value = value.strip()
1102
1116
 
1103
- # Inline list: [a,b,c]
1104
- if value.startswith('[') and value.endswith(']'):
1105
- inner = value[1:-1].strip()
1106
- if not inner:
1107
- return []
1108
- return [parse_value(v.strip()) for v in inner.split(',')]
1117
+ try:
1118
+ return json.loads(value)
1119
+ except Exception:
1120
+ pass
1109
1121
 
1110
1122
  if value.lower() == "true":
1111
1123
  return True
1112
1124
  if value.lower() == "false":
1113
1125
  return False
1114
1126
 
1115
- try:
1116
- return int(value)
1117
- except ValueError:
1118
- pass
1119
-
1120
- try:
1121
- return float(value)
1122
- except ValueError:
1123
- pass
1124
1127
 
1125
1128
  if ',' in value:
1126
1129
  return [parse_value(v) for v in value.split(',')]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oks-cli
3
- Version: 1.19
3
+ Version: 1.21
4
4
  Author: Outscale SAS
5
5
  Author-email: opensource@outscale.com
6
6
  License: BSD
@@ -17,7 +17,7 @@ Description-Content-Type: text/markdown
17
17
  License-File: LICENSE
18
18
  Requires-Dist: certifi>=2024.8.30
19
19
  Requires-Dist: charset-normalizer>=3.3.2
20
- Requires-Dist: click<8.3.0,>=8.1.7
20
+ Requires-Dist: click<8.3.0,>=8.2.0
21
21
  Requires-Dist: colorama>=0.4.6
22
22
  Requires-Dist: idna>=3.10
23
23
  Requires-Dist: pyyaml>=6.0.2
@@ -9,6 +9,7 @@ oks_cli/netpeering.py
9
9
  oks_cli/profile.py
10
10
  oks_cli/project.py
11
11
  oks_cli/quotas.py
12
+ oks_cli/user.py
12
13
  oks_cli/utils.py
13
14
  oks_cli.egg-info/PKG-INFO
14
15
  oks_cli.egg-info/SOURCES.txt
@@ -23,4 +24,5 @@ tests/test_nodepool.py
23
24
  tests/test_profile.py
24
25
  tests/test_project.py
25
26
  tests/test_quota.py
26
- tests/test_shell_completion.py
27
+ tests/test_shell_completion.py
28
+ tests/test_user.py
@@ -1,6 +1,6 @@
1
1
  certifi>=2024.8.30
2
2
  charset-normalizer>=3.3.2
3
- click<8.3.0,>=8.1.7
3
+ click<8.3.0,>=8.2.0
4
4
  colorama>=0.4.6
5
5
  idna>=3.10
6
6
  pyyaml>=6.0.2
@@ -2,11 +2,11 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="oks-cli",
5
- version="1.19",
5
+ version="1.21",
6
6
  packages=['oks_cli'],
7
7
  author="Outscale SAS",
8
8
  author_email="opensource@outscale.com",
9
- long_description=open("README.md").read(),
9
+ long_description=open("README.md", encoding="utf-8").read(),
10
10
  long_description_content_type="text/markdown",
11
11
  include_package_data=True,
12
12
  license="BSD",
@@ -26,7 +26,7 @@ setup(
26
26
  install_requires=[
27
27
  "certifi>=2024.8.30",
28
28
  "charset-normalizer>=3.3.2",
29
- "click>=8.1.7,<8.3.0",
29
+ "click>=8.2.0,<8.3.0",
30
30
  "colorama>=0.4.6",
31
31
  "idna>=3.10",
32
32
  "pyyaml>=6.0.2",
@@ -9,7 +9,7 @@ def test_install_completion_zsh():
9
9
 
10
10
  result = runner.invoke(cli, ["install-completion", "--type", "zsh"])
11
11
  assert result.exit_code == 0
12
- assert "Autocompletion installed for zsh" in result.output
12
+ assert "To activate autocompletion on login please add following lines into your .profile or .zshrc file" in result.output
13
13
 
14
14
  completion_file = Path("~/.oks_cli/completions/oks-cli.sh").expanduser()
15
15
  assert completion_file.exists()
@@ -22,7 +22,7 @@ def test_install_completion_bash():
22
22
 
23
23
  result = runner.invoke(cli, ["install-completion", "--type", "bash"])
24
24
  assert result.exit_code == 0
25
- assert "Autocompletion installed for bash" in result.output
25
+ assert "To activate autocompletion on login please add following lines into your .bash_profile or .bashrc file" in result.output
26
26
 
27
27
  completion_file = Path("~/.oks_cli/completions/oks-cli.sh").expanduser()
28
28
  assert completion_file.exists()
@@ -0,0 +1,56 @@
1
+ from click.testing import CliRunner
2
+ from oks_cli.main import cli
3
+ from unittest.mock import patch, MagicMock
4
+
5
+ @patch("oks_cli.utils.requests.request")
6
+ def test_user_list_command(mock_request, add_default_profile):
7
+ mock_request.side_effect = [
8
+ MagicMock(status_code=200, headers = {}, json=lambda: {"ResponseContext": {}, "Projects": [{"id": "12345"}]}),
9
+ MagicMock(status_code=200, headers = {}, json=lambda: {"ResponseContext": {}, "EimUsers": []})
10
+ ]
11
+
12
+ runner = CliRunner()
13
+ result = runner.invoke(cli, ["user", "list", "-p", "test"])
14
+ assert result.exit_code == 0
15
+ assert 'USER | ACCESS KEY' in result.output
16
+
17
+ @patch("oks_cli.utils.requests.request")
18
+ def test_user_create_command(mock_request, add_default_profile):
19
+ mock_request.side_effect = [
20
+ MagicMock(status_code=200, headers = {}, json=lambda: {"ResponseContext": {}, "Projects": [{"id": "12345"}]}),
21
+ MagicMock(status_code=200, headers = {}, json=lambda: {"ResponseContext": {}, "EimUser": {
22
+ "UserName": "OKSAuditor",
23
+ "CreationDate": "2026-03-04T12:31:58.000+0000",
24
+ "UserId": "BlaBla",
25
+ "UserEmail": "bla@email.local",
26
+ "LastModificationDate": "2026-03-04T12:31:58.000+0000",
27
+ "Path": "/",
28
+ "AccessKeys": [
29
+ {
30
+ "State": "ACTIVE",
31
+ "AccessKeyId": "AK",
32
+ "CreationDate": "2026-03-04T12:31:59.841+0000",
33
+ "ExpirationDate": "2026-03-11T12:31:59.297+0000",
34
+ "SecretKey": "SK",
35
+ "LastModificationDate": "2026-03-04T12:31:59.841+0000"
36
+ }
37
+ ]
38
+ }})
39
+ ]
40
+
41
+ runner = CliRunner()
42
+ result = runner.invoke(cli, ["user", "create", "-p", "test", "-u", "OKSAuditor", "--ttl", "1w"])
43
+ assert result.exit_code == 0
44
+ assert 'bla@email.local' in result.output
45
+
46
+ @patch("oks_cli.utils.requests.request")
47
+ def test_user_delete_command(mock_request, add_default_profile):
48
+ mock_request.side_effect = [
49
+ MagicMock(status_code=200, headers = {}, json=lambda: {"ResponseContext": {}, "Projects": [{"id": "12345"}]}),
50
+ MagicMock(status_code=200, headers = {}, json=lambda: {"ResponseContext": {}, "Details": "User has been deleted." })
51
+ ]
52
+
53
+ runner = CliRunner()
54
+ result = runner.invoke(cli, ["user", "delete", "-p", "test", "-u", "OKSAuditor", "--force"])
55
+ assert result.exit_code == 0
56
+ assert 'User has been deleted.' in result.output
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
File without changes
File without changes
File without changes
File without changes