Skip to content

Commit 181c983

Browse files
mustard-mhgtsiolisfiliptronicek
authored
[server,dashboard] verify image accessibility when updating org workspace 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]>
1 parent 8d2c8d8 commit 181c983

File tree

10 files changed

+251
-56
lines changed

10 files changed

+251
-56
lines changed
Lines changed: 3 additions & 0 deletions
Loading

components/dashboard/src/teams/TeamSettings.tsx

Lines changed: 171 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66

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

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

178-
useEffect(() => {
179-
if (!settings) {
180-
return;
181-
}
182-
setDefaultWorkspaceImage(settings.defaultWorkspaceImage ?? "");
183-
}, [settings]);
178+
const [showImageEditModal, setShowImageEditModal] = useState(false);
184179

185180
const handleUpdateTeamSettings = useCallback(
186181
async (newSettings: Partial<OrganizationSettings>) => {
@@ -195,32 +190,21 @@ function OrgSettingsForm(props: { org?: OrganizationInfo }) {
195190
...settings,
196191
...newSettings,
197192
});
198-
if (newSettings.defaultWorkspaceImage !== undefined) {
199-
toast("Default workspace image has been updated.");
200-
}
201193
} catch (error) {
202194
console.error(error);
203-
toast(
204-
error.message
205-
? "Failed to update organization settings: " + error.message
206-
: "Oh no, there was a problem with our service.",
207-
);
208195
}
209196
},
210-
[updateTeamSettings, org?.id, org?.isOwner, settings, toast],
197+
[updateTeamSettings, org?.id, org?.isOwner, settings],
211198
);
212199

