commit 0efaae17151ab118f6f7007314ee1a7202513362 Author: Mateusz Gruszczyński Date: Fri Oct 30 12:59:58 2026 +0100 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..939b930 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.env +data/ +gpg/ +*.zip +*.sha256 +**/__pycache__/ +**/.pytest_cache/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1cc3ebc --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +SOURCE_REPO=https://git.example.com/rustbox/RustBox.git +SOURCE_BRANCH=master +REPOSITORY_URL=http://127.0.0.1:18765 +HTTP_PORT=18765 +POLL_INTERVAL=300 +REPO_DISTRIBUTION=stable +REPO_COMPONENT=main +PACKAGE_RELEASE=auto +RUN_LINUX_TESTS=compile +MAX_GLIBC_VERSION=2.34 +BUILD_SUSE=1 +RELEASE_RETENTION=10 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e05834c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +data/ +gpg/ +*.zip +__pycache__/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e2b052a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,54 @@ +FROM rockylinux:9 + +ARG RUST_TOOLCHAIN=stable +ARG NFPM_VERSION=2.47.0 +ARG NFPM_RPM_SHA256=5ad3287a0b1d0c068e556bd84965a9866ab6451ef861506a3c09b8971ca5326f +ARG APTLY_VERSION=1.6.3 +ARG APTLY_ZIP_SHA256=535ed24eb706bc25a316b59d709a7e74e10165f2ffab8552097ba7e4816a3e7e + +RUN dnf -y install --allowerasing \ + bash binutils ca-certificates coreutils createrepo_c curl file findutils \ + gcc gcc-c++ git gnupg2 gzip make openssl openssl-devel pkgconf-pkg-config python3 python3.11 \ + rpm rsync shadow-utils tar unzip xz bzip2 zstd \ + && dnf clean all \ + && rm -rf /var/cache/dnf + +RUN set -eux; \ + curl -fsSL -o /tmp/nfpm.rpm \ + "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm-${NFPM_VERSION}-1.x86_64.rpm"; \ + echo "${NFPM_RPM_SHA256} /tmp/nfpm.rpm" | sha256sum -c -; \ + rpm -Uvh /tmp/nfpm.rpm; \ + rm -f /tmp/nfpm.rpm; \ + nfpm --version + +RUN set -eux; \ + curl -fsSL -o /tmp/aptly.zip \ + "https://github.com/aptly-dev/aptly/releases/download/v${APTLY_VERSION}/aptly_${APTLY_VERSION}_linux_amd64.zip"; \ + echo "${APTLY_ZIP_SHA256} /tmp/aptly.zip" | sha256sum -c -; \ + mkdir -p /tmp/aptly; \ + unzip -q /tmp/aptly.zip -d /tmp/aptly; \ + install -m 0755 "$(find /tmp/aptly -type f -name aptly | head -n1)" /usr/local/bin/aptly; \ + rm -rf /tmp/aptly /tmp/aptly.zip; \ + aptly version + +ENV RUSTUP_HOME=/opt/rustup \ + CARGO_HOME=/opt/cargo \ + PATH=/opt/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +RUN set -eux; \ + curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs -o /tmp/rustup-init.sh; \ + sh /tmp/rustup-init.sh -y --no-modify-path --profile minimal --default-toolchain "${RUST_TOOLCHAIN}"; \ + rm -f /tmp/rustup-init.sh; \ + rustc --version; \ + cargo --version + +ENV DATA_DIR=/var/lib/rustbox-package-builder \ + CARGO_HOME=/var/lib/rustbox-package-builder/cargo \ + CARGO_TARGET_DIR=/var/lib/rustbox-package-builder/cargo/target + +COPY scripts/ /opt/rustbox-package-builder/scripts/ +COPY packaging/ /opt/rustbox-package-builder/packaging/ +RUN chmod 0755 /opt/rustbox-package-builder/scripts/*.sh + +VOLUME ["/var/lib/rustbox-package-builder"] +ENTRYPOINT ["/opt/rustbox-package-builder/scripts/entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..a779016 --- /dev/null +++ b/README.md @@ -0,0 +1,218 @@ +# rustbox-package-builder + +Linux-only builder i repozytorium pakietów dla RustBox. Projekt jest uproszczoną wersją pipeline'u używanego dla RustProxy: nie zawiera FreeBSD, OPNsense ani dodatkowego agenta. + +## Co buduje + +Z jednego commita RustBox dla `amd64/x86_64`: + +- podpisany DEB i repo APT, +- podpisany RPM i repo DNF/YUM, +- opcjonalny osobny RPM dla SUSE/openSUSE i repo Zypper, +- panel WWW repozytorium w stylistyce RustBox, +- publiczny JSON `GET /api/v1/packages`. + +Builder obserwuje `SOURCE_BRANCH`. Nowy commit lub zmiana `PACKAGING_REVISION` uruchamia testy, release build i publikację nowego snapshotu. `current` jest przełączany atomowo dopiero po udanym pakowaniu i podpisaniu metadanych. + +## Wymaganie po stronie źródeł RustBox + +Repo RustBox musi obsługiwać natywne logowanie plikowe przez `log_dir`. Pakiet ustawia: + +```toml +log_dir = "/var/log/rustbox" +``` + +Builder celowo przerywa build, jeśli w źródłach nie ma obsługi `log_dir`. Dzięki temu logi aplikacji trafiają do `/var/log/rustbox/rustbox.log`, a nie do stdout usługi systemd. + +## Szybki start + +```bash +cp .env.example .env +nano .env +./start.sh +``` + +Minimalnie ustaw: + +```env +SOURCE_REPO=https://git.example.com/rustbox/RustBox.git +SOURCE_BRANCH=master +REPOSITORY_URL=https://repo.example.com +``` + +Dla lokalnego HTTP można użyć: + +```env +REPOSITORY_URL=http://192.168.1.100 +HTTP_PORT=18765 +``` + +`start.sh` utworzy trwały klucz GPG w `gpg/`, zbuduje obrazy i uruchomi: + +- `rustbox-package-builder` — watcher + build + publikacja, +- `repository-dashboard` — read-only panel/API, +- `repository-http` — Nginx udostępniający panel oraz artefakty APT/RPM. + +Dane buildera są trwałe w `data/`. + +## Domyślne ustawienia builda + +```env +POLL_INTERVAL=300 +REPO_DISTRIBUTION=stable +REPO_COMPONENT=main +PACKAGE_RELEASE=auto +RUN_LINUX_TESTS=compile +MAX_GLIBC_VERSION=2.34 +BUILD_SUSE=1 +RELEASE_RETENTION=10 +``` + +`RUN_LINUX_TESTS=compile` uruchamia `cargo test --release --locked --no-run`. Ustaw `full`, aby uruchamiać wszystkie testy, albo `0`, aby je wyłączyć. + +## Instalacja pakietu RustBox + +Pakiet instaluje: + +```text +/usr/local/bin/rustbox +/usr/lib/systemd/system/rustbox.service +/etc/rustbox/rustbox.toml +/etc/logrotate.d/rustbox +``` + +oraz tworzy: + +```text +/var/lib/rustbox rustbox:rustbox 0700 +/var/log/rustbox rustbox:rustbox 0750 +/etc/rustbox rustbox:rustbox 0750 +``` + +`/etc/rustbox/rustbox.toml` ma finalnie `rustbox:rustbox 0640` i jest oznaczony jako `config|noreplace`, więc aktualizacja pakietu nie nadpisuje lokalnej konfiguracji. + +### systemd + +Jednostka używa: + +```ini +User=rustbox +Group=rustbox +Environment=RUSTBOX_CONFIG=/etc/rustbox/rustbox.toml +WorkingDirectory=/var/lib/rustbox +ExecStart=/usr/local/bin/rustbox +StateDirectory=rustbox +LogsDirectory=rustbox +ProtectSystem=strict +ProtectHome=true +NoNewPrivileges=true +``` + +Logi tracing RustBox trafiają do `/var/log/rustbox/rustbox.log`. `StandardOutput=null`; `StandardError=journal` zostaje tylko dla błędów startowych, które wystąpią zanim aplikacja uruchomi własny logger. + +### logrotate + +`/etc/logrotate.d/rustbox`: + +```text +/var/log/rustbox/rustbox.log +``` + +jest obracany codziennie i przechowywany przez 7 rotacji, z kompresją i właścicielem `rustbox:rustbox`. + +## Domyślna konfiguracja pakietu + +```toml +host = "0.0.0.0" +port = 3001 +environment = "production" +data_dir = "/var/lib/rustbox" +log_dir = "/var/log/rustbox" +session_timeout_hours = 72 +remember_me_enabled = true +remember_me_days = 30 +imap_host = "localhost" +imap_port = 993 +smtp_host = "localhost" +smtp_port = 587 +tls_enabled = true +tls_verify = false +max_attachment_size_mb = 50 +max_message_size_mb = 100 +sieve_port = 4190 +sieve_tls_mode = "none" +sieve_allow_insecure_auth = true +sieve_timeout_secs = 10 +default_junk_folder = "Spam" +default_theme = "system" +default_interface_skin = "apple-mail" +trusted_proxies = "127.0.0.1,::1" +outgoing_headers = [] +pgp_wkd_enabled = true +pgp_hkp_enabled = false +pgp_hkp_url = "https://keys.openpgp.org" +pgp_network_timeout_secs = 10 +pgp_max_key_bytes = 1048576 +pgp_allow_private_export = true +``` + +## Repo APT + +Panel pokazuje gotowe polecenia. Ręcznie: + +```bash +curl -fsSL https://repo.example.com/repo-key.gpg \ + | sudo gpg --dearmor -o /usr/share/keyrings/rustbox.gpg + +echo "deb [signed-by=/usr/share/keyrings/rustbox.gpg] https://repo.example.com/apt stable main" \ + | sudo tee /etc/apt/sources.list.d/rustbox.list + +sudo apt update +sudo apt install rustbox +``` + +## Repo DNF/YUM + +```bash +sudo rpm --import https://repo.example.com/repo-key.gpg +sudo tee /etc/yum.repos.d/rustbox.repo >/dev/null <<'REPO' +[rustbox] +name=RustBox Repository +baseurl=https://repo.example.com/rpm/$basearch +enabled=1 +gpgcheck=1 +repo_gpgcheck=1 +gpgkey=https://repo.example.com/repo-key.gpg +REPO +sudo dnf install rustbox +``` + +## Repo SUSE/Zypper + +```bash +sudo rpm --import https://repo.example.com/repo-key.gpg +sudo zypper addrepo --refresh --gpgcheck https://repo.example.com/suse/x86_64/ rustbox +sudo zypper refresh rustbox +sudo zypper install rustbox +``` + +## Reverse proxy + +Nginx buildera nasłuchuje lokalnie na `HTTP_PORT` (domyślnie `18765`). Dla publicznego HTTPS zalecany jest zewnętrzny reverse proxy kierujący na ten port. + +## Logi buildera + +```bash +docker compose --env-file .env logs -f rustbox-package-builder +docker compose --env-file .env logs -f repository-http +docker compose --env-file .env logs -f repository-dashboard +``` + +## Licencja paczki + +Dostarczone źródła RustBox nie zawierają autorytatywnego pliku `LICENSE`; `NOTICE.md` zaleca weryfikację licencji upstream przed publiczną redystrybucją. Dlatego builder ustawia obecnie metadane pakietu na `UNKNOWN`, zamiast zgadywać licencję. Przed publicznym repozytorium warto ustalić właściwe oznaczenie i wtedy zmienić je w `scripts/prepare-package.sh`. + +## Zależności runtime + +Pakiety deklarują jawnie systemowy OpenSSL i GnuPG używane przez RustBox. Dla APT są to `libssl3`/`libssl3t64` i `gnupg`, dla RHEL/Rocky `openssl-libs` i `gnupg2`, a dla SUSE `libopenssl3` i `gpg2`. + diff --git a/dashboard/.dockerignore b/dashboard/.dockerignore new file mode 100644 index 0000000..8d35cb3 --- /dev/null +++ b/dashboard/.dockerignore @@ -0,0 +1,2 @@ +__pycache__ +*.pyc diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile new file mode 100644 index 0000000..4704e0b --- /dev/null +++ b/dashboard/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.13-alpine +WORKDIR /app +COPY app.py /app/app.py +COPY web /app/web +USER 65532:65532 +EXPOSE 8000 +ENTRYPOINT ["python3", "/app/app.py"] diff --git a/dashboard/app.py b/dashboard/app.py new file mode 100644 index 0000000..49262d9 --- /dev/null +++ b/dashboard/app.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import logging +import os +import re +import threading +import time +from datetime import datetime, timezone +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import quote, urlsplit + +LOG = logging.getLogger("rustbox.repository") +REPOSITORY = Path(os.environ.get("REPOSITORY_ROOT", "/srv/repository")) +WEB = Path(__file__).resolve().parent / "web" +PORT = int(os.environ.get("DASHBOARD_PORT", "8000")) +CACHE_SECONDS = max(1, int(os.environ.get("DASHBOARD_CACHE_SECONDS", "15"))) +REPOSITORY_URL = os.environ.get("REPOSITORY_URL", "http://localhost:18765").rstrip("/") +SITES = ("linux", "suse") +META_PATHS = {"linux": Path("LINUX-BUILD-INFO.txt"), "suse": Path("SUSE-BUILD-INFO.txt")} +DEB_FILENAME = re.compile(r"^(?P.+?)_(?P[^_]+)_(?P[^_]+)\.deb$") +RPM_FILENAME = re.compile(r"^(?P.+)-(?P\d[^-]*)-(?P[^/]+)\.(?Px86_64|amd64|aarch64|noarch|arm64)\.rpm$") + + +def iso_time(stamp: float) -> str: + return datetime.fromtimestamp(stamp, tz=timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + + +def parse_metadata(path: Path) -> dict[str, str]: + try: + text = path.read_text(encoding="utf-8") + except OSError: + return {} + result: dict[str, str] = {} + for line in text.splitlines(): + key, sep, value = line.partition("=") + if sep and re.fullmatch(r"[a-z][a-z0-9_]*", key): + result[key] = value.strip()[:1024] + return result + + +def get_current(repository: Path) -> Path | None: + try: + root = repository.resolve(strict=True) + current = (root / "current").resolve(strict=True) + if current.is_dir() and current.is_relative_to(root): + return current + except (OSError, RuntimeError): + pass + return None + + +def publication_info(current: Path, channel: str) -> dict[str, object]: + location = current / META_PATHS[channel] + raw = parse_metadata(location) + built_at = raw.get("built_at") or None + return { + "channel": channel, + "published": bool(raw), + "app_version": raw.get("app_version", ""), + "package_release": raw.get("package_release", ""), + "package_version": f"{raw.get('app_version','')}-{raw.get('package_release','')}" if raw.get("app_version") and raw.get("package_release") else raw.get("app_version", ""), + "source_commit": raw.get("source_commit", ""), + "source_ref": raw.get("source_ref", ""), + "source_url": raw.get("source_url", ""), + "packaging_revision": raw.get("packaging_revision", ""), + "built_at": built_at, + } + + +def parse_package(path: Path, kind: str) -> tuple[str, str, str] | None: + pattern = DEB_FILENAME if kind == "deb" else RPM_FILENAME + m = pattern.fullmatch(path.name) + if not m: + return None + if kind == "deb": + return m["name"], m["version"], m["arch"] + return m["name"], f"{m['version']}-{m['release']}", m["arch"] + + +def generate_catalog(repository: Path = REPOSITORY) -> dict[str, object]: + current = get_current(repository) + empty = {"published": False, "app_version": "", "package_release": "", "package_version": "", "source_commit": "", "source_ref": "", "source_url": "", "packaging_revision": "", "built_at": None} + builds = {site: publication_info(current, site) if current else {"channel": site, **empty} for site in SITES} + packages: list[dict[str, object]] = [] + if current: + scans = [ + ("linux", "DEB", "deb", current / "apt" / "pool", "*.deb"), + ("linux", "RPM", "rpm", current / "rpm" / "x86_64", "*.rpm"), + ("suse", "RPM", "rpm", current / "suse" / "x86_64", "*.rpm"), + ] + for channel, label, kind, directory, pattern in scans: + if not directory.is_dir(): + continue + for path in directory.rglob(pattern): + parsed = parse_package(path, kind) + if not parsed or not path.is_file(): + continue + name, version, arch = parsed + stat = path.stat() + rel = path.relative_to(current) + encoded = "/".join(quote(part, safe="") for part in rel.parts) + packages.append({ + "name": name, + "version": version, + "architecture": arch, + "format": label, + "channel": channel, + "size_bytes": stat.st_size, + "built_at": iso_time(stat.st_mtime), + "download_url": f"{REPOSITORY_URL}/{encoded}", + "relative_path": rel.as_posix(), + }) + packages.sort(key=lambda item: (str(item["built_at"]), str(item["version"])), reverse=True) + build_times = [str(item["built_at"]) for item in builds.values() if item.get("built_at")] + return { + "generated_at": iso_time(time.time()), + "ready": bool(packages), + "repository_url": REPOSITORY_URL, + "apt_distribution": os.environ.get("REPO_DISTRIBUTION", "stable"), + "apt_component": os.environ.get("REPO_COMPONENT", "main"), + "builds": builds, + "packages": packages, + "summary": { + "packages_count": len(packages), + "channels_published": sum(bool(item.get("published")) for item in builds.values()), + "latest_build_at": max(build_times, default=None), + }, + } + + +class Cache: + def __init__(self) -> None: + self.lock = threading.Lock() + self.expires = 0.0 + self.snapshot: dict[str, object] | None = None + + def get(self) -> dict[str, object]: + with self.lock: + if self.snapshot is None or time.monotonic() >= self.expires: + self.snapshot = generate_catalog() + self.expires = time.monotonic() + CACHE_SECONDS + return self.snapshot + + +CACHE = Cache() + + +class Handler(BaseHTTPRequestHandler): + server_version = "RustBoxRepository/1.0" + sys_version = "" + + def do_GET(self) -> None: + path = urlsplit(self.path).path + if path == "/healthz": + self.send_bytes(b"ok\n", "text/plain; charset=utf-8", "no-store") + elif path == "/api/v1/packages": + self.send_bytes(json.dumps(CACHE.get(), ensure_ascii=False, separators=(",", ":")).encode(), "application/json; charset=utf-8", "no-store") + elif path in ("/", "/index.html"): + self.send_bytes((WEB / "index.html").read_bytes(), "text/html; charset=utf-8", "no-store", True) + elif path in ("/ui/style.css", "/ui/app.js", "/ui/favicon.svg"): + name = path.rsplit("/", 1)[-1] + mime = {"style.css": "text/css", "app.js": "application/javascript", "favicon.svg": "image/svg+xml"}[name] + self.send_bytes((WEB / name).read_bytes(), f"{mime}; charset=utf-8", "public, max-age=600") + else: + self.send_error(HTTPStatus.NOT_FOUND) + + def do_HEAD(self) -> None: + self.do_GET() + + def send_bytes(self, payload: bytes, mime: str, cache: str, html: bool = False) -> None: + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", mime) + self.send_header("Content-Length", str(len(payload))) + self.send_header("Cache-Control", cache) + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("X-Frame-Options", "DENY") + if html: + self.send_header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'") + self.end_headers() + if self.command != "HEAD": + self.wfile.write(payload) + + def log_message(self, fmt: str, *args: object) -> None: + LOG.info("%s - %s", self.address_string(), fmt % args) + + +def main() -> None: + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler) + server.daemon_threads = True + LOG.info("RustBox repository dashboard ready on %s", PORT) + server.serve_forever(poll_interval=0.5) + + +if __name__ == "__main__": + main() diff --git a/dashboard/web/app.js b/dashboard/web/app.js new file mode 100644 index 0000000..f7fe4c3 --- /dev/null +++ b/dashboard/web/app.js @@ -0,0 +1,25 @@ +(() => { + const $ = (id) => document.getElementById(id); + const esc = (value) => String(value ?? '').replace(/[&<>"']/g, (c) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + const state = {data:null, view:'overview', install:'apt', search:'', channel:'all', format:'all'}; + const titles = {overview:'Przegląd',packages:'Paczki',install:'Instalacja',repositories:'Repozytoria'}; + const fmtDate = (value) => value ? new Intl.DateTimeFormat('pl-PL',{dateStyle:'medium',timeStyle:'short'}).format(new Date(value)) : '—'; + const fmtSize = (n) => n < 1024 ? `${n} B` : n < 1048576 ? `${(n/1024).toFixed(1)} KiB` : `${(n/1048576).toFixed(1)} MiB`; + function toast(text){const t=$('toast');t.textContent=text;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),1600)} + function navigate(view){state.view=view;document.querySelectorAll('.view').forEach(x=>x.classList.toggle('active',x.id===view));document.querySelectorAll('.nav').forEach(x=>x.classList.toggle('active',x.dataset.view===view));$('view-title').textContent=titles[view]||view;location.hash=view} + function packageRow(p){return `
${esc(p.name)}
${esc(p.version)}
${esc(p.channel.toUpperCase())}${esc(p.format)}${esc(fmtSize(p.size_bytes))}Pobierz
`} + function renderOverview(){const d=state.data;if(!d)return;const linux=d.builds.linux||{};const suse=d.builds.suse||{};$('latest-version').textContent=linux.app_version||'—';$('latest-build').textContent=fmtDate(linux.built_at);$('packages-count').textContent=d.summary.packages_count;$('linux-status').textContent=linux.published?'GOTOWE':'BRAK';$('suse-status').textContent=suse.published?'GOTOWE':'BRAK';$('recent-packages').innerHTML=d.packages.slice(0,6).map(packageRow).join('')||'
Brak opublikowanych paczek.
'} + function renderPackages(){if(!state.data)return;const q=state.search.toLowerCase();const list=state.data.packages.filter(p=>(state.channel==='all'||p.channel===state.channel)&&(state.format==='all'||p.format===state.format)&&(!q||`${p.name} ${p.version} ${p.architecture}`.toLowerCase().includes(q)));$('packages-table').innerHTML=list.map(p=>`
${esc(p.name)}
${esc(p.version)}
${esc(p.channel.toUpperCase())}${esc(p.format)}${esc(fmtDate(p.built_at))}Pobierz
`).join('')||'
Brak wyników.
'} + function instruction(kind){const d=state.data;const url=d.repository_url,dist=d.apt_distribution,comp=d.apt_component;if(kind==='dnf')return {title:'RHEL / Rocky / Alma / Fedora',desc:'Instalacja przez podpisane repozytorium DNF/YUM.',code:`sudo rpm --import ${url}/repo-key.gpg\n\nsudo tee /etc/yum.repos.d/rustbox.repo >/dev/null <<'REPO'\n[rustbox]\nname=RustBox Repository\nbaseurl=${url}/rpm/$basearch\nenabled=1\ngpgcheck=1\nrepo_gpgcheck=1\ngpgkey=${url}/repo-key.gpg\nREPO\n\nsudo dnf install rustbox`};if(kind==='suse')return {title:'SUSE / openSUSE',desc:'Instalacja przez podpisane repozytorium Zypper.',code:`sudo rpm --import ${url}/repo-key.gpg\nsudo zypper addrepo --refresh --gpgcheck ${url}/suse/x86_64/ rustbox\nsudo zypper refresh rustbox\nsudo zypper install rustbox`};return {title:'Debian / Ubuntu',desc:'Instalacja przez podpisane repozytorium APT.',code:`curl -fsSL ${url}/repo-key.gpg | sudo gpg --dearmor -o /usr/share/keyrings/rustbox.gpg\n\necho "deb [signed-by=/usr/share/keyrings/rustbox.gpg] ${url}/apt ${dist} ${comp}" | sudo tee /etc/apt/sources.list.d/rustbox.list\n\nsudo apt update\nsudo apt install rustbox`}} + function renderInstall(){if(!state.data)return;const x=instruction(state.install);$('install-title').textContent=x.title;$('install-desc').textContent=x.desc;$('install-command').textContent=x.code;document.querySelectorAll('[data-install]').forEach(b=>b.classList.toggle('active',b.dataset.install===state.install))} + function repoCard(title,url,links){return ``} + function renderRepositories(){const d=state.data;if(!d)return;const u=d.repository_url,dist=encodeURIComponent(d.apt_distribution);$('repo-cards').innerHTML=repoCard('APT + DNF',u,[['GPG public key',`${u}/repo-key.gpg`],['APT InRelease',`${u}/apt/dists/${dist}/InRelease`],['RPM repomd.xml',`${u}/rpm/x86_64/repodata/repomd.xml`],['Build info',`${u}/LINUX-BUILD-INFO.txt`]])+repoCard('SUSE / Zypper',`${u}/suse/x86_64`,[['RPM repomd.xml',`${u}/suse/x86_64/repodata/repomd.xml`],['Metadata signature',`${u}/suse/x86_64/repodata/repomd.xml.asc`],['Build info',`${u}/SUSE-BUILD-INFO.txt`]]);$('metadata').innerHTML=['linux','suse'].map(ch=>{const b=d.builds[ch]||{};return `

