Skip to content

Commit

Permalink
Introduce org-level GITPOD_IMAGE_AUTH (#20538)
Browse files Browse the repository at this point in the history
* [db, protocol] Introduce DBOrgEnvVar

* [server, spicedb] Introduce and integrate org env vars into internal services

* [server, public-api] Added API for org-level environment variables

* [dashboard] Add UI for setting/removing GITPOD_IMAGE_AUTH to "Organization Settings"

* [db, server] Fix DB queries, mapping to image-build args and fixed tests

* [dashboard] Review comment "icon spacing"

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

* [dashboard] Review comment superfluous key

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

* [dashboard] more spacing

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

* [dashboard] Copyright year

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

* [public-api] Add converter test case

---------

Co-authored-by: Filip Troníček <[email protected]>
  • Loading branch information
geropl and filiptronicek authored Jan 24, 2025
1 parent e7dbf43 commit ad4b7a8
Show file tree
Hide file tree
Showing 33 changed files with 9,211 additions and 289 deletions.
108 changes: 108 additions & 0 deletions components/dashboard/src/data/organizations/org-envvar-queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Copyright (c) 2025 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License.AGPL.txt in the project root for license information.
*/

import { OrganizationEnvironmentVariable } from "@gitpod/public-api/lib/gitpod/v1/envvar_pb";
import { envVarClient } from "../../service/public-api";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";

const getListOrgEnvVarQueryKey = (orgId: string) => {
const key: any[] = ["organization", orgId, "envvar", "list"];

return key;
};

const getOrgEnvVarQueryKey = (orgId: string, variableId: string) => {
const key: any[] = ["organization", orgId, "envvar", { variableId }];

return key;
};

export const useListOrganizationEnvironmentVariables = (orgId: string) => {
return useQuery<OrganizationEnvironmentVariable[]>(getListOrgEnvVarQueryKey(orgId), {
queryFn: async () => {
const { environmentVariables } = await envVarClient.listOrganizationEnvironmentVariables({
organizationId: orgId,
});

return environmentVariables;
},
cacheTime: 1000 * 60 * 60 * 24, // one day
});
};

type DeleteEnvironmentVariableArgs = {
variableId: string;
organizationId: string;
};
export const useDeleteOrganizationEnvironmentVariable = () => {
const queryClient = useQueryClient();

return useMutation<void, Error, DeleteEnvironmentVariableArgs>({
mutationFn: async ({ variableId }) => {
void (await envVarClient.deleteOrganizationEnvironmentVariable({
environmentVariableId: variableId,
}));
},
onSuccess: (_, { organizationId, variableId }) => {
queryClient.invalidateQueries({ queryKey: getListOrgEnvVarQueryKey(organizationId) });
queryClient.invalidateQueries({ queryKey: getOrgEnvVarQueryKey(organizationId, variableId) });
},
});
};

type CreateEnvironmentVariableArgs = {
organizationId: string;
name: string;
value: string;
};
export const useCreateOrganizationEnvironmentVariable = () => {
const queryClient = useQueryClient();

return useMutation<OrganizationEnvironmentVariable, Error, CreateEnvironmentVariableArgs>({
mutationFn: async ({ organizationId, name, value }) => {
const { environmentVariable } = await envVarClient.createOrganizationEnvironmentVariable({
organizationId,
name,
value,
});
if (!environmentVariable) {
throw new Error("Failed to create environment variable");
}

return environmentVariable;
},
onSuccess: (_, { organizationId }) => {
queryClient.invalidateQueries({ queryKey: getListOrgEnvVarQueryKey(organizationId) });
},
});
};

type UpdateEnvironmentVariableArgs = CreateEnvironmentVariableArgs & {
variableId: string;
};
export const useUpdateOrganizationEnvironmentVariable = () => {
const queryClient = useQueryClient();

return useMutation<OrganizationEnvironmentVariable, Error, UpdateEnvironmentVariableArgs>({
mutationFn: async ({ variableId, name, value, organizationId }: UpdateEnvironmentVariableArgs) => {
const { environmentVariable } = await envVarClient.updateOrganizationEnvironmentVariable({
environmentVariableId: variableId,
organizationId,
name,
value,
});
if (!environmentVariable) {
throw new Error("Failed to update environment variable");
}

return environmentVariable;
},
onSuccess: (_, { organizationId, variableId }) => {
queryClient.invalidateQueries({ queryKey: getListOrgEnvVarQueryKey(organizationId) });
queryClient.invalidateQueries({ queryKey: getOrgEnvVarQueryKey(organizationId, variableId) });
},
});
};
36 changes: 35 additions & 1 deletion components/dashboard/src/service/json-rpc-envvar-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,29 @@ import { EnvironmentVariableService } from "@gitpod/public-api/lib/gitpod/v1/env
import {
CreateConfigurationEnvironmentVariableRequest,
CreateConfigurationEnvironmentVariableResponse,
CreateOrganizationEnvironmentVariableRequest,
CreateOrganizationEnvironmentVariableResponse,
CreateUserEnvironmentVariableRequest,
CreateUserEnvironmentVariableResponse,
DeleteConfigurationEnvironmentVariableRequest,
DeleteConfigurationEnvironmentVariableResponse,
DeleteOrganizationEnvironmentVariableRequest,
DeleteOrganizationEnvironmentVariableResponse,
DeleteUserEnvironmentVariableRequest,
DeleteUserEnvironmentVariableResponse,
EnvironmentVariableAdmission,
ListConfigurationEnvironmentVariablesRequest,
ListConfigurationEnvironmentVariablesResponse,
ListOrganizationEnvironmentVariablesRequest,
ListOrganizationEnvironmentVariablesResponse,
ListUserEnvironmentVariablesRequest,
ListUserEnvironmentVariablesResponse,
ResolveWorkspaceEnvironmentVariablesRequest,
ResolveWorkspaceEnvironmentVariablesResponse,
UpdateConfigurationEnvironmentVariableRequest,
UpdateConfigurationEnvironmentVariableResponse,
UpdateOrganizationEnvironmentVariableRequest,
UpdateOrganizationEnvironmentVariableResponse,
UpdateUserEnvironmentVariableRequest,
UpdateUserEnvironmentVariableResponse,
} from "@gitpod/public-api/lib/gitpod/v1/envvar_pb";
Expand Down Expand Up @@ -163,7 +171,9 @@ export class JsonRpcEnvvarClient implements PromiseClient<typeof EnvironmentVari
req.configurationId,
req.name ?? projectEnvVarfound.name,
req.value ?? "",
req.admission === EnvironmentVariableAdmission.PREBUILD ?? projectEnvVarfound.censored,
req.admission === EnvironmentVariableAdmission.UNSPECIFIED
? projectEnvVarfound.censored
: req.admission === EnvironmentVariableAdmission.PREBUILD,
req.environmentVariableId,
);

