Skip to content

Commit a056a54

Browse files
committed
Implement Project-level environment variables (WIP)
1 parent 11afae4 commit a056a54

File tree

15 files changed

+272
-11
lines changed

15 files changed

+272
-11
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/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: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
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 Modal from "../components/Modal";
10+
import { getGitpodService } from "../service/service";
11+
import { ProjectContext } from "./project-context";
12+
import { ProjectSettingsPage } from "./ProjectSettings";
13+
14+
export default function () {
15+
const { project } = useContext(ProjectContext);
16+
const [ envVars, setEnvVars ] = useState<ProjectEnvVar[]>([]);
17+
const [ showAddVariableModal, setShowAddVariableModal ] = useState<boolean>(false);
18+
19+
const updateEnvVars = async () => {
20+
if (!project) {
21+
return;
22+
}
23+
const vars = await getGitpodService().server.getProjectEnvironmentVariables(project.id);
24+
setEnvVars(vars);
25+
}
26+
27+
useEffect(() => {
28+
updateEnvVars();
29+
}, [project]);
30+
31+
return <ProjectSettingsPage project={project}>
32+
{showAddVariableModal && <AddVariableModal project={project} onClose={() => { updateEnvVars(); setShowAddVariableModal(false); }} />}
33+
<h3>Project Variables</h3>
34+
<ul>{envVars.map(e => <li>{e.name}</li>)}</ul>
35+
<button onClick={() => setShowAddVariableModal(true)}>Add Variable</button>
36+
</ProjectSettingsPage>;
37+
}
38+
39+
function AddVariableModal(props: { project?: Project, onClose: () => void }) {
40+
const [ name, setName ] = useState<string>("");
41+
const [ value, setValue ] = useState<string>("");
42+
const [ error, setError ] = useState<Error | undefined>();
43+
44+
const addVariable = async () => {
45+
if (!props.project) {
46+
return;
47+
}
48+
try {
49+
await getGitpodService().server.setProjectEnvironmentVariable(props.project.id, name, value);
50+
props.onClose();
51+
} catch (err) {
52+
setError(err);
53+
}
54+
}
55+
56+
return <Modal visible={true} onClose={props.onClose}>
57+
<h3 className="mb-4">Add Variable</h3>
58+
<div className="border-t border-b border-gray-200 dark:border-gray-800 -mx-6 px-6 py-4 flex flex-col">
59+
{error && <div className="bg-gitpod-kumquat-light rounded-md p-3 text-gitpod-red text-sm mb-2">
60+
{error}
61+
</div>}
62+
<div>
63+
<h4>Name</h4>
64+
<input autoFocus className="w-full" type="text" value={name} onChange={e => setName(e.target.value)} />
65+
</div>
66+
<div className="mt-4">
67+
<h4>Value</h4>
68+
<input className="w-full" type="text" value={value} onChange={e => setValue(e.target.value)} />
69+
</div>
70+
</div>
71+
<div className="flex justify-end mt-6">
72+
<button className="secondary" onClick={props.onClose}>Cancel</button>
73+
<button className="ml-2" onClick={addVariable} >Add Variable</button>
74+
</div>
75+
</Modal>;
76+
}

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

Lines changed: 4 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 } from "@gitpod/gitpod-protocol";
88

99
export const ProjectDB = Symbol('ProjectDB');
1010
export interface ProjectDB {
@@ -16,4 +16,7 @@ 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+
deleteProjectEnvironmentVariable(projectId: string, name: string): Promise<void>;
1922
}

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+
}

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

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,31 @@
77
import { inject, injectable } from "inversify";
88
import { TypeORM } from "./typeorm";
99
import { Repository } from "typeorm";
10+
import { v4 as uuidv4 } from 'uuid';
11+
import { PartialProject, Project, ProjectEnvVar } from "@gitpod/gitpod-protocol";
12+
import { EncryptionService } from "@gitpod/gitpod-protocol/lib/encryption/encryption-service";
13+
import { censor } from '@gitpod/gitpod-protocol/lib/util/censor';
1014
import { ProjectDB } from "../project-db";
1115
import { DBProject } from "./entity/db-project";
12-
import { PartialProject, Project } from "@gitpod/gitpod-protocol";
16+
import { DBProjectEnvVar } from "./entity/db-project-env-vars";
1317

