Compare commits

..

1 Commits

8 changed files with 193 additions and 222 deletions

View File

@ -28,22 +28,22 @@ RUN \
\ \
&& curl -Lso /usr/bin/cosign "https://github.com/home-assistant/cosign/releases/download/${COSIGN_VERSION}/cosign_${BUILD_ARCH}" \ && curl -Lso /usr/bin/cosign "https://github.com/home-assistant/cosign/releases/download/${COSIGN_VERSION}/cosign_${BUILD_ARCH}" \
&& chmod a+x /usr/bin/cosign \ && chmod a+x /usr/bin/cosign \
&& pip3 install uv==0.6.1 && pip3 install uv==0.2.21
# Install requirements # Install requirements
COPY requirements.txt . COPY requirements.txt .
RUN \ RUN \
if [ "${BUILD_ARCH}" = "i386" ]; then \ if [ "${BUILD_ARCH}" = "i386" ]; then \
linux32 uv pip install --compile-bytecode --no-build -r requirements.txt; \ linux32 uv pip install --no-build -r requirements.txt; \
else \ else \
uv pip install --compile-bytecode --no-build -r requirements.txt; \ uv pip install --no-build -r requirements.txt; \
fi \ fi \
&& rm -f requirements.txt && rm -f requirements.txt
# Install Home Assistant Supervisor # Install Home Assistant Supervisor
COPY . supervisor COPY . supervisor
RUN \ RUN \
uv pip install -e ./supervisor \ pip3 install -e ./supervisor \
&& python3 -m compileall ./supervisor/supervisor && python3 -m compileall ./supervisor/supervisor

View File

