twc-cli 2.7.0__tar.gz → 2.9.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.

Files changed (34) hide show
  1. {twc_cli-2.7.0 → twc_cli-2.9.0}/CHANGELOG.md +19 -0
  2. {twc_cli-2.7.0 → twc_cli-2.9.0}/PKG-INFO +2 -1
  3. {twc_cli-2.7.0 → twc_cli-2.9.0}/pyproject.toml +1 -1
  4. {twc_cli-2.7.0 → twc_cli-2.9.0}/twc/__version__.py +1 -1
  5. {twc_cli-2.7.0 → twc_cli-2.9.0}/twc/api/client.py +23 -3
  6. {twc_cli-2.7.0 → twc_cli-2.9.0}/twc/commands/domain.py +56 -9
  7. {twc_cli-2.7.0 → twc_cli-2.9.0}/twc/commands/server.py +78 -29
  8. {twc_cli-2.7.0 → twc_cli-2.9.0}/COPYING +0 -0
  9. {twc_cli-2.7.0 → twc_cli-2.9.0}/README.md +0 -0
  10. {twc_cli-2.7.0 → twc_cli-2.9.0}/twc/__init__.py +0 -0
  11. {twc_cli-2.7.0 → twc_cli-2.9.0}/twc/__main__.py +0 -0
  12. {twc_cli-2.7.0 → twc_cli-2.9.0}/twc/api/__init__.py +0 -0
  13. {twc_cli-2.7.0 → twc_cli-2.9.0}/twc/api/base.py +0 -0
  14. {twc_cli-2.7.0 → twc_cli-2.9.0}/twc/api/exceptions.py +0 -0
  15. {twc_cli-2.7.0 → twc_cli-2.9.0}/twc/api/types.py +0 -0
  16. {twc_cli-2.7.0 → twc_cli-2.9.0}/twc/apiwrap.py +0 -0
  17. {twc_cli-2.7.0 → twc_cli-2.9.0}/twc/commands/__init__.py +0 -0
  18. {twc_cli-2.7.0 → twc_cli-2.9.0}/twc/commands/account.py +0 -0
  19. {twc_cli-2.7.0 → twc_cli-2.9.0}/twc/commands/balancer.py +0 -0
  20. {twc_cli-2.7.0 → twc_cli-2.9.0}/twc/commands/common.py +0 -0
  21. {twc_cli-2.7.0 → twc_cli-2.9.0}/twc/commands/config.py +0 -0
  22. {twc_cli-2.7.0 → twc_cli-2.9.0}/twc/commands/database.py +0 -0
  23. {twc_cli-2.7.0 → twc_cli-2.9.0}/twc/commands/firewall.py +0 -0
  24. {twc_cli-2.7.0 → twc_cli-2.9.0}/twc/commands/floating_ip.py +0 -0
  25. {twc_cli-2.7.0 → twc_cli-2.9.0}/twc/commands/image.py +0 -0
  26. {twc_cli-2.7.0 → twc_cli-2.9.0}/twc/commands/kubernetes.py +0 -0
  27. {twc_cli-2.7.0 → twc_cli-2.9.0}/twc/commands/project.py +0 -0
  28. {twc_cli-2.7.0 → twc_cli-2.9.0}/twc/commands/ssh_key.py +0 -0
  29. {twc_cli-2.7.0 → twc_cli-2.9.0}/twc/commands/storage.py +0 -0
  30. {twc_cli-2.7.0 → twc_cli-2.9.0}/twc/commands/vpc.py +0 -0
  31. {twc_cli-2.7.0 → twc_cli-2.9.0}/twc/fmt.py +0 -0
  32. {twc_cli-2.7.0 → twc_cli-2.9.0}/twc/typerx.py +0 -0
  33. {twc_cli-2.7.0 → twc_cli-2.9.0}/twc/utils.py +0 -0
  34. {twc_cli-2.7.0 → twc_cli-2.9.0}/twc/vars.py +0 -0
@@ -2,6 +2,25 @@
2
2
 
