diff --git a/DeviceLibrary/DeviceLibrary.py b/DeviceLibrary/DeviceLibrary.py index ee0cac1..3a8f71e 100644 --- a/DeviceLibrary/DeviceLibrary.py +++ b/DeviceLibrary/DeviceLibrary.py @@ -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""" @@ -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}, @@ -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 @@ -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. '--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 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, diff --git a/README.md b/README.md index 5af2e8c..d5984d2 100644 --- a/README.md +++ b/README.md @@ -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 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. diff --git a/pyproject.toml b/pyproject.toml index 6e9b411..d23bf51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] @@ -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 = [ diff --git a/tests/acceptance/compose/compose.robot b/tests/acceptance/compose/compose.robot index b5dd681..232d12c 100644 --- a/tests/acceptance/compose/compose.robot +++ b/tests/acceptance/compose/compose.robot @@ -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 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} diff --git a/tests/acceptance/docker/docker.robot b/tests/acceptance/docker/docker.robot index 49c1370..72d1c8d 100644 --- a/tests/acceptance/docker/docker.robot +++ b/tests/acceptance/docker/docker.robot @@ -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