Adicionar src/renatoxsr-py/scripts/composectl.py

This commit is contained in:
2026-09-17 09:54:27 -03:00
parent 91b60607ab
commit e284166f0a
+353
View File
@@ -0,0 +1,353 @@
#!/usr/bin/env python3
# vim: set: ft=python ts=4 sw=4 et :
# python3 << '#EOF'
try:
import argparse
import asyncio
from collections import namedtuple
from collections.abc import Mapping, Sequence
import datetime as dt
from enum import Enum, IntEnum, StrEnum, property as enum_property
import logging
import os
from pathlib import Path
import shutil
import sys
from typing import TextIO, NamedTuple
except ImportError as e:
args = [e.msg]
msg = ["[%s]"]
if e.name and e.path:
args.append(e.name)
args.append(e.path)
msg.append("('%s' : '%s')")
else:
msg.append("('%s')")
if e.name:
args.append(e.name)
if e.path:
args.append(e.path)
#logging.basicConfig(level=logging.DEBUG)
#logger = logging.getLogger(__name__)
#logger.critical(msg, *args)
print(msg % tuple(args))
sys.exit(1)
#print("import success")
# ***
# Globals and constants
# ANSI CODES
def CSI(code) -> str:
return "\033[" + str(code) + "m"
COLOR_RESET = CSI(0)
class COLOR(IntEnum):
BLACK = 0 #\033[40m\033[90m\033[100m
RED = 1 #\033[41m\033[91m\033[101m
GREEN = 2 #\033[42m\033[92m\033[102m
YELLOW= 3 #\033[43m\033[93m\033[103m
BLUE = 4 #\033[44m\033[94m\033[104m
MAGENTA = 5 #\033[45m\033[95m\033[105m
CYAN = 6 #\033[46m\033[96m\033[106m
WHITE = 7 #\033[47m\033[97m\033[107m
FG = 30
BG = 40
@enum_property
def reset(cls):
return CSI(0)
@enum_property
def fg(self):
return CSI(COLOR.FG + self)
@enum_property
def bg(self):
return CSI(COLOR.BG + self)
# ***
# Global Setup
# Setup logger
#logging.basicConfig(level=logging.DEBUG, format=LOGFMT)
#for handler in logging.root.handlers[:]:
# logging.root.removeHandler(handler)
# handler.close()
# Log utils
LEVEL_COLOR: dict[int, str] = {
logging.DEBUG: COLOR.CYAN.fg,
logging.INFO: COLOR.MAGENTA.fg,
logging.WARNING: COLOR.YELLOW.fg,
logging.ERROR: COLOR.RED.fg,
logging.CRITICAL: COLOR.RED.bg + COLOR.WHITE.fg,
}
LEVEL_NAME: dict[int, str] = {
logging.DEBUG: "DEBUG",
logging.INFO: "INFO",
logging.WARNING: "WARN",
logging.ERROR: "ERR",
logging.CRITICAL: "CRIT",
}
class LevelColorFormatter(logging.Formatter):
def format(self, record):
LOGFMT = ("[%(name)s][%(filename)s" +
#":%(funcName)s" +
"(%(lineno)04d)]" +
"[%(relativeCreated)d]%(levelname)5s: %(message)s")
record.relativeCreatedSeconds = record.relativeCreated / 1000.0
record.levelname = LEVEL_NAME.get(record.levelno, "UNSET")
formatter = logging.Formatter(f"{LEVEL_COLOR[record.levelno]}" +
LOGFMT.replace("%(relativeCreated)d","%(relativeCreatedSeconds).03f")
+ COLOR_RESET)
return formatter.format(record)
# ***
# Get module logger
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
# Console Logger
stderr_handler = logging.StreamHandler(stream=sys.stderr)
stderr_handler.setFormatter(LevelColorFormatter())
stderr_handler.setLevel(logging.DEBUG)
logger.addHandler(stderr_handler)
#print(logger.handlers)
# ***
MIN_VERSION = NamedTuple("MIN_VERSION", [
("major", int),
("minor", int),
("micro", int),
("releaselevel", str),
("serial", int)])(
major=3,
minor=11,
micro=None,
releaselevel=None,
serial=None)
python_requires = f">={MIN_VERSION.major}{MIN_VERSION.minor}"
# Check python version
def check_python_version(version_info, min_version):
logger.debug(version_info)
#print(version_info)
if version_info < min_version:
logger.critical("Insufficient Python version: %d.%d. (needs at least: %d.%d)",
version_info[0],
version_info[1],
min_version[0],
min_version[1])
sys.exit(1)
# Run check
check_python_version(sys.version_info, MIN_VERSION)
# Package version
def set_package_version(file):
mtime = Path(file).stat().st_mtime
version = dt.datetime.fromtimestamp(mtime).strftime("v%Y-%m-%d.%H-%M-%S.%f")
logger.debug("%s:%s", file, version)
return version
# Set version
__version__ = set_package_version(__file__)
# Global Functions
def cmd(compose_cmd: str | Path, compose_file: str | Path, *args, **kwargs):
if isinstance(compose_cmd, str):
compose_bin, compose_args = compose_cmd.split(" ", maxsplit=1)
bin_path = shutil.which(compose_bin)
logger.debug("BIN: %s", bin_path)
full_cmd = [bin_path]
if " " in compose_args:
full_cmd.extend(compose_args.split(" "))
else:
full_cmd.append(compose_args)
if not compose_file:
compose_file = "compose.yml"
compose_fpath = Path(compose_file).resolve()
if not compose_fpath.exists():
logger.error("Compose file does not exist: '%s'", compose_file)
return None
full_cmd.extend(["-f", compose_fpath])
for a in args:
if isinstance(a, str):
full_cmd.append(a)
elif isinstance(a, Mapping):
for k,v in a.items():
if " " in k:
logger.error("Invalid key name: %s", k)
continue
if len(k) > 1:
full_cmd.append(f"--{k}")
else:
full_cmd.append(f"-{k}")
full_cmd.append(str(v))
elif isinstance(a, Sequence):
full_cmd.extend(a)
else:
full_cmd.append(a)
logger.debug("CMD: %s", repr(full_cmd))
return full_cmd
# def log_wait(i, td, outs, errs):
# #if outs is None and errs is None:
# logger.debug("[%d] %d.%03d", i, td.seconds, td.microseconds % 1000)
# if outs is not None:
# sys.stdout.write(
# COLOR.GREEN.fg +
# (outs.decode(encoding="utf-8") if isinstance(outs, bytes) else outs)
# + COLOR_RESET)
# sys.stdout.flush()
# if errs is not None:
# sys.stderr.write(
# COLOR.RED.fg +
# (errs.decode(encoding="utf-8") if isinstance(errs, bytes) else errs)
# + COLOR_RESET)
# sys.stderr.flush()
async def runcmd(cmd, name: str = None):
cmd_exec = cmd[0]
cmd_args = cmd[1:]
logger.debug("EXEC: '%s'", cmd_exec)
logger.debug("ARGS: ['%s']", "', '".join([str(c) for c in cmd_args]))
start = dt.datetime.now()
i = 0
async with asyncio.TaskGroup() as tg:
proc = await asyncio.create_subprocess_exec(
cmd_exec, *cmd_args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE)
tg.create_task(read_stream(proc.stdout, sys.stdout,
name=(name or "STDOUT"),
color=COLOR.GREEN.fg,
start_time=start))
tg.create_task(read_stream(proc.stderr, sys.stderr,
name=(name or "STDERR"),
color=COLOR.RED.fg,
start_time=start))
return_code = await proc.wait()
async def read_stream(
stream_from: TextIO,
stream_to: TextIO,
name: str=None,
color: str = None,
start_time: dt.datetime = None,
):
while True:
line = await stream_from.readline()
if not line:
break
if start_time:
td = dt.datetime.now() - start_time
delta = "%d.%03d " % (td.seconds, td.microseconds % 1000)
stream_to.write(
(color or "") +
(f"[{name}] " if name else "") +
(delta if start_time else "") +
line.decode("raw_unicode_escape") + COLOR_RESET)
stream_to.flush()
# Configured Actions
class ACTION(Enum):
UP = ("--progress=plain", "up", "-d", "--remove-orphans")
UPDB = ("--progress=plain", "up","-d","--build", "--pull", "always", "--remove-orphans")
DOWN = ("--progress=plain", "down","--remove-orphans")
LOGS = ("--progress=plain", "logs",)
LOGSF = ("--progress=plain", "logs","-f")
LS = ("--progress=plain", "ls",)
BUILD = ("--progress=plain", "build", "--pull", "--push")
def main():
logger.debug(f"CWD:\t{os.getcwd()}")
p = argparse.ArgumentParser()
p.add_argument("--root", type=Path, default="/srv/containers")
#p.add_argument("--prefix", default="", help="Prefix of compose files")
#p.add_argument("--suffix", default="", help="Suffix to find compose files, before .yml extension")
p.add_argument("--subdir", default="", help="Subdir to find containers")
p.add_argument("--name", default="*", help="Name of folder to find compose.yml file")
p.add_argument("--cmd", default="docker compose", help="compose command")
p.add_argument("--file", default="compose.yml")
p.add_argument("action", choices=[a.name.lower() for a in ACTION])
args = p.parse_args()
logger.debug("Args:")
for k,v in vars(args).items():
logger.debug(" - %s:\t%s", k, repr(v))
action = ACTION[args.action.upper()].value
logger.debug("Action:\t('%s')", "', '".join(action))
try:
logger.debug("Starting runner context")
with asyncio.Runner() as runner:
i = 0
root_path = args.root.joinpath(args.subdir)
glob_pattern = args.name+"/*"+args.file
logger.debug("Globbing '%s' in '%s'", glob_pattern, root_path)
for path in root_path.glob(glob_pattern):
i += 1
logger.info("[%02d] File: '%s'", i, str(path))
runner.run(
runcmd(
cmd(args.cmd, path, action),
name=path.parent.name))
logger.info("Found %d matches in '%s' (pattern: %s", i, root_path, glob_pattern)
except asyncio.CancelledError as e:
logger.critical("Cancel task: %s (%s)", str(e), path)
asyncio.sleep(0.5)
except RuntimeError as e:
logger.critical(str(e))
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt as sigint:
logger.critical("[KeyboardInterrupt] %s", str(sigint))
try:
loop = asyncio.get_event_loop()
if loop.is_running():
all_tasks = asyncio.all_tasks(loop)
for task in all_tasks:
task.cancel()
loop.run_until_complete(
asyncio.gather(
*all_tasks,
#return_exceptions=True
return_exceptions=False
)
)
except Exception as e:
logger.error("Error during task gathering: %s", str(e))
finally:
logger.info("Exit.")
sys.exit(1)
except RuntimeError as e:
logger.critical("[RuntimeError] %s", str(e))
#sys.exit(1)
#EOF