diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d33a86b..882b24e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -22,6 +22,7 @@ jobs: - py37 - py38 - py39 + - py310 - pypy3 steps: - uses: actions/checkout@v3 diff --git a/.gitignore b/.gitignore index 193319c..b38e915 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .gitignore __pycache__ ENV/ +.env .DS_Store *.py[cod] build/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 070e7af..311f350 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ---- +## [2.5.0] - 2023-07-31 + +### Added + +- Server group support +- Support for labels in servers, storages and server groups +- Server network interface support +- Server simple backup rule support +- Loadbalancer object support with labels included +- Manager support for listing server plans +- Manager support for load balancers with dictionary responses + +## [2.0.1] - 2023-03-22 + +### Added + +Support for metadata in server objects and server creation. + ## [2.0.0] - 2021-05-05 Python 2 is no longer supported. This is a maintenance release without diff --git a/README.md b/README.md index d43f787..7876b00 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Alternatively, if you want the newest (possibly not yet released) stuff, clone t python setup.py install ``` -### Supported Python versions in API v2.0.1 +### Supported Python versions in API v2.5.0 - Python 3.7 - Python 3.8 @@ -31,7 +31,7 @@ python setup.py install **Python 2 has been deprecated** -- Python 2.7 is supported in older API versions (< v2.0.0), still available in [PyPI](https://pypi.org/project/upcloud-api/1.0.1/). +- Python 2.7 is no longer supported, but available in older API versions (< v2.0.0). ## Changelog diff --git a/requirements-dev.txt b/requirements-dev.txt index d3b3c8d..f1c280e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,54 +4,62 @@ # # pip-compile requirements-dev.in # -attrs==20.3.0 - # via pytest -certifi==2020.12.5 +build==0.10.0 + # via pip-tools +certifi==2023.7.22 # via requests -chardet==4.0.0 +charset-normalizer==3.2.0 # via requests -click==7.1.2 +click==8.1.6 # via pip-tools -coverage==5.5 +coverage[toml]==7.2.7 # via pytest-cov -idna==2.10 +exceptiongroup==1.1.2 + # via pytest +idna==3.4 # via requests -iniconfig==1.1.1 +iniconfig==2.0.0 # via pytest -mock==4.0.3 +mock==5.1.0 # via -r requirements-dev.in -packaging==20.9 - # via pytest -pep517==0.10.0 - # via pip-tools -pip-tools==6.1.0 +packaging==23.1 + # via + # build + # pytest +pip-tools==6.14.0 # via -r requirements-dev.in -pluggy==0.13.1 - # via pytest -py==1.10.0 +pluggy==1.2.0 # via pytest -pyparsing==2.4.7 - # via packaging -pytest==6.2.3 +pyproject-hooks==1.0.0 + # via build +pytest==7.4.0 # via # -r requirements-dev.in # pytest-cov -pytest-cov==2.11.1 +pytest-cov==4.1.0 # via -r requirements-dev.in -requests==2.25.1 +pyyaml==6.0.1 # via responses -responses==0.13.2 - # via -r requirements-dev.in -six==1.15.0 +requests==2.31.0 # via responses -toml==0.10.2 +responses==0.23.2 + # via -r requirements-dev.in +tomli==2.0.1 # via - # pep517 + # build + # coverage + # pip-tools + # pyproject-hooks # pytest -urllib3==1.26.5 +types-pyyaml==6.0.12.11 + # via responses +urllib3==2.0.4 # via # requests # responses +wheel==0.41.0 + # via pip-tools # The following packages are considered to be unsafe in a requirements file: # pip +# setuptools diff --git a/test/json_data/plan.json b/test/json_data/plan.json new file mode 100644 index 0000000..491ff0a --- /dev/null +++ b/test/json_data/plan.json @@ -0,0 +1,238 @@ +{ + "plans" : { + "plan" : [ + { + "core_number" : 1, + "memory_amount" : 2048, + "name" : "1xCPU-2GB", + "public_traffic_out" : 2048, + "storage_size" : 50, + "storage_tier" : "maxiops" + }, + { + "core_number" : 1, + "memory_amount" : 1024, + "name" : "1xCPU-1GB", + "public_traffic_out" : 1024, + "storage_size" : 25, + "storage_tier" : "maxiops" + }, + { + "core_number" : 2, + "memory_amount" : 4096, + "name" : "2xCPU-4GB", + "public_traffic_out" : 4096, + "storage_size" : 80, + "storage_tier" : "maxiops" + }, + { + "core_number" : 2, + "memory_amount" : 16384, + "name" : "HIMEM-2xCPU-16GB", + "public_traffic_out" : 2048, + "storage_size" : 100, + "storage_tier" : "maxiops" + }, + { + "core_number" : 2, + "memory_amount" : 8192, + "name" : "HIMEM-2xCPU-8GB", + "public_traffic_out" : 2048, + "storage_size" : 100, + "storage_tier" : "maxiops" + }, + { + "core_number" : 4, + "memory_amount" : 65536, + "name" : "HIMEM-4xCPU-64GB", + "public_traffic_out" : 4096, + "storage_size" : 200, + "storage_tier" : "maxiops" + }, + { + "core_number" : 4, + "memory_amount" : 32768, + "name" : "HIMEM-4xCPU-32GB", + "public_traffic_out" : 4096, + "storage_size" : 100, + "storage_tier" : "maxiops" + }, + { + "core_number" : 4, + "memory_amount" : 8192, + "name" : "4xCPU-8GB", + "public_traffic_out" : 5120, + "storage_size" : 160, + "storage_tier" : "maxiops" + }, + { + "core_number" : 6, + "memory_amount" : 131072, + "name" : "HIMEM-6xCPU-128GB", + "public_traffic_out" : 6144, + "storage_size" : 300, + "storage_tier" : "maxiops" + }, + { + "core_number" : 6, + "memory_amount" : 16384, + "name" : "6xCPU-16GB", + "public_traffic_out" : 6144, + "storage_size" : 320, + "storage_tier" : "maxiops" + }, + { + "core_number" : 8, + "memory_amount" : 16384, + "name" : "HICPU-8xCPU-16GB", + "public_traffic_out" : 4096, + "storage_size" : 200, + "storage_tier" : "maxiops" + }, + { + "core_number" : 8, + "memory_amount" : 12288, + "name" : "HICPU-8xCPU-12GB", + "public_traffic_out" : 4096, + "storage_size" : 100, + "storage_tier" : "maxiops" + }, + { + "core_number" : 8, + "memory_amount" : 32768, + "name" : "8xCPU-32GB", + "public_traffic_out" : 7168, + "storage_size" : 640, + "storage_tier" : "maxiops" + }, + { + "core_number" : 8, + "memory_amount" : 196608, + "name" : "HIMEM-8xCPU-192GB", + "public_traffic_out" : 8192, + "storage_size" : 400, + "storage_tier" : "maxiops" + }, + { + "core_number" : 12, + "memory_amount" : 49152, + "name" : "12xCPU-48GB", + "public_traffic_out" : 9216, + "storage_size" : 960, + "storage_tier" : "maxiops" + }, + { + "core_number" : 12, + "memory_amount" : 262144, + "name" : "HIMEM-12xCPU-256GB", + "public_traffic_out" : 10240, + "storage_size" : 500, + "storage_tier" : "maxiops" + }, + { + "core_number" : 16, + "memory_amount" : 24576, + "name" : "HICPU-16xCPU-24GB", + "public_traffic_out" : 5120, + "storage_size" : 100, + "storage_tier" : "maxiops" + }, + { + "core_number" : 16, + "memory_amount" : 65536, + "name" : "16xCPU-64GB", + "public_traffic_out" : 10240, + "storage_size" : 1280, + "storage_tier" : "maxiops" + }, + { + "core_number" : 16, + "memory_amount" : 393216, + "name" : "HIMEM-16xCPU-384GB", + "public_traffic_out" : 12288, + "storage_size" : 600, + "storage_tier" : "maxiops" + }, + { + "core_number" : 16, + "memory_amount" : 32768, + "name" : "HICPU-16xCPU-32GB", + "public_traffic_out" : 5120, + "storage_size" : 200, + "storage_tier" : "maxiops" + }, + { + "core_number" : 24, + "memory_amount" : 98304, + "name" : "24xCPU-96GB", + "public_traffic_out" : 12288, + "storage_size" : 1920, + "storage_tier" : "maxiops" + }, + { + "core_number" : 32, + "memory_amount" : 49152, + "name" : "HICPU-32xCPU-48GB", + "public_traffic_out" : 6144, + "storage_size" : 200, + "storage_tier" : "maxiops" + }, + { + "core_number" : 32, + "memory_amount" : 65536, + "name" : "HICPU-32xCPU-64GB", + "public_traffic_out" : 6144, + "storage_size" : 300, + "storage_tier" : "maxiops" + }, + { + "core_number" : 32, + "memory_amount" : 131072, + "name" : "32xCPU-128GB", + "public_traffic_out" : 24576, + "storage_size" : 2048, + "storage_tier" : "maxiops" + }, + { + "core_number" : 38, + "memory_amount" : 196608, + "name" : "38xCPU-192GB", + "public_traffic_out" : 24576, + "storage_size" : 2048, + "storage_tier" : "maxiops" + }, + { + "core_number" : 48, + "memory_amount" : 262144, + "name" : "48xCPU-256GB", + "public_traffic_out" : 24576, + "storage_size" : 2048, + "storage_tier" : "maxiops" + }, + { + "core_number" : 64, + "memory_amount" : 98304, + "name" : "HICPU-64xCPU-96GB", + "public_traffic_out" : 7168, + "storage_size" : 200, + "storage_tier" : "maxiops" + }, + { + "core_number" : 64, + "memory_amount" : 393216, + "name" : "64xCPU-384GB", + "public_traffic_out" : 24576, + "storage_size" : 2048, + "storage_tier" : "maxiops" + }, + { + "core_number" : 64, + "memory_amount" : 131072, + "name" : "HICPU-64xCPU-128GB", + "public_traffic_out" : 7168, + "storage_size" : 300, + "storage_tier" : "maxiops" + } + ] + } +} diff --git a/test/json_data/server-group_0b5169fc-23aa-4ba7-aaab-f38868ce99cd.json b/test/json_data/server-group_0b5169fc-23aa-4ba7-aaab-f38868ce99cd.json new file mode 100644 index 0000000..e00c8d9 --- /dev/null +++ b/test/json_data/server-group_0b5169fc-23aa-4ba7-aaab-f38868ce99cd.json @@ -0,0 +1,31 @@ +{ + "server_group": { + "anti_affinity": "yes", + "anti_affinity_status": [ + { + "uuid": "0016dadf-eba4-4331-bd34-a361841f7af1", + "status": "met" + }, + { + "uuid": "00c77bbe-fc0e-436f-a753-37f5b5b76270", + "status": "met" + } + ], + "labels": { + "label": [ + { + "key": "foo", + "value": "bar" + } + ] + }, + "servers": { + "server": [ + "0016dadf-eba4-4331-bd34-a361841f7af2", + "00c77bbe-fc0e-436f-a753-37f5b5b76271" + ] + }, + "title": "test group", + "uuid": "0b5169fc-23aa-4ba7-aaab-f38868ce99cd" + } +} diff --git a/test/json_data/server-group_post.json b/test/json_data/server-group_post.json new file mode 100644 index 0000000..cadd543 --- /dev/null +++ b/test/json_data/server-group_post.json @@ -0,0 +1,15 @@ +{ + "server_group": { + "anti_affinity": "yes", + "labels": { + "label": [ + { + "key": "foo", + "value": "bar" + } + ] + }, + "title": "foo", + "uuid": "0b5169fc-23aa-4ba7-aaab-f38868ce99cd" + } +} diff --git a/test/json_data/server.json b/test/json_data/server.json index 76c904a..9dbedde 100644 --- a/test/json_data/server.json +++ b/test/json_data/server.json @@ -7,6 +7,14 @@ "core_number" : "0", "title" : "Helsinki server", "hostname" : "fi.example.com", + "labels": { + "label": [ + { + "key": "test", + "value": "example" + } + ] + }, "memory_amount" : "1024", "uuid" : "00798b85-efdc-41ca-8021-f6ef457b8531", "state" : "started", @@ -22,6 +30,9 @@ "core_number" : "0", "title" : "London server", "hostname" : "uk.example.com", + "labels": { + "label": [] + }, "memory_amount" : "1024", "uuid" : "009d64ef-31d1-4684-a26b-c86c955cbf46", "state" : "stopped", diff --git a/test/json_data/server_00798b85-efdc-41ca-8021-f6ef457b8531.json b/test/json_data/server_00798b85-efdc-41ca-8021-f6ef457b8531.json index 2e368eb..e958339 100644 --- a/test/json_data/server_00798b85-efdc-41ca-8021-f6ef457b8531.json +++ b/test/json_data/server_00798b85-efdc-41ca-8021-f6ef457b8531.json @@ -18,9 +18,18 @@ } ] }, + "labels": { + "label": [ + { + "key": "test", + "value": "example" + } + ] + }, "license" : 0, "memory_amount" : "1024", "nic_model" : "e1000", + "simple_backup": "0430,monthlies", "state" : "started", "storage_devices" : { "storage_device" : [ diff --git a/test/json_data/server_00798b85-efdc-41ca-8021-f6ef457b8531_cdrom_eject_post.json b/test/json_data/server_00798b85-efdc-41ca-8021-f6ef457b8531_cdrom_eject_post.json index 2e368eb..ce9dc70 100644 --- a/test/json_data/server_00798b85-efdc-41ca-8021-f6ef457b8531_cdrom_eject_post.json +++ b/test/json_data/server_00798b85-efdc-41ca-8021-f6ef457b8531_cdrom_eject_post.json @@ -18,6 +18,14 @@ } ] }, + "labels": { + "label": [ + { + "key": "test", + "value": "example" + } + ] + }, "license" : 0, "memory_amount" : "1024", "nic_model" : "e1000", diff --git a/test/json_data/server_00798b85-efdc-41ca-8021-f6ef457b8531_cdrom_load_post.json b/test/json_data/server_00798b85-efdc-41ca-8021-f6ef457b8531_cdrom_load_post.json index 5d725f4..6165ce9 100644 --- a/test/json_data/server_00798b85-efdc-41ca-8021-f6ef457b8531_cdrom_load_post.json +++ b/test/json_data/server_00798b85-efdc-41ca-8021-f6ef457b8531_cdrom_load_post.json @@ -18,6 +18,14 @@ } ] }, + "labels": { + "label": [ + { + "key": "test", + "value": "example" + } + ] + }, "license" : 0, "memory_amount" : "1024", "nic_model" : "e1000", diff --git a/test/json_data/server_009d64ef-31d1-4684-a26b-c86c955cbf46.json b/test/json_data/server_009d64ef-31d1-4684-a26b-c86c955cbf46.json index 59d3a32..80a0d56 100644 --- a/test/json_data/server_009d64ef-31d1-4684-a26b-c86c955cbf46.json +++ b/test/json_data/server_009d64ef-31d1-4684-a26b-c86c955cbf46.json @@ -16,6 +16,14 @@ } ] }, + "labels": { + "label": [ + { + "key": "test", + "value": "example" + } + ] + }, "license" : 0, "memory_amount" : "1024", "nic_model" : "e1000", diff --git a/test/json_data/server_create.json b/test/json_data/server_create.json index e86bb5c..93e494f 100644 --- a/test/json_data/server_create.json +++ b/test/json_data/server_create.json @@ -16,10 +16,19 @@ } ] }, + "labels": { + "label": [ + { + "key": "test", + "value": "example" + } + ] + }, "license" : 0, "memory_amount" : "1024", "nic_model" : "virtio", "state" : "started", + "simple_backup": "0430,monthlies", "storage_devices" : { "storage_device" : [ { diff --git a/test/json_data/storage_01350eec-6ebf-4418-abe4-e8bb1d5c9643.json b/test/json_data/storage_01350eec-6ebf-4418-abe4-e8bb1d5c9643.json index a96e2ad..085316a 100644 --- a/test/json_data/storage_01350eec-6ebf-4418-abe4-e8bb1d5c9643.json +++ b/test/json_data/storage_01350eec-6ebf-4418-abe4-e8bb1d5c9643.json @@ -1,16 +1,24 @@ -{"storage": - { - "access": "private", - "created": "2020-09-21T17:36:50Z", - "license": 0, - "origin": "01ec5c26-a25d-4752-94e4-27bd88b62816", - "progress": "0", - "servers": {"server": []}, - "size": 666, - "state": "maintenance", - "title": "test-backup", - "type": "backup", - "uuid": "01350eec-6ebf-4418-abe4-e8bb1d5c9643", - "zone": "fi-hel1" +{ + "storage": { + "access": "private", + "created": "2020-09-21T17:36:50Z", + "labels": [ + { + "key": "role", + "value": "primary" + } + ], + "license": 0, + "origin": "01ec5c26-a25d-4752-94e4-27bd88b62816", + "progress": "0", + "servers": { + "server": [] + }, + "size": 666, + "state": "maintenance", + "title": "test-backup", + "type": "backup", + "uuid": "01350eec-6ebf-4418-abe4-e8bb1d5c9643", + "zone": "fi-hel1" } } diff --git a/test/json_data/storage_01d4fcd4-e446-433b-8a9c-551a1284952e.json b/test/json_data/storage_01d4fcd4-e446-433b-8a9c-551a1284952e.json index be29ecf..cc62d8d 100644 --- a/test/json_data/storage_01d4fcd4-e446-433b-8a9c-551a1284952e.json +++ b/test/json_data/storage_01d4fcd4-e446-433b-8a9c-551a1284952e.json @@ -5,6 +5,12 @@ "backups" : { "backup" : [] }, + "labels": [ + { + "key": "role", + "value": "primary" + } + ], "license" : 0, "servers" : { "server" : [ @@ -19,4 +25,4 @@ "uuid" : "01d4fcd4-e446-433b-8a9c-551a1284952e", "zone" : "fi-hel1" } -} \ No newline at end of file +} diff --git a/test/json_data/storage_01d4fcd4-e446-433b-8a9c-551a1284952e_backup_post.json b/test/json_data/storage_01d4fcd4-e446-433b-8a9c-551a1284952e_backup_post.json index a96e2ad..44e6bce 100644 --- a/test/json_data/storage_01d4fcd4-e446-433b-8a9c-551a1284952e_backup_post.json +++ b/test/json_data/storage_01d4fcd4-e446-433b-8a9c-551a1284952e_backup_post.json @@ -1,16 +1,24 @@ {"storage": { - "access": "private", - "created": "2020-09-21T17:36:50Z", - "license": 0, - "origin": "01ec5c26-a25d-4752-94e4-27bd88b62816", - "progress": "0", - "servers": {"server": []}, - "size": 666, - "state": "maintenance", - "title": "test-backup", - "type": "backup", - "uuid": "01350eec-6ebf-4418-abe4-e8bb1d5c9643", - "zone": "fi-hel1" + "access": "private", + "created": "2020-09-21T17:36:50Z", + "labels": [ + { + "key": "role", + "value": "primary" + } + ], + "license": 0, + "origin": "01ec5c26-a25d-4752-94e4-27bd88b62816", + "progress": "0", + "servers": { + "server": [] + }, + "size": 666, + "state": "maintenance", + "title": "test-backup", + "type": "backup", + "uuid": "01350eec-6ebf-4418-abe4-e8bb1d5c9643", + "zone": "fi-hel1" } } diff --git a/test/json_data/storage_01d4fcd4-e446-433b-8a9c-551a1284952e_clone_post.json b/test/json_data/storage_01d4fcd4-e446-433b-8a9c-551a1284952e_clone_post.json index 645397d..21497d6 100644 --- a/test/json_data/storage_01d4fcd4-e446-433b-8a9c-551a1284952e_clone_post.json +++ b/test/json_data/storage_01d4fcd4-e446-433b-8a9c-551a1284952e_clone_post.json @@ -4,7 +4,13 @@ "backup_rule": "", "backups" : { "backup" : [] - }, + }, + "labels": [ + { + "key": "role", + "value": "primary" + } + ], "license" : 0, "servers" : { "server" : [ diff --git a/test/json_data/storage_01d4fcd4-e446-433b-8a9c-551a1284952e_templatize_post.json b/test/json_data/storage_01d4fcd4-e446-433b-8a9c-551a1284952e_templatize_post.json index af564c8..ca13a8c 100644 --- a/test/json_data/storage_01d4fcd4-e446-433b-8a9c-551a1284952e_templatize_post.json +++ b/test/json_data/storage_01d4fcd4-e446-433b-8a9c-551a1284952e_templatize_post.json @@ -1,14 +1,22 @@ { "storage": { - "access": "private", - "license": 0, - "servers": {"server": []}, - "size": 666, - "state": "maintenance", - "tier": "maxiops", - "title": "my server template", - "type": "template", - "uuid": "013721b5-07ca-4d7b-b4ff-e21262223e5b", - "zone": "fi-hel1" + "access": "private", + "labels": [ + { + "key": "role", + "value": "primary" + } + ], + "license": 0, + "servers": { + "server": [] + }, + "size": 666, + "state": "maintenance", + "tier": "maxiops", + "title": "my server template", + "type": "template", + "uuid": "013721b5-07ca-4d7b-b4ff-e21262223e5b", + "zone": "fi-hel1" } } diff --git a/test/json_data/storage_attach.json b/test/json_data/storage_attach.json index abfc4c7..e784f57 100644 --- a/test/json_data/storage_attach.json +++ b/test/json_data/storage_attach.json @@ -16,6 +16,14 @@ } ] }, + "labels": { + "label": [ + { + "key": "test", + "value": "example" + } + ] + }, "license" : 0, "memory_amount" : "1024", "nic_model" : "e1000", @@ -24,12 +32,24 @@ "storage_device" : [ { "address" : "virtio:0", + "labels": [ + { + "key": "role", + "value": "primary" + } + ], "storage" : "012580a1-32a1-466e-a323-689ca16f2d43", "storage_size" : 100, "storage_title" : "Storage for server1.example.com", "type" : "disk" }, { + "labels": [ + { + "key": "role", + "value": "secondary" + } + ], "storage_size": 10, "storage": "01d4fcd4-e446-433b-8a9c-551a1284952e", "storage_title": "Operating system disk", @@ -48,4 +68,4 @@ "vnc_port" : "00000", "zone" : "fi-hel1" } -} \ No newline at end of file +} diff --git a/test/json_data/storage_detach.json b/test/json_data/storage_detach.json index 41d5354..b41c7a7 100644 --- a/test/json_data/storage_detach.json +++ b/test/json_data/storage_detach.json @@ -16,6 +16,14 @@ } ] }, + "labels": { + "label": [ + { + "key": "test", + "value": "example" + } + ] + }, "license" : 0, "memory_amount" : "1024", "nic_model" : "e1000", @@ -24,6 +32,12 @@ "storage_device" : [ { "address" : "virtio:0", + "labels": [ + { + "key": "role", + "value": "primary" + } + ], "storage" : "012580a1-32a1-466e-a323-689ca16f2d43", "storage_size" : 100, "storage_title" : "Storage for server1.example.com", @@ -41,4 +55,4 @@ "vnc_port" : "00000", "zone" : "fi-hel1" } -} \ No newline at end of file +} diff --git a/test/json_data/storage_post.json b/test/json_data/storage_post.json index 978b051..33cec53 100644 --- a/test/json_data/storage_post.json +++ b/test/json_data/storage_post.json @@ -5,6 +5,12 @@ "backups": { "backup": [] }, + "labels": [ + { + "key": "role", + "value": "primary" + } + ], "license": 0, "servers": { "server": [] @@ -17,4 +23,4 @@ "uuid": "013ff32b-5e17-4b8e-918b-f1ea996fa82e", "zone": "fi-hel1" } -} \ No newline at end of file +} diff --git a/test/test_cloud_manager.py b/test/test_cloud_manager.py index f124878..1d99f02 100644 --- a/test/test_cloud_manager.py +++ b/test/test_cloud_manager.py @@ -34,3 +34,10 @@ def test_get_timezones(self, manager): res = manager.get_timezones() assert json.loads(data) == res + + @responses.activate + def test_get_plans(self, manager): + data = Mock.mock_get("plan") + + res = manager.get_server_plans() + assert json.loads(data) == res diff --git a/test/test_server.py b/test/test_server.py index 72ebc69..f56eeec 100644 --- a/test/test_server.py +++ b/test/test_server.py @@ -11,6 +11,8 @@ def test_get_server(self, manager): assert type(server).__name__ == 'Server' assert server.uuid == '00798b85-efdc-41ca-8021-f6ef457b8531' + assert len(server.labels['label']) == 1 + assert server.labels['label'][0]['value'] == "example" @responses.activate def test_get_unpopulated_servers(self, manager): diff --git a/test/test_server_creation.py b/test/test_server_creation.py index 7862146..5025e96 100644 --- a/test/test_server_creation.py +++ b/test/test_server_creation.py @@ -36,6 +36,7 @@ def test_server_init(self, manager): Storage(os='01000000-0000-4000-8000-000030200200', size=10), Storage(size=100, title='storage disk 1'), ], + simple_backup='0430,monthlies', ) assert server1.title == 'my.example.com' @@ -43,6 +44,7 @@ def test_server_init(self, manager): assert server1.memory_amount == 1024 assert server1.hostname == server1.title assert server1.zone == 'us-chi1' + assert server1.simple_backup == '0430,monthlies' def test_server_prepare_post_body(self): server = Server( @@ -188,6 +190,7 @@ def test_create_server_with_dict(self, manager): 'memory_amount': 1024, 'hostname': 'my.example.com', 'zone': 'us-chi1', + 'simple_backup': '0430,monthlies', 'storage_devices': [ {'os': '01000000-0000-4000-8000-000030200200', 'size': 10}, {'size': 100, 'title': 'storage disk 1'}, @@ -210,6 +213,8 @@ def test_create_server_with_dict(self, manager): assert server1.vnc == 'off' assert server1.vnc_password == 'aabbccdd' + assert server1.simple_backup == '0430,monthlies' + @responses.activate def test_create_server_from_template(self, manager): UUID = '01215a5a-c330-4565-81ca-0e0e22eac672' diff --git a/test/test_server_group.py b/test/test_server_group.py new file mode 100644 index 0000000..3664783 --- /dev/null +++ b/test/test_server_group.py @@ -0,0 +1,34 @@ +import responses +from conftest import Mock + +from upcloud_api import Label, ServerGroup, ServerGroupAffinityPolicy + + +class TestServerGroup: + @responses.activate + def test_get_server_group(self, manager): + Mock.mock_get("server-group/0b5169fc-23aa-4ba7-aaab-f38868ce99cd") + server_group = manager.get_server_group("0b5169fc-23aa-4ba7-aaab-f38868ce99cd") + + assert type(server_group).__name__ == "ServerGroup" + assert server_group.uuid == "0b5169fc-23aa-4ba7-aaab-f38868ce99cd" + assert server_group.title == "test group" + + @responses.activate + def test_create_server_group(self, manager): + Mock.mock_post("server-group", ignore_data_field=True) + server_group = ServerGroup( + title="rykelma", + labels=[Label('foo', 'bar')], + anti_affinity=ServerGroupAffinityPolicy.ANTI_AFFINITY_PREFERRED, + ) + created_group = manager.create_server_group(server_group) + + assert created_group.uuid == "0b5169fc-23aa-4ba7-aaab-f38868ce99cd" + assert created_group.title == "foo" + + @responses.activate + def test_delete_server_group(self, manager): + Mock.mock_delete("server-group/0b5169fc-23aa-4ba7-aaab-f38868ce99cd") + res = manager.delete_server_group("0b5169fc-23aa-4ba7-aaab-f38868ce99cd") + assert res == {} diff --git a/tox.ini b/tox.ini index 2f685e5..3c70186 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,7 @@ # and then run "tox" from this directory. [tox] -envlist = py37, py38, py39, pypy3 +envlist = py37, py38, py39, py310, pypy3 skip_missing_interpreters = True [testenv] diff --git a/upcloud_api/__init__.py b/upcloud_api/__init__.py index 84828ca..b395412 100644 --- a/upcloud_api/__init__.py +++ b/upcloud_api/__init__.py @@ -2,7 +2,7 @@ Python Interface to UpCloud's API. """ -__version__ = '2.0.1' +__version__ = '2.5.0' __author__ = 'Developers from UpCloud & elsewhere' __author_email__ = 'hello@upcloud.com' __maintainer__ = 'UpCloud' @@ -17,10 +17,20 @@ from upcloud_api.interface import Interface from upcloud_api.ip_address import IPAddress from upcloud_api.ip_network import IpNetwork +from upcloud_api.label import Label +from upcloud_api.load_balancer import ( + LoadBalancer, + LoadBalancerBackend, + LoadBalancerBackendMember, + LoadBalancerFrontend, + LoadBalancerFrontEndRule, + LoadBalancerNetwork, +) from upcloud_api.network import Network from upcloud_api.object_storage import ObjectStorage from upcloud_api.router import Router -from upcloud_api.server import Server, login_user_block +from upcloud_api.server import Server, ServerNetworkInterface, login_user_block +from upcloud_api.server_group import ServerGroup, ServerGroupAffinityPolicy from upcloud_api.storage import Storage from upcloud_api.storage_import import StorageImport from upcloud_api.tag import Tag diff --git a/upcloud_api/api.py b/upcloud_api/api.py index 781aa1d..b9c7c82 100644 --- a/upcloud_api/api.py +++ b/upcloud_api/api.py @@ -86,7 +86,13 @@ def __error_middleware(self, res, res_json): """ Middleware that raises an exception when HTTP statuscode is an error code. """ - if res.status_code in [400, 401, 402, 403, 404, 405, 406, 409]: + if res.status_code >= 400: + if res_json.get('type'): + raise UpCloudAPIError( + error_code=res_json.get('title'), + error_message=f'Details: {json.dumps(res_json)}', + ) + err_dict = res_json.get('error', {}) raise UpCloudAPIError( error_code=err_dict.get('error_code'), error_message=err_dict.get('error_message') diff --git a/upcloud_api/cloud_manager/__init__.py b/upcloud_api/cloud_manager/__init__.py index c525d0e..8860031 100644 --- a/upcloud_api/cloud_manager/__init__.py +++ b/upcloud_api/cloud_manager/__init__.py @@ -4,6 +4,7 @@ from upcloud_api.cloud_manager.firewall_mixin import FirewallManager from upcloud_api.cloud_manager.host_mixin import HostManager from upcloud_api.cloud_manager.ip_address_mixin import IPManager +from upcloud_api.cloud_manager.lb_mixin import LoadBalancerManager from upcloud_api.cloud_manager.network_mixin import NetworkManager from upcloud_api.cloud_manager.object_storage_mixin import ObjectStorageManager from upcloud_api.cloud_manager.server_mixin import ServerManager @@ -12,14 +13,15 @@ class CloudManager( - ServerManager, - IPManager, - StorageManager, FirewallManager, - TagManager, - NetworkManager, HostManager, + IPManager, + LoadBalancerManager, + NetworkManager, ObjectStorageManager, + ServerManager, + StorageManager, + TagManager, ): """ CloudManager contains the core functionality of the upcloud API library. @@ -82,3 +84,10 @@ def get_server_sizes(self): Returns a list of available server configurations. """ return self.api.get_request('/server_size') + + def get_server_plans(self): + """ + Returns a list of available server plans + :return: + """ + return self.api.get_request('/plan') diff --git a/upcloud_api/cloud_manager/lb_mixin.py b/upcloud_api/cloud_manager/lb_mixin.py new file mode 100644 index 0000000..472d323 --- /dev/null +++ b/upcloud_api/cloud_manager/lb_mixin.py @@ -0,0 +1,109 @@ +from upcloud_api.api import API +from upcloud_api.load_balancer import LoadBalancer, LoadBalancerBackend + + +class LoadBalancerManager: + """ + Functions for managing UpCloud loadbalancer instances and their properties with basic dictionary objects + """ + + api: API + + def get_loadbalancers(self): + """ + Returns a list of loadbalancer dictionary objects + """ + + url = '/load-balancer' + return self.api.get_request(url) + + def get_loadbalancer(self, lb_uuid: str): + """ + Returns details for a single loadbalancer as a dictionary + + :param lb_uuid: + :return: LB details + """ + url = f'/load-balancer/{lb_uuid}' + return self.api.get_request(url) + + def create_loadbalancer(self, body: LoadBalancer): + """ + Creates a loadbalancer service specified in body and returns its details + + :param body: + :return: LB details + """ + + url = '/load-balancer' + return self.api.post_request(url, body.to_dict()) + + def delete_loadbalancer(self, lb_uuid: str): + """ + Deletes a loadbalancer service + + :param lb_uuid: + """ + + url = f'/load-balancer/{lb_uuid}' + return self.api.delete_request(url) + + def get_loadbalancer_backends(self, lb_uuid: str): + """ + Returns a list of backends for a loadbalancer service + + :param lb_uuid: + :return: List of LB backends + """ + + url = f'/load-balancer/{lb_uuid}/backends' + return self.api.get_request(url) + + def get_loadbalancer_backend(self, lb_uuid: str, backend: LoadBalancerBackend): + """ + Returns details for a single loadbalancer backend + + :param lb_uuid: + :param backend: + :return: LB backend details + """ + + url = f'/load-balancer/{lb_uuid}/backends/{backend.name}' + return self.api.get_request(url) + + def create_loadbalancer_backend(self, lb_uuid: str, body: LoadBalancerBackend): + """ + Creates a new backend for a loadbalancer and returns its details + + :param lb_uuid: + :param body: + :return: LB backend details + """ + + url = f'/load-balancer/{lb_uuid}/backends' + return self.api.post_request(url, body.to_dict()) + + def modify_loadbalancer_backend(self, lb_uuid: str, backend: str, body: LoadBalancerBackend): + """ + Modifies an existing loadbalancer backend and returns its details + + :param lb_uuid: + :param backend: + :param body: + :return: LB backend details + """ + + url = f'/load-balancer/{lb_uuid}/backends/{backend}' + return self.api.patch_request(url, body.to_dict()) + + def delete_loadbalancer_backend(self, lb_uuid: str, backend: str): + """ + Deletes a loadbalancer backend + + :param lb_uuid: + :param backend: + :return: + """ + + url = f'/load-balancer/{lb_uuid}/backends/{backend}' + return self.api.delete_request(url) diff --git a/upcloud_api/cloud_manager/server_mixin.py b/upcloud_api/cloud_manager/server_mixin.py index d840b5c..a8f2460 100644 --- a/upcloud_api/cloud_manager/server_mixin.py +++ b/upcloud_api/cloud_manager/server_mixin.py @@ -1,6 +1,7 @@ from upcloud_api.api import API from upcloud_api.ip_address import IPAddress from upcloud_api.server import Server +from upcloud_api.server_group import ServerGroup from upcloud_api.storage import BackupDeletionPolicy, Storage @@ -91,9 +92,10 @@ def create_server(self, server: Server) -> Server: memory_amount = 1024, hostname = "my.example.1", zone = "uk-lon1", + labels = [Label('role', 'example')], storage_devices = [ Storage(os = "01000000-0000-4000-8000-000030200200", size=10, tier=maxiops, title='Example OS disk'), - Storage(size=10), + Storage(size=10, labels=[Label('usage', 'data_disk')]), Storage() title = "My Example Server" ]) @@ -191,3 +193,27 @@ def get_server_data(self, uuid: str): storages = Storage._create_storage_objs(server.pop('storage_devices'), cloud_manager=self) return server, IPAddresses, storages + + def create_server_group(self, server_group: ServerGroup) -> ServerGroup: + """ + Creates a new server group. Allows including servers and defining labels. + """ + body = server_group.to_dict() + + res = self.api.post_request('/server-group', body) + return ServerGroup(cloud_manager=self, **res['server_group']) + + def get_server_group(self, uuid: str) -> ServerGroup: + """ + Fetches server group details and returns a ServerGroup object. + """ + data = self.api.get_request(f'/server-group/{uuid}') + return ServerGroup(cloud_manager=self, **data['server_group']) + + def delete_server_group(self, uuid: str): + """ + DELETE '/server-group/UUID'. Destroys the server group, but not attached servers. + + Returns an empty object. + """ + return self.api.delete_request(f'/server-group/{uuid}') diff --git a/upcloud_api/label.py b/upcloud_api/label.py new file mode 100644 index 0000000..b159001 --- /dev/null +++ b/upcloud_api/label.py @@ -0,0 +1,35 @@ +from upcloud_api.upcloud_resource import UpCloudResource + + +class Label(UpCloudResource): + """ + Class representation of UpCloud resource label + """ + + ATTRIBUTES = { + 'key': "", + 'value': "", + } + + def __init__(self, key="", value="") -> None: + """ + Initialize label. + + Set both values for label if given + """ + self.key = key + self.value = value + + def __str__(self) -> str: + return f"{self.key}={self.value}" + + def to_dict(self): + """ + Return a dict that can be serialised to JSON and sent to UpCloud's API. + """ + body = { + 'key': self.key, + 'value': self.value, + } + + return body diff --git a/upcloud_api/load_balancer.py b/upcloud_api/load_balancer.py new file mode 100644 index 0000000..415b0a7 --- /dev/null +++ b/upcloud_api/load_balancer.py @@ -0,0 +1,238 @@ +from upcloud_api.label import Label +from upcloud_api.upcloud_resource import UpCloudResource + + +class LoadBalancerFrontEndRule(UpCloudResource): + """ + Class representation of loadbalancer frontend rule + """ + + ATTRIBUTES = { + 'name': '', + 'priority': 0, + 'matchers': [], + 'actions': [], + } + + def to_dict(self): + """ + Returns a dictionary object that adheres to UpCloud API json spec + """ + return { + 'name': self.name, + 'priority': getattr(self, 'priority', 0), + 'actions': getattr(self, 'actions', []), + 'matchers': getattr(self, 'matchers', []), + } + + +class LoadBalancerFrontend(UpCloudResource): + """ + Class representation of loadbalancer frontend + """ + + ATTRIBUTES = { + 'name': '', + 'mode': '', + 'port': 0, + 'default_backend': '', + 'networks': [], + 'rules': None, + 'tls_configs': None, + 'properties': None, + } + + def to_dict(self): + """ + Returns a dictionary object that adheres to UpCloud API json spec + """ + body = { + 'name': self.name, + 'mode': self.mode, + 'port': self.port, + 'networks': self.networks, + 'default_backend': self.default_backend, + } + + if hasattr(self, 'rules'): + fe_rules = [] + for rule in self.rules: + if isinstance(rule, LoadBalancerFrontEndRule): + rule = rule.to_dict() + fe_rules.append(rule) + + body['rules'] = fe_rules + + if hasattr(self, 'tls_configs'): + body['tls_configs'] = self.tls_configs + if hasattr(self, 'properties'): + body['properties'] = self.properties + + return body + + +class LoadBalancerBackendMember(UpCloudResource): + """ + Class representation of loadbalancer backend member + """ + + ATTRIBUTES = { + 'name': '', + 'type': 'static', + 'ip': None, + 'port': None, + 'weight': 1, + 'max_sessions': 100, + 'enabled': True, + } + + def to_dict(self): + """ + Returns a dictionary object that adheres to UpCloud API json spec + """ + body = { + 'name': self.name, + 'type': getattr(self, 'type', 'static'), + 'weight': getattr(self, 'weight', 1), + 'max_sessions': getattr(self, 'max_sessions', 100), + 'enabled': getattr(self, 'enabled', True), + } + + if hasattr(self, 'ip'): + body['ip'] = self.ip + if hasattr(self, 'port'): + body['port'] = self.port + + return body + + +class LoadBalancerBackend(UpCloudResource): + """ + Class representation of loadbalancer backend + """ + + ATTRIBUTES = { + 'name': '', + 'members': [], + 'resolver': None, + 'properties': None, + } + + def to_dict(self): + """ + Returns a dictionary object that adheres to UpCloud API json spec + """ + be_members = [] + for member in self.members: + if isinstance(member, LoadBalancerBackendMember): + member = member.to_dict() + be_members.append(member) + + body = { + 'name': self.name, + 'members': be_members, + } + + if hasattr(self, 'resolver'): + body['resolver'] = self.resolver + if hasattr(self, 'properties'): + body['properties'] = self.properties + + return body + + +class LoadBalancerNetwork(UpCloudResource): + """ + Class representation of networks loadbalancer is attached to + """ + + ATTRIBUTES = { + 'name': '', + 'type': '', + 'family': 'IPv4', + 'uuid': None, + } + + def to_dict(self): + """ + Returns a dictionary object that adheres to UpCloud API json spec + """ + body = { + 'name': self.name, + 'type': self.type, + 'family': getattr(self, 'family', 'IPv4'), + } + + if hasattr(self, 'uuid'): + body['uuid'] = self.uuid + + return body + + +class LoadBalancer(UpCloudResource): + """ + Class representation of UpCloud loadbalancer + """ + + ATTRIBUTES = { + 'name': '', + 'zone': '', + 'plan': 'development', + 'configured_status': 'started', + 'networks': [], + 'frontends': None, + 'backends': None, + 'resolvers': None, + 'labels': None, + 'maintenance_dow': None, + 'maintenance_time': None, + } + + def to_dict(self): + """ + Returns a dictionary object that adheres to UpCloud API json spec + """ + nets = [] + for net in self.networks: + if isinstance(net, LoadBalancerNetwork): + net = net.to_dict() + nets.append(net) + + body = { + 'name': self.name, + 'zone': self.zone, + 'plan': getattr(self, 'plan', 'development'), + 'configured_status': getattr(self, 'configured_status', 'started'), + 'networks': nets, + } + + if hasattr(self, 'frontends'): + lb_fe = [] + for fe in self.frontends: + if isinstance(fe, LoadBalancerFrontend): + fe = fe.to_dict() + lb_fe.append(fe) + body['frontends'] = lb_fe + + if hasattr(self, 'backends'): + lb_be = [] + for be in self.backends: + if isinstance(be, LoadBalancerBackend): + be = be.to_dict() + lb_be.append(be) + body['backends'] = lb_be + + if hasattr(self, 'labels'): + lb_labels = [] + for label in self.labels: + if isinstance(label, Label): + label = label.to_dict() + lb_labels.append(label) + body['labels'] = lb_labels + + if hasattr(self, 'maintenance_dow'): + body['maintenance_dow'] = self.maintenance_dow + if hasattr(self, 'maintenance_time'): + body['maintenance_time'] = self.maintenance_time + + return body diff --git a/upcloud_api/server.py b/upcloud_api/server.py index d26f4ac..1815d0e 100644 --- a/upcloud_api/server.py +++ b/upcloud_api/server.py @@ -3,7 +3,9 @@ from upcloud_api.firewall import FirewallRule from upcloud_api.ip_address import IPAddress +from upcloud_api.server_group import ServerGroup from upcloud_api.storage import Storage +from upcloud_api.upcloud_resource import UpCloudResource from upcloud_api.utils import try_it_n_times if TYPE_CHECKING: @@ -27,6 +29,47 @@ def login_user_block(username, ssh_keys, create_password=False): return block +class ServerNetworkInterface(UpCloudResource): + """ + Class representation of server network interface + """ + + ATTRIBUTES = { + 'ip_addresses': [], + 'type': 'public', + 'network': None, + 'source_ip_filtering': 'yes', + } + + def __init__(self, raw_dict, **kwargs): + """ + Initialize network interface and set sane defaults + """ + super().__init__(**kwargs) + for k, v in raw_dict.items(): + if k in ServerNetworkInterface.ATTRIBUTES: + setattr(self, k, v) + + if not raw_dict.get('ip_addresses'): + self.ip_addresses = [{'family': 'IPv4'}] + + def to_dict(self): + """ + Returns a dict implementation of a network interface to support server creation. + """ + body = { + 'type': self.type, + 'ip_addresses': { + 'ip_address': self.ip_addresses, + }, + } + + if hasattr(self, 'network'): + body['network'] = self.network + + return body + + # TODO: should this inherit from UpcloudResource too? class Server: """ @@ -47,31 +90,37 @@ class Server: 'core_number', 'firewall', 'hostname', + 'labels', 'memory_amount', 'nic_model', + 'plan', + 'simple_backup', 'title', 'timezone', 'video_model', 'vnc', 'vnc_password', - 'plan', ] optional_fields = [ - 'plan', - 'core_number', - 'memory_amount', + 'avoid_host', 'boot_order', + 'core_number', 'firewall', + 'labels', + 'login_user', + 'memory_amount', + 'networking', 'nic_model', + 'password_delivery', + 'plan', + 'server_group', + 'simple_backup', 'timezone', + 'metadata', + 'user_data', 'video_model', 'vnc_password', - 'password_delivery', - 'avoid_host', - 'login_user', - 'user_data', - 'metadata', ] def __init__(self, server=None, **kwargs) -> None: @@ -149,11 +198,11 @@ def save(self) -> None: self.cloud_manager.modify_server(self.uuid, **kwargs) self._reset(kwargs) - def destroy(self): + def destroy(self, delete_storages=False): """ Destroy the server. """ - self.cloud_manager.delete_server(self.uuid) + self.cloud_manager.delete_server(self.uuid, delete_storages=delete_storages) def shutdown(self, hard: bool = False, timeout: int = 30) -> None: """ @@ -335,6 +384,18 @@ def prepare_post_body(self): if hasattr(self, optional_field): body['server'][optional_field] = getattr(self, optional_field) + if hasattr(self, 'labels'): + dict_labels = {'label': []} + for label in self.labels: + dict_labels['label'].append(label.to_dict()) + body['server']['labels'] = dict_labels + + if hasattr(self, 'metadata') and isinstance(self.metadata, bool): + body['server']['metadata'] = "yes" if self.metadata else "no" + + if hasattr(self, 'server_group') and isinstance(self.server_group, ServerGroup): + body['server']['server_group'] = f"{self.server_group.uuid}" + # set password_delivery default as 'none' to prevent API from sending # emails (with credentials) about each created server if not hasattr(self, 'password_delivery'): @@ -346,6 +407,16 @@ def prepare_post_body(self): body['server']['storage_devices'] = {'storage_device': []} + if hasattr(self, 'networking') and isinstance(self.networking, list): + interfaces = [] + for iface in self.networking: + if isinstance(iface, ServerNetworkInterface): + interfaces.append(iface.to_dict()) + else: + interfaces.append(iface) + + body['server']['networking'] = {'interfaces': {'interface': interfaces}} + storage_title_id = 0 # running number for unique storage titles for storage in self.storage_devices: if not hasattr(storage, 'os') or storage.os is None: @@ -402,6 +473,9 @@ def to_dict(self): fields['ip_addresses'].append( {'address': ip.address, 'access': ip.access, 'family': ip.family} ) + fields['networking'] = [] + for iface in dict.get(dict.get(self.networking, 'interfaces'), 'interface'): + fields['networking'].append(ServerNetworkInterface(iface).to_dict()) for storage in self.storage_devices: fields['storage_devices'].append( diff --git a/upcloud_api/server_group.py b/upcloud_api/server_group.py new file mode 100644 index 0000000..91b4e07 --- /dev/null +++ b/upcloud_api/server_group.py @@ -0,0 +1,57 @@ +from enum import Enum + +from upcloud_api.upcloud_resource import UpCloudResource + + +class ServerGroupAffinityPolicy(str, Enum): + """ + Enum representation of affinity policy for a server group + """ + + STRICT_ANTI_AFFINITY = 'strict' + ANTI_AFFINITY_PREFERRED = 'yes' + NO_ANTI_AFFINITY = 'no' + + +class ServerGroup(UpCloudResource): + """ + Class representation of UpCloud server group resource + """ + + ATTRIBUTES = { + 'anti_affinity': ServerGroupAffinityPolicy.NO_ANTI_AFFINITY, + 'labels': None, + 'servers': None, + 'title': None, + 'uuid': None, + } + + def __str__(self) -> str: + return self.title + + def to_dict(self): + """ + Return a dict that can be serialised to JSON and sent to UpCloud's API. + """ + body = { + 'title': self.title, + } + + if hasattr(self, 'anti_affinity'): + body['anti_affinity'] = f"{self.anti_affinity}" + + if hasattr(self, 'servers'): + servers = [] + for server in self.servers: + if isinstance(server, server.Server) and hasattr(server, 'uuid'): + servers.append(server.uuid) + else: + servers.append(server) + + if hasattr(self, 'labels'): + dict_labels = {'label': []} + for label in self.labels: + dict_labels['label'].append(label.to_dict()) + body['labels'] = dict_labels + + return {'server_group': body} diff --git a/upcloud_api/storage.py b/upcloud_api/storage.py index dadec1a..9771ef2 100644 --- a/upcloud_api/storage.py +++ b/upcloud_api/storage.py @@ -19,15 +19,16 @@ class Storage(UpCloudResource): """ ATTRIBUTES = { - 'uuid': None, - 'tier': 'maxiops', - 'size': 10, 'access': None, + 'address': None, + 'labels': None, 'license': None, 'state': None, + 'size': 10, + 'tier': 'maxiops', 'title': '', 'type': None, - 'address': None, + 'uuid': None, 'zone': None, } @@ -146,6 +147,12 @@ def to_dict(self): if hasattr(self, 'zone') and self.zone: body['zone'] = self.zone + if hasattr(self, 'labels'): + dict_labels = [] + for label in self.labels: + dict_labels.append(label.to_dict()) + body['labels'] = dict_labels + return body @staticmethod