From e284166f0acccf44f89dfccac352519ad0f7bcf7 Mon Sep 17 00:00:00 2001 From: Renato Rosa Date: Thu, 17 Sep 2026 09:54:27 -0300 Subject: [PATCH] Adicionar src/renatoxsr-py/scripts/composectl.py --- src/renatoxsr-py/scripts/composectl.py | 353 +++++++++++++++++++++++++ 1 file changed, 353 insertions(+) create mode 100644 src/renatoxsr-py/scripts/composectl.py diff --git a/src/renatoxsr-py/scripts/composectl.py b/src/renatoxsr-py/scripts/composectl.py new file mode 100644 index 0000000..65fa0f7 --- /dev/null +++ b/src/renatoxsr-py/scripts/composectl.py @@ -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 \ No newline at end of file