"""Read-only output discovery, history preservation and small runtime snapshots.""" import glob import hashlib import json import os from pathlib import Path import shutil import subprocess from .artifacts import files_under def outputs(patterns): paths = [] for pattern in patterns: expanded = glob.iglob(pattern) if glob.has_magic(pattern) else [pattern] for name in expanded: path = Path(name) if path.is_symlink(): continue if path.is_file():paths.append(path) elif path.is_dir():paths.extend(files_under([path], limit=4097)) if len(paths) > 4096: raise ValueError("原输出文件超过 4096 项,请收窄登记输出范围") return sorted(set(paths)) def preserve(paths, directory, maximum=64 * 1024**3): sizes = [path.stat().st_size for path in paths] total = sum(sizes) if total > maximum or shutil.disk_usage(directory).free < total + 10 * 1024**3: raise ValueError("保护原有同名结果所需空间不足或超过 64 GiB,本次未启动") destination = directory / "previous-outputs" destination.mkdir(mode=0o700) manifest = [] for path in paths: identity = hashlib.sha256(str(path).encode()).hexdigest() target = destination / identity before = path.stat() with path.open("rb") as source, target.open("xb") as output: shutil.copyfileobj(source, output, length=1024 * 1024) after = path.stat() if (before.st_size, before.st_mtime_ns) != (after.st_size, after.st_mtime_ns): raise ValueError("原输出仍被其他任务写入,请等待后再试") manifest.append({"path": str(path), "backup": identity, "size": before.st_size}) (destination / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") return len(manifest) def runtime_snapshot(): result = {"containers": [], "tmux_sessions": [], "errors": []} try: raw = subprocess.run(["/usr/bin/docker", "ps", "-a", "--format", "{{json .}}"], capture_output=True, timeout=10, text=True) if raw.returncode == 0: for line in raw.stdout.splitlines(): value = json.loads(line) result["containers"].append({key: value.get(key) for key in ("ID", "Names", "Image", "State", "Status")}) else:result["errors"].append("容器状态读取失败") except (OSError, ValueError, subprocess.TimeoutExpired):result["errors"].append("容器状态不可用") try: raw = subprocess.run(["/usr/bin/tmux", "list-sessions", "-F", "#{session_id}\t#{session_name}"], capture_output=True, timeout=5, text=True) if raw.returncode == 0: result["tmux_sessions"] = [dict(zip(("id", "name"), line.split("\t", 1))) for line in raw.stdout.splitlines() if "\t" in line] except (OSError, subprocess.TimeoutExpired):result["errors"].append("tmux 状态不可用") return result def differences(before, after): old = {v["ID"]: v for v in before["containers"]} new = {v["ID"]: v for v in after["containers"]} sessions = {v["id"] for v in before["tmux_sessions"]} return {"created_containers": [v for k, v in new.items() if k not in old], "removed_containers": [v for k, v in old.items() if k not in new], "changed_containers": [v for k, v in new.items() if k in old and v["State"] != old[k]["State"]], "new_tmux_sessions": [v for v in after["tmux_sessions"] if v["id"] not in sessions], "note": "共享宿主前后快照差异不等于资源归属证明;不会自动删除这些容器或会话。"}