wayscloud 0.2.3__tar.gz → 0.4.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-0.2.3 → wayscloud-0.4.0}/PKG-INFO +67 -1
- wayscloud-0.4.0/README.md +157 -0
- {wayscloud-0.2.3 → wayscloud-0.4.0}/pyproject.toml +1 -1
- {wayscloud-0.2.3 → wayscloud-0.4.0}/setup.cfg +1 -1
- wayscloud-0.4.0/tests/test_kubernetes_service.py +117 -0
- {wayscloud-0.2.3 → wayscloud-0.4.0}/tests/test_smoke.py +20 -17
- wayscloud-0.4.0/wayscloud/_version.py +1 -0
- {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud/client.py +32 -8
- {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud/services/apps.py +39 -0
- {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud/services/database.py +19 -6
- {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud/services/dns.py +6 -0
- wayscloud-0.4.0/wayscloud/services/impact.py +76 -0
- wayscloud-0.4.0/wayscloud/services/iot.py +376 -0
- wayscloud-0.4.0/wayscloud/services/kubernetes.py +159 -0
- {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud/services/redis.py +9 -3
- {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud/services/storage.py +15 -3
- {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud/services/vps.py +30 -0
- {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud.egg-info/PKG-INFO +67 -1
- {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud.egg-info/SOURCES.txt +3 -0
- wayscloud-0.2.3/README.md +0 -91
- wayscloud-0.2.3/wayscloud/_version.py +0 -1
- wayscloud-0.2.3/wayscloud/services/iot.py +0 -107
- {wayscloud-0.2.3 → wayscloud-0.4.0}/LICENSE +0 -0
- {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud/__init__.py +0 -0
- {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud/exceptions.py +0 -0
- {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud/py.typed +0 -0
- {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud/services/__init__.py +0 -0
- {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud/services/account.py +0 -0
- {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud/services/sms.py +0 -0
- {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud.egg-info/dependency_links.txt +0 -0
- {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud.egg-info/requires.txt +0 -0
- {wayscloud-0.2.3 → wayscloud-0.4.0}/wayscloud.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wayscloud
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Official Python SDK for the WAYSCloud API
|
|
5
5
|
Home-page: https://wayscloud.services
|
|
6
6
|
License: MIT
|
|
@@ -76,6 +76,68 @@ client.storage.create_bucket("my-bucket")
|
|
|
76
76
|
client.sms.send(to="+4712345678", message="Hello from WAYSCloud")
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
+
## Kubernetes
|
|
80
|
+
|
|
81
|
+
`client.kubernetes` manages Managed Kubernetes clusters (PAT scopes
|
|
82
|
+
`kubernetes:read` / `kubernetes:write`). Create and delete are asynchronous:
|
|
83
|
+
the API answers `202` with status `provisioning` / `deleting`, and `wait()`
|
|
84
|
+
blocks until the cluster is `running`, `error`, or gone.
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
# Catalogue
|
|
88
|
+
client.kubernetes.plans(region="no", kind="node")
|
|
89
|
+
client.kubernetes.regions()
|
|
90
|
+
client.kubernetes.versions()
|
|
91
|
+
client.kubernetes.estimate(
|
|
92
|
+
plan_code="k8s-cluster-dev",
|
|
93
|
+
node_pools=[{"name": "default", "plan_code": "k8s-node-2c4g", "count": 2}],
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Clusters
|
|
97
|
+
cluster = client.kubernetes.create(
|
|
98
|
+
"shop",
|
|
99
|
+
node_pools=[{"name": "default", "plan_code": "k8s-node-2c4g", "count": 2}],
|
|
100
|
+
ssh_key_ids=["<ssh-key-id>"],
|
|
101
|
+
)
|
|
102
|
+
cluster = client.kubernetes.wait(cluster["id"])
|
|
103
|
+
|
|
104
|
+
client.kubernetes.list()
|
|
105
|
+
client.kubernetes.get(cluster["id"])
|
|
106
|
+
client.kubernetes.history(cluster["id"])
|
|
107
|
+
client.kubernetes.delete(cluster["id"], confirm_name="shop")
|
|
108
|
+
|
|
109
|
+
# Node pools (labels, taints, per-pool SSH keys)
|
|
110
|
+
client.kubernetes.add_node_pool(
|
|
111
|
+
cluster["id"], "workers", "k8s-node-2c4g", 3,
|
|
112
|
+
labels={"role": "worker"},
|
|
113
|
+
taints=[{"key": "dedicated", "value": "gpu", "effect": "NoSchedule"}],
|
|
114
|
+
ssh_key_ids=["<ssh-key-id>"],
|
|
115
|
+
)
|
|
116
|
+
client.kubernetes.scale_node_pool(cluster["id"], "workers", 5)
|
|
117
|
+
client.kubernetes.delete_node_pool(cluster["id"], "workers")
|
|
118
|
+
|
|
119
|
+
# Access — the kubeconfig is a secret; restrict the API to your CIDRs
|
|
120
|
+
client.kubernetes.kubeconfig(cluster["id"]) # YAML text
|
|
121
|
+
client.kubernetes.set_api_access(cluster["id"], ["203.0.113.7/32"])
|
|
122
|
+
|
|
123
|
+
# Public IPs and reverse DNS (allocate/release return the updated cluster)
|
|
124
|
+
cluster = client.kubernetes.allocate_ip(cluster["id"])
|
|
125
|
+
address = cluster["public_ips"][-1]["address"]
|
|
126
|
+
client.kubernetes.set_ptr(cluster["id"], address, "shop.example.com")
|
|
127
|
+
client.kubernetes.release_ip(cluster["id"], address)
|
|
128
|
+
|
|
129
|
+
# Backups
|
|
130
|
+
client.kubernetes.backup_policy(cluster["id"])
|
|
131
|
+
client.kubernetes.set_backup_policy(cluster["id"], schedule_cron="0 3 * * *", retention_days=14)
|
|
132
|
+
client.kubernetes.backups(cluster["id"])
|
|
133
|
+
client.kubernetes.backup_now(cluster["id"])
|
|
134
|
+
client.kubernetes.restore(cluster["id"], "<backup-id>")
|
|
135
|
+
|
|
136
|
+
# Upgrades — no backup is taken automatically, create one first
|
|
137
|
+
client.kubernetes.available_upgrades(cluster["id"])
|
|
138
|
+
client.kubernetes.upgrade(cluster["id"], "1.34")
|
|
139
|
+
```
|
|
140
|
+
|
|
79
141
|
## Error handling
|
|
80
142
|
|
|
81
143
|
```python
|
|
@@ -109,6 +171,10 @@ Automatic retries on 429, 502, 503, 504 with exponential backoff. Respects `Retr
|
|
|
109
171
|
- Python 3.10+
|
|
110
172
|
- httpx
|
|
111
173
|
|
|
174
|
+
## Changelog
|
|
175
|
+
|
|
176
|
+
See [CHANGELOG.md](https://github.com/wayscloudas/wayscloud-python-sdk/blob/main/CHANGELOG.md).
|
|
177
|
+
|
|
112
178
|
## License
|
|
113
179
|
|
|
114
180
|
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# WAYSCloud Python SDK
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the [WAYSCloud](https://wayscloud.services) API.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install wayscloud
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Authentication
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from wayscloud import WaysCloudClient
|
|
15
|
+
|
|
16
|
+
# Personal Access Token
|
|
17
|
+
client = WaysCloudClient(token="wayscloud_pat_...")
|
|
18
|
+
|
|
19
|
+
# API key
|
|
20
|
+
client = WaysCloudClient(api_key="wayscloud_api_...")
|
|
21
|
+
|
|
22
|
+
# Environment variables (WAYSCLOUD_TOKEN or WAYSCLOUD_API_KEY)
|
|
23
|
+
client = WaysCloudClient()
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Priority: explicit arguments > environment variables.
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
# VPS
|
|
32
|
+
for vm in client.vps.list():
|
|
33
|
+
print(vm["hostname"], vm["status"])
|
|
34
|
+
|
|
35
|
+
# DNS
|
|
36
|
+
client.dns.create_record(
|
|
37
|
+
"example.com",
|
|
38
|
+
record_type="A",
|
|
39
|
+
name="www",
|
|
40
|
+
value="192.0.2.1",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Database
|
|
44
|
+
db = client.database.create(name="prod", db_type="postgresql")
|
|
45
|
+
|
|
46
|
+
# Apps
|
|
47
|
+
app = client.apps.create(name="my-app", region="eu")
|
|
48
|
+
|
|
49
|
+
# Storage
|
|
50
|
+
client.storage.create_bucket("my-bucket")
|
|
51
|
+
|
|
52
|
+
# SMS
|
|
53
|
+
client.sms.send(to="+4712345678", message="Hello from WAYSCloud")
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Kubernetes
|
|
57
|
+
|
|
58
|
+
`client.kubernetes` manages Managed Kubernetes clusters (PAT scopes
|
|
59
|
+
`kubernetes:read` / `kubernetes:write`). Create and delete are asynchronous:
|
|
60
|
+
the API answers `202` with status `provisioning` / `deleting`, and `wait()`
|
|
61
|
+
blocks until the cluster is `running`, `error`, or gone.
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
# Catalogue
|
|
65
|
+
client.kubernetes.plans(region="no", kind="node")
|
|
66
|
+
client.kubernetes.regions()
|
|
67
|
+
client.kubernetes.versions()
|
|
68
|
+
client.kubernetes.estimate(
|
|
69
|
+
plan_code="k8s-cluster-dev",
|
|
70
|
+
node_pools=[{"name": "default", "plan_code": "k8s-node-2c4g", "count": 2}],
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Clusters
|
|
74
|
+
cluster = client.kubernetes.create(
|
|
75
|
+
"shop",
|
|
76
|
+
node_pools=[{"name": "default", "plan_code": "k8s-node-2c4g", "count": 2}],
|
|
77
|
+
ssh_key_ids=["<ssh-key-id>"],
|
|
78
|
+
)
|
|
79
|
+
cluster = client.kubernetes.wait(cluster["id"])
|
|
80
|
+
|
|
81
|
+
client.kubernetes.list()
|
|
82
|
+
client.kubernetes.get(cluster["id"])
|
|
83
|
+
client.kubernetes.history(cluster["id"])
|
|
84
|
+
client.kubernetes.delete(cluster["id"], confirm_name="shop")
|
|
85
|
+
|
|
86
|
+
# Node pools (labels, taints, per-pool SSH keys)
|
|
87
|
+
client.kubernetes.add_node_pool(
|
|
88
|
+
cluster["id"], "workers", "k8s-node-2c4g", 3,
|
|
89
|
+
labels={"role": "worker"},
|
|
90
|
+
taints=[{"key": "dedicated", "value": "gpu", "effect": "NoSchedule"}],
|
|
91
|
+
ssh_key_ids=["<ssh-key-id>"],
|
|
92
|
+
)
|
|
93
|
+
client.kubernetes.scale_node_pool(cluster["id"], "workers", 5)
|
|
94
|
+
client.kubernetes.delete_node_pool(cluster["id"], "workers")
|
|
95
|
+
|
|
96
|
+
# Access — the kubeconfig is a secret; restrict the API to your CIDRs
|
|
97
|
+
client.kubernetes.kubeconfig(cluster["id"]) # YAML text
|
|
98
|
+
client.kubernetes.set_api_access(cluster["id"], ["203.0.113.7/32"])
|
|
99
|
+
|
|
100
|
+
# Public IPs and reverse DNS (allocate/release return the updated cluster)
|
|
101
|
+
cluster = client.kubernetes.allocate_ip(cluster["id"])
|
|
102
|
+
address = cluster["public_ips"][-1]["address"]
|
|
103
|
+
client.kubernetes.set_ptr(cluster["id"], address, "shop.example.com")
|
|
104
|
+
client.kubernetes.release_ip(cluster["id"], address)
|
|
105
|
+
|
|
106
|
+
# Backups
|
|
107
|
+
client.kubernetes.backup_policy(cluster["id"])
|
|
108
|
+
client.kubernetes.set_backup_policy(cluster["id"], schedule_cron="0 3 * * *", retention_days=14)
|
|
109
|
+
client.kubernetes.backups(cluster["id"])
|
|
110
|
+
client.kubernetes.backup_now(cluster["id"])
|
|
111
|
+
client.kubernetes.restore(cluster["id"], "<backup-id>")
|
|
112
|
+
|
|
113
|
+
# Upgrades — no backup is taken automatically, create one first
|
|
114
|
+
client.kubernetes.available_upgrades(cluster["id"])
|
|
115
|
+
client.kubernetes.upgrade(cluster["id"], "1.34")
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Error handling
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from wayscloud import NotFoundError, AuthenticationError
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
client.vps.get("id")
|
|
125
|
+
except NotFoundError:
|
|
126
|
+
print("Not found")
|
|
127
|
+
except AuthenticationError:
|
|
128
|
+
print("Invalid credentials")
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
All exceptions inherit from `WaysCloudError`.
|
|
132
|
+
|
|
133
|
+
## Configuration
|
|
134
|
+
|
|
135
|
+
| Parameter | Environment variable | Default |
|
|
136
|
+
|-----------|---------------------|---------|
|
|
137
|
+
| `token` | `WAYSCLOUD_TOKEN` | — |
|
|
138
|
+
| `api_key` | `WAYSCLOUD_API_KEY` | — |
|
|
139
|
+
| `base_url` | `WAYSCLOUD_API_URL` | `https://api.wayscloud.services` |
|
|
140
|
+
| `timeout` | — | `30.0` |
|
|
141
|
+
|
|
142
|
+
## Retries
|
|
143
|
+
|
|
144
|
+
Automatic retries on 429, 502, 503, 504 with exponential backoff. Respects `Retry-After` headers.
|
|
145
|
+
|
|
146
|
+
## Requirements
|
|
147
|
+
|
|
148
|
+
- Python 3.10+
|
|
149
|
+
- httpx
|
|
150
|
+
|
|
151
|
+
## Changelog
|
|
152
|
+
|
|
153
|
+
See [CHANGELOG.md](https://github.com/wayscloudas/wayscloud-python-sdk/blob/main/CHANGELOG.md).
|
|
154
|
+
|
|
155
|
+
## License
|
|
156
|
+
|
|
157
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""KubernetesService request shapes against a fake client (no HTTP)."""
|
|
2
|
+
import importlib.util
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import types
|
|
6
|
+
import unittest
|
|
7
|
+
|
|
8
|
+
HERE = os.path.dirname(__file__)
|
|
9
|
+
SDK = os.path.join(HERE, "..")
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
# Normal case (repo root on sys.path or package installed): use the real
|
|
13
|
+
# package so later test modules can still import `wayscloud`.
|
|
14
|
+
from wayscloud.exceptions import NotFoundError
|
|
15
|
+
from wayscloud.services.kubernetes import KubernetesService
|
|
16
|
+
except ImportError:
|
|
17
|
+
# Standalone fallback: load the service module without importing the
|
|
18
|
+
# package __init__ (which needs httpx).
|
|
19
|
+
pkg = types.ModuleType("wayscloud"); pkg.__path__ = [os.path.join(SDK, "wayscloud")]
|
|
20
|
+
sys.modules.setdefault("wayscloud", pkg)
|
|
21
|
+
exc_mod = types.ModuleType("wayscloud.exceptions")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class WaysCloudError(Exception):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NotFoundError(WaysCloudError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
exc_mod.WaysCloudError, exc_mod.NotFoundError = WaysCloudError, NotFoundError
|
|
33
|
+
sys.modules["wayscloud.exceptions"] = exc_mod
|
|
34
|
+
spec = importlib.util.spec_from_file_location("wayscloud.services.kubernetes", os.path.join(SDK, "wayscloud", "services", "kubernetes.py"))
|
|
35
|
+
mod = importlib.util.module_from_spec(spec); sys.modules[spec.name] = mod; spec.loader.exec_module(mod)
|
|
36
|
+
KubernetesService = mod.KubernetesService
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class FakeClient:
|
|
40
|
+
def __init__(self, responses=None):
|
|
41
|
+
self.calls, self.responses = [], list(responses or [])
|
|
42
|
+
|
|
43
|
+
def _rec(self, method, path, **kw):
|
|
44
|
+
self.calls.append((method, path, kw))
|
|
45
|
+
return self.responses.pop(0) if self.responses else {}
|
|
46
|
+
|
|
47
|
+
def get(self, path, params=None, raw=False):
|
|
48
|
+
return self._rec("GET", path, params=params, raw=raw)
|
|
49
|
+
|
|
50
|
+
def post(self, path, json=None):
|
|
51
|
+
return self._rec("POST", path, json=json)
|
|
52
|
+
|
|
53
|
+
def put(self, path, json=None):
|
|
54
|
+
return self._rec("PUT", path, json=json)
|
|
55
|
+
|
|
56
|
+
def delete(self, path, json=None):
|
|
57
|
+
return self._rec("DELETE", path, json=json)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ServiceTests(unittest.TestCase):
|
|
61
|
+
def test_create_payload(self):
|
|
62
|
+
c = FakeClient([{"id": "c1", "status": "provisioning"}])
|
|
63
|
+
out = KubernetesService(c).create("shop", [{"name": "default", "plan_code": "k8s-node-2c4g", "count": 2}], api_ip_filter=["1.2.3.0/24"])
|
|
64
|
+
self.assertEqual(out["id"], "c1")
|
|
65
|
+
m, p, kw = c.calls[0]
|
|
66
|
+
self.assertEqual((m, p), ("POST", "/v1/kubernetes/clusters"))
|
|
67
|
+
self.assertEqual(kw["json"]["node_pools"][0]["count"], 2)
|
|
68
|
+
self.assertEqual(kw["json"]["plan_code"], "k8s-cluster-dev")
|
|
69
|
+
self.assertEqual(kw["json"]["ssh_key_ids"], [])
|
|
70
|
+
|
|
71
|
+
def test_add_node_pool_sends_labels_taints_and_ssh_keys(self):
|
|
72
|
+
c = FakeClient([{"id": "c1"}])
|
|
73
|
+
KubernetesService(c).add_node_pool(
|
|
74
|
+
"c1", "workers", "k8s-node-2c4g", 2,
|
|
75
|
+
labels={"role": "worker"},
|
|
76
|
+
taints=[{"key": "dedicated", "value": "gpu", "effect": "NoSchedule"}],
|
|
77
|
+
ssh_key_ids=["11111111-1111-1111-1111-111111111111"])
|
|
78
|
+
m, p, kw = c.calls[0]
|
|
79
|
+
self.assertEqual((m, p), ("POST", "/v1/kubernetes/clusters/c1/node-pools"))
|
|
80
|
+
self.assertEqual(kw["json"]["labels"], {"role": "worker"})
|
|
81
|
+
self.assertEqual(kw["json"]["taints"], [{"key": "dedicated", "value": "gpu", "effect": "NoSchedule"}])
|
|
82
|
+
self.assertEqual(kw["json"]["ssh_key_ids"], ["11111111-1111-1111-1111-111111111111"])
|
|
83
|
+
self.assertEqual(kw["json"]["count"], 2)
|
|
84
|
+
|
|
85
|
+
def test_add_node_pool_defaults_are_empty(self):
|
|
86
|
+
c = FakeClient([{"id": "c1"}])
|
|
87
|
+
KubernetesService(c).add_node_pool("c1", "workers", "k8s-node-2c4g", 1)
|
|
88
|
+
self.assertEqual(c.calls[0][2]["json"], {"name": "workers", "plan_code": "k8s-node-2c4g", "count": 1,
|
|
89
|
+
"labels": {}, "taints": [], "ssh_key_ids": []})
|
|
90
|
+
|
|
91
|
+
def test_delete_sends_confirmation_body(self):
|
|
92
|
+
c = FakeClient([{"status": "deleting"}])
|
|
93
|
+
KubernetesService(c).delete("c1", "shop")
|
|
94
|
+
self.assertEqual(c.calls[0], ("DELETE", "/v1/kubernetes/clusters/c1", {"json": {"confirm_name": "shop"}}))
|
|
95
|
+
|
|
96
|
+
def test_kubeconfig_is_raw_text(self):
|
|
97
|
+
c = FakeClient(["apiVersion: v1"])
|
|
98
|
+
self.assertEqual(KubernetesService(c).kubeconfig("c1"), "apiVersion: v1")
|
|
99
|
+
self.assertTrue(c.calls[0][2]["raw"])
|
|
100
|
+
|
|
101
|
+
def test_list_unwraps_envelope(self):
|
|
102
|
+
c = FakeClient([{"clusters": [{"id": "a"}], "total": 1}])
|
|
103
|
+
self.assertEqual(KubernetesService(c).list(), [{"id": "a"}])
|
|
104
|
+
|
|
105
|
+
def test_wait_returns_on_running_and_on_404(self):
|
|
106
|
+
c = FakeClient([{"status": "provisioning"}, {"status": "running", "id": "c1"}])
|
|
107
|
+
svc = KubernetesService(c)
|
|
108
|
+
self.assertEqual(svc.wait("c1", timeout=5, interval=0)["status"], "running")
|
|
109
|
+
|
|
110
|
+
class Gone(FakeClient):
|
|
111
|
+
def get(self, *a, **k):
|
|
112
|
+
raise NotFoundError("404")
|
|
113
|
+
self.assertEqual(KubernetesService(Gone()).wait("c1", timeout=5, interval=0)["status"], "deleted")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
unittest.main()
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""SDK smoke tests — verify public API surface, auth, lifecycle, and error handling."""
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
import os
|
|
4
5
|
|
|
5
6
|
import httpx
|
|
@@ -115,29 +116,31 @@ def test_all_services_instantiate():
|
|
|
115
116
|
|
|
116
117
|
@respx.mock
|
|
117
118
|
def test_get_returns_json():
|
|
118
|
-
respx.get("https://api.wayscloud.services/
|
|
119
|
-
return_value=httpx.Response(200, json={"
|
|
119
|
+
respx.get("https://api.wayscloud.services/v1/vps/").mock(
|
|
120
|
+
return_value=httpx.Response(200, json={"total": 1, "vps_instances": [{"id": "vps-1", "hostname": "web01"}]})
|
|
120
121
|
)
|
|
121
122
|
with WaysCloudClient(token="t") as c:
|
|
122
123
|
result = c.vps.list()
|
|
123
|
-
assert result == []
|
|
124
|
+
assert result == [{"id": "vps-1", "hostname": "web01"}]
|
|
124
125
|
|
|
125
126
|
|
|
126
127
|
@respx.mock
|
|
127
128
|
def test_post_sends_json_body():
|
|
128
|
-
route = respx.post("https://api.wayscloud.services/
|
|
129
|
-
return_value=httpx.Response(201, json={"
|
|
129
|
+
route = respx.post("https://api.wayscloud.services/v1/dns/zones").mock(
|
|
130
|
+
return_value=httpx.Response(201, json={"zone_id": "z1", "zone_name": "example.com"})
|
|
130
131
|
)
|
|
131
132
|
with WaysCloudClient(token="t") as c:
|
|
132
133
|
result = c.dns.create_zone("example.com")
|
|
133
|
-
assert result["
|
|
134
|
-
|
|
134
|
+
assert result["zone_name"] == "example.com"
|
|
135
|
+
request = route.calls[0].request
|
|
136
|
+
assert request.headers["content-type"] == "application/json"
|
|
137
|
+
assert json.loads(request.content) == {"zone_name": "example.com", "zone_type": "master"}
|
|
135
138
|
|
|
136
139
|
|
|
137
140
|
@respx.mock
|
|
138
141
|
def test_get_does_not_send_content_type():
|
|
139
|
-
route = respx.get("https://api.wayscloud.services/
|
|
140
|
-
return_value=httpx.Response(200, json={"
|
|
142
|
+
route = respx.get("https://api.wayscloud.services/v1/vps/").mock(
|
|
143
|
+
return_value=httpx.Response(200, json={"total": 0, "vps_instances": []})
|
|
141
144
|
)
|
|
142
145
|
with WaysCloudClient(token="t") as c:
|
|
143
146
|
c.vps.list()
|
|
@@ -146,7 +149,7 @@ def test_get_does_not_send_content_type():
|
|
|
146
149
|
|
|
147
150
|
@respx.mock
|
|
148
151
|
def test_204_returns_ok():
|
|
149
|
-
respx.delete("https://api.wayscloud.services/
|
|
152
|
+
respx.delete("https://api.wayscloud.services/v1/vps/abc").mock(
|
|
150
153
|
return_value=httpx.Response(204)
|
|
151
154
|
)
|
|
152
155
|
with WaysCloudClient(token="t") as c:
|
|
@@ -158,7 +161,7 @@ def test_204_returns_ok():
|
|
|
158
161
|
|
|
159
162
|
@respx.mock
|
|
160
163
|
def test_401_raises_authentication_error():
|
|
161
|
-
respx.get("https://api.wayscloud.services/
|
|
164
|
+
respx.get("https://api.wayscloud.services/v1/vps/").mock(
|
|
162
165
|
return_value=httpx.Response(401, json={"detail": "Invalid token"})
|
|
163
166
|
)
|
|
164
167
|
with WaysCloudClient(token="t") as c:
|
|
@@ -169,7 +172,7 @@ def test_401_raises_authentication_error():
|
|
|
169
172
|
|
|
170
173
|
@respx.mock
|
|
171
174
|
def test_404_raises_not_found():
|
|
172
|
-
respx.get("https://api.wayscloud.services/
|
|
175
|
+
respx.get("https://api.wayscloud.services/v1/vps/bad").mock(
|
|
173
176
|
return_value=httpx.Response(404, json={"detail": "Not found"})
|
|
174
177
|
)
|
|
175
178
|
with WaysCloudClient(token="t") as c:
|
|
@@ -179,7 +182,7 @@ def test_404_raises_not_found():
|
|
|
179
182
|
|
|
180
183
|
@respx.mock
|
|
181
184
|
def test_422_raises_validation_error():
|
|
182
|
-
respx.post("https://api.wayscloud.services/
|
|
185
|
+
respx.post("https://api.wayscloud.services/v1/dns/zones").mock(
|
|
183
186
|
return_value=httpx.Response(422, json={"detail": "Invalid zone name"})
|
|
184
187
|
)
|
|
185
188
|
with WaysCloudClient(token="t") as c:
|
|
@@ -189,7 +192,7 @@ def test_422_raises_validation_error():
|
|
|
189
192
|
|
|
190
193
|
@respx.mock
|
|
191
194
|
def test_500_raises_server_error():
|
|
192
|
-
respx.get("https://api.wayscloud.services/
|
|
195
|
+
respx.get("https://api.wayscloud.services/v1/vps/").mock(
|
|
193
196
|
return_value=httpx.Response(500, json={"detail": "Internal error"})
|
|
194
197
|
)
|
|
195
198
|
with WaysCloudClient(token="t") as c:
|
|
@@ -201,16 +204,16 @@ def test_500_raises_server_error():
|
|
|
201
204
|
|
|
202
205
|
@respx.mock
|
|
203
206
|
def test_retries_on_429_then_succeeds():
|
|
204
|
-
route = respx.get("https://api.wayscloud.services/
|
|
207
|
+
route = respx.get("https://api.wayscloud.services/v1/vps/").mock(
|
|
205
208
|
side_effect=[
|
|
206
209
|
httpx.Response(429),
|
|
207
|
-
httpx.Response(200, json={"
|
|
210
|
+
httpx.Response(200, json={"total": 1, "vps_instances": [{"hostname": "ok"}]}),
|
|
208
211
|
]
|
|
209
212
|
)
|
|
210
213
|
with WaysCloudClient(token="t") as c:
|
|
211
214
|
c.BACKOFF_FACTOR = 0.01 # Speed up test
|
|
212
215
|
result = c.vps.list()
|
|
213
|
-
assert
|
|
216
|
+
assert result == [{"hostname": "ok"}]
|
|
214
217
|
assert route.call_count == 2
|
|
215
218
|
|
|
216
219
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.0"
|
|
@@ -86,10 +86,12 @@ class WaysCloudClient:
|
|
|
86
86
|
self._storage: Optional[Any] = None
|
|
87
87
|
self._database: Optional[Any] = None
|
|
88
88
|
self._redis: Optional[Any] = None
|
|
89
|
+
self._kubernetes: Optional[Any] = None
|
|
89
90
|
self._apps: Optional[Any] = None
|
|
90
91
|
self._iot: Optional[Any] = None
|
|
91
92
|
self._sms: Optional[Any] = None
|
|
92
93
|
self._account: Optional[Any] = None
|
|
94
|
+
self._impact: Optional[Any] = None
|
|
93
95
|
|
|
94
96
|
# ── Lifecycle ─────────────────────────────────────────────────
|
|
95
97
|
|
|
@@ -111,9 +113,13 @@ class WaysCloudClient:
|
|
|
111
113
|
path: str,
|
|
112
114
|
json: Any = None,
|
|
113
115
|
params: Optional[dict] = None,
|
|
116
|
+
raw: bool = False,
|
|
114
117
|
) -> Any:
|
|
115
118
|
"""Execute HTTP request with retry and error mapping.
|
|
116
119
|
|
|
120
|
+
``raw=True`` returns the response body as text (e.g. a kubeconfig YAML)
|
|
121
|
+
instead of decoding JSON.
|
|
122
|
+
|
|
117
123
|
Retries up to MAX_RETRIES times on 429/502/503/504 with exponential
|
|
118
124
|
backoff. Maps HTTP error codes to typed exceptions.
|
|
119
125
|
"""
|
|
@@ -134,8 +140,10 @@ class WaysCloudClient:
|
|
|
134
140
|
params=params,
|
|
135
141
|
)
|
|
136
142
|
|
|
137
|
-
# Success
|
|
138
|
-
if response.status_code in (200, 201):
|
|
143
|
+
# Success (202 = accepted, asynchronous operations such as cluster create/delete)
|
|
144
|
+
if response.status_code in (200, 201, 202):
|
|
145
|
+
if raw:
|
|
146
|
+
return response.text
|
|
139
147
|
try:
|
|
140
148
|
return response.json()
|
|
141
149
|
except Exception:
|
|
@@ -217,9 +225,9 @@ class WaysCloudClient:
|
|
|
217
225
|
else:
|
|
218
226
|
raise WaysCloudError(message=message, status_code=status, detail=detail)
|
|
219
227
|
|
|
220
|
-
def get(self, path: str, params: Optional[dict] = None) -> Any:
|
|
221
|
-
"""Execute GET request."""
|
|
222
|
-
return self._request("GET", path, params=params)
|
|
228
|
+
def get(self, path: str, params: Optional[dict] = None, raw: bool = False) -> Any:
|
|
229
|
+
"""Execute GET request. ``raw=True`` returns text instead of JSON."""
|
|
230
|
+
return self._request("GET", path, params=params, raw=raw)
|
|
223
231
|
|
|
224
232
|
def post(self, path: str, json: Any = None) -> Any:
|
|
225
233
|
"""Execute POST request."""
|
|
@@ -233,9 +241,9 @@ class WaysCloudClient:
|
|
|
233
241
|
"""Execute PATCH request."""
|
|
234
242
|
return self._request("PATCH", path, json=json)
|
|
235
243
|
|
|
236
|
-
def delete(self, path: str) -> Any:
|
|
237
|
-
"""Execute DELETE request."""
|
|
238
|
-
return self._request("DELETE", path)
|
|
244
|
+
def delete(self, path: str, json: Any = None) -> Any:
|
|
245
|
+
"""Execute DELETE request (optionally with a JSON body, e.g. a confirmation)."""
|
|
246
|
+
return self._request("DELETE", path, json=json)
|
|
239
247
|
|
|
240
248
|
# ── Lazy service properties ───────────────────────────────────
|
|
241
249
|
|
|
@@ -279,6 +287,14 @@ class WaysCloudClient:
|
|
|
279
287
|
self._redis = RedisService(self)
|
|
280
288
|
return self._redis
|
|
281
289
|
|
|
290
|
+
@property
|
|
291
|
+
def kubernetes(self):
|
|
292
|
+
"""Managed Kubernetes clusters (/v1/kubernetes)."""
|
|
293
|
+
if self._kubernetes is None:
|
|
294
|
+
from .services.kubernetes import KubernetesService
|
|
295
|
+
self._kubernetes = KubernetesService(self)
|
|
296
|
+
return self._kubernetes
|
|
297
|
+
|
|
282
298
|
@property
|
|
283
299
|
def apps(self):
|
|
284
300
|
"""App Platform service."""
|
|
@@ -310,3 +326,11 @@ class WaysCloudClient:
|
|
|
310
326
|
from .services.account import AccountService
|
|
311
327
|
self._account = AccountService(self)
|
|
312
328
|
return self._account
|
|
329
|
+
|
|
330
|
+
@property
|
|
331
|
+
def impact(self):
|
|
332
|
+
"""Impact Trees service — customer-funded reforestation contributions."""
|
|
333
|
+
if self._impact is None:
|
|
334
|
+
from .services.impact import ImpactService
|
|
335
|
+
self._impact = ImpactService(self)
|
|
336
|
+
return self._impact
|
|
@@ -102,3 +102,42 @@ class AppService:
|
|
|
102
102
|
# domains(), add_domain(), remove_domain() removed.
|
|
103
103
|
# No public /v1/apps/{id}/domains endpoint exists (404 verified).
|
|
104
104
|
# Domain management is dashboard-only.
|
|
105
|
+
|
|
106
|
+
# ── GitHub auto-deploy ────────────────────────────────────────
|
|
107
|
+
# Dashboard-only endpoints. PAT auth is accepted by the
|
|
108
|
+
# get_authenticated_customer_or_internal dependency, so SDK/CLI
|
|
109
|
+
# callers can reach /v1/dashboard/apps/.../auto-deploy/github
|
|
110
|
+
# with the same PAT they already use for /v1/apps.
|
|
111
|
+
|
|
112
|
+
def auto_deploy_get(self, app_id: str) -> dict:
|
|
113
|
+
"""Return GitHub auto-deploy config for the app (no secret)."""
|
|
114
|
+
return self._client.get(
|
|
115
|
+
f"/v1/dashboard/apps/{app_id}/auto-deploy/github"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def auto_deploy_configure(
|
|
119
|
+
self,
|
|
120
|
+
app_id: str,
|
|
121
|
+
repo_url: str,
|
|
122
|
+
branch: str = "main",
|
|
123
|
+
auto_deploy_enabled: bool = True,
|
|
124
|
+
) -> dict:
|
|
125
|
+
"""Configure/update GitHub auto-deploy.
|
|
126
|
+
|
|
127
|
+
The first call for an app returns the generated webhook_secret
|
|
128
|
+
in the response — surface it to the user exactly once.
|
|
129
|
+
"""
|
|
130
|
+
return self._client.put(
|
|
131
|
+
f"/v1/dashboard/apps/{app_id}/auto-deploy/github",
|
|
132
|
+
json={
|
|
133
|
+
"repo_url": repo_url,
|
|
134
|
+
"branch": branch,
|
|
135
|
+
"auto_deploy_enabled": auto_deploy_enabled,
|
|
136
|
+
},
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def auto_deploy_rotate_secret(self, app_id: str) -> dict:
|
|
140
|
+
"""Rotate the per-app webhook secret. Returns the new one once."""
|
|
141
|
+
return self._client.post(
|
|
142
|
+
f"/v1/dashboard/apps/{app_id}/auto-deploy/github/rotate-secret"
|
|
143
|
+
)
|
|
@@ -59,17 +59,30 @@ class DatabaseService:
|
|
|
59
59
|
data = self._client.get(f"/v1/databases/{db_type}/{name}/firewall")
|
|
60
60
|
return data if isinstance(data, list) else data.get("rules", [])
|
|
61
61
|
|
|
62
|
-
def add_firewall_rule(self, db_type: str, name: str,
|
|
63
|
-
"""Add a firewall rule to
|
|
62
|
+
def add_firewall_rule(self, db_type: str, name: str, ip_address: str, description: str = "") -> dict:
|
|
63
|
+
"""Add a firewall rule to whitelist an IP address.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
db_type: Database type (postgresql or mariadb).
|
|
67
|
+
name: Database name.
|
|
68
|
+
ip_address: IPv4 address to whitelist (e.g., '203.0.113.50').
|
|
69
|
+
description: Optional description.
|
|
70
|
+
"""
|
|
71
|
+
body: dict = {"ip_address": ip_address}
|
|
72
|
+
if description:
|
|
73
|
+
body["description"] = description
|
|
64
74
|
return self._client.post(
|
|
65
75
|
f"/v1/databases/{db_type}/{name}/firewall",
|
|
66
|
-
json=
|
|
76
|
+
json=body,
|
|
67
77
|
)
|
|
68
78
|
|
|
69
79
|
def remove_firewall_rule(self, db_type: str, name: str, rule_id: str) -> dict:
|
|
70
80
|
"""Remove a firewall rule."""
|
|
71
81
|
return self._client.delete(f"/v1/databases/{db_type}/{name}/firewall/{rule_id}")
|
|
72
82
|
|
|
73
|
-
#
|
|
74
|
-
|
|
75
|
-
|
|
83
|
+
# ── Tiers ──────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
def tiers(self) -> list[dict]:
|
|
86
|
+
"""List available database tiers (standard, encrypted)."""
|
|
87
|
+
data = self._client.get("/v1/databases/tiers")
|
|
88
|
+
return data if isinstance(data, list) else data.get("tiers", [])
|