robotframework-okw-env 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.
- okw_env/__init__.py +12 -0
- okw_env/environment_manager.py +133 -0
- okw_env/library.py +175 -0
- okw_env/provider_base.py +51 -0
- okw_env/provider_registry.py +49 -0
- okw_env/yaml_loader.py +78 -0
- robotframework_okw_env-0.1.0.dist-info/METADATA +119 -0
- robotframework_okw_env-0.1.0.dist-info/RECORD +10 -0
- robotframework_okw_env-0.1.0.dist-info/WHEEL +5 -0
- robotframework_okw_env-0.1.0.dist-info/top_level.txt +1 -0
okw_env/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from .provider_base import OkwEnvProviderBase
|
|
2
|
+
from .provider_registry import get_provider, discover_providers
|
|
3
|
+
from .environment_manager import EnvironmentManager
|
|
4
|
+
from .library import OkwEnvLibrary
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"OkwEnvProviderBase",
|
|
8
|
+
"EnvironmentManager",
|
|
9
|
+
"OkwEnvLibrary",
|
|
10
|
+
"get_provider",
|
|
11
|
+
"discover_providers",
|
|
12
|
+
]
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Manages registered environment components and delegates to providers."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from okw_env.provider_base import OkwEnvProviderBase
|
|
6
|
+
from okw_env.provider_registry import get_provider
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EnvironmentManager:
|
|
10
|
+
"""Tracks all registered components and orchestrates provider calls.
|
|
11
|
+
|
|
12
|
+
Each component can use a different provider (determined by the
|
|
13
|
+
``provider`` key in the component YAML). Providers are resolved
|
|
14
|
+
automatically via the entry point registry.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self._components: dict[str, dict] = {}
|
|
19
|
+
self._component_ids: dict[str, str] = {}
|
|
20
|
+
self._providers: dict[str, OkwEnvProviderBase] = {}
|
|
21
|
+
self._default_provider: OkwEnvProviderBase | None = None
|
|
22
|
+
|
|
23
|
+
def set_provider(self, provider: OkwEnvProviderBase) -> None:
|
|
24
|
+
"""Set a default provider (used when YAML has no 'provider' key)."""
|
|
25
|
+
self._default_provider = provider
|
|
26
|
+
|
|
27
|
+
def register(self, name: str, component: dict, **overrides) -> None:
|
|
28
|
+
"""Register a component for later provisioning."""
|
|
29
|
+
merged = {**component, **overrides}
|
|
30
|
+
self._components[name] = merged
|
|
31
|
+
|
|
32
|
+
def build_and_run(self) -> None:
|
|
33
|
+
"""Create and start all registered components."""
|
|
34
|
+
for name, component in self._components.items():
|
|
35
|
+
provider = self._resolve_provider(name, component)
|
|
36
|
+
component_id = provider.create(component)
|
|
37
|
+
self._component_ids[name] = component_id
|
|
38
|
+
self._providers[name] = provider
|
|
39
|
+
provider.start(component_id)
|
|
40
|
+
runtime_info = provider.get_runtime_info(component_id)
|
|
41
|
+
if runtime_info:
|
|
42
|
+
component.update(runtime_info)
|
|
43
|
+
|
|
44
|
+
def wait_for_ready(self, name: str, timeout: float = 30.0, interval: float = 1.0) -> None:
|
|
45
|
+
"""Poll until the component is ready or timeout is reached."""
|
|
46
|
+
provider = self._require_provider(name)
|
|
47
|
+
component_id = self._require_component_id(name)
|
|
48
|
+
deadline = time.monotonic() + timeout
|
|
49
|
+
while time.monotonic() < deadline:
|
|
50
|
+
if provider.is_ready(component_id):
|
|
51
|
+
return
|
|
52
|
+
time.sleep(interval)
|
|
53
|
+
raise TimeoutError(f"Component '{name}' not ready after {timeout}s.")
|
|
54
|
+
|
|
55
|
+
def get_logs(self, name: str) -> str:
|
|
56
|
+
"""Retrieve logs from a component."""
|
|
57
|
+
provider = self._require_provider(name)
|
|
58
|
+
component_id = self._require_component_id(name)
|
|
59
|
+
return provider.get_logs(component_id)
|
|
60
|
+
|
|
61
|
+
def snapshot(self, name: str) -> str:
|
|
62
|
+
"""Snapshot a component's current state."""
|
|
63
|
+
provider = self._require_provider(name)
|
|
64
|
+
component_id = self._require_component_id(name)
|
|
65
|
+
return provider.snapshot(component_id)
|
|
66
|
+
|
|
67
|
+
def snapshot_all(self) -> dict[str, str]:
|
|
68
|
+
"""Snapshot all running components. Returns {name: snapshot_id}."""
|
|
69
|
+
results = {}
|
|
70
|
+
for name, component_id in self._component_ids.items():
|
|
71
|
+
provider = self._require_provider(name)
|
|
72
|
+
results[name] = provider.snapshot(component_id)
|
|
73
|
+
return results
|
|
74
|
+
|
|
75
|
+
def stop_all(self) -> None:
|
|
76
|
+
"""Stop and destroy all components."""
|
|
77
|
+
for name, component_id in list(self._component_ids.items()):
|
|
78
|
+
provider = self._providers[name]
|
|
79
|
+
provider.stop(component_id)
|
|
80
|
+
provider.destroy(component_id)
|
|
81
|
+
self._component_ids.clear()
|
|
82
|
+
self._components.clear()
|
|
83
|
+
self._providers.clear()
|
|
84
|
+
|
|
85
|
+
def get_component_config(self, name: str) -> dict:
|
|
86
|
+
"""Return the registered component definition."""
|
|
87
|
+
if name not in self._components:
|
|
88
|
+
raise KeyError(f"Component '{name}' not registered. Registered: {list(self._components.keys())}")
|
|
89
|
+
return self._components[name]
|
|
90
|
+
|
|
91
|
+
def get_mem_entries(self) -> dict[str, str]:
|
|
92
|
+
"""Flatten all component configs into dot-notation for $MEM{} expansion.
|
|
93
|
+
|
|
94
|
+
Returns a dict like:
|
|
95
|
+
{"PostgresDB.image": "postgres", "PostgresDB.env.POSTGRES_DB": "shop", ...}
|
|
96
|
+
"""
|
|
97
|
+
entries = {}
|
|
98
|
+
for name, config in self._components.items():
|
|
99
|
+
if name in self._component_ids:
|
|
100
|
+
entries[f"{name}.ID"] = self._component_ids[name]
|
|
101
|
+
self._flatten(name, config, entries)
|
|
102
|
+
return entries
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _flatten(prefix: str, data: dict, result: dict) -> None:
|
|
106
|
+
for key, value in data.items():
|
|
107
|
+
full_key = f"{prefix}.{key}"
|
|
108
|
+
if isinstance(value, dict):
|
|
109
|
+
EnvironmentManager._flatten(full_key, value, result)
|
|
110
|
+
else:
|
|
111
|
+
result[full_key] = str(value)
|
|
112
|
+
|
|
113
|
+
def _resolve_provider(self, name: str, component: dict) -> OkwEnvProviderBase:
|
|
114
|
+
"""Resolve provider from component YAML or fall back to default."""
|
|
115
|
+
provider_name = component.get("provider")
|
|
116
|
+
if provider_name:
|
|
117
|
+
return get_provider(provider_name, component=component)
|
|
118
|
+
if self._default_provider:
|
|
119
|
+
return self._default_provider
|
|
120
|
+
raise RuntimeError(
|
|
121
|
+
f"Component '{name}' has no 'provider' key and no default provider is set. "
|
|
122
|
+
f"Add 'provider: docker' to the YAML or call set_provider()."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def _require_provider(self, name: str) -> OkwEnvProviderBase:
|
|
126
|
+
if name not in self._providers:
|
|
127
|
+
raise KeyError(f"No provider for '{name}'. Was build_and_run() called?")
|
|
128
|
+
return self._providers[name]
|
|
129
|
+
|
|
130
|
+
def _require_component_id(self, name: str) -> str:
|
|
131
|
+
if name not in self._component_ids:
|
|
132
|
+
raise KeyError(f"Component '{name}' not found. Registered: {list(self._component_ids.keys())}")
|
|
133
|
+
return self._component_ids[name]
|
okw_env/library.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""OKW Environment Library – Robot Framework keywords for environment provisioning."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from robot.api import logger
|
|
8
|
+
from robot.api.deco import keyword, library
|
|
9
|
+
|
|
10
|
+
from .environment_manager import EnvironmentManager
|
|
11
|
+
from .yaml_loader import load_component
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@library(scope="GLOBAL")
|
|
15
|
+
class OkwEnvLibrary:
|
|
16
|
+
"""Driver-agnostic Robot Framework library for ephemeral test environments.
|
|
17
|
+
|
|
18
|
+
Provides keywords to build, run, and tear down isolated environments
|
|
19
|
+
per test case. Provider plugins (Docker, Proxmox, ...) implement the
|
|
20
|
+
actual infrastructure.
|
|
21
|
+
|
|
22
|
+
= Keywords =
|
|
23
|
+
|
|
24
|
+
| ``ENV_Start`` | Register a component |
|
|
25
|
+
| ``ENV_BuildAndRun`` | Create and start all components |
|
|
26
|
+
| ``ENV_WaitForReady`` | Wait until a component passes its health check |
|
|
27
|
+
| ``ENV_Stop`` | Stop and destroy all components |
|
|
28
|
+
| ``ENV_CreateImage`` | Snapshot current state for reuse |
|
|
29
|
+
| ``ENV_StartFromImage`` | Start from snapshot or run fallback |
|
|
30
|
+
| ``ENV_SnapshotOnFail`` | Freeze state on test failure |
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
ROBOT_LIBRARY_SCOPE = "GLOBAL"
|
|
34
|
+
|
|
35
|
+
def __init__(self, components_dir: str | None = None):
|
|
36
|
+
self._manager = EnvironmentManager()
|
|
37
|
+
self._components_dir = Path(components_dir) if components_dir else None
|
|
38
|
+
|
|
39
|
+
@keyword("ENV_Start")
|
|
40
|
+
def env_start(self, name: str, **overrides):
|
|
41
|
+
"""Register an environment component for provisioning.
|
|
42
|
+
|
|
43
|
+
Loads the component definition from YAML and applies any overrides.
|
|
44
|
+
|
|
45
|
+
Example:
|
|
46
|
+
| ENV_Start | PostgresDB | version=17 |
|
|
47
|
+
"""
|
|
48
|
+
component = self._load_component(name)
|
|
49
|
+
self._manager.register(name, component, **overrides)
|
|
50
|
+
logger.info(f"ENV_Start: Registered '{name}'")
|
|
51
|
+
|
|
52
|
+
@keyword("ENV_BuildAndRun")
|
|
53
|
+
def env_build_and_run(self):
|
|
54
|
+
"""Create and start all registered environment components.
|
|
55
|
+
|
|
56
|
+
After starting, all component YAML keys are published as
|
|
57
|
+
``$MEM{ComponentName.key}`` variables for use in subsequent
|
|
58
|
+
keywords (e.g. ``$MEM{PostgresDB.env.POSTGRES_DB}``).
|
|
59
|
+
|
|
60
|
+
Example:
|
|
61
|
+
| ENV_Start | WebShop |
|
|
62
|
+
| ENV_Start | PostgresDB |
|
|
63
|
+
| ENV_BuildAndRun | |
|
|
64
|
+
"""
|
|
65
|
+
self._manager.build_and_run()
|
|
66
|
+
self._publish_mem_entries()
|
|
67
|
+
logger.info("ENV_BuildAndRun: All components started.")
|
|
68
|
+
|
|
69
|
+
@keyword("ENV_WaitForReady")
|
|
70
|
+
def env_wait_for_ready(self, name: str, timeout: str | None = None):
|
|
71
|
+
"""Wait until a component passes its health check.
|
|
72
|
+
|
|
73
|
+
Timeout is read from the component YAML (``timeout`` key).
|
|
74
|
+
An explicit parameter overrides the YAML value.
|
|
75
|
+
|
|
76
|
+
Example:
|
|
77
|
+
| ENV_WaitForReady | WebShop | |
|
|
78
|
+
| ENV_WaitForReady | WebShop | timeout=60s |
|
|
79
|
+
"""
|
|
80
|
+
if timeout is None:
|
|
81
|
+
timeout = self._manager.get_component_config(name).get("timeout", "30s")
|
|
82
|
+
timeout_seconds = self._parse_timeout(timeout)
|
|
83
|
+
self._manager.wait_for_ready(name, timeout=timeout_seconds)
|
|
84
|
+
logger.info(f"ENV_WaitForReady: '{name}' is ready.")
|
|
85
|
+
|
|
86
|
+
@keyword("ENV_Stop")
|
|
87
|
+
def env_stop(self):
|
|
88
|
+
"""Stop and destroy all environment components.
|
|
89
|
+
|
|
90
|
+
Example:
|
|
91
|
+
| ENV_Stop |
|
|
92
|
+
"""
|
|
93
|
+
self._log_all_components()
|
|
94
|
+
self._manager.stop_all()
|
|
95
|
+
logger.info("ENV_Stop: All components stopped and destroyed.")
|
|
96
|
+
|
|
97
|
+
@keyword("ENV_CreateImage")
|
|
98
|
+
def env_create_image(self, name: str):
|
|
99
|
+
"""Snapshot the current state of all components for reuse.
|
|
100
|
+
|
|
101
|
+
Example:
|
|
102
|
+
| ENV_CreateImage | ShopWithTestData |
|
|
103
|
+
"""
|
|
104
|
+
snapshots = self._manager.snapshot_all()
|
|
105
|
+
logger.info(f"ENV_CreateImage: Created image '{name}' with snapshots: {snapshots}")
|
|
106
|
+
|
|
107
|
+
@keyword("ENV_StartFromImage")
|
|
108
|
+
def env_start_from_image(self, image_name: str, fallback_keyword: str):
|
|
109
|
+
"""Start from a cached image if it exists, otherwise run the fallback keyword.
|
|
110
|
+
|
|
111
|
+
Example:
|
|
112
|
+
| ENV_StartFromImage | ShopWithTestData | Setup Shop Environment |
|
|
113
|
+
"""
|
|
114
|
+
# TODO: Implement image cache lookup
|
|
115
|
+
# For now, always run the fallback
|
|
116
|
+
from robot.libraries.BuiltIn import BuiltIn
|
|
117
|
+
logger.info(f"ENV_StartFromImage: Image '{image_name}' not found, running fallback '{fallback_keyword}'.")
|
|
118
|
+
BuiltIn().run_keyword(fallback_keyword)
|
|
119
|
+
|
|
120
|
+
@keyword("ENV_SnapshotOnFail")
|
|
121
|
+
def env_snapshot_on_fail(self):
|
|
122
|
+
"""Snapshot all components if the current test has failed.
|
|
123
|
+
|
|
124
|
+
Use in [Teardown] to preserve the failure state for analysis.
|
|
125
|
+
|
|
126
|
+
Example:
|
|
127
|
+
| [Teardown] | Run Keywords | ENV_SnapshotOnFail | ENV_Stop |
|
|
128
|
+
"""
|
|
129
|
+
from robot.libraries.BuiltIn import BuiltIn
|
|
130
|
+
status = BuiltIn().get_variable_value("${TEST STATUS}", "PASS")
|
|
131
|
+
if status == "FAIL":
|
|
132
|
+
snapshots = self._manager.snapshot_all()
|
|
133
|
+
logger.warn(f"ENV_SnapshotOnFail: Test failed. Snapshots: {snapshots}")
|
|
134
|
+
else:
|
|
135
|
+
logger.info("ENV_SnapshotOnFail: Test passed, no snapshot needed.")
|
|
136
|
+
|
|
137
|
+
def _load_component(self, name: str) -> dict:
|
|
138
|
+
"""Load component definition from YAML."""
|
|
139
|
+
if self._components_dir is None:
|
|
140
|
+
raise RuntimeError(f"No components_dir configured. Cannot load '{name}'.")
|
|
141
|
+
yaml_path = self._components_dir / f"{name}.yaml"
|
|
142
|
+
if not yaml_path.exists():
|
|
143
|
+
raise FileNotFoundError(f"Component YAML not found: {yaml_path}")
|
|
144
|
+
data = load_component(yaml_path)
|
|
145
|
+
if name not in data:
|
|
146
|
+
raise KeyError(f"Component '{name}' not found in {yaml_path}. Keys: {list(data.keys())}")
|
|
147
|
+
return data[name]
|
|
148
|
+
|
|
149
|
+
def _log_all_components(self):
|
|
150
|
+
"""Log output of all components into the Robot report."""
|
|
151
|
+
for name in list(self._manager._component_ids.keys()):
|
|
152
|
+
try:
|
|
153
|
+
logs = self._manager.get_logs(name)
|
|
154
|
+
if logs:
|
|
155
|
+
logger.info(f"--- Logs: {name} ---\n{logs}", html=False)
|
|
156
|
+
except Exception as e:
|
|
157
|
+
logger.warn(f"Could not retrieve logs for '{name}': {e}")
|
|
158
|
+
|
|
159
|
+
def _publish_mem_entries(self):
|
|
160
|
+
"""Publish all component config values as Robot Framework test variables."""
|
|
161
|
+
from robot.libraries.BuiltIn import BuiltIn
|
|
162
|
+
entries = self._manager.get_mem_entries()
|
|
163
|
+
for key, value in entries.items():
|
|
164
|
+
BuiltIn().set_test_variable(f"${{{key}}}", value)
|
|
165
|
+
logger.info(f" $MEM{{{key}}} = {value}")
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def _parse_timeout(timeout: str) -> float:
|
|
169
|
+
"""Parse timeout string like '30s', '2m', '120' to seconds."""
|
|
170
|
+
timeout = timeout.strip().lower()
|
|
171
|
+
if timeout.endswith("s"):
|
|
172
|
+
return float(timeout[:-1])
|
|
173
|
+
if timeout.endswith("m"):
|
|
174
|
+
return float(timeout[:-1]) * 60
|
|
175
|
+
return float(timeout)
|
okw_env/provider_base.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Base class for all OKW environment providers."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OkwEnvProviderBase(ABC):
|
|
7
|
+
"""Every environment provider (Docker, Proxmox, K8s, ...) implements this contract.
|
|
8
|
+
|
|
9
|
+
The constructor receives the component dict from YAML so that
|
|
10
|
+
provider-specific connection parameters (e.g. ``docker_host``,
|
|
11
|
+
``proxmox_url``) can be read directly from the component definition.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, component: dict | None = None):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def create(self, component: dict) -> str:
|
|
19
|
+
"""Create the environment component. Returns a provider-specific ID."""
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def start(self, component_id: str) -> None:
|
|
23
|
+
"""Start the component."""
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def is_ready(self, component_id: str) -> bool:
|
|
27
|
+
"""Check if the component is ready (health check passed)."""
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def get_logs(self, component_id: str) -> str:
|
|
31
|
+
"""Retrieve logs from the component."""
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def snapshot(self, component_id: str) -> str:
|
|
35
|
+
"""Freeze the current state for later analysis. Returns a snapshot ID."""
|
|
36
|
+
|
|
37
|
+
def get_runtime_info(self, component_id: str) -> dict:
|
|
38
|
+
"""Return runtime values that override YAML config (e.g. assigned port).
|
|
39
|
+
|
|
40
|
+
Default returns empty dict. Providers override as needed.
|
|
41
|
+
Called after start() to update component config with actual values.
|
|
42
|
+
"""
|
|
43
|
+
return {}
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def stop(self, component_id: str) -> None:
|
|
47
|
+
"""Stop the component."""
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def destroy(self, component_id: str) -> None:
|
|
51
|
+
"""Remove the component and all associated resources."""
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Auto-discovery of installed OKW environment providers via entry points."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from okw_env.provider_base import OkwEnvProviderBase
|
|
8
|
+
|
|
9
|
+
_ENTRY_POINT_GROUP = "okw_env.providers"
|
|
10
|
+
|
|
11
|
+
_cache: dict[str, type[OkwEnvProviderBase]] = {}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def discover_providers() -> dict[str, type[OkwEnvProviderBase]]:
|
|
15
|
+
"""Scan installed packages for OKW environment providers."""
|
|
16
|
+
if _cache:
|
|
17
|
+
return _cache
|
|
18
|
+
|
|
19
|
+
from importlib.metadata import entry_points
|
|
20
|
+
if sys.version_info >= (3, 12):
|
|
21
|
+
eps = entry_points(group=_ENTRY_POINT_GROUP)
|
|
22
|
+
elif sys.version_info >= (3, 10):
|
|
23
|
+
eps = entry_points().select(group=_ENTRY_POINT_GROUP)
|
|
24
|
+
else:
|
|
25
|
+
eps = entry_points().get(_ENTRY_POINT_GROUP, [])
|
|
26
|
+
|
|
27
|
+
for ep in eps:
|
|
28
|
+
_cache[ep.name] = ep.load()
|
|
29
|
+
|
|
30
|
+
return _cache
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_provider(name: str, component: dict | None = None) -> OkwEnvProviderBase:
|
|
34
|
+
"""Get a provider instance by name, passing component config for connection details.
|
|
35
|
+
|
|
36
|
+
The component dict is forwarded to the provider constructor so that
|
|
37
|
+
provider-specific keys (e.g. ``docker_host``) are available without
|
|
38
|
+
a separate configuration mechanism.
|
|
39
|
+
"""
|
|
40
|
+
providers = discover_providers()
|
|
41
|
+
if name not in providers:
|
|
42
|
+
available = list(providers.keys())
|
|
43
|
+
raise KeyError(
|
|
44
|
+
f"Provider '{name}' not found. "
|
|
45
|
+
f"Installed providers: {available}. "
|
|
46
|
+
f"Install a provider package (e.g. pip install robotframework-okw-env-docker)."
|
|
47
|
+
)
|
|
48
|
+
provider_class = providers[name]
|
|
49
|
+
return provider_class(component=component or {})
|
okw_env/yaml_loader.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""YAML loader for environment component definitions with inheritance."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def load_component(yaml_path: str | Path) -> dict:
|
|
9
|
+
"""Load a single environment component definition from YAML.
|
|
10
|
+
|
|
11
|
+
Supports ``extends`` for inheritance: the base component is loaded
|
|
12
|
+
first, then the child keys are merged on top (child wins).
|
|
13
|
+
Paths in ``extends`` are resolved relative to the YAML file.
|
|
14
|
+
"""
|
|
15
|
+
path = Path(yaml_path)
|
|
16
|
+
if not path.exists():
|
|
17
|
+
raise FileNotFoundError(f"Component YAML not found: {path}")
|
|
18
|
+
|
|
19
|
+
with path.open("r", encoding="utf-8") as f:
|
|
20
|
+
data = yaml.safe_load(f)
|
|
21
|
+
|
|
22
|
+
if not isinstance(data, dict):
|
|
23
|
+
return data
|
|
24
|
+
|
|
25
|
+
for component_name, component_def in data.items():
|
|
26
|
+
if isinstance(component_def, dict) and "extends" in component_def:
|
|
27
|
+
data[component_name] = _resolve_extends(component_def, path.parent)
|
|
28
|
+
|
|
29
|
+
return data
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _resolve_extends(component: dict, base_dir: Path) -> dict:
|
|
33
|
+
"""Resolve extends by loading the base and merging child on top."""
|
|
34
|
+
extends_ref = component.pop("extends")
|
|
35
|
+
|
|
36
|
+
base_name, base_def = _load_base(extends_ref, base_dir)
|
|
37
|
+
|
|
38
|
+
merged = {**base_def, **component}
|
|
39
|
+
|
|
40
|
+
for key in base_def:
|
|
41
|
+
if key in component and isinstance(base_def[key], dict) and isinstance(component[key], dict):
|
|
42
|
+
merged[key] = {**base_def[key], **component[key]}
|
|
43
|
+
|
|
44
|
+
if "extends" in merged:
|
|
45
|
+
merged = _resolve_extends(merged, base_dir)
|
|
46
|
+
|
|
47
|
+
return merged
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _load_base(extends_ref: str, base_dir: Path) -> tuple[str, dict]:
|
|
51
|
+
"""Load the base component. extends_ref can be 'ComponentName' or 'file.yaml:ComponentName'."""
|
|
52
|
+
if ":" in extends_ref:
|
|
53
|
+
file_part, component_name = extends_ref.rsplit(":", 1)
|
|
54
|
+
base_path = base_dir / file_part
|
|
55
|
+
else:
|
|
56
|
+
component_name = extends_ref
|
|
57
|
+
base_path = base_dir / f"{component_name}.yaml"
|
|
58
|
+
|
|
59
|
+
if not base_path.exists():
|
|
60
|
+
raise FileNotFoundError(
|
|
61
|
+
f"Base component file not found: {base_path} (extends: '{extends_ref}')"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
with base_path.open("r", encoding="utf-8") as f:
|
|
65
|
+
base_data = yaml.safe_load(f)
|
|
66
|
+
|
|
67
|
+
if component_name not in base_data:
|
|
68
|
+
raise KeyError(
|
|
69
|
+
f"Base component '{component_name}' not found in {base_path}. "
|
|
70
|
+
f"Available: {list(base_data.keys())}"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
base_def = base_data[component_name]
|
|
74
|
+
|
|
75
|
+
if isinstance(base_def, dict) and "extends" in base_def:
|
|
76
|
+
base_def = _resolve_extends(base_def, base_path.parent)
|
|
77
|
+
|
|
78
|
+
return component_name, base_def
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: robotframework-okw-env
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Driver-agnostic environment abstraction for OKW4Robot. Defines keywords and YAML-based component definitions to build, run, and tear down ephemeral test environments.
|
|
5
|
+
Project-URL: Repository, http://192.168.1.130:3000/Hrabovszki1023/robotframework-okw-env
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: robotframework>=6.0
|
|
9
|
+
Requires-Dist: pyyaml>=6.0
|
|
10
|
+
|
|
11
|
+
# robotframework-okw-env
|
|
12
|
+
|
|
13
|
+
Driver-agnostic environment abstraction for [OKW4Robot](http://192.168.1.130:3000/Hrabovszki1023/robotframework-okw4robot).
|
|
14
|
+
|
|
15
|
+
> Deutsche Version: [README_de.md](README_de.md)
|
|
16
|
+
|
|
17
|
+
## What
|
|
18
|
+
|
|
19
|
+
One set of keywords (`StartEnvironment`, `StopEnvironment`, `WaitForReady`, ...)
|
|
20
|
+
to build, run, and tear down ephemeral test environments — regardless of the
|
|
21
|
+
underlying provider (Docker, Proxmox, Kubernetes, ...).
|
|
22
|
+
|
|
23
|
+
## Why — Signal vs. NOISE
|
|
24
|
+
|
|
25
|
+
Test code should describe **what** the test needs, not **how** infrastructure
|
|
26
|
+
is provisioned. Docker Compose syntax, Proxmox API calls, and Kubernetes
|
|
27
|
+
manifests are NOISE — the test only needs:
|
|
28
|
+
|
|
29
|
+
```robot
|
|
30
|
+
ENV_Start WebShop
|
|
31
|
+
ENV_Start PostgresDB version=17
|
|
32
|
+
ENV_BuildAndRun
|
|
33
|
+
ENV_WaitForReady WebShop
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Architecture
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
┌──────────────────────────────┐
|
|
40
|
+
│ Robot Framework Test │ ENV_Start, ENV_Stop, ...
|
|
41
|
+
├──────────────────────────────┤
|
|
42
|
+
│ okw-env (this library) │ Keywords, YAML loader, provider contract
|
|
43
|
+
├──────────────┬───────────────┤
|
|
44
|
+
│ okw-env- │ okw-env- │ Provider plugins
|
|
45
|
+
│ docker │ proxmox │
|
|
46
|
+
├──────────────┼───────────────┤
|
|
47
|
+
│ Docker │ Proxmox │ Underlying APIs
|
|
48
|
+
│ Compose │ VE API │
|
|
49
|
+
└──────────────┴───────────────┘
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install robotframework-okw-env
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Plus one or more provider plugins:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install robotframework-okw-env-docker
|
|
62
|
+
pip install robotframework-okw-env-proxmox
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Component YAML
|
|
66
|
+
|
|
67
|
+
Environment components are defined in YAML — no infrastructure code in tests:
|
|
68
|
+
|
|
69
|
+
```yaml
|
|
70
|
+
PostgresDB:
|
|
71
|
+
provider: docker
|
|
72
|
+
image: postgres
|
|
73
|
+
version: "17"
|
|
74
|
+
port: 5432
|
|
75
|
+
env:
|
|
76
|
+
POSTGRES_DB: testdb
|
|
77
|
+
POSTGRES_PASSWORD: test123
|
|
78
|
+
healthcheck: "pg_isready -U postgres"
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Minimal Example
|
|
82
|
+
|
|
83
|
+
```robot
|
|
84
|
+
*** Settings ***
|
|
85
|
+
Library okw4robot.library.OKW4RobotLibrary WITH NAME OKW
|
|
86
|
+
|
|
87
|
+
*** Test Cases ***
|
|
88
|
+
Order With Payment Gateway
|
|
89
|
+
# Phase 0 — Environment
|
|
90
|
+
ENV_Start WebShop
|
|
91
|
+
ENV_Start PostgresDB
|
|
92
|
+
ENV_Start PaymentGW
|
|
93
|
+
ENV_BuildAndRun
|
|
94
|
+
ENV_WaitForReady WebShop
|
|
95
|
+
|
|
96
|
+
# Phase 1-4 — Test
|
|
97
|
+
StartApp MyShop
|
|
98
|
+
SetValue Article Laptop
|
|
99
|
+
ClickOn Order
|
|
100
|
+
VerifyValue Status Paid
|
|
101
|
+
StopApp
|
|
102
|
+
|
|
103
|
+
# Phase 5 — Teardown
|
|
104
|
+
ENV_Stop
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Key Benefits
|
|
108
|
+
|
|
109
|
+
- **Isolation** — Each test gets its own environment, enabling parallel execution
|
|
110
|
+
- **Ephemeral** — Environments exist only during test execution, no security concerns
|
|
111
|
+
- **Combinable** — Version matrix testing via templates
|
|
112
|
+
- **Provider-agnostic** — Same test, different infrastructure
|
|
113
|
+
|
|
114
|
+
## Provider Plugins
|
|
115
|
+
|
|
116
|
+
| Plugin | Repository |
|
|
117
|
+
|---|---|
|
|
118
|
+
| [okw-env-docker](http://192.168.1.130:3000/Hrabovszki1023/robotframework-okw-env-docker) | Docker Compose |
|
|
119
|
+
| [okw-env-proxmox](http://192.168.1.130:3000/Hrabovszki1023/robotframework-okw-env-proxmox) | Proxmox VE |
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
okw_env/__init__.py,sha256=0diLXgSJvGtXlFxtkm1xWniU_AwWFE8FNHRJ-8cKuGs,331
|
|
2
|
+
okw_env/environment_manager.py,sha256=AqlK4fVNqHLNHJHZnu_t58dK0SSP77eWZiAdS0bwOrU,5655
|
|
3
|
+
okw_env/library.py,sha256=ye8LLZz8tbCN76RHHnmt6rlUpCthLINy5qcT0mPCEg8,6929
|
|
4
|
+
okw_env/provider_base.py,sha256=CQT30tS59rDGXG2TLuEwsb94Odg9ABZ6BJ30MIzlT1o,1710
|
|
5
|
+
okw_env/provider_registry.py,sha256=vLw8879a59H1rLbHyGl3_XfOAaYzt3Wn-tZMeVxGDyc,1608
|
|
6
|
+
okw_env/yaml_loader.py,sha256=P9B1NcRCBYobw_SzxGZjoVC7B8QHtoTOLADQMI3sidc,2563
|
|
7
|
+
robotframework_okw_env-0.1.0.dist-info/METADATA,sha256=fg02Pdfz1lVEz43duwES1cYpN6QYSUj8L4Z6fdFBKUA,3698
|
|
8
|
+
robotframework_okw_env-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
robotframework_okw_env-0.1.0.dist-info/top_level.txt,sha256=f88FRNRHcONjMLQMpRZ74_ZfcwQ9wA8PvpesucCQFok,8
|
|
10
|
+
robotframework_okw_env-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
okw_env
|