Skip to content

Commit b5e00eb

Browse files
committed
Implement Project-level environment variables (WIP)
1 parent bcdecd4 commit b5e00eb

20 files changed

+381
-39
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'));
@@ -305,6 +306,9 @@ function App() {
305306
if (resourceOrPrebuild === "configure") {
306307
return <ConfigureProject />;
307308
}
309+
if (resourceOrPrebuild === "variables") {
310+
return <ProjectVariables />;
311+
}
308312
if (resourceOrPrebuild === "workspaces") {
309313
return <Workspaces />;
310314
}
@@ -338,11 +342,14 @@ function App() {
338342
if (maybeProject === "settings") {
339343
return <TeamSettings />;
340344
}
345+
if (resourceOrPrebuild === "settings") {
346+
return <ProjectSettings />;
347+
}
341348
if (resourceOrPrebuild === "configure") {
342349
return <ConfigureProject />;
343350
}
344-
if (resourceOrPrebuild === "settings") {
345-
return <ProjectSettings />;
351+
if (resourceOrPrebuild === "variables") {
352+
return <ProjectVariables />;
346353
}
347354
if (resourceOrPrebuild === "workspaces") {
348355
return <Workspaces />;

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: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/**
2+
* Copyright (c) 2021 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 { Item, ItemField, ItemsList } from "../components/ItemsList";
10+
import Modal from "../components/Modal";
11+
import { getGitpodService } from "../service/service";
12+
import { ProjectContext } from "./project-context";
13+
import { ProjectSettingsPage } from "./ProjectSettings";
14+
15+
export default function () {
16+
const { project } = useContext(ProjectContext);
17+
const [ envVars, setEnvVars ] = useState<ProjectEnvVar[]>([]);
18+
const [ showAddVariableModal, setShowAddVariableModal ] = useState<boolean>(false);
19+
20+
const updateEnvVars = async () => {
21+
if (!project) {
22+
return;
23+
}
24+
const vars = await getGitpodService().server.getProjectEnvironmentVariables(project.id);
25+
const sortedVars = vars.sort((a, b) => a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1);
26+
setEnvVars(sortedVars);
27+
}
28+
29+
useEffect(() => {
30+
updateEnvVars();
31+
// eslint-disable-next-line react-hooks/exhaustive-deps
32+
}, [project]);
33+
34+
const deleteEnvVar = async (variableId: string) => {
35+
await getGitpodService().server.deleteProjectEnvironmentVariable(variableId);
36+
updateEnvVars();
37+
}
38+
39+
return <ProjectSettingsPage project={project}>
40+
{showAddVariableModal && <AddVariableModal project={project} onClose={() => { updateEnvVars(); setShowAddVariableModal(false); }} />}
41+
<div className="mb-2 flex">
42+
<div className="flex-grow">
43+
<h3>Project Variables</h3>
44+
<h2 className="text-gray-500">Manage environment variables for your project.</h2>
45+
</div>
46+
{envVars.length > 0 && <button onClick={() => setShowAddVariableModal(true)}>Add Variable</button>}
47+
</div>
48+
{envVars.length === 0
49+
? <div className="bg-gray-100 dark:bg-gray-800 rounded-xl w-full py-28 flex flex-col items-center">
50+
<h3 className="text-center pb-3 text-gray-500 dark:text-gray-400">No Environment Variables</h3>
51+
<button onClick={() => setShowAddVariableModal(true)}>New Variable</button>
52+
</div>
53+
: <>
54+
<ItemsList>
55+
<Item header={true} className="grid grid-cols-3 items-center">
56+
<ItemField>Name</ItemField>
57+
<ItemField>Value</ItemField>
58+
<ItemField></ItemField>
59+
</Item>
60+
{envVars.map(variable => {
61+
return <Item className="grid grid-cols-3 items-center">
62+
<ItemField>{variable.name}</ItemField>
63+
<ItemField>****</ItemField>
64+
<ItemField className="text-right"><button onClick={() => deleteEnvVar(variable.id)}>x</button></ItemField>
65+
</Item>
66+
})}
67+
</ItemsList>
68+
</>
69+
}
70+
</ProjectSettingsPage>;
71+
}
72+
73+
function AddVariableModal(props: { project?: Project, onClose: () => void }) {
74+
const [ name, setName ] = useState<string>("");
75+
const [ value, setValue ] = useState<string>("");
76+
const [ error, setError ] = useState<Error | undefined>();
77+
78+
const addVariable = async () => {
79+
if (!props.project) {
80+
return;
81+
}
82+
try {
83+
await getGitpodService().server.setProjectEnvironmentVariable(props.project.id, name, value);
84+
props.onClose();
85+
} catch (err) {
86+
setError(err);
87+
}
88+
}
89+
90+
return <Modal visible={true} onClose={props.onClose} onEnter={() => { addVariable(); return false; }}>
91+
<h3 className="mb-4">Add Variable</h3>
92+
<div className="border-t border-b border-gray-200 dark:border-gray-800 -mx-6 px-6 py-4 flex flex-col">
93+
{error && <div className="bg-gitpod-kumquat-light rounded-md p-3 text-gitpod-red text-sm mb-2">
94+
{error}
95+
</div>}
96+
<div>
97+
<h4>Name</h4>
98+
<input autoFocus className="w-full" type="text" name="name" value={name} onChange={e => setName(e.target.value)} />
99+
</div>
100+
<div className="mt-4">
101+
<h4>Value</h4>
102+
<input className="w-full" type="text" name="value" value={value} onChange={e => setValue(e.target.value)} />
103+
</div>
104+
</div>
105+
<div className="flex justify-end mt-6">
106+
<button className="secondary" onClick={props.onClose}>Cancel</button>
107+
<button className="ml-2" onClick={addVariable} >Add Variable</button>
108+
</div>
109+
</Modal>;
110+
}

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()
@@ -250,6 +250,18 @@ export class GitpodTableDescriptionProvider implements TableDescriptionProvider
250250
deletionColumn: 'deleted',
251251
timeColumn: '_lastModified',
252252
},
253+
{
254+
name: 'd_b_project_env_var',
255+
primaryKeys: ['id', 'projectId'],
256+
deletionColumn: 'deleted',
257+
timeColumn: '_lastModified',
258+
},
259+
/**
260+
* BEWARE
261+
*
262+
* When updating this list, make sure you update the deleted-entry-gc.ts in gitpod-db
263+
* as well, if you're adding a table that needs some of its entries deleted.
264+
*/
253265
]
254266

255267
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: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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 must be part of the primary key as otherwise malicious users could overwrite
20+
// the value of arbitrary user env vars because we use TypeORM.save. If the projectId were
21+
// not part of the primary key, this save call would overwrite env vars with arbitrary IDs,
22+
// allowing an attacker to steal other users environment variables.
23+
// But projectId is part of the primary key and we ensure that users can only overwrite/set variables
24+
// that belong to them.
25+
@PrimaryColumn(TypeORM.UUID_COLUMN_TYPE)
26+
projectId: string;
27+
28+
@Column()
29+
name: string;
30+
31+
@Column({
32+
type: "simple-json",
33+
transformer: Transformer.compose(
34+
Transformer.SIMPLE_JSON([]),
35+
Transformer.encrypted(() => encryptionService)
36+
)
37+
})
38+
value: string;
39+
40+
@Column("varchar")
41+
creationTime: string;
42+
43+
// This column triggers the db-sync deletion mechanism. It's not intended for public consumption.
44+
@Column()
45+
deleted: boolean;
46+
}
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)