Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4af879b2e3
|
||
|
|
f6f71f3c20
|
@@ -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}"
|
"${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."
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
Generated
+1
@@ -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" />
|
||||||
|
|||||||
@@ -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
+2126
-2
File diff suppressed because it is too large
Load Diff
+30
-27
@@ -1,15 +1,23 @@
|
|||||||
[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 = "0.1.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 = "Mirror prebuilt Linux packages from GitHub Releases to Gitea Package Registry"
|
||||||
|
|
||||||
[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"
|
||||||
|
futures-util = "0.3"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
debug = "none"
|
debug = "none"
|
||||||
@@ -17,28 +25,23 @@ 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"
|
||||||
extended-description = """\
|
extended-description = """\
|
||||||
""" # TODO: Ausführliche Paketbeschreibung setzen
|
Mirror prebuilt Linux packages (.deb, .rpm, .pkg.tar.zst) from GitHub Releases into Gitea Package Registries.\
|
||||||
assets = [
|
"""
|
||||||
# TODO: Bei Bedarf zusätzliche Dateien / Assets definieren, z. B.:
|
assets = []
|
||||||
# ["target/release/rust-template", "usr/bin/", "755"],
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.metadata.generate-rpm]
|
[package.metadata.generate-rpm]
|
||||||
assets = [
|
assets = []
|
||||||
# TODO: Bei Bedarf zusätzliche Dateien / Assets definieren, z. B.:
|
requires = { }
|
||||||
# { source = "target/release/rust-template", dest = "/usr/bin/rust-template", mode = "755" },
|
|
||||||
]
|
|
||||||
requires = { } # TODO: RPM-Abhängigkeiten definieren, z. B. { "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"]
|
||||||
optdepends = [] # TODO: Optionale Abhängigkeiten definieren (z. B. ["curl: für Web-Downloads"])
|
optdepends = []
|
||||||
|
|||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# Minimal and secure runtime image
|
||||||
|
FROM debian:bookworm-slim AS runtime
|
||||||
|
|
||||||
|
# Install CA certificates and minimal runtime dynamic libraries
|
||||||
|
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 pointing to the prebuilt binary
|
||||||
|
ARG TARGET_BIN=target/release/mirror-package
|
||||||
|
COPY ${TARGET_BIN} /usr/local/bin/mirror-package
|
||||||
|
|
||||||
|
# Set strict executable permissions
|
||||||
|
RUN chmod 0755 /usr/local/bin/mirror-package
|
||||||
|
|
||||||
|
# Run as unprivileged user
|
||||||
|
USER 10001:10001
|
||||||
|
WORKDIR /home/appuser
|
||||||
|
|
||||||
|
# Expose volume mount points for configuration and persistent state/logs
|
||||||
|
VOLUME ["/home/appuser/.config/mirror-package", "/home/appuser/.local/state/mirror-package"]
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/mirror-package"]
|
||||||
|
CMD ["--help"]
|
||||||
@@ -1,148 +1,254 @@
|
|||||||
# rust-template
|
# mirror-package
|
||||||
|
|
||||||
Ein modernes Template-Projekt für Rust-basierte Linux-Anwendungen und Kommandozeilen-Tools (CLI).
|
Automated tool to extract, download, and mirror prebuilt Linux packages from GitHub Releases into a self-hosted Gitea / Forgejo Package 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` tracks configured GitHub repositories (such as `raspberrypi/rpi-imager` or `Heroic-Games-Launcher/HeroicGamesLauncher`), identifies precompiled Linux packages (`.deb`, `.rpm`, `.pkg.tar.zst`, `.pkg.tar.xz`, `.pkg.tar.gz`, `.pacman`), and automatically publishes them into the appropriate Gitea Package Registry distributions according to release stability rules.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Rust Edition 2024**: Moderner Rust-Sprachstandard.
|
- **Multi-Distribution Package Support**:
|
||||||
- **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)
|
- **Distribution Routing Rules**:
|
||||||
- **Linux-Paketierung out-of-the-box**:
|
- **Stable Releases**: Debian packages are published to **both** `stable` and `testing` Debian pools (`pool/stable/main` and `pool/testing/main`); RPM packages to `rpm/stable`; Arch packages to `arch/stable`.
|
||||||
- **Debian / Ubuntu** (`.deb` via `cargo-deb`)
|
- **Pre-Releases**: Published **only** to testing channels: Debian `pool/testing/main`, RPM `rpm/testing`, and Arch `arch/testing`.
|
||||||
- **Fedora / RHEL / openSUSE** (`.rpm` via `cargo-generate-rpm`)
|
- **Persistent Configuration with `config-ctdra`**:
|
||||||
- **Arch Linux** (`.pkg.tar.zst` via mitgeliefertem `scripts/package-arch.py`)
|
- Automatically stores settings in user (`~/.config/mirror-package/config.toml`) or system (`/etc/mirror-package/config.toml`) paths.
|
||||||
- **Automatisierte CI/CD-Pipelines**:
|
- Adding or removing repositories via CLI automatically updates the configuration file.
|
||||||
- `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 Authentication**:
|
||||||
- `testing`-Branch: Erstellt Preview-Builds und Pre-Releases mit Testing-Paketen.
|
- GitHub releases can be queried anonymously (no token required).
|
||||||
- **Automatisierte Versions- & Build-Nummern**:
|
- Optional GitHub Personal Access Token support for higher 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 instance URL, API token, and registry owner configurable via CLI flags, environment variables, or config file.
|
||||||
- **Vorkonfigurierte Cargo-Einstellungen**:
|
- **Diagnostics & Dry-Run Mode**:
|
||||||
- `.cargo/config.toml` mit vorkonfigurierten Cross-Compilation-Linkern und Unterstützung für private/öffentliche Cargo Registries.
|
- Unified file and console logging powered by `logger-ctdra`.
|
||||||
|
- `--dry-run` flag to inspect download and upload steps without making remote changes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Struktur des Repositories
|
## CLI Usage & Commands
|
||||||
|
|
||||||
|
### 1. Configuration (`config`)
|
||||||
|
Set up your Gitea credentials and optional GitHub token:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Configure Gitea instance and registry owner
|
||||||
|
mirror-package config set \
|
||||||
|
--gitea-url "https://gitea.creative-dragonslayer.de" \
|
||||||
|
--gitea-token "your_gitea_api_token" \
|
||||||
|
--registry-owner "Linuxapps"
|
||||||
|
|
||||||
|
# Optional: Configure GitHub Token for rate limits
|
||||||
|
mirror-package config set --github-token "ghp_xxxxxxxxxxxx"
|
||||||
|
|
||||||
|
# Display current configuration
|
||||||
|
mirror-package config show
|
||||||
|
```
|
||||||
|
|
||||||
|
Credentials can also be passed via environment variables (`GITEA_URL`, `GITEA_TOKEN`, `REGISTRY_OWNER`, `GITHUB_TOKEN`) or global CLI arguments.
|
||||||
|
|
||||||
|
### 2. Managing Monitored Repositories (`add`, `remove`, `list`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add repositories to persistent configuration
|
||||||
|
mirror-package add raspberrypi/rpi-imager
|
||||||
|
mirror-package add Heroic-Games-Launcher/HeroicGamesLauncher
|
||||||
|
|
||||||
|
# Add repository without pre-release syncing
|
||||||
|
mirror-package add some-owner/some-repo --no-prereleases
|
||||||
|
|
||||||
|
# List configured repositories and their sync status
|
||||||
|
mirror-package list
|
||||||
|
|
||||||
|
# Remove a repository
|
||||||
|
mirror-package remove raspberrypi/rpi-imager
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Synchronizing Packages (`sync`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Sync all configured repositories (latest stable and latest pre-release)
|
||||||
|
mirror-package sync
|
||||||
|
|
||||||
|
# Sync a specific repository
|
||||||
|
mirror-package sync raspberrypi/rpi-imager
|
||||||
|
|
||||||
|
# Sync all historical releases for a repository
|
||||||
|
mirror-package sync raspberrypi/rpi-imager --history
|
||||||
|
|
||||||
|
# Dry-run test (simulates without uploading)
|
||||||
|
mirror-package sync --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker & Container Usage
|
||||||
|
|
||||||
|
`mirror-package` is packaged and published as an ultra-secure, lightweight container image to the Gitea Container Registry.
|
||||||
|
|
||||||
|
### 1. Image Registry & Tagging Strategy
|
||||||
|
|
||||||
|
- **Stable 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 & Preview 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>`
|
||||||
|
- *(Note: The `:latest` tag is strictly reserved for the stable `main` workflow).*
|
||||||
|
|
||||||
|
### 2. Persistent Storage Paths
|
||||||
|
|
||||||
|
To persist configurations, tracked repository states, and logs across container restarts, mount the following container paths to host directories or Docker volumes:
|
||||||
|
|
||||||
|
| Container Path | Purpose | Recommended Mount Type |
|
||||||
|
|---|---|---|
|
||||||
|
| `/home/appuser/.config/mirror-package` | Stores `config.toml` (credentials & repository list) | Host Directory / Volume (rw) |
|
||||||
|
| `/home/appuser/.local/state/mirror-package` | Persistent application logs & state files | Host Directory / Volume (rw) |
|
||||||
|
| `/tmp` | Download & stream buffer for package binaries | `tmpfs` (RAM / tempfs, rw, noexec) |
|
||||||
|
|
||||||
|
### 3. Security Hardening
|
||||||
|
|
||||||
|
The container is built with security as the highest priority:
|
||||||
|
- **Unprivileged User**: Runs as `appuser` (UID `10001`, GID `10001`), never as `root`.
|
||||||
|
- **Read-Only Root Filesystem**: Fully operational with `--read-only` / `read_only: true`.
|
||||||
|
- **No Capabilities**: All Linux capabilities can be safely dropped (`--cap-drop=ALL`).
|
||||||
|
- **No Privilege Escalation**: Enforces `no-new-privileges:true`.
|
||||||
|
- **Minimal Image Size**: Based on Debian Bookworm slim, containing only CA certificates and necessary shared libraries (~40 MB).
|
||||||
|
|
||||||
|
### 4. Running via Docker CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Initialize / configure credentials
|
||||||
|
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 "your_token" \
|
||||||
|
--registry-owner "Linuxapps"
|
||||||
|
|
||||||
|
# Add repositories
|
||||||
|
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
|
||||||
|
|
||||||
|
# Run synchronization (can be triggered by a host cronjob or scheduler)
|
||||||
|
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 Example (`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=your_gitea_api_token
|
||||||
|
- REGISTRY_OWNER=Linuxapps
|
||||||
|
- GITHUB_TOKEN=your_optional_github_pat
|
||||||
|
- LOG_LEVEL=info
|
||||||
|
command: ["sync"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repository Structure
|
||||||
|
|
||||||
```text
|
```text
|
||||||
├── .cargo/
|
├── .cargo/
|
||||||
│ └── config.toml # Cargo-Konfiguration (Linker für Cross-Compiling, Registry)
|
│ └── config.toml # Linker & Cargo configuration
|
||||||
├── .gitea/
|
├── .gitea/
|
||||||
│ └── workflows/
|
│ └── workflows/
|
||||||
│ ├── main.yaml # CI/CD-Workflow für stabile Releases (main-Branch)
|
│ ├── main.yaml # CI/CD: Release, Packages & Container (Stable)
|
||||||
│ └── testing.yaml # CI/CD-Workflow für Pre-Releases (testing-Branch)
|
│ └── testing.yaml # CI/CD: Preview, Packages & Container (Testing)
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── get-build-number.py # Dynamische Ermittlung der nächsten Paket-Revisionsnummer
|
│ ├── get-build-number.py # Dynamic build/revision number resolution
|
||||||
│ └── package-arch.py # Erstellung von Arch Linux .pkg.tar.zst Paketen
|
│ └── package-arch.py # Native Arch Linux package builder
|
||||||
├── src/
|
├── src/
|
||||||
│ └── main.rs # Quellcode & Einstiegspunkt der Anwendung
|
│ ├── main.rs # CLI entrypoint & subcommand dispatch
|
||||||
├── Cargo.toml # Cargo Manifest & Paketierungsmetadaten (deb, rpm, arch)
|
│ ├── lib.rs # Library root & module exports
|
||||||
├── LICENSE # Lizenzdatei (Standard: GPL-3.0-or-later)
|
│ ├── cli.rs # Clap CLI arguments & options
|
||||||
├── AGENTS.md # Richtlinien und Leitfaden für KI-Coding-Agenten
|
│ ├── config.rs # config-ctdra integration & models
|
||||||
└── README.md # Projektdokumentation
|
│ ├── gitea.rs # Gitea Package Registry upload client
|
||||||
|
│ ├── github.rs # GitHub API client & package classifier
|
||||||
|
│ └── pipeline.rs # End-to-end sync workflow & temp storage
|
||||||
|
├── tests/
|
||||||
|
│ ├── config_tests.rs # Tests for configuration management
|
||||||
|
│ ├── gitea_tests.rs # Tests for Gitea upload routing
|
||||||
|
│ └── github_tests.rs # Tests for package classification & parsing
|
||||||
|
├── .dockerignore # Container build ignore rules
|
||||||
|
├── Dockerfile # Secure minimal runtime container
|
||||||
|
├── docker-compose.example.yml # Example Docker Compose configuration
|
||||||
|
├── Cargo.toml # Project manifest and packaging metadata
|
||||||
|
├── LICENSE # GPL-3.0-or-later License
|
||||||
|
├── AGENTS.md # Agent & developer guidelines
|
||||||
|
└── README.md # Documentation
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Checkliste zur Verwendung als Template
|
## Local Development & Building
|
||||||
|
|
||||||
Wenn du ein neues Projekt aus diesem Template erstellst, gehe folgende Schritte durch:
|
### Prerequisites
|
||||||
|
- **Rust & Cargo** (Stable toolchain, Edition 2024 supported)
|
||||||
|
- **Python 3** (for packaging scripts)
|
||||||
|
|
||||||
1. **`Cargo.toml` anpassen**:
|
### Build & Test Commands
|
||||||
- `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
|
|
||||||
|
|
||||||
- **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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Projekt bauen
|
# Run syntax and type checks
|
||||||
cargo build
|
cargo check
|
||||||
|
|
||||||
# Tests ausführen
|
# Run unit tests
|
||||||
cargo test
|
cargo test
|
||||||
|
|
||||||
# Release-Build erstellen
|
# Build release binary
|
||||||
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
|
## License
|
||||||
|
|
||||||
### `scripts/get-build-number.py`
|
This project is licensed under the [GPL-3.0-or-later](LICENSE) license.
|
||||||
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.
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
services:
|
||||||
|
mirror-package:
|
||||||
|
image: gitea.creative-dragonslayer.de/linuxapps/mirror-package:latest
|
||||||
|
container_name: mirror-package
|
||||||
|
# Security: run as unprivileged user, read-only rootfs, drop capabilities
|
||||||
|
user: "10001:10001"
|
||||||
|
read_only: true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
# Persistent storage and temporary stream buffer
|
||||||
|
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=your_gitea_api_token
|
||||||
|
- REGISTRY_OWNER=Linuxapps
|
||||||
|
- GITHUB_TOKEN=your_optional_github_pat
|
||||||
|
- LOG_LEVEL=info
|
||||||
|
command: ["sync"]
|
||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Command line parser for mirror-package.
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(
|
||||||
|
name = "mirror-package",
|
||||||
|
author,
|
||||||
|
version,
|
||||||
|
about = "Mirror Linux packages (.deb, .rpm, .pkg.tar.zst) from GitHub Releases to Gitea Package Registry",
|
||||||
|
long_about = None
|
||||||
|
)]
|
||||||
|
pub struct Cli {
|
||||||
|
/// Gitea instance base URL (e.g. https://gitea.creative-dragonslayer.de)
|
||||||
|
#[arg(long, env = "GITEA_URL", global = true)]
|
||||||
|
pub gitea_url: Option<String>,
|
||||||
|
|
||||||
|
/// Gitea API Token with package write permissions
|
||||||
|
#[arg(long, env = "GITEA_TOKEN", global = true)]
|
||||||
|
pub gitea_token: Option<String>,
|
||||||
|
|
||||||
|
/// Gitea Package Registry owner / organization
|
||||||
|
#[arg(long, env = "REGISTRY_OWNER", global = true)]
|
||||||
|
pub registry_owner: Option<String>,
|
||||||
|
|
||||||
|
/// Optional GitHub Token for API requests (avoids rate limits)
|
||||||
|
#[arg(long, env = "GITHUB_TOKEN", global = true)]
|
||||||
|
pub github_token: Option<String>,
|
||||||
|
|
||||||
|
/// Custom path to configuration file
|
||||||
|
#[arg(long, global = true)]
|
||||||
|
pub config: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Perform a dry run without downloading or uploading packages
|
||||||
|
#[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 {
|
||||||
|
/// Synchronize packages from GitHub Releases to Gitea Package Registry
|
||||||
|
Sync(SyncArgs),
|
||||||
|
|
||||||
|
/// Add a GitHub repository to the persistent configuration
|
||||||
|
Add(AddArgs),
|
||||||
|
|
||||||
|
/// Remove a GitHub repository from the persistent configuration
|
||||||
|
#[command(alias = "rm")]
|
||||||
|
Remove(RemoveArgs),
|
||||||
|
|
||||||
|
/// List configured repositories and their sync status
|
||||||
|
#[command(alias = "ls")]
|
||||||
|
List,
|
||||||
|
|
||||||
|
/// View or update application configuration
|
||||||
|
Config(ConfigArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
pub struct SyncArgs {
|
||||||
|
/// Specific repository name or URL to sync (e.g. "raspberrypi/rpi-imager"). If omitted, all configured repositories are synced.
|
||||||
|
pub repo: Option<String>,
|
||||||
|
|
||||||
|
/// Sync all configured repositories
|
||||||
|
#[arg(short = 'a', long)]
|
||||||
|
pub all: bool,
|
||||||
|
|
||||||
|
/// Scan historical releases instead of only the latest release(s)
|
||||||
|
#[arg(long)]
|
||||||
|
pub history: bool,
|
||||||
|
|
||||||
|
/// Include pre-releases during this sync run (defaults to repository configuration if not specified)
|
||||||
|
#[arg(long)]
|
||||||
|
pub prereleases: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
pub struct AddArgs {
|
||||||
|
/// GitHub repository to mirror (e.g. "raspberrypi/rpi-imager" or full GitHub URL)
|
||||||
|
pub repo: String,
|
||||||
|
|
||||||
|
/// Do not mirror pre-releases for this repository
|
||||||
|
#[arg(long)]
|
||||||
|
pub no_prereleases: bool,
|
||||||
|
|
||||||
|
/// Immediately synchronize this repository after adding
|
||||||
|
#[arg(long)]
|
||||||
|
pub sync: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
pub struct RemoveArgs {
|
||||||
|
/// Repository to remove (e.g. "raspberrypi/rpi-imager")
|
||||||
|
pub repo: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
pub struct ConfigArgs {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub action: Option<ConfigAction>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
pub enum ConfigAction {
|
||||||
|
/// Show current configuration and configuration file location
|
||||||
|
Show,
|
||||||
|
|
||||||
|
/// Set configuration parameters
|
||||||
|
Set {
|
||||||
|
/// Gitea instance base URL
|
||||||
|
#[arg(long)]
|
||||||
|
gitea_url: Option<String>,
|
||||||
|
|
||||||
|
/// Gitea API Token
|
||||||
|
#[arg(long)]
|
||||||
|
gitea_token: Option<String>,
|
||||||
|
|
||||||
|
/// Gitea Package 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for an individual repository to mirror.
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||||
|
pub struct RepoConfig {
|
||||||
|
/// Full name of the repository in "owner/repo" format.
|
||||||
|
pub name: String,
|
||||||
|
/// Whether to mirror pre-releases.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub include_prereleases: bool,
|
||||||
|
/// Last successfully synced 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persistent application configuration.
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
/// Gitea instance base URL (e.g. https://gitea.example.com).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub gitea_url: Option<String>,
|
||||||
|
/// Gitea API authentication token.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub gitea_token: Option<String>,
|
||||||
|
/// Gitea Package Registry owner/organization name.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub registry_owner: Option<String>,
|
||||||
|
/// Optional GitHub Personal Access Token for increased rate limits.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub github_token: Option<String>,
|
||||||
|
/// Configured repositories to mirror.
|
||||||
|
#[serde(default)]
|
||||||
|
pub repositories: Vec<RepoConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppConfig {
|
||||||
|
/// Normalizes repository input, stripping leading/trailing slashes and GitHub URL prefixes.
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds or updates a repository in the configuration.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a repository by name. Returns true if it was found and removed.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds a repository by name.
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the last synced tag for a given 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets a custom path for the configuration file if provided.
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the resolved path of the configuration file.
|
||||||
|
pub fn get_config_file_path() -> PathBuf {
|
||||||
|
config_ctdra::get_config_path()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads the application configuration. Falls back to defaults if not found.
|
||||||
|
pub fn load_config() -> AppConfig {
|
||||||
|
config_ctdra::load_config::<AppConfig>()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves the application configuration to disk.
|
||||||
|
pub fn save_config(config: &AppConfig) -> Result<(), config_ctdra::ConfyError> {
|
||||||
|
config_ctdra::store(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modifies the application configuration in-place atomically and saves it.
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// Credentials and target registry information for 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of a package upload attempt.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum UploadStatus {
|
||||||
|
Uploaded,
|
||||||
|
AlreadyExists,
|
||||||
|
SimulatedDryRun,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes the target Gitea package registry upload URLs based on package type and release channel.
|
||||||
|
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 for uploading packages to a Gitea Package Registry.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct GiteaClient {
|
||||||
|
client: reqwest::Client,
|
||||||
|
config: GiteaConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GiteaClient {
|
||||||
|
/// Creates a new 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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uploads a package file from disk to the specified Gitea endpoint.
|
||||||
|
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};
|
||||||
|
|
||||||
|
/// Supported Linux package distribution types.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum PackageType {
|
||||||
|
Debian,
|
||||||
|
Rpm,
|
||||||
|
Arch,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PackageType {
|
||||||
|
/// Classifies an asset by its filename into a supported PackageType.
|
||||||
|
pub fn from_filename(filename: &str) -> Option<Self> {
|
||||||
|
let name = filename.to_ascii_lowercase();
|
||||||
|
// Check Arch packages first due to multi-part extensions
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns human-readable name of the package type.
|
||||||
|
pub fn name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
PackageType::Debian => "Debian (.deb)",
|
||||||
|
PackageType::Rpm => "Fedora/RPM (.rpm)",
|
||||||
|
PackageType::Arch => "Arch Linux (.pkg.tar.zst)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A classified Linux package asset from a release.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct ReleaseAsset {
|
||||||
|
pub name: String,
|
||||||
|
pub download_url: String,
|
||||||
|
pub size: u64,
|
||||||
|
pub package_type: PackageType,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A GitHub release with extracted Linux package 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 for interacting with the GitHub API.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct GitHubClient {
|
||||||
|
client: reqwest::Client,
|
||||||
|
token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GitHubClient {
|
||||||
|
/// Creates a new GitHub API client with optional authentication token.
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches releases for a given repository (format "owner/repo").
|
||||||
|
///
|
||||||
|
/// If `history` is false, retrieves only the latest release(s).
|
||||||
|
/// If `include_prereleases` is false, skips pre-releases.
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not scanning full history, stop after the first page (or once we found stable/prerelease)
|
||||||
|
if !history {
|
||||||
|
// Keep only the most recent stable release and most recent pre-release (if allowed)
|
||||||
|
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 {
|
||||||
|
// Avoid duplicates if stable and pre-release are identical (rare)
|
||||||
|
if !selected.iter().any(|r| r.id == prerelease.id) {
|
||||||
|
selected.push(prerelease.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(releases)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads an asset from the given download URL as a streaming response.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to parse "owner/repo" from repository string.
|
||||||
|
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,5 @@
|
|||||||
|
pub mod cli;
|
||||||
|
pub mod config;
|
||||||
|
pub mod gitea;
|
||||||
|
pub mod github;
|
||||||
|
pub mod pipeline;
|
||||||
+193
-9
@@ -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};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
// Initialize global logger level
|
||||||
|
set_log_level(cli.log_level.into());
|
||||||
|
|
||||||
|
// Initialize custom config path if specified
|
||||||
|
init_config_path(cli.config.as_deref());
|
||||||
|
|
||||||
|
// Load persistent configuration
|
||||||
|
let mut app_config = load_config();
|
||||||
|
|
||||||
|
// CLI flags override configuration values
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
match cli.command {
|
||||||
mod tests {
|
Commands::Sync(sync_args) => {
|
||||||
#[test]
|
let options = SyncOptions {
|
||||||
fn it_works() {
|
history: sync_args.history,
|
||||||
// TODO: Unit-Tests für die Anwendung schreiben
|
prereleases_override: sync_args.prereleases,
|
||||||
assert_eq!(2 + 2, 4);
|
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;
|
||||||
|
|
||||||
|
/// Summary statistics of a synchronization operation.
|
||||||
|
#[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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Options controlling a repository sync run.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SyncOptions {
|
||||||
|
pub history: bool,
|
||||||
|
pub prereleases_override: Option<bool>,
|
||||||
|
pub dry_run: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes the synchronization pipeline for a given repository.
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Resolve Gitea credentials
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download asset to temporary file
|
||||||
|
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))?;
|
||||||
|
|
||||||
|
// Upload asset to all target distribution URLs
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
// Cleanup temp file before returning error
|
||||||
|
let _ = remove_file(&temp_file_path).await;
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup temp file
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist last synced tag if not 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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads a GitHub release asset into a unique temporary file.
|
||||||
|
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";
|
||||||
|
|
||||||
|
// Stable Debian goes to both stable and testing pools
|
||||||
|
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 goes only to 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";
|
||||||
|
|
||||||
|
// Stable 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";
|
||||||
|
|
||||||
|
// Stable 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)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Non-package formats should be ignored
|
||||||
|
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