Expand Down Expand Up @@ -224,6 +234,30 @@ export class JsonRpcEnvvarClient implements PromiseClient<typeof EnvironmentVari
return response;
}

async listOrganizationEnvironmentVariables(
req: PartialMessage<ListOrganizationEnvironmentVariablesRequest>,
): Promise<ListOrganizationEnvironmentVariablesResponse> {
throw new ApplicationError(ErrorCodes.BAD_REQUEST, "Unimplemented");
}

async updateOrganizationEnvironmentVariable(
req: PartialMessage<UpdateOrganizationEnvironmentVariableRequest>,
): Promise<UpdateOrganizationEnvironmentVariableResponse> {
throw new ApplicationError(ErrorCodes.BAD_REQUEST, "Unimplemented");
}

async createOrganizationEnvironmentVariable(
req: PartialMessage<CreateOrganizationEnvironmentVariableRequest>,
): Promise<CreateOrganizationEnvironmentVariableResponse> {
throw new ApplicationError(ErrorCodes.BAD_REQUEST, "Unimplemented");
}

async deleteOrganizationEnvironmentVariable(
req: PartialMessage<DeleteOrganizationEnvironmentVariableRequest>,
): Promise<DeleteOrganizationEnvironmentVariableResponse> {
throw new ApplicationError(ErrorCodes.BAD_REQUEST, "Unimplemented");
}

