commit 878de447aeeba8803a0ca1aec38e2338614260a5 Author: craisin Date: Wed Dec 31 20:06:55 2025 -0800 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..522b986 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.venv +store +data \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ddfc87b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM docker +WORKDIR / +COPY composer /composer +COPY stack /stack +RUN apk update +RUN apk add --no-cache git python3 py3-pip +RUN pip3 install -r composer/requirements.txt +ENTRYPOINT ['python3', 'composer/entrypoint.py'] \ No newline at end of file diff --git a/composer/__pycache__/agent.cpython-312.pyc b/composer/__pycache__/agent.cpython-312.pyc new file mode 100644 index 0000000..2167aca Binary files /dev/null and b/composer/__pycache__/agent.cpython-312.pyc differ diff --git a/composer/agent.py b/composer/agent.py new file mode 100644 index 0000000..ae9644e --- /dev/null +++ b/composer/agent.py @@ -0,0 +1,174 @@ +from git import Repo +import logging +from os import getenv +from pathlib import Path +from python_on_whales import DockerClient +from python_on_whales.exceptions import DockerException +import schedule +import sys + +logger = logging.getLogger("agent-logger") +logger.setLevel(logging.DEBUG) +logger.addHandler(logging.StreamHandler(sys.stdout)) + +class Setup: + + @staticmethod + def create_symlink(): + host_data_env = getenv("HOST_DATA_PATH", None) + if host_data_env is None: + logger.info(f"HOST_DATA_PATH is not set, symlink not created") + return None + try: + host_data_path = Path(host_data_env) + container_data_path = Path("store/data") + if not host_data_path.is_symlink(): + host_data_path.symlink_to(container_data_path) + logger.info(f"Created symlink: {host_data_path} -> {container_data_path}") + else: + logger.info(f"Symlink already exists: {host_data_path} -> {container_data_path}") + except: + logger.error(f"Failed to create symlink from {host_data_env}") + +class Composer: + + services = [] + + def __init__(self, remote_url): + if (not Path("store/stack/compose.yml").exists()) or (not Path("store/stack/.git").exists()): + self.repo = Repo.init("store/stack") + self._add_remote(remote_url) + else: + self.repo = Repo("store/stack") + self.repo.remotes.origin.pull('master') + self.docker = DockerClient( + compose_files=["store/stack/compose.yml"], + compose_env_files=["store/stack/.env"] + ) + if self.docker.compose.config(return_json=True)["name"] != "stack": + logger.warn(f"Composer stack name is wrong, either make sure it is set manually or keep it store/stack") + self.start_update_job() + self._create_services() + self.set_status("up") + logger.info("Composer started and services created") + + def _add_remote(self, url): + self.repo.create_remote('origin', url) + + def _create_services(self): + for service in self.services: + self.services[service].close() + services_dict = self.docker.compose.config().services + self.services = {service: Service(service, services_dict[service].labels, self) for service in services_dict} + + def start_update_job(self): + update_time = getenv("UPDATE", 1) + try: + self.update_time = int(update_time) + assert self.update_time > 0 + except (AssertionError, ValueError): + self.update_time = 1 + logging.warn(f"Composer has an invalid UPDATE value of {getenv("UPDATE")}, defaulting to update interval of {self.update_time} minutes") + schedule.every(self.update_time).minutes.do(self.update) + + def set_status(self, status): + try: + if status == "up": + self.docker.compose.up( + build=True, + detach=True, + quiet=True, + remove_orphans=True + ) + elif status == "down": + self.docker.compose.down( + services=[self.name] + ) + elif status == "restart": + self.docker.compose.restart( + services=[self.name] + ) + else: + logger.warn(f"Undefined status {status} - use up, down, or restart") + except DockerException as error: + logger.critical(f"Setting status of composer failed: {error}") + + def update(self): + current = self.repo.head.commit + self.repo.remotes.origin.pull('master') + if current != self.repo.head.commit: + self._create_services() + self.set_status("up") + logger.info(f"New compose stack deployed! Commit: {self.repo.head.commit}") + +class Service: + + update_task = None + + def __init__(self, name, labels, parent): + self.name = name + self.labels = labels + self.parent = parent + self.update_task = self.start_update_job() + + def close(self): + if self.update_time is None: + return None + schedule.cancel_job(self.update_task) + + def get_status(self): + return "up" if bool(self.parent.docker.compose.ps(services=[self.name])) else "down" + + def start_update_job(self): + self.update_time = self.parent.update_time + if 'composer.update' in self.labels: + try: + self.update_time = int(self.labels['composer.update']) + assert self.update_time > 0 + except (AssertionError, ValueError): + self.update_time = self.parent.update_time + logging.warn(f"Service {self.name} has an invalid composer.update value of {self.labels['composer.update']}, defaulting to update interval of {self.update_time} minutes") + if self.update_time != 0: + return schedule.every(self.update_time).minutes.do(self.update) + return None + + def set_status(self, status): + try: + if status == "up": + self.parent.docker.compose.up( + build=True, + detach=True, + quiet=True, + services=[self.name], + remove_orphans=True + ) + elif status == "down": + self.parent.docker.compose.down( + services=[self.name] + ) + elif status == "restart": + self.parent.docker.compose.restart( + services=[self.name] + ) + else: + logger.warn(f"Undefined status {status} - use up, down, or restart") + except DockerException as error: + logger.critical(f"Setting status for service {self.name} failed: {error}") + logger.critical(error) + + def update(self, restart=True): + try: + self.parent.docker.compose.build( + quiet=True, + services=[self.name] + ) + except DockerException: + logger.warn(f"Failed to build docker image for service {self.name} - cancelling container update") + return None + self.parent.docker.compose.pull( + ignore_pull_failures=True, + quiet=True, + services=[self.name] + ) + logger.info(f"Updated service {self.name}") + self.set_status(self.get_status()) \ No newline at end of file diff --git a/composer/entrypoint.py b/composer/entrypoint.py new file mode 100644 index 0000000..0bd2674 --- /dev/null +++ b/composer/entrypoint.py @@ -0,0 +1,10 @@ +from agent import * +import logging +from os import getenv +import time + +Setup.create_symlink() +composer = Composer(getenv("REMOTE_REPO")) +while True: + schedule.run_pending() + time.sleep(1) \ No newline at end of file diff --git a/composer/requirements.txt b/composer/requirements.txt new file mode 100644 index 0000000..3f03705 --- /dev/null +++ b/composer/requirements.txt @@ -0,0 +1,2 @@ +python-on-whales +GitPython \ No newline at end of file