twc-cli 2.4.0__py3-none-any.whl → 2.5.0__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.

Potentially problematic release.


This version of twc-cli might be problematic. Click here for more details.

CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  В этом файле описаны все значимые изменения в Timeweb Cloud CLI. В выпусках мы придерживается правил [семантического версионирования](https://semver.org/lang/ru/).
4
4
 
5
+ # Версия 2.5.0 (2024.07.24)
6
+
7
+ ## Добавлено
8
+
9
+ - Добавлена поддержка зон доступности. Добавлен параметр конифгурации `availability_zone` (`TWC_AVAILABILITY_ZONE`).
10
+ - Добавлены новые опции для команды `twc server create`: `--availability-zone`, `--user-data`, `--public-ip`, `--no-public-ip`, `--private-ip`.
11
+ - Добавлена новая команда `twc ip` вместо устаревшей `twc server ip`. Поскольку IP-адреса сейчас являются отдельными ресурсами, управление ими вынесено из `twc server`.
12
+
13
+ ## Изменено
14
+
15
+ - Минимальная поддерживаемая версия интерпретатора Python повышена до 3.8.
16
+ - Обновлены версии зависимостей.
17
+ - Обновлены команды и методы API-клиента для работы VPC.
18
+ - Удалён параметр `log_response` принимаемый как значение переменной окружения `TWC_LOG`, теперь тело ответа API логируется всегда.
19
+ - Команда `twc server ip` объявлена устаревшей (deprecated) и будет уделена в следующем мажорном релизе. Вместо неё используйте команду `twc ip`.
20
+ - Другие мелкие обновления CLI без нарушения обратной совместимости.
21
+
22
+ ## Исправлено
23
+
24
+ - Исправлен метод удаления кластеров Kubernetes во встроенном API-клиенте.
25
+ - Исправлена ошибка разбора JSON при получении объекта сервера без IP.
26
+
27
+ # Версия 2.4.1 (2023.08.30)
28
+
29
+ ## Исправлено
30
+
31
+ - Исправлена ошибка парсинга доменного имени в команде `twc domain record add`.
32
+
5
33
  # Версия 2.4.0 (2023.08.21)
6
34
 
7
35
  ## Добавлено
twc/__main__.py CHANGED
@@ -20,6 +20,7 @@ from .commands import (
20
20
  domain,
21
21
  vpc,
22
22
  firewall,
23
+ floating_ip,
23
24
  )
24
25
  from .commands.common import version_callback, version_option, verbose_option
25
26
 
@@ -44,6 +45,7 @@ cli.add_typer(
44
45
  cli.add_typer(domain, name="domain", aliases=["domains", "d"])
45
46
  cli.add_typer(vpc, name="vpc", aliases=["vpcs", "network", "networks"])
46
47
  cli.add_typer(firewall, name="firewall", aliases=["fw"])
48
+ cli.add_typer(floating_ip, name="ip", aliases=["ips"])
47
49
 
48
50
 
49
51
  @cli.command("version")
twc/__version__.py CHANGED
@@ -12,5 +12,5 @@
12
12
  import sys
13
13
 
14
14
 
15
- __version__ = "2.4.0"
15
+ __version__ = "2.5.0"
16
16
  __pyversion__ = sys.version.replace("\n", "")
twc/api/base.py CHANGED
@@ -29,7 +29,6 @@ class TimewebCloudBase:
29
29
  user_agent: Optional[str] = USER_AGENT,
30
30
  timeout: Optional[int] = TIMEOUT,
31
31
  hide_token: Optional[bool] = True,
32
- log_response_body: Optional[bool] = False,
33
32
  request_decorator: Optional[Callable] = None,
34
33
  ):
35
34
  self.api_token = api_token
@@ -44,7 +43,6 @@ class TimewebCloudBase:
44
43
  self.headers["Authorization"] = f"Bearer {self.api_token}"
45
44
  self.log = logging.getLogger("api_client")
46
45
  self.hide_token = hide_token
47
- self.log_response_body = log_response_body
48
46
 
49
47
  if headers:
50
48
  self.headers.update(headers)
@@ -66,10 +64,10 @@ class TimewebCloudBase:
66
64
 
67
65
  def _log_request(self, response: requests.Response) -> None:
68
66
  """Log HTTP requests."""
69
- if self.log_response_body:
70
- res_body = response.text or "<NO_BODY>"
71
- else:
72
- res_body = "<NOT_LOGGED>"
67
+ res_body = response.text or "<NO_BODY>"
68
+ req_body = response.request.body or "<NO_BODY>"
69
+ if isinstance(req_body, (bytes, bytearray)):
70
+ req_body = req_body.decode()
73
71
 
74
72
  self.log.debug(
75
73
  textwrap.dedent(
@@ -89,7 +87,7 @@ class TimewebCloudBase:
89
87
  req_headers=self._format_headers(
90
88
  self._secure_log(response.request.headers)
91
89
  ),
92
- req_body=response.request.body or "<NO_BODY>",
90
+ req_body=req_body,
93
91
  res=response,
94
92
  res_headers=self._format_headers(response.headers),
95
93
  res_body=res_body,
@@ -136,7 +134,6 @@ class TimewebCloudBase:
136
134
  response.raise_for_status()
137
135
  self._log_request(response)
138
136
  except requests.HTTPError as e:
139
- self.log_response_body = True # Always log response body on errors
140
137
  self._log_request(response)
141
138
 
142
139
  # API issue: Bad response: 401 Unauthorized response haven't body
twc/api/client.py CHANGED
@@ -18,6 +18,7 @@ from .types import (
18
18
  BackupInterval,
19
19
  IPVersion,
20
20
  ServiceRegion,
21
+ ServiceAvailabilityZone,
21
22
  ResourceType,
22
23
  DBMS,
23
24
  MySQLAuthPlugin,
@@ -70,9 +71,10 @@ class TimewebCloud(TimewebCloudBase):
70
71
  avatar_id: Optional[str] = None,
71
72
  software_id: Optional[int] = None,
72
73
  ssh_keys_ids: Optional[List[int]] = None,
73
- is_local_network: Optional[bool] = None,
74
+ is_local_network: Optional[bool] = None, # deprecated
74
75
  is_ddos_guard: bool = False,
75
76
  network: Optional[dict] = None,
77
+ availability_zone: Optional[ServiceAvailabilityZone] = None,
76
78
  ):
77
79
  """Create new Cloud Server. Note:
78
80
 
@@ -105,6 +107,11 @@ class TimewebCloud(TimewebCloudBase):
105
107
  **({"preset_id": preset_id} if preset_id else {}),
106
108
  **({"os_id": os_id} if os_id else {}),
107
109
  **({"image_id": image_id} if image_id else {}),
110
+ **(
111
+ {"availability_zone": str(availability_zone)}
112
+ if availability_zone
113
+ else {}
114
+ ),
108
115
  }
109
116
 
110
117
  return self._request("POST", f"{self.api_url}/servers", json=payload)
@@ -697,7 +704,7 @@ class TimewebCloud(TimewebCloudBase):
697
704
  )
698
705
 
699
706
  # -----------------------------------------------------------------------
700
- # Databases
707
+ # Managed databases
701
708
 
702
709
  def get_databases(self, limit: int = 100, offset: int = 0):
703
710
  """Get databases list."""
@@ -1343,7 +1350,7 @@ class TimewebCloud(TimewebCloudBase):
1343
1350
  def delete_k8s_node(self, cluster_id: int, node_id: int):
1344
1351
  """Delete node from cluster."""
1345
1352
  return self._request(
1346
- "GET",
1353
+ "DELETE",
1347
1354
  f"{self.api_url}/k8s/clusters/{cluster_id}/nodes/{node_id}",
1348
1355
  )
1349
1356
 
@@ -1483,6 +1490,7 @@ class TimewebCloud(TimewebCloudBase):
1483
1490
  name: str,
1484
1491
  subnet: IPv4Network,
1485
1492
  location: ServiceRegion,
1493
+ availability_zone: Optional[ServiceAvailabilityZone] = None,
1486
1494
  description: Optional[str] = None,
1487
1495
  ):
1488
1496
  """Create new virtual private network."""
@@ -1490,6 +1498,11 @@ class TimewebCloud(TimewebCloudBase):
1490
1498
  "name": name,
1491
1499
  "subnet_v4": subnet,
1492
1500
  "location": location,
1501
+ **(
1502
+ {"availability_zone": str(availability_zone)}
1503
+ if availability_zone
1504
+ else {}
1505
+ ),
1493
1506
  **({"description": description} if description else {}),
1494
1507
  }
1495
1508
  return self._request("POST", f"{self.api_url_v2}/vpcs", json=payload)
@@ -1690,3 +1703,67 @@ class TimewebCloud(TimewebCloudBase):
1690
1703
  f"{self.api_url}/firewall/service/{resource_type}/{resource_id}",
1691
1704
  params=params,
1692
1705
  )
1706
+
1707
+ # -----------------------------------------------------------------------
1708
+ # Floating IPs
1709
+
1710
+ def get_floating_ips(self):
1711
+ return self._request("GET", f"{self.api_url_v1}/floating-ips")
1712
+
1713
+ def get_floating_ip(self, floating_ip_id: str):
1714
+ return self._request(
1715
+ "GET", f"{self.api_url_v1}/floating-ips/{floating_ip_id}"
1716
+ )
1717
+
1718
+ def create_floating_ip(
1719
+ self,
1720
+ availability_zone: ServiceAvailabilityZone,
1721
+ ddos_protection: bool = False,
1722
+ ):
1723
+ payload = {
1724
+ "is_ddos_guard": ddos_protection,
1725
+ "availability_zone": str(availability_zone),
1726
+ }
1727
+ return self._request(
1728
+ "POST", f"{self.api_url_v1}/floating-ips", json=payload
1729
+ )
1730
+
1731
+ def update_floating_ip(
1732
+ self,
1733
+ floating_ip_id: str,
1734
+ comment: Optional[str] = None,
1735
+ ptr: Optional[str] = None,
1736
+ ):
1737
+ payload = {}
1738
+ if comment:
1739
+ payload["comment"] = comment
1740
+ if ptr:
1741
+ payload["ptr"] = ptr
1742
+ return self._request(
1743
+ "PATCH",
1744
+ f"{self.api_url_v1}/floating-ips/{floating_ip_id}",
1745
+ json=payload,
1746
+ )
1747
+
1748
+ def delete_floating_ip(self, floating_ip_id: str):
1749
+ return self._request(
1750
+ "DELETE", f"{self.api_url_v1}/floating-ips/{floating_ip_id}"
1751
+ )
1752
+
1753
+ def attach_floating_ip(
1754
+ self,
1755
+ floating_ip_id: str,
1756
+ resource_type: ResourceType,
1757
+ resource_id: str,
1758
+ ):
1759
+ payload = {"resource_type": resource_type, "resource_id": resource_id}
1760
+ return self._request(
1761
+ "POST",
1762
+ f"{self.api_url_v1}/floating-ips/{floating_ip_id}/bind",
1763
+ json=payload,
1764
+ )
1765
+
1766
+ def detach_floating_ip(self, floating_ip_id: str):
1767
+ return self._request(
1768
+ "POST", f"{self.api_url_v1}/floating-ips/{floating_ip_id}/unbind"
1769
+ )
twc/api/types.py CHANGED
@@ -1,6 +1,6 @@
1
1
  """Custom data types for Timeweb Cloud API entities."""
2
2
 
3
- from typing import NamedTuple
3
+ from typing import TypedDict, List, Optional
4
4
  from enum import Enum
5
5
 
6
6
 
@@ -9,10 +9,59 @@ class ServiceRegion(str, Enum):
9
9
 
10
10
  RU_1 = "ru-1"
11
11
  RU_2 = "ru-2"
12
- PL_1 = "pl-1"
12
+ RU_3 = "ru-3"
13
13
  KZ_1 = "kz-1"
14
+ PL_1 = "pl-1"
14
15
  NL_1 = "nl-1"
15
16
 
17
+ @classmethod
18
+ def get_zones(cls, region: str) -> List[str]:
19
+ # pylint: disable=too-many-return-statements
20
+ if region == cls.RU_1:
21
+ return ["spb-1", "spb-2", "spb-3", "spb-4"]
22
+ if region == cls.RU_2:
23
+ return ["nsk-1"]
24
+ if region == cls.RU_3:
25
+ return ["msk-1"]
26
+ if region == cls.KZ_1:
27
+ return ["ala-1"]
28
+ if region == cls.PL_1:
29
+ return ["gdn-1"]
30
+ if region == cls.NL_1:
31
+ return ["ams-1"]
32
+ return []
33
+
34
+
35
+ class ServiceAvailabilityZone(str, Enum):
36
+ """Availability zones."""
37
+
38
+ SPB_1 = "spb-1"
39
+ SPB_2 = "spb-2"
40
+ SPB_3 = "spb-3"
41
+ SPB_4 = "spb-4"
42
+ MSK_1 = "msk-1"
43
+ NSK_1 = "nsk-1"
44
+ ALA_1 = "ala-1"
45
+ GDN_1 = "gdn-1"
46
+ AMS_1 = "ams-1"
47
+
48
+ @classmethod
49
+ def get_region(cls, zone: str) -> Optional[str]:
50
+ # pylint: disable=too-many-return-statements
51
+ if zone in [cls.SPB_1, cls.SPB_2, cls.SPB_3, cls.SPB_4]:
52
+ return ServiceRegion.RU_1
53
+ if zone == cls.NSK_1:
54
+ return ServiceRegion.RU_2
55
+ if zone == cls.MSK_1:
56
+ return ServiceRegion.RU_3
57
+ if zone == cls.ALA_1:
58
+ return ServiceRegion.KZ_1
59
+ if zone == cls.GDN_1:
60
+ return ServiceRegion.PL_1
61
+ if zone == cls.AMS_1:
62
+ return ServiceRegion.NL_1
63
+ return None
64
+
16
65
 
17
66
  class ServerAction(str, Enum):
18
67
  """Available actions on Cloud Server."""
@@ -64,13 +113,11 @@ class IPVersion(str, Enum):
64
113
  IPV6 = "ipv6"
65
114
 
66
115
 
67
- class ServerConfiguration(NamedTuple):
116
+ class ServerConfiguration(TypedDict):
68
117
  """
69
118
  For `confugurator_id` see `get_server_configurators()`. `disk` and
70
- `ram` must be in megabytes. Values must values must comply with the
71
- configurator constraints.
72
-
73
- TODO: Replace NamedTuple with TypedDict when drop Python 3.7
119
+ `ram` must be in megabytes. Values must comply with the configurator
120
+ constraints.
74
121
  """
75
122
 
76
123
  configurator_id: int
twc/apiwrap.py CHANGED
@@ -73,8 +73,8 @@ def create_client(config: Path, profile: str, **kwargs) -> TimewebCloud:
73
73
 
74
74
  if log_settings:
75
75
  for param in log_settings.split(","):
76
- if param == "log_response":
77
- kwargs["log_response_body"] = True
76
+ if param.lower() == "debug":
77
+ pass # FUTURE: set logging.DEBUG
78
78
 
79
79
  if token:
80
80
  debug("Config: use API token from environment")
twc/commands/__init__.py CHANGED
@@ -13,3 +13,4 @@ from .kubernetes import cluster
13
13
  from .domain import domain
14
14
  from .vpc import vpc
15
15
  from .firewall import firewall
16
+ from .floating_ip import floating_ip
twc/commands/common.py CHANGED
@@ -13,7 +13,7 @@ from typer.core import TyperOption
13
13
  from click import UsageError
14
14
 
15
15
  from twc.__version__ import __version__
16
- from twc.api.types import ServiceRegion
16
+ from twc.api.types import ServiceRegion, ServiceAvailabilityZone
17
17
 
18
18
 
19
19
  class OutputFormat(str, Enum):
@@ -109,6 +109,11 @@ def load_from_config_callback(
109
109
  regions = [v.value for v in ServiceRegion]
110
110
  if value not in regions:
111
111
  sys.exit(f"Error: Location not in {regions}")
112
+ if param.name == "zone":
113
+ value = value.lower()
114
+ zones = [z.value for z in ServiceAvailabilityZone]
115
+ if value not in zones:
116
+ sys.exit(f"Error: Availability zone not in {zones}")
112
117
  return value
113
118
 
114
119
 
@@ -275,5 +280,16 @@ region_option = typer.Option(
275
280
  envvar="TWC_REGION",
276
281
  show_envvar=False,
277
282
  callback=load_from_config_callback,
278
- help="Use region (location).",
283
+ help="Region (location).",
284
+ )
285
+
286
+ # availability_zone: Optional[str] = zone_option,
287
+
288
+ zone_option = typer.Option(
289
+ None,
290
+ metavar="ZONE",
291
+ envvar="TWC_AVAILABILITY_ZONE",
292
+ show_envvar=False,
293
+ callback=load_from_config_callback,
294
+ help="Availability zone.",
279
295
  )
twc/commands/domain.py CHANGED
@@ -365,7 +365,7 @@ def domain_add_dns_record(
365
365
  # underscores to this function.
366
366
  if record_type.lower() == "txt":
367
367
  # 'ftp.example.org' --> 'ftp'
368
- subdomain = subdomain.split(".")[: offset - 1][0]
368
+ subdomain = ".".join(subdomain.split(".")[:-offset])
369
369
 
370
370
  response = client.add_domain_dns_record(
371
371
  domain_name, record_type, value, subdomain, priority
twc/commands/firewall.py CHANGED
@@ -453,7 +453,7 @@ def filrewall_rule_list(
453
453
 
454
454
 
455
455
  # ------------------------------------------------------------- #
456
- # $ twc firewall rule add #
456
+ # $ twc firewall rule create #
457
457
  # ------------------------------------------------------------- #
458
458
 
459
459
 
@@ -482,8 +482,8 @@ def validate_cidr_callback(value):
482
482
  return value
483
483
 
484
484
 
485
- @firewall_rule.command("add")
486
- def firewall_allow(
485
+ @firewall_rule.command("create", "add")
486
+ def firewall_rule_create(
487
487
  ports: List[str] = typer.Argument(
488
488
  ...,
489
489
  metavar="[PORT[-PORT]/]PROTO...",
@@ -520,7 +520,7 @@ def firewall_allow(
520
520
  help="IPv4 or IPv6 CIDR.",
521
521
  ),
522
522
  ):
523
- """Add new firewall rule."""
523
+ """Create new firewall rule."""
524
524
  client = create_client(config, profile)
525
525
  if make_group is not None and group is not None:
526
526
  raise UsageError(
@@ -0,0 +1,296 @@
1
+ """Manage floating IPs."""
2
+
3
+ import sys
4
+ from typing import Optional, List
5
+ from pathlib import Path
6
+ from uuid import UUID
7
+
8
+ import typer
9
+ from requests import Response
10
+
11
+ from twc import fmt
12
+ from twc.api import TimewebCloud, ResourceType
13
+ from twc.apiwrap import create_client
14
+ from twc.typerx import TyperAlias
15
+ from .common import (
16
+ verbose_option,
17
+ config_option,
18
+ profile_option,
19
+ yes_option,
20
+ output_format_option,
21
+ load_from_config_callback,
22
+ )
23
+
24
+
25
+ floating_ip = TyperAlias(help=__doc__)
26
+
27
+
28
+ def get_floating_ip_id(client: TimewebCloud, ip_addr: str) -> Optional[str]:
29
+ ips = client.get_floating_ips().json()["ips"]
30
+ for ip in ips:
31
+ if ip["ip"] == ip_addr:
32
+ return ip["id"]
33
+ return None
34
+
35
+
36
+ # ------------------------------------------------------------- #
37
+ # $ twc floating-ip list #
38
+ # ------------------------------------------------------------- #
39
+
40
+
41
+ def _print_floating_ips(response: Response):
42
+ table = fmt.Table()
43
+ table.header(["IP", "PTR", "ZONE", "ANTI_DDOS", "USED_ON"])
44
+ ips = response.json()["ips"]
45
+ for ip in ips:
46
+ used = None
47
+ if ip["resource_type"]:
48
+ used = f"{ip['resource_type']}:{ip['resource_id']}"
49
+ table.row(
50
+ [
51
+ ip["ip"],
52
+ ip["ptr"],
53
+ ip["availability_zone"],
54
+ ip["is_ddos_guard"],
55
+ used,
56
+ ]
57
+ )
58
+ table.print()
59
+
60
+
61
+ @floating_ip.command("list", "ls")
62
+ def floating_ip_list(
63
+ verbose: Optional[bool] = verbose_option,
64
+ config: Optional[Path] = config_option,
65
+ profile: Optional[str] = profile_option,
66
+ output_format: Optional[str] = output_format_option,
67
+ ):
68
+ """List floating IPs."""
69
+ client = create_client(config, profile)
70
+ response = client.get_floating_ips()
71
+ fmt.printer(
72
+ response,
73
+ output_format=output_format,
74
+ func=_print_floating_ips,
75
+ )
76
+
77
+
78
+ # ------------------------------------------------------------- #
79
+ # $ twc floating-ip get #
80
+ # ------------------------------------------------------------- #
81
+
82
+
83
+ def _print_floating_ip(response: Response):
84
+ table = fmt.Table()
85
+ table.header(["IP", "PTR", "ZONE", "ANTI_DDOS", "USED_ON"])
86
+ ip = response.json()["ip"]
87
+ used = None
88
+ if ip["resource_type"]:
89
+ used = f"{ip['resource_type']}:{ip['resource_id']}"
90
+ table.row(
91
+ [
92
+ ip["ip"],
93
+ ip["ptr"],
94
+ ip["availability_zone"],
95
+ ip["is_ddos_guard"],
96
+ used,
97
+ ]
98
+ )
99
+ table.print()
100
+
101
+
102
+ @floating_ip.command("get")
103
+ def floating_ip_get(
104
+ ip: str,
105
+ verbose: Optional[bool] = verbose_option,
106
+ config: Optional[Path] = config_option,
107
+ profile: Optional[str] = profile_option,
108
+ output_format: Optional[str] = output_format_option,
109
+ ):
110
+ """Get floating IP."""
111
+ client = create_client(config, profile)
112
+ try:
113
+ _ = UUID(ip)
114
+ except ValueError:
115
+ ip = get_floating_ip_id(client, ip)
116
+ response = client.get_floating_ip(ip)
117
+ fmt.printer(
118
+ response,
119
+ output_format=output_format,
120
+ func=_print_floating_ip,
121
+ )
122
+
123
+
124
+ # ------------------------------------------------------------- #
125
+ # $ twc floating-ip create #
126
+ # ------------------------------------------------------------- #
127
+
128
+
129
+ @floating_ip.command("create")
130
+ def floating_ip_create(
131
+ verbose: Optional[bool] = verbose_option,
132
+ config: Optional[Path] = config_option,
133
+ profile: Optional[str] = profile_option,
134
+ output_format: Optional[str] = output_format_option,
135
+ availability_zone: str = typer.Option(
136
+ ...,
137
+ metavar="ZONE",
138
+ envvar="TWC_AVAILABILITY_ZONE",
139
+ show_envvar=False,
140
+ callback=load_from_config_callback,
141
+ help="Availability zone.",
142
+ ),
143
+ ddos_protection: bool = typer.Option(
144
+ False,
145
+ "--ddos-protection",
146
+ show_default=True,
147
+ help="Request IP-address with L3/L4 DDoS protection.",
148
+ ),
149
+ ):
150
+ """Create new floating IP."""
151
+ client = create_client(config, profile)
152
+ response = client.create_floating_ip(
153
+ availability_zone=availability_zone,
154
+ ddos_protection=ddos_protection,
155
+ )
156
+ fmt.printer(
157
+ response,
158
+ output_format=output_format,
159
+ func=lambda response: print(response.json()["ip"]["ip"]),
160
+ )
161
+
162
+
163
+ # ------------------------------------------------------------- #
164
+ # $ twc floating-ip remove #
165
+ # ------------------------------------------------------------- #
166
+
167
+
168
+ @floating_ip.command("remove", "rm")
169
+ def floating_ip_remove(
170
+ floating_ips: List[str] = typer.Argument(..., metavar="IP..."),
171
+ verbose: Optional[bool] = verbose_option,
172
+ config: Optional[Path] = config_option,
173
+ profile: Optional[str] = profile_option,
174
+ yes: Optional[bool] = yes_option,
175
+ ):
176
+ """Remove floating IPs."""
177
+ if not yes:
178
+ typer.confirm("This action cannot be undone, continue?", abort=True)
179
+
180
+ client = create_client(config, profile)
181
+ for ip in floating_ips:
182
+ try:
183
+ _ = UUID(ip)
184
+ except ValueError:
185
+ ip = get_floating_ip_id(client, ip)
186
+ response = client.delete_floating_ip(ip)
187
+ if response.status_code == 204:
188
+ print(ip)
189
+ else:
190
+ sys.exit(fmt.printer(response))
191
+
192
+
193
+ # ------------------------------------------------------------- #
194
+ # $ twc floating-ip attach #
195
+ # ------------------------------------------------------------- #
196
+
197
+
198
+ @floating_ip.command("attach")
199
+ def floating_ip_attach(
200
+ ip: str,
201
+ verbose: Optional[bool] = verbose_option,
202
+ config: Optional[Path] = config_option,
203
+ profile: Optional[str] = profile_option,
204
+ server: Optional[int] = typer.Option(
205
+ None, help="Attach IP to Cloud Server."
206
+ ),
207
+ balancer: Optional[int] = typer.Option(
208
+ None, help="Attach IP to Load Balancer."
209
+ ),
210
+ database: Optional[int] = typer.Option(
211
+ None, help="Attach IP to managed database cluster."
212
+ ),
213
+ ):
214
+ """Attach floating IP to service."""
215
+ client = create_client(config, profile)
216
+ try:
217
+ _ = UUID(ip)
218
+ except ValueError:
219
+ ip = get_floating_ip_id(client, ip)
220
+ resource_type = resource_id = None
221
+ if server:
222
+ resource_type = ResourceType.SERVER
223
+ resource_id = server
224
+ if balancer:
225
+ resource_type = ResourceType.BALANCER
226
+ resource_id = balancer
227
+ if database:
228
+ resource_type = ResourceType.DATABASE
229
+ resource_id = database
230
+ response = client.attach_floating_ip(
231
+ ip,
232
+ resource_type=resource_type,
233
+ resource_id=resource_id,
234
+ )
235
+ if not resource_type or not resource_id:
236
+ sys.exit(
237
+ "Error: Please set one of options: ['--server', '--balancer', '--database']"
238
+ )
239
+ if response.status_code == 204:
240
+ print(resource_id)
241
+ else:
242
+ sys.exit(fmt.printer(response))
243
+
244
+
245
+ # ------------------------------------------------------------- #
246
+ # $ twc floating-ip detach #
247
+ # ------------------------------------------------------------- #
248
+
249
+
250
+ @floating_ip.command("detach")
251
+ def floating_ip_detach(
252
+ ip: str,
253
+ verbose: Optional[bool] = verbose_option,
254
+ config: Optional[Path] = config_option,
255
+ profile: Optional[str] = profile_option,
256
+ ):
257
+ """Detach floating IP from service."""
258
+ client = create_client(config, profile)
259
+ try:
260
+ _ = UUID(ip)
261
+ except ValueError:
262
+ ip = get_floating_ip_id(client, ip)
263
+ response = client.detach_floating_ip(ip)
264
+ if response.status_code == 204:
265
+ print(ip)
266
+ else:
267
+ sys.exit(fmt.printer(response))
268
+
269
+
270
+ # ------------------------------------------------------------- #
271
+ # $ twc floating-ip set #
272
+ # ------------------------------------------------------------- #
273
+
274
+
275
+ @floating_ip.command("set")
276
+ def floating_ip_set(
277
+ ip: str,
278
+ verbose: Optional[bool] = verbose_option,
279
+ config: Optional[Path] = config_option,
280
+ profile: Optional[str] = profile_option,
281
+ output_format: Optional[str] = output_format_option,
282
+ comment: Optional[str] = typer.Option(None, help="Set comment."),
283
+ ptr: Optional[str] = typer.Option(None, help="Set reverse DNS pointer."),
284
+ ):
285
+ """Set floating IP parameters."""
286
+ client = create_client(config, profile)
287
+ try:
288
+ _ = UUID(ip)
289
+ except ValueError:
290
+ ip = get_floating_ip_id(client, ip)
291
+ response = client.update_floating_ip(ip, comment=comment, ptr=ptr)
292
+ fmt.printer(
293
+ response,
294
+ output_format=output_format,
295
+ func=lambda response: print(response.json()["ip"]["ip"]),
296
+ )
twc/commands/image.py CHANGED
@@ -262,13 +262,16 @@ def image_upload(
262
262
  client = create_client(config, profile)
263
263
  if re.match(r"https?://", file):
264
264
  debug(f"Upload URL: {file}")
265
- response = client.create_image(
266
- upload_url=file,
267
- name=name,
268
- description=desc,
269
- os_type=os_type,
270
- location=region,
271
- )
265
+ else:
266
+ sys.exit(f"Invalid link: {file}")
267
+
268
+ response = client.create_image(
269
+ upload_url=file,
270
+ name=name,
271
+ description=desc,
272
+ os_type=os_type,
273
+ location=region,
274
+ )
272
275
 
273
276
  # FUTURE: Implement file upload from local disk
274
277
 
twc/commands/project.py CHANGED
@@ -295,11 +295,10 @@ def project_resource_move(
295
295
  if bucket:
296
296
  for bucket_id in bucket:
297
297
  if not bucket_id.isdigit():
298
- bucket_name = bucket_id
299
298
  bucket_id = resolve_bucket_id(client, bucket_id)
300
299
  response = client.add_bucket_to_project(bucket_id, project_id)
301
300
  if response.status_code == 200:
302
- print(bucket_name)
301
+ print(bucket_id)
303
302
  else:
304
303
  sys.exit(fmt.printer(response))
305
304
  if cluster:
twc/commands/server.py CHANGED
@@ -7,7 +7,7 @@ from logging import debug
7
7
  from typing import Optional, List, Union
8
8
  from pathlib import Path
9
9
  from datetime import date, datetime
10
- from ipaddress import IPv4Address, IPv6Address
10
+ from ipaddress import IPv4Address, IPv6Address, IPv4Network
11
11
 
12
12
  import typer
13
13
  from click import UsageError
@@ -28,7 +28,6 @@ from twc.api import (
28
28
  BackupAction,
29
29
  )
30
30
  from twc.vars import (
31
- REGIONS_WITH_CONFIGURATOR,
32
31
  REGIONS_WITH_IPV6,
33
32
  CONTROL_PANEL_URL,
34
33
  )
@@ -40,6 +39,7 @@ from .common import (
40
39
  yes_option,
41
40
  output_format_option,
42
41
  region_option,
42
+ zone_option,
43
43
  load_from_config_callback,
44
44
  )
45
45
 
@@ -48,7 +48,7 @@ server = TyperAlias(help=__doc__)
48
48
  server_ip = TyperAlias(help="Manage public IPs.")
49
49
  server_disk = TyperAlias(help="Manage Cloud Server disks.")
50
50
  server_backup = TyperAlias(help="Manage Cloud Server disk backups.")
51
- server.add_typer(server_ip, name="ip")
51
+ server.add_typer(server_ip, name="ip", deprecated=True)
52
52
  server.add_typer(server_disk, name="disk")
53
53
  server.add_typer(server_backup, name="backup")
54
54
 
@@ -80,10 +80,9 @@ def print_servers(
80
80
  ]
81
81
  )
82
82
  for srv in servers:
83
+ main_ipv4 = None
83
84
  for network in srv["networks"]:
84
85
  if network["type"] == "public":
85
- if not network["ips"]:
86
- main_ipv4 = None
87
86
  for addr in network["ips"]:
88
87
  if addr["type"] == "ipv4" and addr["is_main"]:
89
88
  main_ipv4 = addr["ip"]
@@ -139,10 +138,9 @@ def print_server(response: Response):
139
138
  "IPV4",
140
139
  ]
141
140
  )
141
+ main_ipv4 = None
142
142
  for network in srv["networks"]:
143
143
  if network["type"] == "public":
144
- if not network["ips"]:
145
- main_ipv4 = None
146
144
  for addr in network["ips"]:
147
145
  if addr["type"] == "ipv4" and addr["is_main"]:
148
146
  main_ipv4 = addr["ip"]
@@ -509,11 +507,14 @@ def server_create(
509
507
  ssh_keys: Optional[List[str]] = typer.Option(
510
508
  None, "--ssh-key", help="SSH-key file, name or ID. Can be multiple."
511
509
  ),
510
+ user_data: Optional[typer.FileText] = typer.Option(
511
+ None, help="user-data file for cloud-init."
512
+ ),
512
513
  ddos_protection: bool = typer.Option(
513
514
  False,
514
515
  "--ddos-protection",
515
516
  show_default=True,
516
- help="Enable DDoS-Guard.",
517
+ help="Request public IPv4 with L3/L4 DDoS protection.",
517
518
  ),
518
519
  local_network: Optional[bool] = typer.Option(
519
520
  # is_local_network paramenter is deprecated!
@@ -524,6 +525,15 @@ def server_create(
524
525
  hidden=True,
525
526
  ),
526
527
  network: Optional[str] = typer.Option(None, help="Private network ID."),
528
+ private_ip: Optional[str] = typer.Option(
529
+ None, help="Private IPv4 address."
530
+ ),
531
+ public_ip: Optional[str] = typer.Option(
532
+ None, help="Public IPv4 address. New address by default."
533
+ ),
534
+ no_public_ip: Optional[bool] = typer.Option(
535
+ False, "--no-public-ip", help="Do not add public IPv4 address."
536
+ ),
527
537
  nat_mode: ServerNATMode = typer.Option(
528
538
  None,
529
539
  "--nat-mode",
@@ -531,6 +541,7 @@ def server_create(
531
541
  help="Apply NAT mode.",
532
542
  ),
533
543
  region: Optional[str] = region_option,
544
+ availability_zone: Optional[str] = zone_option,
534
545
  project_id: int = typer.Option(
535
546
  None,
536
547
  envvar="TWC_PROJECT",
@@ -541,17 +552,14 @@ def server_create(
541
552
  ):
542
553
  """Create Cloud Server."""
543
554
  client = create_client(config, profile)
544
-
545
- if nat_mode:
546
- if not network:
547
- sys.exit("Error: Pass '--network' option first.")
548
-
549
555
  payload = {
550
556
  "name": name,
551
557
  "comment": comment,
552
558
  "avatar_id": avatar_id,
553
559
  "software_id": software_id,
554
560
  "is_ddos_guard": ddos_protection,
561
+ "availability_zone": availability_zone,
562
+ "network": {},
555
563
  **(
556
564
  {"is_local_network": local_network}
557
565
  if local_network is not None
@@ -559,8 +567,44 @@ def server_create(
559
567
  ),
560
568
  }
561
569
 
570
+ # Check availability zone
571
+ usable_zones = ServiceRegion.get_zones(region)
572
+ if availability_zone is not None and availability_zone not in usable_zones:
573
+ sys.exit(
574
+ f"Error: Wrong availability zone, usable zones are: {usable_zones}"
575
+ )
576
+
577
+ # Set network parameters
578
+ if nat_mode or private_ip:
579
+ if not network:
580
+ sys.exit("Error: Pass '--network' option first.")
562
581
  if network:
563
- payload["network"] = {"id": network}
582
+ payload["network"]["id"] = network
583
+ if private_ip:
584
+ net = IPv4Network(
585
+ client.get_vpc(network).json()["vpc"]["subnet_v4"]
586
+ )
587
+ if IPv4Address(private_ip) > IPv4Address(
588
+ int(net.network_address) + 4
589
+ ):
590
+ payload["network"]["ip"] = private_ip
591
+ else:
592
+ # First 3 addresses is reserved for networks OVN based networks
593
+ sys.exit(
594
+ f"Error: Private address '{private_ip}' is not allowed. "
595
+ "IP must be at least the fourth in order in the network."
596
+ )
597
+ if public_ip:
598
+ try:
599
+ _ = IPv4Address(public_ip)
600
+ payload["network"]["floating_ip"] = public_ip
601
+ except ValueError:
602
+ sys.exit(f"Error: '{public_ip}' is not valid IPv4 address.")
603
+ else:
604
+ # New public IPv4 address will be automatically requested with
605
+ # correct availability zone. This is official dirty hack.
606
+ if no_public_ip is False:
607
+ payload["network"]["floating_ip"] = "create_ip"
564
608
 
565
609
  # Set server configuration parameters
566
610
  if preset_id and (cpu or ram or disk):
@@ -609,6 +653,10 @@ def server_create(
609
653
  ssh_keys_ids.append(process_ssh_key(client, key))
610
654
  payload["ssh_keys_ids"] = ssh_keys_ids
611
655
 
656
+ # Set cloud-init user-data
657
+ if user_data:
658
+ payload["cloud_init"] = user_data.read()
659
+
612
660
  # Check project_id before creating server
613
661
  if project_id:
614
662
  if not project_id in [
@@ -738,12 +786,6 @@ def server_resize(
738
786
  # Return error if user tries to switch from preset to configurator in
739
787
  # location where configurator is unavailable.
740
788
  if cpu or ram or disk:
741
- if old_state["location"] not in REGIONS_WITH_CONFIGURATOR:
742
- sys.exit(
743
- "Error: Can not change configuration in location "
744
- + f"'{old_state['location']}'. Change preset_id instead."
745
- )
746
-
747
789
  # Get original server configurator_id
748
790
  configurator_id = old_state["configurator_id"]
749
791
  configurator = None
@@ -775,6 +817,7 @@ def server_resize(
775
817
  payload["configuration"] = {}
776
818
 
777
819
  # Get original size of primary disk
820
+ primary_disk_size = 0
778
821
  for old_disk in old_state["disks"]:
779
822
  if old_disk["is_system"]: # is True
780
823
  primary_disk_size = old_disk["size"]
@@ -963,13 +1006,19 @@ def server_remove(
963
1006
  config: Optional[Path] = config_option,
964
1007
  profile: Optional[str] = profile_option,
965
1008
  yes: Optional[bool] = yes_option,
1009
+ keep_public_ip: Optional[bool] = typer.Option(
1010
+ False,
1011
+ "--keep-public-ip",
1012
+ help="Do not remove public IP attached to server. [default: false]",
1013
+ ),
966
1014
  ):
967
- """Clone Cloud Server."""
1015
+ """Remove Cloud Server."""
968
1016
  if not yes:
969
1017
  typer.confirm("This action cannot be undone. Continue?", abort=True)
970
1018
 
971
1019
  client = create_client(config, profile)
972
1020
  for server_id in server_ids:
1021
+ server_data = client.get_server(server_id).json()["server"]
973
1022
  response = client.delete_server(server_id)
974
1023
  if response.status_code == 200:
975
1024
  del_hash = response.json()["server_delete"]["hash"]
@@ -981,6 +1030,11 @@ def server_remove(
981
1030
  print(server_id)
982
1031
  else:
983
1032
  sys.exit(fmt.printer(response))
1033
+ if keep_public_ip is False:
1034
+ for network in server_data["networks"]:
1035
+ for ip in network["ips"]:
1036
+ if ip.get("id") is not None:
1037
+ client.delete_floating_ip(ip["id"])
984
1038
 
985
1039
 
986
1040
  # ------------------------------------------------------------- #
twc/commands/storage.py CHANGED
@@ -5,7 +5,6 @@ Cloud specific API methods instead. Use third party S3 clients to manage
5
5
  objects e.g. s3cmd, rclone, etc.
6
6
  """
7
7
 
8
-
9
8
  import sys
10
9
  from logging import debug
11
10
  from typing import Optional, List
twc/commands/vpc.py CHANGED
@@ -13,9 +13,10 @@ import typer
13
13
  from requests import Response
14
14
 
15
15
  from twc import fmt
16
+ from twc.api import ServiceRegion
16
17
  from twc.typerx import TyperAlias
17
18
  from twc.apiwrap import create_client
18
- from twc.vars import REGIONS_WITH_LAN
19
+ from twc.vars import REGIONS_WITH_LAN, ZONES_WITH_LAN
19
20
  from .common import (
20
21
  verbose_option,
21
22
  config_option,
@@ -24,6 +25,7 @@ from .common import (
24
25
  output_format_option,
25
26
  filter_option,
26
27
  region_option,
28
+ zone_option,
27
29
  )
28
30
 
29
31
 
@@ -31,9 +33,11 @@ vpc = TyperAlias(help=__doc__)
31
33
  vpc_port = TyperAlias(help="Manage network ports.")
32
34
  vpc.add_typer(vpc_port, name="port", aliases=["ports"])
33
35
 
34
- ALLOWED_SUBNETS = [IPv4Network("10.0.0.0/16"), IPv4Network("192.168.0.0/16")]
35
- MAX_PREFIXLEN = 16
36
- MIN_PREFIXLEN = 32
36
+ ALLOWED_SUBNETS = [
37
+ IPv4Network("10.0.0.0/8"),
38
+ IPv4Network("192.168.0.0/16"),
39
+ IPv4Network("172.16.0.0/12"),
40
+ ]
37
41
 
38
42
 
39
43
  # ------------------------------------------------------------- #
@@ -46,12 +50,13 @@ def print_networks(response: Response, filters: Optional[str] = None):
46
50
  if filters:
47
51
  nets = fmt.filter_list(nets, filters)
48
52
  table = fmt.Table()
49
- table.header(["ID", "REGION", "SUBNET"])
53
+ table.header(["ID", "REGION", "ZONE", "SUBNET"])
50
54
  for net in nets:
51
55
  table.row(
52
56
  [
53
57
  net["id"],
54
58
  net["location"],
59
+ net["availability_zone"],
55
60
  net["subnet_v4"],
56
61
  ]
57
62
  )
@@ -99,8 +104,6 @@ def validate_network(value):
99
104
  f"Error: Network {value} is not subnet of: "
100
105
  f"{[n.with_prefixlen for n in ALLOWED_SUBNETS]}"
101
106
  )
102
- if network.prefixlen in range(MIN_PREFIXLEN, MAX_PREFIXLEN + 1):
103
- sys.exit("Error: Minimum network prefix is 32, maximum is 16.")
104
107
  return value
105
108
 
106
109
 
@@ -108,7 +111,7 @@ def validate_network(value):
108
111
  def vpc_create(
109
112
  subnet: str = typer.Argument(
110
113
  ...,
111
- metavar="IP_NETWORK",
114
+ metavar="NETWORK_SUBNET",
112
115
  callback=validate_network,
113
116
  help="IPv4 network CIDR.",
114
117
  ),
@@ -119,13 +122,23 @@ def vpc_create(
119
122
  name: str = typer.Option(None, help="Network display name."),
120
123
  desc: Optional[str] = typer.Option(None, help="Description."),
121
124
  region: Optional[str] = region_option,
125
+ availability_zone: Optional[str] = zone_option,
122
126
  ):
123
127
  """Create network."""
124
128
  client = create_client(config, profile)
125
129
  if region not in REGIONS_WITH_LAN:
126
130
  sys.exit(
127
131
  f"Error: Cannot create network in location '{region}'. "
128
- f"Available regions is {REGIONS_WITH_LAN}"
132
+ f"Available regions are: {REGIONS_WITH_LAN}"
133
+ )
134
+ usable_zones = set(ZONES_WITH_LAN).intersection(
135
+ set(ServiceRegion.get_zones(region))
136
+ )
137
+ if availability_zone is not None and availability_zone not in usable_zones:
138
+ sys.exit(
139
+ f"Error: Cannot create network in region '{region}' with "
140
+ f"availability zone '{availability_zone}'. "
141
+ f"Usable zones are: {list(usable_zones)}"
129
142
  )
130
143
  if not name:
131
144
  name = subnet
@@ -134,6 +147,7 @@ def vpc_create(
134
147
  description=desc,
135
148
  subnet=subnet,
136
149
  location=region,
150
+ availability_zone=availability_zone,
137
151
  )
138
152
  fmt.printer(
139
153
  response,
twc/fmt.py CHANGED
@@ -31,7 +31,7 @@ class Table:
31
31
 
32
32
  """
33
33
 
34
- def __init__(self, whitespace: str = "\t"):
34
+ def __init__(self, whitespace: str = " "):
35
35
  self.__rows = []
36
36
  self.__whitespace = whitespace
37
37
 
twc/vars.py CHANGED
@@ -6,11 +6,11 @@ expand or other infrastructure or product changes occur.
6
6
 
7
7
  # Service URLs
8
8
  CONTROL_PANEL_URL = "https://timeweb.cloud/my"
9
- S3_ENDPOINT = "s3.timeweb.com"
9
+ S3_ENDPOINT_DEPRECATED = "s3.timeweb.com"
10
+ S3_ENDPOINT = "s3.timeweb.cloud"
10
11
 
11
12
  # Location specific parameters. May change later.
12
- REGIONS_WITH_CONFIGURATOR = ["ru-1", "ru-2", "pl-1", "kz-1", "nl-1"]
13
13
  REGIONS_WITH_IPV6 = ["ru-1", "pl-1"]
14
- REGIONS_WITH_IMAGES = ["ru-1", "pl-1", "nl-1"]
15
- REGIONS_WITH_LAN = ["ru-1", "pl-1"]
16
- REGIONS_WITH_DBAAS = ["ru-1", "pl-1"]
14
+ REGIONS_WITH_IMAGES = ["ru-1", "ru-3", "kz-1", "pl-1", "nl-1"]
15
+ REGIONS_WITH_LAN = ["ru-1", "ru-3", "nl-1", "pl-1"]
16
+ ZONES_WITH_LAN = ["spb-1", "spb-4", "msk-1", "ams-1", "gdn-1"]
@@ -1,29 +1,29 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: twc-cli
3
- Version: 2.4.0
3
+ Version: 2.5.0
4
4
  Summary: Timeweb Cloud Command Line Interface.
5
5
  Home-page: https://github.com/timeweb-cloud/twc
6
6
  License: MIT
7
7
  Author: ge
8
8
  Author-email: dev@timeweb.cloud
9
- Requires-Python: >=3.7.9,<4.0.0
9
+ Requires-Python: >=3.8.19,<4.0.0
10
10
  Classifier: License :: OSI Approved :: MIT License
11
11
  Classifier: Programming Language :: Python :: 3
12
- Classifier: Programming Language :: Python :: 3.8
13
12
  Classifier: Programming Language :: Python :: 3.9
14
13
  Classifier: Programming Language :: Python :: 3.10
15
14
  Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
16
  Requires-Dist: colorama (>=0.4.6,<0.5.0)
17
- Requires-Dist: pygments (>=2.15.0,<3.0.0)
18
- Requires-Dist: pyyaml (>=6.0,<7.0)
19
- Requires-Dist: requests (>=2.31.0,<3.0.0)
20
- Requires-Dist: shellingham (>=1.5.0.post1,<2.0.0)
17
+ Requires-Dist: pygments (>=2.18.0,<3.0.0)
18
+ Requires-Dist: pyyaml (>=6.0.1,<7.0.0)
19
+ Requires-Dist: requests (>=2.32.3,<3.0.0)
20
+ Requires-Dist: shellingham (>=1.5.4,<2.0.0)
21
21
  Requires-Dist: toml (>=0.10.2,<0.11.0)
22
- Requires-Dist: typer (>=0.7.0,<0.8.0)
22
+ Requires-Dist: typer (>=0.12.3,<0.13.0)
23
23
  Project-URL: Repository, https://github.com/timeweb-cloud/twc
24
24
  Description-Content-Type: text/markdown
25
25
 
26
- ![TWC CLI](https://github.com/timeweb-cloud/twc/blob/master/artwork/logo.svg)
26
+ ![TWC CLI](https://github.com/timeweb-cloud/twc/blob/master/artwork/light.svg)
27
27
 
28
28
  Timeweb Cloud Command Line Interface and simple SDK 💫
29
29
 
@@ -0,0 +1,36 @@
1
+ CHANGELOG.md,sha256=Mv2RQ8cXh1JfTXQjEBD3ybfrjl9ATiKJ7q9C6MmtV2Y,23571
2
+ COPYING,sha256=fpJLxZS_kHr_659xkpmqEtqHeZp6lQR9CmKCwnYbsmE,1089
3
+ twc/__init__.py,sha256=NwPAMNw3NuHdWGQvWS9_lromVF6VM194oVOipojfJns,113
4
+ twc/__main__.py,sha256=YBqi1OMSDaZnu2q8OyNa6N-dkVI90n6DZrJbUvYbHJA,2407
5
+ twc/__version__.py,sha256=pQfQHkeBAja7_QVabo1EyLILiBHrkTK_U0mgO4E2MEY,442
6
+ twc/api/__init__.py,sha256=SXew0Fe51M7nRBNQaaLRH4NjnRHkQUn7J26OCkQsftA,128
7
+ twc/api/base.py,sha256=QRefnIgmlbz8n37GLBKeAK1AtzkcNo1IFjZgHDDECJ4,7912
8
+ twc/api/client.py,sha256=II0hXcmlUPuK_ewYDmMu2CjUffcg0C7D1jRu6xKU_RI,58299
9
+ twc/api/exceptions.py,sha256=UzK3pKRffcXlhnkPy6MDjP_DygVoV17DuZ_mdNbOzts,2369
10
+ twc/api/types.py,sha256=S-lRYAEN5kc32LpWfV49CTcBveYAebCgs5e8Hw374qo,5239
11
+ twc/apiwrap.py,sha256=0SmFZUH013jKHlpAQmsIySMtFuNtGvkMLQv0zTVJijg,2780
12
+ twc/commands/__init__.py,sha256=hvTpQByt7wfl9hVo7xRPCdwAQpctoFiBVdaMLHDqKbs,422
13
+ twc/commands/account.py,sha256=6T7J3McTXJKzT7Gi_AgRcKpWdeXcmBTcpwFF0GjzADo,4998
14
+ twc/commands/balancer.py,sha256=QAouc74ZT5go11gB1vjjfYtd1luTmWrfpACPwokZ5sU,20278
15
+ twc/commands/common.py,sha256=Wph8cVogUNNvc456SQrASb7mv7G88I8ETwHgISVjLQQ,8282
16
+ twc/commands/config.py,sha256=hoRtxn2VRxIsuy9vgO6yd0Cu15Rbl-uYMZeU0Ix7dG0,8797
17
+ twc/commands/database.py,sha256=2NZ-TyRBkFgfYJyUdZUcfdqSaX7QVdWDU4k_yQNtUvo,16052
18
+ twc/commands/domain.py,sha256=SHOERsEvmlkdKQV5kaiEgMOLZme5ykW8LCUd1IF1Zzo,15624
19
+ twc/commands/firewall.py,sha256=FoOA-kC_5M-XQ49DZst8EBt_Jmyz4KdRkSUs5ts8QVI,20400
20
+ twc/commands/floating_ip.py,sha256=G9nD5BbHCZcuytbzeneDJWQDhd8c8WRtq9pAfwI9m7E,8747
21
+ twc/commands/image.py,sha256=OviQwegXK55H3TBlroCASVcgj2QUVCTo0ZhF5ug9eT8,8165
22
+ twc/commands/kubernetes.py,sha256=-Cgas1vFVMcrWGinjstuUz3sqX0ZNXv_4mwPwuwKeLE,20870
23
+ twc/commands/project.py,sha256=0z0MmkW8CVFb8emftE5OshoAwdLBZq_otQjNpeuuklU,10750
24
+ twc/commands/server.py,sha256=nySxygbA1ZwLCE53JjhabLAI7uZjnFE_RdJ_8JzZvOU,70341
25
+ twc/commands/ssh_key.py,sha256=NHgTPhAQpDzt-iPHHVo4XqUJvujNqf019N6N9qYZ9Us,7941
26
+ twc/commands/storage.py,sha256=-XleSCTBb9w8MAmwrf89ayZ6mfjcjCbTzpk6BSNZLbk,19215
27
+ twc/commands/vpc.py,sha256=SAht6UD17mU0d_AZY6W34VEYs7CqUsS2iDakPFxAFQU,8876
28
+ twc/fmt.py,sha256=f8submLhHnm8OXBl_4Oe4tuhhMKvgTK1PP1y_25TmyU,6904
29
+ twc/typerx.py,sha256=AZ6BgTQvlrZYfKVYd9YqRNQnAR2XuyqImz4rf6di6f4,6737
30
+ twc/utils.py,sha256=uWizyUC4dHLwtk50q4Sub3zOvnVESfHKBbXYwk5t71w,651
31
+ twc/vars.py,sha256=Z0KMiBlECqFSplwT0gXoEKyJk8W-fu5_OPMOmEdcNGg,609
32
+ twc_cli-2.5.0.dist-info/COPYING,sha256=fpJLxZS_kHr_659xkpmqEtqHeZp6lQR9CmKCwnYbsmE,1089
33
+ twc_cli-2.5.0.dist-info/METADATA,sha256=g3tnREOgKMZv1D2Ey9-zTfFKUikTOemHxYUKEjse4Fo,1775
34
+ twc_cli-2.5.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
35
+ twc_cli-2.5.0.dist-info/entry_points.txt,sha256=tmTaVRhm8BkNrXC_9XJMum7O9wFVOvkXcBetxmahWvE,40
36
+ twc_cli-2.5.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 1.5.2
2
+ Generator: poetry-core 1.9.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,35 +0,0 @@
1
- CHANGELOG.md,sha256=zTYQURuVK_bCcGHFCvPv7fhrrx1AylMBXIoCHN5GjW8,21537
2
- COPYING,sha256=fpJLxZS_kHr_659xkpmqEtqHeZp6lQR9CmKCwnYbsmE,1089
3
- twc/__init__.py,sha256=NwPAMNw3NuHdWGQvWS9_lromVF6VM194oVOipojfJns,113
4
- twc/__main__.py,sha256=jVkf0XyGIhn3oaWgEzknuUjktYk6ANYwbmrUID15vvc,2335
5
- twc/__version__.py,sha256=vMp4lVqdbPXU8ix7Oqd4GZZxIrxkP1u9yI72iQwW8MY,442
6
- twc/api/__init__.py,sha256=SXew0Fe51M7nRBNQaaLRH4NjnRHkQUn7J26OCkQsftA,128
7
- twc/api/base.py,sha256=9srQS7CrDlfg-CSioIRyW_QUbCBovDs2VqN_4V28YCc,8063
8
- twc/api/client.py,sha256=Tkd9zdzsESCtvy-kdv012R-Q12btU4D0uDVOjXUy8Zc,55898
9
- twc/api/exceptions.py,sha256=UzK3pKRffcXlhnkPy6MDjP_DygVoV17DuZ_mdNbOzts,2369
10
- twc/api/types.py,sha256=vattozb_YI7A_igCd2dUkMcJQJQeegYsl_nQwrwNqVw,3917
11
- twc/apiwrap.py,sha256=uh-G67a8C2KN3BdLCShMsk9AHgV_F75FkXmK-xz_plc,2780
12
- twc/commands/__init__.py,sha256=IXCr-gXf7oxwu7CDUa5U0iUM8lraYq_YKpq1aGUqpcU,385
13
- twc/commands/account.py,sha256=6T7J3McTXJKzT7Gi_AgRcKpWdeXcmBTcpwFF0GjzADo,4998
14
- twc/commands/balancer.py,sha256=QAouc74ZT5go11gB1vjjfYtd1luTmWrfpACPwokZ5sU,20278
15
- twc/commands/common.py,sha256=r3enPCYWMwkD8YNPgcXuXK_HrobiHgfpkn82j_uRDqY,7805
16
- twc/commands/config.py,sha256=hoRtxn2VRxIsuy9vgO6yd0Cu15Rbl-uYMZeU0Ix7dG0,8797
17
- twc/commands/database.py,sha256=2NZ-TyRBkFgfYJyUdZUcfdqSaX7QVdWDU4k_yQNtUvo,16052
18
- twc/commands/domain.py,sha256=tG5CiM5B6AeX_bUDGMtGp1L_xsjoYreWpyBFYTUKu04,15621
19
- twc/commands/firewall.py,sha256=6tSOiOmDx3X-hbD_XwUt8cU6GiLh9gDVMtQ7O59PG14,20381
20
- twc/commands/image.py,sha256=2TQ2tzkraggx7eR2OHU64qDFtgzV5YHtCHl4nLpRMqA,8140
21
- twc/commands/kubernetes.py,sha256=-Cgas1vFVMcrWGinjstuUz3sqX0ZNXv_4mwPwuwKeLE,20870
22
- twc/commands/project.py,sha256=iskODT3eKZ5CpAKdWnsQ104YMRcqhOCry6Omnr-a2TM,10792
23
- twc/commands/server.py,sha256=dbrdMXnhe4sIQqF3P-Mm9uRjeqkp8-a054Cvs5FYkaU,68065
24
- twc/commands/ssh_key.py,sha256=NHgTPhAQpDzt-iPHHVo4XqUJvujNqf019N6N9qYZ9Us,7941
25
- twc/commands/storage.py,sha256=MRZfd2WzLI9HeIEupaMg01EQPih2BIAfjx0Tgx0yp0M,19216
26
- twc/commands/vpc.py,sha256=fe7rNKeRAvGYiXIvgGxzxjV35bQ66MVsNnTp7CsJgYQ,8405
27
- twc/fmt.py,sha256=cmo3EdFEAwl_JocfneIEHpD5c_bHQ6tAw_UJEC-vatY,6904
28
- twc/typerx.py,sha256=AZ6BgTQvlrZYfKVYd9YqRNQnAR2XuyqImz4rf6di6f4,6737
29
- twc/utils.py,sha256=uWizyUC4dHLwtk50q4Sub3zOvnVESfHKBbXYwk5t71w,651
30
- twc/vars.py,sha256=zvSaojyihfDpScKLSb5ICVkXE3EHuU0UfwmZz7TTdrg,577
31
- twc_cli-2.4.0.dist-info/COPYING,sha256=fpJLxZS_kHr_659xkpmqEtqHeZp6lQR9CmKCwnYbsmE,1089
32
- twc_cli-2.4.0.dist-info/METADATA,sha256=fjiKhtNC-IX8aB81T6Jtw1-QoEoSPqjUjuaWt1FAKy4,1772
33
- twc_cli-2.4.0.dist-info/WHEEL,sha256=7Z8_27uaHI_UZAc4Uox4PpBhQ9Y5_modZXWMxtUi4NU,88
34
- twc_cli-2.4.0.dist-info/entry_points.txt,sha256=tmTaVRhm8BkNrXC_9XJMum7O9wFVOvkXcBetxmahWvE,40
35
- twc_cli-2.4.0.dist-info/RECORD,,