Skip to content

Commit 5e9330d

Browse files
feat(updates): add one-click upgrade experience
1 parent 5d7be4a commit 5e9330d

8 files changed

Lines changed: 656 additions & 24 deletions

File tree

client/src/protoFleet/components/AppLayout/AppLayout.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,13 @@ const AppLayoutContent = ({ children, hideShellHeader = false }: Props) => {
108108
open={updateNotification.modalOpen}
109109
release={updateNotification.release}
110110
installCommand={updateNotification.installCommand}
111+
oneClickAvailable={updateNotification.oneClickAvailable}
112+
operation={updateNotification.operation}
113+
connectionLost={updateNotification.connectionLost}
114+
triggering={updateNotification.triggering}
115+
triggerError={updateNotification.triggerError}
116+
onUpgrade={updateNotification.triggerUpgrade}
117+
onReload={updateNotification.reloadFleet}
111118
onDismiss={updateNotification.closeModal}
112119
/>
113120

client/src/protoFleet/features/updates/api/useUpdateStatus.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const statusUnchanged = (prev: GetUpdateStatusResponse, next: GetUpdateStatusRes
1919
prev.updateAvailable === next.updateAvailable &&
2020
prev.channel === next.channel &&
2121
prev.installCommand === next.installCommand &&
22+
prev.oneClickAvailable === next.oneClickAvailable &&
2223
prev.latestEligible?.version === next.latestEligible?.version;
2324

2425
// Polls GetUpdateStatus for permission holders only: the RPC is gated by
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { act, renderHook, waitFor } from "@testing-library/react";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import { create } from "@bufbuild/protobuf";
4+
5+
import { instanceUpdateClient } from "@/protoFleet/api/clients";
6+
import {
7+
GetUpgradeStatusResponseSchema,
8+
TriggerUpgradeResponseSchema,
9+
UpgradeOperationSchema,
10+
UpgradePhase,
11+
} from "@/protoFleet/api/generated/instance/v1/updates_pb";
12+
import { useUpgradeOperation } from "@/protoFleet/features/updates/api/useUpgradeOperation";
13+
14+
vi.mock("@/protoFleet/api/clients", () => ({
15+
instanceUpdateClient: {
16+
getUpgradeStatus: vi.fn(),
17+
triggerUpgrade: vi.fn(),
18+
},
19+
}));
20+
21+
const mockGetUpgradeStatus = vi.mocked(instanceUpdateClient.getUpgradeStatus);
22+
const mockTriggerUpgrade = vi.mocked(instanceUpdateClient.triggerUpgrade);
23+
24+
const activeOperation = create(UpgradeOperationSchema, {
25+
id: "operation-1",
26+
targetVersion: "v1.3.0",
27+
phase: UpgradePhase.PREFLIGHT,
28+
message: "Validating release",
29+
});
30+
31+
describe("useUpgradeOperation", () => {
32+
beforeEach(() => {
33+
vi.clearAllMocks();
34+
});
35+
36+
it("recovers a durable in-progress operation when the page loads", async () => {
37+
mockGetUpgradeStatus.mockResolvedValue(
38+
create(GetUpgradeStatusResponseSchema, {
39+
executorAvailable: true,
40+
operation: activeOperation,
41+
}),
42+
);
43+
44+
const { result } = renderHook(() => useUpgradeOperation(true, true));
45+
46+
await waitFor(() => expect(result.current.operation?.id).toBe("operation-1"));
47+
expect(result.current.connectionLost).toBe(false);
48+
});
49+
50+
it("does not replay an already completed success on every fresh page load", async () => {
51+
mockGetUpgradeStatus.mockResolvedValue(
52+
create(GetUpgradeStatusResponseSchema, {
53+
executorAvailable: true,
54+
operation: create(UpgradeOperationSchema, {
55+
id: "completed-operation",
56+
targetVersion: "v1.3.0",
57+
phase: UpgradePhase.SUCCEEDED,
58+
}),
59+
}),
60+
);
61+
62+
const { result } = renderHook(() => useUpgradeOperation(true, true));
63+
64+
await waitFor(() => expect(mockGetUpgradeStatus).toHaveBeenCalled());
65+
expect(result.current.operation).toBeUndefined();
66+
});
67+
68+
it("starts the exact offered target and retains the returned operation", async () => {
69+
mockGetUpgradeStatus.mockResolvedValue(create(GetUpgradeStatusResponseSchema, { executorAvailable: true }));
70+
mockTriggerUpgrade.mockResolvedValue(
71+
create(TriggerUpgradeResponseSchema, {
72+
operation: activeOperation,
73+
}),
74+
);
75+
const { result } = renderHook(() => useUpgradeOperation(true, true));
76+
77+
await act(async () => {
78+
await result.current.triggerUpgrade("v1.3.0");
79+
});
80+
81+
expect(mockTriggerUpgrade).toHaveBeenCalledWith({ targetVersion: "v1.3.0" });
82+
expect(result.current.operation?.phase).toBe(UpgradePhase.PREFLIGHT);
83+
expect(result.current.triggerError).toBeNull();
84+
});
85+
86+
it("surfaces a trigger failure while leaving the manual fallback usable", async () => {
87+
mockGetUpgradeStatus.mockResolvedValue(create(GetUpgradeStatusResponseSchema, { executorAvailable: true }));
88+
mockTriggerUpgrade.mockRejectedValue(new Error("host updater unavailable"));
89+
const { result } = renderHook(() => useUpgradeOperation(true, true));
90+
91+
let caught: unknown;
92+
await act(async () => {
93+
try {
94+
await result.current.triggerUpgrade("v1.3.0");
95+
} catch (error) {
96+
caught = error;
97+
}
98+
});
99+
expect(caught).toEqual(expect.objectContaining({ message: "host updater unavailable" }));
100+
expect(result.current.operation).toBeUndefined();
101+
expect(result.current.triggerError).toContain("host updater unavailable");
102+
});
103+
});
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { useCallback, useEffect, useRef, useState } from "react";
2+
3+
import { instanceUpdateClient } from "@/protoFleet/api/clients";
4+
import { type UpgradeOperation, UpgradePhase } from "@/protoFleet/api/generated/instance/v1/updates_pb";
5+
import { getErrorMessage } from "@/protoFleet/api/getErrorMessage";
6+
7+
const ACTIVE_POLL_INTERVAL_MS = 2_000;
8+
const IDLE_POLL_INTERVAL_MS = 60_000;
9+
10+
export const isUpgradeTerminal = (phase: UpgradePhase) =>
11+
phase === UpgradePhase.SUCCEEDED || phase === UpgradePhase.FAILED;
12+
13+
export const isUpgradeActive = (operation?: UpgradeOperation) =>
14+
Boolean(operation && operation.phase !== UpgradePhase.UNSPECIFIED && !isUpgradeTerminal(operation.phase));
15+
16+
interface UseUpgradeOperationResult {
17+
connectionLost: boolean;
18+
operation: UpgradeOperation | undefined;
19+
reloadFleet: () => void;
20+
triggerError: string | null;
21+
triggering: boolean;
22+
triggerUpgrade: (targetVersion: string) => Promise<void>;
23+
}
24+
25+
export function useUpgradeOperation(hasPermission: boolean, executorAdvertised: boolean): UseUpgradeOperationResult {
26+
const [operation, setOperation] = useState<UpgradeOperation>();
27+
const [triggering, setTriggering] = useState(false);
28+
const [connectionLost, setConnectionLost] = useState(false);
29+
const [triggerError, setTriggerError] = useState<string | null>(null);
30+
const observedActiveOperation = useRef(false);
31+
const active = isUpgradeActive(operation);
32+
const enabled = hasPermission && (executorAdvertised || operation !== undefined);
33+
34+
useEffect(() => {
35+
if (!enabled) {
36+
return;
37+
}
38+
let cancelled = false;
39+
const poll = async () => {
40+
try {
41+
const response = await instanceUpdateClient.getUpgradeStatus({});
42+
if (cancelled) return;
43+
if (!response.executorAvailable) {
44+
if (active) setConnectionLost(true);
45+
return;
46+
}
47+
setConnectionLost(false);
48+
if (response.operation) {
49+
if (isUpgradeActive(response.operation)) {
50+
observedActiveOperation.current = true;
51+
}
52+
// A completed success is useful to the tab that watched or
53+
// initiated it, but replaying the daemon's last success on every
54+
// fresh page load would reopen the modal forever.
55+
if (response.operation.phase === UpgradePhase.SUCCEEDED && !observedActiveOperation.current) {
56+
return;
57+
}
58+
setOperation(response.operation);
59+
}
60+
} catch {
61+
if (!cancelled && active) {
62+
// fleetd is intentionally offline during activation. Keep the last
63+
// durable phase visible and continue polling until the new stack is
64+
// reachable.
65+
setConnectionLost(true);
66+
}
67+
}
68+
};
69+
70+
void poll();
71+
const interval = window.setInterval(poll, active ? ACTIVE_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS);
72+
return () => {
73+
cancelled = true;
74+
window.clearInterval(interval);
75+
};
76+
}, [active, enabled]);
77+
78+
const triggerUpgrade = useCallback(async (targetVersion: string) => {
79+
setTriggering(true);
80+
setTriggerError(null);
81+
try {
82+
const response = await instanceUpdateClient.triggerUpgrade({ targetVersion });
83+
if (!response.operation) {
84+
throw new Error("Host updater did not return an operation");
85+
}
86+
observedActiveOperation.current = true;
87+
setOperation(response.operation);
88+
setConnectionLost(false);
89+
} catch (error) {
90+
const message = getErrorMessage(error, "Failed to start upgrade");
91+
setTriggerError(message);
92+
throw error;
93+
} finally {
94+
setTriggering(false);
95+
}
96+
}, []);
97+
98+
const reloadFleet = useCallback(() => {
99+
window.location.reload();
100+
}, []);
101+
102+
return {
103+
connectionLost,
104+
operation,
105+
reloadFleet,
106+
triggerError,
107+
triggering,
108+
triggerUpgrade,
109+
};
110+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
2+
import { describe, expect, it, vi } from "vitest";
3+
import { create } from "@bufbuild/protobuf";
4+
5+
import {
6+
ReleaseInfoSchema,
7+
type UpgradeOperation,
8+
UpgradeOperationSchema,
9+
UpgradePhase,
10+
} from "@/protoFleet/api/generated/instance/v1/updates_pb";
11+
import UpdateNotificationModal from "@/protoFleet/features/updates/components/UpdateNotificationModal";
12+
13+
const release = (version = "v1.3.0", prerelease = false) =>
14+
create(ReleaseInfoSchema, {
15+
version,
16+
prerelease,
17+
releaseNotesUrl: `https://github.com/block/proto-fleet/releases/tag/${version}`,
18+
});
19+
20+
type OperationOverrides = Partial<Pick<UpgradeOperation, "error" | "hostLogPath" | "message" | "recoveryCommand">>;
21+
22+
const operation = (phase: UpgradePhase, overrides?: OperationOverrides) =>
23+
create(UpgradeOperationSchema, {
24+
id: "operation-1",
25+
targetVersion: "v1.3.0",
26+
phase,
27+
message: "Preparing upgrade",
28+
...overrides,
29+
});
30+
31+
describe("UpdateNotificationModal one-click upgrade", () => {
32+
it("requires explicit confirmation before triggering a stable upgrade", async () => {
33+
const onUpgrade = vi.fn().mockResolvedValue(undefined);
34+
render(
35+
<UpdateNotificationModal
36+
open
37+
release={release()}
38+
installCommand="manual command"
39+
oneClickAvailable
40+
onUpgrade={onUpgrade}
41+
onDismiss={vi.fn()}
42+
/>,
43+
);
44+
45+
fireEvent.click(screen.getByRole("button", { name: "Upgrade to v1.3.0" }));
46+
expect(screen.getByText("Confirm instance upgrade")).toBeInTheDocument();
47+
expect(onUpgrade).not.toHaveBeenCalled();
48+
49+
fireEvent.click(screen.getByRole("button", { name: "Confirm upgrade" }));
50+
await waitFor(() => expect(onUpgrade).toHaveBeenCalledWith("v1.3.0"));
51+
});
52+
53+
it("warns that a release candidate cannot be downgraded", () => {
54+
render(
55+
<UpdateNotificationModal
56+
open
57+
release={release("v1.3.0-rc.2", true)}
58+
installCommand="manual command"
59+
oneClickAvailable
60+
onUpgrade={vi.fn()}
61+
onDismiss={vi.fn()}
62+
/>,
63+
);
64+
65+
fireEvent.click(screen.getByRole("button", { name: "Upgrade to v1.3.0-rc.2" }));
66+
expect(screen.getByText(/This is a release candidate/)).toBeInTheDocument();
67+
expect(screen.getByText(/cannot downgrade/)).toBeInTheDocument();
68+
});
69+
70+
it("keeps progress visible through the expected server disconnect", () => {
71+
render(
72+
<UpdateNotificationModal
73+
open
74+
release={release()}
75+
installCommand="manual command"
76+
operation={operation(UpgradePhase.ACTIVATING)}
77+
connectionLost
78+
oneClickAvailable
79+
onDismiss={vi.fn()}
80+
/>,
81+
);
82+
83+
expect(screen.getByText(/disconnect is expected during restart/)).toBeInTheDocument();
84+
expect(screen.queryByRole("button", { name: "Close dialog" })).not.toBeInTheDocument();
85+
expect(screen.queryByText("manual command")).not.toBeInTheDocument();
86+
});
87+
88+
it("shows the durable host log and recovery command after a failure", () => {
89+
render(
90+
<UpdateNotificationModal
91+
open
92+
release={release()}
93+
installCommand="manual command"
94+
operation={operation(UpgradePhase.FAILED, {
95+
message: "Upgrade failed",
96+
error: "new stack failed to start",
97+
hostLogPath: "/var/lib/proto-fleet-updater/logs/operation-1.log",
98+
recoveryCommand: "cd /opt/proto-fleet/deployment && ./run-fleet.sh --non-interactive --skip-build",
99+
})}
100+
oneClickAvailable
101+
onDismiss={vi.fn()}
102+
/>,
103+
);
104+
105+
expect(screen.getByText("new stack failed to start")).toBeInTheDocument();
106+
expect(screen.getByText(/operation-1\.log/)).toBeInTheDocument();
107+
expect(screen.getByText(/--skip-build/)).toBeInTheDocument();
108+
expect(screen.getByText("manual command")).toBeInTheDocument();
109+
});
110+
});

0 commit comments

Comments
 (0)