Skip to content

Commit d2177d9

Browse files
authored
Feature/interface option (#47)
Fix interface option in create and update server
1 parent 879392d commit d2177d9

6 files changed

Lines changed: 317 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## v1.4.0 (2021-05-03)
6+
7+
### Minor Chages
8+
9+
- Implemented interface support.
10+
511
## v1.3.1 (2020-12-13)
612

713
### Bug fixes

cloudscale_cli/commands/server.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import jmespath
55
import os
66
from cloudscale import CloudscaleApiException
7+
from ..interface_parameter_parser import parse_interface
78

89
@click.group()
910
@click.pass_context
@@ -125,6 +126,13 @@ def cmd_create(
125126
click.echo(f"Error: Could not format name '{name}': {e}", err=True)
126127
sys.exit(1)
127128

129+
interfaces_parsed = [parse_interface(i).as_json() for i in interfaces]
130+
131+
kwargs = {}
132+
if not interfaces_parsed:
133+
kwargs['use_public_network'] = use_public_network
134+
kwargs['use_private_network'] = use_private_network
135+
128136
s = cloudscale.cmd_create(
129137
silent=True,
130138
name=server_name,
@@ -133,11 +141,9 @@ def cmd_create(
133141
zone=zone,
134142
volume_size=volume_size,
135143
volumes=volumes or None,
136-
interfaces=interfaces or None,
144+
interfaces=interfaces_parsed or None,
137145
ssh_keys=ssh_keys or None,
138146
password=password,
139-
use_public_network=use_public_network,
140-
use_private_network=use_private_network,
141147
use_ipv6=use_ipv6,
142148
server_groups=server_groups or None,
143149
user_data=user_data,
@@ -166,14 +172,16 @@ def cmd_create(
166172
@server.command("update")
167173
@click.pass_obj
168174
def cmd_update(cloudscale, uuid, name, flavor, interfaces, wait, tags, clear_tags, clear_all_tags):
175+
interfaces_parsed = [parse_interface(i).as_json() for i in interfaces]
176+
169177
cloudscale.cmd_update(
170178
uuid=uuid,
171179
tags=tags,
172180
clear_tags=clear_tags,
173181
clear_all_tags=clear_all_tags,
174182
name=name,
175183
flavor=flavor,
176-
interfaces=interfaces or None,
184+
interfaces=interfaces_parsed or None,
177185
wait=wait,
178186
)
179187

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import re
2+
3+
class NetworkParserError(BaseException):
4+
pass
5+
6+
7+
class CloudscaleNetworkInterface():
8+
def __init__(self):
9+
self._uuid = None
10+
# List of addresses in the form:
11+
# [{subnet: UUID1, address: IPv4}, {subnet: UUID2, address: IPv4)]
12+
# start with empty list
13+
self._addresses = []
14+
self.assign_address = True
15+
16+
def set_uuid(self, uuid):
17+
self._uuid = uuid
18+
19+
def add_subnet(self, uuid, address):
20+
if uuid and address:
21+
self._addresses.append({"subnet": uuid, "address": address})
22+
elif uuid:
23+
self._addresses.append({"subnet": uuid})
24+
else:
25+
self._addresses.append({"address": address})
26+
27+
def as_json(self):
28+
if self._uuid and not self._addresses and self.assign_address:
29+
return {'network': self._uuid}
30+
elif self._uuid and self._addresses and self.assign_address:
31+
return {'network': self._uuid, 'addresses': self._addresses}
32+
elif not self._uuid and self.assign_address:
33+
return {'addresses': self._addresses}
34+
elif self._uuid and not self.assign_address:
35+
return {'network': self._uuid, 'addresses': []}
36+
raise NetworkParserError("Cannot create json string out of your interface definiton")
37+
38+
39+
class CloudscaleInterfaceParameterParser():
40+
def __init__(self, string):
41+
self._string = string
42+
self._cursor = 0
43+
44+
def peek_strings(self, *args):
45+
for arg in args:
46+
if self._string[self._cursor:].startswith(arg):
47+
return arg
48+
49+
def expect_string(self, arg):
50+
if arg and self._string[self._cursor:].startswith(arg):
51+
self._cursor += len(arg)
52+
return arg
53+
else:
54+
raise NetworkParserError(f"Expected '{arg}', but found '{self._string[self._cursor:]}' at position {self._cursor}")
55+
56+
def expect_uuid(self):
57+
m = re.match(r'(^[\w]*-[\w]*-[\w]*-[\w]*-[\w]*)', self._string[self._cursor:])
58+
try:
59+
uuid = str(m.group(0))
60+
self._cursor += len(uuid)
61+
return uuid
62+
except Exception:
63+
raise NetworkParserError(f"Expected valid UUID, but found {self._string[self._cursor:]}")
64+
65+
def expect_address(self):
66+
m = re.match(r'(^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})', self._string[self._cursor:])
67+
try:
68+
address = str(m.group(0))
69+
self._cursor += len(address)
70+
return address
71+
except Exception:
72+
raise NetworkParserError(f"Expected valid IPv4 address, but found '{self._string[self._cursor:]}'")
73+
74+
def expect_string_or_uuid(self, arg=None):
75+
try:
76+
return self.expect_string(arg)
77+
except NetworkParserError:
78+
try:
79+
return self.expect_uuid()
80+
except NetworkParserError:
81+
raise NetworkParserError(f"Expected UUID or '{arg}', but found '{self._string[self._cursor:]}' at position {self._cursor}")
82+
83+
def expect_end(self):
84+
if self._cursor != len(self._string):
85+
raise NetworkParserError(f"Expected the end, but found '{self._string[self._cursor:]}'")
86+
87+
def has_empty_address(self):
88+
if self._string[self._cursor:] == 'address=':
89+
return True
90+
else:
91+
return False
92+
93+
def parse_address(self):
94+
self.expect_string('address=')
95+
return self.expect_address()
96+
97+
def parse_subnet(self):
98+
self.expect_string('subnet=')
99+
uuid = self.expect_string_or_uuid()
100+
if self.peek_strings(')', ',', '') == ',':
101+
self.expect_string(',')
102+
return uuid, self.parse_address()
103+
return uuid, None
104+
105+
def parse_multiple_subnets(self, interface):
106+
while self.peek_strings('(') == '(':
107+
self.expect_string('(')
108+
subnet, address = self.parse_subnet()
109+
interface.add_subnet(subnet, address)
110+
self.expect_string(')')
111+
if self.peek_strings(',') == ',':
112+
self.expect_string(',')
113+
114+
def parse_interface(string):
115+
parser = CloudscaleInterfaceParameterParser(string)
116+
interface = CloudscaleNetworkInterface()
117+
type_ = parser.peek_strings('network', 'subnet','(')
118+
if type_ == 'network':
119+
parser.expect_string('network')
120+
parser.expect_string('=')
121+
interface.set_uuid(parser.expect_string_or_uuid('public'))
122+
if parser.peek_strings(',') == ',':
123+
parser.expect_string(',')
124+
if parser.peek_strings('(') != '(':
125+
type_ = parser.peek_strings('subnet', 'address')
126+
if type_ == 'subnet':
127+
subnet, address = parser.parse_subnet()
128+
interface.add_subnet(subnet, address)
129+
elif type_ == 'address':
130+
if parser.has_empty_address():
131+
parser.expect_string('address=')
132+
interface.assign_address = False
133+
else:
134+
raise NetworkParserError(f"Cannot set a fixed IP address without a subnet")
135+
else:
136+
parser.parse_multiple_subnets(interface)
137+
elif type_ == 'subnet':
138+
subnet, address = parser.parse_subnet()
139+
interface.add_subnet(subnet, address)
140+
elif type_ == '(':
141+
if not parser.peek_strings('(subnet') == '(subnet':
142+
raise NetworkParserError(f"Cannot add an interface without network or subnet definition")
143+
parser.parse_multiple_subnets(interface)
144+
else:
145+
raise NetworkParserError(f"Cannot add an interface without network or subnet definition")
146+
147+
parser.expect_end()
148+
return interface

cloudscale_cli/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = '1.3.1'
1+
__version__ = '1.4.0'

docs/server.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,51 @@ cloudscale server create \
5151
!!! tip
5252
To ensure all servers created have booted and are running by providing the option `--wait`.
5353

54+
## Usage of the `--interface` option in `server create` and `server update`
55+
If the `--interface` option is used, `--use-{public,private}-network` options are disabled.
56+
This are the examples from the api documentation:
57+
58+
* Create a public network interface with an automatically assigned IPv4 address and an IPv6 address if `use_ipv6` is set
59+
to true:
60+
~~~
61+
--interface network=public
62+
~~~
63+
64+
* Create a private network interface on the network identified by "UUID1". The interface will automatically be assigned an
65+
address from the DHCP range of the network's subnet.
66+
~~~
67+
--interface network=UUID1
68+
~~~
69+
70+
* Create a private network interface on the network on which the subnet identified by "UUID2" is configured. The interface
71+
will automatically be assigned an address from the DHCP range of the subnet.
72+
~~~
73+
--interface subnet=UUID2
74+
~~~
75+
76+
* This is equivalent to the schema defined above. It is only valid if the subnet identified by "UUID4" is configured on
77+
the network identified by "UUID3".
78+
~~~
79+
--interface network=UUID3,subnet=UUID4
80+
~~~
81+
82+
* Create a private network interface on the network on which the subnet identified by "UUID5" is configured. The interface
83+
will automatically be assigned the address A.B.C.D.
84+
~~~
85+
--interface subnet=UUID5,address=A.B.C.D
86+
~~~
87+
88+
* This is equivalent to the schema defined above. It is only valid if the subnet identified by "UUID7" is configured on
89+
the network identified by "UUID6".
90+
~~~
91+
--interface network=UUID6,subnet=UUID7,address=A.B.C.D
92+
~~~
93+
94+
* Create a private network interface on the network identified by "UUID8". No IP address will be assigned using DHCP.
95+
~~~
96+
--interface network=UUID8,address=
97+
~~~
98+
5499
## List Servers
55100

56101
Get a list as table view:
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import pytest
2+
import cloudscale_cli.interface_parameter_parser as p
3+
4+
@pytest.mark.parametrize(
5+
'input, output, error', [
6+
(
7+
'network=public',
8+
{'network': 'public'},
9+
None
10+
),
11+
(
12+
'network=2db69ba3-1864-4608-853a-0771b6885a3a',
13+
{'network': '2db69ba3-1864-4608-853a-0771b6885a3a'},
14+
None
15+
),
16+
(
17+
'subnet=8e79cc58-dc9f-4f68-aeae-e0eac3f06f16',
18+
{'addresses': [{'subnet': '8e79cc58-dc9f-4f68-aeae-e0eac3f06f16'}]},
19+
None
20+
),
21+
(
22+
'network=9a23808b-dd19-480f-9f55-5a945da4e819,subnet=8e79cc58-dc9f-4f68-aeae-e0eac3f06f16',
23+
{'network': '9a23808b-dd19-480f-9f55-5a945da4e819', 'addresses': [{'subnet': '8e79cc58-dc9f-4f68-aeae-e0eac3f06f16'}]},
24+
None
25+
),
26+
(
27+
'subnet=91dbef92-ddb1-47e0-8625-73326792d3e1,address=172.26.241.14',
28+
{'addresses': [{'subnet': '91dbef92-ddb1-47e0-8625-73326792d3e1', 'address': '172.26.241.14'}]},
29+
None
30+
),
31+
(
32+
'(subnet=91dbef92-ddb1-47e0-8625-73326792d3e1,address=172.26.241.14)',
33+
{'addresses': [{'subnet': '91dbef92-ddb1-47e0-8625-73326792d3e1', 'address': '172.26.241.14'}]},
34+
None
35+
),
36+
(
37+
'network=9a23808b-dd19-480f-9f55-5a945da4e819,subnet=8e79cc58-dc9f-4f68-aeae-e0eac3f06f16,address=172.21.67.54',
38+
{'network': '9a23808b-dd19-480f-9f55-5a945da4e819', 'addresses': [{'subnet': '8e79cc58-dc9f-4f68-aeae-e0eac3f06f16', 'address': '172.21.67.54'}]},
39+
None
40+
),
41+
(
42+
'network=9a23808b-dd19-480f-9f55-5a945da4e819,address=',
43+
{'network': '9a23808b-dd19-480f-9f55-5a945da4e819', 'addresses': []},
44+
None
45+
),
46+
(
47+
'network=9a23808b-dd19-480f-9f55-5a945da4e819,subnet=8e79cc58-dc9f-4f68-aeae-e0eac3f06f16,address=',
48+
None,
49+
"Expected valid IPv4 address, but found ''"
50+
),
51+
(
52+
'subnet=9a23808b-dd19-480f-9f55-5a945da4e819,address=',
53+
None,
54+
"Expected valid IPv4 address, but found ''"
55+
),
56+
(
57+
'subnet=91dbef92,address=172.26.241.14',
58+
None,
59+
"Expected UUID or 'None', but found '91dbef92,address=172.26.241.14' at position 7"
60+
),
61+
(
62+
'subnet=91dbef92-ddb1-47e0-8625-73326792d3e1,address=172.2226.241,XX',
63+
None,
64+
"Expected valid IPv4 address, but found '172.2226.241,XX'"
65+
),
66+
(
67+
'network=91dbef92-ddb1-47e0-8625-73326792d3e1,address=172.26.241.14',
68+
None,
69+
"Cannot set a fixed IP address without a subnet"
70+
),
71+
(
72+
'(address=172.26.241.14)',
73+
None,
74+
"Cannot add an interface without network or subnet definition"
75+
),
76+
(
77+
'address=172.26.241.14',
78+
None,
79+
"Cannot add an interface without network or subnet definition"
80+
),
81+
(
82+
'network=91dbef92-ddb1-47e0-8625-73326792d3e1,aaa',
83+
None,
84+
"Expected the end, but found 'aaa'"
85+
),
86+
('foo', None, "Cannot add an interface without network or subnet definition"),
87+
('network=foo', None, "Expected UUID or 'public', but found 'foo' at position 8"),
88+
(
89+
# It's unclear if the API will support this case without specifing a network.
90+
'(subnet=91dbef92-ddb1-47e0-8625-73326792d3e1,address=172.26.241.14),(subnet=91dbef92-ddb1-47e0-8625-73326792d3e2,address=172.26.241.15)',
91+
{'addresses': [{'subnet': '91dbef92-ddb1-47e0-8625-73326792d3e1', 'address': '172.26.241.14'}, \
92+
{'subnet': '91dbef92-ddb1-47e0-8625-73326792d3e2', 'address': '172.26.241.15'}]},
93+
None
94+
),
95+
]
96+
)
97+
98+
99+
def test_parser(input, output, error):
100+
if error is None:
101+
assert p.parse_interface(input).as_json() == output
102+
else:
103+
with pytest.raises(p.NetworkParserError) as e:
104+
p.parse_interface(input)
105+
assert str(e.value) == error

0 commit comments

Comments
 (0)