mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2025-11-05 09:16:21 +00:00
feat: improve config backup and update and revert (#737)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
Improve the backup of the EOS configuration on configuration migration from another version. Backup files now get a backup id based on date and time. Add the configuration backup listing and the revert to the backup to the EOS api. Add revert to backup to the EOSdash admin tab. Improve documentation about install, update and revert of EOS versions. Add EOS execution profiling to make commands and to test description in the development guideline. Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
This commit is contained in:
@@ -9,6 +9,7 @@ Key features:
|
||||
- Managing directory setups for the application
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
@@ -21,7 +22,7 @@ from pydantic import Field, computed_field, field_validator
|
||||
|
||||
# settings
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.config.configmigrate import migrate_config_file
|
||||
from akkudoktoreos.config.configmigrate import migrate_config_data, migrate_config_file
|
||||
from akkudoktoreos.core.cachesettings import CacheCommonSettings
|
||||
from akkudoktoreos.core.coreabc import SingletonMixin
|
||||
from akkudoktoreos.core.decorators import classproperty
|
||||
@@ -41,7 +42,7 @@ from akkudoktoreos.prediction.prediction import PredictionCommonSettings
|
||||
from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings
|
||||
from akkudoktoreos.prediction.weather import WeatherCommonSettings
|
||||
from akkudoktoreos.server.server import ServerCommonSettings
|
||||
from akkudoktoreos.utils.datetimeutil import to_timezone
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime, to_timezone
|
||||
from akkudoktoreos.utils.utils import UtilsCommonSettings
|
||||
|
||||
|
||||
@@ -379,9 +380,9 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
|
||||
# Apend file settings to sources
|
||||
file_settings: Optional[pydantic_settings.JsonConfigSettingsSource] = None
|
||||
try:
|
||||
backup_file = config_file.with_suffix(".bak")
|
||||
backup_file = config_file.with_suffix(f".{to_datetime(as_string='YYYYMMDDHHmmss')}")
|
||||
if migrate_config_file(config_file, backup_file):
|
||||
# If correct version add it as settings source
|
||||
# If the config file does have the correct version add it as settings source
|
||||
file_settings = pydantic_settings.JsonConfigSettingsSource(
|
||||
settings_cls, json_file=config_file
|
||||
)
|
||||
@@ -478,6 +479,88 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
|
||||
"""
|
||||
self._setup()
|
||||
|
||||
def revert_settings(self, backup_id: str) -> None:
|
||||
"""Revert application settings to a stored backup.
|
||||
|
||||
This method restores configuration values from a backup file identified
|
||||
by `backup_id`. The backup is expected to exist alongside the main
|
||||
configuration file, using the main config file's path but with the given
|
||||
suffix. Any settings previously applied will be overwritten.
|
||||
|
||||
Args:
|
||||
backup_id (str): The suffix used to locate the backup configuration
|
||||
file. Example: ``".bak"`` or ``".backup"``.
|
||||
|
||||
Returns:
|
||||
None: The method does not return a value.
|
||||
|
||||
Raises:
|
||||
ValueError: If the backup file cannot be found at the constructed path.
|
||||
json.JSONDecodeError: If the backup file exists but contains invalid JSON.
|
||||
TypeError: If the unpacked backup data fails to match the signature
|
||||
required by ``self._setup()``.
|
||||
OSError: If reading the backup file fails due to I/O issues.
|
||||
"""
|
||||
backup_file_path = self.general.config_file_path.with_suffix(f".{backup_id}")
|
||||
if not backup_file_path.exists():
|
||||
error_msg = f"Configuration backup `{backup_id}` not found."
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
with backup_file_path.open("r", encoding="utf-8") as f:
|
||||
backup_data: dict[str, Any] = json.load(f)
|
||||
backup_settings = migrate_config_data(backup_data)
|
||||
|
||||
self._setup(**backup_settings.model_dump(exclude_none=True, exclude_unset=True))
|
||||
|
||||
def list_backups(self) -> dict[str, dict[str, Any]]:
|
||||
"""List available configuration backup files and extract metadata.
|
||||
|
||||
Backup files are identified by sharing the same stem as the main config
|
||||
file but having a different suffix. Each backup file is assumed to contain
|
||||
a JSON object.
|
||||
|
||||
The returned dictionary uses `backup_id` (suffix) as keys. The value for
|
||||
each key is a dictionary including:
|
||||
- ``storage_time``: The file modification timestamp in ISO-8601 format.
|
||||
- ``version``: Version information found in the backup file
|
||||
(defaults to ``"unknown"``).
|
||||
|
||||
Returns:
|
||||
dict[str, dict[str, Any]]: Mapping of backup identifiers to metadata.
|
||||
|
||||
Raises:
|
||||
OSError: If directory scanning or file reading fails.
|
||||
json.JSONDecodeError: If a backup file cannot be parsed as JSON.
|
||||
"""
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
|
||||
base_path: Path = self.general.config_file_path
|
||||
parent = base_path.parent
|
||||
stem = base_path.stem
|
||||
|
||||
# Iterate files next to config file
|
||||
for file in parent.iterdir():
|
||||
if file.is_file() and file.stem == stem and file != base_path:
|
||||
backup_id = file.suffix[1:]
|
||||
|
||||
# Read version from file
|
||||
with file.open("r", encoding="utf-8") as f:
|
||||
data: dict[str, Any] = json.load(f)
|
||||
|
||||
# Extract version safely
|
||||
version = data.get("general", {}).get("version", "unknown")
|
||||
|
||||
# Read file modification time (OS-independent)
|
||||
ts = file.stat().st_mtime
|
||||
storage_time = to_datetime(ts, as_string=True)
|
||||
result[backup_id] = {
|
||||
"date_time": storage_time,
|
||||
"version": version,
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
def _create_initial_config_file(self) -> None:
|
||||
if self.general.config_file_path and not self.general.config_file_path.exists():
|
||||
self.general.config_file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Set, Tuple, Union
|
||||
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Set, Tuple, Union
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from akkudoktoreos.core.version import __version__
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# There are circular dependencies - only import here for type checking
|
||||
from akkudoktoreos.config.config import SettingsEOSDefaults
|
||||
|
||||
# -----------------------------
|
||||
# Global migration map constant
|
||||
# -----------------------------
|
||||
@@ -57,6 +61,79 @@ auto_count: int = 0
|
||||
skipped_paths: List[str] = []
|
||||
|
||||
|
||||
def migrate_config_data(config_data: Dict[str, Any]) -> "SettingsEOSDefaults":
|
||||
"""Migrate configuration data to the current version settings.
|
||||
|
||||
Returns:
|
||||
SettingsEOSDefaults: The migrated settings.
|
||||
"""
|
||||
global migrated_source_paths, mapped_count, auto_count, skipped_paths
|
||||
|
||||
# Reset globals at the start of each migration
|
||||
migrated_source_paths = set()
|
||||
mapped_count = 0
|
||||
auto_count = 0
|
||||
skipped_paths = []
|
||||
|
||||
from akkudoktoreos.config.config import SettingsEOSDefaults
|
||||
|
||||
new_config = SettingsEOSDefaults()
|
||||
|
||||
# 1) Apply explicit migration map
|
||||
for old_path, mapping in MIGRATION_MAP.items():
|
||||
new_path = None
|
||||
transform = None
|
||||
if mapping is None:
|
||||
migrated_source_paths.add(old_path.strip("/"))
|
||||
logger.debug(f"🗑️ Migration map: dropping '{old_path}'")
|
||||
continue
|
||||
if isinstance(mapping, tuple):
|
||||
new_path, transform = mapping
|
||||
else:
|
||||
new_path = mapping
|
||||
|
||||
old_value = _get_json_nested_value(config_data, old_path)
|
||||
if old_value is None:
|
||||
migrated_source_paths.add(old_path.strip("/"))
|
||||
mapped_count += 1
|
||||
logger.debug(f"✅ Migrated mapped '{old_path}' → 'None'")
|
||||
continue
|
||||
|
||||
try:
|
||||
if transform:
|
||||
old_value = transform(old_value)
|
||||
new_config.set_nested_value(new_path, old_value)
|
||||
migrated_source_paths.add(old_path.strip("/"))
|
||||
mapped_count += 1
|
||||
logger.debug(f"✅ Migrated mapped '{old_path}' → '{new_path}' = {old_value!r}")
|
||||
except Exception as e:
|
||||
logger.opt(exception=True).warning(
|
||||
f"Failed mapped migration '{old_path}' -> '{new_path}': {e}"
|
||||
)
|
||||
|
||||
# 2) Automatic migration for remaining fields
|
||||
auto_count += _migrate_matching_fields(
|
||||
config_data, new_config, migrated_source_paths, skipped_paths
|
||||
)
|
||||
|
||||
# 3) Ensure version
|
||||
try:
|
||||
new_config.set_nested_value("general/version", __version__)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not set version on new configuration model: {e}")
|
||||
|
||||
# 4) Log final migration summary
|
||||
logger.info(
|
||||
f"Migration summary: "
|
||||
f"mapped fields: {mapped_count}, automatically migrated: {auto_count}, skipped: {len(skipped_paths)}"
|
||||
)
|
||||
if skipped_paths:
|
||||
logger.debug(f"Skipped paths: {', '.join(skipped_paths)}")
|
||||
|
||||
logger.success(f"Configuration successfully migrated to version {__version__}.")
|
||||
return new_config
|
||||
|
||||
|
||||
def migrate_config_file(config_file: Path, backup_file: Path) -> bool:
|
||||
"""Migrate configuration file to the current version.
|
||||
|
||||
@@ -104,54 +181,10 @@ def migrate_config_file(config_file: Path, backup_file: Path) -> bool:
|
||||
f"Failed to backup existing config (replace: {e_replace}; copy: {e_copy}). Continuing without backup."
|
||||
)
|
||||
|
||||
from akkudoktoreos.config.config import SettingsEOSDefaults
|
||||
# Migrate config data
|
||||
new_config = migrate_config_data(config_data)
|
||||
|
||||
new_config = SettingsEOSDefaults()
|
||||
|
||||
# 1) Apply explicit migration map
|
||||
for old_path, mapping in MIGRATION_MAP.items():
|
||||
new_path = None
|
||||
transform = None
|
||||
if mapping is None:
|
||||
migrated_source_paths.add(old_path.strip("/"))
|
||||
logger.debug(f"🗑️ Migration map: dropping '{old_path}'")
|
||||
continue
|
||||
if isinstance(mapping, tuple):
|
||||
new_path, transform = mapping
|
||||
else:
|
||||
new_path = mapping
|
||||
|
||||
old_value = _get_json_nested_value(config_data, old_path)
|
||||
if old_value is None:
|
||||
migrated_source_paths.add(old_path.strip("/"))
|
||||
mapped_count += 1
|
||||
logger.debug(f"✅ Migrated mapped '{old_path}' → 'None'")
|
||||
continue
|
||||
|
||||
try:
|
||||
if transform:
|
||||
old_value = transform(old_value)
|
||||
new_config.set_nested_value(new_path, old_value)
|
||||
migrated_source_paths.add(old_path.strip("/"))
|
||||
mapped_count += 1
|
||||
logger.debug(f"✅ Migrated mapped '{old_path}' → '{new_path}' = {old_value!r}")
|
||||
except Exception as e:
|
||||
logger.opt(exception=True).warning(
|
||||
f"Failed mapped migration '{old_path}' -> '{new_path}': {e}", exc_info=True
|
||||
)
|
||||
|
||||
# 2) Automatic migration for remaining fields
|
||||
auto_count += _migrate_matching_fields(
|
||||
config_data, new_config, migrated_source_paths, skipped_paths
|
||||
)
|
||||
|
||||
# 3) Ensure version
|
||||
try:
|
||||
new_config.set_nested_value("general/version", __version__)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not set version on new configuration model: {e}")
|
||||
|
||||
# 4) Write migrated configuration
|
||||
# Write migrated configuration
|
||||
try:
|
||||
with config_file.open("w", encoding="utf-8", newline=None) as f_out:
|
||||
json_str = new_config.model_dump_json(indent=4)
|
||||
@@ -160,15 +193,6 @@ def migrate_config_file(config_file: Path, backup_file: Path) -> bool:
|
||||
logger.error(f"Failed to write migrated configuration to '{config_file}': {e_write}")
|
||||
return False
|
||||
|
||||
# 5) Log final migration summary
|
||||
logger.info(
|
||||
f"Migration summary for '{config_file}': "
|
||||
f"mapped fields: {mapped_count}, automatically migrated: {auto_count}, skipped: {len(skipped_paths)}"
|
||||
)
|
||||
if skipped_paths:
|
||||
logger.debug(f"Skipped paths: {', '.join(skipped_paths)}")
|
||||
|
||||
logger.success(f"Configuration successfully migrated to version {__version__}.")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -152,7 +152,11 @@ def AdminCache(
|
||||
|
||||
|
||||
def AdminConfig(
|
||||
eos_host: str, eos_port: Union[str, int], data: Optional[dict], config: Optional[dict[str, Any]]
|
||||
eos_host: str,
|
||||
eos_port: Union[str, int],
|
||||
data: Optional[dict],
|
||||
config: Optional[dict[str, Any]],
|
||||
config_backup: Optional[dict[str, dict[str, Any]]],
|
||||
) -> tuple[str, Union[Card, list[Card]]]:
|
||||
"""Creates a configuration management card with save-to-file functionality.
|
||||
|
||||
@@ -177,6 +181,8 @@ def AdminConfig(
|
||||
config_file_path = get_nested_value(config, ["general", "config_file_path"])
|
||||
except Exception as e:
|
||||
logger.debug(f"general.config_file_path: {e}")
|
||||
# revert to backup
|
||||
revert_to_backup_status = (None,)
|
||||
# export config file
|
||||
export_to_file_next_tag = to_datetime(as_string="YYYYMMDDHHmmss")
|
||||
export_to_file_status = (None,)
|
||||
@@ -191,7 +197,7 @@ def AdminConfig(
|
||||
result = requests.put(f"{server}/v1/config/file", timeout=10)
|
||||
result.raise_for_status()
|
||||
config_file_path = result.json()["general"]["config_file_path"]
|
||||
status = Success(f"Saved to '{config_file_path}' on '{eos_hostname}'")
|
||||
status = Success(f"Saved configuration to '{config_file_path}' on '{eos_hostname}'")
|
||||
except requests.exceptions.HTTPError as e:
|
||||
detail = result.json()["detail"]
|
||||
status = Error(
|
||||
@@ -199,6 +205,45 @@ def AdminConfig(
|
||||
)
|
||||
except Exception as e:
|
||||
status = Error(f"Can not save actual config to file on '{eos_hostname}': {e}")
|
||||
elif data["action"] == "revert_to_backup":
|
||||
# Revert configuration to backup file
|
||||
metadata = data.get("backup_metadata", None)
|
||||
if metadata and config_backup:
|
||||
date_time = metadata.split(" ")[0]
|
||||
backup_id = None
|
||||
for bkup_id, bkup_meta in config_backup.items():
|
||||
if bkup_meta.get("date_time") == date_time:
|
||||
backup_id = bkup_id
|
||||
break
|
||||
if backup_id:
|
||||
try:
|
||||
result = requests.put(
|
||||
f"{server}/v1/config/revert",
|
||||
params={"backup_id": backup_id},
|
||||
timeout=10,
|
||||
)
|
||||
result.raise_for_status()
|
||||
config_file_path = result.json()["general"]["config_file_path"]
|
||||
revert_to_backup_status = Success(
|
||||
f"Reverted configuration to backup `{backup_id}` on '{eos_hostname}'"
|
||||
)
|
||||
except requests.exceptions.HTTPError as e:
|
||||
detail = result.json()["detail"]
|
||||
revert_to_backup_status = Error(
|
||||
f"Can not revert to backup `{backup_id}` on '{eos_hostname}': {e}, {detail}"
|
||||
)
|
||||
except Exception as e:
|
||||
revert_to_backup_status = Error(
|
||||
f"Can not revert to backup `{backup_id}` on '{eos_hostname}': {e}"
|
||||
)
|
||||
else:
|
||||
revert_to_backup_status = Error(
|
||||
f"Can not revert to backup `{backup_id}` on '{eos_hostname}': Invalid backup datetime {date_time}"
|
||||
)
|
||||
else:
|
||||
revert_to_backup_status = Error(
|
||||
f"Can not revert to backup configuration on '{eos_hostname}': No backup selected"
|
||||
)
|
||||
elif data["action"] == "export_to_file":
|
||||
# Export current configuration to file
|
||||
export_to_file_tag = data.get("export_to_file_tag", export_to_file_next_tag)
|
||||
@@ -257,6 +302,13 @@ def AdminConfig(
|
||||
|
||||
# Update for display, in case we added a new file before
|
||||
import_from_file_names = [f.name for f in list(export_import_directory.glob("*.json"))]
|
||||
if config_backup is None:
|
||||
revert_to_backup_metadata_list = ["Backup list not available"]
|
||||
else:
|
||||
revert_to_backup_metadata_list = [
|
||||
f"{backup_meta['date_time']} {backup_meta['version']}"
|
||||
for backup_id, backup_meta in config_backup.items()
|
||||
]
|
||||
|
||||
return (
|
||||
category,
|
||||
@@ -283,6 +335,33 @@ def AdminConfig(
|
||||
P(f"Safe actual configuration to '{config_file_path}' on '{eos_hostname}'."),
|
||||
),
|
||||
),
|
||||
Card(
|
||||
Details(
|
||||
Summary(
|
||||
Grid(
|
||||
DivHStacked(
|
||||
UkIcon(icon="play"),
|
||||
AdminButton(
|
||||
"Revert to backup",
|
||||
hx_post="/eosdash/admin",
|
||||
hx_target="#page-content",
|
||||
hx_swap="innerHTML",
|
||||
hx_vals='js:{ "category": "configuration", "action": "revert_to_backup", "backup_metadata": document.querySelector("[name=\'selected_backup_metadata\']").value }',
|
||||
),
|
||||
Select(
|
||||
*Options(*revert_to_backup_metadata_list),
|
||||
id="backup_metadata",
|
||||
name="selected_backup_metadata", # Name of hidden input field with selected value
|
||||
placeholder="Select backup",
|
||||
),
|
||||
),
|
||||
revert_to_backup_status,
|
||||
),
|
||||
cls="list-none",
|
||||
),
|
||||
P(f"Revert configuration to backup on '{eosdash_hostname}'."),
|
||||
),
|
||||
),
|
||||
Card(
|
||||
Details(
|
||||
Summary(
|
||||
@@ -364,7 +443,20 @@ def Admin(eos_host: str, eos_port: Union[str, int], data: Optional[dict] = None)
|
||||
result.raise_for_status()
|
||||
config = result.json()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
config = {}
|
||||
detail = result.json()["detail"]
|
||||
warning_msg = f"Can not retrieve configuration from {server}: {e}, {detail}"
|
||||
logger.warning(warning_msg)
|
||||
return Error(warning_msg)
|
||||
except Exception as e:
|
||||
warning_msg = f"Can not retrieve configuration from {server}: {e}"
|
||||
logger.warning(warning_msg)
|
||||
return Error(warning_msg)
|
||||
# Get current configuration backups from server
|
||||
try:
|
||||
result = requests.get(f"{server}/v1/config/backup", timeout=10)
|
||||
result.raise_for_status()
|
||||
config_backup = result.json()
|
||||
except requests.exceptions.HTTPError as e:
|
||||
detail = result.json()["detail"]
|
||||
warning_msg = f"Can not retrieve configuration from {server}: {e}, {detail}"
|
||||
logger.warning(warning_msg)
|
||||
@@ -378,7 +470,7 @@ def Admin(eos_host: str, eos_port: Union[str, int], data: Optional[dict] = None)
|
||||
last_category = ""
|
||||
for category, admin in [
|
||||
AdminCache(eos_host, eos_port, data, config),
|
||||
AdminConfig(eos_host, eos_port, data, config),
|
||||
AdminConfig(eos_host, eos_port, data, config, config_backup),
|
||||
]:
|
||||
if category != last_category:
|
||||
rows.append(H3(category))
|
||||
|
||||
@@ -639,6 +639,42 @@ def fastapi_config_reset_post() -> ConfigEOS:
|
||||
return config_eos
|
||||
|
||||
|
||||
@app.get("/v1/config/backup", tags=["config"])
|
||||
def fastapi_config_backup_get() -> dict[str, dict[str, Any]]:
|
||||
"""Get the EOS configuration backup identifiers and backup metadata.
|
||||
|
||||
Returns:
|
||||
dict[str, dict[str, Any]]: Mapping of backup identifiers to metadata.
|
||||
"""
|
||||
try:
|
||||
result = config_eos.list_backups()
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Can not list configuration backups: {e}",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@app.put("/v1/config/revert", tags=["config"])
|
||||
def fastapi_config_revert_put(
|
||||
backup_id: str = Query(..., description="EOS configuration backup ID."),
|
||||
) -> ConfigEOS:
|
||||
"""Revert the configuration to a EOS configuration backup.
|
||||
|
||||
Returns:
|
||||
configuration (ConfigEOS): The current configuration after revert.
|
||||
"""
|
||||
try:
|
||||
config_eos.revert_settings(backup_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Error on reverting of configuration: {e}",
|
||||
)
|
||||
return config_eos
|
||||
|
||||
|
||||
@app.put("/v1/config/file", tags=["config"])
|
||||
def fastapi_config_file_put() -> ConfigEOS:
|
||||
"""Save the current configuration to the EOS configuration file.
|
||||
|
||||
Reference in New Issue
Block a user