oks-cli 1.20__tar.gz → 1.22__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.20 → oks_cli-1.22}/PKG-INFO +1 -1
  2. {oks_cli-1.20 → oks_cli-1.22}/oks_cli/cache.py +1 -1
  3. {oks_cli-1.20 → oks_cli-1.22}/oks_cli/cluster.py +64 -49
  4. {oks_cli-1.20 → oks_cli-1.22}/oks_cli/main.py +3 -1
  5. {oks_cli-1.20 → oks_cli-1.22}/oks_cli/profile.py +1 -1
  6. {oks_cli-1.20 → oks_cli-1.22}/oks_cli/project.py +3 -2
  7. oks_cli-1.22/oks_cli/user.py +204 -0
  8. {oks_cli-1.20 → oks_cli-1.22}/oks_cli/utils.py +48 -11
  9. {oks_cli-1.20 → oks_cli-1.22}/oks_cli.egg-info/PKG-INFO +1 -1
  10. {oks_cli-1.20 → oks_cli-1.22}/oks_cli.egg-info/SOURCES.txt +3 -1
  11. {oks_cli-1.20 → oks_cli-1.22}/setup.py +2 -2
  12. oks_cli-1.22/tests/test_user.py +88 -0
  13. {oks_cli-1.20 → oks_cli-1.22}/LICENSE +0 -0
  14. {oks_cli-1.20 → oks_cli-1.22}/README.md +0 -0
  15. {oks_cli-1.20 → oks_cli-1.22}/oks_cli/__init__.py +0 -0
  16. {oks_cli-1.20 → oks_cli-1.22}/oks_cli/netpeering.py +0 -0
  17. {oks_cli-1.20 → oks_cli-1.22}/oks_cli/quotas.py +0 -0
  18. {oks_cli-1.20 → oks_cli-1.22}/oks_cli.egg-info/dependency_links.txt +0 -0
  19. {oks_cli-1.20 → oks_cli-1.22}/oks_cli.egg-info/entry_points.txt +0 -0
  20. {oks_cli-1.20 → oks_cli-1.22}/oks_cli.egg-info/requires.txt +0 -0
  21. {oks_cli-1.20 → oks_cli-1.22}/oks_cli.egg-info/top_level.txt +0 -0
  22. {oks_cli-1.20 → oks_cli-1.22}/setup.cfg +0 -0
  23. {oks_cli-1.20 → oks_cli-1.22}/tests/test_cache.py +0 -0
  24. {oks_cli-1.20 → oks_cli-1.22}/tests/test_cluster.py +0 -0
  25. {oks_cli-1.20 → oks_cli-1.22}/tests/test_netpeering.py +0 -0
  26. {oks_cli-1.20 → oks_cli-1.22}/tests/test_nodepool.py +0 -0
  27. {oks_cli-1.20 → oks_cli-1.22}/tests/test_profile.py +0 -0
  28. {oks_cli-1.20 → oks_cli-1.22}/tests/test_project.py +0 -0
  29. {oks_cli-1.20 → oks_cli-1.22}/tests/test_quota.py +0 -0
  30. {oks_cli-1.20 → oks_cli-1.22}/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.20
3
+ Version: 1.22
4
4
  Author: Outscale SAS
5
5
  Author-email: opensource@outscale.com
6
6
  License: BSD
