workload-profile-controller 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. workload_profile_controller/application.py +13 -0
  2. workload_profile_controller/backend.py +21 -0
  3. workload_profile_controller/backends/__init__.py +0 -0
  4. workload_profile_controller/backends/proxmox/__init__.py +0 -0
  5. workload_profile_controller/backends/proxmox/auth.py +15 -0
  6. workload_profile_controller/backends/proxmox/backend.py +97 -0
  7. workload_profile_controller/backends/proxmox/config.py +16 -0
  8. workload_profile_controller/backends/proxmox/config_loader.py +46 -0
  9. workload_profile_controller/backends/proxmox/discovery.py +54 -0
  10. workload_profile_controller/backends/proxmox/errors.py +13 -0
  11. workload_profile_controller/backends/proxmox/factory.py +43 -0
  12. workload_profile_controller/backends/proxmox/http_client.py +107 -0
  13. workload_profile_controller/backends/proxmox/inventory.py +52 -0
  14. workload_profile_controller/backends/proxmox/resource_client.py +87 -0
  15. workload_profile_controller/backends/proxmox/task.py +16 -0
  16. workload_profile_controller/backends/proxmox/task_waiter.py +53 -0
  17. workload_profile_controller/cli.py +154 -0
  18. workload_profile_controller/config.py +21 -0
  19. workload_profile_controller/config_loader.py +40 -0
  20. workload_profile_controller/config_validator.py +106 -0
  21. workload_profile_controller/controller.py +154 -0
  22. workload_profile_controller/errors.py +26 -0
  23. workload_profile_controller/policy.py +67 -0
  24. workload_profile_controller-0.1.0.dist-info/METADATA +500 -0
  25. workload_profile_controller-0.1.0.dist-info/RECORD +29 -0
  26. workload_profile_controller-0.1.0.dist-info/WHEEL +5 -0
  27. workload_profile_controller-0.1.0.dist-info/entry_points.txt +2 -0
  28. workload_profile_controller-0.1.0.dist-info/licenses/LICENSE +21 -0
  29. workload_profile_controller-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,13 @@
