twc-cli 2.4.1__py3-none-any.whl → 2.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of twc-cli might be problematic. Click here for more details.

@@ -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
+ )
twc/commands/image.py CHANGED
@@ -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
- response = client.create_image(
266
- upload_url=file,
267
- name=name,
268
- description=desc,
269
- os_type=os_type,
270
- location=region,
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
 
twc/commands/project.py CHANGED
@@ -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(bucket_name)
301
+ print(bucket_id)
303
302
  else:
304
303
  sys.exit(fmt.printer(response))
305
304
  if cluster:
twc/commands/server.py CHANGED
@@ -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="Enable DDoS-Guard.",
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"] = {"id": 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
- """Clone Cloud Server."""
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
  # ------------------------------------------------------------- #
twc/commands/storage.py CHANGED
@@ -5,7 +5,6 @@ Cloud specific API methods instead. Use third party S3 clients to manage
5
5
  objects e.g. s3cmd, rclone, etc.
6
6
  """
7
7
 
8
-
9
8
  import sys
10
9
  from logging import debug
11
10
  from typing import Optional, List
@@ -19,7 +18,6 @@ from twc import fmt
19
18
  from twc.typerx import TyperAlias
20
19
  from twc.apiwrap import create_client
21
20
  from twc.api import TimewebCloud, ServiceRegion, BucketType
22
- from twc.vars import S3_ENDPOINT
23
21
  from .common import (
24
22
  verbose_option,
25
23
  config_option,
@@ -614,10 +612,16 @@ def storage_genconfig(
614
612
  "rclone": RCLONE_CONFIG_TEMPLATE.strip(),
615
613
  }
616
614
 
615
+ endpoint = "s3.timeweb.cloud"
616
+ if not access_key.isupper():
617
+ # Legacy object storage service have lowercase usernames only.
618
+ # New storage, on the contrary, always has keys in uppercase.
619
+ endpoint = "s3.timeweb.com"
620
+
617
621
  file_content = templates[s3_client].format(
618
622
  access_key=access_key,
619
623
  secret_key=secret_key,
620
- endpoint=S3_ENDPOINT,
624
+ endpoint=endpoint,
621
625
  )
622
626
 
623
627
  if save_to:
twc/commands/vpc.py CHANGED
@@ -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 = [IPv4Network("10.0.0.0/16"), IPv4Network("192.168.0.0/16")]
35
- MAX_PREFIXLEN = 16
36
- MIN_PREFIXLEN = 32
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="IP_NETWORK",
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 is {REGIONS_WITH_LAN}"
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,
twc/fmt.py CHANGED
@@ -31,7 +31,7 @@ class Table:
31
31
 
32
32
  """
33
33
 
34
- def __init__(self, whitespace: str = "\t"):
34
+ def __init__(self, whitespace: str = " "):
35
35
  self.__rows = []
36
36
  self.__whitespace = whitespace
37
37