Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 518ac1d59d | |||
| ad699dc69a | |||
| a8e247d288 |
@@ -11,6 +11,15 @@ Sections:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [0.5.11] - 2026-01-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Automatic backup of existing custom components before install or update.
|
||||||
|
- Backup retention with a configurable limit per domain.
|
||||||
|
|
||||||
|
### Safety
|
||||||
|
- Automatic rollback is triggered if an install or update fails after a backup was created.
|
||||||
|
|
||||||
## [0.5.10] - 2026-01-17
|
## [0.5.10] - 2026-01-17
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -90,6 +90,10 @@ class BCSCore:
|
|||||||
self._install_lock = asyncio.Lock()
|
self._install_lock = asyncio.Lock()
|
||||||
self._installed_cache: dict[str, Any] = {}
|
self._installed_cache: dict[str, Any] = {}
|
||||||
|
|
||||||
|
# Phase F2: backups before install/update
|
||||||
|
self._backup_root = Path(self.hass.config.path(".bcs_backups"))
|
||||||
|
self._backup_keep_per_domain: int = 5
|
||||||
|
|
||||||
async def async_initialize(self) -> None:
|
async def async_initialize(self) -> None:
|
||||||
"""Async initialization that avoids blocking file IO."""
|
"""Async initialization that avoids blocking file IO."""
|
||||||
self.version = await self._read_manifest_version_async()
|
self.version = await self._read_manifest_version_async()
|
||||||
@@ -482,6 +486,65 @@ class BCSCore:
|
|||||||
return candidate
|
return candidate
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def _ensure_backup_root(self) -> None:
|
||||||
|
"""Create backup root directory if needed."""
|
||||||
|
def _mkdir() -> None:
|
||||||
|
self._backup_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
await self.hass.async_add_executor_job(_mkdir)
|
||||||
|
|
||||||
|
async def _backup_domain(self, domain: str) -> Path | None:
|
||||||
|
"""Backup an existing domain folder.
|
||||||
|
|
||||||
|
Returns the created backup path, or None if the domain folder does not exist.
|
||||||
|
"""
|
||||||
|
dest_root = Path(self.hass.config.path("custom_components"))
|
||||||
|
target = dest_root / domain
|
||||||
|
|
||||||
|
if not target.exists() or not target.is_dir():
|
||||||
|
return None
|
||||||
|
|
||||||
|
await self._ensure_backup_root()
|
||||||
|
|
||||||
|
ts = time.strftime("%Y%m%d_%H%M%S")
|
||||||
|
domain_root = self._backup_root / domain
|
||||||
|
backup_path = domain_root / ts
|
||||||
|
|
||||||
|
def _do_backup() -> None:
|
||||||
|
domain_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
if backup_path.exists():
|
||||||
|
shutil.rmtree(backup_path, ignore_errors=True)
|
||||||
|
shutil.copytree(target, backup_path, dirs_exist_ok=True)
|
||||||
|
|
||||||
|
# Retention: keep only the newest N backups per domain.
|
||||||
|
try:
|
||||||
|
backups = [p for p in domain_root.iterdir() if p.is_dir()]
|
||||||
|
backups.sort(key=lambda p: p.name, reverse=True)
|
||||||
|
for old in backups[self._backup_keep_per_domain :]:
|
||||||
|
shutil.rmtree(old, ignore_errors=True)
|
||||||
|
except Exception:
|
||||||
|
# Never fail install/update because of retention cleanup.
|
||||||
|
pass
|
||||||
|
|
||||||
|
await self.hass.async_add_executor_job(_do_backup)
|
||||||
|
_LOGGER.info("BCS backup created: domain=%s path=%s", domain, backup_path)
|
||||||
|
return backup_path
|
||||||
|
|
||||||
|
async def _restore_domain_from_backup(self, domain: str, backup_path: Path) -> None:
|
||||||
|
"""Restore a domain folder from a backup."""
|
||||||
|
dest_root = Path(self.hass.config.path("custom_components"))
|
||||||
|
target = dest_root / domain
|
||||||
|
|
||||||
|
def _restore() -> None:
|
||||||
|
if not backup_path.exists() or not backup_path.is_dir():
|
||||||
|
return
|
||||||
|
if target.exists():
|
||||||
|
shutil.rmtree(target, ignore_errors=True)
|
||||||
|
shutil.copytree(backup_path, target, dirs_exist_ok=True)
|
||||||
|
|
||||||
|
await self.hass.async_add_executor_job(_restore)
|
||||||
|
_LOGGER.info("BCS rollback applied: domain=%s from=%s", domain, backup_path)
|
||||||
|
|
||||||
async def _copy_domain_dir(self, src_domain_dir: Path, domain: str) -> None:
|
async def _copy_domain_dir(self, src_domain_dir: Path, domain: str) -> None:
|
||||||
dest_root = Path(self.hass.config.path("custom_components"))
|
dest_root = Path(self.hass.config.path("custom_components"))
|
||||||
target = dest_root / domain
|
target = dest_root / domain
|
||||||
@@ -613,6 +676,11 @@ class BCSCore:
|
|||||||
|
|
||||||
_LOGGER.info("BCS install started: repo_id=%s ref=%s zip_url=%s", repo_id, ref, zip_url)
|
_LOGGER.info("BCS install started: repo_id=%s ref=%s zip_url=%s", repo_id, ref, zip_url)
|
||||||
|
|
||||||
|
installed_domains: list[str] = []
|
||||||
|
backups: dict[str, Path] = {}
|
||||||
|
created_new: set[str] = set()
|
||||||
|
|
||||||
|
try:
|
||||||
with tempfile.TemporaryDirectory(prefix="bcs_install_") as td:
|
with tempfile.TemporaryDirectory(prefix="bcs_install_") as td:
|
||||||
tmp = Path(td)
|
tmp = Path(td)
|
||||||
zip_path = tmp / "repo.zip"
|
zip_path = tmp / "repo.zip"
|
||||||
@@ -626,7 +694,8 @@ class BCSCore:
|
|||||||
if not cc_root:
|
if not cc_root:
|
||||||
raise BCSInstallError("custom_components folder not found in repository ZIP")
|
raise BCSInstallError("custom_components folder not found in repository ZIP")
|
||||||
|
|
||||||
installed_domains: list[str] = []
|
dest_root = Path(self.hass.config.path("custom_components"))
|
||||||
|
|
||||||
for domain_dir in cc_root.iterdir():
|
for domain_dir in cc_root.iterdir():
|
||||||
if not domain_dir.is_dir():
|
if not domain_dir.is_dir():
|
||||||
continue
|
continue
|
||||||
@@ -635,6 +704,16 @@ class BCSCore:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
domain = domain_dir.name
|
domain = domain_dir.name
|
||||||
|
target = dest_root / domain
|
||||||
|
|
||||||
|
# Backup only if we are going to overwrite an existing domain.
|
||||||
|
if target.exists() and target.is_dir():
|
||||||
|
bkp = await self._backup_domain(domain)
|
||||||
|
if bkp:
|
||||||
|
backups[domain] = bkp
|
||||||
|
else:
|
||||||
|
created_new.add(domain)
|
||||||
|
|
||||||
await self._copy_domain_dir(domain_dir, domain)
|
await self._copy_domain_dir(domain_dir, domain)
|
||||||
installed_domains.append(domain)
|
installed_domains.append(domain)
|
||||||
|
|
||||||
@@ -673,6 +752,35 @@ class BCSCore:
|
|||||||
"restart_required": True,
|
"restart_required": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Roll back any domains we touched.
|
||||||
|
_LOGGER.error("BCS install failed, attempting rollback: repo_id=%s error=%s", repo_id, e)
|
||||||
|
|
||||||
|
dest_root = Path(self.hass.config.path("custom_components"))
|
||||||
|
|
||||||
|
# Restore backed-up domains.
|
||||||
|
for domain, bkp in backups.items():
|
||||||
|
try:
|
||||||
|
await self._restore_domain_from_backup(domain, bkp)
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.debug("BCS rollback failed for domain=%s", domain, exc_info=True)
|
||||||
|
|
||||||
|
# Remove newly created domains if the install did not complete.
|
||||||
|
for domain in created_new:
|
||||||
|
try:
|
||||||
|
target = dest_root / domain
|
||||||
|
def _rm() -> None:
|
||||||
|
if target.exists() and target.is_dir():
|
||||||
|
shutil.rmtree(target, ignore_errors=True)
|
||||||
|
await self.hass.async_add_executor_job(_rm)
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.debug("BCS cleanup failed for new domain=%s", domain, exc_info=True)
|
||||||
|
|
||||||
|
# Re-raise as install error for clean API response.
|
||||||
|
if isinstance(e, BCSInstallError):
|
||||||
|
raise
|
||||||
|
raise BCSInstallError(str(e)) from e
|
||||||
|
|
||||||
async def update_repo(self, repo_id: str) -> dict[str, Any]:
|
async def update_repo(self, repo_id: str) -> dict[str, Any]:
|
||||||
_LOGGER.info("BCS update started: repo_id=%s", repo_id)
|
_LOGGER.info("BCS update started: repo_id=%s", repo_id)
|
||||||
return await self.install_repo(repo_id)
|
return await self.install_repo(repo_id)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "bahmcloud_store",
|
"domain": "bahmcloud_store",
|
||||||
"name": "Bahmcloud Store",
|
"name": "Bahmcloud Store",
|
||||||
"version": "0.5.10",
|
"version": "0.5.11",
|
||||||
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
|
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
|
||||||
"platforms": ["update"],
|
"platforms": ["update"],
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
|
|||||||
Reference in New Issue
Block a user