wayscloud-cli 0.5.0__tar.gz → 0.6.0__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.
- {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/PKG-INFO +6 -4
- {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/README.md +3 -1
- {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/pyproject.toml +3 -3
- {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/setup.cfg +1 -1
- wayscloud_cli-0.6.0/tests/test_k8s_cli.py +318 -0
- {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/tests/test_smoke.py +27 -17
- wayscloud_cli-0.6.0/wayscloud_cli/__init__.py +1 -0
- {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/k8s.py +144 -4
- {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli.egg-info/PKG-INFO +6 -4
- {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli.egg-info/requires.txt +1 -1
- wayscloud_cli-0.5.0/tests/test_k8s_cli.py +0 -101
- wayscloud_cli-0.5.0/wayscloud_cli/__init__.py +0 -1
- {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/LICENSE +0 -0
- {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli/__main__.py +0 -0
- {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/__init__.py +0 -0
- {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/app.py +0 -0
- {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/db.py +0 -0
- {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/dns.py +0 -0
- {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/impact.py +0 -0
- {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/iot.py +0 -0
- {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/login.py +0 -0
- {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/redis.py +0 -0
- {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/shell.py +0 -0
- {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/storage.py +0 -0
- {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/vps.py +0 -0
- {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli/config.py +0 -0
- {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli/output.py +0 -0
- {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli/sdk.py +0 -0
- {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli.egg-info/SOURCES.txt +0 -0
- {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli.egg-info/dependency_links.txt +0 -0
- {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli.egg-info/entry_points.txt +0 -0
- {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli.egg-info/top_level.txt +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wayscloud-cli
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Command-line interface for WAYSCloud — manage VPS, DNS, databases, storage, apps,
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: Command-line interface for WAYSCloud — manage VPS, DNS, databases, storage, apps, IoT, and more
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://wayscloud.services
|
|
7
7
|
Project-URL: Documentation, https://docs.wayscloud.services/integrations/#wayscloud-cli
|
|
@@ -20,7 +20,7 @@ Classifier: Operating System :: OS Independent
|
|
|
20
20
|
Requires-Python: >=3.9
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
22
22
|
License-File: LICENSE
|
|
23
|
-
Requires-Dist: wayscloud>=0.
|
|
23
|
+
Requires-Dist: wayscloud>=0.5.0
|
|
24
24
|
Requires-Dist: typer>=0.9
|
|
25
25
|
Requires-Dist: rich>=13.0
|
|
26
26
|
Requires-Dist: websockets>=12.0
|
|
@@ -36,7 +36,7 @@ Command-line interface for [WAYSCloud](https://wayscloud.services). Built on the
|
|
|
36
36
|
pip install wayscloud-cli
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
Version 0.
|
|
39
|
+
Version 0.6.0 adds the upgrade preflight (`cloud k8s preflight`, non-zero exit on blockers) and requires SDK `wayscloud>=0.5.0`.
|
|
40
40
|
|
|
41
41
|
## Authentication
|
|
42
42
|
|
|
@@ -121,6 +121,8 @@ cloud k8s list
|
|
|
121
121
|
cloud k8s create shop --pool k8s-node-2c4g:2 --plan k8s-cluster-dev --region no --wait
|
|
122
122
|
cloud k8s info <cluster-id>
|
|
123
123
|
cloud k8s kubeconfig <cluster-id> -o ~/.kube/shop.yaml # written with mode 0600
|
|
124
|
+
cloud k8s preflight <cluster-id> 1.35 # blockers/warnings; exit != 0 on blockers
|
|
125
|
+
cloud k8s upgrade <cluster-id> 1.35 --mode next-maintenance-window --backup-first
|
|
124
126
|
cloud k8s scale <cluster-id> default 4
|
|
125
127
|
cloud k8s add-pool <cluster-id> batch --plan k8s-node-4c16g --count 2 \
|
|
126
128
|
--label role=worker --taint dedicated=gpu:NoSchedule --ssh-key-id <key-id>
|
|
@@ -8,7 +8,7 @@ Command-line interface for [WAYSCloud](https://wayscloud.services). Built on the
|
|
|
8
8
|
pip install wayscloud-cli
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
Version 0.
|
|
11
|
+
Version 0.6.0 adds the upgrade preflight (`cloud k8s preflight`, non-zero exit on blockers) and requires SDK `wayscloud>=0.5.0`.
|
|
12
12
|
|
|
13
13
|
## Authentication
|
|
14
14
|
|
|
@@ -93,6 +93,8 @@ cloud k8s list
|
|
|
93
93
|
cloud k8s create shop --pool k8s-node-2c4g:2 --plan k8s-cluster-dev --region no --wait
|
|
94
94
|
cloud k8s info <cluster-id>
|
|
95
95
|
cloud k8s kubeconfig <cluster-id> -o ~/.kube/shop.yaml # written with mode 0600
|
|
96
|
+
cloud k8s preflight <cluster-id> 1.35 # blockers/warnings; exit != 0 on blockers
|
|
97
|
+
cloud k8s upgrade <cluster-id> 1.35 --mode next-maintenance-window --backup-first
|
|
96
98
|
cloud k8s scale <cluster-id> default 4
|
|
97
99
|
cloud k8s add-pool <cluster-id> batch --plan k8s-node-4c16g --count 2 \
|
|
98
100
|
--label role=worker --taint dedicated=gpu:NoSchedule --ssh-key-id <key-id>
|
|
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "wayscloud-cli"
|
|
7
|
-
version = "0.
|
|
8
|
-
description = "Command-line interface for WAYSCloud — manage VPS, DNS, databases, storage, apps,
|
|
7
|
+
version = "0.6.0"
|
|
8
|
+
description = "Command-line interface for WAYSCloud — manage VPS, DNS, databases, storage, apps, IoT, and more"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
11
11
|
license = {text = "MIT"}
|
|
@@ -22,7 +22,7 @@ classifiers = [
|
|
|
22
22
|
"Operating System :: OS Independent",
|
|
23
23
|
]
|
|
24
24
|
dependencies = [
|
|
25
|
-
"wayscloud>=0.
|
|
25
|
+
"wayscloud>=0.5.0",
|
|
26
26
|
"typer>=0.9",
|
|
27
27
|
"rich>=13.0",
|
|
28
28
|
"websockets>=12.0",
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""k8s command tests -- option parsing, payloads and file handling."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import stat
|
|
5
|
+
|
|
6
|
+
from typer.testing import CliRunner
|
|
7
|
+
|
|
8
|
+
from wayscloud_cli.__main__ import app
|
|
9
|
+
from wayscloud_cli.commands import k8s as k8s_cmd
|
|
10
|
+
|
|
11
|
+
runner = CliRunner()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FakeKubernetes:
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self.calls = []
|
|
17
|
+
self.window = {}
|
|
18
|
+
self.jobs = []
|
|
19
|
+
self.upgrade_response = None
|
|
20
|
+
self.preflight_response = None
|
|
21
|
+
|
|
22
|
+
def add_node_pool(self, *args, **kwargs):
|
|
23
|
+
self.calls.append((args, kwargs))
|
|
24
|
+
return {"id": "c1"}
|
|
25
|
+
|
|
26
|
+
def kubeconfig(self, cluster_id):
|
|
27
|
+
return "apiVersion: v1\nkind: Config\n"
|
|
28
|
+
|
|
29
|
+
def delete_node_pool(self, *args, **kwargs):
|
|
30
|
+
self.calls.append((args, kwargs))
|
|
31
|
+
return {"id": "c1"}
|
|
32
|
+
|
|
33
|
+
def upgrade_node_pool(self, *args, **kwargs):
|
|
34
|
+
self.calls.append((args, kwargs))
|
|
35
|
+
return {"id": "c1"}
|
|
36
|
+
|
|
37
|
+
def versions(self, *args, **kwargs):
|
|
38
|
+
self.calls.append((args, kwargs))
|
|
39
|
+
return {"default": "1.35", "versions": [
|
|
40
|
+
{"version": "1.35", "status": "default", "create_allowed": True, "create_until": None,
|
|
41
|
+
"support_ends_at": "2027-02-28", "upgrade_required_by": None, "recommended_upgrade": None},
|
|
42
|
+
{"version": "1.34", "status": "transition", "create_allowed": True, "create_until": "2026-10-15",
|
|
43
|
+
"support_ends_at": "2026-10-27", "upgrade_required_by": "2026-10-20", "recommended_upgrade": "1.35"}]}
|
|
44
|
+
|
|
45
|
+
def upgrade(self, *args, **kwargs):
|
|
46
|
+
self.calls.append((args, kwargs))
|
|
47
|
+
return self.upgrade_response or {"id": "c1", "status": "upgrading"}
|
|
48
|
+
|
|
49
|
+
def upgrade_preflight(self, *args, **kwargs):
|
|
50
|
+
self.calls.append((args, kwargs))
|
|
51
|
+
return self.preflight_response or {"ok": True, "errors": [], "warnings": []}
|
|
52
|
+
|
|
53
|
+
def upgrade_jobs(self, *args, **kwargs):
|
|
54
|
+
self.calls.append((args, kwargs))
|
|
55
|
+
return self.jobs
|
|
56
|
+
|
|
57
|
+
def upgrade_job(self, *args, **kwargs):
|
|
58
|
+
self.calls.append((args, kwargs))
|
|
59
|
+
return self.jobs[0] if self.jobs else {}
|
|
60
|
+
|
|
61
|
+
def cancel_upgrade_job(self, *args, **kwargs):
|
|
62
|
+
self.calls.append((args, kwargs))
|
|
63
|
+
return {"id": "j1", "status": "cancelled"}
|
|
64
|
+
|
|
65
|
+
def set_maintenance_window(self, *args, **kwargs):
|
|
66
|
+
self.calls.append((args, kwargs))
|
|
67
|
+
return {"enabled": kwargs.get("enabled", True), "timezone": kwargs.get("timezone"), "days": kwargs.get("days"),
|
|
68
|
+
"start": kwargs.get("start"), "duration_minutes": kwargs.get("duration_minutes"),
|
|
69
|
+
"next_window_start": "2026-10-24T02:00:00+02:00", "next_window_end": "2026-10-24T04:00:00+02:00"}
|
|
70
|
+
|
|
71
|
+
def get_maintenance_window(self, *args, **kwargs):
|
|
72
|
+
self.calls.append((args, kwargs))
|
|
73
|
+
return self.window
|
|
74
|
+
|
|
75
|
+
def delete_maintenance_window(self, *args, **kwargs):
|
|
76
|
+
self.calls.append((args, kwargs))
|
|
77
|
+
return {"enabled": False, "timezone": None, "days": [], "start": None, "duration_minutes": None,
|
|
78
|
+
"next_window_start": None, "next_window_end": None}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class FakeClient:
|
|
82
|
+
def __init__(self, kubernetes):
|
|
83
|
+
self.kubernetes = kubernetes
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def patch_client(monkeypatch, kubernetes):
|
|
87
|
+
monkeypatch.setattr(k8s_cmd, "get_client", lambda token=None: FakeClient(kubernetes))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_parse_labels_and_taints():
|
|
91
|
+
assert k8s_cmd._parse_labels([]) == {}
|
|
92
|
+
assert k8s_cmd._parse_labels(["role=worker", "zone=a=b"]) == {"role": "worker", "zone": "a=b"}
|
|
93
|
+
assert k8s_cmd._parse_taints([]) == []
|
|
94
|
+
assert k8s_cmd._parse_taints(["dedicated=gpu:NoSchedule", "spot=:PreferNoSchedule"]) == [
|
|
95
|
+
{"key": "dedicated", "value": "gpu", "effect": "NoSchedule"},
|
|
96
|
+
{"key": "spot", "value": "", "effect": "PreferNoSchedule"},
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_add_pool_passes_labels_taints_and_ssh_keys(monkeypatch):
|
|
101
|
+
kube = FakeKubernetes()
|
|
102
|
+
patch_client(monkeypatch, kube)
|
|
103
|
+
|
|
104
|
+
result = runner.invoke(app, [
|
|
105
|
+
"k8s", "add-pool", "c1", "workers", "--plan", "k8s-node-2c4g", "--count", "3",
|
|
106
|
+
"--label", "role=worker", "--label", "tier=compute",
|
|
107
|
+
"--taint", "dedicated=gpu:NoExecute",
|
|
108
|
+
"--ssh-key-id", "11111111-1111-1111-1111-111111111111",
|
|
109
|
+
])
|
|
110
|
+
|
|
111
|
+
assert result.exit_code == 0, result.output
|
|
112
|
+
(args, kwargs), = kube.calls
|
|
113
|
+
assert args == ("c1", "workers", "k8s-node-2c4g", 3)
|
|
114
|
+
assert kwargs["labels"] == {"role": "worker", "tier": "compute"}
|
|
115
|
+
assert kwargs["taints"] == [{"key": "dedicated", "value": "gpu", "effect": "NoExecute"}]
|
|
116
|
+
assert kwargs["ssh_key_ids"] == ["11111111-1111-1111-1111-111111111111"]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_add_pool_defaults_to_no_labels_taints_or_keys(monkeypatch):
|
|
120
|
+
kube = FakeKubernetes()
|
|
121
|
+
patch_client(monkeypatch, kube)
|
|
122
|
+
|
|
123
|
+
result = runner.invoke(app, ["k8s", "add-pool", "c1", "workers", "--plan", "k8s-node-2c4g"])
|
|
124
|
+
|
|
125
|
+
assert result.exit_code == 0, result.output
|
|
126
|
+
(args, kwargs), = kube.calls
|
|
127
|
+
assert args == ("c1", "workers", "k8s-node-2c4g", 1)
|
|
128
|
+
assert kwargs == {"labels": {}, "taints": [], "ssh_key_ids": []}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_add_pool_rejects_malformed_label_and_taint(monkeypatch):
|
|
132
|
+
patch_client(monkeypatch, FakeKubernetes())
|
|
133
|
+
|
|
134
|
+
for value in ("novalue", "=noKey"):
|
|
135
|
+
result = runner.invoke(app, ["k8s", "add-pool", "c1", "workers", "--plan", "k8s-node-2c4g", "--label", value])
|
|
136
|
+
assert result.exit_code != 0
|
|
137
|
+
assert "key=value" in result.output
|
|
138
|
+
|
|
139
|
+
for value in ("novalue", "key:NoSchedule", "key=value:Sometimes"):
|
|
140
|
+
result = runner.invoke(app, ["k8s", "add-pool", "c1", "workers", "--plan", "k8s-node-2c4g", "--taint", value])
|
|
141
|
+
assert result.exit_code != 0
|
|
142
|
+
assert "key=value:Effect" in result.output or "taint effect" in result.output
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_kubeconfig_output_file_is_0600_for_new_and_existing_files(monkeypatch, tmp_path):
|
|
146
|
+
patch_client(monkeypatch, FakeKubernetes())
|
|
147
|
+
|
|
148
|
+
existing = tmp_path / "kubeconfig-existing.yaml"
|
|
149
|
+
existing.write_text("stale-content")
|
|
150
|
+
os.chmod(existing, 0o644)
|
|
151
|
+
|
|
152
|
+
for target in (existing, tmp_path / "kubeconfig-new.yaml"):
|
|
153
|
+
result = runner.invoke(app, ["k8s", "kubeconfig", "c1", "-o", str(target)])
|
|
154
|
+
assert result.exit_code == 0, result.output
|
|
155
|
+
assert stat.S_IMODE(target.stat().st_mode) == 0o600
|
|
156
|
+
assert target.read_text() == "apiVersion: v1\nkind: Config\n"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_maintenance_window_set_passes_all_options(monkeypatch):
|
|
160
|
+
kube = FakeKubernetes()
|
|
161
|
+
patch_client(monkeypatch, kube)
|
|
162
|
+
|
|
163
|
+
result = runner.invoke(app, ["k8s", "maintenance-window", "set", "c1",
|
|
164
|
+
"--timezone", "Europe/Oslo", "--day", "SAT", "--day", "SUN",
|
|
165
|
+
"--start", "02:00", "--duration", "120"])
|
|
166
|
+
|
|
167
|
+
assert result.exit_code == 0, result.output
|
|
168
|
+
(args, kwargs), = kube.calls
|
|
169
|
+
assert args == ("c1",)
|
|
170
|
+
assert kwargs == {"timezone": "Europe/Oslo", "days": ["SAT", "SUN"], "start": "02:00",
|
|
171
|
+
"duration_minutes": 120, "enabled": True}
|
|
172
|
+
assert "2026-10-24T02:00:00+02:00" in result.output
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_maintenance_window_set_disabled_and_get(monkeypatch):
|
|
176
|
+
kube = FakeKubernetes()
|
|
177
|
+
patch_client(monkeypatch, kube)
|
|
178
|
+
|
|
179
|
+
result = runner.invoke(app, ["k8s", "maintenance-window", "set", "c1", "--timezone", "UTC",
|
|
180
|
+
"--day", "MON", "--start", "03:00", "--disabled"])
|
|
181
|
+
assert result.exit_code == 0, result.output
|
|
182
|
+
assert kube.calls[0][1]["enabled"] is False
|
|
183
|
+
assert kube.calls[0][1]["duration_minutes"] == 120 # default
|
|
184
|
+
|
|
185
|
+
kube.window = {"enabled": True, "timezone": "Europe/Oslo", "days": ["SAT"], "start": "02:00",
|
|
186
|
+
"duration_minutes": 120, "next_window_start": "2026-10-24T02:00:00+02:00",
|
|
187
|
+
"next_window_end": "2026-10-24T04:00:00+02:00"}
|
|
188
|
+
result = runner.invoke(app, ["k8s", "maintenance-window", "get", "c1"])
|
|
189
|
+
assert result.exit_code == 0, result.output
|
|
190
|
+
assert "Europe/Oslo" in result.output
|
|
191
|
+
assert "SAT" in result.output
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_maintenance_window_delete(monkeypatch):
|
|
195
|
+
kube = FakeKubernetes()
|
|
196
|
+
patch_client(monkeypatch, kube)
|
|
197
|
+
result = runner.invoke(app, ["k8s", "maintenance-window", "delete", "c1"])
|
|
198
|
+
assert result.exit_code == 0, result.output
|
|
199
|
+
assert "removed" in result.output.lower()
|
|
200
|
+
assert kube.calls[0][0] == ("c1",)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_upgrade_mode_options_and_queued_output(monkeypatch):
|
|
204
|
+
kube = FakeKubernetes()
|
|
205
|
+
kube.upgrade_response = {"job": {"id": "j1", "target_version": "1.35", "mode": "next_maintenance_window",
|
|
206
|
+
"status": "queued", "execute_after": "2026-10-03T00:00:00+00:00"}}
|
|
207
|
+
patch_client(monkeypatch, kube)
|
|
208
|
+
|
|
209
|
+
result = runner.invoke(app, ["k8s", "upgrade", "c1", "1.35", "--mode", "next-maintenance-window",
|
|
210
|
+
"--strategy", "rolling-update", "--backup-first"])
|
|
211
|
+
|
|
212
|
+
assert result.exit_code == 0, result.output
|
|
213
|
+
(args, kwargs), = kube.calls
|
|
214
|
+
assert args == ("c1", "1.35")
|
|
215
|
+
assert kwargs == {"mode": "next_maintenance_window", "scheduled_at": None,
|
|
216
|
+
"strategy": "rolling-update", "backup_before_upgrade": True}
|
|
217
|
+
assert "queued" in result.output
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_upgrade_defaults_to_rolling_after_qualification(monkeypatch):
|
|
221
|
+
kube = FakeKubernetes()
|
|
222
|
+
patch_client(monkeypatch, kube)
|
|
223
|
+
|
|
224
|
+
result = runner.invoke(app, ["k8s", "upgrade", "c1", "1.35"])
|
|
225
|
+
|
|
226
|
+
assert result.exit_code == 0, result.output
|
|
227
|
+
(args, kwargs), = kube.calls
|
|
228
|
+
assert kwargs == {"mode": "immediate", "scheduled_at": None, "strategy": "rolling-update", "backup_before_upgrade": False}
|
|
229
|
+
assert "Upgrading to 1.35" in result.output
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def test_upgrade_pool_command(monkeypatch):
|
|
233
|
+
kube = FakeKubernetes()
|
|
234
|
+
patch_client(monkeypatch, kube)
|
|
235
|
+
|
|
236
|
+
result = runner.invoke(app, ["k8s", "upgrade-pool", "c1", "default"])
|
|
237
|
+
|
|
238
|
+
assert result.exit_code == 0, result.output
|
|
239
|
+
assert kube.calls[0][0] == ("c1", "default")
|
|
240
|
+
assert "convergence started" in result.output
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_versions_command_shows_lifecycle(monkeypatch):
|
|
244
|
+
kube = FakeKubernetes()
|
|
245
|
+
patch_client(monkeypatch, kube)
|
|
246
|
+
|
|
247
|
+
result = runner.invoke(app, ["k8s", "versions"])
|
|
248
|
+
|
|
249
|
+
assert result.exit_code == 0, result.output
|
|
250
|
+
assert "1.34" in result.output and "transition" in result.output
|
|
251
|
+
assert "2026-10-15" in result.output
|
|
252
|
+
assert "Default for new clusters: 1.35" in result.output
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_upgrade_jobs_list_and_cancel(monkeypatch):
|
|
256
|
+
kube = FakeKubernetes()
|
|
257
|
+
kube.jobs = [{"id": "j1", "target_version": "1.35", "mode": "scheduled", "status": "queued",
|
|
258
|
+
"execute_after": "2026-10-03T00:30:00+00:00", "window_end": "2026-10-03T02:00:00+00:00"}]
|
|
259
|
+
patch_client(monkeypatch, kube)
|
|
260
|
+
|
|
261
|
+
result = runner.invoke(app, ["k8s", "upgrade-jobs", "list", "c1"])
|
|
262
|
+
assert result.exit_code == 0, result.output
|
|
263
|
+
assert "queued" in result.output
|
|
264
|
+
|
|
265
|
+
result = runner.invoke(app, ["k8s", "upgrade-jobs", "cancel", "c1", "j1"])
|
|
266
|
+
assert result.exit_code == 0, result.output
|
|
267
|
+
assert "cancelled" in result.output.lower()
|
|
268
|
+
assert kube.calls[-1][0] == ("c1", "j1")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_preflight_passes_options_and_reports_warnings(monkeypatch):
|
|
272
|
+
kube = FakeKubernetes()
|
|
273
|
+
kube.preflight_response = {"ok": True, "errors": [],
|
|
274
|
+
"warnings": [{"code": "no_recent_backup", "message": "backup is old"}]}
|
|
275
|
+
patch_client(monkeypatch, kube)
|
|
276
|
+
|
|
277
|
+
result = runner.invoke(app, ["k8s", "preflight", "c1", "1.35", "--mode", "next-maintenance-window",
|
|
278
|
+
"--strategy", "rolling-update", "--backup-first"])
|
|
279
|
+
|
|
280
|
+
assert result.exit_code == 0, result.output
|
|
281
|
+
(args, kwargs), = kube.calls
|
|
282
|
+
assert args == ("c1", "1.35")
|
|
283
|
+
assert kwargs == {"mode": "next_maintenance_window", "scheduled_at": None,
|
|
284
|
+
"strategy": "rolling-update", "backup_before_upgrade": True}
|
|
285
|
+
assert "WARNING [no_recent_backup]" in result.output
|
|
286
|
+
assert "Preflight OK" in result.output
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def test_preflight_blockers_exit_nonzero(monkeypatch):
|
|
290
|
+
kube = FakeKubernetes()
|
|
291
|
+
kube.preflight_response = {"ok": False, "errors": [{"code": "node_pool_not_ready", "message": "pool not ready"}],
|
|
292
|
+
"warnings": []}
|
|
293
|
+
patch_client(monkeypatch, kube)
|
|
294
|
+
|
|
295
|
+
result = runner.invoke(app, ["k8s", "preflight", "c1", "1.35"])
|
|
296
|
+
|
|
297
|
+
assert result.exit_code == 1, result.output
|
|
298
|
+
assert "BLOCKER [node_pool_not_ready]" in result.output
|
|
299
|
+
assert "Preflight BLOCKED" in result.output
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def test_preflight_json_mode_prints_full_response_and_still_fails(monkeypatch):
|
|
303
|
+
import json as json_mod
|
|
304
|
+
from wayscloud_cli.output import set_json_mode
|
|
305
|
+
|
|
306
|
+
kube = FakeKubernetes()
|
|
307
|
+
kube.preflight_response = {"ok": False, "errors": [{"code": "target_not_available", "message": "nope"}],
|
|
308
|
+
"warnings": [], "backup": {"latest_completed_at": None, "age_hours": None}}
|
|
309
|
+
patch_client(monkeypatch, kube)
|
|
310
|
+
try:
|
|
311
|
+
result = runner.invoke(app, ["--json", "k8s", "preflight", "c1", "1.35"])
|
|
312
|
+
finally:
|
|
313
|
+
set_json_mode(False)
|
|
314
|
+
|
|
315
|
+
assert result.exit_code == 1
|
|
316
|
+
parsed = json_mod.loads(result.output)
|
|
317
|
+
assert parsed["errors"][0]["code"] == "target_not_available"
|
|
318
|
+
assert parsed["backup"] == {"latest_completed_at": None, "age_hours": None}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
|
+
import re
|
|
5
6
|
from unittest.mock import patch
|
|
6
7
|
|
|
7
8
|
from typer.testing import CliRunner
|
|
@@ -13,19 +14,28 @@ from wayscloud_cli.output import set_json_mode, is_json_mode
|
|
|
13
14
|
|
|
14
15
|
runner = CliRunner()
|
|
15
16
|
|
|
17
|
+
# Rich styles option names with ANSI codes when color is forced (CI sets
|
|
18
|
+
# FORCE_COLOR-like variables), splitting e.g. '--token' across sequences.
|
|
19
|
+
# Assertions read the plain text so they are environment-independent.
|
|
20
|
+
_ANSI = re.compile(r'\x1b\[[0-9;]*m')
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def plain(text):
|
|
24
|
+
return _ANSI.sub('', text)
|
|
25
|
+
|
|
16
26
|
|
|
17
27
|
# ── App structure ───────────────────────────────────────────────
|
|
18
28
|
|
|
19
29
|
def test_app_has_help():
|
|
20
30
|
result = runner.invoke(app, ["--help"])
|
|
21
31
|
assert result.exit_code == 0
|
|
22
|
-
assert "WAYSCloud CLI" in result.output
|
|
32
|
+
assert "WAYSCloud CLI" in plain(result.output)
|
|
23
33
|
|
|
24
34
|
|
|
25
35
|
def test_version_flag():
|
|
26
36
|
result = runner.invoke(app, ["--version"])
|
|
27
37
|
assert result.exit_code == 0
|
|
28
|
-
assert __version__ in result.output
|
|
38
|
+
assert __version__ in plain(result.output)
|
|
29
39
|
|
|
30
40
|
|
|
31
41
|
# ── Command groups registered ───────────────────────────────────
|
|
@@ -36,7 +46,7 @@ EXPECTED_GROUPS = ["auth", "vps", "dns", "storage", "db", "redis", "app", "iot",
|
|
|
36
46
|
def test_all_command_groups_registered():
|
|
37
47
|
result = runner.invoke(app, ["--help"])
|
|
38
48
|
for group in EXPECTED_GROUPS:
|
|
39
|
-
assert group in result.output, f"Command group '{group}' not in help output"
|
|
49
|
+
assert group in plain(result.output), f"Command group '{group}' not in help output"
|
|
40
50
|
|
|
41
51
|
|
|
42
52
|
def test_each_group_has_help():
|
|
@@ -50,7 +60,7 @@ def test_each_group_has_help():
|
|
|
50
60
|
def test_login_shortcut_exists():
|
|
51
61
|
result = runner.invoke(app, ["login", "--help"])
|
|
52
62
|
assert result.exit_code == 0
|
|
53
|
-
assert "token" in result.output.lower()
|
|
63
|
+
assert "token" in plain(result.output).lower()
|
|
54
64
|
|
|
55
65
|
|
|
56
66
|
def test_whoami_shortcut_exists():
|
|
@@ -110,14 +120,14 @@ def test_json_flag_accepted():
|
|
|
110
120
|
def test_vps_list_help():
|
|
111
121
|
result = runner.invoke(app, ["vps", "list", "--help"])
|
|
112
122
|
assert result.exit_code == 0
|
|
113
|
-
assert "--token" in result.output
|
|
123
|
+
assert "--token" in plain(result.output)
|
|
114
124
|
|
|
115
125
|
|
|
116
126
|
def test_vps_create_requires_flags():
|
|
117
127
|
result = runner.invoke(app, ["vps", "create", "--help"])
|
|
118
128
|
assert result.exit_code == 0
|
|
119
129
|
for flag in ["--hostname", "--plan", "--region", "--os"]:
|
|
120
|
-
assert flag in result.output, f"Missing {flag} in vps create"
|
|
130
|
+
assert flag in plain(result.output), f"Missing {flag} in vps create"
|
|
121
131
|
|
|
122
132
|
|
|
123
133
|
# ── DNS commands ────────────────────────────────────────────────
|
|
@@ -125,8 +135,8 @@ def test_vps_create_requires_flags():
|
|
|
125
135
|
def test_dns_records_create_has_type_flag():
|
|
126
136
|
result = runner.invoke(app, ["dns", "records-create", "--help"])
|
|
127
137
|
assert result.exit_code == 0
|
|
128
|
-
assert "--type" in result.output
|
|
129
|
-
assert "--value" in result.output
|
|
138
|
+
assert "--type" in plain(result.output)
|
|
139
|
+
assert "--value" in plain(result.output)
|
|
130
140
|
|
|
131
141
|
|
|
132
142
|
# ── DB commands ─────────────────────────────────────────────────
|
|
@@ -134,7 +144,7 @@ def test_dns_records_create_has_type_flag():
|
|
|
134
144
|
def test_db_create_has_type_flag():
|
|
135
145
|
result = runner.invoke(app, ["db", "create", "--help"])
|
|
136
146
|
assert result.exit_code == 0
|
|
137
|
-
assert "--type" in result.output
|
|
147
|
+
assert "--type" in plain(result.output)
|
|
138
148
|
|
|
139
149
|
|
|
140
150
|
# ── IoT commands ────────────────────────────────────────────────
|
|
@@ -142,8 +152,8 @@ def test_db_create_has_type_flag():
|
|
|
142
152
|
def test_iot_devices_create_has_required_flags():
|
|
143
153
|
result = runner.invoke(app, ["iot", "devices-create", "--help"])
|
|
144
154
|
assert result.exit_code == 0
|
|
145
|
-
assert "--device-id" in result.output
|
|
146
|
-
assert "--name" in result.output
|
|
155
|
+
assert "--device-id" in plain(result.output)
|
|
156
|
+
assert "--name" in plain(result.output)
|
|
147
157
|
|
|
148
158
|
|
|
149
159
|
# ── Impact commands ─────────────────────────────────────────────
|
|
@@ -153,27 +163,27 @@ def test_impact_subgroups_registered():
|
|
|
153
163
|
result = runner.invoke(app, ["impact", "--help"])
|
|
154
164
|
assert result.exit_code == 0
|
|
155
165
|
for sub in ("forest", "tree", "commitments"):
|
|
156
|
-
assert sub in result.output, f"Missing impact subgroup: {sub}"
|
|
166
|
+
assert sub in plain(result.output), f"Missing impact subgroup: {sub}"
|
|
157
167
|
|
|
158
168
|
|
|
159
169
|
def test_impact_tree_grow_has_count_flag():
|
|
160
170
|
result = runner.invoke(app, ["impact", "tree", "grow", "--help"])
|
|
161
171
|
assert result.exit_code == 0
|
|
162
|
-
assert "--count" in result.output
|
|
163
|
-
assert "--idempotency-key" in result.output
|
|
172
|
+
assert "--count" in plain(result.output)
|
|
173
|
+
assert "--idempotency-key" in plain(result.output)
|
|
164
174
|
|
|
165
175
|
|
|
166
176
|
def test_impact_forest_create_help():
|
|
167
177
|
result = runner.invoke(app, ["impact", "forest", "create", "--help"])
|
|
168
178
|
assert result.exit_code == 0
|
|
169
179
|
# Argument should be the forest name; --token override always accepted.
|
|
170
|
-
assert "--token" in result.output
|
|
180
|
+
assert "--token" in plain(result.output)
|
|
171
181
|
|
|
172
182
|
|
|
173
183
|
def test_impact_forest_status_has_visualize():
|
|
174
184
|
result = runner.invoke(app, ["impact", "forest", "status", "--help"])
|
|
175
185
|
assert result.exit_code == 0
|
|
176
|
-
assert "--visualize" in result.output
|
|
186
|
+
assert "--visualize" in plain(result.output)
|
|
177
187
|
|
|
178
188
|
|
|
179
189
|
def test_impact_forbidden_strings_not_in_help():
|
|
@@ -194,6 +204,6 @@ def test_impact_forbidden_strings_not_in_help():
|
|
|
194
204
|
result = runner.invoke(app, sub_path)
|
|
195
205
|
assert result.exit_code == 0, f"help failed for {sub_path}"
|
|
196
206
|
for s in forbidden:
|
|
197
|
-
assert s not in result.output, (
|
|
207
|
+
assert s not in plain(result.output), (
|
|
198
208
|
f"Forbidden string {s!r} appeared in help for {sub_path}"
|
|
199
209
|
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.6.0"
|
|
@@ -12,6 +12,14 @@ from ..output import print_table, print_object, print_json, is_json_mode
|
|
|
12
12
|
|
|
13
13
|
app = typer.Typer(help="Managed Kubernetes")
|
|
14
14
|
|
|
15
|
+
# Sub-app for the recurring upgrade window (#1162)
|
|
16
|
+
window_app = typer.Typer(help="Recurring maintenance window for scheduled upgrades")
|
|
17
|
+
app.add_typer(window_app, name="maintenance-window")
|
|
18
|
+
|
|
19
|
+
# Sub-app for scheduled upgrade jobs (#1163)
|
|
20
|
+
jobs_app = typer.Typer(help="Scheduled upgrade jobs (list, cancel)")
|
|
21
|
+
app.add_typer(jobs_app, name="upgrade-jobs")
|
|
22
|
+
|
|
15
23
|
CLUSTER_COLUMNS = [
|
|
16
24
|
("id", "ID", 38), ("name", "Name", 20), ("status", "Status", 13), ("version", "Version", 8),
|
|
17
25
|
("region", "Region", 7), ("nodes", "Nodes", 6), ("created_at", "Created", 20),
|
|
@@ -76,6 +84,25 @@ def plans(region: str = typer.Option("no", "--region"), kind: Optional[str] = ty
|
|
|
76
84
|
print_table(rows, [("plan_code", "Plan", 18), ("kind", "Kind", 13), ("name", "Name", 22), ("spec", "Spec", 24), ("monthly", "Monthly", 14)])
|
|
77
85
|
|
|
78
86
|
|
|
87
|
+
@app.command("versions")
|
|
88
|
+
def versions(token: Optional[str] = typer.Option(None, "--token")):
|
|
89
|
+
"""Kubernetes version lifecycle: status, cutoffs and deadlines."""
|
|
90
|
+
data = sdk_call(get_client(token).kubernetes.versions)
|
|
91
|
+
if is_json_mode():
|
|
92
|
+
print_json(data); return
|
|
93
|
+
rows = [{"version": v["version"], "status": v["status"],
|
|
94
|
+
"create_until": v.get("create_until") or "—",
|
|
95
|
+
"support_ends": v.get("support_ends_at") or "—",
|
|
96
|
+
"upgrade_by": v.get("upgrade_required_by") or "—",
|
|
97
|
+
"recommended": v.get("recommended_upgrade") or "—"}
|
|
98
|
+
for v in data.get("versions", [])]
|
|
99
|
+
print_table(rows, [("version", "Version", 8), ("status", "Status", 14), ("create_until", "Create until", 13),
|
|
100
|
+
("support_ends", "Support ends", 13), ("upgrade_by", "Upgrade by", 11),
|
|
101
|
+
("recommended", "Recommended", 12)])
|
|
102
|
+
if data.get("default"):
|
|
103
|
+
print(f"Default for new clusters: {data['default']}")
|
|
104
|
+
|
|
105
|
+
|
|
79
106
|
@app.command("list")
|
|
80
107
|
def list_clusters(token: Optional[str] = typer.Option(None, "--token")):
|
|
81
108
|
"""List clusters."""
|
|
@@ -93,7 +120,7 @@ def create(
|
|
|
93
120
|
pool: List[str] = typer.Option(["k8s-node-2c4g:2"], "--pool", help="Node pool: 'plan:count' or 'name=…,plan=…,count=…' (repeatable)"),
|
|
94
121
|
plan: str = typer.Option("k8s-cluster-dev", "--plan", help="k8s-cluster-dev | k8s-cluster-prod"),
|
|
95
122
|
region: str = typer.Option("no", "--region"),
|
|
96
|
-
version: str = typer.Option("1.
|
|
123
|
+
version: str = typer.Option("1.35", "--version"),
|
|
97
124
|
allow: List[str] = typer.Option([], "--allow", help="CIDR allowed to reach the API (repeatable)"),
|
|
98
125
|
wait: bool = typer.Option(False, "--wait", help="Block until the cluster is running"),
|
|
99
126
|
token: Optional[str] = typer.Option(None, "--token"),
|
|
@@ -179,6 +206,13 @@ def delete_pool(cluster_id: str = typer.Argument(...), name: str = typer.Argumen
|
|
|
179
206
|
print_json(c) if is_json_mode() else print(f"Pool {name} deleted.")
|
|
180
207
|
|
|
181
208
|
|
|
209
|
+
@app.command("upgrade-pool")
|
|
210
|
+
def upgrade_pool(cluster_id: str = typer.Argument(...), name: str = typer.Argument(...), token: Optional[str] = typer.Option(None, "--token")):
|
|
211
|
+
"""Converge a node pool's nodes to the cluster's Kubernetes version."""
|
|
212
|
+
c = sdk_call(get_client(token).kubernetes.upgrade_node_pool, cluster_id, name)
|
|
213
|
+
print_json(c) if is_json_mode() else print(f"Pool {name} convergence started; workers follow in the background.")
|
|
214
|
+
|
|
215
|
+
|
|
182
216
|
@app.command("api-access")
|
|
183
217
|
def api_access(cluster_id: str = typer.Argument(...), cidr: List[str] = typer.Option([], "--cidr", help="Allowed CIDR (repeatable); an empty list allows only WAYSCloud access"),
|
|
184
218
|
token: Optional[str] = typer.Option(None, "--token")):
|
|
@@ -236,15 +270,82 @@ def restore(cluster_id: str = typer.Argument(...), backup_id: str = typer.Argume
|
|
|
236
270
|
|
|
237
271
|
@app.command("upgrade")
|
|
238
272
|
def upgrade(cluster_id: str = typer.Argument(...), version: Optional[str] = typer.Argument(None, help="Target version; omit to list"),
|
|
273
|
+
mode: str = typer.Option("immediate", "--mode", help="immediate | next-maintenance-window | scheduled"),
|
|
274
|
+
at: Optional[str] = typer.Option(None, "--at", help="scheduled_at (ISO-8601 with offset); required with --mode scheduled"),
|
|
275
|
+
strategy: Optional[str] = typer.Option("rolling-update", "--strategy", help="rolling-update (default) | manual"),
|
|
276
|
+
backup_first: bool = typer.Option(False, "--backup-first", help="Take a backup first and continue when it completes"),
|
|
239
277
|
token: Optional[str] = typer.Option(None, "--token")):
|
|
240
|
-
"""List available upgrades or upgrade
|
|
278
|
+
"""List available upgrades or request an upgrade (immediate, next window or scheduled)."""
|
|
241
279
|
c = get_client(token)
|
|
242
280
|
if not version:
|
|
243
281
|
v = sdk_call(c.kubernetes.available_upgrades, cluster_id)
|
|
244
282
|
print_json(v) if is_json_mode() else print("Available: " + (", ".join(v) or "none"))
|
|
245
283
|
return
|
|
246
|
-
r = sdk_call(c.kubernetes.upgrade, cluster_id, version)
|
|
247
|
-
|
|
284
|
+
r = sdk_call(c.kubernetes.upgrade, cluster_id, version, mode=mode.strip().lower().replace("-", "_"),
|
|
285
|
+
scheduled_at=at, strategy=strategy, backup_before_upgrade=backup_first)
|
|
286
|
+
if is_json_mode():
|
|
287
|
+
print_json(r); return
|
|
288
|
+
if isinstance(r, dict) and "job" in r:
|
|
289
|
+
j = r["job"]
|
|
290
|
+
line = f"Upgrade {j['target_version']} queued ({j['mode']}): {j['status']}"
|
|
291
|
+
if j.get("execute_after"):
|
|
292
|
+
line += f", starts {j['execute_after']}"
|
|
293
|
+
print(line)
|
|
294
|
+
else:
|
|
295
|
+
print(f"Upgrading to {version}. Brief API interruptions may occur during the control-plane restart.")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@app.command("preflight")
|
|
299
|
+
def preflight(cluster_id: str = typer.Argument(...), version: str = typer.Argument(..., help="Target version to check"),
|
|
300
|
+
mode: str = typer.Option("immediate", "--mode", help="immediate | next-maintenance-window | scheduled"),
|
|
301
|
+
at: Optional[str] = typer.Option(None, "--at", help="scheduled_at (ISO-8601 with offset); required with --mode scheduled"),
|
|
302
|
+
strategy: Optional[str] = typer.Option("rolling-update", "--strategy", help="rolling-update (default) | manual"),
|
|
303
|
+
backup_first: bool = typer.Option(False, "--backup-first", help="Check as if a fresh backup is taken first"),
|
|
304
|
+
token: Optional[str] = typer.Option(None, "--token")):
|
|
305
|
+
"""Pre-upgrade check: blockers and advisory warnings before requesting an upgrade.
|
|
306
|
+
|
|
307
|
+
Read-only. Exits non-zero when the check reports blockers, so pipelines can
|
|
308
|
+
gate on it."""
|
|
309
|
+
r = sdk_call(get_client(token).kubernetes.upgrade_preflight, cluster_id, version,
|
|
310
|
+
mode=mode.strip().lower().replace("-", "_"), scheduled_at=at, strategy=strategy,
|
|
311
|
+
backup_before_upgrade=backup_first)
|
|
312
|
+
if is_json_mode():
|
|
313
|
+
print_json(r)
|
|
314
|
+
if not r.get("ok"):
|
|
315
|
+
raise typer.Exit(code=1)
|
|
316
|
+
return
|
|
317
|
+
for e in r.get("errors") or []:
|
|
318
|
+
print(f"BLOCKER [{e.get('code')}] {e.get('message')}")
|
|
319
|
+
for w in r.get("warnings") or []:
|
|
320
|
+
print(f"WARNING [{w.get('code')}] {w.get('message')}")
|
|
321
|
+
if r.get("ok"):
|
|
322
|
+
print("Preflight OK" + (" — warnings are advisory" if r.get("warnings") else ""))
|
|
323
|
+
else:
|
|
324
|
+
print("Preflight BLOCKED — resolve the blockers above, or re-check later.")
|
|
325
|
+
raise typer.Exit(code=1)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
JOBS_COLUMNS = [("id", "ID", 10), ("target_version", "Version", 8), ("mode", "Mode", 24),
|
|
329
|
+
("status", "Status", 34), ("execute_after", "Execute after", 26), ("window_end", "Window end", 26)]
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@jobs_app.command("list")
|
|
333
|
+
def upgrade_jobs_list(cluster_id: str = typer.Argument(...), token: Optional[str] = typer.Option(None, "--token")):
|
|
334
|
+
"""List scheduled/queued upgrade jobs for a cluster."""
|
|
335
|
+
jobs = sdk_call(get_client(token).kubernetes.upgrade_jobs, cluster_id)
|
|
336
|
+
if is_json_mode():
|
|
337
|
+
print_json(jobs); return
|
|
338
|
+
if not jobs:
|
|
339
|
+
print("No upgrade jobs."); return
|
|
340
|
+
print_table([{**j, "id": str(j["id"])[:8]} for j in jobs], JOBS_COLUMNS)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@jobs_app.command("cancel")
|
|
344
|
+
def upgrade_jobs_cancel(cluster_id: str = typer.Argument(...), job_id: str = typer.Argument(...),
|
|
345
|
+
token: Optional[str] = typer.Option(None, "--token")):
|
|
346
|
+
"""Cancel a queued or preflight upgrade job."""
|
|
347
|
+
j = sdk_call(get_client(token).kubernetes.cancel_upgrade_job, cluster_id, job_id)
|
|
348
|
+
print_json(j) if is_json_mode() else print(f"Upgrade job {job_id} cancelled.")
|
|
248
349
|
|
|
249
350
|
|
|
250
351
|
@app.command("delete")
|
|
@@ -256,3 +357,42 @@ def delete(cluster_id: str = typer.Argument(...), confirm_name: str = typer.Opti
|
|
|
256
357
|
if wait:
|
|
257
358
|
r = sdk_call(c.kubernetes.wait, cluster_id)
|
|
258
359
|
print_json(r) if is_json_mode() else print(f"Cluster {cluster_id} is {r.get('status')}.")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
WINDOW_COLUMNS = [
|
|
363
|
+
("enabled", "Enabled"), ("timezone", "Timezone"), ("days", "Days"), ("start", "Start"),
|
|
364
|
+
("duration_minutes", "Duration (min)"), ("next_window_start", "Next start"), ("next_window_end", "Next end"),
|
|
365
|
+
]
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@window_app.command("get")
|
|
369
|
+
def maintenance_window_get(cluster_id: str = typer.Argument(...), token: Optional[str] = typer.Option(None, "--token")):
|
|
370
|
+
"""Show the recurring upgrade window."""
|
|
371
|
+
w = sdk_call(get_client(token).kubernetes.get_maintenance_window, cluster_id)
|
|
372
|
+
if is_json_mode():
|
|
373
|
+
print_json(w); return
|
|
374
|
+
print_object({**w, "days": ", ".join(w.get("days") or [])}, WINDOW_COLUMNS)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
@window_app.command("set")
|
|
378
|
+
def maintenance_window_set(cluster_id: str = typer.Argument(...),
|
|
379
|
+
timezone: str = typer.Option(..., "--timezone", help="IANA timezone, e.g. Europe/Oslo"),
|
|
380
|
+
day: List[str] = typer.Option(..., "--day", help="Weekday MON..SUN (repeatable)"),
|
|
381
|
+
start: str = typer.Option(..., "--start", help="Local start time HH:MM"),
|
|
382
|
+
duration: int = typer.Option(120, "--duration", min=30, max=480, help="Window length in minutes (30–480)"),
|
|
383
|
+
disabled: bool = typer.Option(False, "--disabled", help="Store the window disabled"),
|
|
384
|
+
token: Optional[str] = typer.Option(None, "--token")):
|
|
385
|
+
"""Create or replace the recurring upgrade window."""
|
|
386
|
+
w = sdk_call(get_client(token).kubernetes.set_maintenance_window, cluster_id,
|
|
387
|
+
timezone=timezone, days=list(day), start=start, duration_minutes=duration, enabled=not disabled)
|
|
388
|
+
if is_json_mode():
|
|
389
|
+
print_json(w); return
|
|
390
|
+
print(f"Maintenance window set: {', '.join(w['days'])} {w['start']} ({w['duration_minutes']} min, {w['timezone']}).")
|
|
391
|
+
print(f"Next window: {w['next_window_start']} – {w['next_window_end']}")
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@window_app.command("delete")
|
|
395
|
+
def maintenance_window_delete(cluster_id: str = typer.Argument(...), token: Optional[str] = typer.Option(None, "--token")):
|
|
396
|
+
"""Remove the recurring upgrade window."""
|
|
397
|
+
w = sdk_call(get_client(token).kubernetes.delete_maintenance_window, cluster_id)
|
|
398
|
+
print_json(w) if is_json_mode() else print("Maintenance window removed.")
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wayscloud-cli
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Command-line interface for WAYSCloud — manage VPS, DNS, databases, storage, apps,
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: Command-line interface for WAYSCloud — manage VPS, DNS, databases, storage, apps, IoT, and more
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://wayscloud.services
|
|
7
7
|
Project-URL: Documentation, https://docs.wayscloud.services/integrations/#wayscloud-cli
|
|
@@ -20,7 +20,7 @@ Classifier: Operating System :: OS Independent
|
|
|
20
20
|
Requires-Python: >=3.9
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
22
22
|
License-File: LICENSE
|
|
23
|
-
Requires-Dist: wayscloud>=0.
|
|
23
|
+
Requires-Dist: wayscloud>=0.5.0
|
|
24
24
|
Requires-Dist: typer>=0.9
|
|
25
25
|
Requires-Dist: rich>=13.0
|
|
26
26
|
Requires-Dist: websockets>=12.0
|
|
@@ -36,7 +36,7 @@ Command-line interface for [WAYSCloud](https://wayscloud.services). Built on the
|
|
|
36
36
|
pip install wayscloud-cli
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
Version 0.
|
|
39
|
+
Version 0.6.0 adds the upgrade preflight (`cloud k8s preflight`, non-zero exit on blockers) and requires SDK `wayscloud>=0.5.0`.
|
|
40
40
|
|
|
41
41
|
## Authentication
|
|
42
42
|
|
|
@@ -121,6 +121,8 @@ cloud k8s list
|
|
|
121
121
|
cloud k8s create shop --pool k8s-node-2c4g:2 --plan k8s-cluster-dev --region no --wait
|
|
122
122
|
cloud k8s info <cluster-id>
|
|
123
123
|
cloud k8s kubeconfig <cluster-id> -o ~/.kube/shop.yaml # written with mode 0600
|
|
124
|
+
cloud k8s preflight <cluster-id> 1.35 # blockers/warnings; exit != 0 on blockers
|
|
125
|
+
cloud k8s upgrade <cluster-id> 1.35 --mode next-maintenance-window --backup-first
|
|
124
126
|
cloud k8s scale <cluster-id> default 4
|
|
125
127
|
cloud k8s add-pool <cluster-id> batch --plan k8s-node-4c16g --count 2 \
|
|
126
128
|
--label role=worker --taint dedicated=gpu:NoSchedule --ssh-key-id <key-id>
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
"""k8s command tests -- option parsing, payloads and file handling."""
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
import stat
|
|
5
|
-
|
|
6
|
-
from typer.testing import CliRunner
|
|
7
|
-
|
|
8
|
-
from wayscloud_cli.__main__ import app
|
|
9
|
-
from wayscloud_cli.commands import k8s as k8s_cmd
|
|
10
|
-
|
|
11
|
-
runner = CliRunner()
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
class FakeKubernetes:
|
|
15
|
-
def __init__(self):
|
|
16
|
-
self.calls = []
|
|
17
|
-
|
|
18
|
-
def add_node_pool(self, *args, **kwargs):
|
|
19
|
-
self.calls.append((args, kwargs))
|
|
20
|
-
return {"id": "c1"}
|
|
21
|
-
|
|
22
|
-
def kubeconfig(self, cluster_id):
|
|
23
|
-
return "apiVersion: v1\nkind: Config\n"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
class FakeClient:
|
|
27
|
-
def __init__(self, kubernetes):
|
|
28
|
-
self.kubernetes = kubernetes
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def patch_client(monkeypatch, kubernetes):
|
|
32
|
-
monkeypatch.setattr(k8s_cmd, "get_client", lambda token=None: FakeClient(kubernetes))
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def test_parse_labels_and_taints():
|
|
36
|
-
assert k8s_cmd._parse_labels([]) == {}
|
|
37
|
-
assert k8s_cmd._parse_labels(["role=worker", "zone=a=b"]) == {"role": "worker", "zone": "a=b"}
|
|
38
|
-
assert k8s_cmd._parse_taints([]) == []
|
|
39
|
-
assert k8s_cmd._parse_taints(["dedicated=gpu:NoSchedule", "spot=:PreferNoSchedule"]) == [
|
|
40
|
-
{"key": "dedicated", "value": "gpu", "effect": "NoSchedule"},
|
|
41
|
-
{"key": "spot", "value": "", "effect": "PreferNoSchedule"},
|
|
42
|
-
]
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def test_add_pool_passes_labels_taints_and_ssh_keys(monkeypatch):
|
|
46
|
-
kube = FakeKubernetes()
|
|
47
|
-
patch_client(monkeypatch, kube)
|
|
48
|
-
|
|
49
|
-
result = runner.invoke(app, [
|
|
50
|
-
"k8s", "add-pool", "c1", "workers", "--plan", "k8s-node-2c4g", "--count", "3",
|
|
51
|
-
"--label", "role=worker", "--label", "tier=compute",
|
|
52
|
-
"--taint", "dedicated=gpu:NoExecute",
|
|
53
|
-
"--ssh-key-id", "11111111-1111-1111-1111-111111111111",
|
|
54
|
-
])
|
|
55
|
-
|
|
56
|
-
assert result.exit_code == 0, result.output
|
|
57
|
-
(args, kwargs), = kube.calls
|
|
58
|
-
assert args == ("c1", "workers", "k8s-node-2c4g", 3)
|
|
59
|
-
assert kwargs["labels"] == {"role": "worker", "tier": "compute"}
|
|
60
|
-
assert kwargs["taints"] == [{"key": "dedicated", "value": "gpu", "effect": "NoExecute"}]
|
|
61
|
-
assert kwargs["ssh_key_ids"] == ["11111111-1111-1111-1111-111111111111"]
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def test_add_pool_defaults_to_no_labels_taints_or_keys(monkeypatch):
|
|
65
|
-
kube = FakeKubernetes()
|
|
66
|
-
patch_client(monkeypatch, kube)
|
|
67
|
-
|
|
68
|
-
result = runner.invoke(app, ["k8s", "add-pool", "c1", "workers", "--plan", "k8s-node-2c4g"])
|
|
69
|
-
|
|
70
|
-
assert result.exit_code == 0, result.output
|
|
71
|
-
(args, kwargs), = kube.calls
|
|
72
|
-
assert args == ("c1", "workers", "k8s-node-2c4g", 1)
|
|
73
|
-
assert kwargs == {"labels": {}, "taints": [], "ssh_key_ids": []}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def test_add_pool_rejects_malformed_label_and_taint(monkeypatch):
|
|
77
|
-
patch_client(monkeypatch, FakeKubernetes())
|
|
78
|
-
|
|
79
|
-
for value in ("novalue", "=noKey"):
|
|
80
|
-
result = runner.invoke(app, ["k8s", "add-pool", "c1", "workers", "--plan", "k8s-node-2c4g", "--label", value])
|
|
81
|
-
assert result.exit_code != 0
|
|
82
|
-
assert "key=value" in result.output
|
|
83
|
-
|
|
84
|
-
for value in ("novalue", "key:NoSchedule", "key=value:Sometimes"):
|
|
85
|
-
result = runner.invoke(app, ["k8s", "add-pool", "c1", "workers", "--plan", "k8s-node-2c4g", "--taint", value])
|
|
86
|
-
assert result.exit_code != 0
|
|
87
|
-
assert "key=value:Effect" in result.output or "taint effect" in result.output
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def test_kubeconfig_output_file_is_0600_for_new_and_existing_files(monkeypatch, tmp_path):
|
|
91
|
-
patch_client(monkeypatch, FakeKubernetes())
|
|
92
|
-
|
|
93
|
-
existing = tmp_path / "kubeconfig-existing.yaml"
|
|
94
|
-
existing.write_text("stale-content")
|
|
95
|
-
os.chmod(existing, 0o644)
|
|
96
|
-
|
|
97
|
-
for target in (existing, tmp_path / "kubeconfig-new.yaml"):
|
|
98
|
-
result = runner.invoke(app, ["k8s", "kubeconfig", "c1", "-o", str(target)])
|
|
99
|
-
assert result.exit_code == 0, result.output
|
|
100
|
-
assert stat.S_IMODE(target.stat().st_mode) == 0o600
|
|
101
|
-
assert target.read_text() == "apiVersion: v1\nkind: Config\n"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.5.0"
|
|
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
|
|
File without changes
|
|
File without changes
|