Skip to content

Commit

Permalink
[server,dashboard] verify image accessibility when updating org works…
Browse files Browse the repository at this point in the history
…pace image setting (#18747)

* [server] verify image accessibility before update settings

* [dashboard] update org settings page

* fixup disable button for members

* Update components/dashboard/src/teams/TeamSettings.tsx

Co-authored-by: George Tsiolis <[email protected]>

* Update components/dashboard/src/teams/TeamSettings.tsx

Co-authored-by: George Tsiolis <[email protected]>

* Update components/dashboard/src/teams/TeamSettings.tsx

Co-authored-by: George Tsiolis <[email protected]>

* Update components/dashboard/src/teams/TeamSettings.tsx

Co-authored-by: George Tsiolis <[email protected]>

* Update components/dashboard/src/teams/TeamSettings.tsx

Co-authored-by: George Tsiolis <[email protected]>

* hid change button for members

* Update components/dashboard/src/teams/TeamSettings.tsx

Co-authored-by: George Tsiolis <[email protected]>

* Update components/dashboard/src/teams/TeamSettings.tsx

Co-authored-by: Filip Troníček <[email protected]>

* update image hint

* Add description to WorkspaceImageButton

* Lighter description

* render desc list

---------

Co-authored-by: George Tsiolis <[email protected]>
Co-authored-by: Filip Troníček <[email protected]>
  • Loading branch information
3 people authored Sep 20, 2023
1 parent 8d2c8d8 commit 181c983
Show file tree
Hide file tree
Showing 10 changed files with 251 additions and 56 deletions.
3 changes: 3 additions & 0 deletions components/dashboard/src/icons/Stack.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
207 changes: 171 additions & 36 deletions components/dashboard/src/teams/TeamSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import { OrganizationSettings } from "@gitpod/gitpod-protocol";
import React, { useCallback, useState, useEffect } from "react";
import React, { Children, ReactNode, useCallback, useMemo, useState } from "react";
import Alert from "../components/Alert";
import { Button } from "../components/Button";
import { CheckboxInputField } from "../components/forms/CheckboxInputField";
Expand All @@ -21,8 +21,10 @@ import { teamsService } from "../service/public-api";
import { gitpodHostUrl } from "../service/service";
import { useCurrentUser } from "../user-context";
import { OrgSettingsPage } from "./OrgSettingsPage";
import { useToast } from "../components/toasts/Toasts";
import { useDefaultWorkspaceImageQuery } from "../data/workspaces/default-workspace-image-query";
import Modal, { ModalBody, ModalFooter, ModalHeader } from "../components/Modal";
import { InputField } from "../components/forms/InputField";
import { ReactComponent as Stack } from "../icons/Stack.svg";

export default function TeamSettingsPage() {
const user = useCurrentUser();
Expand Down Expand Up @@ -172,15 +174,8 @@ function OrgSettingsForm(props: { org?: OrganizationInfo }) {
const { data: settings, isLoading } = useOrgSettingsQuery();
const { data: globalDefaultImage } = useDefaultWorkspaceImageQuery();
const updateTeamSettings = useUpdateOrgSettingsMutation();
const [defaultWorkspaceImage, setDefaultWorkspaceImage] = useState(settings?.defaultWorkspaceImage ?? "");
const { toast } = useToast();

useEffect(() => {
if (!settings) {
return;
}
setDefaultWorkspaceImage(settings.defaultWorkspaceImage ?? "");
}, [settings]);
const [showImageEditModal, setShowImageEditModal] = useState(false);

const handleUpdateTeamSettings = useCallback(
async (newSettings: Partial<OrganizationSettings>) => {
Expand All @@ -195,32 +190,21 @@ function OrgSettingsForm(props: { org?: OrganizationInfo }) {
...settings,
...newSettings,
});
if (newSettings.defaultWorkspaceImage !== undefined) {
toast("Default workspace image has been updated.");
}
} catch (error) {
console.error(error);
toast(
error.message
? "Failed to update organization settings: " + error.message
: "Oh no, there was a problem with our service.",
);
}
},
[updateTeamSettings, org?.id, org?.isOwner, settings, toast],
[updateTeamSettings, org?.id, org?.isOwner, settings],
);

return (
<form
onSubmit={(e) => {
e.preventDefault();
handleUpdateTeamSettings({ defaultWorkspaceImage });
// handleUpdateTeamSettings({ defaultWorkspaceImage });
}}
>
<Heading2 className="pt-12">Collaboration & Sharing</Heading2>
<Subheading className="max-w-2xl">
Choose which workspace images you want to use for your workspaces.
</Subheading>

{updateTeamSettings.isError && (
<Alert type="error" closable={true} className="mb-2 max-w-xl rounded-md">
Expand All @@ -237,22 +221,173 @@ function OrgSettingsForm(props: { org?: OrganizationInfo }) {
disabled={isLoading || !org?.isOwner}
/>

<Heading2 className="pt-12">Workspace Settings</Heading2>
<TextInputField
label="Default Image"
// TODO: Provide document links
hint="Use any official Gitpod Docker image, or Docker image reference"
placeholder={globalDefaultImage}
value={defaultWorkspaceImage}
onChange={setDefaultWorkspaceImage}
disabled={isLoading || !org?.isOwner}
<Heading2 className="pt-12">Workspace Images</Heading2>
<Subheading className="max-w-2xl">
Choose a default image for all workspaces in the organization.
</Subheading>

<WorkspaceImageButton
disabled={!org?.isOwner}
settings={settings}
defaultWorkspaceImage={globalDefaultImage}
onClick={() => setShowImageEditModal(true)}
/>

{org?.isOwner && (
<Button htmlType="submit" className="mt-4" disabled={!org.isOwner}>
Update Default Image
</Button>
{showImageEditModal && (
<OrgDefaultWorkspaceImageModal
settings={settings}
globalDefaultImage={globalDefaultImage}
onClose={() => setShowImageEditModal(false)}
/>
)}
</form>
);
}

function WorkspaceImageButton(props: {
settings?: OrganizationSettings;
defaultWorkspaceImage?: string;
onClick: () => void;
disabled?: boolean;
}) {
function parseDockerImage(image: string) {
// https://docs.docker.com/registry/spec/api/
let registry, repository, tag;
let parts = image.split("/");

if (parts.length > 1 && parts[0].includes(".")) {
registry = parts.shift();
} else {
registry = "docker.io";
}

const remaining = parts.join("/");
[repository, tag] = remaining.split(":");
if (!tag) {
tag = "latest";
}
return {
registry,
repository,
tag,
};
}

const image = props.settings?.defaultWorkspaceImage ?? props.defaultWorkspaceImage ?? "";

const descList = useMemo(() => {
const arr: ReactNode[] = [];
if (!props.settings?.defaultWorkspaceImage) {
arr.push(<span className="font-medium">Default image</span>);
}
if (props.disabled) {
arr.push(
<>
Requires <span className="font-medium">Owner</span> permissions to change
</>,
);
}
return arr;
}, [props.settings, props.disabled]);

const renderedDescription = useMemo(() => {
return Children.toArray(descList).reduce((acc: ReactNode[], child, index) => {
acc.push(child);
if (index < descList.length - 1) {
acc.push(<>&nbsp;&middot;&nbsp;</>);
}
return acc;
}, []);
}, [descList]);

return (
<InputField disabled={props.disabled} className="w-full max-w-lg">
<div className="flex flex-col bg-gray-50 dark:bg-gray-800 p-3 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center overflow-hidden h-8" title={image}>
<span className="w-5 h-5 mr-1">
<Stack />
</span>
<span className="truncate font-medium text-gray-700 dark:text-gray-200">
{parseDockerImage(image).repository}
</span>
&nbsp;&middot;&nbsp;
<span className="flex-none w-16 truncate text-gray-500 dark:text-gray-400">
{parseDockerImage(image).tag}
</span>
</div>
{!props.disabled && (
<Button htmlType="button" type="transparent" className="text-blue-500" onClick={props.onClick}>
Change
</Button>
)}
</div>
{descList.length > 0 && (
<div className="mx-6 text-gray-400 dark:text-gray-500 truncate">{renderedDescription}</div>
)}
</div>
</InputField>
);
}

interface OrgDefaultWorkspaceImageModalProps {
globalDefaultImage: string | undefined;
settings: OrganizationSettings | undefined;
onClose: () => void;
}

function OrgDefaultWorkspaceImageModal(props: OrgDefaultWorkspaceImageModalProps) {
const [errorMsg, setErrorMsg] = useState("");
const [defaultWorkspaceImage, setDefaultWorkspaceImage] = useState(props.settings?.defaultWorkspaceImage ?? "");
const updateTeamSettings = useUpdateOrgSettingsMutation();

const handleUpdateTeamSettings = useCallback(
async (newSettings: Partial<OrganizationSettings>) => {
try {
await updateTeamSettings.mutateAsync({
...props.settings,
...newSettings,
});
props.onClose();
} catch (error) {
console.error(error);
setErrorMsg(error.message);
}
},
[updateTeamSettings, props],
);

return (
<Modal
visible
closeable
onClose={props.onClose}
onSubmit={() => handleUpdateTeamSettings({ defaultWorkspaceImage })}
>
<ModalHeader>Workspace Default Image</ModalHeader>
<ModalBody>
<Alert type="warning" className="mb-2">
<span className="font-medium">Warning:</span> You are setting a default image for all workspaces
within the organization.
</Alert>
{errorMsg.length > 0 && (
<Alert type="error" className="mb-2">
{errorMsg}
</Alert>
)}
<div className="mt-4">
<TextInputField
label="Default Image"
hint="Use any official or custom workspace image from Docker Hub or any private container registry that the Gitpod instance can access."
placeholder={props.globalDefaultImage}
value={defaultWorkspaceImage}
onChange={setDefaultWorkspaceImage}
/>
</div>
</ModalBody>
<ModalFooter>
<Button htmlType="submit">Update Workspace Default Image</Button>
</ModalFooter>
</Modal>
);
}
28 changes: 28 additions & 0 deletions components/gitpod-protocol/src/messaging/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import { scrubber } from "../util/scrubbing";
import { Status } from "nice-grpc-common";

export class ApplicationError extends Error {
constructor(public readonly code: ErrorCode, message: string, public readonly data?: any) {
Expand Down Expand Up @@ -36,6 +37,33 @@ export namespace ApplicationError {
throw e;
}
}

export function fromGRPCError(e: any & Error, data?: any): ApplicationError {
// Argument e should be ServerErrorResponse
// But to reduce dependency requirement, we use Error here
return new ApplicationError(categorizeRPCError(e.code), e.message, data);
}

export function categorizeRPCError(code?: Status): ErrorCode {
// Mostly align to https://github.com/gitpod-io/gitpod/blob/ef95e6f3ca0bf314c40da1b83251423c2208d175/components/public-api-server/pkg/proxy/errors.go#L25
switch (code) {
case Status.INVALID_ARGUMENT:
return ErrorCodes.BAD_REQUEST;
case Status.UNAUTHENTICATED:
return ErrorCodes.NOT_AUTHENTICATED;
case Status.PERMISSION_DENIED:
return ErrorCodes.PERMISSION_DENIED; // or UserBlocked
case Status.NOT_FOUND:
return ErrorCodes.NOT_FOUND;
case Status.ALREADY_EXISTS:
return ErrorCodes.CONFLICT;
case Status.FAILED_PRECONDITION:
return ErrorCodes.PRECONDITION_FAILED;
case Status.RESOURCE_EXHAUSTED:
return ErrorCodes.TOO_MANY_REQUESTS;
}
return ErrorCodes.INTERNAL_SERVER_ERROR;
}
}

export namespace ErrorCode {
Expand Down
4 changes: 2 additions & 2 deletions components/image-builder-api/typescript/src/sugar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export const ImageBuilderClientProvider = Symbol("ImageBuilderClientProvider");
export interface ImageBuilderClientProvider {
getClient(
user: User,
workspace: Workspace,
workspace?: Workspace,
instance?: WorkspaceInstance,
region?: string,
): Promise<PromisifiedImageBuilderClient>;
Expand Down Expand Up @@ -96,7 +96,7 @@ export class CachingImageBuilderClientProvider implements ImageBuilderClientProv
return connection;
}

async getClient(user: User, workspace: Workspace, instance?: WorkspaceInstance) {
async getClient(user: User, workspace?: Workspace, instance?: WorkspaceInstance) {
return this.getDefault();
}

Expand Down
4 changes: 3 additions & 1 deletion components/server/src/workspace/gitpod-server-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2493,7 +2493,9 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
const user = await this.checkAndBlockUser("updateOrgSettings");
traceAPIParams(ctx, { orgId, userId: user.id });
await this.guardTeamOperation(orgId, "update");
// TODO: call ImageBuilder ResolveBaseImage to dry test if we can access this image
if (settings.defaultWorkspaceImage?.trim()) {
await this.workspaceService.resolveBaseImage(ctx, user, settings.defaultWorkspaceImage);
}
return this.organizationService.updateSettings(user.id, orgId, settings);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ export class WorkspaceClusterImagebuilderClientProvider implements ImageBuilderC

async getClient(
user: User,
workspace: Workspace,
instance: WorkspaceInstance,
workspace?: Workspace,
instance?: WorkspaceInstance,
region?: WorkspaceRegion,
): Promise<PromisifiedImageBuilderClient> {
const clusters = await this.clientProvider.getStartClusterSets(user, workspace, instance, region);
Expand Down
10 changes: 10 additions & 0 deletions components/server/src/workspace/workspace-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -898,6 +898,16 @@ export class WorkspaceService {
await this.auth.setWorkspaceIsShared(userId, workspaceId, shareable);
});
}

public async resolveBaseImage(ctx: TraceContext, user: User, imageRef: string) {
try {
return this.workspaceStarter.resolveBaseImage(ctx, user, imageRef);
} catch (e) {
// we could map proper response message according to e.code
// see https://github.com/gitpod-io/gitpod/blob/ef95e6f3ca0bf314c40da1b83251423c2208d175/components/image-builder-mk3/pkg/orchestrator/orchestrator_test.go#L178
throw ApplicationError.fromGRPCError(e);
}
}
}

// TODO(gpl) Make private after FGA rollout
Expand Down
Loading

0 comments on commit 181c983

Please sign in to comment.