CmdForge/src/cmdforge/lockfile.py

434 lines
15 KiB
Python

"""Lock file for reproducible dependency installation.
Provides cmdforge.lock file support for:
- Recording exact versions of all dependencies (direct + transitive)
- Reproducible installs across machines
- Integrity verification via content hashes
"""
import hashlib
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
import yaml
@dataclass
class LockedPackage:
"""A locked dependency with exact version and integrity."""
name: str # Qualified: owner/name
version: str # Exact resolved version
constraint: str # Original constraint
integrity: str # sha256 hash (from registry config_hash)
source: str # "registry", "local", "global"
direct: bool # True if in manifest
required_by: List[str] = field(default_factory=list) # Parent packages
path: Optional[str] = None # Relative path for local tools
content_hash: str = "" # Definition plus dependency identities
dependency_hashes: Dict[str, str] = field(default_factory=dict)
@property
def owner(self) -> str:
"""Extract owner from qualified name."""
return self.name.split("/")[0] if "/" in self.name else ""
@property
def tool_name(self) -> str:
"""Extract tool name from qualified name."""
return self.name.split("/")[1] if "/" in self.name else self.name
@dataclass
class LockfileMetadata:
"""Metadata about when/how the lock file was generated."""
generated_at: str
cmdforge_version: str
manifest_hash: str
platform: str
python_version: str
@dataclass
class Lockfile:
"""Complete lock file representation."""
lockfile_version: int = 1
metadata: Optional[LockfileMetadata] = None
packages: Dict[str, LockedPackage] = field(default_factory=dict)
@classmethod
def load(cls, path: Path = None) -> Optional["Lockfile"]:
"""Load lock file from disk.
Args:
path: Path to lock file (default: ./cmdforge.lock)
Returns:
Lockfile object, or None if not found
"""
path = path or Path("cmdforge.lock")
if not path.exists():
return None
try:
with open(path) as f:
data = yaml.safe_load(f) or {}
return cls._from_dict(data)
except Exception as e:
print(f"Warning: Could not load lock file: {e}")
return None
def save(self, path: Path = None) -> None:
"""Save lock file to disk.
Args:
path: Path to save to (default: ./cmdforge.lock)
"""
path = path or Path("cmdforge.lock")
with open(path, "w") as f:
# Add header comment
f.write("# cmdforge.lock\n")
f.write("# Auto-generated by 'cmdforge lock' - do not edit manually\n")
f.write("# To update: cmdforge lock --force\n\n")
yaml.safe_dump(
self._to_dict(),
f,
sort_keys=False,
default_flow_style=False
)
def get_package(self, name: str) -> Optional[LockedPackage]:
"""Get a locked package by qualified name."""
return self.packages.get(name)
def is_stale(self, manifest_path: Path = None) -> bool:
"""Check if lock file is outdated relative to manifest.
Note: This only checks if the manifest hash changed.
It does NOT detect if local tools were edited after locking.
Use verify_lockfile() for full integrity checking.
Args:
manifest_path: Path to manifest (default: ./cmdforge.yaml)
Returns:
True if lock file is stale
"""
manifest_path = manifest_path or Path("cmdforge.yaml")
if not manifest_path.exists():
return True
if not self.metadata:
return True
current_hash = compute_file_hash(manifest_path)
return current_hash != self.metadata.manifest_hash
@classmethod
def _from_dict(cls, data: dict) -> "Lockfile":
"""Parse lock file from dict."""
metadata = None
if "metadata" in data:
m = data["metadata"]
metadata = LockfileMetadata(
generated_at=m.get("generated_at", ""),
cmdforge_version=m.get("cmdforge_version", ""),
manifest_hash=m.get("manifest_hash", ""),
platform=m.get("platform", ""),
python_version=m.get("python_version", "")
)
packages = {}
for name, pkg_data in data.get("packages", {}).items():
packages[name] = LockedPackage(
name=name,
version=pkg_data.get("version", ""),
constraint=pkg_data.get("constraint", "*"),
integrity=pkg_data.get("integrity", ""),
source=pkg_data.get("source", "registry"),
direct=pkg_data.get("direct", False),
required_by=pkg_data.get("required_by", []),
path=pkg_data.get("path"),
content_hash=pkg_data.get("content_hash", ""),
dependency_hashes=pkg_data.get("dependency_hashes", {})
)
return cls(
lockfile_version=data.get("lockfile_version", 1),
metadata=metadata,
packages=packages
)
def _to_dict(self) -> dict:
"""Convert to dict for YAML serialization."""
d = {"lockfile_version": self.lockfile_version}
if self.metadata:
d["metadata"] = {
"generated_at": self.metadata.generated_at,
"cmdforge_version": self.metadata.cmdforge_version,
"manifest_hash": self.metadata.manifest_hash,
"platform": self.metadata.platform,
"python_version": self.metadata.python_version
}
d["packages"] = {}
for name, pkg in self.packages.items():
pkg_dict = {
"version": pkg.version,
"constraint": pkg.constraint,
"integrity": pkg.integrity,
"source": pkg.source,
"direct": pkg.direct
}
if pkg.required_by:
pkg_dict["required_by"] = pkg.required_by
if pkg.path:
pkg_dict["path"] = pkg.path
if pkg.content_hash:
pkg_dict["content_hash"] = pkg.content_hash
if pkg.dependency_hashes:
pkg_dict["dependency_hashes"] = dict(sorted(pkg.dependency_hashes.items()))
d["packages"][name] = pkg_dict
return d
def compute_file_hash(path: Path) -> str:
"""Compute SHA256 hash of raw file bytes.
Used ONLY for manifest hash (to detect any change, including formatting).
For tool config integrity, use hash_utils.compute_yaml_hash() instead.
Args:
path: Path to file
Returns:
Hash string in format "sha256:<64-char-hex>"
"""
sha256 = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
sha256.update(chunk)
return f"sha256:{sha256.hexdigest()}"
def generate_lockfile(
manifest: "Manifest",
graph: "DependencyGraph",
client: "RegistryClient"
) -> Lockfile:
"""
Generate a lock file from resolved dependencies.
Args:
manifest: Project manifest with constraints
graph: Resolved dependency graph (from DependencyGraphBuilder)
client: Registry client for fetching config_hash
Returns:
Complete Lockfile ready to save
"""
import platform
import sys
from . import __version__
# Compute manifest hash
manifest_path = Path("cmdforge.yaml")
manifest_hash = compute_file_hash(manifest_path) if manifest_path.exists() else ""
metadata = LockfileMetadata(
generated_at=datetime.now().astimezone().isoformat(),
cmdforge_version=__version__,
manifest_hash=manifest_hash,
platform=platform.system().lower(),
python_version=f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
)
lock = Lockfile(metadata=metadata)
# Get direct dependency names for marking
direct_names = {d.name for d in manifest.dependencies}
# Process all nodes in the graph
for qualified_name, node in graph.nodes.items():
if not node.is_resolved:
continue
integrity = _get_integrity_hash(node, client)
pkg = LockedPackage(
name=qualified_name,
version=node.resolved_version or "",
constraint=node.version_constraint or "*",
integrity=integrity,
source=node.source or "registry",
direct=qualified_name in direct_names or _is_direct(qualified_name, direct_names),
required_by=_find_parents(qualified_name, graph),
path=str(node.path) if node.source == "local" and node.path else None
)
lock.packages[qualified_name] = pkg
from .integrity import compute_content_identity
visiting = set()
def identity_for(package_name: str) -> str:
pkg = lock.packages.get(package_name)
node = graph.nodes.get(package_name)
if pkg is None or node is None or not pkg.integrity:
return ""
if pkg.content_hash:
return pkg.content_hash
if package_name in visiting:
return ""
visiting.add(package_name)
dependencies = {}
for child_name in sorted(node.children):
child_hash = identity_for(child_name)
if not child_hash:
visiting.remove(package_name)
return ""
dependencies[child_name] = child_hash
visiting.remove(package_name)
pkg.dependency_hashes = dependencies
pkg.content_hash = compute_content_identity(pkg.integrity, dependencies)
return pkg.content_hash
for package_name in sorted(lock.packages):
identity_for(package_name)
return lock
def _get_integrity_hash(node: "DependencyNode", client: "RegistryClient") -> str:
"""Get integrity hash for a dependency node."""
if node.source in ("local", "global") and node.path:
# For local/global tools, hash normalized config
config_path = node.path / "config.yaml"
if config_path.exists():
from .hash_utils import compute_yaml_hash
return compute_yaml_hash(config_path.read_text())
return ""
elif node.source == "registry":
# For registry tools, get the config_hash from registry
# This is the hash BEFORE any local modifications
try:
result = client.download_tool(
node.owner,
node.name,
version=node.resolved_version,
install=False # Don't count as install, just get config
)
# Use the config_hash from registry response
if hasattr(result, 'config_hash') and result.config_hash:
# config_hash already includes "sha256:" prefix
return result.config_hash
# Fallback: hash the config_yaml content using normalized hashing
if hasattr(result, 'config_yaml') and result.config_yaml:
from .hash_utils import compute_yaml_hash
return compute_yaml_hash(result.config_yaml)
except Exception:
return ""
return ""
def _is_direct(qualified_name: str, direct_names: set) -> bool:
"""Check if a qualified name matches any direct dependency."""
# Handle case where manifest has unqualified names
tool_name = qualified_name.split("/")[1] if "/" in qualified_name else qualified_name
return qualified_name in direct_names or tool_name in direct_names
def _find_parents(qualified_name: str, graph: "DependencyGraph") -> List[str]:
"""Find packages that depend on this one."""
parents = []
for name, node in graph.nodes.items():
if qualified_name in node.children:
parents.append(name)
return parents
def verify_lockfile(
lock: Lockfile,
client: "RegistryClient"
) -> List[str]:
"""
Verify installed tools match lock file.
Args:
lock: Lock file to verify against
client: Registry client for hash verification
Returns:
List of verification errors (empty if all OK)
"""
from .resolver import ToolResolver, ToolNotFoundError
errors = []
# Create resolver that doesn't use manifest (bypass version overrides)
resolver = ToolResolver(auto_fetch=False)
resolver.manifest = None
for name, locked in lock.packages.items():
# Check tool exists
try:
resolved = resolver.resolve(name)
except ToolNotFoundError:
errors.append(f"{name}: not installed")
continue
except Exception:
errors.append(f"{name}: not installed")
continue
# Check version matches
resolved_version = resolved.version or ""
locked_version = locked.version or ""
if resolved_version and locked_version and resolved_version != locked_version:
errors.append(
f"{name}: version mismatch "
f"(installed: {resolved_version}, locked: {locked_version})"
)
# Check integrity against installed tool config
if locked.integrity and resolved.path:
try:
config_path = resolved.path / "config.yaml"
if config_path.exists():
from .hash_utils import compute_yaml_hash
current_hash = compute_yaml_hash(config_path.read_text())
if current_hash != locked.integrity:
errors.append(
f"{name}: integrity mismatch "
f"(installed tool differs from lock)"
)
except Exception as e:
errors.append(f"{name}: could not verify integrity ({e})")
if locked.content_hash:
from .integrity import compute_content_identity
actual_dependencies = {}
missing_dependencies = []
for dep_name, expected_hash in locked.dependency_hashes.items():
dep = lock.packages.get(dep_name)
if dep is None or not dep.content_hash:
missing_dependencies.append(dep_name)
elif dep.content_hash != expected_hash:
errors.append(
f"{name}: dependency identity mismatch ({dep_name})"
)
else:
actual_dependencies[dep_name] = dep.content_hash
if missing_dependencies:
errors.append(
f"{name}: unresolved integrity dependencies: "
+ ", ".join(missing_dependencies)
)
elif locked.integrity:
actual_identity = compute_content_identity(
locked.integrity, actual_dependencies
)
if actual_identity != locked.content_hash:
errors.append(f"{name}: transitive content identity mismatch")
return errors