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
8 changes: 7 additions & 1 deletion packages/control-plane/core/machines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ export interface ProvisionMachineInput {
/** A volume to reuse instead of creating one: a recreate or a machine-type
* change keeps the member's disk. */
volumeId?: string;
/** Whether this machine gets its own volume when it has none to reuse.
* Defaults to true; false skips the volume entirely, so the VM's disk is
* the only one it has and nothing on it outlives the VM. */
persistentVolume?: boolean;
/** An existing machine row to bring back up, instead of inserting one. */
machineId?: string;
recipe?: RecipeBootstrap;
Expand Down Expand Up @@ -257,7 +261,9 @@ export async function provisionMachine(
}
// The member's own disk. It holds /var/lib/blitz, which is the docker
// store and /workspace both, so a destroyed machine can come back on it.
const autoVolume = input.volumeId !== undefined
// A member row that asked for no persistent volume gets none: the VM's
// own disk is all there is, and it goes when the VM goes.
const autoVolume = input.volumeId !== undefined || input.persistentVolume === false
? null
: await provisionWorkspaceVolume({
db: runtime.db,
Expand Down
8 changes: 8 additions & 0 deletions packages/control-plane/core/wire-machines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ export interface AddWorkspaceMemberRequest {
role: WorkspaceMemberRole;
/** Per-member override of the workspace default. */
machineTypeId?: string;
/** Whether this member's machine gets its own persistent volume. Default
* true. False provisions the VM with no disk of its own, so nothing on it
* survives the VM — for a throwaway machine that has nothing to keep. */
persistentVolume?: boolean;
}

export interface UpdateWorkspaceMemberRequest {
Expand All @@ -84,6 +88,10 @@ export interface UpdateWorkspaceMemberRequest {
export interface ProvisionMemberMachineRequest {
/** Overrides the workspace default for this one machine (§1a). */
machineTypeId?: string;
/** Whether this member's machine gets its own persistent volume. Default
* true. False provisions the VM with no disk of its own, so nothing on it
* survives the VM — for a throwaway machine that has nothing to keep. */
persistentVolume?: boolean;
}

export interface WorkspaceMemberResponse {
Expand Down
8 changes: 7 additions & 1 deletion packages/control-plane/core/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,13 @@ export interface CreateWorkspaceRequest {
autoProvision?: boolean;
/** Existing org members, added immediately. The creator is the first
* workspace admin and never needs a row here. */
members?: { membershipId: string; role: WorkspaceMemberRole; machineTypeId?: string }[];
members?: {
membershipId: string;
role: WorkspaceMemberRole;
machineTypeId?: string;
/** Default true; false gives that member's machine no volume. */
persistentVolume?: boolean;
}[];
/** The only path where a credential value is sent. */
credentials?: { name: string; label?: string; value: string }[];
/** Copies config — default machine type, agent rule, repos, credential
Expand Down
25 changes: 24 additions & 1 deletion packages/control-plane/core/workspace-members.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { first, rows } from "./db.js";
import { HttpError, isRecord, readJson, requiredString, type JsonValue } from "./http.js";
import {
HttpError,
isBoolean,
isRecord,
readJson,
requiredString,
type JsonValue,
} from "./http.js";
import {
destroyMachine,
machineFor,
Expand Down Expand Up @@ -38,6 +45,12 @@ export function parseAddWorkspaceMember(value: JsonValue): AddWorkspaceMemberReq
if (value.machineTypeId !== undefined && value.machineTypeId !== null) {
result.machineTypeId = requiredString(value.machineTypeId, "machineTypeId", 256);
}
if (value.persistentVolume !== undefined && value.persistentVolume !== null) {
if (!isBoolean(value.persistentVolume)) {
throw new HttpError(400, "persistentVolume must be a boolean");
}
result.persistentVolume = value.persistentVolume;
}
return result;
}

Expand All @@ -47,6 +60,12 @@ function parseProvisionMemberMachine(value: JsonValue): ProvisionMemberMachineRe
if (value.machineTypeId !== undefined && value.machineTypeId !== null) {
result.machineTypeId = requiredString(value.machineTypeId, "machineTypeId", 256);
}
if (value.persistentVolume !== undefined && value.persistentVolume !== null) {
if (!isBoolean(value.persistentVolume)) {
throw new HttpError(400, "persistentVolume must be a boolean");
}
result.persistentVolume = value.persistentVolume;
}
return result;
}

Expand All @@ -68,6 +87,7 @@ function memberProvisionInput(
machineTypeId: string,
requestOrigin: string,
existing: MachineRow | null,
persistentVolume?: boolean,
): ProvisionMachineInput {
const input: ProvisionMachineInput = {
workspace,
Expand All @@ -77,6 +97,7 @@ function memberProvisionInput(
};
if (existing !== null) input.machineId = existing.id;
if (existing?.volume_id != null) input.volumeId = existing.volume_id;
if (persistentVolume !== undefined) input.persistentVolume = persistentVolume;
return input;
}

Expand Down Expand Up @@ -138,6 +159,7 @@ export async function addWorkspaceMember(
input.machineTypeId ?? workspace.default_machine_type_id,
requestOrigin,
existing,
input.persistentVolume,
))
: existing;
return {
Expand Down Expand Up @@ -263,6 +285,7 @@ export function addWorkspaceMemberRoutes(
input.machineTypeId ?? existing?.machine_type_id ?? workspace.default_machine_type_id,
new URL(context.req.url).origin,
existing,
input.persistentVolume,
));
return context.json<WorkspaceMemberResponse>({
member: {
Expand Down
4 changes: 4 additions & 0 deletions packages/control-plane/test/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ export class FakeProviders implements VmProvider, VolumeProvider {
detachCalls = 0;
onCreate?: (machineId: string) => Promise<void>;
onDestroy?: (machineId: string) => Promise<void>;
/** Undefined by default, so the fake places no volume of its own and a
* suite that never asked for one still sees `volume_id` null. A suite that
* exercises the auto-created volume sets it. */
volumeLocation?: (machineTypeId: string) => string | null;

capabilities() {
// Ticket-capable from epoch: workspaces created in tests are new.
Expand Down
65 changes: 65 additions & 0 deletions packages/control-plane/test/member-machines.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,71 @@ describe("member machines", () => {
expect((await machineRow(workspace.id, "personal"))?.state).toBe("destroyed");
});

it("gives every member a volume unless their row asked for none", async () => {
const { app, providers } = harness();
// The fake places no volume unless a suite asks it to; this one is about
// what happens when it can.
providers.volumeLocation = () => "test";
const cookie = await operatorSession(app);
const keeper = await sameOrgSession("volume-keeper");
const throwaway = await sameOrgSession("volume-throwaway");
const later = await sameOrgSession("volume-later");

const created = await appRequest(app, "/workspaces", {
...json({
machineTypeId: "small",
members: [
{ membershipId: keeper.membershipId, role: "member" },
{ membershipId: throwaway.membershipId, role: "member", persistentVolume: false },
],
}),
headers: { Cookie: cookie, "Content-Type": "application/json" },
});
const workspace = (await created.json<CreatedWorkspace>()).workspace;

// Default true: an absent field is a member who keeps their disk.
expect((await machineRow(workspace.id, keeper.membershipId))?.volume_id).not.toBeNull();
// False skips the existing helper, so there is no volume and no ownership
// row to reclaim later.
const bare = await machineRow(workspace.id, throwaway.membershipId);
expect(bare?.volume_id).toBeNull();
expect(await env.DB
.prepare("SELECT COUNT(*) AS count FROM volume_ownership")
.first<number>("count")).toBe(2);

// The manual provision carries the same field, for a workspace whose
// auto-provision is off or a machine that was destroyed.
const added = await appRequest(app, `/workspaces/${workspace.id}/members`, {
...json({ membershipId: later.membershipId, role: "viewer" }),
headers: { Cookie: cookie, "Content-Type": "application/json" },
});
expect(added.status).toBe(201);
expect((await appRequest(app, `/workspaces/${workspace.id}/members/${later.membershipId}`, {
...json({ role: "member" }, "PATCH"),
headers: { Cookie: cookie, "Content-Type": "application/json" },
})).status).toBe(200);
const machineId = await machineIdFor(workspace.id, later.membershipId);
expect((await appRequest(app, `/machines/${machineId}`, {
method: "DELETE",
headers: { Cookie: cookie },
})).status).toBe(200);
await env.DB.prepare("UPDATE machines SET volume_id = NULL WHERE id = ?1")
.bind(machineId).run();

const back = await appRequest(app, `/workspaces/${workspace.id}/members/${later.membershipId}/machine`, {
...json({ persistentVolume: false }),
headers: { Cookie: cookie, "Content-Type": "application/json" },
});
expect(back.status).toBe(201);
expect((await machineRow(workspace.id, later.membershipId))?.volume_id).toBeNull();

// A field that is not a boolean is refused rather than coerced.
expect((await appRequest(app, `/workspaces/${workspace.id}/members`, {
...json({ membershipId: later.membershipId, role: "member", persistentVolume: "no" }),
headers: { Cookie: cookie, "Content-Type": "application/json" },
})).status).toBe(400);
});

it("counts machines against vm_limit, not workspaces", async () => {
const { app } = harness();
const cookie = await operatorSession(app);
Expand Down
10 changes: 8 additions & 2 deletions packages/control-plane/test/wire-drift.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ const addWorkspaceMemberRequest: SharedShape<
membershipId: viewerMember.membershipId,
role: "member",
machineTypeId: pricedMachineType.id,
persistentVolume: false,
};

const updateWorkspaceMemberRequest: SharedShape<
Expand All @@ -204,7 +205,7 @@ const updateWorkspaceMemberRequest: SharedShape<
const provisionMemberMachineRequest: SharedShape<
wire.ProvisionMemberMachineRequest,
schema.ProvisionMemberMachineRequest
> = { machineTypeId: pricedMachineType.id };
> = { machineTypeId: pricedMachineType.id, persistentVolume: true };

// Every settings field at once. `agentRuleId` also travels as an explicit
// null — the way back to the built-in doc — which is different ground than a
Expand Down Expand Up @@ -383,7 +384,12 @@ const createWorkspaceRequest: SharedShape<
machineTypeId: machineType.id,
defaultMachineTypeId: machineType.id,
autoProvision: false,
members: [{ membershipId: viewerMember.membershipId, role: "member", machineTypeId: machineType.id }],
members: [{
membershipId: viewerMember.membershipId,
role: "member",
machineTypeId: machineType.id,
persistentVolume: false,
}],
credentials: [{ name: workspaceCredential.name, label: "live", value: "sk_test_only" }],
cloneFromWorkspaceId: "workspace",
sshPublicKey: "ssh-ed25519 AAAAcaller",
Expand Down
8 changes: 7 additions & 1 deletion packages/schema/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,13 @@ export interface CreateWorkspaceRequest {
autoProvision?: boolean;
/** Existing org members, added immediately. The creator is the first
* workspace admin and never needs a row here. */
members?: { membershipId: string; role: WorkspaceMemberRole; machineTypeId?: string }[];
members?: {
membershipId: string;
role: WorkspaceMemberRole;
machineTypeId?: string;
/** Default true; false gives that member's machine no volume. */
persistentVolume?: boolean;
}[];
/** The only path where a credential value is sent. */
credentials?: { name: string; label?: string; value: string }[];
/** Copies config — default machine type, agent rule, repos, credential
Expand Down
8 changes: 8 additions & 0 deletions packages/schema/src/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ export interface AddWorkspaceMemberRequest {
role: WorkspaceMemberRole;
/** Per-member override of the workspace default. */
machineTypeId?: string;
/** Whether this member's machine gets its own persistent volume. Default
* true. False provisions the VM with no disk of its own, so nothing on it
* survives the VM — for a throwaway machine that has nothing to keep. */
persistentVolume?: boolean;
}

export interface UpdateWorkspaceMemberRequest {
Expand All @@ -171,6 +175,10 @@ export interface UpdateWorkspaceMemberRequest {
export interface ProvisionMemberMachineRequest {
/** Overrides the workspace default for this one machine (§1a). */
machineTypeId?: string;
/** Whether this member's machine gets its own persistent volume. Default
* true. False provisions the VM with no disk of its own, so nothing on it
* survives the VM — for a throwaway machine that has nothing to keep. */
persistentVolume?: boolean;
}

export interface WorkspaceMemberResponse {
Expand Down
49 changes: 30 additions & 19 deletions packages/webapp/src/CloudApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,9 @@ export default function CloudApp({ client, resolver }: CloudAppProps) {
* blank create. Cleared with the dialog. */
const [cloneFromWorkspaceId, setCloneFromWorkspaceId] = useState<string | null>(null);
const [details, setDetails] = useState<
{ workspaceId: string; tab: WorkspaceDetailsTab } | null
{ workspaceId: string; tab: WorkspaceDetailsTab; focusAddMember?: boolean } | null
>(null);
const [machineWorkspaceId, setMachineWorkspaceId] = useState<string | null>(null);
const [createWorkspaceBusy, setCreateWorkspaceBusy] = useState(false);
const [createWorkspaceError, setCreateWorkspaceError] = useState<string | null>(null);
const [confirmation, setConfirmation] = useState<WebAppConfirmation | null>(null);
Expand Down Expand Up @@ -303,7 +304,6 @@ export default function CloudApp({ client, resolver }: CloudAppProps) {
}
}, [api]);
const listMachineTypes = useCallback(() => api.listMachineTypes(), [api]);
const listVolumes = useCallback(() => api.listVolumes(), [api]);
const refreshWorkspaceRecords = useCallback(async () => {
try {
const records = await api.listWorkspaces();
Expand Down Expand Up @@ -773,19 +773,6 @@ export default function CloudApp({ client, resolver }: CloudAppProps) {
});
}, [activeWorkspaceId, setWorkspaceTabs]);

/** The strip's surface icons focus a panel. They open, they never close:
* the right icon strip owns the toggle. */
const openWorkspacePanel = useCallback((panel: WorkspaceDrawerSegment) => {
if (!activeWorkspaceId) return;
updateWorkspaceTabs((tabs) => showPanelTab(tabs, panel));
if (mobileWebApp) {
setDrawerOpen(false);
setFilesDrawerOpen(true);
return;
}
setFocusedRegion('side');
}, [activeWorkspaceId, mobileWebApp, updateWorkspaceTabs]);

const toggleFiles = useCallback(() => {
if (!activeWorkspaceId) return;
if (mobileWebApp) {
Expand Down Expand Up @@ -828,6 +815,15 @@ export default function CloudApp({ client, resolver }: CloudAppProps) {
});
}, []);

/** The tile's inline rename. The name is the workspace's own settings field,
* so it goes through the same PATCH the settings tab writes; the tile shows
* the new name at once and the next poll agrees. */
const renameWorkspace = useCallback((workspaceId: string, name: string) => {
dispatch({ type: 'workspace_renamed', workspaceId, title: name });
void client.updateWorkspace(workspaceId, { name })
.catch((caught: Error) => setError(caught.message));
}, [client]);

const retryWorkspace = useCallback(async (workspaceId: string) => {
const workspace = storeRef.current.workspaces.find(({ id }) => id === workspaceId);
if (workspace?.retryAction === 'destroy') {
Expand Down Expand Up @@ -1375,12 +1371,20 @@ export default function CloudApp({ client, resolver }: CloudAppProps) {
? railSessions
: []}
activeSessionId={railActiveSessionId ?? ''}
openPanels={openPanels}
pendingRequestCount={activePendingRequests.length}
livePorts={orderedLivePorts}
previewLinks={orderedPreviewLinks}
drawerOpen={drawerOpen}
onSelectWorkspace={selectWorkspace}
onRenameWorkspace={renameWorkspace}
onOpenWorkspaceSettings={(workspaceId) => {
if (mobileWebApp) setDrawerOpen(false);
setDetails({ workspaceId, tab: 'settings' });
}}
onInviteToWorkspace={(workspaceId) => {
if (mobileWebApp) setDrawerOpen(false);
setDetails({ workspaceId, tab: 'members', focusAddMember: true });
}}
onCreateWorkspace={() => setShowCreateWorkspace(true)}
onOpenPanel={openWorkspacePanel}
onSwitchOrg={(orgId) => {
void client.switchOrg(orgId).then(() => window.location.reload());
}}
Expand All @@ -1389,6 +1393,8 @@ export default function CloudApp({ client, resolver }: CloudAppProps) {
onOpenSettings={() => navigateToSettings('profile')}
onSelectSession={selectTtydSession}
onSpawnSession={spawnTtydSession}
onOpenPreview={(port) => { openPreviewPort(port); }}
onOpenPreviewLink={(url, title) => { openPreviewLink(url, title); }}
onOpenWorkspaceMembers={(workspaceId) => {
if (mobileWebApp) setDrawerOpen(false);
setDetails({ workspaceId, tab: 'members' });
Expand All @@ -1397,6 +1403,10 @@ export default function CloudApp({ client, resolver }: CloudAppProps) {
if (mobileWebApp) setDrawerOpen(false);
setDetails({ workspaceId, tab: 'members' });
}}
onOpenWorkspaceMachine={(workspaceId) => {
if (mobileWebApp) setDrawerOpen(false);
setMachineWorkspaceId(workspaceId);
}}
onCloseDrawer={() => setDrawerOpen(false)}
/>
);
Expand All @@ -1417,7 +1427,6 @@ export default function CloudApp({ client, resolver }: CloudAppProps) {
createWorkspaceBusy={createWorkspaceBusy}
createWorkspaceError={createWorkspaceError}
listMachineTypes={listMachineTypes}
listVolumes={listVolumes}
cloneFromWorkspaceId={cloneFromWorkspaceId}
onCancelCreateWorkspace={() => {
if (createWorkspaceBusy) return;
Expand All @@ -1427,6 +1436,8 @@ export default function CloudApp({ client, resolver }: CloudAppProps) {
onCreateWorkspace={(input) => { void createWorkspace(input); }}
details={details}
onCloseDetails={() => setDetails(null)}
machineWorkspaceId={machineWorkspaceId}
onCloseMachine={() => setMachineWorkspaceId(null)}
onCloneWorkspace={(workspaceId) => {
// "New workspace from existing" IS the template now (§0): the create
// dialog opens carrying the source, and the server copies its config.
Expand Down
Loading
Loading