first commit
This commit is contained in:
commit
8bef2e68bb
5 files changed
+441
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
repo/*
|
||||||
|
.venv
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
FROM python:3.14-alpine
|
||||||
|
|
||||||
|
RUN apk add --no-cache libarchive-tools ca-certificates
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY repo_daemon.py /app/repo_daemon.py
|
||||||
|
|
||||||
|
ENV REPO_DIR=.//repo \
|
||||||
|
PORT=8478 \
|
||||||
|
POLL_INTERVAL=3600 \
|
||||||
|
STABLE_SECONDS=60 \
|
||||||
|
RETENTION=10 \
|
||||||
|
LATEST_NAMES=os-rustproxy-latest.pkg
|
||||||
|
|
||||||
|
VOLUME ["/repo"]
|
||||||
|
EXPOSE 8478
|
||||||
|
|
||||||
|
CMD ["python3", "/app/repo_daemon.py"]
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# OPNsense / FreeBSD pkg repo daemon
|
||||||
|
|
||||||
|
Lekki daemon na Alpine. Obserwuje `/repo`, odczytuje manifest z paczek FreeBSD `.pkg`,
|
||||||
|
automatycznie wersjonuje `latest.pkg`, pilnuje retencji i wystawia repo przez HTTP.
|
||||||
|
|
||||||
|
## Start
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
Repo:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://HOST:8478/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Upload z buildu
|
||||||
|
|
||||||
|
Mozesz zawsze wysylac ten sam plik:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scp latest.pkg user@repo-host:/opt/opnsense-pkg-repo/repo/latest.pkg
|
||||||
|
```
|
||||||
|
|
||||||
|
Daemon czyta z paczki:
|
||||||
|
|
||||||
|
```text
|
||||||
|
+COMPACT_MANIFEST
|
||||||
|
```
|
||||||
|
|
||||||
|
i pola:
|
||||||
|
|
||||||
|
```text
|
||||||
|
name
|
||||||
|
version
|
||||||
|
```
|
||||||
|
|
||||||
|
Przyklad:
|
||||||
|
|
||||||
|
```text
|
||||||
|
latest.pkg
|
||||||
|
```
|
||||||
|
|
||||||
|
z manifestem:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"name":"myapp","version":"1.4.7"}
|
||||||
|
```
|
||||||
|
|
||||||
|
zostanie automatycznie przemianowany na:
|
||||||
|
|
||||||
|
```text
|
||||||
|
myapp-1.4.7.pkg
|
||||||
|
```
|
||||||
|
|
||||||
|
Nie musisz znac wersji po stronie CI.
|
||||||
|
|
||||||
|
## Retencja
|
||||||
|
|
||||||
|
Domyslnie daemon zachowuje 10 najnowszych plikow dla kazdej nazwy pakietu:
|
||||||
|
|
||||||
|
```env
|
||||||
|
RETENTION=10
|
||||||
|
```
|
||||||
|
|
||||||
|
Np.:
|
||||||
|
|
||||||
|
```text
|
||||||
|
myapp-1.4.10.pkg
|
||||||
|
...
|
||||||
|
myapp-1.4.1.pkg
|
||||||
|
```
|
||||||
|
|
||||||
|
Starsze sa automatycznie usuwane. Kolejnosc retencji jest wedlug czasu pojawienia sie
|
||||||
|
pliku w repo, a nie leksykograficznego sortowania numeru wersji.
|
||||||
|
|
||||||
|
Bez retencji:
|
||||||
|
|
||||||
|
```env
|
||||||
|
RETENTION=0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pozostale ENV
|
||||||
|
|
||||||
|
```env
|
||||||
|
PORT=8080
|
||||||
|
POLL_INTERVAL=2
|
||||||
|
STABLE_SECONDS=2
|
||||||
|
LATEST_NAMES=latest.pkg
|
||||||
|
```
|
||||||
|
|
||||||
|
Mozesz miec kilka nazw uploadowych:
|
||||||
|
|
||||||
|
```env
|
||||||
|
LATEST_NAMES=latest.pkg,myapp-latest.pkg
|
||||||
|
```
|
||||||
|
|
||||||
|
## OPNsense
|
||||||
|
|
||||||
|
`/usr/local/etc/pkg/repos/myrepo.conf`:
|
||||||
|
|
||||||
|
```ucl
|
||||||
|
MyRepo: {
|
||||||
|
url: "http://HOST:8478",
|
||||||
|
enabled: yes,
|
||||||
|
signature_type: "none"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Potem:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pkg update -f
|
||||||
|
pkg search myapp
|
||||||
|
pkg install -r MyRepo myapp
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logi
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
Przy nowym buildzie zobaczysz np.:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[ingest] latest.pkg -> myapp-1.4.7.pkg
|
||||||
|
[retention] usunieto myapp-1.3.2.pkg (myapp, limit=10)
|
||||||
|
[repo] przebudowano katalog: 10 pakiet(ow)
|
||||||
|
```
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
services:
|
||||||
|
repo:
|
||||||
|
build: .
|
||||||
|
container_name: opnsense-pkg-repo
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${PORT:-8478}:8478"
|
||||||
|
volumes:
|
||||||
|
- ./repo:/repo
|
||||||
|
environment:
|
||||||
|
PORT: "8478"
|
||||||
|
POLL_INTERVAL: "${POLL_INTERVAL:-3600}"
|
||||||
|
STABLE_SECONDS: "${STABLE_SECONDS:-60}"
|
||||||
|
RETENTION: "${RETENTION:-10}"
|
||||||
|
LATEST_NAMES: "${LATEST_NAMES:-os-rustproxy-latest.pkg}"
|
||||||
+273
@@ -0,0 +1,273 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(os.getenv("REPO_DIR", "./repo")).resolve()
|
||||||
|
HOST = os.getenv("HOST", "0.0.0.0")
|
||||||
|
PORT = int(os.getenv("PORT", "8478"))
|
||||||
|
POLL_INTERVAL = float(os.getenv("POLL_INTERVAL", "3600"))
|
||||||
|
STABLE_SECONDS = float(os.getenv("STABLE_SECONDS", "60"))
|
||||||
|
RETENTION = int(os.getenv("RETENTION", "10"))
|
||||||
|
LATEST_NAMES = {
|
||||||
|
x.strip() for x in os.getenv("LATEST_NAMES", "latest.pkg").split(",") if x.strip()
|
||||||
|
}
|
||||||
|
|
||||||
|
CATALOG_FILES = {"meta.conf", "data.pkg", "packagesite.pkg"}
|
||||||
|
LOCK = threading.Lock()
|
||||||
|
_seen = {}
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
print(time.strftime("%Y-%m-%d %H:%M:%S"), msg, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def sha256(path):
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with path.open("rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def read_manifest(pkg):
|
||||||
|
for member in ("+COMPACT_MANIFEST", "+MANIFEST"):
|
||||||
|
p = subprocess.run(
|
||||||
|
["bsdtar", "-xOf", str(pkg), member],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if p.returncode == 0 and p.stdout:
|
||||||
|
return json.loads(p.stdout.decode("utf-8"))
|
||||||
|
raise RuntimeError(f"{pkg.name}: brak +COMPACT_MANIFEST / +MANIFEST")
|
||||||
|
|
||||||
|
|
||||||
|
def safe_filename(value):
|
||||||
|
# Zachowuje typowe znaki wersji FreeBSD, usuwa tylko znaki niebezpieczne dla sciezki.
|
||||||
|
value = str(value).strip().replace("/", "_").replace("\\", "_")
|
||||||
|
value = re.sub(r"[\x00-\x1f]", "_", value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def is_payload_pkg(path):
|
||||||
|
return (
|
||||||
|
path.is_file()
|
||||||
|
and path.suffix == ".pkg"
|
||||||
|
and path.name not in CATALOG_FILES
|
||||||
|
and not path.name.endswith(".tmp.pkg")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def stable(path):
|
||||||
|
try:
|
||||||
|
st = path.stat()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
key = str(path)
|
||||||
|
state = (st.st_size, st.st_mtime_ns)
|
||||||
|
now = time.monotonic()
|
||||||
|
old = _seen.get(key)
|
||||||
|
|
||||||
|
if old is None or old[:2] != state:
|
||||||
|
_seen[key] = (*state, now)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return now - old[2] >= STABLE_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_latest():
|
||||||
|
changed = False
|
||||||
|
for path in sorted(REPO.rglob("*.pkg")):
|
||||||
|
if path.name not in LATEST_NAMES or not stable(path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
manifest = read_manifest(path)
|
||||||
|
name = safe_filename(manifest["name"])
|
||||||
|
version = safe_filename(manifest["version"])
|
||||||
|
target = path.with_name(f"{name}-{version}.pkg")
|
||||||
|
|
||||||
|
if target == path:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if target.exists():
|
||||||
|
if sha256(target) == sha256(path):
|
||||||
|
path.unlink()
|
||||||
|
log(f"[ingest] {path.name}: wersja {name}-{version} juz istnieje, usunieto duplikat")
|
||||||
|
else:
|
||||||
|
os.replace(path, target)
|
||||||
|
log(f"[ingest] {path.name} -> {target.name} (nadpisano ta sama wersje)")
|
||||||
|
else:
|
||||||
|
os.replace(path, target)
|
||||||
|
log(f"[ingest] {path.name} -> {target.name}")
|
||||||
|
|
||||||
|
_seen.pop(str(path), None)
|
||||||
|
changed = True
|
||||||
|
except Exception as e:
|
||||||
|
log(f"[ingest] BLAD {path.name}: {e}")
|
||||||
|
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def collect_packages():
|
||||||
|
packages = []
|
||||||
|
for path in sorted(REPO.rglob("*.pkg")):
|
||||||
|
if not is_payload_pkg(path):
|
||||||
|
continue
|
||||||
|
if path.name in LATEST_NAMES:
|
||||||
|
# latest.pkg nie trafia do indeksu przed normalizacja.
|
||||||
|
continue
|
||||||
|
if not stable(path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
manifest = read_manifest(path)
|
||||||
|
manifest["path"] = path.relative_to(REPO).as_posix()
|
||||||
|
manifest["pkgsize"] = path.stat().st_size
|
||||||
|
manifest["sum"] = sha256(path)
|
||||||
|
packages.append((path, manifest))
|
||||||
|
return packages
|
||||||
|
|
||||||
|
|
||||||
|
def enforce_retention(packages):
|
||||||
|
if RETENTION <= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
groups = defaultdict(list)
|
||||||
|
for path, manifest in packages:
|
||||||
|
groups[manifest.get("name", path.stem)].append((path, manifest))
|
||||||
|
|
||||||
|
removed = False
|
||||||
|
for name, entries in groups.items():
|
||||||
|
entries.sort(key=lambda item: item[0].stat().st_mtime_ns, reverse=True)
|
||||||
|
for path, manifest in entries[RETENTION:]:
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
_seen.pop(str(path), None)
|
||||||
|
log(f"[retention] usunieto {path.name} ({name}, limit={RETENTION})")
|
||||||
|
removed = True
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
def make_tar(dst, member_name, payload):
|
||||||
|
tmp = dst.with_name(dst.name + ".tmp")
|
||||||
|
with tarfile.open(tmp, "w", format=tarfile.PAX_FORMAT) as tf:
|
||||||
|
info = tarfile.TarInfo(member_name)
|
||||||
|
info.size = len(payload)
|
||||||
|
info.mode = 0o644
|
||||||
|
info.mtime = int(time.time())
|
||||||
|
tf.addfile(info, io.BytesIO(payload))
|
||||||
|
os.replace(tmp, dst)
|
||||||
|
|
||||||
|
|
||||||
|
def rebuild():
|
||||||
|
with LOCK:
|
||||||
|
packages = collect_packages()
|
||||||
|
|
||||||
|
if enforce_retention(packages):
|
||||||
|
packages = collect_packages()
|
||||||
|
|
||||||
|
manifests = [m for _, m in packages]
|
||||||
|
|
||||||
|
packagesite = b"".join(
|
||||||
|
json.dumps(m, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + b"\n"
|
||||||
|
for m in manifests
|
||||||
|
)
|
||||||
|
|
||||||
|
# Repo v2. packagesite.pkg pozostaje dla zgodnosci ze starszymi klientami.
|
||||||
|
data = json.dumps(
|
||||||
|
{"groups": [], "expired_packages": [], "packages": manifests},
|
||||||
|
separators=(",", ":"),
|
||||||
|
ensure_ascii=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
make_tar(REPO / "packagesite.pkg", "packagesite.yaml", packagesite)
|
||||||
|
make_tar(REPO / "data.pkg", "data", data)
|
||||||
|
|
||||||
|
meta = (
|
||||||
|
'version: 1;\n'
|
||||||
|
'packing_format: "tar";\n'
|
||||||
|
'manifests: "packagesite.yaml";\n'
|
||||||
|
'manifests_archive: "packagesite";\n'
|
||||||
|
'data: "data";\n'
|
||||||
|
'data_archive: "data";\n'
|
||||||
|
'filesite: "files";\n'
|
||||||
|
'filesite_archive: "files";\n'
|
||||||
|
)
|
||||||
|
tmp = REPO / "meta.conf.tmp"
|
||||||
|
tmp.write_text(meta, encoding="utf-8")
|
||||||
|
os.replace(tmp, REPO / "meta.conf")
|
||||||
|
|
||||||
|
log(f"[repo] przebudowano katalog: {len(manifests)} pakiet(ow)")
|
||||||
|
|
||||||
|
|
||||||
|
def payload_fingerprint():
|
||||||
|
items = []
|
||||||
|
for path in sorted(REPO.rglob("*.pkg")):
|
||||||
|
if not is_payload_pkg(path):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
st = path.stat()
|
||||||
|
items.append((path.relative_to(REPO).as_posix(), st.st_size, st.st_mtime_ns))
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
return tuple(items)
|
||||||
|
|
||||||
|
|
||||||
|
def watcher():
|
||||||
|
previous = None
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
normalize_latest()
|
||||||
|
current = payload_fingerprint()
|
||||||
|
|
||||||
|
if current != previous:
|
||||||
|
# collect_packages() wpusci tylko pliki stabilne przez STABLE_SECONDS.
|
||||||
|
time.sleep(STABLE_SECONDS)
|
||||||
|
normalize_latest()
|
||||||
|
rebuild()
|
||||||
|
previous = payload_fingerprint()
|
||||||
|
except Exception as e:
|
||||||
|
log(f"[repo] BLAD przebudowy: {e}")
|
||||||
|
|
||||||
|
time.sleep(POLL_INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(SimpleHTTPRequestHandler):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, directory=str(REPO), **kwargs)
|
||||||
|
|
||||||
|
def end_headers(self):
|
||||||
|
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
super().end_headers()
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
log("[http] " + (fmt % args))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
REPO.mkdir(parents=True, exist_ok=True)
|
||||||
|
rebuild()
|
||||||
|
|
||||||
|
threading.Thread(target=watcher, daemon=True).start()
|
||||||
|
|
||||||
|
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
||||||
|
log(f"[http] repo: http://{HOST}:{PORT}/ katalog={REPO}")
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in new issue
Block a user