15 Commits

Author SHA1 Message Date
56251caaee update stuff 2026-01-03 12:13:45 -08:00
531a9f84c9 finish composer api 2026-01-02 22:39:44 -08:00
cb9a5f27b4 refactor classes for Connector 2026-01-02 18:05:09 -08:00
fa1fa6402c create connector entrypoint 2026-01-02 17:27:08 -08:00
d2421f248c switch to apscheduler 2026-01-02 17:09:02 -08:00
f275203955 create container info func 2026-01-01 18:20:01 -08:00
2d078648c2 deprecate container restart 2026-01-01 18:10:38 -08:00
f3bdabc3cd add log level setting 2026-01-01 18:06:30 -08:00
5f8dac8235 refactor status 2026-01-01 18:03:02 -08:00
fa449ae9cd some ui stuff ig 2026-01-01 17:46:13 -08:00
8627191d33 modify requirements 2026-01-01 16:08:53 -08:00
74838454da add stack template 2026-01-01 16:08:17 -08:00
e5ecf16127 add recreate 2026-01-01 11:53:25 -08:00
ef86a101bd add git credential auth 2026-01-01 11:24:59 -08:00
9eeb346b8a module docs 2025-12-31 22:16:29 -08:00
7 changed files with 260 additions and 63 deletions

View File

@@ -3,3 +3,4 @@
## TODO
- Support building images within docker compose
- GUI
- Allow stack to be named whatever is needed

14
composer-lens/app.py Normal file
View File

@@ -0,0 +1,14 @@
from flask import Flask, render_template
app = Flask(__name__)
@app.route("/")
def stack():
stack_name = "lab"
containers = ["website", "test-container", "composer"] * 5
image = ["image1", "image2", "image3"] * 5
status = [True, False, True] * 5
return render_template("stack.html", stack="lab", containers=[(containers[i], status[i], image[i]) for i in range(len(containers))])
if __name__=="__main__":
app.run()

View File

@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>𝚌𝚘𝚖𝚙𝚘𝚜𝚎𝚛 - {{ stack }}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Lexend:wght@100..900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
<style>
body{
font-family: "JetBrains Mono", monospace;
font-weight: 100;
}
h1{
font-family: "JetBrains Mono", monospace;
font-weight: 800;
}
h2{
font-family: "Lexend", sans-serif;
font-weight: 600;
}
h3{
font-family: "Lexend", sans-serif;
font-weight: 400;
}
b{
font-weight: 400;
}
</style>
</head>
<body class="text-light bg-dark">
<div class="container-fluid">
<div class="row text-center">
<h1>composer<i class="bi bi-music-note"></i></h1>
</div>
<div class="row">
<div class="col-lg-4">
<div class="border border-2 rounded-4 p-2 m-2">
<h2 class="d-inline-block">{{ stack }}</h2>
<div class="btn-group float-end">
<button type="button" class="btn btn-outline-light"><i class="bi bi-play-circle"></i></button>
<button type="button" class="btn btn-outline-light"><i class="bi bi-pause-circle"></i></button>
<button type="button" class="btn btn-outline-light"><i class="bi bi-arrow-clockwise"></i></button>
</div>
<br>
<p>
<b>running</b>: <br>
<b>stopped</b>: <br>
<b>remote</b>: {{ remote }}
</p>
</div>
</div>
<div class="col-lg-8">
{% for container, status, image in containers %}
<div class="border border-2 rounded-4 p-2 m-2">
<h3 class="d-inline-block">{{ container }}</h3>
<div class="btn-group float-end">
<button type="button" class="btn btn-outline-light"><i class="bi bi-play-circle"></i></button>
<button type="button" class="btn btn-outline-light"><i class="bi bi-pause-circle"></i></button>
<button type="button" class="btn btn-outline-light"><i class="bi bi-arrow-clockwise"></i></button>
</div>
<br>
<p>
{% if status %}
<b>status</b>: <span class="text-success">running</span><br>
{% else %}
<b>status</b>: <span class="text-danger">off</span><br>
{% endif %}
<b>image</b>: {{ image }}
</p>
</div>
{% endfor %}
</div>
</div>
</div>
</body>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" crossorigin="anonymous"></script>
</html>

View File

