Skip to content

Commit ebb1ad4

Browse files
committed
[db][server][dashboard] Implement Project-level environment variables
1 parent 928dbe1 commit ebb1ad4

21 files changed

+412
-42
lines changed

components/dashboard/src/App.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import { trackButtonOrAnchor, trackPathChange, trackLocation } from './Analytics
2121
import { User } from '@gitpod/gitpod-protocol';
2222
import * as GitpodCookie from '@gitpod/gitpod-protocol/lib/util/gitpod-cookie';
2323
import { Experiment } from './experiments';
24-
import ProjectSettings from './projects/ProjectSettings';
2524

2625
const Setup = React.lazy(() => import(/* webpackPrefetch: true */ './Setup'));
2726
const Workspaces = React.lazy(() => import(/* webpackPrefetch: true */ './workspaces/Workspaces'));
@@ -42,6 +41,8 @@ const NewProject = React.lazy(() => import(/* webpackPrefetch: true */ './projec
4241
const ConfigureProject = React.lazy(() => import(/* webpackPrefetch: true */ './projects/ConfigureProject'));
4342
const Projects = React.lazy(() => import(/* webpackPrefetch: true */ './projects/Projects'));
4443
const Project = React.lazy(() => import(/* webpackPrefetch: true */ './projects/Project'));
44+
const ProjectSettings = React.lazy(() => import(/* webpackPrefetch: true */ './projects/ProjectSettings'));
45+
const ProjectVariables = React.lazy(() => import(/* webpackPrefetch: true */ './projects/ProjectVariables'));
4546
const Prebuilds = React.lazy(() => import(/* webpackPrefetch: true */ './projects/Prebuilds'));
4647
const Prebuild = React.lazy(() => import(/* webpackPrefetch: true */ './projects/Prebuild'));
4748
const InstallGitHubApp = React.lazy(() => import(/* webpackPrefetch: true */ './projects/InstallGitHubApp'));
@@ -307,6 +308,9 @@ function App() {
307308
if (resourceOrPrebuild === "configure") {
308309
return <ConfigureProject />;
309310
}
311+
if (resourceOrPrebuild === "variables") {
312+
return <ProjectVariables />;
313+
}
310314
if (resourceOrPrebuild === "prebuilds") {
311315
return <Prebuilds />;
312316
}
@@ -337,11 +341,14 @@ function App() {
337341
if (maybeProject === "settings") {
338342
return <TeamSettings />;
339343
}
344+
if (resourceOrPrebuild === "settings") {
345+
return <ProjectSettings />;
346+
}
340347
if (resourceOrPrebuild === "configure") {
341348
return <ConfigureProject />;
342349
}
343-
if (resourceOrPrebuild === "settings") {
344-
return <ProjectSettings />;
350+
if (resourceOrPrebuild === "variables") {
351+
return <ProjectVariables />;
345352
}
346353
if (resourceOrPrebuild === "prebuilds") {
347354
return <Prebuilds />;

components/dashboard/src/Menu.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export default function Menu() {
4747
})();
4848
const prebuildId = (() => {
4949
const resource = projectSlug && match?.params?.segment3;
50-
if (resource !== "workspaces" && resource !== "prebuilds" && resource !== "settings" && resource !== "configure") {
50+
if (resource !== "workspaces" && resource !== "prebuilds" && resource !== "settings" && resource !== "configure" && resource !== "variables") {
5151
return resource;
5252
}
5353
})();

components/dashboard/src/components/ItemsList.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export function Item(props: {
2222
}) {
2323
const headerClassName = "text-sm text-gray-400 border-t border-b border-gray-200 dark:border-gray-800";
2424
const notHeaderClassName = "rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gitpod-kumquat-light";
25-
return <div className={`flex flex-grow flex-row w-full px-3 py-3 justify-between transition ease-in-out ${props.header ? headerClassName : notHeaderClassName} ${props.className || ""}`}>
25+
return <div className={`flex flex-grow flex-row w-full p-3 justify-between transition ease-in-out ${props.header ? headerClassName : notHeaderClassName} ${props.className || ""}`}>
2626
{props.children}
2727
</div>;
2828
}

components/dashboard/src/projects/ProjectSettings.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ export function getProjectSettingsMenu(project?: Project, team?: Team) {
2525
title: 'Configuration',
2626
link: [`/${teamOrUserSlug}/${project?.slug || project?.name}/configure`],
2727
},
28+
{
29+
title: 'Variables',
30+
link: [`/${teamOrUserSlug}/${project?.slug || project?.name}/variables`],
31+
},
2832
];
2933
}
3034

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License-AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { Project, ProjectEnvVar } from "@gitpod/gitpod-protocol";
8+
import { useContext, useEffect, useState } from "react";
9+
import AlertBox from "../components/AlertBox";
10+
import { Item, ItemField, ItemFieldContextMenu, ItemsList } from "../components/ItemsList";
11+
import Modal from "../components/Modal";
12+
import { getGitpodService } from "../service/service";
13+
import { ProjectContext } from "./project-context";
14+
import { ProjectSettingsPage } from "./ProjectSettings";
15+
16+
export default function () {
17+
const { project } = useContext(ProjectContext);
18+
const [ envVars, setEnvVars ] = useState<ProjectEnvVar[]>([]);
19+
const [ showAddVariableModal, setShowAddVariableModal ] = useState<boolean>(false);
20+
21+
const updateEnvVars = async () => {
22+
if (!project) {
23+
return;
24+
}
25+
const vars = await getGitpodService().server.getProjectEnvironmentVariables(project.id);
26+
const sortedVars = vars.sort((a, b) => a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1);
27+
setEnvVars(sortedVars);
28+
}
29+
30+
useEffect(() => {
31+
updateEnvVars();
32+
// eslint-disable-next-line react-hooks/exhaustive-deps
33+
}, [project]);
34+
35+
const deleteEnvVar = async (variableId: string) => {
36+
await getGitpodService().server.deleteProjectEnvironmentVariable(variableId);
37+
updateEnvVars();
38+
}
39+
40+
return <ProjectSettingsPage project={project}>
41+
{showAddVariableModal && <AddVariableModal project={project} onClose={() => { updateEnvVars(); setShowAddVariableModal(false); }} />}
42+
<div className="mb-2 flex">
43+
<div className="flex-grow">
44+
<h3>Environment Variables</h3>
45+
<h2 className="text-gray-500">Manage environment variables for your project.</h2>
46+
</div>
47+
{envVars.length > 0 && <button onClick={() => setShowAddVariableModal(true)}>Add Variable</button>}
48+
</div>
49+
{envVars.length === 0
50+
? <div className="bg-gray-100 dark:bg-gray-800 rounded-xl w-full py-28 flex flex-col items-center">
51+
<h3 className="text-center pb-3 text-gray-500 dark:text-gray-400">No Environment Variables</h3>
52+
<p className="text-center pb-6 text-gray-500 text-base w-96">Here you can set <strong>project-specific environment variables</strong> that will be visible during prebuilds and (optionally) in workspaces for this project.</p>
53+
<button onClick={() => setShowAddVariableModal(true)}>New Variable</button>
54+
</div>
55+
: <>
56+
<ItemsList>
57+
<Item header={true} className="grid grid-cols-3 items-center">
58+
<ItemField>Name</ItemField>
59+
<ItemField>Value</ItemField>
60+
<ItemField></ItemField>
61+
</Item>
62+
{envVars.map(variable => {
63+
return <Item className="grid grid-cols-3 items-center">
64+
<ItemField>{variable.name}</ItemField>
65+
<ItemField>****</ItemField>
66+
<ItemField className="flex justify-end">
67+
<ItemFieldContextMenu menuEntries={[
68+
{
69+
title: 'Delete',
70+
customFontStyle: 'text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300',
71+
onClick: () => deleteEnvVar(variable.id),
72+
},
73+
]} />
74+
</ItemField>
75+
</Item>
76+
})}
77+
</ItemsList>
78+
</>
79+
}
80+
</ProjectSettingsPage>;
81+
}
82+
83+
function AddVariableModal(props: { project?: Project, onClose: () => void }) {
84+
const [ name, setName ] = useState<string>("");
85+
const [ value, setValue ] = useState<string>("");
86+
const [ error, setError ] = useState<Error | undefined>();
87+
88+
const addVariable = async () => {
89+
if (!props.project) {
90+
return;
91+
}
92+
try {
93+
await getGitpodService().server.setProjectEnvironmentVariable(props.project.id, name, value);
94+
props.onClose();
95+
} catch (err) {
96+
setError(err);
97+
}
98+
}
99+
100+
return <Modal visible={true} onClose={props.onClose} onEnter={() => { addVariable(); return false; }}>
101+
<h3 className="mb-4">Add Variable</h3>
102+
<div className="border-t border-b border-gray-200 dark:border-gray-800 -mx-6 px-6 py-4 flex flex-col">
103+
<AlertBox><strong>Project variables might be accessible by anyone with read access to your repository.</strong><br/>Secret values can be exposed if they are printed in the logs, persisted to the file system, or made visible in workspaces.</AlertBox>
104+
{error && <div className="bg-gitpod-kumquat-light rounded-md p-3 text-gitpod-red text-sm mb-2">
105+
{error}
106+
</div>}
107+
<div className="mt-4">
108+
<h4>Name</h4>
109+
<input autoFocus className="w-full" type="text" name="name" value={name} onChange={e => setName(e.target.value)} />
110+
</div>
111+
<div className="mt-4">
112+
<h4>Value</h4>
113+
<input className="w-full" type="text" name="value" value={value} onChange={e => setValue(e.target.value)} />
114+
</div>
115+
</div>
116+
<div className="flex justify-end mt-6">
117+
<button className="secondary" onClick={props.onClose}>Cancel</button>
118+
<button className="ml-2" onClick={addVariable} >Add Variable</button>
119+
</div>
120+
</Modal>;
121+
}

components/dashboard/src/settings/EnvironmentVariables.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,8 +181,6 @@ export default function EnvVars() {
181181
return '';
182182
};
183183

184-
185-
186184
return <PageWithSubMenu subMenu={settingsMenu} title='Variables' subtitle='Configure environment variables for all workspaces.'>
187185
{isAddEnvVarModalVisible && <AddEnvVarModal
188186
save={save}

components/gitpod-db/src/project-db.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* See License.enterprise.txt in the project root folder.
55
*/
66

7-
import { PartialProject, Project } from "@gitpod/gitpod-protocol";
7+
import { PartialProject, Project, ProjectEnvVar, ProjectEnvVarWithValue } from "@gitpod/gitpod-protocol";
88

99
export const ProjectDB = Symbol('ProjectDB');
1010
export interface ProjectDB {
@@ -16,4 +16,9 @@ export interface ProjectDB {
1616
storeProject(project: Project): Promise<Project>;
1717
updateProject(partialProject: PartialProject): Promise<void>;
1818
markDeleted(projectId: string): Promise<void>;
19+
setProjectEnvironmentVariable(projectId: string, name: string, value: string): Promise<void>;
20+
getProjectEnvironmentVariables(projectId: string): Promise<ProjectEnvVar[]>;
21+
getProjectEnvironmentVariableById(variableId: string): Promise<ProjectEnvVar | undefined>;
22+
deleteProjectEnvironmentVariable(variableId: string): Promise<void>;
23+
getProjectEnvironmentVariableValues(envVars: ProjectEnvVar[]): Promise<ProjectEnvVarWithValue[]>;
1924
}

components/gitpod-db/src/tables.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export class GitpodSessionTableDescriptionProvider implements TableDescriptionPr
4747
/**
4848
* BEWARE
4949
*
50-
* When updating this list, make sure you update the deleted-entry-gc in gitpod-db
50+
* When updating this list, make sure you update the deleted-entry-gc.ts in gitpod-db
5151
* as well, if you're adding a table that needs some of its entries deleted.
5252
*/
5353
@injectable()
@@ -245,6 +245,18 @@ export class GitpodTableDescriptionProvider implements TableDescriptionProvider
245245
deletionColumn: 'deleted',
246246
timeColumn: '_lastModified',
247247
},
248+
{
249+
name: 'd_b_project_env_var',
250+
primaryKeys: ['id', 'projectId'],
251+
deletionColumn: 'deleted',
252+
timeColumn: '_lastModified',
253+
},
254+
/**
255+
* BEWARE
256+
*
257+
* When updating this list, make sure you update the deleted-entry-gc.ts in gitpod-db
258+
* as well, if you're adding a table that needs some of its entries deleted.
259+
*/
248260
]
249261

250262
public getSortedTables(): TableDescription[] {

components/gitpod-db/src/typeorm/deleted-entry-gc.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ const tables: TableWithDeletion[] = [
5858
{ deletionColumn: "deleted", name: "d_b_project" },
5959
{ deletionColumn: "deleted", name: "d_b_prebuild_info" },
6060
{ deletionColumn: "deleted", name: "d_b_oss_allow_list" },
61+
{ deletionColumn: "deleted", name: "d_b_project_env_var" },
6162
];
6263

6364
interface TableWithDeletion {
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License-AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { PrimaryColumn, Entity, Column } from "typeorm";
8+
import { TypeORM } from "../typeorm";
9+
import { ProjectEnvVarWithValue } from "@gitpod/gitpod-protocol";
10+
import { Transformer } from "../transformer";
11+
import { encryptionService } from "../user-db-impl";
12+
13+
@Entity()
14+
// on DB but not Typeorm: @Index("ind_lastModified", ["_lastModified"]) // DBSync
15+
export class DBProjectEnvVar implements ProjectEnvVarWithValue {
16+
@PrimaryColumn(TypeORM.UUID_COLUMN_TYPE)
17+
id: string;
18+
19+
// `projectId` is part of the primary key for safety reasons: This way it's impossible that a user
20+
// (maliciously or by accident) sends us an environment variable that has the same private key (`id`)
21+
// as the environment variable from another project.
22+
@PrimaryColumn(TypeORM.UUID_COLUMN_TYPE)
23+
projectId: string;
24+
25+
@Column()
26+
name: string;
27+
28+
@Column({
29+
type: "simple-json",
30+
transformer: Transformer.compose(
31+
Transformer.SIMPLE_JSON([]),
32+
Transformer.encrypted(() => encryptionService)
33+
)
34+
})
35+
value: string;
36+
37+
@Column("varchar")
38+
creationTime: string;
39+
40+
// This column triggers the db-sync deletion mechanism. It's not intended for public consumption.
41+
@Column()
42+
deleted: boolean;
43+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License-AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { MigrationInterface, QueryRunner } from "typeorm";
8+
9+
export class ProjectEnvVars1639735838107 implements MigrationInterface {
10+
11+
public async up(queryRunner: QueryRunner): Promise<void> {
12+
await queryRunner.query("CREATE TABLE IF NOT EXISTS `d_b_project_env_var` (`id` char(36) NOT NULL, `projectId` char(36) NOT NULL, `name` varchar(255) NOT NULL, `value` text NOT NULL, `creationTime` varchar(255) NOT NULL, `deleted` tinyint(4) NOT NULL DEFAULT '0', `_lastModified` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`, `projectId`), KEY `ind_projectid` (projectId), KEY `ind_dbsync` (`_lastModified`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
13+
}
14+
15+
public async down(queryRunner: QueryRunner): Promise<void> {
16+
}
17+
18+
}

0 commit comments

Comments
 (0)