381 lines
14 KiB
Python
381 lines
14 KiB
Python
"""Receipt-bound, out-of-run preparation of the real developer meta-package.
|
|
|
|
This deliberately does not commit, tag, publish, or update a running release
|
|
console. The complete generated file is reviewed in a separate source checkout.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import hashlib
|
|
import os
|
|
from pathlib import Path
|
|
import runpy
|
|
import stat
|
|
import tempfile
|
|
import tomllib
|
|
|
|
from .git_state import (
|
|
collect_repository_snapshot,
|
|
git,
|
|
git_text,
|
|
registered_developer_meta_path,
|
|
)
|
|
from .repository_tag import normalize_version, remote_tag_commit, run
|
|
from .source_provenance import registered_source_origin_issues
|
|
from .source_tag_batch import (
|
|
_OBJECT,
|
|
_owned_path,
|
|
_source_filesystem,
|
|
_tracked_worktree,
|
|
_trusted_ancestry,
|
|
)
|
|
from .workspace import META_ROOT, load_repository_specs, resolve_repo_path
|
|
|
|
PACKAGE = "packages/govoplan-meta/pyproject.toml"
|
|
MAX_INPUT_FILES = 128
|
|
MAX_INPUT_BYTES = 2 * 1024 * 1024
|
|
MAX_TOTAL_BYTES = 16 * 1024 * 1024
|
|
GENERATOR = ".operator/generate-developer-meta-package.py"
|
|
|
|
|
|
class MetaPreparationError(ValueError):
|
|
"""Preparation is blocked, or an applied file needs explicit reconciliation."""
|
|
|
|
|
|
class MetaPreparationAmbiguous(MetaPreparationError):
|
|
"""The file effect may have happened and requires explicit reconciliation."""
|
|
|
|
|
|
def preparation_command(*, workspace: Path, target_version: str) -> str:
|
|
import shlex
|
|
|
|
return " ".join(
|
|
shlex.quote(value)
|
|
for value in (
|
|
"python",
|
|
str(META_ROOT / "tools/release/prepare-developer-meta-package.py"),
|
|
"--workspace",
|
|
str(workspace),
|
|
"--target-version",
|
|
target_version,
|
|
)
|
|
)
|
|
|
|
|
|
def _read_input(path: Path) -> tuple[bytes, dict]:
|
|
def identity(value):
|
|
return (
|
|
value.st_dev,
|
|
value.st_ino,
|
|
value.st_uid,
|
|
value.st_gid,
|
|
value.st_mode,
|
|
value.st_size,
|
|
value.st_mtime_ns,
|
|
value.st_ctime_ns,
|
|
)
|
|
|
|
before = path.lstat()
|
|
if (
|
|
not stat.S_ISREG(before.st_mode)
|
|
or before.st_uid != os.geteuid()
|
|
or before.st_mode & 0o022
|
|
or not 0 < before.st_size <= MAX_INPUT_BYTES
|
|
):
|
|
raise MetaPreparationError(
|
|
"Preparation inputs must be owned, bounded regular files."
|
|
)
|
|
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
with os.fdopen(descriptor, "rb") as source:
|
|
opened = os.fstat(source.fileno())
|
|
if identity(opened) != identity(before):
|
|
raise MetaPreparationError("Preparation input changed before reading.")
|
|
payload = source.read(before.st_size + 1)
|
|
if (
|
|
identity(os.fstat(source.fileno())) != identity(opened)
|
|
or len(payload) != before.st_size
|
|
):
|
|
raise MetaPreparationError("Preparation input changed while reading.")
|
|
return payload, {
|
|
"sha256": hashlib.sha256(payload).hexdigest(),
|
|
"identity": [
|
|
before.st_dev,
|
|
before.st_ino,
|
|
before.st_uid,
|
|
before.st_gid,
|
|
stat.S_IMODE(before.st_mode),
|
|
before.st_size,
|
|
],
|
|
}
|
|
|
|
|
|
def _snapshot(*, repo_path: Path, target_version: str, output_dirty: bool = False):
|
|
workspace = repo_path.parent
|
|
specs = {spec.name: spec for spec in load_repository_specs(include_website=False)}
|
|
if registered_developer_meta_path(repo_path) != repo_path / PACKAGE:
|
|
raise MetaPreparationError(
|
|
"Only the registered Meta nested-package identity can be prepared."
|
|
)
|
|
if repo_path.resolve() == META_ROOT.resolve():
|
|
raise MetaPreparationError(
|
|
"Prepare a separate registered source checkout, never the running operator tooling."
|
|
)
|
|
paths = [repo_path / PACKAGE, repo_path / "requirements-release.txt"]
|
|
for path in workspace.glob("govoplan-*/pyproject.toml"):
|
|
paths.append(path)
|
|
if len(paths) > MAX_INPUT_FILES:
|
|
raise MetaPreparationError(
|
|
"Developer composition exceeds its input-file bound."
|
|
)
|
|
if workspace / "govoplan-core/pyproject.toml" not in paths:
|
|
raise MetaPreparationError(
|
|
"Prepare and commit matching Core metadata before Meta preparation."
|
|
)
|
|
repositories = {"govoplan": repo_path}
|
|
for path in paths[2:]:
|
|
name = path.parent.name
|
|
if (
|
|
name not in specs
|
|
or resolve_repo_path(specs[name], workspace) != path.parent
|
|
):
|
|
raise MetaPreparationError(
|
|
"Developer composition contains an unregistered package checkout."
|
|
)
|
|
repositories[name] = path.parent
|
|
filesystems = {}
|
|
for name, path in repositories.items():
|
|
filesystems[name] = _source_filesystem(path=path, workspace=workspace)
|
|
_tracked_worktree(path)
|
|
issues = registered_source_origin_issues(
|
|
repo_versions={name: target_version for name in repositories},
|
|
workspace=workspace,
|
|
remote="origin",
|
|
)
|
|
if issues:
|
|
raise MetaPreparationError(
|
|
"Preparation source origins do not match the registered repositories."
|
|
)
|
|
sources = {}
|
|
for name, path in sorted(repositories.items()):
|
|
snapshot = collect_repository_snapshot(
|
|
specs[name],
|
|
workspace_root=workspace,
|
|
target_tag=None,
|
|
online=False,
|
|
)
|
|
dirty_allowed = (
|
|
output_dirty
|
|
and name == "govoplan"
|
|
and snapshot.dirty_entries == (f" M {PACKAGE}",)
|
|
)
|
|
if (
|
|
snapshot.errors
|
|
or not snapshot.has_head
|
|
or snapshot.branch != "main"
|
|
or snapshot.upstream != "origin/main"
|
|
or snapshot.behind
|
|
or (snapshot.dirty and not dirty_allowed)
|
|
):
|
|
raise MetaPreparationError(
|
|
"Preparation requires reviewed clean main sources tracking origin/main."
|
|
)
|
|
head = git_text(path, "rev-parse", "--verify", "HEAD")
|
|
live = run(
|
|
("git", "ls-remote", "--exit-code", "--heads", "origin", "refs/heads/main"),
|
|
cwd=path,
|
|
)
|
|
lines = live.stdout.strip().splitlines()
|
|
if live.returncode or len(lines) != 1:
|
|
raise MetaPreparationError("Could not verify live preparation source main.")
|
|
remote_main, separator, reference = lines[0].partition("\t")
|
|
if (
|
|
not _OBJECT.fullmatch(head)
|
|
or not _OBJECT.fullmatch(remote_main)
|
|
or not separator
|
|
or reference != "refs/heads/main"
|
|
or git(path, "merge-base", "--is-ancestor", remote_main, head).returncode
|
|
):
|
|
raise MetaPreparationError(
|
|
"Preparation source main diverged; fetch and review separately."
|
|
)
|
|
sources[name] = {
|
|
"head": head,
|
|
"remote_main": remote_main,
|
|
"filesystem": filesystems[name],
|
|
}
|
|
tag = f"v{target_version}"
|
|
published = remote_tag_commit(repo_path, remote="origin", tag=tag)
|
|
if (
|
|
published.error
|
|
or published.tag_object
|
|
or git_text(repo_path, "rev-parse", "--verify", f"refs/tags/{tag}")
|
|
):
|
|
raise MetaPreparationError(
|
|
"An existing or unverifiable target Meta tag blocks source preparation."
|
|
)
|
|
inputs, payloads = {}, {}
|
|
total = 0
|
|
for path in sorted(paths):
|
|
payload, identity = _read_input(path)
|
|
total += len(payload)
|
|
if total > MAX_TOTAL_BYTES:
|
|
raise MetaPreparationError(
|
|
"Developer composition exceeds its aggregate input bound."
|
|
)
|
|
relative = path.relative_to(workspace).as_posix()
|
|
inputs[relative], payloads[relative] = identity, payload
|
|
generator_path = META_ROOT / "tools/release/generate-developer-meta-package.py"
|
|
_trusted_ancestry(generator_path.parent)
|
|
_owned_path(META_ROOT, directory=True)
|
|
generator, generator_identity = _read_input(generator_path)
|
|
if total + len(generator) > MAX_TOTAL_BYTES:
|
|
raise MetaPreparationError(
|
|
"Developer composition exceeds its aggregate input bound."
|
|
)
|
|
payloads[GENERATOR] = generator
|
|
current = tomllib.loads(payloads[f"govoplan/{PACKAGE}"].decode("utf-8")).get(
|
|
"project", {}
|
|
)
|
|
core = tomllib.loads(payloads["govoplan-core/pyproject.toml"].decode("utf-8")).get(
|
|
"project", {}
|
|
)
|
|
if (
|
|
not isinstance(current, dict)
|
|
or current.get("name") != "govoplan"
|
|
or not isinstance(current.get("version"), str)
|
|
):
|
|
raise MetaPreparationError(
|
|
"Nested developer-package identity and version must be exact."
|
|
)
|
|
if (
|
|
not isinstance(core, dict)
|
|
or core.get("name") != "govoplan-core"
|
|
or core.get("version") != target_version
|
|
):
|
|
raise MetaPreparationError(
|
|
"Prepare and commit Core at the requested target version before Meta preparation."
|
|
)
|
|
from .version_alignment import repository_version_issues
|
|
|
|
if repository_version_issues(
|
|
workspace / "govoplan-core", expected_version=target_version
|
|
):
|
|
raise MetaPreparationError(
|
|
"Prepare aligned Core version metadata before Meta preparation."
|
|
)
|
|
receipt = {
|
|
"kind": "developer_meta_preparation_v1",
|
|
"workspace": str(workspace),
|
|
"target_version": target_version,
|
|
"sources": sources,
|
|
"inputs": inputs,
|
|
"operator_generator": generator_identity,
|
|
}
|
|
return receipt, payloads
|
|
|
|
|
|
def _render(payloads: dict[str, bytes]) -> bytes:
|
|
# The trusted operator generator sees only the frozen bounded data snapshot.
|
|
with tempfile.TemporaryDirectory(prefix="govoplan-meta-render-") as temporary:
|
|
workspace = Path(temporary)
|
|
for relative, payload in payloads.items():
|
|
path = workspace / relative
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(payload)
|
|
render = runpy.run_path(str(workspace / GENERATOR))["render"]
|
|
result = render(
|
|
workspace=workspace,
|
|
requirements=workspace / "govoplan/requirements-release.txt",
|
|
).encode("utf-8")
|
|
if len(result) > MAX_INPUT_BYTES:
|
|
raise MetaPreparationError(
|
|
"Generated developer package exceeds its output bound."
|
|
)
|
|
return result
|
|
|
|
|
|
def preview_meta_mutation(*, repo_path: Path, target_version: str):
|
|
version = normalize_version(target_version)
|
|
if not version:
|
|
raise MetaPreparationError("A valid target release version is required.")
|
|
receipt, payloads = _snapshot(repo_path=repo_path, target_version=version)
|
|
after = _render(payloads)
|
|
observed, _ = _snapshot(repo_path=repo_path, target_version=version)
|
|
if observed != receipt:
|
|
raise MetaPreparationError(
|
|
"Developer preparation inputs changed during preview."
|
|
)
|
|
receipt["output_sha256"] = hashlib.sha256(after).hexdigest()
|
|
return receipt, payloads[f"govoplan/{PACKAGE}"], after
|
|
|
|
|
|
def prepare_developer_meta_package(
|
|
*,
|
|
repo_path: Path,
|
|
target_version: str,
|
|
apply: bool = False,
|
|
expected_receipt=None,
|
|
confirm_out_of_run: bool = False,
|
|
) -> dict:
|
|
"""Preview or explicitly apply one full generated file; never commit/publish."""
|
|
try:
|
|
receipt, before, after = preview_meta_mutation(
|
|
repo_path=repo_path, target_version=target_version
|
|
)
|
|
result = {
|
|
"status": "planned" if before != after else "noop",
|
|
"path": PACKAGE,
|
|
"receipt": receipt,
|
|
"after_sha256": hashlib.sha256(after).hexdigest(),
|
|
"changed": before != after,
|
|
}
|
|
if not apply:
|
|
return result
|
|
if not confirm_out_of_run:
|
|
raise MetaPreparationError(
|
|
"Explicitly confirm that no durable run is active for this source workspace."
|
|
)
|
|
if receipt != expected_receipt:
|
|
raise MetaPreparationError(
|
|
"Preparation inputs changed since the reviewed preview; create a fresh preview."
|
|
)
|
|
if before == after:
|
|
return result
|
|
from .version_metadata import _atomic_write
|
|
|
|
source_receipt = {
|
|
key: value for key, value in receipt.items() if key != "output_sha256"
|
|
}
|
|
observed, _ = _snapshot(
|
|
repo_path=repo_path, target_version=receipt["target_version"]
|
|
)
|
|
if observed != source_receipt:
|
|
raise MetaPreparationError(
|
|
"Preparation inputs changed before the file effect; create a fresh preview."
|
|
)
|
|
try:
|
|
_atomic_write(repo_path / PACKAGE, after)
|
|
observed, payloads = _snapshot(
|
|
repo_path=repo_path,
|
|
target_version=receipt["target_version"],
|
|
output_dirty=True,
|
|
)
|
|
comparison = copy.deepcopy(observed)
|
|
output = f"govoplan/{PACKAGE}"
|
|
comparison["inputs"][output] = receipt["inputs"][output]
|
|
if comparison != source_receipt or payloads[output] != after:
|
|
raise MetaPreparationError("Preparation inputs changed after writing.")
|
|
except Exception as exc:
|
|
raise MetaPreparationAmbiguous(
|
|
"Generated file may have been written but its write/post-check failed; "
|
|
"review the delta and reconcile manually."
|
|
) from exc
|
|
return {**result, "status": "prepared", "after_receipt": observed}
|
|
except MetaPreparationError:
|
|
raise
|
|
except (OSError, UnicodeError, ValueError, KeyError, TypeError) as exc:
|
|
raise MetaPreparationError(
|
|
f"Developer preparation failed closed ({type(exc).__name__})."
|
|
) from exc
|