Source code for deploy_tools.compare

import difflib
import logging
from collections import defaultdict
from pathlib import Path
from typing import Any

import yaml
from pydantic import TypeAdapter

from .errors import DeployToolsError
from .layout import Layout
from .models.deployment import (
    DefaultVersionsByName,
    Deployment,
    DeploymentSettings,
    ReleasesByNameAndVersion,
)
from .models.module import Module, Release
from .models.save_and_load import load_from_yaml
from .modulefile import get_default_modulefile_version, get_deployed_modulefile_versions
from .snapshot import load_snapshot, load_snapshot_from_ref
from .validate import validate_default_versions

logger = logging.getLogger(__name__)


[docs] class ComparisonError(DeployToolsError): """Raised when comparing the deployment area to its snapshot fails."""
[docs] def compare_to_snapshot( deployment_root: Path, use_ref: str | None = None, from_scratch: bool = False ) -> None: """Compare deployment area to deployment configuration snapshot. This helps us to identify broken environment modules, or a failed deployment step. Note that this does not exclude the possibility of all types of issues. The ``use_ref`` argument can be used to compare against a previous Deployment configuration. It is recommended to use a reference relative to HEAD, e.g. 'HEAD~1'. The ``from_scratch`` argument checks that the deployment area is in a suitable state for a clean deployment into an empty directory. No snapshot is expected. Args: deployment_root: The root folder of the Deployment Area. use_ref: If specified, compare to the snapshot from the given deployment area git ref from_scratch: If True, check that the deployment area is empty and ignore other requirements. """ layout = Layout(deployment_root) if from_scratch: logger.info("Checking deployment area is empty for a from-scratch deployment") if not layout.deployment_root.exists() or not layout.deployment_root.is_dir(): raise ComparisonError( f"Deployment root folder does not exist:\n{layout.deployment_root}" ) if next(layout.deployment_root.iterdir(), None): raise ComparisonError( f"Deployment root folder is not empty:\n{layout.deployment_root}" ) return if use_ref: logger.info("Loading deployment snapshot from %s", use_ref) deployment_snapshot = load_snapshot_from_ref(layout, use_ref) else: logger.info("Loading deployment snapshot") deployment_snapshot = load_snapshot(layout) logger.info("Reconstructing deployment configuration from deployment area") actual_deployment = _reconstruct_deployment_config_from_modules(layout) logger.info("Comparing reconstructed configuration with snapshot") _compare_snapshot_to_actual(snapshot=deployment_snapshot, actual=actual_deployment) logger.info("Comparison finished")
def _reconstruct_deployment_config_from_modules(layout: Layout) -> Deployment: """Use the deployment area to reconstruct a Deployment configuration object. Note that the default versions will be different to those in initial configuration. """ releases = _collect_releases(layout) deployment = Deployment(settings=DeploymentSettings(), releases=releases) # Only non-deprecated (live) modules are associated with a .version file live_names = list(deployment.get_final_deployed_modules()) deployment.settings.default_versions = _collect_default_modulefile_versions( layout, live_names ) return deployment def _collect_releases(layout: Layout) -> ReleasesByNameAndVersion: modules = _collect_modules(layout) modulefiles = get_deployed_modulefile_versions(layout) deprecated_modulefiles = get_deployed_modulefile_versions(layout, True) releases: ReleasesByNameAndVersion = defaultdict(dict) for module in modules: name = module.name version = module.version has_modulefile = version in modulefiles.get(name, []) has_deprecated_modulefile = version in deprecated_modulefiles.get(name, []) if has_modulefile and has_deprecated_modulefile: raise ComparisonError( f"Duplicate modulefiles for {name}/{version} found in default and " f"deprecated areas." ) elif not has_modulefile and not has_deprecated_modulefile: raise ComparisonError(f"No modulefile found for {name}/{version}.") release = Release(deprecated=has_deprecated_modulefile, module=module) releases[name][version] = release for name, versions in modulefiles.items(): for version in versions: if version not in releases[name]: raise ComparisonError( f"Modulefile exists without corresponding built module for " f"{name}/{version}" ) return releases def _collect_modules(layout: Layout) -> list[Module]: """Accumulate deployed modules and their configuration snapshot. This searches for module application files since creation of the modulefiles happens after the module. """ modules: list[Module] = [] for name_path in layout.modules_root.glob("*"): for version_path in name_path.glob("*"): name = name_path.name version = version_path.name with open(layout.get_module_snapshot_path(name, version)) as f: modules.append(load_from_yaml(Module, f)) return modules def _collect_default_modulefile_versions( layout: Layout, names: list[str] ) -> DefaultVersionsByName: default_versions: dict[str, str] = {} for name in names: try: default_version = get_default_modulefile_version(name, layout) except FileNotFoundError as exc: raise ComparisonError( f"Live module '{name}' has no default version file" ) from exc if default_version is not None: default_versions[name] = default_version return default_versions def _compare_snapshot_to_actual(snapshot: Deployment, actual: Deployment) -> None: _compare_releases(snapshot, actual) _compare_default_versions(snapshot, actual) def _compare_releases(snapshot: Deployment, actual: Deployment) -> None: if snapshot.releases != actual.releases: raise ComparisonError( "Snapshot and actual release configuration do not match, see:\n" + _get_dict_diff(snapshot.releases, actual.releases) ) def _compare_default_versions(snapshot: Deployment, actual: Deployment) -> None: snapshot_defaults = validate_default_versions(snapshot) actual_defaults = actual.settings.default_versions if snapshot_defaults != actual_defaults: raise ComparisonError( "Snapshot and actual module default versions do not match, see:\n" + _get_dict_diff(snapshot_defaults, actual_defaults) ) def _get_dict_diff(d1: dict[str, Any], d2: dict[str, Any]) -> str: return "\n" + "\n".join( difflib.ndiff( _yaml_dumps(d1).splitlines(), _yaml_dumps(d2).splitlines(), ) ) def _yaml_dumps(obj: dict[str, Any], indent: int | None = None) -> str: ta = TypeAdapter(dict[str, Any]) # Dump in JSON mode so pydantic special types (e.g. AnyUrl) are coerced to YAML-safe # primitives return yaml.safe_dump(ta.dump_python(obj, mode="json"), indent=indent)