Automatically remove unused game-file versions
This commit is contained in:
+35
-2
@@ -32,7 +32,7 @@ import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
VERSION = "1.4.6"
|
||||
VERSION = "1.4.7"
|
||||
LABEL = "io.electric-cherry"
|
||||
MAX_FILE = 32 * 1024 * 1024
|
||||
LIBRARY_DOWNLOAD_WORKERS = 4
|
||||
@@ -1094,7 +1094,7 @@ class Agent:
|
||||
if line.startswith("PRETTY_NAME="):
|
||||
os_name = line.split("=", 1)[1].strip('"')
|
||||
update = self.update_inventory()
|
||||
capabilities = ["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"] + (["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})}
|
||||
|
||||
def safe_to_disrupt(self, instance_id, generation):
|
||||
@@ -1722,6 +1722,35 @@ class Agent:
|
||||
self.publish_library_progress(payload, timing)
|
||||
raise
|
||||
|
||||
def library_remove(self, payload):
|
||||
library_id = identifier(payload.get("libraryVersionId"))
|
||||
directory = self.library_dir(payload.get("game"), library_id)
|
||||
staging = directory.with_name("." + library_id + ".partial")
|
||||
# Never follow substituted storage parents outside the managed tree.
|
||||
for path in (self.root / "libraries", directory.parent, directory, staging):
|
||||
if path.is_symlink():
|
||||
raise Rejected("Refusing library cleanup through a symlink")
|
||||
# Include stopped and unmanaged containers: any bind mount pins the files.
|
||||
# Docker failures must stop cleanup, never masquerade as an empty host.
|
||||
for container_id in self.docker.call("container", "ls", "--all", "--quiet").splitlines():
|
||||
container = self.docker.inspect(container_id)
|
||||
if container is None:
|
||||
raise Rejected("Container inventory changed during library cleanup")
|
||||
for mount in container["Mounts"]:
|
||||
if not mount.get("Source"): # tmpfs has no host filesystem source.
|
||||
continue
|
||||
source = Path(mount["Source"]).resolve()
|
||||
for target in (directory.resolve(), staging.resolve()):
|
||||
if source == target or target in source.parents or source in target.parents:
|
||||
raise Rejected("Game files are still mounted by a container")
|
||||
self.checkpoint({"phase": "removing-library"})
|
||||
for path in (directory, staging):
|
||||
if path.exists():
|
||||
shutil.rmtree(path)
|
||||
self.library_progress.pop(library_id, None)
|
||||
self.library_transfers.pop(library_id, None)
|
||||
return {"libraryVersionId": library_id, "removed": True}
|
||||
|
||||
def library_import(self, payload):
|
||||
game = payload.get("game")
|
||||
volume = payload.get("sourceVolume")
|
||||
@@ -1803,6 +1832,8 @@ class Agent:
|
||||
return self.inventory()
|
||||
if kind == "library.download":
|
||||
return self.library_download(payload)
|
||||
if kind == "library.remove":
|
||||
return self.library_remove(payload)
|
||||
if kind == "library.import":
|
||||
return self.library_import(payload)
|
||||
if kind == "library.inspect":
|
||||
@@ -1923,6 +1954,8 @@ class Agent:
|
||||
return self.journal.update(operation["id"], "unknown", error="Import reconciliation is waiting for controller contact")
|
||||
finally:
|
||||
self.executing_id = None
|
||||
if kind == "library.remove":
|
||||
return self.journal.update(operation["id"], "completed", self.library_remove(payload))
|
||||
if kind == "library.download":
|
||||
# This is a resumable immutable transfer, not a repeated
|
||||
# container command. Full files skip transfer; verification
|
||||
|
||||
@@ -74,6 +74,56 @@ class AgentTests(unittest.TestCase):
|
||||
self.agent.journal.close()
|
||||
self.temp.cleanup()
|
||||
|
||||
def test_library_remove_preserves_other_versions_and_is_idempotent(self):
|
||||
other = self.agent.library_dir("t5", "other-library")
|
||||
other.mkdir()
|
||||
staging = self.library.with_name("." + self.library_id + ".partial")
|
||||
staging.mkdir()
|
||||
(staging / "partial").write_bytes(b"partial")
|
||||
payload = {"libraryVersionId": self.library_id, "game": "t5"}
|
||||
self.assertTrue(self.agent.library_remove(payload)["removed"])
|
||||
self.assertFalse(self.library.exists())
|
||||
self.assertFalse(staging.exists())
|
||||
self.assertTrue(other.exists())
|
||||
self.assertTrue(self.agent.library_remove(payload)["removed"])
|
||||
|
||||
def test_library_remove_refuses_stopped_container_mounts_and_docker_errors(self):
|
||||
payload = {"libraryVersionId": self.library_id, "game": "t5"}
|
||||
with patch.object(self.docker, "call", return_value="stopped-container"), patch.object(self.docker, "inspect", return_value={
|
||||
"State": {"Running": False}, "Mounts": [{"Source": str(self.library / "content")}]
|
||||
}):
|
||||
with self.assertRaisesRegex(AgentError, "still mounted"):
|
||||
self.agent.library_remove(payload)
|
||||
with patch.object(self.docker, "call", side_effect=AgentError("Docker unavailable")):
|
||||
with self.assertRaises(AgentError):
|
||||
self.agent.library_remove(payload)
|
||||
self.assertTrue((self.library / "content" / "game.ff").exists())
|
||||
|
||||
def test_library_remove_recovers_after_interrupted_deletion(self):
|
||||
payload = {"libraryVersionId": self.library_id, "game": "t5"}
|
||||
operation = {"id": "library-cleanup", "kind": "library.remove", "payload": payload}
|
||||
self.agent.journal.accept(operation)
|
||||
self.agent.journal.update(operation["id"], "running")
|
||||
self.agent.journal.checkpoint(operation["id"], {"phase": "removing-library"})
|
||||
(self.library / "manifest.json").unlink()
|
||||
row = self.agent.reconcile(operation, self.agent.journal.get(operation["id"]))
|
||||
self.assertEqual(row["state"], "completed")
|
||||
self.assertFalse(self.library.exists())
|
||||
|
||||
def test_library_remove_ignores_unrelated_and_tmpfs_mounts(self):
|
||||
with patch.object(self.docker, "call", return_value="other-container"), patch.object(self.docker, "inspect", return_value={
|
||||
"Mounts": [{"Type": "tmpfs", "Source": ""}, {"Source": str(self.agent.root / "instances")}]
|
||||
}):
|
||||
self.agent.library_remove({"libraryVersionId": self.library_id, "game": "t5"})
|
||||
self.assertFalse(self.library.exists())
|
||||
|
||||
def test_library_remove_refuses_symlink_storage(self):
|
||||
linked = self.agent.library_dir("t5", "linked-library")
|
||||
linked.symlink_to(self.library, target_is_directory=True)
|
||||
with self.assertRaisesRegex(AgentError, "symlink"):
|
||||
self.agent.library_remove({"libraryVersionId": "linked-library", "game": "t5"})
|
||||
self.assertTrue(self.library.exists())
|
||||
|
||||
def cpu_sample(self, line):
|
||||
with patch.object(Path, "open", mock_open(read_data=line)):
|
||||
return self.agent.cpu_usage_percent()
|
||||
|
||||
Reference in New Issue
Block a user