wayscloud-cli 0.4.0__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wayscloud_cli-0.5.0/LICENSE +21 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/PKG-INFO +43 -5
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/README.md +38 -2
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/pyproject.toml +3 -3
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/setup.cfg +1 -1
- wayscloud_cli-0.5.0/tests/test_k8s_cli.py +101 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/tests/test_smoke.py +54 -1
- wayscloud_cli-0.5.0/wayscloud_cli/__init__.py +1 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli/__main__.py +4 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli/commands/app.py +160 -13
- wayscloud_cli-0.5.0/wayscloud_cli/commands/impact.py +392 -0
- wayscloud_cli-0.5.0/wayscloud_cli/commands/k8s.py +258 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli.egg-info/PKG-INFO +43 -5
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli.egg-info/SOURCES.txt +4 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.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.5.0}/wayscloud_cli/commands/__init__.py +0 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli/commands/db.py +0 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli/commands/dns.py +0 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli/commands/iot.py +0 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli/commands/login.py +0 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli/commands/redis.py +0 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli/commands/shell.py +0 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli/commands/storage.py +0 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli/commands/vps.py +0 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli/config.py +0 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli/output.py +0 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli/sdk.py +0 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli.egg-info/dependency_links.txt +0 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.0}/wayscloud_cli.egg-info/entry_points.txt +0 -0
- {wayscloud_cli-0.4.0 → wayscloud_cli-0.5.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,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wayscloud-cli
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Command-line interface for WAYSCloud — manage VPS, DNS, databases, storage, apps, IoT, and more
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Command-line interface for WAYSCloud — manage VPS, DNS, databases, storage, apps, Kubernetes, 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
|
|
@@ -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.4.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.5.0 adds Managed Kubernetes (`cloud k8s …`) and requires SDK `wayscloud>=0.4.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,30 @@ 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 scale <cluster-id> default 4
|
|
125
|
+
cloud k8s add-pool <cluster-id> batch --plan k8s-node-4c16g --count 2 \
|
|
126
|
+
--label role=worker --taint dedicated=gpu:NoSchedule --ssh-key-id <key-id>
|
|
127
|
+
cloud k8s delete-pool <cluster-id> batch
|
|
128
|
+
cloud k8s api-access <cluster-id> --cidr 203.0.113.0/24
|
|
129
|
+
cloud k8s ip-allocate <cluster-id>
|
|
130
|
+
cloud k8s ip-release <cluster-id> <address>
|
|
131
|
+
cloud k8s ptr <cluster-id> <address> mail.example.com
|
|
132
|
+
cloud k8s backups <cluster-id>
|
|
133
|
+
cloud k8s backup <cluster-id>
|
|
134
|
+
cloud k8s restore <cluster-id> <backup-id> -n shop
|
|
135
|
+
cloud k8s upgrade <cluster-id> # list available versions
|
|
136
|
+
cloud k8s upgrade <cluster-id> 1.34 # upgrade to a version
|
|
137
|
+
cloud k8s delete <cluster-id> --confirm-name shop --wait
|
|
138
|
+
```
|
|
139
|
+
|
|
111
140
|
### IoT
|
|
112
141
|
|
|
113
142
|
```bash
|
|
@@ -119,6 +148,15 @@ cloud iot groups
|
|
|
119
148
|
cloud iot groups-create --name "Floor 2"
|
|
120
149
|
```
|
|
121
150
|
|
|
151
|
+
### Impact
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
cloud impact forest create "My forest"
|
|
155
|
+
cloud impact forest status --visualize emoji
|
|
156
|
+
cloud impact tree grow --count 10 --idempotency-key <key>
|
|
157
|
+
cloud impact commitments list
|
|
158
|
+
```
|
|
159
|
+
|
|
122
160
|
### Shell
|
|
123
161
|
|
|
124
162
|
```bash
|
|
@@ -138,7 +176,7 @@ cloud vps list --json
|
|
|
138
176
|
## Requirements
|
|
139
177
|
|
|
140
178
|
- Python 3.9+
|
|
141
|
-
- wayscloud (
|
|
179
|
+
- [wayscloud](https://pypi.org/project/wayscloud/) SDK `>=0.4.0` (installed automatically; the Kubernetes commands need a release that exposes `client.kubernetes`)
|
|
142
180
|
|
|
143
181
|
## Documentation
|
|
144
182
|
|
|
@@ -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.5.0 adds Managed Kubernetes (`cloud k8s …`) and requires SDK `wayscloud>=0.4.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,30 @@ 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 scale <cluster-id> default 4
|
|
97
|
+
cloud k8s add-pool <cluster-id> batch --plan k8s-node-4c16g --count 2 \
|
|
98
|
+
--label role=worker --taint dedicated=gpu:NoSchedule --ssh-key-id <key-id>
|
|
99
|
+
cloud k8s delete-pool <cluster-id> batch
|
|
100
|
+
cloud k8s api-access <cluster-id> --cidr 203.0.113.0/24
|
|
101
|
+
cloud k8s ip-allocate <cluster-id>
|
|
102
|
+
cloud k8s ip-release <cluster-id> <address>
|
|
103
|
+
cloud k8s ptr <cluster-id> <address> mail.example.com
|
|
104
|
+
cloud k8s backups <cluster-id>
|
|
105
|
+
cloud k8s backup <cluster-id>
|
|
106
|
+
cloud k8s restore <cluster-id> <backup-id> -n shop
|
|
107
|
+
cloud k8s upgrade <cluster-id> # list available versions
|
|
108
|
+
cloud k8s upgrade <cluster-id> 1.34 # upgrade to a version
|
|
109
|
+
cloud k8s delete <cluster-id> --confirm-name shop --wait
|
|
110
|
+
```
|
|
111
|
+
|
|
85
112
|
### IoT
|
|
86
113
|
|
|
87
114
|
```bash
|
|
@@ -93,6 +120,15 @@ cloud iot groups
|
|
|
93
120
|
cloud iot groups-create --name "Floor 2"
|
|
94
121
|
```
|
|
95
122
|
|
|
123
|
+
### Impact
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
cloud impact forest create "My forest"
|
|
127
|
+
cloud impact forest status --visualize emoji
|
|
128
|
+
cloud impact tree grow --count 10 --idempotency-key <key>
|
|
129
|
+
cloud impact commitments list
|
|
130
|
+
```
|
|
131
|
+
|
|
96
132
|
### Shell
|
|
97
133
|
|
|
98
134
|
```bash
|
|
@@ -112,7 +148,7 @@ cloud vps list --json
|
|
|
112
148
|
## Requirements
|
|
113
149
|
|
|
114
150
|
- Python 3.9+
|
|
115
|
-
- wayscloud (
|
|
151
|
+
- [wayscloud](https://pypi.org/project/wayscloud/) SDK `>=0.4.0` (installed automatically; the Kubernetes commands need a release that exposes `client.kubernetes`)
|
|
116
152
|
|
|
117
153
|
## Documentation
|
|
118
154
|
|
|
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "wayscloud-cli"
|
|
7
|
-
version = "0.
|
|
8
|
-
description = "Command-line interface for WAYSCloud — manage VPS, DNS, databases, storage, apps, IoT, and more"
|
|
7
|
+
version = "0.5.0"
|
|
8
|
+
description = "Command-line interface for WAYSCloud — manage VPS, DNS, databases, storage, apps, Kubernetes, IoT, and more"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
11
11
|
license = {text = "MIT"}
|
|
@@ -22,7 +22,7 @@ classifiers = [
|
|
|
22
22
|
"Operating System :: OS Independent",
|
|
23
23
|
]
|
|
24
24
|
dependencies = [
|
|
25
|
-
"wayscloud>=0.
|
|
25
|
+
"wayscloud>=0.4.0",
|
|
26
26
|
"typer>=0.9",
|
|
27
27
|
"rich>=13.0",
|
|
28
28
|
"websockets>=12.0",
|
|
@@ -0,0 +1,101 @@
|
|
|
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"
|
|
@@ -30,7 +30,7 @@ def test_version_flag():
|
|
|
30
30
|
|
|
31
31
|
# ── Command groups registered ───────────────────────────────────
|
|
32
32
|
|
|
33
|
-
EXPECTED_GROUPS = ["auth", "vps", "dns", "storage", "db", "redis", "app", "iot", "shell"]
|
|
33
|
+
EXPECTED_GROUPS = ["auth", "vps", "dns", "storage", "db", "redis", "app", "iot", "shell", "impact"]
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
def test_all_command_groups_registered():
|
|
@@ -144,3 +144,56 @@ def test_iot_devices_create_has_required_flags():
|
|
|
144
144
|
assert result.exit_code == 0
|
|
145
145
|
assert "--device-id" in result.output
|
|
146
146
|
assert "--name" in result.output
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ── Impact commands ─────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
def test_impact_subgroups_registered():
|
|
152
|
+
"""cloud impact has forest / tree / commitments subgroups."""
|
|
153
|
+
result = runner.invoke(app, ["impact", "--help"])
|
|
154
|
+
assert result.exit_code == 0
|
|
155
|
+
for sub in ("forest", "tree", "commitments"):
|
|
156
|
+
assert sub in result.output, f"Missing impact subgroup: {sub}"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_impact_tree_grow_has_count_flag():
|
|
160
|
+
result = runner.invoke(app, ["impact", "tree", "grow", "--help"])
|
|
161
|
+
assert result.exit_code == 0
|
|
162
|
+
assert "--count" in result.output
|
|
163
|
+
assert "--idempotency-key" in result.output
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_impact_forest_create_help():
|
|
167
|
+
result = runner.invoke(app, ["impact", "forest", "create", "--help"])
|
|
168
|
+
assert result.exit_code == 0
|
|
169
|
+
# Argument should be the forest name; --token override always accepted.
|
|
170
|
+
assert "--token" in result.output
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_impact_forest_status_has_visualize():
|
|
174
|
+
result = runner.invoke(app, ["impact", "forest", "status", "--help"])
|
|
175
|
+
assert result.exit_code == 0
|
|
176
|
+
assert "--visualize" in result.output
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_impact_forbidden_strings_not_in_help():
|
|
180
|
+
"""Anti-greenwashing wording lock: these strings must NEVER appear
|
|
181
|
+
anywhere in the impact CLI surface, not even in help text."""
|
|
182
|
+
forbidden = ["carbon neutral", "offset", "net zero", "Carbon Neutral", "Net Zero"]
|
|
183
|
+
for sub_path in (
|
|
184
|
+
["impact", "--help"],
|
|
185
|
+
["impact", "forest", "--help"],
|
|
186
|
+
["impact", "tree", "--help"],
|
|
187
|
+
["impact", "tree", "grow", "--help"],
|
|
188
|
+
["impact", "tree", "inspect", "--help"],
|
|
189
|
+
["impact", "tree", "water", "--help"],
|
|
190
|
+
["impact", "commitments", "--help"],
|
|
191
|
+
["impact", "forest", "status", "--help"],
|
|
192
|
+
["impact", "forest", "feed", "--help"],
|
|
193
|
+
):
|
|
194
|
+
result = runner.invoke(app, sub_path)
|
|
195
|
+
assert result.exit_code == 0, f"help failed for {sub_path}"
|
|
196
|
+
for s in forbidden:
|
|
197
|
+
assert s not in result.output, (
|
|
198
|
+
f"Forbidden string {s!r} appeared in help for {sub_path}"
|
|
199
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.5.0"
|
|
@@ -18,8 +18,10 @@ from .commands.dns import app as dns_app
|
|
|
18
18
|
from .commands.storage import app as storage_app
|
|
19
19
|
from .commands.db import app as db_app
|
|
20
20
|
from .commands.redis import app as redis_app
|
|
21
|
+
from .commands.k8s import app as k8s_app
|
|
21
22
|
from .commands.app import app as app_platform_app
|
|
22
23
|
from .commands.iot import app as iot_app
|
|
24
|
+
from .commands.impact import app as impact_app
|
|
23
25
|
|
|
24
26
|
app = typer.Typer(
|
|
25
27
|
name="cloud",
|
|
@@ -57,9 +59,11 @@ app.add_typer(dns_app, name="dns", help="DNS zones and records")
|
|
|
57
59
|
app.add_typer(storage_app, name="storage", help="Object storage (S3)")
|
|
58
60
|
app.add_typer(db_app, name="db", help="Managed databases")
|
|
59
61
|
app.add_typer(redis_app, name="redis", help="Managed Redis")
|
|
62
|
+
app.add_typer(k8s_app, name="k8s", help="Managed Kubernetes")
|
|
60
63
|
app.add_typer(app_platform_app, name="app", help="App platform")
|
|
61
64
|
app.add_typer(iot_app, name="iot", help="IoT platform")
|
|
62
65
|
app.add_typer(shell_app, name="shell", help="CloudShell")
|
|
66
|
+
app.add_typer(impact_app, name="impact", help="Impact: forests, trees, contributions")
|
|
63
67
|
|
|
64
68
|
# Top-level shortcuts for login/logout/whoami
|
|
65
69
|
app.command("login")(login)
|
|
@@ -17,7 +17,7 @@ app.add_typer(env_app, name="env")
|
|
|
17
17
|
|
|
18
18
|
@env_app.command("list")
|
|
19
19
|
def env_list(
|
|
20
|
-
app_id: str = typer.Argument(..., help="App ID or name"),
|
|
20
|
+
app_id: str = typer.Argument(..., help="App ID, short ID, or name"),
|
|
21
21
|
token: Optional[str] = typer.Option(None, "--token", help="Override token"),
|
|
22
22
|
):
|
|
23
23
|
"""List environment variable keys for an app."""
|
|
@@ -39,7 +39,7 @@ def env_list(
|
|
|
39
39
|
|
|
40
40
|
@env_app.command("set")
|
|
41
41
|
def env_set(
|
|
42
|
-
app_id: str = typer.Argument(..., help="App ID or name"),
|
|
42
|
+
app_id: str = typer.Argument(..., help="App ID, short ID, or name"),
|
|
43
43
|
pairs: List[str] = typer.Argument(..., help="KEY=value pairs"),
|
|
44
44
|
token: Optional[str] = typer.Option(None, "--token", help="Override token"),
|
|
45
45
|
):
|
|
@@ -59,7 +59,7 @@ def env_set(
|
|
|
59
59
|
|
|
60
60
|
@env_app.command("unset")
|
|
61
61
|
def env_unset(
|
|
62
|
-
app_id: str = typer.Argument(..., help="App ID or name"),
|
|
62
|
+
app_id: str = typer.Argument(..., help="App ID, short ID, or name"),
|
|
63
63
|
keys: List[str] = typer.Argument(..., help="Variable names to remove"),
|
|
64
64
|
token: Optional[str] = typer.Option(None, "--token", help="Override token"),
|
|
65
65
|
):
|
|
@@ -92,14 +92,16 @@ def list_apps(
|
|
|
92
92
|
print("No apps found.")
|
|
93
93
|
return
|
|
94
94
|
|
|
95
|
-
rows = [{"id": str(a.get("id", ""))
|
|
95
|
+
rows = [{"id": str(a.get("id", "")), "short_id": a.get("short_id", ""),
|
|
96
|
+
"name": a.get("name", ""),
|
|
96
97
|
"status": a.get("status", ""), "plan": a.get("plan", a.get("plan_code", "")),
|
|
97
98
|
"url": a.get("default_url", ""), "created_at": a.get("created_at", "")}
|
|
98
99
|
for a in apps_list]
|
|
99
100
|
|
|
100
101
|
print_table(rows, [
|
|
101
|
-
("id", "ID",
|
|
102
|
-
("
|
|
102
|
+
("id", "ID", 30), ("short_id", "Short", 10), ("name", "Name", 20),
|
|
103
|
+
("status", "Status", 10), ("plan", "Plan", 16),
|
|
104
|
+
("url", "URL", 30), ("created_at", "Created", 20),
|
|
103
105
|
])
|
|
104
106
|
|
|
105
107
|
|
|
@@ -124,7 +126,7 @@ def create_app(
|
|
|
124
126
|
|
|
125
127
|
@app.command("info")
|
|
126
128
|
def info_app(
|
|
127
|
-
app_id: str = typer.Argument(..., help="App ID"),
|
|
129
|
+
app_id: str = typer.Argument(..., help="App ID, short ID, or name"),
|
|
128
130
|
token: Optional[str] = typer.Option(None, "--token", help="Override token"),
|
|
129
131
|
):
|
|
130
132
|
"""Show app details."""
|
|
@@ -139,7 +141,7 @@ def info_app(
|
|
|
139
141
|
|
|
140
142
|
@app.command("delete")
|
|
141
143
|
def delete_app(
|
|
142
|
-
app_id: str = typer.Argument(..., help="App ID"),
|
|
144
|
+
app_id: str = typer.Argument(..., help="App ID, short ID, or name"),
|
|
143
145
|
token: Optional[str] = typer.Option(None, "--token", help="Override token"),
|
|
144
146
|
confirm: bool = typer.Option(False, "--confirm", help="Skip confirmation"),
|
|
145
147
|
):
|
|
@@ -159,7 +161,7 @@ def delete_app(
|
|
|
159
161
|
|
|
160
162
|
@app.command("deploy")
|
|
161
163
|
def deploy_app(
|
|
162
|
-
app_id: str = typer.Argument(..., help="App ID"),
|
|
164
|
+
app_id: str = typer.Argument(..., help="App ID, short ID, or name"),
|
|
163
165
|
image: str = typer.Option(..., "--image", help="Container image URI"),
|
|
164
166
|
token: Optional[str] = typer.Option(None, "--token", help="Override token"),
|
|
165
167
|
):
|
|
@@ -174,7 +176,7 @@ def deploy_app(
|
|
|
174
176
|
|
|
175
177
|
@app.command("start")
|
|
176
178
|
def start_app(
|
|
177
|
-
app_id: str = typer.Argument(..., help="App ID"),
|
|
179
|
+
app_id: str = typer.Argument(..., help="App ID, short ID, or name"),
|
|
178
180
|
token: Optional[str] = typer.Option(None, "--token", help="Override token"),
|
|
179
181
|
):
|
|
180
182
|
"""Start an app."""
|
|
@@ -188,7 +190,7 @@ def start_app(
|
|
|
188
190
|
|
|
189
191
|
@app.command("stop")
|
|
190
192
|
def stop_app(
|
|
191
|
-
app_id: str = typer.Argument(..., help="App ID"),
|
|
193
|
+
app_id: str = typer.Argument(..., help="App ID, short ID, or name"),
|
|
192
194
|
token: Optional[str] = typer.Option(None, "--token", help="Override token"),
|
|
193
195
|
):
|
|
194
196
|
"""Stop an app."""
|
|
@@ -202,7 +204,7 @@ def stop_app(
|
|
|
202
204
|
|
|
203
205
|
@app.command("restart")
|
|
204
206
|
def restart_app(
|
|
205
|
-
app_id: str = typer.Argument(..., help="App ID"),
|
|
207
|
+
app_id: str = typer.Argument(..., help="App ID, short ID, or name"),
|
|
206
208
|
token: Optional[str] = typer.Option(None, "--token", help="Override token"),
|
|
207
209
|
):
|
|
208
210
|
"""Restart an app."""
|
|
@@ -216,7 +218,7 @@ def restart_app(
|
|
|
216
218
|
|
|
217
219
|
@app.command("logs")
|
|
218
220
|
def logs_app(
|
|
219
|
-
app_id: str = typer.Argument(..., help="App ID"),
|
|
221
|
+
app_id: str = typer.Argument(..., help="App ID, short ID, or name"),
|
|
220
222
|
lines: int = typer.Option(100, "--lines", "-n", help="Number of lines"),
|
|
221
223
|
token: Optional[str] = typer.Option(None, "--token", help="Override token"),
|
|
222
224
|
):
|
|
@@ -272,6 +274,151 @@ def list_plans(
|
|
|
272
274
|
])
|
|
273
275
|
|
|
274
276
|
|
|
277
|
+
# ── GitHub auto-deploy sub-group ────────────────────────────────
|
|
278
|
+
auto_deploy_app = typer.Typer(help="GitHub auto-deploy")
|
|
279
|
+
app.add_typer(auto_deploy_app, name="auto-deploy")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _print_secret_once(secret: str, webhook_url: str) -> None:
|
|
283
|
+
"""Format the one-time secret reveal for terminal output."""
|
|
284
|
+
print()
|
|
285
|
+
print("=" * 72)
|
|
286
|
+
print(" This is the only time this secret is shown. Copy it now.")
|
|
287
|
+
print("=" * 72)
|
|
288
|
+
print(f" Webhook URL: {webhook_url}")
|
|
289
|
+
print(f" Secret: {secret}")
|
|
290
|
+
print( " Content type: application/json")
|
|
291
|
+
print( " Events: Just the push event")
|
|
292
|
+
print("=" * 72)
|
|
293
|
+
print()
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@auto_deploy_app.command("status")
|
|
297
|
+
def auto_deploy_status(
|
|
298
|
+
app_id: str = typer.Argument(..., help="App ID, short ID, or name"),
|
|
299
|
+
token: Optional[str] = typer.Option(None, "--token", help="Override token"),
|
|
300
|
+
):
|
|
301
|
+
"""Show GitHub auto-deploy configuration for an app."""
|
|
302
|
+
c = get_client(token)
|
|
303
|
+
data = sdk_call(c.apps.auto_deploy_get, app_id)
|
|
304
|
+
|
|
305
|
+
if is_json_mode():
|
|
306
|
+
print_json(data)
|
|
307
|
+
return
|
|
308
|
+
|
|
309
|
+
if not data.get("configured"):
|
|
310
|
+
print("Auto-deploy: not configured.")
|
|
311
|
+
print(f"Webhook URL: {data.get('webhook_url', '')}")
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
print_object(data, [
|
|
315
|
+
("provider", "Provider"),
|
|
316
|
+
("repo_url", "Repository"),
|
|
317
|
+
("branch", "Branch"),
|
|
318
|
+
("auto_deploy_enabled", "Enabled"),
|
|
319
|
+
("secret_configured", "Per-app secret"),
|
|
320
|
+
("credential_linked", "Credential linked"),
|
|
321
|
+
("webhook_url", "Webhook URL"),
|
|
322
|
+
("last_push_event_at", "Last push"),
|
|
323
|
+
("last_auto_deploy_at", "Last deploy"),
|
|
324
|
+
("last_auto_deploy_commit", "Last commit"),
|
|
325
|
+
])
|
|
326
|
+
if not data.get("secret_configured"):
|
|
327
|
+
print()
|
|
328
|
+
print(" [!] This app is using the legacy shared webhook secret.")
|
|
329
|
+
print(" Run `cloud app auto-deploy rotate-secret <app>` to")
|
|
330
|
+
print(" get a per-app secret only GitHub and this app know.")
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@auto_deploy_app.command("enable")
|
|
334
|
+
def auto_deploy_enable(
|
|
335
|
+
app_id: str = typer.Argument(..., help="App ID, short ID, or name"),
|
|
336
|
+
repo_url: str = typer.Option(..., "--repo", help="GitHub repository URL"),
|
|
337
|
+
branch: str = typer.Option("main", "--branch", help="Branch to auto-deploy"),
|
|
338
|
+
token: Optional[str] = typer.Option(None, "--token", help="Override token"),
|
|
339
|
+
):
|
|
340
|
+
"""Enable GitHub auto-deploy for an app.
|
|
341
|
+
|
|
342
|
+
First invocation generates a per-app webhook secret and prints it
|
|
343
|
+
once. Re-invoking with new repo/branch keeps the existing secret.
|
|
344
|
+
"""
|
|
345
|
+
c = get_client(token)
|
|
346
|
+
data = sdk_call(
|
|
347
|
+
c.apps.auto_deploy_configure,
|
|
348
|
+
app_id, repo_url, branch=branch, auto_deploy_enabled=True,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
if is_json_mode():
|
|
352
|
+
print_json(data)
|
|
353
|
+
return
|
|
354
|
+
|
|
355
|
+
print(f"Auto-deploy enabled: {data.get('repo_url')}:{data.get('branch')}")
|
|
356
|
+
secret = data.get("webhook_secret")
|
|
357
|
+
if secret:
|
|
358
|
+
_print_secret_once(secret, data.get("webhook_url", ""))
|
|
359
|
+
else:
|
|
360
|
+
print("Per-app webhook secret already configured — reusing existing.")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@auto_deploy_app.command("disable")
|
|
364
|
+
def auto_deploy_disable(
|
|
365
|
+
app_id: str = typer.Argument(..., help="App ID, short ID, or name"),
|
|
366
|
+
token: Optional[str] = typer.Option(None, "--token", help="Override token"),
|
|
367
|
+
):
|
|
368
|
+
"""Pause auto-deploy without clearing the repo configuration.
|
|
369
|
+
|
|
370
|
+
Webhook secret is kept so re-enabling does not require updating
|
|
371
|
+
GitHub. To delete the config entirely, use `cloud app auto-deploy
|
|
372
|
+
remove` (not yet implemented — reconfigure with a new repo_url
|
|
373
|
+
instead).
|
|
374
|
+
"""
|
|
375
|
+
c = get_client(token)
|
|
376
|
+
# Re-PUT with auto_deploy_enabled=false; backend preserves secret.
|
|
377
|
+
current = sdk_call(c.apps.auto_deploy_get, app_id)
|
|
378
|
+
if not current.get("configured"):
|
|
379
|
+
print("Auto-deploy is not configured for this app.")
|
|
380
|
+
raise typer.Exit(code=1)
|
|
381
|
+
data = sdk_call(
|
|
382
|
+
c.apps.auto_deploy_configure,
|
|
383
|
+
app_id,
|
|
384
|
+
repo_url=current["repo_url"],
|
|
385
|
+
branch=current["branch"],
|
|
386
|
+
auto_deploy_enabled=False,
|
|
387
|
+
)
|
|
388
|
+
if is_json_mode():
|
|
389
|
+
print_json(data)
|
|
390
|
+
else:
|
|
391
|
+
print(f"Auto-deploy paused for {app_id}.")
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@auto_deploy_app.command("rotate-secret")
|
|
395
|
+
def auto_deploy_rotate(
|
|
396
|
+
app_id: str = typer.Argument(..., help="App ID, short ID, or name"),
|
|
397
|
+
token: Optional[str] = typer.Option(None, "--token", help="Override token"),
|
|
398
|
+
confirm: bool = typer.Option(False, "--confirm",
|
|
399
|
+
help="Skip interactive confirmation"),
|
|
400
|
+
):
|
|
401
|
+
"""Generate a new webhook secret. The previous one stops working."""
|
|
402
|
+
if not confirm:
|
|
403
|
+
print("This rotates the webhook secret immediately. Pushes from")
|
|
404
|
+
print("GitHub will fail until you update the secret in the repo")
|
|
405
|
+
print("webhook settings. Re-run with --confirm to proceed.")
|
|
406
|
+
raise typer.Exit(code=1)
|
|
407
|
+
|
|
408
|
+
c = get_client(token)
|
|
409
|
+
data = sdk_call(c.apps.auto_deploy_rotate_secret, app_id)
|
|
410
|
+
|
|
411
|
+
if is_json_mode():
|
|
412
|
+
print_json(data)
|
|
413
|
+
return
|
|
414
|
+
|
|
415
|
+
secret = data.get("webhook_secret")
|
|
416
|
+
if not secret:
|
|
417
|
+
print("Error: response did not include a new secret.", err=True)
|
|
418
|
+
raise typer.Exit(code=1)
|
|
419
|
+
_print_secret_once(secret, data.get("webhook_url", ""))
|
|
420
|
+
|
|
421
|
+
|
|
275
422
|
@app.command("regions")
|
|
276
423
|
def list_regions(
|
|
277
424
|
token: Optional[str] = typer.Option(None, "--token", help="Override token"),
|