Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions apps/web/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,26 @@ request runs.
Graphite hint for automation (`chmod 600`, `--credential-file`). Done is
outline and forgets the credential; closing the dialog keeps it in a pending
card, which points to the Connect a host command below.
- **Add node**: the sandbox limits first, then the one-time command in a Terminal
block (expiry countdown and Copy command in its header), the three progress
steps, and, once the installer's minute passes, an amber card with the reason
and a copyable log command. Below, two folded Hairline disclosures: Host
requirements for the default command, which uses sudo (open until this browser
has shown it once, with notes that it creates the `parsar-node` system service
and, for Docker, that the docker group is root-equivalent), and "No sudo on this
host?", with what the node's own user needs and the command without sudo. The
log command follows the command last copied; after the no-sudo one it adds the
system service's, for a root shell. Until the installation is read, a line says
it is being checked; a failed read, a loopback public URL, or a console without
the provider's node files replaces the limits with one line saying why (the
failed read with Try again), and the footer offers nothing to generate.
- **Clean up the host**: after a node is removed, a dialog gives the host's
uninstall command in the same Terminal block, a Graphite line that it deletes no
sandboxes, volumes or images (and, for microsandbox, keeps its image store and
data), and the no-sudo form behind an "Installed without sudo?" disclosure. A
node enrolled with an earlier Core address adds an "Old Core address gone?"
disclosure with the `--force` form; a loopback console carries Add node's amber
note. Done dismisses it and focus returns to the page heading.
- **How to call**: wherever a new key is shown, a card under it gives three
copyable samples, each a Margin Gray block with a Hairline and its label and copy
button in a header row: a Shell block exporting `OPENAI_BASE_URL` (the
Expand Down
6 changes: 5 additions & 1 deletion apps/web/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,11 @@ workbench.
template build's), a review, and advanced settings
with the complete form — then the node list with each node's capacity, host
figures and allocations, enrollment, renaming, sandbox limits and removal; Add node
asks for the node's sandbox limits before it issues the one-time command), System (the
asks for the node's sandbox limits before it issues the one-time command, which installs
the node with sudo as a system service (a disclosure gives the command without sudo, as a
user service); it issues none before the installation is read, while the public URL is
loopback, or when the console lacks the provider's node files; after Remove, a dialog gives
the host's uninstall command), System (the
installation's public address, API base URL, installation ID and source commit, read-only;
each harness's default model, set, replaced or cleared there beside its read-only startup
state; the sandbox configuration every project shares, with a link to Nodes where it
Expand Down
78 changes: 67 additions & 11 deletions apps/web/e2e/nodes.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { expect, test } from "@playwright/test";

import { expectManagementBoundary, openConsole, setNode, writes } from "./console";
import { expectManagementBoundary, openConsole, resetFixture, setNode, writes } from "./console";

test.afterEach(async ({ request }) => expectManagementBoundary(request));

test("adds a node: host requirements, a countdown, the same command after closing, a new one after expiry, then its own node's registration", async ({ page, request }) => {
test("adds a node: host requirements, a sudo command and one without, a countdown, the same command after closing, a new one after expiry, then its own node's registration", async ({ page, request }) => {
await page.clock.install();
// Core counts a token's ten minutes on its own clock; the page's clock stands in for it, so fast-forwarding expires a command.
await page.route("**/core/v1/sandbox/enrollment-tokens", async (route) => {
Expand All @@ -15,12 +15,16 @@ test("adds a node: host requirements, a countdown, the same command after closin
await openConsole(page, request, "nodes");
await page.getByRole("button", { name: "Add node" }).click();
const add = page.getByRole("dialog", { name: "Add node" });
// What a Docker host needs, with the root commands that prepare it.
await expect(add.getByText("Docker at /var/run/docker.sock for that user, enforcing CPU and memory limits")).toBeVisible();
await expect(add.getByText("sudo usermod -aG docker NODE_USER")).toBeVisible();
await expect(add.getByText("CPUs and memory for at least one sandbox: 2 CPU · 4 GiB")).toBeVisible();
await expect(add.getByText(/^Can reach http:\/\/127\.0\.0\.1:\d+ and https:\/\/core\.example\.com; sandboxes must reach https:\/\/core\.example\.com$/)).toBeVisible();
// What a Docker host needs for the default command, which installs the node with sudo.
await expect(add.getByText("Rootful Docker Engine running, its socket owned by the docker group with mode 0660, enforcing CPU and memory limits (cgroup v2)")).toBeVisible();
await expect(add.getByText("SELinux is not enforcing (otherwise use the no-sudo command)")).toBeVisible();
await expect(add.getByText("One Core per host: a host already running a node for another Core is refused.")).toBeVisible();
await expect(add.getByText("CPUs and memory for at least one sandbox: 2 CPU · 4 GiB; about 2 GB of disk for the Runtime image")).toBeVisible();
await expect(add.getByText(/^Reaches http:\/\/127\.0\.0\.1:\d+ and https:\/\/core\.example\.com; sandboxes reach https:\/\/core\.example\.com$/)).toBeVisible();
await expect(add.getByText("parsar-node joins the docker group, which is equivalent to root on this host.")).toBeVisible();
await expect(add.getByText(/\/dev\/kvm/)).toHaveCount(0);
// Preparing a user instead of using sudo waits behind its disclosure.
await expect(add.getByText("sudo usermod -aG docker NODE_USER")).toBeHidden();
// The fixture console runs on loopback, where another machine can't download from it.
await expect(add.getByRole("note")).toContainText("other machines can't reach");
await add.getByLabel("Sandboxes at once").fill("3");
Expand All @@ -29,8 +33,12 @@ test("adds a node: host requirements, a countdown, the same command after closin
await add.getByRole("button", { name: "Generate command" }).click();
// Docker never suspends, so it retains exactly the sandboxes it runs.
expect((await issued).postDataJSON()).toEqual({ max_active: 3, max_retained: 3 });
const field = add.getByLabel("One-time enrollment command");
const field = add.getByLabel("One-time enrollment command", { exact: true });
await expect(field).toHaveValue(/enroll_fixture_/);
// The token goes on stdin to the checked installer, run with sudo unless the shell is root.
await expect(field).toHaveValue(/^ \(umask 077;.*\|\| s=sudo\n/);
await expect(field).toHaveValue(/\| \$s python3 "\$d\/node-install\.pyz" --enrollment-token-stdin /);
await expect(add.getByText("If the command is interrupted or the download stalls, run the same command again: the download resumes.")).toBeVisible();
await expect(add.getByRole("timer")).toHaveText(/^Expires in (10:00|9:\d\d)$/);
const progress = add.getByRole("status", { name: "Registration progress" });
await expect(progress).toHaveText(/Waiting for registration.*Connect.*Docker check/);
Expand Down Expand Up @@ -79,9 +87,20 @@ test("adds a node: host requirements, a countdown, the same command after closin
await expect(add.getByText("Rerun only on edge-04 if asked")).toBeVisible();
// Past the installer's minute without connecting, the dialog points at the node's log.
await page.clock.fastForward("01:01");
const problem = add.getByRole("alert");
const problem = add.getByRole("alert").filter({ hasText: "Check the log on the host:" });
await expect(problem).toContainText("Not connected yet");
await expect(problem).toContainText("sudo journalctl -u parsar-node-7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f.service");
// Without sudo: what that user needs and the same command without sudo. Once that one is copied, the log
// hint names its user service, and the system service in case root ran it.
await add.getByText("No sudo on this host?").click();
await expect(add.getByText("sudo usermod -aG docker NODE_USER")).toBeVisible();
const userCommand = add.getByLabel("One-time enrollment command without sudo", { exact: true });
await expect(userCommand).toHaveValue(/EXIT\ncurl/);
await expect(userCommand).toHaveValue(/\| python3 "\$d\/node-install\.pyz" --enrollment-token-stdin /);
await expect(problem).not.toContainText("journalctl --user");
await add.getByRole("button", { name: "Copy command without sudo" }).click();
await expect(problem).toContainText("journalctl --user -u parsar-node-7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f.service");
await expect(problem).toContainText("If root ran it, it is a system service:sudo journalctl -u parsar-node-7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f.service");
// Connected, it reports why Docker isn't ready; once ready, the node is connected.
await setNode(request, { id: "node-new", online: true, diagnostic: "docker_limits_unsupported" });
await expect(problem).toContainText("Docker limits unsupported");
Expand All @@ -95,14 +114,41 @@ test("adds a node: host requirements, a countdown, the same command after closin
await expect(add.getByLabel("Sandboxes at once")).toHaveValue("2");
});

test("says the console has no node files for the provider and issues no command", async ({ page, request }) => {
test("issues no command before the installation is read, for a loopback public URL or without node files, and sees a fix on reopening", async ({ page, request }) => {
// Until the installation is read, and while it can't be, nothing is issued: the read decides.
let release = () => {};
const held = new Promise<void>((resolve) => { release = resolve; });
await page.route("**/core/v1/installation", async (route) => {
await held;
await route.fulfill({ status: 500, json: { error: { message: "Unavailable.", type: "server_error", code: null, param: null } } });
});
await openConsole(page, request, "nodes", { installation: "local" });
await page.getByRole("button", { name: "Add node" }).click();
const add = page.getByRole("dialog", { name: "Add node" });
await expect(add.getByRole("status")).toHaveText("Checking this installation's public URL…");
await expect(add.getByRole("button", { name: "Generate command" })).toHaveCount(0);
release();
await expect(add.getByRole("alert")).toContainText("The installation couldn't be read, so no command can be issued.");
await expect(add.getByRole("button", { name: "Generate command" })).toHaveCount(0);
await page.unroute("**/core/v1/installation");
await add.getByRole("button", { name: "Try again" }).click();
// Nodes on other machines can't reach a loopback public_url; this replaces the note about the browser's address.
await expect(add.getByRole("status")).toHaveText("Nodes need an HTTPS public URL that other machines and their sandboxes can reach: set public_url in config.json and run parsar apply");
await expect(add.getByRole("note")).toHaveCount(0);
await expect(add.getByRole("button", { name: "Generate command" })).toHaveCount(0);
await add.getByRole("button", { name: "Close dialog" }).click();

// A thin bundle: the console holds no node files at all.
await openConsole(page, request, "nodes", { nodeArtifacts: [] });
await page.getByRole("button", { name: "Add node" }).click();
const add = page.getByRole("dialog", { name: "Add node" });
await expect(add.getByRole("status")).toHaveText("This console has no node files for Docker. Install Core from the offline bundle, or add the release artifacts and rerun ./install.sh.");
await expect(add.getByRole("button", { name: "Generate command" })).toHaveCount(0);
expect(await writes(request)).toEqual([]);
// Rerunning ./install.sh adds them: reopening reads the console again, without a reload.
await add.getByRole("button", { name: "Close dialog" }).click();
await resetFixture(request);
await page.getByRole("button", { name: "Add node" }).click();
await expect(add.getByRole("button", { name: "Generate command" })).toBeVisible();
});

test("removes a node after confirmation", async ({ page, request }) => {
Expand All @@ -112,6 +158,16 @@ test("removes a node after confirmation", async ({ page, request }) => {
await confirm.getByRole("button", { name: "Confirm removal" }).click();
await expect(confirm).toBeHidden();
await expect(page.getByRole("table", { name: "Sandbox nodes" })).not.toContainText("edge-03");
// The host still runs the node until it is uninstalled there: with sudo, or as the user that installed it.
const cleanup = page.getByRole("dialog", { name: "Clean up the host" });
await expect(cleanup.getByLabel("Uninstall command", { exact: true })).toHaveValue(/\| s=sudo\n[^]*\n\$s python3 "\$d\/node-install\.pyz" --uninstall --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f'\)$/);
await cleanup.getByText("Installed without sudo?").click();
await expect(cleanup.getByLabel("Uninstall command without sudo", { exact: true })).toHaveValue(/\npython3 "\$d\/node-install\.pyz" --uninstall --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f'\)$/);
// Nothing to force for a node on the current address; closing leaves focus on the page, as the row is gone.
await expect(cleanup.getByText("Old Core address gone?")).toHaveCount(0);
await cleanup.getByRole("button", { name: "Done" }).click();
await expect(cleanup).toBeHidden();
await expect(page.getByRole("heading", { name: "Nodes", level: 1 })).toBeFocused();
});

test("sets up own-machine sandboxes page by page, with the Runtime from the distribution", async ({ page, request }) => {
Expand Down
5 changes: 5 additions & 0 deletions apps/web/e2e/public-url.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,9 @@ test("warns on Nodes about a node bound to an old Core address until it is remov
await page.getByRole("dialog", { name: "Remove node" }).getByRole("button", { name: "Confirm removal" }).click();
await expect(page.getByRole("table", { name: "Sandbox nodes" })).not.toContainText("core-01");
await expect(warning).toHaveCount(0);
// Its host's uninstall confirms the removal at that old address; if it no longer answers, --force skips the check.
const cleanup = page.getByRole("dialog", { name: "Clean up the host" });
await cleanup.getByText("Old Core address gone?").click();
await expect(cleanup).toContainText("core-01 still points at the old Core address https://core-old.example.com.");
await expect(cleanup.getByLabel("Uninstall command with --force", { exact: true })).toHaveValue(/--uninstall --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f' --force\)$/);
});
62 changes: 62 additions & 0 deletions apps/web/src/features/sandbox/NodeCleanupDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { TriangleAlert } from "lucide-react";
import { useTranslation } from "react-i18next";

import { Modal } from "../../components/Modal";
import { sandboxSetupOrigin } from "./core-origin";
import { nodeUninstallCommand, type NodeInstallMode } from "./enrollment-command";
import { CommandBlock } from "./node-commands";

/** A node Core has just removed, with what builds its host's uninstall command. */
export interface NodeCleanup {
name: string;
/** This console's address, which the command downloads the installer from. */
sourceUrl: string;
installationId: string;
scriptDigest: string;
provider: string;
/** The Core address the node enrolled with, when it is no longer the deployment's; else null. */
oldAddress: string | null;
}

/**
* After Remove: the command that removes the node's service and files from its
* host (deploy/install/node_install.py `uninstall_system` and `uninstall_user`).
* The installer first confirms with Core, at the node's own address, that the
* node is removed, which holds from the removal on. The sudo form escalates by
* itself, so a node installed without sudo gets its own command, run as that
* node's user. A node enrolled with an earlier address may find it gone; then
* `--force` skips only that confirmation. Nothing deletes sandboxes, volumes or
* images.
*/
export function NodeCleanupDialog({ cleanup, open, onClose }: { cleanup: NodeCleanup | null; open: boolean; onClose: () => void }) {
const { t, i18n } = useTranslation("sandbox");
// Sentences run on with a space in English and without one in Chinese.
const join = (...sentences: string[]) => sentences.join(i18n.resolvedLanguage?.startsWith("zh") ? "" : " ");
const command = (mode: NodeInstallMode, force = false) => cleanup
? nodeUninstallCommand({ sourceUrl: cleanup.sourceUrl, installationId: cleanup.installationId, scriptDigest: cleanup.scriptDigest, mode, force }) : "";
return <Modal open={open} title={t("Clean up the host")} onClose={onClose} footer={<button className="button primary" type="button" onClick={onClose}>{t("Done")}</button>}>
{cleanup ? <div className="sandbox-add-node form-stack">
<p>{t("{{name}} is removed from Core. To remove its service and files from the host, run:", { name: cleanup.name })}</p>
{/* Like Add node's command, this one downloads from the console's own address. */}
{sandboxSetupOrigin(cleanup.sourceUrl) === null ? <p className="sandbox-add-node-warning" role="note"><TriangleAlert size={14} aria-hidden="true" /><span>{t("This console is open at {{origin}}, which other machines can't reach. On another machine, replace it in the command with the console's HTTPS address.", { origin: cleanup.sourceUrl })}</span></p> : null}
<CommandBlock key={command("sudo")} value={command("sudo")} label={t("Uninstall command")} autoFocus />
<p className="sandbox-cleanup-note">{join(t("It never deletes sandboxes, volumes or images."),
...(cleanup.provider === "microsandbox" ? [t("It keeps microsandbox's image store and sandbox data, and prints how to remove them by hand.")] : []))}</p>
<details className="sandbox-host-requirements sandbox-no-sudo">
<summary>{t("Installed without sudo?")}</summary>
<div className="sandbox-no-sudo-body">
<p>{t("Run this as that user instead:")}</p>
<CommandBlock key={command("user")} value={command("user")} label={t("Uninstall command without sudo")} copyName={t("Copy command without sudo")} />
</div>
</details>
{cleanup.oldAddress !== null ? <details className="sandbox-host-requirements sandbox-no-sudo">
<summary>{t("Old Core address gone?")}</summary>
<div className="sandbox-no-sudo-body">
<p>{join(t("{{name}} still points at the old Core address {{address}}.", { name: cleanup.name, address: cleanup.oldAddress }),
t("If this node's old Core address no longer responds, first remove it on the Nodes page, then add --force to the uninstall command."))}</p>
<CommandBlock key={command("sudo", true)} value={command("sudo", true)} label={t("Uninstall command with --force")} copyName={t("Copy command with --force")} />
</div>
</details> : null}
</div> : null}
</Modal>;
}
Loading