Checkpoint production workflow updates
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable
|
||||
|
||||
|
||||
LT1_ARCHIVE_SUFFIXES = (".tar.gz", ".tgz", ".tar", ".zip")
|
||||
LT1_ORBIT_SUFFIXES = (".txt",)
|
||||
ProgressCallback = Callable[[str], None]
|
||||
|
||||
|
||||
def now_text() -> str:
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def stamp_text() -> str:
|
||||
return datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
|
||||
def normalize_path(value: str) -> Path:
|
||||
return Path(value.strip().strip('"')).expanduser()
|
||||
|
||||
|
||||
def parse_path_list(value: str) -> list[Path]:
|
||||
paths: list[Path] = []
|
||||
for line in str(value or "").replace(";", "\n").splitlines():
|
||||
text = line.strip().strip('"')
|
||||
if text:
|
||||
paths.append(normalize_path(text))
|
||||
return paths
|
||||
|
||||
|
||||
def safe_mkdir(path: Path) -> None:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def is_asset_file(path: Path) -> bool:
|
||||
lower = path.name.lower()
|
||||
if lower.startswith("lt1") and lower.endswith(LT1_ARCHIVE_SUFFIXES):
|
||||
return True
|
||||
if lower.startswith("lt1") and lower.endswith(LT1_ORBIT_SUFFIXES):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def classify_asset(path: Path) -> str:
|
||||
lower = path.name.lower()
|
||||
if lower.endswith(LT1_ARCHIVE_SUFFIXES):
|
||||
return "lt1_archive"
|
||||
if lower.endswith(LT1_ORBIT_SUFFIXES):
|
||||
return "lt1_orbit"
|
||||
return "unknown"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssetRecord:
|
||||
kind: str
|
||||
name: str
|
||||
path: str
|
||||
size: int
|
||||
mtime: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CopyRecord:
|
||||
name: str
|
||||
kind: str
|
||||
source_path: str
|
||||
target_path: str
|
||||
size: int
|
||||
action: str
|
||||
reason: str
|
||||
|
||||
|
||||
class FileLogger:
|
||||
def __init__(self, path: Path, ui_log: ProgressCallback | None = None) -> None:
|
||||
self.path = path
|
||||
self.ui_log = ui_log
|
||||
safe_mkdir(path.parent)
|
||||
|
||||
def __call__(self, message: str) -> None:
|
||||
line = f"{now_text()} {message}"
|
||||
with self.path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(line + "\n")
|
||||
if self.ui_log:
|
||||
self.ui_log(line)
|
||||
|
||||
|
||||
def scan_assets(paths: Iterable[Path], *, log: ProgressCallback | None = None) -> list[AssetRecord]:
|
||||
records: list[AssetRecord] = []
|
||||
for root in paths:
|
||||
if not root.exists():
|
||||
if log:
|
||||
log(f"路径不存在,跳过:{root}")
|
||||
continue
|
||||
if not root.is_dir():
|
||||
if log:
|
||||
log(f"不是目录,跳过:{root}")
|
||||
continue
|
||||
count = 0
|
||||
for entry in root.iterdir():
|
||||
if not entry.is_file() or not is_asset_file(entry):
|
||||
continue
|
||||
stat = entry.stat()
|
||||
records.append(
|
||||
AssetRecord(
|
||||
kind=classify_asset(entry),
|
||||
name=entry.name,
|
||||
path=str(entry),
|
||||
size=stat.st_size,
|
||||
mtime=stat.st_mtime,
|
||||
)
|
||||
)
|
||||
count += 1
|
||||
if log:
|
||||
log(f"扫描完成:{root},资产 {count} 个")
|
||||
records.sort(key=lambda item: (item.kind, item.name.lower(), item.path.lower()))
|
||||
return records
|
||||
|
||||
|
||||
def asset_key(record: AssetRecord) -> tuple[str, str]:
|
||||
return record.kind, record.name.lower()
|
||||
|
||||
|
||||
def build_asset_index(records: Iterable[AssetRecord]) -> dict[tuple[str, str], AssetRecord]:
|
||||
index: dict[tuple[str, str], AssetRecord] = {}
|
||||
for record in records:
|
||||
key = asset_key(record)
|
||||
if key not in index:
|
||||
index[key] = record
|
||||
return index
|
||||
|
||||
|
||||
def read_inventory(path: Path) -> list[AssetRecord]:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
records: list[AssetRecord] = []
|
||||
for item in payload.get("assets", payload.get("files", [])):
|
||||
try:
|
||||
records.append(
|
||||
AssetRecord(
|
||||
kind=str(item.get("kind") or ""),
|
||||
name=str(item.get("name") or ""),
|
||||
path=str(item.get("path") or ""),
|
||||
size=int(item.get("size") or 0),
|
||||
mtime=float(item.get("mtime") or 0),
|
||||
)
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return [item for item in records if item.kind and item.name]
|
||||
|
||||
|
||||
def write_inventory(path: Path, records: list[AssetRecord], roots: list[str]) -> None:
|
||||
safe_mkdir(path.parent)
|
||||
payload = {
|
||||
"schema": "lt1_asset_inventory.v2",
|
||||
"generated_at": now_text(),
|
||||
"roots": roots,
|
||||
"asset_count": len(records),
|
||||
"assets": [asdict(item) for item in records],
|
||||
}
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def write_csv(path: Path, records: list[CopyRecord]) -> None:
|
||||
safe_mkdir(path.parent)
|
||||
with path.open("w", newline="", encoding="utf-8-sig") as handle:
|
||||
writer = csv.DictWriter(
|
||||
handle,
|
||||
fieldnames=["name", "kind", "source_path", "target_path", "size", "action", "reason"],
|
||||
)
|
||||
writer.writeheader()
|
||||
for record in records:
|
||||
writer.writerow(asdict(record))
|
||||
|
||||
|
||||
def choose_target_root(target_roots: list[Path], filename: str, size: int) -> tuple[Path | None, str]:
|
||||
for root in target_roots:
|
||||
dest = root / filename
|
||||
if dest.exists():
|
||||
try:
|
||||
if dest.stat().st_size == size:
|
||||
return None, f"目标已存在且大小一致:{dest}"
|
||||
return None, f"目标已存在但大小不同:{dest}"
|
||||
except OSError:
|
||||
return None, f"无法读取目标文件状态:{dest}"
|
||||
for root in target_roots:
|
||||
try:
|
||||
safe_mkdir(root)
|
||||
free_bytes = shutil.disk_usage(root).free
|
||||
if free_bytes > size:
|
||||
return root, "选择第一个空间足够的目标路径"
|
||||
except OSError:
|
||||
continue
|
||||
return None, "没有空间足够的目标路径"
|
||||
|
||||
|
||||
def copy_file_atomic(source: Path, target: Path, *, move: bool = False) -> None:
|
||||
safe_mkdir(target.parent)
|
||||
part = target.with_name(target.name + ".part")
|
||||
if part.exists():
|
||||
part.unlink()
|
||||
if target.exists():
|
||||
raise FileExistsError(str(target))
|
||||
if move:
|
||||
shutil.copy2(source, part)
|
||||
if part.stat().st_size != source.stat().st_size:
|
||||
raise OSError(f"复制后大小不一致:{source} -> {target}")
|
||||
os.replace(part, target)
|
||||
source.unlink()
|
||||
else:
|
||||
shutil.copy2(source, part)
|
||||
if part.stat().st_size != source.stat().st_size:
|
||||
raise OSError(f"复制后大小不一致:{source} -> {target}")
|
||||
os.replace(part, target)
|
||||
|
||||
|
||||
def copy_unc_missing_assets(
|
||||
unc_roots: list[Path],
|
||||
server_inventory_json: Path,
|
||||
target_roots: list[Path],
|
||||
report_dir: Path,
|
||||
*,
|
||||
execute: bool,
|
||||
log: ProgressCallback | None = None,
|
||||
) -> list[CopyRecord]:
|
||||
server_assets = read_inventory(server_inventory_json)
|
||||
server_index = build_asset_index(server_assets)
|
||||
source_assets = scan_assets(unc_roots, log=log)
|
||||
report: list[CopyRecord] = []
|
||||
|
||||
for asset in source_assets:
|
||||
server_asset = server_index.get(asset_key(asset))
|
||||
if server_asset and server_asset.size == asset.size:
|
||||
report.append(
|
||||
CopyRecord(asset.name, asset.kind, asset.path, server_asset.path, asset.size, "skip", "服务器清单已有且大小一致")
|
||||
)
|
||||
continue
|
||||
if server_asset and server_asset.size != asset.size:
|
||||
report.append(
|
||||
CopyRecord(asset.name, asset.kind, asset.path, server_asset.path, asset.size, "conflict", "服务器清单有同名资产但大小不同")
|
||||
)
|
||||
continue
|
||||
|
||||
target_root, reason = choose_target_root(target_roots, asset.name, asset.size)
|
||||
if target_root is None:
|
||||
report.append(CopyRecord(asset.name, asset.kind, asset.path, "", asset.size, "skip", reason))
|
||||
continue
|
||||
target_path = target_root / asset.name
|
||||
if not execute:
|
||||
report.append(CopyRecord(asset.name, asset.kind, asset.path, str(target_path), asset.size, "planned", reason))
|
||||
continue
|
||||
try:
|
||||
copy_file_atomic(Path(asset.path), target_path)
|
||||
report.append(CopyRecord(asset.name, asset.kind, asset.path, str(target_path), asset.size, "copied", reason))
|
||||
if log:
|
||||
log(f"已复制:{asset.name} -> {target_path}")
|
||||
except Exception as exc:
|
||||
report.append(CopyRecord(asset.name, asset.kind, asset.path, str(target_path), asset.size, "failed", str(exc)))
|
||||
if log:
|
||||
log(f"复制失败:{asset.name},{exc}")
|
||||
|
||||
write_csv(report_dir / f"unc_copy_report_{stamp_text()}.csv", report)
|
||||
return report
|
||||
|
||||
|
||||
def import_disk_assets_to_server(
|
||||
disk_roots: list[Path],
|
||||
server_asset_roots: list[Path],
|
||||
report_dir: Path,
|
||||
*,
|
||||
execute: bool,
|
||||
move: bool,
|
||||
log: ProgressCallback | None = None,
|
||||
) -> list[CopyRecord]:
|
||||
source_assets = scan_assets(disk_roots, log=log)
|
||||
server_assets = scan_assets(server_asset_roots, log=log)
|
||||
server_index = build_asset_index(server_assets)
|
||||
report: list[CopyRecord] = []
|
||||
|
||||
for asset in source_assets:
|
||||
existing = server_index.get(asset_key(asset))
|
||||
if existing and existing.size == asset.size:
|
||||
report.append(CopyRecord(asset.name, asset.kind, asset.path, existing.path, asset.size, "skip", "服务器已存在且大小一致"))
|
||||
continue
|
||||
if existing and existing.size != asset.size:
|
||||
report.append(CopyRecord(asset.name, asset.kind, asset.path, existing.path, asset.size, "conflict", "服务器有同名资产但大小不同"))
|
||||
continue
|
||||
|
||||
target_root, reason = choose_target_root(server_asset_roots, asset.name, asset.size)
|
||||
if target_root is None:
|
||||
report.append(CopyRecord(asset.name, asset.kind, asset.path, "", asset.size, "skip", reason))
|
||||
continue
|
||||
target_path = target_root / asset.name
|
||||
if not execute:
|
||||
report.append(CopyRecord(asset.name, asset.kind, asset.path, str(target_path), asset.size, "planned", reason))
|
||||
continue
|
||||
try:
|
||||
copy_file_atomic(Path(asset.path), target_path, move=move)
|
||||
action = "moved" if move else "copied"
|
||||
report.append(CopyRecord(asset.name, asset.kind, asset.path, str(target_path), asset.size, action, reason))
|
||||
if log:
|
||||
log(f"已{('剪切' if move else '复制')}:{asset.name} -> {target_path}")
|
||||
except Exception as exc:
|
||||
report.append(CopyRecord(asset.name, asset.kind, asset.path, str(target_path), asset.size, "failed", str(exc)))
|
||||
if log:
|
||||
log(f"导入失败:{asset.name},{exc}")
|
||||
|
||||
write_csv(report_dir / f"disk_import_report_{stamp_text()}.csv", report)
|
||||
return report
|
||||
Reference in New Issue
Block a user