Skip to content

Commit fe4f9d7

Browse files
author
Vibe Kanban
committed
Add OVH VPS compute driver support via ex_ extension methods
OVH VPS is a separate product line from Public Cloud that uses the proprietary /vps/ API. This adds 7 extension methods to OvhNodeDriver: ex_list_vps, ex_get_vps, ex_reboot_vps, ex_start_vps, ex_stop_vps, ex_rebuild_vps, and ex_list_vps_images. Includes unit tests with mock fixtures and updated documentation.
1 parent 3fb37a2 commit fe4f9d7

10 files changed

Lines changed: 312 additions & 1 deletion

File tree

docs/compute/drivers/ovh.rst

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,51 @@ Create and attach a volume to a node
7070

7171
.. literalinclude:: /examples/compute/ovh/attach_volume.py
7272

73+
VPS (Virtual Private Server) support
74+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
75+
76+
The OVH driver also supports managing OVH VPS instances, which are a separate
77+
product line from the Public Cloud. VPS methods use the ``/vps/`` API and are
78+
available as ``ex_``-prefixed extension methods.
79+
80+
Note: VPS operations do not require ``ex_project_id``, but the driver
81+
constructor still requires it for Public Cloud operations. You can pass any
82+
value if you only need VPS functionality.
83+
84+
.. code-block:: python
85+
86+
from libcloud.compute.types import Provider
87+
from libcloud.compute.providers import get_driver
88+
89+
Ovh = get_driver(Provider.OVH)
90+
driver = Ovh(
91+
"your_app_key",
92+
"your_app_secret",
93+
"project_id",
94+
"your_consumer_key",
95+
)
96+
97+
# List all VPS
98+
vps_names = driver.ex_list_vps()
99+
for name in vps_names:
100+
node = driver.ex_get_vps(name)
101+
print(f"{node.name}: {node.state} - IPs: {node.public_ips}")
102+
103+
# Reboot a VPS
104+
driver.ex_reboot_vps("vps-12345678.vps.ovh.net")
105+
106+
# List available OS images for a VPS
107+
images = driver.ex_list_vps_images("vps-12345678.vps.ovh.net")
108+
for image in images:
109+
print(f"{image.id}: {image.name}")
110+
111+
# Rebuild a VPS with a new OS
112+
driver.ex_rebuild_vps(
113+
"vps-12345678.vps.ovh.net",
114+
"img-debian-12",
115+
ssh_key=["ssh-rsa AAAA..."],
116+
)
117+
73118
API Docs
74119
--------
75120

libcloud/compute/drivers/ovh.py

Lines changed: 173 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
StorageVolume,
2727
VolumeSnapshot,
2828
)
29-
from libcloud.compute.types import Provider, StorageVolumeState, VolumeSnapshotState
29+
from libcloud.compute.types import NodeState, Provider, StorageVolumeState, VolumeSnapshotState
3030
from libcloud.compute.drivers.openstack import OpenStackKeyPair, OpenStackNodeDriver
3131

3232

@@ -50,6 +50,16 @@ class OvhNodeDriver(NodeDriver):
5050
VOLUME_STATE_MAP = OpenStackNodeDriver.VOLUME_STATE_MAP
5151
SNAPSHOT_STATE_MAP = OpenStackNodeDriver.SNAPSHOT_STATE_MAP
5252

