wayscloud-cli 0.4.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.
Files changed (31) hide show
  1. wayscloud_cli-0.6.0/LICENSE +21 -0
  2. {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/PKG-INFO +44 -4
  3. {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/README.md +40 -2
  4. {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/pyproject.toml +2 -2
  5. {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/setup.cfg +1 -1
  6. wayscloud_cli-0.6.0/tests/test_k8s_cli.py +318 -0
  7. {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/tests/test_smoke.py +75 -12
  8. wayscloud_cli-0.6.0/wayscloud_cli/__init__.py +1 -0
  9. {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli/__main__.py +4 -0
  10. {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/app.py +160 -13
  11. wayscloud_cli-0.6.0/wayscloud_cli/commands/impact.py +392 -0
  12. wayscloud_cli-0.6.0/wayscloud_cli/commands/k8s.py +398 -0
  13. {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli.egg-info/PKG-INFO +44 -4
  14. {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli.egg-info/SOURCES.txt +4 -0
  15. {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli.egg-info/requires.txt +1 -1
  16. wayscloud_cli-0.4.0/wayscloud_cli/__init__.py +0 -1
  17. {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/__init__.py +0 -0
  18. {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/db.py +0 -0
  19. {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/dns.py +0 -0
  20. {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/iot.py +0 -0
  21. {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/login.py +0 -0
  22. {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/redis.py +0 -0
  23. {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/shell.py +0 -0
  24. {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/storage.py +0 -0
  25. {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/vps.py +0 -0
  26. {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli/config.py +0 -0
  27. {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli/output.py +0 -0
  28. {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli/sdk.py +0 -0
  29. {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli.egg-info/dependency_links.txt +0 -0
  30. {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli.egg-info/entry_points.txt +0 -0
  31. {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli.egg-info/top_level.txt +0 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 WAYSCloud AS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wayscloud-cli
3
- Version: 0.4.0
3
+ Version: 0.6.0
4
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
@@ -19,10 +19,12 @@ Classifier: Programming Language :: Python :: 3.12
19
19
  Classifier: Operating System :: OS Independent
20
20
  Requires-Python: >=3.9
21
21
  Description-Content-Type: text/markdown
22
- Requires-Dist: wayscloud>=0.3.0
22
+ License-File: LICENSE
23
+ Requires-Dist: wayscloud>=0.5.0
23
24
  Requires-Dist: typer>=0.9
24
25
  Requires-Dist: rich>=13.0
25
26
  Requires-Dist: websockets>=12.0
27
+ Dynamic: license-file
26
28
 
27
29
  # WAYSCloud CLI
28
30
 
@@ -34,6 +36,8 @@ Command-line interface for [WAYSCloud](https://wayscloud.services). Built on the
34
36
  pip install wayscloud-cli
35
37
  ```
36
38
 
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
+
37
41
  ## Authentication
38
42
 
39
43
  ```bash
@@ -94,7 +98,8 @@ cloud redis plans
94
98
  cloud storage buckets
95
99
  cloud storage buckets-create my-bucket
96
100
  cloud storage buckets-delete my-bucket --confirm
97
- cloud storage credentials
101
+ cloud storage keys my-bucket
102
+ cloud storage keys-create my-bucket --name ci
98
103
  ```
99
104
 
100
105
  ### Apps
@@ -108,6 +113,32 @@ cloud app stop <id>
108
113
  cloud app delete <id> --confirm
109
114
  ```
110
115
 
116
+ ### Kubernetes
117
+
118
+ ```bash
119
+ cloud k8s plans --kind node
120
+ cloud k8s list
121
+ cloud k8s create shop --pool k8s-node-2c4g:2 --plan k8s-cluster-dev --region no --wait
122
+ cloud k8s info <cluster-id>
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
126
+ cloud k8s scale <cluster-id> default 4
127
+ cloud k8s add-pool <cluster-id> batch --plan k8s-node-4c16g --count 2 \
128
+ --label role=worker --taint dedicated=gpu:NoSchedule --ssh-key-id <key-id>
129
+ cloud k8s delete-pool <cluster-id> batch
130
+ cloud k8s api-access <cluster-id> --cidr 203.0.113.0/24
131
+ cloud k8s ip-allocate <cluster-id>
132
+ cloud k8s ip-release <cluster-id> <address>
133
+ cloud k8s ptr <cluster-id> <address> mail.example.com
134
+ cloud k8s backups <cluster-id>
135
+ cloud k8s backup <cluster-id>
136
+ cloud k8s restore <cluster-id> <backup-id> -n shop
137
+ cloud k8s upgrade <cluster-id> # list available versions
138
+ cloud k8s upgrade <cluster-id> 1.34 # upgrade to a version
139
+ cloud k8s delete <cluster-id> --confirm-name shop --wait
140
+ ```
141
+
111
142
  ### IoT
112
143
 
113
144
  ```bash
@@ -119,6 +150,15 @@ cloud iot groups
119
150
  cloud iot groups-create --name "Floor 2"
120
151
  ```
121
152
 
153
+ ### Impact
154
+
155
+ ```bash
156
+ cloud impact forest create "My forest"
157
+ cloud impact forest status --visualize emoji
158
+ cloud impact tree grow --count 10 --idempotency-key <key>
159
+ cloud impact commitments list
160
+ ```
161
+
122
162
  ### Shell
123
163
 
124
164
  ```bash
@@ -138,7 +178,7 @@ cloud vps list --json
138
178
  ## Requirements
139
179
 
140
180
  - Python 3.9+
141
- - wayscloud (SDK)
181
+ - [wayscloud](https://pypi.org/project/wayscloud/) SDK `>=0.4.0` (installed automatically; the Kubernetes commands need a release that exposes `client.kubernetes`)
142
182
 
143
183
  ## Documentation
144
184
 
@@ -8,6 +8,8 @@ Command-line interface for [WAYSCloud](https://wayscloud.services). Built on the
8
8
  pip install wayscloud-cli
9
9
  ```
10
10
 
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
+
11
13
  ## Authentication
12
14
 
13
15
  ```bash
@@ -68,7 +70,8 @@ cloud redis plans
68
70
  cloud storage buckets
69
71
  cloud storage buckets-create my-bucket
70
72
  cloud storage buckets-delete my-bucket --confirm
71
- cloud storage credentials
73
+ cloud storage keys my-bucket
74
+ cloud storage keys-create my-bucket --name ci
72
75
  ```
73
76
 
74
77
  ### Apps
@@ -82,6 +85,32 @@ cloud app stop <id>
82
85
  cloud app delete <id> --confirm
83
86
  ```
84
87
 
88
+ ### Kubernetes
89
+
90
+ ```bash
91
+ cloud k8s plans --kind node
92
+ cloud k8s list
93
+ cloud k8s create shop --pool k8s-node-2c4g:2 --plan k8s-cluster-dev --region no --wait
94
+ cloud k8s info <cluster-id>
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
98
+ cloud k8s scale <cluster-id> default 4
99
+ cloud k8s add-pool <cluster-id> batch --plan k8s-node-4c16g --count 2 \
100
+ --label role=worker --taint dedicated=gpu:NoSchedule --ssh-key-id <key-id>
101
+ cloud k8s delete-pool <cluster-id> batch
102
+ cloud k8s api-access <cluster-id> --cidr 203.0.113.0/24
103
+ cloud k8s ip-allocate <cluster-id>
104
+ cloud k8s ip-release <cluster-id> <address>
105
+ cloud k8s ptr <cluster-id> <address> mail.example.com
106
+ cloud k8s backups <cluster-id>
107
+ cloud k8s backup <cluster-id>
108
+ cloud k8s restore <cluster-id> <backup-id> -n shop
109
+ cloud k8s upgrade <cluster-id> # list available versions
110
+ cloud k8s upgrade <cluster-id> 1.34 # upgrade to a version
111
+ cloud k8s delete <cluster-id> --confirm-name shop --wait
112
+ ```
113
+
85
114
  ### IoT
86
115
 
87
116
  ```bash
@@ -93,6 +122,15 @@ cloud iot groups
93
122
  cloud iot groups-create --name "Floor 2"
94
123
  ```
95
124
 
125
+ ### Impact
126
+
127
+ ```bash
128
+ cloud impact forest create "My forest"
129
+ cloud impact forest status --visualize emoji
130
+ cloud impact tree grow --count 10 --idempotency-key <key>
131
+ cloud impact commitments list
132
+ ```
133
+
96
134
  ### Shell
97
135
 
98
136
  ```bash
@@ -112,7 +150,7 @@ cloud vps list --json
112
150
  ## Requirements
113
151
 
114
152
  - Python 3.9+
115
- - wayscloud (SDK)
153
+ - [wayscloud](https://pypi.org/project/wayscloud/) SDK `>=0.4.0` (installed automatically; the Kubernetes commands need a release that exposes `client.kubernetes`)
116
154
 
117
155
  ## Documentation
118
156
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wayscloud-cli"
7
- version = "0.4.0"
7
+ version = "0.6.0"
8
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"
@@ -22,7 +22,7 @@ classifiers = [
22
22
  "Operating System :: OS Independent",
23
23
  ]
24
24
  dependencies = [
25
- "wayscloud>=0.3.0",
25
+ "wayscloud>=0.5.0",
26
26
  "typer>=0.9",
27
27
  "rich>=13.0",
28
28
  "websockets>=12.0",
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = wayscloud-cli
3
- version = 0.4.0
3
+ version = 0.6.0
4
4
 
5
5
  [options]
6
6
  packages = find:
@@ -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}