Skip to content

Commit f490427

Browse files
authored
feat(console): OS image lifecycle on node detail (#186)
* chore(api-client): regenerate from cryptos-manager at d3494ac Picks up GetNodeImageStatus, StageNodeImage, ActivateNodeImage and RollbackNodeImage on FleetService. Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> * feat(console): OS image lifecycle on node detail Node detail now shows a managed node's CryptOS image status (running, active and previous digests, a "Reboot pending" badge when active differs from running) through the manager's GetNodeImageStatus. An admin can stage a new image (the manager fetches image_url itself and streams it to the node; the browser carries only the URL and the detached release signature), activate a staged image (the same type-the-CA-CN confirmation RebootDialog uses, since activating is itself a reboot) and roll back to the previous image. The node's own refusals surface inline (a bad signature, or "another upgrade is already in progress" for a concurrent stage). Refs CryptOS-PKI/cryptos-manager#187 Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> --------- Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> Co-authored-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
1 parent 4bb64b0 commit f490427

16 files changed

Lines changed: 1699 additions & 4 deletions
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/*
2+
Copyright The CryptOS Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
18+
import { beforeEach, describe, expect, it, vi } from "vitest";
19+
20+
import { ActivateNodeImageDialog } from "@/components/activate-node-image-dialog";
21+
import { expectNoA11yViolations } from "@/test/axe";
22+
23+
const activateNodeImage = vi.fn();
24+
vi.mock("@/lib/node-image", () => ({
25+
activateNodeImage: (...a: unknown[]) => activateNodeImage(...a),
26+
}));
27+
28+
const CN = "ACME Issuing CA G1";
29+
const activateButton = () => screen.getByRole("button", { name: /^activate$/i });
30+
31+
const renderDialog = () =>
32+
render(
33+
<ActivateNodeImageDialog
34+
caCn={CN}
35+
nodeName="acme-edge-07"
36+
onClose={vi.fn()}
37+
onDone={vi.fn()}
38+
/>,
39+
);
40+
41+
describe("ActivateNodeImageDialog", () => {
42+
beforeEach(() => {
43+
activateNodeImage.mockReset();
44+
activateNodeImage.mockImplementation(() => Promise.resolve());
45+
});
46+
47+
it("has no axe violations", async () => {
48+
renderDialog();
49+
await expectNoA11yViolations(document.body);
50+
});
51+
52+
it("keeps Activate disabled until the CN matches", () => {
53+
renderDialog();
54+
expect(activateButton()).toBeDisabled();
55+
fireEvent.change(screen.getByLabelText(/type the ca cn/i), { target: { value: CN } });
56+
expect(activateButton()).toBeEnabled();
57+
});
58+
59+
it("stays disabled when the typed CN does not match", () => {
60+
renderDialog();
61+
fireEvent.change(screen.getByLabelText(/type the ca cn/i), { target: { value: "wrong" } });
62+
expect(activateButton()).toBeDisabled();
63+
});
64+
65+
it("on confirm calls activateNodeImage with the echoed CN and shows activating", async () => {
66+
const onDone = vi.fn();
67+
render(
68+
<ActivateNodeImageDialog
69+
caCn={CN}
70+
nodeName="acme-edge-07"
71+
onClose={vi.fn()}
72+
onDone={onDone}
73+
/>,
74+
);
75+
fireEvent.change(screen.getByLabelText(/type the ca cn/i), { target: { value: CN } });
76+
fireEvent.click(activateButton());
77+
78+
await waitFor(() => expect(activateNodeImage).toHaveBeenCalledWith("acme-edge-07", CN));
79+
await waitFor(() => expect(screen.getByText(/activating/i)).toBeInTheDocument());
80+
expect(onDone).toHaveBeenCalled();
81+
});
82+
83+
it("surfaces a server-side refusal inline", async () => {
84+
activateNodeImage.mockRejectedValue(new Error("permission denied: CN mismatch"));
85+
renderDialog();
86+
fireEvent.change(screen.getByLabelText(/type the ca cn/i), { target: { value: CN } });
87+
fireEvent.click(activateButton());
88+
await waitFor(() => expect(screen.getByText(/mismatch/i)).toBeInTheDocument());
89+
});
90+
});
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/*
2+
Copyright The CryptOS Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { Button, Dialog, Notice, TypeToConfirm } from "@cryptos-pki/ui";
18+
import { useState } from "react";
19+
20+
import { activateNodeImage } from "@/lib/node-image";
21+
22+
// ActivateNodeImageDialog reboots a managed node so a staged image starts
23+
// running. It follows the same in-UI confirmation as RebootDialog, never a
24+
// window.confirm: the operator must type the node's CA CN exactly (shown for
25+
// reference, echoed to the node which compares it constant-time) before the
26+
// action enables, because activating is itself a reboot.
27+
export const ActivateNodeImageDialog = ({
28+
caCn,
29+
nodeName,
30+
onClose,
31+
onDone,
32+
}: {
33+
caCn: string;
34+
nodeName: string;
35+
onClose: () => void;
36+
onDone: () => void;
37+
}) => {
38+
const [confirmCn, setConfirmCn] = useState("");
39+
const [pending, setPending] = useState(false);
40+
const [error, setError] = useState<null | string>(null);
41+
const [done, setDone] = useState(false);
42+
43+
const ready = confirmCn === caCn && !pending;
44+
45+
const confirm = () => {
46+
if (!ready) return;
47+
setPending(true);
48+
setError(null);
49+
activateNodeImage(nodeName, confirmCn)
50+
.then(() => {
51+
setDone(true);
52+
setPending(false);
53+
onDone();
54+
})
55+
.catch((error_: unknown) => {
56+
setError(error_ instanceof Error ? error_.message : "Activation failed");
57+
setPending(false);
58+
});
59+
};
60+
61+
return (
62+
<Dialog
63+
description={nodeName}
64+
footer={
65+
<>
66+
<Button onClick={onClose} size="sm" variant="outline">
67+
{done ? "Close" : "Cancel"}
68+
</Button>
69+
{done ? null : (
70+
<Button disabled={!ready} onClick={confirm} size="sm" variant="destructive">
71+
{pending ? "Activating…" : "Activate"}
72+
</Button>
73+
)}
74+
</>
75+
}
76+
onClose={onClose}
77+
open
78+
title="Activate staged image"
79+
variant="destructive"
80+
>
81+
<Notice tone="danger">
82+
<p>
83+
The node reboots onto the staged image. Every certificate operation it serves is
84+
interrupted until it is back.
85+
</p>
86+
</Notice>
87+
88+
{done ? (
89+
<p className="font-mono text-xs text-success" role="status">
90+
{nodeName} is activating the staged image.
91+
</p>
92+
) : (
93+
<>
94+
<div className="space-y-1">
95+
<p className="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">
96+
CA CN (for reference)
97+
</p>
98+
<p className="break-all rounded-md border bg-secondary px-3 py-2 font-mono text-xs">
99+
{caCn}
100+
</p>
101+
</div>
102+
103+
<TypeToConfirm
104+
expected={caCn}
105+
label="Type the CA CN to confirm"
106+
onChange={setConfirmCn}
107+
value={confirmCn}
108+
/>
109+
110+
{error ? (
111+
<Notice role="alert" tone="danger">
112+
{error}
113+
</Notice>
114+
) : null}
115+
</>
116+
)}
117+
</Dialog>
118+
);
119+
};
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
Copyright The CryptOS Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { render, screen } from "@testing-library/react";
18+
import { beforeEach, describe, expect, it, vi } from "vitest";
19+
20+
import { NodeImageLifecycle } from "@/components/node-image-lifecycle";
21+
import { expectNoA11yViolations } from "@/test/axe";
22+
23+
let level: "admin" | "operator" | "viewer" = "admin";
24+
vi.mock("@/context/auth", () => ({
25+
useOptionalAuth: () => ({
26+
operator: { commonName: "op@acme.example", level, serial: "AA" },
27+
status: "authenticated",
28+
}),
29+
}));
30+
31+
describe("NodeImageLifecycle", () => {
32+
beforeEach(() => {
33+
level = "admin";
34+
});
35+
36+
it("has no axe violations", async () => {
37+
const { container } = render(
38+
<NodeImageLifecycle caCn="ACME Issuing CA G1" nodeName="acme-issuing-01" />,
39+
);
40+
await screen.findByText(/running/i);
41+
await expectNoA11yViolations(container);
42+
});
43+
44+
it("shows the running and active image digests", async () => {
45+
render(<NodeImageLifecycle caCn="ACME Issuing CA G1" nodeName="acme-issuing-01" />);
46+
expect(await screen.findAllByText(/7c3a9e2f1b6d/i)).not.toHaveLength(0);
47+
});
48+
49+
it("shows Stage, Activate and Roll back to an admin", async () => {
50+
render(<NodeImageLifecycle caCn="ACME Issuing CA G1" nodeName="acme-issuing-01" />);
51+
await screen.findByText(/running/i);
52+
expect(screen.getByRole("button", { name: /stage image/i })).toBeInTheDocument();
53+
expect(screen.getByRole("button", { name: /activate image/i })).toBeInTheDocument();
54+
expect(screen.getByRole("button", { name: /roll back/i })).toBeInTheDocument();
55+
});
56+
57+
it("hides every action from a non-admin operator", async () => {
58+
level = "operator";
59+
render(<NodeImageLifecycle caCn="ACME Issuing CA G1" nodeName="acme-issuing-01" />);
60+
await screen.findByText(/running/i);
61+
expect(screen.queryByRole("button", { name: /stage image/i })).not.toBeInTheDocument();
62+
expect(screen.queryByRole("button", { name: /activate image/i })).not.toBeInTheDocument();
63+
expect(screen.queryByRole("button", { name: /roll back/i })).not.toBeInTheDocument();
64+
});
65+
});

0 commit comments

Comments
 (0)