213200
return (
214201
<form
215202
onSubmit={(e) => {
216203
e.preventDefault();
217-
handleUpdateTeamSettings({ defaultWorkspaceImage });
204+
// handleUpdateTeamSettings({ defaultWorkspaceImage });
218205
}}
219206
>
220207
<Heading2 className="pt-12">Collaboration & Sharing</Heading2>
221-
<Subheading className="max-w-2xl">
222-
Choose which workspace images you want to use for your workspaces.
223-
</Subheading>
224208

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

240-
<Heading2 className="pt-12">Workspace Settings</Heading2>
241-
<TextInputField
242-
label="Default Image"
243-
// TODO: Provide document links
244-
hint="Use any official Gitpod Docker image, or Docker image reference"
245-
placeholder={globalDefaultImage}
246-
value={defaultWorkspaceImage}
247-
onChange={setDefaultWorkspaceImage}
248-
disabled={isLoading || !org?.isOwner}
224+
<Heading2 className="pt-12">Workspace Images</Heading2>
225+
<Subheading className="max-w-2xl">
226+
Choose a default image for all workspaces in the organization.
227+
</Subheading>
228+
229+
<WorkspaceImageButton
230+
disabled={!org?.isOwner}
231+
settings={settings}
232+
defaultWorkspaceImage={globalDefaultImage}
233+
onClick={() => setShowImageEditModal(true)}
249234
/>
250235

251-
{org?.isOwner && (
252-
<Button htmlType="submit" className="mt-4" disabled={!org.isOwner}>
253-
Update Default Image
254-
</Button>
236+
{showImageEditModal && (
237+
<OrgDefaultWorkspaceImageModal
238+
settings={settings}
239+
globalDefaultImage={globalDefaultImage}
240+
onClose={() => setShowImageEditModal(false)}
241+
/>
255242
)}
256243
</form>
257244
);
258245
}
246+
247+
function WorkspaceImageButton(props: {
248+
settings?: OrganizationSettings;
249+
defaultWorkspaceImage?: string;
250+
onClick: () => void;
251+
disabled?: boolean;
252+
}) {
253+
function parseDockerImage(image: string) {
254+
// https://docs.docker.com/registry/spec/api/
255+
let registry, repository, tag;
256+
let parts = image.split("/");
257+
258+
if (parts.length > 1 && parts[0].includes(".")) {
259+
registry = parts.shift();
260+
} else {
261+
registry = "docker.io";
262+
}
263+
264+
const remaining = parts.join("/");
265+
[repository, tag] = remaining.split(":");
266+
if (!tag) {
267+
tag = "latest";
268+
}
269+
return {
270+
registry,
271+
repository,
272+
tag,
273+
};
274+
}
275+
276+
const image = props.settings?.defaultWorkspaceImage ?? props.defaultWorkspaceImage ?? "";
277+
278+
const descList = useMemo(() => {
279+
const arr: ReactNode[] = [];
280+
if (!props.settings?.defaultWorkspaceImage) {
281+
arr.push(<span className="font-medium">Default image</span>);
282+
}
283+
if (props.disabled) {
284+
arr.push(
285+
<>
286+
Requires <span className="font-medium">Owner</span> permissions to change
287+
</>,
288+
);
289+
}
290+
return arr;
291+
}, [props.settings, props.disabled]);
292+
293+
const renderedDescription = useMemo(() => {
294+
return Children.toArray(descList).reduce((acc: ReactNode[], child, index) => {
295+
acc.push(child);
296+
if (index < descList.length - 1) {
297+
acc.push(<>&nbsp;&middot;&nbsp;</>);
298+
}
299+
return acc;
300+
}, []);
301+
}, [descList]);
302+
303+
return (
304+
<InputField disabled={props.disabled} className="w-full max-w-lg">
305+
<div className="flex flex-col bg-gray-50 dark:bg-gray-800 p-3 rounded-lg">
306+
<div className="flex items-center justify-between">
307+
<div className="flex items-center overflow-hidden h-8" title={image}>
308+
<span className="w-5 h-5 mr-1">
309+
<Stack />
310+
</span>
311+
<span className="truncate font-medium text-gray-700 dark:text-gray-200">
312+
{parseDockerImage(image).repository}
313+
</span>
314+
&nbsp;&middot;&nbsp;
315+
<span className="flex-none w-16 truncate text-gray-500 dark:text-gray-400">
316+
{parseDockerImage(image).tag}
317+
</span>
318+
</div>
319+
{!props.disabled && (
320+
<Button htmlType="button" type="transparent" className="text-blue-500" onClick={props.onClick}>
321+
Change
322+
</Button>
323+
)}
324+
</div>
325+
{descList.length > 0 && (
326+
<div className="mx-6 text-gray-400 dark:text-gray-500 truncate">{renderedDescription}</div>
327+
)}
328+
</div>
329+
</InputField>
330+
);
331+
}
332+
333+
interface OrgDefaultWorkspaceImageModalProps {
334+
globalDefaultImage: string | undefined;
335+
settings: OrganizationSettings | undefined;
336+
onClose: () => void;
337+
}
338+
339+
function OrgDefaultWorkspaceImageModal(props: OrgDefaultWorkspaceImageModalProps) {
340+
const [errorMsg, setErrorMsg] = useState("");
341+
const [defaultWorkspaceImage, setDefaultWorkspaceImage] = useState(props.settings?.defaultWorkspaceImage ?? "");
342+
const updateTeamSettings = useUpdateOrgSettingsMutation();
343+
344+
const handleUpdateTeamSettings = useCallback(
345+
async (newSettings: Partial<OrganizationSettings>) => {
346+
try {
347+
await updateTeamSettings.mutateAsync({
348+
...props.settings,
349+
...newSettings,
350+
});
351+
props.onClose();
352+
} catch (error) {
353+
console.error(error);
354+
setErrorMsg(error.message);
355+
}
356+
},
357+
[updateTeamSettings, props],
358+
);
359+
360+
return (
361+
<Modal
362+
visible
363+
closeable
364+
onClose={props.onClose}
365+
onSubmit={() => handleUpdateTeamSettings({ defaultWorkspaceImage })}
366+
>
367+
<ModalHeader>Workspace Default Image</ModalHeader>
368+
<ModalBody>
369+
<Alert type="warning" className="mb-2">
370+
<span className="font-medium">Warning:</span> You are setting a default image for all workspaces
371+
within the organization.
372+
</Alert>
373+
{errorMsg.length > 0 && (
374+
<Alert type="error" className="mb-2">
375+
{errorMsg}
376+
</Alert>
377+
)}
378+
<div className="mt-4">
379+
<TextInputField
380+
label="Default Image"
381+
hint="Use any official or custom workspace image from Docker Hub or any private container registry that the Gitpod instance can access."
382+
placeholder={props.globalDefaultImage}
383+
value={defaultWorkspaceImage}
384+
onChange={setDefaultWorkspaceImage}
385+
/>
386+
</div>
387+
</ModalBody>
388+
<ModalFooter>
389+
<Button htmlType="submit">Update Workspace Default Image</Button>
390+
</ModalFooter>
391+
</Modal>
392+
);
393+
}

components/gitpod-protocol/src/messaging/error.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66

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

910
export class ApplicationError extends Error {
1011
constructor(public readonly code: ErrorCode, message: string, public readonly data?: any) {
@@ -36,6 +37,33 @@ export namespace ApplicationError {
3637
throw e;
3738
}
3839
}
40+
41+
export function fromGRPCError(e: any & Error, data?: any): ApplicationError {
42+
// Argument e should be ServerErrorResponse
43+
// But to reduce dependency requirement, we use Error here
44+
return new ApplicationError(categorizeRPCError(e.code), e.message, data);
45+
}
46+
47+
export function categorizeRPCError(code?: Status): ErrorCode {
48+
// Mostly align to https://github.com/gitpod-io/gitpod/blob/ef95e6f3ca0bf314c40da1b83251423c2208d175/components/public-api-server/pkg/proxy/errors.go#L25
49+
switch (code) {
50+
case Status.INVALID_ARGUMENT:
51+
return ErrorCodes.BAD_REQUEST;
52+
case Status.UNAUTHENTICATED:
53+
return ErrorCodes.NOT_AUTHENTICATED;
54+
case Status.PERMISSION_DENIED:
55+
return ErrorCodes.PERMISSION_DENIED; // or UserBlocked
56+
case Status.NOT_FOUND:
57+
return ErrorCodes.NOT_FOUND;
58+
case Status.ALREADY_EXISTS:
59+
return ErrorCodes.CONFLICT;
60+
case Status.FAILED_PRECONDITION:
61+
return ErrorCodes.PRECONDITION_FAILED;
62+
case Status.RESOURCE_EXHAUSTED:
63+
return ErrorCodes.TOO_MANY_REQUESTS;
64+
}
65+
return ErrorCodes.INTERNAL_SERVER_ERROR;
66+
}
3967
}
4068

4169
export namespace ErrorCode {

components/image-builder-api/typescript/src/sugar.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export const ImageBuilderClientProvider = Symbol("ImageBuilderClientProvider");
3333
export interface ImageBuilderClientProvider {
3434
getClient(
3535
user: User,
36-
workspace: Workspace,
36+
workspace?: Workspace,
3737
instance?: WorkspaceInstance,
3838
region?: string,
3939
): Promise<PromisifiedImageBuilderClient>;
@@ -96,7 +96,7 @@ export class CachingImageBuilderClientProvider implements ImageBuilderClientProv
9696
return connection;
9797
}
9898

99-
async getClient(user: User, workspace: Workspace, instance?: WorkspaceInstance) {
99+
async getClient(user: User, workspace?: Workspace, instance?: WorkspaceInstance) {
100100
return this.getDefault();
101101
}
102102

components/server/src/workspace/gitpod-server-impl.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2493,7 +2493,9 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
24932493
const user = await this.checkAndBlockUser("updateOrgSettings");
24942494
traceAPIParams(ctx, { orgId, userId: user.id });
24952495
await this.guardTeamOperation(orgId, "update");
2496-
// TODO: call ImageBuilder ResolveBaseImage to dry test if we can access this image
2496+
if (settings.defaultWorkspaceImage?.trim()) {
2497+
await this.workspaceService.resolveBaseImage(ctx, user, settings.defaultWorkspaceImage);
2498+
}
24972499
return this.organizationService.updateSettings(user.id, orgId, settings);
24982500
}
24992501

components/server/src/workspace/workspace-cluster-imagebuilder-client-provider.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ export class WorkspaceClusterImagebuilderClientProvider implements ImageBuilderC
3131

3232
async getClient(
3333
user: User,
34-
workspace: Workspace,
35-
instance: WorkspaceInstance,
34+
workspace?: Workspace,
35+
instance?: WorkspaceInstance,
3636
region?: WorkspaceRegion,
3737
): Promise<PromisifiedImageBuilderClient> {
3838
const clusters = await this.clientProvider.getStartClusterSets(user, workspace, instance, region);

components/server/src/workspace/workspace-service.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -898,6 +898,16 @@ export class WorkspaceService {
898898
await this.auth.setWorkspaceIsShared(userId, workspaceId, shareable);
899899
});
900900
}
901+
902+
public async resolveBaseImage(ctx: TraceContext, user: User, imageRef: string) {
903+
try {
904+
return this.workspaceStarter.resolveBaseImage(ctx, user, imageRef);
905+
} catch (e) {
906+
// we could map proper response message according to e.code
907+
// see https://github.com/gitpod-io/gitpod/blob/ef95e6f3ca0bf314c40da1b83251423c2208d175/components/image-builder-mk3/pkg/orchestrator/orchestrator_test.go#L178
908+
throw ApplicationError.fromGRPCError(e);
909+
}
910+
}
901911
}
902912

903913
// TODO(gpl) Make private after FGA rollout

0 commit comments

Comments
 (0)