maubot-osc/oscbot/__init__.py
Luca Beltrame c9d4e0312d
Support for ACLs
Users in the "admin" level can set aliases, rebuild packages, and
trigger services. On the other hand, users in the "user" level can only
perform read-only operations (reading status at this point).
2022-02-12 10:19:06 +01:00

474 lines
17 KiB
Python

# SPDX-FileCopyrightText: 2022 Luca Beltrame <lbeltrame@kde.org>
# SPDX-License-Identifier: AGPL-3.0-or-later
from dataclasses import dataclass, field
from time import sleep
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
from maubot import Plugin, MessageEvent
from maubot.handlers import command
from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper
HEADER_TEMPLATE = """
### Package status
{% if state %}
Only showing packages with {{ state }} state.
{% endif %}"""
STATUS_TEMPLATE = """
{% for package in 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) %}
- {{ package.name }} - *[{{ package.status }}]({{ build_log_url }})*
{% else %}
- {{ package.name }} - *{{ package.status }}*
{% endif %}
{% 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:
name: str
status: str
@dataclass
class BuildRepository:
name: str
arch: str
packages: List[BuildResult] = field(default_factory=list)
class Config(BaseProxyConfig):
def do_update(self, helper: ConfigUpdateHelper) -> None:
helper.copy("api_url")
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")
class OSCBot(Plugin):
http: aiohttp.ClientSession
async def start(self) -> None:
await super().start()
self.config.load_and_update()
self.http = self.client.api.session
self.template = Environment(loader=BaseLoader,
lstrip_blocks=True,
trim_blocks=True)
@classmethod
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,
package: Optional[str] = None,
repo: Optional[str] = None,
arch: Optional[str] = None) -> Tuple[bool, str]:
trigger_url = f"{self.config['api_url']}/trigger/rebuild"
header = {"Authorization": f"Token {self.config['rebuild_token']}"}
params = {"project": project}
if package:
params["package"] = package
if repo:
params["repository"] = repo
if arch:
params["arch"] = arch
response = await self.http.post(trigger_url, params=params,
headers=header)
status = objectify.fromstring(await response.text())
if response.status == 200:
return (True, status.summary.text)
else:
return (False, status.summary.text)
async def parse_status(
self,
project: str,
package: Optional[str] = None,
state: Optional[str] = None,
repo: Optional[str] = None,
arch: Optional[str] = None) -> List[BuildRepository]:
username = self.config["username"]
password = cryptocode.decrypt(self.config["password"],
self.config["secret"])
api_url = self.config["api_url"]
api_call = f"{api_url}/build/{project}/_result"
auth = aiohttp.BasicAuth(username, password)
params = {}
if package:
params["package"] = package
response = await self.http.get(api_call, auth=auth, params=params)
if response.status != 200:
self.log.error(f"Unexpected status: got {response.status}")
return []
response_text = await response.text()
parsed = objectify.fromstring(response_text)
results = list()
for child in parsed.result:
repository_name = child.get("repository")
repo_arch = child.get("arch")
if repo and repo != repository_name:
self.log.debug(f"Skipping {repository_name}, not matching")
continue
if arch and arch != repo_arch:
self.log.debug(f"Skipping {repo_arch} ({repository_name}), "
" not matching")
continue
packages = list()
for status in child.status:
package_name = status.get("package")
package_status = status.get("code")
if state and state != package_status:
self.log.debug(f"Skipping {package_name},"
f" unwanted state {package_status}")
continue
result = BuildResult(name=package_name, status=package_status)
packages.append(result)
if not packages:
continue
repository = BuildRepository(name=repository_name,
arch=repo_arch,
packages=packages)
results.append(repository)
return results
@command.new(name="osc", help="Manage the bot",
require_subcommand=True)
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("package", "package name (or \"all\" for all packages)")
@command.argument("repository", "repository (optional)", required=False)
@command.argument("arch", "architecture (optional)", required=False)
async def rebuildpac(self, evt: MessageEvent,
project: str,
package: Optional[str] = None,
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
result, status = await self.parse_rebuilpac(project, package,
repository,
arch)
if not result:
message = f"Error received from OBS: {status}"
else:
message = f"Rebuild triggered {status}"
await evt.reply(message, markdown=True)
@osc.subcommand(
"servicerun",
help="Trigger source services for a package")
@command.argument("project", "project name")
@command.argument("package", "package name")
async def servicerun(self, evt: MessageEvent,
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}
header = {"Authorization": f"Token {token}"}
response = await self.http.post(trigger_url, params=params,
headers=header)
status = objectify.fromstring(await response.text())
if response.status == 200:
message = f"Service triggered: {status.summary.text}"
else:
message = f"Error running service: {status.summary.text}"
await evt.reply(message, markdown=True)
@osc.subcommand("status", aliases=("st",),
help="Check status for package and repository")
@command.argument("project", "project name")
@command.argument("package", "package name | all", required=False)
@command.argument("state", "build state | all", required=False)
@command.argument("repository", "repository | all)", required=False)
@command.argument("arch", "architecture state | all", required=False)
async def status(self, evt: MessageEvent,
project: str,
package: Optional[str] = None,
state: Optional[str] = None,
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
response = await self.parse_status(project, package, state=state,
repo=repository, arch=arch)
if not response:
await evt.reply("No results found.")
return
header = self.template.from_string(HEADER_TEMPLATE)
message = header.render(state=state)
await evt.reply(message, markdown=True)
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)