Skip to content

Commit e1e5d3a

Browse files
authored
Create addon boot failed issue for repair (#5397)
* Create addon boot failed issue for repair * MDont make new objects for contains checks
1 parent 473662e commit e1e5d3a

File tree

12 files changed

+381
-23
lines changed

12 files changed

+381
-23
lines changed

supervisor/addons/addon.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@
8181
from ..homeassistant.const import WSEvent, WSType
8282
from ..jobs.const import JobExecutionLimit
8383
from ..jobs.decorator import Job
84-
from ..resolution.const import UnhealthyReason
84+
from ..resolution.const import ContextType, IssueType, UnhealthyReason
85+
from ..resolution.data import Issue
8586
from ..store.addon import AddonStore
8687
from ..utils import check_port
8788
from ..utils.apparmor import adjust_profile
@@ -144,11 +145,19 @@ def __init__(self, coresys: CoreSys, slug: str):
144145
self._listeners: list[EventListener] = []
145146
self._startup_event = asyncio.Event()
146147
self._startup_task: asyncio.Task | None = None
148+
self._boot_failed_issue = Issue(
149+
IssueType.BOOT_FAIL, ContextType.ADDON, reference=self.slug
150+
)
147151

148152
def __repr__(self) -> str:
149153
"""Return internal representation."""
150154
return f"<Addon: {self.slug}>"
151155

156+
@property
157+
def boot_failed_issue(self) -> Issue:
158+
"""Get issue used if start on boot failed."""
159+
return self._boot_failed_issue
160+
152161
@property
153162
def state(self) -> AddonState:
154163
"""Return state of the add-on."""
@@ -166,6 +175,13 @@ def state(self, new_state: AddonState) -> None:
166175
if new_state == AddonState.STARTED or old_state == AddonState.STARTUP:
167176
self._startup_event.set()
168177

178+
# Dismiss boot failed issue if present and we started
179+
if (
180+
new_state == AddonState.STARTED
181+
and self.boot_failed_issue in self.sys_resolution.issues
182+
):
183+
self.sys_resolution.dismiss_issue(self.boot_failed_issue)
184+
169185
self.sys_homeassistant.websocket.send_message(
170186
{
171187
ATTR_TYPE: WSType.SUPERVISOR_EVENT,
@@ -322,6 +338,13 @@ def boot(self, value: AddonBoot) -> None:
322338
"""Store user boot options."""
323339
self.persist[ATTR_BOOT] = value
324340

341+
# Dismiss boot failed issue if present and boot at start disabled
342+
if (
343+
value == AddonBoot.MANUAL
344+
and self._boot_failed_issue in self.sys_resolution.issues
345+
):
346+
self.sys_resolution.dismiss_issue(self._boot_failed_issue)
347+
325348
@property
326349
def auto_update(self) -> bool:
327350
"""Return if auto update is enable."""

supervisor/addons/manager.py

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,22 @@
77
import tarfile
88
from typing import Union
99

10+
from attr import evolve
11+
1012
from ..const import AddonBoot, AddonStartup, AddonState
1113
from ..coresys import CoreSys, CoreSysAttributes
1214
from ..exceptions import (
13-
AddonConfigurationError,
1415
AddonsError,
1516
AddonsJobError,
1617
AddonsNotSupportedError,
1718
CoreDNSError,
18-
DockerAPIError,
1919
DockerError,
20-
DockerNotFound,
2120
HassioError,
2221
HomeAssistantAPIError,
2322
)
2423
from ..jobs.decorator import Job, JobCondition
2524
from ..resolution.const import ContextType, IssueType, SuggestionType
2625
from ..store.addon import AddonStore
27-
from ..utils import check_exception_chain
2826
from ..utils.sentry import capture_exception
2927
from .addon import Addon
3028
from .const import ADDON_UPDATE_CONDITIONS
@@ -118,15 +116,14 @@ async def boot(self, stage: AddonStartup) -> None:
118116
try:
119117
if start_task := await addon.start():
120118
wait_boot.append(start_task)
121-
except AddonsError as err:
122-
# Check if there is an system/user issue
123-
if check_exception_chain(
124-
err, (DockerAPIError, DockerNotFound, AddonConfigurationError)
125-
):
126-
addon.boot = AddonBoot.MANUAL
127-
addon.save_persist()
128119
except HassioError:
129-
pass # These are already handled
120+
self.sys_resolution.add_issue(
121+
evolve(addon.boot_failed_issue),
122+
suggestions=[
123+
SuggestionType.EXECUTE_START,
124+
SuggestionType.DISABLE_BOOT,
125+
],
126+
)
130127
else:
131128
continue
132129

@@ -135,6 +132,19 @@ async def boot(self, stage: AddonStartup) -> None:
135132
# Ignore exceptions from waiting for addon startup, addon errors handled elsewhere
136133
await asyncio.gather(*wait_boot, return_exceptions=True)
137134

135+
# After waiting for startup, create an issue for boot addons that are error or unknown state
136+
# Ignore stopped as single shot addons can be run at boot and this is successful exit
137+
# Timeout waiting for startup is not a failure, addon is probably just slow
138+
for addon in tasks:
139+
if addon.state in {AddonState.ERROR, AddonState.UNKNOWN}:
140+
self.sys_resolution.add_issue(
141+
evolve(addon.boot_failed_issue),
142+
suggestions=[
143+
SuggestionType.EXECUTE_START,
144+
SuggestionType.DISABLE_BOOT,
145+
],
146+
)
147+
138148
async def shutdown(self, stage: AddonStartup) -> None:
139149
"""Shutdown addons."""
140150
tasks: list[Addon] = []

supervisor/mounts/manager.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import logging
77
from pathlib import PurePath
88

9+
from attr import evolve
10+
911
from ..const import ATTR_NAME
1012
from ..coresys import CoreSys, CoreSysAttributes
1113
from ..dbus.const import UnitActiveState
@@ -171,7 +173,7 @@ async def _mount_errors_to_issues(
171173
capture_exception(errors[i])
172174

173175
self.sys_resolution.add_issue(
174-
mounts[i].failed_issue,
176+
evolve(mounts[i].failed_issue),
175177
suggestions=[
176178
SuggestionType.EXECUTE_RELOAD,
177179
SuggestionType.EXECUTE_REMOVE,

supervisor/mounts/mount.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ def __init__(self, coresys: CoreSys, data: MountData) -> None:
6868
self._data: MountData = data
6969
self._unit: SystemdUnit | None = None
7070
self._state: UnitActiveState | None = None
71+
self._failed_issue = Issue(
72+
IssueType.MOUNT_FAILED, ContextType.MOUNT, reference=self.name
73+
)
7174

7275
@classmethod
7376
def from_dict(cls, coresys: CoreSys, data: MountData) -> "Mount":
@@ -162,7 +165,7 @@ def local_where(self) -> Path | None:
162165
@property
163166
def failed_issue(self) -> Issue:
164167
"""Get issue used if this mount has failed."""
165-
return Issue(IssueType.MOUNT_FAILED, ContextType.MOUNT, reference=self.name)
168+
return self._failed_issue
166169

167170
async def is_mounted(self) -> bool:
168171
"""Return true if successfully mounted and available."""

supervisor/resolution/const.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ class UnhealthyReason(StrEnum):
7171
class IssueType(StrEnum):
7272
"""Issue type."""
7373

74+
BOOT_FAIL = "boot_fail"
7475
CORRUPT_DOCKER = "corrupt_docker"
7576
CORRUPT_REPOSITORY = "corrupt_repository"
7677
CORRUPT_FILESYSTEM = "corrupt_filesystem"
@@ -103,13 +104,15 @@ class SuggestionType(StrEnum):
103104
ADOPT_DATA_DISK = "adopt_data_disk"
104105
CLEAR_FULL_BACKUP = "clear_full_backup"
105106
CREATE_FULL_BACKUP = "create_full_backup"
107+
DISABLE_BOOT = "disable_boot"
106108
EXECUTE_INTEGRITY = "execute_integrity"
107109
EXECUTE_REBOOT = "execute_reboot"
108110
EXECUTE_REBUILD = "execute_rebuild"
109111
EXECUTE_RELOAD = "execute_reload"
110112
EXECUTE_REMOVE = "execute_remove"
111113
EXECUTE_REPAIR = "execute_repair"
112114
EXECUTE_RESET = "execute_reset"
115+
EXECUTE_START = "execute_start"
113116
EXECUTE_STOP = "execute_stop"
114117
EXECUTE_UPDATE = "execute_update"
115118
REGISTRY_LOGIN = "registry_login"
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""Helpers to fix addon by disabling boot."""
2+
3+
import logging
4+
5+
from ...const import AddonBoot
6+
from ...coresys import CoreSys
7+
from ..const import ContextType, IssueType, SuggestionType
8+
from .base import FixupBase
9+
10+
_LOGGER: logging.Logger = logging.getLogger(__name__)
11+
12+
13+
def setup(coresys: CoreSys) -> FixupBase:
14+
"""Check setup function."""
15+
return FixupAddonDisableBoot(coresys)
16+
17+
18+
class FixupAddonDisableBoot(FixupBase):
19+
"""Storage class for fixup."""
20+
21+
async def process_fixup(self, reference: str | None = None) -> None:
22+
"""Initialize the fixup class."""
23+
if not (addon := self.sys_addons.get(reference, local_only=True)):
24+
_LOGGER.info("Cannot change addon %s as it does not exist", reference)
25+
return
26+
27+
# Disable boot on addon
28+
addon.boot = AddonBoot.MANUAL
29+
30+
@property
31+
def suggestion(self) -> SuggestionType:
32+
"""Return a SuggestionType enum."""
33+
return SuggestionType.DISABLE_BOOT
34+
35+
@property
36+
def context(self) -> ContextType:
37+
"""Return a ContextType enum."""
38+
return ContextType.ADDON
39+
40+
@property
41+
def issues(self) -> list[IssueType]:
42+
"""Return a IssueType enum list."""
43+
return [IssueType.BOOT_FAIL]
44+
45+
@property
46+
def auto(self) -> bool:
47+
"""Return if a fixup can be apply as auto fix."""
48+
return False
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Helpers to fix addon by starting it."""
2+
3+
import logging
4+
5+
from ...const import AddonState
6+
from ...coresys import CoreSys
7+
from ...exceptions import AddonsError, ResolutionFixupError
8+
from ..const import ContextType, IssueType, SuggestionType
9+
from .base import FixupBase
10+
11+
_LOGGER: logging.Logger = logging.getLogger(__name__)
12+
13+
14+
def setup(coresys: CoreSys) -> FixupBase:
15+
"""Check setup function."""
16+
return FixupAddonExecuteStart(coresys)
17+
18+
19+
class FixupAddonExecuteStart(FixupBase):
20+
"""Storage class for fixup."""
21+
22+
async def process_fixup(self, reference: str | None = None) -> None:
23+
"""Initialize the fixup class."""
24+
if not (addon := self.sys_addons.get(reference, local_only=True)):
25+
_LOGGER.info("Cannot start addon %s as it does not exist", reference)
26+
return
27+
28+
# Start addon
29+
try:
30+
start_task = await addon.start()
31+
except AddonsError as err:
32+
_LOGGER.error("Could not start %s due to %s", reference, err)
33+
raise ResolutionFixupError() from None
34+
35+
# Wait for addon start. If it ends up in error or unknown state it's not fixed
36+
await start_task
37+
if addon.state in {AddonState.ERROR, AddonState.UNKNOWN}:
38+
_LOGGER.error("Addon %s could not start successfully", reference)
39+
raise ResolutionFixupError()
40+
41+
@property
42+
def suggestion(self) -> SuggestionType:
43+
"""Return a SuggestionType enum."""
44+
return SuggestionType.EXECUTE_START
45+
46+
@property
47+
def context(self) -> ContextType:
48+
"""Return a ContextType enum."""
49+
return ContextType.ADDON
50+
51+
@property
52+
def issues(self) -> list[IssueType]:
53+
"""Return a IssueType enum list."""
54+
return [IssueType.BOOT_FAIL]
55+
56+
@property
57+
def auto(self) -> bool:
58+
"""Return if a fixup can be apply as auto fix."""
59+
return False

tests/addons/test_addon.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from supervisor.addons.addon import Addon
1515
from supervisor.addons.const import AddonBackupMode
1616
from supervisor.addons.model import AddonModel
17-
from supervisor.const import AddonState, BusEvent
17+
from supervisor.const import AddonBoot, AddonState, BusEvent
1818
from supervisor.coresys import CoreSys
1919
from supervisor.docker.addon import DockerAddon
2020
from supervisor.docker.const import ContainerState
@@ -24,6 +24,8 @@
2424
from supervisor.store.repository import Repository
2525
from supervisor.utils.dt import utcnow
2626

27+
from .test_manager import BOOT_FAIL_ISSUE, BOOT_FAIL_SUGGESTIONS
28+
2729
from tests.common import get_fixture_path
2830
from tests.const import TEST_ADDON_SLUG
2931

@@ -895,3 +897,32 @@ async def test_addon_manual_only_boot(coresys: CoreSys, install_addon_example: A
895897
# However boot mode can change on update and user may have set auto before, ensure it is ignored
896898
install_addon_example.boot = "auto"
897899
assert install_addon_example.boot == "manual"
900+
901+
902+
async def test_addon_start_dismisses_boot_fail(
903+
coresys: CoreSys, install_addon_ssh: Addon
904+
):
905+
"""Test a successful start dismisses the boot fail issue."""
906+
install_addon_ssh.state = AddonState.ERROR
907+
coresys.resolution.add_issue(
908+
BOOT_FAIL_ISSUE, [suggestion.type for suggestion in BOOT_FAIL_SUGGESTIONS]
909+
)
910+
911+
install_addon_ssh.state = AddonState.STARTED
912+
assert coresys.resolution.issues == []
913+
assert coresys.resolution.suggestions == []
914+
915+
916+
async def test_addon_disable_boot_dismisses_boot_fail(
917+
coresys: CoreSys, install_addon_ssh: Addon
918+
):
919+
"""Test a disabling boot dismisses the boot fail issue."""
920+
install_addon_ssh.boot = AddonBoot.AUTO
921+
install_addon_ssh.state = AddonState.ERROR
922+
coresys.resolution.add_issue(
923+
BOOT_FAIL_ISSUE, [suggestion.type for suggestion in BOOT_FAIL_SUGGESTIONS]
924+
)
925+
926+
install_addon_ssh.boot = AddonBoot.MANUAL
927+
assert coresys.resolution.issues == []
928+
assert coresys.resolution.suggestions == []

0 commit comments

Comments
 (0)