Compare commits

..

No commits in common. "master" and "v.0.1.0" have entirely different histories.

5 changed files with 35 additions and 321 deletions

View file

@ -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

View file

@ -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

View file

@ -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: []

View file

@ -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

View file

@ -1,13 +1,10 @@
# 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 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
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)
body = self.template.from_string(REPO_TEMPLATE)
message = body.render(repo=repository, project=project,
base_url=base_url)
await evt.respond(message, markdown=True)
# Wait 200 milliseconds to avoid triggering rate limiting
sleep(0.2)