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.
Files changed (32) hide show
  1. {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/PKG-INFO +6 -4
  2. {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/README.md +3 -1
  3. {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/pyproject.toml +3 -3
  4. {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/setup.cfg +1 -1
  5. wayscloud_cli-0.6.0/tests/test_k8s_cli.py +318 -0
  6. {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/tests/test_smoke.py +27 -17
  7. wayscloud_cli-0.6.0/wayscloud_cli/__init__.py +1 -0
  8. {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/k8s.py +144 -4
  9. {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli.egg-info/PKG-INFO +6 -4
  10. {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli.egg-info/requires.txt +1 -1
  11. wayscloud_cli-0.5.0/tests/test_k8s_cli.py +0 -101
  12. wayscloud_cli-0.5.0/wayscloud_cli/__init__.py +0 -1
  13. {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/LICENSE +0 -0
  14. {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli/__main__.py +0 -0
  15. {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/__init__.py +0 -0
  16. {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/app.py +0 -0
  17. {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/db.py +0 -0
  18. {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/dns.py +0 -0
  19. {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/impact.py +0 -0
  20. {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/iot.py +0 -0
  21. {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/login.py +0 -0
  22. {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/redis.py +0 -0
  23. {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/shell.py +0 -0
  24. {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/storage.py +0 -0
  25. {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/vps.py +0 -0
  26. {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli/config.py +0 -0
  27. {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli/output.py +0 -0
  28. {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli/sdk.py +0 -0
  29. {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli.egg-info/SOURCES.txt +0 -0
  30. {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli.egg-info/dependency_links.txt +0 -0
  31. {wayscloud_cli-0.5.0 → wayscloud_cli-0.6.0}/wayscloud_cli.egg-info/entry_points.txt +0 -0
  32. {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.5.0
4
- Summary: Command-line interface for WAYSCloud — manage VPS, DNS, databases, storage, apps, Kubernetes, IoT, and more
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.4.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.5.0 adds Managed Kubernetes (`cloud k8s …`) and requires SDK `wayscloud>=0.4.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.5.0 adds Managed Kubernetes (`cloud k8s …`) and requires SDK `wayscloud>=0.4.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.5.0"
8
- description = "Command-line interface for WAYSCloud — manage VPS, DNS, databases, storage, apps, Kubernetes, IoT, and more"
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.4.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.5.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}
@@ -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.34", "--version"),
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 to a version."""
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
- print_json(r) if is_json_mode() else print(f"Upgrading to {version}. No backup is taken automatically; create one first if needed.")
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.5.0
4
- Summary: Command-line interface for WAYSCloud — manage VPS, DNS, databases, storage, apps, Kubernetes, IoT, and more
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.4.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.5.0 adds Managed Kubernetes (`cloud k8s …`) and requires SDK `wayscloud>=0.4.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,4 +1,4 @@
1
- wayscloud>=0.4.0
1
+ wayscloud>=0.5.0
2
2
  typer>=0.9
3
3
  rich>=13.0
4
4
  websockets>=12.0
@@ -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