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.
- workload_profile_controller/application.py +13 -0
- workload_profile_controller/backend.py +21 -0
- workload_profile_controller/backends/__init__.py +0 -0
- workload_profile_controller/backends/proxmox/__init__.py +0 -0
- workload_profile_controller/backends/proxmox/auth.py +15 -0
- workload_profile_controller/backends/proxmox/backend.py +97 -0
- workload_profile_controller/backends/proxmox/config.py +16 -0
- workload_profile_controller/backends/proxmox/config_loader.py +46 -0
- workload_profile_controller/backends/proxmox/discovery.py +54 -0
- workload_profile_controller/backends/proxmox/errors.py +13 -0
- workload_profile_controller/backends/proxmox/factory.py +43 -0
- workload_profile_controller/backends/proxmox/http_client.py +107 -0
- workload_profile_controller/backends/proxmox/inventory.py +52 -0
- workload_profile_controller/backends/proxmox/resource_client.py +87 -0
- workload_profile_controller/backends/proxmox/task.py +16 -0
- workload_profile_controller/backends/proxmox/task_waiter.py +53 -0
- workload_profile_controller/cli.py +154 -0
- workload_profile_controller/config.py +21 -0
- workload_profile_controller/config_loader.py +40 -0
- workload_profile_controller/config_validator.py +106 -0
- workload_profile_controller/controller.py +154 -0
- workload_profile_controller/errors.py +26 -0
- workload_profile_controller/policy.py +67 -0
- workload_profile_controller-0.1.0.dist-info/METADATA +500 -0
- workload_profile_controller-0.1.0.dist-info/RECORD +29 -0
- workload_profile_controller-0.1.0.dist-info/WHEEL +5 -0
- workload_profile_controller-0.1.0.dist-info/entry_points.txt +2 -0
- workload_profile_controller-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
)
|