├── .github ├── CODEOWNERS └── workflows │ ├── scorecard.yml │ ├── publish-testpypi-manual.yml │ ├── codeql.yml │ └── main.yml ├── test ├── json_data │ ├── test_file.json │ ├── account.json │ ├── zone.json │ ├── https:__fi-hel1.img.upcloud.com_uploader_session_07a6c9a3-300e-4d0e-b935-624f3dbdff3f.json │ ├── router_post.json │ ├── router_03b34bc2-5adf-4fc4-8c44-83f869058f5a.json │ ├── router_04da7f97-dc03-4df0-868f-239f304ba72f.json │ ├── ip_address_10.1.0.101.json │ ├── tag_TheTestTag.json │ ├── ip_address_post.json │ ├── server-group_post.json │ ├── server_0082c083-9847-4f9f-ae04-811251309b35_networking_interface_7.json │ ├── host_7653311107.json │ ├── server_0082c083-9847-4f9f-ae04-811251309b35_networking_interface_post.json │ ├── firewall_rule.json │ ├── network_03000000-0000-4000-8001-000000000000.json │ ├── storage_01d4fcd4-e446-433b-8a9c-551a1284952e_templatize_post.json │ ├── storage_01d4fcd4-e446-433b-8a9c-551a1284952e_import.json │ ├── storage_01d4fcd4-e446-433b-8a9c-551a1284952e_import_post.json │ ├── storage_01d4fcd4-e446-433b-8a9c-551a1284952e_import_cancel_post.json │ ├── network_post.json │ ├── storage_01d4fcd4-e446-433b-8a9c-551a1284952e_backup_post.json │ ├── storage_01d4fcd4-e446-433b-8a9c-551a1284952e_clone_post.json │ ├── tag.json │ ├── storage_01d4fcd4-e446-433b-8a9c-551a1284952e.json │ ├── storage_post.json │ ├── network_036df3d0-8629-4549-984e-dc86fc3fa1b0.json │ ├── storage_01350eec-6ebf-4418-abe4-e8bb1d5c9643.json │ ├── router.json │ ├── ip_address.json │ ├── server-group_0b5169fc-23aa-4ba7-aaab-f38868ce99cd.json │ ├── storage_public.json │ ├── server.json │ ├── host.json │ ├── server_009d64ef-31d1-4684-a26b-c86c955cbf46.json │ ├── server_00798b85-efdc-41ca-8021-f6ef457b8531_cdrom_eject_post.json │ ├── server_create.json │ ├── storage_detach.json │ ├── server_00798b85-efdc-41ca-8021-f6ef457b8531.json │ ├── server_0082c083-9847-4f9f-ae04-811251309b35_networking.json │ ├── server_00798b85-efdc-41ca-8021-f6ef457b8531_cdrom_load_post.json │ ├── storage_attach.json │ ├── price.json │ ├── firewall_rules.json │ ├── storage_template.json │ ├── network.json │ └── timezone.json ├── test_apidoc │ ├── README.md │ └── test_10_ip_addresses.py ├── test_host.py ├── test_server_group.py ├── test_cloud_manager.py ├── test_ip_manager.py ├── helpers │ ├── infra_helpers.py │ └── infra.py ├── test_credentials.py ├── test_integration │ └── test_integration_test.py ├── test_tags.py ├── conftest.py ├── test_firewall.py ├── test_server.py └── test_network.py ├── requirements-dev.in ├── MANIFEST.in ├── .snyk ├── .coveragerc ├── .editorconfig ├── .pre-commit-config.yaml ├── upcloud_api ├── ip_network.py ├── router.py ├── interface.py ├── host.py ├── network.py ├── storage_import.py ├── utils.py ├── label.py ├── errors.py ├── cloud_manager │ ├── host_mixin.py │ ├── ip_address_mixin.py │ ├── firewall_mixin.py │ ├── tag_mixin.py │ ├── __init__.py │ ├── lb_mixin.py │ └── server_mixin.py ├── __init__.py ├── firewall.py ├── server_group.py ├── upcloud_resource.py ├── tag.py ├── ip_address.py ├── api.py ├── credentials.py ├── storage.py └── load_balancer.py ├── mkdocs.yml ├── .gitignore ├── tox.ini ├── setup.cfg ├── setup.py ├── pyproject.toml ├── LICENSE.txt ├── docs ├── host-mixin.md ├── CloudManager.md ├── IP-address-mixin.md ├── IP-address.md ├── index.md ├── server-mixin.md ├── Firewall.md ├── storage-mixin.md ├── Server.md ├── network-mixin.md └── Storage.md └── requirements-dev.txt /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @UpCloudLtd/devex 2 | -------------------------------------------------------------------------------- /test/json_data/test_file.json: -------------------------------------------------------------------------------- 1 | test file for upload test 2 | -------------------------------------------------------------------------------- /requirements-dev.in: -------------------------------------------------------------------------------- 1 | keyring 2 | mock 3 | pip-tools 4 | pytest 5 | pytest-cov 6 | responses 7 | -------------------------------------------------------------------------------- /test/json_data/account.json: -------------------------------------------------------------------------------- 1 | { 2 | "account": 3 | { 4 | "credits": 70750.074, 5 | "username": "testuser" 6 | } 7 | } -------------------------------------------------------------------------------- /test/test_apidoc/README.md: -------------------------------------------------------------------------------- 1 | 2 | These tests cover cases from the UpCloud API Documentation. 3 | The client should follow the official doc as closely as possible. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt 2 | include README.md 3 | include mkdocs.yml 4 | include tox.ini 5 | include test/conftest.py 6 | include test/json_data/* 7 | recursive-include docs *.md 8 | -------------------------------------------------------------------------------- /test/json_data/zone.json: -------------------------------------------------------------------------------- 1 | {"zones": {"zone": [{"description": "Helsinki #1", "id": "fi-hel1"}, {"description": "London #1", "id": "uk-lon1"}, {"description": "Chicago #1", "id": "us-chi1"}]}} -------------------------------------------------------------------------------- /.snyk: -------------------------------------------------------------------------------- 1 | version: v1.25.0 2 | ignore: 3 | SNYK-PYTHON-ZIPP-7430899: 4 | - '*': 5 | reason: Introduced by a transitive dependency that is not used in the project. (python 3.7 test in click 1.8.1) 6 | expires: 2025-10-31 7 | patch: {} 8 | -------------------------------------------------------------------------------- /test/json_data/https:__fi-hel1.img.upcloud.com_uploader_session_07a6c9a3-300e-4d0e-b935-624f3dbdff3f.json: -------------------------------------------------------------------------------- 1 | {"written_bytes":909500125,"md5sum":"5cc6f7e7a1c52303ac3137d62410eec5","sha256sum":"bdf14d897406939c11a73d0720ca75c709e756d437f8be9ee26af6b58ede3bd7"} 2 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | setup.py 4 | 5 | [report] 6 | exclude_lines = 7 | @abstract 8 | if TYPE_CHECKING: 9 | if __name__ == .__main__.: 10 | pragma: no cover 11 | raise AssertionError 12 | raise NotImplementedError 13 | -------------------------------------------------------------------------------- /test/json_data/router_post.json: -------------------------------------------------------------------------------- 1 | { 2 | "router": 3 | { 4 | "attached_networks": 5 | { 6 | "network": []}, 7 | "name": "test router", 8 | "type": "normal", 9 | "uuid": "04da7f97-dc03-4df0-868f-239f304ba72f" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /test/json_data/router_03b34bc2-5adf-4fc4-8c44-83f869058f5a.json: -------------------------------------------------------------------------------- 1 | { 2 | "router": 3 | { 4 | "attached_networks": {"network": []}, 5 | "name": "test router", 6 | "type": "normal", 7 | "uuid": "04da7f97-dc03-4df0-868f-239f304ba72f" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/json_data/router_04da7f97-dc03-4df0-868f-239f304ba72f.json: -------------------------------------------------------------------------------- 1 | { 2 | "router": 3 | { 4 | "attached_networks": {"network": []}, 5 | "name": "test router modify", 6 | "type": "normal", 7 | "uuid": "04da7f97-dc03-4df0-868f-239f304ba72f" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/json_data/ip_address_10.1.0.101.json: -------------------------------------------------------------------------------- 1 | { 2 | "ip_address": { 3 | "access": "private", 4 | "address": "10.1.0.101", 5 | "family": "IPv4", 6 | "part_of_plan": "yes", 7 | "ptr_record": "a.ptr.record", 8 | "server": "008c365d-d307-4501-8efc-cd6d3bb0e494" 9 | } 10 | } -------------------------------------------------------------------------------- /test/json_data/tag_TheTestTag.json: -------------------------------------------------------------------------------- 1 | { 2 | "tag" : { 3 | "description" : "Description of TheTestTag", 4 | "name" : "TheTestTag", 5 | "servers" : { 6 | "server" : [ 7 | "0057e20a-6878-43a7-b2b3-530c4a4bdc55", 8 | "00cc17bd-fe22-4305-a0d3-1b81da14de8a" 9 | ] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/json_data/ip_address_post.json: -------------------------------------------------------------------------------- 1 | { 2 | "ip_address": { 3 | "access": "private", 4 | "address": "10.1.0.101", 5 | "family": "IPv4", 6 | "ptr_record": "a.ptr.record", 7 | "server": "00798b85-efdc-41ca-8021-f6ef457b8531", 8 | "floating": "yes", 9 | "zone": "fi-hel2" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | charset = utf-8 10 | end_of_line = lf 11 | indent_size = 4 12 | indent_style = space 13 | insert_final_newline = true 14 | trim_trailing_whitespace = true 15 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | minimum_pre_commit_version: 2.15.0 2 | ci: 3 | autofix_prs: false 4 | repos: 5 | - repo: https://github.com/astral-sh/ruff-pre-commit 6 | rev: v0.0.278 7 | hooks: 8 | - id: ruff 9 | args: 10 | - --fix 11 | - repo: https://github.com/psf/black 12 | rev: 23.7.0 13 | hooks: 14 | - id: black 15 | -------------------------------------------------------------------------------- /test/json_data/server-group_post.json: -------------------------------------------------------------------------------- 1 | { 2 | "server_group": { 3 | "anti_affinity": "yes", 4 | "labels": { 5 | "label": [ 6 | { 7 | "key": "foo", 8 | "value": "bar" 9 | } 10 | ] 11 | }, 12 | "title": "foo", 13 | "uuid": "0b5169fc-23aa-4ba7-aaab-f38868ce99cd" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /upcloud_api/ip_network.py: -------------------------------------------------------------------------------- 1 | from upcloud_api.upcloud_resource import UpCloudResource 2 | 3 | 4 | class IpNetwork(UpCloudResource): 5 | """ 6 | Class representation of UpCloud Ip Network. 7 | """ 8 | 9 | ATTRIBUTES = { 10 | 'address': None, 11 | 'dhcp': None, 12 | 'dhcp_default_route': None, 13 | 'dhcp_dns': [], 14 | 'family': None, 15 | 'gateway': None, 16 | } 17 | -------------------------------------------------------------------------------- /upcloud_api/router.py: -------------------------------------------------------------------------------- 1 | from upcloud_api.upcloud_resource import UpCloudResource 2 | 3 | 4 | class Router(UpCloudResource): 5 | """ 6 | Class representation of UpCloud network. 7 | """ 8 | 9 | ATTRIBUTES = {'name': None, 'type': None, 'uuid': None, 'attached_networks': None} 10 | 11 | def __str__(self): 12 | """ 13 | String representation of Router. 14 | """ 15 | return self.uuid 16 | -------------------------------------------------------------------------------- /test/json_data/server_0082c083-9847-4f9f-ae04-811251309b35_networking_interface_7.json: -------------------------------------------------------------------------------- 1 | { 2 | "interface": 3 | { 4 | "bootable": "no", 5 | "index": 8, 6 | "ip_addresses": {"ip_address": [{"address": "172.16.1.10", "family": "IPv4", "floating": "no"}]}, 7 | "mac": "da:4e:74:bd:ce:18", 8 | "network": "036df3d0-8629-4549-984e-dc86fc3fa1b0", 9 | "source_ip_filtering": "no", 10 | "type": "private" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /upcloud_api/interface.py: -------------------------------------------------------------------------------- 1 | from upcloud_api.upcloud_resource import UpCloudResource 2 | 3 | 4 | class Interface(UpCloudResource): 5 | """ 6 | Class representation of UpCloud network interface. 7 | """ 8 | 9 | ATTRIBUTES = { 10 | 'index': None, 11 | 'ip_addresses': None, 12 | 'mac': None, 13 | 'network': None, 14 | 'source_ip_filtering': None, 15 | 'type': None, 16 | 'bootable': None, 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/scorecard.yml: -------------------------------------------------------------------------------- 1 | name: Scorecard 2 | on: 3 | branch_protection_rule: 4 | schedule: 5 | - cron: "42 4 * * MON" 6 | push: 7 | branches: 8 | - main 9 | 10 | permissions: {} 11 | 12 | jobs: 13 | scorecard: 14 | name: Scorecard 15 | permissions: 16 | contents: read 17 | id-token: write 18 | security-events: write 19 | uses: UpCloudLtd/workflows/.github/workflows/openssf-scorecard.yaml@main 20 | with: 21 | publish-results: true 22 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: UpCloud API Python 2 | theme: readthedocs 3 | site_dir: docs/html 4 | pages: 5 | - [index.md, Home] 6 | - [Server.md, Usage, Server] 7 | - [Storage.md, Usage, Storage] 8 | - [IP-address.md, Usage, IP-address] 9 | - [Firewall.md, Usage, Firewall] 10 | - [CloudManager.md, CloudManager API, General Info] 11 | - [server-mixin.md, CloudManager API, Server Manager] 12 | - [storage-mixin.md, CloudManager API, Storage Manager] 13 | - [IP-address-mixin.md, CloudManager API, IP-address Manager] 14 | 15 | -------------------------------------------------------------------------------- /upcloud_api/host.py: -------------------------------------------------------------------------------- 1 | from upcloud_api.upcloud_resource import UpCloudResource 2 | 3 | 4 | class Host(UpCloudResource): 5 | """ 6 | Class representation of UpCloud network. 7 | """ 8 | 9 | ATTRIBUTES = { 10 | 'id': None, 11 | 'description': None, 12 | 'zone': None, 13 | 'windows_enabled': None, 14 | 'stats': None, 15 | } 16 | 17 | def __str__(self): 18 | """ 19 | String representation of Host. 20 | """ 21 | return self.id 22 | -------------------------------------------------------------------------------- /upcloud_api/network.py: -------------------------------------------------------------------------------- 1 | from upcloud_api.upcloud_resource import UpCloudResource 2 | 3 | 4 | class Network(UpCloudResource): 5 | """ 6 | Class representation of UpCloud network. 7 | """ 8 | 9 | ATTRIBUTES = { 10 | 'name': None, 11 | 'type': None, 12 | 'uuid': None, 13 | 'zone': None, 14 | 'ip_networks': None, 15 | 'servers': None, 16 | } 17 | 18 | def __str__(self): 19 | """ 20 | String representation of Network. 21 | """ 22 | return self.uuid 23 | -------------------------------------------------------------------------------- /test/json_data/host_7653311107.json: -------------------------------------------------------------------------------- 1 | { 2 | "host": { 3 | "id": 7653311107, 4 | "description": "My Host #1", 5 | "zone": "private-zone-id", 6 | "windows_enabled": "no", 7 | "stats": { 8 | "stat": [ 9 | { 10 | "name": "cpu_idle", 11 | "timestamp": "2019-08-09T12:46:57Z", 12 | "value": 95.2 13 | }, 14 | { 15 | "name": "memory_free", 16 | "timestamp": "2019-08-09T12:46:57Z", 17 | "value": 102 18 | } 19 | ] 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test/json_data/server_0082c083-9847-4f9f-ae04-811251309b35_networking_interface_post.json: -------------------------------------------------------------------------------- 1 | { 2 | "interface": 3 | { 4 | "bootable": "yes", 5 | "index": 7, 6 | "ip_addresses": 7 | { 8 | "ip_address": 9 | [ 10 | { 11 | "address": "172.16.1.10", 12 | "family": "IPv4", 13 | "floating": "no"}]}, 14 | "mac": "da:4e:74:bd:ce:18", 15 | "network": "036df3d0-8629-4549-984e-dc86fc3fa1b0", 16 | "source_ip_filtering": "yes", 17 | "type": "private" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | __pycache__ 3 | ENV/ 4 | .env 5 | .DS_Store 6 | *.py[cod] 7 | build/ 8 | dist/ 9 | *.egg-info 10 | *.sublime-workspace 11 | docs/html/ 12 | .tox 13 | .cache 14 | .vscode/ 15 | .idea/ 16 | 17 | # coverage 18 | .coverage 19 | htmlcov 20 | 21 | # pyenv 22 | .python-version 23 | 24 | # local 25 | .test-snippets 26 | sandbox 27 | 28 | ### Vim ### 29 | # swap 30 | .sw[a-p] 31 | .*.sw[a-p] 32 | # session 33 | Session.vim 34 | # temporary 35 | .netrwhist 36 | *~ 37 | # auto-generated tag files 38 | tags 39 | 40 | # virtual environments 41 | venv 42 | .venv 43 | -------------------------------------------------------------------------------- /test/json_data/firewall_rule.json: -------------------------------------------------------------------------------- 1 | { 2 | "firewall_rule" : { 3 | "protocol" : "tcp", 4 | "destination_port_end" : "22", 5 | "source_address_start" : "192.168.1.0", 6 | "source_address_end" : "192.168.1.255", 7 | "position" : "1", 8 | "source_port_end" : "", 9 | "source_port_start" : "", 10 | "destination_address_start" : "", 11 | "direction" : "in", 12 | "action" : "accept", 13 | "icmp_type" : "", 14 | "destination_port_start" : "22", 15 | "destination_address_end" : "" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /test/json_data/network_03000000-0000-4000-8001-000000000000.json: -------------------------------------------------------------------------------- 1 | { 2 | "network": 3 | { 4 | "ip_networks": 5 | { 6 | "ip_network": 7 | [ 8 | { 9 | "address": "80.69.172.0/22", 10 | "dhcp": "yes", 11 | "dhcp_default_route": "yes", 12 | "dhcp_dns": ["94.237.127.9", "94.237.40.9"], 13 | "family": "IPv4", "gateway": "80.69.172.1"}]}, 14 | "name": "Public 80.69.172.0/22", 15 | "type": "public", 16 | "uuid": "03000000-0000-4000-8001-000000000000", 17 | "zone": "fi-hel1" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /test/json_data/storage_01d4fcd4-e446-433b-8a9c-551a1284952e_templatize_post.json: -------------------------------------------------------------------------------- 1 | { 2 | "storage": { 3 | "access": "private", 4 | "labels": [ 5 | { 6 | "key": "role", 7 | "value": "primary" 8 | } 9 | ], 10 | "license": 0, 11 | "servers": { 12 | "server": [] 13 | }, 14 | "size": 666, 15 | "state": "maintenance", 16 | "tier": "maxiops", 17 | "title": "my server template", 18 | "type": "template", 19 | "uuid": "013721b5-07ca-4d7b-b4ff-e21262223e5b", 20 | "zone": "fi-hel1" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test/json_data/storage_01d4fcd4-e446-433b-8a9c-551a1284952e_import.json: -------------------------------------------------------------------------------- 1 | { 2 | "storage_import": { 3 | "client_content_length": 0, 4 | "client_content_type": "", 5 | "completed": "", 6 | "created": "2020-06-26T08:51:07Z", 7 | "error_code": "", 8 | "error_message": "", 9 | "md5sum": "", 10 | "read_bytes": 0, 11 | "sha256sum": "", 12 | "source": "http_import", 13 | "source_location": "https://cdimage.debian.org/debian-cd/current/amd64/iso-cd/debian-10.4.0-amd64-netinst.iso", 14 | "state": "pending", 15 | "uuid": "07a6c9a3-300e-4d0e-b935-624f3dbdff3f", 16 | "written_bytes": 0 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /test/json_data/storage_01d4fcd4-e446-433b-8a9c-551a1284952e_import_post.json: -------------------------------------------------------------------------------- 1 | { 2 | "storage_import": { 3 | "client_content_length": 0, 4 | "client_content_type": "", 5 | "completed": "", 6 | "created": "2020-06-26T08:51:07Z", 7 | "direct_upload_url": "https://fi-hel1.img.upcloud.com/uploader/session/07a6c9a3-300e-4d0e-b935-624f3dbdff3f", 8 | "error_code": "", 9 | "error_message": "", 10 | "md5sum": "", 11 | "read_bytes": 0, 12 | "sha256sum": "", 13 | "source": "direct_upload", 14 | "state": "prepared", 15 | "uuid": "07a6c9a3-300e-4d0e-b935-624f3dbdff3f", 16 | "written_bytes": 0 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (https://tox.readthedocs.io/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = py39, py310, py311, py312, py313, pypy311 8 | skip_missing_interpreters = True 9 | 10 | [testenv] 11 | commands = py.test --cov=test --cov={envsitepackagesdir}/upcloud_api --cov-report term-missing test/ 12 | deps = -rrequirements-dev.txt 13 | 14 | [testenv:integration] 15 | passenv = * 16 | commands = py.test test/ {posargs: -x} 17 | deps = -rrequirements-dev.txt 18 | -------------------------------------------------------------------------------- /test/json_data/storage_01d4fcd4-e446-433b-8a9c-551a1284952e_import_cancel_post.json: -------------------------------------------------------------------------------- 1 | { 2 | "storage_import": { 3 | "client_content_length": 0, 4 | "client_content_type": "", 5 | "completed": "", 6 | "created": "2020-06-26T08:51:07Z", 7 | "error_code": "", 8 | "error_message": "", 9 | "md5sum": "", 10 | "read_bytes": 0, 11 | "sha256sum": "", 12 | "source": "http_import", 13 | "source_location": "https://cdimage.debian.org/debian-cd/current/amd64/iso-cd/debian-10.4.0-amd64-netinst.iso", 14 | "state": "cancelling", 15 | "uuid": "07a6c9a3-300e-4d0e-b935-624f3dbdff3f", 16 | "written_bytes": 0 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /test/json_data/network_post.json: -------------------------------------------------------------------------------- 1 | { 2 | "network": 3 | { 4 | "ip_networks": 5 | { 6 | "ip_network": 7 | [ 8 | { 9 | "address": "172.16.0.0/22", 10 | "dhcp": "yes", 11 | "dhcp_bootfile_url": "tftp://172.16.0.253/pxelinux.0", 12 | "dhcp_default_route": "no", 13 | "dhcp_dns": ["172.16.0.10", "172.16.1.10"], 14 | "family": "IPv4", "gateway": "172.16.0.1"}]}, 15 | "name": "test network", 16 | "router": "04b65749-61e2-4f08-a259-c75afbe81abf", 17 | "type": "private", 18 | "uuid": "036df3d0-8629-4549-984e-dc86fc3fa1b0", 19 | "zone": "fi-hel1"} 20 | } 21 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = upcloud_api 3 | description = UpCloud API Client 4 | long_description = file: README.md 5 | long_description_content_type = text/markdown 6 | original_author = Elias Nygren 7 | maintainer = UpCloud 8 | maintainer_email = hello@upcloud.com 9 | url = https://github.com/UpCloudLtd/upcloud-python-api 10 | packages=['upcloud_api', 'upcloud_api.cloud_manager'] 11 | license = MIT 12 | license_files = [] 13 | 14 | [options] 15 | python_requires = >=3.9, <4 16 | setup_requires = 17 | setuptools 18 | install_requires = 19 | requests 20 | packages = 21 | upcloud_api 22 | upcloud_api.cloud_manager 23 | 24 | [options.extras_require] 25 | keyring = 26 | keyring>=23.0 27 | -------------------------------------------------------------------------------- /test/json_data/storage_01d4fcd4-e446-433b-8a9c-551a1284952e_backup_post.json: -------------------------------------------------------------------------------- 1 | {"storage": 2 | { 3 | "access": "private", 4 | "created": "2020-09-21T17:36:50Z", 5 | "labels": [ 6 | { 7 | "key": "role", 8 | "value": "primary" 9 | } 10 | ], 11 | "license": 0, 12 | "origin": "01ec5c26-a25d-4752-94e4-27bd88b62816", 13 | "progress": "0", 14 | "servers": { 15 | "server": [] 16 | }, 17 | "size": 666, 18 | "state": "maintenance", 19 | "title": "test-backup", 20 | "type": "backup", 21 | "uuid": "01350eec-6ebf-4418-abe4-e8bb1d5c9643", 22 | "zone": "fi-hel1" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import os.path 3 | import re 4 | 5 | from setuptools import setup 6 | 7 | 8 | def get_version(rel_path: str) -> str: 9 | """ 10 | Parse a version string from a __version__ = ... line in the given file. 11 | """ 12 | with open(os.path.join(os.path.dirname(__file__), rel_path)) as infp: 13 | match = re.search("__version__ = (.+?)$", infp.read(), re.M) 14 | if not match: 15 | raise ValueError("No version could be found") 16 | return ast.literal_eval(match.group(1)) 17 | 18 | 19 | with open('README.md') as f: 20 | long_description = f.read() 21 | 22 | if __name__ == "__main__": 23 | setup(version=get_version('upcloud_api/__init__.py')) 24 | -------------------------------------------------------------------------------- /test/json_data/storage_01d4fcd4-e446-433b-8a9c-551a1284952e_clone_post.json: -------------------------------------------------------------------------------- 1 | { 2 | "storage" : { 3 | "access" : "private", 4 | "backup_rule": "", 5 | "backups" : { 6 | "backup" : [] 7 | }, 8 | "labels": [ 9 | { 10 | "key": "role", 11 | "value": "primary" 12 | } 13 | ], 14 | "license" : 0, 15 | "servers" : { 16 | "server" : [ 17 | "00798b85-efdc-41ca-8021-f6ef457b8531" 18 | ] 19 | }, 20 | "size" : 666, 21 | "state" : "online", 22 | "tier" : "maxiops", 23 | "title" : "cloned-storage-test", 24 | "type" : "normal", 25 | "uuid" : "01d3e9ad-8ff5-4a52-9fa2-48938e488e78", 26 | "zone" : "fi-hel1" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/json_data/tag.json: -------------------------------------------------------------------------------- 1 | { 2 | "tags" : { 3 | "tag" : [ 4 | { 5 | "description" : "Description of TheTestTag1", 6 | "name" : "TheTestTag1", 7 | "servers" : { 8 | "server" : [ 9 | "0057e20a-6878-43a7-b2b3-530c4a4bdc55", 10 | "00cc17bd-fe22-4305-a0d3-1b81da14de8a" 11 | ] 12 | } 13 | }, 14 | { 15 | "description" : "Description of TheTestTag2", 16 | "name" : "TheTestTag2", 17 | "servers" : { 18 | "server" : [ 19 | "0057e20a-6878-43a7-b2b3-530c4a4bdc55", 20 | "00cc17bd-fe22-4305-a0d3-1b81da14de8a" 21 | ] 22 | } 23 | } 24 | ] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /test/json_data/storage_01d4fcd4-e446-433b-8a9c-551a1284952e.json: -------------------------------------------------------------------------------- 1 | { 2 | "storage" : { 3 | "access" : "private", 4 | "backup_rule": "", 5 | "backups" : { 6 | "backup" : [] 7 | }, 8 | "encrypted": "yes", 9 | "labels": [ 10 | { 11 | "key": "role", 12 | "value": "primary" 13 | } 14 | ], 15 | "license" : 0, 16 | "servers" : { 17 | "server" : [ 18 | "00798b85-efdc-41ca-8021-f6ef457b8531" 19 | ] 20 | }, 21 | "size" : 10, 22 | "state" : "online", 23 | "tier" : "ssd", 24 | "title" : "Operating system disk", 25 | "type" : "normal", 26 | "uuid" : "01d4fcd4-e446-433b-8a9c-551a1284952e", 27 | "zone" : "fi-hel1" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /test/json_data/storage_post.json: -------------------------------------------------------------------------------- 1 | { 2 | "storage": { 3 | "access": "private", 4 | "backup_rule": {}, 5 | "backups": { 6 | "backup": [] 7 | }, 8 | "encrypted": "yes", 9 | "labels": [ 10 | { 11 | "key": "role", 12 | "value": "primary" 13 | } 14 | ], 15 | "license": 0, 16 | "servers": { 17 | "server": [] 18 | }, 19 | "size": 10, 20 | "state": "online", 21 | "tier": "hdd", 22 | "title": "My data collection", 23 | "type": "normal", 24 | "uuid": "013ff32b-5e17-4b8e-918b-f1ea996fa82e", 25 | "zone": "fi-hel1" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /test/json_data/network_036df3d0-8629-4549-984e-dc86fc3fa1b0.json: -------------------------------------------------------------------------------- 1 | { 2 | "network": 3 | { 4 | "ip_networks": 5 | { 6 | "ip_network": 7 | [ 8 | { 9 | "address": "172.16.0.0/22", 10 | "dhcp": "yes", 11 | "dhcp_bootfile_url": 12 | "tftp://172.16.0.253/pxelinux.0", 13 | "dhcp_default_route": "no", 14 | "dhcp_dns": ["172.16.0.10", "172.16.1.10"], 15 | "family": "IPv4", "gateway": "172.16.0.1"}]}, 16 | "name": "test network modify", 17 | "router": "04b65749-61e2-4f08-a259-c75afbe81abf", 18 | "type": "private", 19 | "uuid": "036df3d0-8629-4549-984e-dc86fc3fa1b0", 20 | "zone": "fi-hel1"} 21 | } 22 | -------------------------------------------------------------------------------- /test/json_data/storage_01350eec-6ebf-4418-abe4-e8bb1d5c9643.json: -------------------------------------------------------------------------------- 1 | { 2 | "storage": { 3 | "access": "private", 4 | "created": "2020-09-21T17:36:50Z", 5 | "labels": [ 6 | { 7 | "key": "role", 8 | "value": "primary" 9 | } 10 | ], 11 | "license": 0, 12 | "origin": "01ec5c26-a25d-4752-94e4-27bd88b62816", 13 | "progress": "0", 14 | "servers": { 15 | "server": [] 16 | }, 17 | "size": 666, 18 | "state": "maintenance", 19 | "title": "test-backup", 20 | "type": "backup", 21 | "uuid": "01350eec-6ebf-4418-abe4-e8bb1d5c9643", 22 | "zone": "fi-hel1" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /upcloud_api/storage_import.py: -------------------------------------------------------------------------------- 1 | from upcloud_api.upcloud_resource import UpCloudResource 2 | 3 | 4 | class StorageImport(UpCloudResource): 5 | """ 6 | Class representation of UpCloud Storage import. 7 | """ 8 | 9 | ATTRIBUTES = { 10 | 'client_content_length': None, 11 | 'client_content_type': None, 12 | 'completed': None, 13 | 'created': None, 14 | 'error_code': None, 15 | 'error_message': None, 16 | 'md5sum': None, 17 | 'read_bytes': None, 18 | 'sha256sum': None, 19 | 'source': None, 20 | 'source_location': None, 21 | 'state': None, 22 | 'uuid': None, 23 | 'written_bytes': None, 24 | 'direct_upload_url': None, 25 | } 26 | -------------------------------------------------------------------------------- /test/json_data/router.json: -------------------------------------------------------------------------------- 1 | { 2 | "routers": { 3 | "router": [ 4 | { 5 | "attached_networks": { 6 | "network": [ 7 | { 8 | "uuid": "03b34bc2-5adf-4fc4-8c44-83f869058f5a"}]}, 9 | "name": "Utility network router for zone fi-hel1", 10 | "type": "service", 11 | "uuid": "04b65b2e-7c4b-465c-a24c-e1b47987f09b" 12 | }, 13 | { 14 | "attached_networks": { 15 | "network": [ 16 | { 17 | "uuid": "03a0020f-896b-4453-913c-e236b8e639d6"}]}, 18 | "name": "Utility network router for zone fi-hel2", 19 | "type": "service", 20 | "uuid": "04cad61f-baae-4019-8740-a840acd68319" 21 | } 22 | ] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /upcloud_api/utils.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | from time import sleep 3 | 4 | from upcloud_api.errors import UpCloudAPIError, UpCloudClientError 5 | 6 | 7 | def try_it_n_times(operation, expected_error_codes, custom_error='operation failed', n=10): 8 | """ 9 | Try a given operation (API call) n times. 10 | 11 | Raises if the API call fails with an error_code that is not expected. 12 | Raises if the API call has not succeeded within n attempts. 13 | Waits 3 seconds between each attempt. 14 | """ 15 | for i in itertools.count(): 16 | try: 17 | operation() 18 | break 19 | except UpCloudAPIError as e: 20 | if e.error_code not in expected_error_codes: 21 | raise e 22 | sleep(3) 23 | if i >= n - 1: 24 | raise UpCloudClientError(custom_error) 25 | -------------------------------------------------------------------------------- /.github/workflows/publish-testpypi-manual.yml: -------------------------------------------------------------------------------- 1 | name: publish-testpypi-manual 2 | 3 | on: 4 | workflow_dispatch: {} # run manually from the Actions tab 5 | 6 | permissions: 7 | contents: read 8 | id-token: write # required for PyPI/TestPyPI Trusted Publishers (OIDC) 9 | 10 | jobs: 11 | testpypi: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 15 | - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 16 | # build artifacts (wheel + sdist) 17 | - run: python -m pip install --upgrade build 18 | - run: python -m build 19 | # publish to **TestPyPI** via Trusted Publishers (OIDC) 20 | - uses: pypa/gh-action-pypi-publish@release/v1 21 | with: 22 | repository-url: https://test.pypi.org/legacy/ 23 | skip-existing: true 24 | -------------------------------------------------------------------------------- /upcloud_api/label.py: -------------------------------------------------------------------------------- 1 | from upcloud_api.upcloud_resource import UpCloudResource 2 | 3 | 4 | class Label(UpCloudResource): 5 | """ 6 | Class representation of UpCloud resource label 7 | """ 8 | 9 | ATTRIBUTES = { 10 | 'key': "", 11 | 'value': "", 12 | } 13 | 14 | def __init__(self, key="", value="") -> None: 15 | """ 16 | Initialize label. 17 | 18 | Set both values for label if given 19 | """ 20 | self.key = key 21 | self.value = value 22 | 23 | def __str__(self) -> str: 24 | return f"{self.key}={self.value}" 25 | 26 | def to_dict(self): 27 | """ 28 | Return a dict that can be serialised to JSON and sent to UpCloud's API. 29 | """ 30 | body = { 31 | 'key': self.key, 32 | 'value': self.value, 33 | } 34 | 35 | return body 36 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 99 3 | target-version = ['py39'] 4 | skip-string-normalization = true 5 | 6 | [tool.ruff] 7 | target-version = "py39" 8 | exclude = [ 9 | ".git", 10 | "ENV", 11 | "__pycache__", 12 | "build", 13 | ] 14 | ignore = [ 15 | "D100", 16 | "D104", 17 | "D105", 18 | "D200", 19 | "D202", 20 | "D203", 21 | "D205", 22 | "D212", 23 | "D400", 24 | "D401", 25 | "D403", 26 | "D415", 27 | "E501", 28 | ] 29 | line-length = 99 30 | select = [ 31 | # NB: `hacking` rules aren't supported by ruff 32 | "B", 33 | "D", 34 | "E", 35 | "F", 36 | "I", 37 | "S", 38 | "UP", 39 | "W", 40 | ] 41 | 42 | [tool.ruff.per-file-ignores] 43 | "*/__init__.py" = ["F401"] 44 | "test/*" = [ 45 | "D", 46 | "F841", 47 | "S101", 48 | "S105", 49 | "S106", 50 | "B011", 51 | ] 52 | -------------------------------------------------------------------------------- /upcloud_api/errors.py: -------------------------------------------------------------------------------- 1 | class UpCloudClientError(Exception): 2 | """ 3 | Base exception for UpCloud API client. 4 | 5 | All exceptions thrown by the client should be of the type UpCloudClientError 6 | or at least one of its subclasses. 7 | """ 8 | 9 | pass 10 | 11 | 12 | class UpCloudAPIError(UpCloudClientError): 13 | """ 14 | Custom Error class for UpCloud API error responses. 15 | 16 | Each API call returns an `error_code` and `error_message` that 17 | are available as attributes via instances of this class. 18 | """ 19 | 20 | def __init__(self, error_code, error_message): 21 | """ 22 | Initialize API error with an error code and message. 23 | """ 24 | self.error_code = error_code 25 | self.error_message = error_message 26 | 27 | def __str__(self): 28 | return f'{self.error_code} {self.error_message}' 29 | -------------------------------------------------------------------------------- /test/json_data/ip_address.json: -------------------------------------------------------------------------------- 1 | { 2 | "ip_addresses": { 3 | "ip_address": [ 4 | { 5 | "access": "private", 6 | "address": "10.1.0.101", 7 | "family": "IPv4", 8 | "ptr_record": "", 9 | "server": "008c365d-d307-4501-8efc-cd6d3bb0e494" 10 | }, 11 | { 12 | "access": "private", 13 | "address": "10.1.0.115", 14 | "family": "IPv4", 15 | "ptr_record": "", 16 | "server": "00887721-4fad-4e9c-b781-6f2265753f11" 17 | }, 18 | { 19 | "access": "private", 20 | "address": "10.1.1.97", 21 | "family": "IPv4", 22 | "ptr_record": "", 23 | "server": "00ee2cf4-91c9-4e7a-89b9-a8087e32896b" 24 | } 25 | ] 26 | } 27 | } -------------------------------------------------------------------------------- /test/json_data/server-group_0b5169fc-23aa-4ba7-aaab-f38868ce99cd.json: -------------------------------------------------------------------------------- 1 | { 2 | "server_group": { 3 | "anti_affinity": "yes", 4 | "anti_affinity_status": [ 5 | { 6 | "uuid": "0016dadf-eba4-4331-bd34-a361841f7af1", 7 | "status": "met" 8 | }, 9 | { 10 | "uuid": "00c77bbe-fc0e-436f-a753-37f5b5b76270", 11 | "status": "met" 12 | } 13 | ], 14 | "labels": { 15 | "label": [ 16 | { 17 | "key": "foo", 18 | "value": "bar" 19 | } 20 | ] 21 | }, 22 | "servers": { 23 | "server": [ 24 | "0016dadf-eba4-4331-bd34-a361841f7af2", 25 | "00c77bbe-fc0e-436f-a753-37f5b5b76271" 26 | ] 27 | }, 28 | "title": "test group", 29 | "uuid": "0b5169fc-23aa-4ba7-aaab-f38868ce99cd" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - '.github/workflows/*.yml' 7 | - '**.py' 8 | push: 9 | branches: 10 | - main 11 | 12 | permissions: {} 13 | 14 | jobs: 15 | analysis: 16 | name: Analysis 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | language: 22 | - actions 23 | - python 24 | permissions: 25 | contents: read 26 | security-events: write 27 | steps: 28 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 29 | with: 30 | persist-credentials: false 31 | - uses: github/codeql-action/init@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3 32 | with: 33 | languages: ${{ matrix.language }} 34 | build-mode: none 35 | - uses: github/codeql-action/analyze@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3 36 | with: 37 | category: /language:${{ matrix.language }} 38 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 UpCloud Oy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/json_data/storage_public.json: -------------------------------------------------------------------------------- 1 | { 2 | "storages": { 3 | "storage": [ 4 | { 5 | "access": "public", 6 | "license": 0, 7 | "size": 1, 8 | "state": "online", 9 | "title": "Windows Server 2003 R2 Standard (CD 1)", 10 | "type": "cdrom", 11 | "uuid": "01000000-0000-4000-8000-000010010101" 12 | }, 13 | { 14 | "access": "public", 15 | "license": 0, 16 | "size": 1, 17 | "state": "online", 18 | "title": "Windows Server 2003 R2 Standard (CD 2)", 19 | "type": "cdrom", 20 | "uuid": "01000000-0000-4000-8000-000010010102" 21 | }, 22 | { 23 | "access": "public", 24 | "license": 0, 25 | "size": 1, 26 | "state": "online", 27 | "title": "Windows Server 2003 R2 Standard (CD 1)", 28 | "type": "cdrom", 29 | "uuid": "01000000-0000-4000-8000-000010010201" 30 | } 31 | ] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /upcloud_api/cloud_manager/host_mixin.py: -------------------------------------------------------------------------------- 1 | from upcloud_api.api import API 2 | from upcloud_api.host import Host 3 | 4 | 5 | class HostManager: 6 | """ 7 | Functions for managing hosts. Intended to be used as a mixin for CloudManager. 8 | """ 9 | 10 | api: API 11 | 12 | def get_hosts(self): 13 | """ 14 | Returns a list of available hosts, along with basic statistics of them when available. 15 | """ 16 | url = '/host' 17 | res = self.api.get_request(url) 18 | return [Host(**host) for host in res['hosts']['host']] 19 | 20 | def get_host(self, id: str) -> Host: 21 | """ 22 | Returns detailed information about a specific host. 23 | """ 24 | url = f'/host/{id}' 25 | res = self.api.get_request(url) 26 | return Host(**res['host']) 27 | 28 | def modify_host(self, host: str, description: str) -> Host: 29 | """ 30 | Modifies description of a specific host. 31 | """ 32 | url = f'/host/{host}' 33 | body = {'host': {'description': description}} 34 | res = self.api.patch_request(url, body) 35 | return Host(**res['host']) 36 | -------------------------------------------------------------------------------- /test/json_data/server.json: -------------------------------------------------------------------------------- 1 | { 2 | "servers" : { 3 | "server" : [ 4 | { 5 | "zone" : "fi-hel1", 6 | "plan": "custom", 7 | "core_number" : "0", 8 | "title" : "Helsinki server", 9 | "hostname" : "fi.example.com", 10 | "labels": { 11 | "label": [ 12 | { 13 | "key": "test", 14 | "value": "example" 15 | } 16 | ] 17 | }, 18 | "memory_amount" : "1024", 19 | "uuid" : "00798b85-efdc-41ca-8021-f6ef457b8531", 20 | "state" : "started", 21 | "tags": { 22 | "tag": [ 23 | "web1" 24 | ] 25 | } 26 | }, 27 | { 28 | "zone" : "uk-lon1", 29 | "plan": "custom", 30 | "core_number" : "0", 31 | "title" : "London server", 32 | "hostname" : "uk.example.com", 33 | "labels": { 34 | "label": [] 35 | }, 36 | "memory_amount" : "1024", 37 | "uuid" : "009d64ef-31d1-4684-a26b-c86c955cbf46", 38 | "state" : "stopped", 39 | "tags": { 40 | "tag": [ 41 | "web2" 42 | ] 43 | } 44 | } 45 | ] 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /docs/host-mixin.md: -------------------------------------------------------------------------------- 1 | ## About 2 | 3 | Server hosts are only available for private cloud users. To get access, [contact UpCloud sales](https://upcloud.com/contact/). 4 | 5 | ```python 6 | class HostManager(): 7 | """ 8 | Functions for managing hosts. Intended to be used as a mixin for CloudManager. 9 | """ 10 | ``` 11 | `HostManager` is a mixed into `CloudManager` and the following methods are available by 12 | 13 | ```python 14 | manager = CloudManager("api-username", "password") 15 | manager.method() 16 | ``` 17 | 18 | ## Methods 19 | 20 | ```python 21 | def get_hosts(self): 22 | """ 23 | Returns a list of available hosts, along with basic statistics of them when available. 24 | Returns a list of Host objects. 25 | """ 26 | ``` 27 | 28 | ```python 29 | def get_host(self, id): 30 | """ 31 | Returns detailed information about a specific host in a Host object. 32 | Id argument must be passed (can be the id of a host or a Host object). 33 | """ 34 | ``` 35 | 36 | ```python 37 | def modify_host(self, host, description='new description'): 38 | """ 39 | Modifies description of a specific host. 40 | Host argument must be provided (can be an id or a Host object). 41 | Returns a Host object. 42 | """ 43 | ``` 44 | -------------------------------------------------------------------------------- /docs/CloudManager.md: -------------------------------------------------------------------------------- 1 | # Cloud Manager 2 | 3 | CloudManager handles all HTTP communications with UpCloud and mixes in the behavior of all Manager 4 | classes. 5 | 6 | In addition to the credentials, CloudManager can be given a timeout parameter that is 7 | relayed to requests library, see [here](http://docs.python-requests.org/en/master/user/advanced/?highlight=timeout#timeouts). 8 | Default timeout is 10. 9 | 10 | ```python 11 | 12 | # create manager and form a token 13 | manager = CloudManager("api-username", "password") 14 | 15 | ``` 16 | 17 | # Account / Authentication 18 | 19 | ```python 20 | 21 | manager.authenticate() # alias: get_account() 22 | manager.get_account() 23 | 24 | ``` 25 | 26 | # Zone 27 | 28 | Zones can be queried from the api. 29 | 30 | ```python 31 | 32 | manager.get_zones() 33 | 34 | ``` 35 | 36 | # TimeZone 37 | 38 | Timezone can be given as a parameter to a server during creation and update. 39 | 40 | ```python 41 | 42 | manager.get_timezones() 43 | 44 | ``` 45 | 46 | # Pricing 47 | 48 | ```python 49 | 50 | manager.get_prices() 51 | 52 | ``` 53 | 54 | # Server Sizes 55 | 56 | List the possible server CPU-ram configurations. 57 | 58 | ```python 59 | 60 | manager.get_server_sizes() 61 | 62 | ``` 63 | -------------------------------------------------------------------------------- /test/json_data/host.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosts": { 3 | "host": [ 4 | { 5 | "id": 7653311107, 6 | "description": "My Host #1", 7 | "zone": "private-zone-id", 8 | "windows_enabled": "no", 9 | "stats": { 10 | "stat": [ 11 | { 12 | "name": "cpu_idle", 13 | "timestamp": "2019-08-09T12:46:57Z", 14 | "value": 95.2 15 | }, 16 | { 17 | "name": "memory_free", 18 | "timestamp": "2019-08-09T12:46:57Z", 19 | "value": 102 20 | } 21 | ] 22 | } 23 | }, 24 | { 25 | "id": 8055964291, 26 | "description": "My Host #2", 27 | "zone": "private-zone-id", 28 | "windows_enabled": "no", 29 | "stats": { 30 | "stat": [ 31 | { 32 | "name": "cpu_idle", 33 | "timestamp": "2019-08-09T12:46:57Z", 34 | "value": 80.1 35 | }, 36 | { 37 | "name": "memory_free", 38 | "timestamp": "2019-08-09T12:46:57Z", 39 | "value": 61 40 | } 41 | ] 42 | } 43 | } 44 | ] 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /test/test_host.py: -------------------------------------------------------------------------------- 1 | import responses 2 | from conftest import Mock 3 | 4 | 5 | class TestHost: 6 | @responses.activate 7 | def test_get_hosts(self, manager): 8 | data = Mock.mock_get('host') 9 | hosts = manager.get_hosts() 10 | 11 | for host in hosts: 12 | assert type(host).__name__ == 'Host' 13 | 14 | @responses.activate 15 | def test_get_host(self, manager): 16 | data = Mock.mock_get('host/7653311107') 17 | host = manager.get_host('7653311107') 18 | 19 | assert type(host).__name__ == 'Host' 20 | assert host.id == 7653311107 21 | assert host.description == 'My Host #1' 22 | assert host.zone == 'private-zone-id' 23 | assert host.windows_enabled == 'no' 24 | assert len(host.stats.get('stat')) == 2 25 | 26 | @responses.activate 27 | def test_modify_host(self, manager): 28 | data = Mock.mock_patch('host/7653311107') 29 | host = manager.modify_host('7653311107', 'My New Host') 30 | 31 | assert type(host).__name__ == 'Host' 32 | assert host.id == 7653311107 33 | assert host.description == 'My New Host' 34 | assert host.zone == 'private-zone-id' 35 | assert host.windows_enabled == 'no' 36 | assert len(host.stats.get('stat')) == 2 37 | -------------------------------------------------------------------------------- /docs/IP-address-mixin.md: -------------------------------------------------------------------------------- 1 | ## About 2 | 3 | ```python 4 | class IPManager(): 5 | """ 6 | Functions for managing IP-addresses. 7 | Intended to be used as a mixin for CloudManager. 8 | """ 9 | ``` 10 | 11 | `IPManager` is a mixed into `CloudManager` and the following methods are available by 12 | 13 | ```python 14 | manager = CloudManager("api-username", "password") 15 | manager.method() 16 | ``` 17 | 18 | ## Methods 19 | 20 | 21 | ```python 22 | def get_ip(self, address): 23 | """ 24 | Get an IPAddress object with the IP address (string) from the API. 25 | e.g manager.get_ip("80.69.175.210") 26 | """ 27 | ``` 28 | 29 | ```python 30 | def get_ips(self): 31 | """ 32 | Get all IPAddress objects from the API. 33 | """ 34 | ``` 35 | 36 | ```python 37 | def attach_ip(self, server, family="IPv4"): 38 | """ 39 | Attach a new (random) IPAddress to the given server (object or UUID) 40 | """ 41 | ``` 42 | ```python 43 | def modify_ip(self, ip_addr, ptr_record): 44 | """ 45 | Modify an IP address' ptr-record (Reverse DNS). 46 | Accepts an IPAddress instance (object) or its address (string). 47 | """ 48 | ``` 49 | 50 | ```python 51 | def release_ip(self, ip_addr): 52 | """ 53 | Destroy an IPAddress. Returns an empty object. 54 | Accepts an IPAddress instance (object) or its address (string). 55 | """ 56 | ``` 57 | -------------------------------------------------------------------------------- /test/json_data/server_009d64ef-31d1-4684-a26b-c86c955cbf46.json: -------------------------------------------------------------------------------- 1 | { 2 | "server" : { 3 | "boot_order" : "disk", 4 | "core_number" : "0", 5 | "firewall" : "on", 6 | "hostname" : "uk.example.com", 7 | "ip_addresses" : { 8 | "ip_address" : [ 9 | { 10 | "access" : "private", 11 | "address" : "10.0.0.0" 12 | }, 13 | { 14 | "access" : "public", 15 | "address" : "0.0.0.0" 16 | } 17 | ] 18 | }, 19 | "labels": { 20 | "label": [ 21 | { 22 | "key": "test", 23 | "value": "example" 24 | } 25 | ] 26 | }, 27 | "license" : 0, 28 | "memory_amount" : "1024", 29 | "nic_model" : "e1000", 30 | "state" : "stopped", 31 | "storage_devices" : { 32 | "storage_device" : [ 33 | { 34 | "address" : "virtio:0", 35 | "storage" : "012580a1-32a1-466e-a323-689ca16f2d43", 36 | "storage_size" : 100, 37 | "storage_title" : "Storage for server1.example.com", 38 | "type" : "disk" 39 | } 40 | ] 41 | }, 42 | "tags": { 43 | "tag": [ 44 | "web2" 45 | ] 46 | }, 47 | "timezone" : "UTC", 48 | "title" : "London server", 49 | "uuid" : "009d64ef-31d1-4684-a26b-c86c955cbf46", 50 | "video_model" : "cirrus", 51 | "vnc" : "on", 52 | "vnc_host" : "fi-he1l.vnc.upcloud.com", 53 | "vnc_password" : "aabbccdd", 54 | "vnc_port" : "00000", 55 | "zone" : "uk-lon1", 56 | "plan": "custom" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /test/test_server_group.py: -------------------------------------------------------------------------------- 1 | import responses 2 | from conftest import Mock 3 | 4 | from upcloud_api import Label, ServerGroup, ServerGroupAffinityPolicy 5 | 6 | 7 | class TestServerGroup: 8 | @responses.activate 9 | def test_get_server_group(self, manager): 10 | Mock.mock_get("server-group/0b5169fc-23aa-4ba7-aaab-f38868ce99cd") 11 | server_group = manager.get_server_group("0b5169fc-23aa-4ba7-aaab-f38868ce99cd") 12 | 13 | assert type(server_group).__name__ == "ServerGroup" 14 | assert server_group.uuid == "0b5169fc-23aa-4ba7-aaab-f38868ce99cd" 15 | assert server_group.title == "test group" 16 | 17 | @responses.activate 18 | def test_create_server_group(self, manager): 19 | Mock.mock_post("server-group", ignore_data_field=True) 20 | server_group = ServerGroup( 21 | title="rykelma", 22 | labels=[Label('foo', 'bar')], 23 | anti_affinity=ServerGroupAffinityPolicy.ANTI_AFFINITY_PREFERRED, 24 | ) 25 | created_group = manager.create_server_group(server_group) 26 | 27 | assert created_group.uuid == "0b5169fc-23aa-4ba7-aaab-f38868ce99cd" 28 | assert created_group.title == "foo" 29 | 30 | @responses.activate 31 | def test_delete_server_group(self, manager): 32 | Mock.mock_delete("server-group/0b5169fc-23aa-4ba7-aaab-f38868ce99cd") 33 | res = manager.delete_server_group("0b5169fc-23aa-4ba7-aaab-f38868ce99cd") 34 | assert res == {} 35 | -------------------------------------------------------------------------------- /upcloud_api/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Python Interface to UpCloud's API. 3 | """ 4 | 5 | __version__ = '2.9.0' 6 | __author__ = 'Developers from UpCloud & elsewhere' 7 | __author_email__ = 'hello@upcloud.com' 8 | __maintainer__ = 'UpCloud' 9 | __maintainer_email__ = 'hello@upcloud.com' 10 | __license__ = 'MIT' 11 | __copyright__ = 'Copyright (c) 2015 UpCloud Oy' 12 | 13 | from upcloud_api.cloud_manager import CloudManager 14 | from upcloud_api.credentials import Credentials 15 | from upcloud_api.errors import UpCloudAPIError, UpCloudClientError 16 | from upcloud_api.firewall import FirewallRule 17 | from upcloud_api.host import Host 18 | from upcloud_api.interface import Interface 19 | from upcloud_api.ip_address import IPAddress 20 | from upcloud_api.ip_network import IpNetwork 21 | from upcloud_api.label import Label 22 | from upcloud_api.load_balancer import ( 23 | LoadBalancer, 24 | LoadBalancerBackend, 25 | LoadBalancerBackendMember, 26 | LoadBalancerFrontend, 27 | LoadBalancerFrontEndRule, 28 | LoadBalancerNetwork, 29 | ) 30 | from upcloud_api.network import Network 31 | from upcloud_api.router import Router 32 | from upcloud_api.server import Server, ServerNetworkInterface, login_user_block 33 | from upcloud_api.server_group import ServerGroup, ServerGroupAffinityPolicy 34 | from upcloud_api.storage import Storage 35 | from upcloud_api.storage_import import StorageImport 36 | from upcloud_api.tag import Tag 37 | from upcloud_api.upcloud_resource import UpCloudResource 38 | -------------------------------------------------------------------------------- /test/test_cloud_manager.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | import responses 5 | from conftest import Mock 6 | 7 | from upcloud_api import CloudManager 8 | from upcloud_api.errors import UpCloudClientError 9 | 10 | 11 | class TestCloudManagerBasic: 12 | def test_no_credentials(self): 13 | with pytest.raises(UpCloudClientError): 14 | CloudManager() 15 | 16 | @responses.activate 17 | def test_get_account(self, manager): 18 | data = Mock.mock_get("account") 19 | 20 | res = manager.authenticate() 21 | assert json.loads(data) == res 22 | res = manager.get_account() 23 | assert json.loads(data) == res 24 | 25 | @responses.activate 26 | def test_get_prices(self, manager): 27 | data = Mock.mock_get("price") 28 | 29 | res = manager.get_prices() 30 | assert json.loads(data) == res 31 | 32 | @responses.activate 33 | def test_get_zones(self, manager): 34 | data = Mock.mock_get("zone") 35 | 36 | res = manager.get_zones() 37 | assert json.loads(data) == res 38 | 39 | @responses.activate 40 | def test_get_timezones(self, manager): 41 | data = Mock.mock_get("timezone") 42 | 43 | res = manager.get_timezones() 44 | assert json.loads(data) == res 45 | 46 | @responses.activate 47 | def test_get_plans(self, manager): 48 | data = Mock.mock_get("plan") 49 | 50 | res = manager.get_server_plans() 51 | assert json.loads(data) == res 52 | -------------------------------------------------------------------------------- /upcloud_api/firewall.py: -------------------------------------------------------------------------------- 1 | from upcloud_api.upcloud_resource import UpCloudResource 2 | 3 | 4 | class FirewallRule(UpCloudResource): 5 | """ 6 | Class representation of the API's firewall rule. Extends UpCloudResource. 7 | """ 8 | 9 | ATTRIBUTES = { 10 | 'action': 'drop', 11 | 'direction': 'in', 12 | 'family': 'IPv4', 13 | 'comment': '', 14 | 'destination_address_end': None, 15 | 'destination_address_start': None, 16 | 'destination_port_end': None, 17 | 'destination_port_start': None, 18 | 'icmp_type': None, 19 | 'position': None, 20 | 'protocol': None, 21 | 'source_address_end': None, 22 | 'source_address_start': None, 23 | 'source_port_end': None, 24 | 'source_port_start': None, 25 | } 26 | 27 | def destroy(self): 28 | """ 29 | Remove this FirewallRule from the API. 30 | 31 | This instance must be associated with a server for this method to work, 32 | which is done by instantiating via server.get_firewall_rules(). 33 | """ 34 | if not hasattr(self, 'server') or not self.server: 35 | raise Exception( 36 | """FirewallRule not associated with server; 37 | please use or server.get_firewall_rules() to get objects 38 | that are associated with a server. 39 | """ 40 | ) 41 | return self.server.cloud_manager.delete_firewall_rule(self.server.uuid, self.position) 42 | -------------------------------------------------------------------------------- /test/json_data/server_00798b85-efdc-41ca-8021-f6ef457b8531_cdrom_eject_post.json: -------------------------------------------------------------------------------- 1 | { 2 | "server" : { 3 | "boot_order" : "disk", 4 | "core_number" : "0", 5 | "firewall" : "on", 6 | "hostname" : "fi.example.com", 7 | "ip_addresses" : { 8 | "ip_address" : [ 9 | { 10 | "access" : "private", 11 | "address" : "10.0.0.0", 12 | "family": "IPv4" 13 | }, 14 | { 15 | "access" : "public", 16 | "address" : "0.0.0.0", 17 | "family": "IPv4" 18 | } 19 | ] 20 | }, 21 | "labels": { 22 | "label": [ 23 | { 24 | "key": "test", 25 | "value": "example" 26 | } 27 | ] 28 | }, 29 | "license" : 0, 30 | "memory_amount" : "1024", 31 | "nic_model" : "e1000", 32 | "state" : "started", 33 | "storage_devices" : { 34 | "storage_device" : [ 35 | { 36 | "address" : "virtio:0", 37 | "storage" : "012580a1-32a1-466e-a323-689ca16f2d43", 38 | "storage_size" : 100, 39 | "storage_title" : "Storage for server1.example.com", 40 | "type" : "disk" 41 | } 42 | ] 43 | }, 44 | "tags": { 45 | "tag": [ 46 | "web1", 47 | "web2" 48 | ] 49 | }, 50 | "timezone" : "UTC", 51 | "title" : "Helsinki server", 52 | "uuid" : "00798b85-efdc-41ca-8021-f6ef457b8531", 53 | "video_model" : "cirrus", 54 | "vnc" : "on", 55 | "vnc_host" : "fi-he1l.vnc.upcloud.com", 56 | "vnc_password" : "aabbccdd", 57 | "vnc_port" : "00000", 58 | "zone" : "fi-hel1", 59 | "plan": "custom" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /test/json_data/server_create.json: -------------------------------------------------------------------------------- 1 | { 2 | "server" : { 3 | "boot_order" : "disk", 4 | "core_number" : "2", 5 | "firewall" : "off", 6 | "hostname" : "my.example.com", 7 | "ip_addresses" : { 8 | "ip_address" : [ 9 | { 10 | "access" : "private", 11 | "address" : "10.3.0.49" 12 | }, 13 | { 14 | "access" : "public", 15 | "address" : "38.100.118.31" 16 | } 17 | ] 18 | }, 19 | "labels": { 20 | "label": [ 21 | { 22 | "key": "test", 23 | "value": "example" 24 | } 25 | ] 26 | }, 27 | "license" : 0, 28 | "memory_amount" : "1024", 29 | "nic_model" : "virtio", 30 | "state" : "started", 31 | "simple_backup": "0430,monthlies", 32 | "storage_devices" : { 33 | "storage_device" : [ 34 | { 35 | "address" : "virtio:0", 36 | "storage" : "01215a5a-c330-4565-81ca-0e0e22eac672", 37 | "storage_size" : 20, 38 | "storage_title" : "Ubuntu 14.04 from template", 39 | "type" : "disk" 40 | }, 41 | { 42 | "address" : "virtio:1", 43 | "storage" : "01a8fc6b-8aa3-42ff-bbce-ed5c544bbd74", 44 | "storage_size" : 100, 45 | "storage_title" : "storage disk 1", 46 | "type" : "disk" 47 | } 48 | ] 49 | }, 50 | "timezone" : "UTC", 51 | "title" : "my.example.com", 52 | "uuid" : "00825599-2df7-4a83-99b4-c9a9c6812f59", 53 | "video_model" : "cirrus", 54 | "vnc" : "off", 55 | "vnc_password" : "aabbccdd", 56 | "zone" : "us-chi1", 57 | "plan": "custom" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /test/json_data/storage_detach.json: -------------------------------------------------------------------------------- 1 | { 2 | "server" : { 3 | "boot_order" : "disk", 4 | "core_number" : "0", 5 | "firewall" : "on", 6 | "hostname" : "fi.example.com", 7 | "ip_addresses" : { 8 | "ip_address" : [ 9 | { 10 | "access" : "private", 11 | "address" : "10.0.0.0" 12 | }, 13 | { 14 | "access" : "public", 15 | "address" : "0.0.0.0" 16 | } 17 | ] 18 | }, 19 | "labels": { 20 | "label": [ 21 | { 22 | "key": "test", 23 | "value": "example" 24 | } 25 | ] 26 | }, 27 | "license" : 0, 28 | "memory_amount" : "1024", 29 | "nic_model" : "e1000", 30 | "state" : "started", 31 | "storage_devices" : { 32 | "storage_device" : [ 33 | { 34 | "address" : "virtio:0", 35 | "labels": [ 36 | { 37 | "key": "role", 38 | "value": "primary" 39 | } 40 | ], 41 | "storage" : "012580a1-32a1-466e-a323-689ca16f2d43", 42 | "storage_size" : 100, 43 | "storage_title" : "Storage for server1.example.com", 44 | "type" : "disk" 45 | } 46 | ] 47 | }, 48 | "timezone" : "UTC", 49 | "title" : "Helsinki server", 50 | "uuid" : "00798b85-efdc-41ca-8021-f6ef457b8531", 51 | "video_model" : "cirrus", 52 | "vnc" : "on", 53 | "vnc_host" : "fi-he1l.vnc.upcloud.com", 54 | "vnc_password" : "aabbccdd", 55 | "vnc_port" : "00000", 56 | "zone" : "fi-hel1" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /test/json_data/server_00798b85-efdc-41ca-8021-f6ef457b8531.json: -------------------------------------------------------------------------------- 1 | { 2 | "server" : { 3 | "boot_order" : "disk", 4 | "core_number" : "0", 5 | "firewall" : "on", 6 | "hostname" : "fi.example.com", 7 | "ip_addresses" : { 8 | "ip_address" : [ 9 | { 10 | "access" : "private", 11 | "address" : "10.0.0.0", 12 | "family": "IPv4" 13 | }, 14 | { 15 | "access" : "public", 16 | "address" : "0.0.0.0", 17 | "family": "IPv4" 18 | } 19 | ] 20 | }, 21 | "labels": { 22 | "label": [ 23 | { 24 | "key": "test", 25 | "value": "example" 26 | } 27 | ] 28 | }, 29 | "license" : 0, 30 | "memory_amount" : "1024", 31 | "nic_model" : "e1000", 32 | "simple_backup": "0430,monthlies", 33 | "state" : "started", 34 | "storage_devices" : { 35 | "storage_device" : [ 36 | { 37 | "address" : "virtio:0", 38 | "storage" : "012580a1-32a1-466e-a323-689ca16f2d43", 39 | "storage_size" : 100, 40 | "storage_title" : "Storage for server1.example.com", 41 | "type" : "disk" 42 | } 43 | ] 44 | }, 45 | "tags": { 46 | "tag": [ 47 | "web1", 48 | "web2" 49 | ] 50 | }, 51 | "timezone" : "UTC", 52 | "title" : "Helsinki server", 53 | "uuid" : "00798b85-efdc-41ca-8021-f6ef457b8531", 54 | "video_model" : "cirrus", 55 | "vnc" : "on", 56 | "vnc_host" : "fi-he1l.vnc.upcloud.com", 57 | "vnc_password" : "aabbccdd", 58 | "vnc_port" : "00000", 59 | "zone" : "fi-hel1", 60 | "plan": "custom" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /docs/IP-address.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## About 4 | 5 | 6 | ``` 7 | Attributes: 8 | access -- "public" or "private" 9 | address -- the IP address (string) 10 | family -- "IPv4" or "IPv6" 11 | ptr_record -- the reverse DNS name (string) 12 | server -- the UUID of the server this IP is attached to (string) 13 | ``` 14 | 15 | The only updateable attribute is the `ptr_record`. 16 | `ptr_record` and `server` are not present if /server/uuid endpoint was used. 17 | 18 | ## List / Get 19 | 20 | CloudManager returns IPAddress objects. 21 | 22 | ```python 23 | 24 | manager.get_ips() 25 | manager.get_ip("185.20.31.125") 26 | 27 | ``` 28 | 29 | ## Create 30 | 31 | A new IPAddress must be attached to a server and has a random address. 32 | 33 | ```python 34 | 35 | # params: server uuid or a Server object and family, for which default is IPv4 36 | manager.attach_ip(server_uuid) 37 | manager.attach_ip(server_uuid, "IPv4") 38 | manager.attach_ip(server_uuid, "IPv6") 39 | manager.attach_ip(Server) 40 | manager.attach_ip(Server, "IPv4") 41 | manager.attach_ip(Server, "IPv6") 42 | 43 | 44 | # or use Server instance 45 | server = manager.get_server(uuid) 46 | server.add_ip() # default is IPv4 47 | server.add_ip("IPv4") 48 | server.add_ip("IPv6") 49 | 50 | ``` 51 | 52 | ## Update 53 | 54 | At the moment only the ptr_record (reverse DNS) of an IP address can be changed. 55 | 56 | ```python 57 | 58 | ip = manager.get_ip("185.20.31.125") 59 | ip.ptr_record = "the.new.ptr.record" 60 | ip.save() 61 | 62 | ``` 63 | 64 | ## Destroy 65 | 66 | ```python 67 | 68 | ip = manager.get_ip("185.20.31.125") 69 | ip.destroy() 70 | 71 | ``` 72 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # UpCloud's Python API Documentation 2 | 3 | This is the documentation for the newest version of the library. For older versions, 4 | please download the correct source from [releases](https://github.com/UpCloudLtd/upcloud-python-api/releases) 5 | and use `mkdocs` to build the documentation. 6 | 7 | This documentation includes many code examples for administrating resources on top of UpCloud. 8 | In some cases it can help to be familiar with [UpCloud's API v1.3 documentation](https://www.upcloud.com/api/). 9 | The code itself also has commentary & examples and is structured similarly to this documentation. 10 | 11 | The documentation is divided into two parts. Usage describes the basic CRUD functionality for the object 12 | representations of different UpCloud resources (servers, storages, networks etc). The CloudManager describes 13 | the API for performing direct API calls. 14 | 15 | Python package `upcloud_api` must be installed before any code examples can be tried. 16 | 17 | ```bash 18 | pip3 install upcloud-api 19 | ``` 20 | 21 | NOTE: Support for Python 2.7 ended with version 1.0.1. If you need to use it, a supporting version can be 22 | installed with `pip install upcloud-api==1.0.1`. 23 | 24 | Many code examples assume that CloudManager object has already been initialized. 25 | In addition to CloudManager, some resources might be needed to be imported. 26 | Full example of imports is below. 27 | 28 | ```python 29 | import upcloud_api 30 | from upcloud_api import Storage 31 | from upcloud_api import Server 32 | from upcloud_api import FirewallRule 33 | from upcloud_api import Network 34 | from upcloud_api import IPAddress 35 | 36 | manager = upcloud_api.CloudManager("username", "password") 37 | ``` 38 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.13 3 | # by the following command: 4 | # 5 | # pip-compile --output-file=requirements-dev.txt requirements-dev.in 6 | # 7 | build==1.2.2.post1 8 | # via pip-tools 9 | certifi==2025.7.14 10 | # via requests 11 | charset-normalizer==3.4.2 12 | # via requests 13 | click==8.1.8 14 | # via pip-tools 15 | coverage[toml]==7.9.2 16 | # via pytest-cov 17 | idna==3.10 18 | # via requests 19 | iniconfig==2.1.0 20 | # via pytest 21 | jaraco-classes==3.4.0 22 | # via keyring 23 | jaraco-context==6.0.1 24 | # via keyring 25 | jaraco-functools==4.2.1 26 | # via keyring 27 | keyring==25.6.0 28 | # via -r requirements-dev.in 29 | mock==5.2.0 30 | # via -r requirements-dev.in 31 | more-itertools==10.7.0 32 | # via 33 | # jaraco-classes 34 | # jaraco-functools 35 | packaging==25.0 36 | # via 37 | # build 38 | # pytest 39 | pip-tools==7.4.1 40 | # via -r requirements-dev.in 41 | pluggy==1.6.0 42 | # via 43 | # pytest 44 | # pytest-cov 45 | pygments==2.19.2 46 | # via pytest 47 | pyproject-hooks==1.2.0 48 | # via 49 | # build 50 | # pip-tools 51 | pytest==8.4.1 52 | # via 53 | # -r requirements-dev.in 54 | # pytest-cov 55 | pytest-cov==6.2.1 56 | # via -r requirements-dev.in 57 | pyyaml==6.0.2 58 | # via responses 59 | requests==2.32.4 60 | # via responses 61 | responses==0.25.7 62 | # via -r requirements-dev.in 63 | urllib3==2.5.0 64 | # via 65 | # requests 66 | # responses 67 | wheel==0.45.1 68 | # via pip-tools 69 | 70 | # The following packages are considered to be unsafe in a requirements file: 71 | # pip 72 | # setuptools 73 | -------------------------------------------------------------------------------- /test/json_data/server_0082c083-9847-4f9f-ae04-811251309b35_networking.json: -------------------------------------------------------------------------------- 1 | { 2 | "networking": 3 | { 4 | "interfaces": 5 | { 6 | "interface": 7 | [ 8 | { 9 | "bootable": "no", 10 | "index": 1, 11 | "ip_addresses": {"ip_address": [{"address": "185.20.136.92", "family": "IPv4", "floating": "no"}]}, 12 | "mac": "da:4e:74:bd:2b:29", 13 | "network": "03000000-0000-4000-8002-000000000000", 14 | "source_ip_filtering": "yes", 15 | "type": "public" 16 | }, 17 | { 18 | "bootable": "no", 19 | "index": 2, 20 | "ip_addresses": {"ip_address": [{"address": "10.1.6.245", "family": "IPv4", "floating": "no"}]}, 21 | "mac": "da:4e:74:bd:90:93", 22 | "network": "03b34bc2-5adf-4fc4-8c44-83f869058f5a", 23 | "source_ip_filtering": "yes", 24 | "type": "utility" 25 | }, 26 | { 27 | "bootable": "no", 28 | "index": 3, 29 | "ip_addresses": {"ip_address": [{"address": "2a04:3540:1000:310:d84e:74ff:febd:0760", "family": "IPv6", "floating": "no"}]}, 30 | "mac": "da:4e:74:bd:07:60", 31 | "network": "03000000-0000-4000-8015-000000000000", 32 | "source_ip_filtering": "yes", 33 | "type": "public" 34 | }, 35 | { 36 | "bootable": "yes", 37 | "index": 7, 38 | "ip_addresses": {"ip_address": [{"address": "172.16.1.10", "family": "IPv4", "floating": "no"}]}, 39 | "mac": "da:4e:74:bd:ce:18", 40 | "network": "036df3d0-8629-4549-984e-dc86fc3fa1b0", 41 | "source_ip_filtering": "yes", 42 | "type": "private" 43 | } 44 | ] 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /upcloud_api/server_group.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from upcloud_api.upcloud_resource import UpCloudResource 4 | 5 | 6 | class ServerGroupAffinityPolicy(str, Enum): 7 | """ 8 | Enum representation of affinity policy for a server group 9 | """ 10 | 11 | STRICT_ANTI_AFFINITY = 'strict' 12 | ANTI_AFFINITY_PREFERRED = 'yes' 13 | NO_ANTI_AFFINITY = 'no' 14 | 15 | 16 | class ServerGroup(UpCloudResource): 17 | """ 18 | Class representation of UpCloud server group resource 19 | """ 20 | 21 | ATTRIBUTES = { 22 | 'anti_affinity': ServerGroupAffinityPolicy.NO_ANTI_AFFINITY, 23 | 'labels': None, 24 | 'servers': None, 25 | 'title': None, 26 | 'uuid': None, 27 | } 28 | 29 | def __str__(self) -> str: 30 | return self.title 31 | 32 | def to_dict(self): 33 | """ 34 | Return a dict that can be serialised to JSON and sent to UpCloud's API. 35 | """ 36 | body = { 37 | 'title': self.title, 38 | } 39 | 40 | if hasattr(self, 'anti_affinity'): 41 | body['anti_affinity'] = f"{self.anti_affinity}" 42 | 43 | if hasattr(self, 'servers'): 44 | servers = [] 45 | for server in self.servers: 46 | if isinstance(server, server.Server) and hasattr(server, 'uuid'): 47 | servers.append(server.uuid) 48 | else: 49 | servers.append(server) 50 | 51 | if hasattr(self, 'labels'): 52 | dict_labels = {'label': []} 53 | for label in self.labels: 54 | dict_labels['label'].append(label.to_dict()) 55 | body['labels'] = dict_labels 56 | 57 | return {'server_group': body} 58 | -------------------------------------------------------------------------------- /test/json_data/server_00798b85-efdc-41ca-8021-f6ef457b8531_cdrom_load_post.json: -------------------------------------------------------------------------------- 1 | { 2 | "server" : { 3 | "boot_order" : "disk", 4 | "core_number" : "0", 5 | "firewall" : "on", 6 | "hostname" : "fi.example.com", 7 | "ip_addresses" : { 8 | "ip_address" : [ 9 | { 10 | "access" : "private", 11 | "address" : "10.0.0.0", 12 | "family": "IPv4" 13 | }, 14 | { 15 | "access" : "public", 16 | "address" : "0.0.0.0", 17 | "family": "IPv4" 18 | } 19 | ] 20 | }, 21 | "labels": { 22 | "label": [ 23 | { 24 | "key": "test", 25 | "value": "example" 26 | } 27 | ] 28 | }, 29 | "license" : 0, 30 | "memory_amount" : "1024", 31 | "nic_model" : "e1000", 32 | "state" : "started", 33 | "storage_devices" : { 34 | "storage_device" : [ 35 | { 36 | "address" : "ide:0:0", 37 | "boot_disk": "0", 38 | "storage" : "01ec5c26-a25d-4752-94e4-27bd88b62816", 39 | "storage_size" : 10, 40 | "storage_title" : "cd-rom test", 41 | "type" : "cdrom" 42 | }, 43 | { 44 | "address" : "virtio:0", 45 | "storage" : "012580a1-32a1-466e-a323-689ca16f2d43", 46 | "storage_size" : 100, 47 | "storage_title" : "Storage for server1.example.com", 48 | "type" : "disk" 49 | } 50 | ] 51 | }, 52 | "tags": { 53 | "tag": [ 54 | "web1", 55 | "web2" 56 | ] 57 | }, 58 | "timezone" : "UTC", 59 | "title" : "Helsinki server", 60 | "uuid" : "00798b85-efdc-41ca-8021-f6ef457b8531", 61 | "video_model" : "cirrus", 62 | "vnc" : "on", 63 | "vnc_host" : "fi-he1l.vnc.upcloud.com", 64 | "vnc_password" : "aabbccdd", 65 | "vnc_port" : "00000", 66 | "zone" : "fi-hel1", 67 | "plan": "custom" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /test/test_apidoc/test_10_ip_addresses.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from conftest import read_from_file 4 | 5 | from upcloud_api import IPAddress, Server 6 | 7 | 8 | class TestIP: 9 | def test_ip_in_server_creation(self): 10 | """IPAddress in server creation. 11 | 12 | https://www.upcloud.com/api/8-servers/#create-server 13 | """ 14 | ip1 = IPAddress(family='IPv4', access='public') 15 | ip2 = IPAddress(family='IPv6', access='private') 16 | assert ip1.to_dict() == {'family': 'IPv4', 'access': 'public'} 17 | assert ip2.to_dict() == {'family': 'IPv6', 'access': 'private'} 18 | 19 | def test_ip_in_server_details(self): 20 | """IPAddress in server details. 21 | 22 | https://www.upcloud.com/api/8-servers/#get-server-details 23 | """ 24 | ip = IPAddress(access='private', address='10.0.0.0', family='IPv4') 25 | assert ip.to_dict() == {'access': 'private', 'address': '10.0.0.0', 'family': 'IPv4'} 26 | 27 | data = read_from_file('server_00798b85-efdc-41ca-8021-f6ef457b8531.json') 28 | s = Server(**json.loads(data)) 29 | for ip in s.ip_addresses: 30 | assert set(ip.to_dict().keys()) == {'address', 'family', 'access'} 31 | 32 | def test_ip_details(self): 33 | """IPAdress LIST/GET. 34 | 35 | https://www.upcloud.com/api/10-ip-addresses/#list-ip-addresses 36 | """ 37 | ip = IPAddress(**json.loads(read_from_file('ip_address_10.1.0.101.json'))['ip_address']) 38 | assert ip.to_dict() == { 39 | 'access': 'private', 40 | 'address': '10.1.0.101', 41 | 'family': 'IPv4', 42 | 'part_of_plan': 'yes', 43 | 'ptr_record': 'a.ptr.record', 44 | 'server': '008c365d-d307-4501-8efc-cd6d3bb0e494', 45 | } 46 | -------------------------------------------------------------------------------- /test/test_ip_manager.py: -------------------------------------------------------------------------------- 1 | import responses 2 | from conftest import Mock 3 | 4 | 5 | class TestIP: 6 | @responses.activate 7 | def test_get_ip(self, manager): 8 | data = Mock.mock_get('ip_address/10.1.0.101') 9 | ip_addr = manager.get_ip('10.1.0.101') 10 | 11 | assert type(ip_addr).__name__ == 'IPAddress' 12 | assert ip_addr.address == '10.1.0.101' 13 | assert ip_addr.ptr_record == 'a.ptr.record' 14 | 15 | @responses.activate 16 | def test_get_ips(self, manager): 17 | data = Mock.mock_get('ip_address') 18 | ip_addrs = manager.get_ips() 19 | 20 | for ip_addr in ip_addrs: 21 | assert type(ip_addr).__name__ == 'IPAddress' 22 | 23 | @responses.activate 24 | def test_modify_ip_oop(self, manager): 25 | # get ip 26 | data = Mock.mock_get('ip_address/10.1.0.101') 27 | ip_addr = manager.get_ip('10.1.0.101') 28 | 29 | # put ip 30 | data = Mock.mock_put('ip_address/10.1.0.101') 31 | ip_addr.ptr_record = 'my.ptr.record' 32 | ip_addr.save() 33 | 34 | assert ip_addr.ptr_record == 'my.ptr.record' 35 | 36 | @responses.activate 37 | def test_modify_ip(self, manager): 38 | data = Mock.mock_put('ip_address/10.1.0.101') 39 | ip_addr = manager.modify_ip('10.1.0.101', ptr_record='my.ptr.record') 40 | assert ip_addr.ptr_record == 'my.ptr.record' 41 | 42 | @responses.activate 43 | def test_ip_delete(self, manager): 44 | Mock.mock_delete('ip_address/10.1.0.101') 45 | res = manager.release_ip('10.1.0.101') 46 | assert res == {} 47 | 48 | @responses.activate 49 | def test_create_floating_ip(self, manager): 50 | Mock.mock_post('ip_address') 51 | floating_ip = manager.create_floating_ip('fi-hel2') 52 | assert type(floating_ip).__name__ == 'IPAddress' 53 | assert floating_ip.floating == 'yes' 54 | assert floating_ip.zone == 'fi-hel2' 55 | -------------------------------------------------------------------------------- /test/json_data/storage_attach.json: -------------------------------------------------------------------------------- 1 | { 2 | "server" : { 3 | "boot_order" : "disk", 4 | "core_number" : "0", 5 | "firewall" : "on", 6 | "hostname" : "fi.example.com", 7 | "ip_addresses" : { 8 | "ip_address" : [ 9 | { 10 | "access" : "private", 11 | "address" : "10.0.0.0" 12 | }, 13 | { 14 | "access" : "public", 15 | "address" : "0.0.0.0" 16 | } 17 | ] 18 | }, 19 | "labels": { 20 | "label": [ 21 | { 22 | "key": "test", 23 | "value": "example" 24 | } 25 | ] 26 | }, 27 | "license" : 0, 28 | "memory_amount" : "1024", 29 | "nic_model" : "e1000", 30 | "state" : "started", 31 | "storage_devices" : { 32 | "storage_device" : [ 33 | { 34 | "address" : "virtio:0", 35 | "labels": [ 36 | { 37 | "key": "role", 38 | "value": "primary" 39 | } 40 | ], 41 | "storage" : "012580a1-32a1-466e-a323-689ca16f2d43", 42 | "storage_size" : 100, 43 | "storage_title" : "Storage for server1.example.com", 44 | "type" : "disk" 45 | }, 46 | { 47 | "labels": [ 48 | { 49 | "key": "role", 50 | "value": "secondary" 51 | } 52 | ], 53 | "storage_size": 10, 54 | "storage": "01d4fcd4-e446-433b-8a9c-551a1284952e", 55 | "storage_title": "Operating system disk", 56 | "address": "ide:0:0", 57 | "type": "disk" 58 | } 59 | ] 60 | }, 61 | "timezone" : "UTC", 62 | "title" : "Helsinki server", 63 | "uuid" : "00798b85-efdc-41ca-8021-f6ef457b8531", 64 | "video_model" : "cirrus", 65 | "vnc" : "on", 66 | "vnc_host" : "fi-he1l.vnc.upcloud.com", 67 | "vnc_password" : "aabbccdd", 68 | "vnc_port" : "00000", 69 | "zone" : "fi-hel1" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /test/json_data/price.json: -------------------------------------------------------------------------------- 1 | {"prices": {"zone": [{"storage_backup": {"amount": 1, "price": 0.007}, "io_request_backup": {"amount": 1000000, "price": 0}, "io_request_maxiops": {"amount": 1000000, "price": 0}, "server_memory": {"amount": 256, "price": 0.45}, "public_bandwidth_in": {"amount": 1, "price": 0}, "io_request_ssd": {"amount": 1000000, "price": 0}, "server_core": {"amount": 1, "price": 1.3}, "storage_ssd": {"amount": 1, "price": 0.05}, "storage_template": {"amount": 1, "price": 0.028}, "public_bandwidth_out": {"amount": 1, "price": 5}, "name": "fi-hel1", "storage_maxiops": {"amount": 1, "price": 0.028}, "ip_address": {"amount": 1, "price": 0.3}, "storage_hdd": {"amount": 1, "price": 0.013}, "io_request_hdd": {"amount": 1000000, "price": 0}, "firewall": {"amount": 1, "price": 0.5}}, {"storage_backup": {"amount": 1, "price": 0.007}, "io_request_backup": {"amount": 1000000, "price": 0}, "io_request_maxiops": {"amount": 1000000, "price": 0}, "server_memory": {"amount": 256, "price": 0.125}, "public_bandwidth_in": {"amount": 1, "price": 0}, "io_request_ssd": {"amount": 1000000, "price": 0}, "server_core": {"amount": 1, "price": 1}, "storage_ssd": {"amount": 1, "price": 0.028}, "storage_template": {"amount": 1, "price": 0.028}, "public_bandwidth_out": {"amount": 1, "price": 5}, "name": "uk-lon1", "storage_maxiops": {"amount": 1, "price": 0.028}, "ip_address": {"amount": 1, "price": 0.3}, "storage_hdd": {"amount": 1, "price": 0.007}, "io_request_hdd": {"amount": 1000000, "price": 0}, "firewall": {"amount": 1, "price": 0.5}}, {"storage_maxiops": {"amount": 1, "price": 0.028}, "io_request_backup": {"amount": 1000000, "price": 0}, "server_core": {"amount": 1, "price": 1}, "storage_backup": {"amount": 1, "price": 0.007}, "public_bandwidth_in": {"amount": 1, "price": 0}, "io_request_maxiops": {"amount": 1000000, "price": 0}, "server_memory": {"amount": 256, "price": 0.125}, "storage_template": {"amount": 1, "price": 0.028}, "public_bandwidth_out": {"amount": 1, "price": 5}, "name": "us-chi1", "ip_address": {"amount": 1, "price": 0.3}, "firewall": {"amount": 1, "price": 0.5}}]}} -------------------------------------------------------------------------------- /upcloud_api/upcloud_resource.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | if TYPE_CHECKING: 4 | from upcloud_api import CloudManager 5 | 6 | 7 | class UpCloudResource: 8 | """ 9 | Base class for all API resources. 10 | 11 | ATTRIBUTES is used to define serialization (see: to_dict) 12 | and defaults (see: __init__ and _reset). 13 | 14 | All UpCloudResources: 15 | - must define ATTRIBUTES accordingly with https://www.upcloud.com/api/ (doc) 16 | - must have `to_dict` for JSON serialization 17 | - must have `_reset` for initializing and refreshing the instance with updated data 18 | - must call `UpCloudResource.__init__` (that uses `_reset`) 19 | - optionally implement `sync` for refreshing the instance with new data from API 20 | """ 21 | 22 | ATTRIBUTES = {} # subclass should define this 23 | 24 | cloud_manager: 'CloudManager' 25 | 26 | def __init__(self, **kwargs) -> None: 27 | """ 28 | Create a resource object from a dict. 29 | 30 | Set attributes from kwargs and any missing defaults from ATTRIBUTES. 31 | """ 32 | self._reset(**kwargs) 33 | 34 | def _reset(self, **kwargs) -> None: 35 | """ 36 | Reset after repopulating from API (or when initializing). 37 | """ 38 | # set object attributes from params 39 | for key in kwargs: 40 | setattr(self, key, kwargs[key]) 41 | 42 | # set defaults (if need be) where the default is not None 43 | for attr in self.ATTRIBUTES: 44 | if not hasattr(self, attr) and self.ATTRIBUTES[attr] is not None: 45 | setattr(self, attr, self.ATTRIBUTES[attr]) 46 | 47 | def sync(self): 48 | """ 49 | Sync the object from the API and use the internal resource._reset to 50 | update fields. 51 | """ 52 | raise NotImplementedError 53 | 54 | def to_dict(self): 55 | """ 56 | Return a dict that can be serialised to JSON and sent to UpCloud's API. 57 | """ 58 | return {attr: getattr(self, attr) for attr in self.ATTRIBUTES if hasattr(self, attr)} 59 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | permissions: {} 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: read 14 | steps: 15 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 16 | - name: Setup Python 17 | uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 18 | with: 19 | python-version: 3.13 20 | - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 21 | test: 22 | runs-on: ubuntu-latest 23 | strategy: 24 | matrix: 25 | tox_env: 26 | - py39 27 | - py310 28 | - py311 29 | - py312 30 | - py313 31 | - pypy311 32 | permissions: 33 | contents: read 34 | steps: 35 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 36 | - name: Fedora Tox with ${{ matrix.tox_env }} 37 | uses: fedora-python/tox-github-action@807f27871410c7391018dc9a245c8cffdced15e9 # v41.0 38 | with: 39 | tox_env: ${{ matrix.tox_env }} 40 | build: 41 | runs-on: ubuntu-latest 42 | permissions: 43 | contents: read 44 | steps: 45 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 46 | - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 47 | - run: python -m pip install --upgrade build 48 | - run: python -m build 49 | - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 50 | with: 51 | name: dist 52 | path: dist/ 53 | publish: 54 | runs-on: ubuntu-latest 55 | needs: build 56 | if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags') }} 57 | permissions: 58 | id-token: write 59 | steps: 60 | - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 61 | with: 62 | name: dist 63 | path: dist/ 64 | - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 65 | -------------------------------------------------------------------------------- /test/helpers/infra_helpers.py: -------------------------------------------------------------------------------- 1 | def create_cluster(manager, cluster): 2 | """Create all servers in cluster.""" 3 | for server in cluster: 4 | s = manager.create_server(cluster[server]) 5 | 6 | for server in cluster: 7 | cluster[server].ensure_started() 8 | 9 | return manager.get_servers() 10 | 11 | 12 | def firewall_test(manager, firewall_rules, server): 13 | """Run tests on firewall rules.""" 14 | # add 1 rule and remove it 15 | server.add_firewall_rule(firewall_rules[0]) 16 | 17 | fs = server.get_firewall_rules() 18 | assert len(fs) == 1 19 | 20 | fs[0].destroy() 21 | fs = server.get_firewall_rules() 22 | assert len(fs) == 0 23 | 24 | # add several rules and remove them 25 | server.configure_firewall(firewall_rules) 26 | 27 | fs = server.get_firewall_rules() 28 | assert len(fs) == 2 29 | 30 | for _ in fs: 31 | manager.delete_firewall_rule(server.uuid, 1) 32 | 33 | fs = server.get_firewall_rules() 34 | assert len(fs) == 0 35 | 36 | 37 | def server_test(manager, server): 38 | """Run tests on a server instance.""" 39 | server.populate() 40 | 41 | server.core_number = '3' 42 | server.memory_amount = '1024' 43 | server.save() 44 | 45 | server.add_ip() 46 | 47 | storage = manager.create_storage(size=10, tier='maxiops', zone='uk-lon1') 48 | server.add_storage(storage) 49 | 50 | server.ensure_started() 51 | 52 | # sync new info from API and assert the changes from above have happened 53 | server.populate() 54 | assert server.core_number == '3' 55 | assert server.memory_amount == '1024' 56 | assert len(server.storage_devices) == 3 57 | assert len(server.ip_addresses) == 4 58 | 59 | server.ensure_started() 60 | 61 | 62 | def tag_servers_test(manager, tags, cluster): 63 | """Run tests on tags.""" 64 | # create tags 65 | for t in tags: 66 | manager.create_tag(str(t)) 67 | 68 | cluster['web1'].add_tags(['testweb']) 69 | cluster['web2'].add_tags(['testweb']) 70 | cluster['lb'].add_tags([tags[1]]) # tags[1] is 'db' 71 | cluster['db'].add_tags(['testlb']) 72 | 73 | fetched_servers = manager.get_servers(tags_has_one=['testlb']) 74 | assert len(fetched_servers) == 1 75 | assert fetched_servers[0].tags[0] == 'testlb' 76 | -------------------------------------------------------------------------------- /test/test_credentials.py: -------------------------------------------------------------------------------- 1 | import keyring 2 | 3 | from upcloud_api import Credentials 4 | 5 | 6 | class DictBackend(keyring.backend.KeyringBackend): 7 | priority = 1 8 | 9 | def __init__(self, secrets=None): 10 | super().__init__() 11 | self._secrets = secrets or {} 12 | 13 | def set_password(self, servicename, username, password): 14 | pass 15 | 16 | def get_password(self, servicename, username): 17 | return self._secrets.get(servicename, {}).get(username) 18 | 19 | def delete_password(self, servicename, username): 20 | pass 21 | 22 | 23 | class TestCredentials: 24 | def test_precedence(self, monkeypatch): 25 | param_basic = 'Basic cGFyYW1fdXNlcjpwYXJhbV9wYXNz' 26 | param_bearer = 'Bearer param_token' 27 | env_basic = 'Basic ZW52X3VzZXI6ZW52X3Bhc3M=' 28 | env_bearer = 'Bearer env_token' 29 | keyring_basic = 'Basic ZW52X3VzZXI6a2V5cmluZ19wYXNz' 30 | keyring_bearer = 'Bearer keyring_token' 31 | 32 | backend = DictBackend( 33 | { 34 | "UpCloud": { 35 | "env_user": "keyring_pass", 36 | "": "keyring_token", 37 | } 38 | } 39 | ) 40 | keyring.set_keyring(backend) 41 | 42 | credentials = Credentials.parse() 43 | assert credentials.authorization == keyring_bearer 44 | 45 | monkeypatch.setenv("UPCLOUD_USERNAME", 'env_user') 46 | 47 | credentials = Credentials.parse() 48 | assert credentials.authorization == keyring_basic 49 | 50 | monkeypatch.setenv("UPCLOUD_PASSWORD", 'env_pass') 51 | 52 | credentials = Credentials.parse(username='param_user', password='param_pass') 53 | assert credentials.authorization == param_basic 54 | 55 | credentials = Credentials.parse() 56 | assert credentials.authorization == env_basic 57 | 58 | monkeypatch.setenv("UPCLOUD_TOKEN", 'env_token') 59 | credentials = Credentials.parse(username='param_user', password='param_pass') 60 | assert credentials.authorization == param_basic 61 | 62 | credentials = Credentials.parse() 63 | assert credentials.authorization == env_bearer 64 | 65 | credentials = Credentials.parse(token='param_token') 66 | assert credentials.authorization == param_bearer 67 | -------------------------------------------------------------------------------- /test/helpers/infra.py: -------------------------------------------------------------------------------- 1 | from upcloud_api.firewall import FirewallRule 2 | from upcloud_api.ip_address import IPAddress 3 | from upcloud_api.server import Server, login_user_block 4 | from upcloud_api.storage import Storage 5 | from upcloud_api.tag import Tag 6 | 7 | CLUSTER = { 8 | 'web1': Server( 9 | core_number=1, 10 | memory_amount=1024, 11 | hostname='web1.example.com', 12 | zone='uk-lon1', 13 | password_delivery='none', 14 | storage_devices=[ 15 | Storage(os='01000000-0000-4000-8000-000030240200', size=10), 16 | Storage(size=10, tier='maxiops'), 17 | ], 18 | ), 19 | 'web2': Server( 20 | core_number=1, 21 | memory_amount=1024, 22 | hostname='web2.example.com', 23 | zone='uk-lon1', 24 | password_delivery='none', 25 | storage_devices=[ 26 | Storage(os='01000000-0000-4000-8000-000030240200', size=10), 27 | Storage(size=10, tier='maxiops'), 28 | ], 29 | ip_addresses=[IPAddress(family='IPv6', access='public')], 30 | ), 31 | 'db': Server( 32 | core_number=1, 33 | memory_amount=1024, 34 | hostname='db.example.com', 35 | zone='uk-lon1', 36 | password_delivery='none', 37 | storage_devices=[ 38 | Storage(os='01000000-0000-4000-8000-000150020100', size=10), 39 | Storage(size=10, tier='standard'), 40 | ], 41 | login_user=login_user_block('testuser', ['ssh-ed25519 AAAA'], False), 42 | ), 43 | 'lb': Server( 44 | plan='1xCPU-1GB', 45 | hostname='balancer.example.com', 46 | zone='uk-lon1', 47 | password_delivery='none', 48 | storage_devices=[Storage(os='01000000-0000-4000-8000-000020070100', size=30)], 49 | login_user=login_user_block('testuser', ['ssh-ed25519 AAAA'], False), 50 | ), 51 | } 52 | 53 | 54 | FIREWALL_RULES = [ 55 | FirewallRule( 56 | position='1', 57 | direction='in', 58 | family='IPv4', 59 | protocol='tcp', 60 | source_address_start='192.168.1.1', 61 | source_address_end='192.168.1.255', 62 | destination_port_start='22', 63 | destination_port_end='22', 64 | action='accept', 65 | ), 66 | FirewallRule( 67 | position='2', 68 | direction='in', 69 | family='IPv4', 70 | protocol='tcp', 71 | source_address_start='192.168.1.1', 72 | source_address_end='192.168.1.255', 73 | destination_port_start='21', 74 | destination_port_end='21', 75 | action='accept', 76 | ), 77 | ] 78 | 79 | 80 | TAGS = [Tag('testlb'), Tag('testdb'), Tag('testweb')] 81 | -------------------------------------------------------------------------------- /upcloud_api/cloud_manager/ip_address_mixin.py: -------------------------------------------------------------------------------- 1 | from upcloud_api.api import API 2 | from upcloud_api.ip_address import IPAddress 3 | 4 | 5 | class IPManager: 6 | """ 7 | Functions for managing IP-addresses. Intended to be used as a mixin for CloudManager. 8 | """ 9 | 10 | api: API 11 | 12 | def get_ip(self, address: str) -> IPAddress: 13 | """ 14 | Get an IPAddress object with the IP address (string) from the API. 15 | 16 | e.g manager.get_ip('80.69.175.210') 17 | """ 18 | res = self.api.get_request('/ip_address/' + address) 19 | return IPAddress(cloud_manager=self, **res['ip_address']) 20 | 21 | def get_ips(self, ignore_ips_without_server=False): 22 | """ 23 | Get all IPAddress objects from the API. 24 | """ 25 | res = self.api.get_request('/ip_address') 26 | IPs = IPAddress._create_ip_address_objs( 27 | res['ip_addresses'], self, ignore_ips_without_server 28 | ) 29 | return IPs 30 | 31 | def attach_ip(self, server: str, family: str = 'IPv4') -> IPAddress: 32 | """ 33 | Attach a new (random) IPAddress to the given server (object or UUID). 34 | """ 35 | body = {'ip_address': {'server': str(server), 'family': family}} 36 | 37 | res = self.api.post_request('/ip_address', body) 38 | return IPAddress(cloud_manager=self, **res['ip_address']) 39 | 40 | def modify_ip(self, ip_addr: str, ptr_record: str) -> IPAddress: 41 | """ 42 | Modify an IP address' ptr-record (Reverse DNS). 43 | 44 | Accepts an IPAddress instance (object) or its address (string). 45 | """ 46 | body = {'ip_address': {'ptr_record': ptr_record}} 47 | 48 | res = self.api.put_request('/ip_address/' + str(ip_addr), body) 49 | return IPAddress(cloud_manager=self, **res['ip_address']) 50 | 51 | def release_ip(self, ip_addr): 52 | """ 53 | Destroy an IPAddress. Returns an empty object. 54 | 55 | Accepts an IPAddress instance (object) or its address (string). 56 | """ 57 | return self.api.delete_request('/ip_address/' + str(ip_addr)) 58 | 59 | def create_floating_ip(self, zone: str, mac: str = '', family: str = 'IPv4') -> IPAddress: 60 | """ 61 | Create a floating IP and returns an IPAddress object. 62 | Specify MAC address of network interface to attach the floating IP when it is created 63 | """ 64 | body = {'ip_address': {'family': family, 'floating': 'yes', 'zone': zone}} 65 | if mac: 66 | body['ip_address']['mac'] = mac 67 | 68 | res = self.api.post_request('/ip_address', body) 69 | return IPAddress(cloud_manager=self, **res['ip_address']) 70 | -------------------------------------------------------------------------------- /upcloud_api/tag.py: -------------------------------------------------------------------------------- 1 | from upcloud_api.server import Server 2 | from upcloud_api.upcloud_resource import UpCloudResource 3 | 4 | 5 | class Tag(UpCloudResource): 6 | """ 7 | Class representation of the API's tags. Extends UpCloudResource. 8 | 9 | Attributes 10 | ---------- 11 | name -- unique name for the tag 12 | description -- optional description 13 | servers -- list of Server objects (with only uuid populated) 14 | can be instantiated with UUID strings or Server objects 15 | """ 16 | 17 | ATTRIBUTES = {'name': None, 'description': None, 'servers': []} 18 | 19 | def __init__(self, name: str, description=None, servers=None, **kwargs) -> None: 20 | """Init with Tag('tagname', 'description', [servers]) syntax.""" 21 | if servers is None: 22 | servers = [] 23 | super().__init__(name=name, description=description, servers=servers, **kwargs) 24 | 25 | def _reset(self, **kwargs) -> None: 26 | """ 27 | Reset the objects attributes. 28 | 29 | Accepts servers as either un-flattened or flattened UUID strings or Server objects. 30 | """ 31 | super()._reset(**kwargs) 32 | 33 | # backup name for changing it (look: Tag.save) 34 | self._api_name = self.name 35 | 36 | # flatten { servers: { server: [] } } 37 | if 'server' in self.servers: 38 | self.servers = kwargs['servers']['server'] 39 | 40 | # convert UUIDs into server objects 41 | if self.servers and isinstance(self.servers[0], str): 42 | self.servers = [Server(uuid=server, populated=False) for server in self.servers] 43 | 44 | @property 45 | def server_uuids(self): 46 | """ 47 | Return the tag's servers as UUIDs. 48 | Useful for forming API requests. 49 | """ 50 | return [server.uuid for server in self.servers] 51 | 52 | def save(self) -> None: 53 | """ 54 | Save any changes made to the tag. 55 | """ 56 | tag_dict = self.cloud_manager._modify_tag( 57 | self._api_name, self.description, self.server_uuids, self.name 58 | ) 59 | self._reset(**tag_dict) 60 | 61 | def destroy(self): 62 | """ 63 | Destroy the tag at the API. 64 | """ 65 | self.cloud_manager.delete_tag(self.name) 66 | 67 | def to_dict(self): 68 | """ 69 | Return a dict that can be serialised to JSON and sent to UpCloud's API. 70 | """ 71 | return { 72 | 'name': self.name, 73 | 'description': self.description or '', 74 | 'servers': {'server': self.server_uuids}, 75 | } 76 | 77 | def __str__(self) -> str: 78 | """ 79 | String representation of Tag. 80 | Can be used to add tags into API requests: str(tag). 81 | """ 82 | return self.name 83 | -------------------------------------------------------------------------------- /docs/server-mixin.md: -------------------------------------------------------------------------------- 1 | ## About 2 | 3 | ```python 4 | class ServerManager(): 5 | """ 6 | Functions for managing IP-addresses. Intended to be used as a mixin for CloudManager. 7 | """ 8 | ``` 9 | 10 | `ServerManager` is a mixed into `CloudManager` and the following methods are available by 11 | 12 | ```python 13 | manager = CloudManager("api-username", "password") 14 | manager.method() 15 | ``` 16 | 17 | ## Methods 18 | 19 | 20 | ```python 21 | def get_servers(self, populate=False): 22 | """ 23 | Returns a list of (populated or unpopulated) Server instances. 24 | Populate = False (default) => 1 API request, returns unpopulated Server instances. 25 | Populate = True => Does 1 + n API requests (n = # of servers), returns populated Server instances. 26 | """ 27 | ``` 28 | 29 | ```python 30 | def get_server(self, UUID): 31 | """ 32 | Returns a (populated) Server instance. 33 | """ 34 | ``` 35 | 36 | ```python 37 | def create_server(self, server): 38 | """ 39 | Creates a server and its storages based on a (locally created) Server object. 40 | Populates the given Server instance with the API response. 41 | 42 | Example: 43 | server1 = Server( core_number = 1, 44 | memory_amount = 1024, 45 | hostname = "my.example.1", 46 | zone = "uk-lon1", 47 | storage_devices = [ 48 | Storage(os="01000000-0000-4000-8000-000030060200", size=10, tier=maxiops, title='The OS drive'), 49 | Storage(size=10), 50 | Storage() 51 | title = "My Example Server" 52 | ]) 53 | manager.create_server(server1) 54 | 55 | One storage should contain an OS. Otherwise storage fields are optional. 56 | - size defaults to 10, 57 | - title defaults to hostname + " OS disk" and hostname + " storage disk id" (id is a running starting from 1) 58 | - tier defaults to maxiops 59 | - valid operating systems with names and ids can be retrieved by calling manager.get_templates(): 60 | More detailed documentation of this method can be found in storage_mixin documentation. 61 | 62 | """ 63 | ``` 64 | 65 | ```python 66 | def modify_server(self, UUID, **kwargs): 67 | """ 68 | modify_server allows updating the server's updateable_fields. 69 | Note: Server's IP-addresses and Storages are managed by their own add/remove methods. 70 | """ 71 | ``` 72 | 73 | ```python 74 | def delete_server(self, UUID): 75 | """ 76 | DELETE '/server/UUID'. Permanently destroys the virtual machine. 77 | DOES NOT remove the storage disks. 78 | 79 | Returns an empty object. 80 | """ 81 | ``` 82 | 83 | ```python 84 | def get_server_by_ip(self, ip_address): 85 | """ 86 | Return a (populated) Server instance by its IP. 87 | Uses GET '/ip_address/x.x.x.x' to retrieve machine UUID using IP-address. 88 | """ 89 | ``` 90 | 91 | ```python 92 | def get_server_data(self, UUID): 93 | """ 94 | Returns '/server/uuid' data in Python dict. 95 | Creates object representations of any IP-address and Storage. 96 | """ 97 | ``` 98 | -------------------------------------------------------------------------------- /upcloud_api/ip_address.py: -------------------------------------------------------------------------------- 1 | from upcloud_api.upcloud_resource import UpCloudResource 2 | 3 | 4 | class IPAddress(UpCloudResource): 5 | """ 6 | Class representation of the API's IP address. Extends UpCloudResource. 7 | 8 | Attributes 9 | ---------- 10 | access -- "public" or "private" 11 | address -- the actual IPAddress (string) 12 | family -- IPv4 or IPv6 13 | part_of_plan -- yes/no string indicating whether this belongs to a preconfigured plan or not 14 | ptr_record -- the reverse DNS name (string) 15 | server -- the UUID of the server this IP is attached to (string) 16 | 17 | The only updatable field is the ptr_record. 18 | 19 | Note that all of the fields are not always available depending on the API call, 20 | consult the official API docs for details. 21 | """ 22 | 23 | ATTRIBUTES = { 24 | 'access': None, 25 | 'address': None, 26 | 'family': 'IPv4', 27 | 'part_of_plan': None, 28 | 'ptr_record': None, 29 | 'server': None, 30 | } 31 | 32 | def save(self) -> None: 33 | """ 34 | IPAddress can only change its PTR record. Saves the current state, PUT /ip_address/uuid. 35 | """ 36 | body = {'ip_address': {'ptr_record': self.ptr_record}} 37 | data = self.cloud_manager.api.put_request('/ip_address/' + self.address, body) 38 | self._reset(**data['ip_address']) 39 | 40 | def destroy(self): 41 | """ 42 | Release the IPAddress. DELETE /ip_address/uuid. 43 | """ 44 | self.cloud_manager.release_ip(self.address) 45 | 46 | def __str__(self): 47 | """ 48 | String representation of IPAddress. 49 | Can be used to add tags into API requests: str(ip_addr). 50 | """ 51 | return self.address 52 | 53 | @staticmethod 54 | def _create_ip_address_objs(ip_addresses, cloud_manager, ignore_ips_without_server=False): 55 | """ 56 | Create IPAddress objects from API response data. 57 | Also associates CloudManager with the objects. 58 | """ 59 | # ip-addresses might be provided as a flat array or as a following dict: 60 | # {'ip_addresses': {'ip_address': [...]}} || {'ip_address': [...]} 61 | 62 | if 'ip_addresses' in ip_addresses: 63 | ip_addresses = ip_addresses['ip_addresses'] 64 | 65 | if 'ip_address' in ip_addresses: 66 | ip_addresses = ip_addresses['ip_address'] 67 | 68 | filtered_ip_addresses = [] if ignore_ips_without_server else ip_addresses 69 | 70 | if ignore_ips_without_server: 71 | for ip_addr in ip_addresses: 72 | if ip_addr.get('server'): 73 | filtered_ip_addresses.append(ip_addr) 74 | 75 | return [ 76 | IPAddress(cloud_manager=cloud_manager, **ip_addr) for ip_addr in filtered_ip_addresses 77 | ] 78 | -------------------------------------------------------------------------------- /upcloud_api/cloud_manager/firewall_mixin.py: -------------------------------------------------------------------------------- 1 | from upcloud_api.api import API 2 | from upcloud_api.firewall import FirewallRule 3 | from upcloud_api.server import Server 4 | 5 | 6 | def uuid_and_instance(server): 7 | """server => uuid, instance""" 8 | if isinstance(server, Server): 9 | return server.uuid, server 10 | return server, None 11 | 12 | 13 | class FirewallManager: 14 | """ 15 | Provides get / list / create / delete functionality for firewall rules. 16 | 17 | These functions are used by the FirewallRule class but may also be used 18 | directly. 19 | """ 20 | 21 | api: API 22 | 23 | # TODO: server_instance is unused? 24 | def get_firewall_rule(self, server_uuid, firewall_rule_position, server_instance=None): 25 | """ 26 | Return a FirewallRule object based on server uuid and rule position. 27 | """ 28 | url = f'/server/{server_uuid}/firewall_rule/{firewall_rule_position}' 29 | res = self.api.get_request(url) 30 | return FirewallRule(**res['firewall_rule']) 31 | 32 | def get_firewall_rules(self, server): 33 | """ 34 | Return all FirewallRule objects based on a server instance or uuid. 35 | """ 36 | server_uuid, server_instance = uuid_and_instance(server) 37 | 38 | url = f'/server/{server_uuid}/firewall_rule' 39 | res = self.api.get_request(url) 40 | 41 | return [ 42 | FirewallRule(server=server_instance, **firewall_rule) 43 | for firewall_rule in res['firewall_rules']['firewall_rule'] 44 | ] 45 | 46 | def create_firewall_rule(self, server, firewall_rule_body): 47 | """ 48 | Create a new firewall rule for a given server uuid. 49 | 50 | The rule can be given as a dict or with FirewallRule.prepare_post_body(). 51 | Returns a FirewallRule object. 52 | """ 53 | server_uuid, server_instance = uuid_and_instance(server) 54 | 55 | url = f'/server/{server_uuid}/firewall_rule' 56 | body = {'firewall_rule': firewall_rule_body} 57 | res = self.api.post_request(url, body) 58 | 59 | return FirewallRule(server=server_instance, **res['firewall_rule']) 60 | 61 | def delete_firewall_rule(self, server_uuid, firewall_rule_position): 62 | """ 63 | Delete a firewall rule based on a server uuid and rule position. 64 | """ 65 | url = f'/server/{server_uuid}/firewall_rule/{firewall_rule_position}' 66 | return self.api.delete_request(url) 67 | 68 | def configure_firewall(self, server, firewall_rule_bodies): 69 | """ 70 | Helper for calling create_firewall_rule in series for a list of firewall_rule_bodies. 71 | """ 72 | server_uuid, server_instance = uuid_and_instance(server) 73 | 74 | return [self.create_firewall_rule(server_uuid, rule) for rule in firewall_rule_bodies] 75 | -------------------------------------------------------------------------------- /upcloud_api/cloud_manager/tag_mixin.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from upcloud_api.api import API 4 | from upcloud_api.tag import Tag 5 | 6 | 7 | class TagManager: 8 | """ 9 | Functions for managing Tags. 10 | 11 | Intended to be used as a mixin for CloudManager. 12 | """ 13 | 14 | api: API 15 | 16 | def get_tags(self): 17 | """List all tags as Tag objects.""" 18 | res = self.api.get_request('/tag') 19 | return [Tag(cloud_manager=self, **tag) for tag in res['tags']['tag']] 20 | 21 | def get_tag(self, name: str) -> Tag: 22 | """Return the tag as Tag object.""" 23 | res = self.api.get_request('/tag/' + name) 24 | return Tag(cloud_manager=self, **res['tag']) 25 | 26 | def create_tag( 27 | self, name: str, description: Optional[str] = None, servers: Optional[list] = None 28 | ) -> Tag: 29 | """ 30 | Create a new Tag. Only name is mandatory. 31 | 32 | Returns the created Tag object. 33 | """ 34 | if servers is None: 35 | servers = [] 36 | servers = [str(server) for server in servers] 37 | body = {'tag': Tag(name, description, servers).to_dict()} 38 | res = self.api.post_request('/tag', body) 39 | 40 | return Tag(cloud_manager=self, **res['tag']) 41 | 42 | def _modify_tag(self, name, description, servers, new_name): 43 | """ 44 | PUT /tag/name. Returns a dict that can be used to create a Tag object. 45 | 46 | Private method used by the Tag class and TagManager.modify_tag. 47 | """ 48 | body = {'tag': Tag(new_name, description, servers).to_dict()} 49 | res = self.api.put_request('/tag/' + name, body) 50 | return res['tag'] 51 | 52 | def modify_tag(self, name, description=None, servers=None, new_name=None): 53 | """ 54 | PUT /tag/name. Returns a new Tag object based on the API response. 55 | """ 56 | res = self._modify_tag(name, description, servers, new_name) 57 | return Tag(cloud_manager=self, **res['tag']) 58 | 59 | def assign_tags(self, server, tags): 60 | """ 61 | Assign tags to a server. 62 | 63 | - server: Server object or UUID string 64 | - tags: list of Tag objects or strings 65 | """ 66 | uuid = str(server) 67 | tags = [str(tag) for tag in tags] 68 | 69 | url = f"/server/{uuid}/tag/{','.join(tags)}" 70 | return self.api.post_request(url) 71 | 72 | def remove_tags(self, server, tags): 73 | """ 74 | Remove tags from a server. 75 | 76 | - server: Server object or UUID string 77 | - tags: list of Tag objects or strings 78 | """ 79 | uuid = str(server) 80 | tags = [str(tag) for tag in tags] 81 | 82 | url = f"/server/{uuid}/untag/{','.join(tags)}" 83 | return self.api.post_request(url) 84 | 85 | def delete_tag(self, tag): 86 | """Delete the Tag. Returns and empty object.""" 87 | return self.api.delete_request('/tag/' + str(tag)) 88 | -------------------------------------------------------------------------------- /upcloud_api/cloud_manager/__init__.py: -------------------------------------------------------------------------------- 1 | from upcloud_api.api import API 2 | from upcloud_api.cloud_manager.firewall_mixin import FirewallManager 3 | from upcloud_api.cloud_manager.host_mixin import HostManager 4 | from upcloud_api.cloud_manager.ip_address_mixin import IPManager 5 | from upcloud_api.cloud_manager.lb_mixin import LoadBalancerManager 6 | from upcloud_api.cloud_manager.network_mixin import NetworkManager 7 | from upcloud_api.cloud_manager.server_mixin import ServerManager 8 | from upcloud_api.cloud_manager.storage_mixin import StorageManager 9 | from upcloud_api.cloud_manager.tag_mixin import TagManager 10 | from upcloud_api.credentials import Credentials 11 | from upcloud_api.errors import UpCloudClientError 12 | 13 | 14 | class CloudManager( 15 | FirewallManager, 16 | HostManager, 17 | IPManager, 18 | LoadBalancerManager, 19 | NetworkManager, 20 | ServerManager, 21 | StorageManager, 22 | TagManager, 23 | ): 24 | """ 25 | CloudManager contains the core functionality of the upcloud API library. 26 | 27 | All other managers are mixed in so code can be organized in corresponding sub-manager classes. 28 | """ 29 | 30 | api: API 31 | 32 | def __init__( 33 | self, username: str = None, password: str = None, timeout: int = 60, token: str = None 34 | ) -> None: 35 | """ 36 | Initiates CloudManager that handles all HTTP connections with UpCloud's API. 37 | 38 | Optionally determine a timeout for API connections (in seconds). A timeout with the value 39 | `None` means that there is no timeout. 40 | """ 41 | credentials = Credentials(username, password, token) 42 | if not credentials.is_defined: 43 | raise UpCloudClientError( 44 | "Credentials are not defined. Please provide username and password or an API token." 45 | ) 46 | 47 | self.api = API( 48 | token=credentials.authorization, 49 | timeout=timeout, 50 | ) 51 | 52 | def authenticate(self): 53 | """ 54 | Authenticate. 55 | """ 56 | return self.get_account() 57 | 58 | def get_account(self): 59 | """ 60 | Returns information on the user's account and resource limits. 61 | """ 62 | return self.api.get_request('/account') 63 | 64 | def get_zones(self): 65 | """ 66 | Returns a list of available zones. 67 | """ 68 | return self.api.get_request('/zone') 69 | 70 | def get_timezones(self): 71 | """ 72 | Returns a list of available timezones. 73 | """ 74 | return self.api.get_request('/timezone') 75 | 76 | def get_prices(self): 77 | """ 78 | Returns a list of resource prices. 79 | """ 80 | return self.api.get_request('/price') 81 | 82 | def get_server_sizes(self): 83 | """ 84 | Returns a list of available server configurations. 85 | """ 86 | return self.api.get_request('/server_size') 87 | 88 | def get_server_plans(self): 89 | """ 90 | Returns a list of available server plans 91 | :return: 92 | """ 93 | return self.api.get_request('/plan') 94 | -------------------------------------------------------------------------------- /test/test_integration/test_integration_test.py: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | import os 3 | 4 | import pytest 5 | from infra import CLUSTER, FIREWALL_RULES, TAGS 6 | from infra_helpers import create_cluster, firewall_test, server_test, tag_servers_test 7 | 8 | from upcloud_api import CloudManager 9 | 10 | USERNAME = os.environ.get('UPCLOUD_API_USER') 11 | PASSWORD = os.environ.get('UPCLOUD_API_PASSWD') 12 | 13 | integration_test = pytest.mark.integration_tests 14 | 15 | # globals to store created resources so we can cleanup after tests 16 | CREATED_SERVERS = [] 17 | CREATED_TAGS = [] 18 | 19 | 20 | def destroy_server(server): 21 | """Destroy a server and it's storages.""" 22 | server.stop_and_destroy() 23 | 24 | 25 | def delete_tag(tag): 26 | """Destroy a tag (only works if the tag is not in use).""" 27 | tag.destroy() 28 | 29 | 30 | @integration_test 31 | def teardown_module(module): 32 | manager = CloudManager(USERNAME, PASSWORD, timeout=160) 33 | 34 | # if we are at CIRCLECI, clean up everything 35 | if os.environ.get('CIRCLECI', False): 36 | pool = multiprocessing.Pool() 37 | pool.map(destroy_server, manager.get_servers()) 38 | pool.map(delete_tag, manager.get_tags()) 39 | else: 40 | print(f'removing {CREATED_SERVERS}') 41 | for server in CREATED_SERVERS: 42 | server.stop_and_destroy() 43 | 44 | print(f'removing {CREATED_TAGS}') 45 | for tag in CREATED_TAGS: 46 | manager.delete_tag(tag) 47 | 48 | 49 | @integration_test 50 | def test_infra_ops(): 51 | global CREATED_SERVERS 52 | global CREATED_TAGS 53 | 54 | CREATED_TAGS = TAGS 55 | 56 | manager = CloudManager(USERNAME, PASSWORD, timeout=120) 57 | 58 | try: 59 | auth = manager.authenticate() 60 | assert True 61 | except Exception: 62 | assert False 63 | 64 | all_servers = create_cluster(manager, CLUSTER) 65 | 66 | # collect & populate servers from CLUSTER 67 | cluster_servers = [] 68 | for name in CLUSTER: 69 | server = CLUSTER[name] 70 | server.populate() 71 | cluster_servers.append(server) 72 | 73 | CREATED_SERVERS = cluster_servers 74 | 75 | # assert all_servers contain cluster_servers 76 | for cs in cluster_servers: 77 | assert cs.state == 'started' 78 | 79 | found = False 80 | for server in all_servers: 81 | if server.uuid == cs.uuid: 82 | found = True 83 | 84 | if not found: 85 | raise Exception(f'server {cs.uuid} not found in all_servers') 86 | 87 | # assert servers' states 88 | # TODO(elnygren): add more assertions here 89 | 90 | # web2 non default IP configuration 91 | web2 = CLUSTER['web2'] 92 | assert len(web2.ip_addresses) == 1 93 | assert web2.ip_addresses[0].family == 'IPv6' 94 | 95 | test_server = CLUSTER['web1'] 96 | 97 | test_server.populate() 98 | test_server._wait_for_state_change(['started']) 99 | test_server.stop() 100 | test_server._wait_for_state_change(['stopped']) 101 | 102 | # contain appropriate asserts 103 | firewall_test(manager, FIREWALL_RULES, test_server) 104 | server_test(manager, test_server) 105 | tag_servers_test(manager, TAGS, CLUSTER) 106 | -------------------------------------------------------------------------------- /docs/Firewall.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # About 4 | 5 | Firewall is configured with FirewallRule objects that are specific to each server. 6 | Please note that a servers firewall rules are ignored if firewall is turned off 7 | (see [Server](/server) and [API documentation](https://developers.upcloud.com/1.3/8-servers/#modify-server)). 8 | 9 | If a server is removed, its firewall and thus its firewall rules are removed too. 10 | 11 | Please refer to the [API documentation](https://developers.upcloud.com/1.3/11-firewall/#create-firewall-rule) 12 | for more info on the attributes of FirewallRule. 13 | 14 | ## List / Get 15 | 16 | ```python 17 | server = manager.get_servers()[0] 18 | 19 | # all firewall rules 20 | firewall_rules = server.get_firewall_rules() 21 | ``` 22 | 23 | ## Create 24 | 25 | ```python 26 | server = manager.get_servers()[0] 27 | 28 | rule = server.add_firewall_rule( 29 | FirewallRule( 30 | position = "1", 31 | direction = "in", 32 | family = "IPv4", 33 | protocol = "tcp", 34 | source_address_start = "192.168.1.1", 35 | source_address_end = "192.168.1.255", 36 | destination_port_start = "22", 37 | destination_port_end = "22", 38 | action = "accept" 39 | ) 40 | ) 41 | ``` 42 | 43 | ### Configure Firewall 44 | 45 | Server provides a helper function to add several firewall rules in series. 46 | Please note that the function does not know about pre-existing rules 47 | (UpCloud servers are created without any firewall rules by default). 48 | 49 | ```python 50 | server = manager.get_servers()[0] 51 | 52 | rules = server.configure_firewall( 53 | [ 54 | FirewallRule( 55 | position = "1", 56 | direction = "in", 57 | family = "IPv4", 58 | protocol = "tcp", 59 | source_address_start = "192.168.1.1", 60 | source_address_end = "192.168.1.255", 61 | destination_port_start = "22", 62 | destination_port_end = "22", 63 | action = "accept" 64 | ), 65 | FirewallRule( 66 | position = "2", 67 | direction = "in", 68 | family = "IPv4", 69 | protocol = "tcp", 70 | source_address_start = "192.168.1.1", 71 | source_address_end = "192.168.1.255", 72 | destination_port_start = "21", 73 | destination_port_end = "21", 74 | action = "accept" 75 | ) 76 | ] 77 | ) 78 | ``` 79 | 80 | ## Destroy 81 | 82 | ```python 83 | server = manager.get_servers()[0] 84 | server.get_firewall_rules()[0].destroy() 85 | ``` 86 | 87 | ### Destroying all firewall rules 88 | 89 | Due to how the API handles positions, the following will NOT work: 90 | 91 | ```python 92 | # does NOT work 93 | for rule in server.get_firewall_rules(): 94 | rule.destroy() 95 | ``` 96 | 97 | This is because rules are based on position and the positions are always so 98 | that they start from 1 and are increment by one for each consecutive rule. 99 | 100 | A better approach would be to use CloudManager/FirewallManager directly 101 | (CloudManager and its mixins provide API functionality to Server, Storage, FirewallRule, etc. objects) 102 | 103 | ```python 104 | for rule in server.get_firewall_rules(): 105 | manager.delete_firewall_rule(server.uuid, 1) 106 | ``` 107 | 108 | 109 | 110 | 111 | -------------------------------------------------------------------------------- /test/json_data/firewall_rules.json: -------------------------------------------------------------------------------- 1 | { 2 | "firewall_rules": { 3 | "firewall_rule" : [ 4 | { 5 | "action" : "accept", 6 | "destination_address_end" : "", 7 | "destination_address_start" : "", 8 | "destination_port_end" : "80", 9 | "destination_port_start" : "80", 10 | "direction" : "in", 11 | "family" : "IPv4", 12 | "icmp_type" : "", 13 | "position" : "1", 14 | "protocol" : "", 15 | "source_address_end" : "", 16 | "source_address_start" : "", 17 | "source_port_end" : "", 18 | "source_port_start" : "" 19 | }, 20 | { 21 | "action" : "accept", 22 | "destination_address_end" : "", 23 | "destination_address_start" : "", 24 | "destination_port_end" : "22", 25 | "destination_port_start" : "22", 26 | "direction" : "in", 27 | "family" : "IPv4", 28 | "icmp_type" : "", 29 | "position" : "2", 30 | "protocol" : "tcp", 31 | "source_address_end" : "192.168.1.255", 32 | "source_address_start" : "192.168.1.1", 33 | "source_port_end" : "", 34 | "source_port_start" : "" 35 | }, 36 | { 37 | "action" : "accept", 38 | "destination_address_end" : "", 39 | "destination_address_start" : "", 40 | "destination_port_end" : "22", 41 | "destination_port_start" : "22", 42 | "direction" : "in", 43 | "family" : "IPv6", 44 | "icmp_type" : "", 45 | "position" : "3", 46 | "protocol" : "tcp", 47 | "source_address_end" : "2a04:3540:1000:aaaa:bbbb:cccc:d001", 48 | "source_address_start" : "2a04:3540:1000:aaaa:bbbb:cccc:d001", 49 | "source_port_end" : "", 50 | "source_port_start" : "" 51 | }, 52 | { 53 | "action" : "accept", 54 | "destination_address_end" : "", 55 | "destination_address_start" : "", 56 | "destination_port_end" : "", 57 | "destination_port_start" : "", 58 | "direction" : "in", 59 | "family" : "IPv4", 60 | "icmp_type" : "8", 61 | "position" : "4", 62 | "protocol" : "icmp", 63 | "source_address_end" : "", 64 | "source_address_start" : "", 65 | "source_port_end" : "", 66 | "source_port_start" : "" 67 | }, 68 | { 69 | "action" : "drop", 70 | "destination_address_end" : "", 71 | "destination_address_start" : "", 72 | "destination_port_end" : "", 73 | "destination_port_start" : "", 74 | "direction" : "in", 75 | "family" : "", 76 | "icmp_type" : "", 77 | "position" : "5", 78 | "protocol" : "", 79 | "source_address_end" : "", 80 | "source_address_start" : "", 81 | "source_port_end" : "", 82 | "source_port_start" : "" 83 | } 84 | ] 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /upcloud_api/cloud_manager/lb_mixin.py: -------------------------------------------------------------------------------- 1 | from upcloud_api.api import API 2 | from upcloud_api.load_balancer import LoadBalancer, LoadBalancerBackend 3 | 4 | 5 | class LoadBalancerManager: 6 | """ 7 | Functions for managing UpCloud loadbalancer instances and their properties with basic dictionary objects 8 | """ 9 | 10 | api: API 11 | 12 | def get_loadbalancers(self): 13 | """ 14 | Returns a list of loadbalancer dictionary objects 15 | """ 16 | 17 | url = '/load-balancer' 18 | return self.api.get_request(url) 19 | 20 | def get_loadbalancer(self, lb_uuid: str): 21 | """ 22 | Returns details for a single loadbalancer as a dictionary 23 | 24 | :param lb_uuid: 25 | :return: LB details 26 | """ 27 | url = f'/load-balancer/{lb_uuid}' 28 | return self.api.get_request(url) 29 | 30 | def create_loadbalancer(self, body: LoadBalancer): 31 | """ 32 | Creates a loadbalancer service specified in body and returns its details 33 | 34 | :param body: 35 | :return: LB details 36 | """ 37 | 38 | url = '/load-balancer' 39 | return self.api.post_request(url, body.to_dict()) 40 | 41 | def delete_loadbalancer(self, lb_uuid: str): 42 | """ 43 | Deletes a loadbalancer service 44 | 45 | :param lb_uuid: 46 | """ 47 | 48 | url = f'/load-balancer/{lb_uuid}' 49 | return self.api.delete_request(url) 50 | 51 | def get_loadbalancer_backends(self, lb_uuid: str): 52 | """ 53 | Returns a list of backends for a loadbalancer service 54 | 55 | :param lb_uuid: 56 | :return: List of LB backends 57 | """ 58 | 59 | url = f'/load-balancer/{lb_uuid}/backends' 60 | return self.api.get_request(url) 61 | 62 | def get_loadbalancer_backend(self, lb_uuid: str, backend: LoadBalancerBackend): 63 | """ 64 | Returns details for a single loadbalancer backend 65 | 66 | :param lb_uuid: 67 | :param backend: 68 | :return: LB backend details 69 | """ 70 | 71 | url = f'/load-balancer/{lb_uuid}/backends/{backend.name}' 72 | return self.api.get_request(url) 73 | 74 | def create_loadbalancer_backend(self, lb_uuid: str, body: LoadBalancerBackend): 75 | """ 76 | Creates a new backend for a loadbalancer and returns its details 77 | 78 | :param lb_uuid: 79 | :param body: 80 | :return: LB backend details 81 | """ 82 | 83 | url = f'/load-balancer/{lb_uuid}/backends' 84 | return self.api.post_request(url, body.to_dict()) 85 | 86 | def modify_loadbalancer_backend(self, lb_uuid: str, backend: str, body: LoadBalancerBackend): 87 | """ 88 | Modifies an existing loadbalancer backend and returns its details 89 | 90 | :param lb_uuid: 91 | :param backend: 92 | :param body: 93 | :return: LB backend details 94 | """ 95 | 96 | url = f'/load-balancer/{lb_uuid}/backends/{backend}' 97 | return self.api.patch_request(url, body.to_dict()) 98 | 99 | def delete_loadbalancer_backend(self, lb_uuid: str, backend: str): 100 | """ 101 | Deletes a loadbalancer backend 102 | 103 | :param lb_uuid: 104 | :param backend: 105 | :return: 106 | """ 107 | 108 | url = f'/load-balancer/{lb_uuid}/backends/{backend}' 109 | return self.api.delete_request(url) 110 | -------------------------------------------------------------------------------- /upcloud_api/api.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import requests 4 | 5 | from upcloud_api import __version__ 6 | from upcloud_api.errors import UpCloudAPIError 7 | 8 | 9 | class API: 10 | """ 11 | Handles basic HTTP communication with API. 12 | """ 13 | 14 | api_root = 'https://api.upcloud.com/1.3' 15 | user_agent = f'upcloud-python-api/{__version__}' 16 | 17 | def __init__(self, token, timeout=None): 18 | """ 19 | Initialize the API with a given Authorization token and default timeout. 20 | """ 21 | self.token = token 22 | self.timeout = timeout 23 | 24 | def api_request(self, method, endpoint, body=None, params=None, timeout=-1): 25 | """ 26 | Perform a request with a given JSON body to a given endpoint in UpCloud's API. 27 | 28 | Handles errors with __error_middleware. 29 | """ 30 | if method not in {'GET', 'POST', 'PUT', 'PATCH', 'DELETE'}: 31 | raise Exception('Invalid/Forbidden HTTP method') 32 | 33 | url = f'{self.api_root}{endpoint}' 34 | headers = {'Authorization': self.token, 'User-Agent': self.user_agent} 35 | 36 | if body: 37 | data = json.dumps(body) 38 | headers['Content-Type'] = 'application/json' 39 | else: 40 | data = None 41 | 42 | call_timeout = timeout if timeout != -1 else self.timeout 43 | 44 | res = requests.request( 45 | method=method, url=url, data=data, params=params, headers=headers, timeout=call_timeout 46 | ) 47 | 48 | if res.text: 49 | res_json = res.json() 50 | else: 51 | res_json = {} 52 | 53 | return self.__error_middleware(res, res_json) 54 | 55 | def get_request(self, endpoint, params=None, timeout=-1): 56 | """ 57 | Perform a GET request to a given endpoint in UpCloud's API. 58 | """ 59 | return self.api_request('GET', endpoint, params=params, timeout=timeout) 60 | 61 | def post_request(self, endpoint, body=None, timeout=-1): 62 | """ 63 | Perform a POST request to a given endpoint in UpCloud's API. 64 | """ 65 | return self.api_request('POST', endpoint, body=body, timeout=timeout) 66 | 67 | def put_request(self, endpoint, body=None, timeout=-1): 68 | """ 69 | Perform a PUT request to a given endpoint in UpCloud's API. 70 | """ 71 | return self.api_request('PUT', endpoint, body=body, timeout=timeout) 72 | 73 | def patch_request(self, endpoint, body=None, timeout=-1): 74 | """ 75 | Perform a PATCH request to a given endpoint in UpCloud's API. 76 | """ 77 | return self.api_request('PATCH', endpoint, body=body, timeout=timeout) 78 | 79 | def delete_request(self, endpoint, timeout=-1): 80 | """ 81 | Perform a DELETE request to a given endpoint in UpCloud's API. 82 | """ 83 | return self.api_request('DELETE', endpoint, timeout=timeout) 84 | 85 | def __error_middleware(self, res, res_json): 86 | """ 87 | Middleware that raises an exception when HTTP statuscode is an error code. 88 | """ 89 | if res.status_code >= 400: 90 | if res_json.get('type'): 91 | raise UpCloudAPIError( 92 | error_code=res_json.get('title'), 93 | error_message=f'Details: {json.dumps(res_json)}', 94 | ) 95 | 96 | err_dict = res_json.get('error', {}) 97 | raise UpCloudAPIError( 98 | error_code=err_dict.get('error_code'), error_message=err_dict.get('error_message') 99 | ) 100 | 101 | return res_json 102 | -------------------------------------------------------------------------------- /docs/storage-mixin.md: -------------------------------------------------------------------------------- 1 | ## About 2 | ```python 3 | class StorageManager(): 4 | """ 5 | Functions for managing Storage disks. Intended to be used as a mixin for CloudManager. 6 | """ 7 | ``` 8 | `StorageManager` is a mixed into `CloudManager` and the following methods are available by 9 | 10 | ```python 11 | manager = CloudManager("api-username", "password") 12 | manager.method() 13 | ``` 14 | 15 | `BackupDeletionPolicy` describes wanted action on backups when deleting a storage or a server with its storages. 16 | Available policies are `KEEP`, `KEEP_LATEST` and `DELETE`. 17 | 18 | ## Methods 19 | 20 | ```python 21 | def get_storages(self, storage_type="normal"): 22 | """ 23 | Returns a list of Storage objects from the API. 24 | Storage types: public, private, normal, backup, cdrom, template, favorite 25 | """ 26 | ``` 27 | 28 | ```python 29 | def get_storage(self, UUID): 30 | """ 31 | Returns a Storage object from the API. 32 | """ 33 | ``` 34 | 35 | ```python 36 | def get_templates(self): 37 | """ 38 | Return a list of Storages that are templates in a dict with title as key and uuid as value. 39 | """ 40 | ``` 41 | 42 | ```python 43 | def create_storage(self, size=10, tier="maxiops", title="Storage disk", zone="fi-hel1"): 44 | """ 45 | Create a Storage object. Returns an object based on the API's response. 46 | """ 47 | ``` 48 | 49 | ```python 50 | def modify_storage(self, UUID, size, title): 51 | """ 52 | Modify a Storage object. Returns an object based on the API's response. 53 | """ 54 | ``` 55 | 56 | ```python 57 | def delete_storage(self, UUID): 58 | """ 59 | Destroy a Storage object. 60 | """ 61 | ``` 62 | 63 | ```python 64 | def attach_storage(self, server_uuid, storage_uuid, storage_type, address): 65 | """ 66 | Attach a Storage object to a Server. Return a list of the server's storages. 67 | """ 68 | ``` 69 | 70 | ```python 71 | def detach_storage(self, server_uuid, address): 72 | """ 73 | Detach a Storage object to a Server. Return a list of the server's storages. 74 | """ 75 | ``` 76 | 77 | ```python 78 | def load_cd_rom(self, server, address): 79 | """ 80 | Loads a storage as a CD-ROM in the CD-ROM device of a server. Returns a list of the server's storages. 81 | """ 82 | ``` 83 | 84 | ```python 85 | def eject_cd_rom(self, server): 86 | """ 87 | Ejects the storage from the CD-ROM device of a server. Returns a list of the server's storages. 88 | """ 89 | ``` 90 | 91 | ```python 92 | def create_storage_backup(self, storage, title): 93 | """ 94 | Creates a point-in-time backup of a storage resource. Returns a storage object. 95 | """ 96 | ``` 97 | 98 | ```python 99 | def restore_storage_backup(self, storage): 100 | """ 101 | Restores the origin storage with data from the specified backup storage. Returns a storage object. 102 | """ 103 | ``` 104 | 105 | ```python 106 | def templatize_storage(self, storage, title): 107 | """ 108 | Creates an exact copy of an existing storage resource which can be used as a template for creating new servers. Returns a storage object. 109 | """ 110 | ``` 111 | 112 | ```python 113 | def create_storage_import(self, storage, source, source_location=None): 114 | """ 115 | Creates an import task to import data into an existing storage and returns a storage import object. 116 | Source types: http_import or direct_upload. 117 | """ 118 | ``` 119 | 120 | ```python 121 | def upload_file_for_storage_import(self, storage_import, file): 122 | """ 123 | Uploads a file directly to UpCloud's uploader session. Returns written bytes, md5sum and sha256sum. 124 | """ 125 | ``` 126 | 127 | ```python 128 | def get_storage_import_details(self, storage): 129 | """ 130 | Returns detailed information of an ongoing or finished import task within a storage import object. 131 | """ 132 | ``` 133 | 134 | ```python 135 | def cancel_storage_import(self, storage): 136 | """ 137 | Cancels an ongoing import task. Returns a storage import object. 138 | """ 139 | ``` 140 | -------------------------------------------------------------------------------- /upcloud_api/credentials.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import os 3 | 4 | try: 5 | import keyring 6 | except ImportError: 7 | keyring = None 8 | 9 | from upcloud_api.errors import UpCloudClientError 10 | 11 | ENV_KEY_USERNAME = "UPCLOUD_USERNAME" 12 | ENV_KEY_PASSWORD = "UPCLOUD_PASSWORD" # noqa: S105 13 | ENV_KEY_TOKEN = "UPCLOUD_TOKEN" # noqa: S105 14 | 15 | KEYRING_SERVICE_NAME = "UpCloud" 16 | KEYRING_TOKEN_USER = "" 17 | KEYRING_GO_PREFIX = "go-keyring-base64:" 18 | 19 | 20 | def _parse_keyring_value(value: str) -> str: 21 | if value.startswith(KEYRING_GO_PREFIX): 22 | value = value[len(KEYRING_GO_PREFIX) :] 23 | return base64.b64decode(value).decode() 24 | 25 | return value 26 | 27 | 28 | def _read_keyring_value(username: str) -> str: 29 | if keyring is None: 30 | return None 31 | 32 | value = keyring.get_password(KEYRING_SERVICE_NAME, username) 33 | try: 34 | return _parse_keyring_value(value) if value else None 35 | except Exception: 36 | raise UpCloudClientError( 37 | f"Failed to read keyring value for {username}. Ensure that the value saved to the system keyring is correct." 38 | ) from None 39 | 40 | 41 | class Credentials: 42 | """ 43 | Class for handling UpCloud API credentials. 44 | """ 45 | 46 | def __init__(self, username: str = None, password: str = None, token: str = None): 47 | """ 48 | Initializes the Credentials object with username, password and/or token. Use `parse` method to read credentials from environment variables or keyring. 49 | """ 50 | self._username = username 51 | self._password = password 52 | self._token = token 53 | 54 | @property 55 | def authorization(self) -> str: 56 | """ 57 | Returns the authorization header value based on the provided credentials. 58 | """ 59 | if self._token: 60 | return f"Bearer {self._token}" 61 | 62 | credentials = f"{self._username}:{self._password}".encode() 63 | encoded_credentials = base64.b64encode(credentials).decode() 64 | return f"Basic {encoded_credentials}" 65 | 66 | @property 67 | def dict(self) -> dict: 68 | """ 69 | Returns the credentials as a dictionary. 70 | """ 71 | return { 72 | "username": self._username, 73 | "password": self._password, 74 | "token": self._token, 75 | } 76 | 77 | @property 78 | def is_defined(self) -> bool: 79 | """ 80 | Checks if the credentials are defined. 81 | """ 82 | return bool(self._username and self._password or self._token) 83 | 84 | def _read_from_env(self): 85 | if not self._username: 86 | self._username = os.getenv(ENV_KEY_USERNAME) 87 | if not self._password: 88 | self._password = os.getenv(ENV_KEY_PASSWORD) 89 | if not self._token: 90 | self._token = os.getenv(ENV_KEY_TOKEN) 91 | 92 | def _read_from_keyring(self): 93 | if self._username and not self._password: 94 | self._password = _read_keyring_value(self._username) 95 | 96 | if self.is_defined: 97 | return 98 | 99 | self._token = _read_keyring_value(KEYRING_TOKEN_USER) 100 | 101 | @classmethod 102 | def parse(cls, username: str = None, password: str = None, token: str = None): 103 | """ 104 | Parses credentials from the provided parameters, environment variables or the system keyring. 105 | """ 106 | credentials = cls(username, password, token) 107 | if credentials.is_defined: 108 | return credentials 109 | 110 | credentials._read_from_env() 111 | if credentials.is_defined: 112 | return credentials 113 | 114 | credentials._read_from_keyring() 115 | if credentials.is_defined: 116 | return credentials 117 | 118 | raise UpCloudClientError( 119 | f"Credentials not found. These must be set in configuration, via environment variables or in the system keyring ({KEYRING_SERVICE_NAME})" 120 | ) 121 | -------------------------------------------------------------------------------- /docs/Server.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Start / Stop / Restart 4 | 5 | ```python 6 | 7 | server.stop() 8 | server.start() 9 | server.restart() 10 | 11 | # populate the object with updated information from API 12 | server.populate() 13 | 14 | ``` 15 | 16 | Please note that the server might not be stopped/started/restarted immediately when the API responds. The `.populate()` method updates the object's fields from the API and is thus useful for checking `server.state`. 17 | 18 | ``` 19 | Server states: 20 | "started","stopped" -- server is shut down or running 21 | "maintenance" -- when shutting down or (re)starting 22 | "error" -- erronous state in UpCloud's backend 23 | ``` 24 | 25 | 26 | 27 | ## List / Get 28 | 29 | The CloudManager returns Server instances. 30 | 31 | ```python 32 | 33 | servers = manager.get_servers() 34 | server = manager.get_server(servers[0].uuid) 35 | 36 | ``` 37 | 38 | ## Create 39 | 40 | Creation of servers in the API is handled by the CloudManager. It accepts a Server instance, forms the correct POST request and populates the Server instance's fields from the POST response. 41 | 42 | ```python 43 | 44 | server = Server( 45 | core_number = 1, 46 | memory_amount = 1024, 47 | hostname = "web1.example.com", 48 | zone = 'uk-lon1', 49 | storage_devices = [ 50 | Storage(os = "01000000-0000-4000-8000-000030240200", size=10), 51 | Storage(size=10, tier="hdd") 52 | ]) 53 | 54 | manager.create_server( server ) 55 | 56 | ``` 57 | 58 | Currently available operating system templates can be retrieved with 'manager.get_templates()'. More information on this method can be found in storage_mixin documentation. 59 | 60 | Please refer to the [API documentation](https://developers.upcloud.com/1.3/8-servers/#modify-server) for the allowed Server attributes. 61 | 62 | ## Update 63 | 64 | ### Attributes 65 | 66 | Updating a Server's attributes is done with its `.save()` method that does a PUT request. If you want to manage the Server's Storages or IP-addresses, see below. 67 | 68 | ```python 69 | 70 | server = manager.get_server( uuid ) 71 | server.core_number = 4 72 | server.memory_amount = 4096 73 | server.save() 74 | 75 | ``` 76 | 77 | The following fields of Server instance may be updated, all other fields are read-only. Trying to assign values to other fields leads to an error. 78 | 79 | ```python 80 | Updateable attributes: 81 | "boot_order", "core_number", "firewall", "hostname", "memory_amount", 82 | "nic_model", "title", "timezone", "video_model", "vnc", "vnc_password" 83 | ``` 84 | 85 | Please refer to the [API documentation](https://developers.upcloud.com/1.3/8-servers/#modify-server) for the allowed values. 86 | 87 | ### Storages 88 | 89 | A Server's Storages can be attached and detached with `.add_storage()` and `.remove_storage()`. Both requests issue an API request instantly. 90 | 91 | ```python 92 | 93 | # attach 94 | storage = manager.create_storage( size=100, zone='fi-hel1' ) 95 | server.add_storage(storage) 96 | 97 | # detach 98 | storage = server.storage_devices[1] 99 | server.remove_storage(storage) 100 | 101 | ``` 102 | 103 | ### IP-addresses 104 | 105 | A Server's IPs can be attached and detached with `.add_ip()` and `.remove_ip()`. Both requests issue an API request instantly. Note that the attached IP is allocated randomly as UpCloud's does not (yet) support floating IPs. 106 | 107 | ```python 108 | 109 | # attach 110 | IP = server.add_ip() 111 | 112 | # detach 113 | server.remove_ip(IP) 114 | 115 | ``` 116 | 117 | ## Delete 118 | 119 | Destroys the Server instance and its IP addresses. However, it does not destroy the Storages. 120 | 121 | ```python 122 | 123 | server.destroy() 124 | 125 | ``` 126 | 127 | ## Delete with storages 128 | 129 | Storages attached to the server and storage backups can also be deleted when deleting the server through CloudManager. 130 | Backups can be deleted only when attached storages are also deleted. Default policy for backup deletions is 131 | `KEEP`, but `KEEP_LATEST` and `DELETE` are also supported. Options are configured through BackupDeletionPolicy enum 132 | under storage. Default behaviour for backups and storages is always to keep them. 133 | 134 | Following example deletes the storages, but keeps the latest existing backup(s). If no backup exists for the storage(s), 135 | nothing is left behind. 136 | 137 | ```python 138 | 139 | from upcloud_api.storage import BackupDeletionPolicy 140 | 141 | manager.delete_server(uuid, delete_storages=True, backups=BackupDeletionPolicy.KEEP_LATEST) 142 | 143 | ``` 144 | -------------------------------------------------------------------------------- /test/test_tags.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import responses 4 | from conftest import Mock 5 | 6 | from upcloud_api import Tag 7 | 8 | 9 | def tag_post_callback(request): 10 | print(request.body) 11 | request_body = json.loads(request.body) 12 | 13 | if 'name' not in request_body['tag']: 14 | raise Exception('required field missing') 15 | 16 | if 'servers' in request_body['tag']: 17 | assert isinstance(request_body['tag']['servers'], dict) 18 | assert isinstance(request_body['tag']['servers']['server'], list) 19 | if len(request_body['tag']['servers']['server']) > 0: 20 | assert isinstance(request_body['tag']['servers']['server'][0], str) 21 | 22 | if 'description' in request_body['tag']: 23 | assert isinstance(request_body['tag']['description'], str) 24 | 25 | return (201, {}, json.dumps(request_body)) 26 | 27 | 28 | class TestTags: 29 | @responses.activate 30 | def test_get_tag(self, manager): 31 | Mock.mock_get('tag/TheTestTag') 32 | tag = manager.get_tag('TheTestTag') 33 | 34 | assert tag.name == 'TheTestTag' 35 | assert tag.description == 'Description of TheTestTag' 36 | assert len(tag.servers) == 2 37 | assert tag.servers[0].uuid == '0057e20a-6878-43a7-b2b3-530c4a4bdc55' 38 | 39 | @responses.activate 40 | def test_get_tags(self, manager): 41 | Mock.mock_get('tag') 42 | tags = manager.get_tags() 43 | 44 | assert len(tags) == 2 45 | assert tags[0].name == 'TheTestTag1' 46 | assert tags[1].name == 'TheTestTag2' 47 | assert tags[0].servers[0].uuid == '0057e20a-6878-43a7-b2b3-530c4a4bdc55' 48 | 49 | @responses.activate 50 | def test_create_new_tag(self, manager): 51 | for _ in range(1, 4): 52 | responses.add_callback( 53 | responses.POST, 54 | Mock.base_url + '/tag', 55 | content_type='application/json', 56 | callback=tag_post_callback, 57 | ) 58 | 59 | tag1 = manager.create_tag('Tag1') 60 | tag2 = manager.create_tag('Tag2', 'a nice tag') 61 | tag3 = manager.create_tag('Tag3', 'a nicer tag', ['00798b85-efdc-41ca-8021-f6ef457b8531']) 62 | 63 | assert tag1.name == 'Tag1' 64 | assert tag2.name == 'Tag2' 65 | assert tag3.name == 'Tag3' 66 | assert isinstance(tag3.servers, list) 67 | assert tag3.servers[0].uuid == '00798b85-efdc-41ca-8021-f6ef457b8531' 68 | 69 | @responses.activate 70 | def test_edit_tag(self, manager): 71 | Mock.mock_get('tag/TheTestTag') 72 | tag = manager.get_tag('TheTestTag') 73 | 74 | responses.add_callback( 75 | responses.PUT, 76 | Mock.base_url + '/tag/TheTestTag', 77 | content_type='application/json', 78 | callback=tag_post_callback, 79 | ) 80 | 81 | tag.name = 'AnotherTestTag' 82 | assert tag._api_name == 'TheTestTag' 83 | 84 | tag.save() 85 | 86 | assert tag.name == 'AnotherTestTag' 87 | assert tag._api_name == 'AnotherTestTag' 88 | 89 | @responses.activate 90 | def test_assign_tags_to_server(self, manager): 91 | data = Mock.mock_get('server/00798b85-efdc-41ca-8021-f6ef457b8531') 92 | server = manager.get_server('00798b85-efdc-41ca-8021-f6ef457b8531') 93 | 94 | responses.add( 95 | responses.POST, 96 | Mock.base_url + '/server/00798b85-efdc-41ca-8021-f6ef457b8531/tag/tag1,tag2', 97 | body=json.dumps({'foo': 'bar'}), 98 | content_type='application/json', 99 | status=200, 100 | ) 101 | server.add_tags(['tag1', Tag('tag2')]) 102 | 103 | for tag in ['web1', 'tag1', 'tag2']: 104 | assert tag in server.tags 105 | 106 | @responses.activate 107 | def test_remove_tags_from_server(self, manager): 108 | data = Mock.mock_get('server/00798b85-efdc-41ca-8021-f6ef457b8531') 109 | server = manager.get_server('00798b85-efdc-41ca-8021-f6ef457b8531') 110 | 111 | responses.add( 112 | responses.POST, 113 | Mock.base_url + '/server/00798b85-efdc-41ca-8021-f6ef457b8531/untag/tag1,tag2', 114 | body=json.dumps({'foo': 'bar'}), 115 | content_type='application/json', 116 | status=200, 117 | ) 118 | server.remove_tags(['tag1', Tag('tag2')]) 119 | 120 | for tag in ['tag1', 'tag2']: 121 | assert tag not in server.tags 122 | assert 'web1' in server.tags 123 | -------------------------------------------------------------------------------- /test/json_data/storage_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "storages": 3 | { 4 | "storage": 5 | [ 6 | { 7 | "access": "public", 8 | "license": 3.36, 9 | "size": 28, 10 | "state": "online", 11 | "title": "Windows Server 2016 Datacenter", 12 | "type": "template", 13 | "uuid": "01000000-0000-4000-8000-000010060200" 14 | }, 15 | { 16 | "access": "public", 17 | "license": 0.694, 18 | "size": 29, 19 | "state": "online", 20 | "title": "Windows Server 2016 Standard", 21 | "type": "template", 22 | "uuid": "01000000-0000-4000-8000-000010060300" 23 | }, 24 | { 25 | "access": "public", 26 | "license": 3.36, 27 | "size": 25, 28 | "state": "online", 29 | "title": "Windows Server 2019 Datacenter", 30 | "type": "template", 31 | "uuid": "01000000-0000-4000-8000-000010070200" 32 | }, 33 | { 34 | "access": "public", 35 | "license": 0.694, 36 | "size": 25, 37 | "state": "online", 38 | "title": "Windows Server 2019 Standard", 39 | "type": "template", 40 | "uuid": "01000000-0000-4000-8000-000010070300" 41 | }, 42 | { 43 | "access": "public", 44 | "license": 0, 45 | "size": 3, 46 | "state": "online", 47 | "title": "Debian GNU/Linux 9 (Stretch)", 48 | "type": "template", 49 | "uuid": "01000000-0000-4000-8000-000020040100" 50 | }, 51 | { 52 | "access": "public", 53 | "license": 0, 54 | "size": 3, 55 | "state": "online", 56 | "title": "Debian GNU/Linux 10 (Buster)", 57 | "type": "template", 58 | "uuid": "01000000-0000-4000-8000-000020050100" 59 | }, 60 | { 61 | "access": "public", 62 | "license": 0, 63 | "size": 3, 64 | "state": "online", 65 | "title": "Ubuntu Server 16.04 LTS (Xenial Xerus)", 66 | "type": "template", 67 | "uuid": "01000000-0000-4000-8000-000030060200" 68 | }, 69 | { 70 | "access": "public", 71 | "license": 0, 72 | "size": 4, 73 | "state": "online", 74 | "title": "Ubuntu Server 18.04 LTS (Bionic Beaver)", 75 | "type": "template", 76 | "uuid": "01000000-0000-4000-8000-000030080200" 77 | }, 78 | { 79 | "access": "public", 80 | "license": 0, 81 | "size": 4, 82 | "state": "online", 83 | "title": "Ubuntu Server 20.04 LTS (Focal Fossa)", 84 | "type": "template", 85 | "uuid": "01000000-0000-4000-8000-000030200200" 86 | }, 87 | { 88 | "access": "public", 89 | "license": 0, 90 | "size": 3, 91 | "state": "online", 92 | "title": "CentOS 6.10", 93 | "type": "template", 94 | "uuid": "01000000-0000-4000-8000-000050010200" 95 | }, 96 | { 97 | "access": "public", 98 | "license": 0, 99 | "size": 3, 100 | "state": "online", 101 | "title": "CentOS 7", 102 | "type": "template", 103 | "uuid": "01000000-0000-4000-8000-000050010300" 104 | }, 105 | { 106 | "access": "public", 107 | "license": 0, 108 | "size": 3, 109 | "state": "online", 110 | "title": "CentOS 8", 111 | "type": "template", 112 | "uuid": "01000000-0000-4000-8000-000050010400" 113 | }, 114 | { 115 | "access": "public", 116 | "license": 0, 117 | "size": 5, 118 | "state": "online", 119 | "title": "Plesk Obsidian", 120 | "type": "template", 121 | "uuid": "01000000-0000-4000-8000-000130010100" 122 | }, 123 | { 124 | "access": "private", 125 | "license": 0, 126 | "size": 10, 127 | "state": "online", 128 | "tier": "maxiops", 129 | "title": "import test", 130 | "type": "template", 131 | "uuid": "0124fdf2-e3ae-408b-8711-db8a3dddc85b", 132 | "zone": "fi-hel1" 133 | }, 134 | { 135 | "access": "private", 136 | "license": 0, 137 | "size": 30, 138 | "state": "online", 139 | "tier": "maxiops", 140 | "title": "class method test 3", 141 | "type": "template", 142 | "uuid": "0134df64-f6be-408e-b960-6511503471af", 143 | "zone": "fi-hel1" 144 | }, 145 | { 146 | "access": "private", 147 | "license": 0, 148 | "size": 10, 149 | "state": "online", 150 | "tier": "maxiops", 151 | "title": "my server template", 152 | "type": "template", 153 | "uuid": "013721b5-07ca-4d7b-b4ff-e21262223e5b", 154 | "zone": "fi-hel2" 155 | } 156 | ] 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import sys 4 | 5 | import pytest 6 | import responses 7 | 8 | # make files under helpers available for import 9 | HELPERS_PATH = os.path.join(os.path.dirname(__file__), 'helpers') 10 | sys.path.append(HELPERS_PATH) 11 | 12 | 13 | def pytest_configure(config): 14 | config.addinivalue_line( 15 | "markers", "integration_tests: mark test to run only on if --integration-tests is passed" 16 | ) 17 | 18 | 19 | def pytest_addoption(parser): 20 | parser.addoption( 21 | '--integration-tests', action='store_true', default=False, help='run integration tests' 22 | ) 23 | 24 | 25 | def pytest_runtest_setup(item): 26 | if 'integration_tests' in item.keywords and not item.config.getoption("--integration-tests"): 27 | pytest.skip("need --integration-tests option to run this test") 28 | 29 | 30 | @pytest.fixture(scope='module') 31 | def manager(): 32 | import upcloud_api 33 | 34 | return upcloud_api.CloudManager("testuser", "mock-api-password") 35 | 36 | 37 | def read_from_file(filename): 38 | filename = filename.replace("/", "_") 39 | cwd = os.path.dirname(__file__) 40 | f = open(cwd + '/json_data/' + filename) 41 | return f.read() 42 | 43 | 44 | class Mock: 45 | base_url = 'https://api.upcloud.com/1.3' 46 | 47 | @staticmethod 48 | def read_from_file(filename): 49 | return read_from_file(filename) 50 | 51 | @staticmethod 52 | def mock_get(target, response_file=None): 53 | if not response_file: 54 | response_file = target + '.json' 55 | 56 | data = Mock.read_from_file(response_file) 57 | responses.add( 58 | responses.GET, 59 | Mock.base_url + '/' + target, 60 | body=data, 61 | status=200, 62 | content_type='application/json', 63 | ) 64 | return data 65 | 66 | @staticmethod 67 | def __put_patch_post_callback( 68 | request, target, data, ignore_data_field=False, empty_payload=False 69 | ): 70 | data_field = target.split("/")[0] 71 | 72 | if not empty_payload: 73 | payload = json.loads(request.body) 74 | 75 | if not ignore_data_field: 76 | for field in data[data_field]: 77 | if field in payload[data_field]: 78 | data[data_field][field] = payload[data_field][field] 79 | return (200, {}, json.dumps(data)) 80 | 81 | @staticmethod 82 | def mock_post(target, empty_content=False, ignore_data_field=False, empty_payload=False): 83 | def callback(request): 84 | if not empty_content: 85 | data = json.loads(Mock.read_from_file(target + '_post.json')) 86 | return Mock.__put_patch_post_callback( 87 | request, target, data, ignore_data_field, empty_payload 88 | ) 89 | else: 90 | return (200, {}, '{}') 91 | 92 | responses.add_callback( 93 | responses.POST, 94 | Mock.base_url + '/' + target, 95 | callback=callback, 96 | content_type='application/json', 97 | ) 98 | 99 | @staticmethod 100 | def mock_put(target, ignore_data_field=False, empty_payload=False, call_api=True): 101 | data = json.loads(Mock.read_from_file(target + '.json')) 102 | 103 | def callback(request): 104 | return Mock.__put_patch_post_callback( 105 | request, target, data, ignore_data_field, empty_payload 106 | ) 107 | 108 | url = Mock.base_url + '/' + target if call_api else target 109 | responses.add_callback( 110 | responses.PUT, url, callback=callback, content_type='application/json' 111 | ) 112 | 113 | @staticmethod 114 | def mock_patch(target, ignore_data_field=False, empty_payload=False, call_api=True): 115 | data = json.loads(Mock.read_from_file(target + '.json')) 116 | 117 | def callback(request): 118 | return Mock.__put_patch_post_callback( 119 | request, target, data, ignore_data_field, empty_payload 120 | ) 121 | 122 | url = Mock.base_url + '/' + target if call_api else target 123 | responses.add_callback( 124 | responses.PATCH, url, callback=callback, content_type='application/json' 125 | ) 126 | 127 | @staticmethod 128 | def mock_delete(target): 129 | responses.add(responses.DELETE, Mock.base_url + '/' + target, status=204) 130 | 131 | @staticmethod 132 | def mock_server_operation(target): 133 | # drop third (last) part of a string divided by two slashes ("/"); e.g "this/is/string" -> "this/is" 134 | targetsplit = target.split('/') 135 | targetfile = '/'.join(targetsplit[:2]) 136 | 137 | data = json.loads(Mock.read_from_file(targetfile + '.json')) 138 | 139 | # API will always respond state: "started", see: Server.stop, Server.start, Server,restart 140 | data['server']['state'] = 'started' 141 | 142 | data = json.dumps(data) 143 | responses.add( 144 | responses.POST, 145 | Mock.base_url + "/" + target, 146 | status=200, 147 | body=data, 148 | content_type='application/json', 149 | ) 150 | -------------------------------------------------------------------------------- /docs/network-mixin.md: -------------------------------------------------------------------------------- 1 | ## About 2 | ```python 3 | class NetworkManager(): 4 | """ 5 | Functions for managing networks. Intended to be used as a mixin for CloudManager. 6 | """ 7 | ``` 8 | `NetworkManager` is a mixed into `CloudManager` and the following methods are available through it. 9 | 10 | ```python 11 | manager = CloudManager("api-username", "password") 12 | manager.method() 13 | ``` 14 | 15 | ## Methods 16 | 17 | ```python 18 | def get_networks(self, zone="fi-hel1"): 19 | """ 20 | Get a list of all networks. 21 | Zone can be passed to return networks in a specific zone but is not mandatory. 22 | """ 23 | ``` 24 | 25 | ```python 26 | def get_network(self, uuid): 27 | """ 28 | Retrieves the details of a specific network. 29 | """ 30 | ``` 31 | 32 | ```python 33 | def create_network( 34 | name='test network', 35 | zone='fi-hel1', 36 | address='172.16.0.0/22', 37 | dhcp='yes', 38 | family='IPv4', 39 | ): 40 | """ 41 | Creates a new SDN private network that cloud servers from the same zone can be attached to. 42 | Name, zone, address, dhcp and family arguments are required. 43 | Router, dhcp_default_route, dhcp_dns, dhcp_bootfile_url and gateway arguments are optional. 44 | Dhcp and dhcp_default_route accept yes or no (string) as a value. 45 | Dhcp_dns accepts an array of addresses. 46 | Returns a Network object. 47 | """ 48 | ``` 49 | 50 | ```python 51 | def modify_network( 52 | network='036df3d0-8629-4549-984e-dc86fc3fa1b0', 53 | dhcp='yes', 54 | family='IPv4', 55 | router='04b65749-61e2-4f08-a259-c75afbe81abf', 56 | ): 57 | """ 58 | Modifies the details of a specific SDN private network. The Utility and public networks cannot be modified. 59 | Network, dhcp, family and router arguments are required (router can be an id of a router or a router object, same goes for network). 60 | Name, router, dhcp_default_route, dhcp_dns, dhcp_bootfile_url and gateway arguments are optional. 61 | Dhcp and dhcp_default_route accept yes or no (string) as a value. 62 | Dhcp_dns accepts an array of addresses. 63 | Returns a Network object. 64 | """ 65 | ``` 66 | 67 | ```python 68 | def delete_network(self, network): 69 | """ 70 | Deletes an SDN private network. All attached cloud servers must first be detached before SDN private networks can be deleted. 71 | Network argument must be provided (can be an id or a Network object). 72 | Returns an empty response. 73 | """ 74 | ``` 75 | 76 | ```python 77 | def get_server_networks(self, server): 78 | """ 79 | List all networks the specific cloud server is connected to. 80 | Server argument must be passed (can be an id or a Server object). 81 | Returns a list of Interface objects. 82 | 83 | """ 84 | ``` 85 | 86 | ```python 87 | def create_network_interface( 88 | server='0082c083-9847-4f9f-ae04-811251309b35', 89 | network='036df3d0-8629-4549-984e-dc86fc3fa1b0', 90 | type='private', 91 | ip_addresses=[{'family': 'IPv4', 'address': '172.16.1.10'}] 92 | ): 93 | """ 94 | Creates a new network interface on the specific cloud server and attaches the specified SDN private network to the new interface. 95 | Server, network, type and ip_addresses arguments must be passed. 96 | Index, source_ip_filtering and bootable arguments are optional. 97 | Server and network arguments can be ids or objects. 98 | Ip_addresses argument must be a list of dicts which contain family and address. 99 | Index must be an integer. 100 | Source_ip_filtering and bootable arguments accept a yes or a no string. 101 | Returns an Interface object. 102 | """ 103 | ``` 104 | 105 | ```python 106 | def modify_network_interface( 107 | server='0082c083-9847-4f9f-ae04-811251309b35', 108 | index_in_path=7 109 | ): 110 | """ 111 | Modifies the network interface at the selected index on the specific cloud server. 112 | Server and index_in_path arguments are mandatory. 113 | Index_in_body, ip_addresses, source_ip_filtering and bootable arguments are optional. 114 | Server argument can be an id or an object. 115 | Index arguments must be integers. 116 | Ip_addresses argument must be a list of dicts which contain family and address. 117 | Source_ip_filtering and bootable arguments accept a yes or a no string. 118 | Returns an Interface object. 119 | """ 120 | ``` 121 | 122 | ```python 123 | def delete_network_interface(self, server, index): 124 | """ 125 | Detaches an SDN private network from a cloud server by deleting the network interface at the selected index on the specific cloud server. 126 | Server and index arguments are mandatory. 127 | Server argument can be an id or an object. 128 | Index argument must be an integer. 129 | Returns an empty response 130 | """ 131 | ``` 132 | 133 | ```python 134 | def get_routers(self): 135 | """ 136 | Returns a list of all available routers associated with the current account (list of Router objects). 137 | """ 138 | ``` 139 | 140 | ```python 141 | def get_router(self, uuid): 142 | """ 143 | Returns detailed information about a specific router (router object). 144 | UUID argument is mandatory 145 | """ 146 | ``` 147 | 148 | ```python 149 | def create_router(self, name): 150 | """ 151 | Creates a new router. 152 | Name is a mandatory argument. 153 | Returns a Router object. 154 | """ 155 | ``` 156 | 157 | ```python 158 | def modify_router(self, router, name): 159 | """ 160 | Modify an existing router. 161 | Router and name arguments are mandatory. 162 | Router can be an id or a Router object. 163 | Returns a Router object. 164 | """ 165 | ``` 166 | 167 | ```python 168 | def delete_router(self, router): 169 | """ 170 | Delete an existing router. 171 | Router argument is mandatory. 172 | Router can be an id or a Router object. 173 | Returns a Router object. 174 | """ 175 | ``` 176 | -------------------------------------------------------------------------------- /test/json_data/network.json: -------------------------------------------------------------------------------- 1 | { 2 | "networks": 3 | { 4 | "network": 5 | [ 6 | { 7 | "ip_networks": 8 | { 9 | "ip_network": 10 | [ 11 | { 12 | "address": "94.237.8.0/22", 13 | "dhcp": "yes", 14 | "dhcp_default_route": "yes", 15 | "dhcp_dns": ["94.237.127.9", "94.237.40.9"], 16 | "family": "IPv4", 17 | "gateway": "94.237.8.1"}]}, 18 | "name": "Public 94.237.8.0/22", 19 | "type": "public", 20 | "uuid": "03000000-0000-4000-8044-000000000000", 21 | "zone": "fi-hel2" 22 | }, 23 | { 24 | "ip_networks": 25 | { 26 | "ip_network": 27 | [ 28 | { 29 | "address": "10.6.0.0/22", 30 | "dhcp": "yes", 31 | "dhcp_default_route": "no", 32 | "dhcp_routes": ["10.0.0.0/8"], 33 | "family": "IPv4", 34 | "gateway": "10.6.0.1"}]}, 35 | "name": "Private 10.6.0.0/22", 36 | "type": "utility", 37 | "uuid": "03000000-0000-4000-8045-000000000000", 38 | "zone": "fi-hel2" 39 | }, 40 | { 41 | "ip_networks": 42 | { 43 | "ip_network": 44 | [ 45 | { 46 | "address": "2a04:3545:1000:720::/64", 47 | "dhcp": "yes", 48 | "dhcp_default_route": "yes", 49 | "dhcp_dns": ["2a04:3540:53::1", "2a04:3544:53::1"], 50 | "family": "IPv6", 51 | "gateway": "2a04:3545:1000:720::1"}]}, 52 | "name": "Public 2a04:3545:1000:720::/64", 53 | "servers": 54 | { 55 | "server": 56 | [ 57 | { 58 | "title": "centos-1cpu-1gb-fi-hel2", 59 | "uuid": "00c0ee31-de2f-4c89-87b6-0d7d69c749a7"}]}, 60 | "type": "public", 61 | "uuid": "03000000-0000-4000-8046-000000000000", 62 | "zone": "fi-hel2" 63 | }, 64 | { 65 | "ip_networks": 66 | { 67 | "ip_network": 68 | [ 69 | { 70 | "address": "94.237.12.0/22", 71 | "dhcp": "yes", 72 | "dhcp_default_route": "yes", 73 | "dhcp_dns": ["94.237.127.9", "94.237.40.9"], 74 | "family": "IPv4", "gateway": "94.237.12.1"}]}, 75 | "name": "Public 94.237.12.0/22", 76 | "type": "public", 77 | "uuid": "03000000-0000-4000-8095-000000000000", 78 | "zone": "fi-hel2" 79 | }, 80 | { 81 | "ip_networks": 82 | { 83 | "ip_network": 84 | [ 85 | { 86 | "address": "10.6.4.0/22", 87 | "dhcp": "yes", 88 | "dhcp_default_route": "no", 89 | "dhcp_routes": ["10.0.0.0/8"], 90 | "family": "IPv4", 91 | "gateway": "10.6.4.1"}]}, 92 | "name": "Private 10.6.4.0/22", 93 | "type": "utility", 94 | "uuid": "03000000-0000-4000-8096-000000000000", 95 | "zone": "fi-hel2" 96 | }, 97 | { 98 | "ip_networks": 99 | { 100 | "ip_network": 101 | [ 102 | { 103 | "address": "94.237.104.0/22", 104 | "dhcp": "yes", 105 | "dhcp_default_route": "yes", 106 | "dhcp_dns": ["94.237.127.9", "94.237.40.9"], 107 | "family": "IPv4", 108 | "gateway": "94.237.104.1"}]}, 109 | "name": "Public fi-hel2 94.237.104.0/22", 110 | "servers": 111 | { 112 | "server": 113 | [ 114 | { 115 | "title": "centos-1cpu-1gb-fi-hel2", 116 | "uuid": "00c0ee31-de2f-4c89-87b6-0d7d69c749a7"}]}, 117 | "type": "public", 118 | "uuid": 119 | "031457f4-0f8c-483c-96f2-eccede02909c", 120 | "zone": "fi-hel2" 121 | }, 122 | { 123 | "ip_networks": 124 | { 125 | "ip_network": 126 | [ 127 | { 128 | "address": "10.6.0.0/22", 129 | "dhcp": "yes", 130 | "dhcp_default_route": "no", 131 | "dhcp_routes": ["10.0.0.0/8"], 132 | "family": "IPv4", 133 | "gateway": "10.6.0.1"}]}, 134 | "name": "Private 10.6.0.0/22", 135 | "router": "04cad61f-baae-4019-8740-a840acd68319", 136 | "servers": 137 | { 138 | "server": 139 | [ 140 | { 141 | "title": "centos-1cpu-1gb-fi-hel2", 142 | "uuid": "00c0ee31-de2f-4c89-87b6-0d7d69c749a7" 143 | } 144 | ] 145 | }, 146 | "type": "utility", 147 | "uuid": "03a0020f-896b-4453-913c-e236b8e639d6", 148 | "zone": "fi-hel2"}]}} 149 | -------------------------------------------------------------------------------- /test/test_firewall.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import responses 4 | from conftest import Mock 5 | 6 | from upcloud_api import FirewallRule 7 | 8 | 9 | def firewall_rule_callback(request): 10 | """ 11 | Checks that firewall rule contains required fields. 12 | Returns the same body with 201 Created. 13 | """ 14 | required_fields = [ 15 | 'position', 16 | 'direction', 17 | 'family', 18 | 'protocol', 19 | 'source_address_start', 20 | 'source_address_end', 21 | 'destination_port_start', 22 | 'destination_port_end', 23 | 'action', 24 | ] 25 | 26 | request_body = json.loads(request.body) 27 | 28 | def check_fields(body): 29 | """ 30 | Helper for checking a firewall rule body against required_fields. 31 | """ 32 | for field in required_fields: 33 | if field not in request_body['firewall_rule']: 34 | raise Exception(f'missing required field: {field}. Body was:{request_body}') 35 | 36 | if isinstance(request_body, list): 37 | for body in request_body: 38 | check_fields(body) 39 | else: 40 | check_fields(request_body) 41 | 42 | return (201, {}, json.dumps(request_body)) 43 | 44 | 45 | class TestFirewall: 46 | @responses.activate 47 | def test_add_firewall_rule(self, manager): 48 | Mock.mock_get('server/00798b85-efdc-41ca-8021-f6ef457b8531') 49 | server = manager.get_server('00798b85-efdc-41ca-8021-f6ef457b8531') 50 | 51 | responses.add_callback( 52 | responses.POST, 53 | Mock.base_url + '/server/00798b85-efdc-41ca-8021-f6ef457b8531/firewall_rule', 54 | content_type='application/json', 55 | callback=firewall_rule_callback, 56 | ) 57 | 58 | returned_firewall = server.add_firewall_rule( 59 | FirewallRule( 60 | position='1', 61 | direction='in', 62 | family='IPv4', 63 | protocol='tcp', 64 | source_address_start='192.168.1.1', 65 | source_address_end='192.168.1.255', 66 | destination_port_start='22', 67 | destination_port_end='22', 68 | action='accept', 69 | ) 70 | ) 71 | 72 | # everything should run without errors, returned created object 73 | assert returned_firewall.position == '1' 74 | assert returned_firewall.direction == 'in' 75 | assert returned_firewall.source_address_end == '192.168.1.255' 76 | 77 | @responses.activate 78 | def test_remove_firewall_rule(self, manager): 79 | Mock.mock_get('server/00798b85-efdc-41ca-8021-f6ef457b8531') 80 | server = manager.get_server('00798b85-efdc-41ca-8021-f6ef457b8531') 81 | 82 | target = 'server/00798b85-efdc-41ca-8021-f6ef457b8531/firewall_rule' 83 | Mock.mock_get(target, 'firewall_rules.json') 84 | firewall_rules = server.get_firewall_rules() 85 | 86 | Mock.mock_delete('server/00798b85-efdc-41ca-8021-f6ef457b8531/firewall_rule/1') 87 | res = firewall_rules[0].destroy() 88 | 89 | Mock.mock_delete('server/00798b85-efdc-41ca-8021-f6ef457b8531/firewall_rule/1') 90 | res = server.remove_firewall_rule(firewall_rules[0]) 91 | 92 | assert res == {} 93 | 94 | @responses.activate 95 | def test_list_and_get_firewall_rules(self, manager): 96 | Mock.mock_get('server/00798b85-efdc-41ca-8021-f6ef457b8531') 97 | server = manager.get_server('00798b85-efdc-41ca-8021-f6ef457b8531') 98 | 99 | target = 'server/00798b85-efdc-41ca-8021-f6ef457b8531/firewall_rule' 100 | Mock.mock_get(target, 'firewall_rules.json') 101 | firewall_rules = server.get_firewall_rules() 102 | 103 | assert firewall_rules[0].position == '1' 104 | 105 | @responses.activate 106 | def test_configure_firewall(self, manager): 107 | Mock.mock_get('server/00798b85-efdc-41ca-8021-f6ef457b8531') 108 | server = manager.get_server('00798b85-efdc-41ca-8021-f6ef457b8531') 109 | 110 | responses.add_callback( 111 | responses.POST, 112 | Mock.base_url + '/server/00798b85-efdc-41ca-8021-f6ef457b8531/firewall_rule', 113 | content_type='application/json', 114 | callback=firewall_rule_callback, 115 | ) 116 | 117 | returned_firewall = server.configure_firewall( 118 | [ 119 | FirewallRule( 120 | position='1', 121 | direction='in', 122 | family='IPv4', 123 | protocol='tcp', 124 | source_address_start='192.168.1.1', 125 | source_address_end='192.168.1.255', 126 | destination_port_start='22', 127 | destination_port_end='22', 128 | action='accept', 129 | ), 130 | FirewallRule( 131 | position='2', 132 | direction='out', 133 | family='IPv4', 134 | protocol='tcp', 135 | source_address_start='192.168.1.1', 136 | source_address_end='192.168.1.255', 137 | destination_port_start='22', 138 | destination_port_end='22', 139 | action='accept', 140 | ), 141 | ] 142 | ) 143 | 144 | # everything should run without errors, returned created object 145 | assert returned_firewall[0].position == '1' 146 | assert returned_firewall[0].direction == 'in' 147 | assert returned_firewall[0].source_address_end == '192.168.1.255' 148 | assert returned_firewall[1].position == '2' 149 | assert returned_firewall[1].direction == 'out' 150 | assert returned_firewall[1].source_address_end == '192.168.1.255' 151 | -------------------------------------------------------------------------------- /test/test_server.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import responses 3 | from conftest import Mock 4 | 5 | 6 | class TestServer: 7 | @responses.activate 8 | def test_get_server(self, manager): 9 | data = Mock.mock_get('server/00798b85-efdc-41ca-8021-f6ef457b8531') 10 | server = manager.get_server('00798b85-efdc-41ca-8021-f6ef457b8531') 11 | 12 | assert type(server).__name__ == 'Server' 13 | assert server.uuid == '00798b85-efdc-41ca-8021-f6ef457b8531' 14 | assert len(server.labels['label']) == 1 15 | assert server.labels['label'][0]['value'] == "example" 16 | 17 | @responses.activate 18 | def test_get_unpopulated_servers(self, manager): 19 | data = Mock.mock_get('server') 20 | servers = manager.get_servers() 21 | 22 | for server in servers: 23 | assert type(server).__name__ == 'Server' 24 | 25 | @responses.activate 26 | def test_get_populated_servers(self, manager): 27 | data = Mock.mock_get('server') 28 | data = Mock.mock_get('server/00798b85-efdc-41ca-8021-f6ef457b8531') 29 | data = Mock.mock_get('server/009d64ef-31d1-4684-a26b-c86c955cbf46') 30 | servers = manager.get_servers(populate=True) 31 | 32 | for server in servers: 33 | assert type(server).__name__ == 'Server' 34 | 35 | @responses.activate 36 | def test_start_server(self, manager): 37 | data = Mock.mock_get('server/009d64ef-31d1-4684-a26b-c86c955cbf46') 38 | server = manager.get_server('009d64ef-31d1-4684-a26b-c86c955cbf46') 39 | 40 | assert server.state == 'stopped' 41 | 42 | data = Mock.mock_server_operation('server/009d64ef-31d1-4684-a26b-c86c955cbf46/start') 43 | server.start() 44 | 45 | assert server.state == 'started' 46 | 47 | @responses.activate 48 | def test_stop_server(self, manager): 49 | data = Mock.mock_get('server/00798b85-efdc-41ca-8021-f6ef457b8531') 50 | server = manager.get_server('00798b85-efdc-41ca-8021-f6ef457b8531') 51 | 52 | assert server.state == 'started' 53 | 54 | data = Mock.mock_server_operation('server/00798b85-efdc-41ca-8021-f6ef457b8531/stop') 55 | server.stop() 56 | 57 | assert server.state == 'maintenance' 58 | 59 | @responses.activate 60 | def test_restart_server(self, manager): 61 | data = Mock.mock_get('server/00798b85-efdc-41ca-8021-f6ef457b8531') 62 | server = manager.get_server('00798b85-efdc-41ca-8021-f6ef457b8531') 63 | 64 | assert server.state == 'started' 65 | 66 | data = Mock.mock_server_operation('server/00798b85-efdc-41ca-8021-f6ef457b8531/restart') 67 | server.restart() 68 | 69 | assert server.state == 'maintenance' 70 | 71 | @responses.activate 72 | def test_attach_and_detach_ip(self, manager): 73 | data = Mock.mock_get('server/00798b85-efdc-41ca-8021-f6ef457b8531') 74 | server = manager.get_server('00798b85-efdc-41ca-8021-f6ef457b8531') 75 | assert len(server.ip_addresses) == 2 76 | 77 | data = Mock.mock_post('ip_address') 78 | server.add_ip() 79 | assert len(server.ip_addresses) == 3 80 | 81 | Mock.mock_delete('ip_address/' + server.ip_addresses[2].address) 82 | server.remove_ip(server.ip_addresses[2]) 83 | assert len(server.ip_addresses) == 2 84 | 85 | @responses.activate 86 | def test_attach_and_detach_storage(self, manager): 87 | data = Mock.mock_get('server/00798b85-efdc-41ca-8021-f6ef457b8531') 88 | server = manager.get_server('00798b85-efdc-41ca-8021-f6ef457b8531') 89 | assert len(server.storage_devices) == 1 90 | assert server.storage_devices[0].title == 'Storage for server1.example.com' 91 | 92 | data = Mock.mock_get('storage/01d4fcd4-e446-433b-8a9c-551a1284952e') 93 | storage = manager.get_storage('01d4fcd4-e446-433b-8a9c-551a1284952e') 94 | 95 | responses.add( 96 | responses.POST, 97 | Mock.base_url + '/server/00798b85-efdc-41ca-8021-f6ef457b8531/storage/attach', 98 | body=Mock.read_from_file('storage_attach.json'), 99 | status=200, 100 | content_type='application/json', 101 | ) 102 | server.add_storage(storage) 103 | assert len(server.storage_devices) == 2 104 | assert server.storage_devices[0].title == 'Storage for server1.example.com' 105 | assert server.storage_devices[1].title == 'Operating system disk' 106 | 107 | responses.add( 108 | responses.POST, 109 | Mock.base_url + '/server/00798b85-efdc-41ca-8021-f6ef457b8531/storage/detach', 110 | body=Mock.read_from_file('storage_attach.json'), 111 | status=200, 112 | content_type='application/json', 113 | ) 114 | server.remove_storage(server.storage_devices[1]) 115 | 116 | assert len(server.storage_devices) == 1 117 | assert server.storage_devices[0].title == 'Storage for server1.example.com' 118 | 119 | @responses.activate 120 | def test_update_server_oop(self, manager): 121 | data = Mock.mock_get('server/00798b85-efdc-41ca-8021-f6ef457b8531') 122 | server = manager.get_server('00798b85-efdc-41ca-8021-f6ef457b8531') 123 | 124 | server.core_number = 6 125 | server.memory_amount = 1024 126 | server.title = 'Updated server' 127 | 128 | data = Mock.mock_put('server/00798b85-efdc-41ca-8021-f6ef457b8531') 129 | server.save() 130 | 131 | assert server.core_number == 6 132 | assert server.memory_amount == 1024 133 | assert server.title == 'Updated server' 134 | 135 | @responses.activate 136 | def test_update_server_non_updateable_fields(self, manager): 137 | data = Mock.mock_get('server/00798b85-efdc-41ca-8021-f6ef457b8531') 138 | server = manager.get_server('00798b85-efdc-41ca-8021-f6ef457b8531') 139 | 140 | with pytest.raises(Exception) as excinfo: 141 | server.state = 'rekt' 142 | assert "'state' is a readonly field" in str(excinfo.value) 143 | -------------------------------------------------------------------------------- /docs/Storage.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # About 4 | 5 | Storages are entirely separate from Servers and can be attached/detached from them. 6 | Storages can be created, updated and destroyed separately from servers. They can be loaded as CDROMs or disks, 7 | and they can be cloned into new storage devices. 8 | 9 | 10 | ### Tiers 11 | 12 | UpCloud offers MaxIOPS (Extremely fast 100k IOPS Storage) and HDD storages. 13 | 14 | ``` 15 | Tiers: 16 | "maxiops", "hdd", 17 | ``` 18 | 19 | ### Templates 20 | 21 | Public templates such as the 01000000-0000-4000-8000-000030240200 can be cloned by anyone to get a pre-installed 22 | server image that is immediately ready to go. A user can also create private templates for themselves out of 23 | any storage. Storages can be cloned from templates during server creation. 24 | 25 | ## List / Get 26 | 27 | CloudManager returns Storage instances. 28 | 29 | ```python 30 | 31 | manager.get_storages() 32 | manager.get_storage(storage.uuid) 33 | 34 | ``` 35 | 36 | `get_storages()` accepts one of the following parameters to filter the query: 37 | ``` 38 | Storages list filters: 39 | "normal" (default), "public", "private", 40 | "backup", "cdrom", "template", "favorite" 41 | ``` 42 | 43 | ## Create 44 | 45 | Storage can be created with the CloudManager's `create_storage()` function. 46 | 47 | 48 | ```python 49 | 50 | storage1 = manager.create_storage( 51 | zone='fi-hel1', 52 | size=10, 53 | tier="maxiops", 54 | title="my storage disk", 55 | encrypted=False 56 | ) 57 | 58 | storage2 = manager.create_storage(zone='de-fra1', size=100) 59 | 60 | ``` 61 | 62 | 63 | ## Update 64 | 65 | Only the size and title of a storage can be updated. Please note that size can not be reduced and that 66 | OS level actions are required to account for the increased size. 67 | 68 | ```python 69 | 70 | storage = manager.get_storage(uuid) 71 | storage.update(size=100, title="new title") 72 | 73 | ``` 74 | 75 | ## Delete 76 | 77 | Warning: data loss is permanent. 78 | 79 | ```python 80 | 81 | storage.destroy() 82 | 83 | ``` 84 | 85 | ## Delete with backups 86 | 87 | Backup deletion policy can be specified when deleting a storage through CloudManager. Default policy is 88 | `KEEP`, so no backups are deleted, but `KEEP_LATEST` and `DELETE` are also supported. Options are configured 89 | through BackupDeletionPolicy enum under storage. Default behaviour for backups is always to keep them. 90 | 91 | Following example deletes the storage, but keeps the latest existing backup. If no backup exists for the storage, 92 | nothing is left behind. 93 | 94 | ```python 95 | 96 | from upcloud_api.storage import BackupDeletionPolicy 97 | 98 | manager.delete_storage(uuid, backups=BackupDeletionPolicy.KEEP_LATEST) 99 | 100 | ``` 101 | 102 | ## Import 103 | 104 | Storages can be imported either by passing a URL or by uploading the file. Currently .iso, .raw and .img formats 105 | are supported. Other formats like QCOW2 or VMDK should be converted before uploading 106 | (with e.g. [`qemu-img convert`](https://linux.die.net/man/1/qemu-img)). 107 | 108 | Uploaded storage is expected to be uncompressed. It is possible to upload gzip (`application/gzip`) 109 | or LZMA2 (`application/x-xz`) compressed files, but you need to specify a separate `content_type` 110 | when calling the `upload_file_for_storage_import` function. 111 | 112 | Warning: the size of the import cannot exceed the size of the storage. 113 | The data will be written starting from the beginning of the storage, 114 | and the storage will not be truncated before starting to write. 115 | 116 | Storages can be uploaded by providing a URL. Note that the upload is not 117 | done by the time `create_storage_import` returns, and you need to poll its 118 | status with `get_storage_import_details`. 119 | ```python 120 | 121 | new_storage = manager.create_storage(size=20, zone='nl-ams1') 122 | storage_import = manager.create_storage_import( 123 | storage=new_storage.uuid, 124 | source='http_import', 125 | source_location='https://username:password@example.server/path/to/data.raw', 126 | ) 127 | 128 | import_details = manager.get_storage_import_details(new_storage.uuid) 129 | 130 | ``` 131 | 132 | Other way is to upload a storage directly. After finishing, you should confirm 133 | that the storage has been processed with `get_storage_import_details`. 134 | ```python 135 | 136 | new_storage = manager.create_storage(size=20, zone='de-fra1', title='New imported storage') 137 | storage_import = manager.create_storage_import(storage=new_storage.uuid, source='direct_upload') 138 | 139 | manager.upload_file_for_storage_import( 140 | storage_import=storage_import, 141 | file='/path/to/your/storage.img', 142 | ) 143 | 144 | import_details = manager.get_storage_import_details(new_storage.uuid) 145 | 146 | ``` 147 | 148 | Ongoing imports can also be cancelled: 149 | ```python 150 | 151 | manager.cancel_storage_import(new_storage.uuid) 152 | 153 | ``` 154 | 155 | ## Clone 156 | 157 | Clone the storage using StorageManager. 158 | Returns an object based on the API's response. 159 | Method requires title and zone to be passed while tier is optional. 160 | 161 | ```python 162 | 163 | storage_clone = storage.clone(title='title of storage clone', zone='fi-hel1', tier=None) 164 | 165 | ``` 166 | 167 | 168 | ## Cancel clone operation 169 | 170 | Cancels a running cloning operation and deletes the incomplete copy using StorageManager. 171 | Needs to be called from the cloned storage (object returned by clone operation) 172 | and not the storage that is being cloned. 173 | 174 | ```python 175 | 176 | storage_clone.cancel_cloning() 177 | 178 | ``` 179 | 180 | 181 | ## Create backup 182 | 183 | Creates a point-in-time backup of a storage resource using StorageManager. 184 | Method requires title to be passed. 185 | 186 | ```python 187 | 188 | storage_backup = storage.create_backup('Backup title') 189 | 190 | ``` 191 | 192 | 193 | ## Restore backup 194 | 195 | Restores the origin storage with data from the specified backup storage using StorageManager. 196 | Must be called from a storage object created by create_backup and not the original one. 197 | 198 | ```python 199 | 200 | storage_backup.restore_backup() 201 | 202 | ``` 203 | 204 | 205 | ## Templatize storage 206 | 207 | Creates an exact copy of an existing storage resource which can be used as a template 208 | for creating new servers using StorageManager. Method requires title to be passed. 209 | 210 | ```python 211 | 212 | storage.templatize('Template title') 213 | 214 | ``` 215 | -------------------------------------------------------------------------------- /test/test_network.py: -------------------------------------------------------------------------------- 1 | import responses 2 | from conftest import Mock 3 | 4 | 5 | class TestNetwork: 6 | @responses.activate 7 | def test_get_networks(self, manager): 8 | data = Mock.mock_get('network') 9 | networks = manager.get_networks() 10 | 11 | for network in networks: 12 | assert type(network).__name__ == "Network" 13 | 14 | @responses.activate 15 | def test_get_network(self, manager): 16 | data = Mock.mock_get('network/03000000-0000-4000-8001-000000000000') 17 | network = manager.get_network('03000000-0000-4000-8001-000000000000') 18 | 19 | assert type(network).__name__ == "Network" 20 | assert network.uuid == '03000000-0000-4000-8001-000000000000' 21 | assert network.type == 'public' 22 | assert network.zone == 'fi-hel1' 23 | 24 | @responses.activate 25 | def test_create_network(self, manager): 26 | data = Mock.mock_post('network', ignore_data_field=True) 27 | network = manager.create_network( 28 | name='test network', 29 | zone='fi-hel1', 30 | address='172.16.0.0/22', 31 | dhcp='yes', 32 | family='IPv4', 33 | router='04b65749-61e2-4f08-a259-c75afbe81abf', 34 | dhcp_default_route='no', 35 | dhcp_dns=["172.16.0.10", "172.16.1.10"], 36 | dhcp_bootfile_url='tftp://172.16.0.253/pxelinux.0', 37 | gateway='172.16.0.1', 38 | ) 39 | 40 | assert type(network).__name__ == "Network" 41 | assert network.uuid == '036df3d0-8629-4549-984e-dc86fc3fa1b0' 42 | assert network.type == 'private' 43 | assert network.zone == 'fi-hel1' 44 | 45 | @responses.activate 46 | def test_modify_network(self, manager): 47 | data = Mock.mock_put( 48 | 'network/036df3d0-8629-4549-984e-dc86fc3fa1b0', ignore_data_field=True 49 | ) 50 | network = manager.modify_network( 51 | network='036df3d0-8629-4549-984e-dc86fc3fa1b0', 52 | dhcp='yes', 53 | family='IPv4', 54 | router='04b65749-61e2-4f08-a259-c75afbe81abf', 55 | name='test network modify', 56 | dhcp_default_route='no', 57 | dhcp_dns=["172.16.0.10", "172.16.1.10"], 58 | dhcp_bootfile_url='tftp://172.16.0.253/pxelinux.0', 59 | gateway='172.16.0.1', 60 | ) 61 | 62 | assert type(network).__name__ == "Network" 63 | assert network.name == 'test network modify' 64 | assert network.uuid == '036df3d0-8629-4549-984e-dc86fc3fa1b0' 65 | assert network.type == 'private' 66 | assert network.zone == 'fi-hel1' 67 | 68 | @responses.activate 69 | def test_get_server_networks(self, manager): 70 | data = Mock.mock_get('server/0082c083-9847-4f9f-ae04-811251309b35/networking') 71 | networks = manager.get_server_networks('0082c083-9847-4f9f-ae04-811251309b35') 72 | 73 | for network in networks: 74 | assert type(network).__name__ == "Interface" 75 | 76 | @responses.activate 77 | def test_create_network_interface(self, manager): 78 | data = Mock.mock_post( 79 | 'server/0082c083-9847-4f9f-ae04-811251309b35/networking/interface', 80 | ignore_data_field=True, 81 | ) 82 | network_interface = manager.create_network_interface( 83 | server='0082c083-9847-4f9f-ae04-811251309b35', 84 | network='036df3d0-8629-4549-984e-dc86fc3fa1b0', 85 | type='private', 86 | ip_addresses=[{'family': 'IPv4', 'address': '172.16.1.10'}], 87 | index=7, 88 | source_ip_filtering='yes', 89 | bootable='yes', 90 | ) 91 | assert type(network_interface).__name__ == "Interface" 92 | 93 | @responses.activate 94 | def test_modify_network_interface(self, manager): 95 | data = Mock.mock_put( 96 | 'server/0082c083-9847-4f9f-ae04-811251309b35/networking/interface/7', 97 | ignore_data_field=True, 98 | ) 99 | network_interface = manager.modify_network_interface( 100 | server='0082c083-9847-4f9f-ae04-811251309b35', 101 | index_in_path=7, 102 | index_in_body=8, 103 | ip_addresses=[{'family': 'IPv4', 'address': '172.16.1.10'}], 104 | source_ip_filtering='no', 105 | bootable='no', 106 | ) 107 | assert type(network_interface).__name__ == "Interface" 108 | 109 | @responses.activate 110 | def test_delete_network_interface(self, manager): 111 | data = Mock.mock_delete( 112 | 'server/0082c083-9847-4f9f-ae04-811251309b35/networking/interface/8' 113 | ) 114 | res = manager.delete_network_interface('0082c083-9847-4f9f-ae04-811251309b35', 8) 115 | 116 | assert res == {} 117 | 118 | @responses.activate 119 | def test_delete_network(sekf, manager): 120 | data = Mock.mock_delete('network/03000000-0000-4000-8001-000000000000') 121 | res = manager.delete_network('03000000-0000-4000-8001-000000000000') 122 | 123 | assert res == {} 124 | 125 | @responses.activate 126 | def test_get_routers(self, manager): 127 | data = Mock.mock_get('router') 128 | routers = manager.get_routers() 129 | 130 | for router in routers: 131 | assert type(router).__name__ == "Router" 132 | 133 | @responses.activate 134 | def test_get_router(self, manager): 135 | data = Mock.mock_get('router/03b34bc2-5adf-4fc4-8c44-83f869058f5a') 136 | router = manager.get_router('03b34bc2-5adf-4fc4-8c44-83f869058f5a') 137 | 138 | assert type(router).__name__ == "Router" 139 | assert router.type == "normal" 140 | assert router.name == "test router" 141 | 142 | @responses.activate 143 | def test_create_router(self, manager): 144 | data = Mock.mock_post('router') 145 | router = manager.create_router('test router') 146 | 147 | assert type(router).__name__ == "Router" 148 | assert router.type == "normal" 149 | assert router.name == "test router" 150 | 151 | @responses.activate 152 | def test_modify_router(self, manager): 153 | data = Mock.mock_patch('router/04da7f97-dc03-4df0-868f-239f304ba72f') 154 | router = manager.modify_router( 155 | '04da7f97-dc03-4df0-868f-239f304ba72f', 'test router modify' 156 | ) 157 | 158 | assert type(router).__name__ == "Router" 159 | assert router.type == "normal" 160 | assert router.name == "test router modify" 161 | 162 | @responses.activate 163 | def test_delete_router(self, manager): 164 | data = Mock.mock_delete('router/04da7f97-dc03-4df0-868f-239f304ba72f') 165 | res = manager.delete_router('04da7f97-dc03-4df0-868f-239f304ba72f') 166 | 167 | assert res == {} 168 | -------------------------------------------------------------------------------- /upcloud_api/storage.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from upcloud_api.upcloud_resource import UpCloudResource 4 | 5 | STORAGE_OSES_WHICH_REQUIRE_METADATA = [ 6 | "01000000-0000-4000-8000-000020070100", # Debian GNU/Linux 12 (Bookworm) 7 | "01000000-0000-4000-8000-000030220200", # Ubuntu Server 22.04 LTS (Jammy Jellyfish) 8 | "01000000-0000-4000-8000-000030240200", # Ubuntu Server 24.04 LTS (Noble Numbat) 9 | "01000000-0000-4000-8000-000140020100", # AlmaLinux 9 10 | "01000000-0000-4000-8000-000150020100", # Rocky Linux 9 11 | ] 12 | 13 | 14 | class BackupDeletionPolicy(Enum): 15 | """ 16 | Class representation of backup deletion policies used on storage deletions. 17 | """ 18 | 19 | KEEP = 'keep' 20 | KEEP_LATEST = 'keep_latest' 21 | DELETE = 'delete' 22 | 23 | 24 | class Storage(UpCloudResource): 25 | """ 26 | Class representation of UpCloud Storage instance. 27 | """ 28 | 29 | ATTRIBUTES = { 30 | 'access': None, 31 | 'address': None, 32 | 'encrypted': None, 33 | 'labels': None, 34 | 'license': None, 35 | 'state': None, 36 | 'size': 10, 37 | 'tier': 'maxiops', 38 | 'title': '', 39 | 'type': None, 40 | 'uuid': None, 41 | 'zone': None, 42 | } 43 | 44 | def _reset(self, **kwargs) -> None: 45 | """ 46 | Reset after repopulating from API. 47 | """ 48 | 49 | # there are some inconsistencies in the API regarding these 50 | # note: this could be written in fancier ways, but this way is simpler 51 | 52 | if 'uuid' in kwargs: 53 | self.uuid = kwargs['uuid'] 54 | elif 'storage' in kwargs: # let's never use storage.storage internally 55 | self.uuid = kwargs['storage'] 56 | 57 | if 'title' in kwargs: 58 | self.title = kwargs['title'] 59 | elif 'storage_title' in kwargs: 60 | self.title = kwargs['storage_title'] 61 | 62 | if 'size' in kwargs: 63 | self.size = kwargs['size'] 64 | elif 'storage_size' in kwargs: 65 | self.size = kwargs['storage_size'] 66 | 67 | if kwargs.get('encrypted') == 'yes': 68 | self.encrypted = True 69 | else: 70 | self.encrypted = False 71 | 72 | # send the rest to super._reset 73 | 74 | filtered_kwargs = { 75 | key: val 76 | for key, val in kwargs.items() 77 | if key 78 | not in [ 79 | 'uuid', 80 | 'storage', 81 | 'title', 82 | 'storage_title', 83 | 'size', 84 | 'storage_size', 85 | 'encrypted', 86 | ] 87 | } 88 | super()._reset(**filtered_kwargs) 89 | 90 | def destroy(self) -> None: 91 | """ 92 | Destroy the storage via the API. 93 | """ 94 | self.cloud_manager.delete_storage(self.uuid) 95 | 96 | def save(self) -> None: 97 | """ 98 | Save (modify) the storage to the API. 99 | Note: only size and title are updateable fields. 100 | """ 101 | res = self.cloud_manager._modify_storage(self, self.size, self.title) 102 | self._reset(**res['storage']) 103 | 104 | def update(self, size, title): 105 | """ 106 | Update the storage to the API. 107 | """ 108 | self.size = size 109 | self.title = title 110 | self.save() 111 | 112 | def clone(self, title: str, zone: str, tier=None) -> 'Storage': 113 | """ 114 | Clone the storage using StorageManager. 115 | Returns an object based on the API's response. 116 | """ 117 | return self.cloud_manager.clone_storage(self.uuid, title, zone, tier) 118 | 119 | def cancel_cloning(self): 120 | """ 121 | Cancels a running cloning operation and deletes the incomplete copy using StorageManager. 122 | Needs to be called from the cloned storage and not the storage that is being cloned. 123 | """ 124 | return self.cloud_manager.cancel_clone_storage(self.uuid) 125 | 126 | def create_backup(self, title: str) -> 'Storage': 127 | """ 128 | Creates a point-in-time backup of a storage resource using StorageManager. 129 | """ 130 | return self.cloud_manager.create_storage_backup(self.uuid, title) 131 | 132 | def restore_backup(self): 133 | """ 134 | Restores the origin storage with data from the specified backup storage using StorageManager. 135 | Must be called from a storage object created by create_backup and not the original one. 136 | """ 137 | return self.cloud_manager.restore_storage_backup(self.uuid) 138 | 139 | def templatize(self, title: str) -> 'Storage': 140 | """ 141 | Creates an exact copy of an existing storage resource which can be used as a template for creating new servers using StorageManager. 142 | """ 143 | return self.cloud_manager.templatize_storage(self.uuid, title) 144 | 145 | def __str__(self) -> str: 146 | """ 147 | String representation of Storage. 148 | Can be used to add tags into API requests: str(storage). 149 | """ 150 | return self.uuid 151 | 152 | def to_dict(self): 153 | """ 154 | Return a dict that can be serialised to JSON and sent to UpCloud's API. 155 | 156 | Uses the convenience attribute `os` for determining `action` and `storage` 157 | fields. 158 | """ 159 | body = { 160 | 'tier': self.tier, 161 | 'title': self.title, 162 | 'size': self.size, 163 | } 164 | 165 | # optionals 166 | 167 | if hasattr(self, 'address') and self.address: 168 | body['address'] = self.address 169 | 170 | if hasattr(self, 'zone') and self.zone: 171 | body['zone'] = self.zone 172 | 173 | if hasattr(self, 'labels'): 174 | dict_labels = [] 175 | for label in self.labels: 176 | dict_labels.append(label.to_dict()) 177 | body['labels'] = dict_labels 178 | 179 | if hasattr(self, 'encrypted') and isinstance(self.encrypted, bool): 180 | body['encrypted'] = "yes" if self.encrypted else "no" 181 | 182 | return body 183 | 184 | @staticmethod 185 | def _create_storage_objs(storages, cloud_manager): 186 | # storages might be provided as a flat array or as a following dict: 187 | # {'storage_devices': {'storage_device': [...]}} || {'storage_device': [...]} 188 | 189 | if 'storage_devices' in storages: 190 | storages = storages['storage_devices'] 191 | if 'storage_device' in storages: 192 | storages = storages['storage_device'] 193 | 194 | # or {'storages': {'storage': [...]}} || {'storage': [...]} 195 | 196 | if 'storages' in storages: 197 | storages = storages['storages'] 198 | if 'storage' in storages: 199 | storages = storages['storage'] 200 | 201 | return [Storage(cloud_manager=cloud_manager, **storage) for storage in storages] 202 | -------------------------------------------------------------------------------- /upcloud_api/load_balancer.py: -------------------------------------------------------------------------------- 1 | from upcloud_api.label import Label 2 | from upcloud_api.upcloud_resource import UpCloudResource 3 | 4 | 5 | class LoadBalancerFrontEndRule(UpCloudResource): 6 | """ 7 | Class representation of loadbalancer frontend rule 8 | """ 9 | 10 | ATTRIBUTES = { 11 | 'name': '', 12 | 'priority': 0, 13 | 'matchers': [], 14 | 'actions': [], 15 | } 16 | 17 | def to_dict(self): 18 | """ 19 | Returns a dictionary object that adheres to UpCloud API json spec 20 | """ 21 | return { 22 | 'name': self.name, 23 | 'priority': getattr(self, 'priority', 0), 24 | 'actions': getattr(self, 'actions', []), 25 | 'matchers': getattr(self, 'matchers', []), 26 | } 27 | 28 | 29 | class LoadBalancerFrontend(UpCloudResource): 30 | """ 31 | Class representation of loadbalancer frontend 32 | """ 33 | 34 | ATTRIBUTES = { 35 | 'name': '', 36 | 'mode': '', 37 | 'port': 0, 38 | 'default_backend': '', 39 | 'networks': [], 40 | 'rules': None, 41 | 'tls_configs': None, 42 | 'properties': None, 43 | } 44 | 45 | def to_dict(self): 46 | """ 47 | Returns a dictionary object that adheres to UpCloud API json spec 48 | """ 49 | body = { 50 | 'name': self.name, 51 | 'mode': self.mode, 52 | 'port': self.port, 53 | 'networks': self.networks, 54 | 'default_backend': self.default_backend, 55 | } 56 | 57 | if hasattr(self, 'rules'): 58 | fe_rules = [] 59 | for rule in self.rules: 60 | if isinstance(rule, LoadBalancerFrontEndRule): 61 | rule = rule.to_dict() 62 | fe_rules.append(rule) 63 | 64 | body['rules'] = fe_rules 65 | 66 | if hasattr(self, 'tls_configs'): 67 | body['tls_configs'] = self.tls_configs 68 | if hasattr(self, 'properties'): 69 | body['properties'] = self.properties 70 | 71 | return body 72 | 73 | 74 | class LoadBalancerBackendMember(UpCloudResource): 75 | """ 76 | Class representation of loadbalancer backend member 77 | """ 78 | 79 | ATTRIBUTES = { 80 | 'name': '', 81 | 'type': 'static', 82 | 'ip': None, 83 | 'port': None, 84 | 'weight': 1, 85 | 'max_sessions': 100, 86 | 'enabled': True, 87 | } 88 | 89 | def to_dict(self): 90 | """ 91 | Returns a dictionary object that adheres to UpCloud API json spec 92 | """ 93 | body = { 94 | 'name': self.name, 95 | 'type': getattr(self, 'type', 'static'), 96 | 'weight': getattr(self, 'weight', 1), 97 | 'max_sessions': getattr(self, 'max_sessions', 100), 98 | 'enabled': getattr(self, 'enabled', True), 99 | } 100 | 101 | if hasattr(self, 'ip'): 102 | body['ip'] = self.ip 103 | if hasattr(self, 'port'): 104 | body['port'] = self.port 105 | 106 | return body 107 | 108 | 109 | class LoadBalancerBackend(UpCloudResource): 110 | """ 111 | Class representation of loadbalancer backend 112 | """ 113 | 114 | ATTRIBUTES = { 115 | 'name': '', 116 | 'members': [], 117 | 'resolver': None, 118 | 'properties': None, 119 | } 120 | 121 | def to_dict(self): 122 | """ 123 | Returns a dictionary object that adheres to UpCloud API json spec 124 | """ 125 | be_members = [] 126 | for member in self.members: 127 | if isinstance(member, LoadBalancerBackendMember): 128 | member = member.to_dict() 129 | be_members.append(member) 130 | 131 | body = { 132 | 'name': self.name, 133 | 'members': be_members, 134 | } 135 | 136 | if hasattr(self, 'resolver'): 137 | body['resolver'] = self.resolver 138 | if hasattr(self, 'properties'): 139 | body['properties'] = self.properties 140 | 141 | return body 142 | 143 | 144 | class LoadBalancerNetwork(UpCloudResource): 145 | """ 146 | Class representation of networks loadbalancer is attached to 147 | """ 148 | 149 | ATTRIBUTES = { 150 | 'name': '', 151 | 'type': '', 152 | 'family': 'IPv4', 153 | 'uuid': None, 154 | } 155 | 156 | def to_dict(self): 157 | """ 158 | Returns a dictionary object that adheres to UpCloud API json spec 159 | """ 160 | body = { 161 | 'name': self.name, 162 | 'type': self.type, 163 | 'family': getattr(self, 'family', 'IPv4'), 164 | } 165 | 166 | if hasattr(self, 'uuid'): 167 | body['uuid'] = self.uuid 168 | 169 | return body 170 | 171 | 172 | class LoadBalancer(UpCloudResource): 173 | """ 174 | Class representation of UpCloud loadbalancer 175 | """ 176 | 177 | ATTRIBUTES = { 178 | 'name': '', 179 | 'zone': '', 180 | 'plan': 'development', 181 | 'configured_status': 'started', 182 | 'networks': [], 183 | 'frontends': None, 184 | 'backends': None, 185 | 'resolvers': None, 186 | 'labels': None, 187 | 'maintenance_dow': None, 188 | 'maintenance_time': None, 189 | } 190 | 191 | def to_dict(self): 192 | """ 193 | Returns a dictionary object that adheres to UpCloud API json spec 194 | """ 195 | nets = [] 196 | for net in self.networks: 197 | if isinstance(net, LoadBalancerNetwork): 198 | net = net.to_dict() 199 | nets.append(net) 200 | 201 | body = { 202 | 'name': self.name, 203 | 'zone': self.zone, 204 | 'plan': getattr(self, 'plan', 'development'), 205 | 'configured_status': getattr(self, 'configured_status', 'started'), 206 | 'networks': nets, 207 | } 208 | 209 | if hasattr(self, 'frontends'): 210 | lb_fe = [] 211 | for fe in self.frontends: 212 | if isinstance(fe, LoadBalancerFrontend): 213 | fe = fe.to_dict() 214 | lb_fe.append(fe) 215 | body['frontends'] = lb_fe 216 | 217 | if hasattr(self, 'backends'): 218 | lb_be = [] 219 | for be in self.backends: 220 | if isinstance(be, LoadBalancerBackend): 221 | be = be.to_dict() 222 | lb_be.append(be) 223 | body['backends'] = lb_be 224 | 225 | if hasattr(self, 'labels'): 226 | lb_labels = [] 227 | for label in self.labels: 228 | if isinstance(label, Label): 229 | label = label.to_dict() 230 | lb_labels.append(label) 231 | body['labels'] = lb_labels 232 | 233 | if hasattr(self, 'maintenance_dow'): 234 | body['maintenance_dow'] = self.maintenance_dow 235 | if hasattr(self, 'maintenance_time'): 236 | body['maintenance_time'] = self.maintenance_time 237 | 238 | return body 239 | -------------------------------------------------------------------------------- /test/json_data/timezone.json: -------------------------------------------------------------------------------- 1 | {"timezones": {"timezone": ["Africa/Abidjan", "Africa/Accra", "Africa/Addis_Ababa", "Africa/Algiers", "Africa/Asmara", "Africa/Bamako", "Africa/Bangui", "Africa/Banjul", "Africa/Bissau", "Africa/Blantyre", "Africa/Brazzaville", "Africa/Bujumbura", "Africa/Cairo", "Africa/Casablanca", "Africa/Ceuta", "Africa/Conakry", "Africa/Dakar", "Africa/Dar_es_Salaam", "Africa/Djibouti", "Africa/Douala", "Africa/El_Aaiun", "Africa/Freetown", "Africa/Gaborone", "Africa/Harare", "Africa/Johannesburg", "Africa/Kampala", "Africa/Khartoum", "Africa/Kigali", "Africa/Kinshasa", "Africa/Lagos", "Africa/Libreville", "Africa/Lome", "Africa/Luanda", "Africa/Lubumbashi", "Africa/Lusaka", "Africa/Malabo", "Africa/Maputo", "Africa/Maseru", "Africa/Mbabane", "Africa/Mogadishu", "Africa/Monrovia", "Africa/Nairobi", "Africa/Ndjamena", "Africa/Niamey", "Africa/Nouakchott", "Africa/Ouagadougou", "Africa/Porto-Novo", "Africa/Sao_Tome", "Africa/Tripoli", "Africa/Tunis", "Africa/Windhoek", "America/Adak", "America/Anchorage", "America/Anguilla", "America/Antigua", "America/Araguaina", "America/Argentina/Buenos_Aires", "America/Argentina/Catamarca", "America/Argentina/Cordoba", "America/Argentina/Jujuy", "America/Argentina/La_Rioja", "America/Argentina/Mendoza", "America/Argentina/Rio_Gallegos", "America/Argentina/Salta", "America/Argentina/San_Juan", "America/Argentina/San_Luis", "America/Argentina/Tucuman", "America/Argentina/Ushuaia", "America/Aruba", "America/Asuncion", "America/Atikokan", "America/Bahia", "America/Bahia_Banderas", "America/Barbados", "America/Belem", "America/Belize", "America/Blanc-Sablon", "America/Boa_Vista", "America/Bogota", "America/Boise", "America/Cambridge_Bay", "America/Campo_Grande", "America/Cancun", "America/Caracas", "America/Cayenne", "America/Cayman", "America/Chicago", "America/Chihuahua", "America/Costa_Rica", "America/Cuiaba", "America/Curacao", "America/Danmarkshavn", "America/Dawson", "America/Dawson_Creek", "America/Denver", "America/Detroit", "America/Dominica", "America/Edmonton", "America/Eirunepe", "America/El_Salvador", "America/Fortaleza", "America/Glace_Bay", "America/Godthab", "America/Goose_Bay", "America/Grand_Turk", "America/Grenada", "America/Guadeloupe", "America/Guatemala", "America/Guayaquil", "America/Guyana", "America/Halifax", "America/Havana", "America/Hermosillo", "America/Indiana/Indianapolis", "America/Indiana/Knox", "America/Indiana/Marengo", "America/Indiana/Petersburg", "America/Indiana/Tell_City", "America/Indiana/Vevay", "America/Indiana/Vincennes", "America/Indiana/Winamac", "America/Inuvik", "America/Iqaluit", "America/Jamaica", "America/Juneau", "America/Kentucky/Louisville", "America/Kentucky/Monticello", "America/La_Paz", "America/Lima", "America/Los_Angeles", "America/Maceio", "America/Managua", "America/Manaus", "America/Marigot", "America/Martinique", "America/Matamoros", "America/Mazatlan", "America/Menominee", "America/Merida", "America/Mexico_City", "America/Miquelon", "America/Moncton", "America/Monterrey", "America/Montevideo", "America/Montreal", "America/Montserrat", "America/Nassau", "America/New_York", "America/Nipigon", "America/Nome", "America/Noronha", "America/North_Dakota/Center", "America/North_Dakota/New_Salem", "America/Ojinaga", "America/Panama", "America/Pangnirtung", "America/Paramaribo", "America/Phoenix", "America/Port-au-Prince", "America/Port_of_Spain", "America/Porto_Velho", "America/Puerto_Rico", "America/Rainy_River", "America/Rankin_Inlet", "America/Recife", "America/Regina", "America/Resolute", "America/Rio_Branco", "America/Santa_Isabel", "America/Santarem", "America/Santiago", "America/Santo_Domingo", "America/Sao_Paulo", "America/Scoresbysund", "America/Shiprock", "America/St_Barthelemy", "America/St_Johns", "America/St_Kitts", "America/St_Lucia", "America/St_Thomas", "America/St_Vincent", "America/Swift_Current", "America/Tegucigalpa", "America/Thule", "America/Thunder_Bay", "America/Tijuana", "America/Toronto", "America/Tortola", "America/Vancouver", "America/Whitehorse", "America/Winnipeg", "America/Yakutat", "America/Yellowknife", "Antarctica/Casey", "Antarctica/Davis", "Antarctica/DumontDUrville", "Antarctica/Macquarie", "Antarctica/Mawson", "Antarctica/McMurdo", "Antarctica/Palmer", "Antarctica/Rothera", "Antarctica/South_Pole", "Antarctica/Syowa", "Antarctica/Vostok", "Arctic/Longyearbyen", "Asia/Aden", "Asia/Almaty", "Asia/Amman", "Asia/Anadyr", "Asia/Aqtau", "Asia/Aqtobe", "Asia/Ashgabat", "Asia/Baghdad", "Asia/Bahrain", "Asia/Baku", "Asia/Bangkok", "Asia/Beirut", "Asia/Bishkek", "Asia/Brunei", "Asia/Choibalsan", "Asia/Chongqing", "Asia/Colombo", "Asia/Damascus", "Asia/Dhaka", "Asia/Dili", "Asia/Dubai", "Asia/Dushanbe", "Asia/Gaza", "Asia/Harbin", "Asia/Ho_Chi_Minh", "Asia/Hong_Kong", "Asia/Hovd", "Asia/Irkutsk", "Asia/Jakarta", "Asia/Jayapura", "Asia/Jerusalem", "Asia/Kabul", "Asia/Kamchatka", "Asia/Karachi", "Asia/Kashgar", "Asia/Kathmandu", "Asia/Kolkata", "Asia/Krasnoyarsk", "Asia/Kuala_Lumpur", "Asia/Kuching", "Asia/Kuwait", "Asia/Macau", "Asia/Magadan", "Asia/Makassar", "Asia/Manila", "Asia/Muscat", "Asia/Nicosia", "Asia/Novokuznetsk", "Asia/Novosibirsk", "Asia/Omsk", "Asia/Oral", "Asia/Phnom_Penh", "Asia/Pontianak", "Asia/Pyongyang", "Asia/Qatar", "Asia/Qyzylorda", "Asia/Rangoon", "Asia/Riyadh", "Asia/Sakhalin", "Asia/Samarkand", "Asia/Seoul", "Asia/Shanghai", "Asia/Singapore", "Asia/Taipei", "Asia/Tashkent", "Asia/Tbilisi", "Asia/Tehran", "Asia/Thimphu", "Asia/Tokyo", "Asia/Ulaanbaatar", "Asia/Urumqi", "Asia/Vientiane", "Asia/Vladivostok", "Asia/Yakutsk", "Asia/Yekaterinburg", "Asia/Yerevan", "Atlantic/Azores", "Atlantic/Bermuda", "Atlantic/Canary", "Atlantic/Cape_Verde", "Atlantic/Faroe", "Atlantic/Madeira", "Atlantic/Reykjavik", "Atlantic/South_Georgia", "Atlantic/Stanley", "Atlantic/St_Helena", "Australia/Adelaide", "Australia/Brisbane", "Australia/Broken_Hill", "Australia/Currie", "Australia/Darwin", "Australia/Eucla", "Australia/Hobart", "Australia/Lindeman", "Australia/Lord_Howe", "Australia/Melbourne", "Australia/Perth", "Australia/Sydney", "Europe/Amsterdam", "Europe/Andorra", "Europe/Athens", "Europe/Belgrade", "Europe/Berlin", "Europe/Bratislava", "Europe/Brussels", "Europe/Bucharest", "Europe/Budapest", "Europe/Chisinau", "Europe/Copenhagen", "Europe/Dublin", "Europe/Gibraltar", "Europe/Guernsey", "Europe/Helsinki", "Europe/Isle_of_Man", "Europe/Istanbul", "Europe/Jersey", "Europe/Kaliningrad", "Europe/Kiev", "Europe/Lisbon", "Europe/Ljubljana", "Europe/London", "Europe/Luxembourg", "Europe/Madrid", "Europe/Malta", "Europe/Mariehamn", "Europe/Minsk", "Europe/Monaco", "Europe/Moscow", "Europe/Oslo", "Europe/Paris", "Europe/Podgorica", "Europe/Prague", "Europe/Riga", "Europe/Rome", "Europe/Samara", "Europe/San_Marino", "Europe/Sarajevo", "Europe/Simferopol", "Europe/Skopje", "Europe/Sofia", "Europe/Stockholm", "Europe/Tallinn", "Europe/Tirane", "Europe/Uzhgorod", "Europe/Vaduz", "Europe/Vatican", "Europe/Vienna", "Europe/Vilnius", "Europe/Volgograd", "Europe/Warsaw", "Europe/Zagreb", "Europe/Zaporozhye", "Europe/Zurich", "Indian/Antananarivo", "Indian/Chagos", "Indian/Christmas", "Indian/Cocos", "Indian/Comoro", "Indian/Kerguelen", "Indian/Mahe", "Indian/Maldives", "Indian/Mauritius", "Indian/Mayotte", "Indian/Reunion", "Pacific/Apia", "Pacific/Auckland", "Pacific/Chatham", "Pacific/Easter", "Pacific/Efate", "Pacific/Enderbury", "Pacific/Fakaofo", "Pacific/Fiji", "Pacific/Funafuti", "Pacific/Galapagos", "Pacific/Gambier", "Pacific/Guadalcanal", "Pacific/Guam", "Pacific/Honolulu", "Pacific/Johnston", "Pacific/Kiritimati", "Pacific/Kosrae", "Pacific/Kwajalein", "Pacific/Majuro", "Pacific/Marquesas", "Pacific/Midway", "Pacific/Nauru", "Pacific/Niue", "Pacific/Norfolk", "Pacific/Noumea", "Pacific/Pago_Pago", "Pacific/Palau", "Pacific/Pitcairn", "Pacific/Ponape", "Pacific/Port_Moresby", "Pacific/Rarotonga", "Pacific/Saipan", "Pacific/Tahiti", "Pacific/Tarawa", "Pacific/Tongatapu", "Pacific/Truk", "Pacific/Wake", "Pacific/Wallis", "UTC"]}} -------------------------------------------------------------------------------- /upcloud_api/cloud_manager/server_mixin.py: -------------------------------------------------------------------------------- 1 | from upcloud_api.api import API 2 | from upcloud_api.ip_address import IPAddress 3 | from upcloud_api.server import Server 4 | from upcloud_api.server_group import ServerGroup 5 | from upcloud_api.storage import BackupDeletionPolicy, Storage 6 | 7 | 8 | class ServerManager: 9 | """ 10 | Functions for managing servers. Intended to be used as a mixin for CloudManager. 11 | """ 12 | 13 | api: API 14 | 15 | def get_servers(self, populate=False, tags_has_one=None, tags_has_all=None): 16 | """ 17 | Return a list of (populated or unpopulated) Server instances. 18 | 19 | - populate = False (default) => 1 API request, returns unpopulated Server instances. 20 | - populate = True => Does 1 + n API requests (n = # of servers), 21 | returns populated Server instances. 22 | 23 | New in 0.3.0: the list can be filtered with tags: 24 | - tags_has_one: list of Tag objects or strings 25 | returns servers that have at least one of the given tags 26 | 27 | - tags_has_all: list of Tag objects or strings 28 | returns servers that have all of the tags 29 | """ 30 | if tags_has_all and tags_has_one: 31 | raise Exception('only one of (tags_has_all, tags_has_one) is allowed.') 32 | 33 | request = '/server' 34 | if tags_has_all: 35 | tags_has_all = [str(tag) for tag in tags_has_all] 36 | tag_list = ':'.join(tags_has_all) 37 | request = f'/server/tag/{tag_list}' 38 | 39 | if tags_has_one: 40 | tags_has_one = [str(tag) for tag in tags_has_one] 41 | tag_list = ','.join(tags_has_one) 42 | request = f'/server/tag/{tag_list}' 43 | 44 | servers = self.api.get_request(request)['servers']['server'] 45 | 46 | server_list = list() 47 | for server in servers: 48 | server_list.append(Server(server, cloud_manager=self)) 49 | 50 | if populate: 51 | for server_instance in server_list: 52 | server_instance.populate() 53 | 54 | return server_list 55 | 56 | def get_server(self, uuid: str) -> Server: 57 | """ 58 | Return a (populated) Server instance. 59 | """ 60 | server, ip_addresses, storages = self.get_server_data(uuid) 61 | 62 | return Server( 63 | server, 64 | ip_addresses=ip_addresses, 65 | storage_devices=storages, 66 | populated=True, 67 | cloud_manager=self, 68 | ) 69 | 70 | def get_server_by_ip(self, ip_address: str): 71 | """ 72 | Return a (populated) Server instance by its IP. 73 | 74 | Uses GET '/ip_address/x.x.x.x' to retrieve machine UUID using IP-address. 75 | """ 76 | data = self.api.get_request(f'/ip_address/{ip_address}') 77 | UUID = data['ip_address']['server'] 78 | return self.get_server(UUID) 79 | 80 | def create_server(self, server: Server) -> Server: 81 | """ 82 | Create a server and its storages based on a (locally created) Server object. 83 | 84 | Populates the given Server instance with the API response. 85 | 86 | 0.3.0: also supports giving the entire POST body as a dict that is directly 87 | serialised into JSON. Refer to the REST API documentation for correct format. 88 | 89 | Example: 90 | ------- 91 | server1 = Server( core_number = 1, 92 | memory_amount = 1024, 93 | hostname = "my.example.1", 94 | zone = "uk-lon1", 95 | labels = [Label('role', 'example')], 96 | storage_devices = [ 97 | Storage(os = "01000000-0000-4000-8000-000030240200", size=10, tier=maxiops, title='Example OS disk'), 98 | Storage(size=10, labels=[Label('usage', 'data_disk')]), 99 | Storage() 100 | title = "My Example Server" 101 | ]) 102 | manager.create_server(server1) 103 | 104 | One storage should contain an OS. Otherwise storage fields are optional. 105 | - size defaults to 10, 106 | - title defaults to hostname + " OS disk" and hostname + " storage disk id" 107 | (id is a running starting from 1) 108 | - tier defaults to maxiops 109 | - valid operating systems are for example: 110 | * Debian GNU/Linux 12 (Bookworm): 01000000-0000-4000-8000-000020070100 111 | * Ubuntu Server 24.04 LTS (Noble Numbat): 01000000-0000-4000-8000-000030240200 112 | * Rocky Linux 9: 01000000-0000-4000-8000-000150020100 113 | * Windows Server 2022 Standard: 01000000-0000-4000-8000-000010080300 114 | (for a more up-to-date listing, use UpCloud's CLI: `upctl storage list --public --template`.) 115 | 116 | """ 117 | if isinstance(server, Server): 118 | body = server.prepare_post_body() 119 | else: 120 | server = Server._create_server_obj(server, cloud_manager=self) 121 | body = server.prepare_post_body() 122 | 123 | res = self.api.post_request('/server', body) 124 | 125 | server_to_return = server 126 | server_to_return._reset(res['server'], cloud_manager=self, populated=True) 127 | return server_to_return 128 | 129 | def modify_server(self, uuid: str, **kwargs) -> Server: 130 | """ 131 | modify_server allows updating the server's updateable_fields. 132 | 133 | Note: Server's IP-addresses and Storages are managed by their own add/remove methods. 134 | """ 135 | body = dict() 136 | body['server'] = {} 137 | for arg in kwargs: 138 | if arg not in Server.updateable_fields: 139 | Exception(f'{arg} is not an updateable field') 140 | body['server'][arg] = kwargs[arg] 141 | 142 | res = self.api.put_request(f'/server/{uuid}', body) 143 | server = res['server'] 144 | 145 | # Populate subobjects 146 | IPAddresses = IPAddress._create_ip_address_objs( 147 | server.pop('ip_addresses'), cloud_manager=self 148 | ) 149 | 150 | storages = Storage._create_storage_objs(server.pop('storage_devices'), cloud_manager=self) 151 | 152 | return Server( 153 | server, 154 | ip_addresses=IPAddresses, 155 | storage_devices=storages, 156 | populated=True, 157 | cloud_manager=self, 158 | ) 159 | 160 | def delete_server( 161 | self, 162 | uuid: str, 163 | delete_storages: bool = False, 164 | backups: BackupDeletionPolicy = BackupDeletionPolicy.KEEP, 165 | ): 166 | """ 167 | DELETE '/server/UUID'. Permanently destroys the virtual machine. 168 | 169 | Does remove storage disks if delete_storages is defined as True. 170 | 171 | Does remove backups of the attached storages if 172 | 173 | Returns an empty object. 174 | """ 175 | storages = '1' if delete_storages else '0' 176 | 177 | return self.api.delete_request( 178 | f'/server/{uuid}?storages={storages}&backups={backups.value}' 179 | ) 180 | 181 | def get_server_data(self, uuid: str): 182 | """ 183 | Return '/server/uuid' data in Python dict. 184 | 185 | Creates object representations of any IP-address and Storage. 186 | """ 187 | data = self.api.get_request(f'/server/{uuid}') 188 | server = data['server'] 189 | 190 | # Populate subobjects 191 | IPAddresses = IPAddress._create_ip_address_objs( 192 | server.pop('ip_addresses'), cloud_manager=self 193 | ) 194 | 195 | storages = Storage._create_storage_objs(server.pop('storage_devices'), cloud_manager=self) 196 | 197 | return server, IPAddresses, storages 198 | 199 | def create_server_group(self, server_group: ServerGroup) -> ServerGroup: 200 | """ 201 | Creates a new server group. Allows including servers and defining labels. 202 | """ 203 | body = server_group.to_dict() 204 | 205 | res = self.api.post_request('/server-group', body) 206 | return ServerGroup(cloud_manager=self, **res['server_group']) 207 | 208 | def get_server_group(self, uuid: str) -> ServerGroup: 209 | """ 210 | Fetches server group details and returns a ServerGroup object. 211 | """ 212 | data = self.api.get_request(f'/server-group/{uuid}') 213 | return ServerGroup(cloud_manager=self, **data['server_group']) 214 | 215 | def delete_server_group(self, uuid: str): 216 | """ 217 | DELETE '/server-group/UUID'. Destroys the server group, but not attached servers. 218 | 219 | Returns an empty object. 220 | """ 221 | return self.api.delete_request(f'/server-group/{uuid}') 222 | --------------------------------------------------------------------------------