From ac6b209c370ac90620755f714f138069eee2f5e6 Mon Sep 17 00:00:00 2001 From: anoracleofra-code Date: Thu, 26 Mar 2026 06:18:09 -0600 Subject: [PATCH] fix: Docker self-update shows pull instructions instead of silently failing The self-updater extracted files inside the container but Docker restarts from the original image, discarding all changes. Now detects Docker via /.dockerenv and returns pull commands for the user to run on their host. Co-Authored-By: Claude Opus 4.6 --- backend/main.py | 3 + backend/services/updater.py | 35 ++++++++++ frontend/src/components/TopRightControls.tsx | 71 +++++++++++++++++++- 3 files changed, 107 insertions(+), 2 deletions(-) diff --git a/backend/main.py b/backend/main.py index 8d8887c..d6a5d5e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -8135,6 +8135,9 @@ async def system_update(request: Request): status_code=500, media_type="application/json", ) + # Docker: skip restart — user must pull new images manually + if result.get("status") == "docker": + return result # Schedule restart AFTER response flushes (2s delay) threading.Timer(2.0, schedule_restart, args=[project_root]).start() return result diff --git a/backend/services/updater.py b/backend/services/updater.py index ca4de95..d0461aa 100644 --- a/backend/services/updater.py +++ b/backend/services/updater.py @@ -25,6 +25,21 @@ logger = logging.getLogger(__name__) GITHUB_RELEASES_URL = "https://api.github.com/repos/BigBodyCobain/Shadowbroker/releases/latest" GITHUB_RELEASES_PAGE_URL = "https://github.com/BigBodyCobain/Shadowbroker/releases/latest" +DOCKER_UPDATE_COMMANDS = ( + "docker compose pull && docker compose up -d" +) + + +def _is_docker() -> bool: + """Detect if we're running inside a Docker container.""" + if os.path.isfile("/.dockerenv"): + return True + try: + with open("/proc/1/cgroup", "r") as f: + return "docker" in f.read() + except (FileNotFoundError, PermissionError): + pass + return os.environ.get("container") == "docker" _EXPECTED_SHA256 = os.environ.get("MESH_UPDATE_SHA256", "").strip().lower() _ALLOWED_UPDATE_HOSTS = { "api.github.com", @@ -314,12 +329,32 @@ def perform_update(project_root: str) -> dict: Returns a dict with status info on success, or {"status": "error", "message": ...} on failure. Does NOT trigger restart — caller should call schedule_restart() separately after the HTTP response has been sent. + + In Docker, file extraction is skipped because containers run from immutable + images. Instead the response tells the frontend to show pull instructions. """ + in_docker = _is_docker() temp_dir = tempfile.mkdtemp(prefix="sb_update_") manual_url = GITHUB_RELEASES_PAGE_URL try: zip_path, version, url, release_url = _download_release(temp_dir) manual_url = release_url or manual_url + + if in_docker: + logger.info("Docker detected — skipping file extraction") + return { + "status": "docker", + "version": version, + "manual_url": manual_url, + "release_url": release_url, + "download_url": url, + "docker_commands": DOCKER_UPDATE_COMMANDS, + "message": ( + f"Version {version} is available. " + "Docker containers must be updated by pulling the new images." + ), + } + _validate_zip_hash(zip_path) backup_path = _backup_current(project_root, temp_dir) copied = _extract_and_copy(zip_path, project_root, temp_dir) diff --git a/frontend/src/components/TopRightControls.tsx b/frontend/src/components/TopRightControls.tsx index 913b6cf..b964c2c 100644 --- a/frontend/src/components/TopRightControls.tsx +++ b/frontend/src/components/TopRightControls.tsx @@ -13,6 +13,7 @@ import { X, Terminal, Server, + Copy, } from 'lucide-react'; import { API_BASE } from '@/lib/api'; import { controlPlaneFetch } from '@/lib/controlPlane'; @@ -41,7 +42,8 @@ type UpdateStatus = | 'confirming' | 'updating' | 'restarting' - | 'update_error'; + | 'update_error' + | 'docker_update'; const DEFAULT_RELEASES_URL = 'https://github.com/BigBodyCobain/Shadowbroker/releases/latest'; @@ -63,6 +65,7 @@ export default function TopRightControls({ const [latestVersion, setLatestVersion] = useState(''); const [errorMessage, setErrorMessage] = useState(''); const [manualUpdateUrl, setManualUpdateUrl] = useState(DEFAULT_RELEASES_URL); + const [dockerCommands, setDockerCommands] = useState(''); const pollRef = useRef | null>(null); const timeoutRef = useRef | null>(null); const [launcherOpen, setLauncherOpen] = useState(false); @@ -398,10 +401,16 @@ export default function TopRightControls({ message?: string; detail?: string; manual_url?: string; + docker_commands?: string; }; if (typeof data.manual_url === 'string' && data.manual_url.trim().length > 0) { setManualUpdateUrl(data.manual_url); } + if (data?.status === 'docker') { + setDockerCommands(data.docker_commands || 'docker compose pull && docker compose up -d'); + setUpdateStatus('docker_update'); + return; + } if (!res.ok || data?.ok === false || data?.status === 'error') { const message = data?.detail || data?.message || 'control_plane_request_failed'; const error = new Error(message) as Error & { manualUrl?: string }; @@ -516,6 +525,50 @@ export default function TopRightControls({ ); + // ── Docker Update Dialog ── + const renderDockerDialog = () => ( +
+
+
+ + DOCKER UPDATE — v{latestVersion} + + +
+
+

+ Docker containers must be updated by pulling new images. + Run this on your host machine: +

+
+ {dockerCommands} + +
+ + + VIEW RELEASE + +
+
+
+ ); + const nodeMode = String(nodeStatus?.node_mode || 'participant').trim().toUpperCase(); const nodeEnabled = Boolean(nodeStatus?.node_enabled); const syncOutcomeRaw = String(nodeStatus?.sync_runtime?.last_outcome || 'idle') @@ -1026,8 +1079,22 @@ export default function TopRightControls({ )} + {/* ── Docker update → show pull instructions ── */} + {updateStatus === 'docker_update' && ( + <> + + {renderDockerDialog()} + + )} + {/* ── Default states: idle / checking / uptodate / check-error ── */} - {!['available', 'confirming', 'updating', 'restarting', 'update_error'].includes( + {!['available', 'confirming', 'updating', 'restarting', 'update_error', 'docker_update'].includes( updateStatus, ) && (