twc-cli 2.5.0__py3-none-any.whl → 2.7.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,45 @@
2
2
 
3
3
  В этом файле описаны все значимые изменения в Timeweb Cloud CLI. В выпусках мы придерживается правил [семантического версионирования](https://semver.org/lang/ru/).
4
4
 
5
+ # Версия 2.7.0 (2024.11.02)
6
+
7
+ ## Добавлено
8
+
9
+ - Добавлена новая команда `twc whoami`, которая показывает логин аккаунта, в котором в текущий момент авторизован CLI.
10
+
11
+ ## Изменено
12
+
13
+ - Команда `twc account status` теперь также отображает логин аккаунта.
14
+
15
+ ## Исправлено
16
+
17
+ - Устранено падение программы при листинге образов (`twc image list`) и при создании сервера из образа.
18
+ - Исправлена ошибка при создании сервера с приватной сетью в зоне доступности SPB-3.
19
+ - Исправлены ошибки в командах `twc storage subdomain gencert` и `twc storage subdomain remove`, возникшие из-за нарушения обратной совместимости эндпоинтов API.
20
+ - Исправлена ошибка валидации приватного адреса при созаднии сервера — ошибочно запрещалось использовать 4-й по порядку адрес в подсети.
21
+ - Исправлена ошибка разбора JSON при выводе списка ресурсов в проекте.
22
+ - Исправлены ошибки добавления TXT записи для домена в команде `twc domain record add`.
23
+ - Исправлена ошибка парсинка субдомена в команде `twc domain record add`.
24
+ - Исправлены ошибки обновления DNS-записей на поддоменах.
25
+
26
+ # Версия 2.6.0 (2024.08.14)
27
+
28
+ ## Добавлено
29
+
30
+ - В команды и API-клиент для управления облачным файрволом добавлена поддержка протоколов TCP6, UDP6, ICMP6 и настройка стандартной политики (DROP или ACCEPT).
31
+ - Добавлены новые команды: `twc firewall group get`, `twc firewall group dump` и `twc firewall group restore`.
32
+
33
+ ## Изменено
34
+
35
+ - Улучшена валидация параметров и подстановка значений по умолчанию в командах `twc firewall`.
36
+ - Команда `twc firewall rule remove` теперь может принимать список UUID правил через пробел.
37
+ - В команде `twc firewall show` аргумент `all` стал необязательным.
38
+ - Зависимость `typer` заменена на `typer-slim`.
39
+
40
+ ## Исправлено
41
+
42
+ - Исправлено определение ендпоинта объектного хранилища при вызове команды `twc storage genconfig`.
43
+
5
44
  # Версия 2.5.0 (2024.07.24)
6
45
 
7
46
  ## Добавлено
twc/__main__.py CHANGED
@@ -21,6 +21,7 @@ from .commands import (
21
21
  vpc,
22
22
  firewall,
23
23
  floating_ip,
24
+ whoami,
24
25
  )
25
26
  from .commands.common import version_callback, version_option, verbose_option
26
27
 
@@ -46,6 +47,7 @@ cli.add_typer(domain, name="domain", aliases=["domains", "d"])
46
47
  cli.add_typer(vpc, name="vpc", aliases=["vpcs", "network", "networks"])
47
48
  cli.add_typer(firewall, name="firewall", aliases=["fw"])
48
49
  cli.add_typer(floating_ip, name="ip", aliases=["ips"])
50
+ cli.add_typer(whoami, name="whoami", aliases=[])
49
51
 
50
52
 
51
53
  @cli.command("version")
twc/__version__.py CHANGED
@@ -12,5 +12,5 @@
12
12
  import sys
13
13
 
14
14
 
15
- __version__ = "2.5.0"
15
+ __version__ = "2.7.0"
16
16
  __pyversion__ = sys.version.replace("\n", "")
twc/api/client.py CHANGED
@@ -26,6 +26,7 @@ from .types import (
26
26
  LoadBalancerAlgo,
27
27
  FirewallProto,
28
28
  FirewallDirection,
29
+ FirewallPolicy,
29
30
  )
30
31
 
31
32
 
@@ -468,14 +469,11 @@ class TimewebCloud(TimewebCloudBase):
468
469
  # -----------------------------------------------------------------------
469
470
  # Images
470
471
 
471
- def get_images(
472
- self, limit: int = 100, offset: int = 0, with_deleted: bool = False
473
- ):
472
+ def get_images(self, limit: int = 100, offset: int = 0):
474
473
  """Get list of images."""
475
474
  params = {
476
475
  "limit": limit,
477
476
  "offset": offset,
478
- "with_deleted": with_deleted,
479
477
  }
480
478
  return self._request("GET", f"{self.api_url}/images", params=params)
481
479
 
@@ -1415,6 +1413,8 @@ class TimewebCloud(TimewebCloudBase):
1415
1413
  value: str,
1416
1414
  subdomain: Optional[str] = None,
1417
1415
  priority: Optional[int] = None,
1416
+ *,
1417
+ null_subdomain: bool = False,
1418
1418
  ):
1419
1419
  """Add DNS record to domain."""
1420
1420
  payload = {
@@ -1423,6 +1423,8 @@ class TimewebCloud(TimewebCloudBase):
1423
1423
  **({"subdomain": subdomain} if subdomain else {}),
1424
1424
  **({"priority": priority} if priority else {}),
1425
1425
  }
1426
+ if null_subdomain:
1427
+ payload["subdomain"] = None
1426
1428
  return self._request(
1427
1429
  "POST",
1428
1430
  f"{self.api_url}/domains/{fqdn}/dns-records",
@@ -1549,14 +1551,20 @@ class TimewebCloud(TimewebCloudBase):
1549
1551
  )
1550
1552
 
1551
1553
  def create_firewall_group(
1552
- self, name: str, description: Optional[str] = None
1554
+ self,
1555
+ name: str,
1556
+ description: Optional[str] = None,
1557
+ policy: Optional[FirewallPolicy] = FirewallPolicy.DROP,
1553
1558
  ):
1554
1559
  payload = {
1555
1560
  "name": name,
1556
1561
  **({"description": description} if description else {}),
1557
1562
  }
1558
1563
  return self._request(
1559
- "POST", f"{self.api_url}/firewall/groups", json=payload
1564
+ "POST",
1565
+ f"{self.api_url}/firewall/groups",
1566
+ json=payload,
1567
+ params={"policy": policy},
1560
1568
  )
1561
1569
 
1562
1570
  def get_firewall_group(self, group_id: UUID):
@@ -1636,16 +1644,16 @@ class TimewebCloud(TimewebCloudBase):
1636
1644
  self,
