twc-cli 2.4.1__py3-none-any.whl → 2.6.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,52 @@
2
2
 
3
3
  В этом файле описаны все значимые изменения в Timeweb Cloud CLI. В выпусках мы придерживается правил [семантического версионирования](https://semver.org/lang/ru/).
4
4
 
5
+ # Версия 2.6.0 (2024.08.14)
6
+
7
+ ## Добавлено
8
+
9
+ - В команды и API-клиент для управления облачным файрволом добавлена поддержка протоколов TCP6, UDP6, ICMP6 и настройка стандартной политики (DROP или ACCEPT).
10
+ - Добавлены новые команды: `twc firewall group get`, `twc firewall group dump` и `twc firewall group restore`.
11
+
12
+ ## Изменено
13
+
14
+ - Улучшена валидация параметров и подстановка значений по умолчанию в командах `twc firewall`.
15
+ - Команда `twc firewall rule remove` теперь может принимать список UUID правил через пробел.
16
+ - В команде `twc firewall show` аргумент `all` стал необязательным.
17
+ - Зависимость `typer` заменена на `typer-slim`.
18
+
19
+ ## Исправлено
20
+
21
+ - Исправлено определение ендпоинта объектного хранилища при вызове команды `twc storage genconfig`.
22
+
23
+ # Версия 2.5.0 (2024.07.24)
24
+
25
+ ## Добавлено
26
+
27
+ - Добавлена поддержка зон доступности. Добавлен параметр конифгурации `availability_zone` (`TWC_AVAILABILITY_ZONE`).
28
+ - Добавлены новые опции для команды `twc server create`: `--availability-zone`, `--user-data`, `--public-ip`, `--no-public-ip`, `--private-ip`.
29
+ - Добавлена новая команда `twc ip` вместо устаревшей `twc server ip`. Поскольку IP-адреса сейчас являются отдельными ресурсами, управление ими вынесено из `twc server`.
30
+
31
+ ## Изменено
32
+
33
+ - Минимальная поддерживаемая версия интерпретатора Python повышена до 3.8.
34
+ - Обновлены версии зависимостей.
35
+ - Обновлены команды и методы API-клиента для работы VPC.
36
+ - Удалён параметр `log_response` принимаемый как значение переменной окружения `TWC_LOG`, теперь тело ответа API логируется всегда.
37
+ - Команда `twc server ip` объявлена устаревшей (deprecated) и будет уделена в следующем мажорном релизе. Вместо неё используйте команду `twc ip`.
38
+ - Другие мелкие обновления CLI без нарушения обратной совместимости.
39
+
40
+ ## Исправлено
41
+
42
+ - Исправлен метод удаления кластеров Kubernetes во встроенном API-клиенте.
43
+ - Исправлена ошибка разбора JSON при получении объекта сервера без IP.
44
+
45
+ # Версия 2.4.1 (2023.08.30)
46
+
47
+ ## Исправлено
48
+
49
+ - Исправлена ошибка парсинга доменного имени в команде `twc domain record add`.
50
+
5
51
  # Версия 2.4.0 (2023.08.21)
6
52
 
7
53
  ## Добавлено
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.1"
15
+ __version__ = "2.6.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,
@@ -25,6 +26,7 @@ from .types import (
25
26
  LoadBalancerAlgo,
26
27
  FirewallProto,
27
28
  FirewallDirection,
29
+ FirewallPolicy,
28
30
  )
29
31
 
30
32
 
@@ -70,9 +72,10 @@ class TimewebCloud(TimewebCloudBase):
70
72
  avatar_id: Optional[str] = None,
71
73
  software_id: Optional[int] = None,
72
74
  ssh_keys_ids: Optional[List[int]] = None,
73
- is_local_network: Optional[bool] = None,
75
+ is_local_network: Optional[bool] = None, # deprecated
74
76
  is_ddos_guard: bool = False,
75
77
  network: Optional[dict] = None,
78
+ availability_zone: Optional[ServiceAvailabilityZone] = None,
76
79
  ):
