diff --git a/.woodpecker.yml b/.woodpecker.yml deleted file mode 100644 index e42fe5a..0000000 --- a/.woodpecker.yml +++ /dev/null @@ -1,37 +0,0 @@ -workspace: - base: /maubot-osc - path: src - -pipeline: - build: - image: dock.mau.dev/maubot/maubot:latest - commands: - - mbc build /maubot-osc/src -o /maubot-osc/src/ - secrets: [mbc_username, mbc_password] - release: - image: plugins/gitea-release - settings: - base_url: https://git.dennogumi.org - api_key: - from_secret: gitea_token - files: - - /maubot-osc/src/*.mbp - checksum: - - sha256 - when: - event: tag - notify: - image: plugins/matrix - settings: - homeserver: https://conference.heavensinferno.net - roomid: - from_secret: roomid - accesstoken: - from_secret: access_token - userid: - from_secret: user_id - when: - status: - - failure - - success - diff --git a/README.md b/README.md index 559df52..6c0e402 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,6 @@ SPDX-License-Identifier: CC0-1.0 ## maubot-osc -![status-badge](https://ci.dennogumi.org/api/badges/einar/maubot-osc/status.svg) - `maubot-osc` is a simple [maubot](https://maubot.xyz/) plugin to query an [Open Build Service](https://openbuildservice.org/) instance. Basically it replicates a few of the features of the `osc` command line tool used to interact with the OBS. ### Features @@ -23,7 +21,6 @@ These are in addition to what maubot requires by itself. - lxml - jinja2 -- [cryptocode](https://github.com/gdavid7/cryptocode) ### Installation @@ -33,11 +30,9 @@ Then you need to modify a few configuration parameters for your instance: - `api_url`: the URL to make API requests to the OBS instance. By default it points to the [public OBS instance of the openSUSE project](https://build.opensuse.org) (`https://api.opensuse.org`). - `instance_url`: the main URL of the Build Service instance. Defaults to `https://build.opensuse.org`. - `username`: A valid username for the instance -- `password`: A password for the username. Suggestions on how to improve this are welcome. +- `password`: A password for the username. **Currently kept in cleartext**. Suggestions on how to improve this are welcome. - `trigger_token`: An OBS access token with the `runservice` capability. See "Creating tokens" below. - `rebuild_token`: An OBS access token with the `rebuild` capability. See "Creating tokens" below. -- `secret`: A random string used to encrypt the password in the configuration file. -- `acl`: User IDs (`@user:homeserver.com`) which are allowed to interact with the bot. See "Access control lists" below. ### Creating tokens @@ -63,26 +58,9 @@ Save both tokens in the configuration. Should you need to, you can view them lat Make sure your bot instance is in a room (refer to the maubot docs for how) and then type `!osc help` for help. -### Access control lists (ACLs) - -By default, no user is allowed to perform any operation. You need to change the `acl` section of the configuration to add user IDs (`@user:homeserver.com`) allowed to interact with the bot. Users in the `admin` list have full powers over the bot, while users in the `user` list can only perform read-only (e.g. status querying) actions. - -This example shows how it works in practice: - -```yaml -acl: - admin: - # Fred and Sue will be able to perform all commands - - @fred:myhome.com - - @sue:otherserver.com - user: - # Phil will only be able to run read-only (status, etc.) commands - - @phil:elsewhere.com -``` - ### Notice -I made this for my own use. Don't expect high quality code or a lot of polish. +I made this in about three or four hours for my own use. Don't expect high quality code or a lot of polish. ### License diff --git a/base-config.yaml b/base-config.yaml index cf823be..5cedd97 100644 --- a/base-config.yaml +++ b/base-config.yaml @@ -6,36 +6,9 @@ api_url: https://api.opensuse.org instance_url: https://build.opensuse.org # Build Service username username: changeme -# Used to encrypt the password. Set it to a random string -secret: changeme -# Build Service password - will be encrypted on first run +# Build Service password password: changeme # Build service access token with the "rebuild" capability rebuild_token: changeme # Build service access token with the "runservices" capability trigger_token: changeme -# Aliases for repositories -repo_aliases: - kua_failed: - project: "KDE:Unstable:Applications" - package: "all" - repository: "KDE_Unstable_Frameworks_openSUSE_Factory" - state: "failed" - arch: "all" - kuf_failed: - project: "KDE:Unstable:Frameworks" - package: "all" - repository: "openSUSE_Factory" - state: "failed" - arch: "all" - kue_failed: - project: "KDE:Unstable:Extra" - package: "all" - repository: "KDE_Unstable_Frameworks_openSUSE_Factory" - state: "failed" - arch: "all" -acl: - admin: - - "@CHANGE_ME" - user: [] - diff --git a/maubot.yaml b/maubot.yaml index 403a0ee..74723ca 100644 --- a/maubot.yaml +++ b/maubot.yaml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: CC0-1.0 maubot: 0.1.0 id: org.dennogumi.osc -version: 0.4.0 +version: 0.1.0 license: AGPL-3.0-or-later modules: - oscbot @@ -12,6 +12,5 @@ extra_files: dependencies: - lxml - jinja2 - - cryptocode database: false config: true diff --git a/oscbot/__init__.py b/oscbot/__init__.py index 73735b5..43cc5a3 100644 --- a/oscbot/__init__.py +++ b/oscbot/__init__.py @@ -1,13 +1,10 @@ # SPDX-FileCopyrightText: 2022 Luca Beltrame # SPDX-License-Identifier: AGPL-3.0-or-later -from dataclasses import dataclass, field -from time import sleep +from dataclasses import dataclass from typing import Optional, List, Type, Tuple import aiohttp -import cryptocode -import more_itertools as mit from lxml import objectify from jinja2 import BaseLoader, Environment @@ -24,11 +21,13 @@ Only showing packages with {{ state }} state. {% endif %}""" -STATUS_TEMPLATE = """ -{% for package in packages %} +REPO_TEMPLATE = """ +#### {{ repo.name }} - {{ repo.arch }} + +{% for package in repo.packages %} {%if package.status != "disabled" %} -{% set build_log_url = "{0}/package/live_build_log/{1}/{2}/{3}/{4}/".format( - base_url, project, package.name, repo.name, repo.arch) %} +{% set build_log_url = "{0}/public/build/{1}/{2}/{3}/{4}/_log".format( + base_url,project, repo.name, repo.arch, package.name) %} - {{ package.name }} - *[{{ package.status }}]({{ build_log_url }})* {% else %} - {{ package.name }} - *{{ package.status }}* @@ -36,21 +35,6 @@ STATUS_TEMPLATE = """ {% endfor %} """ -ALIAS_TEMPLATE = """ -### Configured aliases - -{% for alias, contents in repo_aliases.items() %} -#### {{ alias }} - -- project: {{ contents.project }} -- repository: {{ contents.repository }} -- package(s): {{ contents.package }} -- architecture(s): {{ contents.arch }} -- state: {{ contents.state }} - -{% endfor %} -""" - @dataclass class BuildResult: @@ -62,7 +46,7 @@ class BuildResult: class BuildRepository: name: str arch: str - packages: List[BuildResult] = field(default_factory=list) + packages: List[BuildResult] class Config(BaseProxyConfig): @@ -72,18 +56,8 @@ class Config(BaseProxyConfig): helper.copy("instance_url") helper.copy("rebuild_token") helper.copy("trigger_token") - helper.copy("secret") - - password = self["password"] - if len(password) < 91 and not password.endswith("=="): - encrypted_password = cryptocode.encrypt(password, self["secret"]) - helper.base["password"] = encrypted_password - else: - helper.copy("password") - helper.copy("username") - helper.copy("repo_aliases") - helper.copy("acl") + helper.copy("password") class OSCBot(Plugin): @@ -102,31 +76,6 @@ class OSCBot(Plugin): def get_config_class(cls) -> Type[BaseProxyConfig]: return Config - def get_alias(self, project_alias: str) -> Tuple[str, str, str, str, str]: - data = self.config["repo_aliases"][project_alias] - # There is no concept of non-positional arguments in maubot - # So we just use "all" in case we want to skip something - package = data["package"] if data["package"] != "all" else None - repository = (data["repository"] if data["repository"] != "all" - else None) - arch = data["arch"] if data["arch"] != "all" else None - project = data["project"] - state = data["state"] if data["state"] != "all" else None - - return (project, package, repository, state, arch) - - def check_acl(self, user_id: str, need_admin: bool = False) -> bool: - - acls = self.config["acl"] - if not need_admin: - if user_id in acls["admin"] or acls["user"]: - return True - else: - if user_id in acls["admin"]: - return True - self.log.debug(f"Denied operation as {user_id}") - return False - async def parse_rebuilpac( self, project: str, @@ -163,8 +112,7 @@ class OSCBot(Plugin): arch: Optional[str] = None) -> List[BuildRepository]: username = self.config["username"] - password = cryptocode.decrypt(self.config["password"], - self.config["secret"]) + password = self.config["password"] api_url = self.config["api_url"] api_call = f"{api_url}/build/{project}/_result" @@ -228,124 +176,10 @@ class OSCBot(Plugin): async def osc(self) -> None: pass - @osc.subcommand("alias", help="Manage aliases") - async def alias(self) -> None: - pass - - @alias.subcommand("list", help="List configured aliases") - async def list_aliases(self, evt: MessageEvent) -> None: - - if not self.check_acl(evt.sender, need_admin=False): - await evt.reply("You are not authorized to perform this action.") - return - - body = self.template.from_string(ALIAS_TEMPLATE) - - if self.config.get("repo_aliases", None) is None: - await evt.reply("No aliases defined.") - return - - message = body.render(repo_aliases=self.config["repo_aliases"]) - await evt.respond(message, markdown=True) - - @alias.subcommand("edit", help="Edit configured aliases") - @command.argument("alias", "alias name") - @command.argument("project", "project name") - @command.argument("package", "package name", required=False) - @command.argument("repository", "repository", required=False) - @command.argument("arch", "architecture", required=False) - @command.argument("state", "state", required=False) - async def edit_alias(self, evt: MessageEvent, - alias: str, - project: Optional[str] = None, - package: Optional[str] = None, - repository: Optional[str] = None, - arch: Optional[str] = None, - state: Optional[str] = None) -> None: - - if not self.check_acl(evt.sender, need_admin=True): - await evt.reply("You are not authorized to perform this action.") - return - - if alias not in self.config["repo_aliases"]: - await evt.respond(f"Unknown alias {alias}") - return - - alias_data = self.config["repo_aliases"][alias] - - if project: - alias_data["project"] = project - - if repository: - alias_data["repository"] = repository - - if package: - alias_data["package"] = package - - if arch: - alias_data["arch"] = alias - - if state: - alias_data["state"] = state - - self.config["repo_aliases"][alias] = alias_data - self.config.save() - await evt.reply(f"Alias {alias} successfully changed.") - - @alias.subcommand("create", help="Create a new alias") - @command.argument("alias", "alias name") - @command.argument("project", "project name") - @command.argument("package", "package name", required=False) - @command.argument("repository", "repository", required=False) - @command.argument("arch", "architecture", required=False) - @command.argument("state", "state", required=False) - async def create_alias(self, evt: MessageEvent, - alias: str, - project: Optional[str] = None, - package: Optional[str] = None, - repository: Optional[str] = None, - arch: Optional[str] = None, - state: Optional[str] = None) -> None: - - if not self.check_acl(evt.sender, need_admin=True): - await evt.reply("You are not authorized to perform this action.") - return - - repository = "all" if not repository else repository - package = "all" if not package else package - arch = "all" if not arch else arch - state = "all" if not state else state - alias_data = { - "project": project, - "package": package, - "repository": repository, - "arch": arch, - "state": state - } - self.config["repo_aliases"][alias] = alias_data - self.config.save() - await evt.reply(f"Alias {alias} successfully created.") - - @alias.subcommand("delete", help="Delete an alias", aliases=("rm", )) - @command.argument("alias", "alias name") - async def delete_alias(self, evt: MessageEvent, alias: str): - - if not self.check_acl(evt.sender, need_admin=True): - await evt.reply("You are not authorized to perform this action.") - return - - if alias not in self.config["repo_aliases"]: - await evt.respond(f"Unknown alias {alias}") - return - - del self.config["repo_aliases"][alias] - self.config.save() - await evt.reply(f"Alias {alias} successfully deleted.") - @osc.subcommand( "rebuildpac", aliases=("rb",), help="Rebuild a package or all packages in the repositories") - @command.argument("project", "project name/alias") + @command.argument("project", "project name") @command.argument("package", "package name (or \"all\" for all packages)") @command.argument("repository", "repository (optional)", required=False) @command.argument("arch", "architecture (optional)", required=False) @@ -355,18 +189,12 @@ class OSCBot(Plugin): repository: Optional[str] = None, arch: Optional[str] = None) -> None: - if not self.check_acl(evt.sender, need_admin=True): - await evt.reply("You are not authorized to perform this action.") - return - - if project in self.config["repo_aliases"]: - # We're not interested in state and we query package explicitly - project, _, repository, _, arch = self.get_alias(project) - else: - repository = None if repository == "all" else repository - arch = None if arch == "all" else arch - - package = None if package == "all" else package + if package == "all": + package = None + if repository == "all": + repository = None + if arch == "all": + arch = None result, status = await self.parse_rebuilpac(project, package, repository, @@ -387,10 +215,6 @@ class OSCBot(Plugin): project: str, package: str) -> None: - if not self.check_acl(evt.sender, need_admin=True): - await evt.reply("You are not authorized to perform this action.") - return - token = self.config["trigger_token"] trigger_url = f"{self.config['api_url']}/trigger/runservice" params = {"project": project, "package": package} @@ -420,18 +244,16 @@ class OSCBot(Plugin): repository: Optional[str] = None, arch: Optional[str] = None) -> None: - if not self.check_acl(evt.sender, need_admin=False): - await evt.reply("You are not authorized to perform this action.") - return - - if project in self.config["repo_aliases"]: - project, package, repository, state, arch = self.get_alias(project) - else: - # There is no concept of non-positional arguments in maubot - # So we just use "all" in case we want to skip something - package = None if package == "all" else package - repository = None if repository == "all" else repository - arch = None if arch == "all" else arch + # There is no concept of non-positional arguments in maubot + # So we just use "all" in case we want to skip something + if state == "all": + state = None + if package == "all": + package = None + if repository == "all": + repository = None + if arch == "all": + arch = None response = await self.parse_status(project, package, state=state, repo=repository, arch=arch) @@ -446,29 +268,8 @@ class OSCBot(Plugin): base_url = self.config["instance_url"] - if len(response) > 50: - await evt.respond(f"Too many repositories ({len(response)})" - f"to display in project {project}, " - "please narrow down your search.") - return - for repository in response: - await evt.respond(f"#### {repository.name} - {repository.arch}", - markdown=True) - - # Just so that someone doesn't flood by listing the whole - # openSUSE:Factory - if len(repository.packages) > 1000: - await evt.respond( - f"Too many packages ({len(repository.packages)}) " - "to display, please narrow down your search.") - continue - # To avoid creating too large messages, we chunk packages in - # groups of 100 - for packagelist in mit.chunked(repository.packages, 100): - body = self.template.from_string(STATUS_TEMPLATE) - message = body.render(packages=packagelist, base_url=base_url, - project=project, repo=repository) - await evt.respond(message, markdown=True) - # Wait 200 milliseconds to avoid triggering rate limiting - sleep(0.2) + body = self.template.from_string(REPO_TEMPLATE) + message = body.render(repo=repository, project=project, + base_url=base_url) + await evt.respond(message, markdown=True)