Skip to content

Commit 4d73d88

Browse files
committed
Implement Project-level environment variables (WIP)
1 parent 0ebf146 commit 4d73d88

File tree

15 files changed

+238
-11
lines changed

15 files changed

+238
-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: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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 { ProjectEnvVar } from "@gitpod/gitpod-protocol";
8+
import { useContext, useEffect, useState } from "react";
9+
import { getGitpodService } from "../service/service";
10+
import { ProjectContext } from "./project-context";
11+
import { ProjectSettingsPage } from "./ProjectSettings";
12+
13+
export default function () {
14+
const { project } = useContext(ProjectContext);
15+
const [ envVars, setEnvVars ] = useState<ProjectEnvVar[]>([]);
16+
17+
const updateEnvVars = async (projectId: string) => {
18+
const vars = await getGitpodService().server.getProjectEnvironmentVariables(projectId);
19+
setEnvVars(vars);
20+
}
21+
22+
useEffect(() => {
23+
if (!project) {
24+
return;
25+
}
26+
updateEnvVars(project.id);
27+
}, [project]);
28+
29+
const setEnvVar = async (name: string, value: string) => {
30+
if (!project) {
31+
return;
32+
}
33+
await getGitpodService().server.setProjectEnvironmentVariable(project.id, name, value);
34+
await updateEnvVars(project.id);
35+
}
36+
37+
return <ProjectSettingsPage project={project}>
38+
<h3>Project Variables</h3>
39+
<ul>{envVars.map(e => <li>{e.name}</li>)}</ul>
40+
<button onClick={() => setEnvVar('TEST', 'VALUE')}>Create</button>
41+
</ProjectSettingsPage>;
42+
}

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 });
@@ -77,4 +86,39 @@ export class ProjectDBImpl implements ProjectDB {
7786
await repo.save(project);
7887
}
7988
}
89+
90+
public async setProjectEnvironmentVariable(projectId: string, name: string, value: string): Promise<void>{
91+
const envVarRepo = await this.getProjectEnvVarRepo();
92+
let envVar = await envVarRepo.findOne({ projectId, name, deleted: false });
93+
if (envVar) {
94+
envVar.value = value;
95+
} else {
96+
envVar = {
97+
id: uuidv4(),
98+
projectId,
99+
name,
100+
value,
101+
creationTime: new Date().toISOString(),
102+
deleted: false,
103+
};
104+
}
105+
await envVarRepo.save(envVar);
106+
}
107+
108+
public async getProjectEnvironmentVariables(projectId: string): Promise<ProjectEnvVar[]> {
109+
const envVarRepo = await this.getProjectEnvVarRepo();
110+
const envVarsWithValue = await envVarRepo.find({ projectId, deleted: false });
111+
const envVars = envVarsWithValue.map(v => (censor(v, 'value') as any as ProjectEnvVar));
112+
return envVars;
113+
}
114+
115+
public async deleteProjectEnvironmentVariable(projectId: string, name: string): Promise<void> {
116+
const envVarRepo = await this.getProjectEnvVarRepo();
117+
const envVar = await envVarRepo.findOne({ projectId, name, deleted: false });
118+
if (!envVar) {
119+
throw new Error('A environment variable with this name could not be found for this project');
120+
}
121+
envVar.deleted = true;
122+
envVarRepo.update(envVar.id, envVar);
123+
}
80124
}

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

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import { inject, injectable } from "inversify";
88
import { DBWithTracing, ProjectDB, TeamDB, TracedWorkspaceDB, UserDB, WorkspaceDB } from "@gitpod/gitpod-db/lib";
9-
import { Branch, CommitContext, PrebuildWithStatus, CreateProjectParams, FindPrebuildsParams, Project, User, WorkspaceConfig } from "@gitpod/gitpod-protocol";
9+
import { Branch, CommitContext, PrebuildWithStatus, CreateProjectParams, FindPrebuildsParams, Project, User, WorkspaceConfig, ProjectEnvVar } from "@gitpod/gitpod-protocol";
1010
import { TraceContext } from "@gitpod/gitpod-protocol/lib/util/tracing";
1111
import { HostContextProvider } from "../auth/host-context-provider";
1212
import { FileProvider, RepoURL } from "../repohost";
@@ -230,4 +230,16 @@ export class ProjectsService {
230230
return this.projectDB.updateProject(partialProject);
231231
}
232232

233+
async setProjectEnvironmentVariable(projectId: string, name: string, value: string): Promise<void> {
234+
return this.projectDB.setProjectEnvironmentVariable(projectId, name, value);
235+
}
236+
237+
async getProjectEnvironmentVariables(projectId: string): Promise<ProjectEnvVar[]> {
238+
return this.projectDB.getProjectEnvironmentVariables(projectId);
239+
}
240+
241+
async deleteProjectEnvironmentVariable(projectId: string, name: string): Promise<void> {
242+
return this.projectDB.deleteProjectEnvironmentVariable(projectId, name);
243+
}
244+
233245
}

0 commit comments

Comments
 (0)