diff --git a/packages/jumpstarter-driver-corellium/README.md b/packages/jumpstarter-driver-corellium/README.md new file mode 100644 index 000000000..ca5e0059f --- /dev/null +++ b/packages/jumpstarter-driver-corellium/README.md @@ -0,0 +1,53 @@ +# Corellium Jumpstarter Driver + +A Jumpstarter driver that manages virtual devices in [Corellium](https://corellium.com). + +It implements the following interfaces: + +* PowerInterface + +## Usage + +Check the [examples folder](./examples) for example files. + +### Config Exporter + +You can run an exporter by running: `jmp exporter shell -c $file`: + +```yml +apiVersion: jumpstarter.dev/v1alpha1 +kind: ExporterConfig +# endpoint and token are intentionally left empty +metadata: + namespace: default + name: corellium-demo +endpoint: "" +token: "" +export: + rd1ae: + type: jumpstarter_driver_corellium.driver.Corellium + config: + project_id: "778f00af-5e9b-40e6-8e7f-c4f14b632e9c" + device_name: "jmp-rd1ae" + device_flavor: "kronos" +``` + +```yml +apiVersion: jumpstarter.dev/v1alpha1 +kind: ExporterConfig +# endpoint and token are intentionally left empty +metadata: + namespace: default + name: corellium-demo +endpoint: "" +token: "" +export: + rd1ae: + type: jumpstarter_driver_corellium.driver.Corellium + config: + project_id: "778f00af-5e9b-40e6-8e7f-c4f14b632e9c" + device_name: "jmp-rd1ae" + device_flavor: "kronos" + device_os: "1.0" + device_build: "Critical Application Monitor (Baremetal)" +``` diff --git a/packages/jumpstarter-driver-corellium/examples/exporter.yml b/packages/jumpstarter-driver-corellium/examples/exporter.yml new file mode 100644 index 000000000..e87370263 --- /dev/null +++ b/packages/jumpstarter-driver-corellium/examples/exporter.yml @@ -0,0 +1,18 @@ +apiVersion: jumpstarter.dev/v1alpha1 +kind: ExporterConfig +# endpoint and token are intentionally left empty +metadata: + namespace: default + name: corellium-demo +endpoint: "" +token: "" +export: + rd1ae: + type: jumpstarter_driver_corellium.driver.Corellium + config: + project_id: "778f00af-5e9b-40e6-8e7f-c4f14b632e9c" + device_name: "jmp-rd1ae" + device_flavor: "kronos" + # optional + device_os: "1.1.1" + device_build: "Critical Application Monitor (Baremetal)" diff --git a/packages/jumpstarter-driver-corellium/fixtures/http/403.json b/packages/jumpstarter-driver-corellium/fixtures/http/403.json new file mode 100644 index 000000000..1c10fcd5b --- /dev/null +++ b/packages/jumpstarter-driver-corellium/fixtures/http/403.json @@ -0,0 +1,5 @@ +{ + "error": "Invalid or missing authorization token", + "errorID": "PermissionDenied", + "originalError": "Invalid or missing authorization token" +} diff --git a/packages/jumpstarter-driver-corellium/fixtures/http/create-instance-200.json b/packages/jumpstarter-driver-corellium/fixtures/http/create-instance-200.json new file mode 100644 index 000000000..468f3ee4c --- /dev/null +++ b/packages/jumpstarter-driver-corellium/fixtures/http/create-instance-200.json @@ -0,0 +1,3 @@ +{ + "id": "7f4f241c-821f-4219-905f-c3b50b0db5dd" +} diff --git a/packages/jumpstarter-driver-corellium/fixtures/http/create-instance-400.json b/packages/jumpstarter-driver-corellium/fixtures/http/create-instance-400.json new file mode 100644 index 000000000..18cc494d1 --- /dev/null +++ b/packages/jumpstarter-driver-corellium/fixtures/http/create-instance-400.json @@ -0,0 +1,5 @@ +{ + "error": "Unsupported device model", + "errorID": "UserError", + "field": "flavor" +} diff --git a/packages/jumpstarter-driver-corellium/fixtures/http/delete-instance-404.json b/packages/jumpstarter-driver-corellium/fixtures/http/delete-instance-404.json new file mode 100644 index 000000000..c47b47da2 --- /dev/null +++ b/packages/jumpstarter-driver-corellium/fixtures/http/delete-instance-404.json @@ -0,0 +1,8 @@ +{ + "error": "Instance with instanceId=7f4f241c-821f-4219-905f-c3b50b0db5dd not found", + "errorID": "InstanceNotFound", + "name": "Instance", + "params": { + "instanceId": "7f4f241c-821f-4219-905f-c3b50b0db5dd" + } +} diff --git a/packages/jumpstarter-driver-corellium/fixtures/http/get-instance-state-200.txt b/packages/jumpstarter-driver-corellium/fixtures/http/get-instance-state-200.txt new file mode 100644 index 000000000..b3d86404a --- /dev/null +++ b/packages/jumpstarter-driver-corellium/fixtures/http/get-instance-state-200.txt @@ -0,0 +1 @@ +on diff --git a/packages/jumpstarter-driver-corellium/fixtures/http/get-instance-state-404.json b/packages/jumpstarter-driver-corellium/fixtures/http/get-instance-state-404.json new file mode 100644 index 000000000..a61158b72 --- /dev/null +++ b/packages/jumpstarter-driver-corellium/fixtures/http/get-instance-state-404.json @@ -0,0 +1,5 @@ +{ + "error": "No instance associated with this value", + "errorID": "UserError", + "field": "InstanceId" +} diff --git a/packages/jumpstarter-driver-corellium/fixtures/http/get-models-200.json b/packages/jumpstarter-driver-corellium/fixtures/http/get-models-200.json new file mode 100644 index 000000000..963966328 --- /dev/null +++ b/packages/jumpstarter-driver-corellium/fixtures/http/get-models-200.json @@ -0,0 +1,14 @@ +[ + { + "type": "iot", + "name": "rpi4b", + "flavor": "rpi4b", + "description": "Raspberry Pi 4", + "model": "rpi4b", + "peripherals": false, + "quotas": { + "cores": 4, + "cpus": 4 + } + } +] diff --git a/packages/jumpstarter-driver-corellium/fixtures/http/get-projects-200.json b/packages/jumpstarter-driver-corellium/fixtures/http/get-projects-200.json new file mode 100644 index 000000000..73164335f --- /dev/null +++ b/packages/jumpstarter-driver-corellium/fixtures/http/get-projects-200.json @@ -0,0 +1,40 @@ +[ + { + "id": "d59db33d-27bd-4b22-878d-49e4758a648e", + "name": "Default Project", + "settings": { + "internet-access": true, + "dhcp": true + }, + "quotas": { + "cores": 30, + "instances": 75, + "ram": 184320 + }, + "quotasUsed": { + "cores": 2, + "instances": 7, + "ram": 2048, + "gpu": 0 + } + }, + { + "id": "e2fdb33c-37ae-4b22-878d-49e4758a51f0", + "name": "OtherProject", + "settings": { + "internet-access": true, + "dhcp": true + }, + "quotas": { + "cores": 30, + "instances": 75, + "ram": 184320 + }, + "quotasUsed": { + "cores": 2, + "instances": 7, + "ram": 2048, + "gpu": 0 + } + } +] diff --git a/packages/jumpstarter-driver-corellium/fixtures/http/get-projects-404.json b/packages/jumpstarter-driver-corellium/fixtures/http/get-projects-404.json new file mode 100644 index 000000000..ed4a8ad6b --- /dev/null +++ b/packages/jumpstarter-driver-corellium/fixtures/http/get-projects-404.json @@ -0,0 +1,5 @@ +{ + "error": "Found no matching Projects", + "errorID": "UserError", + "field": "Project" +} diff --git a/packages/jumpstarter-driver-corellium/fixtures/http/json-error.json b/packages/jumpstarter-driver-corellium/fixtures/http/json-error.json new file mode 100644 index 000000000..9b64a677d --- /dev/null +++ b/packages/jumpstarter-driver-corellium/fixtures/http/json-error.json @@ -0,0 +1 @@ +{"token": "a} diff --git a/packages/jumpstarter-driver-corellium/fixtures/http/login-200.json b/packages/jumpstarter-driver-corellium/fixtures/http/login-200.json new file mode 100644 index 000000000..09522bcf3 --- /dev/null +++ b/packages/jumpstarter-driver-corellium/fixtures/http/login-200.json @@ -0,0 +1,4 @@ +{ + "token": "session-token", + "expiration": "2022-03-20T01:50:10.000Z" +} diff --git a/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/__init__.py b/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/client.py b/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/client.py new file mode 100644 index 000000000..604ef5f35 --- /dev/null +++ b/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/client.py @@ -0,0 +1,5 @@ +from jumpstarter_driver_composite.client import CompositeClient + + +class CorelliumClient(CompositeClient): + pass diff --git a/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/corellium/__init__.py b/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/corellium/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/corellium/api.py b/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/corellium/api.py new file mode 100644 index 000000000..ec21fc99a --- /dev/null +++ b/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/corellium/api.py @@ -0,0 +1,173 @@ +from typing import Optional + +import requests + +from .exceptions import CorelliumApiException +from .types import Device, Instance, Project, Session + + +class ApiClient: + """ + Corellium ReST API client used by the Corellium driver. + """ + session: Session + req: requests.Session + + def __init__(self, host: str, token: str) -> None: + """ + Initializes a new client, containing a + """ + self.host = host + self.token = token + self.session = None + self.req = requests.Session() + + @property + def baseurl(self) -> str: + """ + Return the baseurl path for API calls. + """ + return f'https://{self.host}/api' + + def login(self) -> None: + """ + Login against Corellium's ReST API. + + Set an internal Session object instance to be used + in other API calls that require authentication. + + It uses the global requests objects so a new session can be generated. + """ + data = { + 'apiToken': self.token + } + + try: + res = requests.post(f'{self.baseurl}/v1/auth/login', json=data) + data = res.json() + res.raise_for_status() + except (requests.exceptions.RequestException, requests.exceptions.HTTPError) as e: + raise CorelliumApiException(data.get('error', str(e))) from e + + self.session = Session(**data) + self.req.headers.update(self.session.as_header()) + + def get_project(self, project_ref: str = 'Default Project') -> Optional[Project]: + """ + Retrieve a project based on project_ref, which is either its id or name. + """ + try: + res = self.req.get(f'{self.baseurl}/v1/projects') + data = res.json() + res.raise_for_status() + except requests.exceptions.RequestException as e: + raise CorelliumApiException(data.get('error', str(e))) from e + + for project in data: + if project['name'] == project_ref or project['id'] == project_ref: + return Project(id=project['id'], name=project['name']) + + return None + + def get_device(self, model: str) -> Optional[Device]: + """ + Get a device spec from Corellium's list based on the model name. + + A device object is used to create a new virtual instance. + """ + try: + res = self.req.get(f'{self.baseurl}/v1/models') + data = res.json() + res.raise_for_status() + except requests.exceptions.RequestException as e: + raise CorelliumApiException(data.get('error', str(e))) from e + + for device in data: + if device['model'] == model: + return Device(**device) + + return None + + def create_instance(self, name: str, project: Project, device: Device, os_version: str, os_build: str) -> Instance: + """ + Create a new virtual instance from a device spec. + """ + data = { + 'name': name, + 'project': project.id, + 'flavor': device.flavor, + 'os': os_version, + 'osbuild': os_build, + } + + try: + res = self.req.post(f'{self.baseurl}/v1/instances', json=data) + data = res.json() + res.raise_for_status() + except requests.exceptions.RequestException as e: + raise CorelliumApiException(data.get('error', str(e))) from e + + return Instance(**data) + + def get_instance(self, instance_ref: str) -> Optional[Instance]: + """ + Retrieve an existing instance by its name. + + Return None if it does not exist. + """ + try: + res = self.req.get(f'{self.baseurl}/v1/instances') + data = res.json() + res.raise_for_status() + except requests.exceptions.RequestException as e: + raise CorelliumApiException(data.get('error', str(e))) from e + + for instance in data: + if instance['name'] == instance_ref or instance['id'] == instance_ref: + return Instance(id=instance['id'], state=instance['state']) + + return None + + def set_instance_state(self, instance: Instance, instance_state: str) -> None: + """ + Set the virtual instance state from corellium. + + Valid instance state values: + + - on + - off + - booting + - deleting + - creating + - restoring + - paused + - rebooting + - error + """ + data = { + 'state': instance_state + } + + try: + res = self.req.put(f'{self.baseurl}/v1/instances/{instance.id}/state', json=data) + data = res.json() if res.status_code != 204 else None + res.raise_for_status() + except requests.exceptions.RequestException as e: + msgerr = data if data is not None else str(e) + + raise CorelliumApiException(msgerr) from e + + def destroy_instance(self, instance: Instance) -> None: + """ + Delete a virtual instance. + + Does not return anything since Corellium's API return a HTTP 204 response. + """ + try: + res = self.req.delete(f'{self.baseurl}/v1/instances/{instance.id}') + data = res.json() if res.status_code != 204 else None + res.raise_for_status() + except requests.exceptions.RequestException as e: + msgerr = data if data is not None else str(e) + + raise CorelliumApiException(msgerr) from e diff --git a/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/corellium/api_test.py b/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/corellium/api_test.py new file mode 100644 index 000000000..b342b2ee9 --- /dev/null +++ b/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/corellium/api_test.py @@ -0,0 +1,182 @@ +import os + +import pytest + +from .api import ApiClient +from .exceptions import CorelliumApiException +from .types import Device, Instance, Project, Session + + +def fixture(path): + """ + Load file contents from fixtures/$path. + """ + cwd = os.path.dirname(os.path.abspath(__file__)) + fixtures_dir = f'{cwd}/../../fixtures' + + with open(f'{fixtures_dir}/{path}', 'r') as f: + return f.read() + + +def test_login_ok(requests_mock): + requests_mock.post('https://api-host/api/v1/auth/login', text=fixture('http/login-200.json')) + + api = ApiClient('api-host', 'api-token') + api.login() + + assert 'session-token' == api.session.token + assert '2022-03-20T01:50:10.000Z' == api.session.expiration + assert {'Authorization': 'Bearer session-token'} == api.session.as_header() + + +@pytest.mark.parametrize( + 'status_code,data,msg', + [ + (403, fixture('http/403.json'), 'Invalid or missing authorization token'), + (200, fixture('http/json-error.json'), 'Invalid control character at'), + ]) +def test_login_error(requests_mock, status_code, data, msg): + requests_mock.post('https://api-host/api/v1/auth/login', status_code=status_code, text=data) + api = ApiClient('api-host', 'api-token') + + with pytest.raises(CorelliumApiException) as e: + api.login() + + assert msg in str(e.value) + assert api.session is None + + +@pytest.mark.parametrize('project_name,data,has_results', [ + ('OtherProject', fixture('http/get-projects-200.json'), True), + (None, fixture('http/get-projects-200.json'), True), + ('notfound', fixture('http/get-projects-200.json'), False) +]) +def test_get_project_ok(requests_mock, project_name, data, has_results): + requests_mock.get('https://api-host/api/v1/projects', status_code=200, text=data) + api = ApiClient('api-host', 'api-token') + api.session = Session('session-token', '2022-03-20T01:50:10.000Z') + + args = [] + if project_name: + args.append(project_name) + project = api.get_project(*args) + + if has_results: + assert project is not None + assert project.name == project_name if project_name is not None else 'Default Project' + else: + assert project is None + + +@pytest.mark.parametrize( + 'status_code,data,msg', + [ + (403, fixture('http/403.json'), 'Invalid or missing authorization token'), + (404, fixture('http/get-projects-404.json'), ''), + ]) +def test_get_project_error(requests_mock, status_code, data, msg): + requests_mock.get('https://api-host/api/v1/projects', status_code=status_code, text=data) + api = ApiClient('api-host', 'api-token') + api.session = Session('session-token', '2022-03-20T01:50:10.000Z') + + with pytest.raises(CorelliumApiException) as e: + api.get_project() + + assert msg in str(e.value) + + +@pytest.mark.parametrize('model,data,has_results', [ + ('rpi4b', fixture('http/get-models-200.json'), True), + ('notfound', fixture('http/get-models-200.json'), False) +]) +def test_get_device_ok(requests_mock, model, data, has_results): + requests_mock.get('https://api-host/api/v1/models', status_code=200, text=data) + api = ApiClient('api-host', 'api-token') + api.session = Session('session-token', '2022-03-20T01:50:10.000Z') + + device = api.get_device(model) + + if has_results: + assert device is not None + assert device.model == model + else: + assert device is None + + +@pytest.mark.parametrize( + 'status_code,data,msg', + [ + (403, fixture('http/403.json'), 'Invalid or missing authorization token'), + ]) +def test_get_device_error(requests_mock, status_code, data, msg): + requests_mock.get('https://api-host/api/v1/models', status_code=status_code, text=data) + api = ApiClient('api-host', 'api-token') + api.session = Session('session-token', '2022-03-20T01:50:10.000Z') + + with pytest.raises(CorelliumApiException) as e: + api.get_device('mymodel') + + assert msg in str(e.value) + + +def test_create_instance_ok(requests_mock): + data = fixture('http/create-instance-200.json') + requests_mock.post('https://api-host/api/v1/instances', status_code=200, text=data) + api = ApiClient('api-host', 'api-token') + api.session = Session('session-token', '2022-03-20T01:50:10.000Z') + + project = Project('d59db33d-27bd-4b22-878d-49e4758a648e', 'Default Project') + device = Device(name='rd1ae', type='automotive', flavor='kronos', + description='', model='kronos', peripherals=False, quotas={}) + instance = api.create_instance('my-instance', project, device, '1.1.1', 'Critical Application Monitor (Baremetal)') + + assert instance is not None + assert instance.id + + +@pytest.mark.parametrize( + 'status_code,data,msg', + [ + (403, fixture('http/403.json'), 'Invalid or missing authorization token'), + (400, fixture('http/create-instance-400.json'), 'Unsupported device model'), + ]) +def test_create_instance_error(requests_mock, status_code, data, msg): + requests_mock.post('https://api-host/api/v1/instances', status_code=status_code, text=data) + api = ApiClient('api-host', 'api-token') + api.session = Session('session-token', '2022-03-20T01:50:10.000Z') + + with pytest.raises(CorelliumApiException) as e: + project = Project('d59db33d-27bd-4b22-878d-49e4758a648e', 'Default Project') + device = Device(name='rd1ae', type='automotive', flavor='kronos', + description='', model='kronos', peripherals=False, quotas={}) + api.create_instance('my-instance', project, device, '1.1.1', 'Critical Application Monitor (Baremetal)') + + assert msg in str(e.value) + + +def test_destroy_instance_state_ok(requests_mock): + instance = Instance(id='d59db33d-27bd-4b22-878d-49e4758a648e') + + requests_mock.delete(f'https://api-host/api/v1/instances/{instance.id}', status_code=204, text='') + api = ApiClient('api-host', 'api-token') + api.session = Session('session-token', '2022-03-20T01:50:10.000Z') + api.destroy_instance(instance) + + +@pytest.mark.parametrize( + 'status_code,data,msg', + [ + (403, fixture('http/403.json'), 'Invalid or missing authorization token'), + (404, fixture('http/get-instance-state-404.json'), 'No instance associated with this value'), + ]) +def test_destroy_instance_error(requests_mock, status_code, data, msg): + instance = Instance(id='d59db33d-27bd-4b22-878d-49e4758a648e') + + requests_mock.delete(f'https://api-host/api/v1/instances/{instance.id}', status_code=status_code, text=data) + api = ApiClient('api-host', 'api-token') + api.session = Session('session-token', '2022-03-20T01:50:10.000Z') + + with pytest.raises(CorelliumApiException) as e: + api.destroy_instance(instance) + + assert msg in str(e.value) diff --git a/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/corellium/exceptions.py b/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/corellium/exceptions.py new file mode 100644 index 000000000..dc0876a35 --- /dev/null +++ b/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/corellium/exceptions.py @@ -0,0 +1,10 @@ +""" +Corellium API client exceptions module +""" +from jumpstarter.common.exceptions import JumpstarterException + + +class CorelliumApiException(JumpstarterException): + """ + Exception raised when something goes wrong with Corellium's API. + """ diff --git a/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/corellium/types.py b/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/corellium/types.py new file mode 100644 index 000000000..88716e2ee --- /dev/null +++ b/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/corellium/types.py @@ -0,0 +1,56 @@ +""" +Corellium API types. +""" +from dataclasses import dataclass, field +from typing import Dict, Optional + + +@dataclass +class Session: + """ + Session data class to hold Corellium's API session data. + """ + token: str + expiration: str + + def as_header(self) -> Dict[str, str]: + """ + Return a dict to be used as HTTP header for authenticated requests. + """ + return { + 'Authorization': f'Bearer {self.token}' + } + + +@dataclass +class Project: + """ + Dataclass that represents a Corellium project. + """ + id: str + name: str + + +@dataclass +class Device: + """ + Dataclass to represent a Corellium Device. + + A device object is used to create virtual instances. + """ + name: str + type: str + flavor: str + description: str + model: str + peripherals: bool + quotas: dict + + +@dataclass +class Instance: + """ + Virtual instance dataclass. + """ + id: str + state: Optional[str] = field(default=None) diff --git a/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/driver.py b/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/driver.py new file mode 100644 index 000000000..fbb76332e --- /dev/null +++ b/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/driver.py @@ -0,0 +1,203 @@ +""" +Jumpstarter corellium driver(s) implementation module. +""" +import os +import time +from collections.abc import AsyncGenerator +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Dict, Optional + +from jumpstarter_driver_power.driver import PowerReading, VirtualPowerInterface + +from .corellium.api import ApiClient +from .corellium.types import Instance +from jumpstarter.common import exceptions as jmp_exceptions +from jumpstarter.driver import Driver, export + + +@dataclass(kw_only=True) +class Corellium(Driver): + """ + Corellium top-level driver. + """ + _api: ApiClient = field(init=False) + project_id: str + device_name: str + device_flavor: str + device_os: str = field(default='1.1.1') + device_build: str = field(default='Critical Application Monitor (Baremetal)') + + @classmethod + def client(cls) -> str: + """ + Return the driver's client. + """ + return 'jumpstarter_driver_corellium.client.CorelliumClient' + + def __post_init__(self) -> None: + """ + Post initialization method. + + It will check for the following environment variables: + + - CORELLIUM_API_HOST + - CORELLIUM_API_TOKEN + + Raises an exception in case these variables are not set or empty. + + Additionally, it also sets up some internal objects/varibales such as: + + - Corellium API client instance + - Children jumpstarter drives + """ + if hasattr(super(), "__post_init__"): + super().__post_init__() + + api_host = self.get_env_var('CORELLIUM_API_HOST') + api_token = self.get_env_var('CORELLIUM_API_TOKEN') + self._api = ApiClient(api_host, api_token) + + self.children['power'] = CorelliumPower(parent=self) + + def get_env_var(self, name: str) -> str: + """ + Return an env var and raise an exception if said + var does not exist or is empty. + """ + value = os.environ.get(name) + + if value is None: + raise jmp_exceptions.ConfigurationError(f'Missing "{name}" environment variable') + + value = value.strip() + + if len(value) == 0: + raise jmp_exceptions.ConfigurationError(f'"{name}" environment variable is empty') + + return value + + @property + def api(self): + """ + Return the internal Corellium API client instance from `self._api`. + + It will also be responsible for creating/refreshing the session token used + across different API methods that require authentication. + """ + # session does not exist, just login and return + if self._api.session is None: + self._api.login() + + return self._api + + # check if session is about to expire + # currently depends on the magic number of 60 seconds + now = datetime.utcnow() + diff = datetime.strptime(self._api.session.expiration, '%Y-%m-%dT%H:%M:%S.%fZ') - now + if diff > timedelta(seconds=1): + self._api.login() + + return self._api + + +@dataclass(kw_only=True) +class CorelliumPower(VirtualPowerInterface, Driver): + """ + Power driver implementation for corellium virtual devices. + + This driver will create and destroy virtual instances. + """ + parent: Corellium + + def get_timeout_opts(self) -> Dict[str, int]: + """ + Return config/opts to be used when waiting for Corellium's API. + """ + return { + 'retries': int(os.environ.get('CORELLIUM_API_RETRIES', 12)), + 'interval': os.environ.get('CORELLIUM_API_INTERVAL', 5) + } + + def wait_instance(self, current: Instance, desired: Optional[Instance]): + """ + Wait for `current` instance to reach the same state as the `desired` instance. + + Desired can also be set to None, which means the instance should not exist. + """ + opts = self.get_timeout_opts() + counter = 0 + + while True: + if counter >= opts['retries']: + raise ValueError(f'Instance took too long to be reach the desired state: {current}') + + if self.parent.api.get_instance(current.id) == desired: + break + + counter += 1 + time.sleep(opts['interval']) + + @export + def on(self) -> None: + """ + Power a Corellium virtual device on. + + It will create an instance if one does not exist, it will just power the existing one on otherwise. + """ + self.logger.info('Corellium Device:') + self.logger.info(f'\tDevice Name: {self.parent.device_name}') + self.logger.info(f'\tDevice Flavor: {self.parent.device_flavor}') + self.logger.info(f'\tDevice OS Version: {self.parent.device_os}') + + project = self.parent.api.get_project(self.parent.project_id) + if project is None: + raise ValueError(f'Unable to fetch project: {self.parent.project_id}') + self.logger.info(f'Using project: {project.name}') + + device = self.parent.api.get_device(self.parent.device_flavor) + if device is None: + raise ValueError('Unable to find a device for this model: {self.parent.device_model}') + self.logger.info(f'Using device spec: {device.name}') + + # retrieve an existing instance first + instance = self.parent.api.get_instance(self.parent.device_name) + if instance: + self.parent.api.set_instance_state(instance, 'on') + # create a new one otherwise + else: + opts = {} + if self.parent.device_os: + opts['os_version'] = self.parent.device_os + if self.parent.device_build: + opts['os_build'] = self.parent.device_build + instance = self.parent.api.create_instance(self.parent.device_name, project, device, **opts) + self.logger.info(f'Instance: {self.parent.device_name} (ID: {instance.id})') + + self.wait_instance(instance, Instance(id=instance.id, state='on')) + + @export + def off(self, destroy: bool = False) -> None: + """ + Destroy a Corellium virtual device/instance. + """ + # fail if project does not exist + project = self.parent.api.get_project(self.parent.project_id) + if project is None: + raise ValueError(f'Unable to fetch project: {self.parent.project_id}') + + # get instance and fail if instance does not exist + instance = self.parent.api.get_instance(self.parent.device_name) + if instance is None: + raise ValueError('Instance does not exist') + + self.parent.api.set_instance_state(instance, 'off') + self.wait_instance(instance, Instance(id=instance.id, state='off')) + + if destroy: + self.parent.api.destroy_instance(instance) + self.wait_instance(instance, None) + + @export + def read(self) -> AsyncGenerator[PowerReading, None]: + pass diff --git a/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/driver_test.py b/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/driver_test.py new file mode 100644 index 000000000..2feb97c57 --- /dev/null +++ b/packages/jumpstarter-driver-corellium/jumpstarter_driver_corellium/driver_test.py @@ -0,0 +1,156 @@ +from unittest.mock import patch + +import pytest + +from .corellium.exceptions import CorelliumApiException +from .corellium.types import Device, Instance, Project, Session +from .driver import Corellium, CorelliumPower +from jumpstarter.common import exceptions as jmp_exceptions + + +def test_driver_corellium_init_ok(monkeypatch): + monkeypatch.setenv('CORELLIUM_API_HOST', 'api-host') + monkeypatch.setenv('CORELLIUM_API_TOKEN', 'api-token') + + c = Corellium(project_id='1', device_name='jmp', device_flavor='kronos', device_os='1.0') + + assert '1' == c.project_id + assert 'jmp' == c.device_name + assert 'kronos' == c.device_flavor + assert '1.0' == c.device_os + assert 'api-host' == c._api.host + assert 'api-token' == c._api.token + + +@pytest.mark.parametrize( + 'env,err', + [ + ( + {}, + jmp_exceptions.ConfigurationError('Missing "CORELLIUM_API_HOST" environment variable') + ), + ( + {'CORELLIUM_API_HOST': ' '}, + jmp_exceptions.ConfigurationError('"CORELLIUM_API_HOST" environment variable is empty') + ), + ( + {'CORELLIUM_API_HOST': 'api-host'}, + jmp_exceptions.ConfigurationError('Missing "CORELLIUM_API_TOKEN" environment variable') + ), + ( + {'CORELLIUM_API_HOST': 'api-host', 'CORELLIUM_API_TOKEN': ' '}, + jmp_exceptions.ConfigurationError('"CORELLIUM_API_TOKEN" environment variable is empty') + ), + ]) +def test_driver_corellium_init_error(monkeypatch, env, err): + monkeypatch.delenv('CORELLIUM_API_HOST', raising=False) + monkeypatch.delenv('CORELLIUM_API_TOKEN', raising=False) + + for k, v in env.items(): + monkeypatch.setenv(k, v) + + with pytest.raises(type(err)) as e: + Corellium(project_id='1', device_name='jmp', device_flavor='kronos', device_os='1.0') + + assert str(err) == str(e.value) + + +def test_driver_api_client_ok(monkeypatch, requests_mock): + requests_mock.post('https://api-host/api/v1/auth/login', + text='{"token": "token", "expiration": "2022-03-20T01:50:10.000Z"}') + monkeypatch.setenv('CORELLIUM_API_HOST', 'api-host') + monkeypatch.setenv('CORELLIUM_API_TOKEN', 'api-token') + + c = Corellium(project_id='1', device_name='jmp', device_flavor='kronos', device_os='1.0') + + assert Session('token', '2022-03-20T01:50:10.000Z') == c.api.session + + +def test_driver_power_on_ok(monkeypatch): + monkeypatch.setenv('CORELLIUM_API_HOST', 'api-host') + monkeypatch.setenv('CORELLIUM_API_TOKEN', 'api-token') + + project = Project('1', 'Default Project') + device = Device(name='rd1ae', type='automotive', flavor='kronos', + description='', model='kronos', peripherals=False, quotas={}) + instance = Instance(id='7f4f241c-821f-4219-905f-c3b50b0db5dd', state='on') + root = Corellium(project_id='1', device_name='jmp', device_flavor='kronos', device_os='1.0') + power = CorelliumPower(parent=root) + + with (patch.object(root._api, 'login', return_value=None), + patch.object(root._api, 'get_project', return_value=project), + patch.object(root._api, 'get_device', return_value=device), + patch.object(root._api, 'get_instance', side_effect=[None, instance]), + patch.object(root._api, 'create_instance', return_value=instance)): + power.on() + + +@pytest.mark.parametrize('mock_data', [ + ({'login': {'side_effect': CorelliumApiException('login error')}}), + ({'get_project': {'return_value': None}}), + ({'get_instance': {'return_value': None}}), + ({'create_instance': {'side_effect': CorelliumApiException('create error')}}), +]) +def test_driver_power_on_error(monkeypatch, mock_data): + monkeypatch.setenv('CORELLIUM_API_HOST', 'api-host') + monkeypatch.setenv('CORELLIUM_API_TOKEN', 'api-token') + + project = Project('1', 'Default Project') + instance = Instance(id='7f4f241c-821f-4219-905f-c3b50b0db5dd', state='on') + root = Corellium(project_id='1', device_name='jmp', device_flavor='kronos', device_os='1.0') + power = CorelliumPower(parent=root) + + with pytest.raises((CorelliumApiException, ValueError)): + with (patch.object(root._api, 'login', + **mock_data.get('login', {'return_value': None})), + patch.object(root._api, 'get_project', + **mock_data.get('get_project', {'return_value': project})), + patch.object(root._api, 'get_instance', + **mock_data.get('get_instance', {'return_value': instance})), + patch.object(root._api, 'create_instance', + **mock_data.get('create_instance', {'return_value': instance}))): + power.off() + + +def test_driver_power_off_ok(monkeypatch): + monkeypatch.setenv('CORELLIUM_API_HOST', 'api-host') + monkeypatch.setenv('CORELLIUM_API_TOKEN', 'api-token') + + project = Project('1', 'Default Project') + instance = Instance(id='7f4f241c-821f-4219-905f-c3b50b0db5dd', state='on') + root = Corellium(project_id='1', device_name='jmp', device_flavor='kronos', device_os='1.0') + power = CorelliumPower(parent=root) + + with (patch.object(root._api, 'login', return_value=None), + patch.object(root._api, 'get_project', return_value=project), + patch.object(root._api, 'set_instance_state', return_value=None), + patch.object(root._api, 'get_instance', + side_effect=[instance, Instance(id=instance.id, state='off')])): + power.off() + + +@pytest.mark.parametrize('mock_data',[ + ({'login': {'side_effect': CorelliumApiException('login error')}}), + ({'get_project': {'return_value': None}}), + ({'get_instance': {'return_value': None}}), + ({'destroy_instance': {'side_effect': CorelliumApiException('destroy error')}}), +]) +def test_driver_power_off_error(monkeypatch, mock_data): + monkeypatch.setenv('CORELLIUM_API_HOST', 'api-host') + monkeypatch.setenv('CORELLIUM_API_TOKEN', 'api-token') + + project = Project('1', 'Default Project') + instance = Instance(id='7f4f241c-821f-4219-905f-c3b50b0db5dd', state='on') + root = Corellium(project_id='1', device_name='jmp', device_flavor='kronos', device_os='1.0') + power = CorelliumPower(parent=root) + + with pytest.raises((CorelliumApiException, ValueError)): + with (patch.object(root._api, 'login', + **mock_data.get('login', {'return_value': None})), + patch.object(root._api, 'get_project', + **mock_data.get('get_project', {'return_value': project})), + patch.object(root._api, 'get_instance', + **mock_data.get('get_instance', {'side_effect': [instance, None]})), + patch.object(root._api, 'destroy_instance', + **mock_data.get('destroy_instance', {'return_value': instance}))): + power.off() diff --git a/packages/jumpstarter-driver-corellium/poetry.lock b/packages/jumpstarter-driver-corellium/poetry.lock new file mode 100644 index 000000000..12f8da289 --- /dev/null +++ b/packages/jumpstarter-driver-corellium/poetry.lock @@ -0,0 +1,271 @@ +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. + +[[package]] +name = "certifi" +version = "2025.1.31" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, + {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.1" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +files = [ + {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"}, + {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, + {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, +] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "packaging" +version = "24.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, + {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pytest" +version = "8.3.5" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"}, + {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-sugar" +version = "1.0.0" +description = "pytest-sugar is a plugin for pytest that changes the default look and feel of pytest (e.g. progressbar, show tests that fail instantly)." +optional = false +python-versions = "*" +files = [ + {file = "pytest-sugar-1.0.0.tar.gz", hash = "sha256:6422e83258f5b0c04ce7c632176c7732cab5fdb909cb39cca5c9139f81276c0a"}, + {file = "pytest_sugar-1.0.0-py3-none-any.whl", hash = "sha256:70ebcd8fc5795dc457ff8b69d266a4e2e8a74ae0c3edc749381c64b5246c8dfd"}, +] + +[package.dependencies] +packaging = ">=21.3" +pytest = ">=6.2.0" +termcolor = ">=2.1.0" + +[package.extras] +dev = ["black", "flake8", "pre-commit"] + +[[package]] +name = "requests" +version = "2.32.3" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +files = [ + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "termcolor" +version = "2.5.0" +description = "ANSI color formatting for output in terminal" +optional = false +python-versions = ">=3.9" +files = [ + {file = "termcolor-2.5.0-py3-none-any.whl", hash = "sha256:37b17b5fc1e604945c2642c872a3764b5d547a48009871aea3edd3afa180afb8"}, + {file = "termcolor-2.5.0.tar.gz", hash = "sha256:998d8d27da6d48442e8e1f016119076b690d962507531df4890fcd2db2ef8a6f"}, +] + +[package.extras] +tests = ["pytest", "pytest-cov"] + +[[package]] +name = "urllib3" +version = "2.3.0" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.9" +files = [ + {file = "urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df"}, + {file = "urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.12" +content-hash = "c8c45d206e551483bac8aeaddec594d0f2e39b828346e03147de26cce0b82cd2" diff --git a/packages/jumpstarter-driver-corellium/pyproject.toml b/packages/jumpstarter-driver-corellium/pyproject.toml new file mode 100644 index 000000000..b46eff0c2 --- /dev/null +++ b/packages/jumpstarter-driver-corellium/pyproject.toml @@ -0,0 +1,40 @@ +[project] +name = "jumpstarter_driver_corellium" +dynamic = ["version", "urls"] +description = "" +authors = [ + { name = "Leonardo Rossetti", email = "lrossett@redhat.com" }, +] +readme = "README.md" +license = { text = "Apache-2.0" } +requires-python = ">=3.11" +dependencies = [ + "jumpstarter", + "jumpstarter-driver-composite", + "jumpstarter-driver-power", + "pyserial>=3.5", + "asyncclick>=8.1.7.2" +] + +[project.entry-points."jumpstarter.drivers"] +Corellium = "jumpstarter_driver_corellium.driver:Corellium" + +[dependency-groups] +dev = [ + "pytest>=8.3.2", + "pytest-cov>=5.0.0", + "trio>=0.28.0", + "requests_mock", +] + +[tool.hatch.metadata.hooks.vcs.urls] +Homepage = "https://jumpstarter.dev" +source_archive = "https://github.com/jumpstarter-dev/repo/archive/{commit_hash}.zip" + +[tool.hatch.version] +source = "vcs" +raw-options = { 'root' = '../../'} + +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" diff --git a/packages/jumpstarter-driver-power/jumpstarter_driver_power/client.py b/packages/jumpstarter-driver-power/jumpstarter_driver_power/client.py index 9f388ed65..bed1e19cd 100644 --- a/packages/jumpstarter-driver-power/jumpstarter_driver_power/client.py +++ b/packages/jumpstarter-driver-power/jumpstarter_driver_power/client.py @@ -51,3 +51,19 @@ def cycle(wait): self.cycle(wait) return base + + +class VirtualPowerClient(PowerClient): + def off(self, destroy: bool = False) -> None: + self.call('off', destroy) + + def cli(self): + parent = super().cli() + + @parent.command(name='off') + @click.option('--destroy', is_flag=True, help='destroy the instance after powering it off') + def off(destroy: bool): + """Power off""" + self.off(destroy) + + return parent diff --git a/packages/jumpstarter-driver-power/jumpstarter_driver_power/driver.py b/packages/jumpstarter-driver-power/jumpstarter_driver_power/driver.py index e258ee7bf..251482964 100644 --- a/packages/jumpstarter-driver-power/jumpstarter_driver_power/driver.py +++ b/packages/jumpstarter-driver-power/jumpstarter_driver_power/driver.py @@ -20,6 +20,22 @@ async def off(self) -> None: ... async def read(self) -> AsyncGenerator[PowerReading, None]: ... +class VirtualPowerInterface(metaclass=ABCMeta): + @classmethod + def client(cls) -> str: + return "jumpstarter_driver_power.client.VirtualPowerClient" + + @abstractmethod + async def on(self) -> None: ... + + @abstractmethod + async def off(self, destroy: bool = False) -> None: ... + + @abstractmethod + async def read(self) -> AsyncGenerator[PowerReading, None]: ... + + + class MockPower(PowerInterface, Driver): """ MockPower is a mock driver implementing the PowerInterface diff --git a/pyproject.toml b/pyproject.toml index b6f58e46a..e2e351a0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ jumpstarter-cli-common = { workspace = true } jumpstarter-cli-driver = { workspace = true } jumpstarter-driver-can = { workspace = true } jumpstarter-driver-composite = { workspace = true } +jumpstarter-driver-corellium = { workspace = true } jumpstarter-driver-dutlink = { workspace = true } jumpstarter-driver-flashers = { workspace = true } jumpstarter-driver-http = { workspace = true } diff --git a/uv.lock b/uv.lock index 8c781f521..a7da48243 100644 --- a/uv.lock +++ b/uv.lock @@ -12,6 +12,7 @@ members = [ "jumpstarter-cli-driver", "jumpstarter-driver-can", "jumpstarter-driver-composite", + "jumpstarter-driver-corellium", "jumpstarter-driver-dutlink", "jumpstarter-driver-flashers", "jumpstarter-driver-http", @@ -1203,6 +1204,42 @@ dev = [ { name = "pytest-cov", specifier = ">=5.0.0" }, ] +[[package]] +name = "jumpstarter-driver-corellium" +source = { editable = "packages/jumpstarter-driver-corellium" } +dependencies = [ + { name = "asyncclick" }, + { name = "jumpstarter" }, + { name = "jumpstarter-driver-composite" }, + { name = "jumpstarter-driver-power" }, + { name = "pyserial" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "requests-mock" }, + { name = "trio" }, +] + +[package.metadata] +requires-dist = [ + { name = "asyncclick", specifier = ">=8.1.7.2" }, + { name = "jumpstarter", editable = "packages/jumpstarter" }, + { name = "jumpstarter-driver-composite", editable = "packages/jumpstarter-driver-composite" }, + { name = "jumpstarter-driver-power", editable = "packages/jumpstarter-driver-power" }, + { name = "pyserial", specifier = ">=3.5" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.3.2" }, + { name = "pytest-cov", specifier = ">=5.0.0" }, + { name = "requests-mock" }, + { name = "trio", specifier = ">=0.28.0" }, +] + [[package]] name = "jumpstarter-driver-dutlink" source = { editable = "packages/jumpstarter-driver-dutlink" } @@ -2922,6 +2959,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, ] +[[package]] +name = "requests-mock" +version = "1.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/32/587625f91f9a0a3d84688bf9cfc4b2480a7e8ec327cefd0ff2ac891fd2cf/requests-mock-1.12.1.tar.gz", hash = "sha256:e9e12e333b525156e82a3c852f22016b9158220d2f47454de9cae8a77d371401", size = 60901 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/ec/889fbc557727da0c34a33850950310240f2040f3b1955175fdb2b36a8910/requests_mock-1.12.1-py2.py3-none-any.whl", hash = "sha256:b1e37054004cdd5e56c84454cc7df12b25f90f382159087f4b6915aaeef39563", size = 27695 }, +] + [[package]] name = "requests-oauthlib" version = "2.0.0"