Rebuild beta and campaign file activations
This commit is contained in:
+45
-12
@@ -35,7 +35,7 @@ import urllib.parse
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
VERSION = "1.9.1"
|
VERSION = "1.9.2"
|
||||||
LABEL = "io.electric-cherry"
|
LABEL = "io.electric-cherry"
|
||||||
MAX_FILE = 32 * 1024 * 1024
|
MAX_FILE = 32 * 1024 * 1024
|
||||||
MANIFEST_MAX_ENTRIES = 10000
|
MANIFEST_MAX_ENTRIES = 10000
|
||||||
@@ -1504,7 +1504,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", "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", "bank.settlement"] + (["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.rebuild-files", "instance.autoscaling-remove", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest", "files.blobs", "logs.read-offset", "bank.settlement"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
|
||||||
plutonium_cache = self.plutonium_cache_inventory()
|
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)), "preparedRuntimeImages": sorted(set(prepared_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})}
|
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)), "preparedRuntimeImages": sorted(set(prepared_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})}
|
||||||
|
|
||||||
@@ -1889,7 +1889,7 @@ class Agent:
|
|||||||
managed_write(app, marker, raw, uid, gid)
|
managed_write(app, marker, raw, uid, gid)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def create(self, instance_id, generation, payload):
|
def create(self, instance_id, generation, payload, *, before=None):
|
||||||
self.operation_phase = "checking-instance"
|
self.operation_phase = "checking-instance"
|
||||||
self.check_creation_cancellation()
|
self.check_creation_cancellation()
|
||||||
directory = self.instance_dir(instance_id, generation)
|
directory = self.instance_dir(instance_id, generation)
|
||||||
@@ -1930,7 +1930,7 @@ class Agent:
|
|||||||
args.append(image_id)
|
args.append(image_id)
|
||||||
self.check_creation_cancellation()
|
self.check_creation_cancellation()
|
||||||
self.operation_phase = "creating-container"
|
self.operation_phase = "creating-container"
|
||||||
self.checkpoint({"phase": "creating", "generation": generation, "specSha256": spec_sha})
|
self.checkpoint({"phase": "creating", "generation": generation, "specSha256": spec_sha, **({"before": before} if before is not None else {})})
|
||||||
self.docker.call(*args, timeout=120)
|
self.docker.call(*args, timeout=120)
|
||||||
self.check_creation_cancellation()
|
self.check_creation_cancellation()
|
||||||
self.operation_phase = "starting-container"
|
self.operation_phase = "starting-container"
|
||||||
@@ -2126,7 +2126,11 @@ class Agent:
|
|||||||
self.safe_to_disrupt(instance_id, generation, allow_unhealthy=unhealthy_recovery)
|
self.safe_to_disrupt(instance_id, generation, allow_unhealthy=unhealthy_recovery)
|
||||||
if "autoscaling" in payload:
|
if "autoscaling" in payload:
|
||||||
self.authorize_autoscaling_removal(instance_id, generation, payload)
|
self.authorize_autoscaling_removal(instance_id, generation, payload)
|
||||||
self.checkpoint({"phase": "disrupting", "containerId": health.get("containerId"), "startedAt": health.get("startedAt")})
|
if kind == "instance.rebuild" and payload.get("stageId"):
|
||||||
|
before = self.rebuild_before(instance_id, generation, payload["stageId"], spec)
|
||||||
|
if not override_safety:
|
||||||
|
self.safe_to_disrupt(instance_id, generation)
|
||||||
|
self.checkpoint({"phase": "disrupting", "containerId": health.get("containerId"), "startedAt": health.get("startedAt"), **({"before": before} if kind == "instance.rebuild" and payload.get("stageId") else {})})
|
||||||
if payload.get("stageId") and kind == "instance.restart":
|
if payload.get("stageId") and kind == "instance.restart":
|
||||||
before = self.activate(instance_id, generation, payload["stageId"], override_safety=override_safety)
|
before = self.activate(instance_id, generation, payload["stageId"], override_safety=override_safety)
|
||||||
if kind in ("instance.remove", "instance.rebuild"):
|
if kind in ("instance.remove", "instance.rebuild"):
|
||||||
@@ -2135,12 +2139,12 @@ class Agent:
|
|||||||
self.docker.call("container", "rm", name)
|
self.docker.call("container", "rm", name)
|
||||||
self.settle_bank(instance_id, generation)
|
self.settle_bank(instance_id, generation)
|
||||||
if kind == "instance.rebuild":
|
if kind == "instance.rebuild":
|
||||||
result = self.create(instance_id, next_generation, spec)
|
result = self.create(instance_id, next_generation, spec, before=before if payload.get("stageId") else None)
|
||||||
self.operation_phase = "removing-old-instance"
|
self.operation_phase = "removing-old-instance"
|
||||||
old = self.instance_dir(instance_id, generation)
|
old = self.instance_dir(instance_id, generation)
|
||||||
if old.exists():
|
if old.exists():
|
||||||
shutil.rmtree(old, ignore_errors=False)
|
shutil.rmtree(old, ignore_errors=False)
|
||||||
return result
|
return {**result, "before": before}
|
||||||
directory = self.instance_dir(instance_id, generation)
|
directory = self.instance_dir(instance_id, generation)
|
||||||
if directory.exists():
|
if directory.exists():
|
||||||
shutil.rmtree(directory)
|
shutil.rmtree(directory)
|
||||||
@@ -2235,6 +2239,29 @@ class Agent:
|
|||||||
atomic_json(marker, manifest)
|
atomic_json(marker, manifest)
|
||||||
return {"stageId": stage_id, "files": len(planned), "state": "staged"}
|
return {"stageId": stage_id, "files": len(planned), "state": "staged"}
|
||||||
|
|
||||||
|
def rebuild_before(self, instance_id, generation, stage_id, spec):
|
||||||
|
"""Validate requested replacement bytes and snapshot the old generation before teardown."""
|
||||||
|
directory = self.instance_dir(instance_id, generation)
|
||||||
|
marker = directory / "stages" / identifier(stage_id) / "manifest.json"
|
||||||
|
saved = json.loads(marker.read_text())
|
||||||
|
if saved["state"] != "staged":
|
||||||
|
raise Rejected("Rebuild requires an unactivated file stage")
|
||||||
|
replacement = {item["path"]: item for item in self.initial_files(spec)}
|
||||||
|
before = []
|
||||||
|
for item in saved["files"]:
|
||||||
|
target = replacement.get(item["path"])
|
||||||
|
if item["delete"]:
|
||||||
|
if target is not None:
|
||||||
|
raise Rejected("Rebuild includes a file requested for removal")
|
||||||
|
elif target is None or digest(self.decode_file(target)) != item["sha256"]:
|
||||||
|
raise Rejected("Rebuild does not match staged file content")
|
||||||
|
content = managed_read(directory / "app", item["path"])
|
||||||
|
actual_sha = None if content is None else digest(content)
|
||||||
|
if item.get("checkBefore") and actual_sha != item.get("expectedBeforeSha"):
|
||||||
|
raise Rejected("Target changed after campaign review")
|
||||||
|
before.append({"id": item.get("id"), "path": item["path"], "contentBase64": None if content is None else base64.b64encode(content).decode()})
|
||||||
|
return before
|
||||||
|
|
||||||
def activate(self, instance_id, generation, stage_id, *, override_safety=False):
|
def activate(self, instance_id, generation, stage_id, *, override_safety=False):
|
||||||
directory = self.instance_dir(instance_id, generation)
|
directory = self.instance_dir(instance_id, generation)
|
||||||
stage = directory / "stages" / identifier(stage_id)
|
stage = directory / "stages" / identifier(stage_id)
|
||||||
@@ -2966,11 +2993,17 @@ class Agent:
|
|||||||
health = self.inspect(instance_id, observed_generation)
|
health = self.inspect(instance_id, observed_generation)
|
||||||
before = []
|
before = []
|
||||||
if payload.get("stageId"):
|
if payload.get("stageId"):
|
||||||
stage = self.instance_dir(instance_id, generation) / "stages" / identifier(payload["stageId"]) / "manifest.json"
|
if kind == "instance.rebuild":
|
||||||
saved = json.loads(stage.read_text())
|
# The creating checkpoint retains snapshots after old storage is deleted.
|
||||||
if saved["state"] != "activated":
|
if not isinstance(checkpoint.get("before"), list):
|
||||||
return row
|
return row
|
||||||
before = saved["before"]
|
before = checkpoint["before"]
|
||||||
|
else:
|
||||||
|
stage = self.instance_dir(instance_id, generation) / "stages" / identifier(payload["stageId"]) / "manifest.json"
|
||||||
|
saved = json.loads(stage.read_text())
|
||||||
|
if saved["state"] != "activated":
|
||||||
|
return row
|
||||||
|
before = saved["before"]
|
||||||
proven = False
|
proven = False
|
||||||
if kind in ("instance.create", "instance.rebuild"):
|
if kind in ("instance.create", "instance.rebuild"):
|
||||||
proven = health["running"] and health.get("creationOperation") == operation["id"] and health.get("specSha256") == checkpoint.get("specSha256")
|
proven = health["running"] and health.get("creationOperation") == operation["id"] and health.get("specSha256") == checkpoint.get("specSha256")
|
||||||
|
|||||||
@@ -1821,6 +1821,66 @@ class AgentTests(unittest.TestCase):
|
|||||||
self.assertTrue(path.is_symlink())
|
self.assertTrue(path.is_symlink())
|
||||||
request.assert_not_called()
|
request.assert_not_called()
|
||||||
|
|
||||||
|
def staged_rebuild(self, deleting=False):
|
||||||
|
spec = self.create()
|
||||||
|
self.agent.rcon = lambda *_: {"ok": True, "players": 0}
|
||||||
|
file = {"id": 9, "path": "configs/server.cfg", "contentBase64": None if deleting else base64.b64encode(b"replacement").decode(), "checkBefore": True, "expectedBeforeSha": module.digest(b"original")}
|
||||||
|
staged = self.agent.execute(self.operation("files.stage", {"stageId": "campaign", "files": [file]}, "stage"))
|
||||||
|
self.assertEqual(staged["state"], "completed")
|
||||||
|
spec["files"] = [] if deleting else [file]
|
||||||
|
return self.operation("instance.rebuild", {"spec": spec, "stageId": "campaign", "nextGeneration": 2}, "rebuild")
|
||||||
|
|
||||||
|
def test_file_rebuild_replaces_generation_and_retains_before_images(self):
|
||||||
|
operation = self.staged_rebuild()
|
||||||
|
result = self.agent.execute(operation)
|
||||||
|
self.assertEqual(result["state"], "completed", result)
|
||||||
|
receipt = json.loads(result["result"])
|
||||||
|
self.assertEqual(receipt["before"], [{"id": 9, "path": "configs/server.cfg", "contentBase64": base64.b64encode(b"original").decode()}])
|
||||||
|
self.assertEqual(receipt["generation"], 2)
|
||||||
|
self.assertFalse(self.agent.instance_dir(INSTANCE, 1).exists())
|
||||||
|
self.assertEqual((self.agent.instance_dir(INSTANCE, 2) / "app/configs/server.cfg").read_bytes(), b"replacement")
|
||||||
|
self.assertIn("instance.rebuild-files", self.agent.inventory()["capabilities"])
|
||||||
|
calls = list(self.docker.calls)
|
||||||
|
self.assertEqual(self.agent.execute(operation)["state"], "completed")
|
||||||
|
self.assertEqual(self.docker.calls, calls)
|
||||||
|
|
||||||
|
def test_file_rebuild_removes_requested_files(self):
|
||||||
|
operation = self.staged_rebuild(deleting=True)
|
||||||
|
result = self.agent.execute(operation)
|
||||||
|
self.assertEqual(result["state"], "completed", result)
|
||||||
|
self.assertFalse((self.agent.instance_dir(INSTANCE, 2) / "app/configs/server.cfg").exists())
|
||||||
|
self.assertEqual(len(json.loads(result["result"])["before"]), 1)
|
||||||
|
|
||||||
|
def test_file_rebuild_rejects_changed_targets_and_mismatched_replacement(self):
|
||||||
|
operation = self.staged_rebuild()
|
||||||
|
target = self.agent.instance_dir(INSTANCE, 1) / "app/configs/server.cfg"
|
||||||
|
target.write_bytes(b"changed")
|
||||||
|
self.assertEqual(self.agent.execute(operation)["state"], "failed")
|
||||||
|
self.assertTrue(self.agent.inspect(INSTANCE, 1)["running"])
|
||||||
|
target.write_bytes(b"original")
|
||||||
|
operation["id"] = "mismatched"
|
||||||
|
operation["payload"]["spec"]["files"] = []
|
||||||
|
self.assertEqual(self.agent.execute(operation)["state"], "failed")
|
||||||
|
self.assertTrue(self.agent.inspect(INSTANCE, 1)["running"])
|
||||||
|
self.assertFalse(any(call[:2] == ("container", "rm") for call in self.docker.calls))
|
||||||
|
|
||||||
|
def test_file_rebuild_rechecks_players_after_snapshot_preparation(self):
|
||||||
|
operation = self.staged_rebuild()
|
||||||
|
with patch.object(self.agent, "rcon", side_effect=[{"ok": True, "players": 0}, {"ok": True, "players": 1}]):
|
||||||
|
self.assertEqual(self.agent.execute(operation)["state"], "accepted")
|
||||||
|
self.assertTrue(self.agent.inspect(INSTANCE, 1)["running"])
|
||||||
|
self.assertFalse(self.agent.inspect(INSTANCE, 2)["exists"])
|
||||||
|
|
||||||
|
def test_file_rebuild_reconciles_snapshots_after_old_storage_was_removed(self):
|
||||||
|
operation = self.staged_rebuild()
|
||||||
|
self.assertEqual(self.agent.execute(operation)["state"], "completed")
|
||||||
|
self.agent.journal.update(operation["id"], "unknown", error="Lost completion")
|
||||||
|
calls = list(self.docker.calls)
|
||||||
|
result = self.agent.execute(operation)
|
||||||
|
self.assertEqual(result["state"], "completed", result)
|
||||||
|
self.assertEqual(json.loads(result["result"])["before"][0]["contentBase64"], base64.b64encode(b"original").decode())
|
||||||
|
self.assertEqual(self.docker.calls, calls)
|
||||||
|
|
||||||
def test_rebuild_moves_generation_and_rejects_old_commands(self):
|
def test_rebuild_moves_generation_and_rejects_old_commands(self):
|
||||||
payload = self.create()
|
payload = self.create()
|
||||||
self.agent.rcon = lambda *_: {"ok": True, "players": 0}
|
self.agent.rcon = lambda *_: {"ok": True, "players": 0}
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ servers. Servers held by another active beta or with pending deployment work
|
|||||||
are excluded, as are instances on unavailable, draining, or disabled hosts.
|
are excluded, as are instances on unavailable, draining, or disabled hosts.
|
||||||
Unknown or stale player counts are not treated as empty. If the
|
Unknown or stale player counts are not treated as empty. If the
|
||||||
requested counts cannot be met, including coverage of every selected map-scoped
|
requested counts cannot be met, including coverage of every selected map-scoped
|
||||||
file, the beta does not start. Deployment and restarts still wait for confirmed
|
file, the beta does not start. File deployment rebuilds servers after waiting
|
||||||
empty lobbies and restart capacity.
|
for confirmed empty lobbies and rebuild capacity.
|
||||||
|
|
||||||
Open **Advanced options → Choose specific servers** for manual selection. The
|
Open **Advanced options → Choose specific servers** for manual selection. The
|
||||||
picker includes servers matching any file in the bundle; each file must have a
|
picker includes servers matching any file in the bundle; each file must have a
|
||||||
@@ -349,7 +349,7 @@ Health monitoring continues after a pass until promotion.
|
|||||||
|
|
||||||
For failures, select hold as failed, pause for investigation, or automatic
|
For failures, select hold as failed, pause for investigation, or automatic
|
||||||
rollback. Rollback queues restoration only for files touched by the beta.
|
rollback. Rollback queues restoration only for files touched by the beta.
|
||||||
Restoration and restart both wait for an empty server and restart capacity.
|
Restoration rebuilds the server after waiting for an empty lobby and rebuild capacity.
|
||||||
The manual option holds those changes in a rollback campaign until you select
|
The manual option holds those changes in a rollback campaign until you select
|
||||||
**Resume when empty**. Promotion is always manual.
|
**Resume when empty**. Promotion is always manual.
|
||||||
|
|
||||||
@@ -361,9 +361,9 @@ Review the promotion preview before confirming. API clients fetch it with
|
|||||||
409 and requires a fresh preview.
|
409 and requires a fresh preview.
|
||||||
|
|
||||||
Confirming promotion creates a campaign without reading the existing files on
|
Confirming promotion creates a campaign without reading the existing files on
|
||||||
additional servers. Each server waits for an empty lobby and restart capacity,
|
additional servers. Each server waits for an empty lobby and rebuild capacity,
|
||||||
then captures rollback snapshots immediately before applying its queued files
|
then captures rollback snapshots before rebuilding with its queued files.
|
||||||
and restarting. Campaign history records snapshot capture, and file comparisons
|
Campaign history records snapshot capture, and file comparisons
|
||||||
become available once those snapshots are confirmed. Tested servers retain their
|
become available once those snapshots are confirmed. Tested servers retain their
|
||||||
original pre-beta snapshots; their composed configuration is still checked at
|
original pre-beta snapshots; their composed configuration is still checked at
|
||||||
promotion to determine whether another activation is needed.
|
promotion to determine whether another activation is needed.
|
||||||
@@ -393,9 +393,9 @@ binary and large files show their checksums. Files must actually change.
|
|||||||
|
|
||||||
The beta keeps its ID, servers, scope and test policy. Each revision creates new
|
The beta keeps its ID, servers, scope and test policy. Each revision creates new
|
||||||
file versions, clears current test progress and checklist sign-offs, repeats the
|
file versions, clears current test progress and checklist sign-offs, repeats the
|
||||||
configured waves, and starts a fresh testing window at submission. Files install
|
configured waves, and starts a fresh testing window at submission. Servers rebuild
|
||||||
and servers restart through the normal empty-server and capacity gates. Promotion
|
with the requested files through the normal empty-server and capacity gates.
|
||||||
requires the new revision to pass. Pause and retry retain the current revision's
|
Promotion requires the new revision to pass. Pause and retry retain the current revision's
|
||||||
deadline; they do not create a revision.
|
deadline; they do not create a revision.
|
||||||
|
|
||||||
Revision history retains downloadable bundle files and previous test results,
|
Revision history retains downloadable bundle files and previous test results,
|
||||||
@@ -407,7 +407,7 @@ new revision is waiting to deploy. Files originally absent are removed.
|
|||||||
Updates are available until promotion or rollback, including paused, failed and
|
Updates are available until promotion or rollback, including paused, failed and
|
||||||
expired tests. An activation already in progress must settle first; interrupted
|
expired tests. An activation already in progress must settle first; interrupted
|
||||||
file writes and deleted participants must be resolved before an update. Pending,
|
file writes and deleted participants must be resolved before an update. Pending,
|
||||||
unsubmitted restarts are superseded. Adding/removing files, changing targets or
|
unsubmitted activations are superseded. Adding/removing files, changing targets or
|
||||||
changing the policy still requires a new beta.
|
changing the policy still requires a new beta.
|
||||||
|
|
||||||
## API and storage
|
## API and storage
|
||||||
|
|||||||
@@ -150,14 +150,19 @@ and post-action health checks still apply.
|
|||||||
|
|
||||||
## Campaigns, betas and maintenance
|
## Campaigns, betas and maintenance
|
||||||
|
|
||||||
Campaigns, beta runs, and maintenance submit durable restart requests to
|
Campaign and beta file changes submit durable rebuild requests to
|
||||||
Provisioning and observe the resulting operation by ID. Their pages link to that
|
Provisioning and observe the resulting operation by ID. Their pages link to that
|
||||||
operation, and its history links back to the requesting work. Provisioning owns
|
operation, and its history links back to the requesting work. Provisioning owns
|
||||||
the empty-server check, conflict handling, restart capacity, remote command, and
|
the empty-server check, conflict handling, capacity limits, remote command, and
|
||||||
health verification. The callers retain file deployment, beta evidence, and
|
health verification. The callers retain file deployment, beta evidence, and
|
||||||
maintenance outcome verification. Jobs remain visible while waiting for players,
|
maintenance outcome verification. Jobs remain visible while waiting for players,
|
||||||
other work, or integration configuration.
|
other work, or integration configuration.
|
||||||
|
|
||||||
|
File rebuilds require host agent 1.9.2 or later. They capture rollback snapshots
|
||||||
|
before replacing the instance, and install the requested files over the promoted
|
||||||
|
setup. Queued file activations wait for the agent update when necessary.
|
||||||
|
Maintenance jobs and beta recovery cycles without file changes still use restarts.
|
||||||
|
|
||||||
[Maintenance](maintenance.md) also submits recovery operations and links each attempt
|
[Maintenance](maintenance.md) also submits recovery operations and links each attempt
|
||||||
to its incident. A failed Maintenance restart can hand its reserved slot to a rebuild
|
to its incident. A failed Maintenance restart can hand its reserved slot to a rebuild
|
||||||
after five minutes; unrelated recovery slots remain held. Drift campaigns follow
|
after five minutes; unrelated recovery slots remain held. Drift campaigns follow
|
||||||
|
|||||||
@@ -190,14 +190,17 @@ export function listServers(): ServerRow[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Beta ownership lasts until explicit closure, regardless of test outcome or stage. */
|
/** Beta ownership lasts until explicit closure, regardless of test outcome or stage. */
|
||||||
export function activeBetaRemovalBlocker(server: string): string | null {
|
export function activeBetaRemovalBlocker(server: string, activationId?: string): string | null {
|
||||||
const betas = db()
|
const betas = db()
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT b.id FROM beta_servers bs JOIN betas b ON b.id = bs.beta_id
|
`SELECT b.id FROM beta_servers bs JOIN betas b ON b.id = bs.beta_id
|
||||||
WHERE bs.server = ? AND bs.server_deleted_at IS NULL AND b.state IN ('running', 'ready-promote')
|
WHERE bs.server = ? AND bs.server_deleted_at IS NULL AND b.state IN ('running', 'ready-promote')
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM provisioning_sources s WHERE s.operation_id=?
|
||||||
|
AND s.kind='beta' AND s.source_id=CAST(b.id AS TEXT) AND s.server=bs.server
|
||||||
|
AND bs.provisioning_id=s.operation_id)
|
||||||
ORDER BY b.id`
|
ORDER BY b.id`
|
||||||
)
|
)
|
||||||
.all(server) as { id: number }[];
|
.all(server, activationId ?? null) as { id: number }[];
|
||||||
if (!betas.length) return null;
|
if (!betas.length) return null;
|
||||||
return `${server} is linked to active beta${betas.length === 1 ? '' : 's'} ${betas.map((beta) => '#' + beta.id).join(', ')}. Promote or roll back the beta${betas.length === 1 ? '' : 's'} before destroying or rebuilding this server.`;
|
return `${server} is linked to active beta${betas.length === 1 ? '' : 's'} ${betas.map((beta) => '#' + beta.id).join(', ')}. Promote or roll back the beta${betas.length === 1 ? '' : 's'} before destroying or rebuilding this server.`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,11 +56,11 @@ export async function rollbackTestPlan(id: number, plan: BetaPlan): Promise<void
|
|||||||
if (!result.deploymentId)
|
if (!result.deploymentId)
|
||||||
activity(id, 'Cancelled queued changes; no deployed files need restoration');
|
activity(id, 'Cancelled queued changes; no deployed files need restoration');
|
||||||
else if (plan.rollbackRestart === 'when-empty')
|
else if (plan.rollbackRestart === 'when-empty')
|
||||||
activity(id, 'Queued file restoration and rolling restarts; waiting for empty servers');
|
activity(id, 'Queued file restoration and rolling rebuilds; waiting for empty servers');
|
||||||
else
|
else
|
||||||
activity(
|
activity(
|
||||||
id,
|
id,
|
||||||
'File restoration is held; resume the rollback campaign to apply it with a safe restart'
|
'File restoration is held; resume the rollback campaign to apply it with a safe rebuild'
|
||||||
);
|
);
|
||||||
if (result.warnings.length) activity(id, result.warnings.join('; '));
|
if (result.warnings.length) activity(id, result.warnings.join('; '));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { blobSize, getFileBlob } from '../db/file-blobs.js';
|
||||||
|
import type { LaunchRecipePlan } from '../fleet/launch-recipes.js';
|
||||||
|
import type { StagedFileChange } from '../fleet/staged-files.js';
|
||||||
|
|
||||||
|
/** Build the replacement with the exact requested additions, updates and removals. */
|
||||||
|
export function applyActivationFiles(
|
||||||
|
recipe: LaunchRecipePlan,
|
||||||
|
operationId: string,
|
||||||
|
includeContent = false
|
||||||
|
): void {
|
||||||
|
const changes = db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT f.*, ${blobSize('f.content_hash')} AS size, v.version FROM staged_file_changes f
|
||||||
|
JOIN provisioning_sources s ON s.kind=f.source_kind AND s.source_id=f.source_id AND s.server=f.server
|
||||||
|
LEFT JOIN script_versions v ON v.id=f.script_version_id
|
||||||
|
WHERE s.operation_id=? AND f.state='pending' ORDER BY f.id`
|
||||||
|
)
|
||||||
|
.all(operationId) as (StagedFileChange & {
|
||||||
|
content_hash: string | null;
|
||||||
|
size: number;
|
||||||
|
version: number | null;
|
||||||
|
})[];
|
||||||
|
for (const change of changes) {
|
||||||
|
if (change.kind !== 'script' && change.kind !== 'config' && change.kind !== 'plugin')
|
||||||
|
throw new Error('File activation requires a runtime file kind.');
|
||||||
|
recipe.files = recipe.files.filter(
|
||||||
|
(file) => file.kind !== change.kind || file.path !== change.rel_path
|
||||||
|
);
|
||||||
|
if (!change.content_hash) continue;
|
||||||
|
recipe.files.push({
|
||||||
|
kind: change.kind,
|
||||||
|
name: change.rel_path,
|
||||||
|
path: change.rel_path,
|
||||||
|
versionId: change.script_version_id,
|
||||||
|
version: change.version,
|
||||||
|
sha256: change.content_hash,
|
||||||
|
size: change.size,
|
||||||
|
...(includeContent
|
||||||
|
? { content: getFileBlob(change.content_hash)!.toString('base64') }
|
||||||
|
: {})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ export interface NativeLaunchInput {
|
|||||||
unhealthyRecovery?: boolean;
|
unhealthyRecovery?: boolean;
|
||||||
/** Rebalance replays recorded deployed bytes instead of current promoted files. */
|
/** Rebalance replays recorded deployed bytes instead of current promoted files. */
|
||||||
frozenRecipeId?: string;
|
frozenRecipeId?: string;
|
||||||
|
/** Pending beta/campaign files override promoted launch files. */
|
||||||
|
activationId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PreparedNativeLaunch {
|
export interface PreparedNativeLaunch {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { parentPort } from 'node:worker_threads';
|
import { parentPort } from 'node:worker_threads';
|
||||||
import { posix } from 'node:path';
|
import { posix } from 'node:path';
|
||||||
|
import { applyActivationFiles } from './activation-files.js';
|
||||||
import { db } from '../db/index.js';
|
import { db } from '../db/index.js';
|
||||||
import { relDestForKind } from '../config.js';
|
import { relDestForKind } from '../config.js';
|
||||||
import {
|
import {
|
||||||
@@ -32,15 +33,15 @@ parentPort!.on(
|
|||||||
const input = message.input;
|
const input = message.input;
|
||||||
// All file versions and composed configuration belong to one snapshot.
|
// All file versions and composed configuration belong to one snapshot.
|
||||||
const { recipe, password } = db().transaction(() => {
|
const { recipe, password } = db().transaction(() => {
|
||||||
if (!input.frozenRecipeId)
|
if (!input.frozenRecipeId) {
|
||||||
return {
|
const recipe = {
|
||||||
recipe: {
|
...captureRecipe(input.game, input.mapSlug, input.rconPassword),
|
||||||
...captureRecipe(input.game, input.mapSlug, input.rconPassword),
|
native: input.native,
|
||||||
native: input.native,
|
image: input.instance.image
|
||||||
image: input.instance.image
|
|
||||||
},
|
|
||||||
password: input.rconPassword
|
|
||||||
};
|
};
|
||||||
|
if (input.activationId) applyActivationFiles(recipe, input.activationId, true);
|
||||||
|
return { recipe, password: input.rconPassword };
|
||||||
|
}
|
||||||
const stored = recipeForDeployment(input.frozenRecipeId)?.recipe;
|
const stored = recipeForDeployment(input.frozenRecipeId)?.recipe;
|
||||||
const migration = db()
|
const migration = db()
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -85,6 +86,7 @@ parentPort!.on(
|
|||||||
)
|
)
|
||||||
throw new Error('electric_cherry_bank.cfg is reserved for instance credentials.');
|
throw new Error('electric_cherry_bank.cfg is reserved for instance credentials.');
|
||||||
const payload = {
|
const payload = {
|
||||||
|
...(input.activationId ? { stageId: input.activationId } : {}),
|
||||||
name: instance.server,
|
name: instance.server,
|
||||||
game: recipe.game,
|
game: recipe.game,
|
||||||
libraryId: instance.library_id,
|
libraryId: instance.library_id,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { db } from '../db/index.js';
|
|||||||
import { unsettledCancelledCommand } from './cancellation.js';
|
import { unsettledCancelledCommand } from './cancellation.js';
|
||||||
import { config, relDestForKind, serverDir } from '../config.js';
|
import { config, relDestForKind, serverDir } from '../config.js';
|
||||||
import {
|
import {
|
||||||
|
runtimePasswordForServer,
|
||||||
nativeInstance,
|
nativeInstance,
|
||||||
nativeRequest,
|
nativeRequest,
|
||||||
unplacedNativeCreation,
|
unplacedNativeCreation,
|
||||||
@@ -55,6 +56,7 @@ import {
|
|||||||
availableServerKeyCount
|
availableServerKeyCount
|
||||||
} from '../fleet/server-keys.js';
|
} from '../fleet/server-keys.js';
|
||||||
import { activeBetaRemovalBlocker, getServer } from '../fleet/servers.js';
|
import { activeBetaRemovalBlocker, getServer } from '../fleet/servers.js';
|
||||||
|
import { applyActivationFiles } from './activation-files.js';
|
||||||
import { nextLobbyNumber, type CreateServerInput, type CreateServerResult } from './instances.js';
|
import { nextLobbyNumber, type CreateServerInput, type CreateServerResult } from './instances.js';
|
||||||
import {
|
import {
|
||||||
beginDeployment,
|
beginDeployment,
|
||||||
@@ -916,7 +918,10 @@ function queueUnhealthyRecovery(job: Job, instance: NativeInstance): void {
|
|||||||
}
|
}
|
||||||
let payload: Record<string, unknown> = { unhealthyRecovery: true };
|
let payload: Record<string, unknown> = { unhealthyRecovery: true };
|
||||||
if (step === 'rebuild') {
|
if (step === 'rebuild') {
|
||||||
const beta = activeBetaRemovalBlocker(job.server);
|
const beta = activeBetaRemovalBlocker(
|
||||||
|
job.server,
|
||||||
|
job.action === 'rebuild' ? job.id : undefined
|
||||||
|
);
|
||||||
if (beta) {
|
if (beta) {
|
||||||
hold(job, beta);
|
hold(job, beta);
|
||||||
return;
|
return;
|
||||||
@@ -952,6 +957,7 @@ function queueUnhealthyRecovery(job: Job, instance: NativeInstance): void {
|
|||||||
unhealthyRecovery: true
|
unhealthyRecovery: true
|
||||||
};
|
};
|
||||||
delete payload.safetyOverride;
|
delete payload.safetyOverride;
|
||||||
|
delete payload.stageId;
|
||||||
}
|
}
|
||||||
const operation = queueHostOperationSerialized(
|
const operation = queueHostOperationSerialized(
|
||||||
instance.host_id,
|
instance.host_id,
|
||||||
@@ -1701,6 +1707,17 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
|
|||||||
hold(job, 'Waiting for current health evidence before completing verification.');
|
hold(job, 'Waiting for current health evidence before completing verification.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// A queued activation from a previous controller release may still say restart.
|
||||||
|
// Upgrade only intent that has never submitted a host command.
|
||||||
|
if (
|
||||||
|
job.action === 'restart' &&
|
||||||
|
job.state === 'waiting' &&
|
||||||
|
!job.submitted_at &&
|
||||||
|
filesFor(job).length
|
||||||
|
) {
|
||||||
|
db().prepare("UPDATE server_deployments SET action='rebuild' WHERE id=?").run(job.id);
|
||||||
|
job.action = 'rebuild';
|
||||||
|
}
|
||||||
const reason = eligibility(job);
|
const reason = eligibility(job);
|
||||||
if (reason) {
|
if (reason) {
|
||||||
hold(job, reason);
|
hold(job, reason);
|
||||||
@@ -1712,13 +1729,58 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (job.action === 'destroy' || job.action === 'rebuild') {
|
if (job.action === 'destroy' || job.action === 'rebuild') {
|
||||||
const beta = activeBetaRemovalBlocker(job.server);
|
const beta = activeBetaRemovalBlocker(
|
||||||
|
job.server,
|
||||||
|
job.action === 'rebuild' ? job.id : undefined
|
||||||
|
);
|
||||||
if (beta) {
|
if (beta) {
|
||||||
hold(job, beta);
|
hold(job, beta);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const files = job.stage_operation_id ? storedFiles(job) : filesFor(job);
|
const files = job.stage_operation_id ? storedFiles(job) : filesFor(job);
|
||||||
|
if (
|
||||||
|
job.action === 'rebuild' &&
|
||||||
|
files.length &&
|
||||||
|
!host.inventory.capabilities?.includes('instance.rebuild-files')
|
||||||
|
) {
|
||||||
|
hold(job, 'Update the host agent to support beta and campaign file rebuilds.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (job.action === 'rebuild' && !recipeSummaryForDeployment(job.id)) {
|
||||||
|
const server = getServer(job.server);
|
||||||
|
if (!server?.map_slug) throw new Error('A saved map is required before rebuilding.');
|
||||||
|
const recipe: NativeRecipe = {
|
||||||
|
...captureRecipePlan(server.game as 't5' | 't6', server.map_slug),
|
||||||
|
image: instance.image,
|
||||||
|
native: {
|
||||||
|
hostId: instance.host_id,
|
||||||
|
libraryId: instance.library_id,
|
||||||
|
image: instance.image,
|
||||||
|
bindAddress: instance.bind_address
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const stored = recipeStorage(recipe);
|
||||||
|
db().transaction(() => {
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO server_creation_jobs(deployment_id,server,port,game_mode,map_slug,expected_map) VALUES(?,?,?,?,?,?)'
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
job.id,
|
||||||
|
job.server,
|
||||||
|
instance!.port,
|
||||||
|
'zombie',
|
||||||
|
recipe.mapSlug,
|
||||||
|
recipe.expectedMap
|
||||||
|
);
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO server_launch_recipes(deployment_id,recipe,recipe_summary) VALUES(?,?,?)'
|
||||||
|
)
|
||||||
|
.run(job.id, stored.recipe, stored.recipeSummary);
|
||||||
|
})();
|
||||||
|
}
|
||||||
if (files.length && !job.stage_operation_id) {
|
if (files.length && !job.stage_operation_id) {
|
||||||
db().transaction(() => {
|
db().transaction(() => {
|
||||||
const stage = queueHostOperationSerialized(
|
const stage = queueHostOperationSerialized(
|
||||||
@@ -1838,7 +1900,7 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
|
|||||||
'instance.inspect'
|
'instance.inspect'
|
||||||
);
|
);
|
||||||
if (!stillActive(job)) return false;
|
if (!stillActive(job)) return false;
|
||||||
if (job.action === 'rebuild' && health.state === 'unhealthy') {
|
if (job.action === 'rebuild' && !files.length && health.state === 'unhealthy') {
|
||||||
if (
|
if (
|
||||||
!getHost(instance.host_id)?.inventory.capabilities?.includes(
|
!getHost(instance.host_id)?.inventory.capabilities?.includes(
|
||||||
unhealthyRecoveryCapability
|
unhealthyRecoveryCapability
|
||||||
@@ -1929,7 +1991,10 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
|
|||||||
phase: 'prepare',
|
phase: 'prepare',
|
||||||
checkpoint: { key: 'startup-queue', label: 'Startup capacity available' }
|
checkpoint: { key: 'startup-queue', label: 'Startup capacity available' }
|
||||||
});
|
});
|
||||||
preparedPassword = config.rcon.password || randomBytes(32).toString('hex');
|
preparedPassword =
|
||||||
|
(files.length ? runtimePasswordForServer(job.server) : undefined) ||
|
||||||
|
config.rcon.password ||
|
||||||
|
randomBytes(32).toString('hex');
|
||||||
prepared = await prepareNativeLaunch({
|
prepared = await prepareNativeLaunch({
|
||||||
game: prior.game,
|
game: prior.game,
|
||||||
mapSlug: prior.mapSlug,
|
mapSlug: prior.mapSlug,
|
||||||
@@ -1940,9 +2005,10 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
|
|||||||
bankToken: bankCredential(instance.id),
|
bankToken: bankCredential(instance.id),
|
||||||
rebuild: job.action === 'rebuild' && !migration,
|
rebuild: job.action === 'rebuild' && !migration,
|
||||||
safetyOverride: overrideSafety,
|
safetyOverride: overrideSafety,
|
||||||
unhealthyRecovery: host.inventory.capabilities?.includes(
|
unhealthyRecovery:
|
||||||
unhealthyRecoveryCapability
|
!files.length &&
|
||||||
),
|
host.inventory.capabilities?.includes(unhealthyRecoveryCapability),
|
||||||
|
activationId: files.length ? job.id : undefined,
|
||||||
frozenRecipeId
|
frozenRecipeId
|
||||||
});
|
});
|
||||||
if (job.action === 'rebuild' && !migration && !(await checkOccupancy())) return;
|
if (job.action === 'rebuild' && !migration && !(await checkOccupancy())) return;
|
||||||
@@ -1977,7 +2043,10 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (job.action === 'destroy' || job.action === 'rebuild') {
|
if (job.action === 'destroy' || job.action === 'rebuild') {
|
||||||
const beta = activeBetaRemovalBlocker(job.server);
|
const beta = activeBetaRemovalBlocker(
|
||||||
|
job.server,
|
||||||
|
job.action === 'rebuild' ? job.id : undefined
|
||||||
|
);
|
||||||
if (beta) {
|
if (beta) {
|
||||||
hold(job, beta);
|
hold(job, beta);
|
||||||
return;
|
return;
|
||||||
@@ -2032,6 +2101,7 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
|
|||||||
prepared.recipe.mapSlug,
|
prepared.recipe.mapSlug,
|
||||||
preparedPassword
|
preparedPassword
|
||||||
);
|
);
|
||||||
|
if (files.length) applyActivationFiles(expected, job.id);
|
||||||
if (
|
if (
|
||||||
getServerKey(job.server) !== preparedKey ||
|
getServerKey(job.server) !== preparedKey ||
|
||||||
!sameLaunchFiles(prepared.recipe, expected)
|
!sameLaunchFiles(prepared.recipe, expected)
|
||||||
@@ -2119,7 +2189,14 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
|
|||||||
recipe.image,
|
recipe.image,
|
||||||
instance!.port
|
instance!.port
|
||||||
);
|
);
|
||||||
for (const file of recipe.files)
|
for (const file of recipe.files.filter(
|
||||||
|
(file) =>
|
||||||
|
!files.some(
|
||||||
|
(change) =>
|
||||||
|
change.path ===
|
||||||
|
posix.join(relDestForKind(file.kind, recipe.game), file.path)
|
||||||
|
)
|
||||||
|
))
|
||||||
recordScript(
|
recordScript(
|
||||||
job.server,
|
job.server,
|
||||||
file.path,
|
file.path,
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const sourceTables = {
|
|||||||
maintenance: { table: 'maintenance_jobs', key: 'id' }
|
maintenance: { table: 'maintenance_jobs', key: 'id' }
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Submit once per caller attempt; a lost response or terminal failure never replays a restart. */
|
/** File activations rebuild; maintenance and beta recovery cycles without files restart. */
|
||||||
export function requestRestart(server: string, source: RestartSource): LifecycleOperation {
|
export function requestRestart(server: string, source: RestartSource): LifecycleOperation {
|
||||||
return db().transaction(() => {
|
return db().transaction(() => {
|
||||||
const { table, key } = sourceTables[source.kind];
|
const { table, key } = sourceTables[source.kind];
|
||||||
@@ -54,6 +54,13 @@ export function requestRestart(server: string, source: RestartSource): Lifecycle
|
|||||||
.get(source.id, server) as
|
.get(source.id, server) as
|
||||||
{ provisioning_id: string | null; restarted_at: string | null } | undefined;
|
{ provisioning_id: string | null; restarted_at: string | null } | undefined;
|
||||||
if (!row) throw new Error('Restart source no longer exists');
|
if (!row) throw new Error('Restart source no longer exists');
|
||||||
|
const activatesFiles =
|
||||||
|
source.kind !== 'maintenance' &&
|
||||||
|
!!db()
|
||||||
|
.prepare(
|
||||||
|
"SELECT 1 FROM staged_file_changes WHERE source_kind=? AND source_id=? AND server=? AND state='pending' LIMIT 1"
|
||||||
|
)
|
||||||
|
.get(source.kind, source.id, server);
|
||||||
if (row.provisioning_id) {
|
if (row.provisioning_id) {
|
||||||
const operation = provisioningOperation(row.provisioning_id);
|
const operation = provisioningOperation(row.provisioning_id);
|
||||||
if (!operation)
|
if (!operation)
|
||||||
@@ -62,6 +69,18 @@ export function requestRestart(server: string, source: RestartSource): Lifecycle
|
|||||||
adoptSubmittedRestart(operation.id, server, row.restarted_at);
|
adoptSubmittedRestart(operation.id, server, row.restarted_at);
|
||||||
return provisioningOperation(operation.id)!;
|
return provisioningOperation(operation.id)!;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
activatesFiles &&
|
||||||
|
!row.restarted_at &&
|
||||||
|
!operation.submitted_at &&
|
||||||
|
operation.state === 'waiting' &&
|
||||||
|
operation.action === 'restart'
|
||||||
|
) {
|
||||||
|
db()
|
||||||
|
.prepare("UPDATE server_deployments SET action='rebuild' WHERE id=?")
|
||||||
|
.run(operation.id);
|
||||||
|
return provisioningOperation(operation.id)!;
|
||||||
|
}
|
||||||
return operation;
|
return operation;
|
||||||
}
|
}
|
||||||
let id: string | undefined;
|
let id: string | undefined;
|
||||||
@@ -84,7 +103,11 @@ export function requestRestart(server: string, source: RestartSource): Lifecycle
|
|||||||
.run(row.restarted_at, id);
|
.run(row.restarted_at, id);
|
||||||
}
|
}
|
||||||
if (!id) {
|
if (!id) {
|
||||||
id = beginDeployment(server, 'restart', `${source.kind}:${source.id}`);
|
id = beginDeployment(
|
||||||
|
server,
|
||||||
|
activatesFiles && !row.restarted_at ? 'rebuild' : 'restart',
|
||||||
|
`${source.kind}:${source.id}`
|
||||||
|
);
|
||||||
db().prepare("UPDATE server_deployments SET state = 'waiting' WHERE id = ?").run(id);
|
db().prepare("UPDATE server_deployments SET state = 'waiting' WHERE id = ?").run(id);
|
||||||
deploymentStage(id, 'Waiting to verify the server is empty', {
|
deploymentStage(id, 'Waiting to verify the server is empty', {
|
||||||
phase: 'eligibility',
|
phase: 'eligibility',
|
||||||
|
|||||||
@@ -297,7 +297,7 @@
|
|||||||
const restarts =
|
const restarts =
|
||||||
test?.plan.rollbackRestart === 'manual'
|
test?.plan.rollbackRestart === 'manual'
|
||||||
? 'Resume the rollback campaign when ready; files stay unchanged until safety checks pass.'
|
? 'Resume the rollback campaign when ready; files stay unchanged until safety checks pass.'
|
||||||
: 'Files are restored just before restarting, after safety checks pass.';
|
: 'Servers rebuild with the original files after safety checks pass.';
|
||||||
if (
|
if (
|
||||||
!(await confirmAction({
|
!(await confirmAction({
|
||||||
title: reject ? 'Reject beta?' : 'Roll back beta?',
|
title: reject ? 'Reject beta?' : 'Roll back beta?',
|
||||||
@@ -557,7 +557,7 @@
|
|||||||
><td colspan="4">
|
><td colspan="4">
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
Files {server.deployed_at ? 'installed' : 'pending'} ·
|
Files {server.deployed_at ? 'installed' : 'pending'} ·
|
||||||
Restart {server.restarted_at
|
Activation {server.restarted_at
|
||||||
? 'submitted'
|
? 'submitted'
|
||||||
: 'pending'}{#if beta.wait_for_health}
|
: 'pending'}{#if beta.wait_for_health}
|
||||||
· Health {server.healthy_at
|
· Health {server.healthy_at
|
||||||
@@ -734,10 +734,10 @@
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Confirming creates a campaign and queues the file changes. Each server waits until empty
|
Confirming creates a campaign and queues the file changes. Each server waits until empty
|
||||||
and restart capacity is available, then captures rollback snapshots, installs its files,
|
and rebuild capacity is available, then captures rollback snapshots and rebuilds with
|
||||||
and restarts. Tested servers keep their pre-beta snapshots and only need another
|
its files. Tested servers keep their pre-beta snapshots and only need another activation
|
||||||
activation if their configuration changed or earlier queued work could replace the
|
if their configuration changed or earlier queued work could replace the tested files.
|
||||||
tested files. Eligibility follows each file's current scope.
|
Eligibility follows each file's current scope.
|
||||||
</p>
|
</p>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
Reviewed {$timeFormat.dateTime(preview.createdAt)}. If targets, versions, or composed
|
Reviewed {$timeFormat.dateTime(preview.createdAt)}. If targets, versions, or composed
|
||||||
@@ -756,7 +756,7 @@
|
|||||||
<ServerIdentity {name} /><span
|
<ServerIdentity {name} /><span
|
||||||
>{preview.servers.find((server) => server.name === name)?.tested
|
>{preview.servers.find((server) => server.name === name)?.tested
|
||||||
? 'Already tested'
|
? 'Already tested'
|
||||||
: 'Additional target · restart when empty'}</span
|
: 'Additional target · rebuild when empty'}</span
|
||||||
>
|
>
|
||||||
</li>{/each}
|
</li>{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -989,12 +989,12 @@
|
|||||||
? 'Failed checks pause testing.'
|
? 'Failed checks pause testing.'
|
||||||
: 'Failed checks hold the beta for review.'}
|
: 'Failed checks hold the beta for review.'}
|
||||||
{testPlan.rollbackRestart === 'when-empty'
|
{testPlan.rollbackRestart === 'when-empty'
|
||||||
? 'Rollback restarts wait for empty servers.'
|
? 'Rollback rebuilds wait for empty servers.'
|
||||||
: 'Rollback restores files; restart servers manually.'}
|
: 'Rollback is held until you resume its campaign.'}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Starting installs each selected version only on matching test servers and
|
Starting installs each selected version only on matching test servers and
|
||||||
restarts them when empty. Waiting, pausing, and retrying use the original
|
rebuilds them when empty. Waiting, pausing, and retrying use the original
|
||||||
testing window. Promotion is a separate reviewed action after testing.
|
testing window. Promotion is a separate reviewed action after testing.
|
||||||
</p>
|
</p>
|
||||||
</section>{/if}
|
</section>{/if}
|
||||||
|
|||||||
@@ -349,7 +349,7 @@
|
|||||||
<p>
|
<p>
|
||||||
{detail.watchdogIncidentId
|
{detail.watchdogIncidentId
|
||||||
? 'Maintenance rebuilds instance files with the latest promoted setup after checking that the server is safe to update.'
|
? 'Maintenance rebuilds instance files with the latest promoted setup after checking that the server is safe to update.'
|
||||||
: 'Changes wait for an empty server and restart capacity. Rollback snapshots are captured before applying files, then servers restart and verify health. Tested servers retain their pre-beta snapshots.'}
|
: 'Changes wait for an empty server and rebuild capacity. Rollback snapshots are captured before servers rebuild with the requested files and verify health. Tested servers retain their pre-beta snapshots.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
@@ -495,7 +495,7 @@
|
|||||||
<dd><RelativeTime value={s.checked_at} /></dd>
|
<dd><RelativeTime value={s.checked_at} /></dd>
|
||||||
</div>{/if}
|
</div>{/if}
|
||||||
{#if s.restarted_at}<div>
|
{#if s.restarted_at}<div>
|
||||||
<dt>Restart requested</dt>
|
<dt>Rebuild requested</dt>
|
||||||
<dd><RelativeTime value={s.restarted_at} /></dd>
|
<dd><RelativeTime value={s.restarted_at} /></dd>
|
||||||
</div>{/if}
|
</div>{/if}
|
||||||
{#if s.server_deleted_at}<div>
|
{#if s.server_deleted_at}<div>
|
||||||
|
|||||||
+45
-12
@@ -35,7 +35,7 @@ import urllib.parse
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
VERSION = "1.9.1"
|
VERSION = "1.9.2"
|
||||||
LABEL = "io.electric-cherry"
|
LABEL = "io.electric-cherry"
|
||||||
MAX_FILE = 32 * 1024 * 1024
|
MAX_FILE = 32 * 1024 * 1024
|
||||||
MANIFEST_MAX_ENTRIES = 10000
|
MANIFEST_MAX_ENTRIES = 10000
|
||||||
@@ -1504,7 +1504,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", "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", "bank.settlement"] + (["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.rebuild-files", "instance.autoscaling-remove", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest", "files.blobs", "logs.read-offset", "bank.settlement"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
|
||||||
plutonium_cache = self.plutonium_cache_inventory()
|
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)), "preparedRuntimeImages": sorted(set(prepared_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})}
|
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)), "preparedRuntimeImages": sorted(set(prepared_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})}
|
||||||
|
|
||||||
@@ -1889,7 +1889,7 @@ class Agent:
|
|||||||
managed_write(app, marker, raw, uid, gid)
|
managed_write(app, marker, raw, uid, gid)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def create(self, instance_id, generation, payload):
|
def create(self, instance_id, generation, payload, *, before=None):
|
||||||
self.operation_phase = "checking-instance"
|
self.operation_phase = "checking-instance"
|
||||||
self.check_creation_cancellation()
|
self.check_creation_cancellation()
|
||||||
directory = self.instance_dir(instance_id, generation)
|
directory = self.instance_dir(instance_id, generation)
|
||||||
@@ -1930,7 +1930,7 @@ class Agent:
|
|||||||
args.append(image_id)
|
args.append(image_id)
|
||||||
self.check_creation_cancellation()
|
self.check_creation_cancellation()
|
||||||
self.operation_phase = "creating-container"
|
self.operation_phase = "creating-container"
|
||||||
self.checkpoint({"phase": "creating", "generation": generation, "specSha256": spec_sha})
|
self.checkpoint({"phase": "creating", "generation": generation, "specSha256": spec_sha, **({"before": before} if before is not None else {})})
|
||||||
self.docker.call(*args, timeout=120)
|
self.docker.call(*args, timeout=120)
|
||||||
self.check_creation_cancellation()
|
self.check_creation_cancellation()
|
||||||
self.operation_phase = "starting-container"
|
self.operation_phase = "starting-container"
|
||||||
@@ -2126,7 +2126,11 @@ class Agent:
|
|||||||
self.safe_to_disrupt(instance_id, generation, allow_unhealthy=unhealthy_recovery)
|
self.safe_to_disrupt(instance_id, generation, allow_unhealthy=unhealthy_recovery)
|
||||||
if "autoscaling" in payload:
|
if "autoscaling" in payload:
|
||||||
self.authorize_autoscaling_removal(instance_id, generation, payload)
|
self.authorize_autoscaling_removal(instance_id, generation, payload)
|
||||||
self.checkpoint({"phase": "disrupting", "containerId": health.get("containerId"), "startedAt": health.get("startedAt")})
|
if kind == "instance.rebuild" and payload.get("stageId"):
|
||||||
|
before = self.rebuild_before(instance_id, generation, payload["stageId"], spec)
|
||||||
|
if not override_safety:
|
||||||
|
self.safe_to_disrupt(instance_id, generation)
|
||||||
|
self.checkpoint({"phase": "disrupting", "containerId": health.get("containerId"), "startedAt": health.get("startedAt"), **({"before": before} if kind == "instance.rebuild" and payload.get("stageId") else {})})
|
||||||
if payload.get("stageId") and kind == "instance.restart":
|
if payload.get("stageId") and kind == "instance.restart":
|
||||||
before = self.activate(instance_id, generation, payload["stageId"], override_safety=override_safety)
|
before = self.activate(instance_id, generation, payload["stageId"], override_safety=override_safety)
|
||||||
if kind in ("instance.remove", "instance.rebuild"):
|
if kind in ("instance.remove", "instance.rebuild"):
|
||||||
@@ -2135,12 +2139,12 @@ class Agent:
|
|||||||
self.docker.call("container", "rm", name)
|
self.docker.call("container", "rm", name)
|
||||||
self.settle_bank(instance_id, generation)
|
self.settle_bank(instance_id, generation)
|
||||||
if kind == "instance.rebuild":
|
if kind == "instance.rebuild":
|
||||||
result = self.create(instance_id, next_generation, spec)
|
result = self.create(instance_id, next_generation, spec, before=before if payload.get("stageId") else None)
|
||||||
self.operation_phase = "removing-old-instance"
|
self.operation_phase = "removing-old-instance"
|
||||||
old = self.instance_dir(instance_id, generation)
|
old = self.instance_dir(instance_id, generation)
|
||||||
if old.exists():
|
if old.exists():
|
||||||
shutil.rmtree(old, ignore_errors=False)
|
shutil.rmtree(old, ignore_errors=False)
|
||||||
return result
|
return {**result, "before": before}
|
||||||
directory = self.instance_dir(instance_id, generation)
|
directory = self.instance_dir(instance_id, generation)
|
||||||
if directory.exists():
|
if directory.exists():
|
||||||
shutil.rmtree(directory)
|
shutil.rmtree(directory)
|
||||||
@@ -2235,6 +2239,29 @@ class Agent:
|
|||||||
atomic_json(marker, manifest)
|
atomic_json(marker, manifest)
|
||||||
return {"stageId": stage_id, "files": len(planned), "state": "staged"}
|
return {"stageId": stage_id, "files": len(planned), "state": "staged"}
|
||||||
|
|
||||||
|
def rebuild_before(self, instance_id, generation, stage_id, spec):
|
||||||
|
"""Validate requested replacement bytes and snapshot the old generation before teardown."""
|
||||||
|
directory = self.instance_dir(instance_id, generation)
|
||||||
|
marker = directory / "stages" / identifier(stage_id) / "manifest.json"
|
||||||
|
saved = json.loads(marker.read_text())
|
||||||
|
if saved["state"] != "staged":
|
||||||
|
raise Rejected("Rebuild requires an unactivated file stage")
|
||||||
|
replacement = {item["path"]: item for item in self.initial_files(spec)}
|
||||||
|
before = []
|
||||||
|
for item in saved["files"]:
|
||||||
|
target = replacement.get(item["path"])
|
||||||
|
if item["delete"]:
|
||||||
|
if target is not None:
|
||||||
|
raise Rejected("Rebuild includes a file requested for removal")
|
||||||
|
elif target is None or digest(self.decode_file(target)) != item["sha256"]:
|
||||||
|
raise Rejected("Rebuild does not match staged file content")
|
||||||
|
content = managed_read(directory / "app", item["path"])
|
||||||
|
actual_sha = None if content is None else digest(content)
|
||||||
|
if item.get("checkBefore") and actual_sha != item.get("expectedBeforeSha"):
|
||||||
|
raise Rejected("Target changed after campaign review")
|
||||||
|
before.append({"id": item.get("id"), "path": item["path"], "contentBase64": None if content is None else base64.b64encode(content).decode()})
|
||||||
|
return before
|
||||||
|
|
||||||
def activate(self, instance_id, generation, stage_id, *, override_safety=False):
|
def activate(self, instance_id, generation, stage_id, *, override_safety=False):
|
||||||
directory = self.instance_dir(instance_id, generation)
|
directory = self.instance_dir(instance_id, generation)
|
||||||
stage = directory / "stages" / identifier(stage_id)
|
stage = directory / "stages" / identifier(stage_id)
|
||||||
@@ -2966,11 +2993,17 @@ class Agent:
|
|||||||
health = self.inspect(instance_id, observed_generation)
|
health = self.inspect(instance_id, observed_generation)
|
||||||
before = []
|
before = []
|
||||||
if payload.get("stageId"):
|
if payload.get("stageId"):
|
||||||
stage = self.instance_dir(instance_id, generation) / "stages" / identifier(payload["stageId"]) / "manifest.json"
|
if kind == "instance.rebuild":
|
||||||
saved = json.loads(stage.read_text())
|
# The creating checkpoint retains snapshots after old storage is deleted.
|
||||||
if saved["state"] != "activated":
|
if not isinstance(checkpoint.get("before"), list):
|
||||||
return row
|
return row
|
||||||
before = saved["before"]
|
before = checkpoint["before"]
|
||||||
|
else:
|
||||||
|
stage = self.instance_dir(instance_id, generation) / "stages" / identifier(payload["stageId"]) / "manifest.json"
|
||||||
|
saved = json.loads(stage.read_text())
|
||||||
|
if saved["state"] != "activated":
|
||||||
|
return row
|
||||||
|
before = saved["before"]
|
||||||
proven = False
|
proven = False
|
||||||
if kind in ("instance.create", "instance.rebuild"):
|
if kind in ("instance.create", "instance.rebuild"):
|
||||||
proven = health["running"] and health.get("creationOperation") == operation["id"] and health.get("specSha256") == checkpoint.get("specSha256")
|
proven = health["running"] and health.get("creationOperation") == operation["id"] and health.get("specSha256") == checkpoint.get("specSha256")
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"version": "1.9.1",
|
"version": "1.9.2",
|
||||||
"protocolVersion": 1,
|
"protocolVersion": 1,
|
||||||
"platform": "debian-amd64",
|
"platform": "debian-amd64",
|
||||||
"sha256": "c3c9fc251c93802e8016b67dc3fba409df00de6681751b9ba231b80d702c7333",
|
"sha256": "acff8fdbf75652821638a9c90ee9cc4ef5254bc2f19fabc436b9135f93307a5a",
|
||||||
"updaterProtocolVersion": 2,
|
"updaterProtocolVersion": 2,
|
||||||
"updaterSha256": "a9d7f96b87d0ec74d8a0f476dff6e3d2a7a0bc454a93a51d7b57de00c9ce23cb",
|
"updaterSha256": "a9d7f96b87d0ec74d8a0f476dff6e3d2a7a0bc454a93a51d7b57de00c9ce23cb",
|
||||||
"installerSha256": "384d5e397a51d19cb9fc9ed8793b4314353f726e217030246050859c0bc18231"
|
"installerSha256": "384d5e397a51d19cb9fc9ed8793b4314353f726e217030246050859c0bc18231"
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
-----BEGIN PGP SIGNATURE-----
|
|
||||||
|
|
||||||
iJEEABYKADkWIQSpocb6VBfQzvdIh9HF6fdSNniN+gUCas2WORsUgAAAAAAEAA5t
|
|
||||||
YW51MiwyLjUrMS4xMiwwLDMACgkQxen3UjZ4jfrMYQEA3BJSdUa3uJW7Jz8J5+Ia
|
|
||||||
v+zC8uuzBB7nXgW4gtbHguYA/i0M3faiIr2eQ4Og+fKqh2TVyAC2BhMbwBPbPaft
|
|
||||||
JwIA
|
|
||||||
=x+Vg
|
|
||||||
-----END PGP SIGNATURE-----
|
|
||||||
@@ -22,7 +22,7 @@ vi.mock('../../src/lib/server/provisioning/worker.js', () => ({
|
|||||||
await import('../../src/lib/server/fleet/status-evidence.js');
|
await import('../../src/lib/server/fleet/status-evidence.js');
|
||||||
const jobs = db()
|
const jobs = db()
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT id,server,state FROM server_deployments WHERE action='restart' AND state IN ('waiting','awaiting-health')"
|
"SELECT id,server,state FROM server_deployments WHERE action IN ('restart','rebuild') AND state IN ('waiting','awaiting-health')"
|
||||||
)
|
)
|
||||||
.all() as { id: string; server: string; state: string }[];
|
.all() as { id: string; server: string; state: string }[];
|
||||||
for (const job of jobs) {
|
for (const job of jobs) {
|
||||||
@@ -243,7 +243,7 @@ describe('durable beta test plans', () => {
|
|||||||
expect(getScript(file.script_id)?.current_version_id).toBe(replacement);
|
expect(getScript(file.script_id)?.current_version_id).toBe(replacement);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('waits for occupied telemetry to clear before restarting a beta despite an empty RCON response', async () => {
|
it('waits for occupied telemetry to clear before rebuilding a beta despite an empty RCON response', async () => {
|
||||||
const id = await openPlan(defaultPlan());
|
const id = await openPlan(defaultPlan());
|
||||||
const { listBetaServers } = await import('../../src/lib/server/fleet/beta.js');
|
const { listBetaServers } = await import('../../src/lib/server/fleet/beta.js');
|
||||||
await postTelemetry(gameEvent('heartbeat', 4, 2));
|
await postTelemetry(gameEvent('heartbeat', 4, 2));
|
||||||
@@ -437,7 +437,7 @@ describe('durable beta test plans', () => {
|
|||||||
await expect(promoteBeta(id)).rejects.toThrow();
|
await expect(promoteBeta(id)).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('automatically rolls back only the deployed wave and queues safe rollback restarts', async () => {
|
it('automatically rolls back only the deployed wave and queues safe rollback rebuilds', async () => {
|
||||||
await writeFile(scriptPath(names[0]), 'original');
|
await writeFile(scriptPath(names[0]), 'original');
|
||||||
const id = await openPlan(
|
const id = await openPlan(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -290,7 +290,13 @@ describe('staged beta files', () => {
|
|||||||
if (blocked)
|
if (blocked)
|
||||||
await expect(request).rejects.toMatchObject({
|
await expect(request).rejects.toMatchObject({
|
||||||
kind: 'conflict',
|
kind: 'conflict',
|
||||||
details: [expect.objectContaining({ deploymentId: promotion.deploymentId })]
|
...(operationState === 'waiting'
|
||||||
|
? { message: expect.stringContaining('rebuild') }
|
||||||
|
: {
|
||||||
|
details: [
|
||||||
|
expect.objectContaining({ deploymentId: promotion.deploymentId })
|
||||||
|
]
|
||||||
|
})
|
||||||
});
|
});
|
||||||
else await expect(request).resolves.toMatchObject({ servers: [servers[1]] });
|
else await expect(request).resolves.toMatchObject({ servers: [servers[1]] });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,6 +128,29 @@ describe('campaign resume API', () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it('upgrades queued file restarts without replaying submitted operations', async () => {
|
||||||
|
await heldRollback('pending');
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js');
|
||||||
|
const job = requestRestart(server, { kind: 'campaign', id: campaign });
|
||||||
|
expect(job.action).toBe('rebuild');
|
||||||
|
db().prepare("UPDATE server_deployments SET action='restart' WHERE id=?").run(job.id);
|
||||||
|
expect(requestRestart(server, { kind: 'campaign', id: campaign })).toMatchObject({
|
||||||
|
id: job.id,
|
||||||
|
action: 'rebuild'
|
||||||
|
});
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
"UPDATE server_deployments SET action='restart',state='running',submitted_at=? WHERE id=?"
|
||||||
|
)
|
||||||
|
.run(new Date().toISOString(), job.id);
|
||||||
|
expect(requestRestart(server, { kind: 'campaign', id: campaign })).toMatchObject({
|
||||||
|
id: job.id,
|
||||||
|
action: 'restart',
|
||||||
|
state: 'running'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('retains an existing waiting operation when its held campaign resumes', async () => {
|
it('retains an existing waiting operation when its held campaign resumes', async () => {
|
||||||
await heldRollback();
|
await heldRollback();
|
||||||
const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js');
|
const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js');
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const inventory = () => ({
|
|||||||
memoryTotal: 64 * 1024 ** 3,
|
memoryTotal: 64 * 1024 ** 3,
|
||||||
memoryAvailable: 60 * 1024 ** 3,
|
memoryAvailable: 60 * 1024 ** 3,
|
||||||
protocolVersion: 1,
|
protocolVersion: 1,
|
||||||
capabilities: ['instance.unhealthy-recovery'],
|
capabilities: ['instance.unhealthy-recovery', 'instance.rebuild-files'],
|
||||||
dockerRunning: true,
|
dockerRunning: true,
|
||||||
directoriesWritable: true,
|
directoriesWritable: true,
|
||||||
storageAvailable: 100_000_000_000,
|
storageAvailable: 100_000_000_000,
|
||||||
@@ -1725,6 +1725,96 @@ describe('native instance provisioning', () => {
|
|||||||
const { getHost } = await import('../../src/lib/server/hosts/index.js');
|
const { getHost } = await import('../../src/lib/server/hosts/index.js');
|
||||||
expect(getHost(hostId)?.mode).toBe('active');
|
expect(getHost(hostId)?.mode).toBe('active');
|
||||||
});
|
});
|
||||||
|
it('builds campaign replacements with requested plugins, configuration, additions and removals', async () => {
|
||||||
|
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
for (const path of ['replace.dll', 'remove.dll']) {
|
||||||
|
const file = createScript({
|
||||||
|
name: path,
|
||||||
|
kind: 'plugin',
|
||||||
|
targetRelPath: path,
|
||||||
|
scopeType: 'game',
|
||||||
|
scopeValue: 't5',
|
||||||
|
content: Buffer.from('promoted plugin')
|
||||||
|
});
|
||||||
|
db()
|
||||||
|
.prepare('UPDATE scripts SET current_version_id=? WHERE id=?')
|
||||||
|
.run(file.version.id, file.script.id);
|
||||||
|
}
|
||||||
|
const job = await queue();
|
||||||
|
await advance();
|
||||||
|
await acknowledge(job.deploymentId);
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO deployments(id,action,started_at) VALUES('files-rebuild','promote',?)"
|
||||||
|
)
|
||||||
|
.run(new Date().toISOString());
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO deployment_servers(deployment_id,server,files_changed,restart_state) VALUES('files-rebuild',?,4,'pending')"
|
||||||
|
)
|
||||||
|
.run(job.name);
|
||||||
|
const { queueFileChange } = await import('../../src/lib/server/fleet/staged-files.js');
|
||||||
|
const changes = [
|
||||||
|
{ kind: 'plugin', relPath: 'replace.dll', content: Buffer.from('requested plugin') },
|
||||||
|
{ kind: 'plugin', relPath: 'remove.dll', content: null },
|
||||||
|
{ kind: 'script', relPath: 'new.gsc', content: Buffer.from('new script') },
|
||||||
|
{ kind: 'config', relPath: 'custom.cfg', content: Buffer.from('set custom 1') }
|
||||||
|
] as const;
|
||||||
|
for (const change of changes)
|
||||||
|
queueFileChange({
|
||||||
|
sourceKind: 'campaign',
|
||||||
|
sourceId: 'files-rebuild',
|
||||||
|
server: job.name,
|
||||||
|
inventorySource: 'campaign',
|
||||||
|
...change
|
||||||
|
});
|
||||||
|
const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js');
|
||||||
|
const rebuild = requestRestart(job.name, { kind: 'campaign', id: 'files-rebuild' });
|
||||||
|
await advance();
|
||||||
|
const { getHostOperation, reportHostOperation } =
|
||||||
|
await import('../../src/lib/server/hosts/index.js');
|
||||||
|
const staged = db()
|
||||||
|
.prepare('SELECT stage_operation_id FROM native_provisioning WHERE deployment_id=?')
|
||||||
|
.get(rebuild.id) as { stage_operation_id: string };
|
||||||
|
reportHostOperation(hostId, staged.stage_operation_id, { state: 'completed' });
|
||||||
|
await advance();
|
||||||
|
const operation = (await operationFor(rebuild.id))!;
|
||||||
|
expect(operation.kind).toBe('instance.rebuild');
|
||||||
|
expect(operation.payload).not.toHaveProperty('unhealthyRecovery');
|
||||||
|
const { recipeForDeployment } =
|
||||||
|
await import('../../src/lib/server/fleet/launch-recipes.js');
|
||||||
|
const recipe = recipeForDeployment(rebuild.id)!.recipe;
|
||||||
|
for (const change of changes) {
|
||||||
|
const actual = recipe.files.find(
|
||||||
|
(file) => file.kind === change.kind && file.path === change.relPath
|
||||||
|
);
|
||||||
|
if (change.content === null) expect(actual).toBeUndefined();
|
||||||
|
else expect(Buffer.from(actual!.content!, 'base64')).toEqual(change.content);
|
||||||
|
}
|
||||||
|
const files = getHostOperation(staged.stage_operation_id)!.payload.files as Array<{
|
||||||
|
id: number;
|
||||||
|
path: string;
|
||||||
|
}>;
|
||||||
|
reportHostOperation(hostId, operation.id, {
|
||||||
|
state: 'completed',
|
||||||
|
result: { before: files.map((file) => ({ ...file, contentBase64: null })) }
|
||||||
|
});
|
||||||
|
remote.state = 'unhealthy';
|
||||||
|
await advance();
|
||||||
|
expect((await operationFor(rebuild.id))!.kind).toBe('instance.restart');
|
||||||
|
await acknowledge(rebuild.id);
|
||||||
|
const recovery = (await operationFor(rebuild.id))!;
|
||||||
|
expect(recovery.kind).toBe('instance.rebuild');
|
||||||
|
expect(recovery.payload).not.toHaveProperty('stageId');
|
||||||
|
expect(recovery.payload.files).toEqual(operation.payload.files);
|
||||||
|
remote.state = 'running';
|
||||||
|
await acknowledge(rebuild.id);
|
||||||
|
expect((await detail(rebuild.id)).state).toBe('done');
|
||||||
|
const { nativeInstance } = await import('../../src/lib/server/backends/native.js');
|
||||||
|
expect(nativeInstance(job.name)?.generation).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
it('stages campaign bytes while occupied and records agent before images only after safe activation', async () => {
|
it('stages campaign bytes while occupied and records agent before images only after safe activation', async () => {
|
||||||
const job = await queue();
|
const job = await queue();
|
||||||
await advance();
|
await advance();
|
||||||
@@ -1761,6 +1851,19 @@ describe('native instance provisioning', () => {
|
|||||||
});
|
});
|
||||||
const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js');
|
const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js');
|
||||||
const restart = requestRestart(job.name, { kind: 'campaign', id: 'campaign' });
|
const restart = requestRestart(job.name, { kind: 'campaign', id: 'campaign' });
|
||||||
|
expect(restart.action).toBe('rebuild');
|
||||||
|
// Upgrade queued work from older controllers, but wait for a capable host.
|
||||||
|
db().prepare("UPDATE server_deployments SET action='restart' WHERE id=?").run(restart.id);
|
||||||
|
const { heartbeat } = await import('../../src/lib/server/hosts/index.js');
|
||||||
|
heartbeat(hostId, { ...inventory(), capabilities: [] });
|
||||||
|
await advance();
|
||||||
|
expect(await detail(restart.id)).toMatchObject({
|
||||||
|
action: 'rebuild',
|
||||||
|
state: 'waiting',
|
||||||
|
stage: expect.stringContaining('Update the host agent')
|
||||||
|
});
|
||||||
|
expect(await operationFor(restart.id)).toBeNull();
|
||||||
|
heartbeat(hostId, inventory());
|
||||||
remote.players = 2;
|
remote.players = 2;
|
||||||
await advance();
|
await advance();
|
||||||
const staged = db()
|
const staged = db()
|
||||||
@@ -1811,9 +1914,18 @@ describe('native instance provisioning', () => {
|
|||||||
await advance();
|
await advance();
|
||||||
const activation = (await operationFor(restart.id))!;
|
const activation = (await operationFor(restart.id))!;
|
||||||
expect(activation).toMatchObject({
|
expect(activation).toMatchObject({
|
||||||
kind: 'instance.restart',
|
kind: 'instance.rebuild',
|
||||||
generation: 1,
|
generation: 1,
|
||||||
payload: { stageId: restart.id }
|
payload: {
|
||||||
|
stageId: restart.id,
|
||||||
|
nextGeneration: 2,
|
||||||
|
files: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
path: 'runtime/plutonium/storage/t5/raw/scripts/sp/hud.gsc',
|
||||||
|
sha256: createHash('sha256').update(after).digest('hex')
|
||||||
|
})
|
||||||
|
])
|
||||||
|
}
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
db()
|
db()
|
||||||
@@ -3819,6 +3931,12 @@ describe('cancelled native mutation bookkeeping', () => {
|
|||||||
])
|
])
|
||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
const activation = (await operationFor(restart.id))!;
|
const activation = (await operationFor(restart.id))!;
|
||||||
|
expect(activation.kind).toBe('instance.rebuild');
|
||||||
|
expect(activation.payload).toMatchObject({
|
||||||
|
stageId: restart.id,
|
||||||
|
nextGeneration: activation.generation! + 1
|
||||||
|
});
|
||||||
|
expect(activation.payload).not.toHaveProperty('unhealthyRecovery');
|
||||||
reportHostOperation(hostId, activation.id, {
|
reportHostOperation(hostId, activation.id, {
|
||||||
state: 'completed',
|
state: 'completed',
|
||||||
result: {
|
result: {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ beforeEach(async () => {
|
|||||||
storageAvailable: 100_000_000_000,
|
storageAvailable: 100_000_000_000,
|
||||||
preparedRuntimeImages: ['plutainer:test'],
|
preparedRuntimeImages: ['plutainer:test'],
|
||||||
libraries: [{ id: 'published-game-version', state: 'ready' }],
|
libraries: [{ id: 'published-game-version', state: 'ready' }],
|
||||||
capabilities: ['instance.safety-override']
|
capabilities: ['instance.safety-override', 'instance.rebuild-files']
|
||||||
});
|
});
|
||||||
const { db } = await import('../../src/lib/server/db/index.js');
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
instanceId = randomUUID();
|
instanceId = randomUUID();
|
||||||
@@ -105,6 +105,22 @@ async function stageCampaign() {
|
|||||||
"INSERT INTO deployment_servers(deployment_id,server,files_changed,restart_state) VALUES('campaign-race',?,1,'pending')"
|
"INSERT INTO deployment_servers(deployment_id,server,files_changed,restart_state) VALUES('campaign-race',?,1,'pending')"
|
||||||
)
|
)
|
||||||
.run(server);
|
.run(server);
|
||||||
|
const { createLibraryImport, updateHostLibraryCaches } =
|
||||||
|
await import('../../src/lib/server/game-files/index.js');
|
||||||
|
const library = createLibraryImport({ hostId, game: 't5' });
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
"UPDATE game_file_versions SET state='ready',manifest='[]',manifest_sha256=?,size_bytes=1,published_at=? WHERE id=?"
|
||||||
|
)
|
||||||
|
.run('a'.repeat(64), new Date().toISOString(), library.id);
|
||||||
|
updateHostLibraryCaches(hostId, [
|
||||||
|
{ libraryVersionId: library.id, status: 'ready', manifestSha256: 'a'.repeat(64) }
|
||||||
|
]);
|
||||||
|
db().prepare('UPDATE native_instances SET library_id=? WHERE server=?').run(library.id, server);
|
||||||
|
const { reserveServerKey } = await import('../../src/lib/server/fleet/server-keys.js');
|
||||||
|
reserveServerKey(server, 't5', 'saved-key');
|
||||||
|
const backend = await import('../../src/lib/server/backends/native.js');
|
||||||
|
vi.spyOn(backend, 'runtimePasswordForServer').mockReturnValue('saved-password');
|
||||||
const { queueFileChange } = await import('../../src/lib/server/fleet/staged-files.js');
|
const { queueFileChange } = await import('../../src/lib/server/fleet/staged-files.js');
|
||||||
queueFileChange({
|
queueFileChange({
|
||||||
sourceKind: 'campaign',
|
sourceKind: 'campaign',
|
||||||
@@ -413,7 +429,7 @@ describe('provisioning admin override', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('overrides a waiting campaign restart while keeping its staged changes attached', async () => {
|
it('overrides a waiting campaign rebuild while keeping its staged changes attached', async () => {
|
||||||
const id = await stageCampaign();
|
const id = await stageCampaign();
|
||||||
const status = await import('../../src/lib/server/fleet/status-evidence.js');
|
const status = await import('../../src/lib/server/fleet/status-evidence.js');
|
||||||
vi.spyOn(status, 'probePlayerEvidence').mockResolvedValue({
|
vi.spyOn(status, 'probePlayerEvidence').mockResolvedValue({
|
||||||
@@ -426,9 +442,11 @@ describe('provisioning admin override', () => {
|
|||||||
await confirm(id);
|
await confirm(id);
|
||||||
await advance();
|
await advance();
|
||||||
const { getHostOperation } = await import('../../src/lib/server/hosts/index.js');
|
const { getHostOperation } = await import('../../src/lib/server/hosts/index.js');
|
||||||
expect(getHostOperation((await operationId(id))!)?.payload).toMatchObject({
|
const { getDeployment } = await import('../../src/lib/server/provisioning/history.js');
|
||||||
stageId: id,
|
expect(getDeployment(id)?.deployment).toMatchObject({ state: 'running', error: null });
|
||||||
safetyOverride: true
|
expect(getHostOperation((await operationId(id))!)).toMatchObject({
|
||||||
|
kind: 'instance.rebuild',
|
||||||
|
payload: { stageId: id, safetyOverride: true }
|
||||||
});
|
});
|
||||||
const { db } = await import('../../src/lib/server/db/index.js');
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
expect(
|
expect(
|
||||||
@@ -518,7 +536,7 @@ describe('native scheduler ownership', () => {
|
|||||||
).toHaveLength(2);
|
).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('holds a paused campaign and cancels withdrawn work before delivering a restart', async () => {
|
it('holds a paused campaign and cancels withdrawn work before delivering a rebuild', async () => {
|
||||||
const id = await stageCampaign();
|
const id = await stageCampaign();
|
||||||
const { db } = await import('../../src/lib/server/db/index.js');
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
db()
|
db()
|
||||||
|
|||||||
Reference in New Issue
Block a user