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.
- wayscloud_cli-0.6.0/LICENSE +21 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/PKG-INFO +44 -4
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/README.md +40 -2
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/pyproject.toml +2 -2
- {wayscloud_cli-0.4.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.4.0 → wayscloud_cli-0.6.0}/tests/test_smoke.py +75 -12
- wayscloud_cli-0.6.0/wayscloud_cli/__init__.py +1 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli/__main__.py +4 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/app.py +160 -13
- wayscloud_cli-0.6.0/wayscloud_cli/commands/impact.py +392 -0
- wayscloud_cli-0.6.0/wayscloud_cli/commands/k8s.py +398 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli.egg-info/PKG-INFO +44 -4
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli.egg-info/SOURCES.txt +4 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli.egg-info/requires.txt +1 -1
- wayscloud_cli-0.4.0/wayscloud_cli/__init__.py +0 -1
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/__init__.py +0 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/db.py +0 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/dns.py +0 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/iot.py +0 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/login.py +0 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/redis.py +0 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/shell.py +0 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/storage.py +0 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli/commands/vps.py +0 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli/config.py +0 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli/output.py +0 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli/sdk.py +0 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli.egg-info/dependency_links.txt +0 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.6.0}/wayscloud_cli.egg-info/entry_points.txt +0 -0
- {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.
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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 (
|
|
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.
|
|
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.
|
|
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}
|