@@ -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:
@@ -121,20 +121,20 @@ def cluster_list(ctx, project_name, cluster_name, deleted, plain, msword, watch,
121
121
  if all:
122
122
  field_names.insert(0, "PROJECT")
123
123
 
124
- projects = {project["id"]: project for project in do_request("GET", "projects")}
125
- data = do_request("GET", "clusters/all", params=params)
126
-
127
- for cluster in data:
128
- project = projects.get(cluster.get("project_id"))
129
- cluster["project_name"] = project.get("name")
130
- else:
131
- data = do_request("GET", "clusters", params=params)
132
-
133
124
  if output == "wide":
134
125
  field_names.insert(0, "ID")
135
126
  field_names.append("VERSION")
136
127
  field_names.append("CONTROL PLANE")
137
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
+
138
138
  print_output(data, output)
139
139
  return
140
140
 
@@ -149,10 +149,14 @@ def cluster_list(ctx, project_name, cluster_name, deleted, plain, msword, watch,
149
149
  if msword:
150
150
  table.set_style(TableStyle.MSWORD_FRIENDLY)
151
151
 
152
- initial_clusters = {}
153
152
 
154
- for cluster in data:
155
- 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
+
156
160
  row.insert(1, profile_name)
157
161
  row.insert(2, region_name)
158
162
  if all:
@@ -163,6 +167,21 @@ def cluster_list(ctx, project_name, cluster_name, deleted, plain, msword, watch,
163
167
  row.append(cluster.get('version'))
164
168
  row.append(cluster.get('control_planes'))
165
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)
166
185
  table.add_row(row)
167
186
  initial_clusters[cluster.get("id")] = cluster
168
187
 
@@ -177,46 +196,32 @@ def cluster_list(ctx, project_name, cluster_name, deleted, plain, msword, watch,
177
196
 
178
197
  try:
179
198
  if all:
180
- projects = {project["id"]: project for project in do_request("GET", "projects")}
199
+ projects = {p["id"]: p for p in do_request("GET", "projects")}
181
200
  data = do_request("GET", "clusters/all", params=params)
182
-
183
201
  for cluster in data:
184
202
  project = projects.get(cluster.get("project_id"))
185
203
  cluster["project_name"] = project.get("name")
186
204
  else:
187
- data = do_request("GET", 'clusters', params=params)
205
+ data = do_request("GET", "clusters", params=params)
188
206
  except click.ClickException as err:
189
207
  click.echo(f"Error during watch: {err}")
190
208
  continue
191
209
 
192
- current_cluster_ids = {cluster.get('id') for cluster in data}
210
+ current_ids = {c.get('id') for c in data}
193
211
 
194
- for id, cluster in list(initial_clusters.items()):
195
- if id not in current_cluster_ids:
196
- 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()
197
215
  deleted_cluster['statuses']['status'] = 'deleted'
198
216
 
199
- row, current_status, _ = format_row(deleted_cluster.get('statuses'), deleted_cluster.get('name'), cluster_id == deleted_cluster.get('id'))
200
- row.insert(1, profile_name)
201
- row.insert(2, region_name)
202
- if all:
203
- project_name = click.style(cluster.get("project_name"), bold=True)
204
- row.insert(0, project_name)
205
-
217
+ row, _ = build_row(deleted_cluster)
206
218
  new_table = format_changed_row(table, row)
207
219
  click.echo(new_table)
208
-
209
- del initial_clusters[id]
220
+ del initial_clusters[cl_id]
210
221
 
211
222
  for cluster in data:
212
- row, current_status, name = format_row(cluster.get('statuses'), cluster.get('name'), cluster_id == cluster.get('id'))
213
- row.insert(1, profile_name)
214
- row.insert(2, region_name)
215
- if all:
216
- project_name = click.style(cluster.get("project_name"), bold=True)
217
- row.insert(0, project_name)
218
-
219
223
  cl_id = cluster.get('id')
224
+ row, current_status = build_row(cluster)
220
225
 
221
226
  if cl_id not in initial_clusters:
222
227
  new_table = format_changed_row(table, row)
@@ -225,8 +230,8 @@ def cluster_list(ctx, project_name, cluster_name, deleted, plain, msword, watch,
225
230
  continue
226
231
 
227
232
  stored_cluster = initial_clusters[cl_id]
228
- cluster_status = stored_cluster.get('statuses').get('status')
229
- if cluster_status != current_status:
233
+ stored_status = stored_cluster.get('statuses').get('status')
234
+ if stored_status != current_status:
230
235
  new_table = format_changed_row(table, row)
231
236
  click.echo(new_table)
232
237
  initial_clusters[cl_id] = cluster
@@ -368,8 +373,8 @@ def _create_cluster(project_name, cluster_config, output):
368
373
  @click.option('--cidr-service', help='CIDR of services')
369
374
  @click.option('--control-plane', shell_complete=shell_completions, help="Controlplane plan")
370
375
  @click.option('--zone', '-z', multiple=True, shell_complete=shell_completions, help="List of Control Plane availability zones")
371
- @click.option('--enable-admission-plugins', help="List of admission plugins, separated by commas")
372
- @click.option('--disable-admission-plugins', help="List of admission plugins, separated by commas")
376
+ @click.option('--enable-admission-plugins', shell_complete=shell_completions, help="List of admission plugins, separated by commas")
377
+ @click.option('--disable-admission-plugins', shell_complete=shell_completions, help="List of admission plugins, separated by commas")
373
378
  @click.option('--quirk', '-q', multiple=True, help="Quirk")
374
379
  @click.option('--tags', '-t', help="Comma-separated list of tags, example: 'key1=value1,key2=value2'")
375
380
  @click.option('--disable-api-termination', type=click.BOOL, help="Disable delete action by API")
@@ -467,8 +472,8 @@ def cluster_create_command(ctx, project_name, cluster_name, description, admin,
467
472
  @click.option('--admin', '-a', help="Admin Whitelist ips. you can use 'my-ip' to automatically use your current IP.")
468
473
  @click.option('--version', '-v', shell_complete=shell_completions, help="Kubernetes version")
469
474
  @click.option('--tags', '-t', help="Comma-separated list of tags, example: 'key1=value1,key2=value2'")
470
- @click.option('--enable-admission-plugins', help="List of admission plugins, separated by commas")
471
- @click.option('--disable-admission-plugins', help="List of admission plugins, separated by commas")
475
+ @click.option('--enable-admission-plugins', shell_complete=shell_completions, help="List of admission plugins, separated by commas")
476
+ @click.option('--disable-admission-plugins', shell_complete=shell_completions, help="List of admission plugins, separated by commas")
472
477
  @click.option('--quirk', '-q', multiple=True, help="Quirk")
473
478
  @click.option('--disable-api-termination', type=click.BOOL, help="Disable delete action by API")
474
479
  @click.option('--control-plane', shell_complete=shell_completions, help="Controlplane plan")
@@ -523,7 +528,7 @@ def cluster_update_command(ctx, project_name, cluster_name, description, admin,
523
528
  if tags is not None:
524
529
  parsed_tags = {}
525
530
 
526
- if not len(tags) == 0:
531
+ if len(tags) != 0:
527
532
  pairs = tags.split(',')
528
533
  for pair in pairs:
529
534
  if '=' not in pair:
@@ -736,19 +741,29 @@ def _run_kubectl(project_id, cluster_id, user, group, args, input=None, capture=
736
741
 
737
742
  if not kubeconfig_raw:
738
743
  click.echo("Cannot get kubeconfig")
739
- raise SystemExit()
744
+ raise SystemExit(1)
740
745
 
741
746
  kubeconfig_path = save_cache(project_id, cluster_id, 'kubeconfig', kubeconfig_raw, user, group)
742
747
 
743
748
  env = dict(os.environ)
744
749
  env['KUBECONFIG'] = str(kubeconfig_path)
745
- cmd = ['kubectl']
746
- cmd += list(args)
750
+
751
+ cmd = ['kubectl'] + list(args)
747
752
  logging.info("running %s", cmd)
748
- if not input:
749
- return subprocess.run(cmd, env=env, capture_output=capture)
750
- else:
751
- 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)
752
767
 
753
768
 
754
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]:
@@ -328,7 +328,7 @@ def project_update_command(ctx, project_name, description, quirk, tags, disable_
328
328
  if tags is not None:
329
329
  parsed_tags = {}
330
330
 
331
- if not len(tags) == 0:
331
+ if len(tags) != 0:
332
332
  pairs = tags.split(',')
333
333
  for pair in pairs:
334
334
  if '=' not in pair:
@@ -365,7 +365,8 @@ def project_get_quotas(ctx, project_name, output, profile):
365
365
  ["Collection", "QuotaCollection"],
366
366
  ["Description", "ShortDescription"],
367
367
  ["Max Value", "MaxValue"],
368
- ["Used Value", "UsedValue"]])
368
+ ["Used Value", "UsedValue"],
369
+ ["AccountId", "AccountId"]])
369
370
  print_table(data["subregions"], [["Region", "RegionName"],
370
371
  ["Availability Zone", "SubregionName"],
371
372
  ["State", "State"]])
@@ -0,0 +1,204 @@
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
+ from prettytable import TableStyle
11
+
12
+ from .utils import do_request, print_output, find_project_id_by_name, ctx_update, login_profile, profile_completer, project_completer, JSONClickException
13
+
14
+ # DEIFNE THE USER COMMAND GROUP
15
+ @click.group(help="EIM users related commands.")
16
+ @click.option('--project-name', '-p', required=False, help="Project Name", shell_complete=project_completer)
17
+ @click.option('--profile', help="Configuration profile to use", shell_complete=profile_completer)
18
+ @click.pass_context
19
+ def user(ctx, project_name, profile):
20
+ """Group of commands related to project management."""
21
+ ctx_update(ctx, project_name, None, profile)
22
+
23
+ # LIST USERS
24
+ @user.command('list', help="List EIM users")
25
+ @click.option('--output', '-o', type=click.Choice(["json", "yaml"]), help="Specify output format, by default is json")
26
+ @click.option('--project-name', '-p', required=False, help="Project Name", shell_complete=project_completer)
27
+ @click.option('--profile', help="Configuration profile to use")
28
+ @click.pass_context
29
+ def user_list(ctx, output, project_name, profile):
30
+ """List users"""
31
+ project_name, _, profile = ctx_update(ctx, project_name, None, profile)
32
+ login_profile(profile)
33
+
34
+ project_id = find_project_id_by_name(project_name)
35
+
36
+ data = do_request("GET", f'projects/{project_id}/eim_users')
37
+
38
+ if output:
39
+ print_output(data, output)
40
+ return
41
+
42
+ field_names = ["USER", "ACCESS KEY", "STATE", "CREATED", "EXPIRATION DATE"]
43
+ table = prettytable.PrettyTable()
44
+ table.field_names = field_names
45
+
46
+ for user in data:
47
+ access_keys = user.get("AccessKeys", [])
48
+ access_key = access_keys[0] if access_keys else {}
49
+
50
+
51
+ state = access_key.get("State", "N/A")
52
+ if state == 'ACTIVE':
53
+ state = click.style(state, fg='green')
54
+ elif state == "INACTIVE":
55
+ state = click.style(state, fg='red')
56
+
57
+
58
+ row = [
59
+ user.get("UserName"),
60
+ access_key.get("AccessKeyId", "N/A"),
61
+ state
62
+ ]
63
+
64
+ if "CreationDate" in access_key:
65
+ created_at = dateutil.parser.parse(access_key.get("CreationDate"))
66
+ now = datetime.now(tz=created_at.tzinfo)
67
+ row.append(human_readable.date_time(now - created_at))
68
+ else:
69
+ row.append("N/A")
70
+
71
+ if "ExpirationDate" in access_key:
72
+ exp_at = dateutil.parser.parse(access_key.get("ExpirationDate"))
73
+ now = datetime.now(tz=exp_at.tzinfo)
74
+ row.append(human_readable.date_time(now - exp_at))
75
+ else:
76
+ row.append("N/A")
77
+
78
+ table.add_row(row)
79
+
80
+ click.echo(table)
81
+
82
+
83
+ @user.command('create', help="Create a new EIM user")
84
+ @click.option('--project-name', '-p', help="Name of project", type=click.STRING, shell_complete=project_completer)
85
+ @click.option('--output', '-o', type=click.Choice(["json", "yaml"]), help="Specify output format, by default is json")
86
+ @click.option('--profile', help="Configuration profile to use")
87
+ @click.option('--user', '-u', required=True, help="OKS User type")
88
+ @click.option('--ttl', type=click.STRING, help="TTL in human readable format (5h, 1d, 1w), by default is 1w")
89
+ @click.option('--nacl', is_flag=True, help="Use public key encryption on wire")
90
+ @click.pass_context
91
+ def user_create(ctx, project_name, output, profile, user, ttl, nacl):
92
+ """Create a new EIM user."""
93
+ project_name, _, profile = ctx_update(ctx, project_name, None, profile)
94
+ login_profile(profile)
95
+
96
+ project_id = find_project_id_by_name(project_name)
97
+
98
+ params = {
99
+ "user": user
100
+ }
101
+ if ttl:
102
+ params["ttl"] = ttl
103
+
104
+ if nacl:
105
+ ephemeral = PrivateKey.generate()
106
+ unsealbox = SealedBox(ephemeral)
107
+
108
+ headers = {
109
+ 'x-encrypt-nacl': ephemeral.public_key.encode(Base64Encoder).decode('ascii')
110
+ }
111
+
112
+ raw_data = do_request(
113
+ "POST",
114
+ f'projects/{project_id}/eim_users',
115
+ params=params,
116
+ headers=headers
117
+ )
118
+
119
+ decrypted = unsealbox.decrypt(
120
+ raw_data.get("Data").encode('ascii'),
121
+ encoder=Base64Encoder
122
+ ).decode('ascii')
123
+
124
+ data = json.loads(decrypted)
125
+
126
+ # format decrypted errors the same way as the api errors.
127
+ if "Errors" in data:
128
+ response_context = raw_data.get("ResponseContext")
129
+ errors = []
130
+ for error in data.get("Errors", []):
131
+ error["Code"] = str(data.get("Code"))
132
+ errors.append(error)
133
+
134
+ raise JSONClickException(json.dumps({"Errors": errors,"ResponseContext": response_context}, separators=(",", ":")))
135
+
136
+ else:
137
+ data = do_request(
138
+ "POST",
139
+ f'projects/{project_id}/eim_users',
140
+ params=params
141
+ )
142
+
143
+ print_output(data, output)
144
+
145
+ # DELETE USER
146
+ @user.command('delete', help="Delete an EIM user")
147
+ @click.option('--project-name', '-p', required=False, help="Project name", shell_complete=project_completer)
148
+ @click.option('--user', '-u', required=True, help="User name")
149
+ @click.option('--output', '-o', type=click.Choice(["json", "yaml"]), help="Specify output format")
150
+ @click.option('--dry-run', is_flag=True, help="Run without any action")
151
+ @click.option('--force', is_flag=True, help="Force deletion without confirmation")
152
+ @click.option('--profile', help="Configuration profile to use", shell_complete=profile_completer)
153
+ @click.pass_context
154
+ def user_delete(ctx, project_name, user, output, dry_run, force, profile):
155
+ """CLI command to delete an EIM user."""
156
+
157
+ project_name, _, profile = ctx_update(ctx, project_name, None, profile)
158
+ login_profile(profile)
159
+
160
+ project_id = find_project_id_by_name(project_name)
161
+
162
+ if dry_run:
163
+ message = {"message": f"Dry run: The user '{user}' would be deleted."}
164
+ print_output(message, output)
165
+ return
166
+
167
+ if force or click.confirm(f"Are you sure you want to delete the user '{user}'?", abort=True):
168
+ data = do_request("DELETE", f"projects/{project_id}/eim_users/{user}")
169
+ print_output(data, output)
170
+
171
+
172
+ @user.command('types', help="List available user types")
173
+ @click.option('--project-name', '-p', required=False, help="Project Name", shell_complete=project_completer)
174
+ @click.option('--output', '-o', type=click.Choice(["json", "yaml"]), help="Specify output format")
175
+ @click.option('--plain', is_flag=True, help="Plain table format")
176
+ @click.option('--profile', help="Configuration profile to use", shell_complete=profile_completer)
177
+ @click.pass_context
178
+ def user_types(ctx, project_name, output, plain, profile):
179
+ """Display available user types."""
180
+ project_name, _, profile = ctx_update(ctx, project_name, None, profile)
181
+ login_profile(profile)
182
+
183
+ project_id = find_project_id_by_name(project_name)
184
+
185
+ data = do_request("GET", f'projects/{project_id}/eim_users/types')
186
+
187
+ if output:
188
+ print_output(data, output)
189
+ return
190
+
191
+ table = prettytable.PrettyTable()
192
+ table.field_names = ["USER TYPE", "DESCRIPTION"]
193
+
194
+ if plain:
195
+ table.set_style(TableStyle.PLAIN_COLUMNS)
196
+
197
+ for entry in data:
198
+ table.add_row([
199
+ entry.get("UserType", ""),
200
+ entry.get("Description") or "-"
201
+ ])
202
+
203
+ click.echo(table)
204
+
@@ -79,6 +79,16 @@ 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 == "AdmissionPlugins":
87
+ return response["AdmissionPlugins"]
88
+ elif key == "Data":
89
+ return response
90
+ elif key == "EimUserTypes":
91
+ return response["EimUserTypes"]
82
92
 
83
93
  raise click.ClickException("The API response format is incorrect.")
84
94
 
@@ -202,8 +212,8 @@ def print_table(data, table_fields, align="l", style=None):
202
212
  table.align = align
203
213
  if style and isinstance(style, prettytable.TableStyle):
204
214
  table.set_style(style)
205
- fields = list()
206
- values = list()
215
+ fields = []
216
+ values = []
207
217
 
208
218
  for d in table_fields:
209
219
  fields.append(d[0])
@@ -417,7 +427,7 @@ def format_row(data: dict, name: str, is_default: bool):
417
427
  """Parse status and dates from a cluster of project object and returns elements"""
418
428
 
419
429
  if not data.get('status'):
420
- raise click.ClickException(f"Can't find 'status' in project/cluster data")
430
+ raise click.ClickException("Can't find 'status' in project/cluster data")
421
431
 
422
432
  status = data.get('status')
423
433
  if status == 'ready':
@@ -805,14 +815,14 @@ def kubeconfig_parse_fields(kubeconfig, cluster_name, user, group):
805
815
  group: user group name of this kubeconfig (if set)
806
816
  """
807
817
  kubeconfig_str = yaml.safe_load(kubeconfig)
808
- kubedata = list()
818
+ kubedata = []
809
819
 
810
820
  # Ensure loaded YAML returnes a valid dict object
811
821
  if not isinstance(kubeconfig_str, dict):
812
822
  return kubedata
813
823
 
814
824
  for context in kubeconfig_str.get('contexts', []):
815
- data = dict()
825
+ data = {}
816
826
  ctx_cluster = context.get('context').get('cluster', None)
817
827
  ctx_user = context.get('context').get('user', None)
818
828
  ctx_name = context.get('name')
@@ -841,9 +851,11 @@ def kubeconfig_parse_fields(kubeconfig, cluster_name, user, group):
841
851
 
842
852
  return kubedata
843
853
 
844
- def retrieve_cp_sized(filepath, endpoint):
854
+ def retrieve_cp_sized(filepath, endpoint, key = None):
845
855
  """Fetch control plane sizes from API and save to file."""
846
856
  cp_list = do_request("GET", endpoint)
857
+ if key:
858
+ cp_list = cp_list.get(key)
847
859
 
848
860
  with open(filepath, "w") as file:
849
861
  json.dump(cp_list, file)
@@ -865,6 +877,8 @@ def shell_completions(ctx, param: click.core.Option, incomplete):
865
877
  if profile not in profiles:
866
878
  return []
867
879
 
880
+ key = None
881
+
868
882
  login_profile(profile)
869
883
 
870
884
  if param.name == "version":
@@ -873,6 +887,16 @@ def shell_completions(ctx, param: click.core.Option, incomplete):
873
887
  endpoint = "clusters/limits/control_plane_plans"
874
888
  elif param.name == "zone":
875
889
  endpoint = "clusters/limits/cp_subregions"
890
+ elif param.name == "disable_admission_plugins" and ctx.params["version"]:
891
+ key = "DisableAdmissionPlugins"
892
+ version = ctx.params["version"]
893
+ endpoint = f"clusters/limits/admission_plugins?version={version}"
894
+ param.name += f".{version}"
895
+ elif param.name == "enable_admission_plugins" and ctx.params["version"]:
896
+ key = "EnableAdmissionPlugins"
897
+ version = ctx.params["version"]
898
+ endpoint = f"clusters/limits/admission_plugins?version={version}"
899
+ param.name += f".{version}"
876
900
  else:
877
901
  return []
878
902
 
@@ -882,9 +906,9 @@ def shell_completions(ctx, param: click.core.Option, incomplete):
882
906
  if os.path.exists(CP_SIZES_PATH):
883
907
  file_ctime = os.path.getctime(CP_SIZES_PATH)
884
908
  if datetime.timestamp(datetime.now()) - file_ctime > 300:
885
- retrieve_cp_sized(CP_SIZES_PATH, endpoint)
909
+ retrieve_cp_sized(CP_SIZES_PATH, endpoint, key)
886
910
  else:
887
- retrieve_cp_sized(CP_SIZES_PATH, endpoint)
911
+ retrieve_cp_sized(CP_SIZES_PATH, endpoint, key)
888
912
 
889
913
  if os.path.exists(CP_SIZES_PATH):
890
914
  with open(CP_SIZES_PATH, "r") as file:
@@ -892,6 +916,19 @@ def shell_completions(ctx, param: click.core.Option, incomplete):
892
916
  else:
893
917
  cp_list = []
894
918
 
919
+ # Handle comma-separated values
920
+ if "," in incomplete:
921
+ parts = incomplete.split(",")
922
+ selected = set(parts[:-1])
923
+ current = parts[-1]
924
+ prefix = ",".join(parts[:-1])
925
+
926
+ return [
927
+ f"{prefix},{item}"
928
+ for item in cp_list
929
+ if item.startswith(current) and item not in selected
930
+ ]
931
+
895
932
  return [k for k in cp_list if k.startswith(incomplete)]
896
933
 
897
934
  def update_shell_profile(shell_profile, filepath):
@@ -950,8 +987,8 @@ def install_completions(shell_type):
950
987
  shell_name = result.stdout.strip()
951
988
 
952
989
  shell_type = os.path.basename(shell_name).lstrip('-')
953
- except subprocess.SubProcessError:
954
- click.echo("Failed to determine shell type, please specify it by --type")
990
+ except (subprocess.SubprocessError, FileNotFoundError):
991
+ raise click.ClickException("Failed to determine shell type, please specify it by --type")
955
992
 
956
993
  completion_dir = os.path.join(home, ".oks_cli", "completions")
957
994
  os.makedirs(completion_dir, exist_ok=True)
@@ -1037,7 +1074,7 @@ def get_template(type):
1037
1074
  def ctx_update(ctx, project_name=None, cluster_name=None, profile=None, overwrite=True):
1038
1075
  """Update context with project, cluster, and profile; optionally prevent overwrites."""
1039
1076
  if not hasattr(ctx, 'obj') or not ctx.obj:
1040
- ctx.obj = dict()
1077
+ ctx.obj = {}
1041
1078
 
1042
1079
  if project_name is not None:
1043
1080
  if ctx.obj.get('project_name') and not overwrite:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oks-cli
3
- Version: 1.20
3
+ Version: 1.22
4
4
  Author: Outscale SAS
5
5
  Author-email: opensource@outscale.com
6
6
  License: BSD
@@ -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
@@ -2,11 +2,11 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="oks-cli",
5
- version="1.20",
5
+ version="1.22",
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",
@@ -0,0 +1,88 @@
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
57
+
58
+ @patch("oks_cli.utils.requests.request")
59
+ def test_user_types_command(mock_request, add_default_profile):
60
+ mock_request.side_effect = [
61
+ MagicMock(status_code=200, headers={}, json=lambda: {"ResponseContext": {}, "Projects": [{"id": "12345"}]}),
62
+ MagicMock(status_code=200, headers={}, json=lambda: {"ResponseContext": {}, "EimUserTypes": [
63
+ {"UserType": "OKSSnapshotsManager", "Description": "OKS user with full access to snapshots"},
64
+ {"UserType": "OKSVolumesManager", "Description": "OKS user with management access to volumes BSU"},
65
+ ]})
66
+ ]
67
+
68
+ runner = CliRunner()
69
+ result = runner.invoke(cli, ["user", "types", "-p", "test"])
70
+ assert result.exit_code == 0
71
+ assert 'OKSSnapshotsManager' in result.output
72
+ assert 'OKSVolumesManager' in result.output
73
+
74
+ @patch("oks_cli.utils.requests.request")
75
+ def test_user_types_command_json(mock_request, add_default_profile):
76
+ mock_request.side_effect = [
77
+ MagicMock(status_code=200, headers={}, json=lambda: {"ResponseContext": {}, "Projects": [{"id": "12345"}]}),
78
+ MagicMock(status_code=200, headers={}, json=lambda: {"ResponseContext": {}, "EimUserTypes": [
79
+ {"UserType": "OKSSnapshotsManager", "Description": "OKS user with full access to snapshots"},
80
+ {"UserType": "OKSVolumesManager", "Description": "OKS user with management access to volumes BSU"},
81
+ ]})
82
+ ]
83
+
84
+ runner = CliRunner()
85
+ result = runner.invoke(cli, ["user", "types", "-p", "test", "-o", "json"])
86
+ assert result.exit_code == 0
87
+ assert 'OKSSnapshotsManager' in result.output
88
+ assert 'OKS user with full access to snapshots' 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