+ {manualFallbackReady
+ ? "Fleet could not confirm the upgrade outcome"
+ : "Checking whether the upgrade started"}
+
+
+ {manualFallbackReady ? (
+
+ The host updater is still unreachable. Only unlock the manual command after checking the host and
+ confirming no upgrade is running; overlapping installs can leave the deployment unusable.
+
+ ) : (
+
+ Fleet is reconciling the request with the host updater. Do not run the manual install command yet; wait
+ until this check finishes.
+
+ )}
+
+ ) : active ? (
+
+
+
+
+
+
{operation?.message || "Upgrade in progress"}
+
+
Phase: {activePhaseLabel}
+ {connectionLost ? (
+
+ Fleet is temporarily unreachable. A disconnect is expected while services restart; this page will keep
+ checking for progress.
+
+ ) : (
+
+ You can close this dialog while Fleet downloads, validates, and activates the release. Return to this
+ Updates page to check progress.
+
+ Reload Fleet to use the client bundled with {operation?.targetVersion}.
+
+
+ ) : release ? (
+
+
Confirm upgrade to {release.version}
+
+ Fleet will validate and build this exact release first, then take the instance offline for several minutes
+ while containers restart and database migrations run.
+
+ {release.prerelease ? (
+
+ This is a release candidate. The upgrade can run forward-only database migrations, and you cannot
+ downgrade this instance afterward. Continue only if you accept that recovery may require a newer
+ compatible release.
+
+ ) : null}
+
+ ) : null}
+
+ {triggerError ? (
+
+ {triggerError}
+
+ ) : null}
+
+
+ );
+};
+
+export default UpgradeOperationModal;
diff --git a/client/src/protoFleet/features/updates/api/useUpgradeOperation.test.tsx b/client/src/protoFleet/features/updates/api/useUpgradeOperation.test.tsx
new file mode 100644
index 0000000000..d191b271f8
--- /dev/null
+++ b/client/src/protoFleet/features/updates/api/useUpgradeOperation.test.tsx
@@ -0,0 +1,247 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { create } from "@bufbuild/protobuf";
+
+import { instanceUpdateClient } from "@/protoFleet/api/clients";
+import {
+ GetUpgradeStatusResponseSchema,
+ TriggerUpgradeResponseSchema,
+ type UpgradeOperation,
+ UpgradeOperationSchema,
+ UpgradePhase,
+} from "@/protoFleet/api/generated/instance/v1/updates_pb";
+import { useUpgradeOperation } from "@/protoFleet/features/updates/api/useUpgradeOperation";
+
+vi.mock("@/protoFleet/api/clients", () => ({
+ instanceUpdateClient: {
+ getUpgradeStatus: vi.fn(),
+ triggerUpgrade: vi.fn(),
+ },
+}));
+
+const mockGetUpgradeStatus = vi.mocked(instanceUpdateClient.getUpgradeStatus);
+const mockTriggerUpgrade = vi.mocked(instanceUpdateClient.triggerUpgrade);
+const TRACKED_OPERATION_KEY = "protoFleet:tracked-upgrade-operation";
+const ACKNOWLEDGED_OPERATION_KEY = "protoFleet:acknowledged-upgrade-operation";
+
+type MessageOverrides = Omit, "$typeName" | "$unknown">;
+
+const operation = (phase: UpgradePhase, overrides?: MessageOverrides) =>
+ create(UpgradeOperationSchema, {
+ id: "operation-1",
+ targetVersion: "v1.3.0",
+ phase,
+ message: "Preparing upgrade",
+ ...overrides,
+ });
+
+const status = (executorAvailable = true, currentOperation?: UpgradeOperation) =>
+ create(GetUpgradeStatusResponseSchema, {
+ executorAvailable,
+ operation: currentOperation,
+ });
+
+const deferred = () => {
+ let resolve!: (value: T) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, reject, resolve };
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ window.sessionStorage.clear();
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe("useUpgradeOperation", () => {
+ it("recovers a durable active operation without a capability gate", async () => {
+ const activeOperation = operation(UpgradePhase.PREFLIGHT);
+ mockGetUpgradeStatus.mockResolvedValue(status(true, activeOperation));
+
+ const { result } = renderHook(() => useUpgradeOperation({ enabled: true, currentVersion: "v1.2.0" }));
+
+ await waitFor(() => expect(result.current.operation?.id).toBe("operation-1"));
+ expect(result.current.connectionLost).toBe(false);
+ expect(mockGetUpgradeStatus).toHaveBeenCalledWith(
+ {},
+ expect.objectContaining({ signal: expect.any(AbortSignal), timeoutMs: 10_000 }),
+ );
+ });
+
+ it("does not overlap a pending poll and aborts it on cleanup", async () => {
+ vi.useFakeTimers();
+ const request = deferred>();
+ mockGetUpgradeStatus.mockReturnValue(request.promise);
+
+ const hook = renderHook(() => useUpgradeOperation({ enabled: true, currentVersion: "v1.2.0" }));
+ await act(async () => Promise.resolve());
+ expect(mockGetUpgradeStatus).toHaveBeenCalledTimes(1);
+
+ await act(async () => vi.advanceTimersByTimeAsync(120_000));
+ expect(mockGetUpgradeStatus).toHaveBeenCalledTimes(1);
+ const signal = mockGetUpgradeStatus.mock.calls[0]?.[1]?.signal;
+
+ hook.unmount();
+ expect(signal?.aborted).toBe(true);
+ });
+
+ it("starts the exact target and tracks the returned operation", async () => {
+ const activeOperation = operation(UpgradePhase.PREFLIGHT);
+ mockGetUpgradeStatus.mockResolvedValue(status());
+ mockTriggerUpgrade.mockResolvedValue(
+ create(TriggerUpgradeResponseSchema, {
+ operation: activeOperation,
+ }),
+ );
+ const { result } = renderHook(() => useUpgradeOperation({ enabled: true, currentVersion: "v1.2.0" }));
+
+ await waitFor(() => expect(mockGetUpgradeStatus).toHaveBeenCalled());
+ await act(async () => result.current.triggerUpgrade("v1.3.0"));
+
+ expect(mockTriggerUpgrade).toHaveBeenCalledWith({ targetVersion: "v1.3.0" }, { timeoutMs: 30_000 });
+ expect(result.current.operation?.phase).toBe(UpgradePhase.PREFLIGHT);
+ expect(JSON.parse(window.sessionStorage.getItem(TRACKED_OPERATION_KEY) ?? "{}")).toEqual({
+ id: "operation-1",
+ targetVersion: "v1.3.0",
+ });
+ });
+
+ it("reconciles an ambiguous trigger rejection to the durable operation", async () => {
+ const activeOperation = operation(UpgradePhase.PREFLIGHT);
+ mockGetUpgradeStatus.mockResolvedValueOnce(status()).mockResolvedValue(status(true, activeOperation));
+ mockTriggerUpgrade.mockRejectedValue(new Error("response lost"));
+ const { result } = renderHook(() => useUpgradeOperation({ enabled: true, currentVersion: "v1.2.0" }));
+
+ await waitFor(() => expect(mockGetUpgradeStatus).toHaveBeenCalledTimes(1));
+ await act(async () => result.current.triggerUpgrade("v1.3.0"));
+ await waitFor(() => expect(result.current.operation?.id).toBe("operation-1"));
+
+ expect(result.current.reconciling).toBe(false);
+ expect(result.current.triggerError).toBeNull();
+ });
+
+ it("ends bounded reconciliation and preserves an actionable trigger error", async () => {
+ vi.useFakeTimers();
+ mockGetUpgradeStatus.mockResolvedValue(status());
+ mockTriggerUpgrade.mockRejectedValue(new Error("host did not confirm"));
+ const { result } = renderHook(() => useUpgradeOperation({ enabled: true, currentVersion: "v1.2.0" }));
+ await act(async () => Promise.resolve());
+
+ await act(async () => result.current.triggerUpgrade("v1.3.0"));
+ expect(result.current.reconciling).toBe(true);
+
+ await act(async () => vi.advanceTimersByTimeAsync(17_000));
+
+ expect(result.current.reconciling).toBe(false);
+ expect(result.current.triggerError).toContain("host did not confirm");
+ expect(window.sessionStorage.getItem(TRACKED_OPERATION_KEY)).toBeNull();
+ });
+
+ it("does not unlock an unknown trigger outcome while the executor is unreachable", async () => {
+ vi.useFakeTimers();
+ mockGetUpgradeStatus.mockResolvedValueOnce(status()).mockResolvedValue(status(false));
+ mockTriggerUpgrade.mockRejectedValue(new Error("host did not confirm"));
+ const { result } = renderHook(() => useUpgradeOperation({ enabled: true, currentVersion: "v1.2.0" }));
+ await act(async () => Promise.resolve());
+
+ await act(async () => result.current.triggerUpgrade("v1.3.0"));
+ await act(async () => vi.advanceTimersByTimeAsync(30_000));
+
+ expect(result.current.reconciling).toBe(true);
+ expect(result.current.connectionLost).toBe(true);
+ expect(result.current.manualFallbackReady).toBe(true);
+ expect(window.sessionStorage.getItem(TRACKED_OPERATION_KEY)).not.toBeNull();
+
+ act(() => result.current.useManualFallback());
+
+ expect(result.current.reconciling).toBe(false);
+ expect(result.current.triggerError).toBeNull();
+ expect(window.sessionStorage.getItem(TRACKED_OPERATION_KEY)).toBeNull();
+ });
+
+ it("keeps progress through disconnect and accepts the terminal success", async () => {
+ vi.useFakeTimers();
+ const activeOperation = operation(UpgradePhase.ACTIVATING);
+ const succeededOperation = operation(UpgradePhase.SUCCEEDED, { message: "Upgrade complete" });
+ mockGetUpgradeStatus
+ .mockResolvedValueOnce(status(true, activeOperation))
+ .mockRejectedValueOnce(new Error("Fleet restarting"))
+ .mockResolvedValue(status(true, succeededOperation));
+ const { result } = renderHook(() => useUpgradeOperation({ enabled: true, currentVersion: "v1.2.0" }));
+ await act(async () => Promise.resolve());
+ expect(result.current.operation?.phase).toBe(UpgradePhase.ACTIVATING);
+
+ await act(async () => vi.advanceTimersByTimeAsync(2_000));
+ expect(result.current.connectionLost).toBe(true);
+ expect(result.current.operation?.phase).toBe(UpgradePhase.ACTIVATING);
+
+ await act(async () => vi.advanceTimersByTimeAsync(2_000));
+ expect(result.current.operation?.phase).toBe(UpgradePhase.SUCCEEDED);
+ expect(result.current.connectionLost).toBe(false);
+ });
+
+ it("does not replay an untracked historical success", async () => {
+ mockGetUpgradeStatus.mockResolvedValue(status(true, operation(UpgradePhase.SUCCEEDED)));
+ const { result } = renderHook(() => useUpgradeOperation({ enabled: true, currentVersion: "v1.3.0" }));
+
+ await waitFor(() => expect(mockGetUpgradeStatus).toHaveBeenCalled());
+ expect(result.current.operation).toBeUndefined();
+ });
+
+ it("suppresses a stale failure after manual recovery installed its target", async () => {
+ window.sessionStorage.setItem(
+ TRACKED_OPERATION_KEY,
+ JSON.stringify({ id: "operation-1", targetVersion: "v1.3.0" }),
+ );
+ mockGetUpgradeStatus.mockResolvedValue(status(true, operation(UpgradePhase.FAILED)));
+ const { result } = renderHook(() => useUpgradeOperation({ enabled: true, currentVersion: "v1.3.0" }));
+
+ await waitFor(() => expect(mockGetUpgradeStatus).toHaveBeenCalled());
+ expect(result.current.operation).toBeUndefined();
+ expect(window.sessionStorage.getItem(TRACKED_OPERATION_KEY)).toBeNull();
+ });
+
+ it("rechecks an unresolved failure as soon as the current version loads", async () => {
+ mockGetUpgradeStatus.mockResolvedValue(status(true, operation(UpgradePhase.FAILED)));
+ const initialProps: { currentVersion?: string } = {};
+ const { rerender, result } = renderHook(
+ ({ currentVersion }: { currentVersion?: string }) => useUpgradeOperation({ enabled: true, currentVersion }),
+ { initialProps },
+ );
+
+ await waitFor(() => expect(mockGetUpgradeStatus).toHaveBeenCalledTimes(1));
+ expect(result.current.operation).toBeUndefined();
+
+ rerender({ currentVersion: "v1.2.0" });
+
+ await waitFor(() => expect(result.current.operation?.phase).toBe(UpgradePhase.FAILED));
+ });
+
+ it("acknowledges a terminal failure so it is not replayed", async () => {
+ mockGetUpgradeStatus.mockResolvedValue(status(true, operation(UpgradePhase.FAILED)));
+ const { result } = renderHook(() => useUpgradeOperation({ enabled: true, currentVersion: "v1.2.0" }));
+ await waitFor(() => expect(result.current.operation?.phase).toBe(UpgradePhase.FAILED));
+
+ act(() => result.current.acknowledgeOperation());
+
+ expect(result.current.operation).toBeUndefined();
+ expect(window.sessionStorage.getItem(ACKNOWLEDGED_OPERATION_KEY)).toBe("operation-1");
+ });
+
+ it("forwards poll errors for page-level auth handling", async () => {
+ const onPollError = vi.fn();
+ const pollError = new Error("permission revoked");
+ mockGetUpgradeStatus.mockRejectedValue(pollError);
+
+ renderHook(() => useUpgradeOperation({ enabled: true, currentVersion: "v1.2.0", onPollError }));
+
+ await waitFor(() => expect(onPollError).toHaveBeenCalledWith(pollError));
+ });
+});
diff --git a/client/src/protoFleet/features/updates/api/useUpgradeOperation.ts b/client/src/protoFleet/features/updates/api/useUpgradeOperation.ts
new file mode 100644
index 0000000000..b1a0ddcf63
--- /dev/null
+++ b/client/src/protoFleet/features/updates/api/useUpgradeOperation.ts
@@ -0,0 +1,371 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+
+import { instanceUpdateClient } from "@/protoFleet/api/clients";
+import { type UpgradeOperation, UpgradePhase } from "@/protoFleet/api/generated/instance/v1/updates_pb";
+import { getErrorMessage } from "@/protoFleet/api/getErrorMessage";
+
+const ACTIVE_POLL_INTERVAL_MS = 2_000;
+const IDLE_POLL_INTERVAL_MS = 60_000;
+const STATUS_REQUEST_TIMEOUT_MS = 10_000;
+const TRIGGER_REQUEST_TIMEOUT_MS = 30_000;
+const TRIGGER_RECONCILIATION_TIMEOUT_MS = 15_000;
+const TRACKED_OPERATION_KEY = "protoFleet:tracked-upgrade-operation";
+const ACKNOWLEDGED_OPERATION_KEY = "protoFleet:acknowledged-upgrade-operation";
+
+interface TrackedOperation {
+ id?: string;
+ targetVersion: string;
+}
+
+interface UseUpgradeOperationOptions {
+ currentVersion?: string;
+ enabled: boolean;
+ onPollError?: (error: unknown) => void;
+}
+
+interface UseUpgradeOperationResult {
+ acknowledgeOperation: () => void;
+ connectionLost: boolean;
+ manualFallbackReady: boolean;
+ operation: UpgradeOperation | undefined;
+ reconciling: boolean;
+ reloadFleet: () => void;
+ triggerError: string | null;
+ triggering: boolean;
+ trackedTargetVersion: string | undefined;
+ triggerUpgrade: (targetVersion: string) => Promise;
+ useManualFallback: () => void;
+}
+
+export const isUpgradeTerminal = (phase: UpgradePhase) =>
+ phase === UpgradePhase.SUCCEEDED || phase === UpgradePhase.FAILED;
+
+export const isUpgradeActive = (operation?: UpgradeOperation) =>
+ Boolean(operation && operation.phase !== UpgradePhase.UNSPECIFIED && !isUpgradeTerminal(operation.phase));
+
+const readTrackedOperation = (): TrackedOperation | undefined => {
+ try {
+ const raw = window.sessionStorage.getItem(TRACKED_OPERATION_KEY);
+ if (!raw) return undefined;
+ const value = JSON.parse(raw) as Partial;
+ if (typeof value.targetVersion !== "string" || !value.targetVersion) {
+ window.sessionStorage.removeItem(TRACKED_OPERATION_KEY);
+ return undefined;
+ }
+ return {
+ targetVersion: value.targetVersion,
+ ...(typeof value.id === "string" && value.id ? { id: value.id } : {}),
+ };
+ } catch {
+ return undefined;
+ }
+};
+
+const readAcknowledgedOperation = (): string | null => {
+ try {
+ return window.sessionStorage.getItem(ACKNOWLEDGED_OPERATION_KEY);
+ } catch {
+ return null;
+ }
+};
+
+export function useUpgradeOperation({
+ currentVersion,
+ enabled,
+ onPollError,
+}: UseUpgradeOperationOptions): UseUpgradeOperationResult {
+ const [operation, setOperation] = useState();
+ const [triggering, setTriggering] = useState(false);
+ const [trackedOperation, setTrackedOperation] = useState(readTrackedOperation);
+ const recoveredUnknownOutcome = Boolean(trackedOperation && !trackedOperation.id);
+ const [reconciling, setReconciling] = useState(recoveredUnknownOutcome);
+ const [connectionLost, setConnectionLost] = useState(false);
+ const [manualFallbackReady, setManualFallbackReady] = useState(false);
+ const [triggerError, setTriggerError] = useState(
+ recoveredUnknownOutcome ? "Fleet did not confirm whether the previous upgrade request started" : null,
+ );
+ const [pollRevision, setPollRevision] = useState(0);
+ const [acknowledgedOperation, setAcknowledgedOperation] = useState(readAcknowledgedOperation);
+
+ const operationRef = useRef(operation);
+ const trackedOperationRef = useRef(trackedOperation);
+ const acknowledgedOperationRef = useRef(acknowledgedOperation);
+ const reconcilingRef = useRef(reconciling);
+ const currentVersionRef = useRef(currentVersion);
+ const onPollErrorRef = useRef(onPollError);
+ const reconciliationDeadlineRef = useRef(null);
+
+ currentVersionRef.current = currentVersion;
+ onPollErrorRef.current = onPollError;
+
+ useEffect(() => {
+ if (trackedOperationRef.current && !trackedOperationRef.current.id) {
+ reconciliationDeadlineRef.current = Date.now() + TRIGGER_RECONCILIATION_TIMEOUT_MS;
+ }
+ }, []);
+
+ const updateOperation = useCallback((next: UpgradeOperation | undefined) => {
+ operationRef.current = next;
+ setOperation(next);
+ }, []);
+
+ const updateReconciling = useCallback((next: boolean) => {
+ reconcilingRef.current = next;
+ setReconciling(next);
+ }, []);
+
+ const updateTrackedOperation = useCallback((next: TrackedOperation | undefined) => {
+ trackedOperationRef.current = next;
+ setTrackedOperation(next);
+ try {
+ if (next) {
+ window.sessionStorage.setItem(TRACKED_OPERATION_KEY, JSON.stringify(next));
+ } else {
+ window.sessionStorage.removeItem(TRACKED_OPERATION_KEY);
+ }
+ } catch {
+ // Browser storage is an optimization for route/reload recovery. The
+ // host operation remains durable when storage is unavailable.
+ }
+ }, []);
+
+ const updateAcknowledgedOperation = useCallback((next: string | null) => {
+ acknowledgedOperationRef.current = next;
+ setAcknowledgedOperation(next);
+ try {
+ if (next) {
+ window.sessionStorage.setItem(ACKNOWLEDGED_OPERATION_KEY, next);
+ } else {
+ window.sessionStorage.removeItem(ACKNOWLEDGED_OPERATION_KEY);
+ }
+ } catch {
+ // See updateTrackedOperation: storage failure cannot affect host state.
+ }
+ }, []);
+
+ const finishExpiredReconciliation = useCallback(() => {
+ const deadline = reconciliationDeadlineRef.current;
+ if (!reconcilingRef.current || deadline === null || Date.now() < deadline) {
+ return false;
+ }
+ reconciliationDeadlineRef.current = null;
+ setManualFallbackReady(false);
+ updateReconciling(false);
+ if (!trackedOperationRef.current?.id) {
+ updateTrackedOperation(undefined);
+ }
+ return true;
+ }, [updateReconciling, updateTrackedOperation]);
+
+ const allowManualFallbackAfterTimeout = useCallback(() => {
+ const deadline = reconciliationDeadlineRef.current;
+ if (reconcilingRef.current && deadline !== null && Date.now() >= deadline) {
+ setManualFallbackReady(true);
+ }
+ }, []);
+
+ const acceptServerOperation = useCallback(
+ (next: UpgradeOperation) => {
+ const active = isUpgradeActive(next);
+ const tracked = trackedOperationRef.current;
+ const trackedMatches = tracked?.id ? tracked.id === next.id : tracked?.targetVersion === next.targetVersion;
+
+ if (active) {
+ // A durable active operation is authoritative, including one started
+ // by another operator. It takes precedence over a newer release offer.
+ updateAcknowledgedOperation(null);
+ updateTrackedOperation({ id: next.id, targetVersion: next.targetVersion });
+ updateOperation(next);
+ reconciliationDeadlineRef.current = null;
+ setManualFallbackReady(false);
+ updateReconciling(false);
+ setConnectionLost(false);
+ setTriggerError(null);
+ return true;
+ }
+
+ if (!isUpgradeTerminal(next.phase)) {
+ return false;
+ }
+
+ if (acknowledgedOperationRef.current === next.id) {
+ return false;
+ }
+
+ // A manual recovery may have installed the failed target successfully.
+ // Do not replay the updater's stale failure once Fleet reports that exact
+ // version as current.
+ if (next.phase === UpgradePhase.FAILED && currentVersionRef.current === next.targetVersion) {
+ if (trackedMatches) updateTrackedOperation(undefined);
+ if (operationRef.current?.id === next.id) updateOperation(undefined);
+ reconciliationDeadlineRef.current = null;
+ setManualFallbackReady(false);
+ updateReconciling(false);
+ setConnectionLost(false);
+ return false;
+ }
+
+ const unresolvedFailure =
+ next.phase === UpgradePhase.FAILED &&
+ Boolean(currentVersionRef.current && currentVersionRef.current !== next.targetVersion);
+ if (!trackedMatches && !unresolvedFailure) {
+ return false;
+ }
+
+ updateTrackedOperation({ id: next.id, targetVersion: next.targetVersion });
+ updateOperation(next);
+ reconciliationDeadlineRef.current = null;
+ setManualFallbackReady(false);
+ updateReconciling(false);
+ setConnectionLost(false);
+ setTriggerError(null);
+ return true;
+ },
+ [updateAcknowledgedOperation, updateOperation, updateReconciling, updateTrackedOperation],
+ );
+
+ const pollStatus = useCallback(
+ async (signal: AbortSignal) => {
+ try {
+ const response = await instanceUpdateClient.getUpgradeStatus(
+ {},
+ { signal, timeoutMs: STATUS_REQUEST_TIMEOUT_MS },
+ );
+ if (signal.aborted) return;
+
+ if (response.operation && acceptServerOperation(response.operation)) {
+ return;
+ }
+
+ if (!response.executorAvailable) {
+ if (isUpgradeActive(operationRef.current) || reconcilingRef.current || trackedOperationRef.current) {
+ setConnectionLost(true);
+ }
+ allowManualFallbackAfterTimeout();
+ return;
+ }
+
+ if (isUpgradeActive(operationRef.current)) {
+ // The executor is reachable but lost the operation that Fleet was
+ // following. Preserve the last durable phase rather than exposing a
+ // competing action while reconciliation continues.
+ setConnectionLost(true);
+ } else {
+ setConnectionLost(false);
+ }
+ finishExpiredReconciliation();
+ } catch (error) {
+ if (signal.aborted) return;
+ onPollErrorRef.current?.(error);
+ if (isUpgradeActive(operationRef.current) || reconcilingRef.current || trackedOperationRef.current) {
+ setConnectionLost(true);
+ }
+ allowManualFallbackAfterTimeout();
+ }
+ },
+ [acceptServerOperation, allowManualFallbackAfterTimeout, finishExpiredReconciliation],
+ );
+
+ useEffect(() => {
+ if (!enabled) return;
+
+ let alive = true;
+ let timer: number | undefined;
+ let controller: AbortController | undefined;
+
+ const run = async () => {
+ controller = new AbortController();
+ await pollStatus(controller.signal);
+ if (alive) {
+ const awaitingTrackedOperation = Boolean(trackedOperationRef.current && !operationRef.current);
+ const pollIntervalMs =
+ isUpgradeActive(operationRef.current) || reconcilingRef.current || awaitingTrackedOperation
+ ? ACTIVE_POLL_INTERVAL_MS
+ : IDLE_POLL_INTERVAL_MS;
+ timer = window.setTimeout(run, pollIntervalMs);
+ }
+ };
+
+ void run();
+ return () => {
+ alive = false;
+ controller?.abort();
+ if (timer !== undefined) window.clearTimeout(timer);
+ };
+ }, [currentVersion, enabled, pollRevision, pollStatus]);
+
+ const triggerUpgrade = useCallback(
+ async (targetVersion: string) => {
+ updateTrackedOperation({ targetVersion });
+ reconciliationDeadlineRef.current = null;
+ setManualFallbackReady(false);
+ updateReconciling(false);
+ setConnectionLost(false);
+ setTriggerError(null);
+ setTriggering(true);
+ try {
+ const response = await instanceUpdateClient.triggerUpgrade(
+ { targetVersion },
+ { timeoutMs: TRIGGER_REQUEST_TIMEOUT_MS },
+ );
+ if (!response.operation) {
+ throw new Error("Host updater did not return an operation");
+ }
+ acceptServerOperation(response.operation);
+ } catch (error) {
+ setTriggerError(getErrorMessage(error, "Failed to start upgrade"));
+ reconciliationDeadlineRef.current = Date.now() + TRIGGER_RECONCILIATION_TIMEOUT_MS;
+ updateReconciling(true);
+ } finally {
+ setTriggering(false);
+ // An idle status poll may already be scheduled far in the future.
+ // Wake it after trigger admission settles so success and ambiguous
+ // responses both transition immediately to the active cadence.
+ setPollRevision((revision) => revision + 1);
+ }
+ },
+ [acceptServerOperation, updateReconciling, updateTrackedOperation],
+ );
+
+ const acknowledgeOperation = useCallback(() => {
+ if (operationRef.current && isUpgradeTerminal(operationRef.current.phase)) {
+ updateAcknowledgedOperation(operationRef.current.id);
+ }
+ updateTrackedOperation(undefined);
+ updateOperation(undefined);
+ reconciliationDeadlineRef.current = null;
+ setManualFallbackReady(false);
+ updateReconciling(false);
+ setConnectionLost(false);
+ setTriggerError(null);
+ }, [updateAcknowledgedOperation, updateOperation, updateReconciling, updateTrackedOperation]);
+
+ const useManualFallback = useCallback(() => {
+ if (!manualFallbackReady) return;
+ updateTrackedOperation(undefined);
+ reconciliationDeadlineRef.current = null;
+ setManualFallbackReady(false);
+ updateReconciling(false);
+ setConnectionLost(false);
+ setTriggerError(null);
+ }, [manualFallbackReady, updateReconciling, updateTrackedOperation]);
+
+ const reloadFleet = useCallback(() => {
+ updateTrackedOperation(undefined);
+ window.location.reload();
+ }, [updateTrackedOperation]);
+
+ return {
+ acknowledgeOperation,
+ connectionLost,
+ manualFallbackReady,
+ operation,
+ reconciling,
+ reloadFleet,
+ triggerError,
+ triggering,
+ trackedTargetVersion: trackedOperation?.targetVersion,
+ triggerUpgrade,
+ useManualFallback,
+ };
+}
diff --git a/client/src/protoFleet/features/updates/copyInstallCommand.ts b/client/src/protoFleet/features/updates/copyInstallCommand.ts
index 362bffc2bd..1292456180 100644
--- a/client/src/protoFleet/features/updates/copyInstallCommand.ts
+++ b/client/src/protoFleet/features/updates/copyInstallCommand.ts
@@ -1,8 +1,8 @@
import { pushToast, STATUSES } from "@/shared/features/toaster";
import { copyToClipboard } from "@/shared/utils/utility";
-// Shared by the update notification modal and the settings Updates page so
-// both surfaces give identical feedback for the same action.
+// Keep the manual fallback's clipboard feedback consistent wherever the
+// Settings update flow renders it.
export const copyInstallCommand = (installCommand: string) => {
copyToClipboard(installCommand)
.then(() => {
diff --git a/docs/plans/archive/2026-07-29-one-click-upgrade-executor-plan.md b/docs/plans/archive/2026-07-29-one-click-upgrade-executor-plan.md
new file mode 100644
index 0000000000..6f84ba91c5
--- /dev/null
+++ b/docs/plans/archive/2026-07-29-one-click-upgrade-executor-plan.md
@@ -0,0 +1,115 @@
+---
+title: One-Click Upgrade Host Executor - Plan
+date: 2026-07-29
+status: completed
+type: plan
+topic: one-click-upgrade
+artifact_contract: ce-unified-plan/v1
+artifact_readiness: implementation-ready
+---
+
+# One-Click Upgrade Host Executor
+
+## Outcome
+
+Proto Fleet now lets an authorized operator confirm an eligible upgrade in the
+Updates settings route. A privileged process outside the Compose stack
+validates and activates the release, while the client follows durable progress
+through the expected Fleet restart. Hosts without a reachable executor retain
+the release-specific manual install command as the authoritative fallback.
+
+## Final architecture
+
+### Client ownership
+
+- The normal shell owns only a passive version pill. It performs lightweight
+ release discovery and routes the operator to `/settings/updates`; it does not
+ retain installer commands or own upgrade progress globally.
+- The Updates route fetches authoritative status before exposing actions. It
+ shows one-click controls only when Fleet reports a reachable host executor,
+ and otherwise keeps the manual command path intact.
+- Confirmation names the exact target and explains the restart window. Release
+ candidates add the forward-only migration and no-downgrade warning.
+- Confirmation, progress, reconnect, terminal error, recovery, and success are
+ route-owned states. Leaving the route stops only browser polling; it does not
+ cancel the durable host operation. Returning to the route recovers current
+ state through the upgrade-status RPC.
+- An ambiguous trigger keeps conflicting controls locked while the executor is
+ unreachable. After a bounded wait, the manual path can be unlocked only by
+ explicitly confirming on-host that no upgrade is still running.
+
+### Privilege and execution boundary
+
+- `fleetd` re-checks `instance:update`, the selected release channel, and the
+ currently eligible target. The browser cannot provide a download URL,
+ command, downgrade, or arbitrary release tag.
+- The root systemd service runs outside the Compose stack it restarts. Fleet
+ reaches its narrow HTTP API through
+ `/run/proto-fleet-updater/updater.sock`; the application container never
+ receives the host Docker socket.
+- Before teardown, the updater downloads and verifies the release, safely
+ extracts it, preserves deployment configuration, builds release-specific
+ images, and completes preflight. Activation revalidates the staged manifest
+ and image identities before switching the deployment.
+- The supported topology is one Proto Fleet installation per host on Linux
+ with systemd and rootful Docker, including WSL distributions configured with
+ systemd. macOS, rootless Docker, Linux without systemd, and alternate
+ multi-install layouts use the manual flow.
+
+### Trust and recovery boundary
+
+- The SHA-256 sidecar detects corruption and binds the expected asset name to
+ its digest. The bundle and sidecar share a GitHub Release origin, so GitHub
+ remains the publisher trust anchor; independent release signing is outside
+ this phase.
+- Runtime configuration (`.env`, TLS material, Influx configuration, and
+ persisted optional-overlay settings) is carried into the staged deployment.
+- Updater state and per-operation logs survive Fleet restarts at
+ `/var/lib/proto-fleet-updater/state.json` and
+ `/var/lib/proto-fleet-updater/logs/.log`.
+- A terminal failure exposes the host log path and an explicit recovery
+ command. The previous deployment remains at
+ `/deployment.previous` for inspection, but automatic binary
+ rollback is disabled because migrations are forward-only.
+
+## End-to-end flow
+
+```mermaid
+sequenceDiagram
+ participant U as "Authorized operator"
+ participant S as "Fleet shell"
+ participant R as "Updates route"
+ participant F as "fleetd"
+ participant X as "Host updater (systemd)"
+ participant G as "GitHub Releases"
+
+ S->>F: "Discover eligible version"
+ F-->>S: "Version-only indicator data"
+ U->>S: "Select update pill"
+ S->>R: "Navigate to /settings/updates"
+ R->>F: "GetUpdateStatus"
+ F-->>R: "Eligible release, capability, manual command"
+ U->>R: "Confirm exact target"
+ R->>F: "TriggerUpgrade(target version)"
+ F->>F: "Re-check permission, channel, and eligibility"
+ F->>X: "Start operation over Unix socket"
+ X->>G: "Download bundle and checksum"
+ X->>X: "Verify, stage, preflight, and activate"
+ R-->>F: "Poll durable status; restart disconnect is expected"
+ opt "Operator leaves and later returns"
+ U->>R: "Open /settings/updates"
+ R->>F: "GetUpgradeStatus"
+ end
+ F-->>R: "Active or terminal durable operation"
+ R-->>U: "Progress, reload, or recovery guidance"
+```
+
+## Completed implementation units
+
+1. Durable single-flight updater manager and Unix-socket API.
+2. Packaged updater binary, hardened systemd service, and installer lifecycle.
+3. Non-interactive preflight and activation with immutable staging checks.
+4. Release checksum publication and bounded download/extraction validation.
+5. Permission-gated trigger/status RPCs with server-side target validation.
+6. Passive shell discovery plus route-owned confirmation and operation states.
+7. Focused host, API, installer, and client lifecycle tests.
From 789da4caf243f2726a788d66f213c2a91f6c1e16 Mon Sep 17 00:00:00 2001
From: Marvin Charles
Date: Sat, 8 Aug 2026 09:23:59 +0300
Subject: [PATCH 02/13] fix(updates): harden upgrade recovery state
---
.../settings/components/Updates.test.tsx | 29 ++++-
.../features/settings/components/Updates.tsx | 27 +++-
.../components/UpgradeOperationModal.test.tsx | 2 +-
.../components/UpgradeOperationModal.tsx | 8 +-
.../updates/api/useUpgradeOperation.test.tsx | 120 +++++++++++++++---
.../updates/api/useUpgradeOperation.ts | 73 ++++++++++-
6 files changed, 218 insertions(+), 41 deletions(-)
diff --git a/client/src/protoFleet/features/settings/components/Updates.test.tsx b/client/src/protoFleet/features/settings/components/Updates.test.tsx
index d84627f967..9952e7583e 100644
--- a/client/src/protoFleet/features/settings/components/Updates.test.tsx
+++ b/client/src/protoFleet/features/settings/components/Updates.test.tsx
@@ -29,6 +29,7 @@ const permissionsMock = vi.hoisted(() => ({
isAuthenticated: true,
sessionExpiry: new Date(1_000),
setPermissions: vi.fn<(permissions: string[]) => void>(),
+ username: "operator-a",
}));
const authErrorsMock = vi.hoisted(() => ({
handleAuthErrors: vi.fn(),
@@ -63,7 +64,9 @@ vi.mock("@/protoFleet/store", () => {
return {
useHasPermission: vi.fn((permission: string) => permissionsMock.current.includes(permission)),
usePermissions: () => permissionsMock.current,
+ useSessionExpiry: () => permissionsMock.sessionExpiry,
useSetPermissions: () => permissionsMock.setPermissions,
+ useUsername: () => permissionsMock.username,
useAuthErrors: () => authErrorsMock,
useFleetStore: {
getState: () => ({
@@ -84,11 +87,13 @@ vi.mock("@/protoFleet/api/clients", () => ({
},
}));
-vi.mock("@/protoFleet/features/updates/api/useUpgradeOperation", () => ({
- isUpgradeActive: (operation?: { phase: number }) =>
- Boolean(operation && operation.phase !== 0 && operation.phase !== 7 && operation.phase !== 8),
- useUpgradeOperation: vi.fn(() => upgradeHookMock.current),
-}));
+vi.mock("@/protoFleet/features/updates/api/useUpgradeOperation", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useUpgradeOperation: vi.fn(() => upgradeHookMock.current),
+ };
+});
vi.mock("@/shared/utils/utility", () => ({
copyToClipboard: vi.fn(),
@@ -178,6 +183,7 @@ beforeEach(() => {
permissionsMock.current = ["instance:update", "fleet:read"];
permissionsMock.isAuthenticated = true;
permissionsMock.sessionExpiry = new Date(1_000);
+ permissionsMock.username = "operator-a";
permissionsMock.setPermissions.mockImplementation((permissions) => {
permissionsMock.current = permissions;
});
@@ -287,7 +293,18 @@ describe("Updates", () => {
const page = render();
- expect(await page.findByText(/checking whether the upgrade started/i)).toBeInTheDocument();
+ expect(await page.findByText(/checking upgrade status/i)).toBeInTheDocument();
+ expect(page.getByRole("button", { name: "Copy install command" })).toBeDisabled();
+ expect(page.getByRole("checkbox", { name: RC_CHECKBOX_NAME })).toBeDisabled();
+ });
+
+ it("locks competing controls whenever a persisted operation remains unresolved", async () => {
+ upgradeHookMock.current.trackedTargetVersion = "v1.3.0";
+ mockGetUpdateStatus.mockResolvedValue(buildStatus({ oneClickAvailable: true }));
+
+ const page = render();
+
+ expect(await page.findByRole("button", { name: "Upgrade to v1.3.0" })).toBeDisabled();
expect(page.getByRole("button", { name: "Copy install command" })).toBeDisabled();
expect(page.getByRole("checkbox", { name: RC_CHECKBOX_NAME })).toBeDisabled();
});
diff --git a/client/src/protoFleet/features/settings/components/Updates.tsx b/client/src/protoFleet/features/settings/components/Updates.tsx
index 844ede6fd4..a8052b9104 100644
--- a/client/src/protoFleet/features/settings/components/Updates.tsx
+++ b/client/src/protoFleet/features/settings/components/Updates.tsx
@@ -11,7 +11,15 @@ import SettingsPageHeader from "@/protoFleet/features/settings/components/Settin
import UpgradeOperationModal from "@/protoFleet/features/settings/components/UpgradeOperationModal";
import { isUpgradeActive, useUpgradeOperation } from "@/protoFleet/features/updates/api/useUpgradeOperation";
import { copyInstallCommand } from "@/protoFleet/features/updates/copyInstallCommand";
-import { useAuthErrors, useFleetStore, useHasPermission, usePermissions, useSetPermissions } from "@/protoFleet/store";
+import {
+ useAuthErrors,
+ useFleetStore,
+ useHasPermission,
+ usePermissions,
+ useSessionExpiry,
+ useSetPermissions,
+ useUsername,
+} from "@/protoFleet/store";
import { Copy } from "@/shared/assets/icons";
import Button, { variants } from "@/shared/components/Button";
import Checkbox from "@/shared/components/Checkbox";
@@ -87,7 +95,9 @@ const waitForReleaseChannelSave = async () => {
const Updates = () => {
const canUpdateInstance = useHasPermission(INSTANCE_UPDATE_PERMISSION);
const permissions = usePermissions();
+ const sessionExpiry = useSessionExpiry();
const setPermissions = useSetPermissions();
+ const username = useUsername();
const { handleAuthErrors } = useAuthErrors();
const [status, setStatus] = useState(null);
const [loadError, setLoadError] = useState(null);
@@ -133,6 +143,7 @@ const Updates = () => {
);
const upgrade = useUpgradeOperation({
+ authSessionIdentity: `${username}:${sessionExpiry?.getTime() ?? "signed-out"}`,
enabled: canUpdateInstance,
currentVersion: status?.currentVersion,
onPollError: handleUpgradePollError,
@@ -140,9 +151,15 @@ const Updates = () => {
const activeUpgrade = isUpgradeActive(upgrade.operation);
const succeededUpgrade = upgrade.operation?.phase === UpgradePhase.SUCCEEDED;
const upgradeRequestPending = upgrade.triggering || upgrade.reconciling;
- const upgradeLocksConfiguration = upgradeRequestPending || Boolean(upgrade.operation);
+ const unresolvedTrackedUpgrade = Boolean(upgrade.trackedTargetVersion && !upgrade.operation);
+ const upgradeLocksConfiguration = upgradeRequestPending || unresolvedTrackedUpgrade || Boolean(upgrade.operation);
+ const upgradeActionDisabled = isChannelChangePending || upgradeRequestPending || unresolvedTrackedUpgrade;
const manualCommandDisabled =
- isChannelChangePending || activeUpgrade || upgradeRequestPending || Boolean(succeededUpgrade);
+ isChannelChangePending ||
+ activeUpgrade ||
+ upgradeRequestPending ||
+ unresolvedTrackedUpgrade ||
+ Boolean(succeededUpgrade);
const fetchStatus = useCallback(async () => {
const requestId = ++latestStatusRequest.current;
@@ -310,7 +327,7 @@ const Updates = () => {
const operationStatusLabel = upgrade.reconciling
? upgrade.manualFallbackReady
? "Upgrade outcome is unknown — host confirmation required"
- : "Confirming whether the upgrade started"
+ : "Confirming upgrade status"
: upgrade.operation?.phase === UpgradePhase.FAILED
? "Upgrade failed"
: upgrade.operation?.phase === UpgradePhase.SUCCEEDED
@@ -405,7 +422,7 @@ const Updates = () => {
- Fleet is reconciling the request with the host updater. Do not run the manual install command yet; wait
- until this check finishes.
+ Fleet is reconciling the tracked upgrade with the host updater. Do not run the manual install command
+ yet; wait until this check finishes.