@ -94,7 +94,7 @@ class Backup(JobGroup):
coresys, JOB_GROUP_BACKUP.format_map(defaultdict(str, slug=slug)), slug coresys, JOB_GROUP_BACKUP.format_map(defaultdict(str, slug=slug)), slug
) )
self._data: dict[str, Any] = data or {ATTR_SLUG: slug} self._data: dict[str, Any] = data or {ATTR_SLUG: slug}
self._tmp: TemporaryDirectory = None self._tmp = None
self._outer_secure_tarfile: SecureTarFile | None = None self._outer_secure_tarfile: SecureTarFile | None = None
self._key: bytes | None = None self._key: bytes | None = None
self._aes: Cipher | None = None self._aes: Cipher | None = None
@ -463,31 +463,23 @@ class Backup(JobGroup):
@asynccontextmanager @asynccontextmanager
async def create(self) -> AsyncGenerator[None]: async def create(self) -> AsyncGenerator[None]:
"""Create new backup file.""" """Create new backup file."""
if self.tarfile.is_file():
def _open_outer_tarfile(): raise BackupError(
"""Create and open outer tarfile.""" f"Cannot make new backup at {self.tarfile.as_posix()}, file already exists!",
if self.tarfile.is_file(): _LOGGER.error,
raise BackupError(
f"Cannot make new backup at {self.tarfile.as_posix()}, file already exists!",
_LOGGER.error,
)
outer_secure_tarfile = SecureTarFile(
self.tarfile,
"w",
gzip=False,
bufsize=BUF_SIZE,
) )
return outer_secure_tarfile, outer_secure_tarfile.open()
self._outer_secure_tarfile, outer_tarfile = await self.sys_run_in_executor( self._outer_secure_tarfile = SecureTarFile(
_open_outer_tarfile self.tarfile,
"w",
gzip=False,
bufsize=BUF_SIZE,
) )
try: try:
yield with self._outer_secure_tarfile as outer_tarfile:
yield
await self._create_cleanup(outer_tarfile)
finally: finally:
await self._create_cleanup(outer_tarfile)
await self.sys_run_in_executor(self._outer_secure_tarfile.close)
self._outer_secure_tarfile = None self._outer_secure_tarfile = None
@asynccontextmanager @asynccontextmanager
@ -504,34 +496,28 @@ class Backup(JobGroup):
if location == DEFAULT if location == DEFAULT
else self.all_locations[location][ATTR_PATH] else self.all_locations[location][ATTR_PATH]
) )
if not backup_tarfile.is_file():
self.sys_create_task(self.sys_backups.reload(location))
raise BackupFileNotFoundError(
f"Cannot open backup at {backup_tarfile.as_posix()}, file does not exist!",
_LOGGER.error,
)
# extract an existing backup # extract an existing backup
def _extract_backup(): self._tmp = TemporaryDirectory(dir=str(backup_tarfile.parent))
if not backup_tarfile.is_file():
raise BackupFileNotFoundError(
f"Cannot open backup at {backup_tarfile.as_posix()}, file does not exist!",
_LOGGER.error,
)
tmp = TemporaryDirectory(dir=str(backup_tarfile.parent))
def _extract_backup():
"""Extract a backup."""
with tarfile.open(backup_tarfile, "r:") as tar: with tarfile.open(backup_tarfile, "r:") as tar:
tar.extractall( tar.extractall(
path=tmp.name, path=self._tmp.name,
members=secure_path(tar), members=secure_path(tar),
filter="fully_trusted", filter="fully_trusted",
) )
return tmp with self._tmp:
await self.sys_run_in_executor(_extract_backup)
try:
self._tmp = await self.sys_run_in_executor(_extract_backup)
yield yield
except BackupFileNotFoundError as err:
self.sys_create_task(self.sys_backups.reload(location))
raise err
finally:
if self._tmp:
self._tmp.cleanup()
async def _create_cleanup(self, outer_tarfile: TarFile) -> None: async def _create_cleanup(self, outer_tarfile: TarFile) -> None:
"""Cleanup after backup creation. """Cleanup after backup creation.
@ -683,16 +669,17 @@ class Backup(JobGroup):
async def _folder_save(self, name: str): async def _folder_save(self, name: str):
"""Take backup of a folder.""" """Take backup of a folder."""
self.sys_jobs.current.reference = name self.sys_jobs.current.reference = name
slug_name = name.replace("/", "_") slug_name = name.replace("/", "_")
tar_name = f"{slug_name}.tar{'.gz' if self.compressed else ''}" tar_name = f"{slug_name}.tar{'.gz' if self.compressed else ''}"
origin_dir = Path(self.sys_config.path_supervisor, name) origin_dir = Path(self.sys_config.path_supervisor, name)
def _save() -> bool: # Check if exists
# Check if exists if not origin_dir.is_dir():
if not origin_dir.is_dir(): _LOGGER.warning("Can't find backup folder %s", name)
_LOGGER.warning("Can't find backup folder %s", name) return
return False
def _save() -> None:
# Take backup # Take backup
_LOGGER.info("Backing up folder %s", name) _LOGGER.info("Backing up folder %s", name)
@ -725,16 +712,16 @@ class Backup(JobGroup):
) )
_LOGGER.info("Backup folder %s done", name) _LOGGER.info("Backup folder %s done", name)
return True
try: try:
if await self.sys_run_in_executor(_save): await self.sys_run_in_executor(_save)
self._data[ATTR_FOLDERS].append(name)
except (tarfile.TarError, OSError) as err: except (tarfile.TarError, OSError) as err:
raise BackupError( raise BackupError(
f"Can't backup folder {name}: {str(err)}", _LOGGER.error f"Can't backup folder {name}: {str(err)}", _LOGGER.error
) from err ) from err
self._data[ATTR_FOLDERS].append(name)
@Job(name="backup_store_folders", cleanup=False) @Job(name="backup_store_folders", cleanup=False)
async def store_folders(self, folder_list: list[str]): async def store_folders(self, folder_list: list[str]):
"""Backup Supervisor data into backup.""" """Backup Supervisor data into backup."""
@ -753,18 +740,28 @@ class Backup(JobGroup):
) )
origin_dir = Path(self.sys_config.path_supervisor, name) origin_dir = Path(self.sys_config.path_supervisor, name)
# Check if exists inside backup
if not tar_name.exists():
raise BackupInvalidError(
f"Can't find restore folder {name}", _LOGGER.warning
)
# Unmount any mounts within folder
bind_mounts = [
bound.bind_mount
for bound in self.sys_mounts.bound_mounts
if bound.bind_mount.local_where
and bound.bind_mount.local_where.is_relative_to(origin_dir)
]
if bind_mounts:
await asyncio.gather(*[bind_mount.unmount() for bind_mount in bind_mounts])
# Clean old stuff
if origin_dir.is_dir():
await remove_folder(origin_dir, content_only=True)
# Perform a restore # Perform a restore
def _restore() -> bool: def _restore() -> bool:
# Check if exists inside backup
if not tar_name.exists():
raise BackupInvalidError(
f"Can't find restore folder {name}", _LOGGER.warning
)
# Clean old stuff
if origin_dir.is_dir():
remove_folder(origin_dir, content_only=True)
try: try:
_LOGGER.info("Restore folder %s", name) _LOGGER.info("Restore folder %s", name)
with SecureTarFile( with SecureTarFile(
@ -784,16 +781,6 @@ class Backup(JobGroup):
) from err ) from err
return True return True
# Unmount any mounts within folder
bind_mounts = [
bound.bind_mount
for bound in self.sys_mounts.bound_mounts
if bound.bind_mount.local_where
and bound.bind_mount.local_where.is_relative_to(origin_dir)
]
if bind_mounts:
await asyncio.gather(*[bind_mount.unmount() for bind_mount in bind_mounts])
try: try:
return await self.sys_run_in_executor(_restore) return await self.sys_run_in_executor(_restore)
finally: finally:

