Merge pull request 'Merge testing in main: Release 1.0.0' (#7) from testing into main
Main Release & Publish / Build, Publish Packages (Stable) & Create Release (push) Successful in 9m5s

Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
2026-08-28 15:15:12 +00:00
21 changed files with 3931 additions and 165 deletions
+7
View File
@@ -0,0 +1,7 @@
target/
!target/release/mirror-package
!target/x86_64-unknown-linux-gnu/release/mirror-package
.git/
.github/
.gitea/
tests/
+31
View File
@@ -181,3 +181,34 @@ jobs:
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}" "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
echo "Asset ${filename} erfolgreich hochgeladen." echo "Asset ${filename} erfolgreich hochgeladen."
done done
- name: Build and Publish Docker Container Image (Stable)
env:
GITEA_URL: ${{ gitea.server_url || github.server_url }}
REPO: ${{ gitea.repository || github.repository }}
REPO_OWNER: ${{ gitea.repository_owner || github.repository_owner }}
REPO_NAME: ${{ gitea.repository_name || github.event.repository.name }}
TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.RELEASE_TOKEN || secrets.PUBLISH_TOKEN || secrets.API_TOKEN || secrets.PAT_TOKEN || secrets.CUSTOM_TOKEN || secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN || github.token }}
run: |
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)"
REGISTRY_HOST=$(echo "${GITEA_URL}" | sed -E 's|^https?://||; s|/.*$||')
IMAGE_NAME=$(echo "${REGISTRY_HOST}/${REPO_OWNER}/${REPO_NAME}" | tr '[:upper:]' '[:lower:]')
echo "Authentifiziere an der Container Registry ${REGISTRY_HOST}..."
echo "${TOKEN}" | docker login "${REGISTRY_HOST}" -u "${REPO_OWNER}" --password-stdin
echo "Baue Docker-Container Image (Stable)..."
docker build \
--build-arg TARGET_BIN=target/x86_64-unknown-linux-gnu/release/mirror-package \
-t "${IMAGE_NAME}:latest" \
-t "${IMAGE_NAME}:${VERSION}" \
-t "${IMAGE_NAME}:v${VERSION}" \
-t "${IMAGE_NAME}:${VERSION}.${BUILD_NUMBER}" \
.
echo "Veröffentliche Docker-Images nach ${IMAGE_NAME}..."
docker push "${IMAGE_NAME}:latest"
docker push "${IMAGE_NAME}:${VERSION}"
docker push "${IMAGE_NAME}:v${VERSION}"
docker push "${IMAGE_NAME}:${VERSION}.${BUILD_NUMBER}"
echo "Docker-Images (Stable & latest) erfolgreich veröffentlicht."
+33
View File
@@ -181,3 +181,36 @@ jobs:
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}" "${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
echo "Asset ${filename} erfolgreich hochgeladen." echo "Asset ${filename} erfolgreich hochgeladen."
done done
- name: Build and Publish Docker Container Image (Testing)
env:
GITEA_URL: ${{ gitea.server_url || github.server_url }}
REPO: ${{ gitea.repository || github.repository }}
REPO_OWNER: ${{ gitea.repository_owner || github.repository_owner }}
REPO_NAME: ${{ gitea.repository_name || github.event.repository.name }}
TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.RELEASE_TOKEN || secrets.PUBLISH_TOKEN || secrets.API_TOKEN || secrets.PAT_TOKEN || secrets.CUSTOM_TOKEN || secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN || github.token }}
run: |
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)"
REGISTRY_HOST=$(echo "${GITEA_URL}" | sed -E 's|^https?://||; s|/.*$||')
IMAGE_NAME=$(echo "${REGISTRY_HOST}/${REPO_OWNER}/${REPO_NAME}" | tr '[:upper:]' '[:lower:]')
echo "Authentifiziere an der Container Registry ${REGISTRY_HOST}..."
echo "${TOKEN}" | docker login "${REGISTRY_HOST}" -u "${REPO_OWNER}" --password-stdin
echo "Baue Docker-Container Image (Testing / Preview)..."
docker build \
--build-arg TARGET_BIN=target/x86_64-unknown-linux-gnu/release/mirror-package \
-t "${IMAGE_NAME}:testing" \
-t "${IMAGE_NAME}:${VERSION}-preview" \
-t "${IMAGE_NAME}:${VERSION}-testing" \
-t "${IMAGE_NAME}:${VERSION}-preview.${BUILD_NUMBER}" \
-t "${IMAGE_NAME}:testing-${BUILD_NUMBER}" \
.
echo "Veröffentliche Docker-Images nach ${IMAGE_NAME}..."
docker push "${IMAGE_NAME}:testing"
docker push "${IMAGE_NAME}:${VERSION}-preview"
docker push "${IMAGE_NAME}:${VERSION}-testing"
docker push "${IMAGE_NAME}:${VERSION}-preview.${BUILD_NUMBER}"
docker push "${IMAGE_NAME}:testing-${BUILD_NUMBER}"
echo "Docker-Images (Testing) erfolgreich veröffentlicht."
+28
View File
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JsonSchemaMappingsProjectConfiguration">
<state>
<map>
<entry key="GitHub Workflow">
<value>
<SchemaInfo>
<option name="name" value="GitHub Workflow" />
<option name="relativePathToSchema" value="https://www.schemastore.org/github-workflow.json" />
<option name="applicationDefined" value="true" />
<option name="patterns">
<list>
<Item>
<option name="path" value=".gitea/workflows/main.yaml" />
</Item>
<Item>
<option name="path" value=".gitea/workflows/testing.yaml" />
</Item>
</list>
</option>
</SchemaInfo>
</value>
</entry>
</map>
</state>
</component>
</project>
+1
View File
@@ -3,6 +3,7 @@
<component name="NewModuleRootManager"> <component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$"> <content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
<excludeFolder url="file://$MODULE_DIR$/target" /> <excludeFolder url="file://$MODULE_DIR$/target" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
+44 -18
View File
@@ -1,21 +1,26 @@
# AGENTS.md # AGENTS.md
Dieses Dokument dient als technischer Leitfaden und Kontextdokument für KI-Coding-Agenten (sowie Entwickler), die an diesem Repository oder daraus abgeleiteten Projekten arbeiten. Dieses Dokument dient als technischer Leitfaden und Kontextdokument für KI-Coding-Agenten (sowie Entwickler), die an `mirror-package` arbeiten.
--- ---
## 1. Projektübersicht & Philosophie ## 1. Projektübersicht & Philosophie
Dieses Repository ist ein **Rust-Projekt-Template** für Linux-Anwendungen und CLI-Tools mit Fokus auf: `mirror-package` ist eine Rust-basierte CLI-Anwendung zur automatisierten Synchronisation und Spiegelung von Linux-Paketen aus GitHub-Releases in selbstgehostete Gitea/Forgejo-Paket-Registries.
- Automatisierte Multi-Architektur-Kompilierung (`x86_64`, `aarch64`, `i686`).
- Native Paketierung für Debian (`.deb`), Fedora/RHEL (`.rpm`) und Arch Linux (`.pkg.tar.zst`). ### Kernfunktionen & Routing
- Vollständig automatisierte CI/CD-Pipelines via Gitea Actions (kompatibel mit Forgejo / GitHub Actions). - **Unterstützte Paketformate**: Debian (`.deb`), Fedora/RHEL (`.rpm`), Arch Linux (`.pkg.tar.zst`, `.pkg.tar.xz`, `.pkg.tar.gz`, `.pacman`).
- **Distributions-Routing**:
- Stabile Releases: `.deb` wird in `pool/stable/main` **und** `pool/testing/main` geladen; `.rpm` nach `rpm/stable`; Arch nach `arch/stable`.
- Pre-Releases: `.deb` wird ausschließlich nach `pool/testing/main` geladen; `.rpm` nach `rpm/testing`; Arch nach `arch/testing`.
- **Konfigurationsverwaltung**: Typensichere Speicherung über `config-ctdra`.
- **Authentifizierung**: Tokenloser Zugriff auf öffentliche GitHub-Releases (optionaler Token konfigurierbar); Gitea-Token/URL/Owner via CLI, Env-Vars oder Config.
### Standards & Tech-Stack ### Standards & Tech-Stack
- **Sprache**: Rust (Edition 2024), Python 3 (für Hilfsskripte in `scripts/`). - **Sprache**: Rust (Edition 2024), Python 3 (für Hilfsskripte in `scripts/`).
- **Rust Toolchain**: Stable. - **Rust Toolchain**: Stable.
- **Zielplattform**: Linux (GLIBC-basiert, Cross-Kompilierung für `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`, `i686-unknown-linux-gnu`). - **Zielplattform**: Linux (GLIBC-basiert, Cross-Kompilierung für `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`, `i686-unknown-linux-gnu`).
- **Lizenz**: GPL-3.0-or-later (sofern nicht im abgeleiteten Projekt anders definiert). - **Lizenz**: GPL-3.0-or-later.
--- ---
@@ -32,10 +37,23 @@ Dieses Repository ist ein **Rust-Projekt-Template** für Linux-Anwendungen und C
│ ├── get-build-number.py # Ermittelt automatisch die nächste Revisions-/Build-Nummer │ ├── get-build-number.py # Ermittelt automatisch die nächste Revisions-/Build-Nummer
│ └── package-arch.py # Erzeugt native Arch Linux .pkg.tar.zst Pakete │ └── package-arch.py # Erzeugt native Arch Linux .pkg.tar.zst Pakete
├── src/ ├── src/
── main.rs # Einstiegspunkt der Anwendung ── main.rs # Einstiegspunkt der Anwendung & Befehlsausführung
│ ├── lib.rs # Bibliotheks-Wurzel & Modulexporte
│ ├── cli.rs # Clap CLI-Parser & Subcommands
│ ├── config.rs # Konfigurationsmodelle & config-ctdra Anbindung
│ ├── gitea.rs # Gitea Package API Client & Routing-Logik
│ ├── github.rs # GitHub API Client & Paket-Klassifizierung
│ └── pipeline.rs # Synchronisations-Pipeline & Tempfile-Management
├── tests/
│ ├── config_tests.rs # Tests für Konfigurationsverwaltung & Pfade
│ ├── gitea_tests.rs # Tests für Gitea Routing & Endpunkte
│ └── github_tests.rs # Tests für Paket-Klassifizierung & Repo-Parsing
├── .dockerignore # Ausschlussregeln für Container-Builds
├── Dockerfile # Minimales & gehärtetes Runtime-Container-Image
├── docker-compose.example.yml # Beispielkonfiguration für Docker Compose
├── Cargo.toml # Projekt-Manifest & Metadaten für deb, rpm und arch ├── Cargo.toml # Projekt-Manifest & Metadaten für deb, rpm und arch
├── LICENSE # Lizenztext ├── LICENSE # Lizenztext
├── README.md # Benutzerdokumentation & Setup-Checkliste ├── README.md # Benutzerdokumentation
└── AGENTS.md # Dieses Agenten-Handbuch └── AGENTS.md # Dieses Agenten-Handbuch
``` ```
@@ -45,9 +63,9 @@ Dieses Repository ist ein **Rust-Projekt-Template** für Linux-Anwendungen und C
### 3.1 Code-Stil & Best Practices ### 3.1 Code-Stil & Best Practices
- **Idiomatisches Rust**: Nutze moderne Sprachfeatures der Rust Edition 2024. - **Idiomatisches Rust**: Nutze moderne Sprachfeatures der Rust Edition 2024.
- **Fehlerbehandlung**: Verwende aussagekräftige Fehlertypen (z. B. mit `thiserror` oder `anyhow` für CLIs). Vermeide unnötiges `unwrap()` oder `panic!()` im Produktivcode. - **Fehlerbehandlung**: Verwende aussagekräftige Fehlertypen (`anyhow` für CLI und Pipeline-Fehler). Vermeide `unwrap()` oder `panic!()` im Produktivcode.
- **Kommentare**: Ergänze KDoc/RustDoc-Kommentare (`///`) an öffentlichen Funktionen und Typen. Behalte die bestehende Sprachkonvention bei. - **Kommentare**: Ergänze KDoc/RustDoc-Kommentare (`///`) an öffentlichen Funktionen und Typen.
- **Template-TODOs**: Wenn neue Vorlagen-Features oder Platzhalter ergänzt werden, markiere anpassungsbedürftige Stellen eindeutig mit `// TODO:` (Rust), `# TODO:` (TOML/Python/YAML). - **Logging**: Nutze `logger-ctdra` für strukturierte Konsolen- und Datei-Logs.
### 3.2 Paketierungs-Metadaten in `Cargo.toml` ### 3.2 Paketierungs-Metadaten in `Cargo.toml`
Bei Änderungen an Binärnamen, Abhängigkeiten oder Beschreibungen müssen die drei Metadaten-Blöcke in `Cargo.toml` synchron gehalten werden: Bei Änderungen an Binärnamen, Abhängigkeiten oder Beschreibungen müssen die drei Metadaten-Blöcke in `Cargo.toml` synchron gehalten werden:
@@ -59,15 +77,23 @@ Bei Änderungen an Binärnamen, Abhängigkeiten oder Beschreibungen müssen die
- `pkgrel`, `arch`, `depends`, `optdepends`. - `pkgrel`, `arch`, `depends`, `optdepends`.
### 3.3 Skripte in `scripts/` ### 3.3 Skripte in `scripts/`
- **Generizität**: Die Skripte dürfen keine hardcodierten Anwendungsnamen, spezifischen Abhängigkeiten oder projektspezifischen URLs enthalten. Alle Werte müssen dynamisch aus `Cargo.toml` (via `cargo metadata` oder Dateiparsing) oder Umgebungsvariablen (`BUILD_NUMBER`, `GITEA_URL`, `REPO`, `TOKEN`) ermittelt werden. - **Generizität**: Die Skripte dürfen keine hardcodierten Anwendungsnamen, spezifischen Abhängigkeiten oder projektspezifischen URLs enthalten. Alle Werte müssen dynamisch aus `Cargo.toml` oder Umgebungsvariablen (`BUILD_NUMBER`, `GITEA_URL`, `REPO`, `TOKEN`) ermittelt werden.
- **Python-Kompatibilität**: Verwende Standard-Python 3 ohne externe PyPI-Abhängigkeiten (nur Standardbibliothek: `json`, `subprocess`, `urllib`, `argparse`, `os`, `re`, `tempfile`, `tarfile` etc.). - **Python-Kompatibilität**: Verwende Standard-Python 3 ohne externe PyPI-Abhängigkeiten (nur Standardbibliothek).
### 3.4 Container-Sicherheit & Persistenz
- **Sicherheitsrichtlinien**:
- Unprivilegierter Benutzer: Der Container läuft unter `appuser` (UID `10001`, GID `10001`).
- Read-Only Rootfs: Kompatibel mit `--read-only` bzw. `read_only: true`.
- Berechtigungen: Alle Linux-Capabilities können gedroppt werden (`--cap-drop=ALL`), `no-new-privileges: true`.
- **Persistente Pfade**:
- `/home/appuser/.config/mirror-package`: Speichert Konfiguration (`config.toml`).
- `/home/appuser/.local/state/mirror-package`: Speichert Logdateien & Status.
- `/tmp`: Temporärer Puffer beim Herunterladen von Paketen (idealerweise via `tmpfs`).
--- ---
## 4. Workflows: Bauen, Testen & Validieren ## 4. Workflows: Bauen, Testen & Validieren
Agenten müssen Änderungen vor dem Abschluss validieren.
### 4.1 Grundlegende Validierung ### 4.1 Grundlegende Validierung
```bash ```bash
# Syntax- und Typprüfung # Syntax- und Typprüfung
@@ -94,7 +120,7 @@ python3 scripts/package-arch.py --arch x86_64 --pkgrel 1
## 5. CI/CD-Pipeline Details ## 5. CI/CD-Pipeline Details
- **Trigger**: - **Trigger**:
- `push` auf `main`: Baut Binaries für alle 3 Architekturen, baut `.deb`, `.rpm` und `.pkg.tar.zst`, lädt sie in die Gitea Package Registry hoch und erstellt ein Gitea Release `v<VERSION>`. - `push` auf `main`: Baut Binaries für alle 3 Architekturen, baut `.deb`, `.rpm` und `.pkg.tar.zst`, lädt sie in die Gitea Package Registry hoch, erstellt ein Gitea Release `v<VERSION>` und baut/veröffentlicht das Docker-Container-Image mit den Tags `:latest`, `:<VERSION>`, `:v<VERSION>` und `:<VERSION>.<BUILD_NUMBER>`.
- `push` auf `testing`: Baut Binaries & Pakete für den `testing`-Kanal und erstellt ein Pre-Release `v<VERSION>-preview`. - `push` auf `testing`: Baut Binaries & Pakete für den `testing`-Kanal, erstellt ein Pre-Release `v<VERSION>-preview` und baut/veröffentlicht das Docker-Container-Image ausschließlich mit eindeutigen Testing-Tags (`:testing`, `:<VERSION>-preview`, `:<VERSION>-testing`, `:<VERSION>-preview.<BUILD_NUMBER>`, `:testing-<BUILD_NUMBER>`). Der Tag `:latest` ist strikt dem `main`-Workflow vorbehalten.
- **Secrets**: - **Secrets**:
- `PACKAGE_TOKEN` (bzw. Fallback-Token-Namen wie `RELEASE_TOKEN`, `GITEA_TOKEN`) wird für API-Zugriffe auf Gitea Packages und Releases verwendet. - `PACKAGE_TOKEN` (bzw. Fallback-Token-Namen wie `RELEASE_TOKEN`, `GITEA_TOKEN`) wird für API-Zugriffe auf Gitea Packages, Container Registry und Releases verwendet.
Generated
+2125 -2
View File
File diff suppressed because it is too large Load Diff
+33 -23
View File
@@ -1,15 +1,22 @@
[package] [package]
name = "rust-template" # TODO: Projektnamen setzen name = "mirror-package"
version = "0.0.0" # TODO: Initiale Version setzen (z. B. 0.1.0) version = "1.0.0"
edition = "2024" edition = "2024"
authors = ['DragonSlayer_14'] # TODO: Autor(en) anpassen authors = ['DragonSlayer_14']
readme = "README.md" readme = "README.md"
license = "GPL-3.0-or-later" # TODO: Lizenz prüfen / anpassen license = "GPL-3.0-or-later"
repository = "https://gitea.creative-dragonslayer.de/Linuxapps/rust-template" # TODO: Repository-URL anpassen repository = "https://gitea.creative-dragonslayer.de/Linuxapps/MirrorPackage"
description = "" # TODO: Kurze Projektbeschreibung setzen description = "Spiegelt vorkompilierte Linux-Pakete aus GitHub-Releases in Gitea-Paket-Registries"
[dependencies] [dependencies]
# TODO: Projektabhängigkeiten hier hinzufügen config-ctdra = { version = "1.0.4", registry = "gitea" }
logger-ctdra = { version = "1.0.3", registry = "gitea" }
clap = { version = "4.6.6", features = ["derive", "env"] }
reqwest = { version = "0.13.4", features = ["json", "stream"] }
tokio = { version = "1.53.1", features = ["full"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
anyhow = "1.0.104"
[profile.release] [profile.release]
debug = "none" debug = "none"
@@ -17,28 +24,31 @@ debug = "none"
# --- Paketierungs-Metadaten für Linux-Distributionen --- # --- Paketierungs-Metadaten für Linux-Distributionen ---
[package.metadata.deb] [package.metadata.deb]
name = "" # TODO: Optional (Standard: package.name) name = "mirror-package"
maintainer = "" # TODO: Maintainer setzen (z. B. "Max Mustermann <max@example.com>") maintainer = "DragonSlayer_14"
copyright = "" # TODO: Copyright-Hinweis setzen (z. B. "2026 Max Mustermann") copyright = "2026 DragonSlayer_14"
section = "utils" # TODO: Debian-Sektion anpassen (z. B. utils, net, admin) section = "utils"
priority = "optional" # TODO: Debian-Priorität anpassen (z. B. optional) priority = "optional"
depends = "$auto" # TODO: Abhängigkeiten definieren ($auto ermittelt via ldd automatisch) depends = "$auto, ca-certificates"
extended-description = """\ extended-description = """\
""" # TODO: Ausführliche Paketbeschreibung setzen Spiegelt vorkompilierte Linux-Pakete (.deb, .rpm, .pkg.tar.zst) aus GitHub-Releases in Gitea-Paket-Registries.\
"""
assets = [ assets = [
# TODO: Bei Bedarf zusätzliche Dateien / Assets definieren, z. B.: ["target/release/mirror-package", "usr/bin/mirror-package", "755"],
# ["target/release/rust-template", "usr/bin/", "755"], ["README.md", "usr/share/doc/mirror-package/README.md", "644"],
["LICENSE", "usr/share/doc/mirror-package/copyright", "644"],
] ]
[package.metadata.generate-rpm] [package.metadata.generate-rpm]
assets = [ assets = [
# TODO: Bei Bedarf zusätzliche Dateien / Assets definieren, z. B.: { source = "target/release/mirror-package", dest = "/usr/bin/mirror-package", mode = "755" },
# { source = "target/release/rust-template", dest = "/usr/bin/rust-template", mode = "755" }, { source = "README.md", dest = "/usr/share/doc/mirror-package/README.md", mode = "644", doc = true },
{ source = "LICENSE", dest = "/usr/share/licenses/mirror-package/LICENSE", mode = "644", license = true },
] ]
requires = { } # TODO: RPM-Abhängigkeiten definieren, z. B. { "glibc" = "*" } requires = { "ca-certificates" = "*", "glibc" = "*" }
[package.metadata.arch] [package.metadata.arch]
pkgrel = "1" # TODO: Standard Release-Nummer (wird in CI durch get-build-number.py dynamisch überschrieben) pkgrel = "1"
arch = "x86_64" # TODO: Zielarchitektur (z. B. x86_64, aarch64, i686) arch = "x86_64"
depends = ["gcc-libs", "glibc"] # TODO: Arch Linux Paketabhängigkeiten definieren depends = ["gcc-libs", "glibc", "ca-certificates"]
optdepends = [] # TODO: Optionale Abhängigkeiten definieren (z. B. ["curl: für Web-Downloads"]) optdepends = []
+37
View File
@@ -0,0 +1,37 @@
# syntax=docker/dockerfile:1
# Minimales und gehärtetes Runtime-Image
FROM debian:bookworm-slim AS runtime
# CA-Zertifikate und minimale dynamische Laufzeitbibliotheken installieren
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates \
libssl3 \
zlib1g \
libzstd1 && \
rm -rf /var/lib/apt/lists/* /var/cache/apt/* && \
groupadd -g 10001 appuser && \
useradd -u 10001 -g appuser -d /home/appuser -m -s /usr/sbin/nologin appuser && \
mkdir -p /home/appuser/.config/mirror-package \
/home/appuser/.local/state/mirror-package \
/home/appuser/.local/share/mirror-package \
/etc/mirror-package && \
chown -R appuser:appuser /home/appuser /etc/mirror-package
# Build-Argument mit Verweis auf die vorkompilierte Binary
ARG TARGET_BIN=target/release/mirror-package
COPY ${TARGET_BIN} /usr/local/bin/mirror-package
# Strikte Ausführungsrechte setzen
RUN chmod 0755 /usr/local/bin/mirror-package
# Als unprivilegierter Benutzer ausführen
USER 10001:10001
WORKDIR /home/appuser
# Volume-Mount-Punkte für Konfiguration und persistenten Status / Logs exponieren
VOLUME ["/home/appuser/.config/mirror-package", "/home/appuser/.local/state/mirror-package"]
ENTRYPOINT ["/usr/local/bin/mirror-package"]
CMD ["--help"]
+217 -112
View File
@@ -1,148 +1,253 @@
# rust-template # mirror-package
Ein modernes Template-Projekt für Rust-basierte Linux-Anwendungen und Kommandozeilen-Tools (CLI). Automatisiertes Werkzeug zum Extrahieren, Herunterladen und Spiegeln vorkompilierter Linux-Pakete aus GitHub-Releases in eine selbstgehostete Gitea- / Forgejo-Paket-Registry.
Dieses Template bietet eine vorkonfigurierte Umgebung für modernes Rust (Edition 2024), automatisierte Multi-Architektur-Kompilierung und native Paketierung für die gängigsten Linux-Distributionen (Debian/Ubuntu, Fedora/RHEL, Arch Linux) sowie vollständige CI/CD-Pipelines für Gitea Actions (kompatibel mit Forgejo und GitHub Actions). `mirror-package` überwacht konfigurierte GitHub-Repositories (wie z. B. `raspberrypi/rpi-imager` oder `Heroic-Games-Launcher/HeroicGamesLauncher`), identifiziert vorkompilierte Linux-Pakete (`.deb`, `.rpm`, `.pkg.tar.zst`, `.pkg.tar.xz`, `.pkg.tar.gz`, `.pacman`) und veröffentlicht diese anhand von Release-Stabilitätsregeln automatisch in den passenden Distributionen der Gitea-Paket-Registry.
--- ---
## Features ## Funktionen
- **Rust Edition 2024**: Moderner Rust-Sprachstandard. - **Unterstützung mehrerer Distributionen**:
- **Multi-Architektur-Kompilierung**: - **Debian / Ubuntu** (`.deb`)
- `x86_64-unknown-linux-gnu` (64-Bit x86) - **Fedora / RHEL / openSUSE** (`.rpm`)
- `aarch64-unknown-linux-gnu` (64-Bit ARM / ARM64) - **Arch Linux** (`.pkg.tar.zst`, `.pkg.tar.xz`, `.pkg.tar.gz`, `.pacman`)
- `i686-unknown-linux-gnu` (32-Bit x86) - **Distributions-Routing-Regeln**:
- **Linux-Paketierung out-of-the-box**: - **Stabile Releases**: Debian-Pakete werden **sowohl** im `stable`- als auch im `testing`-Debian-Pool veröffentlicht (`pool/stable/main` und `pool/testing/main`); RPM-Pakete unter `rpm/stable`; Arch-Pakete unter `arch/stable`.
- **Debian / Ubuntu** (`.deb` via `cargo-deb`) - **Pre-Releases**: Werden **ausschließlich** in Testing-Kanälen veröffentlicht: Debian `pool/testing/main`, RPM `rpm/testing` und Arch `arch/testing`.
- **Fedora / RHEL / openSUSE** (`.rpm` via `cargo-generate-rpm`) - **Persistente Konfiguration über `config-ctdra`**:
- **Arch Linux** (`.pkg.tar.zst` via mitgeliefertem `scripts/package-arch.py`) - Speichert Einstellungen automatisch im Benutzerpfad (`~/.config/mirror-package/config.toml`) oder Systempfad (`/etc/mirror-package/config.toml`).
- **Automatisierte CI/CD-Pipelines**: - Das Hinzufügen oder Entfernen von Repositories über die CLI aktualisiert automatisch die Konfigurationsdatei.
- `main`-Branch: Erstellt stabile Builds, ermittelt dynamisch Build-Nummern, paketiert für alle Architekturen, lädt Pakete in die Gitea Package Registry und erstellt Gitea Releases mit Dateianhängen. - **Flexible Authentifizierung**:
- `testing`-Branch: Erstellt Preview-Builds und Pre-Releases mit Testing-Paketen. - GitHub-Releases können anonym abgefragt werden (kein Token erforderlich).
- **Automatisierte Versions- & Build-Nummern**: - Optionale Unterstützung für GitHub Personal Access Tokens zur Vermeidung von API-Rate-Limits.
- `scripts/get-build-number.py` fragt Gitea Releases, die Package Registry sowie lokale Artefakte ab, um Revisions-/Release-Nummern (z. B. `1.0.0-1`, `1.0.0-2`) automatisch zu erhöhen. - Gitea-Instanz-URL, API-Token und Registry-Owner können per CLI-Flags, Umgebungsvariablen oder Konfigurationsdatei festgelegt werden.
- **Vorkonfigurierte Cargo-Einstellungen**: - **Diagnose & Dry-Run-Modus**:
- `.cargo/config.toml` mit vorkonfigurierten Cross-Compilation-Linkern und Unterstützung für private/öffentliche Cargo Registries. - Einheitliches Datei- und Konsolen-Logging über `logger-ctdra`.
- `--dry-run`-Flag zur Simulation von Download- und Upload-Schritten ohne Änderungen an Remote-Systemen vorzunehmen.
--- ---
## Struktur des Repositories ## CLI-Verwendung & Befehle
### 1. Konfiguration (`config`)
Richte deine Gitea-Zugangsdaten und das optionale GitHub-Token ein:
```bash
# Gitea-Instanz und Registry-Owner konfigurieren
mirror-package config set \
--gitea-url "https://gitea.creative-dragonslayer.de" \
--gitea-token "dein_gitea_api_token" \
--registry-owner "Linuxapps"
# Optional: GitHub-Token für höhere API-Rate-Limits konfigurieren
mirror-package config set --github-token "ghp_xxxxxxxxxxxx"
# Aktuelle Konfiguration anzeigen
mirror-package config show
```
Zugangsdaten können auch über Umgebungsvariablen (`GITEA_URL`, `GITEA_TOKEN`, `REGISTRY_OWNER`, `GITHUB_TOKEN`) oder globale CLI-Argumente übergeben werden.
### 2. Überwachte Repositories verwalten (`add`, `remove`, `list`)
```bash
# Repositories zur persistenten Konfiguration hinzufügen
mirror-package add raspberrypi/rpi-imager
mirror-package add Heroic-Games-Launcher/HeroicGamesLauncher
# Repository ohne Synchronisierung von Pre-Releases hinzufügen
mirror-package add some-owner/some-repo --no-prereleases
# Konfigurierte Repositories und deren Sync-Status auflisten
mirror-package list
# Ein Repository entfernen
mirror-package remove raspberrypi/rpi-imager
```
### 3. Pakete synchronisieren (`sync`)
```bash
# Alle konfigurierten Repositories synchronisieren (neueste stabile Version und neuestes Pre-Release)
mirror-package sync
# Ein bestimmtes Repository synchronisieren
mirror-package sync raspberrypi/rpi-imager
# Alle historischen Releases eines Repositories synchronisieren
mirror-package sync raspberrypi/rpi-imager --history
# Dry-Run-Test (Simulation ohne Upload)
mirror-package sync --dry-run
```
---
## Docker & Container-Nutzung
`mirror-package` wird als minimales, gehärtetes Container-Image in der Gitea Container Registry bereitgestellt.
### 1. Image-Registry & Tagging-Strategie
- **Stabile Releases (`main`-Branch)**:
- `<registry>/<owner>/mirror-package:latest`
- `<registry>/<owner>/mirror-package:<version>`
- `<registry>/<owner>/mirror-package:v<version>`
- `<registry>/<owner>/mirror-package:<version>.<build_number>`
- **Testing- & Vorschau-Releases (`testing`-Branch)**:
- `<registry>/<owner>/mirror-package:testing`
- `<registry>/<owner>/mirror-package:<version>-preview`
- `<registry>/<owner>/mirror-package:<version>-testing`
- `<registry>/<owner>/mirror-package:<version>-preview.<build_number>`
- `<registry>/<owner>/mirror-package:testing-<build_number>`
- *(Hinweis: Der Tag `:latest` ist strikt dem stabilen `main`-Workflow vorbehalten).*
### 2. Persistente Speicherpfade
Um Konfigurationen, Status und Protokolle über Container-Neustarts hinweg beizubehalten, binde folgende Pfade an Host-Verzeichnisse oder Docker-Volumes:
| Container-Pfad | Zweck | Empfohlener Mount-Typ |
|---------------------------------------------|-------------------------------------------------------|------------------------------------|
| `/home/appuser/.config/mirror-package` | Speichert `config.toml` (Zugangsdaten & Repo-Liste) | Host-Verzeichnis / Volume (rw) |
| `/home/appuser/.local/state/mirror-package` | Persistente Anwendungsprotokolle & Statusdateien | Host-Verzeichnis / Volume (rw) |
| `/tmp` | Download- & Streaming-Puffer für Paketdateien | `tmpfs` (RAM / tempfs, rw, noexec) |
### 3. Sicherheitshärtung
Der Container wurde nach höchsten Sicherheitsstandards aufgebaut:
- **Unprivilegierter Benutzer**: Läuft als `appuser` (UID `10001`, GID `10001`), niemals als `root`.
- **Read-Only Root-Dateisystem**: Voll funktionsfähig mit `--read-only` / `read_only: true`.
- **Keine Capabilities**: Sämtliche Linux-Capabilities können sicher entzogen werden (`--cap-drop=ALL`).
- **Keine Rechteausweitung**: Erzwingt `no-new-privileges:true`.
- **Minimale Image-Größe**: Basiert auf Debian Bookworm Slim und enthält nur CA-Zertifikate und notwendige dynamische Bibliotheken (~40 MB).
### 4. Ausführung über Docker CLI
```bash
# Zugangsdaten initialisieren / konfigurieren
docker run --rm \
--name mirror-package \
--read-only \
--cap-drop=ALL \
--security-opt=no-new-privileges:true \
--tmpfs /tmp:rw,noexec,nosuid,size=2G \
-v "./config:/home/appuser/.config/mirror-package" \
-v "./state:/home/appuser/.local/state/mirror-package" \
gitea.creative-dragonslayer.de/linuxapps/mirror-package:latest \
config set \
--gitea-url "https://gitea.creative-dragonslayer.de" \
--gitea-token "dein_token" \
--registry-owner "Linuxapps"
# Repositories hinzufügen
docker run --rm \
--read-only \
--cap-drop=ALL \
--security-opt=no-new-privileges:true \
--tmpfs /tmp:rw,noexec,nosuid,size=2G \
-v "./config:/home/appuser/.config/mirror-package" \
-v "./state:/home/appuser/.local/state/mirror-package" \
gitea.creative-dragonslayer.de/linuxapps/mirror-package:latest \
add raspberrypi/rpi-imager
# Synchronisation ausführen (kann über Cronjob oder Scheduler auf dem Host getriggert werden)
docker run --rm \
--read-only \
--cap-drop=ALL \
--security-opt=no-new-privileges:true \
--tmpfs /tmp:rw,noexec,nosuid,size=2G \
-v "./config:/home/appuser/.config/mirror-package" \
-v "./state:/home/appuser/.local/state/mirror-package" \
gitea.creative-dragonslayer.de/linuxapps/mirror-package:latest \
sync
```
### 5. Docker Compose Beispiel (`docker-compose.yml`)
```yaml
services:
mirror-package:
image: gitea.creative-dragonslayer.de/linuxapps/mirror-package:latest
container_name: mirror-package
user: "10001:10001"
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
volumes:
- ./config:/home/appuser/.config/mirror-package
- ./state:/home/appuser/.local/state/mirror-package
tmpfs:
- /tmp:rw,noexec,nosuid,size=2G
environment:
- GITEA_URL=https://gitea.creative-dragonslayer.de
- GITEA_TOKEN=dein_gitea_api_token
- REGISTRY_OWNER=Linuxapps
- GITHUB_TOKEN=dein_optionaler_github_pat
- LOG_LEVEL=info
command: ["sync"]
```
---
## Repository-Struktur
```text ```text
├── .cargo/ ├── .cargo/
│ └── config.toml # Cargo-Konfiguration (Linker für Cross-Compiling, Registry) │ └── config.toml # Linker- & Cargo-Konfiguration
├── .gitea/ ├── .gitea/
│ └── workflows/ │ └── workflows/
│ ├── main.yaml # CI/CD-Workflow für stabile Releases (main-Branch) │ ├── main.yaml # CI/CD: Release, Pakete & Container (Stable)
│ └── testing.yaml # CI/CD-Workflow für Pre-Releases (testing-Branch) │ └── testing.yaml # CI/CD: Preview, Pakete & Container (Testing)
├── scripts/ ├── scripts/
│ ├── get-build-number.py # Dynamische Ermittlung der nächsten Paket-Revisionsnummer │ ├── get-build-number.py # Dynamische Ermittlung der Build-/Revisionsnummer
│ └── package-arch.py # Erstellung von Arch Linux .pkg.tar.zst Paketen │ └── package-arch.py # Erstellung nativer Arch Linux-Pakete
├── src/ ├── src/
── main.rs # Quellcode & Einstiegspunkt der Anwendung ── main.rs # CLI-Einstiegspunkt & Befehlsausführung
├── Cargo.toml # Cargo Manifest & Paketierungsmetadaten (deb, rpm, arch) │ ├── lib.rs # Bibliotheks-Wurzel & Modulexporte
├── LICENSE # Lizenzdatei (Standard: GPL-3.0-or-later) │ ├── cli.rs # Clap-CLI-Argumente & Optionen
├── AGENTS.md # Richtlinien und Leitfaden für KI-Coding-Agenten │ ├── config.rs # config-ctdra Anbindung & Konfigurationsmodelle
│ ├── gitea.rs # Gitea-Paket-Registry Upload-Client
│ ├── github.rs # GitHub-API-Client & Paket-Klassifizierung
│ └── pipeline.rs # End-to-End-Synchronisationspipeline & temporärer Speicher
├── tests/
│ ├── config_tests.rs # Tests für die Konfigurationsverwaltung
│ ├── gitea_tests.rs # Tests für das Gitea-Upload-Routing
│ └── github_tests.rs # Tests für Paket-Klassifizierung & Parsing
├── .dockerignore # Ausschlussregeln für Container-Builds
├── Dockerfile # Gehärtetes, minimales Runtime-Container-Image
├── docker-compose.example.yml # Beispielkonfiguration für Docker Compose
├── Cargo.toml # Projekt-Manifest und Paketierungs-Metadaten
├── LICENSE # GPL-3.0-or-later Lizenztext
├── AGENTS.md # Agenten- & Entwickler-Richtlinien
└── README.md # Projektdokumentation └── README.md # Projektdokumentation
``` ```
--- ---
## Checkliste zur Verwendung als Template ## Lokale Entwicklung & Bauen
Wenn du ein neues Projekt aus diesem Template erstellst, gehe folgende Schritte durch:
1. **`Cargo.toml` anpassen**:
- `name`: Den Namen deiner Anwendung setzen.
- `version`: Initiale Version festlegen (z. B. `0.1.0`).
- `authors`, `repository`, `description`, `license`: Projektdaten eintragen.
- Paketierungsabschnitte prüfen:
- `[package.metadata.deb]`: `maintainer`, `copyright`, `section`, `extended-description` setzen.
- `[package.metadata.generate-rpm]`: `requires` anpassen.
- `[package.metadata.arch]`: `depends` und `optdepends` anpassen.
2. **`src/` implementieren**:
- Eigene Anwendungslogik in `src/main.rs` (bzw. Modulen / `src/lib.rs`) implementieren.
- Tests in `src/` oder `tests/` ergänzen.
3. **`.cargo/config.toml` prüfen**:
- Falls crates.io statt einer privaten Registry genutzt werden soll, den Standard-Registry-Eintrag anpassen oder auskommentieren.
4. **CI/CD Secrets konfigurieren**:
- In den Repository-Einstellungen von Gitea/Forgejo ein Secret `PACKAGE_TOKEN` (bzw. `GITEA_TOKEN`) mit Rechten für Pakete und Releases hinterlegen.
5. **Dokumentation aktualisieren**:
- `README.md` an die konkrete Funktionsweise deiner Anwendung anpassen.
---
## Lokale Entwicklung
### Voraussetzungen ### Voraussetzungen
- **Rust & Cargo** (Stable Toolchain, Edition 2024 unterstützt)
- **Python 3** (für Paketierungsskripte)
- **Rust & Cargo** (aktuelle Stable-Version, Edition 2024 unterstützt) ### Befehle zum Bauen & Testen
- **Python 3** (für Hilfsskripte in `scripts/`)
- Für Cross-Compilation (optional):
- `rustup target add x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu i686-unknown-linux-gnu`
- Cross-Toolchains: `gcc-aarch64-linux-gnu`, `gcc-i686-linux-gnu`
- Für lokales Paketieren (optional):
- `cargo install cargo-deb cargo-generate-rpm`
### Wichtige Befehle
```bash ```bash
# Projekt bauen # Syntax- und Typprüfung ausführen
cargo build cargo check
# Tests ausführen # Unit-Tests ausführen
cargo test cargo test
# Release-Build erstellen # Release-Binary kompilieren
cargo build --release cargo build --release
# Cross-Compilation (Beispiel AArch64 / ARM64)
cargo build --release --target aarch64-unknown-linux-gnu
# Debian-Paket bauen (.deb)
cargo deb
# RPM-Paket bauen (.rpm)
cargo generate-rpm
# Arch Linux-Paket bauen (.pkg.tar.zst)
python3 scripts/package-arch.py --arch x86_64 --pkgrel 1
``` ```
--- ---
## Hilfsskripte
### `scripts/get-build-number.py`
Ermittelt dynamisch die nächste Build-/Paketnummer (pkgrel / revision).
- Liest Name und Version automatisch aus `Cargo.toml`.
- Fragt Gitea Releases und Package Registry ab, falls Token/URL vorhanden.
- Berücksichtigt lokal im Verzeichnis `target/` liegende Pakete.
- Aufruf: `python3 scripts/get-build-number.py`
### `scripts/package-arch.py`
Erstellt native Arch Linux-Pakete (`.pkg.tar.zst`), ohne dass `makepkg` oder eine Arch Linux-Installation erforderlich ist.
- Liest Konfiguration aus `[package.metadata.arch]` in `Cargo.toml`.
- Installiert die Binary nach `/usr/bin/`, sowie `LICENSE` und `README.md` nach `/usr/share/`.
- Erzeugt eine standardkonforme `.PKGINFO`-Datei und komprimiert das Paket mit `zstandard`.
- Parameter:
- `--target`: Rust Target-Triple (z. B. `x86_64-unknown-linux-gnu`)
- `--arch`: Zielarchitektur (z. B. `x86_64`, `aarch64`, `i686`)
- `--pkgrel`: Release-Nummer
---
## CI/CD Workflow-Übersicht
| Branch | Workflow | Paket-Kanal | Release-Typ |
| :--- | :--- | :--- | :--- |
| `main` | `.gitea/workflows/main.yaml` | `stable` | Offizielles Release (`v<VERSION>`) |
| `testing` | `.gitea/workflows/testing.yaml` | `testing` | Pre-Release (`v<VERSION>-preview`) |
---
## Lizenz ## Lizenz
Dieses Template steht standardmäßig unter der [GPL-3.0-or-later](LICENSE)-Lizenz. Die Lizenz kann bei Bedarf in `LICENSE` und `Cargo.toml` angepasst werden. Dieses Projekt ist unter der [GPL-3.0-or-later](LICENSE)-Lizenz lizenziert.
+24
View File
@@ -0,0 +1,24 @@
services:
mirror-package:
image: gitea.creative-dragonslayer.de/linuxapps/mirror-package:latest
container_name: mirror-package
# Sicherheit: Als unprivilegierter Benutzer ausführen, Read-Only-Dateisystem, Capabilities entziehen
user: "10001:10001"
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
# Persistenter Speicher und temporärer Streaming-Puffer
volumes:
- ./config:/home/appuser/.config/mirror-package
- ./state:/home/appuser/.local/state/mirror-package
tmpfs:
- /tmp:rw,noexec,nosuid,size=2G
environment:
- GITEA_URL=https://gitea.creative-dragonslayer.de
- GITEA_TOKEN=dein_gitea_api_token
- REGISTRY_OWNER=Linuxapps
- GITHUB_TOKEN=dein_optionaler_github_pat
- LOG_LEVEL=info
command: ["sync"]
+152
View File
@@ -0,0 +1,152 @@
use clap::{Args, Parser, Subcommand, ValueEnum};
use std::path::PathBuf;
/// Befehlszeilen-Parser für mirror-package.
#[derive(Parser, Debug)]
#[command(
name = "mirror-package",
author,
version,
about = "Spiegelt Linux-Pakete (.deb, .rpm, .pkg.tar.zst) aus GitHub-Releases in Gitea-Paket-Registries",
long_about = None
)]
pub struct Cli {
/// Basis-URL der Gitea-Instanz (z. B. https://gitea.creative-dragonslayer.de)
#[arg(long, env = "GITEA_URL", global = true)]
pub gitea_url: Option<String>,
/// Gitea-API-Token mit Schreibberechtigung für Pakete
#[arg(long, env = "GITEA_TOKEN", global = true)]
pub gitea_token: Option<String>,
/// Gitea-Paket-Registry-Owner / Organisation
#[arg(long, env = "REGISTRY_OWNER", global = true)]
pub registry_owner: Option<String>,
/// Optionales GitHub-Token für API-Anfragen (vermeidet Rate-Limits)
#[arg(long, env = "GITHUB_TOKEN", global = true)]
pub github_token: Option<String>,
/// Benutzerdefinierter Pfad zur Konfigurationsdatei
#[arg(long, global = true)]
pub config: Option<PathBuf>,
/// Führt einen Probelauf ohne Herunterladen oder Hochladen von Paketen durch
#[arg(long, global = true)]
pub dry_run: bool,
/// Logging-Level (error, warn, info, debug)
#[arg(long, value_enum, default_value_t = LogLevelArg::Info, global = true)]
pub log_level: LogLevelArg,
#[command(subcommand)]
pub command: Commands,
}
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
pub enum LogLevelArg {
Error,
Warn,
Info,
Debug,
}
impl From<LogLevelArg> for logger_ctdra::LogLevel {
fn from(arg: LogLevelArg) -> Self {
match arg {
LogLevelArg::Error => logger_ctdra::LogLevel::Error,
LogLevelArg::Warn => logger_ctdra::LogLevel::Warn,
LogLevelArg::Info => logger_ctdra::LogLevel::Info,
LogLevelArg::Debug => logger_ctdra::LogLevel::Debug,
}
}
}
#[derive(Subcommand, Debug)]
pub enum Commands {
/// Synchronisiert Pakete aus GitHub-Releases in die Gitea-Paket-Registry
Sync(SyncArgs),
/// Fügt ein GitHub-Repository zur persistenten Konfiguration hinzu
Add(AddArgs),
/// Entfernt ein GitHub-Repository aus der persistenten Konfiguration
#[command(alias = "rm")]
Remove(RemoveArgs),
/// Listet konfigurierte Repositories und deren Sync-Status auf
#[command(alias = "ls")]
List,
/// Zeigt die Anwendungskonfiguration an oder aktualisiert sie
Config(ConfigArgs),
}
#[derive(Args, Debug)]
pub struct SyncArgs {
/// Bestimmter Repository-Name oder URL zur Synchronisation (z. B. "raspberrypi/rpi-imager"). Wenn weggelassen, werden alle konfigurierten Repositories synchronisiert.
pub repo: Option<String>,
/// Alle konfigurierten Repositories synchronisieren
#[arg(short = 'a', long)]
pub all: bool,
/// Historische Releases scannen anstatt nur die neuesten Release(s)
#[arg(long)]
pub history: bool,
/// Pre-Releases während dieses Synchronisationslaufs einbeziehen (Standard: Repository-Konfiguration)
#[arg(long)]
pub prereleases: Option<bool>,
}
#[derive(Args, Debug)]
pub struct AddArgs {
/// Zu spiegelndes GitHub-Repository (z. B. "raspberrypi/rpi-imager" oder vollständige GitHub-URL)
pub repo: String,
/// Keine Pre-Releases für dieses Repository spiegeln
#[arg(long)]
pub no_prereleases: bool,
/// Dieses Repository nach dem Hinzufügen sofort synchronisieren
#[arg(long)]
pub sync: bool,
}
#[derive(Args, Debug)]
pub struct RemoveArgs {
/// Zu entfernendes Repository (z. B. "raspberrypi/rpi-imager")
pub repo: String,
}
#[derive(Args, Debug)]
pub struct ConfigArgs {
#[command(subcommand)]
pub action: Option<ConfigAction>,
}
#[derive(Subcommand, Debug)]
pub enum ConfigAction {
/// Aktuelle Konfiguration und Speicherort der Konfigurationsdatei anzeigen
Show,
/// Konfigurationsparameter setzen
Set {
/// Basis-URL der Gitea-Instanz
#[arg(long)]
gitea_url: Option<String>,
/// Gitea-API-Token
#[arg(long)]
gitea_token: Option<String>,
/// Gitea-Paket-Registry-Owner
#[arg(long)]
registry_owner: Option<String>,
/// GitHub Personal Access Token
#[arg(long)]
github_token: Option<String>,
},
}
+125
View File
@@ -0,0 +1,125 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
fn default_true() -> bool {
true
}
/// Konfiguration für ein einzelnes zu spiegelndes Repository.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct RepoConfig {
/// Vollständiger Name des Repositories im Format "owner/repo".
pub name: String,
/// Gibt an, ob Pre-Releases gespiegelt werden sollen.
#[serde(default = "default_true")]
pub include_prereleases: bool,
/// Zuletzt erfolgreich synchronisierter Tag-Name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_synced_tag: Option<String>,
}
impl RepoConfig {
pub fn new(name: impl Into<String>, include_prereleases: bool) -> Self {
Self {
name: name.into(),
include_prereleases,
last_synced_tag: None,
}
}
}
/// Persistente Anwendungskonfiguration.
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
pub struct AppConfig {
/// Basis-URL der Gitea-Instanz (z. B. https://gitea.example.com).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gitea_url: Option<String>,
/// Gitea-API-Authentifizierungstoken.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gitea_token: Option<String>,
/// Name des Gitea-Paket-Registry-Owners bzw. der Organisation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub registry_owner: Option<String>,
/// Optionales GitHub Personal Access Token für höhere Rate-Limits.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub github_token: Option<String>,
/// Konfigurierte Repositories zur Spiegelung.
#[serde(default)]
pub repositories: Vec<RepoConfig>,
}
impl AppConfig {
/// Normalisiert die Repository-Eingabe, indem führende/nachgestellte Schrägstriche und GitHub-URL-Präfixe entfernt werden.
pub fn normalize_repo_name(input: &str) -> String {
let trimmed = input.trim();
let cleaned = trimmed
.trim_start_matches("https://github.com/")
.trim_start_matches("http://github.com/")
.trim_start_matches("github.com/")
.trim_end_matches(".git")
.trim_matches('/');
cleaned.to_string()
}
/// Fügt ein Repository zur Konfiguration hinzu oder aktualisiert ein bestehendes.
pub fn add_or_update_repo(&mut self, repo: RepoConfig) {
if let Some(existing) = self.repositories.iter_mut().find(|r| r.name.eq_ignore_ascii_case(&repo.name)) {
existing.include_prereleases = repo.include_prereleases;
} else {
self.repositories.push(repo);
}
}
/// Entfernt ein Repository anhand des Namens. Gibt true zurück, wenn es gefunden und entfernt wurde.
pub fn remove_repo(&mut self, repo_name: &str) -> bool {
let normalized = Self::normalize_repo_name(repo_name);
let before_len = self.repositories.len();
self.repositories.retain(|r| !r.name.eq_ignore_ascii_case(&normalized));
self.repositories.len() < before_len
}
/// Sucht ein Repository anhand des Namens.
pub fn find_repo(&self, repo_name: &str) -> Option<&RepoConfig> {
let normalized = Self::normalize_repo_name(repo_name);
self.repositories.iter().find(|r| r.name.eq_ignore_ascii_case(&normalized))
}
/// Aktualisiert den zuletzt synchronisierten Tag für ein bestimmtes Repository.
pub fn update_last_synced_tag(&mut self, repo_name: &str, tag: String) {
let normalized = Self::normalize_repo_name(repo_name);
if let Some(repo) = self.repositories.iter_mut().find(|r| r.name.eq_ignore_ascii_case(&normalized)) {
repo.last_synced_tag = Some(tag);
}
}
}
/// Setzt einen benutzerdefinierten Pfad für die Konfigurationsdatei, falls angegeben.
pub fn init_config_path(custom_path: Option<&std::path::Path>) {
config_ctdra::set_config_name("config");
if let Some(path) = custom_path {
config_ctdra::set_custom_path(path.to_path_buf());
}
}
/// Gibt den aufgelösten Pfad der Konfigurationsdatei zurück.
pub fn get_config_file_path() -> PathBuf {
config_ctdra::get_config_path()
}
/// Lädt die Anwendungskonfiguration. Verwendet Standardwerte, falls keine Datei vorhanden ist.
pub fn load_config() -> AppConfig {
config_ctdra::load_config::<AppConfig>()
}
/// Speichert die Anwendungskonfiguration auf der Festplatte.
pub fn save_config(config: &AppConfig) -> Result<(), config_ctdra::ConfyError> {
config_ctdra::store(config)
}
/// Modifiziert die Anwendungskonfiguration atomar vor Ort und speichert sie.
pub fn modify_config<F>(f: F) -> Result<AppConfig, config_ctdra::ConfyError>
where
F: FnOnce(&mut AppConfig),
{
config_ctdra::modify::<AppConfig, F>(f)
}
+148
View File
@@ -0,0 +1,148 @@
use crate::github::PackageType;
use anyhow::{bail, Context, Result};
use std::path::Path;
use tokio::fs::File;
/// Zugangsdaten und Ziel-Registry-Informationen für Gitea.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GiteaConfig {
pub base_url: String,
pub token: String,
pub owner: String,
}
impl GiteaConfig {
pub fn new(base_url: impl Into<String>, token: impl Into<String>, owner: impl Into<String>) -> Self {
let mut base_url = base_url.into();
while base_url.ends_with('/') {
base_url.pop();
}
Self {
base_url,
token: token.into(),
owner: owner.into(),
}
}
}
/// Ergebnis eines Paket-Upload-Versuchs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UploadStatus {
Uploaded,
AlreadyExists,
SimulatedDryRun,
}
/// Berechnet die Ziel-Upload-URLs der Gitea-Paket-Registry basierend auf Pakettyp und Release-Kanal.
pub fn get_target_upload_urls(
base_url: &str,
owner: &str,
pkg_type: PackageType,
prerelease: bool,
) -> Vec<String> {
let base = base_url.trim_end_matches('/');
match pkg_type {
PackageType::Debian => {
if prerelease {
vec![format!("{}/api/packages/{}/debian/pool/testing/main/upload", base, owner)]
} else {
vec![
format!("{}/api/packages/{}/debian/pool/stable/main/upload", base, owner),
format!("{}/api/packages/{}/debian/pool/testing/main/upload", base, owner),
]
}
}
PackageType::Rpm => {
if prerelease {
vec![format!("{}/api/packages/{}/rpm/testing/upload", base, owner)]
} else {
vec![format!("{}/api/packages/{}/rpm/stable/upload", base, owner)]
}
}
PackageType::Arch => {
if prerelease {
vec![format!("{}/api/packages/{}/arch/testing", base, owner)]
} else {
vec![format!("{}/api/packages/{}/arch/stable", base, owner)]
}
}
}
}
/// Client für den Upload von Paketen in eine Gitea-Paket-Registry.
#[derive(Clone, Debug)]
pub struct GiteaClient {
client: reqwest::Client,
config: GiteaConfig,
}
impl GiteaClient {
/// Erstellt einen neuen Gitea-Client.
pub fn new(config: GiteaConfig) -> Result<Self> {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::USER_AGENT,
reqwest::header::HeaderValue::from_static(concat!("mirror-package/", env!("CARGO_PKG_VERSION"))),
);
let client = reqwest::Client::builder()
.default_headers(headers)
.build()
.context("Failed to initialize Gitea HTTP client")?;
Ok(Self { client, config })
}
/// Lädt eine Paketdatei von der Festplatte zum angegebenen Gitea-Endpunkt hoch.
pub async fn upload_file(
&self,
file_path: &Path,
endpoint_url: &str,
dry_run: bool,
) -> Result<UploadStatus> {
if dry_run {
return Ok(UploadStatus::SimulatedDryRun);
}
let file = File::open(file_path)
.await
.with_context(|| format!("Failed to open package file {:?}", file_path))?;
let body = reqwest::Body::from(file);
let mut auth_header_val = format!("token {}", self.config.token);
if self.config.token.starts_with("Bearer ") || self.config.token.starts_with("token ") {
auth_header_val = self.config.token.clone();
}
let resp = self
.client
.put(endpoint_url)
.header(reqwest::header::AUTHORIZATION, auth_header_val)
.body(body)
.send()
.await
.with_context(|| format!("Failed to send PUT request to {}", endpoint_url))?;
let status = resp.status();
if status.is_success() {
return Ok(UploadStatus::Uploaded);
}
let error_body = resp.text().await.unwrap_or_default();
if status == reqwest::StatusCode::CONFLICT
|| status == reqwest::StatusCode::UNPROCESSABLE_ENTITY
|| error_body.to_ascii_lowercase().contains("already exists")
|| error_body.to_ascii_lowercase().contains("duplicate")
{
return Ok(UploadStatus::AlreadyExists);
}
bail!(
"Failed to upload package to {} (HTTP {}): {}",
endpoint_url,
status,
error_body
);
}
}
+259
View File
@@ -0,0 +1,259 @@
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
/// Unterstützte Linux-Paketverteilungstypen.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PackageType {
Debian,
Rpm,
Arch,
}
impl PackageType {
/// Klassifiziert ein Asset anhand seines Dateinamens in einen unterstützten PackageType.
pub fn from_filename(filename: &str) -> Option<Self> {
let name = filename.to_ascii_lowercase();
// Arch-Pakete aufgrund mehrteiliger Dateiendungen zuerst prüfen
if name.ends_with(".pkg.tar.zst")
|| name.ends_with(".pkg.tar.xz")
|| name.ends_with(".pkg.tar.gz")
|| name.ends_with(".pacman")
{
Some(PackageType::Arch)
} else if name.ends_with(".deb") {
Some(PackageType::Debian)
} else if name.ends_with(".rpm") {
Some(PackageType::Rpm)
} else {
None
}
}
/// Gibt den lesbaren Namen des Pakettyps zurück.
pub fn name(&self) -> &'static str {
match self {
PackageType::Debian => "Debian (.deb)",
PackageType::Rpm => "Fedora/RPM (.rpm)",
PackageType::Arch => "Arch Linux (.pkg.tar.zst)",
}
}
}
/// Ein klassifiziertes Linux-Paket-Asset aus einem Release.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ReleaseAsset {
pub name: String,
pub download_url: String,
pub size: u64,
pub package_type: PackageType,
}
/// Ein GitHub-Release mit extrahierten Linux-Paket-Assets.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GitHubRelease {
pub id: u64,
pub tag_name: String,
pub name: Option<String>,
pub prerelease: bool,
pub draft: bool,
pub assets: Vec<ReleaseAsset>,
}
#[derive(Deserialize, Debug)]
struct GhApiRelease {
id: u64,
tag_name: String,
name: Option<String>,
draft: bool,
prerelease: bool,
#[serde(default)]
assets: Vec<GhApiAsset>,
}
#[derive(Deserialize, Debug)]
struct GhApiAsset {
name: String,
browser_download_url: String,
size: u64,
}
/// Client für die Interaktion mit der GitHub-API.
#[derive(Clone, Debug)]
pub struct GitHubClient {
client: reqwest::Client,
token: Option<String>,
}
impl GitHubClient {
/// Erstellt einen neuen GitHub-API-Client mit optionalem Authentifizierungstoken.
pub fn new(token: Option<String>) -> Result<Self> {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::USER_AGENT,
reqwest::header::HeaderValue::from_static(concat!("mirror-package/", env!("CARGO_PKG_VERSION"))),
);
headers.insert(
reqwest::header::ACCEPT,
reqwest::header::HeaderValue::from_static("application/vnd.github+json"),
);
let client = reqwest::Client::builder()
.default_headers(headers)
.build()
.context("Failed to initialize HTTP client")?;
Ok(Self { client, token })
}
/// Ruft Releases für ein angegebenes Repository ab (Format "owner/repo").
///
/// Wenn `history` false ist, werden nur die neuesten Releases abgerufen.
/// Wenn `include_prereleases` false ist, werden Pre-Releases übersprungen.
pub async fn fetch_releases(
&self,
repo: &str,
history: bool,
include_prereleases: bool,
) -> Result<Vec<GitHubRelease>> {
let (owner, name) = parse_repo_owner_name(repo)?;
let mut releases = Vec::new();
let page_size = 30;
let mut page = 1;
loop {
let url = format!(
"https://api.github.com/repos/{}/{}/releases?per_page={}&page={}",
owner, name, page_size, page
);
let mut req = self.client.get(&url);
if let Some(token) = &self.token {
req = req.bearer_auth(token);
}
let resp = req.send().await.with_context(|| format!("Failed to send request to {}", url))?;
let status = resp.status();
if !status.is_success() {
let error_text = resp.text().await.unwrap_or_default();
bail!(
"GitHub API request failed for {}/{} (HTTP {}): {}",
owner,
name,
status,
error_text
);
}
let raw_releases: Vec<GhApiRelease> = resp
.json()
.await
.with_context(|| format!("Failed to parse GitHub releases JSON for {}/{}", owner, name))?;
if raw_releases.is_empty() {
break;
}
for raw in raw_releases {
if raw.draft {
continue;
}
if raw.prerelease && !include_prereleases {
continue;
}
let filtered_assets: Vec<ReleaseAsset> = raw
.assets
.into_iter()
.filter_map(|a| {
PackageType::from_filename(&a.name).map(|pkg_type| ReleaseAsset {
name: a.name,
download_url: a.browser_download_url,
size: a.size,
package_type: pkg_type,
})
})
.collect();
releases.push(GitHubRelease {
id: raw.id,
tag_name: raw.tag_name,
name: raw.name,
prerelease: raw.prerelease,
draft: raw.draft,
assets: filtered_assets,
});
}
// Wenn kein vollständiger Verlauf gescannt werden soll, nach der ersten Seite stoppen
if !history {
// Nur das neueste stabile Release und neueste Pre-Release behalten (falls zulässig)
let mut selected = Vec::new();
let latest_stable = releases.iter().find(|r| !r.prerelease);
if let Some(stable) = latest_stable {
selected.push(stable.clone());
}
if include_prereleases {
let latest_prerelease = releases.iter().find(|r| r.prerelease);
if let Some(prerelease) = latest_prerelease {
// Duplikate vermeiden, falls Stable und Pre-Release identisch sind
if !selected.iter().any(|r| r.id == prerelease.id) {
selected.push(prerelease.clone());
}
}
}
return Ok(selected);
}
page += 1;
}
Ok(releases)
}
/// Lädt ein Asset von der angegebenen Download-URL als Streaming-Response herunter.
pub async fn download_asset_stream(&self, download_url: &str) -> Result<reqwest::Response> {
let mut req = self.client.get(download_url);
if let Some(token) = &self.token {
req = req.bearer_auth(token);
}
let resp = req
.send()
.await
.with_context(|| format!("Failed to start download from {}", download_url))?;
if !resp.status().is_success() {
bail!(
"Download failed for {} (HTTP status {})",
download_url,
resp.status()
);
}
Ok(resp)
}
}
/// Hilfsfunktion zum Parsen von "owner/repo" aus einer Repository-Zeichenkette.
pub fn parse_repo_owner_name(repo: &str) -> Result<(&str, &str)> {
let cleaned = repo
.trim()
.trim_start_matches("https://github.com/")
.trim_start_matches("http://github.com/")
.trim_start_matches("github.com/")
.trim_end_matches(".git")
.trim_matches('/');
let parts: Vec<&str> = cleaned.split('/').collect();
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
bail!(
"Invalid repository format '{}'. Expected 'owner/repo' (e.g. 'raspberrypi/rpi-imager').",
repo
);
}
Ok((parts[0], parts[1]))
}
+8
View File
@@ -0,0 +1,8 @@
//! `mirror-package` ist eine Anwendung zum Spiegeln vorkompilierter Linux-Pakete
//! (.deb, .rpm, .pkg.tar.zst) aus GitHub-Releases in Gitea- / Forgejo-Paket-Registries.
pub mod cli;
pub mod config;
pub mod gitea;
pub mod github;
pub mod pipeline;
+194 -10
View File
@@ -1,13 +1,197 @@
fn main() { use anyhow::Result;
// TODO: Hauptlogik der Anwendung implementieren use clap::Parser;
println!("Hello, World!"); use logger_ctdra::{log, set_log_level, LogLevel};
} use mirror_package::cli::{Cli, Commands, ConfigAction};
use mirror_package::config::{
get_config_file_path, init_config_path, load_config, modify_config, AppConfig, RepoConfig,
};
use mirror_package::pipeline::{sync_single_repository, SyncOptions, SyncReport};
#[cfg(test)] #[tokio::main]
mod tests { async fn main() -> Result<()> {
#[test] let cli = Cli::parse();
fn it_works() {
// TODO: Unit-Tests für die Anwendung schreiben // Globalen Logging-Level initialisieren
assert_eq!(2 + 2, 4); set_log_level(cli.log_level.into());
// Benutzerdefinierten Konfigurationspfad initialisieren, falls angegeben
init_config_path(cli.config.as_deref());
// Persistente Konfiguration laden
let mut app_config = load_config();
// CLI-Flags überschreiben Konfigurationswerte
if let Some(url) = cli.gitea_url {
app_config.gitea_url = Some(url);
} }
if let Some(token) = cli.gitea_token {
app_config.gitea_token = Some(token);
}
if let Some(owner) = cli.registry_owner {
app_config.registry_owner = Some(owner);
}
if let Some(gh_token) = cli.github_token {
app_config.github_token = Some(gh_token);
}
match cli.command {
Commands::Sync(sync_args) => {
let options = SyncOptions {
history: sync_args.history,
prereleases_override: sync_args.prereleases,
dry_run: cli.dry_run,
};
let repos_to_sync = if let Some(specific_repo) = sync_args.repo {
let norm = AppConfig::normalize_repo_name(&specific_repo);
let prereleases = sync_args.prereleases.unwrap_or(
app_config
.find_repo(&norm)
.map(|r| r.include_prereleases)
.unwrap_or(true),
);
vec![RepoConfig::new(norm, prereleases)]
} else if !app_config.repositories.is_empty() {
app_config.repositories.clone()
} else {
log(
"sync",
"No repositories configured. Add one with 'mirror-package add <owner/repo>' or specify a repository to sync.",
LogLevel::Warn,
);
return Ok(());
};
let mut report = SyncReport::default();
for repo in repos_to_sync {
if let Err(e) = sync_single_repository(&repo, &app_config, &options, &mut report).await {
log(
"sync",
&format!("Failed to sync repository '{}': {:#}", repo.name, e),
LogLevel::Error,
);
}
}
log(
"sync",
&format!(
"Sync finished: {} repository(ies) processed, {} package(s) uploaded, {} skipped (already exist), {} simulated (dry-run).",
report.repositories_processed,
report.packages_uploaded,
report.packages_already_exist,
report.packages_simulated
),
LogLevel::Info,
);
}
Commands::Add(add_args) => {
let normalized_name = AppConfig::normalize_repo_name(&add_args.repo);
let include_prereleases = !add_args.no_prereleases;
let updated_repo = RepoConfig::new(&normalized_name, include_prereleases);
let to_add = updated_repo.clone();
modify_config(move |cfg| {
cfg.add_or_update_repo(to_add);
})?;
log(
"config",
&format!(
"Repository '{}' added (include pre-releases: {}).",
normalized_name, include_prereleases
),
LogLevel::Info,
);
if add_args.sync {
let options = SyncOptions {
history: false,
prereleases_override: None,
dry_run: cli.dry_run,
};
let mut report = SyncReport::default();
sync_single_repository(&updated_repo, &app_config, &options, &mut report).await?;
}
}
Commands::Remove(rm_args) => {
let normalized_name = AppConfig::normalize_repo_name(&rm_args.repo);
let target_name = normalized_name.clone();
modify_config(move |cfg| {
cfg.remove_repo(&target_name);
})?;
log(
"config",
&format!("Repository '{}' removed from configuration.", normalized_name),
LogLevel::Info,
);
}
Commands::List => {
println!("Configured repositories (Config file: {:?}):", get_config_file_path());
if app_config.repositories.is_empty() {
println!(" (None configured yet. Use 'mirror-package add <owner/repo>' to add one.)");
} else {
println!("{:<40} {:<15} {:<20}", "REPOSITORY", "PRE-RELEASES", "LAST SYNCED TAG");
println!("{:-<40} {:-<15} {:-<20}", "", "", "");
for repo in &app_config.repositories {
println!(
"{:<40} {:<15} {:<20}",
repo.name,
if repo.include_prereleases { "yes" } else { "no" },
repo.last_synced_tag.as_deref().unwrap_or("-")
);
}
}
}
Commands::Config(config_args) => match config_args.action {
None | Some(ConfigAction::Show) => {
println!("Configuration location: {:?}", get_config_file_path());
println!("Gitea URL: {}", app_config.gitea_url.as_deref().unwrap_or("(not configured)"));
println!("Registry Owner: {}", app_config.registry_owner.as_deref().unwrap_or("(not configured)"));
println!(
"Gitea Token: {}",
if app_config.gitea_token.is_some() { "******** (set)" } else { "(not configured)" }
);
println!(
"GitHub Token: {}",
if app_config.github_token.is_some() { "******** (set)" } else { "(not configured, public access only)" }
);
println!("Repositories: {} configured", app_config.repositories.len());
}
Some(ConfigAction::Set {
gitea_url,
gitea_token,
registry_owner,
github_token,
}) => {
modify_config(move |cfg| {
if let Some(url) = gitea_url {
cfg.gitea_url = Some(url);
}
if let Some(tok) = gitea_token {
cfg.gitea_token = Some(tok);
}
if let Some(own) = registry_owner {
cfg.registry_owner = Some(own);
}
if let Some(gh) = github_token {
cfg.github_token = Some(gh);
}
})?;
log("config", "Configuration successfully updated.", LogLevel::Info);
}
},
}
Ok(())
} }
+277
View File
@@ -0,0 +1,277 @@
use crate::config::{modify_config, AppConfig, RepoConfig};
use crate::gitea::{get_target_upload_urls, GiteaClient, GiteaConfig, UploadStatus};
use crate::github::GitHubClient;
use anyhow::{bail, Context, Result};
use logger_ctdra::{log, LogLevel};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::fs::{remove_file, File};
use tokio::io::AsyncWriteExt;
/// Zusammenfassende Statistiken eines Synchronisationsvorgangs.
#[derive(Default, Debug, Clone, PartialEq, Eq)]
pub struct SyncReport {
pub repositories_processed: usize,
pub releases_processed: usize,
pub packages_uploaded: usize,
pub packages_already_exist: usize,
pub packages_simulated: usize,
}
/// Optionen zur Steuerung eines Repository-Synchronisationslaufs.
#[derive(Debug, Clone)]
pub struct SyncOptions {
pub history: bool,
pub prereleases_override: Option<bool>,
pub dry_run: bool,
}
/// Führt die Synchronisations-Pipeline für ein bestimmtes Repository aus.
pub async fn sync_single_repository(
repo_config: &RepoConfig,
app_config: &AppConfig,
options: &SyncOptions,
report: &mut SyncReport,
) -> Result<()> {
let repo_name = &repo_config.name;
let include_prereleases = options
.prereleases_override
.unwrap_or(repo_config.include_prereleases);
log(
"sync",
&format!(
"Starting sync for repository '{}' (pre-releases: {}, history: {}, dry-run: {})",
repo_name, include_prereleases, options.history, options.dry_run
),
LogLevel::Info,
);
// Gitea-Zugangsdaten auflösen
let gitea_url = app_config.gitea_url.as_deref().unwrap_or_default();
let gitea_token = app_config.gitea_token.as_deref().unwrap_or_default();
let registry_owner = app_config.registry_owner.as_deref().unwrap_or_default();
if !options.dry_run && (gitea_url.is_empty() || gitea_token.is_empty() || registry_owner.is_empty()) {
bail!(
"Missing Gitea configuration. Ensure --gitea-url, --gitea-token, and --registry-owner are provided or configured."
);
}
let github_client = GitHubClient::new(app_config.github_token.clone())
.context("Failed to initialize GitHub client")?;
let gitea_client = GiteaClient::new(GiteaConfig::new(
gitea_url,
gitea_token,
registry_owner,
))
.context("Failed to initialize Gitea client")?;
let releases = github_client
.fetch_releases(repo_name, options.history, include_prereleases)
.await
.with_context(|| format!("Failed to fetch releases for repository '{}'", repo_name))?;
if releases.is_empty() {
log(
"sync",
&format!("No matching releases found for repository '{}'", repo_name),
LogLevel::Info,
);
report.repositories_processed += 1;
return Ok(());
}
log(
"sync",
&format!(
"Found {} release(s) to process for '{}'",
releases.len(),
repo_name
),
LogLevel::Info,
);
let mut latest_synced_tag: Option<String> = None;
for release in &releases {
report.releases_processed += 1;
let release_type_str = if release.prerelease { "pre-release" } else { "stable release" };
log(
"sync",
&format!(
"Processing {} '{}' (Tag: {}) with {} Linux package asset(s)",
release_type_str,
release.name.as_deref().unwrap_or(&release.tag_name),
release.tag_name,
release.assets.len()
),
LogLevel::Info,
);
if release.assets.is_empty() {
log(
"sync",
&format!(
"No Linux packages (.deb, .rpm, .pkg.tar.zst) found in release {}",
release.tag_name
),
LogLevel::Debug,
);
continue;
}
for asset in &release.assets {
let target_urls = get_target_upload_urls(
gitea_url,
registry_owner,
asset.package_type,
release.prerelease,
);
log(
"sync",
&format!(
"Mirroring asset '{}' ({}, size: {} bytes) -> {} endpoint(s)",
asset.name,
asset.package_type.name(),
asset.size,
target_urls.len()
),
LogLevel::Info,
);
if options.dry_run {
for url in &target_urls {
log(
"dry-run",
&format!("[DRY-RUN] Would upload '{}' to '{}'", asset.name, url),
LogLevel::Info,
);
}
report.packages_simulated += target_urls.len();
continue;
}
// Asset in temporäre Datei herunterladen
let temp_file_path = download_to_temp_file(&github_client, &asset.download_url, &asset.name)
.await
.with_context(|| format!("Failed to download asset '{}'", asset.name))?;
// Asset auf alle Ziel-Distributions-URLs hochladen
for url in &target_urls {
log(
"upload",
&format!("Uploading '{}' to '{}'...", asset.name, url),
LogLevel::Debug,
);
match gitea_client.upload_file(&temp_file_path, url, options.dry_run).await {
Ok(UploadStatus::Uploaded) => {
log(
"upload",
&format!("Successfully uploaded '{}' to {}", asset.name, url),
LogLevel::Info,
);
report.packages_uploaded += 1;
}
Ok(UploadStatus::AlreadyExists) => {
log(
"upload",
&format!(
"Package '{}' already exists in registry ({}), skipping duplicate.",
asset.name, url
),
LogLevel::Warn,
);
report.packages_already_exist += 1;
}
Ok(UploadStatus::SimulatedDryRun) => {
report.packages_simulated += 1;
}
Err(e) => {
log(
"upload",
&format!("Failed to upload '{}' to {}: {:#}", asset.name, url, e),
LogLevel::Error,
);
// Temporäre Datei vor Fehlerrückgabe bereinigen
let _ = remove_file(&temp_file_path).await;
return Err(e);
}
}
}
// Temporäre Datei bereinigen
if let Err(e) = remove_file(&temp_file_path).await {
log(
"sync",
&format!("Failed to clean up temp file {:?}: {}", temp_file_path, e),
LogLevel::Warn,
);
}
}
if latest_synced_tag.is_none() {
latest_synced_tag = Some(release.tag_name.clone());
}
}
// Zuletzt synchronisierten Tag persistieren, wenn kein Dry-Run
if !options.dry_run {
if let Some(tag) = latest_synced_tag {
let name_copy = repo_name.to_string();
let _ = modify_config(move |cfg| {
cfg.update_last_synced_tag(&name_copy, tag);
});
}
}
report.repositories_processed += 1;
Ok(())
}
/// Lädt ein GitHub-Release-Asset in eine eindeutige temporäre Datei herunter.
async fn download_to_temp_file(
github_client: &GitHubClient,
download_url: &str,
filename: &str,
) -> Result<PathBuf> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let temp_dir = std::env::temp_dir().join("mirror-package");
tokio::fs::create_dir_all(&temp_dir)
.await
.with_context(|| format!("Failed to create temp directory {:?}", temp_dir))?;
let safe_filename = format!("{}-{}", now, filename.replace(['/', '\\'], "_"));
let temp_path = temp_dir.join(safe_filename);
log(
"download",
&format!("Downloading '{}' to temporary path {:?}...", filename, temp_path),
LogLevel::Debug,
);
let mut response = github_client.download_asset_stream(download_url).await?;
let mut file = File::create(&temp_path)
.await
.with_context(|| format!("Failed to create temp file {:?}", temp_path))?;
while let Some(chunk) = response.chunk().await.context("Error reading download stream")? {
file.write_all(&chunk)
.await
.context("Error writing chunk to temp file")?;
}
file.flush()
.await
.context("Error flushing temp file to disk")?;
Ok(temp_path)
}
+53
View File
@@ -0,0 +1,53 @@
use mirror_package::config::{AppConfig, RepoConfig};
#[test]
fn test_normalize_repo_name() {
assert_eq!(
AppConfig::normalize_repo_name("raspberrypi/rpi-imager"),
"raspberrypi/rpi-imager"
);
assert_eq!(
AppConfig::normalize_repo_name("https://github.com/raspberrypi/rpi-imager"),
"raspberrypi/rpi-imager"
);
assert_eq!(
AppConfig::normalize_repo_name("https://github.com/raspberrypi/rpi-imager.git"),
"raspberrypi/rpi-imager"
);
assert_eq!(
AppConfig::normalize_repo_name("Heroic-Games-Launcher/HeroicGamesLauncher/"),
"Heroic-Games-Launcher/HeroicGamesLauncher"
);
}
#[test]
fn test_repo_management() {
let mut config = AppConfig::default();
config.add_or_update_repo(RepoConfig::new("owner/repo1", true));
config.add_or_update_repo(RepoConfig::new("owner/repo2", false));
assert_eq!(config.repositories.len(), 2);
assert!(config.find_repo("owner/repo1").is_some());
assert!(config.find_repo("https://github.com/owner/repo1").is_some());
assert!(config.remove_repo("owner/repo1"));
assert_eq!(config.repositories.len(), 1);
assert!(config.find_repo("owner/repo1").is_none());
}
#[test]
fn test_serialization_roundtrip() {
let mut config = AppConfig {
gitea_url: Some("https://gitea.example.com".to_string()),
gitea_token: Some("secret_token".to_string()),
registry_owner: Some("my-org".to_string()),
github_token: Some("gh_pat".to_string()),
repositories: Vec::new(),
};
config.add_or_update_repo(RepoConfig::new("raspberrypi/rpi-imager", true));
let json_str = serde_json::to_string(&config).unwrap();
let deserialized: AppConfig = serde_json::from_str(&json_str).unwrap();
assert_eq!(config, deserialized);
}
+65
View File
@@ -0,0 +1,65 @@
use mirror_package::gitea::get_target_upload_urls;
use mirror_package::github::PackageType;
#[test]
fn test_debian_routing() {
let base_url = "https://gitea.example.com";
let owner = "test-owner";
// Stabile Debian-Pakete werden in stable und testing Pools hochgeladen
let stable_urls = get_target_upload_urls(base_url, owner, PackageType::Debian, false);
assert_eq!(
stable_urls,
vec![
"https://gitea.example.com/api/packages/test-owner/debian/pool/stable/main/upload",
"https://gitea.example.com/api/packages/test-owner/debian/pool/testing/main/upload",
]
);
// Pre-Release Debian-Pakete gehen nur in den testing Pool
let prerelease_urls = get_target_upload_urls(base_url, owner, PackageType::Debian, true);
assert_eq!(
prerelease_urls,
vec!["https://gitea.example.com/api/packages/test-owner/debian/pool/testing/main/upload"]
);
}
#[test]
fn test_rpm_routing() {
let base_url = "https://gitea.example.com/";
let owner = "test-owner";
// Stabiles RPM
let stable_urls = get_target_upload_urls(base_url, owner, PackageType::Rpm, false);
assert_eq!(
stable_urls,
vec!["https://gitea.example.com/api/packages/test-owner/rpm/stable/upload"]
);
// Pre-Release RPM
let prerelease_urls = get_target_upload_urls(base_url, owner, PackageType::Rpm, true);
assert_eq!(
prerelease_urls,
vec!["https://gitea.example.com/api/packages/test-owner/rpm/testing/upload"]
);
}
#[test]
fn test_arch_routing() {
let base_url = "https://gitea.example.com";
let owner = "test-owner";
// Stabiles Arch
let stable_urls = get_target_upload_urls(base_url, owner, PackageType::Arch, false);
assert_eq!(
stable_urls,
vec!["https://gitea.example.com/api/packages/test-owner/arch/stable"]
);
// Pre-Release Arch
let prerelease_urls = get_target_upload_urls(base_url, owner, PackageType::Arch, true);
assert_eq!(
prerelease_urls,
vec!["https://gitea.example.com/api/packages/test-owner/arch/testing"]
);
}
+70
View File
@@ -0,0 +1,70 @@
use mirror_package::github::{parse_repo_owner_name, PackageType};
#[test]
fn test_package_classification() {
// Debian
assert_eq!(
PackageType::from_filename("rpi-imager_1.8.5_amd64.deb"),
Some(PackageType::Debian)
);
assert_eq!(
PackageType::from_filename("heroic_2.14.1_arm64.DEB"),
Some(PackageType::Debian)
);
// RPM
assert_eq!(
PackageType::from_filename("rpi-imager-1.8.5-1.x86_64.rpm"),
Some(PackageType::Rpm)
);
assert_eq!(
PackageType::from_filename("heroic-2.14.1.aarch64.RPM"),
Some(PackageType::Rpm)
);
// Arch Linux
assert_eq!(
PackageType::from_filename("heroic-games-launcher-bin-2.14.1-1-x86_64.pkg.tar.zst"),
Some(PackageType::Arch)
);
assert_eq!(
PackageType::from_filename("rpi-imager-1.8.5-1-x86_64.pkg.tar.xz"),
Some(PackageType::Arch)
);
assert_eq!(
PackageType::from_filename("package-1.0.0-1-any.pkg.tar.gz"),
Some(PackageType::Arch)
);
assert_eq!(
PackageType::from_filename("Heroic-2.14.1.pacman"),
Some(PackageType::Arch)
);
// Nicht unterstützte Paketformate sollten ignoriert werden
assert_eq!(PackageType::from_filename("rpi-imager-1.8.5.AppImage"), None);
assert_eq!(PackageType::from_filename("rpi-imager-1.8.5.dmg"), None);
assert_eq!(PackageType::from_filename("rpi-imager-1.8.5.exe"), None);
assert_eq!(PackageType::from_filename("source-code.tar.gz"), None);
assert_eq!(PackageType::from_filename("source-code.zip"), None);
assert_eq!(PackageType::from_filename("checksums.sha256"), None);
assert_eq!(PackageType::from_filename("release.sig"), None);
}
#[test]
fn test_parse_repo_owner_name() {
assert_eq!(
parse_repo_owner_name("raspberrypi/rpi-imager").unwrap(),
("raspberrypi", "rpi-imager")
);
assert_eq!(
parse_repo_owner_name("https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher").unwrap(),
("Heroic-Games-Launcher", "HeroicGamesLauncher")
);
assert_eq!(
parse_repo_owner_name("https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher.git").unwrap(),
("Heroic-Games-Launcher", "HeroicGamesLauncher")
);
assert!(parse_repo_owner_name("invalid_repo").is_err());
assert!(parse_repo_owner_name("invalid/repo/extra").is_err());
}