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.
- {oks_cli-1.20 → oks_cli-1.22}/PKG-INFO +1 -1
- {oks_cli-1.20 → oks_cli-1.22}/oks_cli/cache.py +1 -1
- {oks_cli-1.20 → oks_cli-1.22}/oks_cli/cluster.py +64 -49
- {oks_cli-1.20 → oks_cli-1.22}/oks_cli/main.py +3 -1
- {oks_cli-1.20 → oks_cli-1.22}/oks_cli/profile.py +1 -1
- {oks_cli-1.20 → oks_cli-1.22}/oks_cli/project.py +3 -2
- oks_cli-1.22/oks_cli/user.py +204 -0
- {oks_cli-1.20 → oks_cli-1.22}/oks_cli/utils.py +48 -11
- {oks_cli-1.20 → oks_cli-1.22}/oks_cli.egg-info/PKG-INFO +1 -1
- {oks_cli-1.20 → oks_cli-1.22}/oks_cli.egg-info/SOURCES.txt +3 -1
- {oks_cli-1.20 → oks_cli-1.22}/setup.py +2 -2
- oks_cli-1.22/tests/test_user.py +88 -0
- {oks_cli-1.20 → oks_cli-1.22}/LICENSE +0 -0
- {oks_cli-1.20 → oks_cli-1.22}/README.md +0 -0
- {oks_cli-1.20 → oks_cli-1.22}/oks_cli/__init__.py +0 -0
- {oks_cli-1.20 → oks_cli-1.22}/oks_cli/netpeering.py +0 -0
- {oks_cli-1.20 → oks_cli-1.22}/oks_cli/quotas.py +0 -0
- {oks_cli-1.20 → oks_cli-1.22}/oks_cli.egg-info/dependency_links.txt +0 -0
- {oks_cli-1.20 → oks_cli-1.22}/oks_cli.egg-info/entry_points.txt +0 -0
- {oks_cli-1.20 → oks_cli-1.22}/oks_cli.egg-info/requires.txt +0 -0
- {oks_cli-1.20 → oks_cli-1.22}/oks_cli.egg-info/top_level.txt +0 -0
- {oks_cli-1.20 → oks_cli-1.22}/setup.cfg +0 -0
- {oks_cli-1.20 → oks_cli-1.22}/tests/test_cache.py +0 -0
- {oks_cli-1.20 → oks_cli-1.22}/tests/test_cluster.py +0 -0
- {oks_cli-1.20 → oks_cli-1.22}/tests/test_netpeering.py +0 -0
- {oks_cli-1.20 → oks_cli-1.22}/tests/test_nodepool.py +0 -0
- {oks_cli-1.20 → oks_cli-1.22}/tests/test_profile.py +0 -0
- {oks_cli-1.20 → oks_cli-1.22}/tests/test_project.py +0 -0
- {oks_cli-1.20 → oks_cli-1.22}/tests/test_quota.py +0 -0
- {oks_cli-1.20 → oks_cli-1.22}/tests/test_shell_completion.py +0 -0
|
@@ -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 =
|
|
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
|
-
|
|
155
|
-
row,
|
|
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 = {
|
|
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",
|
|
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
|
-
|
|
210
|
+
current_ids = {c.get('id') for c in data}
|
|
193
211
|
|
|
194
|
-
for
|
|
195
|
-
if
|
|
196
|
-
deleted_cluster =
|
|
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,
|
|
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
|
-
|
|
229
|
-
if
|
|
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
|
|
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
|
-
|
|
746
|
-
cmd
|
|
750
|
+
|
|
751
|
+
cmd = ['kubectl'] + list(args)
|
|
747
752
|
logging.info("running %s", cmd)
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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 =
|
|
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."""
|
|
@@ -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
|
|
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 =
|
|
206
|
-
values =
|
|
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(
|
|
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 =
|
|
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 =
|
|
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.
|
|
954
|
-
click.
|
|
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 =
|
|
1077
|
+
ctx.obj = {}
|
|
1041
1078
|
|
|
1042
1079
|
if project_name is not None:
|
|
1043
1080
|
if ctx.obj.get('project_name') and not overwrite:
|
|
@@ -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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|