View File

@ -118,24 +118,14 @@ class BackupManager(FileConfiguration, JobGroup):
location = self.sys_mounts.default_backup_mount location = self.sys_mounts.default_backup_mount
if location: if location:
location_mount: Mount = location if not location.local_where.is_mount():
return location_mount.local_where raise BackupMountDownError(
f"{location.name} is down, cannot back-up to it", _LOGGER.error
)
return location.local_where
return self.sys_config.path_backup return self.sys_config.path_backup
async def _check_location(self, location: LOCATION_TYPE | type[DEFAULT] = DEFAULT):
"""Check if backup location is accessible."""
if location == DEFAULT and self.sys_mounts.default_backup_mount:
location = self.sys_mounts.default_backup_mount
if location not in (DEFAULT, LOCATION_CLOUD_BACKUP, None):
location_mount: Mount = location
if not await location_mount.is_mounted():
raise BackupMountDownError(
f"{location_mount.name} is down, cannot back-up to it",
_LOGGER.error,
)
def _get_location_name( def _get_location_name(
self, self,
location: LOCATION_TYPE | type[DEFAULT] = DEFAULT, location: LOCATION_TYPE | type[DEFAULT] = DEFAULT,
@ -362,14 +352,8 @@ class BackupManager(FileConfiguration, JobGroup):
copy(backup.tarfile, self.sys_config.path_core_backup) copy(backup.tarfile, self.sys_config.path_core_backup)
) )
elif location: elif location:
location_mount: Mount = location all_locations[location.name] = Path(
if not location_mount.local_where.is_mount(): copy(backup.tarfile, location.local_where)
raise BackupMountDownError(
f"{location_mount.name} is down, cannot copy to it",
_LOGGER.error,
)
all_locations[location_mount.name] = Path(
copy(backup.tarfile, location_mount.local_where)
) )
else: else:
all_locations[None] = Path( all_locations[None] = Path(
@ -411,8 +395,6 @@ class BackupManager(FileConfiguration, JobGroup):
additional_locations: list[LOCATION_TYPE] | None = None, additional_locations: list[LOCATION_TYPE] | None = None,
) -> Backup | None: ) -> Backup | None:
"""Check backup tarfile and import it.""" """Check backup tarfile and import it."""
await self._check_location(location)
backup = Backup(self.coresys, tar_file, "temp", None) backup = Backup(self.coresys, tar_file, "temp", None)
# Read meta data # Read meta data
@ -560,8 +542,6 @@ class BackupManager(FileConfiguration, JobGroup):
additional_locations: list[LOCATION_TYPE] | None = None, additional_locations: list[LOCATION_TYPE] | None = None,
) -> Backup | None: ) -> Backup | None:
"""Create a full backup.""" """Create a full backup."""
await self._check_location(location)
if self._get_base_path(location) in { if self._get_base_path(location) in {
self.sys_config.path_backup, self.sys_config.path_backup,
self.sys_config.path_core_backup, self.sys_config.path_core_backup,
@ -610,8 +590,6 @@ class BackupManager(FileConfiguration, JobGroup):
additional_locations: list[LOCATION_TYPE] | None = None, additional_locations: list[LOCATION_TYPE] | None = None,
) -> Backup | None: ) -> Backup | None:
"""Create a partial backup.""" """Create a partial backup."""
await self._check_location(location)
if self._get_base_path(location) in { if self._get_base_path(location) in {
self.sys_config.path_backup, self.sys_config.path_backup,
self.sys_config.path_core_backup, self.sys_config.path_core_backup,

View File

@ -9,7 +9,6 @@ from pathlib import Path, PurePath
import shutil import shutil
import tarfile import tarfile
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from typing import Any
from uuid import UUID from uuid import UUID
from awesomeversion import AwesomeVersion, AwesomeVersionException from awesomeversion import AwesomeVersion, AwesomeVersionException
@ -47,7 +46,7 @@ from ..hardware.const import PolicyGroup
from ..hardware.data import Device from ..hardware.data import Device
from ..jobs.decorator import Job, JobExecutionLimit from ..jobs.decorator import Job, JobExecutionLimit
from ..resolution.const import UnhealthyReason from ..resolution.const import UnhealthyReason
from ..utils import remove_folder, remove_folder_with_excludes from ..utils import remove_folder
from ..utils.common import FileConfiguration from ..utils.common import FileConfiguration
from ..utils.json import read_json_file, write_json_file from ..utils.json import read_json_file, write_json_file
from .api import HomeAssistantAPI from .api import HomeAssistantAPI
@ -458,94 +457,91 @@ class HomeAssistant(FileConfiguration, CoreSysAttributes):
self, tar_file: tarfile.TarFile, exclude_database: bool = False self, tar_file: tarfile.TarFile, exclude_database: bool = False
) -> None: ) -> None:
"""Restore Home Assistant Core config/ directory.""" """Restore Home Assistant Core config/ directory."""
with TemporaryDirectory(dir=self.sys_config.path_tmp) as temp:
temp_path = Path(temp)
temp_data = temp_path.joinpath("data")
temp_meta = temp_path.joinpath("homeassistant.json")
def _restore_home_assistant() -> Any: # extract backup
"""Restores data and reads metadata from backup. def _extract_tarfile():
"""Extract tar backup."""
Returns: Home Assistant metdata with tar_file as backup:
""" backup.extractall(
with TemporaryDirectory(dir=self.sys_config.path_tmp) as temp: path=temp_path,
temp_path = Path(temp) members=secure_path(backup),
temp_data = temp_path.joinpath("data") filter="fully_trusted",
temp_meta = temp_path.joinpath("homeassistant.json")
# extract backup
try:
with tar_file as backup:
backup.extractall(
path=temp_path,
members=secure_path(backup),
filter="fully_trusted",
)
except tarfile.TarError as err:
raise HomeAssistantError(
f"Can't read tarfile {tar_file}: {err}", _LOGGER.error
) from err
# Check old backup format v1
if not temp_data.exists():
temp_data = temp_path
_LOGGER.info("Restore Home Assistant Core config folder")
if exclude_database:
remove_folder_with_excludes(
self.sys_config.path_homeassistant,
excludes=HOMEASSISTANT_BACKUP_EXCLUDE_DATABASE,
tmp_dir=self.sys_config.path_tmp,
) )
else:
remove_folder(self.sys_config.path_homeassistant)
try: try:
shutil.copytree( await self.sys_run_in_executor(_extract_tarfile)
temp_data, except tarfile.TarError as err:
self.sys_config.path_homeassistant, raise HomeAssistantError(
symlinks=True, f"Can't read tarfile {tar_file}: {err}", _LOGGER.error
dirs_exist_ok=True, ) from err
)
except shutil.Error as err:
raise HomeAssistantError(
f"Can't restore origin data: {err}", _LOGGER.error
) from err
_LOGGER.info("Restore Home Assistant Core config folder done") # Check old backup format v1
if not temp_data.exists():
temp_data = temp_path
if not temp_meta.exists(): # Restore data
return None def _restore_data():
_LOGGER.info("Restore Home Assistant Core metadata") """Restore data."""
shutil.copytree(
temp_data,
self.sys_config.path_homeassistant,
symlinks=True,
dirs_exist_ok=True,
)
# Read backup data _LOGGER.info("Restore Home Assistant Core config folder")
try: excludes = (
data = read_json_file(temp_meta) HOMEASSISTANT_BACKUP_EXCLUDE_DATABASE if exclude_database else None
except ConfigurationFileError as err: )
raise HomeAssistantError() from err await remove_folder(
self.sys_config.path_homeassistant,
content_only=True,
excludes=excludes,
tmp_dir=self.sys_config.path_tmp,
)
try:
await self.sys_run_in_executor(_restore_data)
except shutil.Error as err:
raise HomeAssistantError(
f"Can't restore origin data: {err}", _LOGGER.error
) from err
return data _LOGGER.info("Restore Home Assistant Core config folder done")
data = await self.sys_run_in_executor(_restore_home_assistant) if not temp_meta.exists():
if data is None: return
return _LOGGER.info("Restore Home Assistant Core metadata")
# Validate metadata # Read backup data
try: try:
data = SCHEMA_HASS_CONFIG(data) data = read_json_file(temp_meta)
except vol.Invalid as err: except ConfigurationFileError as err:
raise HomeAssistantError( raise HomeAssistantError() from err
f"Can't validate backup data: {humanize_error(data, err)}",
_LOGGER.error,
) from err
# Restore metadata # Validate
for attr in ( try:
ATTR_AUDIO_INPUT, data = SCHEMA_HASS_CONFIG(data)
ATTR_AUDIO_OUTPUT, except vol.Invalid as err:
ATTR_PORT, raise HomeAssistantError(
ATTR_SSL, f"Can't validate backup data: {humanize_error(data, err)}",
ATTR_REFRESH_TOKEN, _LOGGER.err,
ATTR_WATCHDOG, ) from err
):
if attr in data: # Restore metadata
self._data[attr] = data[attr] for attr in (
ATTR_AUDIO_INPUT,
ATTR_AUDIO_OUTPUT,
ATTR_PORT,
ATTR_SSL,
ATTR_REFRESH_TOKEN,
ATTR_WATCHDOG,
):
if attr in data:
self._data[attr] = data[attr]
@Job( @Job(
name="home_assistant_get_users", name="home_assistant_get_users",

View File

@ -40,7 +40,7 @@ class FixupStoreExecuteReset(FixupBase):
_LOGGER.warning("Can't find store %s for fixup", reference) _LOGGER.warning("Can't find store %s for fixup", reference)
return return
await self.sys_run_in_executor(remove_folder, repository.git.path) await remove_folder(repository.git.path)
# Load data again # Load data again
try: try:

View File

@ -189,13 +189,9 @@ class GitRepo(CoreSysAttributes):
_LOGGER.warning("There is already a task in progress") _LOGGER.warning("There is already a task in progress")
return return
def _remove_git_dir(path: Path) -> None: if not self.path.is_dir():
if not path.is_dir(): return
return await remove_folder(self.path)
remove_folder(path)
async with self.lock:
await self.sys_run_in_executor(_remove_git_dir, self.path)
class GitRepoHassIO(GitRepo): class GitRepoHassIO(GitRepo):

View File

@ -8,7 +8,6 @@ import os
from pathlib import Path from pathlib import Path
import re import re
import socket import socket
import subprocess
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from typing import Any from typing import Any
@ -81,9 +80,11 @@ def get_message_from_exception_chain(err: Exception) -> str:
return get_message_from_exception_chain(err.__context__) return get_message_from_exception_chain(err.__context__)
def remove_folder( async def remove_folder(
folder: Path, folder: Path,
content_only: bool = False, content_only: bool = False,
excludes: list[str] | None = None,
tmp_dir: Path | None = None,
) -> None: ) -> None:
"""Remove folder and reset privileged. """Remove folder and reset privileged.
@ -91,40 +92,48 @@ def remove_folder(
- CAP_DAC_OVERRIDE - CAP_DAC_OVERRIDE
- CAP_DAC_READ_SEARCH - CAP_DAC_READ_SEARCH
""" """
find_args = [] if excludes:
if content_only: if not tmp_dir:
find_args.extend(["-mindepth", "1"]) raise ValueError("tmp_dir is required if excludes are provided")
try: if not content_only:
proc = subprocess.run( raise ValueError("Cannot delete the folder if excludes are provided")
["/usr/bin/find", str(folder), "-xdev", *find_args, "-delete"],
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
env=clean_env(),
text=True,
check=True,
)
if proc.returncode != 0:
_LOGGER.error("Can't remove folder %s: %s", folder, proc.stderr.strip())
except OSError as err:
_LOGGER.exception("Can't remove folder %s: %s", folder, err)
temp = TemporaryDirectory(dir=tmp_dir)
def remove_folder_with_excludes( temp_path = Path(temp.name)
folder: Path,
excludes: list[str],
tmp_dir: Path | None = None,
) -> None:
"""Remove folder with excludes."""
with TemporaryDirectory(dir=tmp_dir) as temp_path:
temp_path = Path(temp_path)
moved_files: list[Path] = [] moved_files: list[Path] = []
for item in folder.iterdir(): for item in folder.iterdir():
if any(item.match(exclude) for exclude in excludes): if any(item.match(exclude) for exclude in excludes):
moved_files.append(item.rename(temp_path / item.name)) moved_files.append(item.rename(temp_path / item.name))
remove_folder(folder, content_only=True) find_args = []
for item in moved_files: if content_only:
item.rename(folder / item.name) find_args.extend(["-mindepth", "1"])
try:
proc = await asyncio.create_subprocess_exec(
"/usr/bin/find",
folder,
"-xdev",
*find_args,
"-delete",
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
env=clean_env(),
)
_, error_msg = await proc.communicate()
except OSError as err:
_LOGGER.exception("Can't remove folder %s: %s", folder, err)
else:
if proc.returncode == 0:
return
_LOGGER.error(
"Can't remove folder %s: %s", folder, error_msg.decode("utf-8").strip()
)
finally:
if excludes:
for item in moved_files:
item.rename(folder / item.name)
temp.cleanup()
def clean_env() -> dict[str, str]: def clean_env() -> dict[str, str]:

View File

@ -3,10 +3,13 @@
from pathlib import Path from pathlib import Path
import shutil import shutil
import pytest
from supervisor.utils import remove_folder from supervisor.utils import remove_folder
def test_remove_all(tmp_path): @pytest.mark.asyncio
async def test_remove_all(tmp_path):
"""Test remove folder.""" """Test remove folder."""
# Prepair test folder # Prepair test folder
temp_orig = tmp_path.joinpath("orig") temp_orig = tmp_path.joinpath("orig")
@ -14,11 +17,12 @@ def test_remove_all(tmp_path):
shutil.copytree(fixture_data, temp_orig, symlinks=True) shutil.copytree(fixture_data, temp_orig, symlinks=True)
assert temp_orig.exists() assert temp_orig.exists()
remove_folder(temp_orig) await remove_folder(temp_orig)
assert not temp_orig.exists() assert not temp_orig.exists()
def test_remove_content(tmp_path): @pytest.mark.asyncio
async def test_remove_content(tmp_path):
"""Test remove content of folder.""" """Test remove content of folder."""
# Prepair test folder # Prepair test folder
temp_orig = tmp_path.joinpath("orig") temp_orig = tmp_path.joinpath("orig")
@ -34,7 +38,8 @@ def test_remove_content(tmp_path):
assert test_folder.exists() assert test_folder.exists()
assert test_file.exists() assert test_file.exists()
assert test_hidden.exists() assert test_hidden.exists()
remove_folder(temp_orig, content_only=True)
await remove_folder(temp_orig, content_only=True)
assert not test_folder.exists() assert not test_folder.exists()
assert not test_file.exists() assert not test_file.exists()