workload-profile-controller 0.1.0__tar.gz → 1.0.0__tar.gz

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 (43) hide show
  1. {workload_profile_controller-0.1.0/src/workload_profile_controller.egg-info → workload_profile_controller-1.0.0}/PKG-INFO +2 -1
  2. {workload_profile_controller-0.1.0 → workload_profile_controller-1.0.0}/pyproject.toml +2 -1
  3. workload_profile_controller-1.0.0/src/workload_profile_controller/application.py +22 -0
  4. workload_profile_controller-1.0.0/src/workload_profile_controller/backends/aws/backend.py +51 -0
  5. workload_profile_controller-1.0.0/src/workload_profile_controller/backends/aws/config.py +10 -0
  6. workload_profile_controller-1.0.0/src/workload_profile_controller/backends/aws/config_loader.py +28 -0
  7. workload_profile_controller-1.0.0/src/workload_profile_controller/backends/aws/discovery.py +78 -0
  8. workload_profile_controller-1.0.0/src/workload_profile_controller/backends/aws/factory.py +21 -0
  9. workload_profile_controller-1.0.0/src/workload_profile_controller/backends/aws/inventory.py +44 -0
  10. workload_profile_controller-1.0.0/src/workload_profile_controller/backends/aws/resource_client.py +131 -0
  11. workload_profile_controller-1.0.0/src/workload_profile_controller/backends/proxmox/__init__.py +0 -0
  12. {workload_profile_controller-0.1.0 → workload_profile_controller-1.0.0}/src/workload_profile_controller/cli.py +19 -4
  13. {workload_profile_controller-0.1.0 → workload_profile_controller-1.0.0}/src/workload_profile_controller/config.py +2 -1
  14. {workload_profile_controller-0.1.0 → workload_profile_controller-1.0.0}/src/workload_profile_controller/config_loader.py +1 -0
  15. {workload_profile_controller-0.1.0 → workload_profile_controller-1.0.0}/src/workload_profile_controller/config_validator.py +6 -1
  16. {workload_profile_controller-0.1.0 → workload_profile_controller-1.0.0/src/workload_profile_controller.egg-info}/PKG-INFO +2 -1
  17. {workload_profile_controller-0.1.0 → workload_profile_controller-1.0.0}/src/workload_profile_controller.egg-info/SOURCES.txt +8 -0
  18. {workload_profile_controller-0.1.0 → workload_profile_controller-1.0.0}/src/workload_profile_controller.egg-info/requires.txt +1 -0
  19. workload_profile_controller-0.1.0/src/workload_profile_controller/application.py +0 -13
  20. {workload_profile_controller-0.1.0 → workload_profile_controller-1.0.0}/LICENSE +0 -0
  21. {workload_profile_controller-0.1.0 → workload_profile_controller-1.0.0}/README.md +0 -0
  22. {workload_profile_controller-0.1.0 → workload_profile_controller-1.0.0}/setup.cfg +0 -0
  23. {workload_profile_controller-0.1.0 → workload_profile_controller-1.0.0}/src/workload_profile_controller/backend.py +0 -0
  24. {workload_profile_controller-0.1.0 → workload_profile_controller-1.0.0}/src/workload_profile_controller/backends/__init__.py +0 -0
  25. {workload_profile_controller-0.1.0/src/workload_profile_controller/backends/proxmox → workload_profile_controller-1.0.0/src/workload_profile_controller/backends/aws}/__init__.py +0 -0
  26. {workload_profile_controller-0.1.0 → workload_profile_controller-1.0.0}/src/workload_profile_controller/backends/proxmox/auth.py +0 -0
  27. {workload_profile_controller-0.1.0 → workload_profile_controller-1.0.0}/src/workload_profile_controller/backends/proxmox/backend.py +0 -0
  28. {workload_profile_controller-0.1.0 → workload_profile_controller-1.0.0}/src/workload_profile_controller/backends/proxmox/config.py +0 -0
  29. {workload_profile_controller-0.1.0 → workload_profile_controller-1.0.0}/src/workload_profile_controller/backends/proxmox/config_loader.py +0 -0
  30. {workload_profile_controller-0.1.0 → workload_profile_controller-1.0.0}/src/workload_profile_controller/backends/proxmox/discovery.py +0 -0
  31. {workload_profile_controller-0.1.0 → workload_profile_controller-1.0.0}/src/workload_profile_controller/backends/proxmox/errors.py +0 -0
  32. {workload_profile_controller-0.1.0 → workload_profile_controller-1.0.0}/src/workload_profile_controller/backends/proxmox/factory.py +0 -0
  33. {workload_profile_controller-0.1.0 → workload_profile_controller-1.0.0}/src/workload_profile_controller/backends/proxmox/http_client.py +0 -0
  34. {workload_profile_controller-0.1.0 → workload_profile_controller-1.0.0}/src/workload_profile_controller/backends/proxmox/inventory.py +0 -0
  35. {workload_profile_controller-0.1.0 → workload_profile_controller-1.0.0}/src/workload_profile_controller/backends/proxmox/resource_client.py +0 -0
  36. {workload_profile_controller-0.1.0 → workload_profile_controller-1.0.0}/src/workload_profile_controller/backends/proxmox/task.py +0 -0
  37. {workload_profile_controller-0.1.0 → workload_profile_controller-1.0.0}/src/workload_profile_controller/backends/proxmox/task_waiter.py +0 -0
  38. {workload_profile_controller-0.1.0 → workload_profile_controller-1.0.0}/src/workload_profile_controller/controller.py +0 -0
  39. {workload_profile_controller-0.1.0 → workload_profile_controller-1.0.0}/src/workload_profile_controller/errors.py +0 -0
  40. {workload_profile_controller-0.1.0 → workload_profile_controller-1.0.0}/src/workload_profile_controller/policy.py +0 -0
  41. {workload_profile_controller-0.1.0 → workload_profile_controller-1.0.0}/src/workload_profile_controller.egg-info/dependency_links.txt +0 -0
  42. {workload_profile_controller-0.1.0 → workload_profile_controller-1.0.0}/src/workload_profile_controller.egg-info/entry_points.txt +0 -0
  43. {workload_profile_controller-0.1.0 → workload_profile_controller-1.0.0}/src/workload_profile_controller.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: workload-profile-controller
