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