robotframework-okw-env-docker 0.1.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.
- robotframework_okw_env_docker-0.1.0/PKG-INFO +97 -0
- robotframework_okw_env_docker-0.1.0/README.md +85 -0
- robotframework_okw_env_docker-0.1.0/pyproject.toml +28 -0
- robotframework_okw_env_docker-0.1.0/setup.cfg +4 -0
- robotframework_okw_env_docker-0.1.0/src/okw_env_docker/__init__.py +4 -0
- robotframework_okw_env_docker-0.1.0/src/okw_env_docker/docker_provider.py +117 -0
- robotframework_okw_env_docker-0.1.0/src/okw_env_docker/library.py +139 -0
- robotframework_okw_env_docker-0.1.0/src/robotframework_okw_env_docker.egg-info/PKG-INFO +97 -0
- robotframework_okw_env_docker-0.1.0/src/robotframework_okw_env_docker.egg-info/SOURCES.txt +13 -0
- robotframework_okw_env_docker-0.1.0/src/robotframework_okw_env_docker.egg-info/dependency_links.txt +1 -0
- robotframework_okw_env_docker-0.1.0/src/robotframework_okw_env_docker.egg-info/entry_points.txt +2 -0
- robotframework_okw_env_docker-0.1.0/src/robotframework_okw_env_docker.egg-info/requires.txt +4 -0
- robotframework_okw_env_docker-0.1.0/src/robotframework_okw_env_docker.egg-info/top_level.txt +1 -0
- robotframework_okw_env_docker-0.1.0/tests/test_docker_provider.py +204 -0
- robotframework_okw_env_docker-0.1.0/tests/test_integration_docker.py +73 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: robotframework-okw-env-docker
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Robot Framework library providing OKW environment provisioning with Docker.
|
|
5
|
+
Project-URL: Repository, http://192.168.1.130:3000/Hrabovszki1023/robotframework-okw-env-docker
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: robotframework>=6.0
|
|
9
|
+
Requires-Dist: robotframework-okw-env>=0.1.0
|
|
10
|
+
Requires-Dist: docker>=7.0.0
|
|
11
|
+
Requires-Dist: pyyaml>=6.0
|
|
12
|
+
|
|
13
|
+
# robotframework-okw-env-docker
|
|
14
|
+
|
|
15
|
+
Docker Compose provider for [okw-env](http://192.168.1.130:3000/Hrabovszki1023/robotframework-okw-env).
|
|
16
|
+
|
|
17
|
+
> Deutsche Version: [README_de.md](README_de.md)
|
|
18
|
+
|
|
19
|
+
## What
|
|
20
|
+
|
|
21
|
+
Implements the OKW Environment Provider Contract using Docker Compose.
|
|
22
|
+
Generates `docker-compose.yml` from YAML component definitions and
|
|
23
|
+
manages the full container lifecycle (create, start, health check,
|
|
24
|
+
logs, snapshot, stop, destroy).
|
|
25
|
+
|
|
26
|
+
## Why — Signal vs. NOISE
|
|
27
|
+
|
|
28
|
+
Docker Compose syntax is NOISE. The tester only writes:
|
|
29
|
+
|
|
30
|
+
```robot
|
|
31
|
+
ENV_Start PostgresDB
|
|
32
|
+
ENV_BuildAndRun
|
|
33
|
+
ENV_WaitForReady PostgresDB
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The framework generates the Compose file, starts the container,
|
|
37
|
+
and polls the health check. No Docker knowledge required in test code.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install robotframework-okw-env-docker
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
This automatically installs `robotframework-okw-env` (core) and
|
|
46
|
+
`robotframework-docker` (Docker Compose engine).
|
|
47
|
+
|
|
48
|
+
## Component YAML
|
|
49
|
+
|
|
50
|
+
```yaml
|
|
51
|
+
PostgresDB:
|
|
52
|
+
provider: docker
|
|
53
|
+
image: postgres
|
|
54
|
+
version: "17"
|
|
55
|
+
port: 5432
|
|
56
|
+
env:
|
|
57
|
+
POSTGRES_DB: testdb
|
|
58
|
+
POSTGRES_PASSWORD: testpass
|
|
59
|
+
healthcheck: "pg_isready -U postgres"
|
|
60
|
+
timeout: 30s
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Minimal Example
|
|
64
|
+
|
|
65
|
+
```robot
|
|
66
|
+
*** Settings ***
|
|
67
|
+
Library okw_env.library.OkwEnvLibrary components_dir=components WITH NAME ENV
|
|
68
|
+
|
|
69
|
+
*** Test Cases ***
|
|
70
|
+
Database Is Reachable
|
|
71
|
+
ENV_Start PostgresDB
|
|
72
|
+
ENV_BuildAndRun
|
|
73
|
+
ENV_WaitForReady PostgresDB
|
|
74
|
+
Log PostgresDB is ready for testing.
|
|
75
|
+
[Teardown] ENV_Stop
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Architecture
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
okw-env (keywords, YAML loader, registry)
|
|
82
|
+
│
|
|
83
|
+
▼
|
|
84
|
+
okw-env-docker (this library)
|
|
85
|
+
│
|
|
86
|
+
▼
|
|
87
|
+
DockerComposeLibrary (robotframework-docker)
|
|
88
|
+
│
|
|
89
|
+
▼
|
|
90
|
+
Docker Compose CLI → Docker Engine
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Documentation
|
|
94
|
+
|
|
95
|
+
- [CONTRACT.md](docs/CONTRACT.md) — How this provider implements the contract
|
|
96
|
+
- [SPECIFICATION.md](docs/SPECIFICATION.md) — Lifecycle, isolation, snapshot model
|
|
97
|
+
- [KEYWORDS.md](docs/KEYWORDS.md) — Keyword reference with examples
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# robotframework-okw-env-docker
|
|
2
|
+
|
|
3
|
+
Docker Compose provider for [okw-env](http://192.168.1.130:3000/Hrabovszki1023/robotframework-okw-env).
|
|
4
|
+
|
|
5
|
+
> Deutsche Version: [README_de.md](README_de.md)
|
|
6
|
+
|
|
7
|
+
## What
|
|
8
|
+
|
|
9
|
+
Implements the OKW Environment Provider Contract using Docker Compose.
|
|
10
|
+
Generates `docker-compose.yml` from YAML component definitions and
|
|
11
|
+
manages the full container lifecycle (create, start, health check,
|
|
12
|
+
logs, snapshot, stop, destroy).
|
|
13
|
+
|
|
14
|
+
## Why — Signal vs. NOISE
|
|
15
|
+
|
|
16
|
+
Docker Compose syntax is NOISE. The tester only writes:
|
|
17
|
+
|
|
18
|
+
```robot
|
|
19
|
+
ENV_Start PostgresDB
|
|
20
|
+
ENV_BuildAndRun
|
|
21
|
+
ENV_WaitForReady PostgresDB
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The framework generates the Compose file, starts the container,
|
|
25
|
+
and polls the health check. No Docker knowledge required in test code.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install robotframework-okw-env-docker
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
This automatically installs `robotframework-okw-env` (core) and
|
|
34
|
+
`robotframework-docker` (Docker Compose engine).
|
|
35
|
+
|
|
36
|
+
## Component YAML
|
|
37
|
+
|
|
38
|
+
```yaml
|
|
39
|
+
PostgresDB:
|
|
40
|
+
provider: docker
|
|
41
|
+
image: postgres
|
|
42
|
+
version: "17"
|
|
43
|
+
port: 5432
|
|
44
|
+
env:
|
|
45
|
+
POSTGRES_DB: testdb
|
|
46
|
+
POSTGRES_PASSWORD: testpass
|
|
47
|
+
healthcheck: "pg_isready -U postgres"
|
|
48
|
+
timeout: 30s
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Minimal Example
|
|
52
|
+
|
|
53
|
+
```robot
|
|
54
|
+
*** Settings ***
|
|
55
|
+
Library okw_env.library.OkwEnvLibrary components_dir=components WITH NAME ENV
|
|
56
|
+
|
|
57
|
+
*** Test Cases ***
|
|
58
|
+
Database Is Reachable
|
|
59
|
+
ENV_Start PostgresDB
|
|
60
|
+
ENV_BuildAndRun
|
|
61
|
+
ENV_WaitForReady PostgresDB
|
|
62
|
+
Log PostgresDB is ready for testing.
|
|
63
|
+
[Teardown] ENV_Stop
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Architecture
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
okw-env (keywords, YAML loader, registry)
|
|
70
|
+
│
|
|
71
|
+
▼
|
|
72
|
+
okw-env-docker (this library)
|
|
73
|
+
│
|
|
74
|
+
▼
|
|
75
|
+
DockerComposeLibrary (robotframework-docker)
|
|
76
|
+
│
|
|
77
|
+
▼
|
|
78
|
+
Docker Compose CLI → Docker Engine
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Documentation
|
|
82
|
+
|
|
83
|
+
- [CONTRACT.md](docs/CONTRACT.md) — How this provider implements the contract
|
|
84
|
+
- [SPECIFICATION.md](docs/SPECIFICATION.md) — Lifecycle, isolation, snapshot model
|
|
85
|
+
- [KEYWORDS.md](docs/KEYWORDS.md) — Keyword reference with examples
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "robotframework-okw-env-docker"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Robot Framework library providing OKW environment provisioning with Docker."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"robotframework>=6.0",
|
|
13
|
+
"robotframework-okw-env>=0.1.0",
|
|
14
|
+
"docker>=7.0.0",
|
|
15
|
+
"pyyaml>=6.0"
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Repository = "http://192.168.1.130:3000/Hrabovszki1023/robotframework-okw-env-docker"
|
|
20
|
+
|
|
21
|
+
[project.entry-points."okw_env.providers"]
|
|
22
|
+
docker = "okw_env_docker:DockerProvider"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools]
|
|
25
|
+
package-dir = {"" = "src"}
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.packages.find]
|
|
28
|
+
where = ["src"]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Docker provider for OKW environments.
|
|
2
|
+
|
|
3
|
+
Uses the Docker Python SDK (docker-py) to communicate directly with the
|
|
4
|
+
Docker Engine API. No local Docker CLI installation required.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import uuid
|
|
11
|
+
|
|
12
|
+
import docker
|
|
13
|
+
|
|
14
|
+
from okw_env.provider_base import OkwEnvProviderBase
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DockerProvider(OkwEnvProviderBase):
|
|
18
|
+
"""Provisions environment components as Docker containers via the Engine API."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, base_url: str | None = None, component: dict | None = None):
|
|
21
|
+
component = component or {}
|
|
22
|
+
self._base_url = (
|
|
23
|
+
base_url
|
|
24
|
+
or component.get("docker_host")
|
|
25
|
+
or os.environ.get("DOCKER_HOST", "unix:///var/run/docker.sock")
|
|
26
|
+
)
|
|
27
|
+
self._client: docker.DockerClient | None = None
|
|
28
|
+
self._containers: dict[str, docker.models.containers.Container] = {}
|
|
29
|
+
|
|
30
|
+
def _get_client(self) -> docker.DockerClient:
|
|
31
|
+
if self._client is None:
|
|
32
|
+
self._client = docker.DockerClient(base_url=self._base_url)
|
|
33
|
+
return self._client
|
|
34
|
+
|
|
35
|
+
def create(self, component: dict) -> str:
|
|
36
|
+
"""Pull image and create a container (without starting)."""
|
|
37
|
+
image = component.get("image", "")
|
|
38
|
+
version = component.get("version", "latest")
|
|
39
|
+
full_image = f"{image}:{version}"
|
|
40
|
+
port = component.get("port")
|
|
41
|
+
env = component.get("env", {})
|
|
42
|
+
healthcheck_cmd = component.get("healthcheck")
|
|
43
|
+
name = f"okw-{uuid.uuid4().hex[:8]}"
|
|
44
|
+
|
|
45
|
+
self._get_client().images.pull(image, tag=version)
|
|
46
|
+
|
|
47
|
+
ports = {f"{port}/tcp": None} if port else {}
|
|
48
|
+
env_list = [f"{k}={v}" for k, v in env.items()]
|
|
49
|
+
|
|
50
|
+
healthcheck = None
|
|
51
|
+
if healthcheck_cmd:
|
|
52
|
+
healthcheck = docker.types.Healthcheck(
|
|
53
|
+
test=["CMD-SHELL", healthcheck_cmd],
|
|
54
|
+
interval=5_000_000_000,
|
|
55
|
+
timeout=3_000_000_000,
|
|
56
|
+
retries=10,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
container = self._get_client().containers.create(
|
|
60
|
+
full_image,
|
|
61
|
+
name=name,
|
|
62
|
+
ports=ports,
|
|
63
|
+
environment=env_list,
|
|
64
|
+
healthcheck=healthcheck,
|
|
65
|
+
detach=True,
|
|
66
|
+
)
|
|
67
|
+
self._containers[name] = container
|
|
68
|
+
return name
|
|
69
|
+
|
|
70
|
+
def start(self, component_id: str) -> None:
|
|
71
|
+
container = self._require_container(component_id)
|
|
72
|
+
container.start()
|
|
73
|
+
|
|
74
|
+
def get_runtime_info(self, component_id: str) -> dict:
|
|
75
|
+
"""Read the actual host port assigned by Docker after start."""
|
|
76
|
+
container = self._require_container(component_id)
|
|
77
|
+
container.reload()
|
|
78
|
+
ports = container.attrs.get("NetworkSettings", {}).get("Ports", {})
|
|
79
|
+
for container_port, bindings in ports.items():
|
|
80
|
+
if bindings:
|
|
81
|
+
return {"port": int(bindings[0]["HostPort"])}
|
|
82
|
+
return {}
|
|
83
|
+
|
|
84
|
+
def is_ready(self, component_id: str) -> bool:
|
|
85
|
+
container = self._require_container(component_id)
|
|
86
|
+
container.reload()
|
|
87
|
+
state = container.attrs.get("State", {})
|
|
88
|
+
|
|
89
|
+
health = state.get("Health", {})
|
|
90
|
+
if health:
|
|
91
|
+
return health.get("Status") == "healthy"
|
|
92
|
+
|
|
93
|
+
return state.get("Status") == "running"
|
|
94
|
+
|
|
95
|
+
def get_logs(self, component_id: str) -> str:
|
|
96
|
+
container = self._require_container(component_id)
|
|
97
|
+
return container.logs().decode("utf-8", errors="replace")
|
|
98
|
+
|
|
99
|
+
def snapshot(self, component_id: str) -> str:
|
|
100
|
+
container = self._require_container(component_id)
|
|
101
|
+
snapshot_tag = f"{component_id}-snapshot"
|
|
102
|
+
container.commit(repository=snapshot_tag)
|
|
103
|
+
return snapshot_tag
|
|
104
|
+
|
|
105
|
+
def stop(self, component_id: str) -> None:
|
|
106
|
+
container = self._require_container(component_id)
|
|
107
|
+
container.stop(timeout=10)
|
|
108
|
+
|
|
109
|
+
def destroy(self, component_id: str) -> None:
|
|
110
|
+
container = self._require_container(component_id)
|
|
111
|
+
container.remove(v=True, force=True)
|
|
112
|
+
del self._containers[component_id]
|
|
113
|
+
|
|
114
|
+
def _require_container(self, component_id: str):
|
|
115
|
+
if component_id not in self._containers:
|
|
116
|
+
raise KeyError(f"Unknown container '{component_id}'. Active: {list(self._containers.keys())}")
|
|
117
|
+
return self._containers[component_id]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""OKW Environment Docker Library – Robot Framework keywords for Docker-based environments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from robot.api.deco import library
|
|
6
|
+
|
|
7
|
+
from okw_env.library import OkwEnvLibrary
|
|
8
|
+
|
|
9
|
+
from .docker_provider import DockerProvider
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@library(scope="GLOBAL")
|
|
13
|
+
class OkwEnvDockerLibrary(OkwEnvLibrary):
|
|
14
|
+
"""OKW Environment provisioning for Docker.
|
|
15
|
+
|
|
16
|
+
= Zero-Install Architecture =
|
|
17
|
+
|
|
18
|
+
This library communicates with the Docker Engine API directly via
|
|
19
|
+
the [https://docker-py.readthedocs.io/|Docker SDK for Python].
|
|
20
|
+
**No Docker CLI, no Docker Desktop, no docker-compose installation
|
|
21
|
+
required** — only ``pip install robotframework-okw-env-docker``
|
|
22
|
+
and a reachable Docker host.
|
|
23
|
+
|
|
24
|
+
This dramatically reduces installation NOISE: every machine that can
|
|
25
|
+
run Python and reach a Docker host over the network can provision
|
|
26
|
+
test environments — no admin rights, no platform-specific installers.
|
|
27
|
+
|
|
28
|
+
= Connection =
|
|
29
|
+
|
|
30
|
+
The Docker host is configured **per component in the YAML file** via
|
|
31
|
+
the ``docker_host`` key. This is intentional: the connection target is
|
|
32
|
+
a property of the test environment, not of the test machine. A test
|
|
33
|
+
suite can mix components on different Docker hosts — or even different
|
|
34
|
+
providers (Docker + Proxmox) — without any local configuration.
|
|
35
|
+
|
|
36
|
+
Resolution order for ``docker_host``:
|
|
37
|
+
|
|
38
|
+
| 1. | ``docker_host`` key in component YAML (recommended) |
|
|
39
|
+
| 2. | ``docker_host`` parameter on the Library constructor (fallback) |
|
|
40
|
+
| 3. | Environment variable ``DOCKER_HOST`` |
|
|
41
|
+
| 4. | Default: ``unix:///var/run/docker.sock`` (Linux only) |
|
|
42
|
+
|
|
43
|
+
*Why YAML and not a Library parameter?*
|
|
44
|
+
|
|
45
|
+
The test machine (Windows laptop, Linux CI runner) should not determine
|
|
46
|
+
where Docker runs. A developer on Windows and a CI runner on Linux use
|
|
47
|
+
the same YAML — the connection target stays the same. No ``DOCKER_HOST``
|
|
48
|
+
environment variable, no platform-specific config, no surprises.
|
|
49
|
+
|
|
50
|
+
= Installation =
|
|
51
|
+
|
|
52
|
+
| ``pip install robotframework-okw-env-docker`` |
|
|
53
|
+
|
|
54
|
+
That's it. One ``pip install``, one IP address in the YAML, test environments ready.
|
|
55
|
+
|
|
56
|
+
= Usage =
|
|
57
|
+
|
|
58
|
+
| **Settings** |
|
|
59
|
+
| Library | OkwEnvDockerLibrary | components_dir=components |
|
|
60
|
+
|
|
61
|
+
| **Test Cases** |
|
|
62
|
+
| Webshop mit Datenbank |
|
|
63
|
+
| | ENV_Start | PostgresDB |
|
|
64
|
+
| | ENV_BuildAndRun | |
|
|
65
|
+
| | ENV_WaitForReady | PostgresDB |
|
|
66
|
+
| | # ... test steps ... | |
|
|
67
|
+
| | [Teardown] | Run Keywords | ENV_SnapshotOnFail | ENV_Stop |
|
|
68
|
+
|
|
69
|
+
= Component YAML =
|
|
70
|
+
|
|
71
|
+
Each environment component is defined in a YAML file.
|
|
72
|
+
The ``docker_host`` tells the provider where to connect:
|
|
73
|
+
|
|
74
|
+
| ``PostgresDB.yaml`` |
|
|
75
|
+
| provider: docker |
|
|
76
|
+
| docker_host: tcp://192.168.1.123:2375 |
|
|
77
|
+
| image: postgres |
|
|
78
|
+
| version: "17" |
|
|
79
|
+
| port: 5432 |
|
|
80
|
+
| env: |
|
|
81
|
+
| POSTGRES_DB: testdb |
|
|
82
|
+
| healthcheck: "pg_isready -U postgres" |
|
|
83
|
+
| timeout: 30s |
|
|
84
|
+
|
|
85
|
+
= Environment Variables via $MEM{} =
|
|
86
|
+
|
|
87
|
+
After ``ENV_BuildAndRun``, **all YAML keys** are published as Robot
|
|
88
|
+
Framework test variables using dot-notation. This bridges environment
|
|
89
|
+
provisioning with test keywords — the test knows where to connect
|
|
90
|
+
without hardcoding anything.
|
|
91
|
+
|
|
92
|
+
| *Variable* | *Source* | *Example value* |
|
|
93
|
+
| ``${PostgresDB.image}`` | YAML key | ``postgres`` |
|
|
94
|
+
| ``${PostgresDB.version}`` | YAML key | ``17`` |
|
|
95
|
+
| ``${PostgresDB.port}`` | YAML key | ``5432`` |
|
|
96
|
+
| ``${PostgresDB.env.POSTGRES_DB}`` | YAML nested key | ``testdb`` |
|
|
97
|
+
| ``${PostgresDB.env.POSTGRES_PASSWORD}`` | YAML nested key | ``secret`` |
|
|
98
|
+
| ``${PostgresDB.docker_host}`` | YAML key | ``tcp://192.168.1.123:2375`` |
|
|
99
|
+
| ``${PostgresDB.ID}`` | Runtime (container ID) | ``okw-3df2dcc8`` |
|
|
100
|
+
|
|
101
|
+
Usage in test:
|
|
102
|
+
|
|
103
|
+
| ENV_Start | PostgresDB |
|
|
104
|
+
| ENV_BuildAndRun | |
|
|
105
|
+
| ENV_WaitForReady | PostgresDB |
|
|
106
|
+
| Log | DB name: ${PostgresDB.env.POSTGRES_DB} |
|
|
107
|
+
| Log | Port: ${PostgresDB.port} |
|
|
108
|
+
|
|
109
|
+
In OKW GUI/API keywords, use ``$MEM{}`` syntax:
|
|
110
|
+
|
|
111
|
+
| SetValue | DB_Host | $MEM{PostgresDB.docker_host} |
|
|
112
|
+
| SetValue | DB_Name | $MEM{PostgresDB.env.POSTGRES_DB} |
|
|
113
|
+
|
|
114
|
+
*Design rationale:* The connection data lives in the YAML — not in
|
|
115
|
+
environment variables, not in CI config, not hardcoded in the test.
|
|
116
|
+
The same test runs against any Docker host by changing one YAML file.
|
|
117
|
+
|
|
118
|
+
= Signal vs. NOISE =
|
|
119
|
+
|
|
120
|
+
| *Signal* | *NOISE (hidden)* |
|
|
121
|
+
| ``ENV_Start PostgresDB`` | Docker API calls, image pull, container config |
|
|
122
|
+
| ``ENV_WaitForReady PostgresDB`` | Health check polling, timeout logic |
|
|
123
|
+
| ``${PostgresDB.env.POSTGRES_DB}`` | YAML parsing, variable publishing |
|
|
124
|
+
| ``ENV_Stop`` | Container stop, remove, volume cleanup |
|
|
125
|
+
|
|
126
|
+
The test case reads like a specification. The infrastructure is invisible.
|
|
127
|
+
|
|
128
|
+
= Runnable Examples =
|
|
129
|
+
|
|
130
|
+
See [https://github.com/Hrabovszki1023/okw-examples|okw-examples]
|
|
131
|
+
for complete working test suites.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
ROBOT_LIBRARY_SCOPE = "GLOBAL"
|
|
135
|
+
|
|
136
|
+
def __init__(self, components_dir: str | None = None, docker_host: str | None = None):
|
|
137
|
+
super().__init__(components_dir=components_dir)
|
|
138
|
+
provider = DockerProvider(base_url=docker_host)
|
|
139
|
+
self._manager.set_provider(provider)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: robotframework-okw-env-docker
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Robot Framework library providing OKW environment provisioning with Docker.
|
|
5
|
+
Project-URL: Repository, http://192.168.1.130:3000/Hrabovszki1023/robotframework-okw-env-docker
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: robotframework>=6.0
|
|
9
|
+
Requires-Dist: robotframework-okw-env>=0.1.0
|
|
10
|
+
Requires-Dist: docker>=7.0.0
|
|
11
|
+
Requires-Dist: pyyaml>=6.0
|
|
12
|
+
|
|
13
|
+
# robotframework-okw-env-docker
|
|
14
|
+
|
|
15
|
+
Docker Compose provider for [okw-env](http://192.168.1.130:3000/Hrabovszki1023/robotframework-okw-env).
|
|
16
|
+
|
|
17
|
+
> Deutsche Version: [README_de.md](README_de.md)
|
|
18
|
+
|
|
19
|
+
## What
|
|
20
|
+
|
|
21
|
+
Implements the OKW Environment Provider Contract using Docker Compose.
|
|
22
|
+
Generates `docker-compose.yml` from YAML component definitions and
|
|
23
|
+
manages the full container lifecycle (create, start, health check,
|
|
24
|
+
logs, snapshot, stop, destroy).
|
|
25
|
+
|
|
26
|
+
## Why — Signal vs. NOISE
|
|
27
|
+
|
|
28
|
+
Docker Compose syntax is NOISE. The tester only writes:
|
|
29
|
+
|
|
30
|
+
```robot
|
|
31
|
+
ENV_Start PostgresDB
|
|
32
|
+
ENV_BuildAndRun
|
|
33
|
+
ENV_WaitForReady PostgresDB
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The framework generates the Compose file, starts the container,
|
|
37
|
+
and polls the health check. No Docker knowledge required in test code.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install robotframework-okw-env-docker
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
This automatically installs `robotframework-okw-env` (core) and
|
|
46
|
+
`robotframework-docker` (Docker Compose engine).
|
|
47
|
+
|
|
48
|
+
## Component YAML
|
|
49
|
+
|
|
50
|
+
```yaml
|
|
51
|
+
PostgresDB:
|
|
52
|
+
provider: docker
|
|
53
|
+
image: postgres
|
|
54
|
+
version: "17"
|
|
55
|
+
port: 5432
|
|
56
|
+
env:
|
|
57
|
+
POSTGRES_DB: testdb
|
|
58
|
+
POSTGRES_PASSWORD: testpass
|
|
59
|
+
healthcheck: "pg_isready -U postgres"
|
|
60
|
+
timeout: 30s
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Minimal Example
|
|
64
|
+
|
|
65
|
+
```robot
|
|
66
|
+
*** Settings ***
|
|
67
|
+
Library okw_env.library.OkwEnvLibrary components_dir=components WITH NAME ENV
|
|
68
|
+
|
|
69
|
+
*** Test Cases ***
|
|
70
|
+
Database Is Reachable
|
|
71
|
+
ENV_Start PostgresDB
|
|
72
|
+
ENV_BuildAndRun
|
|
73
|
+
ENV_WaitForReady PostgresDB
|
|
74
|
+
Log PostgresDB is ready for testing.
|
|
75
|
+
[Teardown] ENV_Stop
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Architecture
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
okw-env (keywords, YAML loader, registry)
|
|
82
|
+
│
|
|
83
|
+
▼
|
|
84
|
+
okw-env-docker (this library)
|
|
85
|
+
│
|
|
86
|
+
▼
|
|
87
|
+
DockerComposeLibrary (robotframework-docker)
|
|
88
|
+
│
|
|
89
|
+
▼
|
|
90
|
+
Docker Compose CLI → Docker Engine
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Documentation
|
|
94
|
+
|
|
95
|
+
- [CONTRACT.md](docs/CONTRACT.md) — How this provider implements the contract
|
|
96
|
+
- [SPECIFICATION.md](docs/SPECIFICATION.md) — Lifecycle, isolation, snapshot model
|
|
97
|
+
- [KEYWORDS.md](docs/KEYWORDS.md) — Keyword reference with examples
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/okw_env_docker/__init__.py
|
|
4
|
+
src/okw_env_docker/docker_provider.py
|
|
5
|
+
src/okw_env_docker/library.py
|
|
6
|
+
src/robotframework_okw_env_docker.egg-info/PKG-INFO
|
|
7
|
+
src/robotframework_okw_env_docker.egg-info/SOURCES.txt
|
|
8
|
+
src/robotframework_okw_env_docker.egg-info/dependency_links.txt
|
|
9
|
+
src/robotframework_okw_env_docker.egg-info/entry_points.txt
|
|
10
|
+
src/robotframework_okw_env_docker.egg-info/requires.txt
|
|
11
|
+
src/robotframework_okw_env_docker.egg-info/top_level.txt
|
|
12
|
+
tests/test_docker_provider.py
|
|
13
|
+
tests/test_integration_docker.py
|
robotframework_okw_env_docker-0.1.0/src/robotframework_okw_env_docker.egg-info/dependency_links.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
okw_env_docker
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Unit tests for DockerProvider using mocked Docker SDK."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import MagicMock, patch
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from okw_env_docker.docker_provider import DockerProvider
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
def mock_client():
|
|
12
|
+
with patch("okw_env_docker.docker_provider.docker.DockerClient") as mock_cls:
|
|
13
|
+
client = MagicMock()
|
|
14
|
+
mock_cls.return_value = client
|
|
15
|
+
yield client
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
def provider(mock_client):
|
|
20
|
+
return DockerProvider(base_url="tcp://fake:2375")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
def component():
|
|
25
|
+
return {
|
|
26
|
+
"image": "postgres",
|
|
27
|
+
"version": "16",
|
|
28
|
+
"port": 5432,
|
|
29
|
+
"env": {"POSTGRES_PASSWORD": "test"},
|
|
30
|
+
"healthcheck": "pg_isready -U postgres",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_create_pulls_image_and_creates_container(provider, mock_client, component):
|
|
35
|
+
mock_container = MagicMock()
|
|
36
|
+
mock_client.containers.create.return_value = mock_container
|
|
37
|
+
|
|
38
|
+
cid = provider.create(component)
|
|
39
|
+
|
|
40
|
+
mock_client.images.pull.assert_called_once_with("postgres", tag="16")
|
|
41
|
+
mock_client.containers.create.assert_called_once()
|
|
42
|
+
assert cid.startswith("okw-")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_create_without_healthcheck(provider, mock_client):
|
|
46
|
+
mock_client.containers.create.return_value = MagicMock()
|
|
47
|
+
|
|
48
|
+
provider.create({"image": "redis", "version": "7"})
|
|
49
|
+
|
|
50
|
+
call_kwargs = mock_client.containers.create.call_args[1]
|
|
51
|
+
assert call_kwargs["healthcheck"] is None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_create_default_version(provider, mock_client):
|
|
55
|
+
mock_client.containers.create.return_value = MagicMock()
|
|
56
|
+
|
|
57
|
+
provider.create({"image": "nginx"})
|
|
58
|
+
|
|
59
|
+
mock_client.images.pull.assert_called_once_with("nginx", tag="latest")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_start(provider, mock_client, component):
|
|
63
|
+
mock_container = MagicMock()
|
|
64
|
+
mock_client.containers.create.return_value = mock_container
|
|
65
|
+
cid = provider.create(component)
|
|
66
|
+
|
|
67
|
+
provider.start(cid)
|
|
68
|
+
|
|
69
|
+
mock_container.start.assert_called_once()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_is_ready_healthy(provider, mock_client, component):
|
|
73
|
+
mock_container = MagicMock()
|
|
74
|
+
mock_container.attrs = {"State": {"Health": {"Status": "healthy"}}}
|
|
75
|
+
mock_client.containers.create.return_value = mock_container
|
|
76
|
+
cid = provider.create(component)
|
|
77
|
+
|
|
78
|
+
assert provider.is_ready(cid) is True
|
|
79
|
+
mock_container.reload.assert_called_once()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_is_ready_unhealthy(provider, mock_client, component):
|
|
83
|
+
mock_container = MagicMock()
|
|
84
|
+
mock_container.attrs = {"State": {"Health": {"Status": "starting"}}}
|
|
85
|
+
mock_client.containers.create.return_value = mock_container
|
|
86
|
+
cid = provider.create(component)
|
|
87
|
+
|
|
88
|
+
assert provider.is_ready(cid) is False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_is_ready_no_healthcheck_running(provider, mock_client):
|
|
92
|
+
mock_container = MagicMock()
|
|
93
|
+
mock_container.attrs = {"State": {"Status": "running"}}
|
|
94
|
+
mock_client.containers.create.return_value = mock_container
|
|
95
|
+
cid = provider.create({"image": "redis"})
|
|
96
|
+
|
|
97
|
+
assert provider.is_ready(cid) is True
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_is_ready_no_healthcheck_not_running(provider, mock_client):
|
|
101
|
+
mock_container = MagicMock()
|
|
102
|
+
mock_container.attrs = {"State": {"Status": "created"}}
|
|
103
|
+
mock_client.containers.create.return_value = mock_container
|
|
104
|
+
cid = provider.create({"image": "redis"})
|
|
105
|
+
|
|
106
|
+
assert provider.is_ready(cid) is False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_get_logs(provider, mock_client, component):
|
|
110
|
+
mock_container = MagicMock()
|
|
111
|
+
mock_container.logs.return_value = b"2024-01-01 LOG: ready"
|
|
112
|
+
mock_client.containers.create.return_value = mock_container
|
|
113
|
+
cid = provider.create(component)
|
|
114
|
+
|
|
115
|
+
logs = provider.get_logs(cid)
|
|
116
|
+
|
|
117
|
+
assert "ready" in logs
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_snapshot(provider, mock_client, component):
|
|
121
|
+
mock_container = MagicMock()
|
|
122
|
+
mock_client.containers.create.return_value = mock_container
|
|
123
|
+
cid = provider.create(component)
|
|
124
|
+
|
|
125
|
+
tag = provider.snapshot(cid)
|
|
126
|
+
|
|
127
|
+
mock_container.commit.assert_called_once_with(repository=f"{cid}-snapshot")
|
|
128
|
+
assert tag == f"{cid}-snapshot"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_stop(provider, mock_client, component):
|
|
132
|
+
mock_container = MagicMock()
|
|
133
|
+
mock_client.containers.create.return_value = mock_container
|
|
134
|
+
cid = provider.create(component)
|
|
135
|
+
|
|
136
|
+
provider.stop(cid)
|
|
137
|
+
|
|
138
|
+
mock_container.stop.assert_called_once_with(timeout=10)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_destroy(provider, mock_client, component):
|
|
142
|
+
mock_container = MagicMock()
|
|
143
|
+
mock_client.containers.create.return_value = mock_container
|
|
144
|
+
cid = provider.create(component)
|
|
145
|
+
|
|
146
|
+
provider.destroy(cid)
|
|
147
|
+
|
|
148
|
+
mock_container.remove.assert_called_once_with(v=True, force=True)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_destroy_removes_from_tracking(provider, mock_client, component):
|
|
152
|
+
mock_container = MagicMock()
|
|
153
|
+
mock_client.containers.create.return_value = mock_container
|
|
154
|
+
cid = provider.create(component)
|
|
155
|
+
provider.destroy(cid)
|
|
156
|
+
|
|
157
|
+
with pytest.raises(KeyError, match="Unknown container"):
|
|
158
|
+
provider.stop(cid)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_unknown_container_raises(provider):
|
|
162
|
+
with pytest.raises(KeyError, match="Unknown container"):
|
|
163
|
+
provider.start("nonexistent")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_get_runtime_info_returns_host_port(provider, mock_client, component):
|
|
167
|
+
mock_container = MagicMock()
|
|
168
|
+
mock_container.attrs = {
|
|
169
|
+
"NetworkSettings": {"Ports": {"5432/tcp": [{"HostIp": "0.0.0.0", "HostPort": "49321"}]}}
|
|
170
|
+
}
|
|
171
|
+
mock_client.containers.create.return_value = mock_container
|
|
172
|
+
cid = provider.create(component)
|
|
173
|
+
|
|
174
|
+
info = provider.get_runtime_info(cid)
|
|
175
|
+
|
|
176
|
+
assert info == {"port": 49321}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_get_runtime_info_no_ports(provider, mock_client):
|
|
180
|
+
mock_container = MagicMock()
|
|
181
|
+
mock_container.attrs = {"NetworkSettings": {"Ports": {}}}
|
|
182
|
+
mock_client.containers.create.return_value = mock_container
|
|
183
|
+
cid = provider.create({"image": "redis"})
|
|
184
|
+
|
|
185
|
+
info = provider.get_runtime_info(cid)
|
|
186
|
+
|
|
187
|
+
assert info == {}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_create_uses_dynamic_host_port(provider, mock_client, component):
|
|
191
|
+
mock_client.containers.create.return_value = MagicMock()
|
|
192
|
+
provider.create(component)
|
|
193
|
+
|
|
194
|
+
call_kwargs = mock_client.containers.create.call_args[1]
|
|
195
|
+
assert call_kwargs["ports"] == {"5432/tcp": None}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_env_var_fallback():
|
|
199
|
+
with patch("okw_env_docker.docker_provider.docker.DockerClient") as mock_cls, \
|
|
200
|
+
patch.dict("os.environ", {"DOCKER_HOST": "tcp://envhost:2375"}):
|
|
201
|
+
mock_cls.return_value = MagicMock()
|
|
202
|
+
p = DockerProvider()
|
|
203
|
+
p._get_client()
|
|
204
|
+
mock_cls.assert_called_once_with(base_url="tcp://envhost:2375")
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Integration test against a real Docker host.
|
|
2
|
+
|
|
3
|
+
Requires: Docker Engine at tcp://192.168.1.123:2375
|
|
4
|
+
Run with: pytest tests/test_integration_docker.py -v -s
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from okw_env_docker.docker_provider import DockerProvider
|
|
10
|
+
|
|
11
|
+
DOCKER_HOST = "tcp://192.168.1.123:2375"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def provider():
|
|
16
|
+
return DockerProvider(base_url=DOCKER_HOST)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def redis_component():
|
|
21
|
+
return {
|
|
22
|
+
"image": "redis",
|
|
23
|
+
"version": "7",
|
|
24
|
+
"port": 6379,
|
|
25
|
+
"healthcheck": "redis-cli ping",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture
|
|
30
|
+
def redis_container(provider, redis_component):
|
|
31
|
+
cid = provider.create(redis_component)
|
|
32
|
+
yield cid
|
|
33
|
+
try:
|
|
34
|
+
provider.stop(cid)
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
try:
|
|
38
|
+
provider.destroy(cid)
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_full_lifecycle(provider, redis_component, redis_container):
|
|
44
|
+
cid = redis_container
|
|
45
|
+
print(f"\n Container: {cid}")
|
|
46
|
+
|
|
47
|
+
# Start
|
|
48
|
+
provider.start(cid)
|
|
49
|
+
print(" Started.")
|
|
50
|
+
|
|
51
|
+
# Wait for ready (poll)
|
|
52
|
+
import time
|
|
53
|
+
for i in range(30):
|
|
54
|
+
if provider.is_ready(cid):
|
|
55
|
+
print(f" Ready after {i + 1} poll(s).")
|
|
56
|
+
break
|
|
57
|
+
time.sleep(1)
|
|
58
|
+
else:
|
|
59
|
+
pytest.fail("Container not ready after 30s")
|
|
60
|
+
|
|
61
|
+
# Logs
|
|
62
|
+
logs = provider.get_logs(cid)
|
|
63
|
+
print(f" Logs ({len(logs)} chars): {logs[:200]}")
|
|
64
|
+
assert len(logs) > 0
|
|
65
|
+
|
|
66
|
+
# Snapshot
|
|
67
|
+
tag = provider.snapshot(cid)
|
|
68
|
+
print(f" Snapshot: {tag}")
|
|
69
|
+
assert "snapshot" in tag
|
|
70
|
+
|
|
71
|
+
# Stop
|
|
72
|
+
provider.stop(cid)
|
|
73
|
+
print(" Stopped.")
|