diff --git a/brand.js b/brand.js index ab2b994..d62df77 100644 --- a/brand.js +++ b/brand.js @@ -6,17 +6,25 @@ const version=document.getElementById('manifest-version'); const brandCount=document.getElementById('brand-count'); const progress=document.getElementById('progress'); const rawPath=document.getElementById('raw-path'); +const MANIFEST_TIMEOUT_MS=15000; let manifest={brands:[],canonicalRawBase:'https://raw.githubusercontent.com/puchadave/assets/main/'}; +for(const [id,el] of Object.entries({'brand-grid':brandGrid,'asset-list':assetList,'asset-filter':filter,'manifest-version':version,'brand-count':brandCount,'progress':progress,'raw-path':rawPath})){ + if(!el)console.error(`brand.js: missing element #${id}; related portal features are disabled.`); +} + function esc(v=''){return String(v).replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));} function initials(name=''){return name.split(/[\s._-]+/).filter(Boolean).slice(0,2).map(x=>x[0]).join('').toUpperCase()||'•';} function repoPath(path=''){return `https://github.com/puchadave/assets/tree/main/${path}`;} function rawAsset(path=''){return `${manifest.canonicalRawBase||'https://raw.githubusercontent.com/puchadave/assets/main/'}${path}`;} function fallbackMark(b){return `${esc(initials(b.name))}${esc(b.name)}`;} +function brands(){return Array.isArray(manifest.brands)?manifest.brands:[];} + function renderBrands(){ - brandCount.textContent=manifest.brands.length; - brandGrid.innerHTML=manifest.brands.map(b=>{ + if(!brandGrid)return; + if(brandCount)brandCount.textContent=brands().length; + brandGrid.innerHTML=brands().map(b=>{ const primary=(b.assets||[]).find(a=>a.role==='primary'&&a.path); const mark=primary ? `` @@ -31,22 +39,25 @@ function renderBrands(){ document.querySelectorAll('.brand-logo').forEach(img=>{ img.addEventListener('error',()=>{ - const b=manifest.brands.find(x=>x.id===img.dataset.brandId); - if(b)img.closest('.mark').innerHTML=fallbackMark(b); + const b=brands().find(x=>x.id===img.dataset.brandId); + const mark=img.closest('.mark'); + console.warn(`brand.js: logo for "${img.dataset.brandId}" could not be loaded (${img.src}).`); + if(b&&mark)mark.innerHTML=fallbackMark(b); },{once:true}); }); document.querySelectorAll('[data-brand]').forEach(a=>a.addEventListener('click',()=>{ - filter.value=a.dataset.brand; + if(filter)filter.value=a.dataset.brand; renderAssets(a.dataset.brand); })); } function flattenAssets(){ - return manifest.brands.flatMap(b=>(b.assets||[]).map(a=>({...a,brand:b.name,brandId:b.id,base:b.path}))); + return brands().flatMap(b=>(b.assets||[]).map(a=>({...a,brand:b.name,brandId:b.id,base:b.path}))); } function renderAssets(q=''){ + if(!assetList)return; const needle=q.trim().toLowerCase(); const rows=flattenAssets().filter(a=>!needle||[a.brand,a.brandId,a.role,a.format,a.path,a.label].join(' ').toLowerCase().includes(needle)); if(!rows.length){ @@ -60,33 +71,64 @@ function renderAssets(q=''){ }).join(''); } +async function fetchManifest(){ + const r=await fetch(`${MANIFEST_URL}?v=${Date.now()}`,{cache:'no-store',signal:AbortSignal.timeout?.(MANIFEST_TIMEOUT_MS)}); + if(!r.ok)throw new Error(`HTTP ${r.status} ${r.statusText} für ${MANIFEST_URL}`); + let data; + try{ + data=await r.json(); + }catch(e){ + throw new Error(`Manifest ist kein gültiges JSON: ${e.message}`,{cause:e}); + } + if(!data||!Array.isArray(data.brands))throw new Error('Manifest enthält kein "brands"-Array.'); + return data; +} + +function showManifestError(e){ + const reason=e?.name==='TimeoutError'?`Zeitüberschreitung nach ${MANIFEST_TIMEOUT_MS/1000}s`:e?.message||String(e); + console.error('brand.js: Brand-Manifest konnte nicht geladen werden.',e); + if(version)version.textContent='manifest nicht verfügbar'; + if(brandGrid)brandGrid.innerHTML=`
!Manifest

Brand-Manifest nicht verfügbar

Die Portalstruktur ist online, aber das kanonische Brand-Manifest konnte nicht geladen werden.

${esc(reason)}

`; + if(assetList)assetList.innerHTML=`
Manifest konnte nicht geladen werden.${esc(reason)}
`; +} + async function loadManifest(){ try{ - const r=await fetch(`${MANIFEST_URL}?v=${Date.now()}`,{cache:'no-store'}); - if(!r.ok)throw new Error(`HTTP ${r.status}`); - manifest=await r.json(); - version.textContent=`manifest ${manifest.version||'–'}`; + manifest=await fetchManifest(); + }catch(e){ + showManifestError(e); + return; + } + try{ + if(version)version.textContent=`manifest ${manifest.version||'–'}`; renderBrands(); renderAssets(); }catch(e){ - brandGrid.innerHTML='
!Manifest

Brand-Manifest nicht verfügbar

Die Portalstruktur ist online, aber das kanonische Brand-Manifest konnte nicht geladen werden.

'; - assetList.innerHTML='
Manifest konnte nicht geladen werden.
'; - console.error(e); + showManifestError(new Error(`Manifest konnte nicht gerendert werden: ${e.message}`,{cause:e})); } } filter?.addEventListener('input',e=>renderAssets(e.target.value)); document.getElementById('copy-path')?.addEventListener('click',async e=>{ + const button=e.currentTarget; + const reset=()=>setTimeout(()=>{button.textContent='Kopieren';},1600); try{ + if(!rawPath)throw new Error('Element #raw-path fehlt.'); + if(!navigator.clipboard)throw new Error('Clipboard-API nicht verfügbar (benötigt HTTPS).'); await navigator.clipboard.writeText(rawPath.textContent); - e.currentTarget.textContent='Kopiert'; - setTimeout(()=>e.currentTarget.textContent='Kopieren',1200); - }catch{} + button.textContent='Kopiert'; + }catch(err){ + console.error('brand.js: Pfad konnte nicht kopiert werden.',err); + button.textContent='Kopieren fehlgeschlagen'; + } + reset(); }); -window.addEventListener('scroll',()=>{ - const h=document.documentElement.scrollHeight-innerHeight; - progress.style.width=`${h>0?(scrollY/h)*100:0}%`; -},{passive:true}); +if(progress){ + window.addEventListener('scroll',()=>{ + const h=document.documentElement.scrollHeight-innerHeight; + progress.style.width=`${h>0?(scrollY/h)*100:0}%`; + },{passive:true}); +} window.addEventListener('pointermove',e=>{ document.documentElement.style.setProperty('--mx',`${e.clientX}px`); document.documentElement.style.setProperty('--my',`${e.clientY}px`); diff --git a/proxmox/install-webowie-proxmox-branding.sh b/proxmox/install-webowie-proxmox-branding.sh index 2031762..e8026ab 100755 --- a/proxmox/install-webowie-proxmox-branding.sh +++ b/proxmox/install-webowie-proxmox-branding.sh @@ -7,46 +7,90 @@ PVE_IMAGES="/usr/share/pve-manager/images" WTK_IMAGES="/usr/share/javascript/proxmox-widget-toolkit/images" PVE_INDEX="/usr/share/pve-manager/index.html.tpl" INSTALL="/usr/local/share/webowie-branding" +LOG_FILE="/var/log/webowie-pve-branding.log" +ASSETS=(proxmox_logo.png logo-128.png dd_logo.png favicon.ico proxmox_logo.svg pve-background-1920x1080.png) log(){ printf '\033[38;2;93;217;232m[webOwie]\033[0m %s\n' "$*"; } +warn(){ echo "WARN: $*" >&2; } die(){ echo "ERROR: $*" >&2; exit 1; } +trap 'echo "ERROR: ${BASH_SOURCE[0]}: aborted with status $? at line $LINENO: ${BASH_COMMAND}" >&2' ERR [[ $EUID -eq 0 ]] || die "Run as root." command -v pveversion >/dev/null || die "Proxmox VE not detected." command -v curl >/dev/null || die "curl is required." + +fetch_assets(){ + local stage="$1" f + for f in "${ASSETS[@]}"; do + curl -fsSL --retry 3 --connect-timeout 20 "$RAW/$f" -o "$stage/$f" \ + || die "Could not download $RAW/$f (branch '$BRANCH'). Existing branding was left untouched." + [[ -s "$stage/$f" ]] || die "Downloaded asset is empty: $RAW/$f" + done +} + +apply_assets(){ + local f + for f in "${ASSETS[@]}"; do + [[ -s "$INSTALL/$f" ]] || die "Brand asset missing or empty: $INSTALL/$f. Run '$0 --install' again." + done + install -m0644 "$INSTALL/proxmox_logo.png" "$PVE_IMAGES/proxmox_logo.png" + install -m0644 "$INSTALL/logo-128.png" "$PVE_IMAGES/logo-128.png" + install -m0644 "$INSTALL/favicon.ico" "$PVE_IMAGES/favicon.ico" + if [[ -e "$PVE_IMAGES/dd_logo.png" ]]; then install -m0644 "$INSTALL/dd_logo.png" "$PVE_IMAGES/dd_logo.png"; fi + if [[ -e "$WTK_IMAGES/proxmox_logo.svg" ]]; then + install -m0644 "$INSTALL/proxmox_logo.svg" "$WTK_IMAGES/proxmox_logo.svg" + else + warn "Widget-toolkit logo not found at $WTK_IMAGES/proxmox_logo.svg; skipping that override." + fi +} + +brand_index(){ + [[ -f "$PVE_INDEX" ]] || { warn "$PVE_INDEX not found; skipping HTML branding."; return 0; } + grep -q '' "$PVE_INDEX" || die "No found in $PVE_INDEX; HTML branding cannot be injected." + sed -i '//,//d' "$PVE_INDEX" + sed -i -E 's#[^<]*#webOwie Infrastructure Control Plane · puchalla.it.com#' "$PVE_INDEX" + sed -i '/<\/head>/i\ +\ +\ +\ +' "$PVE_INDEX" + grep -q 'WEBOWIE-BRANDING-BEGIN' "$PVE_INDEX" || die "HTML branding block was not written to $PVE_INDEX." +} + case "${1:---install}" in --install) + [[ -f "$0" && -r "$0" ]] || die "Cannot self-install: run the script from a file, not from a pipe (curl -fsSL ... -o install.sh && bash install.sh)." stamp="$(date +%Y%m%d-%H%M%S)"; backup="$BACKUP_ROOT/$stamp"; mkdir -p "$backup" "$INSTALL" for f in "$PVE_IMAGES/proxmox_logo.png" "$PVE_IMAGES/logo-128.png" "$PVE_IMAGES/favicon.ico" "$PVE_IMAGES/dd_logo.png" "$WTK_IMAGES/proxmox_logo.svg" "$PVE_INDEX"; do [[ -f "$f" ]] || continue; mkdir -p "$backup$(dirname "$f")"; cp -a "$f" "$backup$f"; done printf '%s\n' "$backup" >"$BACKUP_ROOT/.last" - for f in proxmox_logo.png logo-128.png dd_logo.png favicon.ico proxmox_logo.svg pve-background-1920x1080.png; do curl -fsSL "$RAW/$f" -o "$INSTALL/$f"; done - install -m0644 "$INSTALL/proxmox_logo.png" "$PVE_IMAGES/proxmox_logo.png" - install -m0644 "$INSTALL/logo-128.png" "$PVE_IMAGES/logo-128.png" - install -m0644 "$INSTALL/favicon.ico" "$PVE_IMAGES/favicon.ico" - [[ -e "$PVE_IMAGES/dd_logo.png" ]] && install -m0644 "$INSTALL/dd_logo.png" "$PVE_IMAGES/dd_logo.png" - [[ -e "$WTK_IMAGES/proxmox_logo.svg" ]] && install -m0644 "$INSTALL/proxmox_logo.svg" "$WTK_IMAGES/proxmox_logo.svg" + stage="$(mktemp -d)"; trap 'rm -rf "$stage"' EXIT + fetch_assets "$stage" + for f in "${ASSETS[@]}"; do install -m0644 "$stage/$f" "$INSTALL/$f"; done + apply_assets install -m0644 "$INSTALL/pve-background-1920x1080.png" "$PVE_IMAGES/webowie-background.png" - if [[ -f "$PVE_INDEX" ]]; then - sed -i '//,//d' "$PVE_INDEX" - sed -i -E 's#[^<]*#webOwie Infrastructure Control Plane · puchalla.it.com#' "$PVE_INDEX" - sed -i '/<\/head>/i\ -\ -\ -\ -' "$PVE_INDEX" - fi + brand_index install -m0755 "$0" /usr/local/sbin/webowie-pve-branding - cat >/etc/apt/apt.conf.d/99-webowie-branding <<'EOF' -DPkg::Post-Invoke { "if [ -x /usr/local/sbin/webowie-pve-branding ]; then /usr/local/sbin/webowie-pve-branding --reapply >/dev/null 2>&1 || true; fi"; }; + cat >/etc/apt/apt.conf.d/99-webowie-branding <>${LOG_FILE} 2>&1 || echo \"\$(date -Is) webowie-pve-branding --reapply failed\" >>${LOG_FILE}; fi"; }; EOF - systemctl restart pveproxy.service; log "Installed. Backup: $backup" ;; + systemctl restart pveproxy.service || die "Branding installed, but restarting pveproxy.service failed. Check 'systemctl status pveproxy.service'." + log "Installed. Backup: $backup" ;; --reapply) [[ -d "$INSTALL" ]] || die "Brand assets are not installed." - install -m0644 "$INSTALL/proxmox_logo.png" "$PVE_IMAGES/proxmox_logo.png"; install -m0644 "$INSTALL/logo-128.png" "$PVE_IMAGES/logo-128.png"; install -m0644 "$INSTALL/favicon.ico" "$PVE_IMAGES/favicon.ico" - [[ -e "$PVE_IMAGES/dd_logo.png" ]] && install -m0644 "$INSTALL/dd_logo.png" "$PVE_IMAGES/dd_logo.png" - [[ -e "$WTK_IMAGES/proxmox_logo.svg" ]] && install -m0644 "$INSTALL/proxmox_logo.svg" "$WTK_IMAGES/proxmox_logo.svg" ;; + apply_assets ;; --restore) - backup="$(cat "$BACKUP_ROOT/.last" 2>/dev/null || true)"; [[ -d "$backup" ]] || die "No backup found." - while IFS= read -r -d '' src; do dst="${src#$backup}"; mkdir -p "$(dirname "$dst")"; cp -a "$src" "$dst"; done < <(find "$backup" -type f -print0) - rm -f /etc/apt/apt.conf.d/99-webowie-branding; systemctl restart pveproxy.service; log "Restored $backup" ;; - --status) echo "webOwie PVE Branding"; echo "Asset branch: $BRANCH"; echo "Source: $RAW"; pveversion | head -1 ;; + backup="$(cat "$BACKUP_ROOT/.last" 2>/dev/null || true)" + [[ -n "$backup" ]] || die "No backup marker at $BACKUP_ROOT/.last." + [[ -d "$backup" ]] || die "Recorded backup directory is missing: $backup" + restored=0 + while IFS= read -r -d '' src; do dst="${src#"$backup"}"; mkdir -p "$(dirname "$dst")"; cp -a "$src" "$dst"; restored=$((restored+1)); done < <(find "$backup" -type f -print0) + [[ $restored -gt 0 ]] || die "Backup $backup contains no files; nothing was restored." + rm -f /etc/apt/apt.conf.d/99-webowie-branding + systemctl restart pveproxy.service || die "Files restored from $backup, but restarting pveproxy.service failed." + log "Restored $backup ($restored files)" ;; + --status) + echo "webOwie PVE Branding"; echo "Asset branch: $BRANCH"; echo "Source: $RAW" + if [[ -d "$INSTALL" ]]; then echo "Local assets: $INSTALL"; else echo "Local assets: not installed"; fi + version="$(pveversion || true)" + [[ -n "$version" ]] || die "pveversion returned no output." + printf '%s\n' "$version" | head -n1 ;; *) die "Usage: $0 [--install|--reapply|--restore|--status]" ;; esac diff --git a/scripts/build-assets.py b/scripts/build-assets.py index 39ba433..344f84c 100755 --- a/scripts/build-assets.py +++ b/scripts/build-assets.py @@ -16,19 +16,49 @@ """ from pathlib import Path from PIL import Image, ImageDraw, ImageFont -import cairosvg, io, json, shutil +import cairosvg, io, json, shutil, sys ROOT = Path(__file__).resolve().parents[1] ASSETS = ROOT / "assets" / "img" FONT_REG = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" FONT_BOLD = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" +REQUIRED_CFG_KEYS = ("brand","tagline") + +class BuildError(RuntimeError): + """Raised for actionable build failures (missing sources, invalid config).""" def fnt(size,bold=False): - return ImageFont.truetype(FONT_BOLD if bold else FONT_REG,size) + path=FONT_BOLD if bold else FONT_REG + try: + return ImageFont.truetype(path,size) + except OSError as e: + raise BuildError(f"Font not available: {path} (install fonts-dejavu-core)") from e def render_svg(path,width=1600): - data=cairosvg.svg2png(url=str(path),output_width=width) - return Image.open(io.BytesIO(data)).convert("RGBA") + if not Path(path).is_file(): + raise BuildError(f"Canonical SVG source missing: {path}") + try: + data=cairosvg.svg2png(url=str(path),output_width=width) + return Image.open(io.BytesIO(data)).convert("RGBA") + except BuildError: + raise + except Exception as e: + raise BuildError(f"Could not render SVG {path} at width {width}: {e}") from e + +def load_cfg(path,accent): + try: + cfg=json.loads(path.read_text()) + except FileNotFoundError as e: + raise BuildError(f"Brand config missing: {path}") from e + except json.JSONDecodeError as e: + raise BuildError(f"Brand config is not valid JSON: {path} ({e})") from e + if not isinstance(cfg,dict): + raise BuildError(f"Brand config must be a JSON object: {path}") + missing=[k for k in REQUIRED_CFG_KEYS if not cfg.get(k)] + if missing: + raise BuildError(f"Brand config {path} is missing required key(s): {', '.join(missing)}") + cfg["accent"]=accent + return cfg def contain(img,size,padding=0,bg=(0,0,0,0)): c=Image.new("RGBA",size,bg) @@ -48,7 +78,10 @@ def grid(size,accent): d=ImageDraw.Draw(im) for x in range(0,w,80): d.line((x,0,x,h),fill=(35,38,42,90)) for y in range(0,h,80): d.line((0,y,w,y),fill=(35,38,42,90)) - rgb=tuple(int(accent[i:i+2],16) for i in (1,3,5)) + try: + rgb=tuple(int(accent[i:i+2],16) for i in (1,3,5)) + except (ValueError,IndexError) as e: + raise BuildError(f"Accent color must be a #RRGGBB hex value, got {accent!r}") from e d.rectangle((0,0,10,h),fill=rgb+(255,)) d.rectangle((10,0,18,h),fill=rgb+(70,)) return im @@ -78,10 +111,11 @@ def banner(logo,cfg,size,label): ("bnd.zone/subbrands/cybersicherheit","bnd.zone_cybersicherheit_horizontal.svg","bnd.zone_cybersicherheit_icon.svg","#001A44"), ] -for rel,logo_name,icon_name,accent in BRANDS: +def build_brand(rel,logo_name,icon_name,accent): base=ASSETS/rel - cfg=json.loads((base/"brand.json").read_text()) - cfg["accent"]=accent + if not base.is_dir(): + raise BuildError(f"Brand directory missing: {base}") + cfg=load_cfg(base/"brand.json",accent) svgdir=base/"logos/svg" out_logo=base/"logos/png"; out_logo.mkdir(parents=True,exist_ok=True) fav=base/"favicons"; fav.mkdir(parents=True,exist_ok=True) @@ -104,23 +138,44 @@ def banner(logo,cfg,size,label): for name,size in {"og-image-1200x630.png":(1200,630),"github-social-preview-1280x640.png":(1280,640),"x-cover-1500x500.png":(1500,500),"linkedin-cover-1584x396.png":(1584,396),"facebook-cover-1640x624.png":(1640,624),"youtube-banner-2560x1440.png":(2560,1440)}.items(): banner(logo,cfg,size,"social").save(social/name) -web=ASSETS/"webOwie/corporate" -svgdir=web/"logos/svg" -pve=web/"proxmox" -pve.mkdir(parents=True,exist_ok=True) -web_logo=render_svg(svgdir/"webOwie_horizontal_transparent.svg",1800) -web_icon=render_svg(svgdir/"webOwie_icon.svg",1024) -contain(web_logo,(209,30),1,(0,0,0,0)).save(pve/"proxmox_logo.png") -contain(web_icon,(128,128),18,"#000000").save(pve/"logo-128.png") -contain(web_icon,(128,128),18,"#000000").save(pve/"dd_logo.png") -favicon(web_icon,pve/"favicon.ico") -shutil.copy2(svgdir/"webOwie_horizontal_transparent.svg",pve/"proxmox_logo.svg") -bg=grid((1920,1080),"#5DD9E8") -wm=contain(web_icon,(620,620),80,(0,0,0,0)) -wm.putalpha(wm.getchannel("A").point(lambda a:int(a*.11))) -bg.alpha_composite(wm,(1200,310)) -d=ImageDraw.Draw(bg) -d.text((90,900),"webOwie // INFRASTRUCTURE CONTROL PLANE",font=fnt(34,True),fill="#F5F5F5") -d.text((90,955),"puchalla.it.com · Powered by Proxmox VE",font=fnt(22),fill="#BFC3C7") -bg.convert("RGB").save(pve/"pve-background-1920x1080.png") -print("Generated complete brand asset sets and Proxmox exports.") +def build_proxmox_exports(): + web=ASSETS/"webOwie/corporate" + svgdir=web/"logos/svg" + pve=web/"proxmox" + pve.mkdir(parents=True,exist_ok=True) + web_logo=render_svg(svgdir/"webOwie_horizontal_transparent.svg",1800) + web_icon=render_svg(svgdir/"webOwie_icon.svg",1024) + contain(web_logo,(209,30),1,(0,0,0,0)).save(pve/"proxmox_logo.png") + contain(web_icon,(128,128),18,"#000000").save(pve/"logo-128.png") + contain(web_icon,(128,128),18,"#000000").save(pve/"dd_logo.png") + favicon(web_icon,pve/"favicon.ico") + shutil.copy2(svgdir/"webOwie_horizontal_transparent.svg",pve/"proxmox_logo.svg") + bg=grid((1920,1080),"#5DD9E8") + wm=contain(web_icon,(620,620),80,(0,0,0,0)) + wm.putalpha(wm.getchannel("A").point(lambda a:int(a*.11))) + bg.alpha_composite(wm,(1200,310)) + d=ImageDraw.Draw(bg) + d.text((90,900),"webOwie // INFRASTRUCTURE CONTROL PLANE",font=fnt(34,True),fill="#F5F5F5") + d.text((90,955),"puchalla.it.com · Powered by Proxmox VE",font=fnt(22),fill="#BFC3C7") + bg.convert("RGB").save(pve/"pve-background-1920x1080.png") + +def main(): + for rel,logo_name,icon_name,accent in BRANDS: + try: + build_brand(rel,logo_name,icon_name,accent) + except BuildError as e: + raise BuildError(f"[{rel}] {e}") from e + except Exception as e: + raise BuildError(f"[{rel}] unexpected failure: {type(e).__name__}: {e}") from e + try: + build_proxmox_exports() + except BuildError as e: + raise BuildError(f"[webOwie/corporate/proxmox] {e}") from e + print("Generated complete brand asset sets and Proxmox exports.") + +if __name__=="__main__": + try: + main() + except BuildError as e: + print(f"ERROR: asset build failed: {e}",file=sys.stderr) + sys.exit(1) diff --git a/scripts/create-netbootxyz-alpine-lxc.sh b/scripts/create-netbootxyz-alpine-lxc.sh index f1a5efa..be7bfbf 100644 --- a/scripts/create-netbootxyz-alpine-lxc.sh +++ b/scripts/create-netbootxyz-alpine-lxc.sh @@ -8,7 +8,6 @@ set -euo pipefail # ============================================================ APP="netboot.xyz" -CTID="${CTID:-$(pvesh get /cluster/nextid 2>/dev/null || echo 180)}" HOSTNAME="${HOSTNAME:-netboot-xyz}" TEMPLATE_STORAGE="${TEMPLATE_STORAGE:-local}" ROOTFS_STORAGE="${ROOTFS_STORAGE:-local-lvm}" @@ -31,16 +30,28 @@ die() { exit 1 } +warn() { + echo "WARN: $*" >&2 +} + need() { command -v "$1" >/dev/null 2>&1 || die "Missing command: $1" } +trap 'echo "ERROR: ${BASH_SOURCE[0]}: aborted with status $? at line $LINENO: ${BASH_COMMAND}" >&2' ERR + need pct need pveam need pvesh [[ "$(id -u)" -eq 0 ]] || die "Run as root on the Proxmox host." +if [[ -z "${CTID:-}" ]]; then + CTID="$(pvesh get /cluster/nextid)" \ + || die "Could not determine a free CTID via 'pvesh get /cluster/nextid'. Pass one explicitly: CTID=180 bash $0" + [[ "${CTID}" =~ ^[0-9]+$ ]] || die "Unexpected CTID from pvesh: '${CTID}'. Pass one explicitly: CTID=180 bash $0" +fi + echo "[1/8] Updating Proxmox template index..." pveam update >/dev/null @@ -103,8 +114,21 @@ if [[ "${GPU}" == "yes" ]]; then fi echo "[5/8] Starting container..." -pct start "${CTID}" -sleep 5 +pct start "${CTID}" || die "Could not start container ${CTID}. Check 'pct start ${CTID}' output and /var/log/pve." + +for _ in $(seq 1 30); do + [[ "$(pct status "${CTID}" 2>/dev/null)" == "status: running" ]] && break + sleep 1 +done +[[ "$(pct status "${CTID}" 2>/dev/null)" == "status: running" ]] \ + || die "Container ${CTID} did not reach state 'running'." + +for _ in $(seq 1 30); do + pct exec "${CTID}" -- /bin/sh -c 'exit 0' >/dev/null 2>&1 && break + sleep 1 +done +pct exec "${CTID}" -- /bin/sh -c 'exit 0' >/dev/null 2>&1 \ + || die "Container ${CTID} is running but does not accept 'pct exec' commands." cat >/tmp/netbootxyz-install-alpine.sh <<'INSTALL' #!/bin/sh @@ -113,6 +137,14 @@ set -eu WEBROOT="/var/www/html" BASE_URL="https://github.com/netbootxyz/netboot.xyz/releases/latest/download" +# Without these files the PXE server cannot serve UEFI or legacy BIOS clients. +ESSENTIAL_FILES=" +netboot.xyz.efi +netboot.xyz-snp.efi +netboot.xyz.kpxe +netboot.xyz-undionly.kpxe +" + BOOT_FILES=" netboot.xyz.efi netboot.xyz.efi.dsk @@ -158,28 +190,43 @@ apk add --no-cache \ tftp-hpa \ tftp-hpa-openrc -update-ca-certificates || true +update-ca-certificates || echo "WARN: update-ca-certificates failed; TLS downloads may fail." >&2 echo "[container] Preparing webroot..." mkdir -p "${WEBROOT}" /run/nginx cd "${WEBROOT}" echo "[container] Fetching netboot.xyz menus..." -curl -fL --retry 5 --connect-timeout 20 \ - -o menus.tar.gz \ - "${BASE_URL}/menus.tar.gz" +if ! curl -fL --retry 5 --connect-timeout 20 -o menus.tar.gz "${BASE_URL}/menus.tar.gz"; then + echo "ERROR: could not download ${BASE_URL}/menus.tar.gz" >&2 + exit 1 +fi tar -xzf menus.tar.gz rm -f menus.tar.gz echo "[container] Fetching netboot.xyz boot files..." +FAILED_FILES="" for f in ${BOOT_FILES}; do echo " -> ${f}" - curl -fL --retry 5 --connect-timeout 20 \ - -o "${f}" \ - "${BASE_URL}/${f}" || echo "WARN: could not fetch ${f}" + if ! curl -fL --retry 5 --connect-timeout 20 -o "${f}" "${BASE_URL}/${f}"; then + rm -f "${f}" + FAILED_FILES="${FAILED_FILES}${f} " + echo "WARN: could not fetch ${f}" >&2 + fi done +MISSING_ESSENTIAL="" +for f in ${ESSENTIAL_FILES}; do + [ -s "${f}" ] || MISSING_ESSENTIAL="${MISSING_ESSENTIAL}${f} " +done +if [ -n "${MISSING_ESSENTIAL}" ]; then + echo "ERROR: essential boot files could not be downloaded: ${MISSING_ESSENTIAL}" >&2 + echo "ERROR: the PXE server would be unusable, aborting." >&2 + exit 1 +fi +[ -z "${FAILED_FILES}" ] || echo "WARN: optional boot files missing: ${FAILED_FILES}" >&2 + echo "[container] Configuring nginx..." cat >/etc/nginx/http.d/default.conf <<'NGINX' server { @@ -222,6 +269,10 @@ rc-service in.tftpd restart touch /root/.netboot-xyz IP="$(ip -4 addr show eth0 | awk '/inet / {print $2}' | cut -d/ -f1 | head -n1 || true)" +if [ -z "${IP}" ]; then + IP="" + echo "WARN: no IPv4 address on eth0; check DHCP or set IPCONFIG=ip=/,gw=." >&2 +fi cat >/etc/motd <