Skip to content

Commit 3d84c3c

Browse files
committed
Report stage with error in jobs
1 parent 543d6ef commit 3d84c3c

File tree

5 files changed

+143
-16
lines changed

5 files changed

+143
-16
lines changed

supervisor/backups/manager.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import errno
88
import logging
99
from pathlib import Path
10-
from shutil import copy
10+
import shutil
1111

1212
from ..addons.addon import Addon
1313
from ..const import (
@@ -364,14 +364,18 @@ async def _copy_to_additional_locations(
364364
):
365365
"""Copy a backup file to additional locations."""
366366

367+
all_new_locations: dict[str | None, Path] = {}
368+
367369
def copy_to_additional_locations() -> dict[str | None, Path]:
368370
"""Copy backup file to additional locations."""
369-
all_locations: dict[str | None, Path] = {}
371+
nonlocal all_new_locations
370372
for location in locations:
371373
try:
372374
if location == LOCATION_CLOUD_BACKUP:
373-
all_locations[LOCATION_CLOUD_BACKUP] = Path(
374-
copy(backup.tarfile, self.sys_config.path_core_backup)
375+
all_new_locations[LOCATION_CLOUD_BACKUP] = Path(
376+
shutil.copy(
377+
backup.tarfile, self.sys_config.path_core_backup
378+
)
375379
)
376380
elif location:
377381
location_mount: Mount = location
@@ -380,12 +384,12 @@ def copy_to_additional_locations() -> dict[str | None, Path]:
380384
f"{location_mount.name} is down, cannot copy to it",
381385
_LOGGER.error,
382386
)
383-
all_locations[location_mount.name] = Path(
384-
copy(backup.tarfile, location_mount.local_where)
387+
all_new_locations[location_mount.name] = Path(
388+
shutil.copy(backup.tarfile, location_mount.local_where)
385389
)
386390
else:
387-
all_locations[None] = Path(
388-
copy(backup.tarfile, self.sys_config.path_backup)
391+
all_new_locations[None] = Path(
392+
shutil.copy(backup.tarfile, self.sys_config.path_backup)
389393
)
390394
except OSError as err:
391395
msg = f"Could not copy backup to {location.name if isinstance(location, Mount) else location} due to: {err!s}"
@@ -397,12 +401,8 @@ def copy_to_additional_locations() -> dict[str | None, Path]:
397401
raise BackupDataDiskBadMessageError(msg, _LOGGER.error) from err
398402
raise BackupError(msg, _LOGGER.error) from err
399403

400-
return all_locations
401-
402404
try:
403-
all_new_locations = await self.sys_run_in_executor(
404-
copy_to_additional_locations
405-
)
405+
await self.sys_run_in_executor(copy_to_additional_locations)
406406
except BackupDataDiskBadMessageError:
407407
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
408408
raise

supervisor/jobs/__init__.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,15 @@ class SupervisorJobError:
7373

7474
type_: type[HassioError] = HassioError
7575
message: str = "Unknown error, see supervisor logs"
76+
stage: str | None = None
7677

7778
def as_dict(self) -> dict[str, str]:
7879
"""Return dictionary representation."""
79-
return {"type": self.type_.__name__, "message": self.message}
80+
return {
81+
"type": self.type_.__name__,
82+
"message": self.message,
83+
"stage": self.stage,
84+
}
8085

8186

8287
@define(order=True)
@@ -126,9 +131,9 @@ def as_dict(self) -> dict[str, Any]:
126131
def capture_error(self, err: HassioError | None = None) -> None:
127132
"""Capture an error or record that an unknown error has occurred."""
128133
if err:
129-
new_error = SupervisorJobError(type(err), str(err))
134+
new_error = SupervisorJobError(type(err), str(err), self.stage)
130135
else:
131-
new_error = SupervisorJobError()
136+
new_error = SupervisorJobError(stage=self.stage)
132137
self.errors += [new_error]
133138

134139
@contextmanager

tests/api/test_backups.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,56 @@ async def test_backup_to_multiple_locations(
658658
assert coresys.backups.get(slug).location is None
659659

660660

661+
@pytest.mark.usefixtures("path_extern", "tmp_supervisor_data")
662+
@pytest.mark.parametrize(
663+
("backup_type", "inputs"), [("full", {}), ("partial", {"folders": ["ssl"]})]
664+
)
665+
async def test_backup_to_multiple_locations_error_on_copy(
666+
api_client: TestClient,
667+
coresys: CoreSys,
668+
backup_type: str,
669+
inputs: dict[str, Any],
670+
):
671+
"""Test making a backup to multiple locations that fails during copy stage."""
672+
await coresys.core.set_state(CoreState.RUNNING)
673+
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
674+
675+
with patch("supervisor.backups.manager.shutil.copy", side_effect=OSError):
676+
resp = await api_client.post(
677+
f"/backups/new/{backup_type}",
678+
json={
679+
"name": "Multiple locations test",
680+
"location": [None, ".cloud_backup"],
681+
}
682+
| inputs,
683+
)
684+
assert resp.status == 200
685+
result = await resp.json()
686+
assert result["result"] == "ok"
687+
slug = result["data"]["slug"]
688+
689+
orig_backup = coresys.config.path_backup / f"{slug}.tar"
690+
assert await coresys.run_in_executor(orig_backup.exists)
691+
assert coresys.backups.get(slug).all_locations == {
692+
None: {"path": orig_backup, "protected": False, "size_bytes": 10240},
693+
}
694+
assert coresys.backups.get(slug).location is None
695+
696+
resp = await api_client.get("/jobs/info")
697+
assert resp.status == 200
698+
result = await resp.json()
699+
assert result["data"]["jobs"][0]["name"] == f"backup_manager_{backup_type}_backup"
700+
assert result["data"]["jobs"][0]["reference"] == slug
701+
assert result["data"]["jobs"][0]["done"] is True
702+
assert result["data"]["jobs"][0]["errors"] == [
703+
{
704+
"type": "BackupError",
705+
"message": "Could not copy backup to .cloud_backup due to: ",
706+
"stage": "copy_additional_locations",
707+
}
708+
]
709+
710+
661711
@pytest.mark.parametrize(
662712
("backup_type", "inputs"), [("full", {}), ("partial", {"folders": ["ssl"]})]
663713
)

tests/api/test_jobs.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import pytest
88

99
from supervisor.coresys import CoreSys
10+
from supervisor.exceptions import SupervisorError
1011
from supervisor.jobs.const import ATTR_IGNORE_CONDITIONS, JobCondition
1112
from supervisor.jobs.decorator import Job
1213

@@ -317,3 +318,72 @@ async def test_jobs_sorted_2(self):
317318
],
318319
},
319320
]
321+
322+
323+
async def test_job_with_error(
324+
api_client: TestClient,
325+
coresys: CoreSys,
326+
):
327+
"""Test job output with an error."""
328+
329+
class TestClass:
330+
"""Test class."""
331+
332+
def __init__(self, coresys: CoreSys):
333+
"""Initialize the test class."""
334+
self.coresys = coresys
335+
336+
@Job(name="test_jobs_api_error_outer", cleanup=False)
337+
async def test_jobs_api_error_outer(self):
338+
"""Error test outer method."""
339+
coresys.jobs.current.stage = "test"
340+
await self.test_jobs_api_error_inner()
341+
342+
@Job(name="test_jobs_api_error_inner", cleanup=False)
343+
async def test_jobs_api_error_inner(self):
344+
"""Error test inner method."""
345+
raise SupervisorError("bad")
346+
347+
test = TestClass(coresys)
348+
with pytest.raises(SupervisorError):
349+
await test.test_jobs_api_error_outer()
350+
351+
resp = await api_client.get("/jobs/info")
352+
result = await resp.json()
353+
assert result["data"]["jobs"] == [
354+
{
355+
"created": ANY,
356+
"name": "test_jobs_api_error_outer",
357+
"reference": None,
358+
"uuid": ANY,
359+
"progress": 0,
360+
"stage": "test",
361+
"done": True,
362+
"errors": [
363+
{
364+
"type": "SupervisorError",
365+
"message": "bad",
366+
"stage": "test",
367+
}
368+
],
369+
"child_jobs": [
370+
{
371+
"created": ANY,
372+
"name": "test_jobs_api_error_inner",
373+
"reference": None,
374+
"uuid": ANY,
375+
"progress": 0,
376+
"stage": None,
377+
"done": True,
378+
"errors": [
379+
{
380+
"type": "SupervisorError",
381+
"message": "bad",
382+
"stage": None,
383+
}
384+
],
385+
"child_jobs": [],
386+
},
387+
],
388+
},
389+
]

tests/jobs/test_job_manager.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ async def test_notify_on_change(coresys: CoreSys, ha_ws_client: AsyncMock):
195195
{
196196
"type": "HassioError",
197197
"message": "Unknown error, see supervisor logs",
198+
"stage": "test",
198199
}
199200
],
200201
"created": ANY,
@@ -221,6 +222,7 @@ async def test_notify_on_change(coresys: CoreSys, ha_ws_client: AsyncMock):
221222
{
222223
"type": "HassioError",
223224
"message": "Unknown error, see supervisor logs",
225+
"stage": "test",
224226
}
225227
],
226228
"created": ANY,

0 commit comments

Comments
 (0)