async resolveWorkspaceEnvironmentVariables(
req: PartialMessage<ResolveWorkspaceEnvironmentVariablesRequest>,
): Promise<ResolveWorkspaceEnvironmentVariablesResponse> {
Expand Down
20 changes: 20 additions & 0 deletions components/dashboard/src/teams/TeamSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { useDocumentTitle } from "../hooks/use-document-title";
import { PlainMessage } from "@bufbuild/protobuf";
import { useToast } from "../components/toasts/Toasts";
import { NamedOrganizationEnvvarItem } from "./variables/NamedOrganizationEnvvarItem";
import { useListOrganizationEnvironmentVariables } from "../data/organizations/org-envvar-queries";
import { EnvVar } from "@gitpod/gitpod-protocol";

export default function TeamSettingsPage() {
useDocumentTitle("Organization Settings - General");
Expand All @@ -46,6 +49,9 @@ export default function TeamSettingsPage() {
const [teamName, setTeamName] = useState(org?.name || "");
const [updated, setUpdated] = useState(false);

const orgEnvVars = useListOrganizationEnvironmentVariables(org?.id || "");
const gitpodImageAuthEnvVar = orgEnvVars.data?.find((v) => v.name === EnvVar.GITPOD_IMAGE_AUTH_ENV_VAR_NAME);

const updateOrg = useUpdateOrgMutation();

const close = () => setModal(false);
Expand Down Expand Up @@ -215,6 +221,20 @@ export default function TeamSettingsPage() {
/>
)}

{org?.id && (
<ConfigurationSettingsField>
<Heading3>Docker Registry authentication</Heading3>
<Subheading>Configure Docker registry permissions for the whole organization.</Subheading>

<NamedOrganizationEnvvarItem
disabled={!isOwner}
name={EnvVar.GITPOD_IMAGE_AUTH_ENV_VAR_NAME}
organizationId={org.id}
variable={gitpodImageAuthEnvVar}
/>
</ConfigurationSettingsField>
)}

{user?.organizationId !== org?.id && isOwner && (
<ConfigurationSettingsField>
<Heading3>Delete organization</Heading3>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
* Copyright (c) 2025 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License.AGPL.txt in the project root for license information.
*/

import { OrganizationEnvironmentVariable } from "@gitpod/public-api/lib/gitpod/v1/envvar_pb";
import { useCallback, useState } from "react";
import { OrganizationRemoveEnvvarModal } from "./OrganizationRemoveEnvvarModal";
import { InputField } from "../../components/forms/InputField";
import { ReactComponent as Stack } from "../../icons/Repository.svg";
import { Button } from "@podkit/buttons/Button";
import { useCreateOrganizationEnvironmentVariable } from "../../data/organizations/org-envvar-queries";
import Modal, { ModalBody, ModalFooter, ModalFooterAlert, ModalHeader } from "../../components/Modal";
import { TextInputField } from "../../components/forms/TextInputField";
import { useToast } from "../../components/toasts/Toasts";
import { LoadingButton } from "@podkit/buttons/LoadingButton";

type Props = {
disabled?: boolean;
organizationId: string;
name: string;
variable: OrganizationEnvironmentVariable | undefined;
};
export const NamedOrganizationEnvvarItem = ({ disabled, organizationId, name, variable }: Props) => {
const [showRemoveModal, setShowRemoveModal] = useState<boolean>(false);
const [showAddModal, setShowAddModal] = useState<boolean>(false);

const value = variable ? "*****" : "not set";

return (
<>
{variable && showRemoveModal && (
<OrganizationRemoveEnvvarModal
variable={variable}
organizationId={organizationId}
onClose={() => setShowRemoveModal(false)}
/>
)}

{showAddModal && (
<AddOrgEnvironmentVariableModal
organizationId={organizationId}
staticName={name}
onClose={() => setShowAddModal(false)}
/>
)}

<InputField disabled={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-1 flex items-center overflow-hidden h-8 gap-2" title={value}>
<span className="w-5 h-5">
<Stack />
</span>
<span className="truncate font-medium text-gray-700 dark:text-gray-200">{name}</span>
</div>
{!disabled && !variable && (
<Button variant="link" onClick={() => setShowAddModal(true)}>
Add
</Button>
)}
{!disabled && variable && (
<Button variant="link" onClick={() => setShowRemoveModal(true)}>
Delete
</Button>
)}
</div>
<div className="mx-7 text-gray-400 dark:text-gray-500 truncate">
<>{value}</>
{disabled && (
<>
&nbsp;&middot;&nbsp; Requires <span className="font-medium">Owner</span> permissions to
change
</>
)}
</div>
</div>
</InputField>
</>
);
};

type AddOrgEnvironmentVariableModalProps = {
organizationId: string;
staticName?: string;
onClose: () => void;
};
export const AddOrgEnvironmentVariableModal = ({
organizationId,
staticName,
onClose,
}: AddOrgEnvironmentVariableModalProps) => {
const { toast } = useToast();

const [name, setName] = useState(staticName || "");
const [value, setValue] = useState("");
const createVariable = useCreateOrganizationEnvironmentVariable();

const addVariable = useCallback(() => {
createVariable.mutateAsync(
{
organizationId,
name,
value,
},
{
onSuccess: () => {
toast("Variable added");
onClose();
},
},
);
}, [createVariable, organizationId, name, value, onClose, toast]);

return (
<Modal visible onClose={onClose} onSubmit={addVariable}>
<ModalHeader>Add a variable</ModalHeader>
<ModalBody>
<div className="mt-8">
<TextInputField
disabled={staticName !== undefined}
label="Name"
autoComplete={"off"}
className="w-full"
value={name}
placeholder="Variable name"
onChange={(name) => setName(name)}
autoFocus
required
/>
</div>
<div className="mt-4">
<TextInputField
label="Value"
autoComplete={"off"}
className="w-full"
value={value}
placeholder="Variable value"
onChange={(value) => setValue(value)}
required
/>
</div>
</ModalBody>
<ModalFooter
alert={
createVariable.isError ? (
<ModalFooterAlert type="danger">
{String(createVariable.error).replace(/Error: Request \w+ failed with message: /, "")}
</ModalFooterAlert>
) : null
}
>
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<LoadingButton type="submit" loading={createVariable.isLoading}>
Add Variable
</LoadingButton>
</ModalFooter>
</Modal>
);
};
Loading

0 comments on commit ad4b7a8

Please sign in to comment.