Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions DeviceLibrary/DeviceLibrary.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@ def normalize_container_name(name: str) -> str:
return re.sub("[^a-zA-Z0-9_.-]", "", name)


def normalize_project_name(name: str) -> str:
"""Normalize a name (e.g. a device serial number) so it is a valid
docker compose project name. Project names must start with a lowercase
letter or digit and may only contain lowercase letters, digits, dashes
and underscores.
"""
name = re.sub(r"[^a-z0-9_-]+", "-", unidecode(name).lower())
return name.lstrip("_-")


@library(scope="SUITE", auto_keywords=False)
class DeviceLibrary:
"""Device Library"""
Expand Down Expand Up @@ -305,6 +315,13 @@ def _setup_compose_stack(
env = config.pop("env", None) or {}
stack = compose_factory.create_stack(
compose_file,
# Name the compose project after the device serial number so the
# stack is easily identifiable (e.g. in docker ps). The serial
# number is unique per setup, so the projects don't clash when
# running test suites in parallel
project_name=normalize_project_name(
config.pop("project_name", None) or device_sn
),
device_service=config.pop("device_service", None),
env_file=env_file,
env={**env, "DEVICE_ID": device_sn},
Expand Down Expand Up @@ -380,6 +397,10 @@ def setup(
resolved from the compose file (label
'device-test-core.role: main', single service, or a service
named 'device')
project_name (str): Compose project name (compose mode).
Defaults to the device serial number so the stack is easily
identifiable. If set, it MUST be unique across parallel
test runs as the project name is the isolation boundary

Returns:
str: Device serial number
Expand Down Expand Up @@ -687,6 +708,53 @@ def _get_compose_stack(self, device_name: Optional[str] = None):
)
return stack

@keyword("Get Container Name")
def get_container_name(self, device_name: Optional[str] = None) -> str:
"""Get the docker container name of a device, e.g. to inspect the
container manually using the docker cli.

For the docker adapter (single container mode), the container name is
equal to the device serial number. In compose mode the container name
is generated by docker compose, e.g. '<project>-<service>-1'.

Only available for container based devices (docker adapter).

Examples:

| ${name}= Get Container Name |
| ${name}= Get Container Name device_name=${SERIAL}:broker |

Args:
device_name (optional, str): Device

Returns:
str: Container name
"""
device = self.get_device(device_name)
container = getattr(device, "container", None)
assert container is not None, (
f"Device '{device.name}' is not a container based device. "
"This keyword is only supported by the docker adapter"
)
return container.name

@keyword("Get Compose Project Name")
def get_compose_project_name(self, device_name: Optional[str] = None) -> str:
"""Get the docker compose project name of the stack a device belongs
to. The project name defaults to the device serial number, so the
stack can be easily identified, e.g. to inspect it manually using
'docker compose -p <project_name> ps'.

Only available for devices created from a docker compose file.

Args:
device_name (optional, str): Device

Returns:
str: Compose project name
"""
return self._get_compose_stack(device_name).project_name

@keyword("Get Service Port")
def get_service_port(
self,
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ services:
- "1883"
```

Each `Setup` creates the stack under a unique compose project name, so all containers, networks and volumes are isolated per test setup and suites can run in parallel. To keep it that way, the compose file must not use `container_name`, fixed host ports (e.g. `8080:80`, use ephemeral ports like `"80"` plus the `Get Service Port` keyword instead), or external/fixed-name networks and volumes. The compose file is validated and the setup is rejected with an explanatory error if it contains such settings.
Each `Setup` creates the stack under a unique compose project name (defaulting to the device serial number, e.g. containers are named `tst_xyz-device-1`), so all containers, networks and volumes are isolated per test setup and suites can run in parallel. Use the `Get Compose Project Name` keyword to retrieve it, e.g. to inspect a stack manually with `docker compose -p <project> ps`. To keep it that way, the compose file must not use `container_name`, fixed host ports (e.g. `8080:80`, use ephemeral ports like `"80"` plus the `Get Service Port` keyword instead), or external/fixed-name networks and volumes. The compose file is validated and the setup is rejected with an explanatory error if it contains such settings.

The `compose_file` and `device_service` settings can also be provided via the `&{DOCKER_CONFIG}` variable instead of keyword arguments.

Expand Down
8 changes: 4 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ dynamic = ["version"]
dependencies = [
"robotframework >= 6.0.0, < 8.0.0",
"unidecode >= 1.3.6, < 2.0.0",
"device-test-core @ git+https://github.com/reubenmiller/device-test-core.git@1.16.0",
"device-test-core @ git+https://github.com/reubenmiller/device-test-core.git@1.16.1",
]

[project.optional-dependencies]
Expand All @@ -33,13 +33,13 @@ all = [
"robotframework-devicelibrary[local]",
]
ssh = [
"device-test-core[ssh] @ git+https://github.com/reubenmiller/device-test-core.git@1.16.0",
"device-test-core[ssh] @ git+https://github.com/reubenmiller/device-test-core.git@1.16.1",
]
local = [
"device-test-core[local] @ git+https://github.com/reubenmiller/device-test-core.git@1.16.0",
"device-test-core[local] @ git+https://github.com/reubenmiller/device-test-core.git@1.16.1",
]
docker = [
"device-test-core[docker] @ git+https://github.com/reubenmiller/device-test-core.git@1.16.0",
"device-test-core[docker] @ git+https://github.com/reubenmiller/device-test-core.git@1.16.1",
]

test = [
Expand Down
10 changes: 10 additions & 0 deletions tests/acceptance/compose/compose.robot
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ Library DeviceLibrary adapter=docker
*** Test Cases ***
Create A Stack From A Compose File
${SERIAL}= Setup skip_bootstrap=${True} compose_file=${CURDIR}/docker-compose.yaml
# the compose project is named after the device serial number so the
# stack is easily identifiable, e.g. docker compose -p <project> ps
${PROJECT}= Get Compose Project Name
${expected}= Evaluate $SERIAL.lower()
Should Be Equal ${PROJECT} ${expected}
# compose generates the container names from the project and service names
${name}= Get Container Name
Should Be Equal ${name} ${PROJECT}-device-1
${name}= Get Container Name device_name=${SERIAL}:helper
Should Be Equal ${name} ${PROJECT}-helper-1
# main device (labelled with device-test-core.role: main) answers by default
${output}= Execute Command echo device says $DEVICE_ID strip=${True}
Should Be Equal ${output} device says ${SERIAL}
Expand Down
3 changes: 3 additions & 0 deletions tests/acceptance/docker/docker.robot
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ Create A Single Container Device
${SERIAL}= Setup skip_bootstrap=${True} image=alpine:3.19
${output}= Execute Command echo device $DEVICE_ID strip=${True}
Should Be Equal ${output} device ${SERIAL}
# in single container mode the container is named after the serial number
${name}= Get Container Name
Should Be Equal ${name} ${SERIAL}

Create Multiple Devices In One Suite
${DEVICE1}= Setup skip_bootstrap=${True} image=alpine:3.19
Expand Down