1418
@injectable()
1519
export class ProjectDBImpl implements ProjectDB {
1620
@inject(TypeORM) typeORM: TypeORM;
21+
@inject(EncryptionService) protected readonly encryptionService: EncryptionService;
1722

1823
protected async getEntityManager() {
1924
return (await this.typeORM.getConnection()).manager;
2025
}
2126

22-
async getRepo(): Promise<Repository<DBProject>> {
27+
protected async getRepo(): Promise<Repository<DBProject>> {
2328
return (await this.getEntityManager()).getRepository<DBProject>(DBProject);
2429
}
2530

31+
protected async getProjectEnvVarRepo(): Promise<Repository<DBProjectEnvVar>> {
32+
return (await this.getEntityManager()).getRepository<DBProjectEnvVar>(DBProjectEnvVar);
33+
}
34+
2635
public async findProjectById(projectId: string): Promise<Project | undefined> {
2736
const repo = await this.getRepo();
2837
return repo.findOne({ id: projectId, markedDeleted: false });
@@ -93,4 +102,39 @@ export class ProjectDBImpl implements ProjectDB {
93102
await repo.save(project);
94103
}
95104
}
105+
106+
public async setProjectEnvironmentVariable(projectId: string, name: string, value: string): Promise<void>{
107+
const envVarRepo = await this.getProjectEnvVarRepo();
108+
let envVar = await envVarRepo.findOne({ projectId, name, deleted: false });
109+
if (envVar) {
110+
envVar.value = value;
111+
} else {
112+
envVar = {
113+
id: uuidv4(),
114+
projectId,
115+
name,
116+
value,
117+
creationTime: new Date().toISOString(),
118+
deleted: false,
119+
};
120+
}
121+
await envVarRepo.save(envVar);
122+
}
123+
124+
public async getProjectEnvironmentVariables(projectId: string): Promise<ProjectEnvVar[]> {
125+
const envVarRepo = await this.getProjectEnvVarRepo();
126+
const envVarsWithValue = await envVarRepo.find({ projectId, deleted: false });
127+
const envVars = envVarsWithValue.map(v => (censor(v, 'value') as any as ProjectEnvVar));
128+
return envVars;
129+
}
130+
131+
public async deleteProjectEnvironmentVariable(projectId: string, name: string): Promise<void> {
132+
const envVarRepo = await this.getProjectEnvVarRepo();
133+
const envVar = await envVarRepo.findOne({ projectId, name, deleted: false });
134+
if (!envVar) {
135+
throw new Error('A environment variable with this name could not be found for this project');
136+
}
137+
envVar.deleted = true;
138+
envVarRepo.update(envVar.id, envVar);
139+
}
96140
}

components/gitpod-protocol/src/gitpod-service.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
WhitelistedRepository, WorkspaceImageBuild, AuthProviderInfo, Branding, CreateWorkspaceMode,
1010
Token, UserEnvVarValue, ResolvePluginsParams, PreparePluginUploadParams, Terms,
1111
ResolvedPlugins, Configuration, InstallPluginsParams, UninstallPluginParams, UserInfo, GitpodTokenType,
12-
GitpodToken, AuthProviderEntry, GuessGitTokenScopesParams, GuessedGitTokenScopes
12+
GitpodToken, AuthProviderEntry, GuessGitTokenScopesParams, GuessedGitTokenScopes, ProjectEnvVar
1313
} from './protocol';
1414
import {
1515
Team, TeamMemberInfo,
@@ -142,6 +142,9 @@ export interface GitpodServer extends JsonRpcServer<GitpodClient>, AdminServer,
142142
guessRepositoryConfiguration(cloneUrl: string): Promise<string | undefined>;
143143
setProjectConfiguration(projectId: string, configString: string): Promise<void>;
144144
updateProjectPartial(partialProject: PartialProject): Promise<void>;
145+
setProjectEnvironmentVariable(projectId: string, name: string, value: string): Promise<void>;
146+
getProjectEnvironmentVariables(projectId: string): Promise<ProjectEnvVar[]>;
147+
deleteProjectEnvironmentVariable(projectId: string, name: string): Promise<void>;
145148

146149
// content service
147150
getContentBlobUploadUrl(name: string): Promise<string>

components/gitpod-protocol/src/protocol.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,10 +151,19 @@ export interface UserFeatureSettings {
151151
export const WorkspaceFeatureFlags = { "full_workspace_backup": undefined, "fixed_resources": undefined };
152152
export type NamedWorkspaceFeatureFlag = keyof (typeof WorkspaceFeatureFlags);
153153

154+
export interface ProjectEnvVarWithValue {
155+
id: string;
156+
projectId: string;
157+
name: string;
158+
value: string;
159+
}
160+
161+
export type ProjectEnvVar = Omit<Project, 'value'>;
162+
154163
export interface UserEnvVarValue {
155164
id?: string;
156165
name: string;
157-
repositoryPattern: string;
166+
repositoryPattern: string; // DEPRECATED: Use ProjectEnvVar instead - https://github.com/gitpod-com/gitpod/issues/5322
158167
value: string;
159168
}
160169
export interface UserEnvVar extends UserEnvVarValue {
@@ -163,6 +172,7 @@ export interface UserEnvVar extends UserEnvVarValue {
163172
deleted?: boolean;
164173
}
165174

175+
// DEPRECATED: Use ProjectEnvVar instead - https://github.com/gitpod-com/gitpod/issues/5322
166176
export namespace UserEnvVar {
167177

168178
export function normalizeRepoPattern(pattern: string) {

components/server/src/auth/rate-limiter.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ function getConfig(config: RateLimiterConfig): RateLimiterConfig {
8080
"getAllEnvVars": { group: "default", points: 1 },
8181
"setEnvVar": { group: "default", points: 1 },
8282
"deleteEnvVar": { group: "default", points: 1 },
83+
"setProjectEnvironmentVariable": { group: "default", points: 1 },
84+
"getProjectEnvironmentVariables": { group: "default", points: 1 },
85+
"deleteProjectEnvironmentVariable": { group: "default", points: 1 },
8386
"getTeams": { group: "default", points: 1 },
8487
"getTeamMembers": { group: "default", points: 1 },
8588
"createTeam": { group: "default", points: 1 },

0 commit comments

Comments
 (0)