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
41 changes: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
29 changes: 28 additions & 1 deletion __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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."""
Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import json
import logging
import shlex
import time
from datetime import timedelta
from collections.abc import Callable
Expand Down Expand Up @@ -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)
Expand Down
28 changes: 28 additions & 0 deletions services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 18 additions & 0 deletions strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
95 changes: 92 additions & 3 deletions tests/integration_tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
CONF_SERVICE,
DOMAIN,
SERVICE_CREATE,
SERVICE_EXECUTE_COMMAND,
SERVICE_GET_LOGS,
SERVICE_REFRESH,
SERVICE_REMOVE,
SERVICE_RESTART,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
26 changes: 26 additions & 0 deletions tests/playwright/mock-docker.sh
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,32 @@ case "$CMD" in
fi
;;

# ── docker exec ───────────────────────────────────────────────────────────
"exec")
# Usage: docker exec [flags] <container> <cmd> [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
Expand Down
Loading
Loading