dict[str, str]:
+ """Return cached container states, fetching fresh if cache is stale."""
+ now = time.monotonic()
+ if now - cls._container_ts >= cls.TTL_SECONDS:
+ cls._container_states = _fetch_container_states(docker)
+ cls._container_ts = now
+ return cls._container_states
+
+ @classmethod
+ def get_service_states(cls, docker: DockerConfig) -> dict[str, str]:
+ """Return cached service states, fetching fresh if cache is stale."""
+ now = time.monotonic()
+ if now - cls._service_ts >= cls.TTL_SECONDS:
+ cls._service_states = _fetch_service_states(docker)
+ cls._service_ts = now
+ return cls._service_states
+
+
+def _fetch_container_states(docker: DockerConfig) -> dict[str, str]:
+ """Fetch all container states in a single Docker API call.
+
+ Returns:
+ Dict mapping container ID (full) to health status string:
+ "running", "starting", "unhealthy", or "stopped".
+ Returns empty dict on request failure.
+ """
+ r = do_request(docker, "/containers/json?all=1")
+ if not r:
+ return {}
+
+ try:
+ containers = r.json()
+ except Exception:
+ return {}
+
+ result: dict[str, str] = {}
+ for container in containers:
+ container_id = container.get("Id", "")
+ if not container_id:
+ continue
+
+ state = container.get("State", "")
+ status = container.get("Status", "")
+
+ if state in ("exited", "dead", "removing", "paused"):
+ result[container_id] = "stopped"
+ elif state != "running":
+ # "created", "restarting", or unknown — transient startup state
+ result[container_id] = "starting"
+ elif "(unhealthy)" in status:
+ result[container_id] = "unhealthy"
+ elif "(health: starting)" in status:
+ result[container_id] = "starting"
+ else:
+ # "running", "running (healthy)", or no health info → ready
+ result[container_id] = "running"
+
+ return result
+
+
+def _fetch_service_states(docker: DockerConfig) -> dict[str, str]:
+ """Fetch all service states in a single Docker API call via /tasks.
+
+ Returns:
+ Dict mapping service ID to health status string: "running" or "starting".
+ Returns empty dict on request failure or non-swarm mode.
+ """
+ r = do_request(docker, "/tasks")
+ if not r:
+ return {}
+
+ try:
+ tasks = r.json()
+ except Exception:
+ return {}
+
+ if isinstance(tasks, dict):
+ # Error response (e.g., not a swarm manager)
+ return {}
+
+ # Group tasks by service and find the best state per service
+ service_states: dict[str, str] = {}
+ for task in tasks:
+ service_id = task.get("ServiceID", "")
+ if not service_id:
+ continue
+
+ task_status = task.get("Status", {})
+ task_state = task_status.get("State", "")
+
+ if task_state == "running":
+ service_states[service_id] = "running"
+ elif service_id not in service_states:
+ # Any non-running state maps to "starting" unless we already have "running"
+ service_states[service_id] = "starting"
+
+ return service_states
+
+
+def get_container_states(docker: DockerConfig) -> dict[str, str]:
+ """Get health states for all containers, with short-TTL caching.
+
+ Returns:
+ Dict mapping container ID to status: "running", "starting", "unhealthy", "stopped".
+ """
+ return _CachedDockerState.get_container_states(docker)
+
+
+def get_service_states(docker: DockerConfig) -> dict[str, str]:
+ """Get health states for all services, with short-TTL caching.
+
+ Returns:
+ Dict mapping service ID to status: "running" or "starting".
+ """
+ return _CachedDockerState.get_service_states(docker)
+
+
def cleanup_container_on_solve(
docker: DockerConfig,
user: Any,
diff --git a/docker_challenges/functions/services.py b/docker_challenges/functions/services.py
index 5374dec..3076f0b 100644
--- a/docker_challenges/functions/services.py
+++ b/docker_challenges/functions/services.py
@@ -100,6 +100,39 @@ def _build_secrets_list(challenge: DockerServiceChallenge, docker: DockerConfig)
return secrets_list
+def find_existing_service(docker: DockerConfig, service_name: str) -> tuple[str | None, str | None]:
+ """Find an existing Docker Swarm service by name.
+
+ Used to recover a service ID when creation returns 409 (name conflict),
+ matching the same pattern as find_existing() for containers.
+
+ Returns:
+ Tuple of (service_id, service_data_json) on success, or (None, None) if not found.
+ service_data_json is formatted with EndpointSpec.Ports for port extraction.
+ """
+ r = do_request(docker, f'/services?filters={{"name":["{service_name}"]}}')
+ if not r:
+ return None, None
+
+ try:
+ services = r.json()
+ except Exception:
+ return None, None
+
+ if not isinstance(services, list):
+ return None, None
+
+ for service in services:
+ spec = service.get("Spec", {})
+ if spec.get("Name") == service_name:
+ service_id = service.get("ID")
+ ports = spec.get("EndpointSpec", {}).get("Ports", [])
+ data = json.dumps({"EndpointSpec": {"Ports": ports}})
+ return service_id, data
+
+ return None, None
+
+
def create_service(
docker: DockerConfig, challenge_id: int, image: str, team: str, portbl: list
) -> tuple[str | None, str | None]:
@@ -143,9 +176,17 @@ def create_service(
# Create service and handle response
r = do_request(docker, url="/services/create", method="POST", data=data)
- if not r:
+ if r is None:
return None, None
+ if r.status_code == 409:
+ # Service name conflict — tracker entry may have been lost while the service survived.
+ # Recover the existing service ID (mirrors find_existing() pattern for containers).
+ logging.warning(
+ "Service name conflict for %s — recovering existing service ID", service_name
+ )
+ return find_existing_service(docker, service_name)
+
instance_id = r.json().get("ID")
if not instance_id:
logging.error("Unable to create service %s with image %s", service_name, image)
diff --git a/docker_challenges/models/models.py b/docker_challenges/models/models.py
index 6389beb..0be4865 100644
--- a/docker_challenges/models/models.py
+++ b/docker_challenges/models/models.py
@@ -35,6 +35,9 @@ class DockerChallengeTracker(db.Model):
instance_id = db.Column("instance_id", db.String(128), index=True)
ports = db.Column("ports", db.String(128), index=True)
host = db.Column("host", db.String(128), index=True)
+ healthy = db.Column(
+ "healthy", db.Boolean, default=False, server_default=db.text("1"), index=True
+ )
class DockerConfigForm(BaseForm):
diff --git a/docker_challenges/templates/admin_docker_status.html b/docker_challenges/templates/admin_docker_status.html
index b331817..47f9698 100644
--- a/docker_challenges/templates/admin_docker_status.html
+++ b/docker_challenges/templates/admin_docker_status.html
@@ -39,6 +39,7 @@
Docker Status
Instance ID
|
+
Status |
Revoke |
@@ -57,6 +58,13 @@
Docker Status
{{docker.instance_id | truncate(15)}}
|
+
+ {% if docker.healthy %}
+ Running
+ {% else %}
+ Starting
+ {% endif %}
+ |
MagicMock:
+ entry = MagicMock()
+ entry.id = 1
+ entry.instance_id = instance_id
+ entry.challenge_id = challenge_id
+ entry.healthy = healthy
+ entry.team_id = None
+ entry.user_id = "1"
+ entry.docker_image = "nginx:latest"
+ entry.timestamp = 1000
+ entry.revert_time = 1300
+ entry.ports = "30001/tcp->80/tcp"
+ return entry
+
+ @pytest.mark.medium
+ @patch("docker_challenges.api.api.db")
+ @patch("docker_challenges.api.api.get_service_states")
+ @patch("docker_challenges.api.api.get_container_states")
+ @patch("docker_challenges.api.api._is_service_challenge")
+ @patch("docker_challenges.api.api.DockerChallengeTracker")
+ @patch("docker_challenges.api.api.DockerConfig")
+ @patch("docker_challenges.api.api.is_teams_mode")
+ @patch("docker_challenges.api.api.get_current_user")
+ def test_healthy_entry_returns_running_status(
+ self,
+ mock_get_user,
+ mock_is_teams,
+ mock_config,
+ mock_tracker,
+ mock_is_service,
+ mock_container_states,
+ mock_service_states,
+ mock_db,
+ ):
+ """Tracker entry with healthy=True returns status='running' with ports and host."""
+ from docker_challenges.api.api import DockerStatus
+
+ mock_is_teams.return_value = False
+ mock_get_user.return_value = MagicMock(id="1")
+ mock_docker = MagicMock()
+ mock_docker.hostname = "docker.host:2376"
+ mock_config.query.filter_by.return_value.first.return_value = mock_docker
+
+ entry = self._make_tracker_entry("cont_abc", healthy=True)
+ mock_tracker.query.filter_by.return_value.__iter__ = MagicMock(return_value=iter([entry]))
+
+ api = DockerStatus()
+ result = api.get()
+
+ assert result["success"] is True
+ assert len(result["data"]) == 1
+ item = result["data"][0]
+ assert item["status"] == "running"
+ assert "ports" in item
+ assert "host" in item
+
+ @pytest.mark.medium
+ @patch("docker_challenges.api.api.db")
+ @patch("docker_challenges.api.api.get_service_states")
+ @patch("docker_challenges.api.api.get_container_states")
+ @patch("docker_challenges.api.api._is_service_challenge")
+ @patch("docker_challenges.api.api.DockerChallengeTracker")
+ @patch("docker_challenges.api.api.DockerConfig")
+ @patch("docker_challenges.api.api.is_teams_mode")
+ @patch("docker_challenges.api.api.get_current_user")
+ def test_unhealthy_entry_starting_returns_starting_without_ports(
+ self,
+ mock_get_user,
+ mock_is_teams,
+ mock_config,
+ mock_tracker,
+ mock_is_service,
+ mock_container_states,
+ mock_service_states,
+ mock_db,
+ ):
+ """Unhealthy entry with Docker status 'starting' returns status='starting' without ports."""
+ from docker_challenges.api.api import DockerStatus
+
+ mock_is_teams.return_value = False
+ mock_get_user.return_value = MagicMock(id="1")
+ mock_docker = MagicMock()
+ mock_docker.hostname = "docker.host:2376"
+ mock_config.query.filter_by.return_value.first.return_value = mock_docker
+
+ entry = self._make_tracker_entry("cont_abc", healthy=False)
+ mock_tracker.query.filter_by.return_value.__iter__ = MagicMock(return_value=iter([entry]))
+
+ mock_is_service.return_value = False
+ mock_container_states.return_value = {"cont_abc": "starting"}
+ mock_service_states.return_value = {}
+
+ api = DockerStatus()
+ result = api.get()
+
+ assert result["success"] is True
+ assert len(result["data"]) == 1
+ item = result["data"][0]
+ assert item["status"] == "starting"
+ assert "ports" not in item
+ assert "host" not in item
+ # healthy flag should NOT have been updated
+ assert entry.healthy is False
+
+ @pytest.mark.medium
+ @patch("docker_challenges.api.api.db")
+ @patch("docker_challenges.api.api.get_service_states")
+ @patch("docker_challenges.api.api.get_container_states")
+ @patch("docker_challenges.api.api._is_service_challenge")
+ @patch("docker_challenges.api.api.DockerChallengeTracker")
+ @patch("docker_challenges.api.api.DockerConfig")
+ @patch("docker_challenges.api.api.is_teams_mode")
+ @patch("docker_challenges.api.api.get_current_user")
+ def test_unhealthy_entry_transitions_to_running(
+ self,
+ mock_get_user,
+ mock_is_teams,
+ mock_config,
+ mock_tracker,
+ mock_is_service,
+ mock_container_states,
+ mock_service_states,
+ mock_db,
+ ):
+ """Unhealthy entry with Docker status 'running' transitions to healthy=True."""
+ from docker_challenges.api.api import DockerStatus
+
+ mock_is_teams.return_value = False
+ mock_get_user.return_value = MagicMock(id="1")
+ mock_docker = MagicMock()
+ mock_docker.hostname = "docker.host:2376"
+ mock_config.query.filter_by.return_value.first.return_value = mock_docker
+
+ entry = self._make_tracker_entry("cont_abc", healthy=False)
+ mock_tracker.query.filter_by.return_value.__iter__ = MagicMock(return_value=iter([entry]))
+
+ mock_is_service.return_value = False
+ mock_container_states.return_value = {"cont_abc": "running"}
+ mock_service_states.return_value = {}
+
+ api = DockerStatus()
+ result = api.get()
+
+ assert result["success"] is True
+ assert len(result["data"]) == 1
+ item = result["data"][0]
+ assert item["status"] == "running"
+ assert "ports" in item
+ assert "host" in item
+ # healthy flag should have been updated
+ assert entry.healthy is True
+ mock_db.session.commit.assert_called_once()
+
+ @pytest.mark.medium
+ @patch("docker_challenges.api.api.db")
+ @patch("docker_challenges.api.api.get_service_states")
+ @patch("docker_challenges.api.api.get_container_states")
+ @patch("docker_challenges.api.api._is_service_challenge")
+ @patch("docker_challenges.api.api.DockerChallengeTracker")
+ @patch("docker_challenges.api.api.DockerConfig")
+ @patch("docker_challenges.api.api.is_teams_mode")
+ @patch("docker_challenges.api.api.get_current_user")
+ def test_dead_container_cleans_up_tracker_entry(
+ self,
+ mock_get_user,
+ mock_is_teams,
+ mock_config,
+ mock_tracker,
+ mock_is_service,
+ mock_container_states,
+ mock_service_states,
+ mock_db,
+ ):
+ """Unhealthy entry not found in Docker states removes tracker entry."""
+ from docker_challenges.api.api import DockerStatus
+
+ mock_is_teams.return_value = False
+ mock_get_user.return_value = MagicMock(id="1")
+ mock_docker = MagicMock()
+ mock_docker.hostname = "docker.host:2376"
+ mock_config.query.filter_by.return_value.first.return_value = mock_docker
+
+ entry = self._make_tracker_entry("cont_dead", healthy=False)
+ mock_tracker.query.filter_by.return_value.__iter__ = MagicMock(return_value=iter([entry]))
+
+ mock_is_service.return_value = False
+ mock_container_states.return_value = {"cont_dead": "stopped"}
+ mock_service_states.return_value = {}
+
+ api = DockerStatus()
+ result = api.get()
+
+ assert result["success"] is True
+ # Dead container should NOT appear in response data
+ assert len(result["data"]) == 0
+ # Tracker entry should be deleted
+ mock_tracker.query.filter_by.assert_called_with(id=entry.id)
+ mock_tracker.query.filter_by.return_value.delete.assert_called_once()
+ mock_db.session.commit.assert_called_once()
+
+ @pytest.mark.medium
+ @patch("docker_challenges.api.api.db")
+ @patch("docker_challenges.api.api.get_service_states")
+ @patch("docker_challenges.api.api.get_container_states")
+ @patch("docker_challenges.api.api._is_service_challenge")
+ @patch("docker_challenges.api.api.DockerChallengeTracker")
+ @patch("docker_challenges.api.api.DockerConfig")
+ @patch("docker_challenges.api.api.is_teams_mode")
+ @patch("docker_challenges.api.api.get_current_user")
+ def test_not_found_in_states_shows_starting(
+ self,
+ mock_get_user,
+ mock_is_teams,
+ mock_config,
+ mock_tracker,
+ mock_is_service,
+ mock_container_states,
+ mock_service_states,
+ mock_db,
+ ):
+ """Container not in states dict (just created) shows as 'starting', not deleted."""
+ from docker_challenges.api.api import DockerStatus
+
+ mock_is_teams.return_value = False
+ mock_get_user.return_value = MagicMock(id="1")
+ mock_docker = MagicMock()
+ mock_docker.hostname = "docker.host:2376"
+ mock_config.query.filter_by.return_value.first.return_value = mock_docker
+
+ entry = self._make_tracker_entry("cont_new", healthy=False)
+ mock_tracker.query.filter_by.return_value.__iter__ = MagicMock(return_value=iter([entry]))
+
+ mock_is_service.return_value = False
+ mock_container_states.return_value = {} # Not yet visible in Docker
+ mock_service_states.return_value = {}
+
+ api = DockerStatus()
+ result = api.get()
+
+ assert result["success"] is True
+ assert len(result["data"]) == 1
+ assert result["data"][0]["status"] == "starting"
+ assert "ports" not in result["data"][0]
+ # Entry should NOT be deleted
+ mock_tracker.query.filter_by.return_value.delete.assert_not_called()
+
+ @pytest.mark.medium
+ @patch("docker_challenges.api.api.db")
+ @patch("docker_challenges.api.api.get_service_states")
+ @patch("docker_challenges.api.api.get_container_states")
+ @patch("docker_challenges.api.api._is_service_challenge")
+ @patch("docker_challenges.api.api.DockerChallengeTracker")
+ @patch("docker_challenges.api.api.DockerConfig")
+ @patch("docker_challenges.api.api.is_teams_mode")
+ @patch("docker_challenges.api.api.get_current_user")
+ def test_stopped_container_cleans_up_tracker_entry(
+ self,
+ mock_get_user,
+ mock_is_teams,
+ mock_config,
+ mock_tracker,
+ mock_is_service,
+ mock_container_states,
+ mock_service_states,
+ mock_db,
+ ):
+ """Unhealthy entry with Docker status 'stopped' removes tracker entry."""
+ from docker_challenges.api.api import DockerStatus
+
+ mock_is_teams.return_value = False
+ mock_get_user.return_value = MagicMock(id="1")
+ mock_docker = MagicMock()
+ mock_docker.hostname = "docker.host:2376"
+ mock_config.query.filter_by.return_value.first.return_value = mock_docker
+
+ entry = self._make_tracker_entry("cont_stopped", healthy=False)
+ mock_tracker.query.filter_by.return_value.__iter__ = MagicMock(return_value=iter([entry]))
+
+ mock_is_service.return_value = False
+ mock_container_states.return_value = {"cont_stopped": "stopped"}
+ mock_service_states.return_value = {}
+
+ api = DockerStatus()
+ result = api.get()
+
+ assert result["success"] is True
+ assert len(result["data"]) == 0
+ mock_db.session.commit.assert_called_once()
+
+ @pytest.mark.medium
+ @patch("docker_challenges.api.api.db")
+ @patch("docker_challenges.api.api.get_service_states")
+ @patch("docker_challenges.api.api.get_container_states")
+ @patch("docker_challenges.api.api._is_service_challenge")
+ @patch("docker_challenges.api.api.DockerChallengeTracker")
+ @patch("docker_challenges.api.api.DockerConfig")
+ @patch("docker_challenges.api.api.is_teams_mode")
+ @patch("docker_challenges.api.api.get_current_user")
+ def test_service_challenge_uses_service_states(
+ self,
+ mock_get_user,
+ mock_is_teams,
+ mock_config,
+ mock_tracker,
+ mock_is_service,
+ mock_container_states,
+ mock_service_states,
+ mock_db,
+ ):
+ """Service challenges look up states in service_states dict, not container_states."""
+ from docker_challenges.api.api import DockerStatus
+
+ mock_is_teams.return_value = False
+ mock_get_user.return_value = MagicMock(id="1")
+ mock_docker = MagicMock()
+ mock_docker.hostname = "docker.host:2376"
+ mock_config.query.filter_by.return_value.first.return_value = mock_docker
+
+ entry = self._make_tracker_entry("svc_abc", challenge_id=5, healthy=False)
+ mock_tracker.query.filter_by.return_value.__iter__ = MagicMock(return_value=iter([entry]))
+
+ mock_is_service.return_value = True # This is a service challenge
+ mock_container_states.return_value = {} # Not in container states
+ mock_service_states.return_value = {"svc_abc": "running"}
+
+ api = DockerStatus()
+ result = api.get()
+
+ assert result["success"] is True
+ assert len(result["data"]) == 1
+ assert result["data"][0]["status"] == "running"
+ assert entry.healthy is True
|