diff --git a/docker_challenges/__init__.py b/docker_challenges/__init__.py index 1304a03..20bb47f 100644 --- a/docker_challenges/__init__.py +++ b/docker_challenges/__init__.py @@ -282,8 +282,34 @@ def docker_secrets(): app.register_blueprint(admin_docker_secrets) +def _ensure_healthy_column(app) -> None: + """Add the `healthy` column to docker_challenge_tracker if it doesn't exist. + + Handles upgrades from plugin versions that predate the health-check feature. + Existing rows get server_default=1 (healthy=True) so running containers + remain visible after upgrade. + """ + try: + from sqlalchemy import inspect, text + + inspector = inspect(app.db.engine) + columns = [col["name"] for col in inspector.get_columns("docker_challenge_tracker")] + if "healthy" not in columns: + with app.db.engine.begin() as conn: + conn.execute( + text( + "ALTER TABLE docker_challenge_tracker " + "ADD COLUMN healthy BOOLEAN NOT NULL DEFAULT 1" + ) + ) + logging.info("docker_challenge_tracker: added 'healthy' column") + except Exception as err: + logging.warning("Could not ensure 'healthy' column on docker_challenge_tracker: %s", err) + + def load(app): app.db.create_all() + _ensure_healthy_column(app) CHALLENGE_CLASSES["docker"] = DockerChallengeType CHALLENGE_CLASSES["docker_service"] = DockerServiceChallengeType diff --git a/docker_challenges/api/api.py b/docker_challenges/api/api.py index 35c5196..73168aa 100644 --- a/docker_challenges/api/api.py +++ b/docker_challenges/api/api.py @@ -18,9 +18,11 @@ from ..functions.general import ( create_secret, delete_secret, + get_container_states, get_repositories, get_required_ports, get_secrets, + get_service_states, get_unavailable_ports, is_swarm_mode, ) @@ -310,6 +312,7 @@ def _track_container( instance_id=instance_id, ports=",".join(ports), host=str(docker.hostname).split(":")[0], + healthy=False, ) db.session.add(entry) db.session.commit() @@ -365,40 +368,109 @@ def post(self): }, 201 +def _get_tracker_for_session() -> list: + """Return tracker entries for the current authenticated session.""" + if is_teams_mode(): + session = get_current_team() + return list(DockerChallengeTracker.query.filter_by(team_id=session.id)) + session = get_current_user() + return list(DockerChallengeTracker.query.filter_by(user_id=session.id)) + + +def _is_service_challenge(challenge_id: int) -> bool: + """Check if a challenge ID belongs to a DockerServiceChallenge.""" + challenge = _get_challenge_by_id(challenge_id) + return challenge is not None and challenge.type == "docker_service" + + @active_docker_namespace.route("", methods=["POST", "GET"]) class DockerStatus(Resource): """ The Purpose of this API is to retrieve a public JSON string of all docker containers in use by the current team/user. + + Entries with healthy=False are checked against Docker to determine if the + container/service has started yet. Connection info is only returned once healthy. """ @authed_only def get(self): docker = DockerConfig.query.filter_by(id=1).first() - if is_teams_mode(): - session = get_current_team() - tracker = DockerChallengeTracker.query.filter_by(team_id=session.id) - else: - session = get_current_user() - tracker = DockerChallengeTracker.query.filter_by(user_id=session.id) + tracker = _get_tracker_for_session() + + # State dicts are cached (5s TTL) — cheap to call unconditionally + container_states = get_container_states(docker) + service_states = get_service_states(docker) + + db_dirty = False data = [] - for tracker_entry in tracker: - data.append( - { - "id": tracker_entry.id, - "team_id": tracker_entry.team_id, - "user_id": tracker_entry.user_id, - "challenge_id": tracker_entry.challenge_id, - "docker_image": tracker_entry.docker_image, - "timestamp": tracker_entry.timestamp, - "revert_time": tracker_entry.revert_time, - "instance_id": tracker_entry.instance_id, - "ports": tracker_entry.ports.split(","), - "host": str(docker.hostname).split(":")[0], - } - ) + + for entry in tracker: + if entry.healthy: + data.append(self._build_entry(entry, docker, status="running")) + else: + if self._process_unhealthy_entry( + entry, docker, container_states, service_states, data + ): + db_dirty = True + + if db_dirty: + db.session.commit() + return {"success": True, "data": data} + @staticmethod + def _process_unhealthy_entry( + entry: DockerChallengeTracker, + docker: DockerConfig, + container_states: dict[str, str], + service_states: dict[str, str], + data: list, + ) -> bool: + """Check Docker state for an unhealthy tracker entry and update accordingly. + + Returns True if the database was modified (healthy flag set or entry deleted). + """ + is_service = _is_service_challenge(entry.challenge_id) + state_dict = service_states if is_service else container_states + docker_status = state_dict.get(entry.instance_id, "") + + if docker_status == "running": + entry.healthy = True + data.append(DockerStatus._build_entry(entry, docker, status="running")) + return True + if docker_status in ("starting", ""): + # "starting": healthcheck not yet passed + # "": not yet visible in Docker (just created) — treat as starting + data.append(DockerStatus._build_entry(entry, docker, status="starting")) + return False + # "stopped", "unhealthy", or unknown explicit state — clean up dead tracker entry + DockerChallengeTracker.query.filter_by(id=entry.id).delete() + return True + + @staticmethod + def _build_entry(entry: DockerChallengeTracker, docker: DockerConfig, status: str) -> dict: + """Build a response dict for a tracker entry. + + When status is 'starting', ports and host are omitted to prevent + showing connection info for a service not yet accepting connections. + """ + base = { + "id": entry.id, + "team_id": entry.team_id, + "user_id": entry.user_id, + "challenge_id": entry.challenge_id, + "docker_image": entry.docker_image, + "timestamp": entry.timestamp, + "revert_time": entry.revert_time, + "instance_id": entry.instance_id, + "status": status, + } + if status == "running": + base["ports"] = entry.ports.split(",") + base["host"] = str(docker.hostname).split(":")[0] + return base + @docker_namespace.route("", methods=["GET"]) class DockerAPI(Resource): diff --git a/docker_challenges/assets/view.html b/docker_challenges/assets/view.html index 17f9822..f703c67 100644 --- a/docker_challenges/assets/view.html +++ b/docker_challenges/assets/view.html @@ -5,13 +5,33 @@ x-data="containerStatus('{{ challenge.docker_image }}', '{{ challenge.id }}')" x-init="init()" > - -
+ +
Start Docker Instance
+ +
+
+
+ Loading... +
+
+ Container starting up... Connection info will appear when ready. +
+
+
+
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