Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 56251caaee | |||
| 531a9f84c9 | |||
| cb9a5f27b4 | |||
| fa1fa6402c | |||
| d2421f248c | |||
| f275203955 | |||
| 2d078648c2 | |||
| f3bdabc3cd | |||
| 5f8dac8235 | |||
| fa449ae9cd | |||
| 8627191d33 | |||
| 74838454da | |||
| e5ecf16127 |
14
composer-lens/app.py
Normal file
14
composer-lens/app.py
Normal 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()
|
||||
80
composer-lens/templates/stack.html
Normal file
80
composer-lens/templates/stack.html
Normal 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>
|
||||
@@ -1,12 +1,12 @@
|
||||
"""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 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)
|
||||
@@ -14,7 +14,16 @@ 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'
|
||||
|
||||
@@ -72,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 = []
|
||||
@@ -80,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):
|
||||
|
||||
@@ -99,11 +111,10 @@ class Composer:
|
||||
except exc.GitCommandError:
|
||||
logger.critical("Failed to autheticate remote, aborting!")
|
||||
raise Exception("Failed to authenticate remote!")
|
||||
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.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):
|
||||
@@ -113,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)
|
||||
@@ -129,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:
|
||||
@@ -151,12 +165,16 @@ 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.error(f"Setting status of composer failed: {error}")
|
||||
|
||||
@@ -164,6 +182,7 @@ class Composer:
|
||||
|
||||
"""Updates the Composer stack based on the remote repo"""
|
||||
|
||||
logger.info("Check for new compose stack")
|
||||
current = self.repo.head.commit
|
||||
try:
|
||||
self.repo.remotes.origin.pull('master')
|
||||
@@ -171,6 +190,7 @@ class Composer:
|
||||
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}")
|
||||
|
||||
@@ -179,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()
|
||||
|
||||
@@ -203,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):
|
||||
@@ -241,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:
|
||||
@@ -257,12 +284,17 @@ 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.error(f"Setting status for service {self.name} failed: {error}")
|
||||
|
||||
@@ -276,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
61
composer/connector.py
Normal 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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
python-on-whales==0.79.0
|
||||
Reference in New Issue
Block a user