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,154 @@
1
+ import argparse
2
+
3
+ from .application import create_controller
4
+ from .config_loader import load_config
5
+
6
+
7
+ def create_parser() -> argparse.ArgumentParser:
8
+ parser = argparse.ArgumentParser(
9
+ prog="wpc",
10
+ description="Safe and configurable workload profile controller.",
11
+ )
12
+
13
+ parser.add_argument(
14
+ "--config",
15
+ required=True,
16
+ help="Path to the profile configuration file.",
17
+ )
18
+
19
+ subparsers = parser.add_subparsers(
20
+ dest="command",
21
+ required=True,
22
+ )
23
+
24
+ subparsers.add_parser(
25
+ "profiles",
26
+ help="List configured profiles.",
27
+ )
28
+
29
+ subparsers.add_parser(
30
+ "status",
31
+ help="Show the current resource status.",
32
+ )
33
+
34
+ plan_parser = subparsers.add_parser(
35
+ "plan",
36
+ help="Plan a transition without changing resources.",
37
+ )
38
+
39
+ plan_parser.add_argument(
40
+ "target_profile",
41
+ help="Target profile to plan.",
42
+ )
43
+
44
+ transition_parser = subparsers.add_parser(
45
+ "transition",
46
+ help="Transition to a configured profile.",
47
+ )
48
+
49
+ transition_parser.add_argument(
50
+ "target_profile",
51
+ help="Target profile to transition to.",
52
+ )
53
+
54
+ return parser
55
+
56
+
57
+ def main(argv=None) -> int:
58
+ parser = create_parser()
59
+ args = parser.parse_args(argv)
60
+
61
+ if args.command == "profiles":
62
+ config = load_config(args.config)
63
+
64
+ for profile in config.profiles.values():
65
+ print(
66
+ f"{profile.name}\t{profile.description}"
67
+ )
68
+
69
+ return 0
70
+
71
+ if args.command == "status":
72
+ controller = create_controller(args.config)
73
+ statuses = controller.get_resource_statuses()
74
+
75
+ for resource_id, status in statuses.items():
76
+ print(
77
+ f"{resource_id}\t{status.value}"
78
+ )
79
+
80
+ return 0
81
+
82
+ if args.command == "plan":
83
+ controller = create_controller(args.config)
84
+
85
+ current_profile = controller.reconcile()
86
+ target_profile = args.target_profile
87
+
88
+ if not controller.has_profile(target_profile):
89
+ parser.error(
90
+ f"Unknown profile: {target_profile}"
91
+ )
92
+
93
+ plan = controller.build_transition_plan(
94
+ current_profile,
95
+ target_profile,
96
+ )
97
+
98
+ print(f"Current profile: {current_profile}")
99
+ print(f"Target profile: {target_profile}")
100
+ print()
101
+ print("Plan:")
102
+
103
+ if not plan.actions:
104
+ print(" no changes required")
105
+ else:
106
+ for action in plan.actions:
107
+ print(
108
+ f" {action.action.value} "
109
+ f"{action.resource_id}"
110
+ )
111
+
112
+ print()
113
+ print("No changes were made.")
114
+
115
+ return 0
116
+
117
+ if args.command == "transition":
118
+ controller = create_controller(args.config)
119
+
120
+ current_profile = controller.reconcile()
121
+ target_profile = args.target_profile
122
+
123
+ if not controller.has_profile(target_profile):
124
+ parser.error(
125
+ f"Unknown profile: {target_profile}"
126
+ )
127
+
128
+ if current_profile == target_profile:
129
+ print(
130
+ f"Already in profile: {target_profile}"
131
+ )
132
+ return 0
133
+
134
+ print(
135
+ f"Transitioning from "
136
+ f"{current_profile} to {target_profile}..."
137
+ )
138
+
139
+ controller.transition(
140
+ current_profile,
141
+ target_profile,
142
+ )
143
+
144
+ print(
145
+ f"Transition completed: {target_profile}"
146
+ )
147
+
148
+ return 0
149
+
150
+ parser.error(f"Unknown command: {args.command}")
151
+
152
+
153
+ if __name__ == "__main__":
154
+ raise SystemExit(main())
@@ -0,0 +1,21 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class ResourceConfig:
6
+ name: str
7
+ description: str
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class ProfileConfig:
12
+ name: str
13
+ description: str
14
+ running: tuple[str, ...]
15
+ stopped: tuple[str, ...]
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Config:
20
+ resources: dict[str, ResourceConfig]
21
+ profiles: dict[str, ProfileConfig]
@@ -0,0 +1,40 @@
1
+ from pathlib import Path
2
+
3
+ import yaml
4
+
5
+ from .config import Config, ProfileConfig, ResourceConfig
6
+ from .config_validator import validate_config
7
+
8
+
9
+ def load_config(path: str | Path) -> Config:
10
+ config_path = Path(path)
11
+
12
+ with config_path.open("r", encoding="utf-8") as file:
13
+ data = yaml.safe_load(file)
14
+
15
+ resources = {
16
+ resource_id: ResourceConfig(
17
+ name=resource_id,
18
+ description=resource_data.get("description", ""),
19
+ )
20
+ for resource_id, resource_data in data["resources"].items()
21
+ }
22
+
23
+ profiles = {
24
+ name: ProfileConfig(
25
+ name=name,
26
+ description=profile_data.get("description", ""),
27
+ running=tuple(profile_data.get("running", [])),
28
+ stopped=tuple(profile_data.get("stopped", [])),
29
+ )
30
+ for name, profile_data in data["profiles"].items()
31
+ }
32
+
33
+ config = Config(
34
+ resources=resources,
35
+ profiles=profiles,
36
+ )
37
+
38
+ validate_config(config)
39
+
40
+ return config
@@ -0,0 +1,106 @@
1
+ from .config import Config
2
+
3
+
4
+ def validate_config(config: Config) -> None:
5
+ # R-01 — At least one resource
6
+ if not config.resources:
7
+ raise ValueError("At least one resource is required")
8
+
9
+ # R-02 — Resource identity
10
+ for resource_id, resource in config.resources.items():
11
+ if not isinstance(resource_id, str) or not resource_id.strip():
12
+ raise ValueError("Resource ID must not be empty")
13
+
14
+ if not isinstance(resource.name, str) or not resource.name.strip():
15
+ raise ValueError(
16
+ f"Resource name must not be empty: {resource_id}"
17
+ )
18
+
19
+ # At least one profile
20
+ if not config.profiles:
21
+ raise ValueError("At least one profile is required")
22
+
23
+ managed_resources = set(config.resources)
24
+
25
+ # Validate every profile
26
+ for profile_name, profile in config.profiles.items():
27
+ # R-03 — Profile name must match dictionary key
28
+ if profile.name != profile_name:
29
+ raise ValueError(
30
+ f"Profile name mismatch: key={profile_name}, "
31
+ f"name={profile.name}"
32
+ )
33
+
34
+ running = profile.running
35
+ stopped = profile.stopped
36
+
37
+ # R-04 — All referenced resources must exist
38
+ referenced_resources = set(running) | set(stopped)
39
+
40
+ unknown_resources = referenced_resources - managed_resources
41
+
42
+ if unknown_resources:
43
+ raise ValueError(
44
+ f"Profile {profile_name} references unknown resources: "
45
+ f"{sorted(unknown_resources)}"
46
+ )
47
+
48
+ # R-05 — Mutual exclusivity
49
+ conflicting_resources = set(running) & set(stopped)
50
+
51
+ if conflicting_resources:
52
+ raise ValueError(
53
+ f"Profile {profile_name} has resources in both states: "
54
+ f"{sorted(conflicting_resources)}"
55
+ )
56
+
57
+ # R-06 — No duplicates
58
+ if len(running) != len(set(running)):
59
+ raise ValueError(
60
+ f"Profile {profile_name} contains duplicate resources in running"
61
+ )
62
+
63
+ if len(stopped) != len(set(stopped)):
64
+ raise ValueError(
65
+ f"Profile {profile_name} contains duplicate resources in stopped"
66
+ )
67
+
68
+ # R-07 — Exactly one state per managed resource
69
+ if referenced_resources != managed_resources:
70
+ missing_resources = managed_resources - referenced_resources
71
+
72
+ if missing_resources:
73
+ raise ValueError(
74
+ f"Profile {profile_name} does not define a state "
75
+ f"for resources: {sorted(missing_resources)}"
76
+ )
77
+
78
+ # R-10 — No empty profile
79
+ if not running and not stopped:
80
+ raise ValueError(
81
+ f"Profile {profile_name} cannot be empty"
82
+ )
83
+
84
+ # R-08 — Same resource universe across all profiles
85
+ profile_universes = {
86
+ profile_name: set(profile.running) | set(profile.stopped)
87
+ for profile_name, profile in config.profiles.items()
88
+ }
89
+
90
+ universes = list(profile_universes.values())
91
+ reference_universe = universes[0]
92
+
93
+ for profile_name, universe in profile_universes.items():
94
+ if universe != reference_universe:
95
+ raise ValueError(
96
+ f"Profile {profile_name} manages a different resource universe"
97
+ )
98
+
99
+ # R-09 — Complete deterministic state
100
+ for profile_name, profile in config.profiles.items():
101
+ universe = set(profile.running) | set(profile.stopped)
102
+
103
+ if universe != managed_resources:
104
+ raise ValueError(
105
+ f"Profile {profile_name} does not define a complete state"
106
+ )
@@ -0,0 +1,154 @@
1
+ from enum import Enum
2
+
3
+ from .backend import ResourceStatus
4
+ from .policy import Policy
5
+
6
+
7
+ class ControllerState(Enum):
8
+ IDLE = "idle"
9
+ TRANSITIONING = "transitioning"
10
+ LOCKED = "locked"
11
+
12
+
13
+ class Controller:
14
+ def __init__(self, config, backend):
15
+ self._config = config
16
+ self._backend = backend
17
+ self._policy = Policy(config)
18
+ self.state = ControllerState.IDLE
19
+
20
+ def get_profile(self, name):
21
+ return self._policy.get_profile(name)
22
+
23
+ def has_profile(self, name):
24
+ return self._policy.has_profile(name)
25
+
26
+ def build_transition_plan(self, source_profile, target_profile):
27
+ return self._policy.build_transition_plan(
28
+ source_profile,
29
+ target_profile,
30
+ )
31
+
32
+ def transition(self, source_profile, target_profile):
33
+ if self.state == ControllerState.LOCKED:
34
+ raise RuntimeError("Controller is locked")
35
+
36
+ self._validate_profile_state(source_profile)
37
+ self._validate_profile_resources(target_profile)
38
+
39
+ self.state = ControllerState.TRANSITIONING
40
+
41
+ try:
42
+ plan = self.build_transition_plan(
43
+ source_profile,
44
+ target_profile,
45
+ )
46
+
47
+ for action in plan.actions:
48
+ if action.action.value == "stop":
49
+ self._backend.stop(action.resource_id)
50
+
51
+ status = self._backend.get_status(action.resource_id)
52
+
53
+ if status != ResourceStatus.STOPPED:
54
+ raise RuntimeError(
55
+ f"Resource did not stop: {action.resource_id}"
56
+ )
57
+
58
+ elif action.action.value == "start":
59
+ self._backend.start(action.resource_id)
60
+
61
+ status = self._backend.get_status(action.resource_id)
62
+
63
+ if status != ResourceStatus.RUNNING:
64
+ raise RuntimeError(
65
+ f"Resource did not start: {action.resource_id}"
66
+ )
67
+
68
+ self._validate_target_profile_state(target_profile)
69
+
70
+ self.state = ControllerState.IDLE
71
+
72
+ except Exception:
73
+ self.state = ControllerState.LOCKED
74
+ raise
75
+
76
+ def _validate_profile_resources(self, profile_name):
77
+ profile = self._policy.get_profile(profile_name)
78
+
79
+ for resource_id in profile.running:
80
+ self._backend.get_status(resource_id)
81
+
82
+ for resource_id in profile.stopped:
83
+ self._backend.get_status(resource_id)
84
+
85
+ def _validate_profile_state(self, profile_name):
86
+ profile = self._policy.get_profile(profile_name)
87
+
88
+ for resource_id in profile.running:
89
+ status = self._backend.get_status(resource_id)
90
+
91
+ if status != ResourceStatus.RUNNING:
92
+ raise RuntimeError("Source profile is not active")
93
+
94
+ for resource_id in profile.stopped:
95
+ status = self._backend.get_status(resource_id)
96
+
97
+ if status != ResourceStatus.STOPPED:
98
+ raise RuntimeError("Source profile is not active")
99
+
100
+ def _validate_target_profile_state(self, profile_name):
101
+ profile = self._policy.get_profile(profile_name)
102
+
103
+ for resource_id in profile.running:
104
+ status = self._backend.get_status(resource_id)
105
+
106
+ if status != ResourceStatus.RUNNING:
107
+ raise RuntimeError("Target profile is not active")
108
+
109
+ for resource_id in profile.stopped:
110
+ status = self._backend.get_status(resource_id)
111
+
112
+ if status != ResourceStatus.STOPPED:
113
+ raise RuntimeError("Target profile is not active")
114
+
115
+ def get_resource_statuses(self):
116
+ statuses = {}
117
+
118
+ for resource_id in self._config.resources:
119
+ statuses[resource_id] = self._backend.get_status(resource_id)
120
+
121
+ return statuses
122
+
123
+ def reconcile(self):
124
+ for profile_name in self._policy.list_profiles():
125
+ profile = self._policy.get_profile(profile_name)
126
+
127
+ matches = True
128
+
129
+ for resource_id in profile.running:
130
+ status = self._backend.get_status(resource_id)
131
+
132
+ if status != ResourceStatus.RUNNING:
133
+ matches = False
134
+ break
135
+
136
+ if not matches:
137
+ continue
138
+
139
+ for resource_id in profile.stopped:
140
+ status = self._backend.get_status(resource_id)
141
+
142
+ if status != ResourceStatus.STOPPED:
143
+ matches = False
144
+ break
145
+
146
+ if matches:
147
+ self.state = ControllerState.IDLE
148
+ return profile_name
149
+
150
+ self.state = ControllerState.LOCKED
151
+
152
+ raise RuntimeError(
153
+ "Current resource state does not match any profile"
154
+ )
@@ -0,0 +1,26 @@
1
+ class BackendError(Exception):
2
+ """Base exception for backend failures."""
3
+
4
+
5
+ class ResourceNotFoundError(BackendError):
6
+ """Raised when a resource cannot be resolved."""
7
+
8
+
9
+ class ResourceAmbiguousError(BackendError):
10
+ """Raised when a resource resolves to multiple infrastructure resources."""
11
+
12
+
13
+ class BackendUnavailableError(BackendError):
14
+ """Raised when the backend cannot be reached or used."""
15
+
16
+
17
+ class InvalidResourceStateError(BackendError):
18
+ """Raised when a resource is in an invalid state for an operation."""
19
+
20
+
21
+ class TaskTimeoutError(Exception):
22
+ """Raised when a backend task does not finish before the timeout."""
23
+
24
+
25
+ class TaskFailedError(Exception):
26
+ """Raised when a backend task finishes unsuccessfully."""
@@ -0,0 +1,67 @@
1
+ from dataclasses import dataclass
2
+ from enum import Enum
3
+
4
+ from .config import Config, ProfileConfig
5
+
6
+
7
+ class ActionType(Enum):
8
+ STOP = "stop"
9
+ START = "start"
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class TransitionAction:
14
+ action: ActionType
15
+ resource_id: str
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class TransitionPlan:
20
+ actions: tuple[TransitionAction, ...]
21
+
22
+
23
+ class Policy:
24
+ def __init__(self, config: Config):
25
+ self._config = config
26
+
27
+ def get_profile(self, name: str) -> ProfileConfig:
28
+ if name not in self._config.profiles:
29
+ raise ValueError(f"Unknown profile: {name}")
30
+
31
+ return self._config.profiles[name]
32
+
33
+ def list_profiles(self) -> tuple[str, ...]:
34
+ return tuple(self._config.profiles.keys())
35
+
36
+ def has_profile(self, name: str) -> bool:
37
+ return name in self._config.profiles
38
+
39
+ def build_transition_plan(
40
+ self,
41
+ source_profile: str,
42
+ target_profile: str,
43
+ ) -> TransitionPlan:
44
+ source = self.get_profile(source_profile)
45
+ target = self.get_profile(target_profile)
46
+
47
+ source_running = set(source.running)
48
+ target_running = set(target.running)
49
+
50
+ stop = tuple(
51
+ resource_id
52
+ for resource_id in source.running
53
+ if resource_id not in target_running
54
+ )
55
+
56
+ start = tuple(
57
+ resource_id
58
+ for resource_id in target.running
59
+ if resource_id not in source_running
60
+ )
61
+
62
+ return TransitionPlan(
63
+ actions=(
64
+ *(TransitionAction(ActionType.STOP, resource_id) for resource_id in stop),
65
+ *(TransitionAction(ActionType.START, resource_id) for resource_id in start),
66
+ )
67
+ )