tuca 0.1__py3-none-any.whl
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.
- tuca/__init__.py +42 -0
- tuca/_version.py +34 -0
- tuca/auth.py +47 -0
- tuca/clouding.py +133 -0
- tuca/config.py +13 -0
- tuca/endpoints/__init__.py +9 -0
- tuca/endpoints/endpoint.py +140 -0
- tuca/endpoints/firewalls.py +18 -0
- tuca/endpoints/images.py +40 -0
- tuca/endpoints/keypairs.py +68 -0
- tuca/endpoints/servers.py +181 -0
- tuca/endpoints/sizes.py +62 -0
- tuca/endpoints/snapshots.py +37 -0
- tuca/resource.py +19 -0
- tuca/version.py +8 -0
- tuca-0.1.dist-info/METADATA +214 -0
- tuca-0.1.dist-info/RECORD +19 -0
- tuca-0.1.dist-info/WHEEL +4 -0
- tuca-0.1.dist-info/entry_points.txt +2 -0
tuca/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 René de Hesselle <dehesselle@web.de>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from tuca.auth import setup_auth_cli
|
|
8
|
+
from tuca.config import config
|
|
9
|
+
from tuca.endpoints import (
|
|
10
|
+
setup_images_endpoint,
|
|
11
|
+
setup_keypairs_endpoint,
|
|
12
|
+
setup_servers_endpoint,
|
|
13
|
+
setup_sizes_endpoint,
|
|
14
|
+
setup_snapshots_endpoint,
|
|
15
|
+
)
|
|
16
|
+
from tuca.version import VERSION
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main() -> None:
|
|
20
|
+
parser = argparse.ArgumentParser(description="unofficial CLI for Clouding.io")
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"-v",
|
|
23
|
+
"--verbose",
|
|
24
|
+
action="store_true",
|
|
25
|
+
default=False,
|
|
26
|
+
help="make output verbose",
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument("--version", action="version", version=f"tuca {VERSION}")
|
|
29
|
+
endpoints = parser.add_subparsers(help="manageable endpoints", dest="endpoint")
|
|
30
|
+
setup_auth_cli(endpoints)
|
|
31
|
+
setup_images_endpoint(endpoints)
|
|
32
|
+
setup_keypairs_endpoint(endpoints)
|
|
33
|
+
setup_servers_endpoint(endpoints)
|
|
34
|
+
setup_snapshots_endpoint(endpoints)
|
|
35
|
+
setup_sizes_endpoint(endpoints)
|
|
36
|
+
|
|
37
|
+
args = parser.parse_args()
|
|
38
|
+
config.be_verbose = args.verbose
|
|
39
|
+
try:
|
|
40
|
+
args.func(args)
|
|
41
|
+
except AttributeError:
|
|
42
|
+
parser.print_usage()
|
tuca/_version.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"__version__",
|
|
6
|
+
"__version_tuple__",
|
|
7
|
+
"version",
|
|
8
|
+
"version_tuple",
|
|
9
|
+
"__commit_id__",
|
|
10
|
+
"commit_id",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
TYPE_CHECKING = False
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from typing import Tuple
|
|
16
|
+
from typing import Union
|
|
17
|
+
|
|
18
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
+
COMMIT_ID = Union[str, None]
|
|
20
|
+
else:
|
|
21
|
+
VERSION_TUPLE = object
|
|
22
|
+
COMMIT_ID = object
|
|
23
|
+
|
|
24
|
+
version: str
|
|
25
|
+
__version__: str
|
|
26
|
+
__version_tuple__: VERSION_TUPLE
|
|
27
|
+
version_tuple: VERSION_TUPLE
|
|
28
|
+
commit_id: COMMIT_ID
|
|
29
|
+
__commit_id__: COMMIT_ID
|
|
30
|
+
|
|
31
|
+
__version__ = version = '0.1'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 1)
|
|
33
|
+
|
|
34
|
+
__commit_id__ = commit_id = None
|
tuca/auth.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 René de Hesselle <dehesselle@web.de>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from argparse import _SubParsersAction
|
|
7
|
+
from enum import StrEnum, auto
|
|
8
|
+
from getpass import getpass
|
|
9
|
+
|
|
10
|
+
import keyring
|
|
11
|
+
|
|
12
|
+
SERVICENAME = "Clouding.io API token"
|
|
13
|
+
USERNAME = "tuca"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Action(StrEnum):
|
|
17
|
+
CREATE = auto()
|
|
18
|
+
DELETE = auto()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def set_token(_) -> None:
|
|
22
|
+
token = getpass("API token:")
|
|
23
|
+
keyring.set_password(SERVICENAME, USERNAME, token)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_token(_) -> str:
|
|
27
|
+
if api_token := os.getenv("CLOUDINGIO_API_TOKEN"):
|
|
28
|
+
return api_token
|
|
29
|
+
else:
|
|
30
|
+
return keyring.get_password(SERVICENAME, USERNAME)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def delete_token(_) -> None:
|
|
34
|
+
keyring.delete_password(SERVICENAME, USERNAME)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def setup_auth_cli(subparser: _SubParsersAction):
|
|
38
|
+
auth = subparser.add_parser("auth", help="manage authentication token")
|
|
39
|
+
auth_actions = auth.add_subparsers()
|
|
40
|
+
auth_action_set = auth_actions.add_parser(
|
|
41
|
+
Action.CREATE, help="set authentication token"
|
|
42
|
+
)
|
|
43
|
+
auth_action_set.set_defaults(func=set_token)
|
|
44
|
+
auth_action_delete = auth_actions.add_parser(
|
|
45
|
+
Action.DELETE, help="delete authentication token"
|
|
46
|
+
)
|
|
47
|
+
auth_action_delete.set_defaults(func=delete_token)
|
tuca/clouding.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 René de Hesselle <dehesselle@web.de>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from http import HTTPStatus
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
from urlpath import URL
|
|
11
|
+
|
|
12
|
+
from tuca.auth import get_token
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ResponseHeader(BaseModel):
|
|
16
|
+
# rate_limit_limit: str = Field(alias="X-Rate-Limit-Limit", default="")
|
|
17
|
+
rate_limit_remaining: str = Field(alias="X-Rate-Limit-Remaining")
|
|
18
|
+
# rate_limit_reset: str = Field(alias="X-Rate-Limit-Reset", default="")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DeleteResponse(BaseModel):
|
|
22
|
+
id: str
|
|
23
|
+
status: str = ""
|
|
24
|
+
startedAt: str = ""
|
|
25
|
+
resourceId: str = ""
|
|
26
|
+
resourceType: str = ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ResponseLinks(BaseModel):
|
|
30
|
+
next: str | None
|
|
31
|
+
previous: str | None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ResponseMeta(BaseModel):
|
|
35
|
+
total: int
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
ValidStatusCodes = [
|
|
39
|
+
HTTPStatus.OK,
|
|
40
|
+
HTTPStatus.CREATED,
|
|
41
|
+
HTTPStatus.ACCEPTED,
|
|
42
|
+
HTTPStatus.NO_CONTENT,
|
|
43
|
+
HTTPStatus.BAD_REQUEST,
|
|
44
|
+
HTTPStatus.UNAUTHORIZED,
|
|
45
|
+
HTTPStatus.FORBIDDEN,
|
|
46
|
+
HTTPStatus.NOT_FOUND,
|
|
47
|
+
HTTPStatus.METHOD_NOT_ALLOWED,
|
|
48
|
+
HTTPStatus.TOO_MANY_REQUESTS,
|
|
49
|
+
HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
50
|
+
HTTPStatus.SERVICE_UNAVAILABLE,
|
|
51
|
+
] # https://api.clouding.io/docs/#section/Introduction/Responses
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Clouding:
|
|
55
|
+
def __init__(self):
|
|
56
|
+
self.api_url = URL("https://api.clouding.io/v1")
|
|
57
|
+
self.api_auth = {"X-API-KEY": get_token(None)}
|
|
58
|
+
self.endpoint = ""
|
|
59
|
+
self.query = ""
|
|
60
|
+
self.response = requests.Response()
|
|
61
|
+
self.response_header = ResponseHeader(**{"X-Rate-Limit-Remaining": ""})
|
|
62
|
+
self.response_links = ResponseLinks(next=None, previous=None)
|
|
63
|
+
self.response_meta = ResponseMeta(total=0)
|
|
64
|
+
self.delete_response = DeleteResponse(id="")
|
|
65
|
+
|
|
66
|
+
def get(self, endpoint: str):
|
|
67
|
+
self.endpoint = endpoint
|
|
68
|
+
self.response = requests.get(
|
|
69
|
+
self.api_url / endpoint / "?pageSize=100", headers=self.api_auth
|
|
70
|
+
)
|
|
71
|
+
self._post_processing()
|
|
72
|
+
|
|
73
|
+
def post(self, endpoint: str, payload: dict, headers: dict = {}):
|
|
74
|
+
self.endpoint = endpoint
|
|
75
|
+
headers.update(self.api_auth)
|
|
76
|
+
self.response = requests.post(
|
|
77
|
+
self.api_url / endpoint, data=json.dumps(payload), headers=headers
|
|
78
|
+
)
|
|
79
|
+
self._post_processing()
|
|
80
|
+
|
|
81
|
+
def delete(self, endpoint: str, id: str):
|
|
82
|
+
self.endpoint = endpoint
|
|
83
|
+
self.response = requests.delete(
|
|
84
|
+
self.api_url / endpoint / id, headers=self.api_auth
|
|
85
|
+
)
|
|
86
|
+
if self.has_content:
|
|
87
|
+
self.delete_response = DeleteResponse.model_validate(self.response.json())
|
|
88
|
+
else:
|
|
89
|
+
self.delete_response.resourceId = id # the only piece of info we got
|
|
90
|
+
self._post_processing()
|
|
91
|
+
|
|
92
|
+
def next(self) -> bool:
|
|
93
|
+
if url := self.response_links.next:
|
|
94
|
+
print(self.response_links.next)
|
|
95
|
+
self.response = requests.get(url, headers=self.api_auth)
|
|
96
|
+
self._post_processing()
|
|
97
|
+
return True
|
|
98
|
+
else:
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def is_status_ok(self) -> bool:
|
|
103
|
+
return self.response.status_code in [
|
|
104
|
+
HTTPStatus.OK,
|
|
105
|
+
HTTPStatus.CREATED,
|
|
106
|
+
HTTPStatus.ACCEPTED,
|
|
107
|
+
HTTPStatus.NO_CONTENT,
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def is_status_valid(self) -> bool:
|
|
112
|
+
return self.response.status_code in ValidStatusCodes
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def has_content(self) -> bool:
|
|
116
|
+
return self.is_status_ok and self.response.status_code != HTTPStatus.NO_CONTENT
|
|
117
|
+
|
|
118
|
+
def _post_processing(self):
|
|
119
|
+
if self.is_status_valid:
|
|
120
|
+
self.response_header = ResponseHeader.model_validate(self.response.headers)
|
|
121
|
+
try:
|
|
122
|
+
self.response_links = ResponseLinks.model_validate(
|
|
123
|
+
self.response.json()["links"]
|
|
124
|
+
)
|
|
125
|
+
self.response_meta = ResponseMeta.model_validate(
|
|
126
|
+
self.response.json()["meta"]
|
|
127
|
+
)
|
|
128
|
+
except KeyError:
|
|
129
|
+
pass # non-paginated responses don't have links and meta
|
|
130
|
+
else:
|
|
131
|
+
# FIXME: rework error handling
|
|
132
|
+
print("invalid HTTP status", self.response.status_code)
|
|
133
|
+
exit(1)
|
tuca/config.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 René de Hesselle <dehesselle@web.de>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
4
|
+
|
|
5
|
+
from .images import setup_images_endpoint
|
|
6
|
+
from .keypairs import setup_keypairs_endpoint
|
|
7
|
+
from .servers import setup_servers_endpoint
|
|
8
|
+
from .sizes import setup_sizes_endpoint
|
|
9
|
+
from .snapshots import setup_snapshots_endpoint
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 René de Hesselle <dehesselle@web.de>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ValidationError
|
|
9
|
+
|
|
10
|
+
from tuca.clouding import Clouding, DeleteResponse
|
|
11
|
+
from tuca.config import config
|
|
12
|
+
from tuca.resource import Resource
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RequestPayload(BaseModel):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Endpoint[T: Resource]:
|
|
20
|
+
def __init__(self, component_type: T, endpoint: str):
|
|
21
|
+
self.clouding = Clouding()
|
|
22
|
+
self.resources: list[T] = []
|
|
23
|
+
self.component_type = component_type
|
|
24
|
+
self.endpoint = endpoint
|
|
25
|
+
self.response_key = endpoint
|
|
26
|
+
|
|
27
|
+
def create(self, payload: RequestPayload) -> list[T]:
|
|
28
|
+
self.clouding.post(
|
|
29
|
+
self.endpoint,
|
|
30
|
+
payload.model_dump(),
|
|
31
|
+
headers={"Content-Type": "application/json"},
|
|
32
|
+
)
|
|
33
|
+
return self._deserialize_response()
|
|
34
|
+
|
|
35
|
+
def delete(self, id: str) -> DeleteResponse:
|
|
36
|
+
self.clouding.delete(self.endpoint, id)
|
|
37
|
+
return self.clouding.delete_response
|
|
38
|
+
|
|
39
|
+
def get(self) -> list[T]:
|
|
40
|
+
if not self.resources:
|
|
41
|
+
self.clouding.get(self.endpoint)
|
|
42
|
+
self.resources.extend(self._deserialize_response(self.response_key))
|
|
43
|
+
while (
|
|
44
|
+
len(self.resources) < 100 and self.clouding.next()
|
|
45
|
+
): # TODO configurable limit?
|
|
46
|
+
self.resources.extend(self._deserialize_response(self.response_key))
|
|
47
|
+
return self.resources
|
|
48
|
+
|
|
49
|
+
def get_by_id(self, id: str) -> T | None:
|
|
50
|
+
try:
|
|
51
|
+
return self._by_id[id]
|
|
52
|
+
except KeyError:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
def get_by_name(self, name: str) -> T | None:
|
|
56
|
+
try:
|
|
57
|
+
return self._by_name[name]
|
|
58
|
+
except KeyError:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
def to_str(self, models: list[BaseModel] | BaseModel | None = None) -> str:
|
|
62
|
+
list_name = self.endpoint
|
|
63
|
+
if models is None:
|
|
64
|
+
models = self.get()
|
|
65
|
+
elif not isinstance(models, list):
|
|
66
|
+
if isinstance(models, DeleteResponse):
|
|
67
|
+
list_name = "delete"
|
|
68
|
+
models = [models]
|
|
69
|
+
result = {list_name: [model.model_dump() for model in models]}
|
|
70
|
+
if config.be_verbose:
|
|
71
|
+
result["verbose"] = {
|
|
72
|
+
"endpoint": self.endpoint,
|
|
73
|
+
"status_code": self.clouding.response.status_code,
|
|
74
|
+
}
|
|
75
|
+
result["verbose"].update(self.clouding.response_header.model_dump())
|
|
76
|
+
return json.dumps(
|
|
77
|
+
result,
|
|
78
|
+
indent=4,
|
|
79
|
+
sort_keys=True,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def list_resources(cls, args: argparse.Namespace):
|
|
84
|
+
endpoint = cls()
|
|
85
|
+
if hasattr(args, "id") and args.id:
|
|
86
|
+
print(endpoint.to_str(endpoint.get_by_id(args.id)))
|
|
87
|
+
elif hasattr(args, "name") and args.name:
|
|
88
|
+
print(endpoint.to_str(endpoint.get_by_name(args.name)))
|
|
89
|
+
else:
|
|
90
|
+
print(endpoint.to_str())
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def delete_resource(cls, args: argparse.Namespace):
|
|
94
|
+
endpoint = cls()
|
|
95
|
+
if args.name:
|
|
96
|
+
if resource := endpoint.get_by_name(args.name):
|
|
97
|
+
resource_id = resource.id
|
|
98
|
+
else:
|
|
99
|
+
resource_id = ""
|
|
100
|
+
else:
|
|
101
|
+
resource_id = args.id
|
|
102
|
+
|
|
103
|
+
if resource_id:
|
|
104
|
+
response = endpoint.delete(resource_id)
|
|
105
|
+
# TODO not checking anything
|
|
106
|
+
print(endpoint.to_str(response))
|
|
107
|
+
else:
|
|
108
|
+
print(f"resource_id not found: {resource_id}") # TODO
|
|
109
|
+
exit(1)
|
|
110
|
+
|
|
111
|
+
def _deserialize_response(self, key: str = "") -> list[T]:
|
|
112
|
+
result = []
|
|
113
|
+
if self.clouding.is_status_ok:
|
|
114
|
+
try:
|
|
115
|
+
result.extend(
|
|
116
|
+
[
|
|
117
|
+
self.component_type.model_validate(_)
|
|
118
|
+
for _ in (
|
|
119
|
+
self.clouding.response.json()[key]
|
|
120
|
+
if key
|
|
121
|
+
else [self.clouding.response.json()]
|
|
122
|
+
)
|
|
123
|
+
]
|
|
124
|
+
)
|
|
125
|
+
except KeyError:
|
|
126
|
+
print("keyerror") # TODO
|
|
127
|
+
except ValidationError:
|
|
128
|
+
print("validationerror") # TODO
|
|
129
|
+
print(self.clouding.response.json()[key])
|
|
130
|
+
else:
|
|
131
|
+
print(f"HTTP {self.clouding.response.status_code}") # TODO
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def _by_id(self) -> dict[str, T]:
|
|
136
|
+
return {component.id: component for component in self.get()}
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def _by_name(self) -> dict[str, T]:
|
|
140
|
+
return {component.name: component for component in self.get()}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 René de Hesselle <dehesselle@web.de>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
4
|
+
|
|
5
|
+
from tuca.resource import Resource
|
|
6
|
+
|
|
7
|
+
from .endpoint import Endpoint
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Firewall(Resource):
|
|
11
|
+
id: str
|
|
12
|
+
name: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Firewalls(Endpoint[Firewall]):
|
|
16
|
+
def __init__(self):
|
|
17
|
+
super().__init__(Firewall, "firewalls")
|
|
18
|
+
self.response_key = "values"
|
tuca/endpoints/images.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 René de Hesselle <dehesselle@web.de>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
4
|
+
|
|
5
|
+
from argparse import _SubParsersAction
|
|
6
|
+
from enum import StrEnum, auto
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from tuca.resource import Resource
|
|
11
|
+
|
|
12
|
+
from .endpoint import Endpoint
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Action(StrEnum):
|
|
16
|
+
LIST = auto()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AccessMethods(BaseModel):
|
|
20
|
+
sshKey: str
|
|
21
|
+
password: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Image(Resource):
|
|
25
|
+
id: str
|
|
26
|
+
name: str
|
|
27
|
+
accessMethods: AccessMethods
|
|
28
|
+
minimumSizeGb: int | None = None # 'None' default for reusability in Snapshot
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Images(Endpoint[Image]):
|
|
32
|
+
def __init__(self):
|
|
33
|
+
super().__init__(Image, "images")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def setup_images_endpoint(subparser: _SubParsersAction):
|
|
37
|
+
images = subparser.add_parser("images", help="manage images")
|
|
38
|
+
images_actions = images.add_subparsers(help="available actions")
|
|
39
|
+
images_action_list = images_actions.add_parser(Action.LIST, help="list snapshots")
|
|
40
|
+
images_action_list.set_defaults(func=Images.list_resources)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 René de Hesselle <dehesselle@web.de>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
4
|
+
|
|
5
|
+
from argparse import _SubParsersAction
|
|
6
|
+
from enum import StrEnum, auto
|
|
7
|
+
|
|
8
|
+
from tuca.resource import Resource
|
|
9
|
+
|
|
10
|
+
from .endpoint import Endpoint, RequestPayload
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Action(StrEnum):
|
|
14
|
+
CREATE = auto()
|
|
15
|
+
DELETE = auto()
|
|
16
|
+
LIST = auto()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Keypair(Resource):
|
|
20
|
+
id: str
|
|
21
|
+
name: str
|
|
22
|
+
fingerprint: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Keypairs(Endpoint[Keypair]):
|
|
26
|
+
def __init__(self):
|
|
27
|
+
super().__init__(Keypair, "keypairs")
|
|
28
|
+
self.response_key = "values"
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def create_resource(cls, args):
|
|
32
|
+
payload = CreateRequestPayload(
|
|
33
|
+
name=args.name, publicKey=args.publickey, privateKey=args.privatekey
|
|
34
|
+
)
|
|
35
|
+
keypairs = Keypairs()
|
|
36
|
+
keypair = keypairs.create(payload)
|
|
37
|
+
print(keypairs.to_str(keypair))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CreateRequestPayload(RequestPayload):
|
|
41
|
+
name: str
|
|
42
|
+
publicKey: str
|
|
43
|
+
privateKey: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def setup_keypairs_endpoint(subparser: _SubParsersAction):
|
|
47
|
+
snapshots = subparser.add_parser("keypairs", help="manage keypairs")
|
|
48
|
+
keypair_actions = snapshots.add_subparsers(help="available actions")
|
|
49
|
+
|
|
50
|
+
keypair_action_create = keypair_actions.add_parser(
|
|
51
|
+
Action.CREATE, help="create new server"
|
|
52
|
+
)
|
|
53
|
+
keypair_action_create.add_argument("--name", type=str, required=True)
|
|
54
|
+
keypair_action_create.add_argument("--publickey", type=str, required=True)
|
|
55
|
+
keypair_action_create.add_argument("--privatekey", type=str, default="")
|
|
56
|
+
keypair_action_create.set_defaults(func=Keypairs.create_resource)
|
|
57
|
+
|
|
58
|
+
keypair_action_delete = keypair_actions.add_parser(
|
|
59
|
+
Action.DELETE, help="delete a server"
|
|
60
|
+
)
|
|
61
|
+
id_or_name = keypair_action_delete.add_mutually_exclusive_group(required=True)
|
|
62
|
+
id_or_name.add_argument("--id", type=str, default="")
|
|
63
|
+
id_or_name.add_argument("--name", type=str, default="")
|
|
64
|
+
keypair_action_delete.set_defaults(func=Keypairs.delete_resource)
|
|
65
|
+
|
|
66
|
+
keypair_action_list = keypair_actions.add_parser(Action.LIST, help="list keypairs")
|
|
67
|
+
keypair_action_list.add_argument("-i", "--id", default="", required=False)
|
|
68
|
+
keypair_action_list.set_defaults(func=Keypairs.list_resources)
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 René de Hesselle <dehesselle@web.de>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
4
|
+
|
|
5
|
+
import platform
|
|
6
|
+
import signal
|
|
7
|
+
import time
|
|
8
|
+
from argparse import _SubParsersAction
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor, TimeoutError
|
|
10
|
+
from enum import StrEnum, auto
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
from slugify import slugify
|
|
14
|
+
|
|
15
|
+
from tuca.resource import Resource
|
|
16
|
+
|
|
17
|
+
from .endpoint import Endpoint, RequestPayload
|
|
18
|
+
from .firewalls import Firewalls
|
|
19
|
+
from .images import Images
|
|
20
|
+
from .sizes import Flavors
|
|
21
|
+
from .snapshots import Snapshots
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Action(StrEnum):
|
|
25
|
+
LIST = auto()
|
|
26
|
+
CREATE = auto()
|
|
27
|
+
DELETE = auto()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Status(StrEnum):
|
|
31
|
+
CREATING = "Creating"
|
|
32
|
+
ACTIVE = "Active"
|
|
33
|
+
PENDING = "Pending"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AccessConfiguration(BaseModel):
|
|
37
|
+
sshKeyId: str | None
|
|
38
|
+
password: str | None
|
|
39
|
+
savePassword: bool
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Volume(BaseModel):
|
|
43
|
+
source: str
|
|
44
|
+
id: str
|
|
45
|
+
ssdGb: int
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CreateRequestPayload(RequestPayload):
|
|
49
|
+
name: str
|
|
50
|
+
hostname: str
|
|
51
|
+
flavorId: str
|
|
52
|
+
accessConfiguration: AccessConfiguration
|
|
53
|
+
volume: Volume
|
|
54
|
+
publicPortFirewallIds: list[str]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Server(Resource):
|
|
58
|
+
id: str
|
|
59
|
+
name: str
|
|
60
|
+
createdAt: str = ""
|
|
61
|
+
publicIp: str | None = ""
|
|
62
|
+
status: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Servers(Endpoint[Server]):
|
|
66
|
+
def __init__(self):
|
|
67
|
+
super().__init__(Server, "servers")
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def create_resource(cls, args):
|
|
71
|
+
if args.flavorid not in Flavors().all:
|
|
72
|
+
print(f"flavor not supported: {args.flavorid}")
|
|
73
|
+
exit(1)
|
|
74
|
+
|
|
75
|
+
if args.snapshot:
|
|
76
|
+
if snapshot := Snapshots().get_by_id(args.snapshot):
|
|
77
|
+
volume = Volume(
|
|
78
|
+
source="snapshot", id=args.snapshot, ssdGb=snapshot.sizeGb
|
|
79
|
+
)
|
|
80
|
+
else:
|
|
81
|
+
print(f"snapshot not found: {args.snapshot}")
|
|
82
|
+
exit(1)
|
|
83
|
+
elif args.image:
|
|
84
|
+
if image := Images().get_by_id(args.image):
|
|
85
|
+
volume = Volume(
|
|
86
|
+
source="image", id=args.image, ssdGb=image.minimumSizeGb
|
|
87
|
+
)
|
|
88
|
+
else:
|
|
89
|
+
print(f"image not found: {args.image}")
|
|
90
|
+
exit(1)
|
|
91
|
+
else:
|
|
92
|
+
print("mandatory mutually exclusive option missing")
|
|
93
|
+
exit(1)
|
|
94
|
+
|
|
95
|
+
if args.password:
|
|
96
|
+
access_configuration = AccessConfiguration(
|
|
97
|
+
sshKeyId=None, password=args.password, savePassword=True
|
|
98
|
+
)
|
|
99
|
+
elif args.sshkey:
|
|
100
|
+
access_configuration = AccessConfiguration(
|
|
101
|
+
sshKeyId=args.sshkey, password=None, savePassword=False
|
|
102
|
+
)
|
|
103
|
+
else:
|
|
104
|
+
print("mandatory mutually exclusive option missing")
|
|
105
|
+
exit(1)
|
|
106
|
+
|
|
107
|
+
firewall_id = 0
|
|
108
|
+
if firewall := Firewalls().get_by_name(args.firewall):
|
|
109
|
+
firewall_id = firewall.id
|
|
110
|
+
if firewall_id:
|
|
111
|
+
payload = CreateRequestPayload(
|
|
112
|
+
name=args.name,
|
|
113
|
+
hostname=slugify(args.name),
|
|
114
|
+
flavorId=args.flavorid,
|
|
115
|
+
accessConfiguration=access_configuration,
|
|
116
|
+
volume=volume,
|
|
117
|
+
publicPortFirewallIds=[firewall_id],
|
|
118
|
+
)
|
|
119
|
+
servers = Servers()
|
|
120
|
+
if server := servers.create(payload):
|
|
121
|
+
server_id = server[0].id
|
|
122
|
+
if args.wait:
|
|
123
|
+
if platform.system() == "Windows":
|
|
124
|
+
signal.signal(signal.SIGINT, signal.SIG_DFL) # make ctrl+c work
|
|
125
|
+
with ThreadPoolExecutor() as executor:
|
|
126
|
+
|
|
127
|
+
def wait(id: str, status: Status, seconds: int) -> None:
|
|
128
|
+
while Servers().get_by_id(id).status != status:
|
|
129
|
+
time.sleep(seconds)
|
|
130
|
+
|
|
131
|
+
future = executor.submit(wait, server_id, Status.ACTIVE, 30)
|
|
132
|
+
try:
|
|
133
|
+
future.result(timeout=300)
|
|
134
|
+
except TimeoutError:
|
|
135
|
+
future.cancel()
|
|
136
|
+
print(servers.to_str(Servers().get_by_id(server_id)))
|
|
137
|
+
else:
|
|
138
|
+
print("failed to create server")
|
|
139
|
+
else:
|
|
140
|
+
print("error no firewall_id") # TODO
|
|
141
|
+
exit(1)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def setup_servers_endpoint(subparser: _SubParsersAction):
|
|
145
|
+
servers = subparser.add_parser("servers", help="manage servers")
|
|
146
|
+
server_actions = servers.add_subparsers(help="available actions")
|
|
147
|
+
|
|
148
|
+
server_action_create = server_actions.add_parser(
|
|
149
|
+
Action.CREATE, help="create new server"
|
|
150
|
+
)
|
|
151
|
+
server_action_create.add_argument("--name", type=str, required=True)
|
|
152
|
+
image_or_snapshot = server_action_create.add_mutually_exclusive_group(required=True)
|
|
153
|
+
image_or_snapshot.add_argument("--snapshot", type=str, default="")
|
|
154
|
+
image_or_snapshot.add_argument("--image", type=str, default="")
|
|
155
|
+
server_action_create.add_argument("--flavorid", type=str, required=True)
|
|
156
|
+
server_action_create.add_argument(
|
|
157
|
+
"--firewall", type=str, required=False, default="default"
|
|
158
|
+
)
|
|
159
|
+
password_or_sshkey = server_action_create.add_mutually_exclusive_group(
|
|
160
|
+
required=True
|
|
161
|
+
)
|
|
162
|
+
password_or_sshkey.add_argument("--password", type=str, default="")
|
|
163
|
+
password_or_sshkey.add_argument("--sshkey", type=str, default="")
|
|
164
|
+
server_action_create.add_argument(
|
|
165
|
+
"--wait", action="store_true", default=False, help="wait until server is active"
|
|
166
|
+
)
|
|
167
|
+
server_action_create.set_defaults(func=Servers.create_resource)
|
|
168
|
+
|
|
169
|
+
server_action_delete = server_actions.add_parser(
|
|
170
|
+
Action.DELETE, help="delete a server"
|
|
171
|
+
)
|
|
172
|
+
id_or_name = server_action_delete.add_mutually_exclusive_group(required=True)
|
|
173
|
+
id_or_name.add_argument("--id", type=str, default="")
|
|
174
|
+
id_or_name.add_argument("--name", type=str, default="")
|
|
175
|
+
server_action_delete.set_defaults(func=Servers.delete_resource)
|
|
176
|
+
|
|
177
|
+
server_action_list = server_actions.add_parser(Action.LIST, help="list servers")
|
|
178
|
+
id_or_name = server_action_list.add_mutually_exclusive_group(required=False)
|
|
179
|
+
id_or_name.add_argument("--id", type=str, default="")
|
|
180
|
+
id_or_name.add_argument("--name", type=str, default="")
|
|
181
|
+
server_action_list.set_defaults(func=Servers.list_resources)
|
tuca/endpoints/sizes.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 René de Hesselle <dehesselle@web.de>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
4
|
+
|
|
5
|
+
from argparse import _SubParsersAction
|
|
6
|
+
from enum import StrEnum, auto
|
|
7
|
+
|
|
8
|
+
from tuca.resource import Resource
|
|
9
|
+
|
|
10
|
+
from .endpoint import Endpoint
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Action(StrEnum):
|
|
14
|
+
LIST = auto()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Flavor(Resource):
|
|
18
|
+
id: str
|
|
19
|
+
vCores: float
|
|
20
|
+
ramGb: int
|
|
21
|
+
pricePerHour: float
|
|
22
|
+
pricePerMonthApprox: float
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class VolumeSize(Resource):
|
|
26
|
+
storageType: str
|
|
27
|
+
sizeGb: int
|
|
28
|
+
pricePerHour: float
|
|
29
|
+
pricePerMonthApprox: float
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Flavors(Endpoint[Flavor]):
|
|
33
|
+
def __init__(self):
|
|
34
|
+
super().__init__(Flavor, "sizes/flavors")
|
|
35
|
+
self.response_key = "flavors"
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def all(self) -> list[str]:
|
|
39
|
+
self.get()
|
|
40
|
+
return [flavor.id for flavor in self.resources]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Sizes(Endpoint[VolumeSize]):
|
|
44
|
+
def __init__(self):
|
|
45
|
+
super().__init__(VolumeSize, "sizes/volumes")
|
|
46
|
+
self.response_key = "volumeSizes"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def setup_sizes_endpoint(subparser: _SubParsersAction):
|
|
50
|
+
volumesizes = subparser.add_parser("volumesizes", help="query volume sizes")
|
|
51
|
+
volumesizes_actions = volumesizes.add_subparsers(help="available actions")
|
|
52
|
+
volumesizes_action_list = volumesizes_actions.add_parser(
|
|
53
|
+
Action.LIST, help="list volume sizes"
|
|
54
|
+
)
|
|
55
|
+
volumesizes_action_list.set_defaults(func=Sizes.list_resources)
|
|
56
|
+
|
|
57
|
+
flavors = subparser.add_parser("flavors", help="query cpu/memory combos")
|
|
58
|
+
flavors_actions = flavors.add_subparsers(help="available actions")
|
|
59
|
+
flavors_action_list = flavors_actions.add_parser(
|
|
60
|
+
Action.LIST, help="list volume sizes"
|
|
61
|
+
)
|
|
62
|
+
flavors_action_list.set_defaults(func=Flavors.list_resources)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 René de Hesselle <dehesselle@web.de>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
4
|
+
|
|
5
|
+
from argparse import _SubParsersAction
|
|
6
|
+
from enum import StrEnum, auto
|
|
7
|
+
|
|
8
|
+
from tuca.resource import Resource
|
|
9
|
+
|
|
10
|
+
from .endpoint import Endpoint
|
|
11
|
+
from .images import Image
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Action(StrEnum):
|
|
15
|
+
LIST = auto()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Snapshot(Resource):
|
|
19
|
+
id: str
|
|
20
|
+
name: str
|
|
21
|
+
createdAt: str
|
|
22
|
+
sizeGb: int
|
|
23
|
+
image: Image
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Snapshots(Endpoint[Snapshot]):
|
|
27
|
+
def __init__(self):
|
|
28
|
+
super().__init__(Snapshot, "snapshots")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def setup_snapshots_endpoint(subparser: _SubParsersAction):
|
|
32
|
+
snapshots = subparser.add_parser("snapshots", help="manage snapshots")
|
|
33
|
+
snapshot_actions = snapshots.add_subparsers(help="available actions")
|
|
34
|
+
snapshot_action_list = snapshot_actions.add_parser(
|
|
35
|
+
Action.LIST, help="list snapshots"
|
|
36
|
+
)
|
|
37
|
+
snapshot_action_list.set_defaults(func=Snapshots.list_resources)
|
tuca/resource.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 René de Hesselle <dehesselle@web.de>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Resource(BaseModel):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def as_str(self):
|
|
15
|
+
return json.dumps(
|
|
16
|
+
self.model_dump(),
|
|
17
|
+
indent=4,
|
|
18
|
+
sort_keys=True,
|
|
19
|
+
)
|
tuca/version.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tuca
|
|
3
|
+
Version: 0.1
|
|
4
|
+
Summary: tool using clouding api
|
|
5
|
+
Author-email: René de Hesselle <dehesselle@web.de>
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: keyring>=25.7.0
|
|
8
|
+
Requires-Dist: pydantic>=2.12.5
|
|
9
|
+
Requires-Dist: python-slugify>=8.0.4
|
|
10
|
+
Requires-Dist: requests>=2.32.5
|
|
11
|
+
Requires-Dist: urlpath>=2.0.0
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# tool using Clouding.io API
|
|
15
|
+
|
|
16
|
+
This is an unofficial CLI tool that interacts with the Clouding.io's REST API. Its main goal is to provide a simple interface that I can use to create and destroy servers from shell scripts. Therefore it neither provides access to all available API endpoints nor to all available attributes and/or actions.
|
|
17
|
+
|
|
18
|
+
The project status is best described as "alpha" as things are still very much in motion and specifically tailored towards my usecase.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
`tuca` is on [PyPi](https://pypi.org/project/tuca/), you can use the package manager of your choice to set yourself up. Here is an example using `uv`:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
uv tool install tuca
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
### the basics
|
|
31
|
+
|
|
32
|
+
The CLI interface follows this pattern:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
tuca <endpoint> <action> [options]
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- `endpoint` is the same as in https://api.clouding.io/docs/
|
|
39
|
+
- `action` is one of `create`, `list` and `delete`
|
|
40
|
+
- `options` depend on `endpoint` and `action`, consult `--help` for details
|
|
41
|
+
|
|
42
|
+
`tuca` writes pretty-printed JSON (no colors) to `stdout`. It's both human-readable and intended to be piped into `jq` for non-interactive usage. The following example shows the available SSH keys (redacted values) in a freshly created account:
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"keypairs": [
|
|
47
|
+
{
|
|
48
|
+
"fingerprint": "00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00",
|
|
49
|
+
"id": "xxxxxxxxxxxxxxxx",
|
|
50
|
+
"name": "default"
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The output is
|
|
57
|
+
|
|
58
|
+
- always organized as list, even if the result count is 1 or 0
|
|
59
|
+
- usually named after the endpoint
|
|
60
|
+
- contains only a limited number attributes, but always `id` and `name`
|
|
61
|
+
- limited to 100 entries
|
|
62
|
+
|
|
63
|
+
### authentication
|
|
64
|
+
|
|
65
|
+
Before showing you examples, you need to setup an API token first. You can do that via environment variable:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
export CLOUDINGIO_API_TOKEN=my_secret_token
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Or, more securely, have tuca write it into your desktop's keyring. The following command will give you an interactive prompt to do that.
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
tuca auth create
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
_And before you say anything, I'm aware that `auth` is not an endpoint._
|
|
78
|
+
|
|
79
|
+
If you provide both, the environment variable takes precendence.
|
|
80
|
+
|
|
81
|
+
### here we go
|
|
82
|
+
|
|
83
|
+
Time to create your first server. First, pick an image.
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
tuca images list
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
<details>
|
|
90
|
+
<summary>Output</summary>
|
|
91
|
+
|
|
92
|
+
_(modified/shortened for brevity)_
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"sizes/flavors": [
|
|
97
|
+
...
|
|
98
|
+
{
|
|
99
|
+
"accessMethods": {
|
|
100
|
+
"password": "required",
|
|
101
|
+
"sshKey": "not-supported"
|
|
102
|
+
},
|
|
103
|
+
"id": "jXEm7yK3MJ2VYkQ9",
|
|
104
|
+
"minimumSizeGb": 25,
|
|
105
|
+
"name": "Windows 11 (English 64Bit | Based on Windows Server 2025)"
|
|
106
|
+
},
|
|
107
|
+
...
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
</details>
|
|
112
|
+
|
|
113
|
+
Now pick a size.
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
tuca flavors list
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
<details>
|
|
120
|
+
<summary>Output</summary>
|
|
121
|
+
|
|
122
|
+
_(modified/shortened for brevity)_
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
{
|
|
126
|
+
"sizes/flavors": [
|
|
127
|
+
...
|
|
128
|
+
{
|
|
129
|
+
"id": "8x16",
|
|
130
|
+
"pricePerHour": 0.05472,
|
|
131
|
+
"pricePerMonthApprox": 39.9456,
|
|
132
|
+
"ramGb": 16,
|
|
133
|
+
"vCores": 8.0
|
|
134
|
+
},
|
|
135
|
+
...
|
|
136
|
+
]
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
</details>
|
|
140
|
+
|
|
141
|
+
That's all to create a server with minimal configuration.
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
# Windows 11 compatible image
|
|
145
|
+
# 8 cores, 16 GB RAM
|
|
146
|
+
# default firewall
|
|
147
|
+
# default image size
|
|
148
|
+
tuca servers create --image jXEm7yK3MJ2VYkQ9 --name MyWinServer --flavorid 8x16 --password start123
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
<details>
|
|
152
|
+
<summary>Output</summary>
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
{
|
|
156
|
+
"servers": [
|
|
157
|
+
{
|
|
158
|
+
"createdAt": "2026-02-04T23:42:16",
|
|
159
|
+
"id": "mJOZBKqGP702Xjax",
|
|
160
|
+
"name": "MyWinServer",
|
|
161
|
+
"publicIp": null,
|
|
162
|
+
"status": "Pending"
|
|
163
|
+
}
|
|
164
|
+
]
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
</details>
|
|
168
|
+
|
|
169
|
+
Spooling up the server can take some time and you can check how it's doing.
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
tuca servers list --name MyWinServer
|
|
173
|
+
```
|
|
174
|
+
<details>
|
|
175
|
+
<summary>Output</summary>
|
|
176
|
+
|
|
177
|
+
```json
|
|
178
|
+
{
|
|
179
|
+
"servers": [
|
|
180
|
+
{
|
|
181
|
+
"createdAt": "2026-02-04T23:42:16",
|
|
182
|
+
"id": "mJOZBKqGP702Xjax",
|
|
183
|
+
"name": "MyWinServer",
|
|
184
|
+
"publicIp": "103.23.60.115",
|
|
185
|
+
"status": "Creating"
|
|
186
|
+
}
|
|
187
|
+
]
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
</details>
|
|
191
|
+
|
|
192
|
+
The server will be ready eventually.
|
|
193
|
+
|
|
194
|
+
<details>
|
|
195
|
+
<summary>Output</summary>
|
|
196
|
+
|
|
197
|
+
```json
|
|
198
|
+
{
|
|
199
|
+
"servers": [
|
|
200
|
+
{
|
|
201
|
+
"createdAt": "2026-02-04T23:42:16",
|
|
202
|
+
"id": "mJOZBKqGP702Xjax",
|
|
203
|
+
"name": "MyWinServer",
|
|
204
|
+
"publicIp": "103.23.60.115",
|
|
205
|
+
"status": "Active"
|
|
206
|
+
}
|
|
207
|
+
]
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
</details>
|
|
211
|
+
|
|
212
|
+
## License
|
|
213
|
+
|
|
214
|
+
[GPL-2.0-or-later](https://github.com/dehesselle/tuca/blob/main/LICENSE)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
tuca/__init__.py,sha256=KzDQV85fxm0H_ASGBafaDf69e2L-0oKI0_t0goAIOfg,1208
|
|
2
|
+
tuca/_version.py,sha256=zCgmOZqbKglNFIB9lB_sgjMOR6jrNzUWcCp-9rpWq18,699
|
|
3
|
+
tuca/auth.py,sha256=OAFRSOOTAsyBPmKJ7KHb3L0ejXRiYVfzs1rVovcT888,1279
|
|
4
|
+
tuca/clouding.py,sha256=Tgejg5O_8pbHj8_CQ986kdmob9ojheyXnH7gqUKdjdk,4295
|
|
5
|
+
tuca/config.py,sha256=aLakXs_1mv8zHLFq3xwncKsIWgjVWoLX9G3gGKQRviw,239
|
|
6
|
+
tuca/resource.py,sha256=gYz6MZ3CYqgypidx-0pEYdYV7On4L1pfD38ULGJqdmA,372
|
|
7
|
+
tuca/version.py,sha256=taB1W2GReTIAt84Pmu0AMkKNNQ8ikyaiZ86scz4O2gQ,216
|
|
8
|
+
tuca/endpoints/__init__.py,sha256=FH69f1PqpNSIqvEOwwklaynpd53XWtj1ONNH_AYbd5w,345
|
|
9
|
+
tuca/endpoints/endpoint.py,sha256=SY5bVXJGWIKLUVrYg2nJjWVlnG7E9-GzJ_2ZFV4OOZ8,4735
|
|
10
|
+
tuca/endpoints/firewalls.py,sha256=e_ch3O8Amp8e_mj6bT1D_FtAqNZtjaIDWecNvpGRUVY,403
|
|
11
|
+
tuca/endpoints/images.py,sha256=VT7_rH4gi6VcpT_AwYoesklhYmxbY43mFih39LTGnBs,1048
|
|
12
|
+
tuca/endpoints/keypairs.py,sha256=cjjCCcuW-YIezwBvqlIkrBPSAkdZxKMpgPWWPuT74nc,2269
|
|
13
|
+
tuca/endpoints/servers.py,sha256=hjdnsuRMt2_afUIvVzhZduKGs1HNeNV8dM_GsbiceuY,6355
|
|
14
|
+
tuca/endpoints/sizes.py,sha256=1RXSbvc8R2GcA9tvj7NG1H64tYEZ6XQ2fq7R8KqEJzI,1755
|
|
15
|
+
tuca/endpoints/snapshots.py,sha256=Pu2Xt66LN9j9m6gQtl9i_-Of65dwg8UcOHS1WfAi1iU,962
|
|
16
|
+
tuca-0.1.dist-info/METADATA,sha256=CYEjLYKCnLr4427Fb_dnCpo6PQkuems5iUUpCzeCd9k,4834
|
|
17
|
+
tuca-0.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
18
|
+
tuca-0.1.dist-info/entry_points.txt,sha256=wRAMuApJ2TVsoGlTJn_krDLmjhKV07pELJ1ccGdMI5o,35
|
|
19
|
+
tuca-0.1.dist-info/RECORD,,
|
tuca-0.1.dist-info/WHEEL
ADDED