twc-cli 2.4.0__tar.gz → 2.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of twc-cli might be problematic. Click here for more details.
- {twc_cli-2.4.0 → twc_cli-2.5.0}/CHANGELOG.md +28 -0
- {twc_cli-2.4.0 → twc_cli-2.5.0}/PKG-INFO +9 -9
- {twc_cli-2.4.0 → twc_cli-2.5.0}/README.md +1 -1
- {twc_cli-2.4.0 → twc_cli-2.5.0}/pyproject.toml +9 -9
- {twc_cli-2.4.0 → twc_cli-2.5.0}/twc/__main__.py +2 -0
- {twc_cli-2.4.0 → twc_cli-2.5.0}/twc/__version__.py +1 -1
- {twc_cli-2.4.0 → twc_cli-2.5.0}/twc/api/base.py +5 -8
- {twc_cli-2.4.0 → twc_cli-2.5.0}/twc/api/client.py +80 -3
- {twc_cli-2.4.0 → twc_cli-2.5.0}/twc/api/types.py +54 -7
- {twc_cli-2.4.0 → twc_cli-2.5.0}/twc/apiwrap.py +2 -2
- {twc_cli-2.4.0 → twc_cli-2.5.0}/twc/commands/__init__.py +1 -0
- {twc_cli-2.4.0 → twc_cli-2.5.0}/twc/commands/common.py +18 -2
- {twc_cli-2.4.0 → twc_cli-2.5.0}/twc/commands/domain.py +1 -1
- {twc_cli-2.4.0 → twc_cli-2.5.0}/twc/commands/firewall.py +4 -4
- twc_cli-2.5.0/twc/commands/floating_ip.py +296 -0
- {twc_cli-2.4.0 → twc_cli-2.5.0}/twc/commands/image.py +10 -7
- {twc_cli-2.4.0 → twc_cli-2.5.0}/twc/commands/project.py +1 -2
- {twc_cli-2.4.0 → twc_cli-2.5.0}/twc/commands/server.py +75 -21
- {twc_cli-2.4.0 → twc_cli-2.5.0}/twc/commands/storage.py +0 -1
- {twc_cli-2.4.0 → twc_cli-2.5.0}/twc/commands/vpc.py +23 -9
- {twc_cli-2.4.0 → twc_cli-2.5.0}/twc/fmt.py +1 -1
- {twc_cli-2.4.0 → twc_cli-2.5.0}/twc/vars.py +5 -5
- {twc_cli-2.4.0 → twc_cli-2.5.0}/COPYING +0 -0
- {twc_cli-2.4.0 → twc_cli-2.5.0}/twc/__init__.py +0 -0
- {twc_cli-2.4.0 → twc_cli-2.5.0}/twc/api/__init__.py +0 -0
- {twc_cli-2.4.0 → twc_cli-2.5.0}/twc/api/exceptions.py +0 -0
- {twc_cli-2.4.0 → twc_cli-2.5.0}/twc/commands/account.py +0 -0
- {twc_cli-2.4.0 → twc_cli-2.5.0}/twc/commands/balancer.py +0 -0
- {twc_cli-2.4.0 → twc_cli-2.5.0}/twc/commands/config.py +0 -0
- {twc_cli-2.4.0 → twc_cli-2.5.0}/twc/commands/database.py +0 -0
- {twc_cli-2.4.0 → twc_cli-2.5.0}/twc/commands/kubernetes.py +0 -0
- {twc_cli-2.4.0 → twc_cli-2.5.0}/twc/commands/ssh_key.py +0 -0
- {twc_cli-2.4.0 → twc_cli-2.5.0}/twc/typerx.py +0 -0
- {twc_cli-2.4.0 → twc_cli-2.5.0}/twc/utils.py +0 -0
|
@@ -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
|
## Добавлено
|
|
@@ -1,29 +1,29 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: twc-cli
|
|
3
|
-
Version: 2.
|
|
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.
|
|
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.
|
|
18
|
-
Requires-Dist: pyyaml (>=6.0,<7.0)
|
|
19
|
-
Requires-Dist: requests (>=2.
|
|
20
|
-
Requires-Dist: shellingham (>=1.5.
|
|
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.
|
|
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
|
-

|
|
27
27
|
|
|
28
28
|
Timeweb Cloud Command Line Interface and simple SDK 💫
|
|
29
29
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "twc-cli"
|
|
3
|
-
version = "2.
|
|
3
|
+
version = "2.5.0"
|
|
4
4
|
description = "Timeweb Cloud Command Line Interface."
|
|
5
5
|
authors = ["ge <dev@timeweb.cloud>"]
|
|
6
6
|
homepage = "https://github.com/timeweb-cloud/twc"
|
|
@@ -11,18 +11,18 @@ include = ["CHANGELOG.md", "COPYING"]
|
|
|
11
11
|
packages = [{ include = "twc", from = "." }]
|
|
12
12
|
|
|
13
13
|
[tool.poetry.dependencies]
|
|
14
|
-
python = "^3.
|
|
15
|
-
requests = "^2.
|
|
16
|
-
typer = "^0.
|
|
17
|
-
shellingham = "^1.5.
|
|
14
|
+
python = "^3.8.19"
|
|
15
|
+
requests = "^2.32.3"
|
|
16
|
+
typer = "^0.12.3"
|
|
17
|
+
shellingham = "^1.5.4"
|
|
18
18
|
colorama = "^0.4.6"
|
|
19
19
|
toml = "^0.10.2"
|
|
20
|
-
pyyaml = "^6.0"
|
|
21
|
-
pygments = "^2.
|
|
20
|
+
pyyaml = "^6.0.1"
|
|
21
|
+
pygments = "^2.18.0"
|
|
22
22
|
|
|
23
23
|
[tool.poetry.group.dev.dependencies]
|
|
24
|
-
black = "^
|
|
25
|
-
pylint = "^2.
|
|
24
|
+
black = "^24.4.2"
|
|
25
|
+
pylint = "^3.2.5"
|
|
26
26
|
|
|
27
27
|
[tool.poetry.scripts]
|
|
28
28
|
twc = "twc.__main__:cli"
|
|
@@ -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")
|
|
@@ -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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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=
|
|
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
|
|
@@ -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
|
-
#
|
|
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
|
-
"
|
|
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
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Custom data types for Timeweb Cloud API entities."""
|
|
2
2
|
|
|
3
|
-
from typing import
|
|
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
|
-
|
|
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(
|
|
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
|
|
71
|
-
|
|
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
|
|
@@ -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 == "
|
|
77
|
-
|
|
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")
|
|
@@ -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="
|
|
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
|
)
|
|
@@ -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(".")[
|
|
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
|
|
@@ -453,7 +453,7 @@ def filrewall_rule_list(
|
|
|
453
453
|
|
|
454
454
|
|
|
455
455
|
# ------------------------------------------------------------- #
|
|
456
|
-
# $ twc firewall rule
|
|
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
|
|
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
|
-
"""
|
|
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
|
+
)
|
|
@@ -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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
|
@@ -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(
|
|
301
|
+
print(bucket_id)
|
|
303
302
|
else:
|
|
304
303
|
sys.exit(fmt.printer(response))
|
|
305
304
|
if cluster:
|
|
@@ -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="
|
|
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"]
|
|
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
|
-
"""
|
|
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
|
# ------------------------------------------------------------- #
|
|
@@ -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 = [
|
|
35
|
-
|
|
36
|
-
|
|
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="
|
|
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
|
|
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,
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|