1
+ from .backends.proxmox.factory import create_proxmox_backend
2
+ from .config_loader import load_config
3
+ from .controller import Controller
4
+
5
+
6
+ def create_controller(config_path: str) -> Controller:
7
+ config = load_config(config_path)
8
+ backend = create_proxmox_backend()
9
+
10
+ return Controller(
11
+ config=config,
12
+ backend=backend,
13
+ )
@@ -0,0 +1,21 @@
1
+ from enum import Enum
2
+ from typing import Protocol
3
+
4
+
5
+ class ResourceStatus(Enum):
6
+ RUNNING = "running"
7
+ STOPPED = "stopped"
8
+ STARTING = "starting"
9
+ STOPPING = "stopping"
10
+ UNKNOWN = "unknown"
11
+
12
+
13
+ class Backend(Protocol):
14
+ def get_status(self, resource_id: str) -> ResourceStatus:
15
+ ...
16
+
17
+ def start(self, resource_id: str) -> None:
18
+ ...
19
+
20
+ def stop(self, resource_id: str) -> None:
21
+ ...
File without changes
@@ -0,0 +1,15 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class ProxmoxToken:
6
+ user: str
7
+ token_name: str
8
+ secret: str
9
+
10
+ def authorization_header(self) -> str:
11
+ return (
12
+ "PVEAPIToken="
13
+ f"{self.user}!{self.token_name}="
14
+ f"{self.secret}"
15
+ )
@@ -0,0 +1,97 @@
1
+ from ...backend import Backend, ResourceStatus
2
+ from .discovery import ProxmoxDiscovery
3
+ from .inventory import ProxmoxResourceType
4
+ from .resource_client import ProxmoxResourceClient
5
+ from .task_waiter import TaskWaiter
6
+
7
+
8
+ class ProxmoxBackend(Backend):
9
+ def __init__(
10
+ self,
11
+ discovery: ProxmoxDiscovery,
12
+ resource_client: ProxmoxResourceClient,
13
+ task_waiter: TaskWaiter,
14
+ ):
15
+ self._discovery = discovery
16
+ self._resource_client = resource_client
17
+ self._task_waiter = task_waiter
18
+
19
+ def get_status(self, resource_id: str) -> ResourceStatus:
20
+ inventory = self._discovery.discover()
21
+ resource = inventory.resolve(resource_id)
22
+
23
+ if resource.resource_type == ProxmoxResourceType.QEMU:
24
+ data = self._resource_client.get_qemu_status(
25
+ resource.proxmox_id
26
+ )
27
+ elif resource.resource_type == ProxmoxResourceType.LXC:
28
+ data = self._resource_client.get_lxc_status(
29
+ resource.proxmox_id
30
+ )
31
+ else:
32
+ raise RuntimeError(
33
+ f"Unsupported Proxmox resource type: "
34
+ f"{resource.resource_type}"
35
+ )
36
+
37
+ status = data["status"]
38
+
39
+ if status == "running":
40
+ return ResourceStatus.RUNNING
41
+
42
+ if status == "stopped":
43
+ return ResourceStatus.STOPPED
44
+
45
+ if status == "starting":
46
+ return ResourceStatus.STARTING
47
+
48
+ if status == "stopping":
49
+ return ResourceStatus.STOPPING
50
+
51
+ return ResourceStatus.UNKNOWN
52
+
53
+ def start(self, resource_id: str) -> None:
54
+ inventory = self._discovery.discover()
55
+ resource = inventory.resolve(resource_id)
56
+
57
+ if resource.resource_type == ProxmoxResourceType.QEMU:
58
+ upid = self._resource_client.start_qemu(
59
+ resource.proxmox_id
60
+ )
61
+ self._task_waiter.wait(upid)
62
+ return
63
+
64
+ if resource.resource_type == ProxmoxResourceType.LXC:
65
+ upid = self._resource_client.start_lxc(
66
+ resource.proxmox_id
67
+ )
68
+ self._task_waiter.wait(upid)
69
+ return
70
+
71
+ raise RuntimeError(
72
+ f"Unsupported Proxmox resource type: "
73
+ f"{resource.resource_type}"
74
+ )
75
+
76
+ def stop(self, resource_id: str) -> None:
77
+ inventory = self._discovery.discover()
78
+ resource = inventory.resolve(resource_id)
79
+
80
+ if resource.resource_type == ProxmoxResourceType.QEMU:
81
+ upid = self._resource_client.stop_qemu(
82
+ resource.proxmox_id
83
+ )
84
+ self._task_waiter.wait(upid)
85
+ return
86
+
87
+ if resource.resource_type == ProxmoxResourceType.LXC:
88
+ upid = self._resource_client.stop_lxc(
89
+ resource.proxmox_id
90
+ )
91
+ self._task_waiter.wait(upid)
92
+ return
93
+
94
+ raise RuntimeError(
95
+ f"Unsupported Proxmox resource type: "
96
+ f"{resource.resource_type}"
97
+ )
@@ -0,0 +1,16 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class ProxmoxConfig:
6
+ host: str
7
+ node: str
8
+ user: str
9
+ token_name: str
10
+ token_secret: str
11
+ verify_tls: bool = True
12
+ ca_file: str | None = None
13
+ tls_server_name: str | None = None
14
+ timeout: float = 5.0
15
+ task_timeout: float = 120.0
16
+ shutdown_timeout: float = 120.0
@@ -0,0 +1,46 @@
1
+ import os
2
+
3
+ from dotenv import load_dotenv
4
+
5
+ from .config import ProxmoxConfig
6
+
7
+ load_dotenv()
8
+
9
+
10
+ def load_proxmox_config() -> ProxmoxConfig:
11
+ return ProxmoxConfig(
12
+ host=os.environ["PVE_PROXMOX_HOST"],
13
+ node=os.environ["PVE_PROXMOX_NODE"],
14
+ user=os.environ["PVE_PROXMOX_USER"],
15
+ token_name=os.environ["PVE_PROXMOX_TOKEN_NAME"],
16
+ token_secret=os.environ["PVE_PROXMOX_TOKEN_SECRET"],
17
+ verify_tls=os.environ.get(
18
+ "PVE_PROXMOX_VERIFY_TLS",
19
+ "true",
20
+ ).lower() == "true",
21
+ ca_file=os.environ.get(
22
+ "PVE_PROXMOX_CA_FILE",
23
+ ),
24
+ tls_server_name=os.environ.get(
25
+ "PVE_PROXMOX_TLS_SERVER_NAME",
26
+ ),
27
+ timeout=float(
28
+ os.environ.get(
29
+ "PVE_PROXMOX_TIMEOUT",
30
+ "5.0",
31
+ )
32
+
33
+ ),
34
+ task_timeout=float(
35
+ os.environ.get(
36
+ "PVE_PROXMOX_TASK_TIMEOUT",
37
+ "120.0",
38
+ )
39
+ ),
40
+ shutdown_timeout=float(
41
+ os.environ.get(
42
+ "PVE_PROXMOX_SHUTDOWN_TIMEOUT",
43
+ "120.0",
44
+ )
45
+ ),
46
+ )
@@ -0,0 +1,54 @@
1
+ from collections.abc import Mapping, Sequence
2
+
3
+ from .inventory import (
4
+ ProxmoxInventory,
5
+ ProxmoxResource,
6
+ ProxmoxResourceType,
7
+ )
8
+ from .resource_client import ProxmoxResourceClient
9
+
10
+
11
+ class ProxmoxDiscovery:
12
+ def __init__(self, resource_client: ProxmoxResourceClient):
13
+ self._resource_client = resource_client
14
+
15
+ def discover(self) -> ProxmoxInventory:
16
+ return self.build_inventory(
17
+ qemu_data=self._resource_client.list_qemu(),
18
+ lxc_data=self._resource_client.list_lxc(),
19
+ )
20
+
21
+ @staticmethod
22
+ def build_inventory(
23
+ qemu_data: Sequence[Mapping[str, object]],
24
+ lxc_data: Sequence[Mapping[str, object]],
25
+ ) -> ProxmoxInventory:
26
+ resources = [
27
+ *(
28
+ ProxmoxDiscovery._build_resource(
29
+ item,
30
+ ProxmoxResourceType.QEMU,
31
+ )
32
+ for item in qemu_data
33
+ ),
34
+ *(
35
+ ProxmoxDiscovery._build_resource(
36
+ item,
37
+ ProxmoxResourceType.LXC,
38
+ )
39
+ for item in lxc_data
40
+ ),
41
+ ]
42
+
43
+ return ProxmoxInventory(resources)
44
+
45
+ @staticmethod
46
+ def _build_resource(
47
+ data: Mapping[str, object],
48
+ resource_type: ProxmoxResourceType,
49
+ ) -> ProxmoxResource:
50
+ return ProxmoxResource(
51
+ resource_type=resource_type,
52
+ proxmox_id=int(data["vmid"]),
53
+ name=str(data["name"]),
54
+ )
@@ -0,0 +1,13 @@
1
+ class ProxmoxHttpError(Exception):
2
+ def __init__(
3
+ self,
4
+ status_code: int,
5
+ reason: str,
6
+ ):
7
+ self.status_code = status_code
8
+ self.reason = reason
9
+
10
+ super().__init__(
11
+ f"Proxmox API request failed: "
12
+ f"HTTP {status_code} {reason}"
13
+ )
@@ -0,0 +1,43 @@
1
+ from .auth import ProxmoxToken
2
+ from .backend import ProxmoxBackend
3
+ from .config_loader import load_proxmox_config
4
+ from .discovery import ProxmoxDiscovery
5
+ from .http_client import ProxmoxHttpClient
6
+ from .resource_client import ProxmoxResourceClient
7
+ from .task_waiter import TaskWaiter
8
+
9
+
10
+ def create_proxmox_backend() -> ProxmoxBackend:
11
+ config = load_proxmox_config()
12
+
13
+ token = ProxmoxToken(
14
+ user=config.user,
15
+ token_name=config.token_name,
16
+ secret=config.token_secret,
17
+ )
18
+
19
+ http_client = ProxmoxHttpClient(
20
+ config=config,
21
+ token=token,
22
+ )
23
+
24
+ resource_client = ProxmoxResourceClient(
25
+ http_client=http_client,
26
+ node=config.node,
27
+ shutdown_timeout=config.shutdown_timeout,
28
+ )
29
+
30
+ discovery = ProxmoxDiscovery(
31
+ resource_client=resource_client,
32
+ )
33
+
34
+ task_waiter = TaskWaiter(
35
+ get_status=resource_client.get_task_status,
36
+ timeout=config.task_timeout,
37
+ )
38
+
39
+ return ProxmoxBackend(
40
+ discovery=discovery,
41
+ resource_client=resource_client,
42
+ task_waiter=task_waiter,
43
+ )
@@ -0,0 +1,107 @@
1
+ import http.client
2
+ import json
3
+ import socket
4
+ import ssl
5
+ import urllib.parse
6
+ from collections.abc import Mapping
7
+ from typing import Any
8
+
9
+ from .auth import ProxmoxToken
10
+ from .config import ProxmoxConfig
11
+ from .errors import ProxmoxHttpError
12
+
13
+
14
+ class _ProxmoxHTTPSConnection(http.client.HTTPSConnection):
15
+ def __init__(
16
+ self,
17
+ connect_host: str,
18
+ tls_server_name: str | None,
19
+ context: ssl.SSLContext,
20
+ timeout: float,
21
+ ):
22
+ super().__init__(
23
+ host=connect_host,
24
+ port=8006,
25
+ timeout=timeout,
26
+ context=context,
27
+ )
28
+
29
+ self._tls_server_name = tls_server_name
30
+
31
+ def connect(self):
32
+ self.sock = socket.create_connection(
33
+ (self.host, self.port),
34
+ self.timeout,
35
+ )
36
+
37
+ self.sock = self._context.wrap_socket(
38
+ self.sock,
39
+ server_hostname=self._tls_server_name or self.host,
40
+ )
41
+
42
+
43
+ class ProxmoxHttpClient:
44
+ def __init__(
45
+ self,
46
+ config: ProxmoxConfig,
47
+ token: ProxmoxToken,
48
+ ):
49
+ self._config = config
50
+ self._token = token
51
+
52
+ def request(
53
+ self,
54
+ method: str,
55
+ path: str,
56
+ data: Mapping[str, Any] | None = None,
57
+ ) -> Any:
58
+ body = None
59
+
60
+ if data is not None:
61
+ body = urllib.parse.urlencode(data).encode("utf-8")
62
+
63
+ context = self._create_ssl_context()
64
+
65
+ connection = _ProxmoxHTTPSConnection(
66
+ connect_host=self._config.host,
67
+ tls_server_name=self._config.tls_server_name,
68
+ context=context,
69
+ timeout=self._config.timeout,
70
+ )
71
+
72
+ try:
73
+ connection.request(
74
+ method,
75
+ f"/api2/json{path}",
76
+ body=body,
77
+ headers={
78
+ "Authorization": (
79
+ self._token.authorization_header()
80
+ ),
81
+ },
82
+ )
83
+
84
+ response = connection.getresponse()
85
+ if response.status < 200 or response.status >= 300:
86
+ reason = response.reason or "Unknown error"
87
+ response.read()
88
+
89
+ raise ProxmoxHttpError(
90
+ status_code=response.status,
91
+ reason=reason,
92
+ )
93
+
94
+ payload = json.load(response)
95
+
96
+ return payload["data"]
97
+
98
+ finally:
99
+ connection.close()
100
+
101
+ def _create_ssl_context(self) -> ssl.SSLContext:
102
+ if self._config.verify_tls:
103
+ return ssl.create_default_context(
104
+ cafile=self._config.ca_file,
105
+ )
106
+
107
+ return ssl._create_unverified_context()
@@ -0,0 +1,52 @@
1
+ from dataclasses import dataclass
2
+ from enum import Enum
3
+
4
+ from ...errors import ResourceAmbiguousError, ResourceNotFoundError
5
+
6
+
7
+ class ProxmoxResourceType(Enum):
8
+ QEMU = "qemu"
9
+ LXC = "lxc"
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ProxmoxResource:
14
+ resource_type: ProxmoxResourceType
15
+ proxmox_id: int
16
+ name: str
17
+
18
+
19
+ class ProxmoxInventory:
20
+ def __init__(self, resources: list[ProxmoxResource]):
21
+ self._resources = tuple(
22
+ sorted(
23
+ resources,
24
+ key=lambda resource: (
25
+ resource.name,
26
+ resource.resource_type.value,
27
+ resource.proxmox_id,
28
+ ),
29
+ )
30
+ )
31
+
32
+ def list_resources(self) -> tuple[ProxmoxResource, ...]:
33
+ return self._resources
34
+
35
+ def resolve(self, name: str) -> ProxmoxResource:
36
+ matches = tuple(
37
+ resource
38
+ for resource in self._resources
39
+ if resource.name == name
40
+ )
41
+
42
+ if not matches:
43
+ raise ResourceNotFoundError(
44
+ f"Proxmox resource not found: {name}"
45
+ )
46
+
47
+ if len(matches) > 1:
48
+ raise ResourceAmbiguousError(
49
+ f"Proxmox resource name is ambiguous: {name}"
50
+ )
51
+
52
+ return matches[0]
@@ -0,0 +1,87 @@
1
+ import urllib.parse
2
+
3
+ from .http_client import ProxmoxHttpClient
4
+ from .task import TaskStatus
5
+
6
+
7
+ class ProxmoxResourceClient:
8
+ def __init__(
9
+ self,
10
+ http_client: ProxmoxHttpClient,
11
+ node: str,
12
+ shutdown_timeout: float = 120.0,
13
+ ):
14
+ self._http_client = http_client
15
+ self._node = node
16
+ self._shutdown_timeout = shutdown_timeout
17
+
18
+ def list_qemu(self):
19
+ return self._http_client.request(
20
+ "GET",
21
+ f"/nodes/{self._node}/qemu",
22
+ )
23
+
24
+ def list_lxc(self):
25
+ return self._http_client.request(
26
+ "GET",
27
+ f"/nodes/{self._node}/lxc",
28
+ )
29
+
30
+ def get_qemu_status(self, proxmox_id: int):
31
+ return self._http_client.request(
32
+ "GET",
33
+ f"/nodes/{self._node}/qemu/{proxmox_id}/status/current",
34
+ )
35
+
36
+ def get_lxc_status(self, proxmox_id: int):
37
+ return self._http_client.request(
38
+ "GET",
39
+ f"/nodes/{self._node}/lxc/{proxmox_id}/status/current",
40
+ )
41
+
42
+ def start_qemu(self, proxmox_id: int):
43
+ return self._http_client.request(
44
+ "POST",
45
+ f"/nodes/{self._node}/qemu/{proxmox_id}/status/start",
46
+ )
47
+
48
+ def start_lxc(self, proxmox_id: int):
49
+ return self._http_client.request(
50
+ "POST",
51
+ f"/nodes/{self._node}/lxc/{proxmox_id}/status/start",
52
+ )
53
+
54
+ def stop_qemu(self, proxmox_id: int):
55
+ return self._http_client.request(
56
+ "POST",
57
+ f"/nodes/{self._node}/qemu/{proxmox_id}/status/shutdown",
58
+ data={
59
+ "timeout": int(self._shutdown_timeout),
60
+ },
61
+ )
62
+
63
+ def stop_lxc(self, proxmox_id: int):
64
+ return self._http_client.request(
65
+ "POST",
66
+ f"/nodes/{self._node}/lxc/{proxmox_id}/status/shutdown",
67
+ data={
68
+ "timeout": int(self._shutdown_timeout),
69
+ },
70
+ )
71
+
72
+ def get_task_status(self, upid: str) -> TaskStatus:
73
+ encoded_upid = urllib.parse.quote(
74
+ upid,
75
+ safe="",
76
+ )
77
+
78
+ data = self._http_client.request(
79
+ "GET",
80
+ f"/nodes/{self._node}/tasks/{encoded_upid}/status",
81
+ )
82
+
83
+ return TaskStatus(
84
+ upid=upid,
85
+ status=str(data["status"]),
86
+ exitstatus=data.get("exitstatus"),
87
+ )
@@ -0,0 +1,16 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class TaskStatus:
6
+ upid: str
7
+ status: str
8
+ exitstatus: str | None
9
+
10
+ @property
11
+ def is_finished(self) -> bool:
12
+ return self.status == "stopped"
13
+
14
+ @property
15
+ def is_successful(self) -> bool:
16
+ return self.is_finished and self.exitstatus == "OK"
@@ -0,0 +1,53 @@
1
+ from collections.abc import Callable
2
+ from time import sleep, time
3
+
4
+ from ...errors import TaskFailedError, TaskTimeoutError
5
+ from .task import TaskStatus
6
+
7
+
8
+ class TaskWaiter:
9
+ def __init__(
10
+ self,
11
+ get_status: Callable[[str], TaskStatus],
12
+ timeout: float = 30.0,
13
+ sleep_fn: Callable[[float], None] = sleep,
14
+ time_fn: Callable[[], float] = time,
15
+ ):
16
+ self._get_status = get_status
17
+ self._timeout = timeout
18
+ self._sleep = sleep_fn
19
+ self._time = time_fn
20
+
21
+ def wait(
22
+ self,
23
+ upid: str,
24
+ interval: float = 1.0,
25
+ timeout: float | None = None,
26
+ ) -> TaskStatus:
27
+ effective_timeout = (
28
+ self._timeout
29
+ if timeout is None
30
+ else timeout
31
+ )
32
+
33
+ started_at = self._time()
34
+
35
+ while True:
36
+ status = self._get_status(upid)
37
+
38
+ if status.is_finished:
39
+ if not status.is_successful:
40
+ raise TaskFailedError(
41
+ f"Task failed: {upid}"
42
+ )
43
+
44
+ return status
45
+
46
+ elapsed = self._time() - started_at
47
+
48
+ if elapsed >= effective_timeout:
49
+ raise TaskTimeoutError(
50
+ f"Task timed out: {upid}"
51
+ )
52
+
53
+ self._sleep(interval)