Skip to content

Commit 9222a3c

Browse files
authored
Report stage with error in jobs (#5784)
* Report stage with error in jobs * Copy doesn't lose track of the successful copies * Add stage to errors in api output test * revert unneessary change to import * Add tests for a bit more coverage of copy_additional_locations
1 parent 92cadb4 commit 9222a3c

File tree

6 files changed

+214
-30
lines changed

6 files changed

+214
-30
lines changed

supervisor/backups/manager.py

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -368,13 +368,15 @@ async def _copy_to_additional_locations(
368368
):
369369
"""Copy a backup file to additional locations."""
370370

371+
all_new_locations: dict[str | None, Path] = {}
372+
371373
def copy_to_additional_locations() -> dict[str | None, Path]:
372374
"""Copy backup file to additional locations."""
373-
all_locations: dict[str | None, Path] = {}
375+
nonlocal all_new_locations
374376
for location in locations:
375377
try:
376378
if location == LOCATION_CLOUD_BACKUP:
377-
all_locations[LOCATION_CLOUD_BACKUP] = Path(
379+
all_new_locations[LOCATION_CLOUD_BACKUP] = Path(
378380
copy(backup.tarfile, self.sys_config.path_core_backup)
379381
)
380382
elif location:
@@ -384,11 +386,11 @@ def copy_to_additional_locations() -> dict[str | None, Path]:
384386
f"{location_mount.name} is down, cannot copy to it",
385387
_LOGGER.error,
386388
)
387-
all_locations[location_mount.name] = Path(
389+
all_new_locations[location_mount.name] = Path(
388390
copy(backup.tarfile, location_mount.local_where)
389391
)
390392
else:
391-
all_locations[None] = Path(
393+
all_new_locations[None] = Path(
392394
copy(backup.tarfile, self.sys_config.path_backup)
393395
)
394396
except OSError as err:
@@ -401,28 +403,24 @@ def copy_to_additional_locations() -> dict[str | None, Path]:
401403
raise BackupDataDiskBadMessageError(msg, _LOGGER.error) from err
402404
raise BackupError(msg, _LOGGER.error) from err
403405

404-
return all_locations
405-
406406
try:
407-
all_new_locations = await self.sys_run_in_executor(
408-
copy_to_additional_locations
409-
)
407+
await self.sys_run_in_executor(copy_to_additional_locations)
410408
except BackupDataDiskBadMessageError:
411409
self.sys_resolution.add_unhealthy_reason(
412410
UnhealthyReason.OSERROR_BAD_MESSAGE
413411
)
414412
raise
415-
416-
backup.all_locations.update(
417-
{
418-
loc: {
419-
ATTR_PATH: path,
420-
ATTR_PROTECTED: backup.protected,
421-
ATTR_SIZE_BYTES: backup.size_bytes,
413+
finally:
414+
backup.all_locations.update(
415+
{
416+
loc: {
417+
ATTR_PATH: path,
418+
ATTR_PROTECTED: backup.protected,
419+
ATTR_SIZE_BYTES: backup.size_bytes,
420+
}
421+
for loc, path in all_new_locations.items()
422422
}
423-
for loc, path in all_new_locations.items()
424-
}
425-
)
423+
)
426424

427425
@Job(name="backup_manager_import_backup")
428426
async def import_backup(

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: 78 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,11 @@ async def test_api_backup_errors(
394394
assert job["child_jobs"][1]["child_jobs"][0]["name"] == "backup_addon_save"
395395
assert job["child_jobs"][1]["child_jobs"][0]["reference"] == "local_ssh"
396396
assert job["child_jobs"][1]["child_jobs"][0]["errors"] == [
397-
{"type": "BackupError", "message": "Can't create backup for local_ssh"}
397+
{
398+
"type": "BackupError",
399+
"message": "Can't create backup for local_ssh",
400+
"stage": None,
401+
}
398402
]
399403
assert job["child_jobs"][2]["name"] == "backup_store_folders"
400404
assert job["child_jobs"][2]["reference"] == slug
@@ -425,11 +429,21 @@ async def test_api_backup_errors(
425429

426430
assert job["name"] == f"backup_manager_{backup_type}_backup"
427431
assert job["done"] is True
428-
assert job["errors"] == (
429-
err := [{"type": "HomeAssistantBackupError", "message": "Backup error"}]
430-
)
432+
assert job["errors"] == [
433+
{
434+
"type": "HomeAssistantBackupError",
435+
"message": "Backup error",
436+
"stage": "home_assistant",
437+
}
438+
]
431439
assert job["child_jobs"][0]["name"] == "backup_store_homeassistant"
432-
assert job["child_jobs"][0]["errors"] == err
440+
assert job["child_jobs"][0]["errors"] == [
441+
{
442+
"type": "HomeAssistantBackupError",
443+
"message": "Backup error",
444+
"stage": None,
445+
}
446+
]
433447
assert len(job["child_jobs"]) == 1
434448

435449

@@ -625,22 +639,28 @@ async def test_upload_download(
625639

626640
@pytest.mark.usefixtures("path_extern", "tmp_supervisor_data")
627641
@pytest.mark.parametrize(
628-
("backup_type", "inputs"), [("full", {}), ("partial", {"folders": ["ssl"]})]
642+
("backup_type", "inputs", "locations"),
643+
[
644+
("full", {}, [None, ".cloud_backup"]),
645+
("full", {}, [".cloud_backup", None]),
646+
("partial", {"folders": ["ssl"]}, [None, ".cloud_backup"]),
647+
("partial", {"folders": ["ssl"]}, [".cloud_backup", None]),
648+
],
629649
)
630650
async def test_backup_to_multiple_locations(
631651
api_client: TestClient,
632652
coresys: CoreSys,
633653
backup_type: str,
634654
inputs: dict[str, Any],
655+
locations: list[str | None],
635656
):
636657
"""Test making a backup to multiple locations."""
637658
await coresys.core.set_state(CoreState.RUNNING)
638659
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
639660

640661
resp = await api_client.post(
641662
f"/backups/new/{backup_type}",
642-
json={"name": "Multiple locations test", "location": [None, ".cloud_backup"]}
643-
| inputs,
663+
json={"name": "Multiple locations test", "location": locations} | inputs,
644664
)
645665
assert resp.status == 200
646666
result = await resp.json()
@@ -658,6 +678,56 @@ async def test_backup_to_multiple_locations(
658678
assert coresys.backups.get(slug).location is None
659679

660680

681+
@pytest.mark.usefixtures("path_extern", "tmp_supervisor_data")
682+
@pytest.mark.parametrize(
683+
("backup_type", "inputs"), [("full", {}), ("partial", {"folders": ["ssl"]})]
684+
)
685+
async def test_backup_to_multiple_locations_error_on_copy(
686+
api_client: TestClient,
687+
coresys: CoreSys,
688+
backup_type: str,
689+
inputs: dict[str, Any],
690+
):
691+
"""Test making a backup to multiple locations that fails during copy stage."""
692+
await coresys.core.set_state(CoreState.RUNNING)
693+
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
694+
695+
with patch("supervisor.backups.manager.copy", side_effect=OSError):
696+
resp = await api_client.post(
697+
f"/backups/new/{backup_type}",
698+
json={
699+
"name": "Multiple locations test",
700+
"location": [None, ".cloud_backup"],
701+
}
702+
| inputs,
703+
)
704+
assert resp.status == 200
705+
result = await resp.json()
706+
assert result["result"] == "ok"
707+
slug = result["data"]["slug"]
708+
709+
orig_backup = coresys.config.path_backup / f"{slug}.tar"
710+
assert await coresys.run_in_executor(orig_backup.exists)
711+
assert coresys.backups.get(slug).all_locations == {
712+
None: {"path": orig_backup, "protected": False, "size_bytes": 10240},
713+
}
714+
assert coresys.backups.get(slug).location is None
715+
716+
resp = await api_client.get("/jobs/info")
717+
assert resp.status == 200
718+
result = await resp.json()
719+
assert result["data"]["jobs"][0]["name"] == f"backup_manager_{backup_type}_backup"
720+
assert result["data"]["jobs"][0]["reference"] == slug
721+
assert result["data"]["jobs"][0]["done"] is True
722+
assert result["data"]["jobs"][0]["errors"] == [
723+
{
724+
"type": "BackupError",
725+
"message": "Could not copy backup to .cloud_backup due to: ",
726+
"stage": "copy_additional_locations",
727+
}
728+
]
729+
730+
661731
@pytest.mark.parametrize(
662732
("backup_type", "inputs"), [("full", {}), ("partial", {"folders": ["ssl"]})]
663733
)

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/backups/test_manager.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from supervisor.homeassistant.module import HomeAssistant
3838
from supervisor.jobs.const import JobCondition
3939
from supervisor.mounts.mount import Mount
40+
from supervisor.resolution.const import UnhealthyReason
4041
from supervisor.utils.json import read_json_file, write_json_file
4142

4243
from tests.common import get_fixture_path
@@ -2079,3 +2080,41 @@ async def test_remove_non_existing_backup_raises(
20792080

20802081
with pytest.raises(BackupFileNotFoundError):
20812082
await coresys.backups.remove(backup)
2083+
2084+
2085+
@pytest.mark.usefixtures("tmp_supervisor_data", "path_extern")
2086+
@pytest.mark.parametrize(
2087+
("error_num", "unhealthy", "default_location", "additional_location"),
2088+
[
2089+
(errno.EBUSY, False, None, ".cloud_backup"),
2090+
(errno.EBUSY, False, ".cloud_backup", None),
2091+
(errno.EBADMSG, True, None, ".cloud_backup"),
2092+
(errno.EBADMSG, True, ".cloud_backup", None),
2093+
],
2094+
)
2095+
async def test_backup_multiple_locations_oserror(
2096+
coresys: CoreSys,
2097+
error_num: int,
2098+
unhealthy: bool,
2099+
default_location: str | None,
2100+
additional_location: str | None,
2101+
):
2102+
"""Test backup to multiple locations raises oserror."""
2103+
await coresys.core.set_state(CoreState.RUNNING)
2104+
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
2105+
2106+
with patch("supervisor.backups.manager.copy", side_effect=(err := OSError())):
2107+
err.errno = error_num
2108+
backup: Backup | None = await coresys.backups.do_backup_full(
2109+
name="test",
2110+
location=default_location,
2111+
additional_locations=[additional_location],
2112+
)
2113+
2114+
assert backup
2115+
assert coresys.backups.get(backup.slug) == backup
2116+
assert backup.location == default_location
2117+
assert additional_location not in backup.all_locations
2118+
assert (
2119+
UnhealthyReason.OSERROR_BAD_MESSAGE in coresys.resolution.unhealthy
2120+
) is unhealthy

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)