${ch==='linux'?'Linux':'SUSE'}

${[['Wersja',b.app_version],['Wydanie paczki',b.package_release],['Build',fmtDate(b.built_at)],['Commit',b.source_commit?b.source_commit.slice(0,12):'—'],['Rewizja pakowania',b.packaging_revision]].map(([a,v])=>`
${esc(a)}${esc(v||'—')}
`).join('')}
`}).join('')} + function renderAll(){renderOverview();renderPackages();renderInstall();renderRepositories()} + async function refresh(){const btn=$('refresh');btn.disabled=true;try{const r=await fetch('/api/v1/packages',{cache:'no-store'});if(!r.ok)throw new Error(`HTTP ${r.status}`);state.data=await r.json();renderAll();$('last-sync').textContent=`Ostatnia synchronizacja: ${fmtDate(state.data.generated_at)}`;$('status-dot').classList.add('ok');$('status-text').textContent=state.data.ready?'Repozytorium aktywne':'Oczekiwanie na build'}catch(e){$('status-dot').classList.remove('ok');$('status-text').textContent='Błąd odczytu repozytorium';console.error(e)}finally{btn.disabled=false}} + function setTheme(theme){document.documentElement.dataset.theme=theme;localStorage.setItem('rustbox-repo-theme',theme)} + document.querySelectorAll('[data-view]').forEach(b=>b.addEventListener('click',()=>navigate(b.dataset.view))); + document.querySelectorAll('[data-go]').forEach(b=>b.addEventListener('click',()=>navigate(b.dataset.go))); + document.querySelectorAll('[data-install]').forEach(b=>b.addEventListener('click',()=>{state.install=b.dataset.install;renderInstall()})); + $('search').addEventListener('input',e=>{state.search=e.target.value;renderPackages()});$('channel').addEventListener('change',e=>{state.channel=e.target.value;renderPackages()});$('format').addEventListener('change',e=>{state.format=e.target.value;renderPackages()});$('refresh').addEventListener('click',refresh);$('copy-install').addEventListener('click',async()=>{try{await navigator.clipboard.writeText($('install-command').textContent);toast('Skopiowano')}catch{toast('Nie udało się skopiować')}});$('theme-toggle').addEventListener('click',()=>setTheme(document.documentElement.dataset.theme==='dark'?'light':'dark')); + try{setTheme(localStorage.getItem('rustbox-repo-theme')||'dark')}catch{}navigate(location.hash.slice(1)||'overview');window.addEventListener('hashchange',()=>navigate(location.hash.slice(1)||'overview'));refresh();setInterval(()=>{if(!document.hidden)refresh()},60000); +})(); diff --git a/dashboard/web/favicon.svg b/dashboard/web/favicon.svg new file mode 100644 index 0000000..6cc6996 --- /dev/null +++ b/dashboard/web/favicon.svg @@ -0,0 +1 @@ + diff --git a/dashboard/web/index.html b/dashboard/web/index.html new file mode 100644 index 0000000..695e82c --- /dev/null +++ b/dashboard/web/index.html @@ -0,0 +1,64 @@ + + + + + + + RustBox Repository + + + + +
+ +
+
+

Przegląd

—

+
+
+ +
+
+
RUSTBOX / LINUX

Repozytorium wydań

Podpisane paczki APT, DNF/RPM i Zypper budowane bezpośrednio ze źródeł RustBox.

+
NAJNOWSZA WERSJA——
+
+
+
Paczki0opublikowane artefakty
+
Linux—APT + DNF
+
SUSE—Zypper RPM
+
+

Ostatnie paczki

+
+ +
+
+
+
+
+
+ +
+
+

Debian / Ubuntu

Instalacja przez podpisane repozytorium APT.

+
Po instalacji konfiguracja znajduje się w /etc/rustbox/rustbox.toml, dane w /var/lib/rustbox, a logi aplikacji w /var/log/rustbox/rustbox.log.
+
+ +
+
+

Metadane buildu

+
+
+
+
+ + + diff --git a/dashboard/web/style.css b/dashboard/web/style.css new file mode 100644 index 0000000..f58630f --- /dev/null +++ b/dashboard/web/style.css @@ -0,0 +1,3 @@ +:root{color-scheme:dark;--bg:#121513;--panel:#181c19;--panel2:#1e231f;--panel3:#242a25;--sidebar:#101310;--line:#313832;--line2:#485149;--text:#edf0ec;--muted:#9da79f;--subtle:#7c877f;--accent:#8fb9a4;--accent2:#a4c9b6;--good:#a7c892;--field:#121713;--hover:#202621;--shadow:rgba(0,0,0,.32);font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} +html[data-theme="light"]{color-scheme:light;--bg:#f2f4f1;--panel:#fff;--panel2:#f7f8f6;--panel3:#edf1ec;--sidebar:#e9eee9;--line:#cbd2cb;--line2:#aeb8af;--text:#202622;--muted:#647068;--subtle:#7a847d;--accent:#507b67;--accent2:#416b58;--good:#587a49;--field:#fff;--hover:#e8ede8;--shadow:rgba(31,39,33,.16)} +*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-size:14px}.shell{min-height:100vh;display:grid;grid-template-columns:228px minmax(0,1fr)}.sidebar{position:sticky;top:0;height:100vh;background:var(--sidebar);border-right:1px solid var(--line);padding:18px 12px;display:flex;flex-direction:column}.brand{display:flex;gap:11px;align-items:center;padding:5px 8px 24px}.brand-icon{width:34px;height:34px;border-radius:9px;background:var(--accent);color:#102018;display:grid;place-items:center;font-weight:800;font-size:18px}.brand strong,.brand span{display:block}.brand span{color:var(--muted);font-size:11px;margin-top:2px}.sidebar nav{display:grid;gap:3px}.nav{border:0;background:transparent;color:var(--muted);text-align:left;padding:10px 12px;border-radius:5px;cursor:pointer;font-weight:600}.nav:hover{background:var(--hover);color:var(--text)}.nav.active{background:var(--panel3);color:var(--text);box-shadow:inset 2px 0 var(--accent)}.sidebar-foot{margin-top:auto;border-top:1px solid var(--line);padding:15px 8px 2px;color:var(--muted);display:flex;align-items:center;gap:8px;font-size:12px}.status-dot{width:8px;height:8px;border-radius:50%;background:var(--subtle)}.status-dot.ok{background:var(--good);box-shadow:0 0 0 3px color-mix(in srgb,var(--good) 15%,transparent)}main{min-width:0}.topbar{height:72px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 24px;background:color-mix(in srgb,var(--bg) 84%,transparent);backdrop-filter:blur(10px);position:sticky;top:0;z-index:5}.topbar h1{font-size:18px;margin:0}.topbar p{margin:3px 0 0;color:var(--subtle);font-size:11px}.toolbar{display:flex;gap:7px}.toolbar button,.panel button,.install-tabs button{border:1px solid var(--line);background:var(--panel);color:var(--text);min-height:32px;padding:0 11px;border-radius:5px;cursor:pointer}.toolbar button:hover,.panel button:hover,.install-tabs button:hover{border-color:var(--line2);background:var(--hover)}.view{display:none;padding:24px;max-width:1320px;margin:0 auto}.view.active{display:block}.hero{display:grid;grid-template-columns:minmax(0,1fr) 240px;gap:18px;background:linear-gradient(135deg,var(--panel),var(--panel2));border:1px solid var(--line);border-radius:7px;padding:28px}.hero h2{font-size:30px;margin:7px 0 8px}.hero p{color:var(--muted);max-width:700px;margin:0;line-height:1.55}.eyebrow{color:var(--accent2);font-size:11px;font-weight:800;letter-spacing:.14em}.version-box{background:var(--field);border:1px solid var(--line);border-radius:6px;padding:18px;display:flex;flex-direction:column;justify-content:center}.version-box span{color:var(--subtle);font-size:10px;letter-spacing:.1em}.version-box strong{font-size:27px;margin:5px 0}.version-box small{color:var(--muted)}.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin:12px 0}.cards article{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:17px}.cards span,.cards small{display:block;color:var(--muted)}.cards strong{display:block;font-size:22px;margin:5px 0}.panel{background:var(--panel);border:1px solid var(--line);border-radius:6px;margin-top:12px;overflow:hidden}.panel-head{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid var(--line)}.panel-head h3{margin:0;font-size:14px}.panel-head p{margin:4px 0 0;color:var(--muted)}.package-row,.table-row{display:grid;grid-template-columns:minmax(220px,1fr) 150px 90px 110px 150px;gap:12px;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line)}.package-row:last-child,.table-row:last-child{border-bottom:0}.package-row:hover,.table-row:hover{background:var(--hover)}.pkg-name{font-weight:650;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.muted{color:var(--muted)}.pill{display:inline-flex;align-items:center;border:1px solid var(--line2);border-radius:99px;padding:3px 7px;font-size:10px;color:var(--muted);width:max-content}.download{color:var(--accent2);text-decoration:none;font-weight:650}.filters{display:grid;grid-template-columns:minmax(240px,1fr) 190px 170px;gap:8px;padding:12px;border-bottom:1px solid var(--line)}input,select{width:100%;height:34px;background:var(--field);color:var(--text);border:1px solid var(--line);border-radius:5px;padding:0 10px;outline:none}input:focus,select:focus{border-color:var(--accent)}.install-tabs{display:flex;gap:7px;flex-wrap:wrap}.install-tabs button.active{background:var(--accent);border-color:var(--accent);color:#102018;font-weight:700}.command-panel pre{margin:0;background:#0c0f0d;color:#e8efe9;padding:18px;overflow:auto;font:12px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace}.note{margin-top:12px;border:1px solid var(--line);background:var(--panel2);padding:14px;border-radius:6px;color:var(--muted)}code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}.repo-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}.repo-card{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:16px}.repo-card h3{margin:0 0 8px}.repo-card>a{color:var(--accent2);word-break:break-all}.repo-links{display:grid;margin-top:12px;border-top:1px solid var(--line)}.repo-links a{padding:9px 0;color:var(--muted);text-decoration:none;border-bottom:1px solid var(--line)}.metadata{display:grid;grid-template-columns:repeat(2,1fr)}.meta-block{padding:16px}.meta-row{display:flex;justify-content:space-between;gap:18px;padding:8px 0;border-bottom:1px solid var(--line)}.meta-row span{color:var(--muted)}.toast{position:fixed;right:18px;bottom:18px;background:var(--panel3);border:1px solid var(--line2);box-shadow:0 12px 35px var(--shadow);padding:10px 14px;border-radius:6px;opacity:0;transform:translateY(8px);pointer-events:none;transition:.18s}.toast.show{opacity:1;transform:none}.empty{padding:28px 16px;color:var(--muted);text-align:center}@media(max-width:860px){.shell{grid-template-columns:1fr}.sidebar{position:static;height:auto;border-right:0;border-bottom:1px solid var(--line);padding:10px}.brand{padding:4px 5px 10px}.sidebar nav{grid-template-columns:repeat(4,1fr)}.nav{text-align:center;padding:9px 5px;font-size:12px}.sidebar-foot{display:none}.topbar{top:0;height:62px;padding:0 14px}.view{padding:14px}.hero{grid-template-columns:1fr;padding:20px}.cards{grid-template-columns:1fr}.repo-grid,.metadata{grid-template-columns:1fr}.filters{grid-template-columns:1fr}.package-row,.table-row{grid-template-columns:minmax(0,1fr) 90px 72px}.package-row>*:nth-child(4),.package-row>*:nth-child(5),.table-row>*:nth-child(4),.table-row>*:nth-child(5){display:none}.hero h2{font-size:24px}} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ae9c69a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,58 @@ +services: + rustbox-package-builder: + build: + context: . + image: rustbox/package-builder:local + environment: + SOURCE_REPO: "${SOURCE_REPO:?SOURCE_REPO is required}" + SOURCE_BRANCH: "${SOURCE_BRANCH:-master}" + REPOSITORY_URL: "${REPOSITORY_URL:?REPOSITORY_URL is required}" + POLL_INTERVAL: "${POLL_INTERVAL:-300}" + REPO_DISTRIBUTION: "${REPO_DISTRIBUTION:-stable}" + REPO_COMPONENT: "${REPO_COMPONENT:-main}" + PACKAGE_RELEASE: "${PACKAGE_RELEASE:-auto}" + RUN_LINUX_TESTS: "${RUN_LINUX_TESTS:-compile}" + MAX_GLIBC_VERSION: "${MAX_GLIBC_VERSION:-2.34}" + BUILD_SUSE: "${BUILD_SUSE:-1}" + RELEASE_RETENTION: "${RELEASE_RETENTION:-10}" + volumes: + - ./data:/var/lib/rustbox-package-builder + - ./gpg:/run/secrets/gpg:ro + restart: unless-stopped + + repository-dashboard: + build: + context: ./dashboard + image: rustbox/repository-dashboard:local + environment: + REPOSITORY_ROOT: /srv/repository + REPOSITORY_URL: "${REPOSITORY_URL:?REPOSITORY_URL is required}" + REPO_DISTRIBUTION: "${REPO_DISTRIBUTION:-stable}" + REPO_COMPONENT: "${REPO_COMPONENT:-main}" + volumes: + - ./data/repository:/srv/repository:ro + read_only: true + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + restart: unless-stopped + + repository-http: + depends_on: + - repository-dashboard + image: nginxinc/nginx-unprivileged:stable-alpine-slim + ports: + - "0.0.0.0:${HTTP_PORT:-18965}:8080" + volumes: + - ./data/repository:/srv/repository:ro + - ./http/nginx.conf:/etc/nginx/nginx.conf:ro + entrypoint: [ "nginx", "-g", "daemon off;" ] + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=16m + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + restart: unless-stopped diff --git a/http/nginx.conf b/http/nginx.conf new file mode 100644 index 0000000..67c9c4f --- /dev/null +++ b/http/nginx.conf @@ -0,0 +1,46 @@ +worker_processes 1; +pid /tmp/nginx.pid; +error_log /dev/stderr warn; + +events { worker_connections 256; } + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + access_log /dev/stdout; + server_tokens off; + sendfile on; + tcp_nopush on; + keepalive_timeout 15s; + client_max_body_size 1k; + + client_body_temp_path /tmp/client_temp; + proxy_temp_path /tmp/proxy_temp; + fastcgi_temp_path /tmp/fastcgi_temp; + uwsgi_temp_path /tmp/uwsgi_temp; + scgi_temp_path /tmp/scgi_temp; + + server { + listen 8080 default_server; + server_name _; + root /srv/repository; + index index.html; + autoindex off; + + if ($request_method !~ ^(GET|HEAD)$) { return 405; } + + location = /healthz { + default_type text/plain; + return 200 "ok\n"; + } + location = / { proxy_pass http://repository-dashboard:8000/; } + location = /index.html { proxy_pass http://repository-dashboard:8000/index.html; } + location ^~ /ui/ { proxy_pass http://repository-dashboard:8000; } + location = /api/v1/packages { proxy_pass http://repository-dashboard:8000; } + location ~ (^|/)\. { deny all; } + location / { + expires 10m; + try_files $uri =404; + } + } +} diff --git a/packaging/rustbox.logrotate b/packaging/rustbox.logrotate new file mode 100644 index 0000000..d561088 --- /dev/null +++ b/packaging/rustbox.logrotate @@ -0,0 +1,11 @@ +/var/log/rustbox/rustbox.log { + daily + rotate 7 + missingok + notifempty + compress + delaycompress + copytruncate + create 0600 rustbox rustbox + su rustbox rustbox +} diff --git a/packaging/rustbox.service b/packaging/rustbox.service new file mode 100644 index 0000000..6425b25 --- /dev/null +++ b/packaging/rustbox.service @@ -0,0 +1,37 @@ +[Unit] +Description=RustBox webmail +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=rustbox +Group=rustbox +Environment=RUSTBOX_CONFIG=/etc/rustbox/rustbox.toml +Environment=RUST_LOG=info +WorkingDirectory=/var/lib/rustbox +ExecStart=/usr/local/bin/rustbox +Restart=on-failure +RestartSec=3 +StateDirectory=rustbox +StateDirectoryMode=0700 +LogsDirectory=rustbox +LogsDirectoryMode=0750 +UMask=0077 +NoNewPrivileges=true +PrivateTmp=true +PrivateDevices=true +ProtectSystem=strict +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +LockPersonality=true +MemoryDenyWriteExecute=true +StandardOutput=null +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/packaging/rustbox.toml b/packaging/rustbox.toml new file mode 100644 index 0000000..0e7de7a --- /dev/null +++ b/packaging/rustbox.toml @@ -0,0 +1,31 @@ +host = "0.0.0.0" +port = 3001 +environment = "production" +data_dir = "/var/lib/rustbox" +log_dir = "/var/log/rustbox" +session_timeout_hours = 72 +remember_me_enabled = true +remember_me_days = 30 +imap_host = "localhost" +imap_port = 993 +smtp_host = "localhost" +smtp_port = 587 +tls_enabled = true +tls_verify = false +max_attachment_size_mb = 50 +max_message_size_mb = 100 +sieve_port = 4190 +sieve_tls_mode = "none" +sieve_allow_insecure_auth = true +sieve_timeout_secs = 10 +default_junk_folder = "Spam" +default_theme = "system" +default_interface_skin = "apple-mail" +trusted_proxies = "127.0.0.1,::1" +outgoing_headers = [] +pgp_wkd_enabled = true +pgp_hkp_enabled = false +pgp_hkp_url = "https://keys.openpgp.org" +pgp_network_timeout_secs = 10 +pgp_max_key_bytes = 1048576 +pgp_allow_private_export = true diff --git a/scripts/build-release.sh b/scripts/build-release.sh new file mode 100755 index 0000000..0947a2b --- /dev/null +++ b/scripts/build-release.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +source /opt/rustbox-package-builder/scripts/lib.sh +: "${SHA:?SHA required}" +: "${SRC:?SRC required}" +REPOSITORY="$DATA_DIR/repository" +APTLY_CONF="$DATA_DIR/state/aptly.conf" +OUT="$DATA_DIR/work/out" +PKG="$DATA_DIR/work/package" +rm -rf "$OUT" "$PKG" +mkdir -p "$OUT" "$DATA_DIR/packages/deb" "$DATA_DIR/packages/rpm/x86_64" "$DATA_DIR/packages/suse/x86_64" + +VERSION=$(python3.11 - "$SRC/Cargo.toml" <<'PY' +import sys, tomllib +p=tomllib.load(open(sys.argv[1],'rb')) +print(p['package']['version']) +PY +) +[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9._-]+)?$ ]] || fatal "unsupported RustBox version: $VERSION" +SHORT_SHA=${SHA:0:12} + +requested=${PACKAGE_RELEASE:-auto} +if [[ "$requested" == auto ]]; then + last_version=""; last_release=0 + [[ -r "$DATA_DIR/state/last_version" ]] && last_version=$(cat "$DATA_DIR/state/last_version") + [[ -r "$DATA_DIR/state/last_package_release" ]] && last_release=$(cat "$DATA_DIR/state/last_package_release") + if [[ "$last_version" == "$VERSION" && "$last_release" =~ ^[0-9]+$ ]]; then + RELEASE=$((last_release + 1)) + else + RELEASE=1 + fi +else + [[ "$requested" =~ ^[1-9][0-9]*$ ]] || fatal "PACKAGE_RELEASE must be auto or a positive integer" + RELEASE=$requested +fi + +cd "$SRC" +TOOLCHAIN=$(/opt/rustbox-package-builder/scripts/ensure-rust-toolchain.sh "$SRC") +log "building RustBox $VERSION-$RELEASE from $SHA with Rust $TOOLCHAIN" + +[[ -f Cargo.lock ]] || fatal "Cargo.lock is required for reproducible packages" +if ! grep -q 'log_dir' src/config.rs || ! grep -q 'log_dir' src/main.rs; then + fatal "RustBox source must support native file logging through log_dir" +fi + +case "${RUN_LINUX_TESTS:-compile}" in + 0|off|false|no) log "cargo tests skipped" ;; + 1|compile|check|no-run) cargo "+$TOOLCHAIN" test --release --locked --no-run ;; + full|run) cargo "+$TOOLCHAIN" test --release --locked ;; + *) fatal "RUN_LINUX_TESTS must be 0, compile, or full" ;; +esac +cargo "+$TOOLCHAIN" build --release --locked +BINARY="$CARGO_TARGET_DIR/release/rustbox" +[[ -x "$BINARY" ]] || fatal "built binary not found: $BINARY" +"$BINARY" --version +RUSTBOX_CONFIG=/opt/rustbox-package-builder/packaging/rustbox.toml "$BINARY" --check-config + +MAX_GLIBC_FOUND=$(objdump -T "$BINARY" 2>/dev/null | grep -oE 'GLIBC_[0-9]+\.[0-9]+' | sed 's/^GLIBC_//' | sort -V | tail -n1 || true) +if [[ -n "$MAX_GLIBC_FOUND" ]]; then + newest=$(printf '%s\n%s\n' "$MAX_GLIBC_VERSION" "$MAX_GLIBC_FOUND" | sort -V | tail -n1) + [[ "$newest" == "$MAX_GLIBC_VERSION" ]] || fatal "binary requires GLIBC_$MAX_GLIBC_FOUND; configured maximum is GLIBC_$MAX_GLIBC_VERSION" + log "glibc compatibility: GLIBC_$MAX_GLIBC_FOUND or older" +fi + +/opt/rustbox-package-builder/scripts/prepare-package.sh "$SRC" "$PKG" "$BINARY" "$VERSION" "$RELEASE" +DEB="$OUT/rustbox_${VERSION}-${RELEASE}_amd64.deb" +RPM="$OUT/rustbox-${VERSION}-${RELEASE}.x86_64.rpm" +SUSE="$OUT/rustbox-${VERSION}-${RELEASE}.suse.x86_64.rpm" +nfpm package --config "$PKG/nfpm-deb.yaml" --packager deb --target "$DEB" +nfpm package --config "$PKG/nfpm-rpm.yaml" --packager rpm --target "$RPM" +if [[ "${BUILD_SUSE:-1}" == 1 ]]; then + nfpm package --config "$PKG/nfpm-suse.yaml" --packager rpm --target "$SUSE" +fi + +ar t "$DEB" | grep '^_gpgorigin$' >/dev/null || fatal "DEB package is not signed" +rpm -qpi "$RPM" | awk -F: '/^Signature/ && $0 !~ /\(none\)/ {ok=1} END{exit !ok}' || fatal "RPM package is not signed" +if [[ -f "$SUSE" ]]; then rpm -qpi "$SUSE" | awk -F: '/^Signature/ && $0 !~ /\(none\)/ {ok=1} END{exit !ok}' || fatal "SUSE RPM package is not signed"; fi + +cp -f "$DEB" "$DATA_DIR/packages/deb/" +cp -f "$RPM" "$DATA_DIR/packages/rpm/x86_64/" +[[ -f "$SUSE" ]] && cp -f "$SUSE" "$DATA_DIR/packages/suse/x86_64/" + +aptly -config="$APTLY_CONF" repo add -force-replace rustbox "$DEB" >/dev/null +SNAPSHOT="rustbox-${VERSION}-${SHORT_SHA}-$(date +%s)" +aptly -config="$APTLY_CONF" snapshot create "$SNAPSHOT" from repo rustbox >/dev/null +APT_SIGN=(-batch -gpg-key="$GPG_KEY_ID") +if aptly -config="$APTLY_CONF" publish list -raw | awk -v d="$REPO_DISTRIBUTION" '$2 == d {found=1} END{exit !found}'; then + aptly -config="$APTLY_CONF" publish switch -force-overwrite "${APT_SIGN[@]}" "$REPO_DISTRIBUTION" . "$SNAPSHOT" >/dev/null +else + aptly -config="$APTLY_CONF" publish snapshot -force-overwrite "${APT_SIGN[@]}" -distribution="$REPO_DISTRIBUTION" -component="$REPO_COMPONENT" -architectures=amd64 "$SNAPSHOT" >/dev/null +fi +APT_PUBLIC="$DATA_DIR/aptly/public" +[[ -f "$APT_PUBLIC/dists/$REPO_DISTRIBUTION/InRelease" ]] || fatal "APT InRelease missing" + +RELEASE_ID="$(date -u '+%Y%m%dT%H%M%SZ')-${SHORT_SHA}" +RELEASE_DIR="$REPOSITORY/releases/$RELEASE_ID" +mkdir -p "$RELEASE_DIR/apt" "$RELEASE_DIR/rpm/x86_64" "$RELEASE_DIR/suse/x86_64" +rsync -a --delete "$APT_PUBLIC/" "$RELEASE_DIR/apt/" +rsync -a --delete "$DATA_DIR/packages/rpm/x86_64/" "$RELEASE_DIR/rpm/x86_64/" +if [[ "${BUILD_SUSE:-1}" == 1 ]]; then rsync -a --delete "$DATA_DIR/packages/suse/x86_64/" "$RELEASE_DIR/suse/x86_64/"; fi + +for rpm_root in "$RELEASE_DIR/rpm/x86_64"; do + rm -rf "$rpm_root/repodata"; createrepo_c --update "$rpm_root" >/dev/null +done +if [[ "${BUILD_SUSE:-1}" == 1 ]]; then + rm -rf "$RELEASE_DIR/suse/x86_64/repodata"; createrepo_c --update "$RELEASE_DIR/suse/x86_64" >/dev/null +fi +GPG_SIGN=(--batch --yes --pinentry-mode loopback) +for rpm_root in "$RELEASE_DIR/rpm/x86_64"; do + gpg "${GPG_SIGN[@]}" --armor --detach-sign --local-user "$GPG_KEY_ID" --output "$rpm_root/repodata/repomd.xml.asc" "$rpm_root/repodata/repomd.xml" +done +if [[ "${BUILD_SUSE:-1}" == 1 ]]; then + gpg "${GPG_SIGN[@]}" --armor --detach-sign --local-user "$GPG_KEY_ID" --output "$RELEASE_DIR/suse/x86_64/repodata/repomd.xml.asc" "$RELEASE_DIR/suse/x86_64/repodata/repomd.xml" +fi +gpg --batch --armor --export "$GPG_KEY_ID" > "$RELEASE_DIR/repo-key.gpg" + +BUILT_AT=$(date -u '+%Y-%m-%dT%H:%M:%SZ') +cat > "$RELEASE_DIR/LINUX-BUILD-INFO.txt" <> "$RELEASE_DIR/SUSE-BUILD-INFO.txt" +else + : > "$RELEASE_DIR/SUSE-BUILD-INFO.txt" +fi + +rm -f "$REPOSITORY/current.new" +ln -s "releases/$RELEASE_ID" "$REPOSITORY/current.new" +mv -Tf "$REPOSITORY/current.new" "$REPOSITORY/current" +printf '%s\n' "$SHA" | atomic_write "$DATA_DIR/state/last_commit" +printf '%s\n' "$VERSION" | atomic_write "$DATA_DIR/state/last_version" +printf '%s\n' "$RELEASE" | atomic_write "$DATA_DIR/state/last_package_release" + +retention=${RELEASE_RETENTION:-10} +if [[ "$retention" =~ ^[1-9][0-9]*$ ]]; then + mapfile -t old < <(find "$REPOSITORY/releases" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\n' | sort -nr | awk -v keep="$retention" 'NR>keep {$1=""; sub(/^ /,""); print}') + for dir in "${old[@]}"; do [[ "$dir" == "$RELEASE_DIR" ]] || rm -rf -- "$dir"; done +fi +log "published RustBox $VERSION-$RELEASE: $REPOSITORY_URL" diff --git a/scripts/check-and-build.sh b/scripts/check-and-build.sh new file mode 100755 index 0000000..a948284 --- /dev/null +++ b/scripts/check-and-build.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +source /opt/rustbox-package-builder/scripts/lib.sh +WORK="$DATA_DIR/work/source" +REPOSITORY="$DATA_DIR/repository" +if [[ ! -d "$WORK/.git" ]]; then + rm -rf "$WORK" + git clone --no-tags "$SOURCE_REPO" "$WORK" +fi +cd "$WORK" +git remote set-url origin "$SOURCE_REPO" +git fetch --no-tags --prune origin "$SOURCE_BRANCH" +SHA=$(git rev-parse FETCH_HEAD) +CURRENT_SHA="" +CURRENT_REV="" +if [[ -r "$REPOSITORY/current/LINUX-BUILD-INFO.txt" ]]; then + CURRENT_SHA=$(sed -n 's/^source_commit=//p' "$REPOSITORY/current/LINUX-BUILD-INFO.txt" | head -n1) + CURRENT_REV=$(sed -n 's/^packaging_revision=//p' "$REPOSITORY/current/LINUX-BUILD-INFO.txt" | head -n1) +fi +if [[ "$CURRENT_SHA" == "$SHA" && "$CURRENT_REV" == "$PACKAGING_REVISION" ]]; then + log "no new RustBox build: commit=$SHA packaging_revision=$PACKAGING_REVISION" + exit 0 +fi +git reset --hard "$SHA" +git clean -fdx +export SHA SRC="$WORK" +/opt/rustbox-package-builder/scripts/build-release.sh diff --git a/scripts/ensure-rust-toolchain.sh b/scripts/ensure-rust-toolchain.sh new file mode 100755 index 0000000..f2c2a2f --- /dev/null +++ b/scripts/ensure-rust-toolchain.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SOURCE_DIR=${1:?usage: ensure-rust-toolchain.sh SOURCE_DIR} +[[ -r "$SOURCE_DIR/Cargo.toml" ]] || { echo "missing Cargo.toml: $SOURCE_DIR" >&2; exit 1; } +metadata=$(python3.11 - "$SOURCE_DIR" <<'PY' +import pathlib, re, sys, tomllib +root=pathlib.Path(sys.argv[1]) +manifest=tomllib.loads((root/'Cargo.toml').read_text()) +minimum=manifest.get('package',{}).get('rust-version','') +pinned='' +p=root/'rust-toolchain.toml' +if p.exists(): pinned=tomllib.loads(p.read_text()).get('toolchain',{}).get('channel','') +if minimum and not re.fullmatch(r'[0-9]+\.[0-9]+(?:\.[0-9]+)?', minimum): raise SystemExit('unsupported rust-version') +if pinned and not (pinned=='stable' or re.fullmatch(r'[0-9]+\.[0-9]+(?:\.[0-9]+)?', pinned)): raise SystemExit('unsupported rust-toolchain channel') +print(minimum) +print(pinned) +PY +) +mapfile -t requested <<< "$metadata" +minimum=${requested[0]:-} +channel=${requested[1]:-stable} +[[ -n "$channel" ]] || channel=stable +if ! rustup run "$channel" rustc --version >/dev/null 2>&1; then + echo "installing Rust toolchain $channel" >&2 + rustup toolchain install "$channel" --profile minimal >&2 +fi +installed=$(rustup run "$channel" rustc --version | awk '{print $2}') +if [[ -n "$minimum" ]]; then + lowest=$(printf '%s\n%s\n' "$installed" "$minimum" | sort -V | head -n1) + if [[ "$lowest" != "$minimum" ]]; then + echo "Rust $installed does not satisfy project MSRV $minimum" >&2 + exit 1 + fi +fi +printf '%s\n' "$channel" diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh new file mode 100755 index 0000000..ca30c44 --- /dev/null +++ b/scripts/entrypoint.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +source /opt/rustbox-package-builder/scripts/lib.sh +: "${SOURCE_REPO:?SOURCE_REPO required}" +: "${REPOSITORY_URL:?REPOSITORY_URL required}" +: "${SOURCE_BRANCH:=master}" +: "${POLL_INTERVAL:=300}" +: "${REPO_DISTRIBUTION:=stable}" +: "${REPO_COMPONENT:=main}" +: "${PACKAGE_RELEASE:=auto}" +: "${RUN_LINUX_TESTS:=compile}" +: "${MAX_GLIBC_VERSION:=2.34}" +: "${BUILD_SUSE:=1}" +: "${RELEASE_RETENTION:=10}" +export SOURCE_BRANCH POLL_INTERVAL REPO_DISTRIBUTION REPO_COMPONENT PACKAGE_RELEASE RUN_LINUX_TESTS MAX_GLIBC_VERSION BUILD_SUSE RELEASE_RETENTION +mkdir -p "$DATA_DIR"/{state,repository,work,cargo,packages} /tmp/rustbox-gnupg +chmod 0700 /tmp/rustbox-gnupg +export GNUPGHOME=/tmp/rustbox-gnupg +GPG_PRIVATE_KEY_FILE=/run/secrets/gpg/private-key.asc +[[ -s "$GPG_PRIVATE_KEY_FILE" ]] || fatal "missing GPG private key: $GPG_PRIVATE_KEY_FILE" +gpg --batch --import "$GPG_PRIVATE_KEY_FILE" >/dev/null 2>&1 +GPG_KEY_ID=$(gpg --batch --with-colons --list-secret-keys | awk -F: '/^sec:/ {seen=1; next} seen && /^fpr:/ {print $10; exit}') +[[ -n "$GPG_KEY_ID" ]] || fatal "cannot resolve GPG signing key" +export GPG_PRIVATE_KEY_FILE GPG_KEY_ID +/opt/rustbox-package-builder/scripts/init-storage.sh +while true; do + if ! /opt/rustbox-package-builder/scripts/check-and-build.sh; then + log "build cycle failed; retrying after ${POLL_INTERVAL}s" + fi + sleep "$POLL_INTERVAL" +done diff --git a/scripts/init-storage.sh b/scripts/init-storage.sh new file mode 100755 index 0000000..62cdbae --- /dev/null +++ b/scripts/init-storage.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +source /opt/rustbox-package-builder/scripts/lib.sh +APTLY_CONF="$DATA_DIR/state/aptly.conf" +APTLY_ROOT="$DATA_DIR/aptly" +REPOSITORY="$DATA_DIR/repository" +mkdir -p "$APTLY_ROOT" "$REPOSITORY/releases" "$DATA_DIR/packages/deb" "$DATA_DIR/packages/rpm/x86_64" "$DATA_DIR/packages/suse/x86_64" +cat > "$APTLY_CONF" </dev/null; then + aptly -config="$APTLY_CONF" repo create -distribution="$REPO_DISTRIBUTION" -component="$REPO_COMPONENT" rustbox >/dev/null +fi +for item in apt rpm suse repo-key.gpg LINUX-BUILD-INFO.txt SUSE-BUILD-INFO.txt; do + [[ ! -e "$REPOSITORY/$item" || -L "$REPOSITORY/$item" ]] || fatal "$REPOSITORY/$item exists and is not a symlink" +done +ln -sfn current/apt "$REPOSITORY/apt" +ln -sfn current/rpm "$REPOSITORY/rpm" +ln -sfn current/suse "$REPOSITORY/suse" +ln -sfn current/repo-key.gpg "$REPOSITORY/repo-key.gpg" +ln -sfn current/LINUX-BUILD-INFO.txt "$REPOSITORY/LINUX-BUILD-INFO.txt" +ln -sfn current/SUSE-BUILD-INFO.txt "$REPOSITORY/SUSE-BUILD-INFO.txt" diff --git a/scripts/lib.sh b/scripts/lib.sh new file mode 100755 index 0000000..1741fdc --- /dev/null +++ b/scripts/lib.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +PACKAGING_REVISION=1 +DATA_DIR=${DATA_DIR:-/var/lib/rustbox-package-builder} + +log() { printf '%s %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$*"; } +fatal() { log "ERROR: $*" >&2; exit 1; } +require_cmd() { command -v "$1" >/dev/null 2>&1 || fatal "required command not found: $1"; } +yaml_quote() { python3 - "$1" <<'PY' +import json, sys +print(json.dumps(sys.argv[1])) +PY +} +atomic_write() { local path=$1 tmp="${path}.tmp.$$"; cat > "$tmp"; mv -f "$tmp" "$path"; } diff --git a/scripts/prepare-package.sh b/scripts/prepare-package.sh new file mode 100755 index 0000000..26500c3 --- /dev/null +++ b/scripts/prepare-package.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +source /opt/rustbox-package-builder/scripts/lib.sh +SRC=${1:?source dir required} +PKG=${2:?package work dir required} +BINARY=${3:?binary path required} +VERSION=${4:?version required} +PACKAGE_RELEASE=${5:?release required} +rm -rf "$PKG" +mkdir -p "$PKG/payload/usr/local/bin" "$PKG/payload/usr/lib/systemd/system" "$PKG/payload/etc/rustbox" "$PKG/payload/etc/logrotate.d" "$PKG/scripts" +install -m 0755 "$BINARY" "$PKG/payload/usr/local/bin/rustbox" +install -m 0644 /opt/rustbox-package-builder/packaging/rustbox.service "$PKG/payload/usr/lib/systemd/system/rustbox.service" +install -m 0640 /opt/rustbox-package-builder/packaging/rustbox.toml "$PKG/payload/etc/rustbox/rustbox.toml" +install -m 0644 /opt/rustbox-package-builder/packaging/rustbox.logrotate "$PKG/payload/etc/logrotate.d/rustbox" + +cat > "$PKG/scripts/deb-postinstall.sh" <<'SCRIPT' +#!/bin/sh +set -e +getent group rustbox >/dev/null 2>&1 || addgroup --system rustbox >/dev/null +if ! id rustbox >/dev/null 2>&1; then + adduser --system --ingroup rustbox --home /var/lib/rustbox --no-create-home --shell /usr/sbin/nologin rustbox >/dev/null +fi +install -d -o rustbox -g rustbox -m 0750 /etc/rustbox /var/log/rustbox +install -d -o rustbox -g rustbox -m 0700 /var/lib/rustbox +chown rustbox:rustbox /etc/rustbox/rustbox.toml +chmod 0640 /etc/rustbox/rustbox.toml +RUSTBOX_CONFIG=/etc/rustbox/rustbox.toml /usr/local/bin/rustbox --check-config +if command -v systemctl >/dev/null 2>&1; then + systemctl daemon-reload || true + systemctl enable rustbox.service >/dev/null 2>&1 || true + if [ -d /run/systemd/system ]; then systemctl restart rustbox.service || systemctl start rustbox.service || true; fi +fi +SCRIPT +cat > "$PKG/scripts/deb-preremove.sh" <<'SCRIPT' +#!/bin/sh +set -e +if [ "${1:-}" = remove ] && command -v systemctl >/dev/null 2>&1; then + systemctl disable --now rustbox.service >/dev/null 2>&1 || true +fi +SCRIPT +cat > "$PKG/scripts/deb-postremove.sh" <<'SCRIPT' +#!/bin/sh +set -e +command -v systemctl >/dev/null 2>&1 && systemctl daemon-reload >/dev/null 2>&1 || true +SCRIPT +cat > "$PKG/scripts/rpm-postinstall.sh" <<'SCRIPT' +#!/bin/sh +set -e +getent group rustbox >/dev/null 2>&1 || groupadd --system rustbox +if ! id rustbox >/dev/null 2>&1; then + NOLOGIN=/usr/sbin/nologin + [ -x "$NOLOGIN" ] || NOLOGIN=/sbin/nologin + useradd --system --gid rustbox --home-dir /var/lib/rustbox --no-create-home --shell "$NOLOGIN" rustbox +fi +install -d -o rustbox -g rustbox -m 0750 /etc/rustbox /var/log/rustbox +install -d -o rustbox -g rustbox -m 0700 /var/lib/rustbox +chown rustbox:rustbox /etc/rustbox/rustbox.toml +chmod 0640 /etc/rustbox/rustbox.toml +RUSTBOX_CONFIG=/etc/rustbox/rustbox.toml /usr/local/bin/rustbox --check-config +if command -v systemctl >/dev/null 2>&1; then + systemctl daemon-reload || true + systemctl enable rustbox.service >/dev/null 2>&1 || true + if [ -d /run/systemd/system ]; then systemctl restart rustbox.service || systemctl start rustbox.service || true; fi +fi +SCRIPT +cat > "$PKG/scripts/rpm-preremove.sh" <<'SCRIPT' +#!/bin/sh +set -e +if [ "${1:-1}" -eq 0 ] 2>/dev/null && command -v systemctl >/dev/null 2>&1; then + systemctl disable --now rustbox.service >/dev/null 2>&1 || true +fi +SCRIPT +cat > "$PKG/scripts/rpm-postremove.sh" <<'SCRIPT' +#!/bin/sh +set -e +command -v systemctl >/dev/null 2>&1 && systemctl daemon-reload >/dev/null 2>&1 || true +SCRIPT +chmod 0755 "$PKG/scripts"/*.sh + +CONTENTS="$PKG/contents.yaml" +cat > "$CONTENTS" < "$PKG/nfpm-deb.yaml" < "$PKG/nfpm-rpm.yaml" < "$PKG/nfpm-suse.yaml" <&2; exit 1; } +require_cmd() { command -v "$1" >/dev/null 2>&1 || die "missing command: $1"; } + +require_cmd docker +if ! docker info >/dev/null 2>&1; then + die "Docker daemon is unavailable for the current user" +fi +if docker compose version >/dev/null 2>&1; then + COMPOSE=(docker compose) +elif command -v docker-compose >/dev/null 2>&1; then + COMPOSE=(docker-compose) +else + die "Docker Compose is required" +fi + +if [[ ! -f "$ENV_FILE" ]]; then + cp "$ROOT/.env.example" "$ENV_FILE" + chmod 0600 "$ENV_FILE" + die "created .env; set SOURCE_REPO and REPOSITORY_URL, then run ./start.sh again" +fi + +set -a +source "$ENV_FILE" +set +a +: "${SOURCE_REPO:?SOURCE_REPO is required in .env}" +: "${REPOSITORY_URL:?REPOSITORY_URL is required in .env}" +HTTP_PORT=${HTTP_PORT:-$DEFAULT_HTTP_PORT} +[[ "$HTTP_PORT" =~ ^[0-9]+$ ]] || die "HTTP_PORT must be numeric" +(( HTTP_PORT >= 1024 && HTTP_PORT <= 65535 )) || die "HTTP_PORT must be between 1024 and 65535" + +REPOSITORY_URL=${REPOSITORY_URL%/} +case "$REPOSITORY_URL" in + http://*|https://*) ;; + *) die "REPOSITORY_URL must start with http:// or https://" ;; +esac +if [[ "$REPOSITORY_URL" == http://* && ! "$REPOSITORY_URL" =~ :[0-9]+$ ]]; then + REPOSITORY_URL="${REPOSITORY_URL}:${HTTP_PORT}" + if grep -q '^REPOSITORY_URL=' "$ENV_FILE"; then + sed -i "s#^REPOSITORY_URL=.*#REPOSITORY_URL=${REPOSITORY_URL}#" "$ENV_FILE" + fi + export REPOSITORY_URL + log "REPOSITORY_URL updated to $REPOSITORY_URL" +fi + +mkdir -p "$DATA_DIR"/{repository,state,cargo,work,packages} "$GPG_DIR" +chmod 0700 "$GPG_DIR" 2>/dev/null || true + +log "building builder and repository dashboard" +"${COMPOSE[@]}" --env-file "$ENV_FILE" build rustbox-package-builder repository-dashboard + +if [[ ! -s "$GPG_DIR/private-key.asc" ]]; then + log "generating persistent GPG signing key" + IMAGE_REF=$("${COMPOSE[@]}" --env-file "$ENV_FILE" config | awk ' + $1 == "rustbox-package-builder:" { in_builder=1; next } + in_builder && $1 == "image:" { print $2; exit } + ') + [[ -n "$IMAGE_REF" ]] || die "cannot resolve builder image name" + uid=$(id -u) + gid=$(id -g) + docker run --rm --user "$uid:$gid" --entrypoint /bin/bash -e HOME=/tmp \ + -v "$GPG_DIR:/out" "$IMAGE_REF" -lc ' + set -Eeuo pipefail + export GNUPGHOME=/tmp/rustbox-keygen + mkdir -p "$GNUPGHOME" && chmod 0700 "$GNUPGHOME" + cat > /tmp/gpg-batch <<"KEY" +Key-Type: RSA +Key-Length: 3072 +Key-Usage: sign +Name-Real: RustBox Package Repository +Name-Email: packages@localhost +Expire-Date: 0 +%no-protection +%commit +KEY + gpg --batch --generate-key /tmp/gpg-batch + fingerprint=$(gpg --batch --with-colons --list-secret-keys | awk -F: '\''/^sec:/ {seen=1; next} seen && /^fpr:/ {print $10; exit}'\'') + test -n "$fingerprint" + gpg --batch --armor --export-secret-keys "$fingerprint" > /out/private-key.asc + gpg --batch --armor --export "$fingerprint" > /out/public-key.asc + printf "%s\n" "$fingerprint" > /out/fingerprint + chmod 0600 /out/private-key.asc + chmod 0644 /out/public-key.asc /out/fingerprint + ' +fi + +log "starting builder and repository HTTP service" +"${COMPOSE[@]}" --env-file "$ENV_FILE" up -d rustbox-package-builder repository-http +printf '\nRepository: %s/\n' "$REPOSITORY_URL" +printf 'Local HTTP: http://127.0.0.1:%s/\n' "$HTTP_PORT" +printf 'Health: http://127.0.0.1:%s/healthz\n' "$HTTP_PORT" +printf 'Builder log: ' +printf '%q ' "${COMPOSE[@]}" +printf 'logs -f rustbox-package-builder\n' diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py new file mode 100644 index 0000000..e6c6004 --- /dev/null +++ b/tests/test_dashboard.py @@ -0,0 +1,34 @@ +import importlib.util +import os +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +os.environ.setdefault("REPOSITORY_URL", "https://repo.example.test") +spec = importlib.util.spec_from_file_location("dashboard_app", ROOT / "dashboard/app.py") +app = importlib.util.module_from_spec(spec) +spec.loader.exec_module(app) + + +def test_package_filename_parsing(): + assert app.parse_package(Path("rustbox_0.0.5-1_amd64.deb"), "deb") == ("rustbox", "0.0.5-1", "amd64") + assert app.parse_package(Path("rustbox-0.0.5-1.x86_64.rpm"), "rpm") == ("rustbox", "0.0.5-1", "x86_64") + + +def test_catalog_reads_linux_snapshot(): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + rel = root / "releases/one" + (rel / "apt/pool/main/r/rustbox").mkdir(parents=True) + (rel / "rpm/x86_64").mkdir(parents=True) + (rel / "suse/x86_64").mkdir(parents=True) + (rel / "LINUX-BUILD-INFO.txt").write_text("app_version=0.0.5\npackage_release=1\nbuilt_at=2026-10-30T12:00:00Z\nsource_commit=abc\npackaging_revision=1\n") + (rel / "SUSE-BUILD-INFO.txt").write_text("app_version=0.0.5\npackage_release=1\nbuilt_at=2026-10-30T12:00:00Z\nsource_commit=abc\npackaging_revision=1\n") + (rel / "apt/pool/main/r/rustbox/rustbox_0.0.5-1_amd64.deb").write_bytes(b"deb") + (rel / "rpm/x86_64/rustbox-0.0.5-1.x86_64.rpm").write_bytes(b"rpm") + (rel / "suse/x86_64/rustbox-0.0.5-1.x86_64.rpm").write_bytes(b"rpm") + (root / "current").symlink_to("releases/one") + catalog = app.generate_catalog(root) + assert catalog["ready"] is True + assert catalog["builds"]["linux"]["app_version"] == "0.0.5" + assert len(catalog["packages"]) == 3 diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 0000000..5c57eee --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,59 @@ +from pathlib import Path +import tomllib + +ROOT = Path(__file__).resolve().parents[1] + + +def test_packaged_config_matches_linux_layout(): + config = tomllib.loads((ROOT / "packaging/rustbox.toml").read_text()) + assert config["host"] == "0.0.0.0" + assert config["data_dir"] == "/var/lib/rustbox" + assert config["log_dir"] == "/var/log/rustbox" + assert config["default_interface_skin"] == "apple-mail" + assert config["max_attachment_size_mb"] == 50 + assert config["max_message_size_mb"] == 100 + + +def test_systemd_hardening_and_paths(): + unit = (ROOT / "packaging/rustbox.service").read_text() + for needle in ( + "User=rustbox", + "Group=rustbox", + "WorkingDirectory=/var/lib/rustbox", + "ExecStart=/usr/local/bin/rustbox", + "StateDirectory=rustbox", + "LogsDirectory=rustbox", + "ProtectSystem=strict", + "ProtectHome=true", + "NoNewPrivileges=true", + "StandardOutput=null", + ): + assert needle in unit + + +def test_logrotate_keeps_seven_daily_rotations(): + data = (ROOT / "packaging/rustbox.logrotate").read_text() + assert "/var/log/rustbox/rustbox.log" in data + assert "daily" in data + assert "rotate 7" in data + assert "create 0600 rustbox rustbox" in data + + +def test_package_runtime_dependencies_are_explicit(): + script = (ROOT / "scripts/prepare-package.sh").read_text() + for needle in ( + "- gnupg", + "- libssl3 | libssl3t64", + "- gnupg2", + "- openssl-libs", + "- gpg2", + "- libopenssl3", + ): + assert needle in script + + +def test_docker_context_excludes_secrets_and_state(): + data = (ROOT / ".dockerignore").read_text().splitlines() + assert ".env" in data + assert "gpg/" in data + assert "data/" in data