fix(observatory): version non-binding idle claims and preserve grant history

This commit is contained in:
DCCONSTRUCTIONS
2026-09-02 23:40:11 +03:00
parent 049ed3eed4
commit cabd3081c7
8 changed files with 828 additions and 15 deletions
@@ -0,0 +1,396 @@
"""Bounded Worker006 control-agent migration; never alters a compute package.
Run inside an existing agent image with Docker's socket and a new /migration
evidence directory mounted. Plan is read-only. Apply requires its exact hash.
It commits a one-file, offline child layer, preserves the executor declarations,
and retains stopped predecessors (restart=no) for explicit rollback. The saved
create-body is the durable transport declaration; old package launchers must
not replace it with the v2 parent image. No model/executor is started here.
"""
from __future__ import annotations
import argparse
import copy
import hashlib
import http.client
import io
import json
import socket
import tarfile
import time
from datetime import UTC, datetime
from pathlib import Path
from urllib.parse import quote, urlencode
INSTALLED_PARENT_SHA = "5ad7d95baac63af13812cb693d492add4e806a333aba8e60edb2ea1aba754373"
M49_PARENT_SHA = "d00274a2a76e254d79b28f887b421cbc267a0483fca0e83e8c2e1c416ac8593c"
TARGETS = {
"ndc-observatory-installed-lab-worker-agent": INSTALLED_PARENT_SHA,
"ndc-observatory-m49-worker-agent": M49_PARENT_SHA,
}
SOURCE = "/opt/nodedc/mission-core/src/k1link/observatory/worker_http_transport.py"
BEFORE_SHA = "fb67ae174c8da66f7d04022310be663b2735fff72966883aaa5b50fdbc3e89c7"
OLD = b"missioncore.observatory-worker-claim-request/v2"
NEW = b"missioncore.observatory-worker-claim-request/v3"
PARENT_LABEL = "com.nodedc.claim-transport-parent.sha256"
def canonical(value: object) -> bytes:
return json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
def sha(payload: bytes) -> str:
return hashlib.sha256(payload).hexdigest()
def patch_source(payload: bytes) -> bytes:
if sha(payload) != BEFORE_SHA or payload.count(OLD) != 1:
raise ValueError("installed transport source is not the reviewed baseline")
result = payload.replace(OLD, NEW)
compile(result, SOURCE, "exec")
return result
class UnixConnection(http.client.HTTPConnection):
def connect(self) -> None:
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.sock.settimeout(45)
self.sock.connect("/var/run/docker.sock")
class Engine:
def request(self, method: str, path: str, body=None, *, raw=False):
connection = UnixConnection("localhost", timeout=45)
payload = body if isinstance(body, bytes) else (None if body is None else canonical(body))
content_type = "application/x-tar" if isinstance(body, bytes) else "application/json"
try:
connection.request(method, "/v1.45" + path, payload, {"Content-Type": content_type})
response = connection.getresponse()
data = response.read(2 * 1024 * 1024 + 1)
if not 200 <= response.status < 300 or len(data) > 2 * 1024 * 1024:
raise RuntimeError(f"Docker request failed: {method} {path}: {response.status}")
return data if raw else (json.loads(data) if data else None)
finally:
connection.close()
def inspect(self, name: str):
return self.request("GET", f"/containers/{quote(name, safe='')}/json")
def execute_json(self, name: str, source: str):
execution = self.request(
"POST",
f"/containers/{name}/exec",
{
"AttachStdout": True,
"AttachStderr": True,
"Cmd": ["python3", "-c", source],
"Env": ["PYTHONDONTWRITEBYTECODE=1"],
},
)["Id"]
data = self.request("POST", f"/exec/{execution}/start", {}, raw=True)
status = self.request("GET", f"/exec/{execution}/json")
if status["Running"] or status["ExitCode"] != 0:
raise RuntimeError("control-only readiness/probe failed; no credential output retained")
streams = bytearray()
while data:
if len(data) < 8:
raise RuntimeError("truncated Docker exec stream")
size = int.from_bytes(data[4:8], "big")
if data[0] != 1 or size > len(data) - 8:
raise RuntimeError("unexpected Docker exec output")
streams.extend(data[8 : 8 + size])
data = data[8 + size :]
return json.loads(streams)
READINESS = """import json, os, pathlib, httpx
headers = {"Authorization": "Bearer " + pathlib.Path(os.environ[
"MISSIONCORE_OBSERVATORY_WORKER_TOKEN_FILE"]).read_text().strip(),
"X-Mission-Core-Contour-Id": "worker-006"}
with httpx.Client(base_url=os.environ["MISSIONCORE_OBSERVATORY_WORKER_BASE_URL"],
headers=headers, timeout=10, trust_env=False) as client:
response = client.get("/api/v1/worker/observatory/recorded-jobs/claim-readiness")
assert response.status_code == 200
print(json.dumps(response.json()))
"""
PROBE = """import json, os, pathlib, uuid
from k1link.observatory.worker_http_transport import ObservatoryWorkerHttpGateway
with ObservatoryWorkerHttpGateway(
base_url=os.environ["MISSIONCORE_OBSERVATORY_WORKER_BASE_URL"],
bearer_token=pathlib.Path(os.environ["MISSIONCORE_OBSERVATORY_WORKER_TOKEN_FILE"]).read_text().strip(),
work_root=pathlib.Path(os.environ["MISSIONCORE_OBSERVATORY_WORKER_WORK_ROOT"]),
) as client:
for index in range(8):
assert client.claim_next(claimant_id="worker-006",
claim_request_id="v3-control-probe-" + uuid.uuid4().hex,
supported_executor_identities=()) is None
print(json.dumps({"idle_claims": 8, "capabilities": [], "model_jobs": 0}))
"""
def require_idle(readiness: dict) -> None:
if readiness.get("schema_version") != "missioncore.observatory-claim-readiness/v1":
raise ValueError("backend v3 readiness is unavailable")
if readiness["open_live_lease_count"] or any(
count
for state, count in readiness["recorded_jobs_by_state"].items()
if state not in {"failed", "succeeded", "cancelled"}
):
raise ValueError("queue is not idle; migration must not interrupt work")
if not readiness["protocols"][1]["grant_capacity_available"]:
raise ValueError("v3 grant capacity is unavailable")
def validate_target(name: str, inspection: dict) -> None:
config, host = inspection["Config"], inspection["HostConfig"]
if inspection["Name"] != "/" + name or inspection["Image"] != "sha256:" + TARGETS[name]:
raise ValueError("target container or parent image changed")
if not inspection["State"]["Running"] or not host["ReadonlyRootfs"]:
raise ValueError("expected durable read-only agent is not running")
if host["NetworkMode"] != "bridge" or host.get("DeviceRequests") or host.get("Privileged"):
raise ValueError("unexpected network/GPU/privileged agent configuration")
labels = config["Labels"]
if (
labels.get("com.nodedc.product") != "mission-core"
or labels.get("com.nodedc.authority") != "observation-only"
):
raise ValueError("agent ownership or authority changed")
for entry in config["Env"]:
key = entry.split("=", 1)[0].upper()
sensitive = any(word in key for word in ("TOKEN", "PASSWORD", "SECRET"))
if sensitive and not key.endswith("_FILE"):
raise ValueError("inline credential is not allowed in a transport declaration")
def plan(engine: Engine) -> dict:
targets = []
for name, parent in TARGETS.items():
inspection = engine.inspect(name)
validate_target(name, inspection)
readiness = engine.execute_json(name, READINESS)
require_idle(readiness)
targets.append(
{
"name": name,
"container_id": inspection["Id"],
"parent_image_sha256": parent,
"create_body_sha256": sha(
canonical(
{
"Config": inspection["Config"],
"HostConfig": inspection["HostConfig"],
}
)
),
}
)
return {
"schema_version": "missioncore.claim-transport-migration-plan/v1",
"targets": targets,
"before_source_sha256": BEFORE_SHA,
"script_sha256": sha(Path(__file__).read_bytes()),
"model_jobs": 0,
}
def save(path: Path, value: object) -> None:
# New immutable evidence only. An existing plan/release is never overwritten.
with path.open("xb") as output:
output.write(canonical(value) + b"\n")
def replacement_body(inspection: dict, image: dict) -> dict:
body = copy.deepcopy(inspection["Config"])
body["Image"] = image["image"]
body["Labels"].update(
{
PARENT_LABEL: image["parent_image_sha256"],
"com.nodedc.claim-protocol": "3",
"com.nodedc.claim-transport-source.sha256": image["source_sha256"],
}
)
body["HostConfig"] = copy.deepcopy(inspection["HostConfig"])
return body
def build_transport(engine: Engine, name: str, parent: str, script_sha: str) -> dict:
temporary = engine.request(
"POST",
"/containers/create?"
+ urlencode(
{
"name": name + "-claim-v3-layer",
}
),
{"Image": "sha256:" + parent, "HostConfig": {"NetworkMode": "none"}},
)["Id"]
try:
archive = engine.request(
"GET", f"/containers/{temporary}/archive?" + urlencode({"path": SOURCE}), raw=True
)
with tarfile.open(fileobj=io.BytesIO(archive)) as source_tar:
members = source_tar.getmembers()
if len(members) != 1 or not members[0].isfile() or members[0].size > 512_000:
raise ValueError("unexpected transport source archive")
member = members[0]
original = source_tar.extractfile(member).read()
replacement = patch_source(original)
member.name = Path(SOURCE).name
member.size = len(replacement)
member.pax_headers = {}
data = io.BytesIO()
with tarfile.open(fileobj=data, mode="w") as output:
output.addfile(member, io.BytesIO(replacement))
engine.request(
"PUT",
f"/containers/{temporary}/archive?"
+ urlencode(
{
"path": str(Path(SOURCE).parent),
}
),
data.getvalue(),
)
changes = engine.request("GET", f"/containers/{temporary}/changes")
allowed = {str(path) for path in Path(SOURCE).parents} | {SOURCE}
if not changes or any(item["Kind"] != 0 or item["Path"] not in allowed for item in changes):
raise ValueError("transport layer changed files outside the reviewed source")
original_image = engine.request("GET", f"/images/sha256:{parent}/json")
config = copy.deepcopy(original_image["Config"])
config.setdefault("Labels", {}).update(
{
PARENT_LABEL: parent,
"com.nodedc.claim-protocol": "3",
"com.nodedc.claim-transport-source.sha256": sha(replacement),
"com.nodedc.claim-migration-script.sha256": script_sha,
}
)
image = engine.request(
"POST",
"/commit?"
+ urlencode(
{
"container": temporary,
"repo": name + "-claim-transport",
"tag": "v3",
}
),
config,
)["Id"]
actual = engine.request("GET", f"/images/{image}/json")
if actual["RootFS"]["Layers"][:-1] != original_image["RootFS"]["Layers"]:
raise ValueError("transport image did not retain the exact parent layers")
return {
"image": image,
"parent_image_sha256": parent,
"source_sha256": sha(replacement),
"filesystem_changes": changes,
}
finally:
engine.request("DELETE", f"/containers/{temporary}")
def apply(engine: Engine, expected_plan_sha: str, evidence: Path) -> dict:
started = time.monotonic_ns()
proposal = plan(engine)
if sha(canonical(proposal)) != expected_plan_sha:
raise ValueError("migration plan changed")
evidence.mkdir(parents=False, exist_ok=False)
save(evidence / "plan.json", proposal)
results = []
for target in proposal["targets"]:
name = target["name"]
inspection = engine.inspect(name)
validate_target(name, inspection)
create_sha = sha(
canonical(
{
"Config": inspection["Config"],
"HostConfig": inspection["HostConfig"],
}
)
)
if inspection["Id"] != target["container_id"] or create_sha != target["create_body_sha256"]:
raise ValueError("agent changed after plan")
require_idle(engine.execute_json(name, READINESS))
image = build_transport(
engine, name, target["parent_image_sha256"], proposal["script_sha256"]
)
body = replacement_body(inspection, image)
# Reusing the old hostname is intentional; paths, resources, argv and env stay exact.
backup = name + "-v2-rollback-" + inspection["Id"][:12]
declaration = {
"name": name,
"create_body": body,
"rollback_name": backup,
"rollback_container_id": inspection["Id"],
"rollback_restart_policy": inspection["HostConfig"]["RestartPolicy"],
"transport": image,
}
save(evidence / (name + "-declaration.json"), declaration)
require_idle(engine.execute_json(name, READINESS))
engine.request("POST", f"/containers/{inspection['Id']}/stop?t=15")
engine.request(
"POST", f"/containers/{inspection['Id']}/update", {"RestartPolicy": {"Name": "no"}}
)
engine.request(
"POST", f"/containers/{inspection['Id']}/rename?" + urlencode({"name": backup})
)
created = engine.request("POST", "/containers/create?" + urlencode({"name": name}), body)[
"Id"
]
engine.request("POST", f"/containers/{created}/start")
# Never automatically delete/roll back a started agent: new operator work may arrive.
# On failure the exact predecessor and create declaration remain for reconciliation.
time.sleep(3)
after = engine.inspect(name)
if not after["State"]["Running"] or after["RestartCount"] != 0:
raise RuntimeError("replacement agent needs reconciliation; predecessor retained")
before_probe = engine.execute_json(name, READINESS)
require_idle(before_probe)
probe = engine.execute_json(name, PROBE)
after_probe = engine.execute_json(name, READINESS)
if before_probe != after_probe:
raise RuntimeError("queue state changed during non-binding control probe")
result = {
"name": name,
"container_id": created,
"transport": image,
"probe": probe,
"readiness": after_probe,
"rollback_name": backup,
}
save(evidence / (name + "-acceptance.json"), result)
results.append(result)
receipt = {
"schema_version": "missioncore.claim-transport-migration/v1",
"utc": datetime.now(UTC).isoformat(),
"monotonic_start_ns": started,
"monotonic_end_ns": time.monotonic_ns(),
"plan_sha256": expected_plan_sha,
"agents": results,
"compute_packages_changed": False,
"model_jobs": 0,
}
save(evidence / "receipt.json", receipt)
return receipt
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--apply-plan-sha256")
parser.add_argument("--evidence", type=Path, default=Path("/migration/release"))
arguments = parser.parse_args()
engine = Engine()
if arguments.apply_plan_sha256:
result = apply(engine, arguments.apply_plan_sha256, arguments.evidence)
else:
result = plan(engine)
result = {"plan": result, "plan_sha256": sha(canonical(result))}
print(json.dumps(result, sort_keys=True))
if __name__ == "__main__":
main()