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"""
|
"""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
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
|
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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user