diff --git a/components/dashboard/src/icons/Stack.svg b/components/dashboard/src/icons/Stack.svg new file mode 100644 index 00000000000000..ffdd933eb2bf58 --- /dev/null +++ b/components/dashboard/src/icons/Stack.svg @@ -0,0 +1,3 @@ + + + diff --git a/components/dashboard/src/teams/TeamSettings.tsx b/components/dashboard/src/teams/TeamSettings.tsx index c7bcbac4d3fbe4..a3db0758520a25 100644 --- a/components/dashboard/src/teams/TeamSettings.tsx +++ b/components/dashboard/src/teams/TeamSettings.tsx @@ -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"; @@ -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(); @@ -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) => { @@ -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 ( { e.preventDefault(); - handleUpdateTeamSettings({ defaultWorkspaceImage }); + // handleUpdateTeamSettings({ defaultWorkspaceImage }); }} > Collaboration & Sharing - - Choose which workspace images you want to use for your workspaces. - {updateTeamSettings.isError && ( @@ -237,22 +221,173 @@ function OrgSettingsForm(props: { org?: OrganizationInfo }) { disabled={isLoading || !org?.isOwner} /> - Workspace Settings - Workspace Images + + Choose a default image for all workspaces in the organization. + + + setShowImageEditModal(true)} /> - {org?.isOwner && ( - - Update Default Image - + {showImageEditModal && ( + setShowImageEditModal(false)} + /> )} ); } + +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(Default image); + } + if (props.disabled) { + arr.push( + <> + Requires Owner 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(<> · >); + } + return acc; + }, []); + }, [descList]); + + return ( + + + + + + + + + {parseDockerImage(image).repository} + + · + + {parseDockerImage(image).tag} + + + {!props.disabled && ( + + Change + + )} + + {descList.length > 0 && ( + {renderedDescription} + )} + + + ); +} + +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) => { + try { + await updateTeamSettings.mutateAsync({ + ...props.settings, + ...newSettings, + }); + props.onClose(); + } catch (error) { + console.error(error); + setErrorMsg(error.message); + } + }, + [updateTeamSettings, props], + ); + + return ( + handleUpdateTeamSettings({ defaultWorkspaceImage })} + > + Workspace Default Image + + + Warning: You are setting a default image for all workspaces + within the organization. + + {errorMsg.length > 0 && ( + + {errorMsg} + + )} + + + + + + Update Workspace Default Image + + + ); +} diff --git a/components/gitpod-protocol/src/messaging/error.ts b/components/gitpod-protocol/src/messaging/error.ts index 3d621c5480eae7..655edc73197949 100644 --- a/components/gitpod-protocol/src/messaging/error.ts +++ b/components/gitpod-protocol/src/messaging/error.ts @@ -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) { @@ -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 { diff --git a/components/image-builder-api/typescript/src/sugar.ts b/components/image-builder-api/typescript/src/sugar.ts index 64fe27e5df079c..4d77d2ca320347 100644 --- a/components/image-builder-api/typescript/src/sugar.ts +++ b/components/image-builder-api/typescript/src/sugar.ts @@ -33,7 +33,7 @@ export const ImageBuilderClientProvider = Symbol("ImageBuilderClientProvider"); export interface ImageBuilderClientProvider { getClient( user: User, - workspace: Workspace, + workspace?: Workspace, instance?: WorkspaceInstance, region?: string, ): Promise; @@ -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(); } diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index d6d12ad8efc69e..b0eab46cb829e9 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -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); } diff --git a/components/server/src/workspace/workspace-cluster-imagebuilder-client-provider.ts b/components/server/src/workspace/workspace-cluster-imagebuilder-client-provider.ts index 9e4a2a6ff8d815..f76f783f9ab80d 100644 --- a/components/server/src/workspace/workspace-cluster-imagebuilder-client-provider.ts +++ b/components/server/src/workspace/workspace-cluster-imagebuilder-client-provider.ts @@ -31,8 +31,8 @@ export class WorkspaceClusterImagebuilderClientProvider implements ImageBuilderC async getClient( user: User, - workspace: Workspace, - instance: WorkspaceInstance, + workspace?: Workspace, + instance?: WorkspaceInstance, region?: WorkspaceRegion, ): Promise { const clusters = await this.clientProvider.getStartClusterSets(user, workspace, instance, region); diff --git a/components/server/src/workspace/workspace-service.ts b/components/server/src/workspace/workspace-service.ts index ab2fd5c1de952d..7f756900cb6f35 100644 --- a/components/server/src/workspace/workspace-service.ts +++ b/components/server/src/workspace/workspace-service.ts @@ -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 diff --git a/components/server/src/workspace/workspace-starter.ts b/components/server/src/workspace/workspace-starter.ts index 38a0bc62fd2035..0a833d229fda6b 100644 --- a/components/server/src/workspace/workspace-starter.ts +++ b/components/server/src/workspace/workspace-starter.ts @@ -266,16 +266,14 @@ export class WorkspaceStarter { } if (options.forceDefaultImage) { - const req = new ResolveBaseImageRequest(); - req.setRef(this.config.workspaceDefaults.workspaceImage); - const allowAll = new BuildRegistryAuthTotal(); - allowAll.setAllowAll(true); - const auth = new BuildRegistryAuth(); - auth.setTotal(allowAll); - req.setAuth(auth); - - const client = await this.getImageBuilderClient(user, workspace, undefined, options?.region); - const res = await client.resolveBaseImage({ span }, req); + const res = await this.resolveBaseImage( + { span }, + user, + this.config.workspaceDefaults.workspaceImage, + workspace, + undefined, + options.region, + ); workspace.imageSource = { baseImageResolved: res.getRef(), }; @@ -1946,13 +1944,32 @@ export class WorkspaceStarter { */ private async getImageBuilderClient( user: User, - workspace: Workspace, + workspace?: Workspace, instance?: WorkspaceInstance, region?: WorkspaceRegion, ) { return this.imagebuilderClientProvider.getClient(user, workspace, instance, region); } + public async resolveBaseImage( + ctx: TraceContext, + user: User, + imageRef: string, + workspace?: Workspace, + instance?: WorkspaceInstance, + region?: WorkspaceRegion, + ) { + const req = new ResolveBaseImageRequest(); + req.setRef(imageRef); + const allowAll = new BuildRegistryAuthTotal(); + allowAll.setAllowAll(true); + const auth = new BuildRegistryAuth(); + auth.setTotal(allowAll); + req.setAuth(auth); + const client = await this.getImageBuilderClient(user, workspace, instance, region); + return client.resolveBaseImage({ span: ctx.span }, req); + } + private async existsWithWsManager(ctx: TraceContext, instance: WorkspaceInstance): Promise { try { const req = new DescribeWorkspaceRequest(); diff --git a/components/ws-manager-api/typescript/src/client-provider.ts b/components/ws-manager-api/typescript/src/client-provider.ts index 97983f7685867b..18a5b5056bca11 100644 --- a/components/ws-manager-api/typescript/src/client-provider.ts +++ b/components/ws-manager-api/typescript/src/client-provider.ts @@ -51,8 +51,8 @@ export class WorkspaceManagerClientProvider implements Disposable { */ public async getStartClusterSets( user: User, - workspace: Workspace, - instance: WorkspaceInstance, + workspace?: Workspace, + instance?: WorkspaceInstance, region?: WorkspaceRegion, ): Promise { const allClusters = await this.source.getAllWorkspaceClusters(); diff --git a/components/ws-manager-api/typescript/src/constraints.ts b/components/ws-manager-api/typescript/src/constraints.ts index f108f11fb332ef..b76ad6283e99dd 100644 --- a/components/ws-manager-api/typescript/src/constraints.ts +++ b/components/ws-manager-api/typescript/src/constraints.ts @@ -43,8 +43,8 @@ export type Constraint = (all: WorkspaceClusterWoTLS[], args: ConstraintArgs) => export type ConstraintArgs = { user: User; - workspace: Workspace; - instance: WorkspaceInstance; + workspace?: Workspace; + instance?: WorkspaceInstance; region?: string; };