kc-sdk-python 0.1.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.
- kc_sdk_python-0.1.0/LICENSE +21 -0
- kc_sdk_python-0.1.0/PKG-INFO +80 -0
- kc_sdk_python-0.1.0/README.md +60 -0
- kc_sdk_python-0.1.0/kc_api.py +774 -0
- kc_sdk_python-0.1.0/kc_sdk_python.egg-info/PKG-INFO +80 -0
- kc_sdk_python-0.1.0/kc_sdk_python.egg-info/SOURCES.txt +9 -0
- kc_sdk_python-0.1.0/kc_sdk_python.egg-info/dependency_links.txt +1 -0
- kc_sdk_python-0.1.0/kc_sdk_python.egg-info/requires.txt +3 -0
- kc_sdk_python-0.1.0/kc_sdk_python.egg-info/top_level.txt +1 -0
- kc_sdk_python-0.1.0/pyproject.toml +31 -0
- kc_sdk_python-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kvindo
|
|
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.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kc-sdk-python
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK / client for the Kvindo Cloud API
|
|
5
|
+
Author: Kvindo
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Kvindo/kc-sdk-python
|
|
8
|
+
Project-URL: Repository, https://github.com/Kvindo/kc-sdk-python
|
|
9
|
+
Keywords: kvindo,cloud,sdk,client,api
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Requires-Python: >=3.8
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: requests
|
|
17
|
+
Requires-Dist: marshmallow-dataclass
|
|
18
|
+
Requires-Dist: py-ulid
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# kc-sdk-python
|
|
22
|
+
|
|
23
|
+
Python SDK / client for the **Kvindo Cloud API**.
|
|
24
|
+
|
|
25
|
+
A thin, typed client over the REST API: one resource client per resource type
|
|
26
|
+
(VMs, volumes, load balancers, kubernetes, S3, VPCs, …), all sharing the same
|
|
27
|
+
create / read / update / delete / list contract.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
pip install kc-sdk-python
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Dependencies: `requests`, `marshmallow-dataclass`, `py-ulid`.
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from kc_api import KcClient
|
|
41
|
+
|
|
42
|
+
client = KcClient("YOUR_API_TOKEN") # api_url defaults to https://cloud-api.kvindo.ru
|
|
43
|
+
|
|
44
|
+
# List (label-filtered, paginated)
|
|
45
|
+
resp = client.vms.get_by_labels({"env": "prod"}, max_page_size=50)
|
|
46
|
+
for vm in resp.resources:
|
|
47
|
+
print(vm["metadata"]["name"])
|
|
48
|
+
|
|
49
|
+
# Read one
|
|
50
|
+
vm = client.vms.read("01H...")
|
|
51
|
+
print(vm.resource)
|
|
52
|
+
|
|
53
|
+
# Create / update (async) then wait for it to reconcile
|
|
54
|
+
created = client.vms.create_or_update({
|
|
55
|
+
"metadata": {"name": "my-vm", "folderId": "01H..."},
|
|
56
|
+
"spec": {"offerId": "g3-1c2-100", "state": "running", ...},
|
|
57
|
+
})
|
|
58
|
+
status = client.vms.wait_request_satisfied(created.requestId, timeout_seconds=300)
|
|
59
|
+
assert status.succeeded
|
|
60
|
+
|
|
61
|
+
# Delete (optionally block until reconciled)
|
|
62
|
+
client.vms.delete("01H...", wait=True)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Create / update / delete are **asynchronous**: they return a `requestId`; poll
|
|
66
|
+
`read_request(requestId)` or use `wait_request_satisfied(...)`. Every response
|
|
67
|
+
object carries `errorMessage` / `errorCode` (a typed `KcApi*ErrorCode`) which are
|
|
68
|
+
`None` on success.
|
|
69
|
+
|
|
70
|
+
### Available resources
|
|
71
|
+
|
|
72
|
+
`KcClient` exposes one `KcResourceClient` per type, e.g. `client.vms`,
|
|
73
|
+
`client.volumes`, `client.s3_buckets`, `client.kubernetes`,
|
|
74
|
+
`client.load_balancers`, `client.vpcs`, `client.postgresql_standalones`,
|
|
75
|
+
`client.folders`, `client.transactions`, … (the surface mirrors the official
|
|
76
|
+
Kvindo Cloud API).
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# kc-sdk-python
|
|
2
|
+
|
|
3
|
+
Python SDK / client for the **Kvindo Cloud API**.
|
|
4
|
+
|
|
5
|
+
A thin, typed client over the REST API: one resource client per resource type
|
|
6
|
+
(VMs, volumes, load balancers, kubernetes, S3, VPCs, …), all sharing the same
|
|
7
|
+
create / read / update / delete / list contract.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pip install kc-sdk-python
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Dependencies: `requests`, `marshmallow-dataclass`, `py-ulid`.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from kc_api import KcClient
|
|
21
|
+
|
|
22
|
+
client = KcClient("YOUR_API_TOKEN") # api_url defaults to https://cloud-api.kvindo.ru
|
|
23
|
+
|
|
24
|
+
# List (label-filtered, paginated)
|
|
25
|
+
resp = client.vms.get_by_labels({"env": "prod"}, max_page_size=50)
|
|
26
|
+
for vm in resp.resources:
|
|
27
|
+
print(vm["metadata"]["name"])
|
|
28
|
+
|
|
29
|
+
# Read one
|
|
30
|
+
vm = client.vms.read("01H...")
|
|
31
|
+
print(vm.resource)
|
|
32
|
+
|
|
33
|
+
# Create / update (async) then wait for it to reconcile
|
|
34
|
+
created = client.vms.create_or_update({
|
|
35
|
+
"metadata": {"name": "my-vm", "folderId": "01H..."},
|
|
36
|
+
"spec": {"offerId": "g3-1c2-100", "state": "running", ...},
|
|
37
|
+
})
|
|
38
|
+
status = client.vms.wait_request_satisfied(created.requestId, timeout_seconds=300)
|
|
39
|
+
assert status.succeeded
|
|
40
|
+
|
|
41
|
+
# Delete (optionally block until reconciled)
|
|
42
|
+
client.vms.delete("01H...", wait=True)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Create / update / delete are **asynchronous**: they return a `requestId`; poll
|
|
46
|
+
`read_request(requestId)` or use `wait_request_satisfied(...)`. Every response
|
|
47
|
+
object carries `errorMessage` / `errorCode` (a typed `KcApi*ErrorCode`) which are
|
|
48
|
+
`None` on success.
|
|
49
|
+
|
|
50
|
+
### Available resources
|
|
51
|
+
|
|
52
|
+
`KcClient` exposes one `KcResourceClient` per type, e.g. `client.vms`,
|
|
53
|
+
`client.volumes`, `client.s3_buckets`, `client.kubernetes`,
|
|
54
|
+
`client.load_balancers`, `client.vpcs`, `client.postgresql_standalones`,
|
|
55
|
+
`client.folders`, `client.transactions`, … (the surface mirrors the official
|
|
56
|
+
Kvindo Cloud API).
|
|
57
|
+
|
|
58
|
+
## License
|
|
59
|
+
|
|
60
|
+
MIT
|
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
"""Python client (SDK) for the Kvindo Cloud API.
|
|
2
|
+
|
|
3
|
+
`KcClient` exposes one `KcResourceClient` per resource type (vms, volumes,
|
|
4
|
+
load balancers, kubernetes, s3, …). Every resource speaks the same REST
|
|
5
|
+
contract under `/api/v1/<resource-type>`:
|
|
6
|
+
|
|
7
|
+
PUT /api/v1/<type> create or update (idempotent on metadata.id)
|
|
8
|
+
GET /api/v1/<type>/<id> read one
|
|
9
|
+
DELETE /api/v1/<type>/<id> delete one
|
|
10
|
+
GET /api/v1/<type>/get-by-labels list (label-filtered, paginated)
|
|
11
|
+
GET /api/v1/<type>/request/<reqId> poll an async change-request's status
|
|
12
|
+
|
|
13
|
+
Create/update/delete are **asynchronous**: they return a `requestId`
|
|
14
|
+
immediately; the actual provisioning is done by server-side reconcilers. Poll
|
|
15
|
+
`read_request(requestId)` (or use `wait_request_satisfied`) until it succeeds.
|
|
16
|
+
|
|
17
|
+
The resource surface mirrors the maintained C# client
|
|
18
|
+
`KvindoCloud.Api/KvindoCloudClient.cs`, which is the source of truth.
|
|
19
|
+
|
|
20
|
+
Dependencies: requests, marshmallow-dataclass, py-ulid.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import time
|
|
24
|
+
import logging
|
|
25
|
+
import requests
|
|
26
|
+
from requests.adapters import HTTPAdapter
|
|
27
|
+
from urllib3.util.retry import Retry
|
|
28
|
+
from urllib.parse import urlencode
|
|
29
|
+
from enum import Enum
|
|
30
|
+
|
|
31
|
+
# https://stackoverflow.com/questions/15476983/deserialize-a-json-string-to-an-object-in-python
|
|
32
|
+
from marshmallow_dataclass import dataclass
|
|
33
|
+
from typing import List, Optional
|
|
34
|
+
from dataclasses import field
|
|
35
|
+
from ulid import ULID
|
|
36
|
+
|
|
37
|
+
# Why a module logger here instead of importing one: this file is published as a
|
|
38
|
+
# standalone SDK, so it must not depend on the internal kc_common module (which
|
|
39
|
+
# pulls in python-json-logger and a pre-configured logger). Callers configure
|
|
40
|
+
# logging as they see fit; by default this logger is silent.
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Vendored from kc_common (create_http_client_with_retries / create_url_with_query_params)
|
|
45
|
+
# so the SDK carries no local-module dependency — its only third-party deps are
|
|
46
|
+
# requests, marshmallow-dataclass and py-ulid.
|
|
47
|
+
def create_http_client_with_retries(
|
|
48
|
+
retry_statuses=[500, 502, 503, 504, 520, 521],
|
|
49
|
+
verify_ssl: bool = False,
|
|
50
|
+
) -> requests.Session:
|
|
51
|
+
"""Build a `requests.Session` that retries idempotent failures with backoff.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
retry_statuses: HTTP status codes that should be retried. 5xx are
|
|
55
|
+
server-side; 520 = web server returned an unknown error;
|
|
56
|
+
521 = origin down (Cloudflare-specific).
|
|
57
|
+
verify_ssl: enable/disable TLS certificate verification. Defaults to
|
|
58
|
+
False because internal/dev endpoints use self-signed certs; pass
|
|
59
|
+
True against public prod.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
A configured `requests.Session` (reuse it for connection pooling).
|
|
63
|
+
"""
|
|
64
|
+
session = requests.Session()
|
|
65
|
+
session.verify = verify_ssl
|
|
66
|
+
# total=5 attempts; backoff_factor=1 -> delays 0.5, 1, 2, 4, 8 ... seconds.
|
|
67
|
+
retries = Retry(total=5, backoff_factor=1, status_forcelist=retry_statuses)
|
|
68
|
+
adapter = HTTPAdapter(max_retries=retries)
|
|
69
|
+
session.mount("http://", adapter)
|
|
70
|
+
session.mount("https://", adapter)
|
|
71
|
+
return session
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def create_url_with_query_params(base_url: str, query_params: dict) -> str:
|
|
75
|
+
"""Append a query string to `base_url`, URL-encoding keys and values.
|
|
76
|
+
|
|
77
|
+
A dict-valued param is flattened to repeated `key[subkey]=value` pairs
|
|
78
|
+
(used for the `labels` filter). `None` values are dropped entirely.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
base_url: URL without a query string.
|
|
82
|
+
query_params: flat dict; values may be scalars or a one-level dict.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
The full URL including the `?...` query string.
|
|
86
|
+
"""
|
|
87
|
+
# Drop params whose value is None (e.g. an unset enumeratorId).
|
|
88
|
+
params = {k: v for k, v in query_params.items() if v is not None}
|
|
89
|
+
|
|
90
|
+
url = base_url + "?"
|
|
91
|
+
|
|
92
|
+
# TODO: Add recursive traverse
|
|
93
|
+
for p in params:
|
|
94
|
+
if isinstance(params[p], dict):
|
|
95
|
+
# Flatten one nesting level: labels={"env":"dev"} -> labels[env]=dev
|
|
96
|
+
for p2 in params[p]:
|
|
97
|
+
url = f"{url}{'' if url.endswith('?') else '&'}{urlencode({f'{p}[{p2}]': params[p][p2]})}"
|
|
98
|
+
else:
|
|
99
|
+
url = f"{url}{'' if url.endswith('?') else '&'}{urlencode({p: params[p]})}"
|
|
100
|
+
|
|
101
|
+
return url
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ── Error codes ───────────────────────────────────────────────────────────────
|
|
105
|
+
# Error-code enums mirror the C# enums in KvindoCloud.Api/Models/*ErrorCode.cs.
|
|
106
|
+
# The API serializes them as the PascalCase member NAME (each C# enum carries
|
|
107
|
+
# [JsonConverter(typeof(JsonStringEnumConverter))]), and marshmallow loads Enum
|
|
108
|
+
# fields by name, so the member names below must match the C# names exactly.
|
|
109
|
+
# Why each value is the name string (not the HTTP status code it used to be):
|
|
110
|
+
# duplicate values make Python collapse members into aliases of the first one
|
|
111
|
+
# with that value (e.g. every 422 became an alias of NotFound), so "MissingIdField"
|
|
112
|
+
# on the wire silently loaded as NotFound. Unique values keep every code distinct.
|
|
113
|
+
#
|
|
114
|
+
# Note: the """docstrings""" after each member/field are attribute docstrings —
|
|
115
|
+
# editors (Pylance/PyCharm) surface them on hover, unlike `#` comments.
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class KcApiGenericErrorCode(Enum):
|
|
119
|
+
"""Generic top-level errors not specific to one operation."""
|
|
120
|
+
|
|
121
|
+
Unauthorized = "Unauthorized"
|
|
122
|
+
"""Token missing/invalid or lacks the required permission."""
|
|
123
|
+
BadData = "BadData"
|
|
124
|
+
"""Request was malformed (bad JSON, wrong types, etc.)."""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class KcApiModificationErrorCode(Enum):
|
|
128
|
+
"""Errors returned by create / update / delete (PUT and DELETE)."""
|
|
129
|
+
|
|
130
|
+
NotFound = "NotFound"
|
|
131
|
+
"""No resource with the given id."""
|
|
132
|
+
Unauthorized = "Unauthorized"
|
|
133
|
+
"""Token lacks permission for this resource/action."""
|
|
134
|
+
MissingIdField = "MissingIdField"
|
|
135
|
+
"""Body had no id (and none could be derived)."""
|
|
136
|
+
ResourceIsScheduling = "ResourceIsScheduling"
|
|
137
|
+
"""A previous change request is still in flight; retry later."""
|
|
138
|
+
MissingNameField = "MissingNameField"
|
|
139
|
+
"""Body had no name."""
|
|
140
|
+
Unknown = "Unknown"
|
|
141
|
+
"""Unhandled server-side error (treat as a 5xx)."""
|
|
142
|
+
ResourceIsDeleteProtected = "ResourceIsDeleteProtected"
|
|
143
|
+
"""deleteProtection=true; clear it before deleting."""
|
|
144
|
+
BadData = "BadData"
|
|
145
|
+
"""Request was malformed."""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class KcResourceDeleteResponse(object):
|
|
150
|
+
"""Response of `KcResourceClient.delete`."""
|
|
151
|
+
|
|
152
|
+
requestId: str = None
|
|
153
|
+
"""Id of the async change request to poll; None on error."""
|
|
154
|
+
resourceId: str = None
|
|
155
|
+
"""Id of the resource being deleted; None on error."""
|
|
156
|
+
errorMessage: str = None
|
|
157
|
+
"""Human-readable error; None on success."""
|
|
158
|
+
errorCode: KcApiModificationErrorCode = None
|
|
159
|
+
"""Machine-readable error; None on success."""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass
|
|
163
|
+
class KcResourceCreateResponse(object):
|
|
164
|
+
"""Response of `KcResourceClient.create` / `create_or_update`."""
|
|
165
|
+
|
|
166
|
+
requestId: str = None
|
|
167
|
+
"""Id of the async change request to poll; None on error."""
|
|
168
|
+
resourceId: str = None
|
|
169
|
+
"""Id of the created/updated resource; None on error."""
|
|
170
|
+
errorMessage: str = None
|
|
171
|
+
"""Human-readable error; None on success."""
|
|
172
|
+
errorCode: KcApiModificationErrorCode = None
|
|
173
|
+
"""Machine-readable error; None on success."""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass
|
|
177
|
+
class KcResourceUpdateResponse(object):
|
|
178
|
+
"""Response of `KcResourceClient.update`. Shape-identical to the create response."""
|
|
179
|
+
|
|
180
|
+
requestId: str = None
|
|
181
|
+
"""Id of the async change request to poll; None on error."""
|
|
182
|
+
resourceId: str = None
|
|
183
|
+
"""Id of the updated resource; None on error."""
|
|
184
|
+
errorMessage: str = None
|
|
185
|
+
"""Human-readable error; None on success."""
|
|
186
|
+
errorCode: KcApiModificationErrorCode = None
|
|
187
|
+
"""Machine-readable error; None on success."""
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class KcApiReadErrorCode(Enum):
|
|
191
|
+
"""Errors returned by a single-resource read (`GET /api/v1/<type>/<id>`)."""
|
|
192
|
+
|
|
193
|
+
Unauthorized = "Unauthorized"
|
|
194
|
+
"""Token lacks read permission."""
|
|
195
|
+
NotFound = "NotFound"
|
|
196
|
+
"""No resource with the given id."""
|
|
197
|
+
ResourceIsScheduling = "ResourceIsScheduling"
|
|
198
|
+
"""Resource exists but its first change request hasn't completed."""
|
|
199
|
+
Unknown = "Unknown"
|
|
200
|
+
"""Unhandled server-side error."""
|
|
201
|
+
BadData = "BadData"
|
|
202
|
+
"""Malformed request (e.g. invalid id)."""
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass
|
|
206
|
+
class KcResourceReadResponse(object):
|
|
207
|
+
"""Response of `KcResourceClient.read`."""
|
|
208
|
+
|
|
209
|
+
resource: dict = None
|
|
210
|
+
"""The resource as a raw dict; None if errorMessage is set."""
|
|
211
|
+
errorMessage: str = None
|
|
212
|
+
"""Human-readable error; None on success."""
|
|
213
|
+
errorCode: KcApiReadErrorCode = None
|
|
214
|
+
"""Machine-readable error; set iff errorMessage is set."""
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class KcApiReadRequestErrorCode(Enum):
|
|
218
|
+
"""Errors returned when polling an async change-request's status."""
|
|
219
|
+
|
|
220
|
+
Unauthorized = "Unauthorized"
|
|
221
|
+
"""Token lacks permission."""
|
|
222
|
+
NotFound = "NotFound"
|
|
223
|
+
"""No change request with the given requestId."""
|
|
224
|
+
Unknown = "Unknown"
|
|
225
|
+
"""Unhandled server-side error."""
|
|
226
|
+
BadData = "BadData"
|
|
227
|
+
"""Malformed request."""
|
|
228
|
+
UnableToReconcile = "UnableToReconcile"
|
|
229
|
+
"""The reconciler failed to apply the change (terminal failure)."""
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclass
|
|
233
|
+
class KcResourceReadRequestResponse(object):
|
|
234
|
+
"""Status of an async create/update/delete request (`GET .../request/<id>`)."""
|
|
235
|
+
|
|
236
|
+
succeeded: bool
|
|
237
|
+
"""True once the reconciler has finished applying the change."""
|
|
238
|
+
scheduledResourceId: str
|
|
239
|
+
"""Id of the resource the request targets."""
|
|
240
|
+
errorMessage: str = None
|
|
241
|
+
"""Set if the request failed; None while pending or on success."""
|
|
242
|
+
errorCode: KcApiReadRequestErrorCode = None
|
|
243
|
+
"""Machine-readable failure code; None while pending or on success."""
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class KcApiGetByLabelsErrorCode(Enum):
|
|
247
|
+
"""Errors returned by label-filtered list (`GET .../get-by-labels`)."""
|
|
248
|
+
|
|
249
|
+
Unauthorized = "Unauthorized"
|
|
250
|
+
"""Token lacks list permission."""
|
|
251
|
+
PageSizeTooBig = "PageSizeTooBig"
|
|
252
|
+
"""maxPageSize exceeded the server limit (max 100)."""
|
|
253
|
+
EnumeratorNotFound = "EnumeratorNotFound"
|
|
254
|
+
"""The pagination enumeratorId expired or is unknown."""
|
|
255
|
+
Unknown = "Unknown"
|
|
256
|
+
"""Unhandled server-side error."""
|
|
257
|
+
BadData = "BadData"
|
|
258
|
+
"""Malformed request."""
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@dataclass
|
|
262
|
+
class KcResourceGetByLabelsPagination(object):
|
|
263
|
+
"""Pagination cursor returned by get-by-labels; pass it back to fetch the next page."""
|
|
264
|
+
|
|
265
|
+
enumeratorId: str = None
|
|
266
|
+
"""Opaque cursor; feed to the next get_by_labels call as enumerator_id."""
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@dataclass
|
|
270
|
+
class KcResourceGetByLabelsResponse(object):
|
|
271
|
+
"""Response of `KcResourceClient.get_by_labels` (one page of results)."""
|
|
272
|
+
|
|
273
|
+
# pagination and resources are null on error responses (errorMessage set), so
|
|
274
|
+
# they must be Optional or marshmallow rejects the payload before the caller
|
|
275
|
+
# can read errorMessage.
|
|
276
|
+
pagination: Optional[KcResourceGetByLabelsPagination] = None
|
|
277
|
+
"""Cursor for the next page; None on error."""
|
|
278
|
+
resources: Optional[List[dict]] = field(default_factory=list)
|
|
279
|
+
"""This page's resources as raw dicts; None on error."""
|
|
280
|
+
errorMessage: str = None
|
|
281
|
+
"""Human-readable error; None on success."""
|
|
282
|
+
errorCode: KcApiGetByLabelsErrorCode = None
|
|
283
|
+
"""Machine-readable error; None on success."""
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# HTTP statuses the API uses to carry a structured (deserializable) body. Anything
|
|
287
|
+
# outside this set is an unexpected transport/server failure and is raised instead.
|
|
288
|
+
_HANDLED_STATUS_CODES = [200, 400, 401, 403, 422]
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class KcResourceClient:
|
|
292
|
+
"""Client for a single resource type of the Kvindo Cloud API.
|
|
293
|
+
|
|
294
|
+
One instance is bound to one resource type (e.g. "vm", "s3-bucket") and
|
|
295
|
+
reused for all calls against it. Obtain instances via `KcClient`, e.g.
|
|
296
|
+
`KcClient(token).vms`.
|
|
297
|
+
|
|
298
|
+
See https://cloud-api.kvindo.ru/swagger/index.html for the full contract.
|
|
299
|
+
"""
|
|
300
|
+
|
|
301
|
+
def __init__(
|
|
302
|
+
self,
|
|
303
|
+
resource_type: str,
|
|
304
|
+
token: str,
|
|
305
|
+
api_url: str = "https://cloud-api.kvindo.ru",
|
|
306
|
+
log_extra: dict = None,
|
|
307
|
+
):
|
|
308
|
+
"""
|
|
309
|
+
Args:
|
|
310
|
+
resource_type: the kebab-case API path segment (e.g. "vm", "s3-bucket").
|
|
311
|
+
token: the bearer token; a leading "Bearer " prefix is stripped if present.
|
|
312
|
+
api_url: base URL of the Cloud API (no trailing slash).
|
|
313
|
+
log_extra: optional dict merged into every debug log record's `extra`.
|
|
314
|
+
"""
|
|
315
|
+
self.__token = token.replace("Bearer ", "")
|
|
316
|
+
self.__resource_type = resource_type
|
|
317
|
+
self.__api_url = api_url
|
|
318
|
+
self.__log_extra = log_extra if log_extra is not None else {}
|
|
319
|
+
|
|
320
|
+
def __headers(self) -> dict:
|
|
321
|
+
"""Standard auth + content-type headers for every request."""
|
|
322
|
+
return {
|
|
323
|
+
"accept": "*/*",
|
|
324
|
+
"Authorization": f"Bearer {self.__token}",
|
|
325
|
+
"Content-Type": "application/json-patch+json",
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
def delete(self, id: str, wait=False) -> KcResourceDeleteResponse:
|
|
329
|
+
"""Delete a resource by id (asynchronous).
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
id: id of the resource to delete.
|
|
333
|
+
wait: if True, block (up to 300s) until the delete reconciles via
|
|
334
|
+
`wait_request_satisfied` before returning.
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
KcResourceDeleteResponse with `requestId` to poll, or `errorMessage`/
|
|
338
|
+
`errorCode` set on a handled error.
|
|
339
|
+
|
|
340
|
+
Raises:
|
|
341
|
+
Exception: on an unexpected HTTP status (outside 200/400/401/403/422).
|
|
342
|
+
"""
|
|
343
|
+
url = f"{self.__api_url}/api/v1/{self.__resource_type}/{id}"
|
|
344
|
+
|
|
345
|
+
response = create_http_client_with_retries().delete(url, headers=self.__headers())
|
|
346
|
+
|
|
347
|
+
logger.debug(
|
|
348
|
+
f"Got {response.status_code} status code while making request DELETE {url}\nResponse body: {response.text}",
|
|
349
|
+
extra=self.__log_extra,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
if response.status_code in _HANDLED_STATUS_CODES:
|
|
353
|
+
result: KcResourceDeleteResponse = KcResourceDeleteResponse.Schema().load(
|
|
354
|
+
response.json()
|
|
355
|
+
)
|
|
356
|
+
if wait:
|
|
357
|
+
self.wait_request_satisfied(result.requestId, 300)
|
|
358
|
+
return result
|
|
359
|
+
else:
|
|
360
|
+
raise Exception(
|
|
361
|
+
f"Got {response.status_code} status code while making request DELETE {url}\nResponse body: {response.text}"
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
def read(self, id: str) -> KcResourceReadResponse:
|
|
365
|
+
"""Read a single resource by id.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
id: id of the resource to read.
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
KcResourceReadResponse with `resource` (a raw dict) on success, or
|
|
372
|
+
`errorMessage`/`errorCode` set on a handled error.
|
|
373
|
+
|
|
374
|
+
Raises:
|
|
375
|
+
Exception: on an unexpected HTTP status.
|
|
376
|
+
"""
|
|
377
|
+
url = f"{self.__api_url}/api/v1/{self.__resource_type}/{id}"
|
|
378
|
+
|
|
379
|
+
response = create_http_client_with_retries().get(url, headers=self.__headers())
|
|
380
|
+
|
|
381
|
+
logger.debug(
|
|
382
|
+
f"Got {response.status_code} status code while making request GET {url}\nResponse body: {response.text}",
|
|
383
|
+
extra=self.__log_extra,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
if response.status_code in _HANDLED_STATUS_CODES:
|
|
387
|
+
return KcResourceReadResponse.Schema().load(response.json())
|
|
388
|
+
else:
|
|
389
|
+
raise Exception(
|
|
390
|
+
f"Got {response.status_code} status code while making request GET {url}\nResponse body: {response.text}"
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
def get_by_labels(
|
|
394
|
+
self, labels: dict = None, enumerator_id: str = None, max_page_size: int = 10
|
|
395
|
+
) -> KcResourceGetByLabelsResponse:
|
|
396
|
+
"""List resources of this type, filtered by labels and paginated.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
labels: label filter as a dict; values may use `*` wildcards. Empty/None
|
|
400
|
+
matches all. Defaults to None (treated as no filter).
|
|
401
|
+
enumerator_id: pagination cursor from a previous call's
|
|
402
|
+
`pagination.enumeratorId`; None starts at the first page.
|
|
403
|
+
max_page_size: max resources per page. **Must not exceed 100** or the
|
|
404
|
+
API returns `PageSizeTooBig`.
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
KcResourceGetByLabelsResponse: one page in `resources`, the next-page
|
|
408
|
+
cursor in `pagination`, or `errorMessage`/`errorCode` on a handled error.
|
|
409
|
+
|
|
410
|
+
Raises:
|
|
411
|
+
Exception: on an unexpected HTTP status.
|
|
412
|
+
"""
|
|
413
|
+
url = f"{self.__api_url}/api/v1/{self.__resource_type}/get-by-labels"
|
|
414
|
+
params = {
|
|
415
|
+
"labels": labels if labels is not None else {},
|
|
416
|
+
"maxPageSize": max_page_size,
|
|
417
|
+
"enumeratorId": enumerator_id,
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
url = create_url_with_query_params(url, params)
|
|
421
|
+
response = create_http_client_with_retries().get(url, headers=self.__headers())
|
|
422
|
+
|
|
423
|
+
logger.debug(
|
|
424
|
+
f"Got {response.status_code} status code while making request GET {url}\nResponse body: {response.text}",
|
|
425
|
+
extra=self.__log_extra,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
if response.status_code in _HANDLED_STATUS_CODES:
|
|
429
|
+
return KcResourceGetByLabelsResponse.Schema().load(response.json())
|
|
430
|
+
else:
|
|
431
|
+
raise Exception(
|
|
432
|
+
f"Got {response.status_code} status code while making request GET {url}\nResponse body: {response.text}"
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
def read_request(self, request_id: str) -> KcResourceReadRequestResponse:
|
|
436
|
+
"""Read the current status of an async change request (one poll).
|
|
437
|
+
|
|
438
|
+
Args:
|
|
439
|
+
request_id: the `requestId` returned by create/update/delete.
|
|
440
|
+
|
|
441
|
+
Returns:
|
|
442
|
+
KcResourceReadRequestResponse: `succeeded` True once applied,
|
|
443
|
+
`errorMessage`/`errorCode` set if it failed, otherwise still pending.
|
|
444
|
+
|
|
445
|
+
Raises:
|
|
446
|
+
Exception: on an unexpected HTTP status.
|
|
447
|
+
"""
|
|
448
|
+
url = f"{self.__api_url}/api/v1/{self.__resource_type}/request/{request_id}"
|
|
449
|
+
|
|
450
|
+
response = create_http_client_with_retries().get(url, headers=self.__headers())
|
|
451
|
+
|
|
452
|
+
logger.debug(
|
|
453
|
+
f"Got {response.status_code} status code while making request GET {url}\nResponse body: {response.text}",
|
|
454
|
+
extra=self.__log_extra,
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
if response.status_code in _HANDLED_STATUS_CODES:
|
|
458
|
+
return KcResourceReadRequestResponse.Schema().load(response.json())
|
|
459
|
+
else:
|
|
460
|
+
raise Exception(
|
|
461
|
+
f"Got {response.status_code} status code while making request GET {url}\nResponse body: {response.text}"
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
def wait_request_satisfied(
|
|
465
|
+
self, request_id: str, timeout_seconds: int
|
|
466
|
+
) -> KcResourceReadRequestResponse:
|
|
467
|
+
"""Poll a change request once per second until it finishes or times out.
|
|
468
|
+
|
|
469
|
+
Returns as soon as the request succeeds (`succeeded == True`) or fails
|
|
470
|
+
(both `errorMessage` and `errorCode` set). On timeout it returns the last
|
|
471
|
+
(still-pending) status rather than raising — inspect `succeeded` to tell
|
|
472
|
+
the cases apart.
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
request_id: the `requestId` to poll.
|
|
476
|
+
timeout_seconds: max number of 1-second polls before giving up.
|
|
477
|
+
|
|
478
|
+
Returns:
|
|
479
|
+
The final (or last-seen) KcResourceReadRequestResponse.
|
|
480
|
+
"""
|
|
481
|
+
result = self.read_request(request_id)
|
|
482
|
+
|
|
483
|
+
i = 0
|
|
484
|
+
while result.succeeded == False and result.errorMessage == None:
|
|
485
|
+
i = i + 1
|
|
486
|
+
if i > timeout_seconds:
|
|
487
|
+
return result # timed out while still pending
|
|
488
|
+
|
|
489
|
+
time.sleep(1)
|
|
490
|
+
result = self.read_request(request_id)
|
|
491
|
+
|
|
492
|
+
if result.succeeded == True:
|
|
493
|
+
return result # applied successfully
|
|
494
|
+
if result.errorMessage != None and result.errorCode != None:
|
|
495
|
+
return result # failed terminally
|
|
496
|
+
|
|
497
|
+
return result
|
|
498
|
+
|
|
499
|
+
def create_or_update(self, data: dict) -> KcResourceCreateResponse:
|
|
500
|
+
"""Create a resource, or update it if one with the same id already exists.
|
|
501
|
+
|
|
502
|
+
Idempotent on the resource id: if no id is present in `data` (neither
|
|
503
|
+
`data["metadata"]["id"]` for the envelope shape nor top-level `data["id"]`
|
|
504
|
+
for the flat shape), a fresh ULID is generated and used, so re-sending the
|
|
505
|
+
same `data` object updates the same resource.
|
|
506
|
+
|
|
507
|
+
Args:
|
|
508
|
+
data: the resource body, either the kubectl-style envelope
|
|
509
|
+
({"metadata": {...}, "spec": {...}}) or the flat shape. **Mutated
|
|
510
|
+
in place** to inject the generated id when absent.
|
|
511
|
+
|
|
512
|
+
Returns:
|
|
513
|
+
KcResourceCreateResponse with `requestId`/`resourceId`, or
|
|
514
|
+
`errorMessage`/`errorCode` on a handled error.
|
|
515
|
+
|
|
516
|
+
Raises:
|
|
517
|
+
Exception: on an unexpected HTTP status.
|
|
518
|
+
"""
|
|
519
|
+
logger.debug(f"create_or_update({data})")
|
|
520
|
+
|
|
521
|
+
# Inject a ULID id when absent so the call is idempotent and the caller can
|
|
522
|
+
# poll the returned requestId. Two body shapes are supported:
|
|
523
|
+
if "metadata" in data:
|
|
524
|
+
# Envelope shape: id lives under metadata.
|
|
525
|
+
if "id" not in data["metadata"] or not data["metadata"]["id"]:
|
|
526
|
+
data["metadata"]["id"] = str(ULID())
|
|
527
|
+
elif "id" not in data or not data["id"]:
|
|
528
|
+
# Flat shape: id at the top level.
|
|
529
|
+
data["id"] = str(ULID())
|
|
530
|
+
|
|
531
|
+
url = f"{self.__api_url}/api/v1/{self.__resource_type}"
|
|
532
|
+
|
|
533
|
+
response = create_http_client_with_retries().put(url, json=data, headers=self.__headers())
|
|
534
|
+
|
|
535
|
+
logger.debug(
|
|
536
|
+
f"Got {response.status_code} status code while making request PUT {url}\nRequest body: {data}\nResponse body: {response.text}",
|
|
537
|
+
extra=self.__log_extra,
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
if response.status_code in _HANDLED_STATUS_CODES:
|
|
541
|
+
return KcResourceCreateResponse.Schema().load(response.json())
|
|
542
|
+
else:
|
|
543
|
+
raise Exception(
|
|
544
|
+
f"Got {response.status_code} status code while making request PUT {url}\nRequest body: {data}\nResponse body: {response.text}"
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
def create(self, data: dict) -> KcResourceCreateResponse:
|
|
548
|
+
"""Left for compatibility! Use create_or_update instead.
|
|
549
|
+
|
|
550
|
+
Args:
|
|
551
|
+
data: see `create_or_update`.
|
|
552
|
+
"""
|
|
553
|
+
return self.create_or_update(data)
|
|
554
|
+
|
|
555
|
+
def update(self, data: dict) -> KcResourceUpdateResponse:
|
|
556
|
+
"""Left for compatibility! Use create_or_update instead.
|
|
557
|
+
|
|
558
|
+
Args:
|
|
559
|
+
data: see `create_or_update`.
|
|
560
|
+
"""
|
|
561
|
+
return self.create_or_update(data)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
class KcClient:
|
|
565
|
+
"""Top-level Kvindo Cloud API client.
|
|
566
|
+
|
|
567
|
+
Construct once with a token, then access a per-type `KcResourceClient` via the
|
|
568
|
+
attributes below (e.g. `KcClient(token).vms.get_by_labels(...)`).
|
|
569
|
+
|
|
570
|
+
See https://cloud-api.kvindo.ru/swagger/index.html. The resource surface
|
|
571
|
+
mirrors the maintained C# client KvindoCloud.Api/KvindoCloudClient.cs.
|
|
572
|
+
"""
|
|
573
|
+
|
|
574
|
+
# Compute
|
|
575
|
+
vms: KcResourceClient
|
|
576
|
+
vm_on_off_maintenance_actions: KcResourceClient
|
|
577
|
+
vm_recurrent_command_maintenance_actions: KcResourceClient
|
|
578
|
+
volumes: KcResourceClient
|
|
579
|
+
volume_attachments: KcResourceClient
|
|
580
|
+
images: KcResourceClient
|
|
581
|
+
image_schedules: KcResourceClient
|
|
582
|
+
ssh_keys: KcResourceClient
|
|
583
|
+
ssh_private_keys: KcResourceClient
|
|
584
|
+
certificates: KcResourceClient
|
|
585
|
+
|
|
586
|
+
# Networking
|
|
587
|
+
floating_ips: KcResourceClient
|
|
588
|
+
vpcs: KcResourceClient
|
|
589
|
+
vpc_subnets: KcResourceClient
|
|
590
|
+
vpc_peerings: KcResourceClient
|
|
591
|
+
vpc_peering_peers: KcResourceClient
|
|
592
|
+
vpc_peering_external_peers: KcResourceClient
|
|
593
|
+
route_tables: KcResourceClient
|
|
594
|
+
route_table_attachments: KcResourceClient
|
|
595
|
+
route_table_routes: KcResourceClient
|
|
596
|
+
security_groups: KcResourceClient
|
|
597
|
+
nat_gateways: KcResourceClient
|
|
598
|
+
|
|
599
|
+
# Load balancer
|
|
600
|
+
load_balancers: KcResourceClient
|
|
601
|
+
load_balancer_http_listeners: KcResourceClient
|
|
602
|
+
load_balancer_http_listener_rules: KcResourceClient
|
|
603
|
+
load_balancer_https_listeners: KcResourceClient
|
|
604
|
+
load_balancer_https_listener_rules: KcResourceClient
|
|
605
|
+
load_balancer_tcp_listeners: KcResourceClient
|
|
606
|
+
load_balancer_tcp_listener_rules: KcResourceClient
|
|
607
|
+
load_balancer_udp_listeners: KcResourceClient
|
|
608
|
+
load_balancer_udp_listener_rules: KcResourceClient
|
|
609
|
+
load_balancer_tls_listeners: KcResourceClient
|
|
610
|
+
load_balancer_tls_listener_rules: KcResourceClient
|
|
611
|
+
load_balancer_target_groups: KcResourceClient
|
|
612
|
+
load_balancer_target_group_service_discovery_targets: KcResourceClient
|
|
613
|
+
load_balancer_target_group_static_targets: KcResourceClient
|
|
614
|
+
|
|
615
|
+
# S3
|
|
616
|
+
s3_buckets: KcResourceClient
|
|
617
|
+
s3_users: KcResourceClient
|
|
618
|
+
s3_user_access_policies: KcResourceClient
|
|
619
|
+
|
|
620
|
+
# Managed services
|
|
621
|
+
kubernetes: KcResourceClient
|
|
622
|
+
kubernetes_node_groups: KcResourceClient
|
|
623
|
+
kubernetes_users: KcResourceClient
|
|
624
|
+
kubernetes_user_roles: KcResourceClient
|
|
625
|
+
postgresqls: KcResourceClient
|
|
626
|
+
postgresql_standalones: KcResourceClient
|
|
627
|
+
postgresql_node_groups: KcResourceClient
|
|
628
|
+
postgresql_parameters_sets: KcResourceClient
|
|
629
|
+
etcd: KcResourceClient
|
|
630
|
+
etcd_node_group: KcResourceClient
|
|
631
|
+
open_vpns: KcResourceClient
|
|
632
|
+
open_vpn_users: KcResourceClient
|
|
633
|
+
open_vpn_user_settings: KcResourceClient
|
|
634
|
+
gitlabs: KcResourceClient
|
|
635
|
+
gitlab_runners: KcResourceClient
|
|
636
|
+
grafanas: KcResourceClient
|
|
637
|
+
victoria_metrics: KcResourceClient
|
|
638
|
+
ollamas: KcResourceClient
|
|
639
|
+
|
|
640
|
+
# IaM / org
|
|
641
|
+
folders: KcResourceClient
|
|
642
|
+
hosting_providers: KcResourceClient
|
|
643
|
+
access_policies: KcResourceClient
|
|
644
|
+
users: KcResourceClient
|
|
645
|
+
user_tokens: KcResourceClient
|
|
646
|
+
billing_accounts: KcResourceClient
|
|
647
|
+
quotas: KcResourceClient
|
|
648
|
+
quota_change_requests: KcResourceClient
|
|
649
|
+
support_plans: KcResourceClient
|
|
650
|
+
support_tickets: KcResourceClient
|
|
651
|
+
support_ticket_comments: KcResourceClient
|
|
652
|
+
support_ticket_comment_attachments: KcResourceClient
|
|
653
|
+
transactions: KcResourceClient
|
|
654
|
+
|
|
655
|
+
def __init__(
|
|
656
|
+
self,
|
|
657
|
+
token: str,
|
|
658
|
+
api_url: str = "https://cloud-api.kvindo.ru",
|
|
659
|
+
log_extra: dict = None,
|
|
660
|
+
):
|
|
661
|
+
"""
|
|
662
|
+
Args:
|
|
663
|
+
token: the bearer token; a leading "Bearer " prefix is stripped if present.
|
|
664
|
+
api_url: base URL of the Cloud API (no trailing slash).
|
|
665
|
+
log_extra: optional dict merged into every debug log record's `extra`;
|
|
666
|
+
propagated to every per-resource client.
|
|
667
|
+
"""
|
|
668
|
+
self.__log_extra = log_extra if log_extra is not None else {}
|
|
669
|
+
self.__token = token.replace("Bearer ", "")
|
|
670
|
+
self.__api_url = api_url
|
|
671
|
+
# Cached response of get_transaction_collection_keys (lazy, fetched once).
|
|
672
|
+
self._transaction_collection_keys = None
|
|
673
|
+
|
|
674
|
+
def _r(resource_type: str) -> KcResourceClient:
|
|
675
|
+
"""Build a per-type client sharing this client's token/url/log_extra."""
|
|
676
|
+
return KcResourceClient(resource_type, token, api_url, log_extra)
|
|
677
|
+
|
|
678
|
+
# Compute
|
|
679
|
+
self.vms = _r("vm")
|
|
680
|
+
self.vm_on_off_maintenance_actions = _r("vm-on-off-maintenance-action")
|
|
681
|
+
self.vm_recurrent_command_maintenance_actions = _r("vm-recurrent-command-maintenance-action")
|
|
682
|
+
self.volumes = _r("volume")
|
|
683
|
+
self.volume_attachments = _r("volume-attachment")
|
|
684
|
+
self.images = _r("image")
|
|
685
|
+
self.image_schedules = _r("image-schedule")
|
|
686
|
+
self.ssh_keys = _r("ssh-key")
|
|
687
|
+
self.ssh_private_keys = _r("ssh-private-key")
|
|
688
|
+
self.certificates = _r("certificate")
|
|
689
|
+
|
|
690
|
+
# Networking
|
|
691
|
+
self.floating_ips = _r("floating-ip")
|
|
692
|
+
self.vpcs = _r("vpc")
|
|
693
|
+
self.vpc_subnets = _r("vpc-subnet")
|
|
694
|
+
self.vpc_peerings = _r("vpc-peering")
|
|
695
|
+
self.vpc_peering_peers = _r("vpc-peering-peer")
|
|
696
|
+
self.vpc_peering_external_peers = _r("vpc-peering-external-peer")
|
|
697
|
+
self.route_tables = _r("route-table")
|
|
698
|
+
self.route_table_attachments = _r("route-table-attachment")
|
|
699
|
+
self.route_table_routes = _r("route-table-route")
|
|
700
|
+
self.security_groups = _r("security-group")
|
|
701
|
+
self.nat_gateways = _r("nat-gateway")
|
|
702
|
+
|
|
703
|
+
# Load balancer
|
|
704
|
+
self.load_balancers = _r("loadbalancer")
|
|
705
|
+
self.load_balancer_http_listeners = _r("loadbalancer-http-listener")
|
|
706
|
+
self.load_balancer_http_listener_rules = _r("loadbalancer-http-listener-rule")
|
|
707
|
+
self.load_balancer_https_listeners = _r("loadbalancer-https-listener")
|
|
708
|
+
self.load_balancer_https_listener_rules = _r("loadbalancer-https-listener-rule")
|
|
709
|
+
self.load_balancer_tcp_listeners = _r("loadbalancer-tcp-listener")
|
|
710
|
+
self.load_balancer_tcp_listener_rules = _r("loadbalancer-tcp-listener-rule")
|
|
711
|
+
self.load_balancer_udp_listeners = _r("loadbalancer-udp-listener")
|
|
712
|
+
self.load_balancer_udp_listener_rules = _r("loadbalancer-udp-listener-rule")
|
|
713
|
+
self.load_balancer_tls_listeners = _r("loadbalancer-tls-listener")
|
|
714
|
+
self.load_balancer_tls_listener_rules = _r("loadbalancer-tls-listener-rule")
|
|
715
|
+
self.load_balancer_target_groups = _r("loadbalancer-target-group")
|
|
716
|
+
self.load_balancer_target_group_service_discovery_targets = _r("loadbalancer-target-group-service-discovery-target")
|
|
717
|
+
self.load_balancer_target_group_static_targets = _r("loadbalancer-target-group-static-target")
|
|
718
|
+
|
|
719
|
+
# S3
|
|
720
|
+
self.s3_buckets = _r("s3-bucket")
|
|
721
|
+
self.s3_users = _r("s3-user")
|
|
722
|
+
self.s3_user_access_policies = _r("s3-user-access-policy")
|
|
723
|
+
|
|
724
|
+
# Managed services
|
|
725
|
+
self.kubernetes = _r("kubernetes")
|
|
726
|
+
self.kubernetes_node_groups = _r("kubernetes-node-group")
|
|
727
|
+
self.kubernetes_users = _r("kubernetes-user")
|
|
728
|
+
self.kubernetes_user_roles = _r("kubernetes-user-role")
|
|
729
|
+
self.postgresqls = _r("postgresql")
|
|
730
|
+
self.postgresql_standalones = _r("postgresql-standalone")
|
|
731
|
+
self.postgresql_node_groups = _r("postgresql-node-group")
|
|
732
|
+
self.postgresql_parameters_sets = _r("postgresql-parameters-set")
|
|
733
|
+
self.etcd = _r("etcd")
|
|
734
|
+
self.etcd_node_group = _r("etcd-node-group")
|
|
735
|
+
self.open_vpns = _r("open-vpn")
|
|
736
|
+
self.open_vpn_users = _r("open-vpn-user")
|
|
737
|
+
self.open_vpn_user_settings = _r("open-vpn-user-settings")
|
|
738
|
+
self.gitlabs = _r("gitlab")
|
|
739
|
+
self.gitlab_runners = _r("gitlab-runner")
|
|
740
|
+
self.grafanas = _r("grafana")
|
|
741
|
+
self.victoria_metrics = _r("victoria-metrics")
|
|
742
|
+
self.ollamas = _r("ollama")
|
|
743
|
+
|
|
744
|
+
# IaM / org
|
|
745
|
+
self.folders = _r("folder")
|
|
746
|
+
self.hosting_providers = _r("hosting-provider")
|
|
747
|
+
self.access_policies = _r("access-policy")
|
|
748
|
+
self.users = _r("user")
|
|
749
|
+
self.user_tokens = _r("user-token")
|
|
750
|
+
self.billing_accounts = _r("billing-account")
|
|
751
|
+
self.quotas = _r("quota")
|
|
752
|
+
self.quota_change_requests = _r("quota-change-request")
|
|
753
|
+
self.support_plans = _r("support-plan")
|
|
754
|
+
self.support_tickets = _r("support-ticket")
|
|
755
|
+
self.support_ticket_comments = _r("support-ticket-comment")
|
|
756
|
+
self.support_ticket_comment_attachments = _r("support-ticket-comment-attachment")
|
|
757
|
+
self.transactions = _r("transaction")
|
|
758
|
+
|
|
759
|
+
def get_transaction_collection_keys(self) -> list:
|
|
760
|
+
"""Return the transaction-spec collection keys (the child-resource
|
|
761
|
+
collection names accepted inside an OrganizationTransaction).
|
|
762
|
+
|
|
763
|
+
Fetched once from `/api/v1/internal/transaction-spec` and cached on the
|
|
764
|
+
instance for subsequent calls.
|
|
765
|
+
|
|
766
|
+
Returns:
|
|
767
|
+
The raw list returned by the transaction-spec endpoint.
|
|
768
|
+
"""
|
|
769
|
+
if self._transaction_collection_keys is None:
|
|
770
|
+
url = f"{self.__api_url}/api/v1/internal/transaction-spec"
|
|
771
|
+
headers = {"Authorization": f"Bearer {self.__token}"}
|
|
772
|
+
response = create_http_client_with_retries().get(url, headers=headers)
|
|
773
|
+
self._transaction_collection_keys = response.json()
|
|
774
|
+
return self._transaction_collection_keys
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kc-sdk-python
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK / client for the Kvindo Cloud API
|
|
5
|
+
Author: Kvindo
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Kvindo/kc-sdk-python
|
|
8
|
+
Project-URL: Repository, https://github.com/Kvindo/kc-sdk-python
|
|
9
|
+
Keywords: kvindo,cloud,sdk,client,api
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Requires-Python: >=3.8
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: requests
|
|
17
|
+
Requires-Dist: marshmallow-dataclass
|
|
18
|
+
Requires-Dist: py-ulid
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# kc-sdk-python
|
|
22
|
+
|
|
23
|
+
Python SDK / client for the **Kvindo Cloud API**.
|
|
24
|
+
|
|
25
|
+
A thin, typed client over the REST API: one resource client per resource type
|
|
26
|
+
(VMs, volumes, load balancers, kubernetes, S3, VPCs, …), all sharing the same
|
|
27
|
+
create / read / update / delete / list contract.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
pip install kc-sdk-python
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Dependencies: `requests`, `marshmallow-dataclass`, `py-ulid`.
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from kc_api import KcClient
|
|
41
|
+
|
|
42
|
+
client = KcClient("YOUR_API_TOKEN") # api_url defaults to https://cloud-api.kvindo.ru
|
|
43
|
+
|
|
44
|
+
# List (label-filtered, paginated)
|
|
45
|
+
resp = client.vms.get_by_labels({"env": "prod"}, max_page_size=50)
|
|
46
|
+
for vm in resp.resources:
|
|
47
|
+
print(vm["metadata"]["name"])
|
|
48
|
+
|
|
49
|
+
# Read one
|
|
50
|
+
vm = client.vms.read("01H...")
|
|
51
|
+
print(vm.resource)
|
|
52
|
+
|
|
53
|
+
# Create / update (async) then wait for it to reconcile
|
|
54
|
+
created = client.vms.create_or_update({
|
|
55
|
+
"metadata": {"name": "my-vm", "folderId": "01H..."},
|
|
56
|
+
"spec": {"offerId": "g3-1c2-100", "state": "running", ...},
|
|
57
|
+
})
|
|
58
|
+
status = client.vms.wait_request_satisfied(created.requestId, timeout_seconds=300)
|
|
59
|
+
assert status.succeeded
|
|
60
|
+
|
|
61
|
+
# Delete (optionally block until reconciled)
|
|
62
|
+
client.vms.delete("01H...", wait=True)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Create / update / delete are **asynchronous**: they return a `requestId`; poll
|
|
66
|
+
`read_request(requestId)` or use `wait_request_satisfied(...)`. Every response
|
|
67
|
+
object carries `errorMessage` / `errorCode` (a typed `KcApi*ErrorCode`) which are
|
|
68
|
+
`None` on success.
|
|
69
|
+
|
|
70
|
+
### Available resources
|
|
71
|
+
|
|
72
|
+
`KcClient` exposes one `KcResourceClient` per type, e.g. `client.vms`,
|
|
73
|
+
`client.volumes`, `client.s3_buckets`, `client.kubernetes`,
|
|
74
|
+
`client.load_balancers`, `client.vpcs`, `client.postgresql_standalones`,
|
|
75
|
+
`client.folders`, `client.transactions`, … (the surface mirrors the official
|
|
76
|
+
Kvindo Cloud API).
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
kc_api
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "kc-sdk-python"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python SDK / client for the Kvindo Cloud API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Kvindo" }]
|
|
14
|
+
keywords = ["kvindo", "cloud", "sdk", "client", "api"]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"requests",
|
|
17
|
+
"marshmallow-dataclass",
|
|
18
|
+
"py-ulid",
|
|
19
|
+
]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Intended Audience :: Developers",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/Kvindo/kc-sdk-python"
|
|
28
|
+
Repository = "https://github.com/Kvindo/kc-sdk-python"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools]
|
|
31
|
+
py-modules = ["kc_api"]
|