mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
Fix critical FTP upload failure and revert dangerous exception narrowing
The CodeQL cleanup in "Housekeeping" (2b11efd) bulk-narrowed except
clauses across 50+ files, breaking FTP uploads on ALL printer models.
ftplib.error_perm (550 errors) is not a subclass of ftplib.error_reply,
so diagnose_storage() CWD failures escaped the handler and prevented
STOR from ever executing — causing 100% upload failure and HTTP 500s
on /api/v1/archives/{id}/reprint and /api/v1/library/files/{id}/print.
FTP fixes:
- Remove diagnose_storage() from upload hot path
- Change all except (OSError, ftplib.error_reply) to
except (OSError, ftplib.Error) across bambu_ftp.py
Exception handling reverts (9 files):
- Revert narrowed except clauses back to except Exception in route
handlers and service code where broad catches are intentional
defensive programming (archive parsing, HTTP clients, 3MF/ZIP
processing, Home Assistant, firmware checks)
- Keep narrow exceptions only where safe (single-op blocks like
int(), file.unlink(), socket.close())
- Remove unused XMLParseError imports from archive.py, threemf_tools.py
Closes #287
This commit is contained in:
+113
@@ -0,0 +1,113 @@
|
||||
# Bambuddy
|
||||
|
||||
**Self-hosted print archive and management system for Bambu Lab 3D printers.**
|
||||
|
||||
No cloud dependency. Complete privacy. Full control.
|
||||
|
||||
[](https://github.com/maziggy/bambuddy)
|
||||
[](https://github.com/maziggy/bambuddy/blob/main/LICENSE)
|
||||
[](https://discord.gg/aFS3ZfScHM)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
mkdir bambuddy && cd bambuddy
|
||||
curl -O https://raw.githubusercontent.com/maziggy/bambuddy/main/docker-compose.yml
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open **http://localhost:8000** and add your printer.
|
||||
|
||||
> **Requirements:** Bambu Lab printer with Developer Mode enabled, on the same local network.
|
||||
|
||||
## Supported Architectures
|
||||
|
||||
| Architecture | Tag |
|
||||
|---|---|
|
||||
| x86-64 (Intel/AMD) | `amd64` |
|
||||
| arm64 (Raspberry Pi 4/5) | `arm64` |
|
||||
|
||||
## Features
|
||||
|
||||
- **Real-Time Monitoring** — Live printer status, camera streaming, HMS error tracking (853 codes translated), resizable multi-printer dashboard
|
||||
- **Print Archive** — Automatic 3MF archiving with metadata, interactive 3D model viewer (Three.js), photo attachments, failure analysis, side-by-side comparison
|
||||
- **Print Scheduling** — Drag-and-drop queue, multi-printer assignment by model or location, time-based scheduling, re-print with AMS mapping
|
||||
- **Smart Automation** — Smart plug control (Tasmota, Home Assistant, MQTT), auto power-on/off, energy monitoring, maintenance reminders
|
||||
- **Proxy Mode** — Print remotely from Bambu Studio/OrcaSlicer without VPN or port forwarding, end-to-end TLS encrypted
|
||||
- **Notifications** — WhatsApp, Telegram, Discord, Email, Pushover, ntfy with customizable templates and quiet hours
|
||||
- **Projects** — Group related prints, track parts and plates, bill of materials, cost tracking, export as ZIP/JSON
|
||||
- **File Manager** — Upload and organize sliced files, folder structure, print directly to any printer
|
||||
- **Integrations** — Spoolman filament sync, MQTT publishing, Prometheus metrics, Bambu Cloud profiles, REST API, Home Assistant
|
||||
- **Virtual Printer** — Appears in your slicer via SSDP discovery, multiple operating modes (archive, review, queue, proxy)
|
||||
- **Security** — Optional authentication with group-based permissions (50+ granular), JWT tokens, API key support
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `TZ` | `UTC` | Timezone (e.g. `America/New_York`, `Europe/Berlin`) |
|
||||
| `PORT` | `8000` | Web UI port |
|
||||
| `PUID` | `1000` | User ID for file permissions |
|
||||
| `PGID` | `1000` | Group ID for file permissions |
|
||||
| `DEBUG` | `false` | Enable debug logging |
|
||||
|
||||
## Volumes
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `/app/data` | Database, archived prints, thumbnails |
|
||||
| `/app/logs` | Application logs |
|
||||
|
||||
## Docker Compose
|
||||
|
||||
```yaml
|
||||
services:
|
||||
bambuddy:
|
||||
image: maziggy/bambuddy:latest
|
||||
container_name: bambuddy
|
||||
network_mode: host
|
||||
environment:
|
||||
- TZ=America/New_York
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
volumes:
|
||||
- bambuddy_data:/app/data
|
||||
- bambuddy_logs:/app/logs
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
bambuddy_data:
|
||||
bambuddy_logs:
|
||||
```
|
||||
|
||||
> **macOS/Windows:** Docker Desktop doesn't support `network_mode: host`. Replace it with `ports: ["8000:8000"]` and add printers manually by IP.
|
||||
|
||||
## Updating
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
## Supported Printers
|
||||
|
||||
| Series | Models | Status |
|
||||
|---|---|---|
|
||||
| H2 | H2D | Tested |
|
||||
| X1 | X1 Carbon, X1E | Tested |
|
||||
| P1 | P1P, P1S | Compatible |
|
||||
| P2 | P2S | Compatible |
|
||||
| A1 | A1, A1 Mini | Compatible |
|
||||
|
||||
All printers require **Developer Mode** enabled for LAN access.
|
||||
|
||||
## Links
|
||||
|
||||
- **Website:** [bambuddy.cool](https://bambuddy.cool)
|
||||
- **Documentation:** [wiki.bambuddy.cool](http://wiki.bambuddy.cool)
|
||||
- **GitHub:** [github.com/maziggy/bambuddy](https://github.com/maziggy/bambuddy)
|
||||
- **Discord:** [discord.gg/aFS3ZfScHM](https://discord.gg/aFS3ZfScHM)
|
||||
- **Issues:** [GitHub Issues](https://github.com/maziggy/bambuddy/issues)
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](https://github.com/maziggy/bambuddy/blob/main/LICENSE) for details.
|
||||
@@ -1886,7 +1886,7 @@ async def get_archive_capabilities(
|
||||
if "<vertex" in content or "<mesh" in content:
|
||||
found_mesh = True
|
||||
break
|
||||
except (KeyError, UnicodeDecodeError):
|
||||
except Exception:
|
||||
pass # Skip unreadable .model entries in archive
|
||||
|
||||
# Extract filament colors from project_settings.config
|
||||
@@ -1928,7 +1928,7 @@ async def get_archive_capabilities(
|
||||
for color in raw_colors:
|
||||
if color and isinstance(color, str):
|
||||
colors.append(color)
|
||||
except (json.JSONDecodeError, KeyError, ValueError, TypeError):
|
||||
except Exception:
|
||||
pass # Skip malformed project_settings.config
|
||||
except zipfile.BadZipFile:
|
||||
pass # File is not a valid zip/3MF archive
|
||||
@@ -1961,7 +1961,7 @@ async def get_archive_capabilities(
|
||||
if "<vertex" in content or "<mesh" in content:
|
||||
has_model = True
|
||||
break
|
||||
except (KeyError, UnicodeDecodeError):
|
||||
except Exception:
|
||||
pass # Skip unreadable .model entries in archive
|
||||
|
||||
# Extract filament colors from slice_info.config (for gcode preview)
|
||||
@@ -1995,7 +1995,7 @@ async def get_archive_capabilities(
|
||||
max_tool = max(filament_map.keys())
|
||||
for i in range(max_tool + 1):
|
||||
slice_colors.append(filament_map.get(i, "#00AE42"))
|
||||
except (KeyError, ValueError, ET.ParseError, UnicodeDecodeError):
|
||||
except Exception:
|
||||
pass # Skip malformed slice_info.config XML
|
||||
|
||||
# Use slice_info colors if we don't have colors from source yet
|
||||
@@ -2041,7 +2041,7 @@ async def get_archive_capabilities(
|
||||
for color in raw_colors:
|
||||
if color and isinstance(color, str):
|
||||
filament_colors.append(color)
|
||||
except (json.JSONDecodeError, KeyError, ValueError, TypeError):
|
||||
except Exception:
|
||||
pass # Skip malformed project_settings.config
|
||||
|
||||
except zipfile.BadZipFile:
|
||||
@@ -2130,7 +2130,7 @@ async def get_plate_preview(
|
||||
plate_elem = root.find(".//plate/metadata[@key='index']")
|
||||
if plate_elem is not None:
|
||||
plate_num = int(plate_elem.get("value", "1"))
|
||||
except (KeyError, ValueError, ET.ParseError, UnicodeDecodeError):
|
||||
except Exception:
|
||||
pass # Default plate_num=1 if slice_info is missing or malformed
|
||||
|
||||
# Try plate-specific image first, then fall back to plate_1
|
||||
@@ -2376,7 +2376,7 @@ async def get_archive_plates(
|
||||
plate_object_ids.setdefault(plater_id, [])
|
||||
if obj_id not in plate_object_ids[plater_id]:
|
||||
plate_object_ids[plater_id].append(obj_id)
|
||||
except (KeyError, ValueError, ET.ParseError, UnicodeDecodeError):
|
||||
except Exception:
|
||||
pass # model_settings.config parsing is optional
|
||||
|
||||
# Parse slice_info.config for plate metadata
|
||||
@@ -2475,7 +2475,7 @@ async def get_archive_plates(
|
||||
names.append(obj_name)
|
||||
if names:
|
||||
plate_json_objects[plate_index] = names
|
||||
except (json.JSONDecodeError, KeyError, ValueError, UnicodeDecodeError):
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Build plate list
|
||||
@@ -2512,7 +2512,7 @@ async def get_archive_plates(
|
||||
}
|
||||
)
|
||||
|
||||
except (KeyError, ValueError, zipfile.BadZipFile, ET.ParseError, UnicodeDecodeError) as e:
|
||||
except Exception as e:
|
||||
logger.warning("Failed to parse plates from archive %s: %s", archive_id, e)
|
||||
|
||||
return {
|
||||
@@ -2548,7 +2548,7 @@ async def get_plate_thumbnail(
|
||||
if thumb_path in zf.namelist():
|
||||
data = zf.read(thumb_path)
|
||||
return Response(content=data, media_type="image/png")
|
||||
except (zipfile.BadZipFile, KeyError, OSError):
|
||||
except Exception:
|
||||
pass # Fall through to 404 if archive is unreadable or thumbnail missing
|
||||
|
||||
raise HTTPException(404, f"Thumbnail for plate {plate_index} not found")
|
||||
@@ -2664,7 +2664,7 @@ async def get_filament_requirements(
|
||||
# Sort by slot ID
|
||||
filaments.sort(key=lambda x: x["slot_id"])
|
||||
|
||||
except (KeyError, ValueError, zipfile.BadZipFile, ET.ParseError, UnicodeDecodeError) as e:
|
||||
except Exception as e:
|
||||
logger.warning("Failed to parse filament requirements from archive %s: %s", archive_id, e)
|
||||
|
||||
return {
|
||||
|
||||
@@ -175,7 +175,7 @@ def extract_gcode_thumbnail(file_path: Path) -> bytes | None:
|
||||
thumbnail_lines.append(data_line)
|
||||
|
||||
return thumbnail_data
|
||||
except OSError as e:
|
||||
except Exception as e:
|
||||
logger.warning("Failed to extract gcode thumbnail: %s", e)
|
||||
return None
|
||||
|
||||
@@ -774,7 +774,7 @@ async def upload_file(
|
||||
return obj
|
||||
|
||||
metadata = clean_metadata(raw_metadata)
|
||||
except (KeyError, ValueError, zipfile.BadZipFile, OSError) as e:
|
||||
except Exception as e:
|
||||
logger.warning("Failed to parse 3MF: %s", e)
|
||||
|
||||
elif ext == ".gcode":
|
||||
@@ -787,7 +787,7 @@ async def upload_file(
|
||||
with open(thumb_path, "wb") as f:
|
||||
f.write(thumbnail_data)
|
||||
thumbnail_path = str(thumb_path)
|
||||
except OSError as e:
|
||||
except Exception as e:
|
||||
logger.warning("Failed to extract gcode thumbnail: %s", e)
|
||||
|
||||
elif ext.lower() in IMAGE_EXTENSIONS:
|
||||
@@ -867,7 +867,7 @@ async def extract_zip_file(
|
||||
content = await file.read()
|
||||
tmp.write(content)
|
||||
tmp_path = tmp.name
|
||||
except OSError as e:
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to save ZIP file: {str(e)}")
|
||||
|
||||
extracted_files: list[ZipExtractResult] = []
|
||||
@@ -1013,7 +1013,7 @@ async def extract_zip_file(
|
||||
return obj
|
||||
|
||||
metadata = clean_metadata(raw_metadata)
|
||||
except (KeyError, ValueError, zipfile.BadZipFile, OSError) as e:
|
||||
except Exception as e:
|
||||
logger.warning("Failed to parse 3MF from ZIP: %s", e)
|
||||
|
||||
elif ext == ".gcode":
|
||||
@@ -1025,7 +1025,7 @@ async def extract_zip_file(
|
||||
with open(thumb_path, "wb") as f:
|
||||
f.write(thumbnail_data)
|
||||
thumbnail_path = str(thumb_path)
|
||||
except OSError as e:
|
||||
except Exception as e:
|
||||
logger.warning("Failed to extract gcode thumbnail from ZIP: %s", e)
|
||||
|
||||
elif ext.lower() in IMAGE_EXTENSIONS:
|
||||
@@ -1423,7 +1423,7 @@ async def get_library_file_plates(
|
||||
plate_object_ids.setdefault(plater_id, [])
|
||||
if obj_id not in plate_object_ids[plater_id]:
|
||||
plate_object_ids[plater_id].append(obj_id)
|
||||
except (KeyError, ValueError, ET.ParseError, UnicodeDecodeError):
|
||||
except Exception:
|
||||
pass # model_settings.config is optional; skip if missing or malformed
|
||||
|
||||
# Parse slice_info.config for plate metadata
|
||||
@@ -1516,7 +1516,7 @@ async def get_library_file_plates(
|
||||
names.append(obj_name)
|
||||
if names:
|
||||
plate_json_objects[plate_index] = names
|
||||
except (json.JSONDecodeError, KeyError, ValueError, UnicodeDecodeError):
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Build plate list
|
||||
@@ -1553,7 +1553,7 @@ async def get_library_file_plates(
|
||||
}
|
||||
)
|
||||
|
||||
except (KeyError, ValueError, zipfile.BadZipFile, ET.ParseError, UnicodeDecodeError) as e:
|
||||
except Exception as e:
|
||||
logger.warning("Failed to parse plates from library file %s: %s", file_id, e)
|
||||
|
||||
return {
|
||||
@@ -1589,7 +1589,7 @@ async def get_library_file_plate_thumbnail(
|
||||
if thumb_path in zf.namelist():
|
||||
data = zf.read(thumb_path)
|
||||
return Response(content=data, media_type="image/png")
|
||||
except (zipfile.BadZipFile, KeyError, OSError):
|
||||
except Exception:
|
||||
pass # Archive unreadable or thumbnail missing; fall through to 404
|
||||
|
||||
raise HTTPException(status_code=404, detail=f"Thumbnail for plate {plate_index} not found")
|
||||
@@ -1711,7 +1711,7 @@ async def get_library_file_filament_requirements(
|
||||
# Sort by slot ID
|
||||
filaments.sort(key=lambda x: x["slot_id"])
|
||||
|
||||
except (KeyError, ValueError, zipfile.BadZipFile, ET.ParseError, UnicodeDecodeError) as e:
|
||||
except Exception as e:
|
||||
logger.warning("Failed to parse filament requirements from library file %s: %s", file_id, e)
|
||||
|
||||
return {
|
||||
|
||||
@@ -92,7 +92,7 @@ def _extract_filament_types_from_3mf(file_path: Path, plate_id: int | None = Non
|
||||
if used_grams > 0 and filament_type:
|
||||
types.add(filament_type)
|
||||
|
||||
except (zipfile.BadZipFile, ET.ParseError, OSError, KeyError, ValueError, UnicodeDecodeError) as e:
|
||||
except Exception as e:
|
||||
logger.warning("Failed to extract filament types from %s: %s", file_path, e)
|
||||
|
||||
return sorted(types)
|
||||
@@ -144,7 +144,7 @@ def _extract_print_time_from_3mf(file_path: Path, plate_id: int | None = None) -
|
||||
return int(meta.get("value", "0"))
|
||||
except ValueError:
|
||||
return None
|
||||
except (zipfile.BadZipFile, ET.ParseError, OSError, KeyError, ValueError, UnicodeDecodeError) as e:
|
||||
except Exception as e:
|
||||
logger.warning("Failed to extract print time from %s: %s", file_path, e)
|
||||
|
||||
return None
|
||||
|
||||
@@ -1113,7 +1113,7 @@ async def get_printer_file_plate_thumbnail(
|
||||
if thumb_path in zf.namelist():
|
||||
image_data = zf.read(thumb_path)
|
||||
return Response(content=image_data, media_type="image/png")
|
||||
except (zipfile.BadZipFile, KeyError, OSError):
|
||||
except Exception:
|
||||
pass # Corrupt or unreadable 3MF; fall through to 404
|
||||
|
||||
raise HTTPException(status_code=404, detail=f"Thumbnail for plate {plate_index} not found")
|
||||
|
||||
@@ -8,7 +8,6 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from defusedxml import ElementTree as ET
|
||||
from defusedxml.ElementTree import ParseError as XMLParseError
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -57,7 +56,7 @@ class ThreeMFParser:
|
||||
self.metadata.pop("_slice_filament_type", None)
|
||||
self.metadata.pop("_slice_filament_color", None)
|
||||
self.metadata.pop("_plate_index", None)
|
||||
except (KeyError, ValueError, zipfile.BadZipFile, XMLParseError, UnicodeDecodeError):
|
||||
except Exception:
|
||||
pass # Return whatever metadata was extracted before the error
|
||||
return self.metadata
|
||||
|
||||
@@ -152,7 +151,7 @@ class ThreeMFParser:
|
||||
self.metadata["_slice_filament_type"] = ", ".join(types)
|
||||
if colors:
|
||||
self.metadata["_slice_filament_color"] = ",".join(colors)
|
||||
except (KeyError, ValueError, XMLParseError, UnicodeDecodeError):
|
||||
except Exception:
|
||||
pass # Skip unparseable slice_info metadata
|
||||
|
||||
def _parse_project_settings(self, zf: zipfile.ZipFile):
|
||||
@@ -166,7 +165,7 @@ class ThreeMFParser:
|
||||
self._extract_print_settings(data)
|
||||
except json.JSONDecodeError:
|
||||
pass # Skip malformed project_settings JSON
|
||||
except (KeyError, ValueError, UnicodeDecodeError):
|
||||
except Exception:
|
||||
pass # Skip unreadable project settings file
|
||||
|
||||
def _parse_gcode_header(self, zf: zipfile.ZipFile):
|
||||
@@ -196,7 +195,7 @@ class ThreeMFParser:
|
||||
|
||||
raw_model = match.group(1).strip()
|
||||
self.metadata["sliced_for_model"] = normalize_printer_model(raw_model)
|
||||
except (KeyError, ValueError, UnicodeDecodeError):
|
||||
except Exception:
|
||||
pass # G-code header parsing is best-effort; metadata may come from other sources
|
||||
|
||||
def _extract_filament_info(self, data: dict):
|
||||
@@ -237,7 +236,7 @@ class ThreeMFParser:
|
||||
if non_support_colors:
|
||||
self.metadata["filament_color"] = ",".join(non_support_colors)
|
||||
|
||||
except (KeyError, ValueError, TypeError, IndexError):
|
||||
except Exception:
|
||||
pass # Filament info is optional; fall back to slice_info values
|
||||
|
||||
def _extract_print_settings(self, data: dict):
|
||||
@@ -284,7 +283,7 @@ class ThreeMFParser:
|
||||
from backend.app.utils.printer_models import normalize_printer_model
|
||||
|
||||
self.metadata["sliced_for_model"] = normalize_printer_model(data["printer_model"])
|
||||
except (KeyError, ValueError, TypeError):
|
||||
except Exception:
|
||||
pass # Print settings are optional; missing values are left unset
|
||||
|
||||
def _extract_settings_from_content(self, content: str):
|
||||
@@ -353,7 +352,7 @@ class ThreeMFParser:
|
||||
if "Title" in makerworld_fields:
|
||||
self.metadata["print_name"] = makerworld_fields["Title"]
|
||||
|
||||
except (KeyError, ValueError, UnicodeDecodeError):
|
||||
except Exception:
|
||||
pass # MakerWorld/3dmodel metadata is optional
|
||||
|
||||
def _extract_thumbnail(self, zf: zipfile.ZipFile):
|
||||
@@ -478,7 +477,7 @@ def extract_printable_objects_from_3mf(
|
||||
except ValueError:
|
||||
pass # Skip objects with non-numeric identify_id
|
||||
|
||||
except (KeyError, ValueError, zipfile.BadZipFile, XMLParseError, UnicodeDecodeError):
|
||||
except Exception:
|
||||
pass # Return empty dict if 3MF is corrupt or unreadable
|
||||
|
||||
if include_positions:
|
||||
@@ -598,7 +597,7 @@ class ProjectPageParser:
|
||||
}
|
||||
)
|
||||
|
||||
except (KeyError, ValueError, zipfile.BadZipFile, UnicodeDecodeError) as e:
|
||||
except Exception as e:
|
||||
result["_error"] = str(e)
|
||||
|
||||
return result
|
||||
@@ -623,7 +622,7 @@ class ProjectPageParser:
|
||||
}
|
||||
content_type = content_types.get(ext, "application/octet-stream")
|
||||
return (data, content_type)
|
||||
except (KeyError, zipfile.BadZipFile, OSError):
|
||||
except Exception:
|
||||
pass # Return None if image cannot be extracted from 3MF
|
||||
return None
|
||||
|
||||
@@ -684,7 +683,7 @@ class ProjectPageParser:
|
||||
shutil.move(tmp_path, self.file_path)
|
||||
return True
|
||||
|
||||
except (zipfile.BadZipFile, OSError, UnicodeDecodeError, KeyError, ValueError):
|
||||
except Exception:
|
||||
# Clean up temp file if it exists
|
||||
if "tmp_path" in locals() and tmp_path.exists():
|
||||
tmp_path.unlink()
|
||||
|
||||
@@ -171,7 +171,7 @@ class BambuFTPClient:
|
||||
logger.warning("FTP SSL error connecting to %s: %s", self.ip_address, e)
|
||||
self._ftp = None
|
||||
return False
|
||||
except (OSError, ftplib.error_reply) as e:
|
||||
except (OSError, ftplib.Error) as e:
|
||||
logger.warning("FTP connection failed to %s: %s (type: %s)", self.ip_address, e, type(e).__name__)
|
||||
self._ftp = None
|
||||
return False
|
||||
@@ -181,7 +181,7 @@ class BambuFTPClient:
|
||||
if self._ftp:
|
||||
try:
|
||||
self._ftp.quit()
|
||||
except (OSError, ftplib.error_reply):
|
||||
except (OSError, ftplib.Error):
|
||||
pass # Best-effort FTP cleanup; connection may already be closed
|
||||
self._ftp = None
|
||||
|
||||
@@ -239,7 +239,7 @@ class BambuFTPClient:
|
||||
file_entry["mtime"] = mtime
|
||||
files.append(file_entry)
|
||||
logger.debug("Listed %s files in %s", len(files), path)
|
||||
except (OSError, ftplib.error_reply) as e:
|
||||
except (OSError, ftplib.Error) as e:
|
||||
logger.info("FTP list_files failed for %s: %s", path, e)
|
||||
|
||||
return files
|
||||
@@ -253,7 +253,7 @@ class BambuFTPClient:
|
||||
buffer = BytesIO()
|
||||
self._ftp.retrbinary(f"RETR {remote_path}", buffer.write)
|
||||
return buffer.getvalue()
|
||||
except (OSError, ftplib.error_reply):
|
||||
except (OSError, ftplib.Error):
|
||||
return None
|
||||
|
||||
def download_to_file(self, remote_path: str, local_path: Path) -> bool:
|
||||
@@ -271,7 +271,7 @@ class BambuFTPClient:
|
||||
file_size = local_path.stat().st_size if local_path.exists() else 0
|
||||
logger.info("Successfully downloaded %s to %s (%s bytes)", remote_path, local_path, file_size)
|
||||
return True
|
||||
except (OSError, ftplib.error_reply) as e:
|
||||
except (OSError, ftplib.Error) as e:
|
||||
# Log at INFO level so we can see failures in normal logs
|
||||
logger.info("FTP download failed for %s: %s", remote_path, e)
|
||||
# Clean up partial file if it exists
|
||||
@@ -302,7 +302,7 @@ class BambuFTPClient:
|
||||
try:
|
||||
results["pwd"] = self._ftp.pwd()
|
||||
logger.debug("FTP current directory: %s", results["pwd"])
|
||||
except (OSError, ftplib.error_reply) as e:
|
||||
except (OSError, ftplib.Error) as e:
|
||||
results["errors"].append(f"PWD failed: {e}")
|
||||
logger.debug("FTP PWD failed: %s", e)
|
||||
|
||||
@@ -314,7 +314,7 @@ class BambuFTPClient:
|
||||
results["can_list_root"] = True
|
||||
results["root_files"] = items[:10] # First 10 entries
|
||||
logger.debug("FTP root listing (%s items): %s", len(items), items[:5])
|
||||
except (OSError, ftplib.error_reply) as e:
|
||||
except (OSError, ftplib.Error) as e:
|
||||
results["errors"].append(f"LIST / failed: {e}")
|
||||
logger.debug("FTP LIST / failed: %s", e)
|
||||
|
||||
@@ -325,7 +325,7 @@ class BambuFTPClient:
|
||||
self._ftp.retrlines("LIST", items.append)
|
||||
results["can_list_cache"] = True
|
||||
logger.debug("FTP /cache listing: %s items", len(items))
|
||||
except (OSError, ftplib.error_reply) as e:
|
||||
except (OSError, ftplib.Error) as e:
|
||||
results["errors"].append(f"LIST /cache failed: {e}")
|
||||
logger.debug("FTP LIST /cache failed: %s", e)
|
||||
|
||||
@@ -333,7 +333,7 @@ class BambuFTPClient:
|
||||
try:
|
||||
results["storage_info"] = self.get_storage_info()
|
||||
logger.debug("FTP storage info: %s", results["storage_info"])
|
||||
except (OSError, ftplib.error_reply) as e:
|
||||
except (OSError, ftplib.Error) as e:
|
||||
results["errors"].append(f"Storage info failed: {e}")
|
||||
|
||||
return results
|
||||
@@ -353,17 +353,6 @@ class BambuFTPClient:
|
||||
file_size = local_path.stat().st_size if local_path.exists() else 0
|
||||
logger.info("FTP uploading %s (%s bytes) to %s", local_path, file_size, remote_path)
|
||||
|
||||
# Run storage diagnostics before upload (debug)
|
||||
logger.debug("Running pre-upload storage diagnostics...")
|
||||
diag = self.diagnose_storage()
|
||||
logger.info(
|
||||
f"FTP storage diagnostics: can_list_root={diag['can_list_root']}, "
|
||||
f"can_list_cache={diag['can_list_cache']}, "
|
||||
f"storage={diag['storage_info']}, errors={diag['errors']}"
|
||||
)
|
||||
if diag["root_files"]:
|
||||
logger.debug("FTP root directory contents: %s", diag["root_files"])
|
||||
|
||||
uploaded = 0
|
||||
|
||||
# Use manual transfer instead of storbinary() for A1 compatibility
|
||||
@@ -414,7 +403,7 @@ class BambuFTPClient:
|
||||
elif error_code == "552":
|
||||
logger.error("FTP 552 error - Storage quota exceeded (SD card full?)")
|
||||
return False
|
||||
except (OSError, ftplib.error_reply) as e:
|
||||
except (OSError, ftplib.Error) as e:
|
||||
logger.error("FTP upload failed for %s: %s (type: %s)", remote_path, e, type(e).__name__)
|
||||
return False
|
||||
|
||||
@@ -443,7 +432,7 @@ class BambuFTPClient:
|
||||
|
||||
conn.close()
|
||||
return True
|
||||
except (OSError, ftplib.error_reply):
|
||||
except (OSError, ftplib.Error):
|
||||
return False
|
||||
|
||||
def delete_file(self, remote_path: str) -> bool:
|
||||
@@ -454,7 +443,7 @@ class BambuFTPClient:
|
||||
try:
|
||||
self._ftp.delete(remote_path)
|
||||
return True
|
||||
except (OSError, ftplib.error_reply) as e:
|
||||
except (OSError, ftplib.Error) as e:
|
||||
logger.warning("Failed to delete %s: %s", remote_path, e)
|
||||
return False
|
||||
|
||||
@@ -465,7 +454,7 @@ class BambuFTPClient:
|
||||
|
||||
try:
|
||||
return self._ftp.size(remote_path)
|
||||
except (OSError, ftplib.error_reply):
|
||||
except (OSError, ftplib.Error):
|
||||
return None
|
||||
|
||||
def get_storage_info(self) -> dict | None:
|
||||
@@ -484,13 +473,13 @@ class BambuFTPClient:
|
||||
parts = response.split()
|
||||
if len(parts) >= 2:
|
||||
result["free_bytes"] = int(parts[1])
|
||||
except (OSError, ftplib.error_reply) as e:
|
||||
except (OSError, ftplib.Error) as e:
|
||||
logger.debug("AVBL command not supported: %s", e)
|
||||
# Try STAT command as fallback
|
||||
try:
|
||||
response = self._ftp.sendcmd("STAT")
|
||||
logger.debug("STAT response: %s", response)
|
||||
except (OSError, ftplib.error_reply):
|
||||
except (OSError, ftplib.Error):
|
||||
pass # Both AVBL and STAT unsupported; storage info will rely on directory scan
|
||||
|
||||
# Calculate used space by listing root directories
|
||||
@@ -511,11 +500,11 @@ class BambuFTPClient:
|
||||
total_used += int(parts[4])
|
||||
except ValueError:
|
||||
pass # Skip entries with non-numeric size fields
|
||||
except (OSError, ftplib.error_reply):
|
||||
except (OSError, ftplib.Error):
|
||||
pass # Directory may not exist on this printer model; skip it
|
||||
|
||||
result["used_bytes"] = total_used
|
||||
except (OSError, ftplib.error_reply):
|
||||
except (OSError, ftplib.Error):
|
||||
pass # Storage scan failed; return whatever info was collected above
|
||||
|
||||
return result if result else None
|
||||
|
||||
@@ -103,7 +103,7 @@ class FirmwareCheckService:
|
||||
logger.info("Got Bambu Lab build ID: %s", self._build_id)
|
||||
return self._build_id
|
||||
logger.warning("Failed to get Bambu Lab page: %s", response.status_code)
|
||||
except (httpx.HTTPError, OSError) as e:
|
||||
except Exception as e:
|
||||
logger.error("Error fetching Bambu Lab build ID: %s", e)
|
||||
|
||||
return self._build_id # Return cached value if available
|
||||
@@ -138,7 +138,7 @@ class FirmwareCheckService:
|
||||
# api_key is a printer model identifier (e.g. "x1", "p1"), not a secret
|
||||
logger.warning("Failed to fetch firmware for %s: %s", api_key, response.status_code)
|
||||
|
||||
except (httpx.HTTPError, OSError, KeyError, ValueError) as e:
|
||||
except Exception as e:
|
||||
# api_key is a printer model identifier (e.g. "x1", "p1"), not a secret
|
||||
logger.error("Error fetching firmware for %s: %s", api_key, e)
|
||||
|
||||
@@ -359,7 +359,7 @@ class FirmwareCheckService:
|
||||
|
||||
return original_path
|
||||
|
||||
except (httpx.HTTPError, OSError) as e:
|
||||
except Exception as e:
|
||||
logger.error("Firmware download failed: %s", e)
|
||||
if temp_path.exists():
|
||||
try:
|
||||
|
||||
@@ -65,7 +65,7 @@ class HomeAssistantService:
|
||||
"reachable": True,
|
||||
"device_name": data.get("attributes", {}).get("friendly_name"),
|
||||
}
|
||||
except (httpx.HTTPError, OSError, KeyError) as e:
|
||||
except Exception as e:
|
||||
logger.warning("Failed to get HA entity state for %s: %s", plug.ha_entity_id, e)
|
||||
return {"state": None, "reachable": False, "device_name": None}
|
||||
|
||||
@@ -106,7 +106,7 @@ class HomeAssistantService:
|
||||
)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
except (httpx.HTTPError, OSError) as e:
|
||||
except Exception as e:
|
||||
logger.warning("Failed to %s HA entity %s: %s", action, plug.ha_entity_id, e)
|
||||
return False
|
||||
|
||||
@@ -166,7 +166,7 @@ class HomeAssistantService:
|
||||
"apparent_power": None,
|
||||
"reactive_power": None,
|
||||
}
|
||||
except (httpx.HTTPError, OSError, KeyError, ValueError) as e:
|
||||
except Exception as e:
|
||||
logger.debug("Failed to get HA energy data: %s", e)
|
||||
return None
|
||||
|
||||
@@ -181,7 +181,7 @@ class HomeAssistantService:
|
||||
state = response.json().get("state")
|
||||
if state and state not in ("unknown", "unavailable"):
|
||||
return float(state)
|
||||
except (httpx.HTTPError, OSError, ValueError):
|
||||
except Exception:
|
||||
pass # Sensor read is best-effort; caller handles None
|
||||
return None
|
||||
|
||||
@@ -284,7 +284,7 @@ class HomeAssistantService:
|
||||
)
|
||||
|
||||
return sorted(entities, key=lambda x: x["friendly_name"].lower())
|
||||
except (httpx.HTTPError, OSError, KeyError) as e:
|
||||
except Exception as e:
|
||||
logger.warning("Failed to list HA entities: %s", e)
|
||||
return []
|
||||
|
||||
@@ -330,7 +330,7 @@ class HomeAssistantService:
|
||||
)
|
||||
|
||||
return sorted(entities, key=lambda x: x["friendly_name"].lower())
|
||||
except (httpx.HTTPError, OSError, KeyError) as e:
|
||||
except Exception as e:
|
||||
logger.warning("Failed to list HA sensor entities: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import defusedxml.ElementTree as ET
|
||||
from defusedxml.ElementTree import ParseError as XMLParseError
|
||||
|
||||
# Default filament properties
|
||||
DEFAULT_FILAMENT_DIAMETER = 1.75 # mm
|
||||
@@ -177,7 +176,7 @@ def extract_layer_filament_usage_from_3mf(file_path: Path) -> dict[int, dict[int
|
||||
gcode_content = zf.read(gcode_path).decode("utf-8", errors="ignore")
|
||||
|
||||
return parse_gcode_layer_filament_usage(gcode_content)
|
||||
except (zipfile.BadZipFile, OSError, UnicodeDecodeError):
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@@ -259,7 +258,7 @@ def extract_filament_properties_from_3mf(file_path: Path) -> dict[int, dict]:
|
||||
properties[fid]["density"] = DEFAULT_FILAMENT_DENSITY
|
||||
except json.JSONDecodeError:
|
||||
pass # Skip malformed project_settings.config JSON
|
||||
except (zipfile.BadZipFile, OSError, KeyError, ValueError, XMLParseError, UnicodeDecodeError):
|
||||
except Exception:
|
||||
pass # Return whatever properties were collected before the error
|
||||
|
||||
return properties
|
||||
@@ -303,7 +302,7 @@ def extract_filament_usage_from_3mf(file_path: Path) -> list[dict]:
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
pass # Skip filament entries with unparseable usage values
|
||||
except (zipfile.BadZipFile, OSError, KeyError, ValueError, XMLParseError, UnicodeDecodeError):
|
||||
except Exception:
|
||||
pass # Return whatever usage data was collected before the error
|
||||
|
||||
return filament_usage
|
||||
|
||||
+120
-37
@@ -1,20 +1,25 @@
|
||||
#!/bin/bash
|
||||
# Build and push multi-architecture Docker image to GitHub Container Registry
|
||||
# Build and push multi-architecture Docker image to GitHub Container Registry AND Docker Hub
|
||||
#
|
||||
# Usage:
|
||||
# ./docker-publish.sh [version] [--parallel]
|
||||
# ./docker-publish.sh [version] [--parallel] [--ghcr-only] [--dockerhub-only]
|
||||
#
|
||||
# Examples:
|
||||
# ./docker-publish.sh 0.1.7b # Sequential build (default)
|
||||
# ./docker-publish.sh 0.1.7b --parallel # Build both archs simultaneously
|
||||
# ./docker-publish.sh 0.1.9b # Sequential build, push to both registries
|
||||
# ./docker-publish.sh 0.1.9b --parallel # Build both archs simultaneously
|
||||
# ./docker-publish.sh 0.1.9b --ghcr-only # Only push to GHCR
|
||||
# ./docker-publish.sh 0.1.9b --dockerhub-only # Only push to Docker Hub
|
||||
#
|
||||
# Note: All versions are also tagged as 'latest'
|
||||
#
|
||||
# Prerequisites:
|
||||
# 1. Log in to ghcr.io first:
|
||||
# 1. Log in to ghcr.io:
|
||||
# echo $GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin
|
||||
#
|
||||
# 2. Create a GitHub Personal Access Token with 'write:packages' scope:
|
||||
# 2. Log in to Docker Hub:
|
||||
# docker login -u YOUR_USERNAME
|
||||
#
|
||||
# 3. Create a GitHub Personal Access Token with 'write:packages' scope:
|
||||
# https://github.com/settings/tokens/new?scopes=write:packages
|
||||
#
|
||||
# Supported architectures:
|
||||
@@ -24,9 +29,11 @@
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
REGISTRY="ghcr.io"
|
||||
GHCR_REGISTRY="ghcr.io"
|
||||
DOCKERHUB_REGISTRY="docker.io"
|
||||
IMAGE_NAME="maziggy/bambuddy"
|
||||
FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}"
|
||||
GHCR_IMAGE="${GHCR_REGISTRY}/${IMAGE_NAME}"
|
||||
DOCKERHUB_IMAGE="${DOCKERHUB_REGISTRY}/${IMAGE_NAME}"
|
||||
PLATFORMS="linux/amd64,linux/arm64"
|
||||
BUILDER_NAME="bambuddy-builder"
|
||||
|
||||
@@ -40,11 +47,19 @@ NC='\033[0m' # No Color
|
||||
# Parse arguments
|
||||
VERSION=""
|
||||
PARALLEL=false
|
||||
PUSH_GHCR=true
|
||||
PUSH_DOCKERHUB=true
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--parallel)
|
||||
PARALLEL=true
|
||||
;;
|
||||
--ghcr-only)
|
||||
PUSH_DOCKERHUB=false
|
||||
;;
|
||||
--dockerhub-only)
|
||||
PUSH_GHCR=false
|
||||
;;
|
||||
*)
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION="$arg"
|
||||
@@ -54,9 +69,11 @@ for arg in "$@"; do
|
||||
done
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo -e "${YELLOW}Usage: $0 <version> [--parallel]${NC}"
|
||||
echo "Example: $0 0.1.6"
|
||||
echo " $0 0.1.6 --parallel # Build both architectures simultaneously"
|
||||
echo -e "${YELLOW}Usage: $0 <version> [--parallel] [--ghcr-only] [--dockerhub-only]${NC}"
|
||||
echo "Example: $0 0.1.9b"
|
||||
echo " $0 0.1.9b --parallel # Build both architectures simultaneously"
|
||||
echo " $0 0.1.9b --ghcr-only # Only push to GitHub Container Registry"
|
||||
echo " $0 0.1.9b --dockerhub-only # Only push to Docker Hub"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -65,7 +82,7 @@ CPU_COUNT=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)
|
||||
|
||||
echo -e "${GREEN}================================================${NC}"
|
||||
echo -e "${GREEN} Building multi-arch image${NC}"
|
||||
echo -e "${GREEN} ${FULL_IMAGE}:${VERSION}${NC}"
|
||||
echo -e "${GREEN} Version: ${VERSION}${NC}"
|
||||
echo -e "${GREEN} Platforms: ${PLATFORMS}${NC}"
|
||||
echo -e "${GREEN} CPU cores: ${CPU_COUNT}${NC}"
|
||||
if [ "$PARALLEL" = true ]; then
|
||||
@@ -73,17 +90,33 @@ if [ "$PARALLEL" = true ]; then
|
||||
else
|
||||
echo -e "${GREEN} Mode: Sequential (amd64 → arm64)${NC}"
|
||||
fi
|
||||
echo -e "${GREEN} Registries:${NC}"
|
||||
if [ "$PUSH_GHCR" = true ]; then
|
||||
echo -e "${GREEN} - ${GHCR_IMAGE}${NC}"
|
||||
fi
|
||||
if [ "$PUSH_DOCKERHUB" = true ]; then
|
||||
echo -e "${GREEN} - ${DOCKERHUB_IMAGE}${NC}"
|
||||
fi
|
||||
echo -e "${GREEN}================================================${NC}"
|
||||
echo ""
|
||||
|
||||
# Check if logged in to ghcr.io
|
||||
if ! grep -q "ghcr.io" ~/.docker/config.json 2>/dev/null; then
|
||||
echo -e "${YELLOW}Warning: You may not be logged in to ghcr.io${NC}"
|
||||
echo "Run: echo \$GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin"
|
||||
echo ""
|
||||
# Check registry logins
|
||||
if [ "$PUSH_GHCR" = true ]; then
|
||||
if ! grep -q "ghcr.io" ~/.docker/config.json 2>/dev/null; then
|
||||
echo -e "${YELLOW}Warning: You may not be logged in to ghcr.io${NC}"
|
||||
echo "Run: echo \$GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin"
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
|
||||
# Always tag as latest (in addition to version tag)
|
||||
if [ "$PUSH_DOCKERHUB" = true ]; then
|
||||
if ! grep -q "index.docker.io\|docker.io" ~/.docker/config.json 2>/dev/null; then
|
||||
echo -e "${RED}Error: You are not logged in to Docker Hub${NC}"
|
||||
echo "Run: docker login -u YOUR_USERNAME"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Setup buildx builder if not exists
|
||||
echo -e "${BLUE}[1/4] Setting up Docker Buildx...${NC}"
|
||||
@@ -110,23 +143,42 @@ if ! docker buildx inspect --bootstrap | grep -q "linux/arm64"; then
|
||||
docker run --privileged --rm tonistiigi/binfmt --install all
|
||||
fi
|
||||
|
||||
# Build tags (always include latest)
|
||||
TAGS="-t ${FULL_IMAGE}:${VERSION} -t ${FULL_IMAGE}:latest"
|
||||
echo -e "${BLUE}[3/4] Building and pushing (version + latest)...${NC}"
|
||||
# Build tags for all target registries
|
||||
TAGS=""
|
||||
if [ "$PUSH_GHCR" = true ]; then
|
||||
TAGS="$TAGS -t ${GHCR_IMAGE}:${VERSION} -t ${GHCR_IMAGE}:latest"
|
||||
fi
|
||||
if [ "$PUSH_DOCKERHUB" = true ]; then
|
||||
TAGS="$TAGS -t ${DOCKERHUB_IMAGE}:${VERSION} -t ${DOCKERHUB_IMAGE}:latest"
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}[3/4] Building and pushing...${NC}"
|
||||
|
||||
# Common build args (no cache to ensure clean builds)
|
||||
BUILD_ARGS="--provenance=false --sbom=false --no-cache --pull"
|
||||
|
||||
if [ "$PARALLEL" = true ]; then
|
||||
# Parallel build: Build each architecture separately then combine
|
||||
# Parallel build: Build each architecture separately then combine manifests
|
||||
echo -e "${YELLOW}Building amd64 and arm64 in parallel (${CPU_COUNT} cores each, no cache)...${NC}"
|
||||
|
||||
# Build per-arch staging tags for each target registry
|
||||
ARCH_TAGS_AMD64=""
|
||||
ARCH_TAGS_ARM64=""
|
||||
if [ "$PUSH_GHCR" = true ]; then
|
||||
ARCH_TAGS_AMD64="$ARCH_TAGS_AMD64 -t ${GHCR_IMAGE}:${VERSION}-amd64"
|
||||
ARCH_TAGS_ARM64="$ARCH_TAGS_ARM64 -t ${GHCR_IMAGE}:${VERSION}-arm64"
|
||||
fi
|
||||
if [ "$PUSH_DOCKERHUB" = true ]; then
|
||||
ARCH_TAGS_AMD64="$ARCH_TAGS_AMD64 -t ${DOCKERHUB_IMAGE}:${VERSION}-amd64"
|
||||
ARCH_TAGS_ARM64="$ARCH_TAGS_ARM64 -t ${DOCKERHUB_IMAGE}:${VERSION}-arm64"
|
||||
fi
|
||||
|
||||
# Build amd64 in background
|
||||
(
|
||||
echo -e "${BLUE}[amd64] Starting build...${NC}"
|
||||
docker buildx build \
|
||||
--platform linux/amd64 \
|
||||
-t "${FULL_IMAGE}:${VERSION}-amd64" \
|
||||
${ARCH_TAGS_AMD64} \
|
||||
${BUILD_ARGS} \
|
||||
--push \
|
||||
. 2>&1 | sed 's/^/[amd64] /'
|
||||
@@ -139,7 +191,7 @@ if [ "$PARALLEL" = true ]; then
|
||||
echo -e "${BLUE}[arm64] Starting build...${NC}"
|
||||
docker buildx build \
|
||||
--platform linux/arm64 \
|
||||
-t "${FULL_IMAGE}:${VERSION}-arm64" \
|
||||
${ARCH_TAGS_ARM64} \
|
||||
${BUILD_ARGS} \
|
||||
--push \
|
||||
. 2>&1 | sed 's/^/[arm64] /'
|
||||
@@ -152,13 +204,23 @@ if [ "$PARALLEL" = true ]; then
|
||||
wait $PID_AMD64
|
||||
wait $PID_ARM64
|
||||
|
||||
# Create and push multi-arch manifest (version + latest)
|
||||
echo -e "${BLUE}Creating multi-arch manifest...${NC}"
|
||||
docker buildx imagetools create \
|
||||
-t "${FULL_IMAGE}:${VERSION}" \
|
||||
-t "${FULL_IMAGE}:latest" \
|
||||
"${FULL_IMAGE}:${VERSION}-amd64" \
|
||||
"${FULL_IMAGE}:${VERSION}-arm64"
|
||||
# Create multi-arch manifests per registry (no cross-registry blob copies)
|
||||
echo -e "${BLUE}Creating multi-arch manifests...${NC}"
|
||||
|
||||
if [ "$PUSH_GHCR" = true ]; then
|
||||
echo -e "${BLUE} Creating GHCR manifest...${NC}"
|
||||
docker buildx imagetools create \
|
||||
-t "${GHCR_IMAGE}:${VERSION}" -t "${GHCR_IMAGE}:latest" \
|
||||
"${GHCR_IMAGE}:${VERSION}-amd64" \
|
||||
"${GHCR_IMAGE}:${VERSION}-arm64"
|
||||
fi
|
||||
if [ "$PUSH_DOCKERHUB" = true ]; then
|
||||
echo -e "${BLUE} Creating Docker Hub manifest...${NC}"
|
||||
docker buildx imagetools create \
|
||||
-t "${DOCKERHUB_IMAGE}:${VERSION}" -t "${DOCKERHUB_IMAGE}:latest" \
|
||||
"${DOCKERHUB_IMAGE}:${VERSION}-amd64" \
|
||||
"${DOCKERHUB_IMAGE}:${VERSION}-arm64"
|
||||
fi
|
||||
else
|
||||
# Sequential build (default): Build both platforms in one command
|
||||
echo -e "${YELLOW}Building sequentially with ${CPU_COUNT} cores (no cache)...${NC}"
|
||||
@@ -170,19 +232,40 @@ else
|
||||
.
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}[4/4] Verifying manifest...${NC}"
|
||||
docker buildx imagetools inspect "${FULL_IMAGE}:${VERSION}"
|
||||
echo -e "${BLUE}[4/4] Verifying manifests...${NC}"
|
||||
if [ "$PUSH_GHCR" = true ]; then
|
||||
echo -e "${BLUE}GHCR:${NC}"
|
||||
docker buildx imagetools inspect "${GHCR_IMAGE}:${VERSION}"
|
||||
fi
|
||||
if [ "$PUSH_DOCKERHUB" = true ]; then
|
||||
echo -e "${BLUE}Docker Hub:${NC}"
|
||||
docker buildx imagetools inspect "${DOCKERHUB_IMAGE}:${VERSION}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}================================================${NC}"
|
||||
echo -e "${GREEN}✓ Successfully pushed multi-arch image:${NC}"
|
||||
echo -e "${GREEN} Successfully pushed multi-arch image:${NC}"
|
||||
echo -e "${GREEN}================================================${NC}"
|
||||
echo " - ${FULL_IMAGE}:${VERSION}"
|
||||
echo " - ${FULL_IMAGE}:latest"
|
||||
if [ "$PUSH_GHCR" = true ]; then
|
||||
echo " GHCR:"
|
||||
echo " - ${GHCR_IMAGE}:${VERSION}"
|
||||
echo " - ${GHCR_IMAGE}:latest"
|
||||
fi
|
||||
if [ "$PUSH_DOCKERHUB" = true ]; then
|
||||
echo " Docker Hub:"
|
||||
echo " - ${DOCKERHUB_IMAGE}:${VERSION}"
|
||||
echo " - ${DOCKERHUB_IMAGE}:latest"
|
||||
fi
|
||||
echo ""
|
||||
echo -e "${BLUE}Supported platforms:${NC}"
|
||||
echo " - linux/amd64 (Intel/AMD servers, desktops)"
|
||||
echo " - linux/arm64 (Raspberry Pi 4/5, Apple Silicon)"
|
||||
echo ""
|
||||
echo -e "${GREEN}Users can now run:${NC}"
|
||||
echo " docker pull ${FULL_IMAGE}:${VERSION}"
|
||||
if [ "$PUSH_GHCR" = true ]; then
|
||||
echo " docker pull ${GHCR_IMAGE}:${VERSION}"
|
||||
fi
|
||||
if [ "$PUSH_DOCKERHUB" = true ]; then
|
||||
echo " docker pull ${DOCKERHUB_IMAGE}:${VERSION}"
|
||||
echo " docker pull ${IMAGE_NAME}:${VERSION} # shorthand"
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user