3
3
  В этом файле описаны все значимые изменения в Timeweb Cloud CLI. В выпусках мы придерживается правил [семантического версионирования](https://semver.org/lang/ru/).
4
4
 
5
+ # Версия 2.9.0 (2025.03.06)
6
+
7
+ ## Добавлено
8
+
9
+ - Поддержка заказа серверов с GPU
10
+ - Поддержка заказа серверов линейки Dedicated CPU
11
+ - Добавлены новые опции для команды `twc server create`: `--disable-ssh-password-auth`, `--gpus`, `--type`, `--configurator-id`.
12
+
13
+ ## Изменено
14
+
15
+ - Мелкие улучшения в коде.
16
+
17
+ # Версия 2.8.0 (2025.02.13)
18
+
19
+ ## Добавлено
20
+
21
+ - Поддержка SRV-записей для доменов.
22
+ - Теперь можно задавать TTL для DNS-записей.
23
+
5
24
  # Версия 2.7.0 (2024.11.02)
6
25
 
7
26
  ## Добавлено
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: twc-cli
3
- Version: 2.7.0
3
+ Version: 2.9.0
4
4
  Summary: Timeweb Cloud Command Line Interface.
5
5
  Home-page: https://github.com/timeweb-cloud/twc
6
6
  License: MIT
@@ -13,6 +13,7 @@ Classifier: Programming Language :: Python :: 3.9
13
13
  Classifier: Programming Language :: Python :: 3.10
14
14
  Classifier: Programming Language :: Python :: 3.11
15
15
  Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
16
17
  Requires-Dist: colorama (>=0.4.6,<0.5.0)
17
18
  Requires-Dist: pygments (>=2.18.0,<3.0.0)
18
19
  Requires-Dist: pyyaml (>=6.0.1,<7.0.0)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "twc-cli"
3
- version = "2.7.0"
3
+ version = "2.9.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"
@@ -12,5 +12,5 @@
12
12
  import sys
13
13
 
14
14
 
15
- __version__ = "2.7.0"
15
+ __version__ = "2.9.0"
16
16
  __pyversion__ = sys.version.replace("\n", "")
@@ -76,6 +76,7 @@ class TimewebCloud(TimewebCloudBase):
76
76
  is_ddos_guard: bool = False,
77
77
  network: Optional[dict] = None,
78
78
  availability_zone: Optional[ServiceAvailabilityZone] = None,
79
+ is_root_password_required: Optional[bool] = None,
79
80
  ):