3
- Version: 0.1.0
3
+ Version: 1.0.0
4
4
  Summary: Safe and configurable workload profile controller
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/lack0fcode/workload-profile-controller
@@ -22,6 +22,7 @@ Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
23
  Requires-Dist: PyYAML>=6.0
24
24
  Requires-Dist: python-dotenv>=1.2
25
+ Requires-Dist: boto3>=1.35
25
26
  Provides-Extra: dev
26
27
  Requires-Dist: pytest>=8.0; extra == "dev"
27
28
  Requires-Dist: ruff>=0.12; extra == "dev"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "workload-profile-controller"
7
- version = "0.1.0"
7
+ version = "1.0.0"
8
8
  description = "Safe and configurable workload profile controller"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -34,6 +34,7 @@ classifiers = [
34
34
  dependencies = [
35
35
  "PyYAML>=6.0",
36
36
  "python-dotenv>=1.2",
37
+ "boto3>=1.35",
37
38
  ]
38
39
 
39
40
  [project.optional-dependencies]
@@ -0,0 +1,22 @@
1
+ from .backends.aws.factory import create_aws_backend
2
+ from .backends.proxmox.factory import create_proxmox_backend
3
+ from .config_loader import load_config
4
+ from .controller import Controller
5
+
6
+
7
+ def create_controller(config_path: str) -> Controller:
8
+ config = load_config(config_path)
9
+
10
+ if config.backend == "proxmox":
11
+ backend = create_proxmox_backend()
12
+ elif config.backend == "aws":
13
+ backend = create_aws_backend()
14
+ else:
15
+ raise ValueError(
16
+ f"Unsupported backend: {config.backend}"
17
+ )
18
+
19
+ return Controller(
20
+ config=config,
21
+ backend=backend,
22
+ )
@@ -0,0 +1,51 @@
1
+ from ...backend import Backend, ResourceStatus
2
+ from .discovery import AwsDiscovery
3
+ from .resource_client import AwsResourceClient
4
+
5
+
6
+ class AwsBackend(Backend):
7
+ def __init__(
8
+ self,
9
+ discovery: AwsDiscovery,
10
+ resource_client: AwsResourceClient,
11
+ ):
12
+ self._discovery = discovery
13
+ self._resource_client = resource_client
14
+
15
+ def get_status(self, resource_id: str) -> ResourceStatus:
16
+ inventory = self._discovery.discover()
17
+ resource = inventory.resolve(resource_id)
18
+
19
+ state = self._resource_client.get_instance_state(
20
+ resource.instance_id,
21
+ )
22
+
23
+ if state == "running":
24
+ return ResourceStatus.RUNNING
25
+
26
+ if state == "stopped":
27
+ return ResourceStatus.STOPPED
28
+
29
+ if state == "pending":
30
+ return ResourceStatus.STARTING
31
+
32
+ if state == "stopping":
33
+ return ResourceStatus.STOPPING
34
+
35
+ return ResourceStatus.UNKNOWN
36
+
37
+ def start(self, resource_id: str) -> None:
38
+ inventory = self._discovery.discover()
39
+ resource = inventory.resolve(resource_id)
40
+
41
+ self._resource_client.start_instance(
42
+ resource.instance_id,
43
+ )
44
+
45
+ def stop(self, resource_id: str) -> None:
46
+ inventory = self._discovery.discover()
47
+ resource = inventory.resolve(resource_id)
48
+
49
+ self._resource_client.stop_instance(
50
+ resource.instance_id,
51
+ )
@@ -0,0 +1,10 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class AwsConfig:
6
+ region: str
7
+ profile: str | None = None
8
+ endpoint_url: str | None = None
9
+ timeout: float = 5.0
10
+ operation_timeout: float = 120.0
@@ -0,0 +1,28 @@
1
+ import os
2
+
3
+ from dotenv import load_dotenv
4
+
5
+ from .config import AwsConfig
6
+
7
+
8
+ load_dotenv()
9
+
10
+
11
+ def load_aws_config() -> AwsConfig:
12
+ return AwsConfig(
13
+ region=os.environ["WPC_AWS_REGION"],
14
+ profile=os.environ.get("WPC_AWS_PROFILE"),
15
+ endpoint_url=os.environ.get("WPC_AWS_ENDPOINT_URL"),
16
+ timeout=float(
17
+ os.environ.get(
18
+ "WPC_AWS_TIMEOUT",
19
+ "5.0",
20
+ )
21
+ ),
22
+ operation_timeout=float(
23
+ os.environ.get(
24
+ "WPC_AWS_OPERATION_TIMEOUT",
25
+ "120.0",
26
+ )
27
+ ),
28
+ )
@@ -0,0 +1,78 @@
1
+ from collections.abc import Mapping, Sequence
2
+
3
+ from .inventory import AwsInventory, AwsResource
4
+ from .resource_client import AwsResourceClient
5
+
6
+
7
+ class AwsDiscovery:
8
+ def __init__(self, resource_client: AwsResourceClient):
9
+ self._resource_client = resource_client
10
+
11
+ def discover(self) -> AwsInventory:
12
+ return self.build_inventory(
13
+ reservations=self._resource_client.list_instances(),
14
+ )
15
+
16
+ @staticmethod
17
+ def build_inventory(
18
+ reservations: Sequence[Mapping[str, object]],
19
+ ) -> AwsInventory:
20
+ resources = []
21
+
22
+ for reservation in reservations:
23
+ instances = reservation.get("Instances", [])
24
+
25
+ if not isinstance(instances, Sequence):
26
+ continue
27
+
28
+ for instance in instances:
29
+ if not isinstance(instance, Mapping):
30
+ continue
31
+
32
+ resource = AwsDiscovery._build_resource(instance)
33
+
34
+ if resource is not None:
35
+ resources.append(resource)
36
+
37
+ return AwsInventory(resources)
38
+
39
+ @staticmethod
40
+ def _build_resource(
41
+ data: Mapping[str, object],
42
+ ) -> AwsResource | None:
43
+ instance_id = data.get("InstanceId")
44
+ tags = data.get("Tags", [])
45
+
46
+ if not isinstance(instance_id, str):
47
+ return None
48
+
49
+ name = AwsDiscovery._get_name_tag(tags)
50
+
51
+ if name is None:
52
+ return None
53
+
54
+ return AwsResource(
55
+ instance_id=instance_id,
56
+ name=name,
57
+ )
58
+
59
+ @staticmethod
60
+ def _get_name_tag(
61
+ tags: object,
62
+ ) -> str | None:
63
+ if not isinstance(tags, Sequence):
64
+ return None
65
+
66
+ for tag in tags:
67
+ if not isinstance(tag, Mapping):
68
+ continue
69
+
70
+ if tag.get("Key") == "Name":
71
+ value = tag.get("Value")
72
+
73
+ if isinstance(value, str):
74
+ return value
75
+
76
+ return None
77
+
78
+ return None
@@ -0,0 +1,21 @@
1
+ from .backend import AwsBackend
2
+ from .config_loader import load_aws_config
3
+ from .discovery import AwsDiscovery
4
+ from .resource_client import AwsResourceClient
5
+
6
+
7
+ def create_aws_backend() -> AwsBackend:
8
+ config = load_aws_config()
9
+
10
+ resource_client = AwsResourceClient(
11
+ config=config,
12
+ )
13
+
14
+ discovery = AwsDiscovery(
15
+ resource_client=resource_client,
16
+ )
17
+
18
+ return AwsBackend(
19
+ discovery=discovery,
20
+ resource_client=resource_client,
21
+ )
@@ -0,0 +1,44 @@
1
+ from dataclasses import dataclass
2
+
3
+ from ...errors import ResourceAmbiguousError, ResourceNotFoundError
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class AwsResource:
8
+ instance_id: str
9
+ name: str
10
+
11
+
12
+ class AwsInventory:
13
+ def __init__(self, resources: list[AwsResource]):
14
+ self._resources = tuple(
15
+ sorted(
16
+ resources,
17
+ key=lambda resource: (
18
+ resource.name,
19
+ resource.instance_id,
20
+ ),
21
+ )
22
+ )
23
+
24
+ def list_resources(self) -> tuple[AwsResource, ...]:
25
+ return self._resources
26
+
27
+ def resolve(self, name: str) -> AwsResource:
28
+ matches = tuple(
29
+ resource
30
+ for resource in self._resources
31
+ if resource.name == name
32
+ )
33
+
34
+ if not matches:
35
+ raise ResourceNotFoundError(
36
+ f"AWS resource not found: {name}"
37
+ )
38
+
39
+ if len(matches) > 1:
40
+ raise ResourceAmbiguousError(
41
+ f"AWS resource name is ambiguous: {name}"
42
+ )
43
+
44
+ return matches[0]
@@ -0,0 +1,131 @@
1
+ import time
2
+ from collections.abc import Mapping
3
+
4
+ import boto3
5
+
6
+ from ...errors import InvalidResourceStateError, TaskTimeoutError
7
+ from .config import AwsConfig
8
+
9
+
10
+ class AwsResourceClient:
11
+ def __init__(self, config: AwsConfig):
12
+ session = boto3.Session(
13
+ profile_name=config.profile,
14
+ region_name=config.region,
15
+ )
16
+
17
+ self._client = session.client(
18
+ "ec2",
19
+ endpoint_url=config.endpoint_url,
20
+ )
21
+
22
+ self._operation_timeout = config.operation_timeout
23
+
24
+ def list_instances(self):
25
+ paginator = self._client.get_paginator(
26
+ "describe_instances",
27
+ )
28
+
29
+ reservations = []
30
+
31
+ for page in paginator.paginate():
32
+ reservations.extend(page["Reservations"])
33
+
34
+ return reservations
35
+
36
+ def get_instance_state(self, instance_id: str) -> str:
37
+ response = self._client.describe_instances(
38
+ InstanceIds=[instance_id],
39
+ )
40
+
41
+ reservations = response.get("Reservations", [])
42
+
43
+ if not isinstance(reservations, list):
44
+ raise InvalidResourceStateError(
45
+ f"AWS instance has no state: {instance_id}",
46
+ )
47
+
48
+ for reservation in reservations:
49
+ if not isinstance(reservation, Mapping):
50
+ continue
51
+
52
+ instances = reservation.get("Instances", [])
53
+
54
+ if not isinstance(instances, list):
55
+ continue
56
+
57
+ for instance in instances:
58
+ if not isinstance(instance, Mapping):
59
+ continue
60
+
61
+ if instance.get("InstanceId") != instance_id:
62
+ continue
63
+
64
+ state = instance.get("State", {})
65
+
66
+ if not isinstance(state, Mapping):
67
+ break
68
+
69
+ state_name = state.get("Name")
70
+
71
+ if isinstance(state_name, str):
72
+ return state_name
73
+
74
+ break
75
+
76
+ raise InvalidResourceStateError(
77
+ f"AWS instance has no state: {instance_id}",
78
+ )
79
+
80
+ def start_instance(self, instance_id: str) -> None:
81
+ self._client.start_instances(
82
+ InstanceIds=[instance_id],
83
+ )
84
+
85
+ self._wait_for_state(
86
+ instance_id=instance_id,
87
+ desired_state="running",
88
+ transitional_state="pending",
89
+ )
90
+
91
+ def stop_instance(self, instance_id: str) -> None:
92
+ self._client.stop_instances(
93
+ InstanceIds=[instance_id],
94
+ )
95
+
96
+ self._wait_for_state(
97
+ instance_id=instance_id,
98
+ desired_state="stopped",
99
+ transitional_state="stopping",
100
+ )
101
+
102
+ def _wait_for_state(
103
+ self,
104
+ instance_id: str,
105
+ desired_state: str,
106
+ transitional_state: str,
107
+ ) -> None:
108
+ started_at = time.monotonic()
109
+
110
+ while True:
111
+ state = self.get_instance_state(instance_id)
112
+
113
+ if state == desired_state:
114
+ return
115
+
116
+ if state != transitional_state:
117
+ raise InvalidResourceStateError(
118
+ "AWS instance entered unexpected state: "
119
+ f"{instance_id}: {state}",
120
+ )
121
+
122
+ if (
123
+ time.monotonic() - started_at
124
+ >= self._operation_timeout
125
+ ):
126
+ raise TaskTimeoutError(
127
+ "AWS instance operation timed out: "
128
+ f"{instance_id}",
129
+ )
130
+
131
+ time.sleep(1.0)
@@ -31,14 +31,19 @@ def create_parser() -> argparse.ArgumentParser:
31
31
  help="Show the current resource status.",
32
32
  )
