twc-cli 1.1.0__tar.gz → 1.3.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-1.3.0/CHANGELOG.md +62 -0
- {twc_cli-1.1.0 → twc_cli-1.3.0}/PKG-INFO +2 -1
- {twc_cli-1.1.0 → twc_cli-1.3.0}/pyproject.toml +2 -1
- twc_cli-1.3.0/twc/__main__.py +96 -0
- {twc_cli-1.1.0 → twc_cli-1.3.0}/twc/__version__.py +1 -1
- {twc_cli-1.1.0 → twc_cli-1.3.0}/twc/api/client.py +319 -2
- twc_cli-1.3.0/twc/click_ext.py +34 -0
- {twc_cli-1.1.0 → twc_cli-1.3.0}/twc/commands/__init__.py +10 -43
- twc_cli-1.3.0/twc/commands/config.py +165 -0
- twc_cli-1.3.0/twc/commands/database.py +633 -0
- {twc_cli-1.1.0 → twc_cli-1.3.0}/twc/commands/image.py +2 -2
- {twc_cli-1.1.0 → twc_cli-1.3.0}/twc/commands/project.py +9 -0
- {twc_cli-1.1.0 → twc_cli-1.3.0}/twc/commands/server.py +89 -47
- twc_cli-1.3.0/twc/commands/storage.py +818 -0
- {twc_cli-1.1.0 → twc_cli-1.3.0}/twc/fmt.py +14 -1
- twc_cli-1.3.0/twc/utils.py +20 -0
- twc_cli-1.3.0/twc/vars.py +20 -0
- twc_cli-1.1.0/CHANGELOG.md +0 -29
- twc_cli-1.1.0/setup.py +0 -43
- twc_cli-1.1.0/twc/__main__.py +0 -25
- twc_cli-1.1.0/twc/commands/config.py +0 -46
- {twc_cli-1.1.0 → twc_cli-1.3.0}/COPYING +0 -0
- {twc_cli-1.1.0 → twc_cli-1.3.0}/README.md +0 -0
- {twc_cli-1.1.0 → twc_cli-1.3.0}/twc/__init__.py +0 -0
- {twc_cli-1.1.0 → twc_cli-1.3.0}/twc/api/__init__.py +0 -0
- {twc_cli-1.1.0 → twc_cli-1.3.0}/twc/api/exceptions.py +0 -0
- {twc_cli-1.1.0 → twc_cli-1.3.0}/twc/commands/account.py +0 -0
- {twc_cli-1.1.0 → twc_cli-1.3.0}/twc/commands/ssh_key.py +0 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Релизы Timeweb Cloud CLI
|
|
2
|
+
|
|
3
|
+
В этом файле описаны все значимые изменения в Timeweb Cloud CLI. В выпусках мы придерживается правил [семантического версионирования](https://semver.org/lang/ru/).
|
|
4
|
+
|
|
5
|
+
# Версия 1.3.0 (2023.04.13)
|
|
6
|
+
|
|
7
|
+
Добавлено:
|
|
8
|
+
|
|
9
|
+
- Добавлена команда `twc storage` (алиас `twc s3`) для управления бакетами в объектном хранилище Timeweb Cloud. Доступны базовые операции с бакетами. Также реализованы субкоманды для работы с доменами и пользователями хранилища. Обратите внимание, что в twc не планируется добавлять реализацию S3-клиента, пользуйтесь любым совместимым клиентом, например, [s3cmd](https://s3tools.org/s3cmd) или [rclone](https://rclone.org/), команда `twc s3 genconfig` позволит сгенерировать конфигурационный файл для этих утилит (см. документацию).
|
|
10
|
+
- Добавлены субкоманды для `twc config`. Теперь можно: сделать дамп настроек CLI (`twc config dump`), изменять настройки без ручного редактирования файла (`twc config set`), получить путь до файла (`twc config file`), открыть файл в редакторе по умолчанию (`twc config edit`).
|
|
11
|
+
|
|
12
|
+
Изменено:
|
|
13
|
+
|
|
14
|
+
- Изменены тексты ошибок при работе с конфигурационным файлом, теперь они более наглядные и явно указывают на проблему.
|
|
15
|
+
- Изменено поведение команды `twc config`. Теперь если уже иммется конфигурационный файл команда предложит добавить новый профиль или отредактировать существующий.
|
|
16
|
+
|
|
17
|
+
# Версия 1.2.0 (2023.04.03)
|
|
18
|
+
|
|
19
|
+
Добавлено:
|
|
20
|
+
|
|
21
|
+
- Добавлена команда `twc database` (алиас `twc db`). Теперь можно:
|
|
22
|
+
- создавать и удалять управляемые базы данных;
|
|
23
|
+
- изменять параметры базы данных;
|
|
24
|
+
- работать с бэкапами баз данных.
|
|
25
|
+
- Реализована возможность работы с токенами, которые требуют подтверждения удаления сервисов через проверочные коды.
|
|
26
|
+
- Добавлены алиасы для основных команд, набирать команды стало ещё быстрее. Например, `twc server list` можно сократить до `twc s ls`. Смотрите список алиасов во встроенной справке `--help`.
|
|
27
|
+
- Добавлены команды `twc server dash` и `twc server vnc`. С их помощью можно быстро перейти в дашборд и веб-консоль сервера соответсвенно. Ссылки откроются в браузере по умолчанию.
|
|
28
|
+
|
|
29
|
+
Изменено:
|
|
30
|
+
|
|
31
|
+
- Поменялся вывод команд `twc server list-presets` и `twc server backup list`, скрыты малоинформативные колонки.
|
|
32
|
+
- Мелкие улучшения в коде.
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Версия 1.1.0 (2023.03.24)
|
|
36
|
+
|
|
37
|
+
Добавлено:
|
|
38
|
+
|
|
39
|
+
- Добавлена команда `twc image` для управления образами дисков. Можно создавать образы из существующих дисков, загрузить свой образ по ссылке, удалять образы и выполнять другие базовые операции.
|
|
40
|
+
- Добавлена возможность использовать UUID пользовательского образа при создании нового сервера (`twc server create`) и при переустановке сервера (`twc server reinstall`).
|
|
41
|
+
- Добавлена команда `twc project` для управления проектами и ресурсами в них: создание, удаление, редактирование проектов, перемещение ресурсов между проектами.
|
|
42
|
+
- В команду `twc server create` добавлена опция `--project-id`, которая позволяет переместить только что созданный сервер в указанный проект. Задать проект можно также через переменную окружения `TWC_PROJECT` и конфигурационный файл.
|
|
43
|
+
- В команду `twc server reinstall` добавлена опция `--add-ssh-key`. Теперь опционально после переустановки на сервер будут добавлены SSH-ключи, которые были на нём ранее.
|
|
44
|
+
- Добавлена команда `twc server disk list-all`. Она покажет список всех дисков со всех серверов.
|
|
45
|
+
- Опция `--filter` в командах где она есть теперь поддерживает поиск по полям, содержащим значения в мегабайтах и гагибайтах.
|
|
46
|
+
|
|
47
|
+
Изменено:
|
|
48
|
+
|
|
49
|
+
- Команда `twc server reinstall` теперь не требует `--image` как обязательный аргумент. Если опция не задана будет выбрана ОС, которая уже установлена на сервере.
|
|
50
|
+
- В команде `twc server clone` теперь используется новый метод клонирования сервера. В ответ команда вернёт информацию о клоне сервера.
|
|
51
|
+
- В командах `twc server list` и `twc server logs` увеличен стандартный `--limit` до 500.
|
|
52
|
+
- В локации `pl-1` (Польша, Гданьск) теперь разрешено добавлять IPv6-адреса.
|
|
53
|
+
- В выводе `twc server list-presets` RAM и объём системного диска в табличном виде теперь отображаются в гигабайтах.
|
|
54
|
+
|
|
55
|
+
Исправлено:
|
|
56
|
+
|
|
57
|
+
- В выводе команды `twc server list-os-images` теперь правильно отображаем поле `REQUIREMENTS`.
|
|
58
|
+
- Исправлена ошибка из-за которой опция `--filter` не фильтровала по ключам, содержащим знак подчёркивания.
|
|
59
|
+
|
|
60
|
+
# Версия 1.0.0 (2023.02.27)
|
|
61
|
+
|
|
62
|
+
Первый релиз.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: twc-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
4
4
|
Summary: Timeweb Cloud Command Line Interface.
|
|
5
5
|
Home-page: https://github.com/timeweb-cloud/twc
|
|
6
6
|
License: MIT
|
|
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.11
|
|
16
16
|
Requires-Dist: click (>=8.1.3,<9.0.0)
|
|
17
17
|
Requires-Dist: click-aliases (>=1.0.1,<2.0.0)
|
|
18
|
+
Requires-Dist: click-default-group (>=1.2.2,<2.0.0)
|
|
18
19
|
Requires-Dist: pygments (>=2.14.0,<3.0.0)
|
|
19
20
|
Requires-Dist: pyyaml (>=6.0,<7.0)
|
|
20
21
|
Requires-Dist: requests (>=2.28.1,<3.0.0)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "twc-cli"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.3.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"
|
|
@@ -20,6 +20,7 @@ click = "^8.1.3"
|
|
|
20
20
|
click-aliases = "^1.0.1"
|
|
21
21
|
toml = "^0.10.2"
|
|
22
22
|
pyyaml = "^6.0"
|
|
23
|
+
click-default-group = "^1.2.2"
|
|
23
24
|
|
|
24
25
|
[tool.poetry.group.dev.dependencies]
|
|
25
26
|
black = "^22.12.0"
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Command Line Interface initial module."""
|
|
2
|
+
|
|
3
|
+
from gettext import gettext as _
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from .commands import options, GLOBAL_OPTIONS
|
|
8
|
+
from .commands.account import account
|
|
9
|
+
from .commands.config import config
|
|
10
|
+
from .commands.server import server
|
|
11
|
+
from .commands.ssh_key import ssh_key
|
|
12
|
+
from .commands.image import image
|
|
13
|
+
from .commands.project import project
|
|
14
|
+
from .commands.database import database
|
|
15
|
+
from .commands.storage import storage
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AliasedCmdGroup(click.Group):
|
|
19
|
+
"""Add aliases for Click commands. Needs a global variable
|
|
20
|
+
ALIASES with dict. Exmaple:
|
|
21
|
+
|
|
22
|
+
@click.group(cls=AliasedCmdGroup)
|
|
23
|
+
def cli():
|
|
24
|
+
'''CLI.'''
|
|
25
|
+
|
|
26
|
+
cli.add_command(my_cmd)
|
|
27
|
+
ALIASES = {"my-cmd-alias": my_cmd}
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def get_command(self, ctx, cmd_name):
|
|
31
|
+
"""Add aliased commands to parser."""
|
|
32
|
+
try:
|
|
33
|
+
cmd_name = ALIASES[cmd_name].name
|
|
34
|
+
except KeyError:
|
|
35
|
+
pass
|
|
36
|
+
return super().get_command(ctx, cmd_name)
|
|
37
|
+
|
|
38
|
+
def format_commands(self, ctx, formatter) -> None:
|
|
39
|
+
commands = []
|
|
40
|
+
for subcommand in self.list_commands(ctx):
|
|
41
|
+
cmd = self.get_command(ctx, subcommand)
|
|
42
|
+
if cmd is None:
|
|
43
|
+
continue
|
|
44
|
+
if cmd.hidden:
|
|
45
|
+
continue
|
|
46
|
+
commands.append((subcommand, cmd))
|
|
47
|
+
|
|
48
|
+
if commands:
|
|
49
|
+
limit = formatter.width - 6 - max(len(cmd[0]) for cmd in commands)
|
|
50
|
+
rows = []
|
|
51
|
+
for subcommand, cmd in commands:
|
|
52
|
+
# Get command alias
|
|
53
|
+
alias = ""
|
|
54
|
+
for a in list(ALIASES.keys()):
|
|
55
|
+
if ALIASES[a].name == cmd.name:
|
|
56
|
+
alias = f" ({a})"
|
|
57
|
+
help_text = cmd.get_short_help_str(limit)
|
|
58
|
+
rows.append((subcommand + alias, help_text))
|
|
59
|
+
if rows:
|
|
60
|
+
with formatter.section(_("Commands")):
|
|
61
|
+
formatter.write_dl(rows)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@click.group(cls=AliasedCmdGroup)
|
|
65
|
+
@options(GLOBAL_OPTIONS[:2])
|
|
66
|
+
def cli():
|
|
67
|
+
"""Timeweb Cloud Command Line Interface."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
cli.add_command(account)
|
|
71
|
+
cli.add_command(config)
|
|
72
|
+
cli.add_command(server)
|
|
73
|
+
cli.add_command(ssh_key)
|
|
74
|
+
cli.add_command(image)
|
|
75
|
+
cli.add_command(project)
|
|
76
|
+
cli.add_command(database)
|
|
77
|
+
cli.add_command(storage)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# -- Aliases list for root level commands. --
|
|
81
|
+
# If there are several aliases, only the last one will
|
|
82
|
+
# be printed in the help text.
|
|
83
|
+
ALIASES = {
|
|
84
|
+
"servers": server,
|
|
85
|
+
"s": server,
|
|
86
|
+
"keys": ssh_key,
|
|
87
|
+
"k": ssh_key,
|
|
88
|
+
"images": image,
|
|
89
|
+
"i": image,
|
|
90
|
+
"projects": project,
|
|
91
|
+
"p": project,
|
|
92
|
+
"dbs": database,
|
|
93
|
+
"db": database,
|
|
94
|
+
"storages": storage,
|
|
95
|
+
"s3": storage,
|
|
96
|
+
}
|
|
@@ -32,14 +32,15 @@ def raise_exceptions(func):
|
|
|
32
32
|
except AttributeError:
|
|
33
33
|
is_json = False
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
# Remove 201 to avoid API bug (201 with no body)
|
|
36
|
+
if status_code in [200, 400, 403, 404, 409, 429, 500]:
|
|
36
37
|
if is_json:
|
|
37
38
|
return response # Success
|
|
38
39
|
raise NonJSONResponseError(
|
|
39
40
|
f"Code: {status_code}, Response body: {response.text}"
|
|
40
41
|
)
|
|
41
42
|
|
|
42
|
-
if status_code
|
|
43
|
+
if status_code in [201, 204]:
|
|
43
44
|
return response # Success
|
|
44
45
|
|
|
45
46
|
if status_code == 401:
|
|
@@ -850,3 +851,319 @@ class TimewebCloud(metaclass=TimewebCloudMeta):
|
|
|
850
851
|
"""List dedicated servers in project by project_id."""
|
|
851
852
|
url = f"{self.api_url}/projects/{project_id}/resources/databases"
|
|
852
853
|
return requests.get(url, headers=self.headers, timeout=self.timeout)
|
|
854
|
+
|
|
855
|
+
# -----------------------------------------------------------------------
|
|
856
|
+
# Databases
|
|
857
|
+
|
|
858
|
+
def get_databases(self, limit: int = 100, offset: int = 0):
|
|
859
|
+
"""Get databases list."""
|
|
860
|
+
url = f"{self.api_url}/dbs"
|
|
861
|
+
return requests.get(
|
|
862
|
+
url,
|
|
863
|
+
headers=self.headers,
|
|
864
|
+
timeout=self.timeout,
|
|
865
|
+
params={"limit": limit, "offset": offset},
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
def get_database(self, db_id: int):
|
|
869
|
+
"""Get database."""
|
|
870
|
+
url = f"{self.api_url}/dbs/{db_id}"
|
|
871
|
+
return requests.get(url, headers=self.headers, timeout=self.timeout)
|
|
872
|
+
|
|
873
|
+
def get_database_presets(self):
|
|
874
|
+
"""Get database presets list."""
|
|
875
|
+
url = f"{self.api_url}/presets/dbs"
|
|
876
|
+
return requests.get(url, headers=self.headers, timeout=self.timeout)
|
|
877
|
+
|
|
878
|
+
def create_database(
|
|
879
|
+
self,
|
|
880
|
+
name: str = None,
|
|
881
|
+
dbms: str = None,
|
|
882
|
+
login: str = None,
|
|
883
|
+
password: str = None,
|
|
884
|
+
hash_type: str = None,
|
|
885
|
+
preset_id: int = None,
|
|
886
|
+
config_parameters: dict = None,
|
|
887
|
+
):
|
|
888
|
+
"""Create database."""
|
|
889
|
+
url = f"{self.api_url}/dbs"
|
|
890
|
+
self.headers.update({"Content-Type": "application/json"})
|
|
891
|
+
payload = {
|
|
892
|
+
"name": name,
|
|
893
|
+
"type": dbms,
|
|
894
|
+
"login": login,
|
|
895
|
+
"password": password,
|
|
896
|
+
"hash_type": hash_type,
|
|
897
|
+
"preset_id": preset_id,
|
|
898
|
+
"config_parameters": config_parameters,
|
|
899
|
+
}
|
|
900
|
+
return requests.post(
|
|
901
|
+
url,
|
|
902
|
+
headers=self.headers,
|
|
903
|
+
timeout=self.timeout,
|
|
904
|
+
data=json.dumps(payload),
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
def update_database(
|
|
908
|
+
self,
|
|
909
|
+
db_id: int,
|
|
910
|
+
name: str = None,
|
|
911
|
+
password: str = None,
|
|
912
|
+
preset_id: int = None,
|
|
913
|
+
config_parameters: dict = None,
|
|
914
|
+
external_ip: bool = None,
|
|
915
|
+
):
|
|
916
|
+
"""Update database."""
|
|
917
|
+
url = f"{self.api_url}/dbs/{db_id}"
|
|
918
|
+
self.headers.update({"Content-Type": "application/json"})
|
|
919
|
+
payload = {}
|
|
920
|
+
if name:
|
|
921
|
+
payload["name"] = name
|
|
922
|
+
if password:
|
|
923
|
+
payload["password"] = password
|
|
924
|
+
if preset_id:
|
|
925
|
+
payload["preset_id"] = preset_id
|
|
926
|
+
if config_parameters:
|
|
927
|
+
payload["config_parameters"] = config_parameters
|
|
928
|
+
if external_ip:
|
|
929
|
+
payload["is_external_ip"] = external_ip
|
|
930
|
+
return requests.patch(
|
|
931
|
+
url,
|
|
932
|
+
headers=self.headers,
|
|
933
|
+
timeout=self.timeout,
|
|
934
|
+
data=json.dumps(payload),
|
|
935
|
+
)
|
|
936
|
+
|
|
937
|
+
def delete_database(
|
|
938
|
+
self, db_id: int, delete_hash: str = None, code: int = None
|
|
939
|
+
):
|
|
940
|
+
"""Delete database."""
|
|
941
|
+
url = f"{self.api_url}/dbs/{db_id}"
|
|
942
|
+
params = {}
|
|
943
|
+
if delete_hash:
|
|
944
|
+
params["hash"] = delete_hash
|
|
945
|
+
if code:
|
|
946
|
+
params["code"] = code
|
|
947
|
+
return requests.delete(
|
|
948
|
+
url, headers=self.headers, timeout=self.timeout, params=params
|
|
949
|
+
)
|
|
950
|
+
|
|
951
|
+
def get_database_backups(
|
|
952
|
+
self, db_id: int, limit: int = 100, offset: int = 0
|
|
953
|
+
):
|
|
954
|
+
"""List database backups."""
|
|
955
|
+
url = f"{self.api_url}/dbs/{db_id}/backups"
|
|
956
|
+
return requests.get(
|
|
957
|
+
url,
|
|
958
|
+
headers=self.headers,
|
|
959
|
+
timeout=self.timeout,
|
|
960
|
+
params={"limit": limit, "offset": offset},
|
|
961
|
+
)
|
|
962
|
+
|
|
963
|
+
def get_database_backup(self, db_id: int, backup_id: int):
|
|
964
|
+
"""Get database backup."""
|
|
965
|
+
url = f"{self.api_url}/dbs/{db_id}/backups/{backup_id}"
|
|
966
|
+
return requests.get(url, headers=self.headers, timeout=self.timeout)
|
|
967
|
+
|
|
968
|
+
def create_database_backup(self, db_id: int):
|
|
969
|
+
"""Create database backup."""
|
|
970
|
+
url = f"{self.api_url}/dbs/{db_id}/backups"
|
|
971
|
+
self.headers.update({"Content-Type": "application/json"})
|
|
972
|
+
payload = {}
|
|
973
|
+
return requests.post(
|
|
974
|
+
url,
|
|
975
|
+
headers=self.headers,
|
|
976
|
+
timeout=self.timeout,
|
|
977
|
+
data=json.dumps(payload),
|
|
978
|
+
)
|
|
979
|
+
|
|
980
|
+
def delete_database_backup(self, db_id: int, backup_id: int):
|
|
981
|
+
"""Delete database backup."""
|
|
982
|
+
url = f"{self.api_url}/dbs/{db_id}/backups/{backup_id}"
|
|
983
|
+
return requests.delete(url, headers=self.headers, timeout=self.timeout)
|
|
984
|
+
|
|
985
|
+
def restore_database_backup(self, db_id: int, backup_id: int):
|
|
986
|
+
"""Restore database backup."""
|
|
987
|
+
url = f"{self.api_url}/dbs/{db_id}/backups/{backup_id}"
|
|
988
|
+
return requests.put(url, headers=self.headers, timeout=self.timeout)
|
|
989
|
+
|
|
990
|
+
# -----------------------------------------------------------------------
|
|
991
|
+
# Object Storage
|
|
992
|
+
|
|
993
|
+
def get_storage_presets(self):
|
|
994
|
+
"""Get storage presets list."""
|
|
995
|
+
url = f"{self.api_url}/presets/storages"
|
|
996
|
+
return requests.get(url, headers=self.headers, timeout=self.timeout)
|
|
997
|
+
|
|
998
|
+
def get_buckets(self):
|
|
999
|
+
"""Get buckets list."""
|
|
1000
|
+
url = f"{self.api_url}/storages/buckets"
|
|
1001
|
+
return requests.get(url, headers=self.headers, timeout=self.timeout)
|
|
1002
|
+
|
|
1003
|
+
def create_bucket(
|
|
1004
|
+
self, name: str = None, preset_id: int = None, is_public: bool = False
|
|
1005
|
+
):
|
|
1006
|
+
"""Create storage bucket."""
|
|
1007
|
+
url = f"{self.api_url}/storages/buckets"
|
|
1008
|
+
self.headers.update({"Content-Type": "application/json"})
|
|
1009
|
+
if is_public:
|
|
1010
|
+
bucket_type = "public"
|
|
1011
|
+
else:
|
|
1012
|
+
bucket_type = "private"
|
|
1013
|
+
payload = {
|
|
1014
|
+
"name": name,
|
|
1015
|
+
"type": bucket_type,
|
|
1016
|
+
"preset_id": preset_id,
|
|
1017
|
+
}
|
|
1018
|
+
return requests.post(
|
|
1019
|
+
url,
|
|
1020
|
+
headers=self.headers,
|
|
1021
|
+
timeout=self.timeout,
|
|
1022
|
+
data=json.dumps(payload),
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
def delete_bucket(
|
|
1026
|
+
self, bucket_id: int, delete_hash: str = None, code: int = None
|
|
1027
|
+
):
|
|
1028
|
+
"""Delete storage bucket."""
|
|
1029
|
+
url = f"{self.api_url}/storages/buckets/{bucket_id}"
|
|
1030
|
+
params = {}
|
|
1031
|
+
if delete_hash:
|
|
1032
|
+
params["hash"] = delete_hash
|
|
1033
|
+
if code:
|
|
1034
|
+
params["code"] = code
|
|
1035
|
+
return requests.delete(
|
|
1036
|
+
url, headers=self.headers, timeout=self.timeout, params=params
|
|
1037
|
+
)
|
|
1038
|
+
|
|
1039
|
+
def update_bucket(
|
|
1040
|
+
self, bucket_id: int, preset_id: int = None, is_public: bool = None
|
|
1041
|
+
):
|
|
1042
|
+
"""Update storage bucket."""
|
|
1043
|
+
url = f"{self.api_url}/storages/buckets/{bucket_id}"
|
|
1044
|
+
self.headers.update({"Content-Type": "application/json"})
|
|
1045
|
+
payload = {}
|
|
1046
|
+
if is_public is None:
|
|
1047
|
+
pass
|
|
1048
|
+
elif is_public is False:
|
|
1049
|
+
payload["bucket_type"] = "private"
|
|
1050
|
+
elif is_public is True:
|
|
1051
|
+
payload["bucket_type"] = "public"
|
|
1052
|
+
if preset_id:
|
|
1053
|
+
payload["preset_id"] = preset_id
|
|
1054
|
+
return requests.patch(
|
|
1055
|
+
url,
|
|
1056
|
+
headers=self.headers,
|
|
1057
|
+
timeout=self.timeout,
|
|
1058
|
+
data=json.dumps(payload),
|
|
1059
|
+
)
|
|
1060
|
+
|
|
1061
|
+
def get_storage_users(self):
|
|
1062
|
+
"""Get storage users list."""
|
|
1063
|
+
url = f"{self.api_url}/storages/users"
|
|
1064
|
+
return requests.get(url, headers=self.headers, timeout=self.timeout)
|
|
1065
|
+
|
|
1066
|
+
def update_storage_user_secret(
|
|
1067
|
+
self, user_id: int = None, secret_key: str = None
|
|
1068
|
+
):
|
|
1069
|
+
"""Update storage user secret key."""
|
|
1070
|
+
url = f"{self.api_url}/storages/users/{user_id}"
|
|
1071
|
+
self.headers.update({"Content-Type": "application/json"})
|
|
1072
|
+
payload = {"secret_key": secret_key}
|
|
1073
|
+
return requests.patch(
|
|
1074
|
+
url,
|
|
1075
|
+
headers=self.headers,
|
|
1076
|
+
timeout=self.timeout,
|
|
1077
|
+
data=json.dumps(payload),
|
|
1078
|
+
)
|
|
1079
|
+
|
|
1080
|
+
def get_storage_transfer_status(self, bucket_id: int = None):
|
|
1081
|
+
"""Get storage transfer status."""
|
|
1082
|
+
url = f"{self.api_url}/storages/buckets/{bucket_id}/transfer-status"
|
|
1083
|
+
return requests.get(url, headers=self.headers, timeout=self.timeout)
|
|
1084
|
+
|
|
1085
|
+
def start_storage_transfer(
|
|
1086
|
+
self,
|
|
1087
|
+
src_bucket: str = None,
|
|
1088
|
+
dst_bucket: str = None,
|
|
1089
|
+
access_key: str = None,
|
|
1090
|
+
secret_key: str = None,
|
|
1091
|
+
location: str = None,
|
|
1092
|
+
endpoint: str = None,
|
|
1093
|
+
force_path_style: bool = False,
|
|
1094
|
+
):
|
|
1095
|
+
"""Start file transfer from any S3-compatible storage to Timeweb Cloud
|
|
1096
|
+
Object Storage.
|
|
1097
|
+
"""
|
|
1098
|
+
url = f"{self.api_url}/storages/transfer"
|
|
1099
|
+
self.headers.update({"Content-Type": "application/json"})
|
|
1100
|
+
payload = {
|
|
1101
|
+
"access_key": access_key,
|
|
1102
|
+
"secret_key": secret_key,
|
|
1103
|
+
"location": location,
|
|
1104
|
+
"is_force_path_style": force_path_style,
|
|
1105
|
+
"endpoint": endpoint,
|
|
1106
|
+
"bucket_name": src_bucket,
|
|
1107
|
+
"new_bucket_name": dst_bucket,
|
|
1108
|
+
}
|
|
1109
|
+
return requests.post(
|
|
1110
|
+
url,
|
|
1111
|
+
headers=self.headers,
|
|
1112
|
+
timeout=self.timeout,
|
|
1113
|
+
data=json.dumps(payload),
|
|
1114
|
+
)
|
|
1115
|
+
|
|
1116
|
+
def get_bucket_subdomains(self, bucket_id: int = None):
|
|
1117
|
+
"""Get bucket subdomains list."""
|
|
1118
|
+
url = f"{self.api_url}/storages/buckets/{bucket_id}/subdomains"
|
|
1119
|
+
return requests.get(url, headers=self.headers, timeout=self.timeout)
|
|
1120
|
+
|
|
1121
|
+
def add_bucket_subdomains(
|
|
1122
|
+
self,
|
|
1123
|
+
bucket_id: int = None,
|
|
1124
|
+
subdomains: list = None,
|
|
1125
|
+
):
|
|
1126
|
+
"""Add subdomains to bucket."""
|
|
1127
|
+
url = f"{self.api_url}/storages/buckets/{bucket_id}/subdomains"
|
|
1128
|
+
self.headers.update({"Content-Type": "application/json"})
|
|
1129
|
+
payload = {
|
|
1130
|
+
"subdomains": subdomains,
|
|
1131
|
+
}
|
|
1132
|
+
return requests.post(
|
|
1133
|
+
url,
|
|
1134
|
+
headers=self.headers,
|
|
1135
|
+
timeout=self.timeout,
|
|
1136
|
+
data=json.dumps(payload),
|
|
1137
|
+
)
|
|
1138
|
+
|
|
1139
|
+
def delete_bucket_subdomains(
|
|
1140
|
+
self,
|
|
1141
|
+
bucket_id: int = None,
|
|
1142
|
+
subdomains: list = None,
|
|
1143
|
+
):
|
|
1144
|
+
"""Delete bucket subdomains."""
|
|
1145
|
+
url = f"{self.api_url}/storages/buckets/{bucket_id}/subdomains"
|
|
1146
|
+
self.headers.update({"Content-Type": "application/json"})
|
|
1147
|
+
payload = {
|
|
1148
|
+
"subdomains": subdomains,
|
|
1149
|
+
}
|
|
1150
|
+
return requests.delete(
|
|
1151
|
+
url,
|
|
1152
|
+
headers=self.headers,
|
|
1153
|
+
timeout=self.timeout,
|
|
1154
|
+
data=json.dumps(payload),
|
|
1155
|
+
)
|
|
1156
|
+
|
|
1157
|
+
def gen_cert_for_bucket_subdomain(self, subdomain: str = None):
|
|
1158
|
+
"""Generate TLS certificate for subdomain attached to bucket."""
|
|
1159
|
+
url = f"{self.api_url}/storages/certificates/generate"
|
|
1160
|
+
self.headers.update({"Content-Type": "application/json"})
|
|
1161
|
+
payload = {
|
|
1162
|
+
"subdomain": subdomain,
|
|
1163
|
+
}
|
|
1164
|
+
return requests.post(
|
|
1165
|
+
url,
|
|
1166
|
+
headers=self.headers,
|
|
1167
|
+
timeout=self.timeout,
|
|
1168
|
+
data=json.dumps(payload),
|
|
1169
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Click extensions."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MutuallyExclusiveOption(click.Option):
|
|
7
|
+
"""Add mutually exclusive options support for Click. Example::
|
|
8
|
+
|
|
9
|
+
@click.option(
|
|
10
|
+
"--dry",
|
|
11
|
+
is_flag=True,
|
|
12
|
+
cls=MutuallyExclusiveOption,
|
|
13
|
+
mutually_exclusive=["wet"],
|
|
14
|
+
)
|
|
15
|
+
@click.option("--wet", is_flag=True)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, *args, **kwargs):
|
|
19
|
+
self.mutually_exclusive = set(kwargs.pop("mutually_exclusive", []))
|
|
20
|
+
help = kwargs.get("help", "") # pylint: disable=redefined-builtin
|
|
21
|
+
if self.mutually_exclusive:
|
|
22
|
+
kwargs["help"] = help + (
|
|
23
|
+
" NOTE: This argument is mutually exclusive with "
|
|
24
|
+
f"arguments: [{', '.join(self.mutually_exclusive)}]."
|
|
25
|
+
)
|
|
26
|
+
super().__init__(*args, **kwargs)
|
|
27
|
+
|
|
28
|
+
def handle_parse_result(self, ctx, opts, args):
|
|
29
|
+
if self.mutually_exclusive.intersection(opts) and self.name in opts:
|
|
30
|
+
raise click.UsageError(
|
|
31
|
+
f"Illegal usage: '{self.name}' is mutually exclusive with "
|
|
32
|
+
f"arguments: [{', '.join(self.mutually_exclusive)}]."
|
|
33
|
+
)
|
|
34
|
+
return super().handle_parse_result(ctx, opts, args)
|
|
@@ -24,14 +24,6 @@ from twc.api import (
|
|
|
24
24
|
# Configuration
|
|
25
25
|
|
|
26
26
|
|
|
27
|
-
USER_AGENT = f"TWC-CLI/{__version__} Python {__pyversion__}"
|
|
28
|
-
DEFAULT_CONFIG = {"default": {"token": ""}}
|
|
29
|
-
DEFAULT_CONFIGURATOR_ID = 11
|
|
30
|
-
REGIONS_WITH_CONFIGURATOR = ["ru-1"]
|
|
31
|
-
REGIONS_WITH_IPV6 = ["ru-1", "pl-1"]
|
|
32
|
-
REGIONS_WITH_IMAGES = ["ru-1"]
|
|
33
|
-
|
|
34
|
-
|
|
35
27
|
def get_default_config_file() -> str:
|
|
36
28
|
if os.name == "nt":
|
|
37
29
|
env_home = "USERPROFILE"
|
|
@@ -45,10 +37,14 @@ def load_config(filepath: str = get_default_config_file()):
|
|
|
45
37
|
try:
|
|
46
38
|
with open(filepath, "r", encoding="utf-8") as file:
|
|
47
39
|
return toml.load(file)
|
|
48
|
-
except
|
|
49
|
-
sys.exit(
|
|
40
|
+
except FileNotFoundError:
|
|
41
|
+
sys.exit(
|
|
42
|
+
f"Configuration file {filepath} not found. Try run 'twc config'"
|
|
43
|
+
)
|
|
44
|
+
except OSError as error:
|
|
45
|
+
sys.exit(f"Error: Cannot read configuration file {filepath}: {error}")
|
|
50
46
|
except toml.TomlDecodeError as error:
|
|
51
|
-
sys.exit(f"Error: {filepath}: {error}")
|
|
47
|
+
sys.exit(f"Error: Check your TOML syntax in file {filepath}: {error}")
|
|
52
48
|
|
|
53
49
|
|
|
54
50
|
def set_value_from_config(ctx, param, value):
|
|
@@ -196,36 +192,6 @@ class MutuallyExclusiveOption(click.Option):
|
|
|
196
192
|
return super().handle_parse_result(ctx, opts, args)
|
|
197
193
|
|
|
198
194
|
|
|
199
|
-
def confirm_action(question: str, default: str = "no"):
|
|
200
|
-
"""Ask a yes/no question via input() and return their answer.
|
|
201
|
-
|
|
202
|
-
The "answer" return value is True for "yes" or False for "no".
|
|
203
|
-
"""
|
|
204
|
-
valid = {
|
|
205
|
-
"yes": True,
|
|
206
|
-
"y": True,
|
|
207
|
-
"ye": True,
|
|
208
|
-
"no": False,
|
|
209
|
-
"n": False,
|
|
210
|
-
}
|
|
211
|
-
if default is None:
|
|
212
|
-
prompt = "[y/n]"
|
|
213
|
-
elif default == "yes":
|
|
214
|
-
prompt = "[Y/n]"
|
|
215
|
-
elif default == "no":
|
|
216
|
-
prompt = "[y/N]"
|
|
217
|
-
else:
|
|
218
|
-
raise ValueError(f"Invalid default answer: '{default}'")
|
|
219
|
-
|
|
220
|
-
while True:
|
|
221
|
-
choice = input(f"{question} {prompt}: ").lower()
|
|
222
|
-
if default is not None and choice == "":
|
|
223
|
-
return valid[default]
|
|
224
|
-
if choice in valid:
|
|
225
|
-
return valid[choice]
|
|
226
|
-
sys.exit("Please respond with 'yes' or 'no' (or 'y' or 'n').")
|
|
227
|
-
|
|
228
|
-
|
|
229
195
|
# -----------------------------------------------------------------------
|
|
230
196
|
# API interaction
|
|
231
197
|
|
|
@@ -274,14 +240,15 @@ def create_client(config, profile):
|
|
|
274
240
|
debug(f"Args: {sys.argv[1:]}")
|
|
275
241
|
|
|
276
242
|
env_token = os.getenv("TWC_TOKEN")
|
|
243
|
+
user_agent = f"TWC-CLI/{__version__} Python {__pyversion__}"
|
|
277
244
|
|
|
278
245
|
if env_token:
|
|
279
246
|
debug("Get Timeweb Cloud token from environment variable")
|
|
280
|
-
return TimewebCloud(env_token, user_agent=
|
|
247
|
+
return TimewebCloud(env_token, user_agent=user_agent)
|
|
281
248
|
|
|
282
249
|
try:
|
|
283
250
|
debug(f"Configuration: config file={config}; profile={profile}")
|
|
284
251
|
token = load_config(config)[profile]["token"]
|
|
285
|
-
return TimewebCloud(token, user_agent=
|
|
252
|
+
return TimewebCloud(token, user_agent=user_agent)
|
|
286
253
|
except KeyError:
|
|
287
254
|
sys.exit(f"Profile '{profile}' not found in {config}")
|