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 ## 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. `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 ### Features
@ -23,7 +21,6 @@ These are in addition to what maubot requires by itself.
- lxml - lxml
- jinja2 - jinja2
- [cryptocode](https://github.com/gdavid7/cryptocode)
### Installation ### 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`). - `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`. - `instance_url`: the main URL of the Build Service instance. Defaults to `https://build.opensuse.org`.
- `username`: A valid username for the instance - `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. - `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. - `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 ### 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. 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 ### 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 ### License

View file

@ -6,36 +6,9 @@ api_url: https://api.opensuse.org
instance_url: https://build.opensuse.org instance_url: https://build.opensuse.org
# Build Service username # Build Service username
username: changeme username: changeme
# Used to encrypt the password. Set it to a random string # Build Service password
secret: changeme
# Build Service password - will be encrypted on first run
password: changeme password: changeme
# Build service access token with the "rebuild" capability # Build service access token with the "rebuild" capability
rebuild_token: changeme rebuild_token: changeme
# Build service access token with the "runservices" capability # Build service access token with the "runservices" capability
trigger_token: changeme 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 # SPDX-License-Identifier: CC0-1.0
maubot: 0.1.0 maubot: 0.1.0
id: org.dennogumi.osc id: org.dennogumi.osc
version: 0.4.0 version: 0.1.0
license: AGPL-3.0-or-later license: AGPL-3.0-or-later
modules: modules:
- oscbot - oscbot
@ -12,6 +12,5 @@ extra_files:
dependencies: dependencies:
- lxml - lxml
- jinja2 - jinja2
- cryptocode
database: false database: false
config: true config: true

View file

@ -1,13 +1,10 @@
# SPDX-FileCopyrightText: 2022 Luca Beltrame <lbeltrame@kde.org> # SPDX-FileCopyrightText: 2022 Luca Beltrame <lbeltrame@kde.org>
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
from dataclasses import dataclass, field from dataclasses import dataclass
from time import sleep
from typing import Optional, List, Type, Tuple from typing import Optional, List, Type, Tuple
import aiohttp import aiohttp
import cryptocode
import more_itertools as mit
from lxml import objectify from lxml import objectify
from jinja2 import BaseLoader, Environment from jinja2 import BaseLoader, Environment
@ -24,11 +21,13 @@ Only showing packages with {{ state }} state.
{% endif %}""" {% endif %}"""
STATUS_TEMPLATE = """ REPO_TEMPLATE = """
{% for package in packages %} #### {{ repo.name }} - {{ repo.arch }}
{% for package in repo.packages %}
{%if package.status != "disabled" %} {%if package.status != "disabled" %}
{% set build_log_url = "{0}/package/live_build_log/{1}/{2}/{3}/{4}/".format( {% set build_log_url = "{0}/public/build/{1}/{2}/{3}/{4}/_log".format(
base_url, project, package.name, repo.name, repo.arch) %} base_url,project, repo.name, repo.arch, package.name) %}
- {{ package.name }} - *[{{ package.status }}]({{ build_log_url }})* - {{ package.name }} - *[{{ package.status }}]({{ build_log_url }})*
{% else %} {% else %}
- {{ package.name }} - *{{ package.status }}* - {{ package.name }} - *{{ package.status }}*
@ -36,21 +35,6 @@ STATUS_TEMPLATE = """
{% endfor %} {% 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 @dataclass
class BuildResult: class BuildResult:
@ -62,7 +46,7 @@ class BuildResult:
class BuildRepository: class BuildRepository:
name: str name: str
arch: str arch: str
packages: List[BuildResult] = field(default_factory=list) packages: List[BuildResult]
class Config(BaseProxyConfig): class Config(BaseProxyConfig):
@ -72,18 +56,8 @@ class Config(BaseProxyConfig):
helper.copy("instance_url") helper.copy("instance_url")
helper.copy("rebuild_token") helper.copy("rebuild_token")
helper.copy("trigger_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("username")
helper.copy("repo_aliases") helper.copy("password")
helper.copy("acl")
class OSCBot(Plugin): class OSCBot(Plugin):
@ -102,31 +76,6 @@ class OSCBot(Plugin):
def get_config_class(cls) -> Type[BaseProxyConfig]: def get_config_class(cls) -> Type[BaseProxyConfig]:
return Config 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( async def parse_rebuilpac(
self, self,
project: str, project: str,
@ -163,8 +112,7 @@ class OSCBot(Plugin):
arch: Optional[str] = None) -> List[BuildRepository]: arch: Optional[str] = None) -> List[BuildRepository]:
username = self.config["username"] username = self.config["username"]
password = cryptocode.decrypt(self.config["password"], password = self.config["password"]
self.config["secret"])
api_url = self.config["api_url"] api_url = self.config["api_url"]
api_call = f"{api_url}/build/{project}/_result" api_call = f"{api_url}/build/{project}/_result"
@ -228,124 +176,10 @@ class OSCBot(Plugin):
async def osc(self) -> None: async def osc(self) -> None:
pass 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( @osc.subcommand(
"rebuildpac", aliases=("rb",), "rebuildpac", aliases=("rb",),
help="Rebuild a package or all packages in the repositories") 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("package", "package name (or \"all\" for all packages)")
@command.argument("repository", "repository (optional)", required=False) @command.argument("repository", "repository (optional)", required=False)
@command.argument("arch", "architecture (optional)", required=False) @command.argument("arch", "architecture (optional)", required=False)
@ -355,18 +189,12 @@ class OSCBot(Plugin):
repository: Optional[str] = None, repository: Optional[str] = None,
arch: Optional[str] = None) -> None: arch: Optional[str] = None) -> None:
if not self.check_acl(evt.sender, need_admin=True): if package == "all":
await evt.reply("You are not authorized to perform this action.") package = None
return if repository == "all":
repository = None
if project in self.config["repo_aliases"]: if arch == "all":
# We're not interested in state and we query package explicitly arch = None
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, result, status = await self.parse_rebuilpac(project, package,
repository, repository,
@ -387,10 +215,6 @@ class OSCBot(Plugin):
project: str, project: str,
package: str) -> None: 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"] token = self.config["trigger_token"]
trigger_url = f"{self.config['api_url']}/trigger/runservice" trigger_url = f"{self.config['api_url']}/trigger/runservice"
params = {"project": project, "package": package} params = {"project": project, "package": package}
@ -420,18 +244,16 @@ class OSCBot(Plugin):
repository: Optional[str] = None, repository: Optional[str] = None,
arch: Optional[str] = None) -> 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 # There is no concept of non-positional arguments in maubot
# So we just use "all" in case we want to skip something # So we just use "all" in case we want to skip something
package = None if package == "all" else package if state == "all":
repository = None if repository == "all" else repository state = None
arch = None if arch == "all" else arch if package == "all":
package = None
if repository == "all":
repository = None
if arch == "all":
arch = None
response = await self.parse_status(project, package, state=state, response = await self.parse_status(project, package, state=state,
repo=repository, arch=arch) repo=repository, arch=arch)
@ -446,29 +268,8 @@ class OSCBot(Plugin):
base_url = self.config["instance_url"] 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: for repository in response:
await evt.respond(f"#### {repository.name} - {repository.arch}", body = self.template.from_string(REPO_TEMPLATE)
markdown=True) message = body.render(repo=repository, project=project,
base_url=base_url)
# 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) await evt.respond(message, markdown=True)
# Wait 200 milliseconds to avoid triggering rate limiting
sleep(0.2)