mirror of
https://github.com/nearai/ironclaw.git
synced 2026-09-03 08:06:01 +08:00
* fix(release): isolate smoke workspace on Windows * fix(filesystem): publish absent writes atomically on Windows * fix(release): preserve Windows identity in smoke environment * fix(windows): keep ACL output out of CLI JSON * chore(release): forward-port 1.2.0 metadata and healthcheck fix * fix(release): address review feedback Use create-only Windows publication, preserve successful writes when temp cleanup fails, surface ACL errors, and make release guard assertions structurally precise. Remove the migration-shaped thread projection repair from the rebuilt PR branch. * fix(threads): restore projection repair for upgrades Keep the one-time thread-index projection repair while moving it off the listing request path. Bound concurrent and pending work, retry incomplete repairs, cover oversized scopes, and add a force-write CAS mode for damaged sidecars. * fix(storage): address review feedback (#7663) Bound projection repair with stable keyset directory pages and a per-scope retry budget. Run release smoke commands from the isolated workspace and classify Windows publish conflicts from the original OS error.
320 lines
11 KiB
Python
Executable File
320 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Exercise the exact native IronClaw binary that a release job will package."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import tarfile
|
|
import tempfile
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
|
|
REQUIRED_EVIDENCE = frozenset(
|
|
{
|
|
"version",
|
|
"help",
|
|
"profiles",
|
|
"bundled_extensions",
|
|
"local_libsql_migrations",
|
|
"runtime_assembly",
|
|
"migration_profile",
|
|
}
|
|
)
|
|
REQUIRED_BUNDLED_RUNTIME_KINDS = frozenset({"first_party", "mcp_server", "wasm_tool"})
|
|
_PASSTHROUGH_ENV = (
|
|
"PATH",
|
|
"SystemRoot",
|
|
"WINDIR",
|
|
"TMPDIR",
|
|
"TMP",
|
|
"TEMP",
|
|
"LANG",
|
|
"USERNAME",
|
|
"USERDOMAIN",
|
|
)
|
|
|
|
|
|
class SmokeFailure(RuntimeError):
|
|
"""The release binary did not satisfy the packaged-product smoke contract."""
|
|
|
|
|
|
Runner = Callable[
|
|
[Path, tuple[str, ...], dict[str, str], Path], subprocess.CompletedProcess[str]
|
|
]
|
|
|
|
|
|
def _run_command(
|
|
binary: Path,
|
|
args: tuple[str, ...],
|
|
environment: dict[str, str],
|
|
working_directory: Path,
|
|
) -> subprocess.CompletedProcess[str]:
|
|
try:
|
|
return subprocess.run(
|
|
[str(binary), *args],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
env=environment,
|
|
cwd=working_directory,
|
|
timeout=120,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired) as error:
|
|
raise SmokeFailure(
|
|
f"could not execute {binary.name} {' '.join(args)}: {error}"
|
|
) from error
|
|
|
|
|
|
def _checked_output(
|
|
binary: Path,
|
|
args: tuple[str, ...],
|
|
environment: dict[str, str],
|
|
working_directory: Path,
|
|
runner: Runner,
|
|
) -> str:
|
|
result = runner(binary, args, environment, working_directory)
|
|
if result.returncode != 0:
|
|
stdout = result.stdout[-4000:]
|
|
stderr = result.stderr[-4000:]
|
|
raise SmokeFailure(
|
|
f"{binary.name} {' '.join(args)} exited {result.returncode}\n"
|
|
f"stdout:\n{stdout}\nstderr:\n{stderr}"
|
|
)
|
|
return result.stdout
|
|
|
|
|
|
def _parse_json_object(output: str, label: str) -> dict[str, object]:
|
|
try:
|
|
value = json.loads(output)
|
|
except json.JSONDecodeError as error:
|
|
prefix = output[:500]
|
|
raise SmokeFailure(
|
|
f"{label} did not emit valid JSON: {error}; stdout prefix: {prefix!r}"
|
|
) from error
|
|
if not isinstance(value, dict):
|
|
raise SmokeFailure(f"{label} must emit a JSON object")
|
|
return value
|
|
|
|
|
|
def _validate_profiles(output: str) -> None:
|
|
payload = _parse_json_object(output, "profile list --json")
|
|
profiles = payload.get("profiles")
|
|
if not isinstance(profiles, list) or not profiles:
|
|
raise SmokeFailure("profile list --json emitted no supported profiles")
|
|
names = {
|
|
profile.get("name")
|
|
for profile in profiles
|
|
if isinstance(profile, dict) and isinstance(profile.get("name"), str)
|
|
}
|
|
required = {"local-dev", "production", "migration-dry-run"}
|
|
missing = sorted(required - names)
|
|
if missing:
|
|
raise SmokeFailure("profile list --json is missing: " + ", ".join(missing))
|
|
|
|
|
|
def _validate_bundled_extensions(output: str) -> None:
|
|
payload = _parse_json_object(output, "extension search --json")
|
|
lifecycle_payload = payload.get("payload")
|
|
if not isinstance(lifecycle_payload, dict):
|
|
raise SmokeFailure("extension search --json has no lifecycle payload")
|
|
extensions = lifecycle_payload.get("extensions")
|
|
if not isinstance(extensions, list) or not extensions:
|
|
raise SmokeFailure("shipping binary exposed no bundled extensions")
|
|
|
|
ids: list[str] = []
|
|
runtime_kinds: set[str] = set()
|
|
for extension in extensions:
|
|
if not isinstance(extension, dict):
|
|
raise SmokeFailure("extension search returned a non-object entry")
|
|
if extension.get("source") != "host_bundled":
|
|
raise SmokeFailure("extension search returned a non-bundled package")
|
|
package_ref = extension.get("package_ref")
|
|
extension_id = package_ref.get("id") if isinstance(package_ref, dict) else None
|
|
if not isinstance(extension_id, str) or not extension_id.strip():
|
|
raise SmokeFailure(
|
|
"extension search returned an entry without a package id"
|
|
)
|
|
runtime_kind = extension.get("runtime_kind")
|
|
if not isinstance(runtime_kind, str) or not runtime_kind.strip():
|
|
raise SmokeFailure(
|
|
f"bundled extension {extension_id!r} has no runtime kind"
|
|
)
|
|
ids.append(extension_id)
|
|
runtime_kinds.add(runtime_kind)
|
|
if len(ids) != len(set(ids)):
|
|
raise SmokeFailure("extension search returned duplicate package ids")
|
|
missing_runtime_kinds = sorted(REQUIRED_BUNDLED_RUNTIME_KINDS - runtime_kinds)
|
|
if missing_runtime_kinds:
|
|
raise SmokeFailure(
|
|
"shipping binary is missing bundled runtime kinds: "
|
|
+ ", ".join(missing_runtime_kinds)
|
|
)
|
|
|
|
|
|
def _isolated_environment(root: Path) -> dict[str, str]:
|
|
environment = {
|
|
key: value for key in _PASSTHROUGH_ENV if (value := os.environ.get(key))
|
|
}
|
|
home = root / "home"
|
|
reborn_home = root / "reborn-home"
|
|
workspace = root / "workspace"
|
|
home.mkdir()
|
|
workspace.mkdir()
|
|
environment.update(
|
|
{
|
|
"HOME": str(home),
|
|
"USERPROFILE": str(home),
|
|
"IRONCLAW_REBORN_HOME": str(reborn_home),
|
|
"IRONCLAW_REBORN_WORKSPACE_ROOT": str(workspace),
|
|
"IRONCLAW_DISABLE_OS_KEYCHAIN": "1",
|
|
"TZ": "UTC",
|
|
"LANG": environment.get("LANG", "C.UTF-8"),
|
|
}
|
|
)
|
|
return environment
|
|
|
|
|
|
def smoke_release_binary(binary: Path, runner: Runner = _run_command) -> set[str]:
|
|
binary = binary.resolve()
|
|
if not binary.is_file():
|
|
raise SmokeFailure(f"shipping binary does not exist: {binary}")
|
|
|
|
evidence: set[str] = set()
|
|
with tempfile.TemporaryDirectory(prefix="ironclaw-release-smoke-") as temp:
|
|
root = Path(temp)
|
|
environment = _isolated_environment(root)
|
|
workspace = Path(environment["IRONCLAW_REBORN_WORKSPACE_ROOT"])
|
|
|
|
version = _checked_output(
|
|
binary, ("--version",), environment, workspace, runner
|
|
)
|
|
if "ironclaw" not in version.lower():
|
|
raise SmokeFailure("--version did not identify IronClaw")
|
|
evidence.add("version")
|
|
|
|
help_output = _checked_output(
|
|
binary, ("--help",), environment, workspace, runner
|
|
)
|
|
for command in ("serve", "run", "extension", "profile"):
|
|
if command not in help_output:
|
|
raise SmokeFailure(f"--help is missing the {command!r} command")
|
|
evidence.add("help")
|
|
|
|
profiles = _checked_output(
|
|
binary,
|
|
("profile", "list", "--json"),
|
|
environment,
|
|
workspace,
|
|
runner,
|
|
)
|
|
_validate_profiles(profiles)
|
|
evidence.add("profiles")
|
|
|
|
extensions = _checked_output(
|
|
binary,
|
|
("extension", "search", "--json"),
|
|
environment,
|
|
workspace,
|
|
runner,
|
|
)
|
|
_validate_bundled_extensions(extensions)
|
|
evidence.add("bundled_extensions")
|
|
evidence.add("runtime_assembly")
|
|
databases = list((root / "reborn-home").rglob("*.db"))
|
|
if len(databases) != 1 or databases[0].stat().st_size == 0:
|
|
raise SmokeFailure(
|
|
"runtime assembly did not create exactly one non-empty local libSQL database"
|
|
)
|
|
evidence.add("local_libsql_migrations")
|
|
|
|
migration_environment = dict(environment)
|
|
migration_environment["IRONCLAW_REBORN_PROFILE"] = "migration-dry-run"
|
|
migration = _checked_output(
|
|
binary,
|
|
("run", "--dry-run"),
|
|
migration_environment,
|
|
workspace,
|
|
runner,
|
|
)
|
|
if "profile: migration-dry-run" not in migration:
|
|
raise SmokeFailure(
|
|
"migration dry-run did not report the migration-dry-run profile"
|
|
)
|
|
evidence.add("migration_profile")
|
|
|
|
missing = sorted(REQUIRED_EVIDENCE - evidence)
|
|
if missing:
|
|
raise SmokeFailure(
|
|
"release smoke skipped required evidence: " + ", ".join(missing)
|
|
)
|
|
return evidence
|
|
|
|
|
|
def smoke_release_archive(
|
|
archive: Path,
|
|
binary_name: str,
|
|
runner: Runner = _run_command,
|
|
) -> set[str]:
|
|
archive = archive.resolve()
|
|
if not archive.is_file():
|
|
raise SmokeFailure(f"release archive does not exist: {archive}")
|
|
if Path(binary_name).name != binary_name or binary_name in {"", ".", ".."}:
|
|
raise SmokeFailure(f"invalid release binary name: {binary_name!r}")
|
|
|
|
try:
|
|
with tarfile.open(archive, mode="r:gz") as package:
|
|
matches = [
|
|
member
|
|
for member in package.getmembers()
|
|
if member.isfile() and Path(member.name).name == binary_name
|
|
]
|
|
if len(matches) != 1:
|
|
raise SmokeFailure(
|
|
f"{archive.name} must contain exactly one {binary_name}; "
|
|
f"found {len(matches)}"
|
|
)
|
|
source = package.extractfile(matches[0])
|
|
if source is None:
|
|
raise SmokeFailure(f"could not read {binary_name} from {archive.name}")
|
|
with tempfile.TemporaryDirectory(
|
|
prefix="ironclaw-release-archive-"
|
|
) as temp:
|
|
extracted = Path(temp) / binary_name
|
|
extracted.write_bytes(source.read())
|
|
extracted.chmod(0o755)
|
|
return smoke_release_binary(extracted, runner)
|
|
except (tarfile.TarError, OSError) as error:
|
|
raise SmokeFailure(
|
|
f"could not read release archive {archive}: {error}"
|
|
) from error
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
source = parser.add_mutually_exclusive_group(required=True)
|
|
source.add_argument("--binary", type=Path, help="exact native binary to package")
|
|
source.add_argument("--archive", type=Path, help="cargo-dist .tar.gz to extract")
|
|
parser.add_argument(
|
|
"--binary-name",
|
|
choices=("ironclaw", "ironclaw.exe"),
|
|
help="shipping binary basename inside --archive",
|
|
)
|
|
args = parser.parse_args()
|
|
if args.archive:
|
|
if not args.binary_name:
|
|
parser.error("--binary-name is required with --archive")
|
|
evidence = smoke_release_archive(args.archive, args.binary_name)
|
|
else:
|
|
if args.binary_name:
|
|
parser.error("--binary-name is only valid with --archive")
|
|
evidence = smoke_release_binary(args.binary)
|
|
print("release binary smoke passed: " + ", ".join(sorted(evidence)))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|