Initial Commit

This commit is contained in:
2025-12-31 20:06:55 -08:00
commit 878de447ae
6 changed files with 197 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.venv
store
data

8
Dockerfile Normal file
View File

@@ -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']

Binary file not shown.

174
composer/agent.py Normal file
View File

@@ -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())

10
composer/entrypoint.py Normal file
View File

@@ -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)

View File

@@ -0,0 +1,2 @@
python-on-whales
GitPython