🔒 Repository is read-only – file editing is disabled.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704
#!/usr/bin/env python3
# =============================================================================
# PAGSYNC v2 – PaganOS Recipe Sync & Build Queue Manager
# Skanuje drzewo recipes/, buduje pakiety przez pagbuild, synchronizuje do repo.
# =============================================================================
import os, sys, json, time, argparse, subprocess, shutil, re, tempfile, signal, glob
from pathlib import Path
from datetime import datetime, timezone
# ── Pełny PATH – procesy wywoływane z cron.d / panelu mają ucięte PATH
# (/usr/bin:/bin) i NIE znajdują /usr/sbin/chroot, /usr/sbin/sshd itp.
# To była przyczyna masowych „⚠ Instalacja do rootfs nieudana: [Errno 2]
# No such file or directory: 'chroot'” – pakiet był rozpakowywany do rootfs,
# ale NIE rejestrowany w installed.json, przez co pre-flight kolejnych
# buildów w kółko zgłaszał BRAK i dobudowywał te same zależności.
os.environ["PATH"] = os.environ.get(
"PATH", "") + ":/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
def _find_chroot():
"""Ścieżka do binarki chroot – bywa w /usr/sbin, poza uciętym PATH."""
for _cand in ("/usr/sbin/chroot", "/sbin/chroot", "/bin/chroot",
"/usr/bin/chroot", "/usr/local/sbin/chroot"):
if os.path.exists(_cand):
return _cand
return shutil.which("chroot") or "chroot"
CHROOT_BIN = _find_chroot()
def _load_build_conf(path=None):
"""Wczytuje /etc/pagan/build.conf do os.environ (bez nadpisywania już
ustawionych zmiennych).
Dzięki temu KAŻDE wywołanie pagsync – panel, cron, timer czy ręczne z CLI –
ma PAGAN_DO_SIGN/PAGAN_GPG_KEY, więc repo.json jest aktualizowany po każdym
udanym buildzie, a paczki są podpisywane. Wcześniej zależało to od tego, czy
wywołujący sam wyeksportował te zmienne (panel/cron/timer robiły to osobno).
"""
if path is None:
path = os.environ.get("PAGAN_BUILD_CONF", "/etc/pagan/build.conf")
try:
with open(path) as _f:
for _line in _f:
_line = _line.strip()
if not _line or _line.startswith("#") or "=" not in _line:
continue
if _line.startswith("export "):
_line = _line[len("export "):].strip()
_k, _, _v = _line.partition("=")
_k = _k.strip()
_v = _v.strip().strip('"').strip("'")
if _k and _k not in os.environ:
os.environ[_k] = _v
except FileNotFoundError:
pass
except Exception:
pass
_load_build_conf()
# ── Konfiguracja (nadpisywalna przez env – ułatwia testy i lokalne repo) ──
STATE_FILE = os.environ.get("PAGAN_STATE", "/var/lib/pagan-sync/state.json")
RECIPES_DIR = os.environ.get("PAGAN_RECIPES", "/var/lib/pagan-sync/recipes")
REPO_BASE = os.environ.get("PAGAN_REPO", "/var/www/repo.paganlinux.eu")
BUILD_OUT = os.environ.get("PAGAN_BUILD_OUT", "/var/cache/pagbuild/output")
PAGBUILD_BIN = os.environ.get("PAGAN_PAGBUILD", "/opt/pagan-web-v2/pagbuild")
ROOTFS_PATH = os.environ.get("PAGAN_ROOTFS", "/var/lib/pagan-build/rootfs")
PKG_EXT = ".pag"
GPG_KEY = os.environ.get("PAGAN_GPG_KEY", "") # ID klucza GPG do podpisywania
DO_SIGN = os.environ.get("PAGAN_DO_SIGN", "") == "1" or bool(GPG_KEY)
# Diagnostyka nieudanych buildów (diagnose-<name>-<ver>.json pisany przez
# pagbuild). pagsync --auto-deps czyta ją i dobudowuje brakujące zależności.
DIAG_DIR = os.environ.get("PAGAN_DIAG", "/var/cache/pagbuild/diagnostics")
AUTO_DEPS = True # --no-auto-deps wyłącza
MAX_AUTO_DEPS_RETRIES = 3 # ile razy ponowić build po dobudowaniu deps
# Zawsze wywołuj pagbuild z --strict-deps: brak makedepends w rootfs = twardy
# błąd PRZED buildem (zamiast ostrzeżenia i budowania „na ślepo”). Auto-deps
# i tak dobuduje/doinstaluje braki i ponowi – tryb twardy tylko nie marnuje
# czasu na build, który i tak by padł. Wyłącz przez PAGAN_STRICT_DEPS=0.
STRICT_DEPS = os.environ.get("PAGAN_STRICT_DEPS", "1") not in ("0", "no", "false")
# Exit 75 z pagbuild = „rootfs zajęty przez inny build” (flock). To zwykle stan
# PRZEJŚCIOWY (inny build właśnie się kończy / osierocony proces dobudowuje) –
# zamiast od razu oznaczać pakiet jako FAIL, czekamy i ponawiamy.
MAX_ROOTFS_RETRIES = int(os.environ.get("PAGAN_ROOTFS_RETRIES", "60"))
ROOTFS_RETRY_SLEEP = 30 # sekundy między próbami (30 min max czekania)
# Aktywne procesy pagbuild – przy SIGTERM/SIGINT zabijamy CAŁĄ grupę procesów,
# żeby osierocony build nie trzymał dalej locka rootfs (przyczyna masowych
# porażek exit 75 w przebiegach historycznych).
_ACTIVE_PROCS = []
def _kill_active_procs(signum, frame):
for _p in list(_ACTIVE_PROCS):
try:
os.killpg(_p.pid, signal.SIGTERM) # cała grupa (build script, gcc, ...)
except Exception:
try:
_p.terminate()
except Exception:
pass
sys.exit(128 + signum)
signal.signal(signal.SIGTERM, _kill_active_procs)
signal.signal(signal.SIGINT, _kill_active_procs)
# Kategorie – mapowanie ścieżek recipes na kategorie repo
RECIPE_CAT_MAP = {
"core": "core", "libs": "core", "boot": "core",
"drivers": "drivers", "net": "network",
"gui": "desktop", "de": "desktop",
"utils": "tools",
}
# Domyślne kategorie w repo
REPO_CATEGORIES = ["stable"]
# Git repo z recepturami
RECIPES_GIT_REMOTE = "/var/git/recipes.git" # lokalne bare repo
RECIPES_GIT_DIR = "/var/git/recipes.git" # bare repo na serwerze
# Pakiety z martwym źródłem (host zlikwidowany / brak wydania) – pomijane
# w trybie --missing, żeby nie blokowały kolejki. Można nadpisać env
# PAGAN_SKIP (spacja-oddzielona) lub odkomentowując/wpisując nazwy.
SKIP_PACKAGES = set(os.environ.get("PAGAN_SKIP", "").split()) | {
"vamp-plugin-sdk", "libmpeg2", "libsynctex", "upp", "spotify",
"teams", "nutyx", "xquisite", "volumeicon", "foomatic-db",
"foomatic-db-engine", "foomatic-db-nonfree", "qt2", "openjdk",
"ttf-mplus", "libfakekey", "netcf", "volume_key",
"sayonara-player", "xfce4-datetime-plugin", "virt-viewer",
"qt-creator", "fox", "libunique1",
"syncrosvn", # martwe źródło (syncrosvnclient.com przekierowuje na marketing)
}
# Zależności buildowe dostarczane przez bazowy builder (zawsze "obecne")
BUILD_SYSTEM_DEPS = {
"glibc", "libc", "gcc", "g++", "cc", "make", "cmake", "meson", "ninja",
"binutils", "ld", "ar", "coreutils", "bash", "sh", "tar", "gzip", "xz",
"bzip2", "findutils", "grep", "sed", "gawk", "awk", "diffutils", "patch",
"file", "m4", "perl", "python3", "python", "pkg-config", "pkgconf",
"linux-api-headers", "kernel-headers", "autoconf", "automake", "libtool",
"flex", "bison", "gettext", "makeinfo", "which", "util-linux",
}
# Znane mapowania nazw pkg-config / modułów → nazwy receptur (heurystyka dla
# auto-deps i sugerowania makedepends)
PC_ALIASES = {
"libsoup-3.0": "libsoup3", "libsoup-2.4": "libsoup2",
"gtk+-3.0": "gtk3", "gtk+-2.0": "gtk2", "gtk4": "gtk4",
"glib-2.0": "glib", "gobject-2.0": "glib", "gio-2.0": "glib",
"gdk-pixbuf-2.0": "gdk-pixbuf2", "libpng": "libpng",
"libjpeg": "libjpeg-turbo", "libtiff-4": "libtiff", "ffi": "libffi",
"libcurl": "curl", "openssl": "openssl", "libxml-2.0": "libxml2",
"libxslt": "libxslt", "freetype2": "freetype", "fontconfig": "fontconfig",
"xlib": "xorg-libx11", "xutil": "xorg-libx11",
# ── system / baza ──
"libcrypto": "openssl", "libssl": "openssl",
"libudev": "systemd", "libmount": "util-linux", "libblkid": "util-linux",
"uuid": "util-linux", "libexpat": "expat", "libpcre": "pcre",
"ncursesw": "ncurses", "tinfo": "ncurses", "panel": "ncurses",
"readline": "readline", "libseccomp": "libseccomp", "pam": "linux-pam",
"libxcrypt": "libxcrypt", "libcrypt": "libxcrypt", "libnsl": "libnsl",
"liblz4": "lz4", "bzip2": "bzip2", "zlib": "zlib",
"libevent": "libevent", "libarchive": "libarchive",
"sqlite3": "sqlite", "libpq": "postgresql", "mysqlclient": "mariadb",
"json-c": "json-c", "json-glib-1.0": "json-glib",
"libunistring": "libunistring", "libidn2": "libidn2", "libpsl": "libpsl",
"icu-uc": "icu", "icu-i18n": "icu", "icu-io": "icu",
"gnutls": "gnutls", "nettle": "nettle", "hogweed": "nettle",
"libgcrypt": "libgcrypt", "libgpg-error": "libgpg-error",
"libassuan": "libassuan", "libusb-1.0": "libusb", "libusb": "libusb",
"libgudev-1.0": "libgudev", "gudev-1.0": "libgudev",
"polkit-gobject-1": "polkit", "polkit-agent-1": "polkit",
"libpciaccess": "libpciaccess", "pixman-1": "pixman", "libdrm": "libdrm",
"libva": "libva", "libva-drm": "libva", "libva-x11": "libva",
"libva-wayland": "libva", "vdpau": "libvdpau", "libvdpau": "libvdpau",
"libinput": "libinput", "libevdev": "libevdev", "mtdev": "mtdev",
# ── grafika / GL / multimedia ──
"gbm": "mesa", "gl": "libglvnd", "egl": "libglvnd",
"glesv2": "libglvnd", "glx": "libglvnd", "vulkan": "vulkan-loader",
"libxkbcommon": "libxkbcommon", "xkbcommon": "libxkbcommon",
"xkbcommon-x11": "libxkbcommon", "xcb": "xorg-libxcb",
"xcb-util": "xcb-util", "xcb-keysyms": "xcb-util-keysyms",
"xcb-icccm": "xcb-util-wm", "xcb-cursor": "xcb-util-cursor",
"xcb-renderutil": "xcb-util-renderutil", "xcb-image": "xcb-util-image",
"xcb-errors": "xcb-util-errors",
"wayland-client": "wayland", "wayland-server": "wayland",
"wayland-cursor": "wayland", "wayland-egl": "wayland",
"wayland-protocols": "wayland-protocols",
"gstreamer-1.0": "gstreamer", "gstreamer-base-1.0": "gstreamer",
"gstreamer-check-1.0": "gstreamer", "gstreamer-controller-1.0": "gstreamer",
"gstreamer-app-1.0": "gst-plugins-base", "gstreamer-video-1.0": "gst-plugins-base",
"gstreamer-audio-1.0": "gst-plugins-base", "gstreamer-pbutils-1.0": "gst-plugins-base",
"gstreamer-fft-1.0": "gst-plugins-base", "gstreamer-riff-1.0": "gst-plugins-base",
"gstreamer-rtp-1.0": "gst-plugins-base", "gstreamer-rtsp-1.0": "gst-plugins-base",
"gstreamer-sdp-1.0": "gst-plugins-base", "gstreamer-net-1.0": "gst-plugins-base",
"gstreamer-gl-1.0": "gst-plugins-base",
"libpulse": "libpulse", "libpulse-simple": "libpulse",
"libpulse-mainloop-glib": "libpulse", "alsa": "alsa-lib",
"jack": "jack2", "libsamplerate": "libsamplerate", "sndfile": "libsndfile",
"libavcodec": "ffmpeg", "libavformat": "ffmpeg", "libavutil": "ffmpeg",
"libavfilter": "ffmpeg", "libswscale": "ffmpeg", "libswresample": "ffmpeg",
"libpostproc": "ffmpeg",
"SDL2": "sdl2", "SDL": "sdl", "SDL2_image": "sdl2-image",
"SDL2_ttf": "sdl2-ttf", "SDL2_mixer": "sdl2-mixer", "SDL2_net": "sdl2-net",
"libpng16": "libpng", "libwebp": "libwebp", "libwebpmux": "libwebp",
"libwebpdemux": "libwebp", "libopenjp2": "openjpeg2", "lcms2": "lcms2",
"libheif": "libheif", "libde265": "libde265", "x264": "x264",
"x265": "x265",
# ── glib / gio ──
"gio-unix-2.0": "glib", "gmodule-2.0": "glib", "gthread-2.0": "glib",
"girepository-2.0": "gobject-introspection",
"girepository-1.0": "gobject-introspection",
"libglib-2.0": "glib", "libgobject-2.0": "glib",
}
# Narzędzia buildowe (komenda → pakiet dostarczający) – dla auto-deps i sugestii
TOOL_ALIASES = {
"jam": "ftjam", "pkg-config": "pkgconf", "pkgconfig": "pkgconf",
"glib-mkenums": "glib", "g-ir-scanner": "gobject-introspection",
"intltool-update": "intltool", "msgfmt": "gettext", "autopoint": "gettext",
"xsltproc": "libxslt", "xmllint": "libxml2", "help2man": "help2man",
"gperf": "gperf", "yacc": "bison", "lex": "flex",
}
X11_SHORT = {"xt", "xmu", "ice", "sm", "x11", "xext", "xrandr", "xfixes",
"xcursor", "xinerama", "xrender", "xau", "xcb", "xdamage",
"xcomposite", "xft", "xss", "xvmc", "xres", "xtst",
"xpresent", "xshmfence", "xscrnsaver", "xxf86vm", "xv",
"xkbfile", "xpm", "xaw"}
def load_state():
if os.path.exists(STATE_FILE):
try:
with open(STATE_FILE, "r") as f:
return json.load(f)
except Exception:
pass
return {
"builds": [],
"last_sync": "",
"current_build": None,
"packages": {}, # pkg_name -> {version, category, status, sha256}
"recipe_index": {}, # pkg_name -> {path, mtime, sha256}
}
def save_state(state):
# Zapis ATOMOWY: tmp + os.replace, żeby panel build nigdy nie przeczytał
# napoczętego (niekompletnego) state.json – wcześniej json.dump pisał
# bezpośrednio do pliku i przy dużej zawartości (logi buildów) dało się
# złapać JSONDecodeError w trakcie zapisu.
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
_tmp = STATE_FILE + ".tmp"
with open(_tmp, "w") as f:
json.dump(state, f, indent=2, default=str)
f.flush()
os.fsync(f.fileno())
os.replace(_tmp, STATE_FILE)
def update_current_build(state, name, version, category, progress, step_text=""):
state["current_build"] = {
"name": name, "version": version, "category": category,
"progress": progress, "step_text": step_text,
"start_time": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"),
}
save_state(state)
def clear_current_build(state):
state["current_build"] = None
save_state(state)
# ── Parsowanie PAGBUILD.yaml ──
def parse_recipe(path):
"""Parsuje PAGBUILD.yaml i zwraca słownik z metadanymi."""
import yaml
try:
with open(path) as f:
data = yaml.safe_load(f)
except Exception as e:
print(f"⚠ Błąd parsowania {path}: {e}")
return None
if not data:
return None
pkgname = data.get("pkgname", "")
if not pkgname:
# zgadnij z nazwy katalogu
pkgname = os.path.basename(os.path.dirname(path))
depends = data.get("depends") or []
if isinstance(depends, str):
depends = [depends]
makedepends = data.get("makedepends") or []
if isinstance(makedepends, str):
makedepends = [makedepends]
provides = data.get("provides") or []
if isinstance(provides, str):
provides = [provides]
pkgconfig = data.get("pkgconfig") or []
if isinstance(pkgconfig, str):
pkgconfig = [pkgconfig]
provides = list(provides) + ["pkgconfig(%s)" % p for p in pkgconfig]
return {
"name": pkgname,
"version": str(data.get("pkgver", "0")),
"release": data.get("pkgrel", 1),
"description": data.get("pkgdesc", ""),
"url": data.get("url", ""),
"arch": data.get("arch", "x86_64"),
"license": data.get("license", []) if isinstance(data.get("license"), list) else [data.get("license", "")],
"depends": depends,
"makedepends": makedepends,
"provides": provides,
"sources": data.get("source", []) if isinstance(data.get("source"), list) else [data.get("source", "")],
"sha256sums": data.get("sha256sums", []) if isinstance(data.get("sha256sums"), list) else [data.get("sha256sums", "")],
}
def find_recipe_path(pkg_name):
"""Szuka PAGBUILD.yaml dla pakietu w drzewie recipes."""
for root, dirs, files in os.walk(RECIPES_DIR):
if os.path.basename(root) == pkg_name:
for fn in ["PAGBUILD.yaml", "package.yml", "recipe.yaml"]:
fp = os.path.join(root, fn)
if os.path.isfile(fp):
return fp
return None
def recipe_to_repo_category(recipe_path):
"""Mapuje ścieżkę recipes na kategorię repo."""
parts = recipe_path.split("/")
for part in parts:
if part in RECIPE_CAT_MAP:
return RECIPE_CAT_MAP[part]
return "other"
def _pkg_meta_name(fp):
"""Odczytuje pole 'name' z metadata.json wewnątrz pakietu (None, gdy brak)."""
import tarfile as _tf
import json as _json
try:
with _tf.open(fp, "r:xz") as tf:
for mname in ("metadata.json", "./metadata.json"):
if mname in tf.getnames():
f = tf.extractfile(mname)
if f:
return _json.loads(f.read().decode()).get("name", "")
except Exception:
pass
return None
def _record_installed(name, provides):
"""Dopisuje pakiet do rejestru zainstalowanych w rootfs
($ROOTFS/var/lib/pagbuild/installed.json).
Dzięki temu probe zależności (pre-flight pagbuild i
_dep_satisfied_in_rootfs) rozpoznaje pakiety, których nie da się wykryć
po artefaktach – np. docbook-xsl (dane w /usr/share/xml),
gobject-introspection (binarka g-ir-scanner, nie gobject-introspection),
kerberos (krb5.pc), texinfo (makeinfo), ..."""
if not name:
return
db_path = os.path.join(ROOTFS_PATH.rstrip("/"), "var/lib/pagbuild/installed.json")
try:
os.makedirs(os.path.dirname(db_path), exist_ok=True)
db = {}
if os.path.isfile(db_path):
with open(db_path, encoding="utf-8") as fh:
db = json.load(fh)
inst = set(db.get("installed", []))
prov = set(db.get("provides", []))
inst.add(name)
prov.update(p for p in (provides or []) if p)
db["installed"] = sorted(inst)
db["provides"] = sorted(prov)
with open(db_path, "w", encoding="utf-8") as fh:
json.dump(db, fh, indent=1)
except Exception:
pass
def _install_to_rootfs(pkg_file):
"""Rozpakowuje zbudowany pakiet do działającego rootfs buildera.
Kolejne receptury potrzebują nagłówków/bibliotek wcześniej zbudowanych
pakietów (pagbuild buduje tylko .pag – nie instaluje). Dodatkowo zapisuje
nazwę/provides pakietu do installed.json (patrz _record_installed)."""
import tarfile as _tar
# PUSTE .pag (historyczne 4.0K sprzed naprawy make install) NIE instalujemy:
# nie dają artefaktów, a rejestr installed.json i tak by je „uznał” za
# obecne i probe zależności przestałby je zgłaszać jako BRAK.
if os.path.getsize(pkg_file) < 30000 and _pag_is_empty(pkg_file):
print(f"⏭ PUSTY pakiet {os.path.basename(pkg_file)} – nie instaluję do rootfs (dobuduj z receptury)")
return
tmp_name = ""
meta = {}
try:
with _tar.open(pkg_file, "r:xz") as tf:
names = tf.getnames()
mname = None
if "metadata.json" in names:
mname = "metadata.json"
elif "./metadata.json" in names:
mname = "./metadata.json"
if mname:
mf = tf.extractfile(mname)
if mf:
meta = json.loads(mf.read().decode())
if "data.tar.xz" not in names and "./data.tar.xz" not in names:
return
member = "data.tar.xz" if "data.tar.xz" in names else "./data.tar.xz"
data = tf.extractfile(member)
if not data:
return
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".data.tar.xz")
tmp_name = tmp.name
shutil.copyfileobj(data, tmp)
tmp.close()
with _tar.open(tmp_name, "r:xz") as tf2:
# Python 3.12+: filter="data" (ochrona przed path traversal +
# koniec DeprecationWarning 3.14). Gdyby jakiś pakiet miał np.
# absolutny symlink, wracamy do zachowania sprzed filtra.
try:
tf2.extractall(ROOTFS_PATH, filter="data")
except Exception:
tf2.extractall(ROOTFS_PATH)
os.unlink(tmp_name)
# ldconfig przez chroot: binarka bywa w /usr/sbin (poza uciętym PATH
# cron.d). Błąd ldconfig NIE może blokować rejestracji pakietu – pliki
# są już rozpakowane; rejestr (installed.json) decyduje o pre-flight.
try:
subprocess.run([CHROOT_BIN, ROOTFS_PATH, "/sbin/ldconfig"],
capture_output=True, timeout=120)
except Exception as e:
print(f"⚠ ldconfig w rootfs nieudany (niekrytyczne): {e}")
print(f"🧰 Zainstalowano do rootfs: {os.path.basename(pkg_file)}")
_record_installed(meta.get("name") or "", meta.get("provides") or [])
except Exception as e:
if tmp_name and os.path.exists(tmp_name):
os.unlink(tmp_name)
# Rozpakowanie mogło się już udać – mimo błędu rejestrujemy pakiet,
# żeby pre-flight nie zgłaszał go w kółko jako BRAK (to zapobiega
# pętli „dobuduj tę samą zależność” w kolejnych przejściach kolejki).
if meta and meta.get("name"):
_record_installed(meta.get("name"), meta.get("provides") or [])
print(f"⚠ Instalacja do rootfs nieudana: {e}")
def install_pkg_to_rootfs(name):
"""Instaluje zbudowany pakiet <name> (stable/<name>-*.pag) do rootfs buildera.
Jeśli pakietu nie ma w repo – informuje, że trzeba go najpierw zbudować.
"""
import glob as _g
stable = os.path.join(REPO_BASE, "stable")
files = sorted(_g.glob(os.path.join(stable, name + "-*.pag")))
if not files:
print(f"❌ Brak pakietu {name} w repo (stable). Najpierw zbuduj go – "
f"pojedynczy build automatycznie instaluje do rootfs.")
return
pkg_file = files[-1]
print(f"🧰 Instalacja do rootfs: {os.path.basename(pkg_file)}")
_install_to_rootfs(pkg_file)
def _purge_empty_from_installed_db():
"""Usuwa z installed.json pakiety, których .pag w repo jest PUSTY.
Historyczne puste .pag (receptury bez make install, sprzed naprawy)
zostały zainstalowane do rootfs i zarejestrowane – rejestr maskuje probe
zależności (pre-flight uznaje dep za obecny), więc nigdy nie są
przebudowywane, a konsumenci padają na brakujących .pc/nagłówkach
(xorg-libice -> ice.pc, xorg-libxv -> xv.pc, libevdev -> libevdev.pc).
Po usunięciu z rejestru probe zgłosi BRAK i auto-deps dobuduje je."""
db_path = os.path.join(ROOTFS_PATH.rstrip("/"), "var/lib/pagbuild/installed.json")
if not os.path.isfile(db_path):
return
import glob as _g
try:
with open(db_path, encoding="utf-8") as fh:
db = json.load(fh)
except Exception:
return
installed = set(db.get("installed", []))
if not installed:
return
stable = os.path.join(REPO_BASE, "stable")
removed = []
for name in sorted(installed):
files = sorted(_g.glob(os.path.join(stable, name + "-*.pag")))
if not files:
continue
pkg = files[-1]
if os.path.getsize(pkg) >= 30000:
continue
if _pag_is_empty(pkg):
installed.discard(name)
removed.append(name)
if removed:
db["installed"] = sorted(installed)
with open(db_path, "w", encoding="utf-8") as fh:
json.dump(db, fh, indent=1)
print(f"🧹 Purge installed.json: usunięto puste pakiety (dobuduję je): {', '.join(removed)}")
# ── Sprawdzanie makedepends/depends względem rootfs (pre-flight) ──
def _dep_satisfied_in_rootfs(dep):
"""Heurystyczna proba: czy zależność buildowa jest obecna w rootfs.
Sprawdza narzędzie (usr/bin), moduł pkg-config (*.pc), nagłówek (include/)
i bibliotekę (lib*.so) – to artefakty, których szukają buildy.
"""
if not dep:
return True
rootfs = ROOTFS_PATH.rstrip("/")
# 0) rejestr pakietów zainstalowanych do rootfs (installed.json pisany przez
# _install_to_rootfs / pagsync --install) – dokładne pokrycie pakietów,
# których nie da się wykryć po artefaktach (docbook-xsl, kerberos, ...)
inst_db = os.path.join(rootfs, "var/lib/pagbuild/installed.json")
if os.path.isfile(inst_db):
try:
with open(inst_db, encoding="utf-8") as fh:
db = json.load(fh)
names = set(db.get("installed", [])) | set(db.get("provides", []))
check = dep
if dep.startswith("pkgconfig(") and dep.endswith(")"):
check = dep[len("pkgconfig("):-1]
if dep in names or check in names:
return True
except Exception:
pass
# 1) narzędzie
for sub in ("usr/local/bin", "usr/bin", "usr/sbin", "bin", "sbin"):
if os.path.exists(os.path.join(rootfs, sub, dep)):
return True
# 2) moduł pkg-config
for sub in ("usr/lib/pkgconfig", "usr/share/pkgconfig", "usr/lib64/pkgconfig",
"usr/lib/x86_64-linux-gnu/pkgconfig"):
if os.path.isfile(os.path.join(rootfs, sub, dep + ".pc")):
return True
# 3) nagłówek / katalog nagłówków
if os.path.isfile(os.path.join(rootfs, "usr/include", dep + ".h")):
return True
if os.path.isdir(os.path.join(rootfs, "usr/include", dep)):
return True
# 4) biblioteka lib<dep>.so* / lib<dep>.a (i <dep>.so*, gdy dep już ma
# prefiks lib – np. liblmdb -> liblmdb.so, a nie libliblmdb.so)
for sub in ("usr/lib", "usr/lib64", "usr/lib/x86_64-linux-gnu", "lib", "lib64"):
d = os.path.join(rootfs, sub)
if not os.path.isdir(d):
continue
for entry in os.listdir(d):
if entry.startswith("lib" + dep + ".") or entry.startswith(dep + "."):
return True
# 5) heurystyka xorg-libX11 (nazwa pakietu != nazwa biblioteki)
if dep.startswith("xorg-lib"):
short = dep[len("xorg-lib"):]
libname = "lib" + (short[:1].upper() + short[1:])
for sub in ("usr/lib", "usr/lib64", "usr/lib/x86_64-linux-gnu"):
d = os.path.join(rootfs, sub)
if not os.path.isdir(d):
continue
for entry in os.listdir(d):
if entry.startswith(libname + "."):
return True
# 6) znane mapowania pakiet -> artefakty (nazwa pakietu != nazwa artefaktu:
# dbus -> dbus-1.pc/libdbus-1, kerberos -> krb5.pc, alsa-lib -> alsa.pc,
# ...). Bez tego zbudowane pakiety były fałszywie zgłaszane jako BRAK
# i niepotrzebnie przebudowywane (--strict-deps potrafił nawet zatrzymać
# build na zainstalowanym pakiecie).
arts = PKG_ARTIFACTS.get(dep)
if arts:
pcs, bins, hdrs, libs = arts
for sub in ("usr/lib/pkgconfig", "usr/share/pkgconfig", "usr/lib64/pkgconfig",
"usr/lib/x86_64-linux-gnu/pkgconfig"):
for pc in pcs:
if os.path.isfile(os.path.join(rootfs, sub, pc)):
return True
for sub in ("usr/local/bin", "usr/bin", "usr/sbin", "bin", "sbin"):
for b in bins:
if os.path.exists(os.path.join(rootfs, sub, b)):
return True
for h in hdrs:
if os.path.isfile(os.path.join(rootfs, "usr/include", h)):
return True
for sub in ("usr/lib", "usr/lib64", "usr/lib/x86_64-linux-gnu"):
d = os.path.join(rootfs, sub)
if not os.path.isdir(d):
continue
for entry in os.listdir(d):
for lb in libs:
if entry.startswith(lb + "."):
return True
# 7) pakiety-dane (katalogi w /usr/share)
if dep == "docbook-xsl" and os.path.isdir(os.path.join(rootfs, "usr/share/xml/docbook")):
return True
if dep == "sgml-common" and os.path.isdir(os.path.join(rootfs, "usr/share/sgml")):
return True
# 8) moduły Pythona – czysty python (python-setuptools, python-build, ...)
# nie ma binarki/.pc/nagłówka/lib, więc probe po artefaktach zawsze
# zgłaszał BRAK (nawet po instalacji do rootfs) i pętla auto-deps
# przebudowywała te same pakiety. Szukamy katalogu modułu i
# dist-info/egg-info w site-packages.
if _py_dep_satisfied_in_rootfs(dep, rootfs):
return True
# 9) moduły Perla (perl-*): brak binarki/.pc/lib o tej nazwie – szukamy
# pliku/katalogu modułu w site_perl/vendor_perl (perl-xml-simple ->
# XML/Simple.pm).
if _perl_dep_satisfied_in_rootfs(dep, rootfs):
return True
return False
PY_MODULE_ALIASES = {
# nazwa receptury (python-*) -> nazwa modułu / katalogu w site-packages,
# gdy nie wynika z nazwy (python-xdg instaluje się jako pyxdg, ...)
"python-xdg": "pyxdg",
"python-yaml": "yaml",
"python-gobject": "gi",
"python-jinja": "jinja2",
"python-importlib-metadata": "importlib_metadata",
"python-setuptools-scm": "setuptools_scm",
"python-vcs-versioning": "vcs_versioning",
"python-pyproject-hooks": "pyproject_hooks",
"python-typing-extensions": "typing_extensions",
"python-docutils": "docutils",
"python-markupsafe": "markupsafe",
}
def _py_module_candidates(dep):
"""Kandydaci na nazwy modułów/katalogów w site-packages dla zależności."""
out = []
n = dep
if n.startswith("pkgconfig(") and n.endswith(")"):
n = n[len("pkgconfig("):-1]
if n.startswith("python-") or n.startswith("py-"):
mod = n.split("-", 1)[1]
out += [mod, mod.replace("-", "_")]
out.append(n.replace("-", "_"))
else:
out.append(n)
if "-" in n:
out.append(n.replace("-", "_"))
out.append(PY_MODULE_ALIASES.get(dep, ""))
return [c for c in dict.fromkeys(out) if c]
PERL_MODULE_PATHS = {
# nazwa receptury (perl-*) -> relatywna ścieżka modułu w site_perl
"perl-libwww": "LWP.pm",
"perl-lwp-mediatypes": "LWP/MediaTypes.pm",
"perl-www-robotrules": "WWW/RobotRules.pm",
"perl-uri": "URI.pm",
"perl-gd": "GD.pm",
"perl-dbi": "DBI.pm",
"perl-xml-parser": "XML/Parser.pm",
"perl-xml-simple": "XML/Simple.pm",
"perl-xml-sax": "XML/SAX.pm",
"perl-xml-sax-base": "XML/SAX/Base.pm",
"perl-xml-libxml": "XML/LibXML.pm",
"perl-xml-namespacesupport": "XML/NamespaceSupport.pm",
"perl-xml-xpath": "XML/XPath.pm",
"perl-html-parser": "HTML/Parser.pm",
"perl-html-tagset": "HTML/Tagset.pm",
"perl-html-tree": "HTML/Tree.pm",
"perl-http-date": "HTTP/Date.pm",
"perl-http-message": "HTTP/Message.pm",
"perl-http-cookies": "HTTP/Cookies.pm",
"perl-http-daemon": "HTTP/Daemon.pm",
"perl-http-negotiate": "HTTP/Negotiate.pm",
"perl-net-http": "Net/HTTP.pm",
"perl-file-listing": "File/Listing.pm",
"perl-encode-locale": "Encode/Locale.pm",
"perl-datemanip": "Date/Manip.pm",
}
def _perl_dep_satisfied_in_rootfs(dep, rootfs):
"""Czy zależność to moduł Perla obecny w site_perl/vendor_perl rootfs."""
if not dep.startswith("perl-"):
return False
rel = PERL_MODULE_PATHS.get(dep)
if not rel:
mod = dep[len("perl-"):]
rel = "/".join(s.capitalize() for s in mod.split("-")) + ".pm"
pl_roots = []
for cand in glob.glob(os.path.join(rootfs, "usr/lib/perl*/*/site_perl")):
pl_roots.append(cand)
for cand in glob.glob(os.path.join(rootfs, "usr/lib/perl*/*/vendor_perl")):
pl_roots.append(cand)
for pl in pl_roots:
if os.path.isfile(os.path.join(pl, rel)):
return True
if os.path.isdir(os.path.join(pl, rel[: -len(".pm")])):
return True
return False
def _py_dep_satisfied_in_rootfs(dep, rootfs):
"""Czy zależność wygląda na moduł Pythona obecny w site-packages rootfs."""
sp_roots = []
for cand in glob.glob(os.path.join(rootfs, "usr/lib/python*")):
sp = os.path.join(cand, "site-packages")
if os.path.isdir(sp):
sp_roots.append(sp)
# dystro mogą kłaść też w /usr/lib64/python*/site-packages
for cand in glob.glob(os.path.join(rootfs, "usr/lib64/python*")):
sp = os.path.join(cand, "site-packages")
if os.path.isdir(sp):
sp_roots.append(sp)
if not sp_roots:
return False
entries = set()
for sp in sp_roots:
try:
entries.update(os.listdir(sp))
except OSError:
continue
norm = {e.replace("-", "_") for e in entries}
for cand in _py_module_candidates(dep):
c_ = cand.replace("-", "_")
if c_ in norm:
return True
# dist-info / egg-info: „setuptools-84.0.0.dist-info” → prefix „setuptools”,
# „typing_extensions-4.16.0.dist-info” → „typing_extensions”
for e in norm:
stem = e
for sep in (".dist_info", ".egg_info"):
if sep in stem:
stem = stem.split(sep)[0]
break
m = re.match(r"^(.+?)_\d+", stem) # odetnij wersję
if m:
stem = m.group(1)
if stem == c_:
return True
return False
# Pakiet -> (moduły .pc, binarki, nagłówki, biblioteki z prefiksem lib) –
# używane przez _dep_satisfied_in_rootfs. Lustrzane do tabeli aliasów
# probe_rootfs_dep w pagbuild (dwóch źródeł nie unikniemy – różne języki).
PKG_ARTIFACTS = {
"dbus": (["dbus-1.pc"], ["dbus-daemon"], ["dbus-1.0/dbus/dbus.h"], ["libdbus-1"]),
"kerberos": (["krb5.pc"], ["krb5-config"], ["krb5/krb5.h"], ["libkrb5"]),
"alsa-lib": (["alsa.pc"], [], ["alsa/asoundlib.h"], ["libasound"]),
"e2fsprogs": (["e2p.pc"], ["mkfs.ext4"], ["ext2fs/ext2fs.h"], ["libext2fs"]),
"gobject-introspection": (["gobject-introspection-1.0.pc"], ["g-ir-scanner"],
["girepository-1.0/girepository.h"], ["libgirepository-1.0"]),
"sgml-common": ([], ["install-catalog"], [], []),
"glib": (["glib-2.0.pc"], [], [], ["libglib-2.0"]),
"gtk3": (["gtk+-3.0.pc"], [], [], ["libgtk-3"]),
"gtk2": (["gtk+-2.0.pc"], [], [], ["libgtk-x11-2.0"]),
"gtk4": (["gtk4.pc"], [], [], ["libgtk-4"]),
"gdk-pixbuf": (["gdk-pixbuf-2.0.pc"], [], [], ["libgdk_pixbuf-2.0"]),
"pango": (["pango.pc"], [], [], ["libpango-1.0"]),
"cairo": (["cairo.pc"], [], [], ["libcairo"]),
"harfbuzz": (["harfbuzz.pc"], [], [], ["libharfbuzz"]),
"freetype2": (["freetype2.pc"], [], [], ["libfreetype"]),
"libxml2": (["libxml-2.0.pc"], [], [], ["libxml2"]),
"libpng": (["libpng.pc"], [], [], ["libpng"]),
"libjpeg-turbo": (["libjpeg.pc"], [], [], ["libjpeg"]),
"libtiff": (["libtiff-4.pc"], [], [], ["libtiff"]),
"lcms2": (["lcms2.pc"], [], [], ["liblcms2"]),
"libpcap": (["libpcap.pc"], [], [], ["libpcap"]),
"ncurses": (["ncursesw.pc"], [], [], ["libncursesw"]),
"zlib": (["zlib.pc"], [], [], ["libz"]),
"bzip2": (["bzip2.pc"], [], [], ["libbz2"]),
"xz": (["liblzma.pc"], [], [], ["liblzma"]),
"readline": (["readline.pc"], [], [], ["libreadline"]),
"expat": (["expat.pc"], [], [], ["libexpat"]),
"sqlite": (["sqlite3.pc"], [], [], ["libsqlite3"]),
"curl": (["libcurl.pc"], [], [], ["libcurl"]),
"openssl": (["openssl.pc"], [], [], ["libssl"]),
"libffi": (["libffi.pc"], [], [], ["libffi"]),
"libxcrypt": (["libxcrypt.pc"], [], [], ["libxcrypt"]),
"libgpg-error": (["gpg-error.pc"], [], [], ["libgpg-error"]),
"gnutls": (["gnutls.pc"], [], [], ["libgnutls"]),
"libxkbcommon": (["xkbcommon.pc"], [], [], ["libxkbcommon"]),
"wayland": (["wayland-client.pc"], [], [], ["libwayland-client"]),
"libx11": (["x11.pc"], [], [], ["libX11"]),
"xorg-libx11": (["x11.pc"], [], [], ["libX11"]),
"mesa": (["gl.pc"], [], [], ["libGL"]),
"libgl": (["gl.pc"], [], [], ["libGL"]),
# cargo-c instaluje binarki cargo-cbuild/cargo-capi/... – nie ma binarki
# "cargo-c", więc probe po nazwie nie trafiała i pakiet był niepotrzebnie
# przebudowywany mimo dobrego .pag w repo.
"cargo-c": ([], ["cargo-cbuild", "cargo-capi", "cargo-cinstall", "cargo-ctest"], [], []),
# openssh / apr / apr-util: instalowane artefakty mają inne nazwy niż
# pakiet (ssh/sshd, apr-1.pc/libapr-1.so, apu-1.pc/libaprutil-1.so) –
# probe po nazwie zawsze zgłaszał BRAK mimo obecności w rootfs.
"openssh": (["openssh.pc"], ["ssh", "sshd", "ssh-keygen", "ssh-keyscan", "ssh-agent"], [], []),
"apr": (["apr-1.pc"], ["apr-1-config"], ["apr-1/apr.h"], ["libapr-1"]),
"apr-util": (["apr-util-1.pc"], ["apu-1-config"], ["apr-1/apu.h"], ["libaprutil-1"]),
# sway-desktop: wlroots (wlroots-0.20.pc), pcre2 (pcre2-8.pc), pam i
# header-only tllist (tllist.h) – artefakty o innych nazwach niż pakiet.
"wlroots": (["wlroots-0.20.pc"], [], ["wlr/types.h"], ["libwlroots-0.20"]),
"pcre2": (["pcre2-8.pc"], [], ["pcre2.h"], ["libpcre2-8"]),
"pam": (["pam.pc"], [], ["security/pam_appl.h"], ["libpam"]),
"tllist": ([], [], ["tllist.h"], []),
}
def warn_unsatisfied_deps(ordered, all_recipes):
"""Pre-flight: sprawdza makedepends/depends względem rootfs, receptur i repo.
Depy obecne w repo instaluje do rootfs OD RAZU (zanim build ruszy –
„instaluj z repo; jak nie ma w repo, dobuduj z receptury"); ostrzega
tylko o tych, których nie ma NIGDZIE.
"""
name_to_meta = {m["name"]: m for _, m in all_recipes}
installed = set() # już doinstalowane w tej sesji pre-flightu
for fp, meta in ordered:
name = meta["name"]
for dep in list(meta.get("makedepends", [])) + list(meta.get("depends", [])):
if dep in name_to_meta or dep in BUILD_SYSTEM_DEPS:
continue
if _dep_satisfied_in_rootfs(dep):
continue
hit = _search_repo_package(dep)
if hit:
if dep not in installed:
print(f" 🔧 {name}: '{dep}' brak w rootfs – instaluję z repo ({hit[0]})")
_install_to_rootfs(hit[1])
installed.add(dep)
continue
print(f" ⚠ {name}: '{dep}' nie jest spełniony w rootfs i nie ma receptury "
f"ani w repo – build może paść (auto-deps nie pomoże, brak przepisu)")
# ── Auto-deps: analiza diagnostyki pagbuild i dobudowa zależności ──
def read_build_diagnostics(pkg_name):
"""Czyta najnowszy plik diagnose-<name>-*.json z DIAG_DIR.
Zwraca listę brakujących zależności; plik jest usuwany po odczytaniu.
"""
import glob as _g
if not os.path.isdir(DIAG_DIR):
return []
files = sorted(_g.glob(os.path.join(DIAG_DIR, "diagnose-" + pkg_name + "-*.json")),
key=os.path.getmtime, reverse=True)
for f in files:
try:
with open(f, encoding="utf-8") as fh:
data = json.load(fh)
except Exception:
continue
try:
os.unlink(f)
except OSError:
pass
return data.get("missing", [])
return []
def _provider_candidates(name, kind=""):
"""Możliwe nazwy receptur dostarczających brakujący element (heurystyka)."""
if not isinstance(name, str) or not name:
return set()
n = name.strip()
if n.startswith("pkgconfig(") and n.endswith(")"):
n = n[len("pkgconfig("):-1]
cands = {name, n}
if not n.startswith("pkgconfig("):
cands.add("pkgconfig(" + n + ")")
if n in PC_ALIASES:
cands.add(PC_ALIASES[n])
if n in TOOL_ALIASES:
cands.add(TOOL_ALIASES[n])
# lib<foo> -> <foo> (liblmdb -> lmdb, libpcre2 -> pcre2, ...) – dopasowanie
# nazw bibliotek do nazw pakietów bez prefiksu "lib" (receptury bywają
# nazwane od biblioteki, a zależność wpisana z prefiksem albo odwrotnie).
if n.startswith("lib") and len(n) > 4:
cands.add(n[3:])
m = re.match(r"^(lib[\w+]+)-(\d+)(?:[.-]\d+)*$", n)
if m:
cands.add(m.group(1) + m.group(2))
if n in X11_SHORT or n.lower() in X11_SHORT:
cands.add("xorg-lib" + n.lower())
# Wzorce: moduły Qt (Qt5Core/Qt6Widgets) i GStreamer (gstreamer-video-1.0)
if n.startswith("Qt5"):
cands.add("qt5")
if n.startswith("Qt6"):
cands.add("qt6")
if n.startswith("gstreamer-") and n.endswith("-1.0"):
cands.add("gstreamer")
if n.startswith("gst-"):
cands.add("gst-plugins-base")
if kind == "header":
base = os.path.basename(n).replace(".h", "")
if base:
cands.add(base)
cands.add("lib" + base)
if base.lower() in PC_ALIASES:
cands.add(PC_ALIASES[base.lower()])
if base in X11_SHORT or base.lower() in X11_SHORT:
cands.add("xorg-lib" + base.lower())
if re.match(r"^X[A-Za-z]+$", base):
cands.add("xorg-lib" + base.lower())
if kind == "library":
cands.add("lib" + n)
return {c for c in cands if c}
def _recipes_maps(state):
"""(name -> recipe_path, provides -> [recipe_names]) dla całego drzewa."""
recipes = scan_recipes(state)
name_to_path = {m["name"]: fp for fp, m in recipes}
provides_index = {}
for fp, m in recipes:
for p in m.get("provides", []):
provides_index.setdefault(p, []).append(m["name"])
return name_to_path, provides_index
def resolve_missing_to_recipes(missing, state):
"""Dla listy braków z diagnostyki zwraca nazwy receptur do dobudowania."""
name_to_path, provides_index = _recipes_maps(state)
out = []
for item in missing:
if not isinstance(item, dict):
continue
name = item.get("name", "")
kind = item.get("kind", "")
if not name:
continue
for c in _provider_candidates(name, kind):
if c in name_to_path:
if c not in out and c not in SKIP_PACKAGES:
out.append(c)
break
found = next((p for p in provides_index.get(c, []) if p not in SKIP_PACKAGES), None)
if found:
if found not in out:
out.append(found)
break
return out
def _pkg_built(name, state):
"""Czy pakiet ma już zbudowany .pag (w state packages lub w repo)."""
sp = state.get("packages", {}).get(name)
if sp and sp.get("status") == "ok":
return True
import glob as _g
for cat in REPO_CATEGORIES:
if _g.glob(os.path.join(REPO_BASE, cat, name + "-*.pag")):
return True
return False
def _search_repo_package(name, kind=""):
"""Szuka w lokalnym repo pakietu dostarczającego brakujący element –
odpowiednik `pag search <nazwa>` po stronie buildera.
Najpierw czyta repo.json (indeks z polami provides/pkgconfig – dokładne
mapowanie, np. pkgconfig(foo) -> pakiet), potem robi fallback do globa
po plikach .pag na dysku. Zwraca (nazwa_pakietu, ścieżka_do_pagu) lub None.
"""
import glob as _g
cands = _provider_candidates(name, kind) | {name.strip()}
# 1) repo.json – provides (np. pkgconfig(foo)) i nazwy pakietów
for cat in REPO_CATEGORIES:
rj = os.path.join(REPO_BASE, cat, "repo.json")
if not os.path.isfile(rj):
continue
try:
with open(rj, encoding="utf-8") as fh:
data = json.load(fh)
except Exception:
continue
for p in data.get("packages", []) or []:
pname = p.get("name", "")
provs = set(p.get("provides") or [])
if not (pname in cands or (provs & cands)):
continue
fname = p.get("filename", "")
fp = os.path.join(REPO_BASE, cat, fname)
if fname and os.path.isfile(fp):
return pname, fp
# 2) fallback: pliki .pag na dysku
for cat in REPO_CATEGORIES:
pkg_dir = os.path.join(REPO_BASE, cat)
if not os.path.isdir(pkg_dir):
continue
for c in sorted(cands):
hits = sorted(_g.glob(os.path.join(pkg_dir, c + "-*.pag")))
if hits:
return c, hits[-1]
return None
def _install_missing_from_repo(missing):
"""Fallback dla braków BEZ receptury: szuka gotowego pakietu w repo
(odpowiednik `pag search <nazwa>`) i instaluje go do rootfs buildera
(odpowiednik `pag install <nazwa>`, ale do CHROOT – `pag install` celuje
w system HOSTA i nie pomógłby buildowi w chroot; bazę pag trzyma też poza
PAG_ROOT, więc użycie PAG_ROOT=rootfs zepsułoby DB hosta).
Zwraca True, jeśli cokolwiek zainstalowano (lub już było spełnione).
"""
installed_any = False
for item in missing:
if not isinstance(item, dict):
continue
name = item.get("name", "")
kind = item.get("kind", "")
if not name:
continue
hit = _search_repo_package(name, kind)
if not hit:
print(f" ⚠ brak receptury i brak w repo: {name}")
continue
pkg_name, pkg_file = hit
# pkgconfig(foo) sprawdzamy po wewnętrznej nazwie modułu (foo.pc)
check = name[10:-1] if name.startswith("pkgconfig(") and name.endswith(")") else name
if _dep_satisfied_in_rootfs(check):
print(f" ✅ {name} już obecny w rootfs (repo: {pkg_name})")
installed_any = True
continue
print(f"📦 Z repo (odpowiednik `pag install {pkg_name}`): "
f"{os.path.basename(pkg_file)} → instaluję do rootfs")
_install_to_rootfs(pkg_file)
installed_any = True
return installed_any
def _unsatisfied_declared_deps(recipe_path, state):
"""Makedepends/depends z receptury, które NIE są spełnione w rootfs ani przez
zbudowany .pag – uzupełnienie dla diagnostyki z logu builda.
Dzięki temu auto-deps działa nawet wtedy, gdy log nie wskazuje wprost braku
(np. ftjam dostarcza binarkę `jam`, a build woła `jam: command not found`
– po logu nie widać związku z pakietem ftjam, a po makedepends widać).
"""
meta = parse_recipe(recipe_path)
if not meta:
return []
name_to_path, _ = _recipes_maps(state)
out = []
for dep in list(meta.get("makedepends", [])) + list(meta.get("depends", [])):
if not dep or dep in BUILD_SYSTEM_DEPS:
continue
if dep in name_to_path:
# ma recepturę w drzewie – dobuduj, jeśli nie ma jeszcze .pag
if not _pkg_built(dep, state):
out.append({"kind": "makedep", "name": dep, "suggested": dep})
continue
if not _dep_satisfied_in_rootfs(dep):
out.append({"kind": "makedep", "name": dep, "suggested": dep})
return out
def clean_old_versions(category_dir, name, keep_filename):
"""Usuwa starsze wersje pakietu `name` z category_dir (zostaje tylko aktualna).
Bezpieczne dzięki weryfikacji metadata.json — nazwa pliku może zawierać
myślniki (np. xorg-libx11), więc prefiks `<name>-` nie wystarcza; sprawdzamy
rzeczywistą nazwę pakietu w metadata, aby nie skasować innego pakietu.
"""
removed = []
for fname in sorted(os.listdir(category_dir)):
if not fname.endswith(PKG_EXT):
continue
if fname == keep_filename:
continue
if not fname.startswith(name + "-"):
continue
fp = os.path.join(category_dir, fname)
if _pkg_meta_name(fp) != name:
continue
try:
os.remove(fp)
removed.append(fname)
except OSError:
continue
# usuń też podpisy odłączone
for sfx in (".asc", ".sig"):
sp = fp + sfx
if os.path.exists(sp):
try:
os.remove(sp)
except OSError:
pass
return removed
def _ver_key(pkg):
"""Klucz sortowania wersji – porównywalna krotka (major.minor.patch, release).
Umożliwia wybór najnowszej wersji przy dedupe repo.json. Wersje nie-numeryczne
(np. '1.4rc5') traktowane są leksykalnie jako fallback.
"""
ver = str(pkg.get("version", "0"))
rel = int(pkg.get("release", 0) or 0)
nums = []
for part in re.split(r"[^\d]+", ver):
nums.append(int(part) if part.isdigit() else -1)
return (tuple(nums), rel, ver)
# ── Budowanie pojedynczego pakietu ──
def _warn_unresolved_runtime_deps(name, depends, state):
"""(test pakietu) Po udanym buildzie sprawdza, czy runtime `depends` są
osiągalne: w repo (stable), w rootfs buildera albo jako receptura (dobuduje
się w kolejce). Odpowiednik repotest – ostrzega o depends, których klient
pag nie będzie umiał rozwiązać przy instalacji."""
if not depends:
return
import glob as _g
name_to_path, _ = _recipes_maps(state)
bad = []
for dep in depends:
dep = (dep or "").strip()
if not dep or dep in BUILD_SYSTEM_DEPS:
continue
if _g.glob(os.path.join(REPO_BASE, "stable", dep + "-*.pag")):
continue
if dep in name_to_path or _dep_satisfied_in_rootfs(dep):
continue
bad.append(dep)
if bad:
print(f"⚠ [test] {name}: depends spoza repo/rootfs/receptur: "
f"{', '.join(bad)} – pag install może ich nie rozwiązać")
def _do_single_build(pkg_name, recipe_path, state):
meta = parse_recipe(recipe_path)
if not meta:
print(f"❌ Nie można sparsować {recipe_path}")
# caller robi: ok, preflight_missing = _do_single_build(...) –
# sam bool wywalał kolejkę TypeError (cannot unpack).
return (False, [])
name = meta["name"]
ver = meta["version"]
rel = meta["release"]
category = recipe_to_repo_category(recipe_path)
print(f"🔨 [BUILD] {name}-{ver}-{rel} [{category}] – start")
update_current_build(state, name, ver, category, "0%", "Uruchamianie pagbuild...")
cmd = [PAGBUILD_BIN, recipe_path, "--rootfs", ROOTFS_PATH]
if STRICT_DEPS:
cmd.append("--strict-deps")
if DO_SIGN:
cmd.append("--sign")
if GPG_KEY:
cmd.extend(["--gpg-key", GPG_KEY])
log_lines = []
start_t = time.time()
# Exit 75 z pagbuild = „rootfs zajęty przez inny build” – to zwykle stan
# przejściowy (konkurujący/osierocony build właśnie się kończy). Zamiast
# od razu oznaczać pakiet jako FAIL, czekamy i ponawiamy.
rootfs_retries = 0
while True:
try:
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
errors='replace',
env={**os.environ, "PYTHONUNBUFFERED": "1"},
start_new_session=True, # własna grupa – czyszczenie przy SIGTERM
)
except Exception as e:
# ETXTBSY („Text file busy”) = pagbuild jest właśnie podmieniany
# (deploy w trakcie runu) – stan przejściowy, ponawiamy jak exit 75.
if rootfs_retries < MAX_ROOTFS_RETRIES:
rootfs_retries += 1
msg = (f"⏳ Nie można uruchomić pagbuild ({e}) – czekam "
f"{ROOTFS_RETRY_SLEEP} s i ponawiam ({rootfs_retries}/{MAX_ROOTFS_RETRIES})...")
print(f" │ {msg}")
time.sleep(ROOTFS_RETRY_SLEEP)
continue
print(f"❌ Nie można uruchomić pagbuild: {e}")
clear_current_build(state)
# UWAGA: caller oczekuje krotki (ok, preflight_missing) – zwrócenie
# samego False wywalało całą kolejkę TypeError (cannot unpack).
return False, []
_ACTIVE_PROCS.append(proc)
for line in proc.stdout:
line = line.rstrip()
log_lines.append(line)
print(f" │ {line}")
if "FAZA BUILD" in line:
update_current_build(state, name, ver, category, "30%", "Kompilacja...")
elif "FAZA PACKAGE" in line:
update_current_build(state, name, ver, category, "70%", "Instalacja do DESTDIR...")
elif "Pakowanie" in line or "🗜" in line:
update_current_build(state, name, ver, category, "90%", f"Generowanie {PKG_EXT}...")
proc.wait()
try:
_ACTIVE_PROCS.remove(proc)
except ValueError:
pass
if proc.returncode == 75 and rootfs_retries < MAX_ROOTFS_RETRIES:
rootfs_retries += 1
msg = (f"⏳ rootfs zajęty przez inny build – czekam {ROOTFS_RETRY_SLEEP} s "
f"i ponawiam ({rootfs_retries}/{MAX_ROOTFS_RETRIES})...")
print(f" │ {msg}")
log_lines.append(msg)
time.sleep(ROOTFS_RETRY_SLEEP)
continue
break
duration = round(time.time() - start_t, 1)
status = "ok" if proc.returncode == 0 else "failed"
# ── Kopiowanie zbudowanego pakietu do repo ──
if status == "ok":
print(f"✅ [BUILD] {name}-{ver} OK ({duration}s)")
# Szukaj wyjściowego pliku
pkg_pattern = f"{name}-{ver}-{rel}{PKG_EXT}"
src_pkg = os.path.join(BUILD_OUT, pkg_pattern)
if not os.path.exists(src_pkg):
# Spróbuj alternatywne nazwy
import glob as gmod
candidates = gmod.glob(os.path.join(BUILD_OUT, f"{name}-{ver}*{PKG_EXT}"))
if candidates:
src_pkg = candidates[0]
if os.path.exists(src_pkg):
dst_dir = os.path.join(REPO_BASE, "stable")
os.makedirs(dst_dir, exist_ok=True)
dst_file = os.path.basename(src_pkg)
shutil.move(src_pkg, os.path.join(dst_dir, dst_file))
print(f"📦 Przeniesiono do: {dst_dir}/{dst_file}")
# Podpis GPG (.asc) musi trafić do repo razem z pakietem –
# klient pag weryfikuje go fail-closed (brak podpisu = odrzucenie).
sig_src = src_pkg + ".asc"
if os.path.exists(sig_src):
shutil.move(sig_src, os.path.join(dst_dir, dst_file + ".asc"))
print(f"🔏 Przeniesiono podpis: {dst_file}.asc")
else:
print(f"⚠ Brak podpisu {dst_file}.asc – pakiet zostanie odrzucony przez pag!")
# Gwarancja podpisu: gdy .asc nie dotarł (podpisywanie padło), a w repo
# leżał STARY .asc od tej samej nazwy pliku, klient dostawał
# „NIEPRAWIDŁOWY PODPIS GPG". Zweryfikuj i w razie potrzeby podpisz
# ponownie; jeśli to się nie uda – usuń zły .asc (czytelny błąd > BAD SIG).
_dst_fp = os.path.join(dst_dir, dst_file)
_sig_ok, _sig_reason = _verify_sig(_dst_fp)
if not _sig_ok:
if _sign_file(_dst_fp):
_sig_ok, _sig_reason = _verify_sig(_dst_fp)
if _sig_ok:
print(f"🔏 Podpis zweryfikowany/odtworzony: {dst_file}")
else:
print(f"⚠ {dst_file}: podpis nieprawidłowy ({_sig_reason}) – usuwam .asc")
try:
os.remove(_dst_fp + ".asc")
except OSError:
pass
# Zachowaj tylko aktualną wersję – usuń starsze <name>-*.pag w repo
removed = clean_old_versions(dst_dir, name, dst_file)
for r in removed:
print(f"🗑 Usunięto starą wersję: {r}")
# Gdy (prze)budowano klienta pag – odśwież luźny /stable/pag
# (payload self-update) z tej właśnie paczki, ZANIM zaktualizujemy
# repo.json, żeby pag_version wskazywał opublikowaną wersję.
if name == "pag":
_publish_pag_client(dst_dir)
# Natychmiast odśwież wpis w repo.json. Stara wersja została właśnie
# skasowana, więc bez tego panel i klienci linkowali do nieistniejącego
# pliku (404) aż do końca całej serii buildów.
update_repo_json_after_build(dst_dir, dst_file)
# Instalacja do rootfs – build-deps dla kolejnych pakietów.
_install_to_rootfs(os.path.join(dst_dir, dst_file))
# Auto-zapis configu jądra (olddefconfig) z powrotem do receptury.
_maybe_save_kernel_config(name)
# Test pakietu: czy runtime depends będą rozwiązywalne dla klienta.
_warn_unresolved_runtime_deps(name, meta.get("depends") or [], state)
# Aktualizuj indeks pakietów w state
state.setdefault("packages", {})[name] = {
"version": ver, "release": rel, "category": category,
"status": "ok", "built_at": datetime.now(timezone.utc).isoformat(),
"filename": dst_file,
}
else:
print(f"⚠ Brak pliku {PKG_EXT} w {BUILD_OUT}")
else:
print(f"❌ [BUILD] {name}-{ver} FAIL (kod {proc.returncode})")
# Zapis historii
build_record = {
"name": name, "version": ver, "release": rel,
"category": category, "status": status,
"duration": f"{duration}s",
"time": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"),
"log": "\n".join(log_lines[-150:]),
}
state.setdefault("builds", []).append(build_record)
# Historia: do 5000 wpisów (statystyki 24h/7d/30d i strona panelu).
# Pełny log tylko dla ostatnich 300 wpisów – starsze zostają jako lekkie
# metadane (bez logu), żeby state.json nie urósł do setek MB.
MAX_BUILD_HISTORY = 5000
KEEP_LOG_BUILDS = 300
if len(state["builds"]) > MAX_BUILD_HISTORY:
state["builds"] = state["builds"][-MAX_BUILD_HISTORY:]
if len(state["builds"]) > KEEP_LOG_BUILDS:
for _older in state["builds"][:-KEEP_LOG_BUILDS]:
_older.pop("log", None)
clear_current_build(state)
save_state(state)
# Pre-flight pagbuild: makedepends wykryte jako BRAK w rootfs. To nie są
# fałszywe alarmy (mapa aliasów w pagbuild), więc pagsync WYMUSI ich
# przebudowę nawet jeśli state/repo mówi "zbudowane" – stare .pag bywają
# puste/zepsute (skutek historycznych błędów buildera).
preflight_missing = []
for ln in log_lines:
if "makedepends:" in ln and "BRAK w rootfs" in ln:
m = re.search(r"makedepends:\s*([\w+.-]+)\s*–\s*BRAK", ln)
if m:
preflight_missing.append(m.group(1))
return (status == "ok", preflight_missing)
def _pag_is_empty(pkg_file):
"""Czy .pag ma PUSTE data.tar.xz (historyczne puste pakiety 4.0K).
Puste .pag powstawały, gdy receptura nie instalowała nic do PKGDIR
(build: bez make install). Instalacja takiego pakietu do rootfs nic nie
daje, a rejestr installed.json i tak by go „uznał” – dlatego przy wyborze
„doinstaluj istniejący .pag zamiast budować” pomijamy puste.
"""
import tarfile as _tar
try:
with _tar.open(pkg_file, "r:xz") as tf:
for m in tf:
if not m.name.endswith("data.tar.xz"):
continue
fobj = tf.extractfile(m)
if fobj is None:
return True
with _tar.open(fileobj=fobj, mode="r:xz") as dt:
for mm in dt.getmembers():
# pusty pakiet ma tylko wpis '.' (katalog); pliki,
# symlinki i realne katalogi oznaczają treść
if mm.isdir() and mm.name in (".", "./", "/"):
continue
# stary bug pagbuild: tar pakował metadata.json DO
# data.tar.xz (--exclude nie działał) – to nie treść
if mm.name.rstrip("/") in ("metadata.json", "./metadata.json"):
continue
return False
return True
return False
except Exception:
# uszkodzony/ucięty .pag – nie uznajemy za pusty (bezpieczniej dobudować)
return False
def run_single_build(pkg_name, recipe_path, state, auto_retry=0, chain=None):
"""Buduje pakiet; z AUTO_DEPS po nieudanym buildzie analizuje diagnostykę
pagbuild, dobudowuje brakujące zależności (jeśli mają receptury) i ponawia.
chain – zbiór nazw już próbowanych w tej sesji (zabezpieczenie przed cyklami:
A potrzebuje B, B potrzebuje A).
"""
if chain is None:
chain = set()
chain.add(pkg_name)
ok, preflight_missing = _do_single_build(pkg_name, recipe_path, state)
if ok:
return True
if not AUTO_DEPS:
print("ℹ --auto-deps wyłączone – brak dobudowy zależności.")
return False
if auto_retry >= MAX_AUTO_DEPS_RETRIES:
print(f"ℹ --auto-deps: osiągnięto limit ponowień ({MAX_AUTO_DEPS_RETRIES}) dla {pkg_name}.")
return False
missing = read_build_diagnostics(pkg_name)
# Uzupełnij braki o NIESPEŁNIONE makedepends/depends z samej receptury
# (np. ftjam → jam): log pokazuje tylko objaw („jam: command not found"),
# a receptura mówi wprost, że potrzebuje pakietu ftjam.
declared = _unsatisfied_declared_deps(recipe_path, state)
missing_names = {m.get("name") for m in missing if isinstance(m, dict)}
for d in declared:
if d.get("name") not in missing_names:
missing.append(d)
missing_names.add(d.get("name"))
if declared:
print(f"ℹ --auto-deps: niespełnione makedepends/depends receptury: "
f"{', '.join(sorted(d.get('name', '') for d in declared))}")
# Makedepends, które pre-flight pagbuild wykrył jako BRAK w rootfs – wymuszamy
# ich (prze)budowę, nawet jeśli state mówi "zbudowane" (stare .pag bywają
# puste/zepsute po historycznych błędach buildera).
# Narzędzia DOC generują tylko dokumentację – pomijamy je: budowa i tak
# zwykle działa bez nich (np. librsvg z -Ddocs=disabled), a ich łańcuch
# zależności potrafi blokować retry na długo.
_OPTIONAL_DOC_DEPS = {
"gi-docgen", "gtk-doc", "python-docutils", "python-smartypants",
"doxygen", "help2man", "gnome-doc-utils", "yelp-tools", "itstool",
}
force_rebuild = set()
for name in preflight_missing:
if name in _OPTIONAL_DOC_DEPS:
continue
if name not in missing_names:
missing.append({"kind": "preflight", "name": name})
missing_names.add(name)
force_rebuild.add(name)
_forced = sorted(f for f in preflight_missing if f not in _OPTIONAL_DOC_DEPS)
if _forced:
print(f"ℹ --auto-deps: pre-flight wykrył BRAK w rootfs (wymuszam przebudowę): "
f"{', '.join(_forced)}")
elif preflight_missing:
print("ℹ --auto-deps: pre-flight: brakuje tylko narzędzi docs (pomijam dobudowę): "
f"{', '.join(sorted(preflight_missing))}")
if not missing:
print("ℹ --auto-deps: nie wykryto brakujących zależności – brak dobudowy.")
return False
providers = [p for p in resolve_missing_to_recipes(missing, state) if p not in chain]
# Braki BEZ receptury: szukaj gotowego pakietu w repo (odpowiednik
# `pag search` + `pag install`) i instaluj do rootfs buildera – zanim
# uznamy build za niemożliwy.
provider_set = set(providers)
repo_missing = []
for item in missing:
if not isinstance(item, dict) or not item.get("name"):
continue
if item["name"] in provider_set:
continue
# Brak pokryty przez provider wybrany przez alias (pkgconfig(foo) -> foo)?
if any(item["name"] in _provider_candidates(p, item.get("kind", ""))
for p in provider_set):
continue
repo_missing.append(item)
installed_repo = _install_missing_from_repo(repo_missing) if repo_missing else False
if not providers:
if installed_repo:
print(f"🔁 --auto-deps: ponawiam build {pkg_name} (próba {auto_retry + 1}/{MAX_AUTO_DEPS_RETRIES})")
return run_single_build(pkg_name, recipe_path, state, auto_retry + 1, chain)
names = ", ".join(sorted({m.get("name", "") for m in missing if isinstance(m, dict)}))
print(f"ℹ --auto-deps: brak receptury dostarczającej: {names}")
print(f" Sprawdź makedepends w {recipe_path} – dodaj brakujący pakiet albo dopisz recepturę.")
return False
print(f"🔧 --auto-deps: dobudowuję zależności buildowe: {', '.join(providers)}")
for prov in providers:
if _pkg_built(prov, state):
# .pag już jest w repo – doinstaluj go do rootfs zamiast budować:
# sam skip zostawiał rootfs BEZ pakietu i build padał dalej.
# Dotyczy też force_rebuild (pre-flight BRAK): np. cargo-c instaluje
# binarki cargo-cbuild/... (probe po nazwie nie trafia), a dobry .pag
# leży w repo – instalacja rejestruje pakiet w installed.json i probe
# przechodzi. PUSTEGO .pag (historyczne 4.0K) nie instalujemy – taki
# trzeba dobudować z receptury.
hit = _search_repo_package(prov, "makedep")
if hit and not _dep_satisfied_in_rootfs(prov) and not _pag_is_empty(hit[1]):
print(f" 📦 {prov} już zbudowany – instaluję .pag z repo do rootfs")
_install_to_rootfs(hit[1])
if prov not in force_rebuild or _dep_satisfied_in_rootfs(prov):
print(f" ⏭ {prov} już zbudowany – pomijam dobudowę")
continue
prov_path = find_recipe_path(prov)
if not prov_path:
print(f" ⚠ brak receptury dla {prov} (mimo indeksu)")
continue
run_single_build(prov, prov_path, state, auto_retry=0, chain=chain)
print(f"🔁 --auto-deps: ponawiam build {pkg_name} (próba {auto_retry + 1}/{MAX_AUTO_DEPS_RETRIES})")
return run_single_build(pkg_name, recipe_path, state, auto_retry + 1, chain)
# ── REBUILD-DEPS: wymuszona przebudowa całego łańcucha zależności ──
def rebuild_deps_chain(pkg_name, state):
"""Przebudowuje pakiet wraz z CAŁYM łańcuchem zależności build/runtime.
Zbiera wszystkich przodków (transitive depends+makedepends) z drzewa
recipes, sortuje topologicznie i przebudowuje wszystkich (nawet już
zbudowanych) w poprawnej kolejności – jak `--rebuild` w portage.
"""
recipes = scan_recipes(state)
name_to_meta = {m["name"]: m for _, m in recipes}
name_to_path = {m["name"]: fp for fp, m in recipes}
if pkg_name not in name_to_meta:
print(f"❌ Brak receptury dla pakietu: {pkg_name}")
return
needed = set()
def collect(name):
if name in needed or name not in name_to_meta:
return
needed.add(name)
meta = name_to_meta[name]
for dep in list(meta.get("depends", [])) + list(meta.get("makedepends", [])):
if dep in name_to_meta:
collect(dep)
collect(pkg_name)
ordered = resolve_build_order([(name_to_path[n], name_to_meta[n]) for n in needed])
# Zależności spoza recipes (baza systemu / rootfs) – tylko informacyjnie
external = set()
for n in needed:
meta = name_to_meta[n]
for dep in list(meta.get("depends", [])) + list(meta.get("makedepends", [])):
if dep not in name_to_meta and dep not in BUILD_SYSTEM_DEPS:
external.add(dep)
print(f"🔧 REBUILD-DEPS {pkg_name}: przebudowa całego łańcucha ({len(ordered)} pakietów):")
for fp, meta in ordered:
print(f" • {meta['name']}-{meta['version']}")
if external:
print(f" (pomijam zależności spoza recipes: {', '.join(sorted(external))})")
print()
ok_count = fail_count = 0
for fp, meta in ordered:
success = run_single_build(meta["name"], fp, state)
if success:
ok_count += 1
else:
fail_count += 1
generate_repo_json(verbose=True)
save_state(state)
print(f"\n✨ REBUILD-DEPS zakończony: ✅ {ok_count} | ❌ {fail_count}")
# ── Skanowanie drzewa recipes ──
def scan_recipes(state):
"""Skanuje wszystkie receptury i zwraca listę do zbudowania.
Używa recipe_index jako cache: niezmienione pliki (ta sama ścieżka i mtime)
nie są ponownie parsowane – pełny YAML-parse wszystkich receptur był
zauważalnie wolny, a każdy --build robił skan.
"""
print(f"🔍 Skanowanie {RECIPES_DIR}...")
index = state.setdefault("recipe_index", {})
found = []
cached = 0
parsed = 0
for root, dirs, files in os.walk(RECIPES_DIR):
for fn in ["PAGBUILD.yaml", "package.yml", "recipe.yaml"]:
if fn in files:
fp = os.path.join(root, fn)
mtime = os.path.getmtime(fp)
entry = index.get(fp)
if entry and entry.get("mtime") == mtime and entry.get("meta"):
meta = entry["meta"]
cached += 1
else:
meta = parse_recipe(fp)
if meta:
index[fp] = {"mtime": mtime, "meta": meta}
parsed += 1
if meta:
found.append((fp, meta))
break # tylko jeden plik na katalog
print(f" Znaleziono {len(found)} receptur (cache: {cached}, parsowanych: {parsed}).")
# ── Sprzątanie cache recipe_index ──
# Po rename'ach/usunięciach zostają wpisy dla nieistniejących plików oraz
# legacy wpisy kluczowane nazwą (duplikaty ścieżek) – przez to indeks puchł
# (np. 4413 wpisów przy 2214 recepturach), a panel mógł pokazywać stare
# pozycje mimo świeżego git pull. Zostawiamy WYŁĄCZNIE aktualne wpisy
# ścieżkowe (meta z cache dla niezmienionych plików – bez ponownego parsu).
live_paths = {fp for fp, _m in found}
stale = [k for k in index
if not str(k).startswith("/") or k not in live_paths]
for k in stale:
del index[k]
if stale:
print(f" 🧹 Usunięto z cache {len(stale)} nieaktualnych wpisów")
save_state(state)
return found
def resolve_build_order(recipes):
"""
Topologiczne sortowanie receptur według zależności.
Zwraca listę (recipe_path, meta) w poprawnej kolejności.
"""
# Budowanie grafu zależności
name_to_meta = {}
for fp, meta in recipes:
name_to_meta[meta["name"]] = (fp, meta)
in_degree = {meta["name"]: 0 for _, meta in recipes}
adj = {meta["name"]: [] for _, meta in recipes}
for _, meta in recipes:
# Zarówno zależności runtime, jak i build-time wpływają na kolejność:
# makedepends dostarcza nagłówków/pkg-config dla kolejnych receptur.
all_deps = list(meta.get("depends", [])) + list(meta.get("makedepends", []))
for dep in all_deps:
if dep in name_to_meta and meta["name"] not in adj[dep]:
adj[dep].append(meta["name"])
in_degree[meta["name"]] += 1
# Część receptur historycznie nie deklaruje bibliotek PaganOS w depends,
# mimo że ich build wymaga pkg-config. Dodaj znane relacje z rodziny XFCE,
# aby czyste --rebuild-failed nie próbowało budować konsumenta przed biblioteką.
xfce_edges = {
"libxfce4util": ("exo", "garcon", "libxfce4ui"),
}
for provider, consumers in xfce_edges.items():
if provider not in name_to_meta:
continue
for consumer in consumers:
if consumer in name_to_meta and consumer not in adj[provider]:
adj[provider].append(consumer)
in_degree[consumer] += 1
# Kahn's algorithm
queue = [n for n, d in in_degree.items() if d == 0]
order = []
while queue:
# Sortuj alfabetycznie dla stabilności
queue.sort()
node = queue.pop(0)
order.append(name_to_meta[node])
for neighbor in adj.get(node, []):
in_degree[neighbor] -= 1
if in_degree[neighbor] == 0:
queue.append(neighbor)
# Dodaj pozostałe (cykle lub niezależne)
for name, (fp, meta) in name_to_meta.items():
if (fp, meta) not in order:
order.append((fp, meta))
return order
# ── Git Sync – aktualizuje recipes z repozytorium git ──
def git_sync_recipes():
"""
Synchronizuje lokalne recipes z git repo.
Jeśli RECIPES_DIR nie istnieje lub nie jest repo – klonuje.
Jeśli istnieje – robi git pull (auto-stash lokalnych zmian).
"""
if not os.path.isdir(os.path.join(RECIPES_DIR, ".git")):
# Pierwszy raz – klonujemy
print(f"📥 Klonowanie recipes z {RECIPES_GIT_REMOTE}...")
if os.path.isdir(RECIPES_DIR):
backup = RECIPES_DIR + ".bak." + datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
os.rename(RECIPES_DIR, backup)
print(f" 📁 Stary katalog przeniesiony do {backup}")
os.makedirs(os.path.dirname(RECIPES_DIR), exist_ok=True)
try:
result = subprocess.run(
["git", "clone", RECIPES_GIT_REMOTE, RECIPES_DIR],
capture_output=True, text=True, errors='replace', timeout=120
)
if result.returncode != 0:
print(f" ⚠ git clone nieudany: {result.stderr.strip()[:200]}")
return False
print(f" ✅ Sklonowano ({len(os.listdir(RECIPES_DIR))} plików/katalogów)")
return True
except Exception as e:
print(f" ❌ Błąd klonowania: {e}")
return False
else:
# Już jest repo – stash lokalnych zmian, pull, drop stash
print(f"🔄 Git pull recipes ({RECIPES_GIT_REMOTE})...")
try:
# Sprzątanie po wcześniej przerwanym rebase/mergu (konflikt zostawiał
# .git/rebase-merge lub MERGE_HEAD → każdy kolejny pull padał na
# "unmerged files" i blokował kolejkę aż do ręcznej naprawy).
if os.path.isdir(os.path.join(RECIPES_DIR, ".git", "rebase-merge")) or \
os.path.isdir(os.path.join(RECIPES_DIR, ".git", "rebase-apply")):
print(" 🧹 Znaleziono przerwany rebase – przerywam (abort)")
subprocess.run(["git", "-C", RECIPES_DIR, "rebase", "--abort"],
capture_output=True, timeout=15)
if os.path.exists(os.path.join(RECIPES_DIR, ".git", "MERGE_HEAD")):
print(" 🧹 Znaleziono niedokończony merge – przerywam (abort)")
subprocess.run(["git", "-C", RECIPES_DIR, "merge", "--abort"],
capture_output=True, timeout=15)
# Sprawdź czy są lokalne zmiany
status = subprocess.run(
["git", "-C", RECIPES_DIR, "status", "--porcelain"],
capture_output=True, text=True, errors='replace', timeout=10
)
has_changes = bool(status.stdout.strip())
if has_changes:
# Stash lokalnych zmian przed pullem
subprocess.run(
["git", "-C", RECIPES_DIR, "stash", "--include-untracked"],
capture_output=True, text=True, errors='replace', timeout=10
)
print(f" 📦 Lokalne zmiany odłożone na stash")
result = subprocess.run(
["git", "-C", RECIPES_DIR, "pull", "--ff-only", "origin", "main"],
capture_output=True, text=True, errors='replace', timeout=60
)
output = result.stdout.strip()
if result.returncode != 0:
# Rozjazd gałęzi: lokalne commity automatyki (check-updates/auto-fix)
# nie weszły do origin (push bywa cicho odrzucany jako non-fast-forward).
# Zamiast przerywać całą kolejkę – rebase lokalnych commitów na origin.
# To bezpieczne: to output automatyki, nie ręczna praca użytkownika.
print(f" ⚠ Pull --ff-only nieudany – próbuję rebase lokalnych commitów...")
rb = subprocess.run(
["git", "-C", RECIPES_DIR, "pull", "--rebase", "origin", "main"],
capture_output=True, text=True, errors='replace', timeout=120
)
if rb.returncode != 0:
# Nie zostawiaj pół-rebase'a – kolejne synce padałyby na
# "unmerged files" do czasu ręcznej naprawy.
subprocess.run(["git", "-C", RECIPES_DIR, "rebase", "--abort"],
capture_output=True, timeout=15)
print(f" ⚠ Pull nieudany: {result.stderr.strip()[:200]}")
print(f" ⚠ Rebase też nieudany: {rb.stderr.strip()[:200]}")
return False
result = rb
output = result.stdout.strip()
if has_changes:
# Próbuj przywrócić stash – jeśli konflikt, discard
pop = subprocess.run(
["git", "-C", RECIPES_DIR, "stash", "pop"],
capture_output=True, text=True, errors='replace', timeout=10
)
if pop.returncode != 0:
print(f" ⚠ Konflikt ze stash – zachowuję wersję z repo")
subprocess.run(
["git", "-C", RECIPES_DIR, "checkout", "--", "."],
capture_output=True, timeout=10
)
subprocess.run(
["git", "-C", RECIPES_DIR, "stash", "drop"],
capture_output=True, timeout=5
)
if "Already up to date" in output:
print(f" ✅ Już aktualne")
elif result.returncode == 0:
print(f" ✅ Zaktualizowano: {output[:150]}")
else:
print(f" ⚠ Pull nieudany: {result.stderr.strip()[:200]}")
return False
return True
except Exception as e:
print(f" ⚠ Błąd git pull: {e}")
return False
# ── Główna funkcja synchronizacji ──
# ── Generowanie repo.json dla każdej kategorii ──
def _pkg_entry_from_file(fp, fname):
"""Buduje wpis repo.json dla jednego pliku .pag (metadata.json + sha256)."""
# Odczytaj metadata.json z paczki (toleruj prefiks ./)
meta = {}
import tarfile
try:
with tarfile.open(fp, "r:xz") as tf:
names = tf.getnames()
mname = None
if "metadata.json" in names:
mname = "metadata.json"
elif "./metadata.json" in names:
mname = "./metadata.json"
if mname:
meta_f = tf.extractfile(mname)
if meta_f:
meta = json.loads(meta_f.read().decode())
except Exception:
pass
# Fallback: parsuj nazwę pliku
base = fname.replace(PKG_EXT, "")
m = re.match(r'^(.+)-([\d][\w.-]*)-(\d+)$', base)
if m:
pname, pver, prel = m.group(1), m.group(2), m.group(3)
else:
pname, pver, prel = base, "0", "1"
# SHA256 całej paczki – klient pag weryfikuje pobieranie
import hashlib as _hl
_h = _hl.sha256()
with open(fp, "rb") as _f:
for _chunk in iter(lambda: _f.read(65536), b""):
_h.update(_chunk)
return {
"name": meta.get("name", pname),
"version": meta.get("version", pver),
"release": meta.get("release", int(prel)),
"filename": fname,
"size": os.path.getsize(fp),
"sha256": _h.hexdigest(),
"arch": meta.get("arch", "x86_64"),
"description": meta.get("description", ""),
"depends": meta.get("depends", []),
"provides": meta.get("provides", []),
"license": meta.get("license", []),
}
def _publish_pag_client(cat_dir, verbose=True):
"""Publikuje luźny klient `pag` (dla self-update) z najnowszej paczki `pag-*.pag`.
`pag self-update` pobiera `/stable/pag` (+ `.asc`, `.sha256`), a
`repo.json["pag_version"]` jest z niego czytany. Ten plik był wcześniej
publikowany ręcznie i potrafił zostać w tyle za paczką (np. 3.3.17 vs
3.3.19). Tutaj wyciągamy `usr/bin/pag` z NAJNOWSZEJ paczki pag w repo i
odtwarzamy luźny plik wraz z SHA256 i podpisem, więc self-update i indeks
zawsze wskazują tę samą wersję co pakiet z repo.
"""
import glob as _g, hashlib as _hl, tarfile as _tf, tempfile as _tmp
cands = _g.glob(os.path.join(cat_dir, "pag-*.pag"))
if not cands:
return False
def _ver_of(f):
m = re.search(r'/pag-([\d][\w.]*?)-\d+\.pag$', f)
return m.group(1) if m else "0"
src = max(cands, key=_ver_of)
try:
with _tf.open(src, "r:xz") as tf:
data_member = next((m for m in tf.getmembers()
if m.name.endswith("data.tar.xz")), None)
if data_member is None:
return False
inner_xz = tf.extractfile(data_member).read()
with _tmp.NamedTemporaryFile(delete=False, suffix=".tar.xz") as t:
t.write(inner_xz)
inner_path = t.name
pag_data = None
try:
with _tf.open(inner_path, "r:xz") as itf:
for m in itf.getmembers():
if m.name.lstrip("./") == "usr/bin/pag":
pag_data = itf.extractfile(m).read()
break
finally:
try:
os.unlink(inner_path)
except OSError:
pass
if not pag_data:
return False
# Nie publikuj uszkodzonego klienta (self-update i tak sprawdza składnię,
# ale lepiej nie wypuszczać śmiecia do repo).
try:
compile(pag_data, "pag", "exec")
except SyntaxError:
return False
except Exception:
return False
dst = os.path.join(cat_dir, "pag")
old = None
if os.path.isfile(dst):
try:
old = open(dst, "rb").read()
except Exception:
old = None
if old == pag_data:
return True # bez zmian – nie ruszamy podpisu
tmp_dst = dst + ".new"
with open(tmp_dst, "wb") as f:
f.write(pag_data)
os.chmod(tmp_dst, 0o755)
os.replace(tmp_dst, dst)
with open(dst + ".sha256", "w") as f:
f.write(_hl.sha256(pag_data).hexdigest() + " pag\n")
if DO_SIGN:
gpg_bin = shutil.which("gpg2") or shutil.which("gpg") or ""
if gpg_bin:
sig = dst + ".asc"
if os.path.exists(sig):
os.remove(sig)
key_args = ["--local-user", GPG_KEY] if GPG_KEY else []
subprocess.run(
[gpg_bin, "--detach-sign", "--armor", "--batch", "--no-tty"] + key_args + [dst],
check=False, capture_output=True,
)
if verbose:
print(f" 🔁 Opublikowano klienta pag z {os.path.basename(src)} "
f"→ {os.path.basename(dst)} (+.asc/.sha256)")
return True
def _write_and_sign_repo_json(cat_dir, packages, cat, verbose=True):
"""Zapisuje repo.json (+ podpis GPG, gdy DO_SIGN). Wspólne dla pełnej
generacji i przyrostowej aktualizacji po pojedynczym buildzie."""
repo_json = os.path.join(cat_dir, "repo.json")
# Wersja pag dla self-update (repo.json["pag_version"]) – czytana
# z pliku `pag` w katalogu repo. Klient pag porównuje ją z lokalnym
# PAG_VERSION przy `pag update` i sugeruje `pag self-update`.
pag_version = ""
_pag_file = os.path.join(cat_dir, "pag")
if os.path.isfile(_pag_file):
try:
with open(_pag_file, "rb") as _pf:
_head = _pf.read(4096)
_m = re.search(rb'PAG_VERSION\s*=\s*"(\d+\.\d+\.\d+[a-z]?)"', _head)
if _m:
pag_version = _m.group(1).decode()
except Exception:
pass
# Gdy plik `pag` nie leży w katalogu repo, zachowaj wersję z istniejącego indeksu.
if not pag_version and os.path.isfile(repo_json):
try:
pag_version = json.load(open(repo_json)).get("pag_version", "") or pag_version
except Exception:
pass
with open(repo_json, "w") as f:
json.dump({
"category": cat,
"updated": datetime.now(timezone.utc).isoformat(),
"count": len(packages),
"pag_version": pag_version,
"packages": packages,
}, f, indent=2)
# Podpisz repo.json GPG. UWAGA: gpg --detach-sign NIE nadpisuje
# istniejącego pliku .asc ("File exists") – stary podpis trzeba usunąć,
# inaczej klienci dostają NIEPRAWIDŁOWY PODPIS indeksu.
repo_sig = repo_json + ".asc"
signed = False
if DO_SIGN:
gpg_bin = shutil.which("gpg2") or shutil.which("gpg") or ""
if gpg_bin:
key_args = ["--local-user", GPG_KEY] if GPG_KEY else []
if os.path.exists(repo_sig):
os.remove(repo_sig)
sign_res = subprocess.run(
[gpg_bin, "--detach-sign", "--armor", "--batch", "--no-tty"] + key_args + [repo_json],
check=False, capture_output=True,
)
sig_ok = (sign_res.returncode == 0 and os.path.exists(repo_sig))
if sig_ok:
signed = True
if verbose:
print(f" {cat}: {len(packages)} pakietów → repo.json + 🔏 repo.json.asc")
else:
print(f" ⚠ {cat}: podpisanie repo.json NIE powiodło się "
f"(rc={sign_res.returncode}): {sign_res.stderr.decode(errors='replace').strip()[:120]}")
else:
print(f" ⚠ {cat}: brak gpg w PATH – repo.json bez podpisu!")
if not signed and os.path.exists(repo_sig):
# repo.json został właśnie nadpisany, a .asc nie powstał w tej
# iteracji → stary podpis jest NIEAKTUALNY. Usuń go, żeby klienci
# dostawali czytelny błąd „brak podpisu” zamiast BAD SIGNATURE.
try:
os.remove(repo_sig)
except OSError:
pass
if verbose:
print(f" {cat}: {len(packages)} pakietów → repo.json")
return signed
def update_repo_json_after_build(cat_dir, dst_file, verbose=True):
"""Przyrostowa aktualizacja repo.json po pojedynczym buildzie.
Bez tego indeks jest odświeżany dopiero na KOŃCU całej serii buildów.
Ponieważ `clean_old_versions` usuwa starą wersję .pag natychmiast po
przeniesieniu nowej, przez cały czas trwania serii panel linkował do
skasowanych plików (404). Tutaj:
1) wyrzucamy wpisy, których plik zniknął,
2) podmieniamy/dodajemy wpis świeżo zbudowanej paczki.
"""
if not DO_SIGN:
# Nie nadpisujemy (być może podpisanego) repo.json wersją bez podpisu.
return False
fp = os.path.join(cat_dir, dst_file)
if not os.path.isfile(fp):
return False
repo_json = os.path.join(cat_dir, "repo.json")
try:
data = json.load(open(repo_json)) if os.path.isfile(repo_json) else {}
except Exception:
data = {}
packages = [
p for p in data.get("packages", [])
if isinstance(p, dict) and p.get("filename")
and os.path.isfile(os.path.join(cat_dir, p["filename"]))
]
entry = _pkg_entry_from_file(fp, dst_file)
packages = [p for p in packages if p.get("name") != entry["name"]]
packages.append(entry)
# Dodaj pliki obecne na dysku, których nie ma w indeksie (np. zbudowane
# inną ścieżką albo po przerwanej serii) – indeks sam się leczy, a hashujemy
# tylko te brakujące pozycje, bez pełnego skanu przy każdym pakiecie.
known = {p.get("filename") for p in packages}
for fname in sorted(os.listdir(cat_dir)):
if not fname.endswith(PKG_EXT) or fname in known:
continue
try:
packages.append(_pkg_entry_from_file(os.path.join(cat_dir, fname), fname))
except Exception:
continue
# Dedupe po nazwie – zostaw najnowszą wersję (jak generate_repo_json).
best = {}
for p in packages:
cur = best.get(p.get("name"))
if cur is None or _ver_key(p) > _ver_key(cur):
best[p["name"]] = p
packages = sorted(best.values(), key=lambda p: p.get("name", ""))
cat = os.path.basename(cat_dir.rstrip("/")) or (data.get("category") or "stable")
if verbose:
print(f"🔄 repo.json: przyrostowa aktualizacja wpisu {entry['name']}-{entry['version']}...")
_write_and_sign_repo_json(cat_dir, packages, cat, verbose=verbose)
return True
def generate_repo_json(verbose=True):
"""Skanuje katalogi repo i generuje repo.json (indeks pakietów)."""
if verbose:
print("\n📋 Aktualizacja plików repo.json...")
generated = 0
for cat in REPO_CATEGORIES:
cat_dir = os.path.join(REPO_BASE, cat)
if not os.path.isdir(cat_dir):
continue
packages = []
for fname in sorted(os.listdir(cat_dir)):
if not fname.endswith(PKG_EXT):
continue
packages.append(_pkg_entry_from_file(os.path.join(cat_dir, fname), fname))
# Dedupe: zostaw tylko najnowszą wersję per-pakiet (zgodnie z polityką
# "w repo trzymamy tylko aktualną wersję" – zabezpieczenie, gdyby w
# katalogu zostały stare pliki z innego źródła).
best = {}
for p in packages:
key = p["name"]
cur = best.get(key)
if cur is None or _ver_key(p) > _ver_key(cur):
best[key] = p
packages = sorted(best.values(), key=lambda p: p["name"])
# Utrzymaj luźnego klienta pag (self-update) w zgodzie z najnowszą
# paczką pag w repo, zanim odczytamy z niego pag_version.
_publish_pag_client(cat_dir, verbose=False)
_write_and_sign_repo_json(cat_dir, packages, cat, verbose=verbose)
generated += 1
return generated
# ── AUTO-REBUILD MODUŁÓW PO AKTUALIZACJI JĄDRA ──
# Gdy pakiet jądra (kernel*, np. kernel-618) zostanie (prze)zbudowany w tej
# sesji, przebuduj pakiety, które deklarują `depends:` na nim (np.
# nvidia-kernel-618). Ich .ko niosą vermagic nowej wersji jądra.
#
# Bezpieczeństwo:
# * tylko JEDEN kierunek (kernel -> konsumenci), brak pętli,
# * pkgrel konsumenta podbijamy WYŁĄCZNIE gdy wcześniej zbudowano go pod
# INNĄ wersją jądra (state['kernel_builds']) – force-rebuild jądra o tej
# samej wersji nie powoduje churnu pkgrel,
# * wyłącznik: PAGAN_KERNEL_REBUILD=0 (np. w /etc/pagan/build.conf),
# * zmiany w recepturach commituje wzorcem auto-fix SHA (pull --rebase + push).
KERNEL_REBUILD = os.environ.get("PAGAN_KERNEL_REBUILD", "1") != "0"
# Auto-zapis configu jądra (olddefconfig) z powrotem do receptury po udanym
# buildzie. Wyłącznik: PAGAN_KERNEL_SAVE_CONFIG=0 (np. w /etc/pagan/build.conf).
KERNEL_SAVE_CONFIG = os.environ.get("PAGAN_KERNEL_SAVE_CONFIG", "1") != "0"
_PENDING_KERNEL_RECORD = {} # konsument -> „zbudowany pod kernel-X-Y-Z” (zapis po buildzie w pętli)
_KERNEL_INDEX = None
def _kernel_key_from_rootfs(kernel_name):
"""Rzeczywista wersja jądra w rootfs buildera (kernel.release) – moduły
powstają przeciw SYSSRC=/usr/src/<kernel>, więc to jest źródło prawdy.
Zwraca 'kernel-618-6.18.44' albo None, gdy rootfs jeszcze nie ma jądra."""
for _sub in ("include/config/kernel.release", ".kernelrelease"):
try:
_v = open(os.path.join(ROOTFS_PATH, "usr/src", kernel_name, _sub),
encoding="utf-8").read().strip()
except OSError:
continue
if _v:
return "%s-%s" % (kernel_name, _v)
return None
def _kernel_index(state):
"""Indeks pakietów jądra (kernel*) – budowany raz na proces (cache: skan)."""
global _KERNEL_INDEX
if _KERNEL_INDEX is None:
_KERNEL_INDEX = [m for _fp, m in scan_recipes(state) if _kernel_like(m.get("name"))]
return _KERNEL_INDEX
def _kernel_like(name):
"""Czy pakiet to jądro (kernel, kernel-618, kernel-mainline, ...)."""
return bool(name and re.match(r"^kernel(?:-|$)", name))
def _consumer_recipes_of(kernel_name, recipes):
"""Receptury, których depends/makedepends wskazuje DOKŁADNIE kernel_name."""
out = []
for fp, meta in recipes or []:
if (meta or {}).get("name") == kernel_name:
continue
deps = list(meta.get("depends") or []) + list(meta.get("makedepends") or [])
if any(str(d or "").strip() == kernel_name for d in deps):
out.append((fp, meta))
return out
def _bump_pkgrel(fp):
"""pkgrel +1 (linia `pkgrel:`), bez przebudowy całego YAML. Zwraca nowy rel albo None."""
try:
text = open(fp, encoding="utf-8").read()
except OSError:
return None
def _rep(m):
return "%s%d%s" % (m.group(1), int(m.group(2)) + 1, m.group(3))
new, n = re.subn(r"(?m)^(pkgrel:\s*['\"]?)(\d+)(['\"]?)\s*$", _rep, text, count=1)
if n != 1:
return None
try:
with open(fp, "w", encoding="utf-8") as fh:
fh.write(new)
except OSError:
return None
m = re.search(r"(?m)^pkgrel:\s*['\"]?(\d+)['\"]?\s*$", new)
return m.group(1) if m else None
def _git_commit_recipes(msg):
"""Commit + pull --rebase + push do recipes.git (wzorzec jak auto-fix SHA)."""
try:
subprocess.run(["git", "-C", RECIPES_DIR, "add", "-A"],
capture_output=True, text=True, errors='replace', timeout=30)
subprocess.run(["git", "-C", RECIPES_DIR, "commit", "-m", msg],
capture_output=True, text=True, errors='replace', timeout=30)
rebase_r = subprocess.run(["git", "-C", RECIPES_DIR, "pull", "--rebase", "origin", "main"],
capture_output=True, text=True, errors='replace', timeout=120)
if rebase_r.returncode != 0:
subprocess.run(["git", "-C", RECIPES_DIR, "rebase", "--abort"],
capture_output=True, timeout=10)
print(" ⚠ Rebase przed pushem nieudany – zmiany receptur zostają lokalnie")
push_r = subprocess.run(["git", "-C", RECIPES_DIR, "push", "origin", "main"],
capture_output=True, text=True, errors='replace', timeout=30)
if push_r.returncode != 0:
print(f" ⚠ Push receptur NIEUDANY (commit lokalnie): {push_r.stderr.strip()[:200]}")
else:
print(" 📤 Receptury wypchnięte do recipes.git")
except Exception as e:
print(f" ⚠ git commit receptur: {e}")
def _set_sha_entry(text, idx, value):
"""Podmienia idx-ty wpis (od 0) listy sha256sums w tekście YAML receptury."""
lines = text.split("\n")
start = None
for i, ln in enumerate(lines):
if ln.startswith("sha256sums:"):
start = i
break
if start is None:
return None
n = -1
for j in range(start + 1, len(lines)):
ln = lines[j]
if ln.startswith("- "):
n += 1
if n == idx:
lines[j] = "- " + value
return "\n".join(lines)
elif ln and not ln.startswith((" ", "\t")):
break
return None
def _extract_kernel_config(pkg_path):
"""Wyjmuje (config, release) z .pag → data.tar.xz → boot/config-<release>.
W pakiecie jądra config leży pod boot/config-<release> (po olddefconfig)."""
import tarfile, io
try:
with tarfile.open(pkg_path, "r:xz") as tf:
fh = None
for entry in ("data.tar.xz", "./data.tar.xz"):
try:
fh = tf.extractfile(entry)
break
except KeyError:
continue
if fh is None:
return None, None
data = fh.read()
except Exception:
return None, None
try:
with tarfile.open(fileobj=io.BytesIO(data), mode="r:xz") as df:
for ti in df.getmembers():
nm = ti.name[2:] if ti.name.startswith("./") else ti.name
parts = nm.split("/")
if len(parts) == 2 and parts[0] == "boot" and parts[1].startswith("config-"):
fh = df.extractfile(ti)
if fh is None:
continue
return fh.read().decode("utf-8", "replace"), parts[1][len("config-"):]
except Exception:
return None, None
return None, None
def _find_built_pkg(name):
"""Najnowszy zbudowany <name>-*.pag w repo (stable/ oraz katalogu kategorii)."""
cands = []
for d in (os.path.join(REPO_BASE, "stable"), REPO_BASE):
if os.path.isdir(d):
cands += glob.glob(os.path.join(d, name + "-*" + PKG_EXT))
if not cands:
return None
return max(cands, key=os.path.getmtime)
def save_kernel_config(verbose=True):
"""
Aktualizuje config jąder w recepturach na podstawie zbudowanych .pag.
Faza build receptury robi `make olddefconfig`, więc w pakiecie leży config
dopasowany do bieżącego Kconfig (boot/config-<release>). Ten config jest
źródłem prawdy: jeśli różni się od recipes/utils/<kernel>/config, zapisujemy
go z powrotem (+ świeży SHA wpisu `config`) i commitujemy do recipes.git.
Zwraca listę nazw zaktualizowanych jąder.
"""
import yaml as _yaml
import hashlib as _hl
if verbose:
print("🌱 PAGSYNC SAVE-KERNEL-CONFIG – aktualizacja configu jąder...\n")
state = load_state()
recipes = scan_recipes(state)
changed = []
for fp, meta in recipes:
name = meta["name"]
if not _kernel_like(name):
continue
recipe_dir = os.path.dirname(fp)
cfg_path = os.path.join(recipe_dir, "config")
if not os.path.isfile(cfg_path):
continue
pkg = _find_built_pkg(name)
if not pkg:
if verbose:
print(f" ⏭ {name}: brak zbudowanego {PKG_EXT} – pomijam")
continue
content, release = _extract_kernel_config(pkg)
if not content:
if verbose:
print(f" ⚠ {name}: brak boot/config-* w {os.path.basename(pkg)}")
continue
if not content.endswith("\n"):
content += "\n"
try:
old = open(cfg_path, encoding="utf-8").read()
except OSError:
old = ""
if content == old:
if verbose:
print(f" ✅ {name}: config aktualny ({release})")
continue
with open(cfg_path, "w", encoding="utf-8") as f:
f.write(content)
sha = _hl.sha256(open(cfg_path, "rb").read()).hexdigest()
# Odśwież wpis `config` w sha256sums (zachowaj resztę pliku 1:1).
try:
rtext = open(fp, encoding="utf-8").read()
data = _yaml.safe_load(rtext) or {}
srcs = data.get("source") or []
if isinstance(srcs, str):
srcs = [srcs]
if "config" in srcs:
new_text = _set_sha_entry(rtext, srcs.index("config"), sha)
if new_text and new_text != rtext:
with open(fp, "w", encoding="utf-8") as f:
f.write(new_text)
except Exception as e:
if verbose:
print(f" ⚠ {name}: nie zaktualizowano sha ({e})")
changed.append(name)
if verbose:
print(f" 💾 {name}: zaktualizowano config ({release}, sha {sha[:12]}…)")
if changed:
if verbose:
print(f"\n📝 Zmieniono config: {', '.join(changed)}")
_git_commit_recipes("Update kernel config: " + ", ".join(changed))
elif verbose:
print("\n✅ Wszystkie configi jąder aktualne.")
return changed
def _maybe_save_kernel_config(name):
"""Hook po publikacji pakietu: dla jądra zsynchronizuj config z recepturą."""
if not KERNEL_SAVE_CONFIG or not _kernel_like(name):
return
try:
save_kernel_config(verbose=True)
except Exception as e:
print(f" ⚠ save-kernel-config: {e}")
def _record_pending_kernel(name, state):
"""Po udanym buildzie konsumenta (w pętli) zapisz, pod jakim jądrem powstał."""
if name in _PENDING_KERNEL_RECORD:
state.setdefault("kernel_builds", {})[name] = _PENDING_KERNEL_RECORD.pop(name)
save_state(state)
def _remember_kernel_build(name, deps, state):
"""Po udanym buildzie pakietu zależnego od jądra (np. nvidia-kernel-618
zbudowanego SAMODZIELNIE po bumpie drivera 7.1.8, bez zmiany jądra)
zapamiętaj wersję jądra z rootfs – żeby późniejszy bump jądra dał czysty
pkgrel+1 zamiast podwójnego bumpu."""
if not KERNEL_REBUILD:
return
_deps = set(str(d or "").strip() for d in (deps or []))
_kb = state.setdefault("kernel_builds", {})
_changed = False
for _k in _kernel_index(state):
if _k["name"] not in _deps:
continue
_cur = _kernel_key_from_rootfs(_k["name"]) or (
"%s-%s-%s" % (_k["name"], _k.get("version"), _k.get("release")))
if _cur and _kb.get(name) != _cur:
_kb[name] = _cur
_changed = True
if _changed:
save_state(state)
def _rebuild_kernel_consumers(kernel_name, kernel_meta, all_recipes, later_names, state):
"""Po udanym buildzie jądra: przebuduj (lub przygotuj) zależne moduły.
Zwraca (ok_extra, fail_extra)."""
if not KERNEL_REBUILD or not _kernel_like(kernel_name):
return 0, 0
consumers = _consumer_recipes_of(kernel_name, all_recipes)
if not consumers:
return 0, 0
cur = _kernel_key_from_rootfs(kernel_name)
if not cur:
cur = "%s-%s-%s" % (kernel_name, kernel_meta.get("version"), kernel_meta.get("release"))
kb = state.setdefault("kernel_builds", {})
ok_n = fail_n = 0
bumped = []
for fp, meta in consumers:
cname = meta["name"]
if kb.get(cname) == cur:
print(f" ⏭ {cname}: moduły już zbudowane pod {cur} – pomijam")
continue
if cname in later_names:
# konsument i tak jest w kolejce PO kernelu – tylko podbij pkgrel
# (build zrobi główna pętla; zapis rekordu po jego buildzie)
if kb.get(cname) is not None:
b = _bump_pkgrel(fp)
if b:
bumped.append(f"{cname}->{b}")
_PENDING_KERNEL_RECORD[cname] = cur
continue
b = None
if kb.get(cname) is not None:
b = _bump_pkgrel(fp)
if b is None:
print(f" ⚠ {cname}: pkgrel nie do podbicia – pomijam auto-rebuild")
continue
bumped.append(f"{cname}->{b}")
print("🔁 %s: przebudowa modułów po %s%s" % (
cname, cur, f" (pkgrel -> {b})" if b else " (pierwszy build)"))
if run_single_build(cname, fp, state):
kb[cname] = cur
ok_n += 1
else:
fail_n += 1
if bumped:
_git_commit_recipes(f"auto: pkgrel+1 modułów po {cur} ({', '.join(bumped)})")
save_state(state)
return ok_n, fail_n
def sync_and_build(force=False, missing_only=False, single_pkg=None, rebuild_failed=None, fix_sha=False):
state = load_state()
state["last_sync"] = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
save_state(state)
# ── Self-heal: puste .pag nie mogą maskować probe zależności ──
_purge_empty_from_installed_db()
# ── Git Sync – pobierz najnowsze recipes ZANIM cokolwiek innego ──
# Także dla --build <pkg>: panel buduje pojedyncze pakiety, a bez pulla
# build szedłby na starym drzewie po pushu receptur (git.paganlinux.eu).
if not git_sync_recipes():
print("❌ Przerwano: nie udało się zsynchronizować recipes z git.")
print(" Bez aktualnych receptur nie buduję – sprawdź /var/git/recipes.git")
sys.exit(1)
print()
# ── Auto-fix SHA256 na pierwszym uruchomieniu (lub gdy --fix-sha) ──
is_first_run = not state.get("builds") and not state.get("sha_fixed")
if fix_sha or is_first_run:
fixed, _ = auto_fix_sha256sums(verbose=True)
state["sha_fixed"] = True
save_state(state)
if fix_sha:
print(f"\n✅ SHA256 fix complete – {fixed} receptur poprawionych.")
return
print()
# ── Pojedynczy pakiet ──
if single_pkg:
found = find_recipe_path(single_pkg)
if found:
ok = run_single_build(single_pkg, found, state)
# Po udanym buildzie jądra (panel/CLI: --build kernel-*) przebuduj
# zależne moduły (nvidia-kernel-*) od razu, w tej samej sesji.
if ok and _kernel_like(single_pkg):
_allr = scan_recipes(state)
_kmeta = next((m for _fp, m in _allr if _fp == found), None)
if _kmeta is not None:
_rebuild_kernel_consumers(single_pkg, _kmeta, _allr, set(), state)
else:
print(f" ⚠ brak metadanych receptury {single_pkg} – pomijam auto-rebuild modułów")
elif ok:
# samodzielny build konsumenta (np. bump drivera) – zapamiętaj,
# pod jaką wersją jądra w rootfs powstał
try:
_m0 = parse_recipe(found) or {}
_remember_kernel_build(single_pkg, _m0.get("depends") or [], state)
except Exception:
pass
# Po udanym pojedynczym buildzie odśwież i podpisz repo.json,
# żeby pakiet od razu był widoczny dla klienta pag (bez pełnego syncu).
# Odświeżamy TYLKO gdy podpisywanie jest włączone (DO_SIGN), żeby
# nigdy nie nadpisać podpisanego repo.json wersją niepodpisaną.
if ok and DO_SIGN:
print("🔄 Aktualizacja repo.json po pojedynczym buildzie...")
generate_repo_json(verbose=True)
elif ok:
print("ℹ Build OK, ale pomijam repo.json (brak DO_SIGN – nie chcę nadpisać podpisu).")
else:
print("⚠ Build nieudany – repo.json nieaktualizowany.")
else:
print(f"❌ Nie znaleziono przepisu: {single_pkg}")
return
# ── Odbudowa failed ──
if rebuild_failed:
failed = [b["name"] for b in state.get("builds", []) if b.get("status") == "failed"]
if not failed:
print("✅ Brak nieudanych buildów.")
return
print(f"🔄 Odbudowa {len(set(failed))} nieudanych pakietów...")
for name in sorted(set(failed)):
found = find_recipe_path(name)
if found:
run_single_build(name, found, state)
return
# ── Skanuj wszystkie receptury ──
all_recipes = scan_recipes(state)
# ── Określ co trzeba zbudować ──
queue = []
for fp, meta in all_recipes:
name = meta["name"]
ver = meta["version"]
rel = meta["release"]
cat = recipe_to_repo_category(fp)
# Pakiety ze znanym martwym źródłem – pomiń (nie blokują kolejki).
if name in SKIP_PACKAGES and missing_only:
print(f" ⚠ {name}-{ver} – pominięto (martwe źródło, na liście SKIP_PACKAGES)")
continue
# Sprawdź czy już zbudowane w repo (dowolna kategoria) LUB w BUILD_OUT
already_built = False
pkg_pattern = f"{name}-{ver}-{rel}{PKG_EXT}"
# 1. Sprawdź BUILD_OUT (świeżo zbudowane, jeszcze nie przeniesione) –
# dokładnie name-ver-rel.pag (BUILD_OUT jest transient, nie wchodzi
# w grę pomijanie release-update przez glob name-ver*).
build_out_pkg = os.path.join(BUILD_OUT, pkg_pattern)
if os.path.exists(build_out_pkg) and not force:
already_built = True
# 2. Sprawdź katalogi repo – DOKŁADNIE name-ver-rel.pag, żeby wykryć
# aktualizacje (zmiana wersji LUB release'a) i nie pomijać przebudowy.
if not already_built:
for check_cat in REPO_CATEGORIES:
pkg_dir = os.path.join(REPO_BASE, check_cat)
if os.path.isdir(pkg_dir):
existing = os.path.join(pkg_dir, pkg_pattern)
if os.path.exists(existing) and not force:
already_built = True
break
# 3. Sprawdź w state packages (szybciej niż skanowanie dysku)
if not already_built and name in state.get("packages", {}):
sp = state["packages"][name]
if (sp.get("status") == "ok" and sp.get("version") == ver
and sp.get("release") == rel):
already_built = True
if force:
print(f" 🔨 {name}-{ver} – kolejka (--force: wymuszona przebudowa)")
queue.append((fp, meta))
elif missing_only and already_built:
continue
elif not already_built:
# Powód kolejkowania – pomaga znaleźć przyczynę niechcianych
# przebudów (zmiana pkgver/pkgrel vs brak .pag w repo).
import glob as _gg
any_ver = _gg.glob(os.path.join(BUILD_OUT, name + "-*.pag"))
if not any_ver:
for _c in REPO_CATEGORIES:
_d = os.path.join(REPO_BASE, _c)
if os.path.isdir(_d):
any_ver += _gg.glob(os.path.join(_d, name + "-*.pag"))
reason = "zmiana pkgver/pkgrel" if any_ver else "brak .pag w repo/BUILD_OUT"
print(f" 🔨 {name}-{ver} – kolejka ({reason})")
queue.append((fp, meta))
else:
print(f" ⏭ {name}-{ver} – już zbudowane, pomijam")
if not queue:
print("✨ Wszystkie pakiety są aktualne. Nie ma nic do zrobienia.")
return
print(f"\n📊 Kolejka: {len(queue)} pakietów do zbudowania")
# Sortowanie topologiczne
ordered = resolve_build_order(queue)
print(f" Kolejność (topo-sort): {' → '.join(m[1]['name'] for m in ordered[:10])}{'...' if len(ordered)>10 else ''}")
# ── Pre-flight: sprawdź makedepends/depends względem rootfs i receptur ──
print("\n🩺 Pre-flight: sprawdzam makedepends/depends względem rootfs i receptur...")
warn_unsatisfied_deps(ordered, all_recipes)
ok_count = 0
fail_count = 0
for i, (fp, meta) in enumerate(ordered):
success = run_single_build(meta["name"], fp, state)
if success:
ok_count += 1
_record_pending_kernel(meta["name"], state)
# każdy udany build (też samodzielny bump drivera) aktualizuje rekord
_remember_kernel_build(
meta["name"],
list(meta.get("depends") or []) + list(meta.get("makedepends") or []),
state)
# kernel się (prze)budował → dołóż przebudowę konsumentów modułów
if _kernel_like(meta["name"]):
_later = {m[1]["name"] for m in ordered[i + 1:]}
_ek, _ef = _rebuild_kernel_consumers(meta["name"], meta, all_recipes, _later, state)
ok_count += _ek
fail_count += _ef
else:
fail_count += 1
# ── Generowanie repo.json dla każdej kategorii ──
generate_repo_json(verbose=True)
state["last_sync"] = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
save_state(state)
print(f"\n✨ Gotowe! ✅ {ok_count} | ❌ {fail_count} | 📦 repo zaktualizowane")
# ── Cache źródeł: po pełnym przebiegu nie trzymamy pobranych archiwów ──
purge_source_cache()
# ── AUTO-FIX SHA256SUMS – poprawia nieaktualne sumy w recepturach ──
def _looks_like_archive(path):
"""Sprawdza magic bytes – czy plik wygląda na prawdziwe archiwum (nie HTML/śmieci)."""
try:
with open(path, "rb") as f:
magic = f.read(8)
except Exception:
return False
return (
magic.startswith(b"\x1f\x8b") # gzip
or magic.startswith(b"BZh") # bzip2
or magic.startswith(b"\xfd7zXZ\x00") # xz
or magic.startswith(b"PK\x03\x04") # zip
)
def _dump_recipe_yaml(data):
"""Zapisuje YAML receptury, zachowując bloki '|' i kolejność kluczy."""
import yaml as _yaml
class _FixDumper(_yaml.Dumper):
pass
def _str_rep(dumper, s):
if '\n' in s:
return dumper.represent_scalar('tag:yaml.org,2002:str', s, style='|')
if re.match(r'^\d+\.\d+', s) or ':' in s or s.startswith('https://'):
return dumper.represent_scalar('tag:yaml.org,2002:str', s, style="'")
return dumper.represent_scalar('tag:yaml.org,2002:str', s)
_FixDumper.add_representer(str, _str_rep)
return _yaml.dump(data, Dumper=_FixDumper, default_flow_style=False,
allow_unicode=True, sort_keys=False, width=120)
def auto_fix_sha256sums(verbose=True):
"""
Sprawdza wszystkie receptury i automatycznie poprawia sha256sums
jeśli plik źródłowy istnieje w cache'u a suma się nie zgadza (lub jest SKIP).
Zwraca (fixed_count, total_checked).
"""
import yaml as _yaml
import hashlib as _hl
SRC_CACHE = "/var/cache/pagbuild/sources"
state = load_state()
if verbose:
print("🔧 PAGSYNC AUTO-FIX SHA256 – sprawdzanie i poprawa sum kontrolnych...\n")
recipes = scan_recipes(state)
if not recipes:
if verbose:
print("❌ Brak receptur w", RECIPES_DIR)
return 0, 0
fixed = 0
already_ok = 0
skipped_no_cache = 0
# ── OPT: cache SHA256 per plik — każdy plik w cache hashowany tylko RAZ.
# (np. linux-firmware ~200MB jest wcześniej czytany i hashowany ~245×,
# co powodowało przekroczenie limitu czasu. Teraz: 1×.) ──
_sha_cache = {}
def _file_sha(fpath):
h = _sha_cache.get(fpath)
if h is None:
with open(fpath, "rb") as f:
h = _hl.sha256(f.read()).hexdigest()
_sha_cache[fpath] = h
return h
# ── OPT: indeks cache (nazwa bez rozszerzenia -> ścieżka) budowany RAZ,
# zamiast os.listdir() w pętli dla każdej brakującej sumy. ──
cache_index = {}
if os.path.isdir(SRC_CACHE):
for cf in os.listdir(SRC_CACHE):
cf_no_ext = re.sub(r'\.(tar\.(gz|bz2|xz)|tgz|zip)$', '', cf)
cache_index.setdefault(cf_no_ext, os.path.join(SRC_CACHE, cf))
n_total = len(recipes)
for idx, (fp, meta) in enumerate(recipes, 1):
name = meta["name"]
ver = meta["version"]
sources = meta.get("sources", [])
sha256s = meta.get("sha256sums", [])
needs_fix = False
new_shas = []
for i, src_url in enumerate(sources):
if not src_url:
new_shas.append("")
continue
src_url_expanded = src_url.replace("${pkgver}", ver).replace("$pkgver", ver)
fname = os.path.basename(src_url_expanded.split("?")[0])
cached = os.path.join(SRC_CACHE, fname)
current_sha = sha256s[i] if i < len(sha256s) else ""
if os.path.exists(cached) and _looks_like_archive(cached):
actual = _file_sha(cached)
new_shas.append(actual)
if current_sha != actual:
needs_fix = True
elif os.path.exists(cached):
# Plik w cache uszkodzony (HTML/niekompletny) – nie wpisuj sumy śmieci
if verbose:
print(f" ⚠ {name}: {fname} w cache jest uszkodzony – usuń go: {cached}")
new_shas.append(current_sha if current_sha else "SKIP")
skipped_no_cache += 1
else:
# Brak pliku w cache – szukaj po nazwie bez rozszerzenia (przez indeks)
base_no_ext = re.sub(r'\.(tar\.(gz|bz2|xz)|tgz|zip)$', '', fname)
cached_alt = cache_index.get(base_no_ext)
if cached_alt:
if _looks_like_archive(cached_alt):
actual = _file_sha(cached_alt)
new_shas.append(actual)
if current_sha != actual:
needs_fix = True
else:
if verbose:
print(f" ⚠ {name}: {os.path.basename(cached_alt)} w cache jest uszkodzony – usuń go: {cached_alt}")
new_shas.append(current_sha if current_sha else "SKIP")
skipped_no_cache += 1
else:
# Zachowaj istniejącą sumę (lub SKIP)
new_shas.append(current_sha if current_sha else "SKIP")
if not current_sha or current_sha == "SKIP":
skipped_no_cache += 1
if needs_fix:
# Wczytaj, zmodyfikuj, zapisz YAML
try:
with open(fp, "r", encoding="utf-8") as f:
data = _yaml.safe_load(f.read())
if data:
data["sha256sums"] = new_shas
with open(fp, "w", encoding="utf-8") as f:
f.write(_dump_recipe_yaml(data))
fixed += 1
if verbose:
print(f" 🔧 {name}-{ver}: sha256sums poprawione")
except Exception as e:
print(f" ⚠ {name}: błąd zapisu YAML – {e}")
else:
already_ok += 1
# ── OPT: postęp co 100 receptur (żeby konsola pokazywała aktywność) ──
if verbose and idx % 100 == 0:
print(f" … {idx}/{n_total} receptur sprawdzonych ({fixed} naprawionych)…")
if verbose:
print(f"\n📊 AUTO-FIX SHA256: {fixed} poprawionych, {already_ok} OK, "
f"{skipped_no_cache} bez źródła w cache'u")
# Commit i push zmian do git repo (jeśli coś naprawiono)
if fixed > 0:
try:
add_r = subprocess.run(
["git", "-C", RECIPES_DIR, "add", "-A"],
capture_output=True, text=True, errors='replace', timeout=30
)
commit_r = subprocess.run(
["git", "-C", RECIPES_DIR, "commit", "-m", f"auto-fix: SHA256 sums ({fixed} recipes)"],
capture_output=True, text=True, errors='replace', timeout=30
)
# Unifikacja zapisu: commit -> pull --rebase -> push (rebase lokalnych
# commitów na origin, żeby push nie byl odrzucany non-fast-forward).
rebase_r = subprocess.run(
["git", "-C", RECIPES_DIR, "pull", "--rebase", "origin", "main"],
capture_output=True, text=True, errors='replace', timeout=120
)
if rebase_r.returncode != 0:
subprocess.run(["git", "-C", RECIPES_DIR, "rebase", "--abort"],
capture_output=True, timeout=10)
print(f" ⚠ Rebase przed pushem nieudany – zmiany zostają lokalnie")
push_r = subprocess.run(
["git", "-C", RECIPES_DIR, "push", "origin", "main"],
capture_output=True, text=True, errors='replace', timeout=30
)
if push_r.returncode != 0:
# Ignorowany push powodował ciche kumulowanie lokalnych commitów
# i rozjazd gałęzi (pull --ff-only potem padał). Sygnalizuj głośno.
print(f" ⚠ Push do {RECIPES_GIT_REMOTE} NIEUDANY: {push_r.stderr.strip()[:200]}")
print(f" Commit został lokalnie – następny pull zrobi rebase i wypchnie.")
elif verbose:
print(f" 📤 Zmiany wypchnięte do {RECIPES_GIT_REMOTE}")
except Exception as e:
if verbose:
print(f" ⚠ Nie udało się wypchnąć zmian: {e}")
return fixed, len(recipes)
# ── CHECK MODE – walidacja bez budowania ──
# ── Kontrola i naprawa podpisów GPG ──
def _gpg_bin():
return shutil.which("gpg2") or shutil.which("gpg") or ""
def _sign_file(path):
"""Podpisuje plik detached (armor) kluczem GPG_KEY. True gdy .asc powstał."""
gpg = _gpg_bin()
if not (gpg and DO_SIGN):
return False
sig = path + ".asc"
try:
if os.path.exists(sig):
os.remove(sig)
except OSError:
pass
key_args = ["--local-user", GPG_KEY] if GPG_KEY else []
r = subprocess.run(
[gpg, "--detach-sign", "--armor", "--batch", "--no-tty"] + key_args + [path],
check=False, capture_output=True,
)
if r.returncode != 0 or not os.path.exists(sig):
print(f" ⚠ podpisywanie nieudane: {r.stderr.decode(errors='replace').strip()[:160]}")
return False
return True
def _verify_sig(path):
"""(ok, reason) – weryfikuje detached podpis <path>.asc."""
gpg = _gpg_bin()
if not gpg:
return (False, "NO_GPG")
sig = path + ".asc"
if not os.path.isfile(sig):
return (False, "MISSING_SIG")
r = subprocess.run([gpg, "--verify", sig, path], check=False, capture_output=True)
if r.returncode == 0:
return (True, "OK")
err = (r.stderr or b"").decode(errors="replace").lower()
if "no public key" in err or "no_pubkey" in err:
return (False, "NO_PUBKEY")
return (False, "BAD_SIG")
def check_signatures(verbose=True, fix=False):
"""Kontrola podpisów GPG: klucz, repo.json.asc, wszystkie .pag.asc.
Diagnozuje „sypanie kluczy”: brak klucza tajnego, niedziałające podpisywanie,
stare/niepasujące .asc przy paczkach (klient dostaje wtedy
„NIEPRAWIDŁOWY PODPIS GPG"). Z fix=True podpisuje ponownie problematyczne.
"""
import concurrent.futures as _cf
gpg = _gpg_bin()
print("\n🔐 Kontrola podpisów GPG...")
if not gpg:
print(" ❌ Brak gpg/gpg2 w PATH – podpisywanie niemożliwe!")
return 1
rc = 0
print(f" 🔑 Klucz: {GPG_KEY or '(nie ustawiony)'} | DO_SIGN={'on' if DO_SIGN else 'off'}")
if GPG_KEY:
r = subprocess.run([gpg, "--list-secret-keys", "--with-colons", GPG_KEY],
check=False, capture_output=True, text=True)
if r.returncode != 0 or "sec:" not in (r.stdout or ""):
print(f" ❌ BRAK klucza TAJNEGO {GPG_KEY} w keyringu – buildy NIE podpiszą paczek!")
rc = 1
else:
print(" ✅ Klucz tajny obecny w keyringu")
# Self-test podpisywania
if DO_SIGN:
try:
fd, tp = tempfile.mkstemp(prefix="pag-sigtest-")
os.write(fd, b"pagan signature self-test\n")
os.close(fd)
ok = _sign_file(tp)
ok2, reason = _verify_sig(tp) if ok else (False, "SIGN_FAILED")
print(f" {'✅' if ok2 else '❌'} Test podpisywania: {reason}")
if not ok2:
rc = 1
for _p in (tp, tp + ".asc"):
try:
if os.path.exists(_p):
os.remove(_p)
except OSError:
pass
except Exception as e:
print(f" ⚠ Test podpisywania pominięty: {e}")
total_ok = total_bad = total_fixed = 0
for cat in REPO_CATEGORIES:
cat_dir = os.path.join(REPO_BASE, cat)
if not os.path.isdir(cat_dir):
continue
rj = os.path.join(cat_dir, "repo.json")
if os.path.isfile(rj):
ok, reason = _verify_sig(rj)
print(f" {'✅' if ok else '❌'} {cat}/repo.json: {reason}")
if not ok:
rc = 1
if fix and _sign_file(rj):
ok2, r2 = _verify_sig(rj)
print(f" {'✅ odtworzono podpis repo.json' if ok2 else '❌ ' + r2}")
files = [f for f in sorted(os.listdir(cat_dir)) if f.endswith(PKG_EXT)]
def _v(fn):
ok, reason = _verify_sig(os.path.join(cat_dir, fn))
return (fn, reason) if not ok else None
bad = [res for res in _cf.ThreadPoolExecutor(max_workers=6).map(_v, files) if res]
total_ok += len(files) - len(bad)
total_bad += len(bad)
if bad:
print(f" ❌ {cat}: {len(files)-len(bad)}/{len(files)} OK, {len(bad)} problemów:")
for fn, reason in bad:
print(f" {reason:11s} {fn}")
if fix:
for fn, reason in bad:
if reason == "NO_PUBKEY":
print(f" ⚠ {fn}: brak klucza publicznego w keyringu – pomijam")
continue
if _sign_file(os.path.join(cat_dir, fn)):
ok2, r2 = _verify_sig(os.path.join(cat_dir, fn))
if ok2:
total_fixed += 1
print(f" ✅ {fn}: podpis odtworzony")
else:
print(f" ❌ {fn}: nadal {r2}")
else:
print(f" ✅ {cat}: wszystkie {len(files)} podpisów paczek poprawne")
if total_bad:
print(f"\n{'✅' if total_fixed >= total_bad else '⚠'} Podsumowanie: {total_ok} OK, "
f"{total_bad} problemów, naprawiono {total_fixed}.")
if total_fixed < total_bad:
rc = 1
else:
print(f"\n✅ Wszystkie {total_ok} podpisów poprawne.")
return rc
def check_recipes():
"""Sprawdza wszystkie receptury: YAML, SHA256, zależności, źródła."""
import urllib.request, ssl as _ssl
print("🔍 PAGSYNC CHECK – walidacja wszystkich receptur\n")
state = load_state()
state["last_sync"] = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
save_state(state)
recipes = scan_recipes(state)
if not recipes:
print("❌ Brak receptur w", RECIPES_DIR)
return
total = len(recipes)
ok_yaml = 0
ok_sha = 0
ok_deps = 0
ok_src = 0
errors = []
warnings = []
# Zbierz wszystkie nazwy do sprawdzania zależności (+ indeks provides)
all_names = {meta["name"] for _, meta in recipes}
provides_index = {}
for fp, meta in recipes:
for p in meta.get("provides", []):
provides_index.setdefault(p, []).append(meta["name"])
print(f"📊 Sprawdzanie {total} receptur...\n")
for fp, meta in recipes:
name = meta["name"]
ver = meta["version"]
cat = recipe_to_repo_category(fp)
issues = []
# ── YAML już sparsowany (przez scan_recipes) ──
ok_yaml += 1
# ── SHA256 weryfikacja (tylko jeśli źródło jest w cache) ──
src_cache = "/var/cache/pagbuild/sources"
sources = meta.get("sources", [])
sha256s = meta.get("sha256sums", [])
for i, src_url in enumerate(sources):
if not src_url:
continue
src_url = src_url.replace("${pkgver}", ver).replace("$pkgver", ver)
fname = os.path.basename(src_url.split("?")[0])
cached = os.path.join(src_cache, fname)
if os.path.exists(cached):
if i < len(sha256s) and sha256s[i]:
import hashlib
with open(cached, "rb") as f:
actual = hashlib.sha256(f.read()).hexdigest()
if actual == sha256s[i]:
ok_sha += 1
else:
# SHA256 w cache się nie zgadza – pagbuild i tak pobierze świeże
warnings.append(f"{name}: SHA256 w cache nieaktualne dla {fname} – będzie pobrane od nowa")
ok_sha += 1 # nie blokuje budowania
else:
ok_sha += 1 # w cache, ale brak sumy do porównania
else:
warnings.append(f"{name}: źródło {fname} – jeszcze nie pobrane (OK, będzie przy buildzie)")
# ── Wzorzec „cd w build + builddir w package bez cd” ──
# Faza PACKAGE w pagbuild startuje ZAWSZE z katalogu workspace
# (cd {chroot_work}/src-<pkg> przed package_phase). Receptura z
# `cd ${pkgname}-${pkgver}` w build MUSI mieć to samo cd w package,
# gdy odwołuje się do artefaktów builda (builddir/build) – inaczej
# „Install data not found” / „cmake --install build: brak katalogu”.
try:
_raw = open(fp, encoding="utf-8", newline="").read()
_mb = re.search(r"build:\s*[|\"]\s*(.*?)(?:\n\S.*?:|\Z)", _raw, re.S)
_mp = re.search(r"package:\s*[|\"]\s*(.*?)(?:\n\S.*?:|\Z)", _raw, re.S)
_b = _mb.group(1) if _mb else ""
_p = _mp.group(1) if _mp else ""
if ("cd ${pkgname}-${pkgver}" in _b or "cd ${pkgname}-v${pkgver}" in _b) \
and re.search(r"(meson install -C builddir|cmake --install build|ninja -C build)", _p) \
and "cd ${pkgname}-${pkgver}" not in _p:
warnings.append(
f"{name}: package odwołuje się do builddir/build, ale nie ma "
f"'cd ${{pkgname}}-${{pkgver}}' – faza PACKAGE startuje z workspace "
f"(ryzyko: 'Install data not found')"
)
except Exception:
pass
# ── Sprawdzenie zależności ──
missing_deps = []
for dep in meta.get("depends", []):
if dep in all_names:
continue
if any(dep in provides_index.get(n, []) for n in all_names):
continue
missing_deps.append(dep)
if missing_deps:
warnings.append(f"{name}: zależności spoza recipes: {', '.join(missing_deps)}")
else:
ok_deps += 1
# ── Sprawdzenie dostępności URL (HEAD request, tylko pierwsze źródło) ──
if sources and sources[0]:
try:
test_url = sources[0].replace("${pkgver}", ver).replace("$pkgver", ver)
req = urllib.request.Request(test_url, method="HEAD")
ctx = _ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = _ssl.CERT_NONE
# Krótki timeout – nie chcemy wisieć
urllib.request.urlopen(req, timeout=5, context=ctx)
ok_src += 1
except Exception as e:
err_msg = str(e)[:80]
warnings.append(f"{name}: URL niedostępny – {err_msg}")
# ── Podsumowanie per-pakiet ──
icon = "✅" if not issues else "❌"
print(f" {icon} {name}-{ver} [{cat}]")
# ── Raport końcowy ──
print(f"\n{'═'*60}")
print(f"📋 RAPORT KOŃCOWY")
print(f"{'═'*60}")
print(f" Receptur: {total}")
print(f" YAML OK: {ok_yaml}/{total}")
print(f" SHA256 OK: {ok_sha}")
print(f" Deps OK: {ok_deps}/{total}")
print(f" URL dostępne: {ok_src}/{total}")
if errors:
print(f"\n❌ BŁĘDY ({len(errors)}):")
for e in errors:
print(f" {e}")
if warnings:
print(f"\n⚠ OSTRZEŻENIA ({len(warnings)}):")
for w in warnings[:20]:
print(f" {w}")
if len(warnings) > 20:
print(f" ... i {len(warnings)-20} więcej")
if not errors:
print(f"\n✅ Wszystkie receptury poprawne – można budować!")
print(f" pagsync --once")
else:
print(f"\n❌ Najpierw popraw {len(errors)} błędów przed budowaniem.")
# ── CLEAN CACHE – usuwa nieużywane źródła ──
# ── AUDYT – namcap-podobny: czego brakuje w makedepends po FAILach ──
# Szuka w logach nieudanych buildów śladów brakujących narzędzi / modułów
# pkg-config / nagłówków i podpowiada dopisanie ich do makedepends receptury.
AUDIT_PATTERNS = [
(r"configure: error: Cannot find ([A-Za-z0-9_+.\-]+)", "tool"),
(r"Cannot find ([A-Za-z0-9_+.\-]+) Is", "tool"),
(r"([A-Za-z0-9_+.\-]+)-config could not be found", "tool"),
(r"([A-Za-z0-9_+.\-]+): command not found", "tool"),
(r"No package ([A-Za-z0-9_+.\-]+) found", "pkgconfig"),
(r"fatal error: ([A-Za-z0-9_+./\-]+): No such file", "header"),
(r"Run-time dependency ([A-Za-z0-9_+.\-]+) found: NO", "pkgconfig"),
]
def _audit_suggestions(limit=60):
"""Z logów ostatnich FAILów zwraca: pakiet -> (tokeny, proponowane receptury)."""
state = load_state()
name_to_path, _ = _recipes_maps(state)
fails = {}
for b in state.get("builds", []):
if b.get("status") == "failed":
fails.setdefault(b["name"], b)
out = {}
for pkg, b in list(fails.items())[:limit]:
log = b.get("log", "") or ""
tokens = set()
for pat, kind in AUDIT_PATTERNS:
for m in re.finditer(pat, log, re.IGNORECASE):
tokens.add(m.group(1))
if not tokens:
continue
prov = set()
for t in sorted(tokens):
if t.lower() in BUILD_SYSTEM_DEPS:
continue
hit = None
for c in _provider_candidates(t, "tool"):
if c in name_to_path:
hit = c
break
if hit:
prov.add(hit)
else:
# „Cannot find X” a receptura istnieje jako libX / X?
for cand in (t, "lib" + t, "xorg-" + t):
if cand in name_to_path:
prov.add(cand)
break
if prov:
out[pkg] = (sorted(tokens), sorted(prov))
return out
def audit_failed_builds(apply=False, limit=60):
"""Wypisuje (i opcjonalnie dopisuje) brakujące makedepends wg logów FAILów."""
name_to_path, _ = _recipes_maps(load_state())
sugg = _audit_suggestions(limit)
if not sugg:
print("✅ Brak pakietów z wykrywalnymi brakami makedepends (albo brak FAILów).")
return
for pkg in sorted(sugg):
tokens, prov = sugg[pkg]
print(f"• {pkg}: log mówi o: {', '.join(tokens)}")
rp = name_to_path.get(pkg)
if not rp:
print(f" (brak receptury {pkg} w drzewie)")
continue
already = set()
try:
with open(rp, encoding="utf-8") as fh:
for ln in fh:
if ln.lstrip().startswith("- "):
already.add(ln.split("-", 1)[1].strip().strip("'\""))
except OSError:
pass
add = [p for p in prov if p not in already]
if add:
print(f" → proponuję dopisać do makedepends: {', '.join(add)}")
if apply:
_recipe_add_makedepends(rp, add)
else:
print(f" (makedepends już pokryte: {', '.join(prov)} – problem może być w recepturze)")
if apply:
print("\n✅ Dopisano braki – sprawdź git diff w recipes/ przed wypchnięciem.")
def _recipe_add_makedepends(recipe_path, names):
"""Dopisuje nazwy do listy makedepends w PAGBUILD.yaml (prosta edycja tekstu)."""
with open(recipe_path, encoding="utf-8") as fh:
lines = fh.read().splitlines()
idx = next((i for i, l in enumerate(lines)
if l.rstrip() == "makedepends:" or l.rstrip().startswith("makedepends: [")), None)
add = ["- '%s'" % n for n in names]
if idx is None:
pos = next((i for i, l in enumerate(lines) if l.rstrip().startswith("build:")),
len(lines))
block = ["makedepends:"] + add
lines = lines[:pos] + block + lines[pos:]
elif "[" in lines[idx]:
lines[idx] = "makedepends:"
lines = lines[:idx + 1] + add + lines[idx + 1:]
else:
j = idx + 1
while j < len(lines) and (lines[j].startswith("- ") or not lines[j].strip()):
j += 1
lines = lines[:j] + add + lines[j:]
with open(recipe_path, "w", encoding="utf-8") as fh:
fh.write("\n".join(lines) + "\n")
print(f" ✏️ {recipe_path}: dodano {', '.join(names)}")
def clean_source_cache():
"""Czyści cache źródeł pagbuild.
Pobrane archiwa nie są trzymane „na zapas” – po zbudowaniu pakietu źródło
nie jest już potrzebne (zawsze można pobrać ponownie), a cache potrafi
urosnąć do kilkunastu GB. Domyślnie usuwamy WSZYSTKO; zachowanie selektywne
(tylko pliki nieużywane przez receptury) po ustawieniu PAGAN_KEEP_SRC_CACHE=1.
"""
SRC_CACHE = "/var/cache/pagbuild/sources"
if not os.path.isdir(SRC_CACHE):
print("📁 Cache źródeł jest pusty.")
return
keep_all = os.environ.get("PAGAN_KEEP_SRC_CACHE", "") == "1"
needed_files = set()
if keep_all:
# Stare zachowanie: zachowaj pliki, których nazwa pasuje do którejkolwiek
# receptury (przydatne przy budowaniu offline).
state = load_state()
all_recipes = scan_recipes(state)
for fp, meta in all_recipes:
ver = str(meta.get("version", ""))
pkg = str(meta.get("name", ""))
rel = str(meta.get("release", "1"))
for src_url in meta.get("sources", []):
u = (str(src_url)
.replace("${pkgver}", ver).replace("$pkgver", ver)
.replace("${pkgname}", pkg).replace("$pkgname", pkg)
.replace("${pkgrel}", rel).replace("$pkgrel", rel))
if u.endswith(".git") or "://" not in u:
continue
fname = os.path.basename(u.split("?")[0])
if fname and not fname.startswith("${"):
needed_files.add(fname)
total_size = 0
removed = 0
kept = 0
for fname in sorted(os.listdir(SRC_CACHE)):
fp = os.path.join(SRC_CACHE, fname)
if not os.path.isfile(fp):
continue
size = os.path.getsize(fp)
if keep_all and fname in needed_files:
kept += 1
continue
os.remove(fp)
removed += 1
total_size += size
print(f" 🗑 {fname} ({size//1048576} MB)")
print(f"\n🧹 Usunięto {removed} plików ({total_size//1048576} MB)")
if keep_all:
print(f"📦 Zachowano {kept} plików w cache (PAGAN_KEEP_SRC_CACHE=1)")
else:
print("📦 Cache źródeł pusty – kolejne buildy pobiorą źródła na nowo")
def purge_source_cache():
"""Sprzątanie cache źródeł po zakończonym przebiegu (chyba że
PAGAN_KEEP_SRC_CACHE=1) – nie trzymamy GB pobranych archiwów."""
if os.environ.get("PAGAN_KEEP_SRC_CACHE", "") != "1":
clean_source_cache()
# ── CHECK UPDATES – sprawdzanie nowych wersji upstream ──
def _version_tuple(ver):
"""Porównywalna krotka dla wersji tekstowych ('1.4rc5', '2.4.0', 'v3.1')."""
parts = re.split(r"[.\-_+]", str(ver).lstrip("vV"))
out = []
for p in parts:
m = re.match(r"^(\d+)(.*)$", p)
if m:
out.append((1, int(m.group(1)), m.group(2)))
elif p:
out.append((0, p, ""))
return tuple(out)
def _cmp_versions(a, b):
ta, tb = _version_tuple(a), _version_tuple(b)
n = max(len(ta), len(tb))
# Padding z elementem tekstowym (drugi element str), by nie porównywać
# int ze str (np. "1.0.20" vs "1.0.18-stable" → TypeError przy (0,0,'') vs (0,'stable','')).
ta += ((0, "", ""),) * (n - len(ta))
tb += ((0, "", ""),) * (n - len(tb))
for x, y in zip(ta, tb):
if x != y:
return -1 if x < y else 1
return 0
def _latest_version(candidates):
"""Najnowsza wersja z listy; ignoruje sufiksy typu -stable/-msvc."""
def key(v):
s = str(v).lstrip("vV")
parts = re.split(r"[.\-_+]", s)
nums = []
for p in parts:
m = re.match(r"^(\d+)", p)
nums.append(int(m.group(1)) if m else -1)
return (tuple(nums), len(parts))
best = None
for c in candidates:
if best is None or key(c) > key(best):
best = c
if best is None:
return None
b = str(best).lstrip("vV")
b = re.sub(r"-.*$", "", b) # 1.0.22-stable-msvc -> 1.0.22
return b
def _fetch_dir_listing(url, timeout=20):
import urllib.request, ssl as _ssl
ctx = _ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = _ssl.CERT_NONE
req = urllib.request.Request(url, headers={"User-Agent": "pagsync/check-updates"})
try:
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as r:
return r.read().decode("utf-8", "replace")
except Exception:
return ""
def _run_update_script(body, name, ver):
try:
env = {**os.environ, "pkgname": name, "pkgver": ver, "name": name, "version": ver}
p = subprocess.run(["bash", "-c", body], capture_output=True, text=True, errors='replace',
timeout=60, env=env)
for line in reversed(p.stdout.strip().splitlines()):
m = re.search(r"(\d[\w.\-]*)", line.strip())
if m:
return m.group(1).lstrip("vV")
except Exception:
pass
return None
def _git_latest(url):
if not url:
return None
try:
p = subprocess.run(["git", "ls-remote", "--tags", "--refs", url],
capture_output=True, text=True, errors='replace', timeout=60)
except Exception:
return None
if p.returncode != 0:
return None
tags = []
for line in p.stdout.splitlines():
ref = line.split("\t")[-1]
t = ref.removeprefix("refs/tags/").removesuffix("^{}")
t = t.lstrip("vV")
if t:
tags.append(t)
return _latest_version(tags)
def _clean_version_token(t, name=None):
"""Wycina czystą wersję z tokena tagu: 'v2.86.4'/'glib-2.86.4' -> '2.86.4'."""
if not t:
return None
t = t.strip().lstrip("vV").strip()
if name and t.lower().startswith(name.lower() + "-"):
t = t[len(name) + 1:]
m = re.match(r"^(\d+(?:[.\-_]\d+)*)", t)
return m.group(1) if m else None
def _github_latest(url, name):
"""Najnowsza wersja z GitHub (strona /tags – bez limitu API)."""
m = re.search(r"github\.com/([^/]+)/([^/?#]+)", url)
if not m:
return None
owner, repo = m.group(1), m.group(2)
html = _fetch_dir_listing(f"https://github.com/{owner}/{repo}/tags")
if not html:
return None
tags = set()
tags.update(re.findall(r'/releases/tag/([^"<]+)', html))
tags.update(t.strip() for t in re.findall(r'tag-name[^>]*>\s*([^<]+)<', html))
cands = set()
for t in tags:
c = _clean_version_token(t, name)
if c:
cands.add(c)
return _latest_version(cands) if cands else None
def _sourceforge_latest(url, name):
"""Najnowsza wersja ze SourceForge (RSS projektu)."""
m = re.search(r"(?:sourceforge\.net|sf\.net)[/:](?:projects?/)?([^/?#]+)", url)
if not m:
return None
project = m.group(1).strip("/")
html = _fetch_dir_listing(f"https://sourceforge.net/projects/{project}/rss")
if not html:
return None
pat = re.compile(r"^" + re.escape(name) + r"-(\d[\w.\-]*)\.(tar\.(gz|xz|bz2|lz|zst)|tgz|zip)$",
re.IGNORECASE)
cands = set()
for l in re.findall(r"<link>([^<]+)</link>", html):
base = l.split("?")[0].rstrip("/")
base = re.sub(r"/download$", "", base) # usuń /download PRZED basename
base = base.rsplit("/", 1)[-1]
mm = pat.match(base)
if mm:
cands.add(mm.group(1))
for t in re.findall(r"<title>([^<]*)</title>", html):
mt = re.match(r"^\s*" + re.escape(name) + r"\s*[\- ]\s*(\d[\w.\-]*)", t)
if mt:
cands.add(mt.group(1))
return _latest_version(cands) if cands else None
def _default_update_check(name, ver, sources):
"""Heurystyka: WSZYSTKIE źródła HTTP (listing + GitHub + SourceForge).
Dla każdego źródła próbuje wykryć najnowszą wersję:
* GitHub -> strona /tags (owner/repo)
* SourceForge -> RSS projektu
* inne -> listing katalogu nadrzędnego (kernel.org, ftp.gnu.org, ...)
Wybiera najnowszą wersję spośród WSZYSTKICH źródeł.
"""
if not sources:
return None, "brak źródła"
pat = re.compile(r"^" + re.escape(name) + r"-(\d[\w.\-]*)\.(tar\.(gz|xz|bz2|lz|zst)|tgz|zip)$",
re.IGNORECASE)
cands = set()
tried = 0
methods = set()
for s in sources:
urls = s if isinstance(s, (list, tuple)) else [s]
for u in urls:
if isinstance(u, dict):
u = u.get("url") or u.get("source") or ""
u = str(u).strip()
if not u.startswith(("http://", "https://")):
continue # plik lokalny (patch, service, config, ...)
src = (u.replace("${pkgname}", name).replace("$pkgname", name)
.replace("${pkgver}", str(ver)).replace("$pkgver", str(ver)))
host = (src.split("://", 1)[1].split("/", 1)[0].lower()
if "://" in src else "")
if host in ("github.com", "www.github.com"):
gh = _github_latest(src, name)
tried += 1
if gh:
cands.add(gh)
methods.add("github")
continue
if "sourceforge.net" in host or "sf.net" in host:
sf = _sourceforge_latest(src, name)
tried += 1
if sf:
cands.add(sf)
methods.add("sourceforge")
continue
base = src.rsplit("/", 1)[0] + "/"
html = _fetch_dir_listing(base)
tried += 1
if not html:
continue
methods.add("listing")
for f in re.findall(r'href="([^"]+)"', html):
# listingi bywają względne: "/download/curl-8.21.0.tar.xz"
b = f.rstrip("/").rsplit("/", 1)[-1]
m = pat.match(b)
if m:
cands.add(m.group(1))
if not cands:
return None, ("brak wersjonowanych plików" if tried else "tylko źródła lokalne")
how = ("/".join(sorted(methods)) + " (%d źr.)" % tried) if methods else "listing"
return _latest_version(cands), how
def check_updates(single=None, apply=False):
"""Sprawdza nowe wersje upstream dla receptur. Z --apply podbija pkgver."""
import yaml as _yaml
state = load_state()
recipes = scan_recipes(state)
if not recipes:
print("❌ Brak receptur w", RECIPES_DIR)
return
print(f"🔎 CHECK UPDATES – {len(recipes)} receptur...\n")
if apply:
print("⚠ Tryb --apply: pkgver zostanie PODBITY, pkgrel=1, sha256sums=SKIP.\n")
updated = 0
up_to_date = 0
failed = 0
report = []
for fp, meta in recipes:
name = meta["name"]
ver = str(meta["version"])
if single and name != single:
continue
with open(fp, encoding="utf-8") as f:
data = _yaml.safe_load(f) or {}
up = data.get("update")
latest = None
how = "heuristics"
if isinstance(up, str) and up.strip():
how = "update-script"
latest = _run_update_script(up, name, ver)
elif isinstance(up, dict):
utype = up.get("type", "git")
how = f"update:{utype}"
if utype == "git":
latest = _git_latest(up.get("url", ""))
elif utype == "script":
latest = _run_update_script(up.get("script", ""), name, ver)
elif utype == "httpdir":
url = up.get("url", "")
ext = up.get("ext", "tar.gz")
html = _fetch_dir_listing(url)
pat = re.compile(r"^" + re.escape(name) + r"-(\d[\w.\-]*)\." + re.escape(ext) + r"$")
cands = set()
for f in re.findall(r'href="([^"]+)"', html):
b = f.rstrip("/").rsplit("/", 1)[-1]
m = pat.match(b)
if m:
cands.add(m.group(1))
if cands:
latest = _latest_version(cands)
if latest is None:
# Fallback: brak update lub metoda nie dała wyniku → heurystyka ze źródeł
# Fallback: skrypt/listing nie dał wyniku → heurystyka ze źródła
latest, how = _default_update_check(name, ver, meta.get("sources", []))
if latest is None:
failed += 1
report.append((name, ver, None, how))
continue
newer = _cmp_versions(latest, ver) > 0
if not newer:
up_to_date += 1
report.append((name, ver, latest, how))
continue
report.append((name, ver, latest, how))
print(f" 🔄 {name}: {ver} → {latest} [{how}]")
if apply:
try:
data["pkgver"] = latest
data["pkgrel"] = 1
srcs = data.get("source", [])
if isinstance(srcs, str):
srcs = [srcs]
data["sha256sums"] = ["SKIP"] * len(srcs)
with open(fp, "w", encoding="utf-8") as f:
f.write(_dump_recipe_yaml(data))
updated += 1
except Exception as e:
print(f" ⚠ {name}: błąd zapisu – {e}")
print(f"\n📊 CHECK UPDATES: 🔄 {updated if apply else sum(1 for n, v, l, h in report if l and _cmp_versions(l, v) > 0)} "
f"nowych | ✅ {up_to_date} aktualnych | ❓ {failed} bez wyniku")
if not apply and not single:
for n, v, l, h in report:
if l and _cmp_versions(l, v) > 0:
print(f" {n}: {v} → {l}")
if apply and updated:
print(f"\n✅ Podbito wersje w {updated} recepturach (pkgrel=1, sha256sums=SKIP).")
print(" Zbuduj ponownie: pagsync --once (lub --build <pkg>)")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="PaganOS Build Sync Manager v2")
parser.add_argument("--once", action="store_true", help="Jednorazowe uruchomienie")
parser.add_argument("--force", action="store_true", help="Wymuś przebudowanie wszystkich")
parser.add_argument("--missing", action="store_true", help="Buduj tylko brakujące")
parser.add_argument("--build", type=str, default=None, help="Buduj konkretny pakiet")
parser.add_argument("--rebuild-failed", action="store_true", help="Odbuduj tylko nieudane")
parser.add_argument("--rootfs", type=str, default=ROOTFS_PATH, help="Ścieżka do rootfs")
parser.add_argument("--sign", action="store_true", help="Podpisuj pakiety i repo.json GPG")
parser.add_argument("--gpg-key", type=str, default=GPG_KEY, help="ID klucza GPG")
parser.add_argument("--check", action="store_true", help="Sprawdź receptury bez budowania (YAML, SHA256, deps, URL)")
parser.add_argument("--check-sigs", action="store_true", help="Sprawdź podpisy GPG: klucz, repo.json.asc, wszystkie .pag.asc (bez budowania)")
parser.add_argument("--fix-sigs", action="store_true", help="Z --check-sigs: podpisz ponownie paczki z brakującym/nieprawidłowym .asc")
parser.add_argument("--check-updates", action="store_true", help="Sprawdź nowe wersje upstream (z update: skryptem lub heurystyką ze źródła)")
parser.add_argument("--apply", action="store_true", help="Z --check-updates: podbij pkgver w recepturach (pkgrel=1, sha256sums=SKIP)")
parser.add_argument("--update-pkg", type=str, default=None, help="Z --check-updates: sprawdź tylko ten pakiet")
parser.add_argument("--fix-sha", action="store_true", help="Sprawdź i automatycznie popraw sha256sums w recepturach")
parser.add_argument("--save-kernel-config", dest="save_kernel_config", action="store_true",
help="Zapisz config jąder z ostatnio zbudowanych .pag do receptur (+SHA, commit/push)")
parser.add_argument("--clean-cache", action="store_true", help="Wyczyść cache źródeł (pliki nieużywane przez żaden przepis)")
parser.add_argument("--audit", action="store_true",
help="Audyt FAILów: podpowiedz brakujące makedepends (jak namcap)")
parser.add_argument("--audit-apply", action="store_true",
help="--audit + dopisz znalezione braki do makedepends receptur")
parser.add_argument("--gen-repo", action="store_true", help="Wygeneruj repo.json z istniejących pakietów w repo")
parser.add_argument("--sync-only", action="store_true", help="Tylko sync: git pull recipes + skan + repo.json, BEZ budowania")
parser.add_argument("--rescan", action="store_true",
help="Tylko odśwież indeks receptur (skan RECIPES_DIR -> state.json), bez git pull / repo.json / buildów")
parser.add_argument("--install", type=str, default=None, help="Zainstaluj zbudowany pakiet (stable/<pkg>-*.pag) do rootfs buildera")
parser.add_argument("--rebuild-deps", type=str, default=None,
help="Wymuś przebudowę całego łańcucha zależności pakietu (deps + makedeps + sam pakiet)")
parser.add_argument("--auto-deps", dest="auto_deps", action="store_true", default=True,
help="Po nieudanym buildzie dobuduj brakujące zależności (jeśli mają receptury) i ponów (domyślnie ON)")
parser.add_argument("--no-auto-deps", dest="auto_deps", action="store_false",
help="Wyłącz automatyczną dobudowę zależności")
args = parser.parse_args()
# Kontrola/naprawa podpisów jest bezpieczna i nie mutuje stanu buildów – nie
# blokujemy jej globalnym lockiem, żeby dało się jej użyć TAKŻE w trakcie
# budowania (właśnie wtedy najczęściej widać problemy z kluczami).
if args.check_sigs:
sys.exit(check_signatures(verbose=True, fix=args.fix_sigs))
# ── Globalny lock: tylko jedna instancja pagsync naraz ──
# Bez tego dwa równoległe wywołania (timer + ręczne `--once`, panel + CLI)
# walczą o rootfs (flock) i nadpisują sobie state.json. run-pagsync.sh
# mapuje exit 3 na „pominięto” – przebieg powtórzy się przy następnym
# wyzwoleniu timera. Lock zwalnia się sam przy wyjściu procesu (zamknięcie fd).
_PAGAN_LOCK = os.environ.get("PAGAN_LOCK", "/var/lib/pagan-sync/pagsync.lock")
try:
import fcntl as _fcntl
except ImportError:
_fcntl = None
if _fcntl:
try:
_lock_fd = os.open(_PAGAN_LOCK, os.O_CREAT | os.O_RDWR, 0o644)
_fcntl.flock(_lock_fd, _fcntl.LOCK_EX | _fcntl.LOCK_NB)
except OSError:
print("❌ Inna instancja pagsync już działa (globalny lock).")
print(" Jeśli to nie Twoje uruchomienie, sprawdź: ps -eo pid,args | grep pagsync")
sys.exit(3)
AUTO_DEPS = args.auto_deps
if args.rootfs:
ROOTFS_PATH = args.rootfs
if args.sign:
DO_SIGN = True
if args.gpg_key:
GPG_KEY = args.gpg_key
if args.check:
check_recipes()
sys.exit(0)
if args.check_updates:
check_updates(single=args.update_pkg, apply=args.apply)
sys.exit(0)
if args.fix_sha:
auto_fix_sha256sums(verbose=True)
sys.exit(0)
if args.save_kernel_config:
save_kernel_config(verbose=True)
sys.exit(0)
if args.clean_cache:
clean_source_cache()
sys.exit(0)
if args.audit:
audit_failed_builds(apply=args.audit_apply)
sys.exit(0)
if args.gen_repo:
generate_repo_json(verbose=True)
sys.exit(0)
if args.rescan:
state = load_state()
recipes = scan_recipes(state)
print(f"✅ Indeks receptur odświeżony: {len(recipes)} receptur (bez git pull i budowania).")
sys.exit(0)
if args.sync_only:
state = load_state()
if not git_sync_recipes():
print("❌ Nie udało się zsynchronizować recipes.")
sys.exit(1)
scan_recipes(state)
generate_repo_json(verbose=True)
print("✅ Sync zakończony (bez budowania).")
sys.exit(0)
if args.install:
install_pkg_to_rootfs(args.install)
sys.exit(0)
if args.rebuild_deps:
state = load_state()
rebuild_deps_chain(args.rebuild_deps, state)
sys.exit(0)
sync_and_build(
force=args.force,
missing_only=args.missing,
single_pkg=args.build,
rebuild_failed=args.rebuild_failed,
fix_sha=args.fix_sha,
)