1637
1645
  group_id: UUID,
1638
1646
  direction: FirewallDirection,
1639
- proto: FirewallProto,
1647
+ protocol: FirewallProto,
1640
1648
  cidr: Union[IPv4Network, IPv6Network],
1641
1649
  port: Optional[str] = None,
1642
1650
  description: Optional[str] = None,
1643
1651
  ):
1644
1652
  payload = {
1645
1653
  **({"description": description} if description else {}),
1646
- **({} if proto == FirewallProto.ICMP.value else {"port": port}),
1654
+ **({} if protocol == FirewallProto.ICMP.value else {"port": port}),
1647
1655
  "direction": direction,
1648
- "protocol": proto,
1656
+ "protocol": protocol,
1649
1657
  "cidr": cidr,
1650
1658
  }
1651
1659
  return self._request(
@@ -1670,16 +1678,16 @@ class TimewebCloud(TimewebCloudBase):
1670
1678
  group_id: UUID,
1671
1679
  rule_id: UUID,
1672
1680
  direction: FirewallDirection,
1673
- proto: FirewallProto,
1681
+ protocol: FirewallProto,
1674
1682
  cidr: Union[IPv4Network, IPv6Network],
1675
1683
  port: Optional[str] = None,
1676
1684
  description: Optional[str] = None,
1677
1685
  ):
1678
1686
  payload = {
1679
1687
  **({"description": description} if description else {}),
1680
- **({} if proto == FirewallProto.ICMP.value else {"port": port}),
1688
+ **({} if protocol == FirewallProto.ICMP.value else {"port": port}),
1681
1689
  "direction": direction,
1682
- "protocol": proto,
1690
+ "protocol": protocol,
1683
1691
  "cidr": cidr,
1684
1692
  }
