3 Commits
0.6.0 ... 0.6.1

Author SHA1 Message Date
f292e22301 Fix restore version 2026-01-18 09:08:25 +00:00
2eb194c001 Add 0.6 1 2026-01-18 09:07:40 +00:00
f4e367987a 0.6.1 2026-01-18 09:07:03 +00:00
3 changed files with 146 additions and 5 deletions

View File

@@ -11,6 +11,13 @@ Sections:
--- ---
## [0.6.1] - 2026-01-18
### Fixed
- Restored integrations now correctly report the restored version instead of the latest installed version.
- Update availability is correctly recalculated after restoring a backup, allowing updates to be applied again.
- Improved restore compatibility with backups created before version metadata was introduced.
## [0.6.0] - 2026-01-18 ## [0.6.0] - 2026-01-18
### Added ### Added

View File

@@ -30,6 +30,8 @@ DOMAIN = "bahmcloud_store"
SIGNAL_UPDATED = f"{DOMAIN}_updated" SIGNAL_UPDATED = f"{DOMAIN}_updated"
RESTART_REQUIRED_ISSUE_ID = "restart_required" RESTART_REQUIRED_ISSUE_ID = "restart_required"
BACKUP_META_FILENAME = ".bcs_backup_meta.json"
class BCSError(Exception): class BCSError(Exception):
"""BCS core error.""" """BCS core error."""
@@ -493,7 +495,19 @@ class BCSCore:
await self.hass.async_add_executor_job(_mkdir) await self.hass.async_add_executor_job(_mkdir)
async def _backup_domain(self, domain: str) -> Path | None: def _build_backup_meta(self, repo_id: str, domain: str) -> dict[str, object]:
"""Build metadata for backup folders so restores can recover the stored version."""
inst = self.get_installed(repo_id) or {}
return {
"repo_id": repo_id,
"domain": domain,
"installed_version": inst.get("installed_version") or inst.get("ref"),
"installed_manifest_version": inst.get("installed_manifest_version"),
"ref": inst.get("ref") or inst.get("installed_version"),
"created_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
}
async def _backup_domain(self, domain: str, *, meta: dict[str, object] | None = None) -> Path | None:
"""Backup an existing domain folder. """Backup an existing domain folder.
Returns the created backup path, or None if the domain folder does not exist. Returns the created backup path, or None if the domain folder does not exist.
@@ -515,6 +529,13 @@ class BCSCore:
if backup_path.exists(): if backup_path.exists():
shutil.rmtree(backup_path, ignore_errors=True) shutil.rmtree(backup_path, ignore_errors=True)
shutil.copytree(target, backup_path, dirs_exist_ok=True) shutil.copytree(target, backup_path, dirs_exist_ok=True)
# Store backup metadata (kept inside backup folder; removed from target after restore).
if meta:
try:
meta_path = backup_path / BACKUP_META_FILENAME
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding='utf-8')
except Exception:
pass
# Retention: keep only the newest N backups per domain. # Retention: keep only the newest N backups per domain.
try: try:
@@ -541,6 +562,28 @@ class BCSCore:
if target.exists(): if target.exists():
shutil.rmtree(target, ignore_errors=True) shutil.rmtree(target, ignore_errors=True)
shutil.copytree(backup_path, target, dirs_exist_ok=True) shutil.copytree(backup_path, target, dirs_exist_ok=True)
try:
meta_file = target / BACKUP_META_FILENAME
if meta_file.exists():
meta_file.unlink(missing_ok=True)
except Exception:
pass
# Do not leave backup metadata inside the restored integration folder.
try:
meta_p = target / BACKUP_META_FILENAME
if meta_p.exists():
meta_p.unlink()
except Exception:
pass
# Do not leave backup metadata inside the live integration folder.
try:
m = target / BACKUP_META_FILENAME
if m.exists():
m.unlink()
except Exception:
pass
await self.hass.async_add_executor_job(_restore) await self.hass.async_add_executor_job(_restore)
_LOGGER.info("BCS rollback applied: domain=%s from=%s", domain, backup_path) _LOGGER.info("BCS rollback applied: domain=%s from=%s", domain, backup_path)
@@ -579,7 +622,13 @@ class BCSCore:
for bid, present in id_map.items(): for bid, present in id_map.items():
complete = present == all_domains complete = present == all_domains
label = self._format_backup_id(bid) label = self._format_backup_id(bid)
items.append({"id": bid, "label": label, "complete": complete, "domains": sorted(present)}) meta = await self._read_backup_meta(dom_list[0], bid)
ver = None
if isinstance(meta, dict):
ver = meta.get("installed_version") or meta.get("ref")
if ver:
label = f"{label} ({ver})"
items.append({"id": bid, "label": label, "complete": complete, "domains": sorted(present), "installed_version": str(ver) if ver else None})
# Sort newest first by id (lexicographic works for timestamp format). # Sort newest first by id (lexicographic works for timestamp format).
items.sort(key=lambda x: str(x.get("id") or ""), reverse=True) items.sort(key=lambda x: str(x.get("id") or ""), reverse=True)
@@ -631,12 +680,87 @@ class BCSCore:
for d in dom_list: for d in dom_list:
await self._restore_domain_from_backup(d, self._backup_root / d / backup_id) await self._restore_domain_from_backup(d, self._backup_root / d / backup_id)
# Update stored installed version to the restored one (so UI shows the restored state).
#
# Backups created before 0.6.1 may not have metadata. For those legacy backups we fall back to:
# 1) version from the backup's manifest.json (best-effort), else
# 2) a synthetic marker (restored:<backup_id>) so the UI reflects a restored state and updates
# remain available.
restored_meta = await self._read_backup_meta(dom_list[0], backup_id)
restored_version: str | None = None
restored_manifest_version: str | None = None
if isinstance(restored_meta, dict):
rv = restored_meta.get("installed_version") or restored_meta.get("ref")
if rv is not None and str(rv).strip():
restored_version = str(rv).strip()
mv = restored_meta.get("installed_manifest_version")
if mv is not None and str(mv).strip():
restored_manifest_version = str(mv).strip()
# Legacy backups (no meta): try to read manifest.json version from the backup folder.
if not restored_manifest_version:
restored_manifest_version = await self._read_backup_manifest_version(dom_list[0], backup_id)
# Use manifest version as a fallback display value if we don't have the exact installed ref.
if not restored_version and restored_manifest_version:
restored_version = restored_manifest_version
# Last resort: ensure the installed version changes so the UI does not keep showing the newest version.
if not restored_version:
restored_version = f"restored:{backup_id}"
repo = self.get_repo(repo_id)
repo_url = getattr(repo, "url", None) or ""
await self.storage.set_installed_repo(
repo_id=repo_id,
url=repo_url,
domains=dom_list,
installed_version=restored_version,
installed_manifest_version=restored_manifest_version,
ref=restored_version,
)
await self._refresh_installed_cache() await self._refresh_installed_cache()
self._mark_restart_required() self._mark_restart_required()
self.signal_updated() self.signal_updated()
_LOGGER.info("BCS restore complete: repo_id=%s backup_id=%s", repo_id, backup_id) _LOGGER.info("BCS restore complete: repo_id=%s backup_id=%s", repo_id, backup_id)
return {"ok": True, "repo_id": repo_id, "backup_id": backup_id, "domains": dom_list, "restart_required": True} return {"ok": True, "repo_id": repo_id, "backup_id": backup_id, "domains": dom_list, "restored_version": restored_version, "restart_required": True}
async def _read_backup_meta(self, domain: str, backup_id: str) -> dict[str, Any] | None:
"""Read backup metadata for a domain backup.
Metadata is stored inside the backup folder and will be removed from the
live folder after restore.
"""
try:
p = self._backup_root / domain / backup_id / BACKUP_META_FILENAME
if not p.exists():
return None
txt = await self.hass.async_add_executor_job(p.read_text, 'utf-8')
data = json.loads(txt)
return data if isinstance(data, dict) else None
except Exception:
return None
async def _read_backup_manifest_version(self, domain: str, backup_id: str) -> str | None:
"""Best-effort: read manifest.json version from a legacy backup (no metadata)."""
def _read() -> str | None:
try:
p = self._backup_root / domain / backup_id / 'manifest.json'
if not p.exists():
return None
data = json.loads(p.read_text(encoding='utf-8'))
v = data.get('version')
return str(v) if v else None
except Exception:
return None
return await self.hass.async_add_executor_job(_read)
async def _list_domain_backup_ids(self, domain: str) -> list[str]: async def _list_domain_backup_ids(self, domain: str) -> list[str]:
"""List backup ids for a domain (newest->oldest).""" """List backup ids for a domain (newest->oldest)."""
@@ -802,6 +926,14 @@ class BCSCore:
installed_domains: list[str] = [] installed_domains: list[str] = []
backups: dict[str, Path] = {} backups: dict[str, Path] = {}
inst_before = self.get_installed(repo_id) or {}
backup_meta = {
"repo_id": repo_id,
"installed_version": inst_before.get("installed_version") or inst_before.get("ref"),
"installed_manifest_version": inst_before.get("installed_manifest_version"),
"ref": inst_before.get("ref") or inst_before.get("installed_version"),
}
created_new: set[str] = set() created_new: set[str] = set()
try: try:
@@ -832,7 +964,9 @@ class BCSCore:
# Backup only if we are going to overwrite an existing domain. # Backup only if we are going to overwrite an existing domain.
if target.exists() and target.is_dir(): if target.exists() and target.is_dir():
bkp = await self._backup_domain(domain) m = dict(backup_meta)
m["domain"] = domain
bkp = await self._backup_domain(domain, meta=m)
if bkp: if bkp:
backups[domain] = bkp backups[domain] = bkp
else: else:

View File

@@ -1,7 +1,7 @@
{ {
"domain": "bahmcloud_store", "domain": "bahmcloud_store",
"name": "Bahmcloud Store", "name": "Bahmcloud Store",
"version": "0.6.0", "version": "0.6.1",
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store", "documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
"platforms": ["update"], "platforms": ["update"],
"requirements": [], "requirements": [],