53+
VPS_STATE_MAP = {
54+
"running": NodeState.RUNNING,
55+
"stopped": NodeState.STOPPED,
56+
"installing": NodeState.PENDING,
57+
"rebooting": NodeState.REBOOTING,
58+
"stopping": NodeState.STOPPING,
59+
"rescued": NodeState.PENDING,
60+
"maintenance": NodeState.PENDING,
61+
}
62+
5363
def __init__(self, key, secret, ex_project_id, ex_consumer_key=None, region=None):
5464
"""
5565
Instantiate the driver with the given API credentials.
@@ -629,5 +639,167 @@ def _to_snapshot(self, obj):
629639
def _to_snapshots(self, objs):
630640
return [self._to_snapshot(obj) for obj in objs]
631641

642+
# ------------------------------------------------------------------
643+
# VPS (Virtual Private Server) extension methods
644+
# ------------------------------------------------------------------
645+
# OVH VPS is a separate product line from Public Cloud instances.
646+
# It uses the /vps/ API instead of /cloud/project/.
647+
# ------------------------------------------------------------------
648+
649+
def ex_list_vps(self):
650+
"""
651+
List all VPS on the account.
652+
653+
:return: List of VPS names
654+
:rtype: ``list`` of ``str``
655+
"""
656+
action = "{}/vps".format(API_ROOT)
657+
response = self.connection.request(action)
658+
659+
return response.object
660+
661+
def ex_get_vps(self, name):
662+
"""
663+
Get VPS details as a :class:`Node`.
664+
665+
:param name: VPS name (e.g. ``vps-12345678.vps.ovh.net``)
666+
:type name: ``str``
667+
668+
:return: Node representing the VPS
669+
:rtype: :class:`Node`
670+
"""
671+
action = "{}/vps/{}".format(API_ROOT, name)
672+
response = self.connection.request(action)
673+
674+
return self._to_vps_node(response.object)
675+
676+
def ex_reboot_vps(self, name):
677+
"""
678+
Reboot a VPS.
679+
680+
:param name: VPS name
681+
:type name: ``str``
682+
683+
:return: True on success
684+
:rtype: ``bool``
685+
"""
686+
action = "{}/vps/{}/reboot".format(API_ROOT, name)
687+
self.connection.request(action, method="POST")
688+
689+
return True
690+
691+
def ex_start_vps(self, name):
692+
"""
693+
Start a VPS.
694+
695+
:param name: VPS name
696+
:type name: ``str``
697+
698+
:return: True on success
699+
:rtype: ``bool``
700+
"""
701+
action = "{}/vps/{}/start".format(API_ROOT, name)
702+
self.connection.request(action, method="POST")
703+
704+
return True
705+
706+
def ex_stop_vps(self, name):
707+
"""
708+
Stop a VPS.
709+
710+
:param name: VPS name
711+
:type name: ``str``
712+
713+
:return: True on success
714+
:rtype: ``bool``
715+
"""
716+
action = "{}/vps/{}/stop".format(API_ROOT, name)
717+
self.connection.request(action, method="POST")
718+
719+
return True
720+
721+
def ex_rebuild_vps(self, name, image_id, ssh_key=None):
722+
"""
723+
Reinstall a VPS with a new OS image.
724+
725+
:param name: VPS name
726+
:type name: ``str``
727+
728+
:param image_id: Image ID to install
729+
:type image_id: ``str``
730+
731+
:param ssh_key: SSH public key(s) to install (optional)
732+
:type ssh_key: ``list`` of ``str``
733+
734+
:return: True on success
735+
:rtype: ``bool``
736+
"""
737+
action = "{}/vps/{}/rebuild".format(API_ROOT, name)
738+
data = {"imageId": image_id}
739+
740+
if ssh_key:
741+
data["sshKey"] = ssh_key
742+
self.connection.request(action, data=data, method="POST")
743+
744+
return True
745+
746+
def ex_list_vps_images(self, name):
747+
"""
748+
List available OS images for a VPS.
749+
750+
:param name: VPS name
751+
:type name: ``str``
752+
753+
:return: List of available images
754+
:rtype: ``list`` of :class:`NodeImage`
755+
"""
756+
action = "{}/vps/{}/images/available".format(API_ROOT, name)
757+
response = self.connection.request(action)
758+
759+
return self._to_vps_images(response.object)
760+
761+
def _to_vps_node(self, obj):
762+
state = self.VPS_STATE_MAP.get(obj.get("state", ""), NodeState.UNKNOWN)
763+
764+
public_ips = []
765+
if obj.get("ips"):
766+
public_ips = obj["ips"]
767+
elif obj.get("ip"):
768+
public_ips = [obj["ip"]]
769+
770+
extra = {
771+
"model": obj.get("model"),
772+
"netbootMode": obj.get("netbootMode"),
773+
"offerType": obj.get("offerType"),
774+
"vcore": obj.get("vcore"),
775+
"memory": obj.get("memory"),
776+
"disk": obj.get("disk"),
777+
"zone": obj.get("zone"),
778+
}
779+
780+
return Node(
781+
id=obj.get("name", ""),
782+
name=obj.get("displayName") or obj.get("name", ""),
783+
state=state,
784+
public_ips=public_ips,
785+
private_ips=[],
786+
driver=self,
787+
extra=extra,
788+
)
789+
790+
def _to_vps_images(self, objs):
791+
images = []
792+
for obj in objs:
793+
images.append(
794+
NodeImage(
795+
id=obj.get("id", ""),
796+
name=obj.get("name", ""),
797+
driver=self,
798+
extra={"lastModifiedDate": obj.get("lastModifiedDate")},
799+
)
800+
)
801+
802+
return images
803+
632804
def _ex_connection_class_kwargs(self):
633805
return {"ex_consumer_key": self.consumer_key, "region": self.region}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
["testvps", "testvps2"]
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"name": "testvps", "displayName": "my-vps", "state": "running", "model": {"name": "VPS Value 1-2-40", "offer": "value", "version": "2019v1", "vcore": 1, "memory": 2048, "disk": 40, "maximumAdditionalIp": 16}, "netbootMode": "local", "offerType": "ssd", "vcore": 1, "memory": 2048, "disk": 40, "zone": "eu-west-rbx", "ips": ["198.51.100.42", "2001:db8::1"]}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[{"id": "img-debian-12", "name": "Debian 12", "lastModifiedDate": "2025-01-15T10:00:00Z"}, {"id": "img-ubuntu-2404", "name": "Ubuntu 24.04", "lastModifiedDate": "2025-02-01T12:00:00Z"}, {"id": "img-centos-stream-9", "name": "CentOS Stream 9", "lastModifiedDate": "2024-11-20T08:00:00Z"}]
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"taskId": 12345, "type": "rebootVm", "state": "todo"}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"taskId": 12348, "type": "reinstallVm", "state": "todo"}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"taskId": 12346, "type": "startVm", "state": "todo"}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"taskId": 12347, "type": "stopVm", "state": "todo"}

libcloud/test/compute/test_ovh.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from libcloud.test.secrets import OVH_PARAMS
2323
from libcloud.common.exceptions import BaseHTTPError
2424
from libcloud.test.file_fixtures import ComputeFileFixtures
25+
from libcloud.compute.types import NodeState
2526
from libcloud.compute.drivers.ovh import OvhNodeDriver
2627
from libcloud.test.common.test_ovh import BaseOvhMockHttp
2728

@@ -155,6 +156,36 @@ def _json_1_0_cloud_project_project_id_instance_get_invalid_app_key_error(
155156
body = '{"message":"Invalid application key"}'
156157
return (httplib.UNAUTHORIZED, body, {}, httplib.responses[httplib.OK])
157158

159+
# VPS mock handlers
160+
161+
def _json_1_0_vps_get(self, method, url, body, headers):
162+
body = self.fixtures.load("vps_get.json")
163+
return (httplib.OK, body, {}, httplib.responses[httplib.OK])
164+
165+
def _json_1_0_vps_testvps_get(self, method, url, body, headers):
166+
body = self.fixtures.load("vps_get_detail.json")
167+
return (httplib.OK, body, {}, httplib.responses[httplib.OK])
168+
169+
def _json_1_0_vps_testvps_reboot_post(self, method, url, body, headers):
170+
body = self.fixtures.load("vps_reboot_post.json")
171+
return (httplib.OK, body, {}, httplib.responses[httplib.OK])
172+
173+
def _json_1_0_vps_testvps_start_post(self, method, url, body, headers):
174+
body = self.fixtures.load("vps_start_post.json")
175+
return (httplib.OK, body, {}, httplib.responses[httplib.OK])
176+
177+
def _json_1_0_vps_testvps_stop_post(self, method, url, body, headers):
178+
body = self.fixtures.load("vps_stop_post.json")
179+
return (httplib.OK, body, {}, httplib.responses[httplib.OK])
180+
181+
def _json_1_0_vps_testvps_rebuild_post(self, method, url, body, headers):
182+
body = self.fixtures.load("vps_rebuild_post.json")
183+
return (httplib.OK, body, {}, httplib.responses[httplib.OK])
184+
185+
def _json_1_0_vps_testvps_images_available_get(self, method, url, body, headers):
186+
body = self.fixtures.load("vps_images_available_get.json")
187+
return (httplib.OK, body, {}, httplib.responses[httplib.OK])
188+
158189

159190
@patch("libcloud.common.ovh.OvhConnection._timedelta", 42)
160191
class OvhTests(unittest.TestCase):
@@ -305,6 +336,62 @@ def test_destroy_volume_snapshot(self):
305336
def test_get_pricing(self):
306337
self.driver.ex_get_pricing("foo-id")
307338

339+
# VPS tests
340+
341+
def test_ex_list_vps(self):
342+
names = self.driver.ex_list_vps()
343+
self.assertEqual(len(names), 2)
344+
self.assertEqual(names[0], "testvps")
345+
self.assertEqual(names[1], "testvps2")
346+
347+
def test_ex_get_vps(self):
348+
node = self.driver.ex_get_vps("testvps")
349+
self.assertEqual(node.id, "testvps")
350+
self.assertEqual(node.name, "my-vps")
351+
self.assertEqual(node.state, NodeState.RUNNING)
352+
self.assertEqual(len(node.public_ips), 2)
353+
self.assertIn("198.51.100.42", node.public_ips)
354+
self.assertEqual(node.extra["vcore"], 1)
355+
self.assertEqual(node.extra["memory"], 2048)
356+
self.assertEqual(node.extra["disk"], 40)
357+
self.assertEqual(node.extra["zone"], "eu-west-rbx")
358+
359+
def test_ex_reboot_vps(self):
360+
result = self.driver.ex_reboot_vps("testvps")
361+
self.assertTrue(result)
362+
363+
def test_ex_start_vps(self):
364+
result = self.driver.ex_start_vps("testvps")
365+
self.assertTrue(result)
366+
367+
def test_ex_stop_vps(self):
368+
result = self.driver.ex_stop_vps("testvps")
369+
self.assertTrue(result)
370+
371+
def test_ex_rebuild_vps(self):
372+
result = self.driver.ex_rebuild_vps("testvps", "img-debian-12")
373+
self.assertTrue(result)
374+
375+
def test_ex_rebuild_vps_with_ssh_key(self):
376+
result = self.driver.ex_rebuild_vps(
377+
"testvps", "img-debian-12", ssh_key=["ssh-rsa AAAA..."]
378+
)
379+
self.assertTrue(result)
380+
381+
def test_ex_list_vps_images(self):
382+
images = self.driver.ex_list_vps_images("testvps")
383+
self.assertEqual(len(images), 3)
384+
self.assertEqual(images[0].id, "img-debian-12")
385+
self.assertEqual(images[0].name, "Debian 12")
386+
self.assertEqual(images[1].id, "img-ubuntu-2404")
387+
self.assertEqual(images[1].name, "Ubuntu 24.04")
388+
389+
def test_vps_state_map(self):
390+
self.assertEqual(self.driver.VPS_STATE_MAP["running"], NodeState.RUNNING)
391+
self.assertEqual(self.driver.VPS_STATE_MAP["stopped"], NodeState.STOPPED)
392+
self.assertEqual(self.driver.VPS_STATE_MAP["rebooting"], NodeState.REBOOTING)
393+
self.assertEqual(self.driver.VPS_STATE_MAP["installing"], NodeState.PENDING)
394+
308395

309396
if __name__ == "__main__":
310397
sys.exit(unittest.main())

0 commit comments

Comments
 (0)