Batch remote drift checks into agent file manifests
This commit is contained in:
+155
-4
@@ -32,9 +32,13 @@ import urllib.parse
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
VERSION = "1.4.7"
|
VERSION = "1.4.8"
|
||||||
LABEL = "io.electric-cherry"
|
LABEL = "io.electric-cherry"
|
||||||
MAX_FILE = 32 * 1024 * 1024
|
MAX_FILE = 32 * 1024 * 1024
|
||||||
|
MANIFEST_MAX_ENTRIES = 10000
|
||||||
|
MANIFEST_MAX_BYTES = 16 * 1024 * 1024
|
||||||
|
MANIFEST_MAX_SECONDS = 15
|
||||||
|
MANIFEST_MAX_DEPTH = 64
|
||||||
LIBRARY_DOWNLOAD_WORKERS = 4
|
LIBRARY_DOWNLOAD_WORKERS = 4
|
||||||
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$")
|
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$")
|
||||||
AGENT_CONFIG = Path("/etc/electric-cherry/agent.json")
|
AGENT_CONFIG = Path("/etc/electric-cherry/agent.json")
|
||||||
@@ -296,6 +300,151 @@ def managed_read(root, value):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def managed_manifest(root, payload, stopping):
|
||||||
|
"""Hash a bounded managed tree without following runtime-controlled symlinks."""
|
||||||
|
deadline = time.monotonic() + MANIFEST_MAX_SECONDS
|
||||||
|
|
||||||
|
def check_budget():
|
||||||
|
if stopping.is_set() or time.monotonic() >= deadline:
|
||||||
|
raise AgentError("Manifest scan exceeded its time limit or was interrupted")
|
||||||
|
|
||||||
|
roots = payload.get("roots")
|
||||||
|
ignored = payload.get("ignoreNames")
|
||||||
|
paths = payload.get("paths", [])
|
||||||
|
if not isinstance(roots, list) or not roots or len(roots) > 64:
|
||||||
|
raise AgentError("Manifest roots must contain between 1 and 64 managed directories")
|
||||||
|
if not isinstance(ignored, list) or len(ignored) > 256 or any(
|
||||||
|
not isinstance(name, str) or not name or name in (".", "..") or any(char in name for char in ("/", "\\", "\x00"))
|
||||||
|
for name in ignored
|
||||||
|
):
|
||||||
|
raise AgentError("Manifest ignored names must be plain file or directory names")
|
||||||
|
if not isinstance(paths, list) or len(paths) > 10000:
|
||||||
|
raise AgentError("Manifest managed paths must contain at most 10000 files")
|
||||||
|
for value in roots:
|
||||||
|
check_budget()
|
||||||
|
scoped(root, value)
|
||||||
|
roots = sorted(set(roots))
|
||||||
|
roots = [value for index, value in enumerate(roots) if not any(value.startswith(parent + "/") for parent in roots[:index])]
|
||||||
|
ignored = set(ignored)
|
||||||
|
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
|
||||||
|
entries = 0
|
||||||
|
result_bytes = len(json.dumps({"files": []}).encode())
|
||||||
|
files = []
|
||||||
|
|
||||||
|
def validate_paths():
|
||||||
|
for value in paths:
|
||||||
|
check_budget()
|
||||||
|
try:
|
||||||
|
with managed_open(root, value):
|
||||||
|
pass
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def identity(info):
|
||||||
|
return info.st_dev, info.st_ino, info.st_mode, info.st_size, info.st_mtime_ns, info.st_ctime_ns
|
||||||
|
|
||||||
|
def open_directory(app, relative):
|
||||||
|
directory = os.dup(app)
|
||||||
|
try:
|
||||||
|
for part in PurePosixPath(relative).parts:
|
||||||
|
nested = os.open(part, flags, dir_fd=directory)
|
||||||
|
os.close(directory)
|
||||||
|
directory = nested
|
||||||
|
return directory
|
||||||
|
except BaseException:
|
||||||
|
os.close(directory)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def scan(directory, relative, depth):
|
||||||
|
nonlocal entries, result_bytes
|
||||||
|
check_budget()
|
||||||
|
if depth > MANIFEST_MAX_DEPTH:
|
||||||
|
raise AgentError("Manifest scan exceeded its directory depth limit")
|
||||||
|
before = os.fstat(directory)
|
||||||
|
with os.scandir(directory) as children:
|
||||||
|
for child in children:
|
||||||
|
check_budget()
|
||||||
|
entries += 1
|
||||||
|
if entries > MANIFEST_MAX_ENTRIES:
|
||||||
|
raise AgentError("Manifest scan exceeded its entry limit")
|
||||||
|
if child.name in ignored:
|
||||||
|
continue
|
||||||
|
info = os.stat(child.name, dir_fd=directory, follow_symlinks=False)
|
||||||
|
if stat_module.S_ISLNK(info.st_mode):
|
||||||
|
continue
|
||||||
|
path = relative + "/" + child.name
|
||||||
|
if stat_module.S_ISDIR(info.st_mode):
|
||||||
|
nested = os.open(child.name, flags, dir_fd=directory)
|
||||||
|
try:
|
||||||
|
if identity(info) != identity(os.fstat(nested)):
|
||||||
|
raise AgentError("Manifest directory changed during the scan")
|
||||||
|
scan(nested, path, depth + 1)
|
||||||
|
if identity(os.fstat(nested)) != identity(os.stat(child.name, dir_fd=directory, follow_symlinks=False)):
|
||||||
|
raise AgentError("Manifest directory changed during the scan")
|
||||||
|
finally:
|
||||||
|
os.close(nested)
|
||||||
|
elif stat_module.S_ISREG(info.st_mode):
|
||||||
|
descriptor = os.open(child.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory)
|
||||||
|
try:
|
||||||
|
if identity(info) != identity(os.fstat(descriptor)):
|
||||||
|
raise AgentError("Manifest file changed during the scan")
|
||||||
|
sha = hashlib.sha256()
|
||||||
|
size = 0
|
||||||
|
with os.fdopen(descriptor, "rb", closefd=False) as stream:
|
||||||
|
while True:
|
||||||
|
check_budget()
|
||||||
|
block = stream.read(1024 * 1024)
|
||||||
|
if not block:
|
||||||
|
break
|
||||||
|
sha.update(block)
|
||||||
|
size += len(block)
|
||||||
|
if size != info.st_size or identity(info) != identity(os.fstat(descriptor)) or identity(info) != identity(os.stat(child.name, dir_fd=directory, follow_symlinks=False)):
|
||||||
|
raise AgentError("Manifest file changed during the scan")
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
item = {"path": path, "size": size, "sha256": sha.hexdigest()}
|
||||||
|
result_bytes += len(json.dumps(item).encode()) + (2 if files else 0)
|
||||||
|
if result_bytes > MANIFEST_MAX_BYTES:
|
||||||
|
raise AgentError("Manifest scan exceeded its result size limit")
|
||||||
|
files.append(item)
|
||||||
|
else:
|
||||||
|
raise AgentError("Manifest scan encountered a non-regular managed file")
|
||||||
|
if identity(before) != identity(os.fstat(directory)):
|
||||||
|
raise AgentError("Manifest directory changed during the scan")
|
||||||
|
|
||||||
|
try:
|
||||||
|
app = os.open(root, flags)
|
||||||
|
try:
|
||||||
|
app_identity = os.fstat(app)
|
||||||
|
validate_paths()
|
||||||
|
for relative in roots:
|
||||||
|
check_budget()
|
||||||
|
try:
|
||||||
|
directory = open_directory(app, relative)
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue # A destination directory need not exist yet.
|
||||||
|
try:
|
||||||
|
scan(directory, relative, len(PurePosixPath(relative).parts))
|
||||||
|
current_directory = open_directory(app, relative)
|
||||||
|
try:
|
||||||
|
if identity(os.fstat(directory)) != identity(os.fstat(current_directory)):
|
||||||
|
raise AgentError("Manifest directory changed during the scan")
|
||||||
|
finally:
|
||||||
|
os.close(current_directory)
|
||||||
|
finally:
|
||||||
|
os.close(directory)
|
||||||
|
validate_paths()
|
||||||
|
current = os.stat(root, follow_symlinks=False)
|
||||||
|
if (current.st_dev, current.st_ino, current.st_mode) != (app_identity.st_dev, app_identity.st_ino, app_identity.st_mode):
|
||||||
|
raise AgentError("Manifest managed root changed during the scan")
|
||||||
|
check_budget()
|
||||||
|
finally:
|
||||||
|
os.close(app)
|
||||||
|
except OSError:
|
||||||
|
raise AgentError("Manifest scan could not read the complete managed tree") from None
|
||||||
|
return {"files": sorted(files, key=lambda item: item["path"])}
|
||||||
|
|
||||||
|
|
||||||
def managed_write(root, value, content, uid=1000, gid=1000):
|
def managed_write(root, value, content, uid=1000, gid=1000):
|
||||||
if content is None:
|
if content is None:
|
||||||
try:
|
try:
|
||||||
@@ -1094,7 +1243,7 @@ class Agent:
|
|||||||
if line.startswith("PRETTY_NAME="):
|
if line.startswith("PRETTY_NAME="):
|
||||||
os_name = line.split("=", 1)[1].strip('"')
|
os_name = line.split("=", 1)[1].strip('"')
|
||||||
update = self.update_inventory()
|
update = self.update_inventory()
|
||||||
capabilities = ["library.remove", "instance.cancel-create", "operations.read-lane", "operations.health-lane"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
|
capabilities = ["library.remove", "instance.cancel-create", "operations.read-lane", "operations.health-lane", "files.manifest"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
|
||||||
return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, **resources, **({"cpuUsagePercent": cpu_usage} if cpu_usage is not None else {}), "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})}
|
return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, **resources, **({"cpuUsagePercent": cpu_usage} if cpu_usage is not None else {}), "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})}
|
||||||
|
|
||||||
def safe_to_disrupt(self, instance_id, generation):
|
def safe_to_disrupt(self, instance_id, generation):
|
||||||
@@ -1446,6 +1595,8 @@ class Agent:
|
|||||||
return result
|
return result
|
||||||
if kind == "logs.read":
|
if kind == "logs.read":
|
||||||
return self.read_log(instance_id, generation, payload)
|
return self.read_log(instance_id, generation, payload)
|
||||||
|
if kind == "files.manifest":
|
||||||
|
return managed_manifest(app, payload, self.stopping)
|
||||||
path = scoped(app, payload.get("path", ""), allow_root=kind in ("files.list", "files.stat"))
|
path = scoped(app, payload.get("path", ""), allow_root=kind in ("files.list", "files.stat"))
|
||||||
if kind == "files.read":
|
if kind == "files.read":
|
||||||
content = managed_read(app, payload["path"])
|
content = managed_read(app, payload["path"])
|
||||||
@@ -1967,7 +2118,7 @@ class Agent:
|
|||||||
return self.journal.update(operation["id"], "failed", error="Interrupted library download could not be resumed")
|
return self.journal.update(operation["id"], "failed", error="Interrupted library download could not be resumed")
|
||||||
if kind == "files.stage":
|
if kind == "files.stage":
|
||||||
return self.journal.update(operation["id"], "completed", self.stage(instance_id, generation, payload, operation["id"]))
|
return self.journal.update(operation["id"], "completed", self.stage(instance_id, generation, payload, operation["id"]))
|
||||||
if kind in ("host.inspect", "instance.inspect", "files.read", "files.list", "files.stat", "logs.read") or kind == "rcon.execute" and payload.get("command") in ("status", "getstatus", "version"):
|
if kind in ("host.inspect", "instance.inspect", "files.read", "files.list", "files.stat", "files.manifest", "logs.read") or kind == "rcon.execute" and payload.get("command") in ("status", "getstatus", "version"):
|
||||||
return self.journal.update(operation["id"], "completed", self.dispatch(operation))
|
return self.journal.update(operation["id"], "completed", self.dispatch(operation))
|
||||||
if kind in ("files.activate", "files.rollback") and checkpoint:
|
if kind in ("files.activate", "files.rollback") and checkpoint:
|
||||||
stage = self.instance_dir(instance_id, generation) / "stages" / identifier(payload["stageId"]) / "manifest.json"
|
stage = self.instance_dir(instance_id, generation) / "stages" / identifier(payload["stageId"]) / "manifest.json"
|
||||||
@@ -2054,7 +2205,7 @@ class Agent:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def read_operation(operation):
|
def read_operation(operation):
|
||||||
return operation.get("kind") in ("host.inspect", "instance.inspect", "files.read", "files.list", "files.stat", "logs.read") or (operation.get("kind") == "rcon.execute" and operation.get("payload", {}).get("command") in ("status", "getstatus", "version"))
|
return operation.get("kind") in ("host.inspect", "instance.inspect", "files.read", "files.list", "files.stat", "files.manifest", "logs.read") or (operation.get("kind") == "rcon.execute" and operation.get("payload", {}).get("command") in ("status", "getstatus", "version"))
|
||||||
|
|
||||||
def instance_lock(self, operation):
|
def instance_lock(self, operation):
|
||||||
key = operation.get("instanceId")
|
key = operation.get("instanceId")
|
||||||
|
|||||||
@@ -57,6 +57,209 @@ class Response(io.BytesIO):
|
|||||||
self.headers = headers or {}
|
self.headers = headers or {}
|
||||||
|
|
||||||
|
|
||||||
|
class ManifestTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temp.name) / "app"
|
||||||
|
self.root.mkdir()
|
||||||
|
self.stopping = threading.Event()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.temp.cleanup()
|
||||||
|
|
||||||
|
def write(self, path, content=b"content"):
|
||||||
|
target = self.root / path
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_bytes(content)
|
||||||
|
return target
|
||||||
|
|
||||||
|
def manifest(self, roots=None, ignore_names=None, paths=None):
|
||||||
|
return module.managed_manifest(self.root, {
|
||||||
|
"roots": ["scripts", "configs", "plugins"] if roots is None else roots,
|
||||||
|
"ignoreNames": [] if ignore_names is None else ignore_names,
|
||||||
|
"paths": [] if paths is None else paths,
|
||||||
|
}, self.stopping)
|
||||||
|
|
||||||
|
def test_hashes_sorted_unique_files_across_overlapping_roots_and_skips_ignored_names(self):
|
||||||
|
self.write("scripts/nested/z.gsc", b"script")
|
||||||
|
self.write("configs/server.cfg", b"config")
|
||||||
|
self.write("scripts/cache/ignored.gsc")
|
||||||
|
self.write("scripts/nested/ignore.gsc")
|
||||||
|
expected = [{"path": path, "size": len(content), "sha256": module.digest(content)} for path, content in (
|
||||||
|
("configs/server.cfg", b"config"), ("scripts/nested/z.gsc", b"script"),
|
||||||
|
)]
|
||||||
|
self.assertEqual(self.manifest(["scripts/nested", "configs", "scripts", "scripts", "plugins"], ["cache", "ignore.gsc"]), {"files": expected})
|
||||||
|
|
||||||
|
def test_nested_symlinks_are_skipped_but_requested_symlink_roots_fail(self):
|
||||||
|
self.write("scripts/good.gsc")
|
||||||
|
outside = Path(self.temp.name) / "outside"
|
||||||
|
outside.mkdir()
|
||||||
|
(outside / "secret").write_bytes(b"secret")
|
||||||
|
(self.root / "scripts/file-link").symlink_to(outside / "secret")
|
||||||
|
(self.root / "scripts/dir-link").symlink_to(outside, target_is_directory=True)
|
||||||
|
(self.root / "scripts/dangling").symlink_to(outside / "missing")
|
||||||
|
self.assertEqual([item["path"] for item in self.manifest()["files"]], ["scripts/good.gsc"])
|
||||||
|
for roots in (["scripts/dir-link"], ["scripts", "scripts/dir-link"]):
|
||||||
|
with self.subTest(roots=roots), self.assertRaisesRegex(AgentError, "symlink|Symlink"):
|
||||||
|
self.manifest(roots)
|
||||||
|
|
||||||
|
def test_validates_roots_and_ignored_names(self):
|
||||||
|
for roots in ([], "scripts", [""], ["."], [".."], ["../scripts"], ["/scripts"], ["scripts//nested"], ["scripts/../configs"], [None], ["scripts"] * 65):
|
||||||
|
with self.subTest(roots=roots), self.assertRaises(AgentError):
|
||||||
|
self.manifest(roots)
|
||||||
|
for ignored in ("cache", [None], [""], ["."], [".."], ["scripts/cache"], ["a\\b"], ["a\x00b"], ["cache"] * 257):
|
||||||
|
with self.subTest(ignored=ignored), self.assertRaises(AgentError):
|
||||||
|
self.manifest(ignore_names=ignored)
|
||||||
|
for paths in ("scripts/a", [None], ["../outside"], ["."], ["scripts/a"] * 10001):
|
||||||
|
with self.subTest(paths=paths), self.assertRaises(AgentError):
|
||||||
|
self.manifest(paths=paths)
|
||||||
|
|
||||||
|
def test_explicit_managed_paths_cannot_be_symlinks_or_directories(self):
|
||||||
|
self.write("scripts/good.gsc")
|
||||||
|
(self.root / "scripts/link.gsc").symlink_to(self.root / "scripts/good.gsc")
|
||||||
|
(self.root / "scripts/dangling.gsc").symlink_to(self.root / "missing")
|
||||||
|
for path in ("scripts", "scripts/link.gsc", "scripts/dangling.gsc"):
|
||||||
|
with self.subTest(path=path), self.assertRaises(AgentError):
|
||||||
|
self.manifest(paths=[path])
|
||||||
|
self.assertEqual([item["path"] for item in self.manifest(paths=["scripts/good.gsc", "scripts/missing.gsc", "missing/future.gsc"])["files"]], ["scripts/good.gsc"])
|
||||||
|
|
||||||
|
def test_explicit_managed_paths_are_validated_again_after_the_scan(self):
|
||||||
|
self.write("scripts/good.gsc")
|
||||||
|
original = module.managed_open
|
||||||
|
opened = 0
|
||||||
|
|
||||||
|
@module.contextlib.contextmanager
|
||||||
|
def swap(root, value):
|
||||||
|
nonlocal opened
|
||||||
|
opened += 1
|
||||||
|
if opened == 2:
|
||||||
|
(self.root / "scripts/good.gsc").unlink()
|
||||||
|
(self.root / "scripts/good.gsc").symlink_to(self.root / "missing")
|
||||||
|
with original(root, value) as stream:
|
||||||
|
yield stream
|
||||||
|
|
||||||
|
with patch.object(module, "managed_open", side_effect=swap), self.assertRaisesRegex(AgentError, "Symlinks"):
|
||||||
|
self.manifest(paths=["scripts/good.gsc"])
|
||||||
|
|
||||||
|
def test_missing_destinations_are_empty_but_unavailable_app_and_regular_roots_fail(self):
|
||||||
|
self.assertEqual(self.manifest(), {"files": []})
|
||||||
|
self.write("scripts", b"not a directory")
|
||||||
|
with self.assertRaisesRegex(AgentError, "complete managed tree"):
|
||||||
|
self.manifest()
|
||||||
|
(self.root / "scripts").unlink()
|
||||||
|
self.root.rmdir()
|
||||||
|
with self.assertRaisesRegex(AgentError, "complete managed tree"):
|
||||||
|
self.manifest()
|
||||||
|
replacement = Path(self.temp.name) / "replacement"
|
||||||
|
replacement.mkdir()
|
||||||
|
self.root.symlink_to(replacement, target_is_directory=True)
|
||||||
|
with self.assertRaisesRegex(AgentError, "symlink"):
|
||||||
|
self.manifest()
|
||||||
|
|
||||||
|
def test_errors_and_special_files_never_return_a_partial_manifest(self):
|
||||||
|
self.write("scripts/good.gsc")
|
||||||
|
self.write("scripts/unreadable.gsc")
|
||||||
|
original = module.os.stat
|
||||||
|
|
||||||
|
def denied(path, *args, **kwargs):
|
||||||
|
if path == "unreadable.gsc":
|
||||||
|
raise PermissionError("unreadable")
|
||||||
|
return original(path, *args, **kwargs)
|
||||||
|
|
||||||
|
with patch.object(module.os, "stat", side_effect=denied), self.assertRaisesRegex(AgentError, "complete managed tree"):
|
||||||
|
self.manifest()
|
||||||
|
module.os.mkfifo(self.root / "scripts/fifo")
|
||||||
|
with self.assertRaisesRegex(AgentError, "non-regular"):
|
||||||
|
self.manifest()
|
||||||
|
|
||||||
|
def test_scan_limits_fail_instead_of_truncating_results(self):
|
||||||
|
self.write("scripts/a.gsc")
|
||||||
|
self.write("scripts/b.gsc")
|
||||||
|
for setting, limit, message in (
|
||||||
|
("MANIFEST_MAX_ENTRIES", 1, "entry limit"),
|
||||||
|
("MANIFEST_MAX_BYTES", 1, "result size limit"),
|
||||||
|
("MANIFEST_MAX_SECONDS", 0, "time limit"),
|
||||||
|
("MANIFEST_MAX_DEPTH", 0, "depth limit"),
|
||||||
|
):
|
||||||
|
with self.subTest(setting=setting), patch.object(module, setting, limit), self.assertRaisesRegex(AgentError, message):
|
||||||
|
self.manifest()
|
||||||
|
self.stopping.set()
|
||||||
|
with self.assertRaisesRegex(AgentError, "interrupted"):
|
||||||
|
self.manifest()
|
||||||
|
|
||||||
|
def test_files_are_streamed_and_not_limited_by_the_content_read_api(self):
|
||||||
|
content = b"x" * (2 * 1024 * 1024 + 13)
|
||||||
|
self.write("plugins/large.dll", content)
|
||||||
|
original = module.os.fdopen
|
||||||
|
reads = []
|
||||||
|
|
||||||
|
@module.contextlib.contextmanager
|
||||||
|
def tracked(*args, **kwargs):
|
||||||
|
with original(*args, **kwargs) as stream:
|
||||||
|
proxy = MagicMock(wraps=stream)
|
||||||
|
def read(size):
|
||||||
|
reads.append(size)
|
||||||
|
return stream.read(size)
|
||||||
|
proxy.read.side_effect = read
|
||||||
|
yield proxy
|
||||||
|
|
||||||
|
with patch.object(module, "MAX_FILE", 1), patch.object(module.os, "fdopen", side_effect=tracked):
|
||||||
|
result = self.manifest()
|
||||||
|
self.assertEqual(result, {"files": [{"path": "plugins/large.dll", "size": len(content), "sha256": module.digest(content)}]})
|
||||||
|
self.assertGreater(len(reads), 2)
|
||||||
|
self.assertEqual(set(reads), {1024 * 1024})
|
||||||
|
|
||||||
|
def test_file_changes_while_hashing_invalidate_the_scan(self):
|
||||||
|
target = self.write("scripts/changing.gsc", b"before")
|
||||||
|
original = module.hashlib.sha256
|
||||||
|
|
||||||
|
def changing_hash():
|
||||||
|
digest = original()
|
||||||
|
proxy = MagicMock(wraps=digest)
|
||||||
|
def update(block):
|
||||||
|
digest.update(block)
|
||||||
|
prior = target.stat()
|
||||||
|
target.write_bytes(b"after!")
|
||||||
|
module.os.utime(target, ns=(prior.st_atime_ns, prior.st_mtime_ns + 1))
|
||||||
|
proxy.update.side_effect = update
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
with patch.object(module.hashlib, "sha256", side_effect=changing_hash), self.assertRaisesRegex(AgentError, "file changed"):
|
||||||
|
self.manifest()
|
||||||
|
|
||||||
|
def test_symlink_swap_after_validation_cannot_hash_outside_the_app(self):
|
||||||
|
self.write("scripts/good.gsc")
|
||||||
|
outside = Path(self.temp.name) / "outside"
|
||||||
|
outside.mkdir()
|
||||||
|
(outside / "secret").write_bytes(b"secret")
|
||||||
|
original = module.scoped
|
||||||
|
|
||||||
|
def swap(root, relative, **kwargs):
|
||||||
|
result = original(root, relative, **kwargs)
|
||||||
|
(self.root / "scripts").rename(self.root / "saved")
|
||||||
|
(self.root / "scripts").symlink_to(outside, target_is_directory=True)
|
||||||
|
return result
|
||||||
|
|
||||||
|
with patch.object(module, "scoped", side_effect=swap), self.assertRaisesRegex(AgentError, "complete managed tree"):
|
||||||
|
self.manifest(["scripts"])
|
||||||
|
|
||||||
|
def test_disappearing_app_after_hashing_is_not_successful_evidence(self):
|
||||||
|
self.write("scripts/good.gsc")
|
||||||
|
original = module.hashlib.sha256
|
||||||
|
|
||||||
|
def moving_hash():
|
||||||
|
digest = original()
|
||||||
|
proxy = MagicMock(wraps=digest)
|
||||||
|
def update(block):
|
||||||
|
digest.update(block)
|
||||||
|
self.root.rename(self.root.with_name("moved-app"))
|
||||||
|
proxy.update.side_effect = update
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
with patch.object(module.hashlib, "sha256", side_effect=moving_hash), self.assertRaisesRegex(AgentError, "complete managed tree"):
|
||||||
|
self.manifest()
|
||||||
|
|
||||||
|
|
||||||
class AgentTests(unittest.TestCase):
|
class AgentTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.temp = tempfile.TemporaryDirectory()
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
@@ -227,6 +430,20 @@ class AgentTests(unittest.TestCase):
|
|||||||
self.assertEqual(second["result"], first["result"])
|
self.assertEqual(second["result"], first["result"])
|
||||||
self.assertEqual(sum(call[:2] == ("container", "restart") for call in self.docker.calls), 1)
|
self.assertEqual(sum(call[:2] == ("container", "restart") for call in self.docker.calls), 1)
|
||||||
|
|
||||||
|
def test_manifest_is_advertised_and_reconciles_as_a_read_operation(self):
|
||||||
|
self.create()
|
||||||
|
operation = self.operation("files.manifest", {"roots": ["configs", "scripts"], "ignoreNames": [], "paths": ["configs/server.cfg"]})
|
||||||
|
self.assertIn("files.manifest", self.agent.inventory()["capabilities"])
|
||||||
|
self.assertTrue(self.agent.read_operation(operation))
|
||||||
|
self.assertFalse(self.agent.health_operation(operation))
|
||||||
|
self.agent.journal.accept(operation)
|
||||||
|
self.agent.journal.update(operation["id"], "unknown")
|
||||||
|
calls = list(self.docker.calls)
|
||||||
|
result = self.agent.execute(operation)
|
||||||
|
self.assertEqual(result["state"], "completed")
|
||||||
|
self.assertEqual(json.loads(result["result"]), {"files": [{"path": "configs/server.cfg", "size": 8, "sha256": module.digest(b"original")}]})
|
||||||
|
self.assertEqual(self.docker.calls, calls)
|
||||||
|
|
||||||
def test_reusing_id_with_different_payload_is_rejected(self):
|
def test_reusing_id_with_different_payload_is_rejected(self):
|
||||||
self.agent.execute(self.operation("host.inspect"))
|
self.agent.execute(self.operation("host.inspect"))
|
||||||
with self.assertRaisesRegex(AgentError, "different content"):
|
with self.assertRaisesRegex(AgentError, "different content"):
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import * as fs from 'node:fs/promises';
|
import * as fs from 'node:fs/promises';
|
||||||
import { relative, resolve, sep } from 'node:path';
|
import { join, posix, relative, resolve, sep } from 'node:path';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { nativeInstance, nativeRequest } from './native.js';
|
import { nativeInstance, nativeRequest } from './native.js';
|
||||||
|
import { getHost } from '../hosts/index.js';
|
||||||
|
|
||||||
function remote(path: string) {
|
function remote(path: string) {
|
||||||
for (const root of [config.serversRoot, config.t6ServersRoot]) {
|
for (const root of [config.serversRoot, config.t6ServersRoot]) {
|
||||||
@@ -16,6 +17,35 @@ function remote(path: string) {
|
|||||||
}
|
}
|
||||||
function missing(path: string): never { throw Object.assign(new Error(`File does not exist: ${path}`), { code: 'ENOENT' }); }
|
function missing(path: string): never { throw Object.assign(new Error(`File does not exist: ${path}`), { code: 'ENOENT' }); }
|
||||||
|
|
||||||
|
export interface FileFingerprint { size: number; sha256: string }
|
||||||
|
|
||||||
|
/** One scoped agent scan; null preserves compatibility with older agents and local files. */
|
||||||
|
export async function nativeFileManifest(root: string, directories: string[], ignoreNames: string[], managedPaths: string[]): Promise<Map<string, FileFingerprint> | null> {
|
||||||
|
const target = remote(root);
|
||||||
|
if (!target || target.path !== '.' || !getHost(target.instance.host_id)?.inventory.capabilities?.includes('files.manifest')) return null;
|
||||||
|
const roots = [...new Set(directories.map(directory => relative(root, directory).split(sep).join('/')))];
|
||||||
|
// Preserve legacy configurations that scan the entire instance root.
|
||||||
|
if (roots.some(directory => !directory)) return null;
|
||||||
|
const paths = [...new Set(managedPaths.map(path => relative(root, path).split(sep).join('/')))];
|
||||||
|
const result = await nativeRequest<{ files: Array<FileFingerprint & { path: string }> }>(target.instance, 'files.manifest', {
|
||||||
|
roots, paths, ignoreNames: ignoreNames.filter(name => !paths.some(path => path.split('/').includes(name)))
|
||||||
|
});
|
||||||
|
if (!result || !Array.isArray(result.files) || result.files.length > 10_000) throw new Error('Agent returned an invalid file manifest.');
|
||||||
|
const manifest = new Map<string, FileFingerprint>();
|
||||||
|
for (const entry of result.files) {
|
||||||
|
if (!entry || typeof entry.path !== 'string' || !entry.path || entry.path.includes('\\') || entry.path.includes('\0')
|
||||||
|
|| posix.normalize(entry.path) !== entry.path || entry.path.split('/').some(part => part === '..' || part === '.')
|
||||||
|
|| !roots.some(directory => entry.path.startsWith(`${directory}/`))
|
||||||
|
|| !Number.isSafeInteger(entry.size) || entry.size < 0 || !/^[a-f0-9]{64}$/.test(entry.sha256)) {
|
||||||
|
throw new Error('Agent returned an invalid file manifest entry.');
|
||||||
|
}
|
||||||
|
const path = join(root, entry.path);
|
||||||
|
if (manifest.has(path)) throw new Error('Agent returned duplicate file manifest entries.');
|
||||||
|
manifest.set(path, { size: entry.size, sha256: entry.sha256 });
|
||||||
|
}
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
export async function readFile(path: string): Promise<Buffer> {
|
export async function readFile(path: string): Promise<Buffer> {
|
||||||
const target = remote(path);
|
const target = remote(path);
|
||||||
if (!target) return fs.readFile(path);
|
if (!target) return fs.readFile(path);
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function unplacedNativeCreation(server: string): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const observations = new Map<string, Promise<unknown>>();
|
const observations = new Map<string, Promise<unknown>>();
|
||||||
const readKinds = new Set(['instance.inspect', 'files.read', 'files.stat', 'files.list', 'logs.read']);
|
const readKinds = new Set(['instance.inspect', 'files.read', 'files.stat', 'files.list', 'files.manifest', 'logs.read']);
|
||||||
|
|
||||||
/** Share overlapping observations only, never completed results or mutations. */
|
/** Share overlapping observations only, never completed results or mutations. */
|
||||||
export async function nativeRequest<T = Record<string, unknown>>(instance: NativeInstance, kind: string, payload: Record<string, unknown> = {}, key?: string): Promise<T> {
|
export async function nativeRequest<T = Record<string, unknown>>(instance: NativeInstance, kind: string, payload: Record<string, unknown> = {}, key?: string): Promise<T> {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readdir, readFile, stat } from '../backends/files.js';
|
import { readdir, readFile, stat, nativeFileManifest, type FileFingerprint } from '../backends/files.js';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { DriftPlan, DriftPlanFile } from '$lib/drift-plan.js';
|
import type { DriftPlan, DriftPlanFile } from '$lib/drift-plan.js';
|
||||||
import { join, posix, relative, sep } from 'node:path';
|
import { join, posix, relative, sep } from 'node:path';
|
||||||
@@ -11,7 +11,7 @@ import { getBaselineForScope, getVersion } from './scripts.js';
|
|||||||
import { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } from './server-config.js';
|
import { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } from './server-config.js';
|
||||||
import { selectedZombieMap } from './map-aliases.js';
|
import { selectedZombieMap } from './map-aliases.js';
|
||||||
import { getServer, listServers } from './servers.js';
|
import { getServer, listServers } from './servers.js';
|
||||||
import { promotedArtifacts } from './promoted-artifacts.js';
|
import { promotedArtifactMetadata } from './promoted-artifacts.js';
|
||||||
import { queueFileChange } from './staged-files.js';
|
import { queueFileChange } from './staged-files.js';
|
||||||
import { trackActivity } from '../pipeline/activity.js';
|
import { trackActivity } from '../pipeline/activity.js';
|
||||||
|
|
||||||
@@ -88,6 +88,12 @@ async function readExistingFile(path: string): Promise<Buffer | null> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fingerprint(path: string, manifest: Map<string, FileFingerprint> | null): Promise<FileFingerprint | null> {
|
||||||
|
if (manifest) return manifest.get(path) ?? null;
|
||||||
|
const content = await readExistingFile(path);
|
||||||
|
return content === null ? null : { size: content.byteLength, sha256: sha256Buffer(content) };
|
||||||
|
}
|
||||||
|
|
||||||
async function walkFiles(root: string): Promise<string[]> {
|
async function walkFiles(root: string): Promise<string[]> {
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
let directories = [root];
|
let directories = [root];
|
||||||
@@ -128,11 +134,15 @@ async function scanServerFiles(server: string): Promise<DriftEntry[]> {
|
|||||||
const srv = getServer(server);
|
const srv = getServer(server);
|
||||||
if (!srv || srv.destroyed_at || hasActiveBeta(server)) return [];
|
if (!srv || srv.destroyed_at || hasActiveBeta(server)) return [];
|
||||||
// A missing mount is unavailable evidence, not an empty managed tree.
|
// A missing mount is unavailable evidence, not an empty managed tree.
|
||||||
await requireServerDirectory(server);
|
const expectedPaths = promotedArtifactMetadata(srv.game, srv.map).map(({ kind, script }) => join(destOfKind(server, kind), script.target_rel_path));
|
||||||
const drift = await scanManagedForServer(server);
|
if (srv.game && getBaselineForScope('game', srv.game)?.current_version_id) expectedPaths.push(join(destOfKind(server, 'config'), configFileOf(server)));
|
||||||
|
if (srv.created_by && srv.game_mode === 'zombie' && getServerMapConfig(srv.game, srv.map, srv.map_slug)) expectedPaths.push(join(destOfKind(server, 'config'), MAP_CONFIG_FILE));
|
||||||
|
const manifest = await nativeFileManifest(serverDir(server), DRIFT_DESTS.map(kind => destOfKind(server, kind)), [...DRIFT_IGNORE_FILENAMES], expectedPaths);
|
||||||
|
if (!manifest) await requireServerDirectory(server);
|
||||||
|
const drift = await scanManagedForServer(server, manifest);
|
||||||
// Previous inventory is an audit of installed bytes, not permission to
|
// Previous inventory is an audit of installed bytes, not permission to
|
||||||
// retain artifacts whose scope was moved or removed from the library.
|
// retain artifacts whose scope was moved or removed from the library.
|
||||||
const managed = new Set(promotedArtifacts(srv.game, srv.map)
|
const managed = new Set(promotedArtifactMetadata(srv.game, srv.map)
|
||||||
.map(({ kind, script }) => `${kind}:${script.target_rel_path}`));
|
.map(({ kind, script }) => `${kind}:${script.target_rel_path}`));
|
||||||
const baseline = srv.game ? getBaselineForScope('game', srv.game) : undefined;
|
const baseline = srv.game ? getBaselineForScope('game', srv.game) : undefined;
|
||||||
if (baseline?.current_version_id) managed.add(`config:${configFileOf(server)}`);
|
if (baseline?.current_version_id) managed.add(`config:${configFileOf(server)}`);
|
||||||
@@ -145,7 +155,10 @@ async function scanServerFiles(server: string): Promise<DriftEntry[]> {
|
|||||||
// Scan for untracked files only after resolving managed targets.
|
// Scan for untracked files only after resolving managed targets.
|
||||||
for (const kind of DRIFT_DESTS) {
|
for (const kind of DRIFT_DESTS) {
|
||||||
const dest = destOfKind(server, kind);
|
const dest = destOfKind(server, kind);
|
||||||
const onDisk = await walkFiles(dest);
|
const onDisk = manifest
|
||||||
|
? [...manifest.keys()].filter(path => path.startsWith(`${dest}${sep}`)).map(path => toPosix(relative(dest, path)))
|
||||||
|
.filter(path => !path.split('/').some(name => DRIFT_IGNORE_FILENAMES.has(name))).sort()
|
||||||
|
: await walkFiles(dest);
|
||||||
if (onDisk.length === 0) continue;
|
if (onDisk.length === 0) continue;
|
||||||
|
|
||||||
const untracked = await scanBatch(onDisk, async (relPath): Promise<DriftEntry | null> => {
|
const untracked = await scanBatch(onDisk, async (relPath): Promise<DriftEntry | null> => {
|
||||||
@@ -155,11 +168,11 @@ async function scanServerFiles(server: string): Promise<DriftEntry[]> {
|
|||||||
&& srv.game_mode === 'zombie' && getServerMapConfig(srv.game, srv.map, srv.map_slug)) return null;
|
&& srv.game_mode === 'zombie' && getServerMapConfig(srv.game, srv.map, srv.map_slug)) return null;
|
||||||
const abs = join(dest, relPath);
|
const abs = join(dest, relPath);
|
||||||
try {
|
try {
|
||||||
const buf = await readFile(abs);
|
const info = await fingerprint(abs, manifest);
|
||||||
|
if (!info) return null;
|
||||||
return {
|
return {
|
||||||
server, relPath,
|
server, relPath,
|
||||||
size: buf.byteLength,
|
...info,
|
||||||
sha256: sha256Buffer(buf),
|
|
||||||
kind,
|
kind,
|
||||||
driftKind: 'untracked'
|
driftKind: 'untracked'
|
||||||
};
|
};
|
||||||
@@ -172,14 +185,14 @@ async function scanServerFiles(server: string): Promise<DriftEntry[]> {
|
|||||||
drift.push(...untracked.filter((entry): entry is DriftEntry => entry !== null));
|
drift.push(...untracked.filter((entry): entry is DriftEntry => entry !== null));
|
||||||
}
|
}
|
||||||
|
|
||||||
await requireServerDirectory(server);
|
if (!manifest) await requireServerDirectory(server);
|
||||||
return hasActiveBeta(server) ? [] : drift;
|
return hasActiveBeta(server) ? [] : drift;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every promoted artifact whose scope matches this server's game/map should
|
// Every promoted artifact whose scope matches this server's game/map should
|
||||||
// have its target file present on disk. Anything absent is 'missing' drift.
|
// have its target file present on disk. Anything absent is 'missing' drift.
|
||||||
// Configs are handled as a composed layer (baseline + overlays → one cfg).
|
// Configs are handled as a composed layer (baseline + overlays → one cfg).
|
||||||
async function scanManagedForServer(server: string): Promise<DriftEntry[]> {
|
async function scanManagedForServer(server: string, manifest: Map<string, FileFingerprint> | null): Promise<DriftEntry[]> {
|
||||||
const srv = getServer(server);
|
const srv = getServer(server);
|
||||||
if (!srv) return [];
|
if (!srv) return [];
|
||||||
const out: DriftEntry[] = [];
|
const out: DriftEntry[] = [];
|
||||||
@@ -190,8 +203,8 @@ async function scanManagedForServer(server: string): Promise<DriftEntry[]> {
|
|||||||
? getServerMapConfig(srv.game, srv.map, srv.map_slug) : null;
|
? getServerMapConfig(srv.game, srv.map, srv.map_slug) : null;
|
||||||
if (mapConfig) {
|
if (mapConfig) {
|
||||||
const abs = join(destOfKind(server, 'config'), MAP_CONFIG_FILE);
|
const abs = join(destOfKind(server, 'config'), MAP_CONFIG_FILE);
|
||||||
const content = await readExistingFile(abs);
|
const content = await fingerprint(abs, manifest);
|
||||||
if (!content?.equals(mapConfig)) {
|
if (content?.sha256 !== sha256Buffer(mapConfig)) {
|
||||||
out.push({ server, relPath: MAP_CONFIG_FILE, kind: 'config', driftKind: content ? 'replace' : 'missing',
|
out.push({ server, relPath: MAP_CONFIG_FILE, kind: 'config', driftKind: content ? 'replace' : 'missing',
|
||||||
size: mapConfig.byteLength, sha256: sha256Buffer(mapConfig) });
|
size: mapConfig.byteLength, sha256: sha256Buffer(mapConfig) });
|
||||||
}
|
}
|
||||||
@@ -199,10 +212,10 @@ async function scanManagedForServer(server: string): Promise<DriftEntry[]> {
|
|||||||
|
|
||||||
// Inventory records the last managed write, not the current disk bytes.
|
// Inventory records the last managed write, not the current disk bytes.
|
||||||
// Check both so edits and outdated versions are detected on tracked files.
|
// Check both so edits and outdated versions are detected on tracked files.
|
||||||
const managed = await scanBatch(promotedArtifacts(srv.game, srv.map), async ({ kind, script, version }): Promise<DriftEntry | null> => {
|
const managed = await scanBatch(promotedArtifactMetadata(srv.game, srv.map), async ({ kind, script, version }): Promise<DriftEntry | null> => {
|
||||||
const content = await readExistingFile(join(destOfKind(server, kind), script.target_rel_path));
|
const content = await fingerprint(join(destOfKind(server, kind), script.target_rel_path), manifest);
|
||||||
const inventory = getServerScript(server, script.target_rel_path);
|
const inventory = getServerScript(server, script.target_rel_path);
|
||||||
if (content?.equals(version.content) && inventory?.sha256 === version.sha256
|
if (content?.sha256 === version.sha256 && inventory?.sha256 === version.sha256
|
||||||
&& inventory.script_version_id === version.id) return null;
|
&& inventory.script_version_id === version.id) return null;
|
||||||
return {
|
return {
|
||||||
server, relPath: script.target_rel_path, kind,
|
server, relPath: script.target_rel_path, kind,
|
||||||
@@ -226,8 +239,8 @@ async function scanManagedForServer(server: string): Promise<DriftEntry[]> {
|
|||||||
const targetRel = configFileOf(server);
|
const targetRel = configFileOf(server);
|
||||||
const abs = join(dest, targetRel);
|
const abs = join(dest, targetRel);
|
||||||
const inv = getServerScript(server, targetRel);
|
const inv = getServerScript(server, targetRel);
|
||||||
const content = await readExistingFile(abs);
|
const content = await fingerprint(abs, manifest);
|
||||||
const onDiskMatches = content?.equals(composed.content) && inv?.sha256 === composed.sha256;
|
const onDiskMatches = content?.sha256 === composed.sha256 && inv?.sha256 === composed.sha256;
|
||||||
if (!onDiskMatches) {
|
if (!onDiskMatches) {
|
||||||
out.push({
|
out.push({
|
||||||
server,
|
server,
|
||||||
@@ -366,7 +379,7 @@ export async function prepareDriftCampaign(server: string): Promise<PreparedDrif
|
|||||||
beforeSha256: before ? sha256Buffer(before) : null, afterSha256: after ? sha256Buffer(after) : null,
|
beforeSha256: before ? sha256Buffer(before) : null, afterSha256: after ? sha256Buffer(after) : null,
|
||||||
beforeSize: before?.byteLength ?? null, afterSize: after?.byteLength ?? null } };
|
beforeSize: before?.byteLength ?? null, afterSize: after?.byteLength ?? null } };
|
||||||
});
|
});
|
||||||
await requireServerDirectory(server);
|
if (entries.length) await requireServerDirectory(server);
|
||||||
if (hasActiveBeta(server)) throw new Error('Active beta owns this server; reconciliation deferred');
|
if (hasActiveBeta(server)) throw new Error('Active beta owns this server; reconciliation deferred');
|
||||||
return { plan: buildPlan(servers, files.map(({ file }) => file)), files };
|
return { plan: buildPlan(servers, files.map(({ file }) => file)), files };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -435,7 +435,7 @@ export function pendingHostOperationRows(hostId: string, lane: OperationLane = '
|
|||||||
WHERE host_id=? AND kind='instance.cancel-create' AND state IN ('pending','accepted','running','unknown') LIMIT 1`).get(hostId)) return [];
|
WHERE host_id=? AND kind='instance.cancel-create' AND state IN ('pending','accepted','running','unknown') LIMIT 1`).get(hostId)) return [];
|
||||||
// Accepted/running/unknown commands keep the same immutable ID. The agent
|
// Accepted/running/unknown commands keep the same immutable ID. The agent
|
||||||
// consults its durable journal before acknowledging or executing a replay.
|
// consults its durable journal before acknowledging or executing a replay.
|
||||||
const readOnly = `(kind IN ('host.inspect','instance.inspect','files.read','files.list','files.stat','logs.read')
|
const readOnly = `(kind IN ('host.inspect','instance.inspect','files.read','files.list','files.stat','files.manifest','logs.read')
|
||||||
OR (kind='rcon.execute' AND json_extract(${operationPayloadSql},'$.command') IN ('status','getstatus','version')))`;
|
OR (kind='rcon.execute' AND json_extract(${operationPayloadSql},'$.command') IN ('status','getstatus','version')))`;
|
||||||
const healthOnly = `(kind='instance.inspect' OR (kind='rcon.execute' AND json_extract(${operationPayloadSql},'$.command') IN ('status','getstatus','version')))`;
|
const healthOnly = `(kind='instance.inspect' OR (kind='rcon.execute' AND json_extract(${operationPayloadSql},'$.command') IN ('status','getstatus','version')))`;
|
||||||
const healthLane = host.inventory.capabilities?.includes('operations.health-lane');
|
const healthLane = host.inventory.capabilities?.includes('operations.health-lane');
|
||||||
|
|||||||
+155
-4
@@ -32,9 +32,13 @@ import urllib.parse
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
VERSION = "1.4.7"
|
VERSION = "1.4.8"
|
||||||
LABEL = "io.electric-cherry"
|
LABEL = "io.electric-cherry"
|
||||||
MAX_FILE = 32 * 1024 * 1024
|
MAX_FILE = 32 * 1024 * 1024
|
||||||
|
MANIFEST_MAX_ENTRIES = 10000
|
||||||
|
MANIFEST_MAX_BYTES = 16 * 1024 * 1024
|
||||||
|
MANIFEST_MAX_SECONDS = 15
|
||||||
|
MANIFEST_MAX_DEPTH = 64
|
||||||
LIBRARY_DOWNLOAD_WORKERS = 4
|
LIBRARY_DOWNLOAD_WORKERS = 4
|
||||||
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$")
|
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$")
|
||||||
AGENT_CONFIG = Path("/etc/electric-cherry/agent.json")
|
AGENT_CONFIG = Path("/etc/electric-cherry/agent.json")
|
||||||
@@ -296,6 +300,151 @@ def managed_read(root, value):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def managed_manifest(root, payload, stopping):
|
||||||
|
"""Hash a bounded managed tree without following runtime-controlled symlinks."""
|
||||||
|
deadline = time.monotonic() + MANIFEST_MAX_SECONDS
|
||||||
|
|
||||||
|
def check_budget():
|
||||||
|
if stopping.is_set() or time.monotonic() >= deadline:
|
||||||
|
raise AgentError("Manifest scan exceeded its time limit or was interrupted")
|
||||||
|
|
||||||
|
roots = payload.get("roots")
|
||||||
|
ignored = payload.get("ignoreNames")
|
||||||
|
paths = payload.get("paths", [])
|
||||||
|
if not isinstance(roots, list) or not roots or len(roots) > 64:
|
||||||
|
raise AgentError("Manifest roots must contain between 1 and 64 managed directories")
|
||||||
|
if not isinstance(ignored, list) or len(ignored) > 256 or any(
|
||||||
|
not isinstance(name, str) or not name or name in (".", "..") or any(char in name for char in ("/", "\\", "\x00"))
|
||||||
|
for name in ignored
|
||||||
|
):
|
||||||
|
raise AgentError("Manifest ignored names must be plain file or directory names")
|
||||||
|
if not isinstance(paths, list) or len(paths) > 10000:
|
||||||
|
raise AgentError("Manifest managed paths must contain at most 10000 files")
|
||||||
|
for value in roots:
|
||||||
|
check_budget()
|
||||||
|
scoped(root, value)
|
||||||
|
roots = sorted(set(roots))
|
||||||
|
roots = [value for index, value in enumerate(roots) if not any(value.startswith(parent + "/") for parent in roots[:index])]
|
||||||
|
ignored = set(ignored)
|
||||||
|
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
|
||||||
|
entries = 0
|
||||||
|
result_bytes = len(json.dumps({"files": []}).encode())
|
||||||
|
files = []
|
||||||
|
|
||||||
|
def validate_paths():
|
||||||
|
for value in paths:
|
||||||
|
check_budget()
|
||||||
|
try:
|
||||||
|
with managed_open(root, value):
|
||||||
|
pass
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def identity(info):
|
||||||
|
return info.st_dev, info.st_ino, info.st_mode, info.st_size, info.st_mtime_ns, info.st_ctime_ns
|
||||||
|
|
||||||
|
def open_directory(app, relative):
|
||||||
|
directory = os.dup(app)
|
||||||
|
try:
|
||||||
|
for part in PurePosixPath(relative).parts:
|
||||||
|
nested = os.open(part, flags, dir_fd=directory)
|
||||||
|
os.close(directory)
|
||||||
|
directory = nested
|
||||||
|
return directory
|
||||||
|
except BaseException:
|
||||||
|
os.close(directory)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def scan(directory, relative, depth):
|
||||||
|
nonlocal entries, result_bytes
|
||||||
|
check_budget()
|
||||||
|
if depth > MANIFEST_MAX_DEPTH:
|
||||||
|
raise AgentError("Manifest scan exceeded its directory depth limit")
|
||||||
|
before = os.fstat(directory)
|
||||||
|
with os.scandir(directory) as children:
|
||||||
|
for child in children:
|
||||||
|
check_budget()
|
||||||
|
entries += 1
|
||||||
|
if entries > MANIFEST_MAX_ENTRIES:
|
||||||
|
raise AgentError("Manifest scan exceeded its entry limit")
|
||||||
|
if child.name in ignored:
|
||||||
|
continue
|
||||||
|
info = os.stat(child.name, dir_fd=directory, follow_symlinks=False)
|
||||||
|
if stat_module.S_ISLNK(info.st_mode):
|
||||||
|
continue
|
||||||
|
path = relative + "/" + child.name
|
||||||
|
if stat_module.S_ISDIR(info.st_mode):
|
||||||
|
nested = os.open(child.name, flags, dir_fd=directory)
|
||||||
|
try:
|
||||||
|
if identity(info) != identity(os.fstat(nested)):
|
||||||
|
raise AgentError("Manifest directory changed during the scan")
|
||||||
|
scan(nested, path, depth + 1)
|
||||||
|
if identity(os.fstat(nested)) != identity(os.stat(child.name, dir_fd=directory, follow_symlinks=False)):
|
||||||
|
raise AgentError("Manifest directory changed during the scan")
|
||||||
|
finally:
|
||||||
|
os.close(nested)
|
||||||
|
elif stat_module.S_ISREG(info.st_mode):
|
||||||
|
descriptor = os.open(child.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory)
|
||||||
|
try:
|
||||||
|
if identity(info) != identity(os.fstat(descriptor)):
|
||||||
|
raise AgentError("Manifest file changed during the scan")
|
||||||
|
sha = hashlib.sha256()
|
||||||
|
size = 0
|
||||||
|
with os.fdopen(descriptor, "rb", closefd=False) as stream:
|
||||||
|
while True:
|
||||||
|
check_budget()
|
||||||
|
block = stream.read(1024 * 1024)
|
||||||
|
if not block:
|
||||||
|
break
|
||||||
|
sha.update(block)
|
||||||
|
size += len(block)
|
||||||
|
if size != info.st_size or identity(info) != identity(os.fstat(descriptor)) or identity(info) != identity(os.stat(child.name, dir_fd=directory, follow_symlinks=False)):
|
||||||
|
raise AgentError("Manifest file changed during the scan")
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
item = {"path": path, "size": size, "sha256": sha.hexdigest()}
|
||||||
|
result_bytes += len(json.dumps(item).encode()) + (2 if files else 0)
|
||||||
|
if result_bytes > MANIFEST_MAX_BYTES:
|
||||||
|
raise AgentError("Manifest scan exceeded its result size limit")
|
||||||
|
files.append(item)
|
||||||
|
else:
|
||||||
|
raise AgentError("Manifest scan encountered a non-regular managed file")
|
||||||
|
if identity(before) != identity(os.fstat(directory)):
|
||||||
|
raise AgentError("Manifest directory changed during the scan")
|
||||||
|
|
||||||
|
try:
|
||||||
|
app = os.open(root, flags)
|
||||||
|
try:
|
||||||
|
app_identity = os.fstat(app)
|
||||||
|
validate_paths()
|
||||||
|
for relative in roots:
|
||||||
|
check_budget()
|
||||||
|
try:
|
||||||
|
directory = open_directory(app, relative)
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue # A destination directory need not exist yet.
|
||||||
|
try:
|
||||||
|
scan(directory, relative, len(PurePosixPath(relative).parts))
|
||||||
|
current_directory = open_directory(app, relative)
|
||||||
|
try:
|
||||||
|
if identity(os.fstat(directory)) != identity(os.fstat(current_directory)):
|
||||||
|
raise AgentError("Manifest directory changed during the scan")
|
||||||
|
finally:
|
||||||
|
os.close(current_directory)
|
||||||
|
finally:
|
||||||
|
os.close(directory)
|
||||||
|
validate_paths()
|
||||||
|
current = os.stat(root, follow_symlinks=False)
|
||||||
|
if (current.st_dev, current.st_ino, current.st_mode) != (app_identity.st_dev, app_identity.st_ino, app_identity.st_mode):
|
||||||
|
raise AgentError("Manifest managed root changed during the scan")
|
||||||
|
check_budget()
|
||||||
|
finally:
|
||||||
|
os.close(app)
|
||||||
|
except OSError:
|
||||||
|
raise AgentError("Manifest scan could not read the complete managed tree") from None
|
||||||
|
return {"files": sorted(files, key=lambda item: item["path"])}
|
||||||
|
|
||||||
|
|
||||||
def managed_write(root, value, content, uid=1000, gid=1000):
|
def managed_write(root, value, content, uid=1000, gid=1000):
|
||||||
if content is None:
|
if content is None:
|
||||||
try:
|
try:
|
||||||
@@ -1094,7 +1243,7 @@ class Agent:
|
|||||||
if line.startswith("PRETTY_NAME="):
|
if line.startswith("PRETTY_NAME="):
|
||||||
os_name = line.split("=", 1)[1].strip('"')
|
os_name = line.split("=", 1)[1].strip('"')
|
||||||
update = self.update_inventory()
|
update = self.update_inventory()
|
||||||
capabilities = ["library.remove", "instance.cancel-create", "operations.read-lane", "operations.health-lane"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
|
capabilities = ["library.remove", "instance.cancel-create", "operations.read-lane", "operations.health-lane", "files.manifest"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
|
||||||
return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, **resources, **({"cpuUsagePercent": cpu_usage} if cpu_usage is not None else {}), "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})}
|
return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, **resources, **({"cpuUsagePercent": cpu_usage} if cpu_usage is not None else {}), "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})}
|
||||||
|
|
||||||
def safe_to_disrupt(self, instance_id, generation):
|
def safe_to_disrupt(self, instance_id, generation):
|
||||||
@@ -1446,6 +1595,8 @@ class Agent:
|
|||||||
return result
|
return result
|
||||||
if kind == "logs.read":
|
if kind == "logs.read":
|
||||||
return self.read_log(instance_id, generation, payload)
|
return self.read_log(instance_id, generation, payload)
|
||||||
|
if kind == "files.manifest":
|
||||||
|
return managed_manifest(app, payload, self.stopping)
|
||||||
path = scoped(app, payload.get("path", ""), allow_root=kind in ("files.list", "files.stat"))
|
path = scoped(app, payload.get("path", ""), allow_root=kind in ("files.list", "files.stat"))
|
||||||
if kind == "files.read":
|
if kind == "files.read":
|
||||||
content = managed_read(app, payload["path"])
|
content = managed_read(app, payload["path"])
|
||||||
@@ -1967,7 +2118,7 @@ class Agent:
|
|||||||
return self.journal.update(operation["id"], "failed", error="Interrupted library download could not be resumed")
|
return self.journal.update(operation["id"], "failed", error="Interrupted library download could not be resumed")
|
||||||
if kind == "files.stage":
|
if kind == "files.stage":
|
||||||
return self.journal.update(operation["id"], "completed", self.stage(instance_id, generation, payload, operation["id"]))
|
return self.journal.update(operation["id"], "completed", self.stage(instance_id, generation, payload, operation["id"]))
|
||||||
if kind in ("host.inspect", "instance.inspect", "files.read", "files.list", "files.stat", "logs.read") or kind == "rcon.execute" and payload.get("command") in ("status", "getstatus", "version"):
|
if kind in ("host.inspect", "instance.inspect", "files.read", "files.list", "files.stat", "files.manifest", "logs.read") or kind == "rcon.execute" and payload.get("command") in ("status", "getstatus", "version"):
|
||||||
return self.journal.update(operation["id"], "completed", self.dispatch(operation))
|
return self.journal.update(operation["id"], "completed", self.dispatch(operation))
|
||||||
if kind in ("files.activate", "files.rollback") and checkpoint:
|
if kind in ("files.activate", "files.rollback") and checkpoint:
|
||||||
stage = self.instance_dir(instance_id, generation) / "stages" / identifier(payload["stageId"]) / "manifest.json"
|
stage = self.instance_dir(instance_id, generation) / "stages" / identifier(payload["stageId"]) / "manifest.json"
|
||||||
@@ -2054,7 +2205,7 @@ class Agent:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def read_operation(operation):
|
def read_operation(operation):
|
||||||
return operation.get("kind") in ("host.inspect", "instance.inspect", "files.read", "files.list", "files.stat", "logs.read") or (operation.get("kind") == "rcon.execute" and operation.get("payload", {}).get("command") in ("status", "getstatus", "version"))
|
return operation.get("kind") in ("host.inspect", "instance.inspect", "files.read", "files.list", "files.stat", "files.manifest", "logs.read") or (operation.get("kind") == "rcon.execute" and operation.get("payload", {}).get("command") in ("status", "getstatus", "version"))
|
||||||
|
|
||||||
def instance_lock(self, operation):
|
def instance_lock(self, operation):
|
||||||
key = operation.get("instanceId")
|
key = operation.get("instanceId")
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"version": "1.4.7",
|
"version": "1.4.8",
|
||||||
"protocolVersion": 1,
|
"protocolVersion": 1,
|
||||||
"platform": "debian-amd64",
|
"platform": "debian-amd64",
|
||||||
"sha256": "e9e1b3869d66eca351dadb045d99451b97c91d15c5b7199a3de3241b550de998",
|
"sha256": "45eb0ef619fb0ceef2ac843657fd7fdaf8cfa610951f0ddfabdbf5942abc6e8c",
|
||||||
"updaterProtocolVersion": 1,
|
"updaterProtocolVersion": 1,
|
||||||
"updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8"
|
"updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
|
||||||
|
const server = 't5_zombie_theater_1';
|
||||||
|
const scriptRoot = 'runtime/plutonium/storage/t5/raw/scripts/sp';
|
||||||
|
const pluginRoot = 'runtime/plutonium/plugins';
|
||||||
|
const hash = (content: Buffer) => createHash('sha256').update(content).digest('hex');
|
||||||
|
let fixture: Fixture;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
fixture = await newFixture([]);
|
||||||
|
});
|
||||||
|
afterEach(async () => { await fixture.cleanup(); vi.restoreAllMocks(); });
|
||||||
|
|
||||||
|
async function setup(capable = true) {
|
||||||
|
const hosts = await import('../../src/lib/server/hosts/index.js');
|
||||||
|
const host = hosts.createHost({ name: 'Drift manifest host' });
|
||||||
|
hosts.enrollHost(hosts.createEnrollment(host.id).token, {
|
||||||
|
protocolVersion: 1, capabilities: capable ? ['files.manifest'] : []
|
||||||
|
});
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const database = db();
|
||||||
|
database.prepare(`INSERT INTO servers(name,komodo_name,game,map,game_mode,map_slug,created_by)
|
||||||
|
VALUES(?,'','t5','zombie_theater','zombie','theater','ui')`).run(server);
|
||||||
|
database.prepare(`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at)
|
||||||
|
VALUES(?,?,?,'library','image',28960,?)`).run(randomUUID(), server, host.id, new Date().toISOString());
|
||||||
|
|
||||||
|
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
|
||||||
|
const { recordScript } = await import('../../src/lib/server/fleet/inventory.js');
|
||||||
|
const contents = new Map<string, Buffer>();
|
||||||
|
function promoted(kind: 'script' | 'plugin' | 'baseline', name: string, content: Buffer, targetRelPath?: string) {
|
||||||
|
const artifact = createScript({ name, kind, targetRelPath, scopeType: 'game', scopeValue: 't5', content });
|
||||||
|
database.prepare('UPDATE scripts SET current_version_id=? WHERE id=?').run(artifact.version.id, artifact.script.id);
|
||||||
|
return artifact;
|
||||||
|
}
|
||||||
|
const script = promoted('script', 'Managed script', Buffer.from('main() { wait 1; }'), 'managed.gsc');
|
||||||
|
const plugin = promoted('plugin', 'Managed plugin', Buffer.from('promoted plugin'), 'managed.dll');
|
||||||
|
const baseline = promoted('baseline', 'Baseline', Buffer.from('set sv_voice "1"\n'));
|
||||||
|
for (const [root, artifact] of [[scriptRoot, script], [pluginRoot, plugin]] as const) {
|
||||||
|
const { version } = artifact;
|
||||||
|
contents.set(`${root}/${artifact.script.target_rel_path}`, version.content);
|
||||||
|
recordScript(server, artifact.script.target_rel_path, version.id, version.sha256, version.size, 'test');
|
||||||
|
}
|
||||||
|
const { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } = await import('../../src/lib/server/fleet/server-config.js');
|
||||||
|
const config = getPromotedServerConfig('t5', 'zombie_theater', 'theater')!;
|
||||||
|
contents.set('configs/dedicated_sp.cfg', config);
|
||||||
|
recordScript(server, 'dedicated_sp.cfg', null, hash(config), config.length, 'test');
|
||||||
|
contents.set(`configs/${MAP_CONFIG_FILE}`, getServerMapConfig('t5', 'zombie_theater', 'theater')!);
|
||||||
|
// The runtime owns this generated file; neither scanner should classify it as drift.
|
||||||
|
contents.set('configs/dedicated.cfg', Buffer.from('runtime generated'));
|
||||||
|
|
||||||
|
const backend = await import('../../src/lib/server/backends/native.js');
|
||||||
|
const request = vi.spyOn(backend, 'nativeRequest').mockImplementation(async (_instance, kind, payload = {}) => {
|
||||||
|
if (kind === 'files.manifest') {
|
||||||
|
const roots = payload.roots as string[];
|
||||||
|
const ignored = payload.ignoreNames as string[];
|
||||||
|
return { files: [...contents].filter(([path]) => roots.some(root => path.startsWith(`${root}/`))
|
||||||
|
&& !path.split('/').some(part => ignored.includes(part)))
|
||||||
|
.map(([path, content]) => ({ path, size: content.length, sha256: hash(content) })) } as never;
|
||||||
|
}
|
||||||
|
if (capable) throw new Error(`Manifest scan requested an extra remote operation: ${kind}`);
|
||||||
|
const path = String(payload.path);
|
||||||
|
const content = contents.get(path);
|
||||||
|
if (kind === 'files.read') return { contentBase64: content?.toString('base64') ?? null } as never;
|
||||||
|
if (kind === 'files.stat') {
|
||||||
|
const isDirectory = path === '.' || [...contents.keys()].some(file => file.startsWith(`${path}/`));
|
||||||
|
return { exists: content !== undefined || isDirectory, size: content?.length ?? 0,
|
||||||
|
mtime: new Date().toISOString(), isDirectory, isFile: content !== undefined } as never;
|
||||||
|
}
|
||||||
|
if (kind === 'files.list') {
|
||||||
|
const entries = new Map<string, { name: string; isDirectory: boolean; isFile: boolean }>();
|
||||||
|
for (const file of contents.keys()) {
|
||||||
|
if (!file.startsWith(`${path}/`)) continue;
|
||||||
|
const [name, ...nested] = file.slice(path.length + 1).split('/');
|
||||||
|
entries.set(name, { name, isDirectory: nested.length > 0, isFile: nested.length === 0 });
|
||||||
|
}
|
||||||
|
return { entries: [...entries.values()] } as never;
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected remote operation: ${kind}`);
|
||||||
|
});
|
||||||
|
const drift = await import('../../src/lib/server/fleet/drift.js');
|
||||||
|
return { ...drift, database, request, contents, script, plugin, baseline, promoted, mapConfigFile: MAP_CONFIG_FILE };
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectOneManifest(request: Awaited<ReturnType<typeof setup>>['request']) {
|
||||||
|
expect(request).toHaveBeenCalledTimes(1);
|
||||||
|
expect(request.mock.calls[0][1]).toBe('files.manifest');
|
||||||
|
expect(request.mock.calls[0][2]).toEqual({
|
||||||
|
roots: [scriptRoot, 'configs', pluginRoot], ignoreNames: ['dedicated.cfg'],
|
||||||
|
paths: [`${scriptRoot}/managed.gsc`, `${pluginRoot}/managed.dll`, 'configs/dedicated_sp.cfg', 'configs/electric_cherry_map.cfg']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('native drift manifests', () => {
|
||||||
|
it('checks managed files, composed config, generated config, and untracked trees in one request', async () => {
|
||||||
|
const api = await setup();
|
||||||
|
expect(await api.scanServerDrift(server)).toEqual([]);
|
||||||
|
expectOneManifest(api.request);
|
||||||
|
|
||||||
|
api.request.mockClear();
|
||||||
|
expect(await api.prepareDriftCampaign(server)).toMatchObject({ plan: { files: [] }, files: [] });
|
||||||
|
expectOneManifest(api.request);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects changed, missing, and nested untracked files from the same manifest', async () => {
|
||||||
|
const api = await setup();
|
||||||
|
api.contents.set(`${scriptRoot}/managed.gsc`, Buffer.alloc(0));
|
||||||
|
api.contents.delete(`${pluginRoot}/managed.dll`);
|
||||||
|
api.contents.set('configs/dedicated_sp.cfg', Buffer.from('changed config'));
|
||||||
|
const stray = Buffer.from('untracked bytes');
|
||||||
|
api.contents.set(`${scriptRoot}/custom/stray.gsc`, stray);
|
||||||
|
|
||||||
|
const changes = await api.scanServerDrift(server);
|
||||||
|
expect(changes).toHaveLength(4);
|
||||||
|
expect(changes).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ kind: 'script', relPath: 'managed.gsc', driftKind: 'replace', sha256: api.script.version.sha256 }),
|
||||||
|
expect.objectContaining({ kind: 'plugin', relPath: 'managed.dll', driftKind: 'missing', sha256: api.plugin.version.sha256 }),
|
||||||
|
expect.objectContaining({ kind: 'config', relPath: 'dedicated_sp.cfg', driftKind: 'replace' }),
|
||||||
|
expect.objectContaining({ kind: 'script', relPath: 'custom/stray.gsc', driftKind: 'untracked', size: stray.length, sha256: hash(stray) })
|
||||||
|
]));
|
||||||
|
expectOneManifest(api.request);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still repairs missing inventory and generated map config', async () => {
|
||||||
|
const api = await setup();
|
||||||
|
api.database.prepare('DELETE FROM server_scripts WHERE server=? AND rel_path=?').run(server, 'managed.gsc');
|
||||||
|
api.contents.delete(`configs/${api.mapConfigFile}`);
|
||||||
|
expect(await api.scanServerDrift(server)).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ relPath: 'managed.gsc', driftKind: 'replace' }),
|
||||||
|
expect.objectContaining({ relPath: api.mapConfigFile, driftKind: 'missing' })
|
||||||
|
]));
|
||||||
|
expectOneManifest(api.request);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compares explicitly managed ignored filenames without treating runtime-owned files as untracked', async () => {
|
||||||
|
const api = await setup();
|
||||||
|
api.promoted('plugin', 'Managed ignored name', Buffer.from('expected'), 'dedicated.cfg');
|
||||||
|
api.contents.set(`${pluginRoot}/dedicated.cfg`, Buffer.from('changed'));
|
||||||
|
expect(await api.scanServerDrift(server)).toEqual([
|
||||||
|
expect.objectContaining({ kind: 'plugin', relPath: 'dedicated.cfg', driftKind: 'replace' })
|
||||||
|
]);
|
||||||
|
expect(api.request).toHaveBeenCalledTimes(1);
|
||||||
|
expect(api.request.mock.calls[0][2]).toMatchObject({ ignoreNames: [], paths: expect.arrayContaining([`${pluginRoot}/dedicated.cfg`]) });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['running', 'ready-promote'])('skips remote reads while a %s beta owns the server', async state => {
|
||||||
|
const api = await setup();
|
||||||
|
const beta = api.database.prepare("INSERT INTO betas(script_id,script_version_id,trigger_kind,state) VALUES(?,?,'manual',?)")
|
||||||
|
.run(api.script.script.id, api.script.version.id, state).lastInsertRowid;
|
||||||
|
api.database.prepare("INSERT INTO beta_servers(beta_id,server,had_original,stage) VALUES(?,?,0,'awaiting-empty')").run(beta, server);
|
||||||
|
expect(await api.scanServerDrift(server)).toEqual([]);
|
||||||
|
expect(api.request).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves unavailable evidence as a failed scan without falling back to per-file reads', async () => {
|
||||||
|
const api = await setup();
|
||||||
|
api.request.mockRejectedValueOnce(new Error('Agent manifest unavailable'));
|
||||||
|
await expect(api.scanServerDrift(server)).rejects.toThrow('Agent manifest unavailable');
|
||||||
|
expectOneManifest(api.request);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a malformed manifest instead of treating it as an empty tree', async () => {
|
||||||
|
const api = await setup();
|
||||||
|
api.request.mockResolvedValueOnce({} as never);
|
||||||
|
await expect(api.scanServerDrift(server)).rejects.toThrow();
|
||||||
|
expectOneManifest(api.request);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps legacy agents on the existing per-file scan and detects their drift', async () => {
|
||||||
|
const api = await setup(false);
|
||||||
|
api.contents.set(`${scriptRoot}/managed.gsc`, Buffer.from('old script'));
|
||||||
|
api.contents.set('configs/stray.cfg', Buffer.from('untracked config'));
|
||||||
|
expect(await api.scanServerDrift(server)).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ relPath: 'managed.gsc', driftKind: 'replace' }),
|
||||||
|
expect.objectContaining({ relPath: 'stray.cfg', driftKind: 'untracked' })
|
||||||
|
]));
|
||||||
|
const operations = api.request.mock.calls.map(([, kind]) => kind);
|
||||||
|
expect(operations).not.toContain('files.manifest');
|
||||||
|
expect(operations).toEqual(expect.arrayContaining(['files.stat', 'files.list', 'files.read']));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -119,7 +119,7 @@ describe('native host enrollment and scoped authentication', () => {
|
|||||||
|
|
||||||
it('separates observation and mutation delivery and wakes long polls on newly queued work', async () => {
|
it('separates observation and mutation delivery and wakes long polls on newly queued work', async () => {
|
||||||
const api = await enrolled();
|
const api = await enrolled();
|
||||||
const inspect = api.queueHostOperation(api.host.id, 'host.inspect', {});
|
const inspect = api.queueHostOperation(api.host.id, 'files.manifest', { roots: ['configs'] });
|
||||||
const restart = api.queueHostOperation(api.host.id, 'instance.restart', {});
|
const restart = api.queueHostOperation(api.host.id, 'instance.restart', {});
|
||||||
const status = api.queueHostOperation(api.host.id, 'rcon.execute', { command: 'status' });
|
const status = api.queueHostOperation(api.host.id, 'rcon.execute', { command: 'status' });
|
||||||
const command = api.queueHostOperation(api.host.id, 'rcon.execute', { command: 'map_restart' });
|
const command = api.queueHostOperation(api.host.id, 'rcon.execute', { command: 'map_restart' });
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ async function setup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('overlapping native observations', () => {
|
describe('overlapping native observations', () => {
|
||||||
it.each(['files.read', 'files.list', 'files.stat', 'logs.read', 'instance.inspect'])('shares %s only until completion', async kind => {
|
it.each(['files.read', 'files.list', 'files.stat', 'files.manifest', 'logs.read', 'instance.inspect'])('shares %s only until completion', async kind => {
|
||||||
const api = await setup();
|
const api = await setup();
|
||||||
const requests = Array.from({ length: 4 }, () => api.nativeRequest(api.instance, kind, { path: 'same' }));
|
const requests = Array.from({ length: 4 }, () => api.nativeRequest(api.instance, kind, { path: 'same' }));
|
||||||
const operations = api.listHostOperations(api.host.id);
|
const operations = api.listHostOperations(api.host.id);
|
||||||
|
|||||||
Reference in New Issue
Block a user