Skip to content

Commit 997a51f

Browse files
authored
Remove I/O in event loop for add-on backup and restore (#5649)
* Remove I/O in event loop for add-on backup and restore Remove I/O in event loop for add-on backup and restore operations. On backup, this moves the add-on shutdown before metadata is stored in the backup, which slightly lenghens the time the add-on is actually stopped. However, the biggest contributor here is likely adding the image itself if it is a local backup. However, since that is the minority of cases, I've opted for simplicity over optimizing for this case. * Use partial to explicitly bind arguments
1 parent cda6325 commit 997a51f

File tree

4 files changed

+101
-81
lines changed

4 files changed

+101
-81
lines changed

supervisor/addons/addon.py

+95-68
Original file line numberDiff line numberDiff line change
@@ -1238,46 +1238,45 @@ async def backup(self, tar_file: tarfile.TarFile) -> asyncio.Task | None:
12381238
Returns a Task that completes when addon has state 'started' (see start)
12391239
for cold backup. Else nothing is returned.
12401240
"""
1241-
wait_for_start: Awaitable[None] | None = None
1242-
1243-
with TemporaryDirectory(dir=self.sys_config.path_tmp) as temp:
1244-
temp_path = Path(temp)
12451241

1246-
# store local image
1247-
if self.need_build:
1248-
try:
1249-
await self.instance.export_image(temp_path.joinpath("image.tar"))
1250-
except DockerError as err:
1251-
raise AddonsError() from err
1252-
1253-
data = {
1254-
ATTR_USER: self.persist,
1255-
ATTR_SYSTEM: self.data,
1256-
ATTR_VERSION: self.version,
1257-
ATTR_STATE: _MAP_ADDON_STATE.get(self.state, self.state),
1258-
}
1242+
def _addon_backup(
1243+
store_image: bool,
1244+
metadata: dict[str, Any],
1245+
apparmor_profile: str | None,
1246+
addon_config_used: bool,
1247+
):
1248+
"""Start the backup process."""
1249+
with TemporaryDirectory(dir=self.sys_config.path_tmp) as temp:
1250+
temp_path = Path(temp)
12591251

1260-
# Store local configs/state
1261-
try:
1262-
write_json_file(temp_path.joinpath("addon.json"), data)
1263-
except ConfigurationFileError as err:
1264-
raise AddonsError(
1265-
f"Can't save meta for {self.slug}", _LOGGER.error
1266-
) from err
1252+
# store local image
1253+
if store_image:
1254+
try:
1255+
self.instance.export_image(temp_path.joinpath("image.tar"))
1256+
except DockerError as err:
1257+
raise AddonsError() from err
12671258

1268-
# Store AppArmor Profile
1269-
if self.sys_host.apparmor.exists(self.slug):
1270-
profile = temp_path.joinpath("apparmor.txt")
1259+
# Store local configs/state
12711260
try:
1272-
await self.sys_host.apparmor.backup_profile(self.slug, profile)
1273-
except HostAppArmorError as err:
1261+
write_json_file(temp_path.joinpath("addon.json"), metadata)
1262+
except ConfigurationFileError as err:
12741263
raise AddonsError(
1275-
"Can't backup AppArmor profile", _LOGGER.error
1264+
f"Can't save meta for {self.slug}", _LOGGER.error
12761265
) from err
12771266

1278-
# write into tarfile
1279-
def _write_tarfile():
1280-
"""Write tar inside loop."""
1267+
# Store AppArmor Profile
1268+
if apparmor_profile:
1269+
profile_backup_file = temp_path.joinpath("apparmor.txt")
1270+
try:
1271+
self.sys_host.apparmor.backup_profile(
1272+
apparmor_profile, profile_backup_file
1273+
)
1274+
except HostAppArmorError as err:
1275+
raise AddonsError(
1276+
"Can't backup AppArmor profile", _LOGGER.error
1277+
) from err
1278+
1279+
# Write tarfile
12811280
with tar_file as backup:
12821281
# Backup metadata
12831282
backup.add(temp, arcname=".")
@@ -1293,7 +1292,7 @@ def _write_tarfile():
12931292
)
12941293

12951294
# Backup config
1296-
if self.addon_config_used:
1295+
if addon_config_used:
12971296
atomic_contents_add(
12981297
backup,
12991298
self.path_config,
@@ -1303,19 +1302,39 @@ def _write_tarfile():
13031302
arcname="config",
13041303
)
13051304

1306-
is_running = await self.begin_backup()
1307-
try:
1308-
_LOGGER.info("Building backup for add-on %s", self.slug)
1309-
await self.sys_run_in_executor(_write_tarfile)
1310-
except (tarfile.TarError, OSError) as err:
1311-
raise AddonsError(
1312-
f"Can't write tarfile {tar_file}: {err}", _LOGGER.error
1313-
) from err
1314-
finally:
1315-
if is_running:
1316-
wait_for_start = await self.end_backup()
1305+
wait_for_start: Awaitable[None] | None = None
1306+
1307+
data = {
1308+
ATTR_USER: self.persist,
1309+
ATTR_SYSTEM: self.data,
1310+
ATTR_VERSION: self.version,
1311+
ATTR_STATE: _MAP_ADDON_STATE.get(self.state, self.state),
1312+
}
1313+
apparmor_profile = (
1314+
self.slug if self.sys_host.apparmor.exists(self.slug) else None
1315+
)
1316+
1317+
was_running = await self.begin_backup()
1318+
try:
1319+
_LOGGER.info("Building backup for add-on %s", self.slug)
1320+
await self.sys_run_in_executor(
1321+
partial(
1322+
_addon_backup,
1323+
store_image=self.need_build,
1324+
metadata=data,
1325+
apparmor_profile=apparmor_profile,
1326+
addon_config_used=self.addon_config_used,
1327+
)
1328+
)
1329+
_LOGGER.info("Finish backup for addon %s", self.slug)
1330+
except (tarfile.TarError, OSError) as err:
1331+
raise AddonsError(
1332+
f"Can't write tarfile {tar_file}: {err}", _LOGGER.error
1333+
) from err
1334+
finally:
1335+
if was_running:
1336+
wait_for_start = await self.end_backup()
13171337

1318-
_LOGGER.info("Finish backup for addon %s", self.slug)
13191338
return wait_for_start
13201339

13211340
@Job(
@@ -1330,30 +1349,36 @@ async def restore(self, tar_file: tarfile.TarFile) -> asyncio.Task | None:
13301349
if addon is started after restore. Else nothing is returned.
13311350
"""
13321351
wait_for_start: Awaitable[None] | None = None
1333-
with TemporaryDirectory(dir=self.sys_config.path_tmp) as temp:
1334-
# extract backup
1335-
def _extract_tarfile():
1336-
"""Extract tar backup."""
1352+
1353+
# Extract backup
1354+
def _extract_tarfile() -> tuple[TemporaryDirectory, dict[str, Any]]:
1355+
"""Extract tar backup."""
1356+
tmp = TemporaryDirectory(dir=self.sys_config.path_tmp)
1357+
try:
13371358
with tar_file as backup:
13381359
backup.extractall(
1339-
path=Path(temp),
1360+
path=tmp.name,
13401361
members=secure_path(backup),
13411362
filter="fully_trusted",
13421363
)
13431364

1344-
try:
1345-
await self.sys_run_in_executor(_extract_tarfile)
1346-
except tarfile.TarError as err:
1347-
raise AddonsError(
1348-
f"Can't read tarfile {tar_file}: {err}", _LOGGER.error
1349-
) from err
1365+
data = read_json_file(Path(tmp.name, "addon.json"))
1366+
except:
1367+
tmp.cleanup()
1368+
raise
13501369

1351-
# Read backup data
1352-
try:
1353-
data = read_json_file(Path(temp, "addon.json"))
1354-
except ConfigurationFileError as err:
1355-
raise AddonsError() from err
1370+
return tmp, data
1371+
1372+
try:
1373+
tmp, data = await self.sys_run_in_executor(_extract_tarfile)
1374+
except tarfile.TarError as err:
1375+
raise AddonsError(
1376+
f"Can't read tarfile {tar_file}: {err}", _LOGGER.error
1377+
) from err
1378+
except ConfigurationFileError as err:
1379+
raise AddonsError() from err
13561380

1381+
try:
13571382
# Validate
13581383
try:
13591384
data = SCHEMA_ADDON_BACKUP(data)
@@ -1387,7 +1412,7 @@ def _extract_tarfile():
13871412
if not await self.instance.exists():
13881413
_LOGGER.info("Restore/Install of image for addon %s", self.slug)
13891414

1390-
image_file = Path(temp, "image.tar")
1415+
image_file = Path(tmp.name, "image.tar")
13911416
if image_file.is_file():
13921417
with suppress(DockerError):
13931418
await self.instance.import_image(image_file)
@@ -1406,13 +1431,13 @@ def _extract_tarfile():
14061431
# Restore data and config
14071432
def _restore_data():
14081433
"""Restore data and config."""
1409-
temp_data = Path(temp, "data")
1434+
temp_data = Path(tmp.name, "data")
14101435
if temp_data.is_dir():
14111436
shutil.copytree(temp_data, self.path_data, symlinks=True)
14121437
else:
14131438
self.path_data.mkdir()
14141439

1415-
temp_config = Path(temp, "config")
1440+
temp_config = Path(tmp.name, "config")
14161441
if temp_config.is_dir():
14171442
shutil.copytree(temp_config, self.path_config, symlinks=True)
14181443
elif self.addon_config_used:
@@ -1432,15 +1457,16 @@ def _restore_data():
14321457
) from err
14331458