80
81
  """Create new Cloud Server. Note:
81
82
 
@@ -113,6 +114,11 @@ class TimewebCloud(TimewebCloudBase):
113
114
  if availability_zone
114
115
  else {}
115
116
  ),
117
+ **(
118
+ {"is_root_password_required": is_root_password_required}
119
+ if is_root_password_required is not None
120
+ else {}
121
+ ),
116
122
  }
117
123
 
118
124
  return self._request("POST", f"{self.api_url}/servers", json=payload)
@@ -186,6 +192,10 @@ class TimewebCloud(TimewebCloudBase):
186
192
  "POST", f"{self.api_url}/servers/{server_id}/clone", json={}
187
193
  )
188
194
 
195
+ def get_server_preset_types(self):
196
+ """List all available preset and configurator IDs by their types."""
197
+ return self._request("GET", f"{self.api_url}/presets/types/servers")
198
+
189
199
  def get_server_configurators(self):
190
200
  """List configurators."""
191
201
  return self._request("GET", f"{self.api_url}/configurator/servers")
@@ -1410,18 +1420,28 @@ class TimewebCloud(TimewebCloudBase):
1410
1420
  self,
1411
1421
  fqdn: str,
1412
1422
  dns_record_type: DNSRecordType,
1413
- value: str,
1423
+ value: Optional[str] = None,
1414
1424
  subdomain: Optional[str] = None,
1415
1425
  priority: Optional[int] = None,
1426
+ ttl: Optional[int] = None,
1427
+ protocol: Optional[str] = None,
1428
+ service: Optional[str] = None,
1429
+ host: Optional[str] = None,
1430
+ port: Optional[int] = None,
1416
1431
  *,
1417
1432
  null_subdomain: bool = False,
1418
1433
  ):
1419
1434
  """Add DNS record to domain."""
1420
1435
  payload = {
1421
1436
  "type": dns_record_type,
1422
- "value": value,
1437
+ **({"value": value} if value else {}),
1423
1438
  **({"subdomain": subdomain} if subdomain else {}),
1424
- **({"priority": priority} if priority else {}),
1439
+ **({"priority": priority} if priority is not None else {}),
1440
+ **({"ttl": ttl} if ttl else {}),
1441
+ **({"protocol": protocol} if protocol else {}),
1442
+ **({"service": service} if service else {}),
1443
+ **({"host": host} if host else {}),
1444
+ **({"port": port} if port else {}),
1425
1445
  }
1426
1446
  if null_subdomain:
1427
1447
  payload["subdomain"] = None
@@ -2,6 +2,7 @@
2
2
 
3
3
  import re
4
4
  import sys
5
+ from enum import Enum
5
6
  from typing import Optional, List
6
7
  from pathlib import Path
7
8
 
@@ -316,6 +317,14 @@ def domain_remove_dns_record(
316
317
  # ------------------------------------------------------------- #
317
318
 
318
319
 
320
+ class SRVProtocol(str, Enum):
321
+ """Supported protocols for SRV records."""
322
+
323
+ TCP = "TCP"
324
+ UPD = "UDP"
325
+ TLS = "TLS"
326
+
327
+
319
328
  @domain_record.command("add", "create")
320
329
  def domain_add_dns_record(
321
330
  domain_name: str,
@@ -331,12 +340,40 @@ def domain_add_dns_record(
331
340
  metavar="TYPE",
332
341
  help=f"[{'|'.join([k.value for k in DNSRecordType])}]",
333
342
  ),
334
- value: Optional[str] = typer.Option(...),
343
+ value: Optional[str] = typer.Option(
344
+ None,
345
+ help="Record value. Skip it for SRV records.",
346
+ ),
335
347
  priority: Optional[int] = typer.Option(
336
348
  None,
337
349
  "--prio",
338
350
  help="Record priority. Supported for MX, SRV records.",
339
351
  ),
352
+ service: Optional[str] = typer.Option(
353
+ None,
354
+ "--service",
355
+ help="Service for SRV record e.g '_matrix'.",
356
+ ),
357
+ proto: Optional[SRVProtocol] = typer.Option(
358
+ None,
359
+ "--proto",
360
+ help="Protocol for SRV record.",
361
+ ),
362
+ host: Optional[str] = typer.Option(
363
+ None,
364
+ "--host",
365
+ help="Host for SRV record.",
366
+ ),
367
+ port: Optional[int] = typer.Option(
368
+ None,
369
+ "--port",
370
+ help="Port for SRV record.",
371
+ min=1,
372
+ max=65535,
373
+ ),
374
+ ttl: Optional[int] = typer.Option(
375
+ None, "--ttl", help="Time-To-Live for DNS record."
376
+ ),
340
377
  second_ld: Optional[bool] = typer.Option(
341
378
  False,
342
379
  "--2ld",
@@ -346,6 +383,9 @@ def domain_add_dns_record(
346
383
  """Add dns record for domain or subdomain."""
347
384
  client = create_client(config, profile)
348
385
 
386
+ if record_type != "SRV" and not value:
387
+ sys.exit("Error: --value is expected for non-SRV DNS records")
388
+
349
389
  null_subdomain = False
350
390
 
351
391
  if second_ld:
@@ -372,14 +412,21 @@ def domain_add_dns_record(
372
412
  domain_name = original_domain_name
373
413
  subdomain = None
374
414
 
375
- response = client.add_domain_dns_record(
376
- domain_name,
377
- record_type,
378
- value,
379
- subdomain,
380
- priority,
381
- null_subdomain=null_subdomain,
382
- )
415
+ payload = {
416
+ "fqdn": domain_name,
417
+ "dns_record_type": record_type,
418
+ "value": value,
419
+ "subdomain": subdomain,
420
+ "priority": priority,
421
+ "ttl": ttl,
422
+ "protocol": "_" + proto if proto else None,
423
+ "service": service,
424
+ "host": host,
425
+ "port": port,
426
+ "null_subdomain": null_subdomain,
427
+ }
428
+
429
+ response = client.add_domain_dns_record(**payload)
383
430
  fmt.printer(
384
431
  response,
385
432
  output_format=output_format,
@@ -3,6 +3,7 @@
3
3
  import re
4
4
  import sys
5
5
  import webbrowser
6
+ from enum import Enum
6
7
  from logging import debug
7
8
  from typing import Optional, List, Union
8
9
  from pathlib import Path
@@ -466,15 +467,31 @@ def process_ssh_key(client: TimewebCloud, ssh_key: str) -> int:
466
467
  sys.exit(f"Error: SSH-key '{ssh_key}' not found.")
467
468
 
468
469
 
469
- def select_configurator(client: TimewebCloud, region: str) -> int:
470
+ def select_configurator(client: TimewebCloud, kind: str, region: str) -> int:
470
471
  """Find and return configurator_id by location name."""
471
- configurators = client.get_server_configurators().json()[
472
- "server_configurators"
472
+ kind = kind.replace("-", "_")
473
+ configurators_by_type = client.get_server_preset_types().json()[
474
+ "configurators"
473
475
  ]
474
- for configurator in configurators:
476
+ available_configurators = configurators_by_type.get(kind, None)
477
+ if not available_configurators:
478
+ sys.exit(
479
+ f"Error: Unable to select server configurator_id: no configurators with type '{kind}'"
480
+ )
481
+ for configurator in available_configurators:
475
482
  if configurator["location"] == region:
476
483
  return configurator["id"]
477
- sys.exit(f"Unable to select location: '{region}' not found.")
484
+ sys.exit(
485
+ f"Error: Unable to select configurator_id: no configurators for region {region}"
486
+ )
487
+
488
+
489
+ class InstanceKind(str, Enum):
490
+ """Instance types used to select configurator."""
491
+
492
+ STANDARD = "standard"
493
+ PREMIUM = "premium"
494
+ DEDICATED_CPU = "dedicated-cpu"
478
495
 
479
496
 
480
497
  @server.command("create")
@@ -491,7 +508,17 @@ def server_create(
491
508
  None,
492
509
  help="Cloud Server configuration preset ID. "
493
510
  "NOTE: This argument is mutually exclusive with arguments: "
494
- "['--cpu', '--ram', '--disk'].",
511
+ "['--cpu', '--ram', '--disk', '--gpus'].",
512
+ ),
513
+ configurator_id: int = typer.Option(
514
+ None, help="ID of configuration constraints set."
515
+ ),
516
+ instance_kind: Optional[InstanceKind] = typer.Option(
517
+ InstanceKind.PREMIUM,
518
+ "--type",
519
+ help="Cloud Server type. "
520
+ "Servers with GPU always is 'premium'. "
521
+ "This option will be ignored if '--gpus' or '--preset-id' is set.",
495
522
  ),
496
523
  cpu: int = typer.Option(None, help="Number of vCPUs."),
497
524
  ram: str = typer.Option(
@@ -500,6 +527,12 @@ def server_create(
500
527
  disk: str = typer.Option(
501
528
  None, metavar="SIZE", help="System disk size, e.g. 10240M, 10G."
502
529
  ),
530
+ gpus: Optional[int] = typer.Option(
531
+ None,
532
+ min=0,
533
+ max=4,
534
+ help="Number of GPUs to attach.",
535
+ ),
503
536
  bandwidth: int = typer.Option(
504
537
  None, callback=bandwidth_callback, help="Network bandwidth."
505
538
  ),
@@ -549,6 +582,11 @@ def server_create(
549
582
  callback=load_from_config_callback,
550
583
  help="Add server to specific project.",
551
584
  ),
585
+ disable_ssh_password_auth: Optional[bool] = typer.Option(
586
+ False,
587
+ "--disable-ssh-password-auth",
588
+ help="Disable sshd password authentication.",
589
+ ),
552
590
  ):
553
591
  """Create Cloud Server."""
554
592
  client = create_client(config, profile)
@@ -560,13 +598,28 @@ def server_create(
560
598
  "is_ddos_guard": ddos_protection,
561
599
  "availability_zone": availability_zone,
562
600
  "network": {},
563
- **(
564
- {"is_local_network": local_network}
565
- if local_network is not None
566
- else {}
567
- ),
568
601
  }
569
602
 
603
+ if local_network is not None:
604
+ print(
605
+ "Option --local-network is deprecated and will be removed soon",
606
+ file=sys.stderr,
607
+ )
608
+ payload["is_local_network"] = local_network
609
+
610
+ if disable_ssh_password_auth:
611
+ if not ssh_keys:
612
+ print(
613
+ "You applied --disable-ssh-password-auth, but no ssh keys is set. "
614
+ "Pass keys to --ssh-key option or setup master ssh key.",
615
+ file=sys.stderr,
616
+ )
617
+ payload["is_root_password_required"] = False
618
+
619
+ instance_kind = instance_kind.value
620
+ if gpus:
621
+ instance_kind = "gpu"
622
+
570
623
  # Check availability zone
571
624
  usable_zones = ServiceRegion.get_zones(region)
572
625
  if availability_zone is not None and availability_zone not in usable_zones:
@@ -587,7 +640,7 @@ def server_create(
587
640
  if IPv4Address(private_ip) >= net.network_address + 4:
588
641
  payload["network"]["ip"] = private_ip
589
642
  else:
590
- # First 3 addresses is reserved for networks OVN based networks
643
+ # First 3 addresses is reserved by Timeweb Cloud for gateway and future use.
591
644
  sys.exit(
592
645
  f"Error: Private address '{private_ip}' is not allowed. "
593
646
  "IP must be at least the fourth in order in the network."
@@ -600,15 +653,15 @@ def server_create(
600
653
  sys.exit(f"Error: '{public_ip}' is not valid IPv4 address.")
601
654
  else:
602
655
  # New public IPv4 address will be automatically requested with
603
- # correct availability zone. This is official dirty hack.
656
+ # correct availability zone. This is an official dirty hack.
604
657
  if no_public_ip is False:
605
658
  payload["network"]["floating_ip"] = "create_ip"
606
659
 
607
660
  # Set server configuration parameters
608
- if preset_id and (cpu or ram or disk):
661
+ if preset_id and (cpu or ram or disk or gpus):
609
662
  raise UsageError(
610
663
  "'--preset-id' is mutually exclusive with: "
611
- + "['--cpu', '--ram', '--disk']."
664
+ + "['--cpu', '--ram', '--disk', '--gpus']."
612
665
  )
613
666
  if not preset_id and not (cpu or ram or disk):
614
667
  raise UsageError(
@@ -620,7 +673,10 @@ def server_create(
620
673
  if not locals()[param]:
621
674
  raise UsageError(f"Missing parameter: '--{param}'.")
622
675
  # Select configurator_id by region
623
- configurator_id = select_configurator(client, region)
676
+ if not configurator_id:
677
+ configurator_id = select_configurator(
678
+ client, kind=instance_kind, region=region
679
+ )
624
680
  requirements = get_requirements(client, configurator_id)
625
681
  payload["configuration"] = {
626
682
  "configurator_id": configurator_id,
@@ -628,6 +684,8 @@ def server_create(
628
684
  "ram": validate_ram(requirements, size_to_mb(ram)),
629
685
  "disk": validate_disk(requirements, size_to_mb(disk)),
630
686
  }
687
+ if gpus:
688
+ payload["configuration"]["gpus"] = gpus
631
689
  if bandwidth:
632
690
  payload["bandwidth"] = validate_bandwidth(requirements, bandwidth)
633
691
  else:
@@ -655,24 +713,13 @@ def server_create(
655
713
  if user_data:
656
714
  payload["cloud_init"] = user_data.read()
657
715
 
658
- # Check project_id before creating server
659
716
  if project_id:
660
- if not project_id in [
661
- prj["id"] for prj in client.get_projects().json()["projects"]
662
- ]:
663
- sys.exit(f"Wrong project ID: Project '{project_id}' not found.")
717
+ payload["project_id"] = project_id
664
718
 
665
719
  # Create Cloud Server
666
720
  debug("Create Cloud Server...")
667
721
  response = client.create_server(**payload)
668
722
 
669
- if project_id:
670
- debug(f"Add Server to project '{project_id}'...")
671
- client.add_server_to_project(
672
- response.json()["server"]["id"],
673
- project_id,
674
- )
675
-
676
723
  if nat_mode:
677
724
  debug(f"Set NAT mode to '{nat_mode}'")
678
725
  client.set_server_nat_mode(
@@ -793,7 +840,9 @@ def server_resize(
793
840
  debug("Get configurator_id...")
794
841
  if not configurator_id:
795
842
  configurator_id = select_configurator(
796
- client, old_state["location"]
843
+ client,
844
+ region=old_state["location"],
845
+ kind="premium",
797
846
  )
798
847
 
799
848
  # Get configurator by configurator_id
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
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