ops(observatory): install exact recorded-progress agent layers
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
# Recorded Observatory — attempt-bound progress and first real run
|
||||
|
||||
Date: 2026-09-03. Current implementation: `bee8552`.
|
||||
This is an observation/control-plane increment of stage 2, not onboard or
|
||||
full recorded-analysis acceptance. No Synology release, physical acquisition,
|
||||
actuation, model replacement or GPU benchmark is involved.
|
||||
|
||||
## Implemented and activated
|
||||
|
||||
- Worker counters are sampled independently of execution and sent through the
|
||||
authenticated Worker gateway. Claim owner, generation, sequence and monotonic
|
||||
counters are checked transactionally. Observation neither renews a lease nor
|
||||
changes job identity, terminal state, artifact integrity or publication.
|
||||
- One bounded snapshot per job is stored in a three-column SQLite table;
|
||||
snapshots are at most 2048 bytes. Existing job JSON and six previous tables
|
||||
are unchanged. The existing 128 MiB queue quota is not raised.
|
||||
- A source/profile/definition/attempt-bound read endpoint supplies the existing
|
||||
Observatory activity indicator and visible domain copy. No new per-LAB app,
|
||||
generic visual control, completion badge or completion button was introduced.
|
||||
- Phases: input delivery, input preparation, computation, result assembly and
|
||||
output delivery; publication uses the existing queue state. M49 computation
|
||||
counts complete ordered rows from the real TGS timing output. LAB V1 exposes
|
||||
completed package operations, not invented per-frame inference counts.
|
||||
- The two existing control agents receive an eight-file offline child layer.
|
||||
Compute package declarations, compiled TGS runner, models, profile definition,
|
||||
GPU allocation and resource limits are preserved. Previous agents remain
|
||||
stopped with `restart=no`; no old and new owner are intentionally run together.
|
||||
|
||||
## Validation before the real run
|
||||
|
||||
- Frontend: architecture tests, typecheck, **726 unit tests**, production build.
|
||||
Tests and build ran sequentially; memory pressure remained level 1 and swap
|
||||
was approximately 2594 MiB. The existing large-chunk Vite warning remains.
|
||||
- Backend/control tests: **128 focused tests** including payload admission,
|
||||
owner/generation fencing, duplicate/regressive snapshots, sender connection
|
||||
failure, queue persistence, HTTP authentication, old/new transport compatibility,
|
||||
actual subprocess invocation and timeout child cleanup. A separate **40 tests**
|
||||
cover portable runtime, M49 entrypoint/queue/LAB V1 wiring and minimal imports.
|
||||
Ruff and focused mypy pass. The existing TestClient deprecation warning remains.
|
||||
- Canonical8000 was replaced only while the queue was idle. Previous UI output
|
||||
and a SQLite backup were retained. All rows in the six pre-existing tables
|
||||
matched their pre-update hashes; the new progress table was empty.
|
||||
- Browser: actual source/profile selected, normal and expanded window checked,
|
||||
dropdown Escape checked, no Calculate action while that profile runs. Reload
|
||||
and reopening/selecting M49 recover the same job and generation, not a new job.
|
||||
The workspace maximize action itself is not an Escape-to-restore contract.
|
||||
|
||||
## Exact control-agent installation
|
||||
|
||||
Plan SHA-256:
|
||||
`108897767de320562b4f75297e1c49d0ae5c44770f9c820d92d127f0fdad9216`.
|
||||
Installer SHA-256:
|
||||
`13edd1dd4e2579de788721de5ff397f57a5e1af0e4c59593c4af5a29f1fc2a77`.
|
||||
Started `2026-09-03T08:06:19.107635Z`, finished
|
||||
`2026-09-03T08:06:48.126401Z`. Receipt retains UTC and monotonic timestamps.
|
||||
|
||||
| Control agent | New immutable image | Retained predecessor |
|
||||
|---|---|---|
|
||||
| `ndc-observatory-m49-worker-agent` | `927c3c4f5b00ae6c084d1f5a8bc77f7b262cfc5e83cf4be1c48f615c12c06e80` | `ndc-observatory-m49-worker-agent-pre-progress-7a3e07929c82` |
|
||||
| `ndc-observatory-installed-lab-worker-agent` | `052af3ccd10e11b162c163943f5427af2dc95b09b93481dd85e954e494ba1107` | `ndc-observatory-installed-lab-worker-agent-pre-progress-a8e8353d4eb9` |
|
||||
|
||||
Durable release:
|
||||
`D:\NDC_MISSIONCORE\runtime\services\observatory-progress-20260903-DbxaPA\release`.
|
||||
Its exact `*-declaration.json` files supersede the previous claim-v3 control-agent
|
||||
declarations. Package manifests remain the compute source of truth. The installer
|
||||
rechecks the exact container and complete Config/HostConfig hash immediately
|
||||
before cutover. The loaded Python file hashes and running state are verified
|
||||
after replacement. Only temporary offline layer/helper containers are removed.
|
||||
|
||||
Rollback is explicit, idle-only and per agent: prove no open recorded/live work,
|
||||
retain current declarations/evidence, stop the exact replacement, then restore
|
||||
the corresponding predecessor's name and previous restart policy. Do not start
|
||||
predecessors alongside replacements, use the old package launcher to overwrite
|
||||
the control layer, or restore an old SQLite backup over newer work. No reboot
|
||||
was performed; installed restart declarations, not reboot recovery, were checked.
|
||||
|
||||
## Real run — status at 08:20 UTC
|
||||
|
||||
One ordinary UI Calculate action submitted:
|
||||
|
||||
- Source: RAVNOVES004TREE / `20260828T130511Z_viewer_live`, source duration 13:29.
|
||||
- Setup: `m49-tgs-portable-v2`, definition
|
||||
`f56d6321bd794ccdfb7d2e3b05d044b11f616ffb81ee29517386cc253046d4eb`.
|
||||
- Job: `observatory-run-b230216709dc4c59bc56c98c7e329bf1`, generation 1.
|
||||
- Input inventory: 6837 members. Delivery completed after approximately 298 s;
|
||||
input preparation is still running. There is no result/cache/view acceptance yet.
|
||||
- Queue: 10 retained failures, one retained historical success/not-required,
|
||||
one running job, one current progress row. No second model/profile was started.
|
||||
|
||||
An early diagnosis based on a stationary 556912640-byte temporary camera archive
|
||||
did **not** prove a network stall. That equals the sealed archive's complete
|
||||
length; the job subsequently advanced normally. Do not report it as a proven
|
||||
transport outage or benchmark.
|
||||
|
||||
## Remaining boundaries — not hidden by the progress feature
|
||||
|
||||
1. This does not introduce `recorded-analysis` into the retained live runtime.
|
||||
No live freshness, latest-wins, ownership or recovery rule is weakened.
|
||||
2. Existing M49 still delivers the whole admitted input before TGS. The current
|
||||
LiDAR builder performs full capture passes **before** its replay-pack cache
|
||||
lookup (`compute/lidar_replay.py`, `build_lidar_replay_pack_v2`). Eliminating
|
||||
this barrier needs its own versioned input/runtime change and equivalence proof.
|
||||
3. Camera archive download/verification and initial LiDAR decode have no internal
|
||||
byte/message counter yet. File/frame counts may stay unchanged within those
|
||||
operations. Unknown totals remain unknown; no made-up percentage or ETA.
|
||||
4. Phase snapshots are not a complete performance trace. Warmup/stage timings,
|
||||
throughput, all required-frame accounting and onboard estimates need separate
|
||||
evidence. These UI counts are not FPS or real-time qualification.
|
||||
5. Stage 2B remains open until this run publishes an exact, reviewable result and
|
||||
cached reopening is verified. LAB V1 and the other recording follow sequentially.
|
||||
|
||||
Private evidence, not Git: `.runtime/observatory-progress-DbxaPA/` contains the
|
||||
SQLite backup and row hashes, payload/plan/receipt/declarations, build/test logs,
|
||||
submitted job and bounded read-only progress observations. Model assets and
|
||||
recorded camera/LiDAR payloads are not copied into this report.
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Hash-gated observation-only layer for the two existing recorded agents.
|
||||
|
||||
Pack locally, plan read-only on Worker, then apply the exact plan. No downloads,
|
||||
model changes, resource changes or live jobs. Stopped predecessors are retained.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
import tarfile
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from migrate_claim_transport_v3 import (
|
||||
READINESS,
|
||||
Engine,
|
||||
canonical,
|
||||
require_idle,
|
||||
save,
|
||||
sha,
|
||||
)
|
||||
|
||||
SOURCE_ROOT = "/opt/nodedc/installed-lab/src/k1link/observatory"
|
||||
BEFORE = {
|
||||
"worker_agent.py": "cf81131746ff4c4a3af6b186a852238f2cdde7e66c70aaa8ac1e9a3dc66bde7c",
|
||||
"worker_http_transport.py": "81bdc27f374cd91358f0eb58603e3e1f53e1f3017bf158d269e75affa4567293",
|
||||
"m49_portable_executor.py": "19a1b5bdb9c8dfc6a91af42ebc648d7f525cce95637f8a368c7679148b9666f0",
|
||||
"m49_portable_source.py": "ab178740e5e577d00863cca6d1d1963acad766b3aa6d7a19f9de870953131fca",
|
||||
"portable_worker_runtime.py": (
|
||||
"1c62636a6da24cae0b9e632ccd34108043dab7d2b0aefba7868376e3ca3feaf7"
|
||||
),
|
||||
"installed_lab_package_runner.py": (
|
||||
"43195cc53e524ff57307ead691441cdd7c2ba09949bbdae6fe77920a63263eac"
|
||||
),
|
||||
"recorded_progress.py": None,
|
||||
"m49_timing_progress.py": None,
|
||||
}
|
||||
TARGETS = {
|
||||
"ndc-observatory-m49-worker-agent": (
|
||||
"7aa6ccd2ddba4ebb0c07d793e5331539a2e13b07961c269439d5b79762328194"
|
||||
),
|
||||
"ndc-observatory-installed-lab-worker-agent": (
|
||||
"1b1e335916c1c3d77888b7537331e9d775d078957c4ac0f82e91852301725390"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def probe() -> str:
|
||||
return f"""import hashlib, json, pathlib
|
||||
from k1link.observatory import worker_agent
|
||||
root=pathlib.Path(worker_agent.__file__).parent
|
||||
assert str(root) == {SOURCE_ROOT!r}
|
||||
print(json.dumps({{name: hashlib.sha256((root/name).read_bytes()).hexdigest()
|
||||
if (root/name).is_file() else None for name in {list(BEFORE)!r}}}))
|
||||
"""
|
||||
|
||||
|
||||
def pack(repository: Path, output: Path) -> None:
|
||||
output.mkdir(parents=False, exist_ok=False)
|
||||
files = {}
|
||||
for name in BEFORE:
|
||||
payload = (repository / "src/k1link/observatory" / name).read_bytes()
|
||||
compile(payload, name, "exec")
|
||||
(output / name).write_bytes(payload)
|
||||
files[name] = sha(payload)
|
||||
save(output / "payload.json", {"schema_version": 1, "files": files})
|
||||
|
||||
|
||||
def payload_files(root: Path) -> dict[str, bytes]:
|
||||
manifest = json.loads((root / "payload.json").read_bytes())
|
||||
if set(manifest) != {"schema_version", "files"} or manifest["schema_version"] != 1:
|
||||
raise ValueError("invalid progress payload manifest")
|
||||
if set(manifest["files"]) != set(BEFORE):
|
||||
raise ValueError("progress file set changed")
|
||||
files = {}
|
||||
for name, digest in manifest["files"].items():
|
||||
path = root / name
|
||||
if path.is_symlink() or not path.is_file() or path.stat().st_size > 256_000:
|
||||
raise ValueError("unsafe progress payload")
|
||||
value = path.read_bytes()
|
||||
if sha(value) != digest:
|
||||
raise ValueError("progress payload hash mismatch")
|
||||
compile(value, name, "exec")
|
||||
files[name] = value
|
||||
return files
|
||||
|
||||
|
||||
def validate_target(name: str, row: dict) -> None:
|
||||
host, config = row["HostConfig"], row["Config"]
|
||||
if row["Name"] != "/" + name or row["Image"] != "sha256:" + TARGETS[name]:
|
||||
raise ValueError("agent identity changed")
|
||||
if not row["State"]["Running"] or not host["ReadonlyRootfs"]:
|
||||
raise ValueError("agent is not in its expected running/read-only state")
|
||||
if host["NetworkMode"] != "bridge" or host.get("DeviceRequests") or host.get("Privileged"):
|
||||
raise ValueError("agent GPU/network/privilege boundary changed")
|
||||
if config["Labels"].get("com.nodedc.authority") != "observation-only":
|
||||
raise ValueError("agent authority changed")
|
||||
for entry in config["Env"]:
|
||||
key = entry.split("=", 1)[0].upper()
|
||||
if any(word in key for word in ("TOKEN", "PASSWORD", "SECRET")) and not key.endswith(
|
||||
"_FILE"
|
||||
):
|
||||
raise ValueError("inline secret in declaration")
|
||||
|
||||
|
||||
def create_hash(row: dict) -> str:
|
||||
return sha(canonical({"Config": row["Config"], "HostConfig": row["HostConfig"]}))
|
||||
|
||||
|
||||
def validate_fence(target: dict, row: dict) -> None:
|
||||
validate_target(target["name"], row)
|
||||
if row["Id"] != target["id"] or create_hash(row) != target["create_sha256"]:
|
||||
raise ValueError("agent or declaration changed since plan")
|
||||
|
||||
|
||||
def plan(engine: Engine, root: Path) -> dict:
|
||||
files = payload_files(root)
|
||||
targets = []
|
||||
for name in TARGETS:
|
||||
row = engine.inspect(name)
|
||||
validate_target(name, row)
|
||||
if engine.execute_json(name, probe()) != BEFORE:
|
||||
raise ValueError("imported agent code is not the reviewed baseline")
|
||||
require_idle(engine.execute_json(name, READINESS))
|
||||
targets.append(
|
||||
{
|
||||
"name": name,
|
||||
"id": row["Id"],
|
||||
"parent": TARGETS[name],
|
||||
"create_sha256": create_hash(row),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"schema_version": "missioncore.recorded-progress-install-plan/v1",
|
||||
"targets": targets,
|
||||
"files": {name: sha(value) for name, value in files.items()},
|
||||
"installer_sha256": sha(Path(__file__).read_bytes()),
|
||||
"engine_helper_sha256": sha(
|
||||
Path(__file__).with_name("migrate_claim_transport_v3.py").read_bytes()
|
||||
),
|
||||
"compute_packages_changed": False,
|
||||
}
|
||||
|
||||
|
||||
def build(engine: Engine, target: dict, files: dict[str, bytes], plan_sha: str) -> str:
|
||||
created = engine.request(
|
||||
"POST",
|
||||
"/containers/create",
|
||||
{
|
||||
"Image": "sha256:" + target["parent"],
|
||||
"Entrypoint": ["/bin/true"],
|
||||
"Cmd": [],
|
||||
"HostConfig": {
|
||||
"NetworkMode": "none",
|
||||
"CapDrop": ["ALL"],
|
||||
"PidsLimit": 32,
|
||||
"SecurityOpt": ["no-new-privileges"],
|
||||
},
|
||||
},
|
||||
)["Id"]
|
||||
try:
|
||||
engine.request("POST", f"/containers/{created}/start")
|
||||
if engine.request("POST", f"/containers/{created}/wait")["StatusCode"] != 0:
|
||||
raise ValueError("offline layer initialization failed")
|
||||
buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=buffer, mode="w") as archive:
|
||||
for name, payload in files.items():
|
||||
member = tarfile.TarInfo(name)
|
||||
member.size, member.mode, member.mtime = len(payload), 0o644, int(time.time())
|
||||
archive.addfile(member, io.BytesIO(payload))
|
||||
engine.request(
|
||||
"PUT",
|
||||
f"/containers/{created}/archive?" + urlencode({"path": SOURCE_ROOT}),
|
||||
buffer.getvalue(),
|
||||
)
|
||||
changes = engine.request("GET", f"/containers/{created}/changes")
|
||||
allowed = {str(Path(SOURCE_ROOT) / name) for name in files}
|
||||
parents = {str(p) for p in Path(SOURCE_ROOT).parents} | {SOURCE_ROOT}
|
||||
if not changes or any(
|
||||
item["Path"] not in allowed | parents or item["Kind"] not in (0, 1) for item in changes
|
||||
):
|
||||
raise ValueError("unrelated filesystem changes in progress layer")
|
||||
if not allowed.issubset({item["Path"] for item in changes}):
|
||||
raise ValueError("a progress file was omitted from the image layer")
|
||||
parent = engine.request("GET", f"/images/sha256:{target['parent']}/json")
|
||||
config = copy.deepcopy(parent["Config"])
|
||||
config.setdefault("Labels", {}).update(
|
||||
{
|
||||
"com.nodedc.recorded-progress.plan-sha256": plan_sha,
|
||||
"com.nodedc.recorded-progress.parent-sha256": target["parent"],
|
||||
}
|
||||
)
|
||||
image = engine.request(
|
||||
"POST",
|
||||
"/commit?"
|
||||
+ urlencode(
|
||||
{
|
||||
"container": created,
|
||||
"repo": target["name"] + "-progress",
|
||||
"tag": "v1",
|
||||
}
|
||||
),
|
||||
config,
|
||||
)["Id"]
|
||||
result = engine.request("GET", f"/images/{image}/json")
|
||||
if result["RootFS"]["Layers"][:-1] != parent["RootFS"]["Layers"]:
|
||||
raise ValueError("parent image layers changed")
|
||||
return image
|
||||
finally:
|
||||
engine.request("DELETE", f"/containers/{created}")
|
||||
|
||||
|
||||
def apply(engine: Engine, root: Path, expected: str, evidence: Path) -> dict:
|
||||
started_at = datetime.now(UTC).isoformat()
|
||||
started_mono = time.monotonic_ns()
|
||||
proposal = plan(engine, root)
|
||||
if sha(canonical(proposal)) != expected:
|
||||
raise ValueError("progress install plan changed")
|
||||
evidence.mkdir(parents=False, exist_ok=False)
|
||||
save(evidence / "plan.json", proposal)
|
||||
files = payload_files(root)
|
||||
results = []
|
||||
for target in proposal["targets"]:
|
||||
name = target["name"]
|
||||
before = engine.inspect(name)
|
||||
validate_fence(target, before)
|
||||
image = build(engine, target, files, expected)
|
||||
require_idle(engine.execute_json(name, READINESS))
|
||||
validate_fence(target, engine.inspect(name))
|
||||
body = copy.deepcopy(before["Config"])
|
||||
body["Image"] = image
|
||||
body["Labels"]["com.nodedc.recorded-progress.plan-sha256"] = expected
|
||||
body["HostConfig"] = copy.deepcopy(before["HostConfig"])
|
||||
backup = name + "-pre-progress-" + before["Id"][:12]
|
||||
declaration = {
|
||||
"name": name,
|
||||
"create_body": body,
|
||||
"rollback_name": backup,
|
||||
"rollback_container_id": before["Id"],
|
||||
"parent": target["parent"],
|
||||
}
|
||||
save(evidence / (name + "-declaration.json"), declaration)
|
||||
engine.request("POST", f"/containers/{before['Id']}/stop?t=15")
|
||||
engine.request(
|
||||
"POST", f"/containers/{before['Id']}/update", {"RestartPolicy": {"Name": "no"}}
|
||||
)
|
||||
engine.request("POST", f"/containers/{before['Id']}/rename?" + urlencode({"name": backup}))
|
||||
created = engine.request("POST", "/containers/create?" + urlencode({"name": name}), body)[
|
||||
"Id"
|
||||
]
|
||||
engine.request("POST", f"/containers/{created}/start")
|
||||
# A started replacement may already own operator work; never auto-delete it.
|
||||
time.sleep(3)
|
||||
after = engine.inspect(name)
|
||||
if not after["State"]["Running"] or after["RestartCount"] != 0:
|
||||
raise ValueError("replacement needs reconciliation; predecessor retained")
|
||||
if engine.execute_json(name, probe()) != proposal["files"]:
|
||||
raise ValueError("replacement imported another progress payload")
|
||||
results.append(
|
||||
{
|
||||
"name": name,
|
||||
"id": created,
|
||||
"image": image,
|
||||
"rollback": backup,
|
||||
"readiness": engine.execute_json(name, READINESS),
|
||||
}
|
||||
)
|
||||
save(evidence / (name + "-acceptance.json"), results[-1])
|
||||
receipt = {
|
||||
"plan_sha256": expected,
|
||||
"agents": results,
|
||||
"compute_packages_changed": False,
|
||||
"started_at_utc": started_at,
|
||||
"finished_at_utc": datetime.now(UTC).isoformat(),
|
||||
"started_monotonic_ns": started_mono,
|
||||
"finished_monotonic_ns": time.monotonic_ns(),
|
||||
}
|
||||
save(evidence / "receipt.json", receipt)
|
||||
return receipt
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repository", type=Path)
|
||||
parser.add_argument("--pack", type=Path)
|
||||
parser.add_argument("--payload", type=Path)
|
||||
parser.add_argument("--apply-plan-sha256")
|
||||
parser.add_argument("--evidence", type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.pack:
|
||||
pack(args.repository, args.pack)
|
||||
return
|
||||
engine = Engine()
|
||||
if args.apply_plan_sha256:
|
||||
result = apply(engine, args.payload, args.apply_plan_sha256, args.evidence)
|
||||
else:
|
||||
proposal = plan(engine, args.payload)
|
||||
result = {"plan": proposal, "plan_sha256": sha(canonical(proposal))}
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Control-agent layer admission; no Docker or model execution."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPTS = Path(__file__).parents[1] / "experiments/perception/worker/observatory_portable"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def installer(monkeypatch):
|
||||
monkeypatch.syspath_prepend(str(SCRIPTS))
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"recorded_progress_installer", SCRIPTS / "install_recorded_progress.py"
|
||||
)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
monkeypatch.setitem(sys.modules, spec.name, module)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_pack_is_exact_bounded_code_only_and_hash_gated(installer, tmp_path):
|
||||
output = tmp_path / "payload"
|
||||
installer.pack(Path(__file__).parents[1], output)
|
||||
assert set(installer.payload_files(output)) == set(installer.BEFORE)
|
||||
with pytest.raises(FileExistsError):
|
||||
installer.pack(Path(__file__).parents[1], output)
|
||||
file = output / "worker_agent.py"
|
||||
file.write_bytes(file.read_bytes() + b"\n# modified\n")
|
||||
with pytest.raises(ValueError, match="hash mismatch"):
|
||||
installer.payload_files(output)
|
||||
|
||||
|
||||
def test_manifest_cannot_add_unreviewed_paths(installer, tmp_path):
|
||||
output = tmp_path / "payload"
|
||||
installer.pack(Path(__file__).parents[1], output)
|
||||
manifest = json.loads((output / "payload.json").read_text())
|
||||
manifest["files"]["../../outside.py"] = "0" * 64
|
||||
(output / "payload.json").write_text(json.dumps(manifest))
|
||||
with pytest.raises(ValueError, match="file set changed"):
|
||||
installer.payload_files(output)
|
||||
|
||||
|
||||
def test_cutover_rechecks_exact_container_and_declaration(installer):
|
||||
name = next(iter(installer.TARGETS))
|
||||
row = {
|
||||
"Id": "a" * 64,
|
||||
"Name": "/" + name,
|
||||
"Image": "sha256:" + installer.TARGETS[name],
|
||||
"State": {"Running": True},
|
||||
"HostConfig": {"ReadonlyRootfs": True, "NetworkMode": "bridge"},
|
||||
"Config": {"Labels": {"com.nodedc.authority": "observation-only"}, "Env": []},
|
||||
}
|
||||
target = {"name": name, "id": row["Id"], "create_sha256": installer.create_hash(row)}
|
||||
installer.validate_fence(target, row)
|
||||
row["HostConfig"]["Memory"] = 1024
|
||||
with pytest.raises(ValueError, match="changed since plan"):
|
||||
installer.validate_fence(target, row)
|
||||
Reference in New Issue
Block a user