Lars Talian commited on
Commit
313a7b0
·
1 Parent(s): 0a3cd7a

Isolate runtime-backed snapshots across resets

Browse files
src/open_range/server/compose_runner.py CHANGED
@@ -47,9 +47,10 @@ class ComposeProjectRunner:
47
  snapshot_id: str,
48
  artifacts_dir: Path,
49
  compose: dict[str, Any],
 
50
  ) -> BootedSnapshotProject:
51
  compose_file = artifacts_dir / "docker-compose.yml"
52
- project_name = self.project_name_for(snapshot_id)
53
 
54
  self._run(
55
  [
 
47
  snapshot_id: str,
48
  artifacts_dir: Path,
49
  compose: dict[str, Any],
50
+ project_name: str | None = None,
51
  ) -> BootedSnapshotProject:
52
  compose_file = artifacts_dir / "docker-compose.yml"
53
+ project_name = project_name or self.project_name_for(snapshot_id)
54
 
55
  self._run(
56
  [
src/open_range/server/environment.py CHANGED
@@ -28,6 +28,7 @@ from open_range.protocols import SnapshotSpec, TaskSpec
28
  from open_range.server.models import RangeAction, RangeObservation, RangeState
29
 
30
  if TYPE_CHECKING:
 
31
  from open_range.server.runtime import ManagedSnapshotRuntime
32
 
33
  logger = logging.getLogger(__name__)
@@ -123,6 +124,7 @@ class RangeEnvironment(_BASE): # type: ignore[misc]
123
  self._docker_available = docker_available
124
  self._runtime = runtime
125
  self._episode_recorded = False
 
126
 
127
  # Execution mode: "auto", "docker", or "subprocess"
128
  self._execution_mode = execution_mode
@@ -173,6 +175,11 @@ class RangeEnvironment(_BASE): # type: ignore[misc]
173
  the bare hostname is returned as a fallback for test compatibility.
174
  """
175
  if self._snapshot and self._snapshot.compose:
 
 
 
 
 
176
  services = self._snapshot.compose.get("services", {})
177
  if host in services:
178
  project = self._snapshot.compose.get(
@@ -505,6 +512,52 @@ class RangeEnvironment(_BASE): # type: ignore[misc]
505
  logger.debug("NPC stop error (ignored): %s", exc)
506
  self._npc_manager = None
507
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
508
  def _refresh_npc_traffic_log(self) -> None:
509
  """Pull latest NPC activity from the manager into the traffic log."""
510
  if self._npc_manager is not None:
@@ -851,6 +904,8 @@ class RangeEnvironment(_BASE): # type: ignore[misc]
851
  Initial RangeObservation with the challenge briefing.
852
  """
853
  self._report_episode_result(completed=False)
 
 
854
 
855
  # Select snapshot
856
  self._snapshot = self._select_snapshot(**kwargs)
@@ -880,8 +935,11 @@ class RangeEnvironment(_BASE): # type: ignore[misc]
880
  except Exception:
881
  pass
882
 
883
- # Deploy snapshot artifacts to running containers
884
- self._apply_snapshot(self._snapshot)
 
 
 
885
 
886
  # Start NPC traffic for this episode
887
  self._start_npcs(self._snapshot)
@@ -979,6 +1037,7 @@ class RangeEnvironment(_BASE): # type: ignore[misc]
979
  self._report_if_done(obs)
980
  return obs
981
 
 
982
  # Route to container
983
  target = self._resolve_target(action)
984
  timeout = timeout_s or self._exec_timeout
@@ -1201,6 +1260,7 @@ class RangeEnvironment(_BASE): # type: ignore[misc]
1201
  """Release resources (Docker client, NPC manager, episode state)."""
1202
  self._report_episode_result(completed=False)
1203
  self._stop_npcs()
 
1204
  if self._docker_client is not None:
1205
  try:
1206
  self._docker_client.close()
 
28
  from open_range.server.models import RangeAction, RangeObservation, RangeState
29
 
30
  if TYPE_CHECKING:
31
+ from open_range.server.compose_runner import BootedSnapshotProject
32
  from open_range.server.runtime import ManagedSnapshotRuntime
33
 
34
  logger = logging.getLogger(__name__)
 
124
  self._docker_available = docker_available
125
  self._runtime = runtime
126
  self._episode_recorded = False
127
+ self._active_project: "BootedSnapshotProject | None" = None
128
 
129
  # Execution mode: "auto", "docker", or "subprocess"
130
  self._execution_mode = execution_mode
 
175
  the bare hostname is returned as a fallback for test compatibility.
176
  """
177
  if self._snapshot and self._snapshot.compose:
178
+ if (
179
+ self._active_project is not None
180
+ and host in self._active_project.containers.container_ids
181
+ ):
182
+ return self._active_project.containers.container_ids[host]
183
  services = self._snapshot.compose.get("services", {})
184
  if host in services:
185
  project = self._snapshot.compose.get(
 
512
  logger.debug("NPC stop error (ignored): %s", exc)
513
  self._npc_manager = None
514
 
515
+ def _teardown_active_project(self) -> None:
516
+ """Tear down the currently active runtime-backed episode project."""
517
+ if self._active_project is None:
518
+ return
519
+ project = self._active_project
520
+ self._active_project = None
521
+ if self._runtime is None:
522
+ return
523
+ try:
524
+ self._runtime.teardown_snapshot_project(project)
525
+ except Exception as exc:
526
+ logger.warning(
527
+ "Failed to tear down active snapshot project %s: %s",
528
+ project.project_name,
529
+ exc,
530
+ )
531
+
532
+ def _activate_runtime_snapshot(
533
+ self,
534
+ snapshot: SnapshotSpec,
535
+ *,
536
+ episode_id: str,
537
+ ) -> bool:
538
+ """Boot a clean project for a runtime-backed admitted snapshot.
539
+
540
+ Returns True when the snapshot was activated through the managed
541
+ runtime and no overlay deployment is needed in-process.
542
+ """
543
+ if self._runtime is None or not self._snapshot_id:
544
+ return False
545
+ if self._execution_mode != "docker":
546
+ return False
547
+ if self._get_docker() is None:
548
+ return False
549
+
550
+ project = self._runtime.activate_snapshot_project(
551
+ snapshot_id=self._snapshot_id,
552
+ snapshot=snapshot,
553
+ episode_id=episode_id,
554
+ )
555
+ self._active_project = project
556
+ compose = dict(snapshot.compose)
557
+ compose["x-project-name"] = project.project_name
558
+ snapshot.compose = compose
559
+ return True
560
+
561
  def _refresh_npc_traffic_log(self) -> None:
562
  """Pull latest NPC activity from the manager into the traffic log."""
563
  if self._npc_manager is not None:
 
904
  Initial RangeObservation with the challenge briefing.
905
  """
906
  self._report_episode_result(completed=False)
907
+ self._stop_npcs()
908
+ self._teardown_active_project()
909
 
910
  # Select snapshot
911
  self._snapshot = self._select_snapshot(**kwargs)
 
935
  except Exception:
936
  pass
937
 
938
+ # Runtime-backed episodes boot a fresh project per reset. Manual/mock
939
+ # snapshots still use direct artifact application.
940
+ activated = self._activate_runtime_snapshot(self._snapshot, episode_id=eid)
941
+ if not activated:
942
+ self._apply_snapshot(self._snapshot)
943
 
944
  # Start NPC traffic for this episode
945
  self._start_npcs(self._snapshot)
 
1037
  self._report_if_done(obs)
1038
  return obs
1039
 
1040
+
1041
  # Route to container
1042
  target = self._resolve_target(action)
1043
  timeout = timeout_s or self._exec_timeout
 
1260
  """Release resources (Docker client, NPC manager, episode state)."""
1261
  self._report_episode_result(completed=False)
1262
  self._stop_npcs()
1263
+ self._teardown_active_project()
1264
  if self._docker_client is not None:
1265
  try:
1266
  self._docker_client.close()
src/open_range/server/runtime.py CHANGED
@@ -1053,6 +1053,57 @@ class ManagedSnapshotRuntime:
1053
  rendered.compose = yaml.safe_load(compose_path.read_text(encoding="utf-8")) or {}
1054
  return rendered
1055
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1056
  def _run_live_admission(self, snapshot: SnapshotSpec, snapshot_id: str) -> None:
1057
  project: BootedSnapshotProject | None = None
1058
  try:
 
1053
  rendered.compose = yaml.safe_load(compose_path.read_text(encoding="utf-8")) or {}
1054
  return rendered
1055
 
1056
+ def activate_snapshot_project(
1057
+ self,
1058
+ *,
1059
+ snapshot_id: str,
1060
+ snapshot: SnapshotSpec,
1061
+ episode_id: str | None = None,
1062
+ ) -> BootedSnapshotProject:
1063
+ """Boot a fresh per-episode project for an admitted snapshot.
1064
+
1065
+ This is the runtime-facing execution path used by RangeEnvironment.
1066
+ It keeps episode state isolated by booting a new compose project from
1067
+ the admitted artifact bundle rather than layering files onto a
1068
+ long-lived shared stack.
1069
+ """
1070
+ self.start()
1071
+
1072
+ materialized = snapshot
1073
+ artifacts_dir = self._artifacts_dir(snapshot_id)
1074
+ if not artifacts_dir.exists():
1075
+ materialized = self._materialize_snapshot(snapshot, snapshot_id)
1076
+
1077
+ project_name_seed = snapshot_id
1078
+ if episode_id:
1079
+ project_name_seed = f"{snapshot_id}-{episode_id}"
1080
+ project_name = self.compose_runner.project_name_for(project_name_seed)
1081
+
1082
+ project: BootedSnapshotProject | None = None
1083
+ try:
1084
+ project = self.compose_runner.boot(
1085
+ snapshot_id=snapshot_id,
1086
+ artifacts_dir=artifacts_dir,
1087
+ compose=materialized.compose,
1088
+ project_name=project_name,
1089
+ )
1090
+ self._apply_rendered_payloads(snapshot_id, project.containers, materialized)
1091
+ return project
1092
+ except Exception:
1093
+ if project is not None:
1094
+ try:
1095
+ self.compose_runner.teardown(project)
1096
+ except Exception: # noqa: BLE001
1097
+ logger.warning(
1098
+ "Failed to tear down project %s after activation failure",
1099
+ project.project_name,
1100
+ )
1101
+ raise
1102
+
1103
+ def teardown_snapshot_project(self, project: BootedSnapshotProject) -> None:
1104
+ """Tear down a previously activated episode project."""
1105
+ self.compose_runner.teardown(project)
1106
+
1107
  def _run_live_admission(self, snapshot: SnapshotSpec, snapshot_id: str) -> None:
1108
  project: BootedSnapshotProject | None = None
1109
  try:
tests/test_runtime.py CHANGED
@@ -2,9 +2,11 @@
2
 
3
  from __future__ import annotations
4
 
 
 
5
  import pytest
6
 
7
- from open_range.protocols import CheckResult
8
  from open_range.server.compose_runner import BootedSnapshotProject
9
  from open_range.server.environment import RangeEnvironment
10
  from open_range.server.runtime import ManagedSnapshotRuntime
@@ -243,6 +245,73 @@ class TestManagedSnapshotRuntime:
243
  finally:
244
  runtime.stop()
245
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
 
247
  class TestEnvironmentRuntimeIntegration:
248
  def test_reset_uses_managed_runtime_snapshot(self, tier1_manifest, tmp_path):
@@ -299,3 +368,75 @@ class TestEnvironmentRuntimeIntegration:
299
  finally:
300
  env.close()
301
  runtime.stop()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  from __future__ import annotations
4
 
5
+ from pathlib import Path
6
+
7
  import pytest
8
 
9
+ from open_range.protocols import CheckResult, ContainerSet, SnapshotSpec
10
  from open_range.server.compose_runner import BootedSnapshotProject
11
  from open_range.server.environment import RangeEnvironment
12
  from open_range.server.runtime import ManagedSnapshotRuntime
 
245
  finally:
246
  runtime.stop()
247
 
248
+ def test_activate_snapshot_project_uses_unique_episode_project_name(
249
+ self,
250
+ tier1_manifest,
251
+ tmp_path,
252
+ ):
253
+ class FakeContainers:
254
+ def __init__(self) -> None:
255
+ self.exec_calls: list[tuple[str, str]] = []
256
+ self.cp_calls: list[tuple[str, str, str]] = []
257
+
258
+ async def exec(self, container: str, cmd: str, **kwargs) -> str:
259
+ self.exec_calls.append((container, cmd))
260
+ return "ok"
261
+
262
+ async def cp(self, container: str, src: str, dest: str) -> None:
263
+ self.cp_calls.append((container, src, dest))
264
+
265
+ async def is_healthy(self, container: str) -> bool:
266
+ return True
267
+
268
+ class FakeComposeRunner:
269
+ def __init__(self) -> None:
270
+ self.boot_calls: list[tuple[str, str, str | None]] = []
271
+ self.teardown_calls: list[str] = []
272
+ self.containers = FakeContainers()
273
+
274
+ def project_name_for(self, snapshot_id: str) -> str:
275
+ return f"openrange-{snapshot_id}"[:63]
276
+
277
+ def boot(self, *, snapshot_id, artifacts_dir, compose, project_name=None):
278
+ self.boot_calls.append((snapshot_id, str(artifacts_dir), project_name))
279
+ return BootedSnapshotProject(
280
+ project_name=project_name or f"openrange-{snapshot_id}",
281
+ compose_file=artifacts_dir / "docker-compose.yml",
282
+ artifacts_dir=artifacts_dir,
283
+ containers=self.containers, # type: ignore[arg-type]
284
+ )
285
+
286
+ def teardown(self, project):
287
+ self.teardown_calls.append(project.project_name)
288
+
289
+ compose_runner = FakeComposeRunner()
290
+ runtime = ManagedSnapshotRuntime(
291
+ manifest=tier1_manifest,
292
+ store_dir=tmp_path / "snapshots",
293
+ pool_size=1,
294
+ refill_enabled=False,
295
+ compose_runner=compose_runner, # type: ignore[arg-type]
296
+ )
297
+
298
+ runtime.start()
299
+ try:
300
+ admitted = runtime.acquire_snapshot()
301
+ project = runtime.activate_snapshot_project(
302
+ snapshot_id=admitted.snapshot_id,
303
+ snapshot=admitted.snapshot,
304
+ episode_id="episode-123",
305
+ )
306
+ assert compose_runner.boot_calls
307
+ _, artifacts_dir, project_name = compose_runner.boot_calls[0]
308
+ assert artifacts_dir.endswith(f"{admitted.snapshot_id}/artifacts")
309
+ assert project_name == f"openrange-{admitted.snapshot_id}-episode-123"
310
+ runtime.teardown_snapshot_project(project)
311
+ assert compose_runner.teardown_calls == [project.project_name]
312
+ finally:
313
+ runtime.stop()
314
+
315
 
316
  class TestEnvironmentRuntimeIntegration:
317
  def test_reset_uses_managed_runtime_snapshot(self, tier1_manifest, tmp_path):
 
368
  finally:
369
  env.close()
370
  runtime.stop()
371
+
372
+ def test_reset_activates_clean_runtime_project_and_tears_down_previous(self):
373
+ class FakeRuntime:
374
+ def __init__(self, snapshot: SnapshotSpec) -> None:
375
+ self.snapshot = snapshot
376
+ self.activate_calls: list[tuple[str, str | None]] = []
377
+ self.teardown_calls: list[str] = []
378
+ self.recorded: list[bool] = []
379
+
380
+ def acquire_snapshot(self):
381
+ return type(
382
+ "Admitted",
383
+ (),
384
+ {"snapshot_id": "snap-001", "snapshot": self.snapshot},
385
+ )()
386
+
387
+ def get_snapshot(self, snapshot_id: str):
388
+ assert snapshot_id == "snap-001"
389
+ return self.acquire_snapshot()
390
+
391
+ def activate_snapshot_project(self, *, snapshot_id, snapshot, episode_id=None):
392
+ self.activate_calls.append((snapshot_id, episode_id))
393
+ return BootedSnapshotProject(
394
+ project_name=f"project-{episode_id}",
395
+ compose_file=Path("/tmp/docker-compose.yml"),
396
+ artifacts_dir=Path("/tmp"),
397
+ containers=ContainerSet(
398
+ project_name=f"project-{episode_id}",
399
+ container_ids={"web": "cid-web", "attacker": "cid-attacker", "siem": "cid-siem"},
400
+ ),
401
+ )
402
+
403
+ def teardown_snapshot_project(self, project):
404
+ self.teardown_calls.append(project.project_name)
405
+
406
+ def record_episode_result(self, **kwargs):
407
+ self.recorded.append(bool(kwargs.get("completed", False)))
408
+
409
+ snapshot = SnapshotSpec(
410
+ topology={"hosts": ["attacker", "siem", "web"]},
411
+ compose={"services": {"attacker": {}, "siem": {}, "web": {}}},
412
+ task={"red_briefing": "Go.", "blue_briefing": "Watch."},
413
+ )
414
+ runtime = FakeRuntime(snapshot)
415
+ env = RangeEnvironment(
416
+ runtime=runtime, # type: ignore[arg-type]
417
+ docker_available=True,
418
+ execution_mode="docker",
419
+ )
420
+
421
+ env._get_docker = lambda: object() # type: ignore[method-assign]
422
+ apply_calls: list[str] = []
423
+ env._apply_snapshot = lambda snapshot: apply_calls.append("overlay") # type: ignore[method-assign]
424
+ env._start_npcs = lambda snapshot: None # type: ignore[method-assign]
425
+
426
+ try:
427
+ env.reset(episode_id="ep-1")
428
+ assert runtime.activate_calls == [("snap-001", "ep-1")]
429
+ assert apply_calls == []
430
+ assert env.snapshot is not None
431
+ assert env.snapshot.compose["x-project-name"] == "project-ep-1"
432
+ assert env._container_name("web") == "cid-web"
433
+
434
+ env.reset(episode_id="ep-2")
435
+ assert runtime.activate_calls == [("snap-001", "ep-1"), ("snap-001", "ep-2")]
436
+ assert runtime.teardown_calls == ["project-ep-1"]
437
+ assert env.snapshot is not None
438
+ assert env.snapshot.compose["x-project-name"] == "project-ep-2"
439
+ finally:
440
+ env.close()
441
+
442
+ assert runtime.teardown_calls == ["project-ep-1", "project-ep-2"]