diff --git a/README.md b/README.md index b5256c2..91024ed 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ A Home Assistant custom component that monitors and controls Docker containers o - πŸ€– **Auto-Update** β€” Optionally recreate containers when a newer image is detected - πŸ”” **HA Update Entities** β€” Containers with available image updates appear natively in Home Assistant's **Settings β†’ Updates** panel and can be installed from there with one click - πŸ” **Automatic Discovery** β€” Discovers all containers on a host and offers to add them to Home Assistant -- πŸŽ›οΈ **Full Container Control** β€” Create, start, restart, stop, and remove containers from within Home Assistant +- πŸŽ›οΈ **Full Container Control** β€” Create, start, restart, stop, remove, and execute arbitrary commands in containers from within Home Assistant - πŸ“Š **Sidebar Panel** β€” Auto-registered dashboard listing all containers grouped by host with filtering - 🎴 **Lovelace Card** β€” Individual container card for any Lovelace dashboard - πŸ“‘ **SSH Transport** β€” All host communication is delegated to `ssh_command.execute` β€” no direct SSH dependencies in this integration @@ -210,6 +210,45 @@ target: entity_id: sensor.ssh_docker_grocy ``` +### `ssh_docker.refresh` + +Triggers an immediate sensor update for the container (fetches fresh state from the remote host). Useful for automation-driven polling or after an out-of-band change. + +```yaml +action: ssh_docker.refresh +target: + entity_id: sensor.ssh_docker_grocy +``` + +### `ssh_docker.get_logs` + +Returns the last 200 lines of the container's logs (stdout + stderr combined). The response is available via the `logs` key. + +```yaml +action: ssh_docker.get_logs +target: + entity_id: sensor.ssh_docker_grocy +response_variable: result +# result.logs contains the log output string +``` + +### `ssh_docker.execute_command` + +Executes an arbitrary command inside the running Docker container via `docker exec` and returns the combined stdout + stderr output along with the exit status. The command is passed to `sh -c` inside the container. + +```yaml +action: ssh_docker.execute_command +data: + entity_id: sensor.ssh_docker_grocy + command: "cat /etc/os-release" + timeout: 30 # optional β€” seconds to wait (default: 60, max: 3600) +response_variable: result +# result.output β€” combined stdout + stderr of the command +# result.exit_status β€” integer exit code returned by the command +``` + +This service is useful for one-off diagnostic commands, health checks, or configuration queries without requiring a separate SSH session. + --- ## πŸ” Automatic Discovery diff --git a/__init__.py b/__init__.py index 740d70b..ac7c7a7 100644 --- a/__init__.py +++ b/__init__.py @@ -18,7 +18,7 @@ DOMAIN, CONF_KEY_FILE, CONF_CHECK_KNOWN_HOSTS, CONF_KNOWN_HOSTS, CONF_DOCKER_COMMAND, CONF_AUTO_UPDATE, CONF_CHECK_FOR_UPDATES, CONF_SERVICE, SERVICE_CREATE, SERVICE_RESTART, SERVICE_STOP, SERVICE_REMOVE, SERVICE_REFRESH, - SERVICE_GET_LOGS, + SERVICE_GET_LOGS, SERVICE_EXECUTE_COMMAND, DEFAULT_DOCKER_COMMAND, DEFAULT_CHECK_KNOWN_HOSTS, DEFAULT_TIMEOUT, DEFAULT_AUTO_UPDATE, DEFAULT_CHECK_FOR_UPDATES, DOCKER_SERVICES_EXECUTABLE, @@ -37,6 +37,14 @@ } ) +SERVICE_EXECUTE_COMMAND_SCHEMA = vol.Schema( + { + vol.Required("entity_id"): str, + vol.Required("command"): str, + vol.Optional("timeout"): vol.All(int, vol.Range(min=1)), + } +) + def _get_entry_for_entity(hass: HomeAssistant, entity_id: str) -> ConfigEntry: """Return the config entry associated with the given entity_id.""" @@ -164,6 +172,25 @@ async def async_get_logs(service_call: ServiceCall) -> ServiceResponse: supports_response=SupportsResponse.ONLY, ) + async def async_execute_command(service_call: ServiceCall) -> ServiceResponse: + """Execute an arbitrary command inside the docker container.""" + entity_id = service_call.data["entity_id"] + command = service_call.data["command"] + timeout = service_call.data.get("timeout", DEFAULT_TIMEOUT) + _LOGGER.debug("Service 'execute_command' called for entity %s", entity_id) + entry = _get_entry_for_entity(hass, entity_id) + coordinator = _get_coordinator(hass, entry) + output, exit_status = await coordinator.execute_command(command, timeout=timeout) + return {"output": output, "exit_status": exit_status} + + hass.services.async_register( + DOMAIN, + SERVICE_EXECUTE_COMMAND, + async_execute_command, + schema=SERVICE_EXECUTE_COMMAND_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + _LOGGER.debug("SSH Docker integration setup complete") return True diff --git a/const.py b/const.py index 6ea0751..82a5693 100644 --- a/const.py +++ b/const.py @@ -31,6 +31,7 @@ SERVICE_REMOVE = "remove" SERVICE_REFRESH = "refresh" SERVICE_GET_LOGS = "get_logs" +SERVICE_EXECUTE_COMMAND = "execute_command" DEFAULT_DOCKER_COMMAND = "docker" DEFAULT_CHECK_KNOWN_HOSTS = True diff --git a/coordinator.py b/coordinator.py index ffc77f2..601db8f 100644 --- a/coordinator.py +++ b/coordinator.py @@ -17,6 +17,7 @@ import json import logging +import shlex import time from datetime import timedelta from collections.abc import Callable @@ -414,6 +415,27 @@ async def get_logs(self) -> str: ) return output + async def execute_command(self, command: str, timeout: int = DEFAULT_TIMEOUT) -> tuple[str, int]: + """Execute an arbitrary command inside the container via ``docker exec``. + + Returns ``(output, exit_status)`` where *output* contains the combined + stdout and stderr of the command. + """ + options = dict(self.entry.options) + docker_cmd = options.get(CONF_DOCKER_COMMAND, DEFAULT_DOCKER_COMMAND) + name = self._service + _LOGGER.debug("Executing command in container %s: %s", name, command) + output, exit_status = await _ssh_run( + self.hass, + options, + f"{docker_cmd} exec {shlex.quote(name)} sh -c {shlex.quote(command)} 2>&1", + timeout=timeout, + ) + _LOGGER.debug( + "Command in container %s exited with status %d", name, exit_status + ) + return output, exit_status + async def restart(self) -> None: """Restart the container.""" options = dict(self.entry.options) diff --git a/services.yaml b/services.yaml index 71b7723..4c08d3e 100644 --- a/services.yaml +++ b/services.yaml @@ -69,3 +69,31 @@ get_logs: selector: entity: domain: sensor + +execute_command: + name: Execute Command in Docker Container + description: Executes an arbitrary command inside the docker container on the remote host and returns the output. + fields: + entity_id: + name: Entity ID + description: The entity ID of the docker container sensor. + required: true + selector: + entity: + domain: sensor + command: + name: Command + description: The command to execute inside the docker container. + required: true + selector: + text: + timeout: + name: Timeout + description: Maximum number of seconds to wait for the command to complete. Defaults to 60. + required: false + default: 60 + selector: + number: + min: 1 + max: 3600 + unit_of_measurement: seconds diff --git a/strings.json b/strings.json index 8783fcd..508a8b3 100644 --- a/strings.json +++ b/strings.json @@ -145,6 +145,24 @@ "description": "The entity ID of the docker container sensor." } } + }, + "execute_command": { + "name": "Execute Command in Docker Container", + "description": "Executes an arbitrary command inside the docker container on the remote host and returns the output.", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "The entity ID of the docker container sensor." + }, + "command": { + "name": "Command", + "description": "The command to execute inside the docker container." + }, + "timeout": { + "name": "Timeout", + "description": "Maximum number of seconds to wait for the command to complete. Defaults to 60." + } + } } }, "entity": { diff --git a/tests/integration_tests/test_integration.py b/tests/integration_tests/test_integration.py index e46749a..fa1b95b 100644 --- a/tests/integration_tests/test_integration.py +++ b/tests/integration_tests/test_integration.py @@ -25,6 +25,8 @@ CONF_SERVICE, DOMAIN, SERVICE_CREATE, + SERVICE_EXECUTE_COMMAND, + SERVICE_GET_LOGS, SERVICE_REFRESH, SERVICE_REMOVE, SERVICE_RESTART, @@ -165,6 +167,16 @@ async def test_refresh_service_registered(self, hass, mock_ssh): await _setup_entry(hass, entry) assert hass.services.has_service(DOMAIN, SERVICE_REFRESH) + async def test_get_logs_service_registered(self, hass, mock_ssh): + entry = _make_entry() + await _setup_entry(hass, entry) + assert hass.services.has_service(DOMAIN, SERVICE_GET_LOGS) + + async def test_execute_command_service_registered(self, hass, mock_ssh): + entry = _make_entry() + await _setup_entry(hass, entry) + assert hass.services.has_service(DOMAIN, SERVICE_EXECUTE_COMMAND) + # --------------------------------------------------------------------------- # docker_services availability check @@ -892,10 +904,87 @@ async def mock_run(h, opts, cmd, timeout=60): assert len(check_cmds) > 0 + async def test_execute_command_returns_output_and_exit_status(self, hass): + """execute_command issues a docker exec command and returns output + exit_status.""" + entry = _make_entry(entry_id="e1") + commands_seen: list[str] = [] -# --------------------------------------------------------------------------- -# Update entity -# --------------------------------------------------------------------------- + async def mock_run(h, opts, cmd, timeout=60): + commands_seen.append(cmd) + if "exec" in cmd and "echo hello" in cmd: + return "hello\n", 0 + return await _default_ssh_run(h, opts, cmd, timeout) + + with patch("custom_components.ssh_docker.coordinator._ssh_run", side_effect=mock_run), \ + patch("custom_components.ssh_docker._ssh_run", side_effect=mock_run): + await _setup_entry(hass, entry) + commands_seen.clear() + result = await hass.services.async_call( + DOMAIN, SERVICE_EXECUTE_COMMAND, + {"entity_id": "sensor.ssh_docker_my_container", "command": "echo hello"}, + blocking=True, + return_response=True, + ) + await hass.async_block_till_done() + + assert any("exec" in c for c in commands_seen), ( + f"Expected a docker exec command, got: {commands_seen}" + ) + assert result is not None, "Expected a service response" + assert result.get("output") == "hello\n", f"Unexpected output: {result.get('output')!r}" + assert result.get("exit_status") == 0, f"Unexpected exit_status: {result.get('exit_status')}" + + async def test_execute_command_forwards_timeout(self, hass): + """execute_command forwards the timeout parameter to _ssh_run.""" + entry = _make_entry(entry_id="e1") + captured_timeouts: list[int] = [] + + async def mock_run(h, opts, cmd, timeout=60): + if "exec" in cmd: + captured_timeouts.append(timeout) + return "output\n", 0 + return await _default_ssh_run(h, opts, cmd, timeout) + + with patch("custom_components.ssh_docker.coordinator._ssh_run", side_effect=mock_run), \ + patch("custom_components.ssh_docker._ssh_run", side_effect=mock_run): + await _setup_entry(hass, entry) + await hass.services.async_call( + DOMAIN, SERVICE_EXECUTE_COMMAND, + {"entity_id": "sensor.ssh_docker_my_container", "command": "id", "timeout": 120}, + blocking=True, + return_response=True, + ) + await hass.async_block_till_done() + + assert len(captured_timeouts) == 1 + assert captured_timeouts[0] == 120, ( + f"Expected timeout 120, got: {captured_timeouts[0]}" + ) + + async def test_execute_command_captures_nonzero_exit_status(self, hass): + """execute_command returns the non-zero exit code from the remote command.""" + entry = _make_entry(entry_id="e1") + + async def mock_run(h, opts, cmd, timeout=60): + if "exec" in cmd: + return "error output\n", 42 + return await _default_ssh_run(h, opts, cmd, timeout) + + with patch("custom_components.ssh_docker.coordinator._ssh_run", side_effect=mock_run), \ + patch("custom_components.ssh_docker._ssh_run", side_effect=mock_run): + await _setup_entry(hass, entry) + result = await hass.services.async_call( + DOMAIN, SERVICE_EXECUTE_COMMAND, + {"entity_id": "sensor.ssh_docker_my_container", "command": "exit 42"}, + blocking=True, + return_response=True, + ) + await hass.async_block_till_done() + + assert result is not None, "Expected a service response" + assert result.get("exit_status") == 42, ( + f"Expected exit_status 42, got: {result.get('exit_status')}" + ) class TestUpdateEntity: diff --git a/tests/playwright/mock-docker.sh b/tests/playwright/mock-docker.sh index 1238360..7ddaec5 100755 --- a/tests/playwright/mock-docker.sh +++ b/tests/playwright/mock-docker.sh @@ -187,6 +187,32 @@ case "$CMD" in fi ;; + # ── docker exec ─────────────────────────────────────────────────────────── + "exec") + # Usage: docker exec [flags] [args...] + # Skip any leading flags (e.g. -it, -e KEY=VAL, -u user) + while [ $# -gt 0 ]; do + case "$1" in + -i|-t|-d|-it|-ti) shift ;; + -e|-u|-w|--env|--user|--workdir) shift 2 ;; + *) break ;; + esac + done + container="$1"; shift + + if ! container_exists "$container"; then + printf 'Error response from daemon: No such container: %s\n' "$container" >&2 + exit 1 + fi + state=$(cat "$DOCKER_STATE_DIR/$container/state" 2>/dev/null || echo "running") + if [ "$state" != "running" ]; then + printf 'Error response from daemon: Container %s is not running\n' "$container" >&2 + exit 1 + fi + # Execute the command in the current shell environment, simulating exec + "$@" + ;; + # ── docker pull ─────────────────────────────────────────────────────────── "pull") # Silent success β€” no network access needed diff --git a/tests/playwright/test_services.py b/tests/playwright/test_services.py index 82ed1fc..d52a845 100644 --- a/tests/playwright/test_services.py +++ b/tests/playwright/test_services.py @@ -34,7 +34,7 @@ def test_services_registered(self, ha_api: requests.Session, ensure_integration: assert ssh_docker_domain is not None, "ssh_docker domain not found in services list" registered = set(ssh_docker_domain.get("services", {}).keys()) - expected = {"restart", "stop", "remove", "refresh", "get_logs"} + expected = {"restart", "stop", "remove", "refresh", "get_logs", "execute_command"} assert expected.issubset(registered), ( f"Missing services: {expected - registered}. Registered: {registered}" ) @@ -159,3 +159,89 @@ def test_api_requires_authentication(self) -> None: timeout=10, ) assert resp.status_code == 401, resp.text + + +class TestExecuteCommand: + """Tests focused on the execute_command service.""" + + def test_execute_command_returns_output_and_exit_status( + self, ha_api: requests.Session, ensure_integration: str + ) -> None: + """execute_command runs a command and returns output + exit_status.""" + entity = _get_ssh_docker_entity(ha_api) + assert entity is not None, "No ssh_docker sensor entity found" + + resp = ha_api.post( + f"{HA_URL}/api/services/ssh_docker/execute_command?return_response", + json={"entity_id": entity["entity_id"], "command": "echo hello"}, + ) + assert resp.status_code == 200, f"execute_command service failed: {resp.text}" + data = resp.json() + service_response = data.get("service_response", data) + assert "output" in service_response, ( + f"Expected 'output' key in response, got: {list(service_response.keys())}" + ) + assert "exit_status" in service_response, ( + f"Expected 'exit_status' key in response, got: {list(service_response.keys())}" + ) + assert "hello" in service_response["output"], ( + f"Expected 'hello' in output, got: {service_response['output']!r}" + ) + assert service_response["exit_status"] == 0, ( + f"Expected exit_status 0, got: {service_response['exit_status']}" + ) + + def test_execute_command_captures_nonzero_exit_status( + self, ha_api: requests.Session, ensure_integration: str + ) -> None: + """execute_command captures non-zero exit codes from failing commands.""" + entity = _get_ssh_docker_entity(ha_api) + assert entity is not None, "No ssh_docker sensor entity found" + + resp = ha_api.post( + f"{HA_URL}/api/services/ssh_docker/execute_command?return_response", + json={"entity_id": entity["entity_id"], "command": "exit 42"}, + ) + assert resp.status_code == 200, f"execute_command service failed: {resp.text}" + data = resp.json() + service_response = data.get("service_response", data) + assert service_response.get("exit_status") == 42, ( + f"Expected exit_status 42, got: {service_response.get('exit_status')}" + ) + + def test_execute_command_with_explicit_timeout( + self, ha_api: requests.Session, ensure_integration: str + ) -> None: + """execute_command accepts an explicit timeout parameter.""" + entity = _get_ssh_docker_entity(ha_api) + assert entity is not None, "No ssh_docker sensor entity found" + + resp = ha_api.post( + f"{HA_URL}/api/services/ssh_docker/execute_command?return_response", + json={ + "entity_id": entity["entity_id"], + "command": "echo success", + "timeout": 30, + }, + ) + assert resp.status_code == 200, f"execute_command with timeout failed: {resp.text}" + data = resp.json() + service_response = data.get("service_response", data) + assert service_response.get("exit_status") == 0, ( + f"Expected exit_status 0, got: {service_response.get('exit_status')}" + ) + + def test_execute_command_requires_command_field( + self, ha_api: requests.Session, ensure_integration: str + ) -> None: + """Calling execute_command without the command field returns a validation error.""" + entity = _get_ssh_docker_entity(ha_api) + assert entity is not None, "No ssh_docker sensor entity found" + + resp = ha_api.post( + f"{HA_URL}/api/services/ssh_docker/execute_command?return_response", + json={"entity_id": entity["entity_id"]}, # missing command + ) + assert resp.status_code in (400, 422), ( + f"Expected validation error (400/422) for missing command, got {resp.status_code}" + ) diff --git a/tests/unit_tests/homeassistant_mock/voluptuous.py b/tests/unit_tests/homeassistant_mock/voluptuous.py index c2a27fa..3854715 100644 --- a/tests/unit_tests/homeassistant_mock/voluptuous.py +++ b/tests/unit_tests/homeassistant_mock/voluptuous.py @@ -31,3 +31,8 @@ def __call__(self, data): def All(*args): """Mock All validator - returns last argument.""" return args[-1] if args else {} + + +def Range(min=None, max=None): # pylint: disable=redefined-builtin + """Mock Range validator - pass through.""" + return int diff --git a/tests/unit_tests/test_init.py b/tests/unit_tests/test_init.py index 578500b..1b32423 100644 --- a/tests/unit_tests/test_init.py +++ b/tests/unit_tests/test_init.py @@ -15,7 +15,7 @@ from ssh_docker import async_setup, async_setup_entry # noqa: E402 from ssh_docker.const import ( # noqa: E402 DOMAIN, SERVICE_CREATE, SERVICE_RESTART, SERVICE_STOP, SERVICE_REMOVE, SERVICE_REFRESH, - SERVICE_GET_LOGS, + SERVICE_GET_LOGS, SERVICE_EXECUTE_COMMAND, DEFAULT_TIMEOUT, ) from ssh_docker.frontend import SshDockerPanelRegistration # noqa: E402 from homeassistant.config_entries import ConfigEntry # noqa: E402 @@ -36,7 +36,7 @@ async def test_services_are_registered(self): self.assertTrue(result) registered_calls = mock_hass.services.async_register.call_args_list - self.assertEqual(len(registered_calls), 6) + self.assertEqual(len(registered_calls), 7) service_names = [call.args[1] for call in registered_calls] self.assertIn(SERVICE_CREATE, service_names) @@ -45,6 +45,7 @@ async def test_services_are_registered(self): self.assertIn(SERVICE_REMOVE, service_names) self.assertIn(SERVICE_REFRESH, service_names) self.assertIn(SERVICE_GET_LOGS, service_names) + self.assertIn(SERVICE_EXECUTE_COMMAND, service_names) domains = [call.args[0] for call in registered_calls] for domain in domains: @@ -373,5 +374,53 @@ async def mock_ssh_run(h, opts, cmd, timeout=60): self.assertIsNone(cached[0]) # None means "not found / no output" +class TestExecuteCommandTimeout(unittest.IsolatedAsyncioTestCase): + """Test that the execute_command service forwards the timeout parameter correctly.""" + + async def test_execute_command_uses_default_timeout(self): + """execute_command uses DEFAULT_TIMEOUT when no timeout is given.""" + from ssh_docker.coordinator import SshDockerCoordinator # noqa: PLC0415 + + entry = _make_entry(service="my_container") + mock_hass = MagicMock() + coordinator = SshDockerCoordinator(hass=mock_hass, entry=entry) + + captured_timeout = None + + async def mock_ssh_run(h, opts, cmd, timeout=DEFAULT_TIMEOUT): + nonlocal captured_timeout + captured_timeout = timeout + return "output", 0 + + with patch("ssh_docker.coordinator._ssh_run", mock_ssh_run): + output, exit_status = await coordinator.execute_command("echo hello") + + self.assertEqual(output, "output") + self.assertEqual(exit_status, 0) + self.assertEqual(captured_timeout, DEFAULT_TIMEOUT) + + async def test_execute_command_uses_custom_timeout(self): + """execute_command forwards a custom timeout to _ssh_run.""" + from ssh_docker.coordinator import SshDockerCoordinator # noqa: PLC0415 + + entry = _make_entry(service="my_container") + mock_hass = MagicMock() + coordinator = SshDockerCoordinator(hass=mock_hass, entry=entry) + + captured_timeout = None + + async def mock_ssh_run(h, opts, cmd, timeout=DEFAULT_TIMEOUT): + nonlocal captured_timeout + captured_timeout = timeout + return "custom output", 0 + + with patch("ssh_docker.coordinator._ssh_run", mock_ssh_run): + output, exit_status = await coordinator.execute_command("echo hello", timeout=120) + + self.assertEqual(output, "custom output") + self.assertEqual(exit_status, 0) + self.assertEqual(captured_timeout, 120) + + if __name__ == "__main__": unittest.main() diff --git a/translations/de.json b/translations/de.json index 53b27fe..f5f3e49 100644 --- a/translations/de.json +++ b/translations/de.json @@ -145,6 +145,24 @@ "description": "Die EntitΓ€ts-ID des Docker-Container-Sensors." } } + }, + "execute_command": { + "name": "Befehl im Docker-Container ausfΓΌhren", + "description": "FΓΌhrt einen beliebigen Befehl im Docker-Container auf dem entfernten Host aus und gibt die Ausgabe zurΓΌck.", + "fields": { + "entity_id": { + "name": "EntitΓ€ts-ID", + "description": "Die EntitΓ€ts-ID des Docker-Container-Sensors." + }, + "command": { + "name": "Befehl", + "description": "Der im Docker-Container auszufΓΌhrende Befehl." + }, + "timeout": { + "name": "Zeitlimit", + "description": "Maximale Anzahl von Sekunden, die auf den Abschluss des Befehls gewartet wird. Standard: 60." + } + } } }, "entity": { diff --git a/translations/en.json b/translations/en.json index 8783fcd..508a8b3 100644 --- a/translations/en.json +++ b/translations/en.json @@ -145,6 +145,24 @@ "description": "The entity ID of the docker container sensor." } } + }, + "execute_command": { + "name": "Execute Command in Docker Container", + "description": "Executes an arbitrary command inside the docker container on the remote host and returns the output.", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "The entity ID of the docker container sensor." + }, + "command": { + "name": "Command", + "description": "The command to execute inside the docker container." + }, + "timeout": { + "name": "Timeout", + "description": "Maximum number of seconds to wait for the command to complete. Defaults to 60." + } + } } }, "entity": {