Read chat logs incrementally and drain retention (Refs #62)
This commit is contained in:
+30
-2
@@ -35,7 +35,7 @@ import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
VERSION = "1.8.4"
|
||||
VERSION = "1.8.5"
|
||||
LABEL = "io.electric-cherry"
|
||||
MAX_FILE = 32 * 1024 * 1024
|
||||
MANIFEST_MAX_ENTRIES = 10000
|
||||
@@ -1497,7 +1497,7 @@ class Agent:
|
||||
if line.startswith("PRETTY_NAME="):
|
||||
os_name = line.split("=", 1)[1].strip('"')
|
||||
update = self.update_inventory()
|
||||
capabilities = ["library.remove", "plutonium.cache", "instance.cancel-create", "instance.safety-override", "instance.unhealthy-recovery", "instance.autoscaling-remove", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest", "files.blobs"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
|
||||
capabilities = ["library.remove", "plutonium.cache", "instance.cancel-create", "instance.safety-override", "instance.unhealthy-recovery", "instance.autoscaling-remove", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest", "files.blobs", "logs.read-offset"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
|
||||
plutonium_cache = self.plutonium_cache_inventory()
|
||||
return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "agentLanes": self.lane_inventory(), "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)), **({"plutoniumCache": plutonium_cache} if plutonium_cache else {}), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})}
|
||||
|
||||
@@ -2231,6 +2231,8 @@ class Agent:
|
||||
# swapping either the original link or a target component cannot escape.
|
||||
with managed_open(app, relative) as stream:
|
||||
stat = os.fstat(stream.fileno())
|
||||
if payload.get("mode") == "incremental":
|
||||
return self.read_log_chunk(stream, stat, payload.get("cursor"))
|
||||
start = max(0, stat.st_size - 256 * 1024)
|
||||
stream.seek(start)
|
||||
lines = stream.read(256 * 1024).decode("utf-8", "replace").splitlines()
|
||||
@@ -2238,6 +2240,32 @@ class Agent:
|
||||
lines = lines[1:]
|
||||
return {"lines": lines[-count:], "size": stat.st_size, "mtime": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(stat.st_mtime)), "truncated": start > 0}
|
||||
|
||||
@staticmethod
|
||||
def read_log_chunk(stream, stat, cursor):
|
||||
file_id, modified = f"{stat.st_dev}:{stat.st_ino}", f"{stat.st_mtime_ns}:{stat.st_ctime_ns}"
|
||||
size = stat.st_size
|
||||
mtime = datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat()
|
||||
if cursor is not None and (not isinstance(cursor, dict)
|
||||
or any(type(cursor.get(key)) is not int or cursor[key] < 0 for key in ("offset", "size", "anchorLength"))
|
||||
or cursor["anchorLength"] > min(256, cursor["offset"])
|
||||
or any(not isinstance(cursor.get(key), str) for key in ("fileId", "modified", "anchorHash"))):
|
||||
raise AgentError("Invalid log cursor")
|
||||
if cursor and cursor["fileId"] == file_id and cursor["modified"] == modified and cursor["size"] == size and cursor["offset"] == size:
|
||||
return {"contentBase64": "", "size": size, "mtime": mtime, "truncated": size > 0, "cursor": cursor, "reset": False}
|
||||
reset = not cursor or cursor["fileId"] != file_id or cursor["offset"] > size
|
||||
if not reset:
|
||||
stream.seek(cursor["offset"] - cursor["anchorLength"])
|
||||
anchor = stream.read(cursor["anchorLength"])
|
||||
reset = len(anchor) != cursor["anchorLength"] or digest(anchor) != cursor["anchorHash"]
|
||||
start = max(0, size - 256 * 1024) if reset else cursor["offset"]
|
||||
stream.seek(start)
|
||||
content = stream.read(min(256 * 1024, size - start))
|
||||
offset = start + len(content)
|
||||
stream.seek(max(0, offset - 256))
|
||||
anchor = stream.read(min(256, offset))
|
||||
return {"contentBase64": base64.b64encode(content).decode(), "size": size, "mtime": mtime, "truncated": start > 0, "reset": reset,
|
||||
"cursor": {"fileId": file_id, "modified": modified, "size": size, "offset": offset, "anchorLength": len(anchor), "anchorHash": digest(anchor)}}
|
||||
|
||||
def library_dir(self, game, library_id):
|
||||
if game not in ("t5", "t6"):
|
||||
raise AgentError("Unsupported library game")
|
||||
|
||||
@@ -3,6 +3,7 @@ import importlib.util
|
||||
import http.client
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import threading
|
||||
@@ -2699,6 +2700,71 @@ class AgentTests(unittest.TestCase):
|
||||
self.agent.rcon = lambda *_: {"ok": True, "players": 0}
|
||||
self.assertEqual(self.agent.execute(operation)["state"], "completed")
|
||||
|
||||
def test_incremental_log_reads_append_bytes_without_rereading_unchanged_content(self):
|
||||
self.create()
|
||||
app = self.agent.instance_dir(INSTANCE, 1) / "app"
|
||||
(app / "logs").mkdir()
|
||||
path = app / "logs/games_sp.log"
|
||||
path.write_bytes(b"first\n")
|
||||
payload = {"path": "logs/games_sp.log", "mode": "incremental"}
|
||||
result = self.agent.execute(self.operation("logs.read", payload))
|
||||
self.assertEqual(result["state"], "completed")
|
||||
first = json.loads(result["result"])
|
||||
self.assertEqual(base64.b64decode(first["contentBase64"]), b"first\n")
|
||||
self.assertTrue(first["reset"])
|
||||
# An unchanged stat must not read even the cursor anchor.
|
||||
with path.open("rb") as stream, patch.object(stream, "read", side_effect=AssertionError("unchanged file was read")):
|
||||
unchanged = self.agent.read_log_chunk(stream, os.fstat(stream.fileno()), first["cursor"])
|
||||
self.assertEqual(unchanged["contentBase64"], "")
|
||||
with path.open("ab") as stream:
|
||||
stream.write("café\n".encode())
|
||||
appended = self.agent.read_log(INSTANCE, 1, {**payload, "cursor": first["cursor"]})
|
||||
self.assertEqual(base64.b64decode(appended["contentBase64"]), "café\n".encode())
|
||||
self.assertFalse(appended["reset"])
|
||||
self.assertEqual(appended["cursor"]["offset"], len("first\ncafé\n".encode()))
|
||||
self.assertIn("logs.read-offset", self.agent.inventory()["capabilities"])
|
||||
|
||||
def test_incremental_log_rotation_and_copy_truncate_reset_the_cursor(self):
|
||||
self.create()
|
||||
app = self.agent.instance_dir(INSTANCE, 1) / "app"
|
||||
(app / "logs").mkdir()
|
||||
path = app / "logs/games_sp.log"
|
||||
header = b"same game header\n" * 30
|
||||
path.write_bytes(header + b"original\n")
|
||||
payload = {"path": "logs/games_sp.log", "mode": "incremental"}
|
||||
result = self.agent.read_log(INSTANCE, 1, payload)
|
||||
path.rename(path.with_suffix(".old"))
|
||||
for content in (header + b"rotated!\n", header + b"rewritten and longer\n", b"short\n", b""):
|
||||
with self.subTest(content=content):
|
||||
path.write_bytes(content)
|
||||
result = self.agent.read_log(INSTANCE, 1, {**payload, "cursor": result["cursor"]})
|
||||
self.assertTrue(result["reset"])
|
||||
self.assertEqual(base64.b64decode(result["contentBase64"]), content)
|
||||
|
||||
def test_incremental_log_backlogs_are_bounded_and_preserve_split_utf8_bytes(self):
|
||||
self.create()
|
||||
app = self.agent.instance_dir(INSTANCE, 1) / "app"
|
||||
(app / "logs").mkdir()
|
||||
path = app / "logs/games_sp.log"
|
||||
window = 256 * 1024
|
||||
path.write_bytes(b"x" * (window + 10))
|
||||
payload = {"path": "logs/games_sp.log", "mode": "incremental"}
|
||||
initial = self.agent.read_log(INSTANCE, 1, payload)
|
||||
self.assertTrue(initial["truncated"])
|
||||
self.assertEqual(len(base64.b64decode(initial["contentBase64"])), window)
|
||||
data = b"y" * (window - 1) + "é\n".encode()
|
||||
with path.open("ab") as stream:
|
||||
stream.write(data)
|
||||
first = self.agent.read_log(INSTANCE, 1, {**payload, "cursor": initial["cursor"]})
|
||||
second = self.agent.read_log(INSTANCE, 1, {**payload, "cursor": first["cursor"]})
|
||||
self.assertFalse(first["reset"])
|
||||
self.assertEqual(len(base64.b64decode(first["contentBase64"])), window)
|
||||
self.assertEqual(base64.b64decode(first["contentBase64"]) + base64.b64decode(second["contentBase64"]), data)
|
||||
self.assertEqual(second["cursor"]["offset"], second["size"])
|
||||
for cursor in ({}, {**second["cursor"], "offset": -1}, {**second["cursor"], "anchorLength": 257}, {**second["cursor"], "offset": True}):
|
||||
with self.subTest(cursor=cursor), self.assertRaisesRegex(AgentError, "Invalid log cursor"):
|
||||
self.agent.read_log(INSTANCE, 1, {**payload, "cursor": cursor})
|
||||
|
||||
def test_game_log_can_follow_internal_relative_plutainer_symlink(self):
|
||||
self.create()
|
||||
app = self.agent.instance_dir(INSTANCE, 1) / "app"
|
||||
@@ -2709,6 +2775,8 @@ class AgentTests(unittest.TestCase):
|
||||
result = self.agent.execute(self.operation("logs.read", {"path": "logs/games_sp.log", "lines": 2}))
|
||||
self.assertEqual(result["state"], "completed")
|
||||
self.assertEqual(json.loads(result["result"])["lines"], ["second", "third"])
|
||||
incremental = self.agent.read_log(INSTANCE, 1, {"path": "logs/games_sp.log", "mode": "incremental"})
|
||||
self.assertEqual(base64.b64decode(incremental["contentBase64"]), b"first\nsecond\nthird\n")
|
||||
with self.assertRaises(AgentError):
|
||||
module.managed_write(app, "logs/games_sp.log", b"must-not-write-through-link")
|
||||
|
||||
@@ -2722,6 +2790,8 @@ class AgentTests(unittest.TestCase):
|
||||
result = self.agent.execute(self.operation("logs.read", {"path": "logs/games_sp.log"}))
|
||||
self.assertEqual(result["state"], "failed")
|
||||
self.assertIn("outside", result["error"])
|
||||
with self.assertRaisesRegex(AgentError, "outside"):
|
||||
self.agent.read_log(INSTANCE, 1, {"path": "logs/games_sp.log", "mode": "incremental"})
|
||||
|
||||
def test_console_log_falls_back_to_owned_docker_output_including_stderr(self):
|
||||
self.create()
|
||||
|
||||
Reference in New Issue
Block a user