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
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:
@@ -0,0 +1,7 @@
|
||||
target/
|
||||
!target/release/mirror-package
|
||||
!target/x86_64-unknown-linux-gnu/release/mirror-package
|
||||
.git/
|
||||
.github/
|
||||
.gitea/
|
||||
tests/
|
||||
@@ -181,3 +181,34 @@ jobs:
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
|
||||
echo "Asset ${filename} erfolgreich hochgeladen."
|
||||
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."
|
||||
|
||||
@@ -181,3 +181,36 @@ jobs:
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
|
||||
echo "Asset ${filename} erfolgreich hochgeladen."
|
||||
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."
|
||||
|
||||
Generated
+28
@@ -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>
|
||||
Generated
+1
@@ -3,6 +3,7 @@
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/target" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
# 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
|
||||
|
||||
Dieses Repository ist ein **Rust-Projekt-Template** für Linux-Anwendungen und CLI-Tools mit Fokus auf:
|
||||
- Automatisierte Multi-Architektur-Kompilierung (`x86_64`, `aarch64`, `i686`).
|
||||
- Native Paketierung für Debian (`.deb`), Fedora/RHEL (`.rpm`) und Arch Linux (`.pkg.tar.zst`).
|
||||
- Vollständig automatisierte CI/CD-Pipelines via Gitea Actions (kompatibel mit Forgejo / GitHub Actions).
|
||||
`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.
|
||||
|
||||
### Kernfunktionen & Routing
|
||||
- **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
|
||||
- **Sprache**: Rust (Edition 2024), Python 3 (für Hilfsskripte in `scripts/`).
|
||||
- **Rust Toolchain**: Stable.
|
||||
- **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
|
||||
│ └── package-arch.py # Erzeugt native Arch Linux .pkg.tar.zst Pakete
|
||||
├── 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
|
||||
├── LICENSE # Lizenztext
|
||||
├── README.md # Benutzerdokumentation & Setup-Checkliste
|
||||
├── README.md # Benutzerdokumentation
|
||||
└── 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
|
||||
- **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.
|
||||
- **Kommentare**: Ergänze KDoc/RustDoc-Kommentare (`///`) an öffentlichen Funktionen und Typen. Behalte die bestehende Sprachkonvention bei.
|
||||
- **Template-TODOs**: Wenn neue Vorlagen-Features oder Platzhalter ergänzt werden, markiere anpassungsbedürftige Stellen eindeutig mit `// TODO:` (Rust), `# TODO:` (TOML/Python/YAML).
|
||||
- **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.
|
||||
- **Logging**: Nutze `logger-ctdra` für strukturierte Konsolen- und Datei-Logs.
|
||||
|
||||
### 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:
|
||||
@@ -59,15 +77,23 @@ Bei Änderungen an Binärnamen, Abhängigkeiten oder Beschreibungen müssen die
|
||||
- `pkgrel`, `arch`, `depends`, `optdepends`.
|
||||
|
||||
### 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.
|
||||
- **Python-Kompatibilität**: Verwende Standard-Python 3 ohne externe PyPI-Abhängigkeiten (nur Standardbibliothek: `json`, `subprocess`, `urllib`, `argparse`, `os`, `re`, `tempfile`, `tarfile` etc.).
|
||||
- **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).
|
||||
|
||||
### 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
|
||||
|
||||
Agenten müssen Änderungen vor dem Abschluss validieren.
|
||||
|
||||
### 4.1 Grundlegende Validierung
|
||||
```bash
|
||||
# Syntax- und Typprüfung
|
||||
@@ -94,7 +120,7 @@ python3 scripts/package-arch.py --arch x86_64 --pkgrel 1
|
||||
## 5. CI/CD-Pipeline Details
|
||||
|
||||
- **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 `testing`: Baut Binaries & Pakete für den `testing`-Kanal und erstellt ein Pre-Release `v<VERSION>-preview`.
|
||||
- `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, 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**:
|
||||
- `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
File diff suppressed because it is too large
Load Diff
+33
-23
@@ -1,15 +1,22 @@
|
||||
[package]
|
||||
name = "rust-template" # TODO: Projektnamen setzen
|
||||
version = "0.0.0" # TODO: Initiale Version setzen (z. B. 0.1.0)
|
||||
name = "mirror-package"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
authors = ['DragonSlayer_14'] # TODO: Autor(en) anpassen
|
||||
authors = ['DragonSlayer_14']
|
||||
readme = "README.md"
|
||||
license = "GPL-3.0-or-later" # TODO: Lizenz prüfen / anpassen
|
||||
repository = "https://gitea.creative-dragonslayer.de/Linuxapps/rust-template" # TODO: Repository-URL anpassen
|
||||
description = "" # TODO: Kurze Projektbeschreibung setzen
|
||||
license = "GPL-3.0-or-later"
|
||||
repository = "https://gitea.creative-dragonslayer.de/Linuxapps/MirrorPackage"
|
||||
description = "Spiegelt vorkompilierte Linux-Pakete aus GitHub-Releases in Gitea-Paket-Registries"
|
||||
|
||||
[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]
|
||||
debug = "none"
|
||||
@@ -17,28 +24,31 @@ debug = "none"
|
||||
# --- Paketierungs-Metadaten für Linux-Distributionen ---
|
||||
|
||||
[package.metadata.deb]
|
||||
name = "" # TODO: Optional (Standard: package.name)
|
||||
maintainer = "" # TODO: Maintainer setzen (z. B. "Max Mustermann <max@example.com>")
|
||||
copyright = "" # TODO: Copyright-Hinweis setzen (z. B. "2026 Max Mustermann")
|
||||
section = "utils" # TODO: Debian-Sektion anpassen (z. B. utils, net, admin)
|
||||
priority = "optional" # TODO: Debian-Priorität anpassen (z. B. optional)
|
||||
depends = "$auto" # TODO: Abhängigkeiten definieren ($auto ermittelt via ldd automatisch)
|
||||
name = "mirror-package"
|
||||
maintainer = "DragonSlayer_14"
|
||||
copyright = "2026 DragonSlayer_14"
|
||||
section = "utils"
|
||||
priority = "optional"
|
||||
depends = "$auto, ca-certificates"
|
||||
extended-description = """\
|
||||
""" # TODO: Ausführliche Paketbeschreibung setzen
|
||||
Spiegelt vorkompilierte Linux-Pakete (.deb, .rpm, .pkg.tar.zst) aus GitHub-Releases in Gitea-Paket-Registries.\
|
||||
"""
|
||||
assets = [
|
||||
# TODO: Bei Bedarf zusätzliche Dateien / Assets definieren, z. B.:
|
||||
# ["target/release/rust-template", "usr/bin/", "755"],
|
||||
["target/release/mirror-package", "usr/bin/mirror-package", "755"],
|
||||
["README.md", "usr/share/doc/mirror-package/README.md", "644"],
|
||||
["LICENSE", "usr/share/doc/mirror-package/copyright", "644"],
|
||||
]
|
||||
|
||||
[package.metadata.generate-rpm]
|
||||
assets = [
|
||||
# TODO: Bei Bedarf zusätzliche Dateien / Assets definieren, z. B.:
|
||||
# { source = "target/release/rust-template", dest = "/usr/bin/rust-template", mode = "755" },
|
||||
{ source = "target/release/mirror-package", dest = "/usr/bin/mirror-package", 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]
|
||||
pkgrel = "1" # TODO: Standard Release-Nummer (wird in CI durch get-build-number.py dynamisch überschrieben)
|
||||
arch = "x86_64" # TODO: Zielarchitektur (z. B. x86_64, aarch64, i686)
|
||||
depends = ["gcc-libs", "glibc"] # TODO: Arch Linux Paketabhängigkeiten definieren
|
||||
optdepends = [] # TODO: Optionale Abhängigkeiten definieren (z. B. ["curl: für Web-Downloads"])
|
||||
pkgrel = "1"
|
||||
arch = "x86_64"
|
||||
depends = ["gcc-libs", "glibc", "ca-certificates"]
|
||||
optdepends = []
|
||||
|
||||
+37
@@ -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"]
|
||||
@@ -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.
|
||||
- **Multi-Architektur-Kompilierung**:
|
||||
- `x86_64-unknown-linux-gnu` (64-Bit x86)
|
||||
- `aarch64-unknown-linux-gnu` (64-Bit ARM / ARM64)
|
||||
- `i686-unknown-linux-gnu` (32-Bit x86)
|
||||
- **Linux-Paketierung out-of-the-box**:
|
||||
- **Debian / Ubuntu** (`.deb` via `cargo-deb`)
|
||||
- **Fedora / RHEL / openSUSE** (`.rpm` via `cargo-generate-rpm`)
|
||||
- **Arch Linux** (`.pkg.tar.zst` via mitgeliefertem `scripts/package-arch.py`)
|
||||
- **Automatisierte CI/CD-Pipelines**:
|
||||
- `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.
|
||||
- `testing`-Branch: Erstellt Preview-Builds und Pre-Releases mit Testing-Paketen.
|
||||
- **Automatisierte Versions- & Build-Nummern**:
|
||||
- `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.
|
||||
- **Vorkonfigurierte Cargo-Einstellungen**:
|
||||
- `.cargo/config.toml` mit vorkonfigurierten Cross-Compilation-Linkern und Unterstützung für private/öffentliche Cargo Registries.
|
||||
- **Unterstützung mehrerer Distributionen**:
|
||||
- **Debian / Ubuntu** (`.deb`)
|
||||
- **Fedora / RHEL / openSUSE** (`.rpm`)
|
||||
- **Arch Linux** (`.pkg.tar.zst`, `.pkg.tar.xz`, `.pkg.tar.gz`, `.pacman`)
|
||||
- **Distributions-Routing-Regeln**:
|
||||
- **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`.
|
||||
- **Pre-Releases**: Werden **ausschließlich** in Testing-Kanälen veröffentlicht: Debian `pool/testing/main`, RPM `rpm/testing` und Arch `arch/testing`.
|
||||
- **Persistente Konfiguration über `config-ctdra`**:
|
||||
- Speichert Einstellungen automatisch im Benutzerpfad (`~/.config/mirror-package/config.toml`) oder Systempfad (`/etc/mirror-package/config.toml`).
|
||||
- Das Hinzufügen oder Entfernen von Repositories über die CLI aktualisiert automatisch die Konfigurationsdatei.
|
||||
- **Flexible Authentifizierung**:
|
||||
- GitHub-Releases können anonym abgefragt werden (kein Token erforderlich).
|
||||
- Optionale Unterstützung für GitHub Personal Access Tokens zur Vermeidung von API-Rate-Limits.
|
||||
- Gitea-Instanz-URL, API-Token und Registry-Owner können per CLI-Flags, Umgebungsvariablen oder Konfigurationsdatei festgelegt werden.
|
||||
- **Diagnose & Dry-Run-Modus**:
|
||||
- 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
|
||||
├── .cargo/
|
||||
│ └── config.toml # Cargo-Konfiguration (Linker für Cross-Compiling, Registry)
|
||||
│ └── config.toml # Linker- & Cargo-Konfiguration
|
||||
├── .gitea/
|
||||
│ └── workflows/
|
||||
│ ├── main.yaml # CI/CD-Workflow für stabile Releases (main-Branch)
|
||||
│ └── testing.yaml # CI/CD-Workflow für Pre-Releases (testing-Branch)
|
||||
│ ├── main.yaml # CI/CD: Release, Pakete & Container (Stable)
|
||||
│ └── testing.yaml # CI/CD: Preview, Pakete & Container (Testing)
|
||||
├── scripts/
|
||||
│ ├── get-build-number.py # Dynamische Ermittlung der nächsten Paket-Revisionsnummer
|
||||
│ └── package-arch.py # Erstellung von Arch Linux .pkg.tar.zst Paketen
|
||||
│ ├── get-build-number.py # Dynamische Ermittlung der Build-/Revisionsnummer
|
||||
│ └── package-arch.py # Erstellung nativer Arch Linux-Pakete
|
||||
├── src/
|
||||
│ └── main.rs # Quellcode & Einstiegspunkt der Anwendung
|
||||
├── Cargo.toml # Cargo Manifest & Paketierungsmetadaten (deb, rpm, arch)
|
||||
├── LICENSE # Lizenzdatei (Standard: GPL-3.0-or-later)
|
||||
├── AGENTS.md # Richtlinien und Leitfaden für KI-Coding-Agenten
|
||||
│ ├── main.rs # CLI-Einstiegspunkt & Befehlsausführung
|
||||
│ ├── lib.rs # Bibliotheks-Wurzel & Modulexporte
|
||||
│ ├── cli.rs # Clap-CLI-Argumente & Optionen
|
||||
│ ├── 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checkliste zur Verwendung als Template
|
||||
|
||||
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
|
||||
## Lokale Entwicklung & Bauen
|
||||
|
||||
### Voraussetzungen
|
||||
- **Rust & Cargo** (Stable Toolchain, Edition 2024 unterstützt)
|
||||
- **Python 3** (für Paketierungsskripte)
|
||||
|
||||
- **Rust & Cargo** (aktuelle Stable-Version, Edition 2024 unterstützt)
|
||||
- **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
|
||||
### Befehle zum Bauen & Testen
|
||||
|
||||
```bash
|
||||
# Projekt bauen
|
||||
cargo build
|
||||
# Syntax- und Typprüfung ausführen
|
||||
cargo check
|
||||
|
||||
# Tests ausführen
|
||||
# Unit-Tests ausführen
|
||||
cargo test
|
||||
|
||||
# Release-Build erstellen
|
||||
# Release-Binary kompilieren
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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]))
|
||||
}
|
||||
@@ -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
@@ -1,13 +1,197 @@
|
||||
fn main() {
|
||||
// TODO: Hauptlogik der Anwendung implementieren
|
||||
println!("Hello, World!");
|
||||
}
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
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)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn it_works() {
|
||||
// TODO: Unit-Tests für die Anwendung schreiben
|
||||
assert_eq!(2 + 2, 4);
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
// Globalen Logging-Level initialisieren
|
||||
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
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"]
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user