Dateien nach "/" hochladen

This commit is contained in:
2026-08-26 15:17:47 +02:00
parent ba7974d1cb
commit b5c894a389
5 changed files with 838 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
# Copy this file to .env. docker-compose.yml loads it via env_file for both
# containers, and docker compose itself also uses it to resolve ${VAR}
# references inside docker-compose.yml (e.g. NGINX_PORT, LOCAL_SOURCES_YAML).
# This file is the single place to configure everything - nothing is
# hardcoded in docker-compose.yml itself.
# ============================================================================
# Where the sources.yaml comes from
# ============================================================================
# Option A (default): local file, bind-mounted into the syncer container.
# Point LOCAL_SOURCES_YAML at your real file (path is relative to
# docker-compose.yml, defaults to the bundled sources.example.yaml) and
# leave SOURCES_GIT_REPO empty. SOURCES_YAML_FILE must then be the absolute
# in-container path the bind mount uses (leave as-is unless you also change
# the mount target in docker-compose.yml).
#LOCAL_SOURCES_YAML=./sources.example.yaml
SOURCES_GIT_REPO=https://gitea.leanderserver.de/leander19961/dnf-repo-test
SOURCES_GIT_BRANCH=main
SOURCES_YAML_FILE=packages.yaml
# Option B: pull the list from git instead. Set SOURCES_GIT_REPO, and
# SOURCES_YAML_FILE to the path of the YAML file *inside* that git repo
# (relative), e.g.:
# SOURCES_GIT_REPO=https://git.example.com/infra/rpm-sources.git
# SOURCES_GIT_BRANCH=main
# SOURCES_YAML_FILE=sources.yaml
# (LOCAL_SOURCES_YAML / the bind mount is then simply unused.)
# ============================================================================
# Paths inside the syncer container
# ============================================================================
# These map to the volumes defined in docker-compose.yml - only change them
# if you also change the corresponding volume mounts.
WORK_DIR=/var/cache/rpm-mirror-sync
REPO_BASE_DIR=/srv/repo
CLIENT_REPO_DIR=/srv/repo/client-repos
# ============================================================================
# Client-facing URL
# ============================================================================
# Public URL under which the nginx container serves the repo. Used to
# generate the .repo files under /repo/client-repos/. Point this at
# wherever the host running nginx is reachable from your AlmaLinux clients
# (should match NGINX_PORT below).
CLIENT_BASE_URL=http://repo.leanderserver.de/repo
# ============================================================================
# repoids of AlmaLinux's own repos inside the syncer container
# ============================================================================
# Check with: docker compose exec syncer dnf repolist
ALMA_REPO_IDS=baseos appstream extras crb
# ============================================================================
# Sync schedule
# ============================================================================
# How often (seconds) the syncer container re-syncs. 3600 = hourly.
SYNC_INTERVAL_SECONDS=3600
# Set to "true" to sync once and exit instead of looping (e.g. if you'd
# rather trigger this container from an external scheduler).
RUN_ONCE=false
# ============================================================================
# Mirror signing key
# ============================================================================
# Identity used for the ONE GPG key the mirror signs every mirrored package
# with (regardless of how many upstream sources there are). Generated once
# on first run - together with a revocation certificate - and reused
# forever. Both live under GPG_HOME on the sync-cache volume, so don't run
# "docker compose down -v" unless you're fine with clients having to
# re-trust a brand new key afterwards.
GPG_HOME=/var/cache/rpm-mirror-sync/gnupg
GPG_KEY_NAME=RPM Mirror
GPG_KEY_EMAIL=rpm-mirror@example.local
# ============================================================================
# Ports
# ============================================================================
# Host port nginx publishes the repo on.
NGINX_PORT=8081
+328
View File
@@ -0,0 +1,328 @@
#!/usr/bin/env python3
"""
Erzeugt einen AppStream-Katalog (Collection-XML) aus packages.yaml.
Der Katalog referenziert die gespiegelten RPMs direkt ueber <pkgname>.
Es werden keine Wrapper-Pakete gebaut. Das Ergebnis wird per
`modifyrepo_c --mdtype=appstream` in die Repo-Metadaten eingehaengt,
damit Discover / GNOME Software die Pakete findet.
Aufruf:
appstream-gen.py packages.yaml -o appstream.xml.gz \
[--available-packages vorhanden.txt] [--origin firmen-repo]
"""
import argparse
import gzip
import re
import sys
import xml.etree.ElementTree as ET
from xml.dom import minidom
import yaml
# AppStream-Spezifikationsversion des Katalogformats.
CATALOG_VERSION = "0.14"
DEFAULT_TYPE = "console-application"
DEFAULT_ICON = "application-x-executable"
DEFAULT_NOTE = (
"Bereitgestellt über das Unternehmens-Repo der IT-Abteilung. "
"Fragen und Paketwünsche bitte an den IT-Service-Desk."
)
def die(msg):
sys.stdout.flush()
print(f"[appstream-gen] FEHLER: {msg}", file=sys.stderr, flush=True)
sys.exit(1)
def warn(msg):
sys.stdout.flush()
print(f"[appstream-gen] WARNUNG: {msg}", file=sys.stderr, flush=True)
def info(msg):
print(f"[appstream-gen] {msg}", flush=True)
def sanitize_id_part(value):
"""Macht aus einem Paketnamen ein gueltiges AppStream-ID-Segment."""
part = re.sub(r"[^A-Za-z0-9]+", "-", value).strip("-")
if not part:
die(f"Paketname ergibt kein gueltiges ID-Segment: {value!r}")
if part[0].isdigit():
part = "pkg-" + part
return part
def prettify_name(pkgname):
"""Fallback-Anzeigename, wenn in der YAML keiner gesetzt ist.
dotnet-sdk-9.0 -> Dotnet Sdk 9.0
"""
words = re.split(r"[-_]", pkgname)
return " ".join(w[:1].upper() + w[1:] if w else w for w in words)
def as_list(value):
if value is None:
return []
if isinstance(value, (list, tuple)):
return list(value)
return [value]
def normalize_entry(entry, source_name):
"""Akzeptiert sowohl 'paketname' als auch ein Mapping mit Metadaten."""
if isinstance(entry, str):
return {"name": entry, "_source": source_name}
if isinstance(entry, dict):
if "name" not in entry:
die(f"Paketeintrag in Quelle {source_name!r} ohne 'name': {entry!r}")
data = dict(entry)
data["_source"] = source_name
return data
die(f"Unerwarteter Paketeintrag in Quelle {source_name!r}: {entry!r}")
def pick(entry, defaults, key, fallback=None):
"""Wert aus dem Paketeintrag, sonst aus defaults, sonst fallback."""
if key in entry and entry[key] is not None:
return entry[key]
if key in defaults and defaults[key] is not None:
return defaults[key]
return fallback
def build_component(entry, defaults, id_prefix):
pkgname = entry["name"]
comp_type = pick(entry, defaults, "type", DEFAULT_TYPE)
component = ET.Element("component", {"type": comp_type})
comp_id = entry.get("id") or f"{id_prefix}.{sanitize_id_part(pkgname)}"
ET.SubElement(component, "id").text = comp_id
ET.SubElement(component, "pkgname").text = pkgname
display_name = entry.get("display_name") or entry.get("name_display")
if not display_name:
display_name = prettify_name(pkgname)
ET.SubElement(component, "name").text = display_name
note = pick(entry, defaults, "note", DEFAULT_NOTE)
summary = entry.get("summary") or note
ET.SubElement(component, "summary").text = summary
# Beschreibung: eigener Text plus Hinweis auf die Herkunft.
description = ET.SubElement(component, "description")
body = entry.get("description")
if body:
for paragraph in [p.strip() for p in str(body).split("\n\n") if p.strip()]:
ET.SubElement(description, "p").text = paragraph
if note and note != summary:
ET.SubElement(description, "p").text = note
elif not body and note == summary:
ET.SubElement(description, "p").text = note
icon = pick(entry, defaults, "icon", DEFAULT_ICON)
if icon:
ET.SubElement(component, "icon", {"type": "stock"}).text = icon
categories = as_list(pick(entry, defaults, "categories"))
if categories:
cat_el = ET.SubElement(component, "categories")
for category in categories:
ET.SubElement(cat_el, "category").text = str(category)
keywords = as_list(entry.get("keywords"))
if keywords:
kw_el = ET.SubElement(component, "keywords")
for keyword in keywords:
ET.SubElement(kw_el, "keyword").text = str(keyword)
binaries = as_list(entry.get("binary") or entry.get("binaries"))
if binaries:
provides = ET.SubElement(component, "provides")
for binary in binaries:
ET.SubElement(provides, "binary").text = str(binary)
homepage = entry.get("homepage") or defaults.get("homepage")
if homepage:
ET.SubElement(component, "url", {"type": "homepage"}).text = str(homepage)
help_url = entry.get("help_url") or defaults.get("help_url")
if help_url:
ET.SubElement(component, "url", {"type": "help"}).text = str(help_url)
developer = entry.get("developer") or defaults.get("developer")
if developer:
# Das id-Attribut ist seit AppStream 1.0 erwartet; ohne es meldet
# appstreamcli 'developer-id-missing'.
dev_id = entry.get("developer_id") or defaults.get("developer_id")
attrs = {"id": str(dev_id)} if dev_id else {}
dev_el = ET.SubElement(component, "developer", attrs)
ET.SubElement(dev_el, "name").text = str(developer)
project_license = entry.get("project_license") or defaults.get("project_license")
if project_license:
ET.SubElement(component, "project_license").text = str(project_license)
# desktop-application braucht einen Launchable-Eintrag, sonst ignoriert
# Discover die Komponente beim Starten der installierten App.
if comp_type == "desktop-application":
desktop_id = entry.get("desktop_id")
if desktop_id:
ET.SubElement(
component, "launchable", {"type": "desktop-id"}
).text = str(desktop_id)
else:
warn(
f"{pkgname}: type=desktop-application ohne 'desktop_id'. "
"Discover kann die Anwendung dann nicht starten."
)
return comp_id, component
def load_available(path):
if not path:
return None
try:
with open(path, "r", encoding="utf-8") as handle:
names = {line.strip() for line in handle if line.strip()}
except OSError as exc:
die(f"Konnte Paketliste {path!r} nicht lesen: {exc}")
info(f"{len(names)} tatsaechlich vorhandene Pakete eingelesen")
return names
def main():
parser = argparse.ArgumentParser(
description="Erzeugt einen AppStream-Katalog aus packages.yaml"
)
parser.add_argument("packages_yaml", help="Pfad zur packages.yaml")
parser.add_argument(
"-o", "--output", default="appstream.xml.gz",
help="Zieldatei (.xml.gz oder .xml), Standard: appstream.xml.gz",
)
parser.add_argument(
"--origin",
help="Origin des Katalogs. Ueberschreibt 'appstream.origin' aus der YAML.",
)
parser.add_argument(
"--available-packages",
help="Datei mit den tatsaechlich gespiegelten Paketnamen (einer pro Zeile). "
"Nicht vorhandene Pakete werden dann uebersprungen.",
)
parser.add_argument(
"--source",
help="Nur Pakete dieser einen Quelle beruecksichtigen. Fuer Setups "
"mit einem Repo-Verzeichnis pro Quelle.",
)
parser.add_argument(
"--strict", action="store_true",
help="Mit Fehler abbrechen, wenn ein gelistetes Paket nicht vorhanden ist.",
)
args = parser.parse_args()
try:
with open(args.packages_yaml, "r", encoding="utf-8") as handle:
config = yaml.safe_load(handle) or {}
except OSError as exc:
die(f"Konnte {args.packages_yaml!r} nicht lesen: {exc}")
except yaml.YAMLError as exc:
die(f"YAML-Fehler in {args.packages_yaml!r}: {exc}")
appstream_cfg = config.get("appstream") or {}
defaults = appstream_cfg.get("defaults") or {}
origin = args.origin or appstream_cfg.get("origin") or "firmen-repo"
id_prefix = appstream_cfg.get("id_prefix") or "de.firma.repo"
# Falls kein developer_id gesetzt ist, die ersten beiden Segmente des
# id_prefix verwenden: de.firma.repo -> de.firma
if "developer_id" not in defaults or not defaults.get("developer_id"):
segments = id_prefix.split(".")
if len(segments) >= 2:
defaults = dict(defaults)
defaults["developer_id"] = ".".join(segments[:2])
available = load_available(args.available_packages)
sources = config.get("sources") or []
if not sources:
die("Keine 'sources' in der packages.yaml gefunden.")
if args.source:
sources = [s for s in sources if s.get("name") == args.source]
if not sources:
die(f"Keine Quelle mit dem Namen {args.source!r} in der YAML gefunden.")
info(f"Beschraenke auf Quelle {args.source!r}")
root = ET.Element("components", {"version": CATALOG_VERSION, "origin": origin})
seen_ids = {}
seen_pkgs = set()
skipped = []
count = 0
for source in sources:
source_name = source.get("name", "<unbenannt>")
for raw_entry in source.get("packages") or []:
entry = normalize_entry(raw_entry, source_name)
pkgname = entry["name"]
if entry.get("appstream") is False or entry.get("hidden") is True:
info(f"{pkgname}: per Konfiguration von AppStream ausgenommen")
continue
if available is not None and pkgname not in available:
skipped.append(pkgname)
continue
if pkgname in seen_pkgs:
warn(f"{pkgname}: mehrfach gelistet, spaetere Eintraege ignoriert")
continue
seen_pkgs.add(pkgname)
comp_id, component = build_component(entry, defaults, id_prefix)
if comp_id in seen_ids:
die(
f"Doppelte AppStream-ID {comp_id!r} "
f"({seen_ids[comp_id]} und {pkgname}). "
"Setze 'id:' fuer eines der beiden Pakete."
)
seen_ids[comp_id] = pkgname
root.append(component)
count += 1
if skipped:
message = (
f"{len(skipped)} gelistete Pakete sind nicht im Repo vorhanden "
f"und wurden uebersprungen: {', '.join(sorted(skipped))}"
)
if args.strict:
die(message)
warn(message)
raw = ET.tostring(root, encoding="utf-8")
pretty = minidom.parseString(raw).toprettyxml(indent=" ", encoding="UTF-8")
try:
if args.output.endswith(".gz"):
with gzip.open(args.output, "wb") as handle:
handle.write(pretty)
else:
with open(args.output, "wb") as handle:
handle.write(pretty)
except OSError as exc:
die(f"Konnte {args.output!r} nicht schreiben: {exc}")
info(f"{count} Komponente(n) nach {args.output} geschrieben (origin={origin})")
if count == 0:
warn("Katalog ist leer. Discover wird nichts anzeigen.")
if __name__ == "__main__":
main()
+35
View File
@@ -0,0 +1,35 @@
services:
syncer:
build:
# Kontext ist das Repo-Root (eine Ebene ueber dieser Datei), damit der
# Dockerfile-Pfad zur tatsaechlichen Verzeichnisstruktur passt.
dockerfile: docker/syncer/Dockerfile
image: rpm-mirror-syncer:latest
container_name: rpm-mirror-syncer
restart: unless-stopped
env_file:
- .env
volumes:
# Oeffentliche Repo-Daten, read-write. Der Syncer legt darin pkgs/ an.
- repo-data:/srv/repo
# Geklonte packages.yaml und Lockfile - ueberlebt ein Recreate.
- sync-cache:/var/cache/rpm-mirror-sync
# Lokale packages.yaml statt Git. Wenn gemountet, hat sie Vorrang und
# CONFIG_REPO_URL wird nicht mehr gebraucht.
#- ../packages.yaml:/config/packages.yaml:ro
nginx:
image: nginx:alpine
container_name: rpm-mirror-nginx
restart: unless-stopped
depends_on:
- syncer
ports:
- "${NGINX_PORT:-8080}:80"
volumes:
- repo-data:/usr/share/nginx/html/repo:ro
- ./docker/nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
volumes:
repo-data:
sync-cache:
+28
View File
@@ -0,0 +1,28 @@
server {
listen 80;
server_name _;
# Das Volume repo-data haengt unter /usr/share/nginx/html/repo.
# Der Syncer schreibt darin nach pkgs/, damit ergibt sich:
# /repo/pkgs/repodata/repomd.xml
root /usr/share/nginx/html;
location /repo/ {
autoindex on;
autoindex_exact_size off;
autoindex_localtime on;
# Bewusst KEIN try_files: das verschluckt Rechte-Fehler (EACCES) und
# macht daraus einen pauschalen 404 statt eines aussagekraeftigen 403.
}
location = /healthz {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
location = / {
return 302 /repo/pkgs/;
}
}
+367
View File
@@ -0,0 +1,367 @@
#!/usr/bin/env python3
"""
sync_helpers.py - small helper used by sync.sh
Reliable YAML parsing and XML escaping in pure bash/sed is a losing game, so
these two jobs live here:
parse-yaml <file>
Reads the "sources:" list from the YAML file, NORMALISES it and prints
it as JSON on stdout, so sync.sh can loop over it safely.
Normalisation: every entry under "packages:" becomes an object with at
least a "name" key, whether it was written as a plain string or as a
mapping with extra AppStream metadata. sync.sh can therefore always do
p["name"] without caring which form the YAML used.
gen-appstream --origin O --yaml F --entries-dir D
Builds the AppStream catalog for the combined repo.
Metadata precedence, highest first:
1. what packages.yaml says about the package
2. the upstream RPM summary collected by sync.sh into the
appstream-entries.tsv files under --entries-dir
3. a generated fallback
Only packages that actually made it into the mirror (i.e. that appear
in one of the TSV files) get a component - never pulled-in
dependencies, and never packages whose download failed.
NOTE ON DISCOVER/GNOME SOFTWARE:
Components without an icon/screenshots/.desktop file make a package
installable and findable via SEARCH, but they will not appear on the
curated "Explore" front page or when browsing categories. That is a
frontend behaviour for non-desktop-app components and cannot be fixed
at the repo-metadata level. Only a real desktop-application component
(type: desktop-application + desktop_id: in packages.yaml, which
requires the package to ship a .desktop file) shows up there.
"""
import argparse
import glob
import json
import os
import re
import sys
from xml.sax.saxutils import escape, quoteattr
CATALOG_VERSION = "0.14"
DEFAULT_NOTE = (
"Bereitgestellt über das Unternehmens-Repo der IT-Abteilung. "
"Fragen und Paketwünsche bitte an den IT-Service-Desk."
)
def die(msg):
sys.stdout.flush()
sys.stderr.write(f"ERROR: {msg}\n")
sys.exit(1)
def warn(msg):
sys.stdout.flush()
sys.stderr.write(f"WARNING: {msg}\n")
def load_yaml(path):
try:
import yaml
except ImportError:
die(
"python3-pyyaml (PyYAML) is not installed.\n"
"Install it with: dnf install -y python3-pyyaml"
)
try:
with open(path, "r", encoding="utf-8") as fh:
return yaml.safe_load(fh) or {}
except OSError as exc:
die(f"could not read {path!r}: {exc}")
except Exception as exc: # yaml.YAMLError and friends
die(f"could not parse {path!r}: {exc}")
def normalise_package(entry, source_name, index):
"""Accept both 'pkgname' and {name: pkgname, display_name: ...}."""
if isinstance(entry, str):
return {"name": entry}
if isinstance(entry, dict):
if not entry.get("name"):
die(
f"package #{index} in source {source_name!r} has no 'name' key: "
f"{entry!r}"
)
return dict(entry)
die(
f"package #{index} in source {source_name!r} must be a string or a "
f"mapping, got {type(entry).__name__}"
)
def normalise_sources(data):
sources = data.get("sources") or []
if not isinstance(sources, list):
die("'sources' in YAML is not a list")
for i, src in enumerate(sources):
if not isinstance(src, dict):
die(f"source #{i} is not a mapping")
for field in ("name", "baseurl", "packages"):
if field not in src:
die(f"source #{i} is missing required field '{field}'")
if not isinstance(src["packages"], list) or not src["packages"]:
die(
f"source '{src.get('name')}' has an empty/invalid packages list"
)
src["packages"] = [
normalise_package(p, src.get("name"), j)
for j, p in enumerate(src["packages"])
]
return sources
def cmd_parse_yaml(path):
data = load_yaml(path)
json.dump(normalise_sources(data), sys.stdout)
# ---------------------------------------------------------------------------
# AppStream catalog generation
# ---------------------------------------------------------------------------
def sanitize_id_part(value):
part = re.sub(r"[^A-Za-z0-9]+", "-", value).strip("-")
if not part:
die(f"package name yields no usable AppStream id segment: {value!r}")
if part[0].isdigit():
part = "pkg-" + part
return part
def as_list(value):
if value is None:
return []
if isinstance(value, (list, tuple)):
return list(value)
return [value]
def pick(meta, defaults, key, fallback=None):
if meta.get(key) is not None:
return meta[key]
if defaults.get(key) is not None:
return defaults[key]
return fallback
def read_entries(entries_dir):
"""Merge every source's appstream-entries.tsv into {name: summary}."""
entries = {}
pattern = os.path.join(entries_dir, "*", "appstream-entries.tsv")
for path in sorted(glob.glob(pattern)):
try:
with open(path, encoding="utf-8") as fh:
for line in fh:
line = line.rstrip("\n")
if not line:
continue
parts = line.split("\t", 1)
name = parts[0].strip()
summary = parts[1].strip() if len(parts) > 1 else ""
if not name:
continue
if name in entries and entries[name] and summary \
and entries[name] != summary:
warn(
f"package '{name}' was contributed by more than one "
"source with differing summaries - keeping the first"
)
continue
entries.setdefault(name, summary)
except OSError:
continue
return entries
def collect_metadata(yaml_path):
"""Return (defaults, {pkgname: metadata}, id_prefix, yaml_origin)."""
data = load_yaml(yaml_path)
appstream_cfg = data.get("appstream") or {}
defaults = dict(appstream_cfg.get("defaults") or {})
id_prefix = appstream_cfg.get("id_prefix") or "de.firma.repo"
yaml_origin = appstream_cfg.get("origin")
if not defaults.get("developer_id"):
segments = id_prefix.split(".")
if len(segments) >= 2:
defaults["developer_id"] = ".".join(segments[:2])
by_name = {}
for src in normalise_sources(data):
for pkg in src["packages"]:
name = pkg["name"]
if name in by_name:
warn(f"package '{name}' listed more than once - keeping the first")
continue
by_name[name] = pkg
return defaults, by_name, id_prefix, yaml_origin
def emit(out, depth, text):
out.append(" " * depth + text)
def build_component(pkgname, meta, upstream_summary, defaults, id_prefix, out):
if meta.get("appstream") is False or meta.get("hidden") is True:
return False
binaries = as_list(meta.get("binary") or meta.get("binaries"))
# A console-application component without <provides><binary> trips
# appstreamcli's 'console-app-no-binary' check, so only claim that type
# when a binary is actually declared. Runtimes and libraries stay
# 'generic', which Discover still finds via search.
comp_type = pick(meta, defaults, "type")
if not comp_type:
comp_type = "console-application" if binaries else "generic"
comp_id = meta.get("id") or f"{id_prefix}.{sanitize_id_part(pkgname)}"
display_name = meta.get("display_name") or pkgname
note = pick(meta, defaults, "note", DEFAULT_NOTE)
summary = meta.get("summary") or upstream_summary or note
emit(out, 1, f"<component type={quoteattr(comp_type)}>")
emit(out, 2, f"<id>{escape(comp_id)}</id>")
emit(out, 2, f"<pkgname>{escape(pkgname)}</pkgname>")
emit(out, 2, f"<name>{escape(display_name)}</name>")
emit(out, 2, f"<summary>{escape(summary)}</summary>")
emit(out, 2, "<description>")
body = meta.get("description")
wrote_paragraph = False
if body:
for para in [p.strip() for p in str(body).split("\n\n") if p.strip()]:
emit(out, 3, f"<p>{escape(para)}</p>")
wrote_paragraph = True
if note and (not wrote_paragraph or note != summary):
emit(out, 3, f"<p>{escape(note)}</p>")
elif not wrote_paragraph:
emit(out, 3, f"<p>{escape(summary)}</p>")
emit(out, 2, "</description>")
icon = pick(meta, defaults, "icon", "application-x-executable")
if icon:
emit(out, 2, f'<icon type="stock">{escape(str(icon))}</icon>')
categories = as_list(pick(meta, defaults, "categories"))
if categories:
emit(out, 2, "<categories>")
for cat in categories:
emit(out, 3, f"<category>{escape(str(cat))}</category>")
emit(out, 2, "</categories>")
keywords = as_list(meta.get("keywords"))
if keywords:
emit(out, 2, "<keywords>")
for kw in keywords:
emit(out, 3, f"<keyword>{escape(str(kw))}</keyword>")
emit(out, 2, "</keywords>")
if binaries:
emit(out, 2, "<provides>")
for binary in binaries:
emit(out, 3, f"<binary>{escape(str(binary))}</binary>")
emit(out, 2, "</provides>")
homepage = meta.get("homepage") or defaults.get("homepage")
if homepage:
emit(out, 2, f'<url type="homepage">{escape(str(homepage))}</url>')
help_url = meta.get("help_url") or defaults.get("help_url")
if help_url:
emit(out, 2, f'<url type="help">{escape(str(help_url))}</url>')
developer = meta.get("developer") or defaults.get("developer")
if developer:
dev_id = meta.get("developer_id") or defaults.get("developer_id")
attr = f" id={quoteattr(str(dev_id))}" if dev_id else ""
emit(out, 2, f"<developer{attr}>")
emit(out, 3, f"<name>{escape(str(developer))}</name>")
emit(out, 2, "</developer>")
project_license = meta.get("project_license") or defaults.get("project_license")
if project_license:
emit(out, 2, f"<project_license>{escape(str(project_license))}</project_license>")
# A desktop-application component that Discover cannot launch is worse
# than a generic one, so insist on the desktop file id.
if comp_type == "desktop-application":
desktop_id = meta.get("desktop_id")
if desktop_id:
emit(
out, 2,
f'<launchable type="desktop-id">{escape(str(desktop_id))}</launchable>',
)
else:
warn(
f"{pkgname}: type=desktop-application without 'desktop_id' - "
"Discover will not be able to launch it after installation"
)
emit(out, 1, "</component>")
return True
def cmd_gen_appstream(origin, yaml_path, entries_dir):
defaults, by_name, id_prefix, yaml_origin = collect_metadata(yaml_path)
entries = read_entries(entries_dir)
if not entries:
warn(
f"no appstream-entries.tsv found under {entries_dir} - the catalog "
"will be empty and nothing will show up in Discover"
)
effective_origin = origin or yaml_origin or "rpm-mirror"
out = []
out.append('<?xml version="1.0" encoding="UTF-8"?>')
out.append(
f'<components version="{CATALOG_VERSION}" origin={quoteattr(effective_origin)}>'
)
written = 0
for pkgname in sorted(entries):
meta = by_name.get(pkgname, {})
if build_component(pkgname, meta, entries[pkgname], defaults,
id_prefix, out):
written += 1
out.append("</components>")
sys.stdout.write("\n".join(out) + "\n")
sys.stderr.write(
f"gen-appstream: wrote {written} component(s), origin={effective_origin}\n"
)
def main():
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
p1 = sub.add_parser("parse-yaml")
p1.add_argument("path")
p2 = sub.add_parser("gen-appstream")
p2.add_argument("--origin", default="")
p2.add_argument("--yaml", required=True)
p2.add_argument("--entries-dir", required=True)
args = ap.parse_args()
if args.cmd == "parse-yaml":
cmd_parse_yaml(args.path)
elif args.cmd == "gen-appstream":
cmd_gen_appstream(args.origin, args.yaml, args.entries_dir)
if __name__ == "__main__":
main()