14341459
# Restore AppArmor
1435-
profile_file = Path(temp, "apparmor.txt")
1460+
profile_file = Path(tmp.name, "apparmor.txt")
14361461
if profile_file.exists():
14371462
try:
14381463
await self.sys_host.apparmor.load_profile(
14391464
self.slug, profile_file
14401465
)
14411466
except HostAppArmorError as err:
14421467
_LOGGER.error(
1443-
"Can't restore AppArmor profile for add-on %s", self.slug
1468+
"Can't restore AppArmor profile for add-on %s",
1469+
self.slug,
14441470
)
14451471
raise AddonsError() from err
14461472

@@ -1452,7 +1478,8 @@ def _restore_data():
14521478
# Run add-on
14531479
if data[ATTR_STATE] == AddonState.STARTED:
14541480
wait_for_start = await self.start()
1455-
1481+
finally:
1482+
tmp.cleanup()
14561483
_LOGGER.info("Finished restore for add-on %s", self.slug)
14571484
return wait_for_start
14581485

supervisor/docker/addon.py

+1-8
Original file line numberDiff line numberDiff line change
@@ -697,16 +697,9 @@ async def _build(self, version: AwesomeVersion, image: str | None = None) -> Non
697697

698698
_LOGGER.info("Build %s:%s done", self.image, version)
699699

