Add cancellation and cleanup for native server creation
This commit is contained in:
@@ -30,7 +30,7 @@ import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
VERSION = "1.2.2"
|
||||
VERSION = "1.3.0"
|
||||
LABEL = "io.electric-cherry"
|
||||
MAX_FILE = 32 * 1024 * 1024
|
||||
LIBRARY_DOWNLOAD_WORKERS = 4
|
||||
@@ -672,6 +672,7 @@ class Agent:
|
||||
self.docker = docker or Docker()
|
||||
self.stopping = threading.Event()
|
||||
self.current_operation = None
|
||||
self.cancelled_creations = frozenset()
|
||||
self.library_progress = {}
|
||||
self.library_transfers = {}
|
||||
self.config_path = config.get("configPath", "/etc/electric-cherry/agent.json")
|
||||
@@ -952,7 +953,7 @@ class Agent:
|
||||
if line.startswith("PRETTY_NAME="):
|
||||
os_name = line.split("=", 1)[1].strip('"')
|
||||
update = self.update_inventory()
|
||||
capabilities = (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
|
||||
capabilities = ["instance.cancel-create"] + (["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, "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):
|
||||
@@ -1040,6 +1041,7 @@ class Agent:
|
||||
return game, library, image, port, bind, env
|
||||
|
||||
def create(self, instance_id, generation, payload):
|
||||
self.check_creation_cancellation()
|
||||
directory = self.instance_dir(instance_id, generation)
|
||||
current_marker = directory.parent / "current.json"
|
||||
if current_marker.exists():
|
||||
@@ -1068,12 +1070,19 @@ class Agent:
|
||||
for protocol in ("udp", "tcp"):
|
||||
args.extend(["--publish", f"{bind_arg}:{port}:{port}/{protocol}"])
|
||||
args.append(image)
|
||||
self.check_creation_cancellation()
|
||||
self.checkpoint({"phase": "creating", "generation": generation, "specSha256": spec_sha})
|
||||
self.docker.call(*args, timeout=120)
|
||||
self.check_creation_cancellation()
|
||||
self.docker.call("container", "start", name)
|
||||
self.check_creation_cancellation()
|
||||
atomic_json(current_marker, {"generation": generation})
|
||||
return self.inspect(instance_id, generation)
|
||||
|
||||
def check_creation_cancellation(self):
|
||||
if self.executing_id and self.executing_id in self.cancelled_creations:
|
||||
raise AgentError("Creation cancelled; waiting for instance cleanup")
|
||||
|
||||
def write_initial_files(self, root, files):
|
||||
if not isinstance(files, list) or len(files) > 10000:
|
||||
raise AgentError("Invalid file list")
|
||||
@@ -1093,6 +1102,8 @@ class Agent:
|
||||
return content
|
||||
|
||||
def lifecycle(self, kind, instance_id, generation, payload):
|
||||
if kind == "instance.cancel-create":
|
||||
return self.cancel_creation(instance_id, generation, payload)
|
||||
if kind == "instance.create":
|
||||
return self.create(instance_id, generation, payload)
|
||||
if kind == "instance.inspect":
|
||||
@@ -1146,6 +1157,38 @@ class Agent:
|
||||
raise AgentError("Unsupported instance operation")
|
||||
return {**self.inspect(instance_id, generation), "before": before}
|
||||
|
||||
def cancel_creation(self, instance_id, generation, payload):
|
||||
"""Explicit launch cancellation, fenced to its immutable creation identity.
|
||||
|
||||
The operation loop serializes this with create. Once delivered, removal
|
||||
needs no RCON response and a durable tombstone prevents a delayed create.
|
||||
Repeating this cleanup after an ambiguous Docker result is safe.
|
||||
"""
|
||||
create_id = identifier(payload.get("createOperationId"))
|
||||
marker = self.instance_dir(instance_id, generation).parent / "current.json"
|
||||
if marker.exists() and json.loads(marker.read_text())["generation"] > generation:
|
||||
raise Rejected("Cancelled creation was superseded by a newer generation")
|
||||
saved = self.journal.db.execute("SELECT request FROM operations WHERE id=?", (create_id,)).fetchone()
|
||||
if saved:
|
||||
original = json.loads(saved["request"])
|
||||
if original["kind"] != "instance.create" or original["instanceId"] != instance_id or original["generation"] != generation:
|
||||
raise Rejected("Cancellation does not match the original creation")
|
||||
health = self.inspect(instance_id, generation)
|
||||
if health["exists"] and health.get("creationOperation") != create_id:
|
||||
raise Rejected("Container belongs to a different creation operation")
|
||||
self.checkpoint({"phase": "cancelling-creation", "createOperationId": create_id})
|
||||
# Fence before deleting: a crash must never permit the old creation to run again.
|
||||
self.mark_removed(instance_id, generation)
|
||||
if health["exists"]:
|
||||
self.docker.call("container", "rm", "--force", "--volumes", self.container_name(instance_id, generation))
|
||||
after = self.inspect(instance_id, generation)
|
||||
if after["exists"]:
|
||||
raise AgentError("Cancelled creation container still exists")
|
||||
directory = self.instance_dir(instance_id, generation)
|
||||
if directory.exists():
|
||||
shutil.rmtree(directory)
|
||||
return {**after, "cancelledCreation": create_id}
|
||||
|
||||
def stage(self, instance_id, generation, payload, operation_id):
|
||||
directory = self.instance_dir(instance_id, generation)
|
||||
self.spec(instance_id, generation)
|
||||
@@ -1609,7 +1652,7 @@ class Agent:
|
||||
current_marker = self.instance_dir(instance_id, generation).parent / "current.json"
|
||||
if current_marker.exists():
|
||||
current = json.loads(current_marker.read_text())
|
||||
if current["generation"] > generation or current["generation"] == generation and current.get("removed") and kind not in ("instance.inspect", "instance.remove"):
|
||||
if current["generation"] > generation or current["generation"] == generation and current.get("removed") and kind not in ("instance.inspect", "instance.remove", "instance.cancel-create"):
|
||||
raise AgentError("Instance generation has been superseded or removed")
|
||||
if kind.startswith("instance."):
|
||||
return self.lifecycle(kind, instance_id, generation, payload)
|
||||
@@ -1673,6 +1716,14 @@ class Agent:
|
||||
instance_id, generation = operation.get("instanceId"), operation.get("generation")
|
||||
checkpoint = json.loads(row["checkpoint"]) if row.get("checkpoint") else {}
|
||||
try:
|
||||
if kind == "instance.cancel-create":
|
||||
return self.journal.update(operation["id"], "completed", self.cancel_creation(instance_id, generation, payload))
|
||||
if kind == "instance.create":
|
||||
marker = self.instance_dir(instance_id, generation).parent / "current.json"
|
||||
if marker.exists():
|
||||
current = json.loads(marker.read_text())
|
||||
if current["generation"] > generation or current["generation"] == generation and current.get("removed"):
|
||||
return self.journal.update(operation["id"], "failed", error="Creation was cancelled or superseded")
|
||||
if kind == "host.decommission":
|
||||
self.executing_id = operation["id"]
|
||||
try:
|
||||
@@ -1792,6 +1843,9 @@ class Agent:
|
||||
|
||||
def heartbeat(self):
|
||||
response = self.request("POST", "/api/agent/heartbeat", {"inventory": self.inventory(), "operationId": self.current_operation})
|
||||
# Only the heartbeat thread replaces this immutable snapshot; it never
|
||||
# mutates the operation journal or races Docker cleanup against create.
|
||||
self.cancelled_creations = frozenset(value for value in response.get("cancelledCreations", []) if isinstance(value, str))
|
||||
host = response.get("host", {})
|
||||
if isinstance(host.get("portMin"), int) and isinstance(host.get("portMax"), int):
|
||||
self.config["portRange"] = {"min": host["portMin"], "max": host["portMax"]}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": "1.2.2",
|
||||
"version": "1.3.0",
|
||||
"protocolVersion": 1,
|
||||
"platform": "debian-amd64",
|
||||
"sha256": "c266a8099130051ee540cdc3b9cdc7e42144fa67e21cce76eea6cf0a426bf40d",
|
||||
"sha256": "a698bdbba3716726a5a63fbd18392b53bd4c1e0c4cbaca94eabd66d8ca328dc2",
|
||||
"updaterProtocolVersion": 1,
|
||||
"updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user