13 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
6 changed files with 225 additions and 55 deletions

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,12 +1,12 @@
"""Provides setup and the core Composer class functionality to interact with a compose stack""" """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 from git import exc, Repo
import logging import logging
from os import environ, getenv from os import environ, getenv
from pathlib import Path from pathlib import Path
from python_on_whales import DockerClient from python_on_whales import DockerClient
from python_on_whales.exceptions import DockerException from python_on_whales.exceptions import DockerException
import schedule
import sys import sys
handler = logging.StreamHandler(sys.stdout) handler = logging.StreamHandler(sys.stdout)
@@ -14,7 +14,16 @@ formatter = logging.Formatter("[%(levelname)s] - %(message)s")
handler.setFormatter(formatter) handler.setFormatter(formatter)
logger = logging.getLogger("composer-agent-logger") logger = logging.getLogger("composer-agent-logger")
logger.addHandler(handler) 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' environ['GIT_TERMINAL_PROMPT'] = '0'
@@ -72,7 +81,9 @@ class Composer:
Attributes: Attributes:
docker: DockerClient instance used to interact with the compose stack 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 services: list of Service objects defined by the stack
scheduler: BackgroundScheduler for updates
""" """
services = [] services = []
@@ -80,6 +91,7 @@ class Composer:
compose_files=["store/stack/compose.yml"], compose_files=["store/stack/compose.yml"],
compose_env_files=["store/stack/.env"] compose_env_files=["store/stack/.env"]
) )
name = docker.compose.config(return_json=True)["name"]
def __init__(self, remote_url): def __init__(self, remote_url):
@@ -99,11 +111,10 @@ class Composer:
except exc.GitCommandError: except exc.GitCommandError:
logger.critical("Failed to autheticate remote, aborting!") logger.critical("Failed to autheticate remote, aborting!")
raise Exception("Failed to authenticate remote!") raise Exception("Failed to authenticate remote!")
if self.docker.compose.config(return_json=True)["name"] != "stack": self.scheduler = BackgroundScheduler()
logger.warn(f"Composer stack name is wrong, either make sure it is set manually or keep it store/stack") self.scheduler.start()
self.start_update_job() self._update_job = self.start_update_job()
self._create_services() self._create_services()
self.set_status("up")
logger.info("Composer started and services created") logger.info("Composer started and services created")
def _create_services(self): def _create_services(self):
@@ -113,13 +124,16 @@ class Composer:
for service in self.services: for service in self.services:
self.services[service].close() self.services[service].close()
services_dict = self.docker.compose.config().services 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): def start_update_job(self):
"""Schedules a recurring update based on the UPDATE environment variable """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. 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) update_time = getenv("UPDATE", 1)
@@ -129,14 +143,14 @@ class Composer:
except (AssertionError, ValueError): except (AssertionError, ValueError):
self.update_time = 1 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") 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): def set_status(self, status):
"""Sets the running status of the compose stack """Sets the running status of the compose stack
Args: 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: try:
@@ -151,12 +165,16 @@ class Composer:
self.docker.compose.down( self.docker.compose.down(
services=[self.name] services=[self.name]
) )
elif status == "restart": elif status == "recreate":
self.docker.compose.restart( self.docker.compose.up(
services=[self.name] build=True,
detach=True,
force_recreate=True,
quiet=True,
remove_orphans=True
) )
else: 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: except DockerException as error:
logger.error(f"Setting status of composer failed: {error}") logger.error(f"Setting status of composer failed: {error}")
@@ -164,6 +182,7 @@ class Composer:
"""Updates the Composer stack based on the remote repo""" """Updates the Composer stack based on the remote repo"""
logger.info("Check for new compose stack")
current = self.repo.head.commit current = self.repo.head.commit
try: try:
self.repo.remotes.origin.pull('master') self.repo.remotes.origin.pull('master')
@@ -171,6 +190,7 @@ class Composer:
logger.critical("Failed to autheticate remote, aborting!") logger.critical("Failed to autheticate remote, aborting!")
if current != self.repo.head.commit: if current != self.repo.head.commit:
self._create_services() self._create_services()
self.name = self.docker.compose.config(return_json=True)["name"]
self.set_status("up") self.set_status("up")
logger.info(f"New compose stack deployed! Commit: {self.repo.head.commit}") logger.info(f"New compose stack deployed! Commit: {self.repo.head.commit}")
@@ -179,23 +199,23 @@ class Service:
"""Service instance meant to represent a container in a Composer stack """Service instance meant to represent a container in a Composer stack
Attributes: Attributes:
labels: labels given to the service config: config of the service
name: name given to the service name: name given to the service
parent: Composer instance containing the service parent: Composer instance containing the service
""" """
def __init__(self, name, labels, parent): def __init__(self, name, config, parent):
"""Initializes the Service instance """Initializes the Service instance
Args: Args:
name: name given to the service name: name given to the service
labels: labels given to the service config: config of the service
parent: Composer instance containing the service parent: Composer instance containing the service
""" """
self.name = name self.name = name
self.labels = labels self.config = config
self.parent = parent self.parent = parent
self._update_task = self.start_update_job() self._update_task = self.start_update_job()
@@ -203,37 +223,44 @@ class Service:
"""Safely removes the service instance""" """Safely removes the service instance"""
if self.update_time is None: if self._update_task is None:
return None return None
schedule.cancel_job(self._update_task) self._update_task.remove()
def get_status(self): def get_status(self):
"""Gets the running status of the service """Gets the status of the docker container
Returns: 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): def start_update_job(self):
"""Schedules a recurring update based on the composer.update label """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. 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 self.update_time = self.parent.update_time
if 'composer.update' in self.labels: labels = self.config.labels
if 'composer.update' in labels:
try: try:
self.update_time = int(self.labels['composer.update']) self.update_time = int(labels['composer.update'])
assert self.update_time > 0 assert self.update_time > 0
except (AssertionError, ValueError): except (AssertionError, ValueError):
self.update_time = self.parent.update_time 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: 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 return None
def set_status(self, status): def set_status(self, status):
@@ -241,7 +268,7 @@ class Service:
"""Sets the running status of the service """Sets the running status of the service
Args: 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: try:
@@ -257,12 +284,17 @@ class Service:
self.parent.docker.compose.down( self.parent.docker.compose.down(
services=[self.name] services=[self.name]
) )
elif status == "restart": elif status == "recreate":
self.parent.docker.compose.restart( self.parent.docker.compose.up(
services=[self.name] build=True,
detach=True,
force_recreate=True,
quiet=True,
services=[self.name],
remove_orphans=True
) )
else: 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: except DockerException as error:
logger.error(f"Setting status for service {self.name} failed: {error}") logger.error(f"Setting status for service {self.name} failed: {error}")
@@ -276,4 +308,5 @@ class Service:
services=[self.name] services=[self.name]
) )
logger.info(f"Updated service {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,12 +0,0 @@
from agent import *
import logging
from os import getenv
import time
Setup.populate_store()
Setup.create_symlink()
remote = Setup.create_credential_remote()
composer = Composer(remote)
while True:
schedule.run_pending()
time.sleep(1)

View File

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