77
80
  """Create new Cloud Server. Note:
78
81
 
@@ -105,6 +108,11 @@ class TimewebCloud(TimewebCloudBase):
105
108
  **({"preset_id": preset_id} if preset_id else {}),
106
109
  **({"os_id": os_id} if os_id else {}),
107
110
  **({"image_id": image_id} if image_id else {}),
111
+ **(
112
+ {"availability_zone": str(availability_zone)}
113
+ if availability_zone
114
+ else {}
115
+ ),
108
116
  }
109
117
 
110
118
  return self._request("POST", f"{self.api_url}/servers", json=payload)
@@ -697,7 +705,7 @@ class TimewebCloud(TimewebCloudBase):
697
705
  )
698
706
 
699
707
  # -----------------------------------------------------------------------
700
- # Databases
708
+ # Managed databases
701
709
 
702
710
  def get_databases(self, limit: int = 100, offset: int = 0):
703
711
  """Get databases list."""
@@ -1343,7 +1351,7 @@ class TimewebCloud(TimewebCloudBase):
1343
1351
  def delete_k8s_node(self, cluster_id: int, node_id: int):
1344
1352
  """Delete node from cluster."""
1345
1353
  return self._request(
1346
- "GET",
1354
+ "DELETE",
1347
1355
  f"{self.api_url}/k8s/clusters/{cluster_id}/nodes/{node_id}",
1348
1356
  )
1349
1357
 
@@ -1483,6 +1491,7 @@ class TimewebCloud(TimewebCloudBase):
1483
1491
  name: str,
1484
1492
  subnet: IPv4Network,
1485
1493
  location: ServiceRegion,
1494
+ availability_zone: Optional[ServiceAvailabilityZone] = None,
1486
1495
  description: Optional[str] = None,
1487
1496
  ):
1488
1497
  """Create new virtual private network."""
@@ -1490,6 +1499,11 @@ class TimewebCloud(TimewebCloudBase):
1490
1499
  "name": name,
1491
1500
  "subnet_v4": subnet,
1492
1501
  "location": location,
1502
+ **(
1503
+ {"availability_zone": str(availability_zone)}
1504
+ if availability_zone
1505
+ else {}
1506
+ ),
1493
1507
  **({"description": description} if description else {}),
1494
1508
  }
1495
1509
  return self._request("POST", f"{self.api_url_v2}/vpcs", json=payload)
@@ -1536,14 +1550,20 @@ class TimewebCloud(TimewebCloudBase):
1536
1550
  )
1537
1551
 
1538
1552
  def create_firewall_group(
1539
- self, name: str, description: Optional[str] = None
1553
+ self,
1554
+ name: str,
1555
+ description: Optional[str] = None,
1556
+ policy: Optional[FirewallPolicy] = FirewallPolicy.DROP,
1540
1557
  ):
1541
1558
  payload = {
1542
1559
  "name": name,
1543
1560
  **({"description": description} if description else {}),
1544
1561
  }
1545
1562
  return self._request(
1546
- "POST", f"{self.api_url}/firewall/groups", json=payload
1563
+ "POST",
1564
+ f"{self.api_url}/firewall/groups",
1565
+ json=payload,
1566
+ params={"policy": policy},
1547
1567
  )
1548
1568
 
1549
1569
  def get_firewall_group(self, group_id: UUID):
@@ -1623,16 +1643,16 @@ class TimewebCloud(TimewebCloudBase):
1623
1643
  self,
1624
1644
  group_id: UUID,
1625
1645
  direction: FirewallDirection,
1626
- proto: FirewallProto,
1646
+ protocol: FirewallProto,
1627
1647
  cidr: Union[IPv4Network, IPv6Network],
1628
1648
  port: Optional[str] = None,
1629
1649
  description: Optional[str] = None,
1630
1650
  ):
1631
1651
  payload = {
1632
1652
  **({"description": description} if description else {}),
1633
- **({} if proto == FirewallProto.ICMP.value else {"port": port}),
1653
+ **({} if protocol == FirewallProto.ICMP.value else {"port": port}),
1634
1654
  "direction": direction,
1635
- "protocol": proto,
1655
+ "protocol": protocol,
1636
1656
  "cidr": cidr,
1637
1657
  }
1638
1658
  return self._request(
@@ -1657,16 +1677,16 @@ class TimewebCloud(TimewebCloudBase):
1657
1677
  group_id: UUID,
1658
1678
  rule_id: UUID,
1659
1679
  direction: FirewallDirection,
1660
- proto: FirewallProto,
1680
+ protocol: FirewallProto,
1661
1681
  cidr: Union[IPv4Network, IPv6Network],
1662
1682
  port: Optional[str] = None,
1663
1683
  description: Optional[str] = None,
1664
1684
  ):
1665
1685
  payload = {
1666
1686
  **({"description": description} if description else {}),
1667
- **({} if proto == FirewallProto.ICMP.value else {"port": port}),
1687
+ **({} if protocol == FirewallProto.ICMP.value else {"port": port}),
1668
1688
  "direction": direction,
1669
- "protocol": proto,
1689
+ "protocol": protocol,
1670
1690
  "cidr": cidr,
1671
1691
  }
1672
1692
  return self._request(
@@ -1690,3 +1710,67 @@ class TimewebCloud(TimewebCloudBase):
1690
1710
  f"{self.api_url}/firewall/service/{resource_type}/{resource_id}",
1691
1711
  params=params,
1692
1712
  )
1713
+
1714
+ # -----------------------------------------------------------------------
1715
+ # Floating IPs
1716
+
1717
+ def get_floating_ips(self):
1718
+ return self._request("GET", f"{self.api_url_v1}/floating-ips")
1719
+
1720
+ def get_floating_ip(self, floating_ip_id: str):
1721
+ return self._request(
1722
+ "GET", f"{self.api_url_v1}/floating-ips/{floating_ip_id}"
1723
+ )
1724
+
1725
+ def create_floating_ip(
1726
+ self,
1727
+ availability_zone: ServiceAvailabilityZone,
1728
+ ddos_protection: bool = False,
1729
+ ):
1730
+ payload = {
1731
+ "is_ddos_guard": ddos_protection,
1732
+ "availability_zone": str(availability_zone),
1733
+ }
1734
+ return self._request(
1735
+ "POST", f"{self.api_url_v1}/floating-ips", json=payload
1736
+ )
1737
+
1738
+ def update_floating_ip(
1739
+ self,
1740
+ floating_ip_id: str,
1741
+ comment: Optional[str] = None,
1742
+ ptr: Optional[str] = None,
1743
+ ):
1744
+ payload = {}
1745
+ if comment:
1746
+ payload["comment"] = comment
1747
+ if ptr:
1748
+ payload["ptr"] = ptr
1749
+ return self._request(
1750
+ "PATCH",
1751
+ f"{self.api_url_v1}/floating-ips/{floating_ip_id}",
1752
+ json=payload,
1753
+ )
1754
+
1755
+ def delete_floating_ip(self, floating_ip_id: str):
1756
+ return self._request(
1757
+ "DELETE", f"{self.api_url_v1}/floating-ips/{floating_ip_id}"
1758
+ )
1759
+
1760
+ def attach_floating_ip(
1761
+ self,
1762
+ floating_ip_id: str,
1763
+ resource_type: ResourceType,
1764
+ resource_id: str,
1765
+ ):
1766
+ payload = {"resource_type": resource_type, "resource_id": resource_id}
1767
+ return self._request(
1768
+ "POST",
1769
+ f"{self.api_url_v1}/floating-ips/{floating_ip_id}/bind",
1770
+ json=payload,
1771
+ )
1772
+
1773
+ def detach_floating_ip(self, floating_ip_id: str):
1774
+ return self._request(
1775
+ "POST", f"{self.api_url_v1}/floating-ips/{floating_ip_id}/unbind"
1776
+ )
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
@@ -181,6 +228,9 @@ class FirewallProto(str, Enum):
181
228
  TCP = "tcp"
182
229
  UDP = "udp"
183
230
  ICMP = "icmp"
231
+ TCP6 = "tcp6"
232
+ UDP6 = "udp6"
233
+ ICMP6 = "icmp6"
184
234
 
185
235
 
186
236
  class FirewallDirection(str, Enum):
@@ -188,3 +238,10 @@ class FirewallDirection(str, Enum):
188
238
 
189
239
  INGRESS = "ingress"
190
240
  EGRESS = "egress"
241
+
242
+
243
+ class FirewallPolicy(str, Enum):
244
+ """Firewall default policy."""
245
+
246
+ DROP = "DROP"
247
+ ACCEPT = "ACCEPT"
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
  )