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 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,13 @@
1
+ # SPDX-FileCopyrightText: 2026 René de Hesselle <dehesselle@web.de>
2
+ #
3
+ # SPDX-License-Identifier: GPL-2.0-or-later
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass
9
+ class Config:
10
+ be_verbose: bool = False
11
+
12
+
13
+ config = Config()
@@ -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"
@@ -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)
@@ -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,8 @@
1
+ # SPDX-FileCopyrightText: 2026 René de Hesselle <dehesselle@web.de>
2
+ #
3
+ # SPDX-License-Identifier: GPL-2.0-or-later
4
+
5
+ try:
6
+ from ._version import version as VERSION
7
+ except ImportError:
8
+ VERSION = "0.0.0"
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tuca = tuca:main