434 lines
15 KiB
Python
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
|