700-
@Job(
701-
name="docker_addon_export_image",
702-
limit=JobExecutionLimit.GROUP_ONCE,
703-
on_condition=DockerJobError,
704-
)
705700
def export_image(self, tar_file: Path) -> Awaitable[None]:
706701
"""Export current images into a tar file."""
707-
return self.sys_run_in_executor(
708-
self.sys_docker.export_image, self.image, self.version, tar_file
709-
)
702+
return self.sys_docker.export_image(self.image, self.version, tar_file)
710703

711704
@Job(
712705
name="docker_addon_import_image",

supervisor/host/apparmor.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -115,12 +115,12 @@ async def remove_profile(self, profile_name: str) -> None:
115115
_LOGGER.info("Removing AppArmor profile: %s", profile_name)
116116
self._profiles.remove(profile_name)
117117

118-
async def backup_profile(self, profile_name: str, backup_file: Path) -> None:
118+
def backup_profile(self, profile_name: str, backup_file: Path) -> None:
119119
"""Backup A profile into a new file."""
120120
profile_file: Path = self._get_profile(profile_name)
121121

122122
try:
123-
await self.sys_run_in_executor(shutil.copy, profile_file, backup_file)
123+
shutil.copy(profile_file, backup_file)
124124
except OSError as err:
125125
if err.errno == errno.EBADMSG:
126126
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE

tests/host/test_apparmor_control.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ async def test_remove_profile_error(coresys: CoreSys, path_extern):
4545
assert coresys.core.healthy is False
4646

4747

48-
async def test_backup_profile_error(coresys: CoreSys, path_extern):
48+
def test_backup_profile_error(coresys: CoreSys, path_extern):
4949
"""Test error while backing up apparmor profile."""
5050
test_path = Path("test")
5151
coresys.host.apparmor._profiles.add("test") # pylint: disable=protected-access
@@ -54,10 +54,10 @@ async def test_backup_profile_error(coresys: CoreSys, path_extern):
5454
):
5555
err.errno = errno.EBUSY
5656
with raises(HostAppArmorError):
57-
await coresys.host.apparmor.backup_profile("test", test_path)
57+
coresys.host.apparmor.backup_profile("test", test_path)
5858
assert coresys.core.healthy is True
5959

6060
err.errno = errno.EBADMSG
6161
with raises(HostAppArmorError):
62-
await coresys.host.apparmor.backup_profile("test", test_path)
62+
coresys.host.apparmor.backup_profile("test", test_path)
6363
assert coresys.core.healthy is False

0 commit comments

Comments
 (0)