33
33
 
34
+ subparsers.add_parser(
35
+ "reconcile",
36
+ help="Detect the current profile.",
37
+ )
38
+
34
39
  plan_parser = subparsers.add_parser(
35
- "plan",
36
- help="Plan a transition without changing resources.",
40
+ "plan",
41
+ help="Plan a transition without changing resources.",
37
42
  )
38
43
 
39
44
  plan_parser.add_argument(
40
- "target_profile",
41
- help="Target profile to plan.",
45
+ "target_profile",
46
+ help="Target profile to plan.",
42
47
  )
43
48
 
44
49
  transition_parser = subparsers.add_parser(
@@ -79,6 +84,16 @@ def main(argv=None) -> int:
79
84
 
80
85
  return 0
81
86
 
87
+ if args.command == "reconcile":
88
+ controller = create_controller(args.config)
89
+ current_profile = controller.reconcile()
90
+
91
+ print(
92
+ f"Current profile: {current_profile}"
93
+ )
94
+
95
+ return 0
96
+
82
97
  if args.command == "plan":
83
98
  controller = create_controller(args.config)
84
99
 
@@ -18,4 +18,5 @@ class ProfileConfig:
18
18
  @dataclass(frozen=True)
19
19
  class Config:
20
20
  resources: dict[str, ResourceConfig]
21
- profiles: dict[str, ProfileConfig]
21
+ profiles: dict[str, ProfileConfig]
22
+ backend: str = "proxmox"
@@ -33,6 +33,7 @@ def load_config(path: str | Path) -> Config:
33
33
  config = Config(
34
34
  resources=resources,
35
35
  profiles=profiles,
36
+ backend=data.get("backend", "proxmox"),
36
37
  )
37
38
 
38
39
  validate_config(config)
@@ -1,7 +1,12 @@
1
1
  from .config import Config
2
2
 
3
-
4
3
  def validate_config(config: Config) -> None:
4
+ # R-11 — Supported backend
5
+ if config.backend not in {"proxmox", "aws"}:
6
+ raise ValueError(
7
+ f"Unsupported backend: {config.backend}"
8
+ )
9
+
5
10
  # R-01 — At least one resource
6
11
  if not config.resources:
7
12
  raise ValueError("At least one resource is required")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: workload-profile-controller
3
- Version: 0.1.0
3
+ Version: 1.0.0
4
4
  Summary: Safe and configurable workload profile controller
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/lack0fcode/workload-profile-controller
@@ -22,6 +22,7 @@ Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
23
  Requires-Dist: PyYAML>=6.0
24
24
  Requires-Dist: python-dotenv>=1.2
25
+ Requires-Dist: boto3>=1.35
25
26
  Provides-Extra: dev
26
27
  Requires-Dist: pytest>=8.0; extra == "dev"
27
28
  Requires-Dist: ruff>=0.12; extra == "dev"
@@ -17,6 +17,14 @@ src/workload_profile_controller.egg-info/entry_points.txt
17
17
  src/workload_profile_controller.egg-info/requires.txt
18
18
  src/workload_profile_controller.egg-info/top_level.txt
19
19
  src/workload_profile_controller/backends/__init__.py
20
+ src/workload_profile_controller/backends/aws/__init__.py
21
+ src/workload_profile_controller/backends/aws/backend.py
22
+ src/workload_profile_controller/backends/aws/config.py
23
+ src/workload_profile_controller/backends/aws/config_loader.py
24
+ src/workload_profile_controller/backends/aws/discovery.py
25
+ src/workload_profile_controller/backends/aws/factory.py
26
+ src/workload_profile_controller/backends/aws/inventory.py
27
+ src/workload_profile_controller/backends/aws/resource_client.py
20
28
  src/workload_profile_controller/backends/proxmox/__init__.py
21
29
  src/workload_profile_controller/backends/proxmox/auth.py
22
30
  src/workload_profile_controller/backends/proxmox/backend.py
@@ -1,5 +1,6 @@
1
1
  PyYAML>=6.0
2
2
  python-dotenv>=1.2
3
+ boto3>=1.35
3
4
 
4
5
  [dev]
5
6
  pytest>=8.0
@@ -1,13 +0,0 @@
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
- )