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 <noreply@anthropic.com>
This commit is contained in:
anoracleofra-code
2026-03-26 06:18:09 -06:00
parent ed3da5c901
commit ac6b209c37
3 changed files with 107 additions and 2 deletions
+3
View File
@@ -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
+35
View File
@@ -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)
+69 -2
View File
@@ -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<string>('');
const [errorMessage, setErrorMessage] = useState('');
const [manualUpdateUrl, setManualUpdateUrl] = useState(DEFAULT_RELEASES_URL);
const [dockerCommands, setDockerCommands] = useState('');
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | 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({
</div>
);
// ── Docker Update Dialog ──
const renderDockerDialog = () => (
<div className="absolute top-full right-0 mt-2 w-80 z-[9999]">
<div className="bg-[var(--bg-primary)]/95 backdrop-blur-sm border border-cyan-800/60 shadow-[0_4px_30px_rgba(0,255,255,0.15)] overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 border-b border-[var(--border-primary)]">
<span className="text-[10px] font-mono tracking-widest text-cyan-400">
DOCKER UPDATE v{latestVersion}
</span>
<button
onClick={() => setUpdateStatus('idle')}
className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors"
>
<X size={12} />
</button>
</div>
<div className="p-3 flex flex-col gap-2">
<p className="text-[9px] font-mono text-[var(--text-muted)] leading-relaxed">
Docker containers must be updated by pulling new images.
Run this on your host machine:
</p>
<div className="relative bg-black/40 border border-[var(--border-primary)] p-2 group">
<code className="text-[9px] font-mono text-green-400 break-all">{dockerCommands}</code>
<button
onClick={() => navigator.clipboard.writeText(dockerCommands)}
className="absolute top-1 right-1 p-1 opacity-0 group-hover:opacity-100 transition-opacity text-[var(--text-muted)] hover:text-cyan-400"
title="Copy command"
>
<Copy size={10} />
</button>
</div>
<a
href={manualUpdateUrl}
target="_blank"
rel="noreferrer"
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-[var(--bg-secondary)]/50 border border-[var(--border-primary)] hover:border-[var(--text-muted)] transition-all text-[10px] text-[var(--text-muted)] font-mono tracking-widest"
>
<ExternalLink size={12} />
VIEW RELEASE
</a>
</div>
</div>
</div>
);
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' && (
<>
<button
onClick={() => setUpdateStatus('docker_update')}
className="flex items-center gap-1.5 px-2.5 py-1.5 bg-cyan-500/10 backdrop-blur-sm border border-cyan-500/50 text-[10px] text-cyan-400 font-mono shadow-[0_0_15px_rgba(0,255,255,0.2)]"
>
<Terminal size={12} className="w-3 h-3" />
<span className="tracking-widest">DOCKER UPDATE</span>
</button>
{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,
) && (
<button