wayscloud 0.3.0__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.3.0 → wayscloud-0.4.0}/PKG-INFO +67 -1
- wayscloud-0.4.0/README.md +157 -0
- {wayscloud-0.3.0 → wayscloud-0.4.0}/pyproject.toml +1 -1
- {wayscloud-0.3.0 → wayscloud-0.4.0}/setup.cfg +1 -1
- wayscloud-0.4.0/tests/test_kubernetes_service.py +117 -0
- {wayscloud-0.3.0 → wayscloud-0.4.0}/tests/test_smoke.py +20 -17
- wayscloud-0.4.0/wayscloud/_version.py +1 -0
- {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud/client.py +32 -8
- {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud/services/apps.py +39 -0
- wayscloud-0.4.0/wayscloud/services/impact.py +76 -0
- wayscloud-0.4.0/wayscloud/services/kubernetes.py +159 -0
- {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud.egg-info/PKG-INFO +67 -1
- {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud.egg-info/SOURCES.txt +3 -0
- wayscloud-0.3.0/README.md +0 -91
- wayscloud-0.3.0/wayscloud/_version.py +0 -1
- {wayscloud-0.3.0 → wayscloud-0.4.0}/LICENSE +0 -0
- {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud/__init__.py +0 -0
- {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud/exceptions.py +0 -0
- {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud/py.typed +0 -0
- {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud/services/__init__.py +0 -0
- {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud/services/account.py +0 -0
- {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud/services/database.py +0 -0
- {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud/services/dns.py +0 -0
- {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud/services/iot.py +0 -0
- {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud/services/redis.py +0 -0
- {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud/services/sms.py +0 -0
- {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud/services/storage.py +0 -0
- {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud/services/vps.py +0 -0
- {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud.egg-info/dependency_links.txt +0 -0
- {wayscloud-0.3.0 → wayscloud-0.4.0}/wayscloud.egg-info/requires.txt +0 -0
- {wayscloud-0.3.0 → 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
|
+
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Impact service — customer-funded reforestation contributions.
|
|
2
|
+
|
|
3
|
+
Maps 1:1 to /v1/impact/* endpoints. Mock provider only at MVP — every item
|
|
4
|
+
where `provider == 'mock'` carries `demo_data: True` from the server.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import uuid as _uuid
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ImpactService:
|
|
14
|
+
"""Forests, tree commitments, items (seeds/sprouts/trees), and feed."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, client):
|
|
17
|
+
self._client = client
|
|
18
|
+
|
|
19
|
+
# ── Forests ───────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
def create_forest(self, name: str) -> dict:
|
|
22
|
+
return self._client.post("/v1/impact/forests", json={"name": name})
|
|
23
|
+
|
|
24
|
+
def list_forests(self) -> list[dict]:
|
|
25
|
+
data = self._client.get("/v1/impact/forests")
|
|
26
|
+
return data.get("forests", data) if isinstance(data, dict) else data
|
|
27
|
+
|
|
28
|
+
def forest_status(self, forest: str) -> dict:
|
|
29
|
+
"""Counts by stage + commitment buckets. `forest` may be UUID or name."""
|
|
30
|
+
return self._client.get(f"/v1/impact/forests/{forest}")
|
|
31
|
+
|
|
32
|
+
def forest_feed(self, forest: str, limit: int = 20) -> list[dict]:
|
|
33
|
+
data = self._client.get(
|
|
34
|
+
f"/v1/impact/forests/{forest}/feed", params={"limit": limit}
|
|
35
|
+
)
|
|
36
|
+
return data.get("feed", data) if isinstance(data, dict) else data
|
|
37
|
+
|
|
38
|
+
# ── Tree commitments ──────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
def commit_trees(
|
|
41
|
+
self,
|
|
42
|
+
forest: str,
|
|
43
|
+
quantity: int,
|
|
44
|
+
idempotency_key: Optional[str] = None,
|
|
45
|
+
) -> dict:
|
|
46
|
+
"""Commit N trees to a forest. Server returns the commitment + items.
|
|
47
|
+
|
|
48
|
+
Idempotency: same idempotency_key returns the same commitment. If
|
|
49
|
+
omitted, the SDK generates a UUID4 client-side so retries on transient
|
|
50
|
+
network failures don't double-charge.
|
|
51
|
+
"""
|
|
52
|
+
body = {
|
|
53
|
+
"forest_id": forest,
|
|
54
|
+
"quantity": quantity,
|
|
55
|
+
"idempotency_key": idempotency_key or f"sdk_{_uuid.uuid4().hex}",
|
|
56
|
+
}
|
|
57
|
+
return self._client.post("/v1/impact/tree-commitments", json=body)
|
|
58
|
+
|
|
59
|
+
def list_commitments(self, limit: int = 50) -> list[dict]:
|
|
60
|
+
data = self._client.get(
|
|
61
|
+
"/v1/impact/tree-commitments", params={"limit": limit}
|
|
62
|
+
)
|
|
63
|
+
return data.get("commitments", data) if isinstance(data, dict) else data
|
|
64
|
+
|
|
65
|
+
def get_commitment(self, commitment_id: str) -> dict:
|
|
66
|
+
return self._client.get(f"/v1/impact/tree-commitments/{commitment_id}")
|
|
67
|
+
|
|
68
|
+
# ── Items (seeds / sprouts / trees) ───────────────────────────
|
|
69
|
+
|
|
70
|
+
def get_tree(self, item_id: str) -> dict:
|
|
71
|
+
"""`item_id` may be a UUID or an external short id like 'seed_81ab1f2c93'."""
|
|
72
|
+
return self._client.get(f"/v1/impact/trees/{item_id}")
|
|
73
|
+
|
|
74
|
+
def water_tree(self, item_id: str) -> dict:
|
|
75
|
+
"""Cosmetic watering. Rate-limited server-side to once per 24h."""
|
|
76
|
+
return self._client.post(f"/v1/impact/trees/{item_id}/water", json={})
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Kubernetes service -- Managed Kubernetes clusters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class KubernetesService:
|
|
10
|
+
"""Manage Kubernetes clusters.
|
|
11
|
+
|
|
12
|
+
All methods use the public API at /v1/kubernetes (PAT with the
|
|
13
|
+
``kubernetes:read`` / ``kubernetes:write`` scopes, or API key auth).
|
|
14
|
+
Create and delete are asynchronous: the API answers 202 with the cluster
|
|
15
|
+
in status ``provisioning`` / ``deleting``; use :meth:`wait` to block.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
TERMINAL = {"running", "error", "deleted"}
|
|
19
|
+
|
|
20
|
+
def __init__(self, client):
|
|
21
|
+
self._client = client
|
|
22
|
+
|
|
23
|
+
# -- catalogue -----------------------------------------------------------
|
|
24
|
+
def plans(self, region: str = "no", kind: Optional[str] = None) -> list[dict]:
|
|
25
|
+
"""List cluster and node plans (``kind``: controlplane, node, ip, lb, storage, backup)."""
|
|
26
|
+
params = {"region": region}
|
|
27
|
+
if kind:
|
|
28
|
+
params["kind"] = kind
|
|
29
|
+
data = self._client.get("/v1/kubernetes/plans", params=params)
|
|
30
|
+
return data.get("plans", []) if isinstance(data, dict) else data
|
|
31
|
+
|
|
32
|
+
def regions(self) -> list[dict]:
|
|
33
|
+
data = self._client.get("/v1/kubernetes/regions")
|
|
34
|
+
return data.get("regions", []) if isinstance(data, dict) else data
|
|
35
|
+
|
|
36
|
+
def versions(self) -> dict:
|
|
37
|
+
return self._client.get("/v1/kubernetes/versions")
|
|
38
|
+
|
|
39
|
+
def estimate(self, plan_code: str, node_pools: list[dict], currency: str = "NOK", region: str = "no") -> dict:
|
|
40
|
+
return self._client.post("/v1/kubernetes/estimate",
|
|
41
|
+
json={"plan_code": plan_code, "node_pools": node_pools, "currency": currency, "region": region})
|
|
42
|
+
|
|
43
|
+
# -- clusters ------------------------------------------------------------
|
|
44
|
+
def list(self) -> list[dict]:
|
|
45
|
+
data = self._client.get("/v1/kubernetes/clusters")
|
|
46
|
+
return data.get("clusters", []) if isinstance(data, dict) else data
|
|
47
|
+
|
|
48
|
+
def get(self, cluster_id: str) -> dict:
|
|
49
|
+
return self._client.get(f"/v1/kubernetes/clusters/{cluster_id}")
|
|
50
|
+
|
|
51
|
+
def create(
|
|
52
|
+
self,
|
|
53
|
+
name: str,
|
|
54
|
+
node_pools: list[dict],
|
|
55
|
+
plan_code: str = "k8s-cluster-dev",
|
|
56
|
+
region: str = "no",
|
|
57
|
+
version: str = "1.34",
|
|
58
|
+
api_ip_filter: Optional[list[str]] = None,
|
|
59
|
+
ssh_key_ids: Optional[list[str]] = None,
|
|
60
|
+
) -> dict:
|
|
61
|
+
"""Create a cluster. ``node_pools``: ``[{"name": "default", "plan_code": "k8s-node-2c4g", "count": 2}]``."""
|
|
62
|
+
return self._client.post("/v1/kubernetes/clusters", json={
|
|
63
|
+
"name": name, "region": region, "plan_code": plan_code, "version": version,
|
|
64
|
+
"node_pools": node_pools, "api_ip_filter": api_ip_filter or [], "ssh_key_ids": ssh_key_ids or [],
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
def delete(self, cluster_id: str, confirm_name: str) -> dict:
|
|
68
|
+
"""Delete a cluster. ``confirm_name`` must equal the cluster's name."""
|
|
69
|
+
return self._client.delete(f"/v1/kubernetes/clusters/{cluster_id}", json={"confirm_name": confirm_name})
|
|
70
|
+
|
|
71
|
+
def wait(self, cluster_id: str, timeout: int = 1800, interval: int = 15) -> dict:
|
|
72
|
+
"""Poll until the cluster reaches running/error (or is gone after a delete)."""
|
|
73
|
+
from ..exceptions import NotFoundError
|
|
74
|
+
|
|
75
|
+
deadline = time.time() + timeout
|
|
76
|
+
while True:
|
|
77
|
+
try:
|
|
78
|
+
c = self.get(cluster_id)
|
|
79
|
+
except NotFoundError: # gone after a delete: terminal
|
|
80
|
+
return {"id": cluster_id, "status": "deleted"}
|
|
81
|
+
if c.get("status") in self.TERMINAL:
|
|
82
|
+
return c
|
|
83
|
+
if time.time() > deadline:
|
|
84
|
+
raise TimeoutError(f"cluster {cluster_id} still {c.get('status')} after {timeout}s")
|
|
85
|
+
time.sleep(interval)
|
|
86
|
+
|
|
87
|
+
def history(self, cluster_id: str) -> list[dict]:
|
|
88
|
+
data = self._client.get(f"/v1/kubernetes/clusters/{cluster_id}/history")
|
|
89
|
+
return data.get("events", []) if isinstance(data, dict) else data
|
|
90
|
+
|
|
91
|
+
# -- node pools ----------------------------------------------------------
|
|
92
|
+
def add_node_pool(self, cluster_id: str, name: str, plan_code: str, count: int,
|
|
93
|
+
labels: Optional[dict] = None, taints: Optional[list[dict]] = None,
|
|
94
|
+
ssh_key_ids: Optional[list[str]] = None) -> dict:
|
|
95
|
+
"""Add a node pool. ``count`` must be 1-16; ``taints`` entries are
|
|
96
|
+
``{"key": ..., "value": ..., "effect": ...}``. ``ssh_key_ids`` must be
|
|
97
|
+
SSH key ids on the account (ownership is validated by the API)."""
|
|
98
|
+
return self._client.post(f"/v1/kubernetes/clusters/{cluster_id}/node-pools",
|
|
99
|
+
json={"name": name, "plan_code": plan_code, "count": count, "labels": labels or {},
|
|
100
|
+
"taints": taints or [], "ssh_key_ids": ssh_key_ids or []})
|
|
101
|
+
|
|
102
|
+
def scale_node_pool(self, cluster_id: str, pool_name: str, count: int) -> dict:
|
|
103
|
+
"""Scale a node pool. ``count`` must be between 1 and 16; scale-to-zero is not supported."""
|
|
104
|
+
return self._client.post(f"/v1/kubernetes/clusters/{cluster_id}/node-pools/{pool_name}/scale", json={"count": count})
|
|
105
|
+
|
|
106
|
+
def delete_node_pool(self, cluster_id: str, pool_name: str) -> dict:
|
|
107
|
+
return self._client.delete(f"/v1/kubernetes/clusters/{cluster_id}/node-pools/{pool_name}")
|
|
108
|
+
|
|
109
|
+
# -- access --------------------------------------------------------------
|
|
110
|
+
def kubeconfig(self, cluster_id: str) -> str:
|
|
111
|
+
"""Admin kubeconfig as YAML text. Treat as a secret."""
|
|
112
|
+
return self._client.get(f"/v1/kubernetes/clusters/{cluster_id}/kubeconfig", raw=True)
|
|
113
|
+
|
|
114
|
+
def set_api_access(self, cluster_id: str, cidrs: list[str]) -> dict:
|
|
115
|
+
"""Set the CIDRs allowed to reach the Kubernetes API. An empty list means the API is
|
|
116
|
+
only reachable from WAYSCloud infrastructure; add the client CIDR for external
|
|
117
|
+
kubectl/API access."""
|
|
118
|
+
return self._client.put(f"/v1/kubernetes/clusters/{cluster_id}/api-access", json={"cidrs": cidrs})
|
|
119
|
+
|
|
120
|
+
# -- public IPs ----------------------------------------------------------
|
|
121
|
+
def allocate_ip(self, cluster_id: str) -> dict:
|
|
122
|
+
return self._client.post(f"/v1/kubernetes/clusters/{cluster_id}/ips")
|
|
123
|
+
|
|
124
|
+
def release_ip(self, cluster_id: str, address: str) -> dict:
|
|
125
|
+
return self._client.delete(f"/v1/kubernetes/clusters/{cluster_id}/ips/{address}")
|
|
126
|
+
|
|
127
|
+
def set_ptr(self, cluster_id: str, address: str, ptr_record: str) -> dict:
|
|
128
|
+
"""Set reverse DNS. The name must already resolve to the address."""
|
|
129
|
+
return self._client.put(f"/v1/kubernetes/clusters/{cluster_id}/ips/{address}/ptr", json={"ptr_record": ptr_record})
|
|
130
|
+
|
|
131
|
+
# -- backups -------------------------------------------------------------
|
|
132
|
+
def backup_policy(self, cluster_id: str) -> dict:
|
|
133
|
+
return self._client.get(f"/v1/kubernetes/clusters/{cluster_id}/backup-policy")
|
|
134
|
+
|
|
135
|
+
def set_backup_policy(self, cluster_id: str, *, enabled: bool = True, schedule_cron: str = "0 3 * * *",
|
|
136
|
+
retention_days: int = 14, include_volumes: bool = True, namespaces: Optional[list[str]] = None) -> dict:
|
|
137
|
+
return self._client.put(f"/v1/kubernetes/clusters/{cluster_id}/backup-policy", json={
|
|
138
|
+
"enabled": enabled, "schedule_cron": schedule_cron, "retention_days": retention_days,
|
|
139
|
+
"include_volumes": include_volumes, "namespaces": namespaces})
|
|
140
|
+
|
|
141
|
+
def backups(self, cluster_id: str) -> list[dict]:
|
|
142
|
+
data = self._client.get(f"/v1/kubernetes/clusters/{cluster_id}/backups")
|
|
143
|
+
return data.get("backups", []) if isinstance(data, dict) else data
|
|
144
|
+
|
|
145
|
+
def backup_now(self, cluster_id: str, namespaces: Optional[list[str]] = None) -> dict:
|
|
146
|
+
return self._client.post(f"/v1/kubernetes/clusters/{cluster_id}/backups", json={"namespaces": namespaces})
|
|
147
|
+
|
|
148
|
+
def restore(self, cluster_id: str, backup_id: str, namespaces: Optional[list[str]] = None) -> dict:
|
|
149
|
+
return self._client.post(f"/v1/kubernetes/clusters/{cluster_id}/backups/{backup_id}/restore", json={"namespaces": namespaces})
|
|
150
|
+
|
|
151
|
+
# -- upgrades ------------------------------------------------------------
|
|
152
|
+
def available_upgrades(self, cluster_id: str) -> list[str]:
|
|
153
|
+
data = self._client.get(f"/v1/kubernetes/clusters/{cluster_id}/upgrade")
|
|
154
|
+
return data.get("versions", []) if isinstance(data, dict) else data
|
|
155
|
+
|
|
156
|
+
def upgrade(self, cluster_id: str, version: str) -> dict:
|
|
157
|
+
"""Upgrade the control plane to `version`. No backup is taken automatically; create
|
|
158
|
+
one first if the workload needs a restore point (the portal shows the same warning)."""
|
|
159
|
+
return self._client.post(f"/v1/kubernetes/clusters/{cluster_id}/upgrade", json={"version": version})
|
|
@@ -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).
|
|
@@ -2,6 +2,7 @@ LICENSE
|
|
|
2
2
|
README.md
|
|
3
3
|
pyproject.toml
|
|
4
4
|
setup.cfg
|
|
5
|
+
tests/test_kubernetes_service.py
|
|
5
6
|
tests/test_smoke.py
|
|
6
7
|
wayscloud/__init__.py
|
|
7
8
|
wayscloud/_version.py
|
|
@@ -18,7 +19,9 @@ wayscloud/services/account.py
|
|
|
18
19
|
wayscloud/services/apps.py
|
|
19
20
|
wayscloud/services/database.py
|
|
20
21
|
wayscloud/services/dns.py
|
|
22
|
+
wayscloud/services/impact.py
|
|
21
23
|
wayscloud/services/iot.py
|
|
24
|
+
wayscloud/services/kubernetes.py
|
|
22
25
|
wayscloud/services/redis.py
|
|
23
26
|
wayscloud/services/sms.py
|
|
24
27
|
wayscloud/services/storage.py
|
wayscloud-0.3.0/README.md
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
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
|
-
## Error handling
|
|
57
|
-
|
|
58
|
-
```python
|
|
59
|
-
from wayscloud import NotFoundError, AuthenticationError
|
|
60
|
-
|
|
61
|
-
try:
|
|
62
|
-
client.vps.get("id")
|
|
63
|
-
except NotFoundError:
|
|
64
|
-
print("Not found")
|
|
65
|
-
except AuthenticationError:
|
|
66
|
-
print("Invalid credentials")
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
All exceptions inherit from `WaysCloudError`.
|
|
70
|
-
|
|
71
|
-
## Configuration
|
|
72
|
-
|
|
73
|
-
| Parameter | Environment variable | Default |
|
|
74
|
-
|-----------|---------------------|---------|
|
|
75
|
-
| `token` | `WAYSCLOUD_TOKEN` | — |
|
|
76
|
-
| `api_key` | `WAYSCLOUD_API_KEY` | — |
|
|
77
|
-
| `base_url` | `WAYSCLOUD_API_URL` | `https://api.wayscloud.services` |
|
|
78
|
-
| `timeout` | — | `30.0` |
|
|
79
|
-
|
|
80
|
-
## Retries
|
|
81
|
-
|
|
82
|
-
Automatic retries on 429, 502, 503, 504 with exponential backoff. Respects `Retry-After` headers.
|
|
83
|
-
|
|
84
|
-
## Requirements
|
|
85
|
-
|
|
86
|
-
- Python 3.10+
|
|
87
|
-
- httpx
|
|
88
|
-
|
|
89
|
-
## License
|
|
90
|
-
|
|
91
|
-
MIT — see [LICENSE](LICENSE).
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.3.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|