1685
1693
  return self._request(
twc/api/types.py CHANGED
@@ -228,6 +228,9 @@ class FirewallProto(str, Enum):
228
228
  TCP = "tcp"
229
229
  UDP = "udp"
230
230
  ICMP = "icmp"
231
+ TCP6 = "tcp6"
232
+ UDP6 = "udp6"
233
+ ICMP6 = "icmp6"
231
234
 
232
235
 
233
236
  class FirewallDirection(str, Enum):
@@ -235,3 +238,10 @@ class FirewallDirection(str, Enum):
235
238
 
236
239
  INGRESS = "ingress"
237
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
@@ -4,7 +4,7 @@ import os
4
4
  import sys
5
5
  import textwrap
6
6
  from pathlib import Path
7
- from logging import debug
7
+ from logging import debug, warning
8
8
 
9
9
  from .api import TimewebCloud
10
10
  from .api import exceptions as exc
@@ -70,6 +70,11 @@ def create_client(config: Path, profile: str, **kwargs) -> TimewebCloud:
70
70
  """
71
71
  token = os.getenv("TWC_TOKEN")
72
72
  log_settings = os.getenv("TWC_LOG")
73
+ api_endpoint = os.getenv("TWC_ENDPOINT")
74
+
75
+ if api_endpoint:
76
+ warning("Using API URL from environment: %s", api_endpoint)
77
+ kwargs["api_base_url"] = api_endpoint
73
78
 
74
79
  if log_settings:
75
80
  for param in log_settings.split(","):
twc/commands/__init__.py CHANGED
@@ -1,6 +1,6 @@
1
1
  """Commands."""
2
2
 
3
- from .account import account
3
+ from .account import account, whoami
4
4
  from .config import config
5
5
  from .server import server
6
6
  from .ssh_key import ssh_key
twc/commands/account.py CHANGED
@@ -18,6 +18,35 @@ from .common import (
18
18
  )
19
19
 
20
20
 
21
+ whoami = TyperAlias(help="Display current login.", no_args_is_help=False)
22
+
23
+
24
+ # ------------------------------------------------------------- #
25
+ # $ twc whoami #
26
+ # ------------------------------------------------------------- #
27
+
28
+
29
+ @whoami.callback(invoke_without_command=True)
30
+ def whoami_callback(
31
+ verbose: Optional[bool] = verbose_option,
32
+ config: Optional[Path] = config_option,
33
+ profile: Optional[str] = profile_option,
34
+ output_format: Optional[str] = output_format_option,
35
+ ):
36
+ """Display current login."""
37
+ client = create_client(config, profile)
38
+ response = client.get_account_status()
39
+ login = response.json()["status"]["login"]
40
+ fmt.printer(
41
+ {"login": login, "profile": profile},
42
+ output_format=output_format,
43
+ func=lambda data: print(data["login"]),
44
+ )
45
+
46
+
47
+ # ------------------------------------------------------------- #
48
+
49
+
21
50
  account = TyperAlias(help=__doc__)
22
51
  account_access = TyperAlias(help="Manage account access restrictions.")
23
52
  account.add_typer(account_access, name="access")
@@ -33,6 +62,7 @@ def print_account_status(response: Response):
33
62
  status = response.json()["status"]
34
63
  output = dedent(
35
64
  f"""
65
+ Login: {status["login"]}
36
66
  Provider: {status["company_info"]["name"]}
37
67
  Yandex.Metrika ID: {status["ym_client_id"]}
38
68
  Blocked: {status["is_blocked"]}
twc/commands/domain.py CHANGED
@@ -195,7 +195,7 @@ def domain_delete(
195
195
  # ------------------------------------------------------------- #
196
196
 
197
197
 
198
- @domain.command("add")
198
+ @domain.command("add", "create")
199
199
  def domain_add(
200
200
  domain_name: str,
201
201
  verbose: Optional[bool] = verbose_option,
@@ -316,7 +316,7 @@ def domain_remove_dns_record(
316
316
  # ------------------------------------------------------------- #
317
317
 
318
318
 
319
- @domain_record.command("add")
319
+ @domain_record.command("add", "create")
320
320
  def domain_add_dns_record(
321
321
  domain_name: str,
322
322
  verbose: Optional[bool] = verbose_option,
@@ -346,29 +346,39 @@ def domain_add_dns_record(
346
346
  """Add dns record for domain or subdomain."""
347
347
  client = create_client(config, profile)
348
348
 
349
+ null_subdomain = False
350
+
349
351
  if second_ld:
350
352
  offset = 3
351
353
  else:
352
354
  offset = 2
353
355
 
354
356
  subdomain = domain_name
357
+ original_domain_name = domain_name
355
358
  domain_name = ".".join(domain_name.split(".")[-offset:])
356
359
 
357
360
  if subdomain == domain_name:
358
361
  subdomain = None
359
362
 
360
- # API issue: see text below
361
- # API can add TXT record (only TXT, why?) with non-existent subdomain,
362
- # but 'subdomain' option must not be passed as FQDN!
363
- # API issue: You cannot create subdomains with underscore. Why?
364
- # Use previous described bug for this! Pass your subdomain with
365
- # underscores to this function.
366
363
  if record_type.lower() == "txt":
367
- # 'ftp.example.org' --> 'ftp'
368
- subdomain = ".".join(subdomain.split(".")[:-offset])
364
+ if subdomain is None:
365
+ null_subdomain = True
366
+ else:
367
+ # 'ftp.example.org' --> 'ftp'
368
+ subdomain = ".".join(subdomain.split(".")[:-offset])
369
+ else:
370
+ subdomain = ".".join(original_domain_name.split(".")[:-offset])
371
+ if subdomain != "":
372
+ domain_name = original_domain_name
373
+ subdomain = None
369
374
 
370
375
  response = client.add_domain_dns_record(
371
- domain_name, record_type, value, subdomain, priority
376
+ domain_name,
377
+ record_type,
378
+ value,
379
+ subdomain,
380
+ priority,
381
+ null_subdomain=null_subdomain,
372
382
  )
373
383
  fmt.printer(
374
384
  response,
@@ -424,6 +434,12 @@ def domain_update_dns_records(
424
434
  if subdomain == domain_name:
425
435
  subdomain = None
426
436
 
437
+ if record_type.lower() == "txt" and subdomain is not None:
438
+ subdomain = ".".join(subdomain.split(".")[:-offset])
439
+ elif subdomain is not None:
440
+ domain_name = subdomain
441
+ subdomain = None
442
+
427
443
  response = client.update_domain_dns_record(
428
444
  domain_name, record_id, record_type, value, subdomain, priority
429
445
  )
@@ -439,7 +455,7 @@ def domain_update_dns_records(
439
455
  # ------------------------------------------------------------- #
440
456
 
441
457
 
442
- @domain_subdomain.command("add")
458
+ @domain_subdomain.command("add", "create")
443
459
  def domain_add_subdomain(
444
460
  subdomain: str = typer.Argument(..., metavar="FQDN"),
445
461
  verbose: Optional[bool] = verbose_option,
twc/commands/firewall.py CHANGED
@@ -18,7 +18,7 @@ import yaml
18
18
  from requests import Response
19
19
 
20
20
  from twc import fmt
21
- from twc.api import TimewebCloud, FirewallProto
21
+ from twc.api import TimewebCloud, FirewallProto, FirewallPolicy
22
22
  from twc.typerx import TyperAlias
23
23
  from twc.apiwrap import create_client
24
24
  from .common import (
@@ -73,7 +73,7 @@ def print_firewall_status(data: list):
73
73
  rules_total += len(group["rules"])
74
74
  print("Rules total:", rules_total)
75
75
  for group in data:
76
- info = f"Group: {group['name']} ({group['id']})"
76
+ info = f"Group: {group['name']} ({group['id']}) {group['policy']}"
77
77
  for rule in group["rules"]:
78
78
  info += "\n" + textwrap.indent(
79
79
  textwrap.dedent(
@@ -140,7 +140,7 @@ def print_rules_by_service(rules, filters):
140
140
  @firewall.command("show")
141
141
  def firewall_status(
142
142
  resource_type: _ResourceType2 = typer.Argument(
143
- ...,
143
+ _ResourceType2.ALL,
144
144
  metavar="(server|database|balancer|all)",
145
145
  ),
146
146
  resource_id: str = typer.Argument(None),
@@ -184,6 +184,7 @@ def firewall_status(
184
184
  {
185
185
  "id": group["id"],
186
186
  "name": group["name"],
187
+ "policy": group["policy"],
187
188
  "rules": rules,
188
189
  "resources": resources,
189
190
  }
@@ -212,18 +213,19 @@ def firewall_status(
212
213
  def print_firewall_groups(response: Response):
213
214
  groups = response.json()["groups"]
214
215
  table = fmt.Table()
215
- table.header(["ID", "NAME"])
216
+ table.header(["ID", "POLICY", "NAME"])
216
217
  for group in groups:
217
218
  table.row(
218
219
  [
219
220
  group["id"],
221
+ group["policy"],
220
222
  group["name"],
221
223
  ]
222
224
  )
223
225
  table.print()
224
226
 
225
227
 
226
- @firewall_group.command("list")
228
+ @firewall_group.command("list", "ls")
227
229
  def firewall_group_list(
228
230
  verbose: Optional[bool] = verbose_option,
229
231
  config: Optional[Path] = config_option,
@@ -240,6 +242,43 @@ def firewall_group_list(
240
242
  )
241
243
 
242
244
 
245
+ # ------------------------------------------------------------- #
246
+ # $ twc firewall group get #
247
+ # ------------------------------------------------------------- #
248
+
249
+
250
+ def print_firewall_group(response: Response):
251
+ group = response.json()["group"]
252
+ table = fmt.Table()
253
+ table.header(["ID", "POLICY", "NAME"])
254
+ table.row(
255
+ [
256
+ group["id"],
257
+ group["policy"],
258
+ group["name"],
259
+ ]
260
+ )
261
+ table.print()
262
+
263
+
264
+ @firewall_group.command("get")
265
+ def firewall_group_get(
266
+ group_id: UUID,
267
+ verbose: Optional[bool] = verbose_option,
268
+ config: Optional[Path] = config_option,
269
+ profile: Optional[str] = profile_option,
270
+ output_format: Optional[str] = output_format_option,
271
+ ):
272
+ """Get firewall fules group."""
273
+ client = create_client(config, profile)
274
+ response = client.get_firewall_group(group_id)
275
+ fmt.printer(
276
+ response,
277
+ output_format=output_format,
278
+ func=print_firewall_group,
279
+ )
280
+
281
+
243
282
  # ------------------------------------------------------------- #
244
283
  # $ twc firewall group create #
245
284
  # ------------------------------------------------------------- #
@@ -253,12 +292,18 @@ def firewall_group_create(
253
292
  output_format: Optional[str] = output_format_option,
254
293
  name: str = typer.Option(..., help="Group display name."),
255
294
  desc: Optional[str] = typer.Option(None, help="Description."),
295
+ policy: Optional[FirewallPolicy] = typer.Option(
296
+ FirewallPolicy.DROP,
297
+ case_sensitive=False,
298
+ help="Default firewall policy",
299
+ ),
256
300
  ):
257
301
  """Create new group of firewall rules."""
258
302
  client = create_client(config, profile)
259
303
  response = client.create_firewall_group(
260
304
  name=name,
261
305
  description=desc,
306
+ policy=policy,
262
307
  )
263
308
  fmt.printer(
264
309
  response,
@@ -324,6 +369,149 @@ def firewall_group_set(
324
369
  )
325
370
 
326
371
 
372
+ # ------------------------------------------------------------- #
373
+ # $ twc firewall group dump #
374
+ # ------------------------------------------------------------- #
375
+
376
+
377
+ @firewall_group.command("dump")
378
+ def firewall_group_dump(
379
+ group_id: UUID,
380
+ verbose: Optional[bool] = verbose_option,
381
+ config: Optional[Path] = config_option,
382
+ profile: Optional[str] = profile_option,
383
+ ):
384
+ """Dump firewall rules."""
385
+ client = create_client(config, profile)
386
+ group = client.get_firewall_group(group_id).json()["group"]
387
+ rules = client.get_firewall_rules(group_id, limit=1000).json()["rules"]
388
+ dump = {"group": group, "rules": rules}
389
+ fmt.print_colored(json.dumps(dump), lang="json")
390
+
391
+
392
+ # ------------------------------------------------------------- #
393
+ # $ twc firewall group restore #
394
+ # ------------------------------------------------------------- #
395
+
396
+
397
+ def _get_rules_diff(
398
+ rules_local: List[dict], rules_remote: List[dict]
399
+ ) -> Tuple[List[dict], List[dict]]:
400
+ loc = [rule.copy() for rule in rules_local]
401
+ rem = [rule.copy() for rule in rules_remote]
402
+
403
+ for l in loc:
404
+ del l["id"]
405
+ del l["group_id"]
406
+ for r in rem:
407
+ del r["id"]
408
+ del r["group_id"]
409
+
410
+ # Rules from rules_local that not present in rules_remote
411
+ to_create = []
412
+ for idx, rule in enumerate(loc):
413
+ if rule not in rem:
414
+ to_create.append(rules_local[idx])
415
+
416
+ # Rules from rules_remote that not present in rules_local
417
+ to_delete = []
418
+ for idx, rule in enumerate(rem):
419
+ if rule not in loc:
420
+ to_delete.append(rules_remote[idx])
421
+
422
+ return to_create, to_delete
423
+
424
+
425
+ @firewall_group.command("restore")
426
+ def firewall_group_restore(
427
+ group_id: UUID,
428
+ verbose: Optional[bool] = verbose_option,
429
+ config: Optional[Path] = config_option,
430
+ profile: Optional[str] = profile_option,
431
+ output_format: Optional[str] = output_format_option,
432
+ dump_file: Optional[typer.FileText] = typer.Option(
433
+ None, "-f", "--file", help="Firewall rules dump in JSON format."
434
+ ),
435
+ rules_only: Optional[bool] = typer.Option(
436
+ False,
437
+ "--rules-only",
438
+ help="Do not restore group name and description.",
439
+ ),
440
+ dry_run: Optional[bool] = typer.Option(
441
+ False, "--dry-run", help="Does not make any changes."
442
+ ),
443
+ ):
444
+ """Restore firewall rules group from dump file."""
445
+ try:
446
+ dump = json.load(dump_file)
447
+ except json.JSONDecodeError as e:
448
+ sys.exit(f"Error: Cannot load dump file: {dump_file.name}: {e}")
449
+
450
+ client = create_client(config, profile)
451
+ group = client.get_firewall_group(group_id).json()["group"]
452
+ rules = client.get_firewall_rules(group_id, limit=1000).json()["rules"]
453
+ if group["policy"].lower() != dump["group"]["policy"].lower():
454
+ sys.exit(
455
+ f"Error: Cannot restore rules to group with {group['policy']} policy. "
456
+ "Create new rules group instead."
457
+ )
458
+
459
+ # Make list of rules to be created, updated or deleted
460
+ # fmt: off
461
+ rules_to_add, rules_to_del = _get_rules_diff(dump['rules'], rules)
462
+ rules_to_upd = [r for r in rules_to_add if r['id'] in [r['id'] for r in rules]]
463
+ rules_to_add = [r for r in rules_to_add if r not in rules_to_upd]
464
+ rules_to_del = [r for r in rules_to_del if r['id'] not in [r['id'] for r in rules_to_upd]]
465
+ # fmt: on
466
+
467
+ if rules_to_add == rules_to_upd == rules_to_del == []:
468
+ sys.exit("Nothing to do")
469
+
470
+ if dry_run:
471
+ fstring = "{sign} {id:<37} {direction:<8} {portproto:<18} {cidr}"
472
+ rules_lists = [
473
+ (rules_to_add, "Following new rules will be created:", "+"),
474
+ (rules_to_upd, "Following rules will be updated:", "+"),
475
+ (rules_to_del, "Following rules will be deleted:", "-"),
476
+ ]
477
+ for rules_list in rules_lists:
478
+ if rules_list[0]:
479
+ print(rules_list[1])
480
+ for rule in rules_list[0]:
481
+ rule_id = rule["id"]
482
+ if rules_list == rules_lists[0]:
483
+ rule_id = "known-after-create"
484
+ print(
485
+ " "
486
+ + fstring.format(
487
+ sign=rules_list[2],
488
+ id=rule_id,
489
+ direction=rule["direction"],
490
+ portproto=f"{rule['port']}/{rule['protocol']}",
491
+ cidr=rule["cidr"],
492
+ )
493
+ )
494
+ return
495
+
496
+ if rules_only is False and dry_run is False:
497
+ client.update_firewall_group(
498
+ group_id,
499
+ name=dump["group"]["name"],
500
+ description=dump["group"]["description"],
501
+ )
502
+
503
+ for rule in rules_to_add:
504
+ del rule["id"]
505
+ del rule["group_id"]
506
+ client.create_firewall_rule(group_id, **rule)
507
+
508
+ for rule in rules_to_upd:
509
+ client.update_firewall_rule(**rule)
510
+
511
+ for rule in rules_to_del:
512
+ client.delete_firewall_rule(group_id, rule["id"])
513
+
514
+
327
515
  # ------------------------------------------------------------- #
328
516
  # $ twc firewall link #
329
517
  # ------------------------------------------------------------- #
@@ -460,16 +648,17 @@ def filrewall_rule_list(
460
648
  def port_proto_callback(values) -> List[Tuple[Optional[str], str]]:
461
649
  new_values = []
462
650
  for value in values:
463
- if not re.match(r"((^\d+(-\d+)?/)?(tcp|udp)$)|(^icmp$)", value, re.I):
651
+ if not re.match(r"((^\d+(-\d+)?\/)?((tcp|udp|icmp)6?)$)", value, re.I):
464
652
  sys.exit(
465
653
  f"Error: Malformed argument: '{value}': "
466
654
  "correct patterns: '22/TCP', '2000-3000/UDP', 'ICMP', etc."
467
655
  )
468
- if re.match(r"^icmp$", value, re.I):
469
- new_values.append((None, "icmp"))
656
+ pair = value.split("/")
657
+ if len(pair) == 1:
658
+ ports, proto = None, pair[0]
470
659
  else:
471
- ports, proto = value.split("/")
472
- new_values.append((ports, proto.lower()))
660
+ ports, proto = pair
661
+ new_values.append((ports, proto.lower()))
473
662
  return new_values
474
663
 
475
664
 
@@ -510,14 +699,19 @@ def firewall_rule_create(
510
699
  None,
511
700
  help="Rules group name, can be used with '--make-group'",
512
701
  ),
702
+ group_policy: Optional[FirewallPolicy] = typer.Option(
703
+ FirewallPolicy.DROP,
704
+ case_sensitive=False,
705
+ help="Default firewall policy, can be used with '--make-group'",
706
+ ),
513
707
  direction_: bool = typer.Option(
514
708
  True, "--ingress/--egress", help="Traffic direction."
515
709
  ),
516
710
  cidr: Optional[str] = typer.Option(
517
- "0.0.0.0/0",
711
+ None,
518
712
  metavar="IP_NETWORK",
519
713
  callback=validate_cidr_callback,
520
- help="IPv4 or IPv6 CIDR.",
714
+ help="IPv4 or IPv6 CIDR. [default: 0.0.0.0/0 or ::/0]",
521
715
  ),
522
716
  ):
523
717
  """Create new firewall rule."""
@@ -535,7 +729,9 @@ def firewall_rule_create(
535
729
  group_name = "Firewall Group " + datetime.now().strftime(
536
730
  "%Y.%m.%d-%H:%M:%S"
537
731
  )
538
- group_resp = client.create_firewall_group(group_name)
732
+ group_resp = client.create_firewall_group(
733
+ group_name, policy=group_policy
734
+ )
539
735
  group = group_resp.json()["group"]["id"]
540
736
  logging.debug("New firewall rules group: %s", group)
541
737
  fmt.printer(
@@ -543,7 +739,17 @@ def firewall_rule_create(
543
739
  output_format=output_format,
544
740
  func=lambda x: print("Created rules group:", group),
545
741
  )
546
- for port in ports:
742
+ for rule in ports:
743
+ port, proto = rule
744
+ if not cidr:
745
+ if proto in [
746
+ FirewallProto.TCP6,
747
+ FirewallProto.UDP6,
748
+ FirewallProto.ICMP6,
749
+ ]:
750
+ cidr = "::/0"
751
+ else:
752
+ cidr = "0.0.0.0/0"
547
753
  if direction_ is True:
548
754
  direction = "ingress"
549
755
  else:
@@ -551,8 +757,8 @@ def firewall_rule_create(
551
757
  response = client.create_firewall_rule(
552
758
  group,
553
759
  direction=direction,
554
- port=port[0], # :str port or port range
555
- proto=port[1], # :str protocol name
760
+ port=port,
761
+ protocol=proto,
556
762
  cidr=cidr,
557
763
  )
558
764
  fmt.printer(
@@ -579,19 +785,20 @@ def get_group_id_by_rule(client: TimewebCloud, rule_id: UUID) -> str:
579
785
 
580
786
  @firewall_rule.command("remove", "rm")
581
787
  def firewall_rule_remove(
582
- rule_id: UUID,
788
+ rules_ids: List[UUID] = typer.Argument(..., metavar="RULE_ID..."),
583
789
  verbose: Optional[bool] = verbose_option,
584
790
  config: Optional[Path] = config_option,
585
791
  profile: Optional[str] = profile_option,
586
792
  ):
587
793
  """Remove firewall rule."""
588
794
  client = create_client(config, profile)
589
- group_id = get_group_id_by_rule(client, rule_id)
590
- response = client.delete_firewall_rule(group_id, rule_id)
591
- if response.status_code == 204:
592
- print(rule_id)
593
- else:
594
- sys.exit(fmt.printer(response))
795
+ for rule_id in rules_ids:
796
+ group_id = get_group_id_by_rule(client, rule_id)
797
+ response = client.delete_firewall_rule(group_id, rule_id)
798
+ if response.status_code == 204:
799
+ print(rule_id)
800
+ else:
801
+ sys.exit(fmt.printer(response))
595
802
 
596
803
 
597
804
  # ------------------------------------------------------------- #
@@ -622,7 +829,9 @@ def filrewa_rule_update(
622
829
  metavar="PORT[-PORT]",
623
830
  help="Port or ports range e.g. 22, 2000-3000",
624
831
  ),
625
- proto: Optional[FirewallProto] = typer.Option(None, help="Protocol."),
832
+ proto: Optional[FirewallProto] = typer.Option(
833
+ None, case_sensitive=False, help="Protocol."
834
+ ),
626
835
  ):
627
836
  """Change firewall rule."""
628
837
  client = create_client(config, profile)
@@ -643,7 +852,7 @@ def filrewa_rule_update(
643
852
  "rule_id": rule_id,
644
853
  "direction": direction,
645
854
  "port": port,
646
- "proto": proto,
855
+ "protocol": proto,
647
856
  "cidr": cidr,
648
857
  }
649
858
  response = client.update_firewall_rule(**payload)
twc/commands/project.py CHANGED
@@ -188,17 +188,11 @@ def print_resources(response: Response):
188
188
  for key in resource_keys:
189
189
  if resources[key]:
190
190
  for resource in resources[key]:
191
- try:
192
- location = resource["location"]
193
- except KeyError:
194
- # Balancers, clusters, and databases has no 'location' field.
195
- # These services is available only in 'ru-1' location.
196
- location = "ru-1"
197
191
  table.row(
198
192
  [
199
193
  resource["id"],
200
- resource["name"],
201
- location,
194
+ resource.get("name", resource.get("fqdn")),
195
+ resource.get("location", "ru-1"),
202
196
  key[:-1], # this is resource name e.g. 'server'
203
197
  ]
204
198
  )
twc/commands/server.py CHANGED
@@ -584,9 +584,7 @@ def server_create(
584
584
  net = IPv4Network(
585
585
  client.get_vpc(network).json()["vpc"]["subnet_v4"]
586
586
  )
587
- if IPv4Address(private_ip) > IPv4Address(
588
- int(net.network_address) + 4
589
- ):
587
+ if IPv4Address(private_ip) >= net.network_address + 4:
590
588
  payload["network"]["ip"] = private_ip
591
589
  else:
592
590
  # First 3 addresses is reserved for networks OVN based networks
twc/commands/storage.py CHANGED
@@ -18,7 +18,6 @@ from twc import fmt
18
18
  from twc.typerx import TyperAlias
19
19
  from twc.apiwrap import create_client
20
20
  from twc.api import TimewebCloud, ServiceRegion, BucketType
21
- from twc.vars import S3_ENDPOINT
22
21
  from .common import (
23
22
  verbose_option,
24
23
  config_option,
@@ -501,7 +500,6 @@ def storage_subdomain_remove(
501
500
  verbose: Optional[bool] = verbose_option,
502
501
  config: Optional[Path] = config_option,
503
502
  profile: Optional[str] = profile_option,
504
- output_format: Optional[str] = output_format_option,
505
503
  yes: Optional[bool] = yes_option,
506
504
  ):
507
505
  """Remove subdomains."""
@@ -510,11 +508,11 @@ def storage_subdomain_remove(
510
508
  client = create_client(config, profile)
511
509
  bucket_id = resolve_bucket_id(client, bucket)
512
510
  response = client.delete_bucket_subdomains(bucket_id, subdomains)
513
- fmt.printer(
514
- response,
515
- output_format=output_format,
516
- func=print_subdomains_state,
517
- )
511
+ if response.status_code == 204:
512
+ for subdomain in subdomains:
513
+ print(subdomain)
514
+ else:
515
+ sys.exit(fmt.printer(response))
518
516
 
519
517
 
520
518
  # ------------------------------------------------------------- #
@@ -533,7 +531,7 @@ def storage_subdomain_gencert(
533
531
  client = create_client(config, profile)
534
532
  for subdomain in subdomains:
535
533
  response = client.gen_cert_for_bucket_subdomain(subdomain)
536
- if response.status_code == 201:
534
+ if response.status_code == 204:
537
535
  print(subdomain)
538
536
  else:
539
537
  sys.exit(fmt.printer(response))
@@ -613,10 +611,16 @@ def storage_genconfig(
613
611
  "rclone": RCLONE_CONFIG_TEMPLATE.strip(),
614
612
  }
615
613
 
614
+ endpoint = "s3.timeweb.cloud"
615
+ if not access_key.isupper():
616
+ # Legacy object storage service have lowercase usernames only.
617
+ # New storage, on the contrary, always has keys in uppercase.
618
+ endpoint = "s3.timeweb.com"
619
+
616
620
  file_content = templates[s3_client].format(
617
621
  access_key=access_key,
618
622
  secret_key=secret_key,
619
- endpoint=S3_ENDPOINT,
623
+ endpoint=endpoint,
620
624
  )
621
625
 
622
626
  if save_to:
twc/fmt.py CHANGED
@@ -67,6 +67,9 @@ class Printer:
67
67
 
68
68
  def raw(self):
69
69
  """Print raw API response text (mostly raw JSON)."""
70
+ if isinstance(self._data, dict):
71
+ typer.echo(json.dumps(self._data))
72
+ return
70
73
  typer.echo(self._data.text)
71
74
 
72
75
  def colorize(self, data: str, lexer: object = JsonLexer()):
@@ -77,9 +80,12 @@ class Printer:
77
80
  """Print colorized JSON output. Fallback to non-color output if
78
81
  Pygments not installed and fallback to raw output on JSONDecodeError.
79
82
  """
83
+ data = self._data
84
+ if not isinstance(self._data, dict):
85
+ data = self._data.json()
80
86
  try:
81
87
  json_data = json.dumps(
82
- self._data.json(), indent=4, sort_keys=True, ensure_ascii=False
88
+ data, indent=4, sort_keys=True, ensure_ascii=False
83
89
  )
84
90
  self.colorize(json_data, lexer=JsonLexer())
85
91
  except json.JSONDecodeError:
@@ -89,10 +95,11 @@ class Printer:
89
95
  """Print colorized YAML output. Fallback to non-color output if
90
96
  Pygments not installed and fallback to raw output on YAMLError.
91
97
  """
98
+ data = self._data
99
+ if not isinstance(self._data, dict):
100
+ data = self._data.json()
92
101
  try:
93
- yaml_data = yaml.dump(
94
- self._data.json(), sort_keys=True, allow_unicode=True
95
- )
102
+ yaml_data = yaml.dump(data, sort_keys=True, allow_unicode=True)
96
103
  self.colorize(yaml_data, lexer=YamlLexer())
97
104
  except yaml.YAMLError:
98
105
  self.raw()
twc/vars.py CHANGED
@@ -6,11 +6,9 @@ expand or other infrastructure or product changes occur.
6
6
 
7
7
  # Service URLs
8
8
  CONTROL_PANEL_URL = "https://timeweb.cloud/my"
9
- S3_ENDPOINT_DEPRECATED = "s3.timeweb.com"
10
- S3_ENDPOINT = "s3.timeweb.cloud"
11
9
 
12
10
  # Location specific parameters. May change later.
13
11
  REGIONS_WITH_IPV6 = ["ru-1", "pl-1"]
14
12
  REGIONS_WITH_IMAGES = ["ru-1", "ru-3", "kz-1", "pl-1", "nl-1"]
15
13
  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"]
14
+ ZONES_WITH_LAN = ["spb-1", "spb-3", "spb-4", "msk-1", "ams-1", "gdn-1"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: twc-cli
3
- Version: 2.5.0
3
+ Version: 2.7.0
4
4
  Summary: Timeweb Cloud Command Line Interface.
5
5
  Home-page: https://github.com/timeweb-cloud/twc
6
6
  License: MIT
@@ -19,23 +19,40 @@ Requires-Dist: pyyaml (>=6.0.1,<7.0.0)
19
19
  Requires-Dist: requests (>=2.32.3,<3.0.0)
20
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.12.3,<0.13.0)
22
+ Requires-Dist: typer-slim (>=0.12.3,<0.13.0)
23
23
  Project-URL: Repository, https://github.com/timeweb-cloud/twc
24
24
  Description-Content-Type: text/markdown
25
25
 
26
- ![TWC CLI](https://github.com/timeweb-cloud/twc/blob/master/artwork/light.svg)
26
+ <picture>
27
+ <source media="(prefers-color-scheme: dark)" srcset="https://ec650031-twc-cli.s3.timeweb.cloud/dark.svg" type="image/svg+xml">
28
+ <source media="(prefers-color-scheme: dark)" srcset="https://ec650031-twc-cli.s3.timeweb.cloud/dark.png" type="image/png">
29
+ <source media="(prefers-color-scheme: light)" srcset="https://ec650031-twc-cli.s3.timeweb.cloud/light.svg" type="image/svg+xml">
30
+ <source media="(prefers-color-scheme: light)" srcset="https://ec650031-twc-cli.s3.timeweb.cloud/light.png" type="image/png">
31
+ <img alt="TWC CLI" src="https://ec650031-twc-cli.s3.timeweb.cloud/light.png">
32
+ </picture>
27
33
 
28
34
  Timeweb Cloud Command Line Interface and simple SDK 💫
29
35
 
30
- > [Руководство пользователя](https://github.com/timeweb-cloud/twc/blob/master/docs/ru/README.md) 🇷🇺
31
- > [Command Line Interface (CLI) Reference](https://github.com/timeweb-cloud/twc/blob/master/docs/ru/CLI_REFERENCE.md) 📜
36
+ * [Руководство пользователя](https://github.com/timeweb-cloud/twc/blob/master/docs/ru/README.md) 🇷🇺
37
+ * [Command Line Interface (CLI) Reference](https://github.com/timeweb-cloud/twc/blob/master/docs/ru/CLI_REFERENCE.md)
32
38
 
33
39
  # Installation
34
40
 
41
+ From PyPI registry via pip:
42
+
35
43
  ```
36
44
  pip install twc-cli
37
45
  ```
38
46
 
47
+ Using [pipx](https://pipx.pypa.io/stable/):
48
+
49
+ ```
50
+ pipx install twc-cli
51
+ ```
52
+
53
+ Or install [zippap](https://docs.python.org/3/library/zipapp.html) in your PATH.
54
+ Look for prebuilt `.pyz` archives on [releases page](https://github.com/timeweb-cloud/twc/releases/latest).
55
+
39
56
  # Getting started
40
57
 
41
58
  Get Timeweb Cloud [access token](https://timeweb.cloud/my/api-keys) and
@@ -0,0 +1,36 @@
1
+ CHANGELOG.md,sha256=AnIkFEP7S8yrjLrgp1Vbsp47c79n6RQxz6-6OEwwEmk,26436
2
+ COPYING,sha256=fpJLxZS_kHr_659xkpmqEtqHeZp6lQR9CmKCwnYbsmE,1089
3
+ twc/__init__.py,sha256=NwPAMNw3NuHdWGQvWS9_lromVF6VM194oVOipojfJns,113
4
+ twc/__main__.py,sha256=ADHceaQUzgLmvhYHvb5O8urdJWj5IcEHLpTQkSExiD8,2468
5
+ twc/__version__.py,sha256=cJqV4mvQsKCOeicZYGqEH-nKLLWLVP_E-ByegiDQ9js,442
6
+ twc/api/__init__.py,sha256=SXew0Fe51M7nRBNQaaLRH4NjnRHkQUn7J26OCkQsftA,128
7
+ twc/api/base.py,sha256=QRefnIgmlbz8n37GLBKeAK1AtzkcNo1IFjZgHDDECJ4,7912
8
+ twc/api/client.py,sha256=8y3Q8zoZeNoKm8ywOXXbSXvMIvCXyAsU43HLy_nb4hQ,58514
9
+ twc/api/exceptions.py,sha256=UzK3pKRffcXlhnkPy6MDjP_DygVoV17DuZ_mdNbOzts,2369
10
+ twc/api/types.py,sha256=HCxdTi-o8nVq4ShPthd2fUvlYufEoXafx_6qrNHFH04,5406
11
+ twc/apiwrap.py,sha256=hKrg_o6rLfY32SEnWMc1BSXHnSAh7TGar1JQ90YnG5M,2970
12
+ twc/commands/__init__.py,sha256=a-6fHQQwOj--Z7uBZGZL3z1rvJiOGUMQMRET1UknIYo,430
13
+ twc/commands/account.py,sha256=6q9ri02oFbUUZuqNVXO-uHOX45B4ELJlPjyfVaEL5Qw,5960
14
+ twc/commands/balancer.py,sha256=QAouc74ZT5go11gB1vjjfYtd1luTmWrfpACPwokZ5sU,20278
15
+ twc/commands/common.py,sha256=Wph8cVogUNNvc456SQrASb7mv7G88I8ETwHgISVjLQQ,8282
16
+ twc/commands/config.py,sha256=hoRtxn2VRxIsuy9vgO6yd0Cu15Rbl-uYMZeU0Ix7dG0,8797
17
+ twc/commands/database.py,sha256=2NZ-TyRBkFgfYJyUdZUcfdqSaX7QVdWDU4k_yQNtUvo,16052
18
+ twc/commands/domain.py,sha256=uKr2rP9ajLiZrxA_Xl47Jn7Fh9IRODCtjwXwTfbo-EU,15943
19
+ twc/commands/firewall.py,sha256=KNolqbi2rsppOZwbs_j3yoZQt-0wKbj1JPGiZdfGxDE,27439
20
+ twc/commands/floating_ip.py,sha256=G9nD5BbHCZcuytbzeneDJWQDhd8c8WRtq9pAfwI9m7E,8747
21
+ twc/commands/image.py,sha256=OviQwegXK55H3TBlroCASVcgj2QUVCTo0ZhF5ug9eT8,8165
22
+ twc/commands/kubernetes.py,sha256=-Cgas1vFVMcrWGinjstuUz3sqX0ZNXv_4mwPwuwKeLE,20870
23
+ twc/commands/project.py,sha256=xnL3kLIumKzrI9EZ6r6m-PGOl3mZ9IhLQua7WZ3Rghg,10499
24
+ twc/commands/server.py,sha256=Cw8VxOcWEVPtcKZ53h9erhV3VOj7io9E8xQwVN0S53Y,70294
25
+ twc/commands/ssh_key.py,sha256=NHgTPhAQpDzt-iPHHVo4XqUJvujNqf019N6N9qYZ9Us,7941
26
+ twc/commands/storage.py,sha256=Pztk5iUBp9RtkdOwsfHaZFCnD8GuH6zOPtluawkRmiI,19404
27
+ twc/commands/vpc.py,sha256=SAht6UD17mU0d_AZY6W34VEYs7CqUsS2iDakPFxAFQU,8876
28
+ twc/fmt.py,sha256=nbuYZ8nVabYDwCmZqnL3-c6Tmri4B-R_sTCkG6sdfeI,7171
29
+ twc/typerx.py,sha256=AZ6BgTQvlrZYfKVYd9YqRNQnAR2XuyqImz4rf6di6f4,6737
30
+ twc/utils.py,sha256=uWizyUC4dHLwtk50q4Sub3zOvnVESfHKBbXYwk5t71w,651
31
+ twc/vars.py,sha256=fva3O2leMGtExb1aWiAk6sOV0O8et9_kEyRpYYIZ7CM,543
32
+ twc_cli-2.7.0.dist-info/COPYING,sha256=fpJLxZS_kHr_659xkpmqEtqHeZp6lQR9CmKCwnYbsmE,1089
33
+ twc_cli-2.7.0.dist-info/METADATA,sha256=w4WaXHt_EXUtrWRlRhntcYi0p5poZ7PM-ZfJGK4BZTY,2601
34
+ twc_cli-2.7.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
35
+ twc_cli-2.7.0.dist-info/entry_points.txt,sha256=tmTaVRhm8BkNrXC_9XJMum7O9wFVOvkXcBetxmahWvE,40
36
+ twc_cli-2.7.0.dist-info/RECORD,,
@@ -1,36 +0,0 @@
1
- CHANGELOG.md,sha256=Mv2RQ8cXh1JfTXQjEBD3ybfrjl9ATiKJ7q9C6MmtV2Y,23571
2
- COPYING,sha256=fpJLxZS_kHr_659xkpmqEtqHeZp6lQR9CmKCwnYbsmE,1089
3
- twc/__init__.py,sha256=NwPAMNw3NuHdWGQvWS9_lromVF6VM194oVOipojfJns,113
4
- twc/__main__.py,sha256=YBqi1OMSDaZnu2q8OyNa6N-dkVI90n6DZrJbUvYbHJA,2407
5
- twc/__version__.py,sha256=pQfQHkeBAja7_QVabo1EyLILiBHrkTK_U0mgO4E2MEY,442
6
- twc/api/__init__.py,sha256=SXew0Fe51M7nRBNQaaLRH4NjnRHkQUn7J26OCkQsftA,128
7
- twc/api/base.py,sha256=QRefnIgmlbz8n37GLBKeAK1AtzkcNo1IFjZgHDDECJ4,7912
8
- twc/api/client.py,sha256=II0hXcmlUPuK_ewYDmMu2CjUffcg0C7D1jRu6xKU_RI,58299
9
- twc/api/exceptions.py,sha256=UzK3pKRffcXlhnkPy6MDjP_DygVoV17DuZ_mdNbOzts,2369
10
- twc/api/types.py,sha256=S-lRYAEN5kc32LpWfV49CTcBveYAebCgs5e8Hw374qo,5239
11
- twc/apiwrap.py,sha256=0SmFZUH013jKHlpAQmsIySMtFuNtGvkMLQv0zTVJijg,2780
12
- twc/commands/__init__.py,sha256=hvTpQByt7wfl9hVo7xRPCdwAQpctoFiBVdaMLHDqKbs,422
13
- twc/commands/account.py,sha256=6T7J3McTXJKzT7Gi_AgRcKpWdeXcmBTcpwFF0GjzADo,4998
14
- twc/commands/balancer.py,sha256=QAouc74ZT5go11gB1vjjfYtd1luTmWrfpACPwokZ5sU,20278
15
- twc/commands/common.py,sha256=Wph8cVogUNNvc456SQrASb7mv7G88I8ETwHgISVjLQQ,8282
16
- twc/commands/config.py,sha256=hoRtxn2VRxIsuy9vgO6yd0Cu15Rbl-uYMZeU0Ix7dG0,8797
17
- twc/commands/database.py,sha256=2NZ-TyRBkFgfYJyUdZUcfdqSaX7QVdWDU4k_yQNtUvo,16052
18
- twc/commands/domain.py,sha256=SHOERsEvmlkdKQV5kaiEgMOLZme5ykW8LCUd1IF1Zzo,15624
19
- twc/commands/firewall.py,sha256=FoOA-kC_5M-XQ49DZst8EBt_Jmyz4KdRkSUs5ts8QVI,20400
20
- twc/commands/floating_ip.py,sha256=G9nD5BbHCZcuytbzeneDJWQDhd8c8WRtq9pAfwI9m7E,8747
21
- twc/commands/image.py,sha256=OviQwegXK55H3TBlroCASVcgj2QUVCTo0ZhF5ug9eT8,8165
22
- twc/commands/kubernetes.py,sha256=-Cgas1vFVMcrWGinjstuUz3sqX0ZNXv_4mwPwuwKeLE,20870
23
- twc/commands/project.py,sha256=0z0MmkW8CVFb8emftE5OshoAwdLBZq_otQjNpeuuklU,10750
24
- twc/commands/server.py,sha256=nySxygbA1ZwLCE53JjhabLAI7uZjnFE_RdJ_8JzZvOU,70341
25
- twc/commands/ssh_key.py,sha256=NHgTPhAQpDzt-iPHHVo4XqUJvujNqf019N6N9qYZ9Us,7941
26
- twc/commands/storage.py,sha256=-XleSCTBb9w8MAmwrf89ayZ6mfjcjCbTzpk6BSNZLbk,19215
27
- twc/commands/vpc.py,sha256=SAht6UD17mU0d_AZY6W34VEYs7CqUsS2iDakPFxAFQU,8876
28
- twc/fmt.py,sha256=f8submLhHnm8OXBl_4Oe4tuhhMKvgTK1PP1y_25TmyU,6904
29
- twc/typerx.py,sha256=AZ6BgTQvlrZYfKVYd9YqRNQnAR2XuyqImz4rf6di6f4,6737
30
- twc/utils.py,sha256=uWizyUC4dHLwtk50q4Sub3zOvnVESfHKBbXYwk5t71w,651
31
- twc/vars.py,sha256=Z0KMiBlECqFSplwT0gXoEKyJk8W-fu5_OPMOmEdcNGg,609
32
- twc_cli-2.5.0.dist-info/COPYING,sha256=fpJLxZS_kHr_659xkpmqEtqHeZp6lQR9CmKCwnYbsmE,1089
33
- twc_cli-2.5.0.dist-info/METADATA,sha256=g3tnREOgKMZv1D2Ey9-zTfFKUikTOemHxYUKEjse4Fo,1775
34
- twc_cli-2.5.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
35
- twc_cli-2.5.0.dist-info/entry_points.txt,sha256=tmTaVRhm8BkNrXC_9XJMum7O9wFVOvkXcBetxmahWvE,40
36
- twc_cli-2.5.0.dist-info/RECORD,,