Skip to content

Commit 8f438ee

Browse files
committed
Merge remote-tracking branch 'origin/main' into copilot/add-failure-marker-in-settings
2 parents 946859f + ac34139 commit 8f438ee

16 files changed

Lines changed: 477 additions & 13 deletions

File tree

README.md

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ A Home Assistant custom component that monitors and controls Docker containers o
1515
- 🤖 **Auto-Update** — Optionally recreate containers when a newer image is detected
1616
- 🔔 **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
1717
- 🔍 **Automatic Discovery** — Discovers all containers on a host and offers to add them to Home Assistant
18-
- 🎛️ **Full Container Control** — Create, start, restart, stop, and remove containers from within Home Assistant
18+
- 🎛️ **Full Container Control** — Create, start, restart, stop, remove, and execute arbitrary commands in containers from within Home Assistant
1919
- 📊 **Sidebar Panel** — Auto-registered dashboard listing all containers grouped by host with filtering
2020
- 🎴 **Lovelace Card** — Individual container card for any Lovelace dashboard
2121
- 📡 **SSH Transport** — All host communication is delegated to `ssh_command.execute` — no direct SSH dependencies in this integration
@@ -210,6 +210,45 @@ target:
210210
entity_id: sensor.ssh_docker_grocy
211211
```
212212

213+
### `ssh_docker.refresh`
214+
215+
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.
216+
217+
```yaml
218+
action: ssh_docker.refresh
219+
target:
220+
entity_id: sensor.ssh_docker_grocy
221+
```
222+
223+
### `ssh_docker.get_logs`
224+
225+
Returns the last 200 lines of the container's logs (stdout + stderr combined). The response is available via the `logs` key.
226+
227+
```yaml
228+
action: ssh_docker.get_logs
229+
target:
230+
entity_id: sensor.ssh_docker_grocy
231+
response_variable: result
232+
# result.logs contains the log output string
233+
```
234+
235+
### `ssh_docker.execute_command`
236+
237+
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.
238+
239+
```yaml
240+
action: ssh_docker.execute_command
241+
data:
242+
entity_id: sensor.ssh_docker_grocy
243+
command: "cat /etc/os-release"
244+
timeout: 30 # optional — seconds to wait (default: 60, max: 3600)
245+
response_variable: result
246+
# result.output — combined stdout + stderr of the command
247+
# result.exit_status — integer exit code returned by the command
248+
```
249+
250+
This service is useful for one-off diagnostic commands, health checks, or configuration queries without requiring a separate SSH session.
251+
213252
---
214253

215254
## 🔍 Automatic Discovery

__init__.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
DOMAIN, CONF_KEY_FILE, CONF_CHECK_KNOWN_HOSTS, CONF_KNOWN_HOSTS,
1919
CONF_DOCKER_COMMAND, CONF_AUTO_UPDATE, CONF_CHECK_FOR_UPDATES, CONF_SERVICE,
2020
SERVICE_CREATE, SERVICE_RESTART, SERVICE_STOP, SERVICE_REMOVE, SERVICE_REFRESH,
21-
SERVICE_GET_LOGS,
21+
SERVICE_GET_LOGS, SERVICE_EXECUTE_COMMAND,
2222
DEFAULT_DOCKER_COMMAND, DEFAULT_CHECK_KNOWN_HOSTS, DEFAULT_TIMEOUT,
2323
DEFAULT_AUTO_UPDATE, DEFAULT_CHECK_FOR_UPDATES,
2424
DOCKER_SERVICES_EXECUTABLE,
@@ -37,6 +37,14 @@
3737
}
3838
)
3939

40+
SERVICE_EXECUTE_COMMAND_SCHEMA = vol.Schema(
41+
{
42+
vol.Required("entity_id"): str,
43+
vol.Required("command"): str,
44+
vol.Optional("timeout"): vol.All(int, vol.Range(min=1)),
45+
}
46+
)
47+
4048

4149
def _get_entry_for_entity(hass: HomeAssistant, entity_id: str) -> ConfigEntry:
4250
"""Return the config entry associated with the given entity_id."""
@@ -164,6 +172,25 @@ async def async_get_logs(service_call: ServiceCall) -> ServiceResponse:
164172
supports_response=SupportsResponse.ONLY,
165173
)
166174

175+
async def async_execute_command(service_call: ServiceCall) -> ServiceResponse:
176+
"""Execute an arbitrary command inside the docker container."""
177+
entity_id = service_call.data["entity_id"]
178+
command = service_call.data["command"]
179+
timeout = service_call.data.get("timeout", DEFAULT_TIMEOUT)
180+
_LOGGER.debug("Service 'execute_command' called for entity %s", entity_id)
181+
entry = _get_entry_for_entity(hass, entity_id)
182+
coordinator = _get_coordinator(hass, entry)
183+
output, exit_status = await coordinator.execute_command(command, timeout=timeout)
184+
return {"output": output, "exit_status": exit_status}
185+
186+
hass.services.async_register(
187+
DOMAIN,
188+
SERVICE_EXECUTE_COMMAND,
189+
async_execute_command,
190+
schema=SERVICE_EXECUTE_COMMAND_SCHEMA,
191+
supports_response=SupportsResponse.ONLY,
192+
)
193+
167194
_LOGGER.debug("SSH Docker integration setup complete")
168195
return True
169196

const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
SERVICE_REMOVE = "remove"
3232
SERVICE_REFRESH = "refresh"
3333
SERVICE_GET_LOGS = "get_logs"
34+
SERVICE_EXECUTE_COMMAND = "execute_command"
3435

3536
DEFAULT_DOCKER_COMMAND = "docker"
3637
DEFAULT_CHECK_KNOWN_HOSTS = True

coordinator.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import json
1919
import logging
20+
import shlex
2021
import time
2122
from datetime import timedelta
2223
from collections.abc import Callable
@@ -341,6 +342,7 @@ async def _async_fetch_data(self) -> None:
341342
update_available = False
342343
new_image_id: str | None = None
343344
if options.get(CONF_CHECK_FOR_UPDATES, False):
345+
self.set_pending_state("pulling")
344346
pull_cmd = (
345347
f"{docker_cmd} pull {image_name} > /dev/null 2>&1;"
346348
f" {docker_cmd} image inspect {image_name} --format '{{{{.Id}}}}'"
@@ -414,6 +416,27 @@ async def get_logs(self) -> str:
414416
)
415417
return output
416418

419+
async def execute_command(self, command: str, timeout: int = DEFAULT_TIMEOUT) -> tuple[str, int]:
420+
"""Execute an arbitrary command inside the container via ``docker exec``.
421+
422+
Returns ``(output, exit_status)`` where *output* contains the combined
423+
stdout and stderr of the command.
424+
"""
425+
options = dict(self.entry.options)
426+
docker_cmd = options.get(CONF_DOCKER_COMMAND, DEFAULT_DOCKER_COMMAND)
427+
name = self._service
428+
_LOGGER.debug("Executing command in container %s: %s", name, command)
429+
output, exit_status = await _ssh_run(
430+
self.hass,
431+
options,
432+
f"{docker_cmd} exec {shlex.quote(name)} sh -c {shlex.quote(command)} 2>&1",
433+
timeout=timeout,
434+
)
435+
_LOGGER.debug(
436+
"Command in container %s exited with status %d", name, exit_status
437+
)
438+
return output, exit_status
439+
417440
async def restart(self) -> None:
418441
"""Restart the container."""
419442
options = dict(self.entry.options)

frontend/ssh-docker-panel.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ class SshDockerPanel extends HTMLElement {
264264
case "removing": return "#c0392b";
265265
case "stopping": return "#e67e22";
266266
case "creating": return "#2980b9";
267+
case "pulling": return "#2471a3";
267268
case "refreshing": return "#7f8c8d";
268269
default: return "#95a5a6";
269270
}
@@ -291,7 +292,7 @@ class SshDockerPanel extends HTMLElement {
291292

292293
// Conditional button visibility per the requirements.
293294
// Create/Recreate: only if docker_create is available; label changes based on container state.
294-
const transitionalStates = ["creating", "initializing", "restarting", "starting", "stopping", "removing", "refreshing"];
295+
const transitionalStates = ["creating", "initializing", "restarting", "starting", "stopping", "removing", "refreshing", "pulling"];
295296
const isTransitional = transitionalStates.includes(state);
296297
const showCreate = !isTransitional && attrs.docker_create_available === true;
297298
const createLabel = state !== "unavailable"
@@ -303,8 +304,8 @@ class SshDockerPanel extends HTMLElement {
303304
const showStart = !isTransitional && stoppedStates.includes(state);
304305
const showStop = !isTransitional && state === "running";
305306
const showRemove = !isTransitional && state !== "unavailable" && state !== "unknown";
306-
const showRefresh = state !== "initializing";
307-
const showLogs = state !== "unavailable" && state !== "unknown" && state !== "initializing";
307+
const showRefresh = state !== "initializing" && state !== "pulling";
308+
const showLogs = state !== "unavailable" && state !== "unknown" && state !== "initializing" && state !== "pulling";
308309

309310
const actionButtons = [
310311
showCreate ? `<button class="action-btn create-btn" data-action="create" data-entity="${entityId}">${createLabel}</button>` : "",
@@ -343,7 +344,7 @@ class SshDockerPanel extends HTMLElement {
343344

344345
const allContainers = this._getAllContainers();
345346

346-
const states = ["running", "exited", "paused", "restarting", "starting", "dead", "created", "removing", "stopping", "creating", "initializing", "unavailable", "refreshing"];
347+
const states = ["running", "exited", "paused", "restarting", "starting", "dead", "created", "removing", "stopping", "creating", "initializing", "pulling", "unavailable", "refreshing"];
347348
const counts = { all: allContainers.length };
348349
for (const s of states) {
349350
counts[s] = allContainers.filter((c) => c.state === s).length;

requirements_integration_tests.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
pytest-homeassistant-custom-component==0.13.318
1+
pytest-homeassistant-custom-component==0.13.320

services.yaml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,31 @@ get_logs:
6969
selector:
7070
entity:
7171
domain: sensor
72+
73+
execute_command:
74+
name: Execute Command in Docker Container
75+
description: Executes an arbitrary command inside the docker container on the remote host and returns the output.
76+
fields:
77+
entity_id:
78+
name: Entity ID
79+
description: The entity ID of the docker container sensor.
80+
required: true
81+
selector:
82+
entity:
83+
domain: sensor
84+
command:
85+
name: Command
86+
description: The command to execute inside the docker container.
87+
required: true
88+
selector:
89+
text:
90+
timeout:
91+
name: Timeout
92+
description: Maximum number of seconds to wait for the command to complete. Defaults to 60.
93+
required: false
94+
default: 60
95+
selector:
96+
number:
97+
min: 1
98+
max: 3600
99+
unit_of_measurement: seconds

strings.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,24 @@
145145
"description": "The entity ID of the docker container sensor."
146146
}
147147
}
148+
},
149+
"execute_command": {
150+
"name": "Execute Command in Docker Container",
151+
"description": "Executes an arbitrary command inside the docker container on the remote host and returns the output.",
152+
"fields": {
153+
"entity_id": {
154+
"name": "Entity ID",
155+
"description": "The entity ID of the docker container sensor."
156+
},
157+
"command": {
158+
"name": "Command",
159+
"description": "The command to execute inside the docker container."
160+
},
161+
"timeout": {
162+
"name": "Timeout",
163+
"description": "Maximum number of seconds to wait for the command to complete. Defaults to 60."
164+
}
165+
}
148166
}
149167
},
150168
"entity": {
@@ -165,6 +183,7 @@
165183
"recreating": "Recreating",
166184
"dead": "Dead",
167185
"unavailable": "Unavailable",
186+
"pulling": "Pulling",
168187
"refreshing": "Refreshing"
169188
}
170189
}

tests/integration_tests/test_integration.py

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
CONF_SERVICE,
2626
DOMAIN,
2727
SERVICE_CREATE,
28+
SERVICE_EXECUTE_COMMAND,
29+
SERVICE_GET_LOGS,
2830
SERVICE_REFRESH,
2931
SERVICE_REMOVE,
3032
SERVICE_RESTART,
@@ -165,6 +167,16 @@ async def test_refresh_service_registered(self, hass, mock_ssh):
165167
await _setup_entry(hass, entry)
166168
assert hass.services.has_service(DOMAIN, SERVICE_REFRESH)
167169

170+
async def test_get_logs_service_registered(self, hass, mock_ssh):
171+
entry = _make_entry()
172+
await _setup_entry(hass, entry)
173+
assert hass.services.has_service(DOMAIN, SERVICE_GET_LOGS)
174+
175+
async def test_execute_command_service_registered(self, hass, mock_ssh):
176+
entry = _make_entry()
177+
await _setup_entry(hass, entry)
178+
assert hass.services.has_service(DOMAIN, SERVICE_EXECUTE_COMMAND)
179+
168180

169181
# ---------------------------------------------------------------------------
170182
# docker_services availability check
@@ -892,10 +904,87 @@ async def mock_run(h, opts, cmd, timeout=60):
892904

893905
assert len(check_cmds) > 0
894906

907+
async def test_execute_command_returns_output_and_exit_status(self, hass):
908+
"""execute_command issues a docker exec command and returns output + exit_status."""
909+
entry = _make_entry(entry_id="e1")
910+
commands_seen: list[str] = []
895911

896-
# ---------------------------------------------------------------------------
897-
# Update entity
898-
# ---------------------------------------------------------------------------
912+
async def mock_run(h, opts, cmd, timeout=60):
913+
commands_seen.append(cmd)
914+
if "exec" in cmd and "echo hello" in cmd:
915+
return "hello\n", 0
916+
return await _default_ssh_run(h, opts, cmd, timeout)
917+
918+
with patch("custom_components.ssh_docker.coordinator._ssh_run", side_effect=mock_run), \
919+
patch("custom_components.ssh_docker._ssh_run", side_effect=mock_run):
920+
await _setup_entry(hass, entry)
921+
commands_seen.clear()
922+
result = await hass.services.async_call(
923+
DOMAIN, SERVICE_EXECUTE_COMMAND,
924+
{"entity_id": "sensor.ssh_docker_my_container", "command": "echo hello"},
925+
blocking=True,
926+
return_response=True,
927+
)
928+
await hass.async_block_till_done()
929+
930+
assert any("exec" in c for c in commands_seen), (
931+
f"Expected a docker exec command, got: {commands_seen}"
932+
)
933+
assert result is not None, "Expected a service response"
934+
assert result.get("output") == "hello\n", f"Unexpected output: {result.get('output')!r}"
935+
assert result.get("exit_status") == 0, f"Unexpected exit_status: {result.get('exit_status')}"
936+
937+
async def test_execute_command_forwards_timeout(self, hass):
938+
"""execute_command forwards the timeout parameter to _ssh_run."""
939+
entry = _make_entry(entry_id="e1")
940+
captured_timeouts: list[int] = []
941+
942+
async def mock_run(h, opts, cmd, timeout=60):
943+
if "exec" in cmd:
944+
captured_timeouts.append(timeout)
945+
return "output\n", 0
946+
return await _default_ssh_run(h, opts, cmd, timeout)
947+
948+
with patch("custom_components.ssh_docker.coordinator._ssh_run", side_effect=mock_run), \
949+
patch("custom_components.ssh_docker._ssh_run", side_effect=mock_run):
950+
await _setup_entry(hass, entry)
951+
await hass.services.async_call(
952+
DOMAIN, SERVICE_EXECUTE_COMMAND,
953+
{"entity_id": "sensor.ssh_docker_my_container", "command": "id", "timeout": 120},
954+
blocking=True,
955+
return_response=True,
956+
)
957+
await hass.async_block_till_done()
958+
959+
assert len(captured_timeouts) == 1
960+
assert captured_timeouts[0] == 120, (
961+
f"Expected timeout 120, got: {captured_timeouts[0]}"
962+
)
963+
964+
async def test_execute_command_captures_nonzero_exit_status(self, hass):
965+
"""execute_command returns the non-zero exit code from the remote command."""
966+
entry = _make_entry(entry_id="e1")
967+
968+
async def mock_run(h, opts, cmd, timeout=60):
969+
if "exec" in cmd:
970+
return "error output\n", 42
971+
return await _default_ssh_run(h, opts, cmd, timeout)
972+
973+
with patch("custom_components.ssh_docker.coordinator._ssh_run", side_effect=mock_run), \
974+
patch("custom_components.ssh_docker._ssh_run", side_effect=mock_run):
975+
await _setup_entry(hass, entry)
976+
result = await hass.services.async_call(
977+
DOMAIN, SERVICE_EXECUTE_COMMAND,
978+
{"entity_id": "sensor.ssh_docker_my_container", "command": "exit 42"},
979+
blocking=True,
980+
return_response=True,
981+
)
982+
await hass.async_block_till_done()
983+
984+
assert result is not None, "Expected a service response"
985+
assert result.get("exit_status") == 42, (
986+
f"Expected exit_status 42, got: {result.get('exit_status')}"
987+
)
899988

900989

901990
class TestUpdateEntity:

0 commit comments

Comments
 (0)