@@ -1,10 +1,12 @@
from git import Repo
"""Provides setup and the core Composer class functionality to interact with a compose stack"""
from apscheduler.schedulers.background import BackgroundScheduler
from git import exc, Repo
import logging
from os import getenv
from os import environ, getenv
from pathlib import Path
from python_on_whales import DockerClient
from python_on_whales.exceptions import DockerException
import schedule
import sys
handler = logging.StreamHandler(sys.stdout)
@@ -12,12 +14,38 @@ formatter = logging.Formatter("[%(levelname)s] - %(message)s")
handler.setFormatter(formatter)
logger = logging.getLogger("composer-agent-logger")
logger.addHandler(handler)
logger.setLevel(logging.DEBUG) #TODO: add something to change log levles
log_levels = {
"DEBUG": logging.DEBUG,
"INFO": logging.INFO,
"WARN": logging.WARN,
"ERROR": logging.ERROR
}
if getenv("LOG_LEVEL") in log_levels:
logger.setLevel(log_levels[getenv("LOG_LEVEL")])
else:
logger.setLevel(logging.INFO)
environ['GIT_TERMINAL_PROMPT'] = '0'
class Setup:
"""Setup class ensuring appropriate files and symlinks are created for a Composer instance"""
@staticmethod
def create_credential_remote():
"""Creates a remote url with credentials embedded"""
username, password = getenv("GIT_USERNAME"), getenv("GIT_PASSWORD")
remote = getenv("REMOTE_REPO")
if not remote:
logger.critical("Remote URL not defined, aborting!")
raise Exception("Remote URL not defined!")
if username is not None and password is not None:
return f"https://{username}:{password}@{remote.removeprefix('https://')}"
else:
return remote
@staticmethod
def create_symlink():
@@ -53,7 +81,9 @@ class Composer:
Attributes:
docker: DockerClient instance used to interact with the compose stack
name: string of the name of the compose file
services: list of Service objects defined by the stack
scheduler: BackgroundScheduler for updates
"""
services = []
@@ -61,6 +91,7 @@ class Composer:
compose_files=["store/stack/compose.yml"],
compose_env_files=["store/stack/.env"]
)
name = docker.compose.config(return_json=True)["name"]
def __init__(self, remote_url):
@@ -72,15 +103,18 @@ class Composer:
if not Path("store/stack/.git").exists():
self.repo = Repo.init("store/stack")
self.repo.create_remote('origin', url)
self.repo.create_remote('origin', remote_url)
else:
self.repo = Repo("store/stack")
self.repo.remotes.origin.pull('master')
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()
try:
self.repo.remotes.origin.pull('master')
except exc.GitCommandError:
logger.critical("Failed to autheticate remote, aborting!")
raise Exception("Failed to authenticate remote!")
self.scheduler = BackgroundScheduler()
self.scheduler.start()
self._update_job = self.start_update_job()
self._create_services()
self.set_status("up")
logger.info("Composer started and services created")
def _create_services(self):
@@ -90,13 +124,16 @@ class Composer:
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}
self.services = {service: Service(service, services_dict[service], self) for service in services_dict}
def start_update_job(self):
"""Schedules a recurring update based on the UPDATE environment variable
Each update occurs after an interval of UPDATE minutes. If UPDATE does not contain a valid integer in minutes, defaults to 1 minute.
Returns:
apscheduler.job.Job instance of the scheduled update job
"""
update_time = getenv("UPDATE", 1)
@@ -106,14 +143,14 @@ class Composer:
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)
return self.scheduler.add_job(self.update, 'interval', minutes=self.update_time)
def set_status(self, status):
"""Sets the running status of the compose stack
Args:
status: string containing "up", "down", or "restart" corresponding to the respective running state of the composer
status: string containing "up", "down", or "recreate" corresponding to the respective running state of the composer
"""
try:
@@ -128,23 +165,32 @@ class Composer:
self.docker.compose.down(
services=[self.name]
)
elif status == "restart":
self.docker.compose.restart(
services=[self.name]
elif status == "recreate":
self.docker.compose.up(
build=True,
detach=True,
force_recreate=True,
quiet=True,
remove_orphans=True
)
else:
logger.warn(f"Undefined status {status} - use up, down, or restart")
logger.warn(f"Undefined status {status} - use up, down, or recreate")
except DockerException as error:
logger.critical(f"Setting status of composer failed: {error}")
logger.error(f"Setting status of composer failed: {error}")
def update(self):
"""Updates the Composer stack based on the remote repo"""
logger.info("Check for new compose stack")
current = self.repo.head.commit
self.repo.remotes.origin.pull('master')
try:
self.repo.remotes.origin.pull('master')
except exc.GitCommandError:
logger.critical("Failed to autheticate remote, aborting!")
if current != self.repo.head.commit:
self._create_services()
self.name = self.docker.compose.config(return_json=True)["name"]
self.set_status("up")
logger.info(f"New compose stack deployed! Commit: {self.repo.head.commit}")
@@ -153,23 +199,23 @@ class Service:
"""Service instance meant to represent a container in a Composer stack
Attributes:
labels: labels given to the service
config: config of the service
name: name given to the service
parent: Composer instance containing the service
"""
def __init__(self, name, labels, parent):
def __init__(self, name, config, parent):
"""Initializes the Service instance
Args:
name: name given to the service
labels: labels given to the service
config: config of the service
parent: Composer instance containing the service
"""
self.name = name
self.labels = labels
self.config = config
self.parent = parent
self._update_task = self.start_update_job()
@@ -177,37 +223,44 @@ class Service:
"""Safely removes the service instance"""
if self.update_time is None:
if self._update_task is None:
return None
schedule.cancel_job(self._update_task)
self._update_task.remove()
def get_status(self):
"""Gets the running status of the service
"""Gets the status of the docker container
Returns:
"up" or "down" depending on the running status of the service
str specifying the status of the docker container
"""
return "up" if bool(self.parent.docker.compose.ps(services=[self.name])) else "down"
container = self.parent.docker.compose.ps(services=[self.name])
if not container:
return "down"
return container[0].state.status
def start_update_job(self):
"""Schedules a recurring update based on the composer.update label
Each update occurs after an interval of composer.update minutes. If composer.update does not contain a valid integer in minutes, defaults to the parent Composer's update_time.
Returns:
apscheduler.job.Job instance of the scheduled job, None if the job was not scheduled
"""
self.update_time = self.parent.update_time
if 'composer.update' in self.labels:
labels = self.config.labels
if 'composer.update' in labels:
try:
self.update_time = int(self.labels['composer.update'])
self.update_time = int(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")
logging.warn(f"Service {self.name} has an invalid composer.update value of {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 self.parent.scheduler.add_job(self.update, 'interval', minutes=self.update_time)
return None
def set_status(self, status):
@@ -215,7 +268,7 @@ class Service:
"""Sets the running status of the service
Args:
status: string containing "up", "down", or "restart" corresponding to the respective running state of the service
status: string containing "up", "down", or "recreate" corresponding to the respective running state of the service
"""
try:
@@ -231,15 +284,19 @@ class Service:
self.parent.docker.compose.down(
services=[self.name]
)
elif status == "restart":
self.parent.docker.compose.restart(
services=[self.name]
elif status == "recreate":
self.parent.docker.compose.up(
build=True,
detach=True,
force_recreate=True,
quiet=True,
services=[self.name],
remove_orphans=True
)
else:
logger.warn(f"Undefined status {status} - use up, down, or restart")
logger.warn(f"Undefined status {status} - use up, down, or recreate")
except DockerException as error:
logger.critical(f"Setting status for service {self.name} failed: {error}")
logger.critical(error)
logger.error(f"Setting status for service {self.name} failed: {error}")
def update(self, restart=True):
@@ -251,4 +308,5 @@ class Service:
services=[self.name]
)
logger.info(f"Updated service {self.name}")
self.set_status(self.get_status())
status = "up" if bool(self.parent.docker.compose.ps(services=[self.name])) else "down"
self.set_status(status)

61
composer/connector.py Normal file
View File

@@ -0,0 +1,61 @@
from agent import *
from flask import Flask
import json
class Connector:
@staticmethod
def get_composer_info(composer):
return {
"name": composer.name,
"running": len(composer.docker.compose.ps()),
"stopped": len(composer.services) - len(composer.docker.compose.ps()),
"remote": composer.repo.remotes.origin.url
}
@staticmethod
def get_container_info(composer, container_name):
if container_name not in composer.services:
return None
container = composer.services[container_name]
return {
"image": container.config.image,
"status": container.get_status()
}
@staticmethod
def set_composer_status(composer, status):
composer.set_status(status)
@staticmethod
def set_container_status(composer, container_name, status):
if container_name not in composer.services:
return None
composer.services[container_name].set_status(status)
Setup.populate_store()
Setup.create_symlink()
remote = Setup.create_credential_remote()
composer = Composer(remote)
app = Flask(__name__)
@app.route("/composer")
def get_composer():
return Connector.get_composer_info(composer)
@app.route("/composer/<status>")
def set_composer(status):
Connector.set_composer_status(composer, status)
return ""
@app.route("/container/<container>")
def get_container(container):
return Connector.get_container_info(composer, container)
@app.route("/container/<container>/<status>")
def set_container(container, status):
Connector.set_container_status(composer, container, status)
return ""
app.run()

View File

@@ -1,11 +0,0 @@
from agent import *
import logging
from os import getenv
import time
Setup.populate_store()
Setup.create_symlink()
composer = Composer(getenv("REMOTE_REPO"))
while True:
schedule.run_pending()
time.sleep(1)

View File

@@ -1,10 +1,4 @@
annotated-types==0.7.0
gitdb==4.0.12
APScheduler==3.11.2
Flask==3.1.2
GitPython==3.1.45
pydantic==2.12.5
pydantic_core==2.41.5
python-on-whales==0.79.0
schedule==1.2.2
smmap==5.0.2
typing-inspection==0.4.2
typing_extensions==4.15.0