fix: repin semver compose tags during fleet self-update (#1587)

* fix: repin semver compose tags during fleet self-update

Fleet updates failed when docker-compose.yml pinned a semver tag because recreate reused the on-disk pin. Pull the target image first, rewrite semver pins via the update helper, and block digest or unresolved pins with fast 409s.

* fix: update OFFLINE_META shape in capability and node-registry meta tests
This commit is contained in:
Anso
2026-07-07 14:40:50 -04:00
committed by GitHub
parent dbe230eef3
commit e12602091a
31 changed files with 1460 additions and 74 deletions
+8
View File
@@ -58,6 +58,10 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteR
const { prefs, updatePrefs } = useFleetPreferences();
const updateStatus = useFleetUpdateStatus();
const overview = useFleetOverview({ prefs, updatePrefs, updateStatuses: updateStatus.updateStatuses });
// The local node's status backs the confirm dialog copy (pin + target ref).
const localUpdateConfirmStatus = updateStatus.localUpdateConfirm !== null
? updateStatus.updateStatuses.find(s => s.nodeId === updateStatus.localUpdateConfirm)
: undefined;
const topology = useTopologyPreferences();
const { exporting, exportDossier } = useFleetDossierExport();
@@ -318,6 +322,10 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteR
open={updateStatus.localUpdateConfirm !== null}
onOpenChange={(open) => { if (!open) updateStatus.setLocalUpdateConfirm(null); }}
onConfirm={updateStatus.confirmLocalUpdate}
imagePinKind={localUpdateConfirmStatus?.imagePinKind}
composeImageRef={localUpdateConfirmStatus?.composeImageRef}
targetImageRef={localUpdateConfirmStatus?.targetImageRef}
targetVersion={localUpdateConfirmStatus?.latestVersion}
/>
{NodeActionModals}
@@ -1,13 +1,22 @@
import { Download } from 'lucide-react';
import { ConfirmModal } from '@/components/ui/modal';
import { formatVersion } from '@/lib/version';
import type { ImagePinKind } from './types';
interface LocalUpdateConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
imagePinKind?: ImagePinKind | null;
composeImageRef?: string | null;
targetImageRef?: string | null;
targetVersion?: string | null;
}
export function LocalUpdateConfirmDialog({ open, onOpenChange, onConfirm }: LocalUpdateConfirmDialogProps) {
export function LocalUpdateConfirmDialog({
open, onOpenChange, onConfirm, imagePinKind, composeImageRef, targetImageRef, targetVersion,
}: LocalUpdateConfirmDialogProps) {
const versionLabel = formatVersion(targetVersion) ?? 'the latest release';
return (
<ConfirmModal
open={open}
@@ -22,9 +31,16 @@ export function LocalUpdateConfirmDialog({ open, onOpenChange, onConfirm }: Loca
}
onConfirm={onConfirm}
>
<p className="text-sm text-stat-subtitle">
Pulls the latest Sencho image and restarts the server. The dashboard briefly disconnects and reconnects automatically when the update completes.
</p>
{imagePinKind === 'semver' && composeImageRef && targetImageRef ? (
<p className="text-sm text-stat-subtitle">
This install pins <code className="text-stat-value">{composeImageRef}</code>. Updating rewrites it to{' '}
<code className="text-stat-value">{targetImageRef}</code> and restarts the server. The dashboard briefly disconnects and reconnects automatically when the update completes.
</p>
) : (
<p className="text-sm text-stat-subtitle">
Pulls Sencho {versionLabel} and restarts the server. The dashboard briefly disconnects and reconnects automatically when the update completes.
</p>
)}
</ConfirmModal>
);
}
@@ -26,6 +26,7 @@ import { useLicense } from '@/context/LicenseContext';
import { useNodes, type Node } from '@/context/NodeContext';
import { cordonNode, uncordonNode } from '@/lib/nodesApi';
import { UpdateStatusBadge } from './UpdateStatusBadge';
import { PinnedUpdateBadge } from './PinnedUpdateBadge';
import { StackSection } from './NodeCardStackList';
import type { Label as StackLabel } from '../label-types';
import type { FleetNode, NodeUpdateStatus } from './types';
@@ -227,11 +228,14 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
onDismiss={isAdmin && onDismissUpdate ? () => onDismissUpdate(node.id) : undefined}
/>
)}
{updateStatus?.updateAvailable && !updateStatus.updateStatus && !updateStatus?.skipActive && (
{updateStatus?.updateAvailable && !updateStatus.updateStatus && !updateStatus?.skipActive && !updateStatus?.updateBlocked && (
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-warning/15 text-warning border-warning/30 shrink-0">
Update available
</Badge>
)}
{updateStatus?.updateBlocked && updateStatus?.updateAvailable && !updateStatus.updateStatus && !updateStatus?.skipActive && (
<PinnedUpdateBadge reason={updateStatus.updateBlockedReason} />
)}
{updateStatus?.skipActive && (
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-muted text-muted-foreground border-card-border/40 shrink-0">
Skipped
@@ -310,7 +314,7 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
)}
{/* Update button (mutating action: admin only, matches the requireAdmin route guard) */}
{isOnline && updateStatus?.updateAvailable && !updateStatus.updateStatus && !updateStatus?.skipActive && onUpdate && isAdmin && (
{isOnline && updateStatus?.updateAvailable && !updateStatus.updateStatus && !updateStatus?.skipActive && !updateStatus?.updateBlocked && onUpdate && isAdmin && (
<div className="mt-3 pt-3 border-t border-border/50">
<Button
variant="outline"
@@ -13,6 +13,7 @@ import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { formatVersion, isValidVersion } from '@/lib/version';
import { UpdateStatusBadge } from './UpdateStatusBadge';
import { PinnedUpdateBadge } from './PinnedUpdateBadge';
import type { NodeUpdateStatus } from './types';
interface NodeUpdatesSheetProps {
@@ -434,7 +435,13 @@ export function NodeUpdatesSheet({
Unskip
</Button>
)}
{s.updateAvailable && !s.updateStatus && !s.skipActive && isAdmin && (
{s.updateBlocked && s.updateAvailable && !s.updateStatus && !s.skipActive && (
<PinnedUpdateBadge
reason={s.updateBlockedReason}
className="text-[10px] px-1.5 py-0 h-5 bg-muted text-muted-foreground border-card-border/40"
/>
)}
{s.updateAvailable && !s.updateStatus && !s.skipActive && !s.updateBlocked && isAdmin && (
<Button
variant="outline"
size="sm"
@@ -460,7 +467,7 @@ export function NodeUpdatesSheet({
Skip
</Button>
)}
{s.updateAvailable && !s.updateStatus && !s.skipActive && !isAdmin && (
{s.updateAvailable && !s.updateStatus && !s.skipActive && !s.updateBlocked && !isAdmin && (
<Badge className="text-[10px] px-1.5 py-0 h-5 bg-warning/15 text-warning border-warning/30">
<CircleAlert className="w-2.5 h-2.5 mr-0.5" /> Available
</Badge>
@@ -0,0 +1,19 @@
import { Ban } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { PINNED_UPDATE_BLOCKED_FALLBACK } from './types';
interface PinnedUpdateBadgeProps {
reason?: string | null;
className?: string;
}
export function PinnedUpdateBadge({
reason,
className = 'text-[10px] px-1.5 py-0 h-4 bg-muted text-muted-foreground border-card-border/40 shrink-0',
}: PinnedUpdateBadgeProps) {
return (
<Badge className={className} title={reason ?? PINNED_UPDATE_BLOCKED_FALLBACK}>
<Ban className="w-2.5 h-2.5 mr-0.5" strokeWidth={1.5} /> Pinned
</Badge>
);
}
@@ -73,7 +73,7 @@ export function ReconnectingOverlay({ preUpdateStartedAt }: ReconnectingOverlayP
<Loader2 className="w-10 h-10 text-muted-foreground animate-spin mx-auto" strokeWidth={1.5} />
<h2 className="text-lg font-medium">Updating Sencho...</h2>
<p className="text-sm text-muted-foreground max-w-sm">
The server is pulling the latest image and restarting. This page will reload automatically.
The server is pulling the update and restarting. This page will reload automatically.
</p>
<p className="text-xs text-muted-foreground tabular-nums">{elapsed}s elapsed</p>
</>
@@ -0,0 +1,49 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
vi.mock('@/components/ui/modal', () => ({
ConfirmModal: ({ open, title, children, confirmLabel }: {
open: boolean; title: string; children: React.ReactNode; confirmLabel: React.ReactNode;
}) => open ? (
<div>
<h2>{title}</h2>
{children}
<button type="button">{confirmLabel}</button>
</div>
) : null,
}));
import { LocalUpdateConfirmDialog } from '../LocalUpdateConfirmDialog';
describe('LocalUpdateConfirmDialog', () => {
it('explains semver repinning when compose and target refs are known', () => {
render(
<LocalUpdateConfirmDialog
open
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
imagePinKind="semver"
composeImageRef="saelix/sencho:0.93.3"
targetImageRef="saelix/sencho:0.94.0"
targetVersion="0.94.0"
/>,
);
expect(screen.getByText(/rewrites it to/i)).toBeInTheDocument();
expect(screen.getByText('saelix/sencho:0.93.3')).toBeInTheDocument();
expect(screen.getByText('saelix/sencho:0.94.0')).toBeInTheDocument();
});
it('uses the generic pull copy for a floating tag', () => {
render(
<LocalUpdateConfirmDialog
open
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
imagePinKind="floating"
targetVersion="0.94.0"
/>,
);
expect(screen.getByText(/Pulls Sencho v0\.94\.0/i)).toBeInTheDocument();
expect(screen.queryByText(/rewrites it to/i)).not.toBeInTheDocument();
});
});
@@ -116,10 +116,16 @@ describe('NodeCard', () => {
expect(screen.getByRole('button', { name: /Update/ })).toBeInTheDocument();
});
it('hides the update button for a non-admin but still shows the read-only badge', () => {
useAuthMock.mockReturnValue({ isAdmin: false });
render(<NodeCard {...baseProps(onlineNode())} updateStatus={updateAvailableStatus} onUpdate={vi.fn()} />);
it('hides the update button and shows Pinned when updateBlocked', () => {
useAuthMock.mockReturnValue({ isAdmin: true });
render(
<NodeCard
{...baseProps(onlineNode())}
updateStatus={{ ...updateAvailableStatus, updateBlocked: true, updateBlockedReason: 'Digest pin.' }}
onUpdate={vi.fn()}
/>,
);
expect(screen.getByText('Pinned')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Update/ })).not.toBeInTheDocument();
expect(screen.getByText('Update available')).toBeInTheDocument();
});
});
@@ -336,6 +336,17 @@ describe('NodeUpdatesSheet', () => {
expect(toast.info).not.toHaveBeenCalled();
});
it('hides the Update button and shows a Pinned badge when updateBlocked', () => {
const blocked: NodeUpdateStatus = {
...STATUSES[1],
updateBlocked: true,
updateBlockedReason: 'Digest pin blocks automatic update.',
};
render(<NodeUpdatesSheet {...baseProps({ updateStatuses: [STATUSES[0], blocked, STATUSES[2]] })} />);
expect(screen.getByText('Pinned')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Update$/ })).not.toBeInTheDocument();
});
it('hides every mutating affordance for a non-admin but keeps the read-only table', () => {
render(<NodeUpdatesSheet {...baseProps({ isAdmin: false })} />);
// Read-only status remains visible
@@ -24,6 +24,11 @@ const STATUSES: NodeUpdateStatus[] = [
{ nodeId: 2, name: 'Edge', type: 'remote', version: '1.0.0', latestVersion: '1.1.0', updateAvailable: true, updateStatus: null },
];
const BLOCKED_STATUS: NodeUpdateStatus = {
nodeId: 3, name: 'Pinned', type: 'remote', version: '1.0.0', latestVersion: '1.1.0',
updateAvailable: true, updateStatus: null, updateBlocked: true, updateBlockedReason: 'Digest pin blocks update.',
};
beforeEach(() => {
apiFetchMock.mockReset();
toastSuccess.mockReset();
@@ -142,6 +147,78 @@ describe('useFleetUpdateStatus', () => {
expect(toastSuccess).toHaveBeenCalledWith(expect.stringContaining('2 nodes'));
});
it('triggerNodeUpdate on a blocked node toasts and does not POST', async () => {
apiFetchMock.mockResolvedValue(okJson({ nodes: [...STATUSES, BLOCKED_STATUS] }));
const { result } = renderHook(() => useFleetUpdateStatus());
await act(async () => { await result.current.fetchUpdateStatus(); });
apiFetchMock.mockClear();
await act(async () => { await result.current.triggerNodeUpdate(3); });
expect(toastError).toHaveBeenCalledWith('Digest pin blocks update.');
expect(apiFetchMock).not.toHaveBeenCalled();
});
it('confirmLocalUpdate forwards targetVersion when latestVersion is valid', async () => {
apiFetchMock.mockResolvedValue(okJson({ nodes: STATUSES }));
const { result } = renderHook(() => useFleetUpdateStatus());
await act(async () => { await result.current.fetchUpdateStatus(); });
await act(async () => { await result.current.triggerNodeUpdate(1); });
expect(result.current.localUpdateConfirm).toBe(1);
apiFetchMock.mockResolvedValue(okJson({ message: 'ok' }));
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(
new Response(JSON.stringify({ startedAt: 1000 }), { status: 200, headers: { 'Content-Type': 'application/json' } }),
)));
await act(async () => { await result.current.confirmLocalUpdate(); });
expect(apiFetchMock).toHaveBeenCalledWith(
'/fleet/nodes/1/update',
expect.objectContaining({
method: 'POST',
localOnly: true,
body: JSON.stringify({ targetVersion: '1.1.0' }),
}),
);
expect(result.current.reconnecting).toBe(true);
vi.unstubAllGlobals();
});
it('dismisses the reconnecting overlay when the local update resolves failed', async () => {
apiFetchMock.mockResolvedValue(okJson({ nodes: STATUSES }));
const { result } = renderHook(() => useFleetUpdateStatus());
await act(async () => { await result.current.fetchUpdateStatus(); });
vi.useFakeTimers();
try {
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(
new Response(JSON.stringify({ startedAt: 1000 }), { status: 200, headers: { 'Content-Type': 'application/json' } }),
)));
apiFetchMock.mockResolvedValueOnce(okJson({ message: 'ok' }));
await act(async () => { await result.current.triggerNodeUpdate(1); });
await act(async () => { await result.current.confirmLocalUpdate(); });
expect(result.current.reconnecting).toBe(true);
const failedLocal = {
...STATUSES[0],
updateStatus: 'failed' as const,
error: 'Pull failed',
};
apiFetchMock.mockResolvedValue(okJson({ nodes: [failedLocal, STATUSES[1]] }));
await act(async () => { await vi.advanceTimersByTimeAsync(3000); });
expect(result.current.reconnecting).toBe(false);
expect(toastError).toHaveBeenCalledWith('Pull failed');
} finally {
vi.useRealTimers();
vi.unstubAllGlobals();
}
});
it('checkUpdates opens the modal and toggles the checking flag', async () => {
apiFetchMock.mockResolvedValue(okJson({ nodes: STATUSES }));
const { result } = renderHook(() => useFleetUpdateStatus());
@@ -1,7 +1,30 @@
import { useState, useCallback, useRef } from 'react';
import { useState, useCallback, useRef, useEffect } from 'react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import type { NodeUpdateStatus } from '../types';
import { isValidVersion } from '@/lib/version';
import { PINNED_UPDATE_BLOCKED_FALLBACK, type NodeUpdateStatus } from '../types';
/** POST body for an update trigger: forward the target release when it is a
* valid version so the receiving node can repin a semver pin to it; omit
* otherwise so the backend falls back to its compare target. */
function updateRequestInit(status: NodeUpdateStatus | undefined): RequestInit & { localOnly: true } {
const base = { method: 'POST', localOnly: true } as const;
return isValidVersion(status?.latestVersion)
? { ...base, body: JSON.stringify({ targetVersion: status!.latestVersion }) }
: base;
}
function parseUpdateError(err: Record<string, unknown>, fallback: string): string {
const nested = err?.data as Record<string, unknown> | undefined;
const message = err?.message ?? err?.error ?? nested?.error;
return typeof message === 'string' && message ? message : fallback;
}
function toastIfUpdateBlocked(status: NodeUpdateStatus | undefined): boolean {
if (!status?.updateBlocked) return false;
toast.error(status.updateBlockedReason ?? PINNED_UPDATE_BLOCKED_FALLBACK);
return true;
}
export function useFleetUpdateStatus() {
const [updateStatuses, setUpdateStatuses] = useState<NodeUpdateStatus[]>([]);
@@ -42,6 +65,9 @@ export function useFleetUpdateStatus() {
const triggerNodeUpdate = useCallback(async (nodeId: number) => {
const status = updateStatusesRef.current.find(s => s.nodeId === nodeId);
// A pin we cannot repin (digest/unknown) has no update action; the button
// is disabled upstream, but guard here so a stale click cannot POST.
if (toastIfUpdateBlocked(status)) return;
if (status?.type === 'local') {
setLocalUpdateConfirm(nodeId);
return;
@@ -49,13 +75,13 @@ export function useFleetUpdateStatus() {
setUpdatingNodeId(nodeId);
try {
const res = await apiFetch(`/fleet/nodes/${nodeId}/update`, { method: 'POST', localOnly: true });
const res = await apiFetch(`/fleet/nodes/${nodeId}/update`, updateRequestInit(status));
if (res.ok) {
toast.success(`Update initiated on ${status?.name ?? 'node'}.`);
fetchUpdateStatus();
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.message || err?.error || err?.data?.error || 'Failed to trigger update.');
toast.error(parseUpdateError(err, 'Failed to trigger update.'));
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
@@ -68,6 +94,8 @@ export function useFleetUpdateStatus() {
const nodeId = localUpdateConfirm;
setLocalUpdateConfirm(null);
if (!nodeId) return;
const status = updateStatusesRef.current.find(s => s.nodeId === nodeId);
if (toastIfUpdateBlocked(status)) return;
setUpdatingNodeId(nodeId);
try {
@@ -82,13 +110,15 @@ export function useFleetUpdateStatus() {
}
} catch { /* fall back to offline-then-online detection */ }
const res = await apiFetch(`/fleet/nodes/${nodeId}/update`, { method: 'POST', localOnly: true });
const res = await apiFetch(`/fleet/nodes/${nodeId}/update`, updateRequestInit(status));
if (res.ok) {
setPreUpdateStartedAt(bootBefore);
setReconnecting(true);
} else {
// A blocked pin returns 409 fast (before any 202), so the overlay
// never starts here; surface the reason through the toast path.
const err = await res.json().catch(() => ({}));
toast.error(err?.message || err?.error || err?.data?.error || 'Failed to trigger local update.');
toast.error(parseUpdateError(err, 'Failed to trigger local update.'));
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
@@ -110,7 +140,7 @@ export function useFleetUpdateStatus() {
fetchUpdateStatus();
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.message || err?.error || err?.data?.error || 'Failed to trigger fleet update.');
toast.error(parseUpdateError(err, 'Failed to trigger fleet update.'));
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
@@ -137,6 +167,37 @@ export function useFleetUpdateStatus() {
setCheckingUpdates(false);
}, [fetchUpdateStatus]);
// While the reconnect overlay is up, poll the local node's update status.
// A pull/patch failure leaves the old gateway alive (no restart), so the
// overlay's health poll would sit for the full 5-minute timeout. Detecting
// the resolved `failed` status here dismisses the overlay fast and surfaces
// the error, instead of leaving the operator on the spinner. A genuine
// restart makes this endpoint unreachable (caught, keeps polling) and the
// overlay's own health poll reloads the page on success.
useEffect(() => {
if (!reconnecting) return;
const poll = setInterval(async () => {
try {
const res = await apiFetch('/fleet/update-status', { localOnly: true });
if (!res.ok) return;
const data = await res.json();
const nodes: NodeUpdateStatus[] = data.nodes ?? [];
setUpdateStatuses(prev => JSON.stringify(prev) === JSON.stringify(nodes) ? prev : nodes);
const local = nodes.find(s => s.type === 'local');
if (local && (local.updateStatus === 'failed' || local.updateStatus === 'timeout')) {
setReconnecting(false);
setPreUpdateStartedAt(null);
toast.error(local.error || 'Local update failed. The server did not restart.');
}
} catch (error) {
// Expected while the process restarts; the overlay's health poll
// drives the reload on success.
console.warn('[Fleet] Reconnect status poll failed:', error);
}
}, 3000);
return () => clearInterval(poll);
}, [reconnecting]);
return {
updateStatuses,
updatingNodeId,
@@ -31,6 +31,12 @@ export interface FleetNode {
pilot_last_seen?: number | null;
}
export type ImagePinKind = 'floating' | 'semver' | 'digest' | 'unknown';
/** Shown when the backend omits a node-specific block reason. */
export const PINNED_UPDATE_BLOCKED_FALLBACK =
'This node cannot be updated automatically while its image is pinned this way.';
export interface NodeUpdateStatus {
nodeId: number;
name: string;
@@ -42,6 +48,17 @@ export interface NodeUpdateStatus {
error?: string | null;
skipActive?: boolean;
skippedVersion?: string | null;
/** How this node's Sencho image is pinned. Present for the local node and,
* as the safe subset, for remotes that advertise it; null/absent otherwise. */
imagePinKind?: ImagePinKind | null;
/** The compose-declared image ref. Local node only (authenticated route). */
composeImageRef?: string | null;
/** The ref a semver pin will be rewritten to. Local node only. */
targetImageRef?: string | null;
/** True when the pin (digest/unknown) cannot be updated automatically. */
updateBlocked?: boolean;
/** Human-readable block reason. Local node only. */
updateBlockedReason?: string | null;
}
export type ViewMode = 'grid' | 'topology';