From 7ec6f78fbd76790d7fbf604005e2a7fc7c331445 Mon Sep 17 00:00:00 2001 From: Nudesuppe42 Date: Mon, 13 Apr 2026 17:07:30 +0200 Subject: [PATCH 1/4] feat: initial v2 implementation using docker compose instead of run commands --- .vscode/settings.json | 2 + README.md | 43 ++- _config.json | 24 +- bot.py | 713 ++++++++++++++++++++++++++++++++---------- config_loader.py | 219 +++++++++++-- requirements.txt | 1 - watchman.py | 20 +- 7 files changed, 798 insertions(+), 224 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7a73a41 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/README.md b/README.md index 6925e86..48dc7b2 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,45 @@ # Watchman -Our internal project for managing docker containers via Discord. +Our internal project for managing compose stacks via Discord slash commands. -For the config, just check out `_config.json`! It's quite self-documenting and includes two example bots. +## Root Config -If you use the containerized version, make sure to mount `/var/run/docker.sock`! +Check \_config.json for a complete example. +## Service Folder Requirements + +Each service root must contain: + +- watchman.json +- compose.yml or docker-compose.yml + +Extra files/folders are ignored by Watchman. + +Example watchman.json: + +```json +{ + "name": "main-bot", + "icon": "", + "permissions": { + "roles": ["123"], + "users": ["456"] + }, + "hooks": { + "pre_up": "infisical run -- docker compose config > /dev/null", + "pre_update": "infisical run -- env | head -n 1", + "strict": true + }, + "compose_commands": { + "up": "infisical run -- docker compose up -d", + "pull": "infisical run -- docker compose pull" + } +} +``` + +If compose_commands.up and/or compose_commands.pull are defined, Watchman uses those commands instead of the default docker compose pull and docker compose up -d invocations. + +## Notes + +- If you use the containerized version, mount /var/run/docker.sock. +- Watchman tries docker compose first, then falls back to docker-compose. diff --git a/_config.json b/_config.json index a98b51c..9f77f75 100644 --- a/_config.json +++ b/_config.json @@ -1,30 +1,12 @@ { - "prefix": "=wm ", "token": "", "botGroupId": "", - "bots": { + "services": { "main-bot": { - "icon": "", - "image": "buildtheearth/main-bot", - "network": "bot-network", - "volumes": { - "/etc/buildtheearth/main-bot/config/config.json5": "/etc/buildtheearth/main-bot/config/config.json5" - }, - "ports": { - }, - "restart_policy": "unless-stopped" + "root": "/etc/buildtheearth/main-bot" }, "support-bot": { - "icon": "", - "image": "buildtheearth/support-bot", - "network": "bot-network", - "volumes": { - "/etc/buildtheearth/support-bot/config.ini": "/etc/buildtheearth/support-bot/config.ini" - }, - "ports": { - "8890/tcp": "8890/tcp" - }, - "restart_policy": "unless-stopped" + "root": "/etc/buildtheearth/support-bot" } }, "roles": [ diff --git a/bot.py b/bot.py index 0787dd1..0c4971d 100644 --- a/bot.py +++ b/bot.py @@ -1,6 +1,8 @@ +import subprocess +import json + import interactions -import docker -import interactions + from config_loader import Config status_dict = { @@ -11,226 +13,593 @@ "paused": ":white_circle: Paused", "exited": ":red_circle: Exited", "dead": ":red_circle: Dead", - "none": ":white_circle: No container was found!" + "none": ":white_circle: No service stack was found!", } generic_reason = "This is an extremely confidential bot for confidential purposes, scram." base = interactions.SlashCommand(name="wm", description=generic_reason) config = Config("config.json") -bot_option = interactions.slash_str_option("bot", True, False, None, None, None, "container") -def no_container_embed(): - return interactions.Embed(title="Error", description="No container found. Please specify a valid container.", - color=0xff0000) +def no_service_embed(): + return interactions.Embed( + title="Error", + description="No service found. Please specify a valid service.", + color=0xFF0000, + ) def no_perms_embed(): - return interactions.Embed(title="Error", description="No permissions for this container. Please specify a container you have permissions for.", - color=0xff0000) + return interactions.Embed( + title="Error", + description="No permissions for this service.", + color=0xFF0000, + ) -class Watchman(interactions.Extension): +def command_failed_embed(operation, err): + return interactions.Embed( + title=f"{operation} Failed", + description=f"```\n{err}\n```", + color=0xFF0000, + ) +class Watchman(interactions.Extension): def __init__(self, bot): self.bot = bot - self.client = docker.from_env() - - def fetch_container(self, name): - if name is None: - return None - for container in self.client.containers.list(all=True): - if container.name == name and name in config.list_bots(): - return container + + def _service_icon(self, service_name): + try: + cfg = config.load_service_config(service_name) + icon = cfg.get("icon") + if isinstance(icon, str) and icon.strip(): + return icon + except Exception: + pass return None - def container_embed(self, container, title, description, color): - embed = interactions.Embed( - title=title, description=description, color=color) - embed.set_author( - name=container, icon_url=config.get_bot(container)['icon']) + def service_embed(self, service_name, title, description, color): + embed = interactions.Embed(title=title, description=description, color=color) + icon = self._service_icon(service_name) + if icon: + embed.set_author(name=service_name, icon_url=icon) + else: + embed.set_author(name=service_name) return embed def command_name(self, name): - return "`" + config.prefix + name + "` " + return f"`/wm {name}`" + + @interactions.listen() + async def on_startup(self): + print("Bot is ready!") @interactions.listen(interactions.api.events.Error) async def on_error(self, error: interactions.api.events.Error): - embed = interactions.Embed(title="Watchman Error", description=f"```\n{error.source}\n{error.error}\n```", color=0xFF0000) - await bot.fetch_channel(config.error_channel).send(embeds=[embed]) - + embed = interactions.Embed( + title="Watchman Error", + description=f"```\n{error.source}\n{error.error}\n```", + color=0xFF0000, + ) + await self.bot.fetch_channel(config.error_channel).send(embeds=[embed]) + @interactions.listen(interactions.api.events.InteractionCreate) async def on_interaction_create(self, ctx: interactions.api.events.InteractionCreate): - embed = interactions.Embed(description=f'[{ctx.guild.name}] {ctx.author.name} ran \'{ctx.data.options[0].name}\' command.', color=0xFF0000) - await bot.fetch_channel(config.error_channel).send(embeds=[embed]) + command_name = "unknown" + if ctx.data and getattr(ctx.data, "options", None): + command_name = ctx.data.options[0].name + guild_name = ctx.guild.name if ctx.guild else "DM" + embed = interactions.Embed( + description=f"[{guild_name}] {ctx.author.name} ran '{command_name}' command.", + color=0xFF0000, + ) + await self.bot.fetch_channel(config.error_channel).send(embeds=[embed]) + + def _compose_base_cmd(self, root): + docker_compose_probe = subprocess.run( + ["docker", "compose", "version"], + cwd=root, + capture_output=True, + text=True, + ) + if docker_compose_probe.returncode == 0: + return ["docker", "compose"] + + compose_v1_probe = subprocess.run( + ["docker-compose", "version"], + cwd=root, + capture_output=True, + text=True, + ) + if compose_v1_probe.returncode == 0: + return ["docker-compose"] + + return None + + def _run_compose(self, service_name, compose_args): + root = config.get_service_root(service_name) + if not root: + return False, "Service root missing in config." + + compose_file = config.resolve_compose_file(root) + if compose_file is None: + return False, "compose.yml or docker-compose.yml is missing." + + base_cmd = self._compose_base_cmd(root) + if base_cmd is None: + return False, "Neither 'docker compose' nor 'docker-compose' is available on host." + + command = base_cmd + ["-f", compose_file] + compose_args + proc = subprocess.run( + command, + cwd=root, + capture_output=True, + text=True, + ) + output = (proc.stdout or "") + (proc.stderr or "") + output = output.strip() + if len(output) > 1800: + output = output[-1800:] + if proc.returncode != 0: + return False, output or "Compose command failed without output." + return True, output or "OK" + + def _run_custom_command(self, service_name, command): + root = config.get_service_root(service_name) + if not root: + return False, "Service root missing in config." + + proc = subprocess.run( + command, + cwd=root, + shell=True, + capture_output=True, + text=True, + ) + output = (proc.stdout or "") + (proc.stderr or "") + output = output.strip() + if len(output) > 1800: + output = output[-1800:] + if proc.returncode != 0: + return False, output or "Custom command failed without output." + return True, output or "OK" + + def _run_service_step(self, service_name, step, default_compose_args): + cfg = config.load_service_config(service_name) + compose_commands = cfg.get("compose_commands", {}) if isinstance(cfg, dict) else {} + if isinstance(compose_commands, dict): + custom = compose_commands.get(step) + if isinstance(custom, str) and custom.strip(): + return self._run_custom_command(service_name, custom) + return self._run_compose(service_name, default_compose_args) + + def _run_hook(self, service_name, hook_name): + cfg = config.load_service_config(service_name) + hooks = cfg.get("hooks", {}) if isinstance(cfg, dict) else {} + if not isinstance(hooks, dict): + return True, "" + + command = hooks.get(hook_name) + if not isinstance(command, str) or not command.strip(): + return True, "" + + strict = bool(hooks.get("strict", True)) + root = config.get_service_root(service_name) + proc = subprocess.run( + command, + cwd=root, + shell=True, + capture_output=True, + text=True, + ) + output = (proc.stdout or "") + (proc.stderr or "") + output = output.strip() + if len(output) > 1200: + output = output[-1200:] + + if proc.returncode != 0 and strict: + return False, output or f"Hook '{hook_name}' failed." + + return True, output + + def _ensure_service_access(self, ctx, service_name): + if service_name not in config.list_services(): + return False, no_service_embed() + if not config.check_service_specific_perms(ctx, service_name): + return False, no_perms_embed() + return True, None + + def _service_status_summary(self, service_name): + # Prefer structured output when supported by Compose v2. + ok_json, output_json = self._run_compose(service_name, ["ps", "--format", "json"]) + if ok_json: + try: + rows = json.loads(output_json) + if isinstance(rows, dict): + rows = [rows] + if isinstance(rows, list): + total = len(rows) + running = 0 + exited = 0 + restarting = 0 + for row in rows: + state = str(row.get("State", "")).lower() + status = str(row.get("Status", "")).lower() + if state == "running" or status.startswith("up") or "running" in status: + running += 1 + elif "restart" in state or "restart" in status: + restarting += 1 + elif "exit" in state or "dead" in state or "exit" in status: + exited += 1 + return True, { + "total": total, + "running": running, + "restarting": restarting, + "exited": exited, + } + except Exception: + pass + + # Fallback parser for docker-compose / non-json output. + ok_ps, output_ps = self._run_compose(service_name, ["ps"]) + if not ok_ps: + return False, output_ps + + lines = [line for line in output_ps.splitlines() if line.strip()] + if len(lines) <= 1: + return True, {"total": 0, "running": 0, "restarting": 0, "exited": 0} + + rows = lines[1:] + total = len(rows) + running = 0 + exited = 0 + restarting = 0 + for row in rows: + lower_row = row.lower() + if " up " in f" {lower_row} " or "running" in lower_row: + running += 1 + elif "restart" in lower_row: + restarting += 1 + elif "exit" in lower_row or "dead" in lower_row: + exited += 1 + + return True, { + "total": total, + "running": running, + "restarting": restarting, + "exited": exited, + } + + def _service_autocomplete_choices(self, ctx): + query = (ctx.input_text or "").lower() + visible = [] + for service_name in config.list_services(): + if not config.check_service_specific_perms(ctx, service_name): + continue + if query and query not in service_name.lower(): + continue + visible.append({"name": service_name, "value": service_name}) + return visible[:25] @base.subcommand(sub_cmd_name="help", sub_cmd_description=generic_reason) @interactions.check(config.has_perms_async) async def help(self, ctx: interactions.SlashContext): - # Shows all commands for watchman - - embed = interactions.Embed(title="Watchman Help", description="Commands:", color=0x21304a) - embed.add_field(name=self.command_name("info"), value="Get system information.", inline=False) - embed.add_field(name=self.command_name("status"), value="Check the status of the bots.", inline=False) - embed.add_field(name=self.command_name("start "), value="Start a bot.", inline=False) - embed.add_field(name=self.command_name("stop "), value="Stop a bot.", inline=False) - embed.add_field(name=self.command_name("kill "), value="Kill a bot.", inline=False) - embed.add_field(name=self.command_name("restart "), value="Start a bot.", inline=False) - embed.add_field(name=self.command_name("pull "), value="Pull a new image for the bot.", inline=False) - return await ctx.send(embeds=[embed]) - - @base.subcommand(sub_cmd_name="info", sub_cmd_description=generic_reason) - @interactions.check(config.has_perms_async) - async def info(self, ctx: interactions.SlashContext): - # Shows info for watchman host machine - embed = interactions.Embed(title="Docker Info", description="", color=0x21304a) - version = self.client.version() - embed.add_field(name="Platform", value=version['Platform']['Name'], inline=False) - embed.add_field(name="Version", value=version['Version'], inline=False) - embed.add_field(name="API Version", value=version['ApiVersion'], inline=False) + embed = interactions.Embed(title="Watchman Help", description="Commands:", color=0x21304A) + embed.add_field(name=self.command_name("status [service]"), value="Check status of all configured service stacks or one service.", inline=False) + embed.add_field(name=self.command_name("start "), value="Run docker compose up -d.", inline=False) + embed.add_field(name=self.command_name("stop "), value="Run docker compose stop.", inline=False) + embed.add_field(name=self.command_name("restart "), value="Run docker compose restart.", inline=False) + embed.add_field(name=self.command_name("update "), value="Run docker compose pull && docker compose up -d.", inline=False) + embed.add_field(name=self.command_name("service list"), value="List known services and roots.", inline=False) + embed.add_field(name=self.command_name("service add "), value="Validate and register a new service root.", inline=False) + embed.add_field(name=self.command_name("service remove "), value="Unregister a service.", inline=False) + embed.add_field(name=self.command_name("service validate "), value="Validate watchman.json and compose file presence.", inline=False) return await ctx.send(embeds=[embed]) @base.subcommand(sub_cmd_name="status", sub_cmd_description=generic_reason) + @interactions.slash_option( + "service", + "optional service filter", + opt_type=interactions.OptionType.STRING, + required=False, + autocomplete=True, + ) @interactions.check(config.has_perms_async) - async def status(self, ctx: interactions.SlashContext): - # Displays current status of bot containers - embed = interactions.Embed(title="Container Status", description="", color=0x21304a) - for b in config.list_bots(): - container = self.fetch_container(b) - if container: - desc = status_dict[container.status] + "\n\n" + async def status(self, ctx: interactions.SlashContext, service: str = None): + embed = interactions.Embed(title="Compose Status", description="", color=0x21304A) + + if service: + services = [service] + else: + services = config.list_services() + + if not services: + embed.description = "No services configured." + return await ctx.send(embeds=[embed]) + + for service_name in services: + if not config.check_service_specific_perms(ctx, service_name): + continue + ok, summary = self._service_status_summary(service_name) + if ok: + value = ( + ":green_circle: Reachable\n" + f"Services: {summary['total']}\n" + f"Running: {summary['running']}\n" + f"Restarting: {summary['restarting']}\n" + f"Exited/Dead: {summary['exited']}" + ) else: - desc = ":white_circle: No container was found!\n\n" - embed.add_field(name="**" + b + "**", value=desc, inline=False) + short_err = str(summary).replace("\n", " ") + if len(short_err) > 300: + short_err = short_err[:300] + "..." + value = f":red_circle: Unavailable\nReason: {short_err}" + embed.add_field(name=f"**{service_name}**", value=value, inline=False) + + if len(embed.fields) == 0: + return await ctx.send(embeds=[no_perms_embed()]) return await ctx.send(embeds=[embed]) @base.subcommand(sub_cmd_name="start", sub_cmd_description=generic_reason) + @interactions.slash_option( + "service", + "service", + opt_type=interactions.OptionType.STRING, + required=True, + autocomplete=True, + ) @interactions.check(config.has_perms_async) - async def start(self, ctx: interactions.SlashContext, bot: bot_option): - # Starts a bot by its container name - bot_info = config.get_bot(bot) - container = self.fetch_container(bot) - if not container or not bot_info: - return await ctx.send(embeds=[no_container_embed()]) - - if not config.check_bot_specific_perms(ctx, bot_info): - return await ctx.send(embeds=[no_perms_embed()]) + async def start(self, ctx: interactions.SlashContext, service: str): + allowed, error = self._ensure_service_access(ctx, service) + if not allowed: + return await ctx.send(embeds=[error]) - message = await ctx.send( - embeds=[self.container_embed(bot, "Start Container", "Starting...", 0x21304a)]) - container.start() - await message.edit(embeds=[self.container_embed(bot, "Start Container", "Successfully started container.", 0x00ff00)]) + hook_ok, hook_output = self._run_hook(service, "pre_up") + if not hook_ok: + return await ctx.send(embeds=[command_failed_embed("Pre-up Hook", hook_output)]) + + message = await ctx.send(embeds=[self.service_embed(service, "Start Service", "Starting...", 0x21304A)]) + ok, output = self._run_service_step(service, "up", ["up", "-d"]) + if not ok: + return await message.edit(embeds=[command_failed_embed("Start Service", output)]) + + desc = "Successfully started service stack." + if hook_output: + desc += f"\n\nHook output:\n```\n{hook_output[:500]}\n```" + await message.edit(embeds=[self.service_embed(service, "Start Service", desc, 0x00FF00)]) @base.subcommand(sub_cmd_name="stop", sub_cmd_description=generic_reason) + @interactions.slash_option( + "service", + "service", + opt_type=interactions.OptionType.STRING, + required=True, + autocomplete=True, + ) @interactions.check(config.has_perms_async) - async def stop(self, ctx: interactions.SlashContext, bot: bot_option): - # Stops a bot by its container name - bot_info = config.get_bot(bot) - container = self.fetch_container(bot) - if not container or not bot_info: - return await ctx.send(embeds=[no_container_embed()]) - - if not config.check_bot_specific_perms(ctx, bot_info): - return await ctx.send(embeds=[no_perms_embed()]) + async def stop(self, ctx: interactions.SlashContext, service: str): + allowed, error = self._ensure_service_access(ctx, service) + if not allowed: + return await ctx.send(embeds=[error]) - message = await ctx.send( - embeds=[self.container_embed(bot, "Stop Container", "Stopping...", 0x21304a)]) - container.stop() - await message.edit(embeds=[self.container_embed(bot, "Stop Container", "Successfully stopped container.", 0x00ff00)]) + message = await ctx.send(embeds=[self.service_embed(service, "Stop Service", "Stopping...", 0x21304A)]) + ok, output = self._run_compose(service, ["stop"]) + if not ok: + return await message.edit(embeds=[command_failed_embed("Stop Service", output)]) + await message.edit(embeds=[self.service_embed(service, "Stop Service", "Successfully stopped service stack.", 0x00FF00)]) - @base.subcommand(sub_cmd_name="kill", sub_cmd_description=generic_reason) + @base.subcommand(sub_cmd_name="restart", sub_cmd_description=generic_reason) + @interactions.slash_option( + "service", + "service", + opt_type=interactions.OptionType.STRING, + required=True, + autocomplete=True, + ) @interactions.check(config.has_perms_async) - async def kill(self, ctx: interactions.SlashContext, bot: bot_option): - # Kills a bot by its container name - bot_info = config.get_bot(bot) - container = self.fetch_container(bot) - if not container or not bot_info: - return await ctx.send(embeds=[no_container_embed()]) - - if not config.check_bot_specific_perms(ctx, bot_info): - return await ctx.send(embeds=[no_perms_embed()]) + async def restart(self, ctx: interactions.SlashContext, service: str): + allowed, error = self._ensure_service_access(ctx, service) + if not allowed: + return await ctx.send(embeds=[error]) - message = await ctx.send( - embeds=[self.container_embed(bot, "Kill Container", "Killing...", 0x21304a)]) - container.kill() - await message.edit(embeds=[self.container_embed(bot, "Kill Container", "Successfully killed container.", 0x00ff00)]) + message = await ctx.send(embeds=[self.service_embed(service, "Restart Service", "Restarting...", 0x21304A)]) + ok, output = self._run_compose(service, ["restart"]) + if not ok: + return await message.edit(embeds=[command_failed_embed("Restart Service", output)]) + await message.edit(embeds=[self.service_embed(service, "Restart Service", "Successfully restarted service stack.", 0x00FF00)]) - @base.subcommand(sub_cmd_name="restart", sub_cmd_description=generic_reason) + @base.subcommand(sub_cmd_name="update", sub_cmd_description=generic_reason) + @interactions.slash_option( + "service", + "service", + opt_type=interactions.OptionType.STRING, + required=True, + autocomplete=True, + ) @interactions.check(config.has_perms_async) - async def restart(self, ctx: interactions.SlashContext, bot: bot_option): - # Restarts a bot by its container name - bot_info = config.get_bot(bot) - container = self.fetch_container(bot) - if not container or not bot_info: - return await ctx.send(embeds=[no_container_embed()]) - - if not config.check_bot_specific_perms(ctx, bot_info): - return await ctx.send(embeds=[no_perms_embed()]) + async def update(self, ctx: interactions.SlashContext, service: str): + allowed, error = self._ensure_service_access(ctx, service) + if not allowed: + return await ctx.send(embeds=[error]) + + hook_ok, hook_output = self._run_hook(service, "pre_update") + if not hook_ok: + return await ctx.send(embeds=[command_failed_embed("Pre-update Hook", hook_output)]) + + message = await ctx.send(embeds=[self.service_embed(service, "Update Service", "Pulling images...", 0x21304A)]) + ok_pull, pull_output = self._run_service_step(service, "pull", ["pull"]) + if not ok_pull: + return await message.edit(embeds=[command_failed_embed("Update Service (pull)", pull_output)]) + + await message.edit(embeds=[self.service_embed(service, "Update Service", "Applying updated images...", 0x21304A)]) + ok_up, up_output = self._run_service_step(service, "up", ["up", "-d"]) + if not ok_up: + return await message.edit(embeds=[command_failed_embed("Update Service (up)", up_output)]) - message = await ctx.send( - embeds=[self.container_embed(bot, "Restart Container", "Restarting...", 0x21304a)]) - container.restart() - await message.edit( - embeds=[self.container_embed(bot, "Restart Container", "Successfully restarted bot.", 0x00ff00)]) + combined = "Successfully updated service stack." + if hook_output: + combined += f"\n\nHook output:\n```\n{hook_output[:300]}\n```" + await message.edit(embeds=[self.service_embed(service, "Update Service", combined, 0x00FF00)]) - @base.subcommand(sub_cmd_name="pull", sub_cmd_description=generic_reason) + @base.subcommand( + sub_cmd_name="list", + group_name="service", + group_description="Manage service registry", + sub_cmd_description=generic_reason, + ) @interactions.check(config.has_perms_async) - async def pull(self, ctx: interactions.SlashContext, bot: bot_option): - # Pulls any changes from the registry, and creates a new container - bot_info = config.get_bot(bot) - if not bot_info: - return await ctx.send(embeds=[no_container_embed()]) - - if not config.check_bot_specific_perms(ctx, bot_info): + async def service_list(self, ctx: interactions.SlashContext): + embed = interactions.Embed(title="Configured Services", description="", color=0x21304A) + + if not config.list_services(): + embed.description = "No services configured." + return await ctx.send(embeds=[embed]) + + for service_name in config.list_services(): + root = config.get_service_root(service_name) or "" + if config.check_service_specific_perms(ctx, service_name): + embed.add_field(name=service_name, value=root, inline=False) + + if len(embed.fields) == 0: return await ctx.send(embeds=[no_perms_embed()]) - container = self.fetch_container(bot) - image = bot_info['image'] - message = await ctx.send(embeds=[self.container_embed(bot, "Pull Container", "Stopping: " - ":hourglass" - ":\nPulling: " - ":black_small_square:\nStarting: :black_small_square:", - 0x21304a)]) - try: - if container is not None: - container.stop() - container.remove() - await message.edit(embeds=[self.container_embed(bot, "Pull Container", "Stopping: :white_check_mark" - ":\nPulling: :hourglass:\nStarting: " - ":black_small_square:", 0x21304a)]) - real_tag = bot_info['tag'] if "tag" in bot_info else "latest" - print(bot_info) - print(real_tag) - self.client.images.pull(repository=image, tag=real_tag) - await message.edit(embeds=[self.container_embed(bot, "Pull Container", "Stopping: :white_check_mark" - ":\nPulling: " - ":white_check_mark:\nStarting: " - ":hourglass:", 0x21304a)]) - volumes = {} - ports = {} - restart_policy = { - "Name": bot_info['restart_policy'] - } - if bot_info['restart_policy'] == "never": restart_policy = None - for k in bot_info['volumes']: - volumes[k] = { - "bind": bot_info['volumes'][k], - "mode": "rw" - } - for k in bot_info['ports']: - ports[k] = bot_info['ports'][k] - labels = { - "io.portainer.accesscontrol.teams": config.bot_group - } - self.client.containers.run(name=bot, image=image+":"+real_tag, network=bot_info['network'], volumes=volumes, labels=labels, ports=ports, restart_policy=restart_policy, detach=True) - await message.edit(embeds=[self.container_embed(bot, "Pull Container", "Stopping: :white_check_mark" - ":\nPulling: " - ":white_check_mark:\nStarting: " - ":white_check_mark:\n\n" - ":white_check_mark: Successfully " - "built new image", 0x21304a)]) - except Exception as err: - await message.edit(embeds=[self.container_embed(bot, "Pull Container", ":x: Failed to pull new " - "container.\n```" + str(err) + - "```", 0x21304a)]) - + return await ctx.send(embeds=[embed]) + + @base.subcommand( + sub_cmd_name="add", + group_name="service", + group_description="Manage service registry", + sub_cmd_description=generic_reason, + ) + @interactions.slash_option( + "root", + "absolute service root directory", + opt_type=interactions.OptionType.STRING, + required=True, + ) + @interactions.check(config.has_perms_base) + async def service_add(self, ctx: interactions.SlashContext, root: str): + service_name, errors = config.add_service(root) + if errors: + return await ctx.send( + embeds=[ + interactions.Embed( + title="Service Add Failed", + description="\n".join(f"- {err}" for err in errors), + color=0xFF0000, + ) + ] + ) + + return await ctx.send( + embeds=[ + interactions.Embed( + title="Service Added", + description=f"Registered service '{service_name}' from root '{root}'.", + color=0x00FF00, + ) + ] + ) + + @base.subcommand( + sub_cmd_name="remove", + group_name="service", + group_description="Manage service registry", + sub_cmd_description=generic_reason, + ) + @interactions.slash_option( + "service", + "service", + opt_type=interactions.OptionType.STRING, + required=True, + autocomplete=True, + ) + @interactions.check(config.has_perms_base) + async def service_remove(self, ctx: interactions.SlashContext, service: str): + removed = config.remove_service(service) + if not removed: + return await ctx.send(embeds=[no_service_embed()]) + return await ctx.send( + embeds=[ + interactions.Embed( + title="Service Removed", + description=f"Removed service '{service}' from config.", + color=0x00FF00, + ) + ] + ) + + @base.subcommand( + sub_cmd_name="validate", + group_name="service", + group_description="Manage service registry", + sub_cmd_description=generic_reason, + ) + @interactions.slash_option( + "service", + "service", + opt_type=interactions.OptionType.STRING, + required=True, + autocomplete=True, + ) + @interactions.check(config.has_perms_async) + async def service_validate(self, ctx: interactions.SlashContext, service: str): + allowed, error = self._ensure_service_access(ctx, service) + if not allowed: + return await ctx.send(embeds=[error]) + + root = config.get_service_root(service) + ok, errors = config.validate_service_root(root) + + if not ok: + return await ctx.send( + embeds=[ + interactions.Embed( + title="Service Validation Failed", + description="\n".join(f"- {err}" for err in errors), + color=0xFF0000, + ) + ] + ) + + compose_file = config.resolve_compose_file(root) + return await ctx.send( + embeds=[ + interactions.Embed( + title="Service Valid", + description=f"watchman.json and compose file found.\nCompose: {compose_file}", + color=0x00FF00, + ) + ] + ) + + @start.autocomplete("service") + async def start_service_autocomplete(self, ctx: interactions.AutocompleteContext): + await ctx.send(self._service_autocomplete_choices(ctx)) + + @status.autocomplete("service") + async def status_service_autocomplete(self, ctx: interactions.AutocompleteContext): + await ctx.send(self._service_autocomplete_choices(ctx)) + + @stop.autocomplete("service") + async def stop_service_autocomplete(self, ctx: interactions.AutocompleteContext): + await ctx.send(self._service_autocomplete_choices(ctx)) + + @restart.autocomplete("service") + async def restart_service_autocomplete(self, ctx: interactions.AutocompleteContext): + await ctx.send(self._service_autocomplete_choices(ctx)) + + @update.autocomplete("service") + async def update_service_autocomplete(self, ctx: interactions.AutocompleteContext): + await ctx.send(self._service_autocomplete_choices(ctx)) + + @service_remove.autocomplete("service") + async def remove_service_autocomplete(self, ctx: interactions.AutocompleteContext): + await ctx.send(self._service_autocomplete_choices(ctx)) + + @service_validate.autocomplete("service") + async def validate_service_autocomplete(self, ctx: interactions.AutocompleteContext): + await ctx.send(self._service_autocomplete_choices(ctx)) + + + def setup(client): Watchman(client) diff --git a/config_loader.py b/config_loader.py index 31ec5b5..dabe155 100644 --- a/config_loader.py +++ b/config_loader.py @@ -1,40 +1,219 @@ import json +import os +import tempfile +from pathlib import Path + import interactions class Config: def __init__(self, config_file): - self.config_file = json.load(open(config_file, encoding="utf-8-sig")) + self.path = Path(config_file) + self.config_file = {} + self.registry_key = "services" + self.token = "" + self.bot_group = "" + self.prefix = "/wm " + self.roles = set() + self.users = set() + self.error_channel = "" + self.services = {} + self.reload() + + def reload(self): + with open(self.path, encoding="utf-8-sig") as f: + self.config_file = json.load(f) + self.token = self.config_file["token"] - self.bot_group = self.config_file["botGroup"] - self.bots = self.config_file["bots"] - self.prefix = self.config_file["prefix"] - self.roles = self.config_file["roles"] - self.users = set(self.config_file["users"]) #This is largely unnecessary, but S P E E D + self.bot_group = self.config_file.get("botGroupId", "") + self.prefix = self.config_file.get("prefix", "/wm ") + self.roles = {str(role) for role in self.config_file.get("roles", [])} + self.users = {str(user) for user in self.config_file.get("users", [])} self.error_channel = self.config_file["error_channel"] + if "services" in self.config_file: + self.registry_key = "services" + self.services = self.config_file.get("services", {}) + elif "directories" in self.config_file: + self.registry_key = "directories" + self.services = self.config_file.get("directories", {}) + else: + self.registry_key = "services" + self.services = {} + + def save(self): + self.config_file[self.registry_key] = self.services + with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False, dir=str(self.path.parent)) as tf: + json.dump(self.config_file, tf, indent=4) + tf.write("\n") + tmp_name = tf.name + os.replace(tmp_name, self.path) + + def list_services(self): + return list(self.services.keys()) + + def get_service(self, name): + return self.services.get(name) + + def get_service_root(self, name): + service = self.get_service(name) + if not service: + return None + return service.get("root") + + def resolve_compose_file(self, root): + compose_candidates = ["compose.yml", "docker-compose.yml"] + root_path = Path(root) + for candidate in compose_candidates: + candidate_path = root_path / candidate + if candidate_path.exists() and candidate_path.is_file(): + return str(candidate_path) + return None + + def load_service_config(self, name): + root = self.get_service_root(name) + if not root: + return {} + path = Path(root) / "watchman.json" + if not path.exists() or not path.is_file(): + return {} + with open(path, encoding="utf-8-sig") as f: + return json.load(f) + + def validate_service_root(self, root): + errors = [] + root_path = Path(root) + if not root_path.exists() or not root_path.is_dir(): + errors.append("Root directory does not exist.") + return False, errors + + watchman_json = root_path / "watchman.json" + if not watchman_json.exists() or not watchman_json.is_file(): + errors.append("watchman.json is missing in the service root.") + + compose_file = self.resolve_compose_file(str(root_path)) + if compose_file is None: + errors.append("Neither compose.yml nor docker-compose.yml was found.") + + return len(errors) == 0, errors + + def infer_service_name(self, root): + root_path = Path(root) + watchman_json = root_path / "watchman.json" + if watchman_json.exists() and watchman_json.is_file(): + try: + with open(watchman_json, encoding="utf-8-sig") as f: + data = json.load(f) + name = data.get("name") + if isinstance(name, str) and name.strip(): + return name.strip() + except Exception: + pass + return root_path.name + + def add_service(self, root): + valid, errors = self.validate_service_root(root) + if not valid: + return None, errors + + root_abs = str(Path(root).resolve()) + for service_name, service in self.services.items(): + if str(service.get("root", "")).strip() == root_abs: + return service_name, [] + + service_name = self.infer_service_name(root_abs) + if service_name in self.services: + return None, [f"Service '{service_name}' already exists in config."] + + self.services[service_name] = { + "root": root_abs + } + self.save() + return service_name, [] + + def remove_service(self, name): + if name not in self.services: + return False + del self.services[name] + self.save() + return True + def list_bots(self): - return list(self.bots.keys()) + return self.list_services() def get_bot(self, name): - return self.bots.get(name) + return self.get_service(name) + + def _extract_author_role_ids(self, ctx): + role_ids = set() + author_roles = getattr(ctx.author, "roles", None) + if not author_roles: + return role_ids + for role in author_roles: + role_id = getattr(role, "id", role) + role_ids.add(str(role_id)) + return role_ids + + def _has_role_match(self, ctx, allowed_roles): + if not allowed_roles: + return False + return len(self._extract_author_role_ids(ctx).intersection(allowed_roles)) > 0 def has_perms_base(self, ctx): - return str(ctx.author.id) in self.users - - def has_perms_container(self, ctx): - for bot in self.bots.values(): - if 'users' in bot: - return str(ctx.author.id) in bot["users"] - return False + author_id = str(ctx.author.id) + return author_id in self.users or self._has_role_match(ctx, self.roles) - async def has_perms_async(self, ctx): - return self.has_perms_base(ctx) or self.has_perms_container(ctx) + def _service_permission_sets(self, service_name): + service_users = set() + service_roles = set() + service_cfg = self.load_service_config(service_name) - def check_bot_specific_perms(self, ctx, bot_info): + for key in ("users", "allowed_users"): + values = service_cfg.get(key, []) + if isinstance(values, list): + service_users.update(str(v) for v in values) + + for key in ("roles", "allowed_roles"): + values = service_cfg.get(key, []) + if isinstance(values, list): + service_roles.update(str(v) for v in values) + + perms = service_cfg.get("permissions", {}) + if isinstance(perms, dict): + users = perms.get("users", []) + roles = perms.get("roles", []) + if isinstance(users, list): + service_users.update(str(v) for v in users) + if isinstance(roles, list): + service_roles.update(str(v) for v in roles) + + return service_users, service_roles + + def has_perms_service(self, ctx, service_name): + service_users, service_roles = self._service_permission_sets(service_name) + author_id = str(ctx.author.id) + return author_id in service_users or self._has_role_match(ctx, service_roles) + + async def has_perms_async(self, ctx): if self.has_perms_base(ctx): return True - elif 'users' in bot_info: - if str(ctx.author.id) in bot_info["users"]: + for service_name in self.list_services(): + if self.has_perms_service(ctx, service_name): return True + return False + + def check_service_specific_perms(self, ctx, service_name): + if self.has_perms_base(ctx): + return True + return self.has_perms_service(ctx, service_name) + + def check_bot_specific_perms(self, ctx, bot_info): + if not isinstance(bot_info, dict): + return False + root = bot_info.get("root") + if not root: + return False + for name, service in self.services.items(): + if service.get("root") == root: + return self.check_service_specific_perms(ctx, name) return False \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index bcde021..9a6cd16 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1 @@ discord-py-interactions -docker diff --git a/watchman.py b/watchman.py index 797f84b..053745d 100644 --- a/watchman.py +++ b/watchman.py @@ -1,14 +1,16 @@ import interactions from config_loader import Config +from interactions.client.errors import LoginError +import asyncio splash = """ __ __ _ _ - \ \ / / | | | | - \ \ /\ / /_ _| |_ ___| |__ _ __ ___ __ _ _ __ - \ \/ \/ / _` | __/ __| '_ \| '_ ` _ \ / _` | '_ \ - \ /\ / (_| | || (__| | | | | | | | | (_| | | | | - \/ \/ \__,_|\__\___|_| |_|_| |_| |_|\__,_|_| |_| - A minimal bot manager for BuildTheEarthβ„’ + \\ \\ / / | | | | + \\ \\ /\\ / /_ _| |_ ___| |__ _ __ ___ __ _ _ __ + \\ \\/ \\/ / _` | __/ __| '_ \\| '_ ` _ \\ / _` | '_ \\ + \\ /\\ / (_| | || (__| | | | | | | | | (_| | | | | + \\/ \\/ \\__,_|\\__\\___|_| |_|_| |_| |_|\\__,_|_| |_| + A minimal container manager for BuildTheEarthβ„’ """ @@ -18,4 +20,8 @@ print(splash) print("Starting Watchman") -bot.start() +try: + bot.start() +except LoginError: + print("Invalid Discord bot token in config.json") + asyncio.run(bot.http.close()) From b6a1822141ff85ef4172a740a1a86452f7e02c62 Mon Sep 17 00:00:00 2001 From: Donati Filippo <65790947+DonatiFilippo@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:10:03 +0200 Subject: [PATCH 2/4] Support for Pre-commands --- bot.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/bot.py b/bot.py index 0c4971d..3391363 100644 --- a/bot.py +++ b/bot.py @@ -1,5 +1,6 @@ import subprocess import json +import shlex import interactions @@ -126,7 +127,17 @@ def _run_compose(self, service_name, compose_args): if base_cmd is None: return False, "Neither 'docker compose' nor 'docker-compose' is available on host." - command = base_cmd + ["-f", compose_file] + compose_args + command = base_cmd + ["-f", compose_file] + compose_argsΓΉ + try: + service_cfg = config.load_service_config(service_name) + pre_cmd_str = service_cfg.get("pre_command").strip() + except Exception: + pre_cmd_str = "" + + if pre_cmd_str: + pre_cmd = shlex.split(pre_cmd_str) + command = pre_cmd + command + proc = subprocess.run( command, cwd=root, From 85a7593c696005733bc48e4695c0d5a5d8f9a015 Mon Sep 17 00:00:00 2001 From: Nudelsuppe42 Date: Sat, 18 Jul 2026 17:38:02 +0200 Subject: [PATCH 3/4] feat: keycloak for RBAC --- README.md | 81 ++++++++++++++++++++----- _config.json | 14 +++-- bot.py | 147 ++++++++++++++++++++++++++++++++------------- config_loader.py | 122 ++++++++++++++++++++++++------------- keycloak_client.py | 111 ++++++++++++++++++++++++++++++++++ requirements.txt | 1 + 6 files changed, 372 insertions(+), 104 deletions(-) create mode 100644 keycloak_client.py diff --git a/README.md b/README.md index 48dc7b2..380167d 100644 --- a/README.md +++ b/README.md @@ -2,28 +2,59 @@ Our internal project for managing compose stacks via Discord slash commands. -## Root Config +## Root Configuration (`config.json`) -Check \_config.json for a complete example. +To enable Keycloak SSO RBAC, add the following parameters to your root `config.json`: -## Service Folder Requirements +```json +{ + "token": "YOUR_DISCORD_BOT_TOKEN", + "botGroupId": "YOUR_DISCORD_GUILD_ID", + "services": { + "main-bot": { + "root": "/etc/buildtheearth/main-bot" + } + }, + "roles": [], + "users": [], + + "keycloak_enabled": true, + "keycloak_server_url": "https://sso.example.com", + "keycloak_realm": "staff", + "keycloak_client_id": "watchman", + "keycloak_client_secret": "YOUR_KEYCLOAK_CLIENT_SECRET", + "keycloak_idp_alias": "discord", + "keycloak_root_role": "watchman-user", + "keycloak_admin_role": "watchman-admin" +} +``` -Each service root must contain: +* **`keycloak_enabled`** (`bool`): Set to `true` to retrieve roles from Keycloak based on the user's Discord ID. +* **`keycloak_server_url`** (`str`): Base URL of your self-hosted Keycloak instance. +* **`keycloak_realm`** (`str`): Realm name where users reside. +* **`keycloak_client_id`** (`str`): Keycloak client ID configured for Watchman. +* **`keycloak_client_secret`** (`str`): Secret used to authenticate with Keycloak via Client Credentials. +* **`keycloak_idp_alias`** (`str`): Identity Provider alias configured in Keycloak (defaults to `"discord"`). +* **`keycloak_root_role`** (`str`): Global gateway role. Users must have this role to interact with Watchman. +* **`keycloak_admin_role`** (`str`): Administrative role required to manage the service registry (add/remove services). -- watchman.json -- compose.yml or docker-compose.yml +--- -Extra files/folders are ignored by Watchman. +## Service Configs (`watchman.json`) -Example watchman.json: +Each service root folder must contain a `watchman.json` and a `compose.yml` (or `docker-compose.yml`) file. + +### Permissions Syntax +Permissions must be defined under the nested `"permissions"` key. You can also specify `status_commands_require_access` to control visibility on a per-service level: ```json { "name": "main-bot", - "icon": "", + "icon": "https://example.com/icon.png", "permissions": { - "roles": ["123"], - "users": ["456"] + "roles": ["main-bot-operator", "sso-role-name"], + "users": ["123456789012345678"], + "status_commands_require_access": false }, "hooks": { "pre_up": "infisical run -- docker compose config > /dev/null", @@ -37,9 +68,31 @@ Example watchman.json: } ``` -If compose_commands.up and/or compose_commands.pull are defined, Watchman uses those commands instead of the default docker compose pull and docker compose up -d invocations. +* **`permissions.roles`** (`list`): Keycloak realm or client roles that grant mutation access (start, stop, restart, update) for this service. +* **`permissions.users`** (`list`): Discord user IDs for local bypass or fallback access. +* **`permissions.status_commands_require_access`** (`bool`): + * If `true`, only users with the mutation role (or admins) can check this service's status or see it listed. + * If `false` (default), anyone with the Keycloak `root_role` can see the service in `/wm status` and `/wm service list` as read-only. Read-only services are decorated with a `πŸ”’` emoji, while write-accessible services are decorated with a `πŸ”“` emoji. + +--- + +## Slash Commands + +* `/wm help`: Displays the help menu. +* `/wm status [service]`: Check the status of all stacks or filter by a specific service. +* `/wm start `: Start a compose stack. +* `/wm stop `: Stop a compose stack. +* `/wm restart `: Restart a compose stack. +* `/wm update `: Pull images and update a stack. +* `/wm service list`: List registered services and roots. +* `/wm service validate `: Check validation of `watchman.json` and compose file. +* `/wm service add `: Add a service (Admins only). +* `/wm service remove `: Remove a service (Admins only). +* `/wm debug`: Prints diagnostic info showing your Keycloak identity, roles, gateway permissions, and service-by-service access breakdown. + +--- ## Notes -- If you use the containerized version, mount /var/run/docker.sock. -- Watchman tries docker compose first, then falls back to docker-compose. +- Mount `/var/run/docker.sock` if running Watchman containerized. +- Watchman tries `docker compose` first, then falls back to `docker-compose`. diff --git a/_config.json b/_config.json index 9f77f75..ccee1f6 100644 --- a/_config.json +++ b/_config.json @@ -9,13 +9,17 @@ "root": "/etc/buildtheearth/support-bot" } }, - "roles": [ - "id1", - "id2" - ], "users": [ "user1id", "user2id" ], - "error_channel": "watchman_log" + "keycloak_enabled": false, + "keycloak_server_url": "https://sso.example.com", + "keycloak_realm": "staff", + "keycloak_client_id": "watchman", + "keycloak_client_secret": "client-secret-here", + "keycloak_idp_alias": "discord", + "keycloak_root_role": "watchman-user", + "keycloak_admin_role": "watchman-admin", + "status_commands_require_access": false } \ No newline at end of file diff --git a/bot.py b/bot.py index 3391363..0a655f3 100644 --- a/bot.py +++ b/bot.py @@ -74,24 +74,7 @@ async def on_startup(self): @interactions.listen(interactions.api.events.Error) async def on_error(self, error: interactions.api.events.Error): - embed = interactions.Embed( - title="Watchman Error", - description=f"```\n{error.source}\n{error.error}\n```", - color=0xFF0000, - ) - await self.bot.fetch_channel(config.error_channel).send(embeds=[embed]) - - @interactions.listen(interactions.api.events.InteractionCreate) - async def on_interaction_create(self, ctx: interactions.api.events.InteractionCreate): - command_name = "unknown" - if ctx.data and getattr(ctx.data, "options", None): - command_name = ctx.data.options[0].name - guild_name = ctx.guild.name if ctx.guild else "DM" - embed = interactions.Embed( - description=f"[{guild_name}] {ctx.author.name} ran '{command_name}' command.", - color=0xFF0000, - ) - await self.bot.fetch_channel(config.error_channel).send(embeds=[embed]) + print(f"Watchman Error from source {error.source}: {error.error}", flush=True) def _compose_base_cmd(self, root): docker_compose_probe = subprocess.run( @@ -127,7 +110,7 @@ def _run_compose(self, service_name, compose_args): if base_cmd is None: return False, "Neither 'docker compose' nor 'docker-compose' is available on host." - command = base_cmd + ["-f", compose_file] + compose_argsΓΉ + command = base_cmd + ["-f", compose_file] + compose_args try: service_cfg = config.load_service_config(service_name) pre_cmd_str = service_cfg.get("pre_command").strip() @@ -210,10 +193,10 @@ def _run_hook(self, service_name, hook_name): return True, output - def _ensure_service_access(self, ctx, service_name): + async def _ensure_service_access(self, ctx, service_name): if service_name not in config.list_services(): return False, no_service_embed() - if not config.check_service_specific_perms(ctx, service_name): + if not await config.check_service_specific_perms(ctx, service_name): return False, no_perms_embed() return True, None @@ -278,12 +261,14 @@ def _service_status_summary(self, service_name): "exited": exited, } - def _service_autocomplete_choices(self, ctx): + async def _service_autocomplete_choices(self, ctx, is_status=False): query = (ctx.input_text or "").lower() visible = [] for service_name in config.list_services(): - if not config.check_service_specific_perms(ctx, service_name): - continue + require_access = config.get_service_status_commands_require_access(service_name) + if not is_status or require_access: + if not await config.check_service_specific_perms(ctx, service_name): + continue if query and query not in service_name.lower(): continue visible.append({"name": service_name, "value": service_name}) @@ -302,8 +287,80 @@ async def help(self, ctx: interactions.SlashContext): embed.add_field(name=self.command_name("service add "), value="Validate and register a new service root.", inline=False) embed.add_field(name=self.command_name("service remove "), value="Unregister a service.", inline=False) embed.add_field(name=self.command_name("service validate "), value="Validate watchman.json and compose file presence.", inline=False) + embed.add_field(name=self.command_name("debug"), value="Print out RBAC diagnostics and services you have access to.", inline=False) return await ctx.send(embeds=[embed]) + @base.subcommand(sub_cmd_name="debug", sub_cmd_description=generic_reason) + @interactions.check(config.has_perms_async) + async def debug(self, ctx: interactions.SlashContext): + author_id = str(ctx.author.id) + embed = interactions.Embed(title="Watchman RBAC Debug", color=0x21304A) + + embed.add_field(name="User", value=f"{ctx.author.name} (ID: {author_id})", inline=False) + + if config.keycloak_enabled: + embed.add_field(name="Keycloak Integration", value="Enabled", inline=True) + embed.add_field(name="Keycloak Server", value=config.keycloak_server_url, inline=True) + embed.add_field(name="Keycloak Realm", value=config.keycloak_realm, inline=True) + + roles = await config.keycloak.get_user_roles(author_id) + roles_list = sorted(list(roles)) if roles else [] + roles_str = ", ".join(f"`{r}`" for r in roles_list) if roles_list else "*None*" + embed.add_field(name="Keycloak Roles", value=roles_str, inline=False) + + has_root = config.keycloak_root_role in roles if config.keycloak_root_role else True + has_admin = config.keycloak_admin_role in roles if config.keycloak_admin_role else False + + root_status = f"Root Role (`{config.keycloak_root_role}`): {'βœ… Yes' if has_root else '❌ No'}" + admin_status = f"Admin Role (`{config.keycloak_admin_role}`): {'βœ… Yes' if has_admin else '❌ No'}" + embed.add_field(name="Role Checks", value=f"{root_status}\n{admin_status}", inline=False) + + services_status = [] + for service_name in config.list_services(): + has_access = await config.check_service_specific_perms(ctx, service_name) + service_users, service_roles = config._service_permission_sets(service_name) + + reason = "Denied" + if has_access: + if has_admin: + reason = "Allowed (Admin Role)" + elif author_id in service_users: + reason = "Allowed (User Bypass)" + elif service_roles and len(roles.intersection(service_roles)) > 0: + matching = roles.intersection(service_roles) + reason = f"Allowed (Service Role: {', '.join(matching)})" + else: + reason = "Allowed (Local Bypass)" + + status_char = "🟒" if has_access else "πŸ”΄" + services_status.append(f"{status_char} **{service_name}**: {reason}") + + services_str = "\n".join(services_status) if services_status else "No services configured." + embed.add_field(name="Service Access Details", value=services_str, inline=False) + else: + embed.add_field(name="Keycloak Integration", value="Disabled", inline=False) + + services_status = [] + has_base = await config.has_perms_base(ctx) + for service_name in config.list_services(): + has_access = await config.check_service_specific_perms(ctx, service_name) + service_users, service_roles = config._service_permission_sets(service_name) + + reason = "Denied" + if has_access: + if has_base: + reason = "Allowed (Base/Global Perms)" + elif author_id in service_users: + reason = "Allowed (User Bypass)" + else: + reason = "Allowed (Service Role)" + + status_char = "🟒" if has_access else "πŸ”΄" + services_status.append(f"{status_char} **{service_name}**: {reason}") + + services_str = "\n".join(services_status) if services_status else "No services configured." + embed.add_field(name="Service Access Details", value=services_str, inline=False) + @base.subcommand(sub_cmd_name="status", sub_cmd_description=generic_reason) @interactions.slash_option( "service", @@ -326,12 +383,16 @@ async def status(self, ctx: interactions.SlashContext, service: str = None): return await ctx.send(embeds=[embed]) for service_name in services: - if not config.check_service_specific_perms(ctx, service_name): + has_access = await config.check_service_specific_perms(ctx, service_name) + require_access = config.get_service_status_commands_require_access(service_name) + if require_access and not has_access: continue + ok, summary = self._service_status_summary(service_name) + access_emoji = "πŸ”“" if has_access else "πŸ”’" if ok: value = ( - ":green_circle: Reachable\n" + f"{access_emoji} Reachable\n" f"Services: {summary['total']}\n" f"Running: {summary['running']}\n" f"Restarting: {summary['restarting']}\n" @@ -341,7 +402,7 @@ async def status(self, ctx: interactions.SlashContext, service: str = None): short_err = str(summary).replace("\n", " ") if len(short_err) > 300: short_err = short_err[:300] + "..." - value = f":red_circle: Unavailable\nReason: {short_err}" + value = f"{access_emoji} Unavailable\nReason: {short_err}" embed.add_field(name=f"**{service_name}**", value=value, inline=False) if len(embed.fields) == 0: @@ -359,7 +420,7 @@ async def status(self, ctx: interactions.SlashContext, service: str = None): ) @interactions.check(config.has_perms_async) async def start(self, ctx: interactions.SlashContext, service: str): - allowed, error = self._ensure_service_access(ctx, service) + allowed, error = await self._ensure_service_access(ctx, service) if not allowed: return await ctx.send(embeds=[error]) @@ -387,7 +448,7 @@ async def start(self, ctx: interactions.SlashContext, service: str): ) @interactions.check(config.has_perms_async) async def stop(self, ctx: interactions.SlashContext, service: str): - allowed, error = self._ensure_service_access(ctx, service) + allowed, error = await self._ensure_service_access(ctx, service) if not allowed: return await ctx.send(embeds=[error]) @@ -407,7 +468,7 @@ async def stop(self, ctx: interactions.SlashContext, service: str): ) @interactions.check(config.has_perms_async) async def restart(self, ctx: interactions.SlashContext, service: str): - allowed, error = self._ensure_service_access(ctx, service) + allowed, error = await self._ensure_service_access(ctx, service) if not allowed: return await ctx.send(embeds=[error]) @@ -427,7 +488,7 @@ async def restart(self, ctx: interactions.SlashContext, service: str): ) @interactions.check(config.has_perms_async) async def update(self, ctx: interactions.SlashContext, service: str): - allowed, error = self._ensure_service_access(ctx, service) + allowed, error = await self._ensure_service_access(ctx, service) if not allowed: return await ctx.send(embeds=[error]) @@ -466,8 +527,11 @@ async def service_list(self, ctx: interactions.SlashContext): for service_name in config.list_services(): root = config.get_service_root(service_name) or "" - if config.check_service_specific_perms(ctx, service_name): - embed.add_field(name=service_name, value=root, inline=False) + has_access = await config.check_service_specific_perms(ctx, service_name) + require_access = config.get_service_status_commands_require_access(service_name) + if has_access or not require_access: + access_emoji = "πŸ”“" if has_access else "πŸ”’" + embed.add_field(name=f"{access_emoji} {service_name}", value=root, inline=False) if len(embed.fields) == 0: return await ctx.send(embeds=[no_perms_embed()]) @@ -553,7 +617,7 @@ async def service_remove(self, ctx: interactions.SlashContext, service: str): ) @interactions.check(config.has_perms_async) async def service_validate(self, ctx: interactions.SlashContext, service: str): - allowed, error = self._ensure_service_access(ctx, service) + allowed, error = await self._ensure_service_access(ctx, service) if not allowed: return await ctx.send(embeds=[error]) @@ -584,32 +648,31 @@ async def service_validate(self, ctx: interactions.SlashContext, service: str): @start.autocomplete("service") async def start_service_autocomplete(self, ctx: interactions.AutocompleteContext): - await ctx.send(self._service_autocomplete_choices(ctx)) + await ctx.send(await self._service_autocomplete_choices(ctx)) @status.autocomplete("service") async def status_service_autocomplete(self, ctx: interactions.AutocompleteContext): - await ctx.send(self._service_autocomplete_choices(ctx)) + await ctx.send(await self._service_autocomplete_choices(ctx, is_status=True)) @stop.autocomplete("service") async def stop_service_autocomplete(self, ctx: interactions.AutocompleteContext): - await ctx.send(self._service_autocomplete_choices(ctx)) + await ctx.send(await self._service_autocomplete_choices(ctx)) @restart.autocomplete("service") async def restart_service_autocomplete(self, ctx: interactions.AutocompleteContext): - await ctx.send(self._service_autocomplete_choices(ctx)) + await ctx.send(await self._service_autocomplete_choices(ctx)) @update.autocomplete("service") async def update_service_autocomplete(self, ctx: interactions.AutocompleteContext): - await ctx.send(self._service_autocomplete_choices(ctx)) + await ctx.send(await self._service_autocomplete_choices(ctx)) @service_remove.autocomplete("service") async def remove_service_autocomplete(self, ctx: interactions.AutocompleteContext): - await ctx.send(self._service_autocomplete_choices(ctx)) + await ctx.send(await self._service_autocomplete_choices(ctx)) @service_validate.autocomplete("service") async def validate_service_autocomplete(self, ctx: interactions.AutocompleteContext): - await ctx.send(self._service_autocomplete_choices(ctx)) - + await ctx.send(await self._service_autocomplete_choices(ctx)) def setup(client): diff --git a/config_loader.py b/config_loader.py index dabe155..ba648b5 100644 --- a/config_loader.py +++ b/config_loader.py @@ -14,22 +14,32 @@ def __init__(self, config_file): self.token = "" self.bot_group = "" self.prefix = "/wm " - self.roles = set() self.users = set() - self.error_channel = "" self.services = {} + from keycloak_client import KeycloakClient + self.keycloak = KeycloakClient(self) self.reload() def reload(self): + if hasattr(self, "keycloak"): + self.keycloak.clear_cache() with open(self.path, encoding="utf-8-sig") as f: self.config_file = json.load(f) self.token = self.config_file["token"] self.bot_group = self.config_file.get("botGroupId", "") self.prefix = self.config_file.get("prefix", "/wm ") - self.roles = {str(role) for role in self.config_file.get("roles", [])} self.users = {str(user) for user in self.config_file.get("users", [])} - self.error_channel = self.config_file["error_channel"] + + # Keycloak integration settings + self.keycloak_enabled = bool(self.config_file.get("keycloak_enabled", False)) + self.keycloak_server_url = str(self.config_file.get("keycloak_server_url", "")) + self.keycloak_realm = str(self.config_file.get("keycloak_realm", "")) + self.keycloak_client_id = str(self.config_file.get("keycloak_client_id", "")) + self.keycloak_client_secret = str(self.config_file.get("keycloak_client_secret", "")) + self.keycloak_idp_alias = str(self.config_file.get("keycloak_idp_alias", "discord")) + self.keycloak_root_role = str(self.config_file.get("keycloak_root_role", "")) + self.keycloak_admin_role = str(self.config_file.get("keycloak_admin_role", "")) if "services" in self.config_file: self.registry_key = "services" @@ -144,40 +154,26 @@ def list_bots(self): def get_bot(self, name): return self.get_service(name) - def _extract_author_role_ids(self, ctx): - role_ids = set() - author_roles = getattr(ctx.author, "roles", None) - if not author_roles: - return role_ids - for role in author_roles: - role_id = getattr(role, "id", role) - role_ids.add(str(role_id)) - return role_ids - - def _has_role_match(self, ctx, allowed_roles): - if not allowed_roles: - return False - return len(self._extract_author_role_ids(ctx).intersection(allowed_roles)) > 0 - - def has_perms_base(self, ctx): + async def has_perms_base(self, ctx): author_id = str(ctx.author.id) - return author_id in self.users or self._has_role_match(ctx, self.roles) + if author_id in self.users: + return True + + if self.keycloak_enabled: + roles = await self.keycloak.get_user_roles(author_id) + if self.keycloak_root_role and self.keycloak_root_role not in roles: + return False + if self.keycloak_admin_role and self.keycloak_admin_role in roles: + return True + if not self.keycloak_admin_role and self.keycloak_root_role in roles: + return True + return False def _service_permission_sets(self, service_name): service_users = set() service_roles = set() service_cfg = self.load_service_config(service_name) - for key in ("users", "allowed_users"): - values = service_cfg.get(key, []) - if isinstance(values, list): - service_users.update(str(v) for v in values) - - for key in ("roles", "allowed_roles"): - values = service_cfg.get(key, []) - if isinstance(values, list): - service_roles.update(str(v) for v in values) - perms = service_cfg.get("permissions", {}) if isinstance(perms, dict): users = perms.get("users", []) @@ -189,25 +185,65 @@ def _service_permission_sets(self, service_name): return service_users, service_roles - def has_perms_service(self, ctx, service_name): - service_users, service_roles = self._service_permission_sets(service_name) + def get_service_status_commands_require_access(self, service_name): + service_cfg = self.load_service_config(service_name) + val = service_cfg.get("status_commands_require_access") + if val is None: + perms = service_cfg.get("permissions", {}) + if isinstance(perms, dict): + val = perms.get("status_commands_require_access") + return bool(val) + + async def has_perms_service(self, ctx, service_name): author_id = str(ctx.author.id) - return author_id in service_users or self._has_role_match(ctx, service_roles) + service_users, service_roles = self._service_permission_sets(service_name) + if author_id in service_users: + return True + + if self.keycloak_enabled: + roles = await self.keycloak.get_user_roles(author_id) + if self.keycloak_root_role and self.keycloak_root_role not in roles: + return False + if self.keycloak_admin_role and self.keycloak_admin_role in roles: + return True + if service_roles and len(roles.intersection(service_roles)) > 0: + return True + return False async def has_perms_async(self, ctx): - if self.has_perms_base(ctx): + author_id = str(ctx.author.id) + if author_id in self.users: return True - for service_name in self.list_services(): - if self.has_perms_service(ctx, service_name): + + if self.keycloak_enabled: + roles = await self.keycloak.get_user_roles(author_id) + if self.keycloak_root_role and self.keycloak_root_role not in roles: + return False + + if self.keycloak_admin_role and self.keycloak_admin_role in roles: + return True + if not self.keycloak_admin_role and self.keycloak_root_role in roles: + return True + + for service_name in self.list_services(): + if not self.get_service_status_commands_require_access(service_name): + return True + if await self.has_perms_service(ctx, service_name): + return True + else: + if await self.has_perms_base(ctx): return True + for service_name in self.list_services(): + if await self.has_perms_service(ctx, service_name): + return True return False - def check_service_specific_perms(self, ctx, service_name): - if self.has_perms_base(ctx): + async def check_service_specific_perms(self, ctx, service_name): + if await self.has_perms_base(ctx): return True - return self.has_perms_service(ctx, service_name) + return await self.has_perms_service(ctx, service_name) - def check_bot_specific_perms(self, ctx, bot_info): + async def check_bot_specific_perms(self, ctx, bot_info): if not isinstance(bot_info, dict): return False root = bot_info.get("root") @@ -215,5 +251,5 @@ def check_bot_specific_perms(self, ctx, bot_info): return False for name, service in self.services.items(): if service.get("root") == root: - return self.check_service_specific_perms(ctx, name) - return False \ No newline at end of file + return await self.check_service_specific_perms(ctx, name) + return False \ No newline at end of file diff --git a/keycloak_client.py b/keycloak_client.py new file mode 100644 index 0000000..9a3bd87 --- /dev/null +++ b/keycloak_client.py @@ -0,0 +1,111 @@ +from keycloak import KeycloakAdmin +import time +import logging + +logger = logging.getLogger("WatchmanKeycloak") + +class KeycloakClient: + def __init__(self, config): + self.config = config + self._admin_client = None + self._client_uuid = None + self._roles_cache = {} # discord_id -> (roles_set, expires_at) + self._cache_ttl = 30 # seconds + + def clear_cache(self): + """Clears all cached client UUIDs, tokens, and user roles.""" + self._roles_cache.clear() + self._admin_client = None + self._client_uuid = None + + def _get_admin_client(self): + if self._admin_client: + return self._admin_client + + # Instantiate KeycloakAdmin client + self._admin_client = KeycloakAdmin( + server_url=self.config.keycloak_server_url, + realm_name=self.config.keycloak_realm, + client_id=self.config.keycloak_client_id, + client_secret_key=self.config.keycloak_client_secret or None, + ) + return self._admin_client + + async def _get_client_uuid(self): + if self._client_uuid: + return self._client_uuid + try: + admin = self._get_admin_client() + # a_get_client_id returns the client UUID string given its human-readable clientId + self._client_uuid = await admin.a_get_client_id(self.config.keycloak_client_id) + return self._client_uuid + except Exception as e: + logger.error(f"Exception fetching client uuid via python-keycloak: {e}") + return None + + async def get_user_roles(self, discord_id): + """Retrieves combined set of realm and client roles for a user by Discord ID.""" + now = time.time() + if discord_id in self._roles_cache: + roles, expires_at = self._roles_cache[discord_id] + if now < expires_at: + return roles + + roles = await self._fetch_user_roles_from_api(discord_id) + if roles is not None: + self._roles_cache[discord_id] = (roles, now + self._cache_ttl) + return roles + return set() + + async def _fetch_user_roles_from_api(self, discord_id): + if not self.config.keycloak_server_url or not self.config.keycloak_realm or not self.config.keycloak_client_id: + logger.warning("Keycloak is enabled but server URL, realm, or client ID is missing in configuration.") + return set() + + try: + admin = self._get_admin_client() + + # Step 1: Find Keycloak user by Discord identity + idp_alias = self.config.keycloak_idp_alias or "discord" + query = { + "idpAlias": idp_alias, + "idpUserId": str(discord_id) + } + users = await admin.a_get_users(query=query) + if not users: + logger.info(f"No Keycloak user found linked with discord id: {discord_id}") + return set() + user_uuid = users[0].get("id") + + roles_set = set() + + # Step 2: Fetch Realm composite roles (includes inherited roles) + try: + realm_roles = await admin.a_get_composite_realm_roles_of_user(user_id=user_uuid) + for role in realm_roles: + name = role.get("name") + if name: + roles_set.add(name) + except Exception as e: + logger.error(f"Exception fetching realm composite roles via python-keycloak: {e}") + + # Step 3: Fetch Client composite roles (includes inherited roles) + client_uuid = await self._get_client_uuid() + if client_uuid: + try: + client_roles = await admin.a_get_composite_client_roles_of_user( + user_id=user_uuid, + client_id=client_uuid + ) + for role in client_roles: + name = role.get("name") + if name: + roles_set.add(name) + except Exception as e: + logger.error(f"Exception fetching client composite roles via python-keycloak: {e}") + + return roles_set + + except Exception as e: + logger.error(f"Exception in python-keycloak operations: {e}") + return None diff --git a/requirements.txt b/requirements.txt index 9a6cd16..1336edf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ discord-py-interactions +python-keycloak From 922ed28a911bd5ece272cdf3cbed47316b1dd728 Mon Sep 17 00:00:00 2001 From: Nudelsuppe42 Date: Sat, 25 Jul 2026 15:10:12 +0200 Subject: [PATCH 4/4] feat: logs command --- README.md | 44 ++++++-------- bot.py | 173 +++++++++++++++++++++++++++++++++++++----------------- 2 files changed, 138 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 380167d..dda74e0 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ To enable Keycloak SSO RBAC, add the following parameters to your root `config.j }, "roles": [], "users": [], - + "keycloak_enabled": true, "keycloak_server_url": "https://sso.example.com", "keycloak_realm": "staff", @@ -29,15 +29,6 @@ To enable Keycloak SSO RBAC, add the following parameters to your root `config.j } ``` -* **`keycloak_enabled`** (`bool`): Set to `true` to retrieve roles from Keycloak based on the user's Discord ID. -* **`keycloak_server_url`** (`str`): Base URL of your self-hosted Keycloak instance. -* **`keycloak_realm`** (`str`): Realm name where users reside. -* **`keycloak_client_id`** (`str`): Keycloak client ID configured for Watchman. -* **`keycloak_client_secret`** (`str`): Secret used to authenticate with Keycloak via Client Credentials. -* **`keycloak_idp_alias`** (`str`): Identity Provider alias configured in Keycloak (defaults to `"discord"`). -* **`keycloak_root_role`** (`str`): Global gateway role. Users must have this role to interact with Watchman. -* **`keycloak_admin_role`** (`str`): Administrative role required to manage the service registry (add/remove services). - --- ## Service Configs (`watchman.json`) @@ -45,6 +36,7 @@ To enable Keycloak SSO RBAC, add the following parameters to your root `config.j Each service root folder must contain a `watchman.json` and a `compose.yml` (or `docker-compose.yml`) file. ### Permissions Syntax + Permissions must be defined under the nested `"permissions"` key. You can also specify `status_commands_require_access` to control visibility on a per-service level: ```json @@ -68,27 +60,27 @@ Permissions must be defined under the nested `"permissions"` key. You can also s } ``` -* **`permissions.roles`** (`list`): Keycloak realm or client roles that grant mutation access (start, stop, restart, update) for this service. -* **`permissions.users`** (`list`): Discord user IDs for local bypass or fallback access. -* **`permissions.status_commands_require_access`** (`bool`): - * If `true`, only users with the mutation role (or admins) can check this service's status or see it listed. - * If `false` (default), anyone with the Keycloak `root_role` can see the service in `/wm status` and `/wm service list` as read-only. Read-only services are decorated with a `πŸ”’` emoji, while write-accessible services are decorated with a `πŸ”“` emoji. +- **`permissions.roles`** (`list`): Keycloak realm or client roles that grant mutation access (start, stop, restart, update) for this service. +- **`permissions.users`** (`list`): Discord user IDs for local bypass or fallback access. +- **`permissions.status_commands_require_access`** (`bool`): + - If `true`, only users with the mutation role (or admins) can check this service's status or see it listed. + - If `false` (default), anyone with the Keycloak `root_role` can see the service in `/wm status` and `/wm service list` as read-only. Read-only services are decorated with a `πŸ”’` emoji, while write-accessible services are decorated with a `πŸ”“` emoji. --- ## Slash Commands -* `/wm help`: Displays the help menu. -* `/wm status [service]`: Check the status of all stacks or filter by a specific service. -* `/wm start `: Start a compose stack. -* `/wm stop `: Stop a compose stack. -* `/wm restart `: Restart a compose stack. -* `/wm update `: Pull images and update a stack. -* `/wm service list`: List registered services and roots. -* `/wm service validate `: Check validation of `watchman.json` and compose file. -* `/wm service add `: Add a service (Admins only). -* `/wm service remove `: Remove a service (Admins only). -* `/wm debug`: Prints diagnostic info showing your Keycloak identity, roles, gateway permissions, and service-by-service access breakdown. +- `/wm help`: Displays the help menu. +- `/wm status [service]`: Check the status of all stacks or filter by a specific service. +- `/wm start `: Start a compose stack. +- `/wm stop `: Stop a compose stack. +- `/wm restart `: Restart a compose stack. +- `/wm update `: Pull images and update a stack. +- `/wm service list`: List registered services and roots. +- `/wm service validate `: Check validation of `watchman.json` and compose file. +- `/wm service add `: Add a service (Admins only). +- `/wm service remove `: Remove a service (Admins only). +- `/wm debug`: Prints diagnostic info showing your Keycloak identity, roles, gateway permissions, and service-by-service access breakdown. --- diff --git a/bot.py b/bot.py index 0a655f3..015bfd1 100644 --- a/bot.py +++ b/bot.py @@ -21,6 +21,7 @@ base = interactions.SlashCommand(name="wm", description=generic_reason) config = Config("config.json") + def no_service_embed(): return interactions.Embed( title="Error", @@ -28,6 +29,7 @@ def no_service_embed(): color=0xFF0000, ) + def no_perms_embed(): return interactions.Embed( title="Error", @@ -35,6 +37,7 @@ def no_perms_embed(): color=0xFF0000, ) + def command_failed_embed(operation, err): return interactions.Embed( title=f"{operation} Failed", @@ -42,6 +45,7 @@ def command_failed_embed(operation, err): color=0xFF0000, ) + class Watchman(interactions.Extension): def __init__(self, bot): self.bot = bot @@ -57,7 +61,8 @@ def _service_icon(self, service_name): return None def service_embed(self, service_name, title, description, color): - embed = interactions.Embed(title=title, description=description, color=color) + embed = interactions.Embed( + title=title, description=description, color=color) icon = self._service_icon(service_name) if icon: embed.set_author(name=service_name, icon_url=icon) @@ -74,7 +79,8 @@ async def on_startup(self): @interactions.listen(interactions.api.events.Error) async def on_error(self, error: interactions.api.events.Error): - print(f"Watchman Error from source {error.source}: {error.error}", flush=True) + print( + f"Watchman Error from source {error.source}: {error.error}", flush=True) def _compose_base_cmd(self, root): docker_compose_probe = subprocess.run( @@ -157,7 +163,8 @@ def _run_custom_command(self, service_name, command): def _run_service_step(self, service_name, step, default_compose_args): cfg = config.load_service_config(service_name) - compose_commands = cfg.get("compose_commands", {}) if isinstance(cfg, dict) else {} + compose_commands = cfg.get( + "compose_commands", {}) if isinstance(cfg, dict) else {} if isinstance(compose_commands, dict): custom = compose_commands.get(step) if isinstance(custom, str) and custom.strip(): @@ -202,7 +209,8 @@ async def _ensure_service_access(self, ctx, service_name): def _service_status_summary(self, service_name): # Prefer structured output when supported by Compose v2. - ok_json, output_json = self._run_compose(service_name, ["ps", "--format", "json"]) + ok_json, output_json = self._run_compose( + service_name, ["ps", "--format", "json"]) if ok_json: try: rows = json.loads(output_json) @@ -265,7 +273,8 @@ async def _service_autocomplete_choices(self, ctx, is_status=False): query = (ctx.input_text or "").lower() visible = [] for service_name in config.list_services(): - require_access = config.get_service_status_commands_require_access(service_name) + require_access = config.get_service_status_commands_require_access( + service_name) if not is_status or require_access: if not await config.check_service_specific_perms(ctx, service_name): continue @@ -277,17 +286,28 @@ async def _service_autocomplete_choices(self, ctx, is_status=False): @base.subcommand(sub_cmd_name="help", sub_cmd_description=generic_reason) @interactions.check(config.has_perms_async) async def help(self, ctx: interactions.SlashContext): - embed = interactions.Embed(title="Watchman Help", description="Commands:", color=0x21304A) - embed.add_field(name=self.command_name("status [service]"), value="Check status of all configured service stacks or one service.", inline=False) - embed.add_field(name=self.command_name("start "), value="Run docker compose up -d.", inline=False) - embed.add_field(name=self.command_name("stop "), value="Run docker compose stop.", inline=False) - embed.add_field(name=self.command_name("restart "), value="Run docker compose restart.", inline=False) - embed.add_field(name=self.command_name("update "), value="Run docker compose pull && docker compose up -d.", inline=False) - embed.add_field(name=self.command_name("service list"), value="List known services and roots.", inline=False) - embed.add_field(name=self.command_name("service add "), value="Validate and register a new service root.", inline=False) - embed.add_field(name=self.command_name("service remove "), value="Unregister a service.", inline=False) - embed.add_field(name=self.command_name("service validate "), value="Validate watchman.json and compose file presence.", inline=False) - embed.add_field(name=self.command_name("debug"), value="Print out RBAC diagnostics and services you have access to.", inline=False) + embed = interactions.Embed( + title="Watchman Help", description="Commands:", color=0x21304A) + embed.add_field(name=self.command_name( + "status [service]"), value="Check status of all configured service stacks or one service.", inline=False) + embed.add_field(name=self.command_name("start "), + value="Run docker compose up -d.", inline=False) + embed.add_field(name=self.command_name("stop "), + value="Run docker compose stop.", inline=False) + embed.add_field(name=self.command_name("restart "), + value="Run docker compose restart.", inline=False) + embed.add_field(name=self.command_name("update "), + value="Run docker compose pull && docker compose up -d.", inline=False) + embed.add_field(name=self.command_name("service list"), + value="List known services and roots.", inline=False) + embed.add_field(name=self.command_name("service add "), + value="Validate and register a new service root.", inline=False) + embed.add_field(name=self.command_name( + "service remove "), value="Unregister a service.", inline=False) + embed.add_field(name=self.command_name("service validate "), + value="Validate watchman.json and compose file presence.", inline=False) + embed.add_field(name=self.command_name( + "debug"), value="Print out RBAC diagnostics and services you have access to.", inline=False) return await ctx.send(embeds=[embed]) @base.subcommand(sub_cmd_name="debug", sub_cmd_description=generic_reason) @@ -295,31 +315,39 @@ async def help(self, ctx: interactions.SlashContext): async def debug(self, ctx: interactions.SlashContext): author_id = str(ctx.author.id) embed = interactions.Embed(title="Watchman RBAC Debug", color=0x21304A) - - embed.add_field(name="User", value=f"{ctx.author.name} (ID: {author_id})", inline=False) - + + embed.add_field( + name="User", value=f"{ctx.author.name} (ID: {author_id})", inline=False) + if config.keycloak_enabled: - embed.add_field(name="Keycloak Integration", value="Enabled", inline=True) - embed.add_field(name="Keycloak Server", value=config.keycloak_server_url, inline=True) - embed.add_field(name="Keycloak Realm", value=config.keycloak_realm, inline=True) - + embed.add_field(name="Keycloak Integration", + value="Enabled", inline=True) + embed.add_field(name="Keycloak Server", + value=config.keycloak_server_url, inline=True) + embed.add_field(name="Keycloak Realm", + value=config.keycloak_realm, inline=True) + roles = await config.keycloak.get_user_roles(author_id) roles_list = sorted(list(roles)) if roles else [] - roles_str = ", ".join(f"`{r}`" for r in roles_list) if roles_list else "*None*" - embed.add_field(name="Keycloak Roles", value=roles_str, inline=False) - + roles_str = ", ".join( + f"`{r}`" for r in roles_list) if roles_list else "*None*" + embed.add_field(name="Keycloak Roles", + value=roles_str, inline=False) + has_root = config.keycloak_root_role in roles if config.keycloak_root_role else True has_admin = config.keycloak_admin_role in roles if config.keycloak_admin_role else False - + root_status = f"Root Role (`{config.keycloak_root_role}`): {'βœ… Yes' if has_root else '❌ No'}" admin_status = f"Admin Role (`{config.keycloak_admin_role}`): {'βœ… Yes' if has_admin else '❌ No'}" - embed.add_field(name="Role Checks", value=f"{root_status}\n{admin_status}", inline=False) - + embed.add_field( + name="Role Checks", value=f"{root_status}\n{admin_status}", inline=False) + services_status = [] for service_name in config.list_services(): has_access = await config.check_service_specific_perms(ctx, service_name) - service_users, service_roles = config._service_permission_sets(service_name) - + service_users, service_roles = config._service_permission_sets( + service_name) + reason = "Denied" if has_access: if has_admin: @@ -331,21 +359,26 @@ async def debug(self, ctx: interactions.SlashContext): reason = f"Allowed (Service Role: {', '.join(matching)})" else: reason = "Allowed (Local Bypass)" - + status_char = "🟒" if has_access else "πŸ”΄" - services_status.append(f"{status_char} **{service_name}**: {reason}") - - services_str = "\n".join(services_status) if services_status else "No services configured." - embed.add_field(name="Service Access Details", value=services_str, inline=False) + services_status.append( + f"{status_char} **{service_name}**: {reason}") + + services_str = "\n".join( + services_status) if services_status else "No services configured." + embed.add_field(name="Service Access Details", + value=services_str, inline=False) else: - embed.add_field(name="Keycloak Integration", value="Disabled", inline=False) - + embed.add_field(name="Keycloak Integration", + value="Disabled", inline=False) + services_status = [] has_base = await config.has_perms_base(ctx) for service_name in config.list_services(): has_access = await config.check_service_specific_perms(ctx, service_name) - service_users, service_roles = config._service_permission_sets(service_name) - + service_users, service_roles = config._service_permission_sets( + service_name) + reason = "Denied" if has_access: if has_base: @@ -354,12 +387,15 @@ async def debug(self, ctx: interactions.SlashContext): reason = "Allowed (User Bypass)" else: reason = "Allowed (Service Role)" - + status_char = "🟒" if has_access else "πŸ”΄" - services_status.append(f"{status_char} **{service_name}**: {reason}") - - services_str = "\n".join(services_status) if services_status else "No services configured." - embed.add_field(name="Service Access Details", value=services_str, inline=False) + services_status.append( + f"{status_char} **{service_name}**: {reason}") + + services_str = "\n".join( + services_status) if services_status else "No services configured." + embed.add_field(name="Service Access Details", + value=services_str, inline=False) @base.subcommand(sub_cmd_name="status", sub_cmd_description=generic_reason) @interactions.slash_option( @@ -371,7 +407,8 @@ async def debug(self, ctx: interactions.SlashContext): ) @interactions.check(config.has_perms_async) async def status(self, ctx: interactions.SlashContext, service: str = None): - embed = interactions.Embed(title="Compose Status", description="", color=0x21304A) + embed = interactions.Embed( + title="Compose Status", description="", color=0x21304A) if service: services = [service] @@ -384,10 +421,11 @@ async def status(self, ctx: interactions.SlashContext, service: str = None): for service_name in services: has_access = await config.check_service_specific_perms(ctx, service_name) - require_access = config.get_service_status_commands_require_access(service_name) + require_access = config.get_service_status_commands_require_access( + service_name) if require_access and not has_access: continue - + ok, summary = self._service_status_summary(service_name) access_emoji = "πŸ”“" if has_access else "πŸ”’" if ok: @@ -403,7 +441,8 @@ async def status(self, ctx: interactions.SlashContext, service: str = None): if len(short_err) > 300: short_err = short_err[:300] + "..." value = f"{access_emoji} Unavailable\nReason: {short_err}" - embed.add_field(name=f"**{service_name}**", value=value, inline=False) + embed.add_field(name=f"**{service_name}**", + value=value, inline=False) if len(embed.fields) == 0: return await ctx.send(embeds=[no_perms_embed()]) @@ -497,7 +536,8 @@ async def update(self, ctx: interactions.SlashContext, service: str): return await ctx.send(embeds=[command_failed_embed("Pre-update Hook", hook_output)]) message = await ctx.send(embeds=[self.service_embed(service, "Update Service", "Pulling images...", 0x21304A)]) - ok_pull, pull_output = self._run_service_step(service, "pull", ["pull"]) + ok_pull, pull_output = self._run_service_step( + service, "pull", ["pull"]) if not ok_pull: return await message.edit(embeds=[command_failed_embed("Update Service (pull)", pull_output)]) @@ -511,6 +551,30 @@ async def update(self, ctx: interactions.SlashContext, service: str): combined += f"\n\nHook output:\n```\n{hook_output[:300]}\n```" await message.edit(embeds=[self.service_embed(service, "Update Service", combined, 0x00FF00)]) + @base.subcommand(sub_cmd_name="logs", sub_cmd_description=generic_reason) + @interactions.slash_option( + "service", + "service", + opt_type=interactions.OptionType.STRING, + required=True, + autocomplete=True, + ) + @interactions.check(config.has_perms_async) + async def logs(self, ctx: interactions.SlashContext, service: str): + allowed, error = await self._ensure_service_access(ctx, service) + if not allowed: + return await ctx.send(embeds=[error]) + + message = await ctx.send(embeds=[self.service_embed(service, "View Logs", "Fetching logs...", 0x21304A)]) + ok, output = self._run_service_step(service, "logs", ["logs"]) + if not ok: + return await message.edit(embeds=[command_failed_embed("View Logs", output)]) + + desc = "" + if output: + desc = f"\n\nLogs:\n```\n{output[:400]}\n```" + await message.edit(embeds=[self.service_embed(service, "View Logs", desc, 0x00FF00)]) + @base.subcommand( sub_cmd_name="list", group_name="service", @@ -519,7 +583,8 @@ async def update(self, ctx: interactions.SlashContext, service: str): ) @interactions.check(config.has_perms_async) async def service_list(self, ctx: interactions.SlashContext): - embed = interactions.Embed(title="Configured Services", description="", color=0x21304A) + embed = interactions.Embed( + title="Configured Services", description="", color=0x21304A) if not config.list_services(): embed.description = "No services configured." @@ -528,10 +593,12 @@ async def service_list(self, ctx: interactions.SlashContext): for service_name in config.list_services(): root = config.get_service_root(service_name) or "" has_access = await config.check_service_specific_perms(ctx, service_name) - require_access = config.get_service_status_commands_require_access(service_name) + require_access = config.get_service_status_commands_require_access( + service_name) if has_access or not require_access: access_emoji = "πŸ”“" if has_access else "πŸ”’" - embed.add_field(name=f"{access_emoji} {service_name}", value=root, inline=False) + embed.add_field( + name=f"{access_emoji} {service_name}", value=root, inline=False) if len(embed.fields) == 0: return await ctx.send(embeds=[no_perms_embed()])