From 2621aa355e68f0525504e5c8b78a4ca4733f5fe4 Mon Sep 17 00:00:00 2001 From: quietsy Date: Thu, 2 Jul 2026 10:53:48 +0300 Subject: [PATCH 01/17] add wol support --- root/app/ondemand/container_thread.py | 68 +++++++++---------- root/app/ondemand/data_classes.py | 33 +++++++++ root/app/ondemand/helper.py | 24 ------- root/app/ondemand/requirements.txt | 2 + .../s6-rc.d/init-mod-swag-ondemand-setup/run | 20 +++--- 5 files changed, 80 insertions(+), 67 deletions(-) delete mode 100644 root/app/ondemand/helper.py create mode 100644 root/app/ondemand/requirements.txt diff --git a/root/app/ondemand/container_thread.py b/root/app/ondemand/container_thread.py index 7bdd10bf..d56c2221 100644 --- a/root/app/ondemand/container_thread.py +++ b/root/app/ondemand/container_thread.py @@ -1,5 +1,4 @@ from data_classes import DockerHost, OnDemandContainer -import helper from shared_state import last_accessed_urls, last_accessed_urls_lock from datetime import datetime @@ -7,6 +6,7 @@ import os import threading import time +import wakeonlan CONTAINER_QUERY_SLEEP = float(os.environ.get("SWAG_ONDEMAND_CONTAINER_QUERY_SLEEP", "5.0")) STOP_THRESHOLD = int(os.environ.get("SWAG_ONDEMAND_STOP_THRESHOLD", "600")) @@ -22,9 +22,9 @@ def __init__(self): def init_docker_hosts(self): docker_host_url = os.environ.get("DOCKER_HOST", None) - client, url = helper.get_docker_client(docker_host_url, True) - if client: - self.docker_hosts.append(DockerHost(client=client, url=url)) + if docker_host_url and not docker_host_url.startswith("tcp://"): + docker_host_url = f"tcp://{docker_host_url}:2375" + self.docker_hosts.append(DockerHost(url=docker_host_url)) remote_hosts_env_vars = { key: value for key, value in os.environ.items() if key.startswith(REMOTE_HOSTS_PREFIX) } for i in range(1, 21): @@ -32,25 +32,22 @@ def init_docker_hosts(self): break docker_host_url = remote_hosts_env_vars[f"{REMOTE_HOSTS_PREFIX}{i}"] - client, url = helper.get_docker_client(docker_host_url) - - if client: - self.docker_hosts.append(DockerHost(client=client, url=url)) - - if not self.docker_hosts: - logging.error("Failed to connect to any docker host") + if docker_host_url and not docker_host_url.startswith("tcp://"): + docker_host_url = f"tcp://{docker_host_url}:2375" + remote_host = DockerHost(url=docker_host_url) + remote_host.wol_mac = remote_hosts_env_vars.get(f"{REMOTE_HOSTS_PREFIX}{i}_WOL_MAC", None) + remote_host.wol_broadcast = remote_hosts_env_vars.get(f"{REMOTE_HOSTS_PREFIX}{i}_WOL_BROADCAST", "255.255.255.255") + remote_host.wol_urls = remote_hosts_env_vars.get(f"{REMOTE_HOSTS_PREFIX}{i}_WOL_URLS", None) + remote_host.wol_port = int(remote_hosts_env_vars.get(f"{REMOTE_HOSTS_PREFIX}{i}_WOL_PORT", "9")) + remote_host.wol_interface = remote_hosts_env_vars.get(f"{REMOTE_HOSTS_PREFIX}{i}_WOL_INTERFACE", None) + self.docker_hosts.append(remote_host) def process_containers(self): for docker_host in self.docker_hosts: - if not helper.is_docker_connected(docker_host.client): - if docker_host.is_connected: - logging.warning(f"Lost connection to {docker_host.url}") - docker_host.is_connected = False - continue + docker_host.init_docker_client() if not docker_host.is_connected: - logging.info(f"Connection to {docker_host.url} has been restored") - docker_host.is_connected = True + continue containers = docker_host.client.containers.list(all=True, filters={ "label": ["swag_ondemand=enable"] }) container_names = {container.name for container in containers} @@ -89,18 +86,10 @@ def stop_containers(self): if inactive_seconds < STOP_THRESHOLD: continue - if not helper.is_docker_connected(docker_host.client): - logging.warning(f"Failed to stop {container_name}, docker host {docker_host.url} is unavailable") - continue - - docker_host.client.containers.get(container_name).stop() + docker_host.get_container(container_name).stop() logging.info(f"Stopped {container_name} after {STOP_THRESHOLD}s of inactivity") - def start_containers(self): - with last_accessed_urls_lock: - last_accessed_urls_combined = ",".join(last_accessed_urls) - last_accessed_urls.clear() - + def start_containers(self, last_accessed_urls_combined: str): for docker_host in self.docker_hosts: for container_name, container in docker_host.ondemand_containers.items(): accessed = False @@ -113,19 +102,30 @@ def start_containers(self): if not accessed or container.status == "running": continue - if not helper.is_docker_connected(docker_host.client): - logging.warning(f"Failed to start {container_name}, docker host {docker_host.url} is unavailable") - continue - - docker_host.client.containers.get(container_name).start() + container_obj = docker_host.get_container(container_name) + self.handle_wol(docker_host, container_name) + container_obj.start() logging.info(f"Started {container_name}") container.status = "running" + def send_wol(self, last_accessed_urls_combined: str): + for docker_host in self.docker_hosts: + if not docker_host.wol_mac or not docker_host.wol_urls or docker_host.is_connected: + continue + for wol_url in docker_host.wol_urls.split(","): + if wol_url in last_accessed_urls_combined: + wakeonlan.send_magic_packet(docker_host.wol_mac, ip_address=docker_host.wol_broadcast, port=docker_host.wol_port, interface=docker_host.wol_interface) + break + def run(self): while True: try: self.process_containers() - self.start_containers() + with last_accessed_urls_lock: + last_accessed_urls_combined = ",".join(last_accessed_urls) + last_accessed_urls.clear() + self.send_wol(last_accessed_urls_combined) + self.start_containers(last_accessed_urls_combined) self.stop_containers() time.sleep(CONTAINER_QUERY_SLEEP) except Exception as e: diff --git a/root/app/ondemand/data_classes.py b/root/app/ondemand/data_classes.py index ccc4384b..fe71c342 100644 --- a/root/app/ondemand/data_classes.py +++ b/root/app/ondemand/data_classes.py @@ -1,6 +1,8 @@ from dataclasses import dataclass, field from datetime import datetime import docker +import logging +import requests @dataclass class OnDemandContainer: @@ -12,5 +14,36 @@ class OnDemandContainer: class DockerHost: client: docker.DockerClient url: str + wol_mac: str + wol_broadcast: str + wol_port: int + wol_interface: str + wol_urls: str is_connected: bool = False + was_connected: bool = False ondemand_containers: dict[str, OnDemandContainer] = field(default_factory=dict) + + def init_docker_client(self): + try: + self.was_connected = self.is_connected + if self.client: + return + if self.url: + self.client = docker.DockerClient(base_url=self.url) + else: + self.client = docker.from_env() + self.is_connected = True + if not self.was_connected: + logging.info(f"Connection to {self.url} has been restored") + except (docker.errors.DockerException, requests.exceptions.ConnectionError): + self.client = None + self.is_connected = False + if self.was_connected: + logging.warning(f"Lost connection to {self.url}") + + def get_container(self, container_name: str): + try: + return self.docker_client.containers.get(container_name) + except (docker.errors.DockerException, requests.exceptions.ConnectionError): + logging.warning(f"Failed to get {container_name}, docker host {self.url} is unavailable") + return None diff --git a/root/app/ondemand/helper.py b/root/app/ondemand/helper.py deleted file mode 100644 index 4af9d35c..00000000 --- a/root/app/ondemand/helper.py +++ /dev/null @@ -1,24 +0,0 @@ -import docker -import requests -from typing import Optional - - -def get_docker_client(docker_host_url: str, from_env: bool = False) -> tuple[Optional[docker.DockerClient], str]: - try: - if docker_host_url: - if not docker_host_url.startswith("tcp://"): - docker_host_url = f"tcp://{docker_host_url}:2375" - return docker.DockerClient(base_url=docker_host_url), docker_host_url - elif from_env: - return docker.from_env(), "unix://var/run/docker.sock" - else: - return None, "" - except (docker.errors.DockerException, requests.exceptions.ConnectionError): - return None, "" - -def is_docker_connected(client: docker.DockerClient) -> bool: - try: - return client.ping() - except (docker.errors.DockerException, requests.exceptions.ConnectionError): - return False - \ No newline at end of file diff --git a/root/app/ondemand/requirements.txt b/root/app/ondemand/requirements.txt new file mode 100644 index 00000000..5d0b057b --- /dev/null +++ b/root/app/ondemand/requirements.txt @@ -0,0 +1,2 @@ +docker +wakeonlan \ No newline at end of file diff --git a/root/etc/s6-overlay/s6-rc.d/init-mod-swag-ondemand-setup/run b/root/etc/s6-overlay/s6-rc.d/init-mod-swag-ondemand-setup/run index 57536391..bbb073b8 100755 --- a/root/etc/s6-overlay/s6-rc.d/init-mod-swag-ondemand-setup/run +++ b/root/etc/s6-overlay/s6-rc.d/init-mod-swag-ondemand-setup/run @@ -7,18 +7,20 @@ if [ ! -S /var/run/docker.sock ] && [ -z "$DOCKER_HOST" ]; then exit 0 fi -echo '**** Checking if docker-py is already installed ****' -if ! pip list 2>&1 | grep -q "docker"; then - echo "**** Adding docker-py to package install lists ****" - echo "\ - docker" >> /mod-pip-packages-to-install.list -else - echo "**** docker-py already installed, skipping ****" -fi +for pkg in $(cat /app/ondemand/requirements.txt); do + echo "**** Checking if $pkg are already installed ****" + if ! pip list 2>&1 | grep -iq "$pkg"; then + echo "**** Adding $pkg to package install lists ****" + echo "\ + $pkg" >> /mod-pip-packages-to-install.list + else + echo "**** $pkg already installed, skipping ****" + fi +done if [ ! -f /config/nginx/ondemand.conf ]; then cp /defaults/ondemand.conf /config/nginx/ondemand.conf lsiown -R abc:abc /config/nginx/ondemand.conf fi -echo "Applied the swag-ondemand mod" \ No newline at end of file +echo "Applied the swag-ondemand mod" From cc6a56319bf95b93002cbda4cee700fcb6c4f611 Mon Sep 17 00:00:00 2001 From: quietsy Date: Thu, 2 Jul 2026 11:17:29 +0300 Subject: [PATCH 02/17] Add default values --- root/app/ondemand/data_classes.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/root/app/ondemand/data_classes.py b/root/app/ondemand/data_classes.py index fe71c342..23aa6869 100644 --- a/root/app/ondemand/data_classes.py +++ b/root/app/ondemand/data_classes.py @@ -3,6 +3,7 @@ import docker import logging import requests +from typing import Optional @dataclass class OnDemandContainer: @@ -12,13 +13,13 @@ class OnDemandContainer: @dataclass class DockerHost: - client: docker.DockerClient url: str - wol_mac: str - wol_broadcast: str - wol_port: int - wol_interface: str - wol_urls: str + client: Optional[docker.DockerClient] = None + wol_mac: Optional[str] = None + wol_broadcast: str = "255.255.255.255" + wol_port: int = 9 + wol_interface: Optional[str] = None + wol_urls: Optional[str] = None is_connected: bool = False was_connected: bool = False ondemand_containers: dict[str, OnDemandContainer] = field(default_factory=dict) From 854bd60d7cf91fbbbd6c6894fe8154d598832ed4 Mon Sep 17 00:00:00 2001 From: quietsy Date: Thu, 2 Jul 2026 11:25:19 +0300 Subject: [PATCH 03/17] Fix typo --- root/app/ondemand/data_classes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/root/app/ondemand/data_classes.py b/root/app/ondemand/data_classes.py index 23aa6869..a2616791 100644 --- a/root/app/ondemand/data_classes.py +++ b/root/app/ondemand/data_classes.py @@ -44,7 +44,7 @@ def init_docker_client(self): def get_container(self, container_name: str): try: - return self.docker_client.containers.get(container_name) + return self.client.containers.get(container_name) except (docker.errors.DockerException, requests.exceptions.ConnectionError): logging.warning(f"Failed to get {container_name}, docker host {self.url} is unavailable") return None From c35ef48665db940cb28dbc76fcf55b6d598c55e7 Mon Sep 17 00:00:00 2001 From: quietsy Date: Thu, 2 Jul 2026 11:29:37 +0300 Subject: [PATCH 04/17] Fix typo --- root/app/ondemand/container_thread.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/root/app/ondemand/container_thread.py b/root/app/ondemand/container_thread.py index d56c2221..2734aa87 100644 --- a/root/app/ondemand/container_thread.py +++ b/root/app/ondemand/container_thread.py @@ -102,9 +102,7 @@ def start_containers(self, last_accessed_urls_combined: str): if not accessed or container.status == "running": continue - container_obj = docker_host.get_container(container_name) - self.handle_wol(docker_host, container_name) - container_obj.start() + docker_host.get_container(container_name).start() logging.info(f"Started {container_name}") container.status = "running" From 1652dbc4da53d64e06daa1456ca6ef18c4aa8576 Mon Sep 17 00:00:00 2001 From: quietsy Date: Thu, 2 Jul 2026 12:00:11 +0300 Subject: [PATCH 05/17] Fix bugs --- root/app/ondemand/container_thread.py | 13 +++++++------ root/app/ondemand/data_classes.py | 10 +++++++++- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/root/app/ondemand/container_thread.py b/root/app/ondemand/container_thread.py index 2734aa87..8137b8dd 100644 --- a/root/app/ondemand/container_thread.py +++ b/root/app/ondemand/container_thread.py @@ -49,13 +49,13 @@ def process_containers(self): if not docker_host.is_connected: continue - containers = docker_host.client.containers.list(all=True, filters={ "label": ["swag_ondemand=enable"] }) + containers = docker_host.get_containers() container_names = {container.name for container in containers} for container_name in list(docker_host.ondemand_containers.keys()): if container_name not in container_names: docker_host.ondemand_containers.pop(container_name) - logging.info(f"Stopped monitoring {container_name}") + logging.info(f"Stopped monitoring {container_name} on {docker_host.url}") for container in containers: default_url = container.labels.get("swag_url", f"{container.name}.").rstrip("*") @@ -63,12 +63,12 @@ def process_containers(self): if container.name not in docker_host.ondemand_containers: last_accessed = datetime.now() - logging.info(f"Started monitoring {container.name} for urls: {container_urls}") + logging.info(f"Started monitoring {container.name} on {docker_host.url} for urls: {container_urls}") else: existing_container = docker_host.ondemand_containers[container.name] last_accessed = existing_container.last_accessed if container_urls != existing_container.urls: - logging.info(f"Updated urls for {container.name} to: {container_urls}") + logging.info(f"Updated urls for {container.name} on {docker_host.url} to: {container_urls}") docker_host.ondemand_containers[container.name] = OnDemandContainer( status=container.status, @@ -87,7 +87,7 @@ def stop_containers(self): continue docker_host.get_container(container_name).stop() - logging.info(f"Stopped {container_name} after {STOP_THRESHOLD}s of inactivity") + logging.info(f"Stopped {container_name} on {docker_host.url} after {STOP_THRESHOLD}s of inactivity") def start_containers(self, last_accessed_urls_combined: str): for docker_host in self.docker_hosts: @@ -103,7 +103,7 @@ def start_containers(self, last_accessed_urls_combined: str): continue docker_host.get_container(container_name).start() - logging.info(f"Started {container_name}") + logging.info(f"Started {container_name} on {docker_host.url}") container.status = "running" def send_wol(self, last_accessed_urls_combined: str): @@ -113,6 +113,7 @@ def send_wol(self, last_accessed_urls_combined: str): for wol_url in docker_host.wol_urls.split(","): if wol_url in last_accessed_urls_combined: wakeonlan.send_magic_packet(docker_host.wol_mac, ip_address=docker_host.wol_broadcast, port=docker_host.wol_port, interface=docker_host.wol_interface) + logging.info(f"Sent a WoL packet to mac {docker_host.wol_mac} via broadcast {docker_host.wol_broadcast} on port {docker_host.wol_port} on interface {docker_host.wol_interface or 'default'} activated by {wol_url}") break def run(self): diff --git a/root/app/ondemand/data_classes.py b/root/app/ondemand/data_classes.py index a2616791..2b0b47d2 100644 --- a/root/app/ondemand/data_classes.py +++ b/root/app/ondemand/data_classes.py @@ -27,12 +27,13 @@ class DockerHost: def init_docker_client(self): try: self.was_connected = self.is_connected - if self.client: + if self.client.ping(): return if self.url: self.client = docker.DockerClient(base_url=self.url) else: self.client = docker.from_env() + self.url = "unix:///var/run/docker.sock" self.is_connected = True if not self.was_connected: logging.info(f"Connection to {self.url} has been restored") @@ -48,3 +49,10 @@ def get_container(self, container_name: str): except (docker.errors.DockerException, requests.exceptions.ConnectionError): logging.warning(f"Failed to get {container_name}, docker host {self.url} is unavailable") return None + + def get_containers(self): + try: + return self.client.containers.list(all=True, filters={ "label": ["swag_ondemand=enable"] }) + except (docker.errors.DockerException, requests.exceptions.ConnectionError): + logging.warning(f"Failed to get containers, docker host {self.url} is unavailable") + return None From 98f923faf70f273e51d5d5d33af77c79ae870fb3 Mon Sep 17 00:00:00 2001 From: quietsy Date: Thu, 2 Jul 2026 12:04:13 +0300 Subject: [PATCH 06/17] Fix typo --- root/app/ondemand/data_classes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/root/app/ondemand/data_classes.py b/root/app/ondemand/data_classes.py index 2b0b47d2..1996ade6 100644 --- a/root/app/ondemand/data_classes.py +++ b/root/app/ondemand/data_classes.py @@ -27,7 +27,7 @@ class DockerHost: def init_docker_client(self): try: self.was_connected = self.is_connected - if self.client.ping(): + if self.client and self.client.ping(): return if self.url: self.client = docker.DockerClient(base_url=self.url) From 9a753dd2e63f353de8f9572811173a8d513df9d1 Mon Sep 17 00:00:00 2001 From: quietsy Date: Thu, 2 Jul 2026 12:09:37 +0300 Subject: [PATCH 07/17] Add error handling --- root/app/ondemand/container_thread.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/root/app/ondemand/container_thread.py b/root/app/ondemand/container_thread.py index 8137b8dd..1a5e7bc5 100644 --- a/root/app/ondemand/container_thread.py +++ b/root/app/ondemand/container_thread.py @@ -50,6 +50,9 @@ def process_containers(self): continue containers = docker_host.get_containers() + if not containers: + continue + container_names = {container.name for container in containers} for container_name in list(docker_host.ondemand_containers.keys()): @@ -78,15 +81,19 @@ def process_containers(self): def stop_containers(self): for docker_host in self.docker_hosts: - for container_name, container in docker_host.ondemand_containers.items(): - if container.status != "running": + for container_name, ondemand_container in docker_host.ondemand_containers.items(): + if ondemand_container.status != "running": continue - inactive_seconds = (datetime.now() - container.last_accessed).total_seconds() + inactive_seconds = (datetime.now() - ondemand_container.last_accessed).total_seconds() if inactive_seconds < STOP_THRESHOLD: continue - docker_host.get_container(container_name).stop() + container = docker_host.get_container(container_name) + if not container: + container + + container.stop() logging.info(f"Stopped {container_name} on {docker_host.url} after {STOP_THRESHOLD}s of inactivity") def start_containers(self, last_accessed_urls_combined: str): @@ -102,7 +109,11 @@ def start_containers(self, last_accessed_urls_combined: str): if not accessed or container.status == "running": continue - docker_host.get_container(container_name).start() + container = docker_host.get_container(container_name) + if not container: + container + + container.start() logging.info(f"Started {container_name} on {docker_host.url}") container.status = "running" From 56cc740edca0a3ad5c4257ad1dd57b74273ce500 Mon Sep 17 00:00:00 2001 From: quietsy Date: Thu, 2 Jul 2026 12:12:33 +0300 Subject: [PATCH 08/17] Fix typo --- root/app/ondemand/container_thread.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/root/app/ondemand/container_thread.py b/root/app/ondemand/container_thread.py index 1a5e7bc5..3a1db895 100644 --- a/root/app/ondemand/container_thread.py +++ b/root/app/ondemand/container_thread.py @@ -91,7 +91,7 @@ def stop_containers(self): container = docker_host.get_container(container_name) if not container: - container + continue container.stop() logging.info(f"Stopped {container_name} on {docker_host.url} after {STOP_THRESHOLD}s of inactivity") @@ -111,7 +111,7 @@ def start_containers(self, last_accessed_urls_combined: str): container = docker_host.get_container(container_name) if not container: - container + continue container.start() logging.info(f"Started {container_name} on {docker_host.url}") From 01f93d63043c2096329b7cca45aa2ce9f6377a98 Mon Sep 17 00:00:00 2001 From: quietsy Date: Thu, 2 Jul 2026 12:21:46 +0300 Subject: [PATCH 09/17] Add error handling --- root/app/ondemand/data_classes.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/root/app/ondemand/data_classes.py b/root/app/ondemand/data_classes.py index 1996ade6..e41acc9e 100644 --- a/root/app/ondemand/data_classes.py +++ b/root/app/ondemand/data_classes.py @@ -37,7 +37,7 @@ def init_docker_client(self): self.is_connected = True if not self.was_connected: logging.info(f"Connection to {self.url} has been restored") - except (docker.errors.DockerException, requests.exceptions.ConnectionError): + except (docker.errors.DockerException, requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout): self.client = None self.is_connected = False if self.was_connected: @@ -45,6 +45,8 @@ def init_docker_client(self): def get_container(self, container_name: str): try: + if not self.client: + return None return self.client.containers.get(container_name) except (docker.errors.DockerException, requests.exceptions.ConnectionError): logging.warning(f"Failed to get {container_name}, docker host {self.url} is unavailable") @@ -52,6 +54,8 @@ def get_container(self, container_name: str): def get_containers(self): try: + if not self.client: + return None return self.client.containers.list(all=True, filters={ "label": ["swag_ondemand=enable"] }) except (docker.errors.DockerException, requests.exceptions.ConnectionError): logging.warning(f"Failed to get containers, docker host {self.url} is unavailable") From b86a48ead9865541c4214118e1807e22daead4aa Mon Sep 17 00:00:00 2001 From: quietsy Date: Thu, 2 Jul 2026 12:33:57 +0300 Subject: [PATCH 10/17] Fix vars --- root/app/ondemand/container_thread.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/root/app/ondemand/container_thread.py b/root/app/ondemand/container_thread.py index 3a1db895..f3ff7541 100644 --- a/root/app/ondemand/container_thread.py +++ b/root/app/ondemand/container_thread.py @@ -94,19 +94,20 @@ def stop_containers(self): continue container.stop() + ondemand_container.status = "exited" logging.info(f"Stopped {container_name} on {docker_host.url} after {STOP_THRESHOLD}s of inactivity") def start_containers(self, last_accessed_urls_combined: str): for docker_host in self.docker_hosts: - for container_name, container in docker_host.ondemand_containers.items(): + for container_name, ondemand_container in docker_host.ondemand_containers.items(): accessed = False - for ondemand_url in container.urls.split(","): + for ondemand_url in ondemand_container.urls.split(","): if ondemand_url in last_accessed_urls_combined: - container.last_accessed = datetime.now() + ondemand_container.last_accessed = datetime.now() accessed = True break - if not accessed or container.status == "running": + if not accessed or ondemand_container.status == "running": continue container = docker_host.get_container(container_name) @@ -114,8 +115,8 @@ def start_containers(self, last_accessed_urls_combined: str): continue container.start() + ondemand_container.status = "running" logging.info(f"Started {container_name} on {docker_host.url}") - container.status = "running" def send_wol(self, last_accessed_urls_combined: str): for docker_host in self.docker_hosts: From f98f91133245e77712585ef10dca5b007078a68d Mon Sep 17 00:00:00 2001 From: quietsy Date: Thu, 2 Jul 2026 12:44:48 +0300 Subject: [PATCH 11/17] Add timeout env var --- root/app/ondemand/container_thread.py | 3 ++- root/app/ondemand/data_classes.py | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/root/app/ondemand/container_thread.py b/root/app/ondemand/container_thread.py index f3ff7541..bd9f7c86 100644 --- a/root/app/ondemand/container_thread.py +++ b/root/app/ondemand/container_thread.py @@ -9,6 +9,7 @@ import wakeonlan CONTAINER_QUERY_SLEEP = float(os.environ.get("SWAG_ONDEMAND_CONTAINER_QUERY_SLEEP", "5.0")) +DOCKER_API_TIMEOUT = int(os.environ.get("SWAG_ONDEMAND_DOCKER_API_TIMEOUT", "5")) STOP_THRESHOLD = int(os.environ.get("SWAG_ONDEMAND_STOP_THRESHOLD", "600")) REMOTE_HOSTS_PREFIX = "SWAG_ONDEMAND_REMOTE" @@ -44,7 +45,7 @@ def init_docker_hosts(self): def process_containers(self): for docker_host in self.docker_hosts: - docker_host.init_docker_client() + docker_host.init_client(DOCKER_API_TIMEOUT) if not docker_host.is_connected: continue diff --git a/root/app/ondemand/data_classes.py b/root/app/ondemand/data_classes.py index e41acc9e..2bd9057b 100644 --- a/root/app/ondemand/data_classes.py +++ b/root/app/ondemand/data_classes.py @@ -24,15 +24,15 @@ class DockerHost: was_connected: bool = False ondemand_containers: dict[str, OnDemandContainer] = field(default_factory=dict) - def init_docker_client(self): + def init_client(self, timeout: int): try: self.was_connected = self.is_connected if self.client and self.client.ping(): return if self.url: - self.client = docker.DockerClient(base_url=self.url) + self.client = docker.DockerClient(base_url=self.url, timeout=timeout) else: - self.client = docker.from_env() + self.client = docker.from_env(timeout=timeout) self.url = "unix:///var/run/docker.sock" self.is_connected = True if not self.was_connected: From 32b5e2ea05cd71165cc7d6e14c54952295f693e6 Mon Sep 17 00:00:00 2001 From: quietsy Date: Thu, 2 Jul 2026 13:12:15 +0300 Subject: [PATCH 12/17] Add readme --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b1d1f7e5..b904ed20 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,14 @@ This mod gives SWAG the ability to start containers on-demand when accessed thro - `SWAG_ONDEMAND_STOP_THRESHOLD` - duration of inactivity in seconds before stopping on-demand containers, defaults to `600` (10 minutes). - `SWAG_ONDEMAND_CONTAINER_QUERY_SLEEP` - sleep time in seconds between querying containers, defaults to `5.0`. - `SWAG_ONDEMAND_LOG_READER_SLEEP` - sleep time in seconds between log reads, defaults to `1.0`. - - `SWAG_ONDEMAND_REMOTE1...20` - the remote API of other hosts for ondemand to manage, such as: tcp://otherhost:2375. can add up to 20. + - `SWAG_ONDEMAND_REMOTE1` - the remote API of other hosts for ondemand to manage. For example: `tcp://otherhost:2375`. + - `SWAG_ONDEMAND_REMOTE1_WOL_MAC` - Required for WoL, specifies which MAC address to send the WoL packet to. For example: `00:00:0A:BB:28:FC`. + - `SWAG_ONDEMAND_REMOTE1_WOL_URLS` - Required for WoL, specifies which URL prefixes would trigger WoL. Same syntax as `swag_ondemand_urls` below. For example: `https://somecontainer.`. + - `SWAG_ONDEMAND_REMOTE1_WOL_BROADCAST` - Optional, override which broadcast to send the WoL packet to. Defaults to `255.255.255.255`. + - `SWAG_ONDEMAND_REMOTE1_WOL_PORT` - Optional, override which port to send the WoL packet to. Defaults to `9`. + - `SWAG_ONDEMAND_REMOTE1_WOL_INTERFACE` - Optional, override which interface to use for sending the WoL packet. Defaults to the first interface. + +**You can increment the number for up to 20 remote hosts. For example: `SWAG_ONDEMAND_REMOTE2`, `SWAG_ONDEMAND_REMOTE3`, etc.** ### Loading Page: From 3aaf3c0bc3632a5e89762de67f2ae5cf6dc166d5 Mon Sep 17 00:00:00 2001 From: quietsy Date: Thu, 2 Jul 2026 13:32:14 +0300 Subject: [PATCH 13/17] Add a note about wol --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index b904ed20..d20549f8 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ This mod gives SWAG the ability to start containers on-demand when accessed thro **You can increment the number for up to 20 remote hosts. For example: `SWAG_ONDEMAND_REMOTE2`, `SWAG_ONDEMAND_REMOTE3`, etc.** + **For WoL to work in a container, you need to either set `network_mode: host` or broadcast to the IP of the remote host and set a static ARP on the router. For example: in opnsense add an entry under Interfaces > Neighbors > Static Assignments.** + ### Loading Page: ![loading-page](.assets/loading-page.png) From 2e660725ecdc25cce289bec55d50674ab80874a0 Mon Sep 17 00:00:00 2001 From: quietsy Date: Thu, 2 Jul 2026 16:30:00 +0300 Subject: [PATCH 14/17] Improve readme --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d20549f8..01e57f8e 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ This mod gives SWAG the ability to start containers on-demand when accessed thro - `SWAG_ONDEMAND_STOP_THRESHOLD` - duration of inactivity in seconds before stopping on-demand containers, defaults to `600` (10 minutes). - `SWAG_ONDEMAND_CONTAINER_QUERY_SLEEP` - sleep time in seconds between querying containers, defaults to `5.0`. - `SWAG_ONDEMAND_LOG_READER_SLEEP` - sleep time in seconds between log reads, defaults to `1.0`. + - `SWAG_ONDEMAND_DOCKER_API_TIMEOUT` - the timeout for docker's API. Defaults to `5`. - `SWAG_ONDEMAND_REMOTE1` - the remote API of other hosts for ondemand to manage. For example: `tcp://otherhost:2375`. - `SWAG_ONDEMAND_REMOTE1_WOL_MAC` - Required for WoL, specifies which MAC address to send the WoL packet to. For example: `00:00:0A:BB:28:FC`. - `SWAG_ONDEMAND_REMOTE1_WOL_URLS` - Required for WoL, specifies which URL prefixes would trigger WoL. Same syntax as `swag_ondemand_urls` below. For example: `https://somecontainer.`. @@ -37,7 +38,7 @@ This mod gives SWAG the ability to start containers on-demand when accessed thro **You can increment the number for up to 20 remote hosts. For example: `SWAG_ONDEMAND_REMOTE2`, `SWAG_ONDEMAND_REMOTE3`, etc.** - **For WoL to work in a container, you need to either set `network_mode: host` or broadcast to the IP of the remote host and set a static ARP on the router. For example: in opnsense add an entry under Interfaces > Neighbors > Static Assignments.** +**For WoL to work in a container, you need to either set `network_mode: host` or broadcast to the IP of the remote host and set a static ARP on the router. For example: in opnsense add an entry under Interfaces > Neighbors > Static Assignments.** ### Loading Page: From aa9135d08fa9d299abedd7d38c470c721c6c4be1 Mon Sep 17 00:00:00 2001 From: quietsy Date: Thu, 2 Jul 2026 18:06:51 +0300 Subject: [PATCH 15/17] Separate healthcheck to another thread --- root/app/ondemand/container_thread.py | 24 ++++++++++++++++-------- root/app/ondemand/data_classes.py | 22 +++++++++++++++------- root/app/ondemand/healthcheck_thread.py | 20 ++++++++++++++++++++ root/app/ondemand/main.py | 10 ++++++++-- 4 files changed, 59 insertions(+), 17 deletions(-) create mode 100644 root/app/ondemand/healthcheck_thread.py diff --git a/root/app/ondemand/container_thread.py b/root/app/ondemand/container_thread.py index bd9f7c86..b2601e5a 100644 --- a/root/app/ondemand/container_thread.py +++ b/root/app/ondemand/container_thread.py @@ -9,7 +9,6 @@ import wakeonlan CONTAINER_QUERY_SLEEP = float(os.environ.get("SWAG_ONDEMAND_CONTAINER_QUERY_SLEEP", "5.0")) -DOCKER_API_TIMEOUT = int(os.environ.get("SWAG_ONDEMAND_DOCKER_API_TIMEOUT", "5")) STOP_THRESHOLD = int(os.environ.get("SWAG_ONDEMAND_STOP_THRESHOLD", "600")) REMOTE_HOSTS_PREFIX = "SWAG_ONDEMAND_REMOTE" @@ -27,7 +26,7 @@ def init_docker_hosts(self): docker_host_url = f"tcp://{docker_host_url}:2375" self.docker_hosts.append(DockerHost(url=docker_host_url)) - remote_hosts_env_vars = { key: value for key, value in os.environ.items() if key.startswith(REMOTE_HOSTS_PREFIX) } + remote_hosts_env_vars = {key: value for key, value in os.environ.items() if key.startswith(REMOTE_HOSTS_PREFIX)} for i in range(1, 21): if f"{REMOTE_HOSTS_PREFIX}{i}" not in remote_hosts_env_vars: break @@ -42,11 +41,9 @@ def init_docker_hosts(self): remote_host.wol_port = int(remote_hosts_env_vars.get(f"{REMOTE_HOSTS_PREFIX}{i}_WOL_PORT", "9")) remote_host.wol_interface = remote_hosts_env_vars.get(f"{REMOTE_HOSTS_PREFIX}{i}_WOL_INTERFACE", None) self.docker_hosts.append(remote_host) - + def process_containers(self): for docker_host in self.docker_hosts: - docker_host.init_client(DOCKER_API_TIMEOUT) - if not docker_host.is_connected: continue @@ -82,6 +79,8 @@ def process_containers(self): def stop_containers(self): for docker_host in self.docker_hosts: + if not docker_host.is_connected: + continue for container_name, ondemand_container in docker_host.ondemand_containers.items(): if ondemand_container.status != "running": continue @@ -100,6 +99,8 @@ def stop_containers(self): def start_containers(self, last_accessed_urls_combined: str): for docker_host in self.docker_hosts: + if not docker_host.is_connected: + continue for container_name, ondemand_container in docker_host.ondemand_containers.items(): accessed = False for ondemand_url in ondemand_container.urls.split(","): @@ -125,7 +126,12 @@ def send_wol(self, last_accessed_urls_combined: str): continue for wol_url in docker_host.wol_urls.split(","): if wol_url in last_accessed_urls_combined: - wakeonlan.send_magic_packet(docker_host.wol_mac, ip_address=docker_host.wol_broadcast, port=docker_host.wol_port, interface=docker_host.wol_interface) + wakeonlan.send_magic_packet( + docker_host.wol_mac, + ip_address=docker_host.wol_broadcast, + port=docker_host.wol_port, + interface=docker_host.wol_interface + ) logging.info(f"Sent a WoL packet to mac {docker_host.wol_mac} via broadcast {docker_host.wol_broadcast} on port {docker_host.wol_port} on interface {docker_host.wol_interface or 'default'} activated by {wol_url}") break @@ -133,12 +139,14 @@ def run(self): while True: try: self.process_containers() + with last_accessed_urls_lock: last_accessed_urls_combined = ",".join(last_accessed_urls) last_accessed_urls.clear() + self.send_wol(last_accessed_urls_combined) self.start_containers(last_accessed_urls_combined) self.stop_containers() - time.sleep(CONTAINER_QUERY_SLEEP) except Exception as e: - logging.exception(e) + logging.error(f"Error in container thread main loop: {e}") + time.sleep(CONTAINER_QUERY_SLEEP) diff --git a/root/app/ondemand/data_classes.py b/root/app/ondemand/data_classes.py index 2bd9057b..20d1de56 100644 --- a/root/app/ondemand/data_classes.py +++ b/root/app/ondemand/data_classes.py @@ -24,16 +24,19 @@ class DockerHost: was_connected: bool = False ondemand_containers: dict[str, OnDemandContainer] = field(default_factory=dict) - def init_client(self, timeout: int): + def check_connection(self, timeout: int): try: self.was_connected = self.is_connected if self.client and self.client.ping(): + self.is_connected = True return + if self.url: self.client = docker.DockerClient(base_url=self.url, timeout=timeout) else: self.client = docker.from_env(timeout=timeout) self.url = "unix:///var/run/docker.sock" + self.is_connected = True if not self.was_connected: logging.info(f"Connection to {self.url} has been restored") @@ -41,22 +44,27 @@ def init_client(self, timeout: int): self.client = None self.is_connected = False if self.was_connected: - logging.warning(f"Lost connection to {self.url}") + logging.warning(f"Lost connection to {self.url} during health check") + + def handle_disconnect(self): + self.client = None + self.is_connected = False + logging.warning(f"Lost connection to {self.url} during runtime operation") def get_container(self, container_name: str): try: - if not self.client: + if not self.client or not self.is_connected: return None return self.client.containers.get(container_name) except (docker.errors.DockerException, requests.exceptions.ConnectionError): - logging.warning(f"Failed to get {container_name}, docker host {self.url} is unavailable") + self.handle_disconnect() return None def get_containers(self): try: - if not self.client: + if not self.client or not self.is_connected: return None - return self.client.containers.list(all=True, filters={ "label": ["swag_ondemand=enable"] }) + return self.client.containers.list(all=True, filters={"label": ["swag_ondemand=enable"]}) except (docker.errors.DockerException, requests.exceptions.ConnectionError): - logging.warning(f"Failed to get containers, docker host {self.url} is unavailable") + self.handle_disconnect() return None diff --git a/root/app/ondemand/healthcheck_thread.py b/root/app/ondemand/healthcheck_thread.py new file mode 100644 index 00000000..2b913293 --- /dev/null +++ b/root/app/ondemand/healthcheck_thread.py @@ -0,0 +1,20 @@ +from data_classes import DockerHost + +import os +import threading +import time + +DOCKER_API_TIMEOUT = int(os.environ.get("SWAG_ONDEMAND_DOCKER_API_TIMEOUT", "5")) + + +class HealthcheckThread(threading.Thread): + def __init__(self, docker_hosts: list[DockerHost]): + super().__init__(name="HealthcheckThread") + self.daemon = True + self.docker_hosts = docker_hosts + + def run(self): + while True: + for docker_host in self.docker_hosts: + docker_host.check_connection(DOCKER_API_TIMEOUT) + time.sleep(DOCKER_API_TIMEOUT) diff --git a/root/app/ondemand/main.py b/root/app/ondemand/main.py index a0278f53..11d5ee18 100644 --- a/root/app/ondemand/main.py +++ b/root/app/ondemand/main.py @@ -1,4 +1,5 @@ from container_thread import ContainerThread +from healthcheck_thread import HealthcheckThread from log_reader_thread import LogReaderThread import logging @@ -16,9 +17,14 @@ datefmt='%Y-%m-%d %H:%M:%S', level=logging.INFO) logging.info("Starting swag-ondemand...") + + container_thread = ContainerThread() + healthcheck_thread = HealthcheckThread(container_thread.docker_hosts) + log_reader_thread = LogReaderThread() - ContainerThread().start() - LogReaderThread().start() + healthcheck_thread.start() + container_thread.start() + log_reader_thread.start() while True: time.sleep(1) From 3e8f2dceeaab893bda4507281b85ae254ac30084 Mon Sep 17 00:00:00 2001 From: quietsy Date: Thu, 2 Jul 2026 18:27:16 +0300 Subject: [PATCH 16/17] Lower the sleep --- root/app/ondemand/healthcheck_thread.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/root/app/ondemand/healthcheck_thread.py b/root/app/ondemand/healthcheck_thread.py index 2b913293..73ae9d5a 100644 --- a/root/app/ondemand/healthcheck_thread.py +++ b/root/app/ondemand/healthcheck_thread.py @@ -17,4 +17,4 @@ def run(self): while True: for docker_host in self.docker_hosts: docker_host.check_connection(DOCKER_API_TIMEOUT) - time.sleep(DOCKER_API_TIMEOUT) + time.sleep(1) From 56687344667227c707195fa7f8126e3c98e22d31 Mon Sep 17 00:00:00 2001 From: quietsy Date: Thu, 2 Jul 2026 18:55:24 +0300 Subject: [PATCH 17/17] Refactor --- root/app/ondemand/container_thread.py | 11 ++++++++--- root/app/ondemand/data_classes.py | 10 ++++++---- root/app/ondemand/healthcheck_thread.py | 23 +++++++++++++++++++---- 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/root/app/ondemand/container_thread.py b/root/app/ondemand/container_thread.py index b2601e5a..a9823ac5 100644 --- a/root/app/ondemand/container_thread.py +++ b/root/app/ondemand/container_thread.py @@ -98,6 +98,9 @@ def stop_containers(self): logging.info(f"Stopped {container_name} on {docker_host.url} after {STOP_THRESHOLD}s of inactivity") def start_containers(self, last_accessed_urls_combined: str): + if not last_accessed_urls_combined: + return + for docker_host in self.docker_hosts: if not docker_host.is_connected: continue @@ -121,6 +124,9 @@ def start_containers(self, last_accessed_urls_combined: str): logging.info(f"Started {container_name} on {docker_host.url}") def send_wol(self, last_accessed_urls_combined: str): + if not last_accessed_urls_combined: + return + for docker_host in self.docker_hosts: if not docker_host.wol_mac or not docker_host.wol_urls or docker_host.is_connected: continue @@ -138,15 +144,14 @@ def send_wol(self, last_accessed_urls_combined: str): def run(self): while True: try: - self.process_containers() - with last_accessed_urls_lock: last_accessed_urls_combined = ",".join(last_accessed_urls) last_accessed_urls.clear() self.send_wol(last_accessed_urls_combined) + self.process_containers() self.start_containers(last_accessed_urls_combined) self.stop_containers() except Exception as e: - logging.error(f"Error in container thread main loop: {e}") + logging.exception(e) time.sleep(CONTAINER_QUERY_SLEEP) diff --git a/root/app/ondemand/data_classes.py b/root/app/ondemand/data_classes.py index 20d1de56..4bc1cf02 100644 --- a/root/app/ondemand/data_classes.py +++ b/root/app/ondemand/data_classes.py @@ -53,18 +53,20 @@ def handle_disconnect(self): def get_container(self, container_name: str): try: - if not self.client or not self.is_connected: + client = self.client + if not client or not self.is_connected: return None - return self.client.containers.get(container_name) + return client.containers.get(container_name) except (docker.errors.DockerException, requests.exceptions.ConnectionError): self.handle_disconnect() return None def get_containers(self): try: - if not self.client or not self.is_connected: + client = self.client + if not client or not self.is_connected: return None - return self.client.containers.list(all=True, filters={"label": ["swag_ondemand=enable"]}) + return client.containers.list(all=True, filters={"label": ["swag_ondemand=enable"]}) except (docker.errors.DockerException, requests.exceptions.ConnectionError): self.handle_disconnect() return None diff --git a/root/app/ondemand/healthcheck_thread.py b/root/app/ondemand/healthcheck_thread.py index 73ae9d5a..779e66d8 100644 --- a/root/app/ondemand/healthcheck_thread.py +++ b/root/app/ondemand/healthcheck_thread.py @@ -1,5 +1,7 @@ from data_classes import DockerHost +from concurrent.futures import ThreadPoolExecutor +import logging import os import threading import time @@ -14,7 +16,20 @@ def __init__(self, docker_hosts: list[DockerHost]): self.docker_hosts = docker_hosts def run(self): - while True: - for docker_host in self.docker_hosts: - docker_host.check_connection(DOCKER_API_TIMEOUT) - time.sleep(1) + max_workers = max(1, len(self.docker_hosts)) + logging.info(f"Starting HealthcheckThread with a pool of {max_workers} workers.") + + with ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="HealthcheckWorker") as executor: + while True: + futures = [ + executor.submit(docker_host.check_connection, DOCKER_API_TIMEOUT) + for docker_host in self.docker_hosts + ] + + for future in futures: + try: + future.result() + except Exception as e: + logging.exception(e) + + time.sleep(1)