diff --git a/components/gitpod-cli/cmd/env.go b/components/gitpod-cli/cmd/env.go index 3331ec4c0f7910..521f5ff56c2ca2 100644 --- a/components/gitpod-cli/cmd/env.go +++ b/components/gitpod-cli/cmd/env.go @@ -17,12 +17,10 @@ import ( "github.com/spf13/cobra" "golang.org/x/sync/errgroup" "golang.org/x/xerrors" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" - "github.com/gitpod-io/gitpod/common-go/util" + "github.com/gitpod-io/gitpod/gitpod-cli/pkg/supervisor" serverapi "github.com/gitpod-io/gitpod/gitpod-protocol" - supervisor "github.com/gitpod-io/gitpod/supervisor/api" + supervisorapi "github.com/gitpod-io/gitpod/supervisor/api" ) var exportEnvs = false @@ -31,8 +29,8 @@ var unsetEnvs = false // envCmd represents the env command var envCmd = &cobra.Command{ Use: "env", - Short: "Controls user-defined, persistent environment variables.", - Long: `This command can print and modify the persistent environment variables associated with your user, for this repository. + Short: "Controls workspace environment variables.", + Long: `This command can print and modify the persistent environment variables associated with your workspace. To set the persistent environment variable 'foo' to the value 'bar' use: gp env foo=bar @@ -78,15 +76,20 @@ delete environment variables with a repository pattern of */foo, foo/* or */*. type connectToServerResult struct { repositoryPattern string + wsInfo *supervisorapi.WorkspaceInfoResponse client *serverapi.APIoverJSONRPC + + useDeprecatedGetEnvVar bool } func connectToServer(ctx context.Context) (*connectToServerResult, error) { - supervisorConn, err := grpc.Dial(util.GetSupervisorAddress(), grpc.WithTransportCredentials(insecure.NewCredentials())) + supervisorClient, err := supervisor.New(ctx) if err != nil { return nil, xerrors.Errorf("failed connecting to supervisor: %w", err) } - wsinfo, err := supervisor.NewInfoServiceClient(supervisorConn).WorkspaceInfo(ctx, &supervisor.WorkspaceInfoRequest{}) + defer supervisorClient.Close() + + wsinfo, err := supervisorClient.Info.WorkspaceInfo(ctx, &supervisorapi.WorkspaceInfoRequest{}) if err != nil { return nil, xerrors.Errorf("failed getting workspace info from supervisor: %w", err) } @@ -97,19 +100,35 @@ func connectToServer(ctx context.Context) (*connectToServerResult, error) { return nil, xerrors.New("repository info is missing owner") } if wsinfo.Repository.Name == "" { - return nil, xerrors.New("repository info is missing name") + xerrors.New("repository info is missing name") } repositoryPattern := wsinfo.Repository.Owner + "/" + wsinfo.Repository.Name - clientToken, err := supervisor.NewTokenServiceClient(supervisorConn).GetToken(ctx, &supervisor.GetTokenRequest{ + + var useDeprecatedGetEnvVar bool + clientToken, err := supervisorClient.Token.GetToken(ctx, &supervisorapi.GetTokenRequest{ Host: wsinfo.GitpodApi.Host, Kind: "gitpod", Scope: []string{ - "function:getEnvVars", + "function:getWorkspaceEnvVars", "function:setEnvVar", "function:deleteEnvVar", "resource:envVar::" + repositoryPattern + "::create/get/update/delete", }, }) + if err != nil { + // TODO remove then GetWorkspaceEnvVars is deployed + clientToken, err = supervisorClient.Token.GetToken(ctx, &supervisorapi.GetTokenRequest{ + Host: wsinfo.GitpodApi.Host, + Kind: "gitpod", + Scope: []string{ + "function:getEnvVars", // TODO remove then getWorkspaceEnvVars is deployed + "function:setEnvVar", + "function:deleteEnvVar", + "resource:envVar::" + repositoryPattern + "::create/get/update/delete", + }, + }) + useDeprecatedGetEnvVar = true + } if err != nil { return nil, xerrors.Errorf("failed getting token from supervisor: %w", err) } @@ -121,7 +140,7 @@ func connectToServer(ctx context.Context) (*connectToServerResult, error) { if err != nil { return nil, xerrors.Errorf("failed connecting to server: %w", err) } - return &connectToServerResult{repositoryPattern, client}, nil + return &connectToServerResult{repositoryPattern, wsinfo, client, useDeprecatedGetEnvVar}, nil } func getEnvs(ctx context.Context) error { @@ -131,13 +150,18 @@ func getEnvs(ctx context.Context) error { } defer result.client.Close() - vars, err := result.client.GetEnvVars(ctx) + var vars []*serverapi.EnvVar + if !result.useDeprecatedGetEnvVar { + vars, err = result.client.GetWorkspaceEnvVars(ctx, result.wsInfo.WorkspaceId) + } else { + vars, err = result.client.GetEnvVars(ctx) + } if err != nil { return xerrors.Errorf("failed to fetch env vars from server: %w", err) } for _, v := range vars { - printVar(v, exportEnvs) + printVar(v.Name, v.Value, exportEnvs) } return nil @@ -163,7 +187,7 @@ func setEnvs(ctx context.Context, args []string) error { if err != nil { return err } - printVar(v, exportEnvs) + printVar(v.Name, v.Value, exportEnvs) return nil }) } @@ -189,12 +213,12 @@ func deleteEnvs(ctx context.Context, args []string) error { return g.Wait() } -func printVar(v *serverapi.UserEnvVarValue, export bool) { - val := strings.Replace(v.Value, "\"", "\\\"", -1) +func printVar(name string, value string, export bool) { + val := strings.Replace(value, "\"", "\\\"", -1) if export { - fmt.Printf("export %s=\"%s\"\n", v.Name, val) + fmt.Printf("export %s=\"%s\"\n", name, val) } else { - fmt.Printf("%s=%s\n", v.Name, val) + fmt.Printf("%s=%s\n", name, val) } } diff --git a/components/gitpod-cli/pkg/supervisor/client.go b/components/gitpod-cli/pkg/supervisor/client.go index 9a4549613fc790..fd6bd063d493aa 100644 --- a/components/gitpod-cli/pkg/supervisor/client.go +++ b/components/gitpod-cli/pkg/supervisor/client.go @@ -26,6 +26,7 @@ type SupervisorClient struct { Info api.InfoServiceClient Notification api.NotificationServiceClient Control api.ControlServiceClient + Token api.TokenServiceClient } type SupervisorClientOption struct { @@ -51,6 +52,7 @@ func New(ctx context.Context, options ...*SupervisorClientOption) (*SupervisorCl Info: api.NewInfoServiceClient(conn), Notification: api.NewNotificationServiceClient(conn), Control: api.NewControlServiceClient(conn), + Token: api.NewTokenServiceClient(conn), }, nil } diff --git a/components/gitpod-protocol/go/gitpod-service.go b/components/gitpod-protocol/go/gitpod-service.go index 441f07197b0163..4aa13287e66213 100644 --- a/components/gitpod-protocol/go/gitpod-service.go +++ b/components/gitpod-protocol/go/gitpod-service.go @@ -65,7 +65,8 @@ type APIInterface interface { ClosePort(ctx context.Context, workspaceID string, port float32) (err error) GetUserStorageResource(ctx context.Context, options *GetUserStorageResourceOptions) (res string, err error) UpdateUserStorageResource(ctx context.Context, options *UpdateUserStorageResourceOptions) (err error) - GetEnvVars(ctx context.Context) (res []*UserEnvVarValue, err error) + GetWorkspaceEnvVars(ctx context.Context, workspaceID string) (res []*EnvVar, err error) + GetEnvVars(ctx context.Context) (res []*EnvVar, err error) SetEnvVar(ctx context.Context, variable *UserEnvVarValue) (err error) DeleteEnvVar(ctx context.Context, variable *UserEnvVarValue) (err error) HasSSHPublicKey(ctx context.Context) (res bool, err error) @@ -1128,15 +1129,35 @@ func (gp *APIoverJSONRPC) UpdateUserStorageResource(ctx context.Context, options return } +// GetWorkspaceEnvVars calls GetWorkspaceEnvVars on the server +func (gp *APIoverJSONRPC) GetWorkspaceEnvVars(ctx context.Context, workspaceID string) (res []*EnvVar, err error) { + if gp == nil { + err = errNotConnected + return + } + var _params []interface{} + + _params = append(_params, workspaceID) + + var result []*EnvVar + err = gp.C.Call(ctx, "GetWorkspaceEnvVars", _params, &result) + if err != nil { + return + } + res = result + + return +} + // GetEnvVars calls getEnvVars on the server -func (gp *APIoverJSONRPC) GetEnvVars(ctx context.Context) (res []*UserEnvVarValue, err error) { +func (gp *APIoverJSONRPC) GetEnvVars(ctx context.Context) (res []*EnvVar, err error) { if gp == nil { err = errNotConnected return } var _params []interface{} - var result []*UserEnvVarValue + var result []*EnvVar err = gp.C.Call(ctx, "getEnvVars", _params, &result) if err != nil { return @@ -1978,6 +1999,13 @@ type WhitelistedRepository struct { URL string `json:"url,omitempty"` } +// EnvVar is the EnvVar message type +type EnvVar struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Value string `json:"value,omitempty"` +} + // UserEnvVarValue is the UserEnvVarValue message type type UserEnvVarValue struct { ID string `json:"id,omitempty"` diff --git a/components/gitpod-protocol/go/mock.go b/components/gitpod-protocol/go/mock.go index 403a33114a985e..a31cbb9c7550a2 100644 --- a/components/gitpod-protocol/go/mock.go +++ b/components/gitpod-protocol/go/mock.go @@ -357,11 +357,20 @@ func (mr *MockAPIInterfaceMockRecorder) GetContentBlobUploadURL(ctx, name interf return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetContentBlobUploadURL", reflect.TypeOf((*MockAPIInterface)(nil).GetContentBlobUploadURL), ctx, name) } +// GetWorkspaceEnvVars mocks base method. +func (m *MockAPIInterface) GetWorkspaceEnvVars(ctx context.Context, workspaceID string) ([]*EnvVar, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetWorkspaceEnvVars", ctx, workspaceID) + ret0, _ := ret[0].([]*EnvVar) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + // GetEnvVars mocks base method. -func (m *MockAPIInterface) GetEnvVars(ctx context.Context) ([]*UserEnvVarValue, error) { +func (m *MockAPIInterface) GetEnvVars(ctx context.Context) ([]*EnvVar, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetEnvVars", ctx) - ret0, _ := ret[0].([]*UserEnvVarValue) + ret0, _ := ret[0].([]*EnvVar) ret1, _ := ret[1].(error) return ret0, ret1 } diff --git a/components/gitpod-protocol/src/gitpod-service.ts b/components/gitpod-protocol/src/gitpod-service.ts index 75771e469fa5b3..019c94797d07a5 100644 --- a/components/gitpod-protocol/src/gitpod-service.ts +++ b/components/gitpod-protocol/src/gitpod-service.ts @@ -27,6 +27,7 @@ import { UserSSHPublicKeyValue, SSHPublicKeyValue, IDESettings, + EnvVarWithValue, } from "./protocol"; import { Team, @@ -152,6 +153,9 @@ export interface GitpodServer extends JsonRpcServer, AdminServer, getUserStorageResource(options: GitpodServer.GetUserStorageResourceOptions): Promise; updateUserStorageResource(options: GitpodServer.UpdateUserStorageResourceOptions): Promise; + // Workspace env vars + getWorkspaceEnvVars(workspaceId: string): Promise; + // User env vars getEnvVars(): Promise; getAllEnvVars(): Promise; diff --git a/components/gitpod-protocol/src/protocol.ts b/components/gitpod-protocol/src/protocol.ts index 589f3fd25f8278..6e11cbe604964e 100644 --- a/components/gitpod-protocol/src/protocol.ts +++ b/components/gitpod-protocol/src/protocol.ts @@ -311,6 +311,8 @@ export namespace NamedWorkspaceFeatureFlag { } } +export type EnvVar = UserEnvVar | ProjectEnvVarWithValue | EnvVarWithValue; + export interface EnvVarWithValue { name: string; value: string; diff --git a/components/server/ee/src/prebuilds/prebuild-manager.ts b/components/server/ee/src/prebuilds/prebuild-manager.ts index d62c6fc96e4aa2..454571bf5a93ef 100644 --- a/components/server/ee/src/prebuilds/prebuild-manager.ts +++ b/components/server/ee/src/prebuilds/prebuild-manager.ts @@ -10,7 +10,6 @@ import { CommitInfo, PrebuiltWorkspace, Project, - ProjectEnvVar, StartPrebuildContext, StartPrebuildResult, TaskConfig, @@ -39,6 +38,7 @@ import { ResponseError } from "vscode-ws-jsonrpc"; import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; import { UserService } from "../../../src/user/user-service"; import { EntitlementService, MayStartWorkspaceResult } from "../../../src/billing/entitlement-service"; +import { EnvVarService, ResolvedEnvVars } from "../../../src/workspace/env-var-service"; export class WorkspaceRunningError extends Error { constructor(msg: string, public instance: WorkspaceInstance) { @@ -67,6 +67,7 @@ export class PrebuildManager { @inject(UserService) protected readonly userService: UserService; @inject(TeamDB) protected readonly teamDB: TeamDB; @inject(EntitlementService) protected readonly entitlementService: EntitlementService; + @inject(EnvVarService) private readonly envVarService: EnvVarService; async abortPrebuildsForBranch(ctx: TraceContext, project: Project, user: User, branch: string): Promise { const span = TraceContext.startSpan("abortPrebuildsForBranch", ctx); @@ -221,8 +222,6 @@ export class PrebuildManager { } } - const projectEnvVarsPromise = project ? this.projectService.getProjectEnvironmentVariables(project.id) : []; - let organizationId = (await this.teamDB.findTeamById(project.id))?.id; if (!user.additionalData?.isMigratedToTeamOnlyAttribution) { // If the user is not migrated to team-only attribution, we retrieve the organization from the attribution logic. @@ -238,6 +237,9 @@ export class PrebuildManager { prebuildContext, context.normalizedContextURL!, ); + + const envVarsPromise = this.resolveEvnVars(workspace); + const prebuild = await this.workspaceDB.trace({ span }).findPrebuildByWorkspaceID(workspace.id)!; if (!prebuild) { throw new Error(`Failed to create a prebuild for: ${context.normalizedContextURL}`); @@ -281,8 +283,8 @@ export class PrebuildManager { await this.workspaceDB.trace({ span }).storePrebuiltWorkspace(prebuild); } else { span.setTag("starting", true); - const projectEnvVars = await projectEnvVarsPromise; - await this.workspaceStarter.startWorkspace({ span }, workspace, user, project, [], projectEnvVars, { + const envVars = await envVarsPromise; + await this.workspaceStarter.startWorkspace({ span }, workspace, user, project, envVars, { excludeFeatureFlags: ["full_workspace_backup"], }); } @@ -341,11 +343,8 @@ export class PrebuildManager { if (!prebuild) { throw new Error("No prebuild found for workspace " + workspaceId); } - let projectEnvVars: ProjectEnvVar[] = []; - if (workspace.projectId) { - projectEnvVars = await this.projectService.getProjectEnvironmentVariables(workspace.projectId); - } - await this.workspaceStarter.startWorkspace({ span }, workspace, user, project, [], projectEnvVars); + const envVars = await this.resolveEvnVars(workspace); + await this.workspaceStarter.startWorkspace({ span }, workspace, user, project, envVars); return { prebuildId: prebuild.id, wsid: workspace.id, done: false }; } catch (err) { TraceContext.setError({ span }, err); @@ -477,4 +476,11 @@ export class PrebuildManager { span.finish(); } } + + private resolveEvnVars(workspace: Workspace): Promise { + return this.envVarService.resolve({ + user: undefined, // no user specific in prebuild + workspace, + }); + } } diff --git a/components/server/src/auth/rate-limiter.ts b/components/server/src/auth/rate-limiter.ts index 7aa077c133e281..01cf246a124528 100644 --- a/components/server/src/auth/rate-limiter.ts +++ b/components/server/src/auth/rate-limiter.ts @@ -89,6 +89,7 @@ const defaultFunctions: FunctionsConfig = { closePort: { group: "default", points: 1 }, getUserStorageResource: { group: "default", points: 1 }, updateUserStorageResource: { group: "default", points: 1 }, + getWorkspaceEnvVars: { group: "default", points: 1 }, getEnvVars: { group: "default", points: 1 }, getAllEnvVars: { group: "default", points: 1 }, setEnvVar: { group: "default", points: 1 }, diff --git a/components/server/src/container-module.ts b/components/server/src/container-module.ts index 1937f6ebd599ea..b72dfe8537f372 100644 --- a/components/server/src/container-module.ts +++ b/components/server/src/container-module.ts @@ -114,6 +114,7 @@ import { retryMiddleware } from "nice-grpc-client-middleware-retry"; import { IamSessionApp } from "./iam/iam-session-app"; import { spicedbClientFromEnv, SpiceDBClient } from "./authorization/spicedb"; import { Authorizer, PermissionChecker } from "./authorization/perms"; +import { EnvVarService } from "./workspace/env-var-service"; export const productionContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => { bind(Config).toConstantValue(ConfigFile.fromFile()); @@ -256,6 +257,8 @@ export const productionContainerModule = new ContainerModule((bind, unbind, isBo bind(ProjectsService).toSelf().inSingletonScope(); + bind(EnvVarService).toSelf().inSingletonScope(); + bind(NewsletterSubscriptionController).toSelf().inSingletonScope(); bind(UsageServiceDefinition.name) diff --git a/components/server/src/env-var-service.spec.ts b/components/server/src/env-var-service.spec.ts new file mode 100644 index 00000000000000..5935a7995703dd --- /dev/null +++ b/components/server/src/env-var-service.spec.ts @@ -0,0 +1,212 @@ +/** + * Copyright (c) 2023 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 "reflect-metadata"; + +import { suite, test } from "@testdeck/mocha"; +import * as chai from "chai"; +import { EnvVarService } from "./workspace/env-var-service"; +import { + CommitContext, + EnvVarWithValue, + ProjectEnvVar, + ProjectEnvVarWithValue, + User, + UserEnvVar, + WithEnvvarsContext, + Workspace, +} from "@gitpod/gitpod-protocol"; +import { ProjectDB, UserDB } from "@gitpod/gitpod-db/lib"; +import { ProjectsService } from "./projects/projects-service"; +const expect = chai.expect; + +const userEnvVar: UserEnvVar = { + id: "1", + name: "foo", + value: "any", + repositoryPattern: "gitpod/openvscode-service", + userId: "1", +}; + +const commitContext = { + repository: { + owner: "gitpod", + name: "gitpod-io", + }, + revision: "abcd123", + title: "test", +} as CommitContext; + +const userCommitEnvVar: UserEnvVar = { + id: "2", + name: "bar", + value: "commit", + repositoryPattern: "gitpod/gitpod-io", + userId: "1", +}; + +const projectCensoredEnvVar: ProjectEnvVarWithValue = { + id: "3", + name: "bar", + projectId: "1", + censored: true, + value: "project1", +}; + +const projectEnvVar2: ProjectEnvVarWithValue = { + id: "4", + name: "baz", + projectId: "1", + censored: false, + value: "project2", +}; + +const contextEnvVar: EnvVarWithValue = { + name: "bar", + value: "context", +}; +const contextEnvVars = { + envvars: [contextEnvVar], +} as WithEnvvarsContext; + +@suite +class TestEnvVarService { + protected envVarService: EnvVarService; + + public before() { + this.envVarService = new EnvVarService(); + this.envVarService["userDB"] = { + getEnvVars: (_) => { + return Promise.resolve([userEnvVar, userCommitEnvVar]); + }, + } as UserDB; + this.envVarService["projectsService"] = { + getProjectEnvironmentVariables: (_) => { + return Promise.resolve([projectCensoredEnvVar, projectEnvVar2] as ProjectEnvVar[]); + }, + } as ProjectsService; + this.envVarService["projectDB"] = { + getProjectEnvironmentVariableValues: (envs) => { + return Promise.resolve(envs as ProjectEnvVarWithValue[]); + }, + } as ProjectDB; + } + + @test + public async test_none() { + const envVars = await this.envVarService.resolve({ + user: undefined, + workspace: { + type: "regular", + } as Workspace, + }); + expect(envVars).to.deep.equal({ + user: [], + project: [], + context: [], + workspace: [], + }); + } + + @test + public async test_user_any() { + const envVars = await this.envVarService.resolve({ + user: { + id: "1", + } as User, + workspace: { + type: "regular", + } as Workspace, + }); + expect(envVars).to.deep.equal({ + user: [userEnvVar, userCommitEnvVar], + project: [], + context: [], + workspace: [userEnvVar, userCommitEnvVar], + }); + } + + @test + public async test_user_commit() { + const envVars = await this.envVarService.resolve({ + user: { + id: "1", + } as User, + workspace: { + type: "regular", + context: commitContext, + } as any, + }); + expect(envVars).to.deep.equal({ + user: [userCommitEnvVar], + project: [], + context: [], + workspace: [userCommitEnvVar], + }); + } + + @test + public async test_project() { + const envVars = await this.envVarService.resolve({ + user: { + id: "1", + } as User, + workspace: { + type: "regular", + context: commitContext, + projectId: "1", + } as any, + }); + expect(envVars).to.deep.equal({ + user: [userCommitEnvVar], + project: [projectCensoredEnvVar, projectEnvVar2], + context: [], + workspace: [userCommitEnvVar, projectEnvVar2], + }); + } + + @test + public async test_prebuild_project() { + const envVars = await this.envVarService.resolve({ + user: { + id: "1", + } as User, + workspace: { + type: "prebuild", + context: commitContext, + projectId: "1", + } as any, + }); + expect(envVars).to.deep.equal({ + user: [userCommitEnvVar], + project: [projectCensoredEnvVar, projectEnvVar2], + context: [], + workspace: [projectCensoredEnvVar, projectEnvVar2], + }); + } + + @test + public async test_from_context() { + const envVars = await this.envVarService.resolve({ + user: { + id: "1", + } as User, + workspace: { + type: "prebuild", + context: { ...commitContext, ...contextEnvVars }, + projectId: "1", + } as any, + }); + expect(envVars).to.deep.equal({ + user: [userCommitEnvVar], + project: [projectCensoredEnvVar, projectEnvVar2], + context: [contextEnvVar], + workspace: [contextEnvVar, projectEnvVar2], + }); + } +} + +module.exports = new TestEnvVarService(); diff --git a/components/server/src/workspace/env-var-service.ts b/components/server/src/workspace/env-var-service.ts new file mode 100644 index 00000000000000..10de2f75aab987 --- /dev/null +++ b/components/server/src/workspace/env-var-service.ts @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2023 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 { ProjectDB, UserDB } from "@gitpod/gitpod-db/lib"; +import { + CommitContext, + EnvVar, + EnvVarWithValue, + ProjectEnvVar, + User, + UserEnvVar, + WithEnvvarsContext, + Workspace, +} from "@gitpod/gitpod-protocol"; +import { inject, injectable } from "inversify"; +import { ProjectsService } from "../projects/projects-service"; + +export interface ResolvedEnvVars { + user: UserEnvVar[]; + // all project env vars, censored included always + project: ProjectEnvVar[]; + context: EnvVarWithValue[]; + // merged workspace env vars + workspace: EnvVar[]; +} + +@injectable() +export class EnvVarService { + @inject(UserDB) + private userDB: UserDB; + + @inject(ProjectsService) + private projectsService: ProjectsService; + + @inject(ProjectDB) + private projectDB: ProjectDB; + + async resolve({ user, workspace }: { user: User | undefined; workspace: Workspace }): Promise { + const workspaceEnvVars = new Map(); + const merge = (envs: EnvVar[]) => { + for (const env of envs) { + workspaceEnvVars.set(env.name, env); + } + }; + + // 1. first merge user envs + let userEnvVars = user ? await this.userDB.getEnvVars(user.id) : []; + if (CommitContext.is(workspace.context)) { + // this is a commit context, thus we can filter the env vars + userEnvVars = UserEnvVar.filter( + userEnvVars, + workspace.context.repository.owner, + workspace.context.repository.name, + ); + } + merge(userEnvVars); + + // 2. then from the project + const projectEnvVars = workspace.projectId + ? await this.projectsService.getProjectEnvironmentVariables(workspace.projectId) + : []; + if (projectEnvVars.length) { + // Instead of using an access guard for Project environment variables, we let Project owners decide whether + // a variable should be: + // - exposed in all workspaces (even for non-Project members when the repository is public), or + // - censored from all workspaces (even for Project members) + let availablePrjEnvVars = projectEnvVars; + if (workspace.type !== "prebuild") { + availablePrjEnvVars = projectEnvVars.filter((variable) => !variable.censored); + } + const withValues = await this.projectDB.getProjectEnvironmentVariableValues(availablePrjEnvVars); + merge(withValues); + } + + // 3. then parsed from the context URL + let contextEnvVars: EnvVarWithValue[] = []; + if (WithEnvvarsContext.is(workspace.context)) { + contextEnvVars = workspace.context.envvars; + } + merge(contextEnvVars); + + return { + user: userEnvVars, + project: projectEnvVars, + context: contextEnvVars, + workspace: [...workspaceEnvVars.values()], + }; + } +} diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index 5dcf952166bd6e..d988cddb2b99be 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -162,7 +162,7 @@ import { IDEOptions } from "@gitpod/gitpod-protocol/lib/ide-protocol"; import { PartialProject } from "@gitpod/gitpod-protocol/lib/teams-projects-protocol"; import { ClientMetadata, traceClientMetadata } from "../websocket/websocket-connection-manager"; import { ConfigurationService } from "../config/configuration-service"; -import { ProjectEnvVar } from "@gitpod/gitpod-protocol/lib/protocol"; +import { EnvVarWithValue, ProjectEnvVar } from "@gitpod/gitpod-protocol/lib/protocol"; import { InstallationAdminSettings, TelemetryData } from "@gitpod/gitpod-protocol"; import { Deferred } from "@gitpod/gitpod-protocol/lib/util/deferred"; import { InstallationAdminTelemetryDataProvider } from "../installation-admin/telemetry-data-provider"; @@ -200,6 +200,7 @@ import { import { reportCentralizedPermsValidation } from "../prometheus-metrics"; import { RegionService } from "./region-service"; import { isWorkspaceRegion, WorkspaceRegion } from "@gitpod/gitpod-protocol/lib/workspace-cluster"; +import { EnvVarService } from "./env-var-service"; // shortcut export const traceWI = (ctx: TraceContext, wi: Omit) => TraceContext.setOWI(ctx, wi); // userId is already taken care of in WebsocketConnectionManager @@ -274,6 +275,9 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { @inject(PermissionChecker) protected readonly authorizer: Authorizer; + @inject(EnvVarService) + private readonly envVarService: EnvVarService; + /** Id the uniquely identifies this server instance */ public readonly uuid: string = uuidv4(); public readonly clientMetadata: ClientMetadata; @@ -741,8 +745,7 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { if (workspace.deleted) { throw new ResponseError(ErrorCodes.PERMISSION_DENIED, "Cannot (re-)start a deleted workspace."); } - const userEnvVars = this.userDB.getEnvVars(user.id); - const projectEnvVarsPromise = this.internalGetProjectEnvVars(workspace.projectId); + const envVarsPromise = this.envVarService.resolve({ user, workspace }); const projectPromise = workspace.projectId ? this.projectDB.findProjectById(workspace.projectId) : Promise.resolve(undefined); @@ -757,8 +760,7 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { workspace, user, await projectPromise, - await userEnvVars, - await projectEnvVarsPromise, + await envVarsPromise, options, ); traceWI(ctx, { instanceId: result.instanceID }); @@ -1087,7 +1089,6 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { const user = this.checkAndBlockUser("createWorkspace", { options }); await this.checkTermsAcceptance(); - const envVars = this.userDB.getEnvVars(user.id); logContext = { userId: user.id }; // Credit check runs in parallel with the other operations up until we start consuming resources. @@ -1225,7 +1226,7 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { throw err; } - let projectEnvVarsPromise = this.internalGetProjectEnvVars(workspace.projectId); + const envVarsPromise = this.envVarService.resolve({ user, workspace }); options.region = await this.determineWorkspaceRegion(workspace, options.region || ""); logContext.workspaceId = workspace.id; @@ -1235,8 +1236,7 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { workspace, user, project, - await envVars, - await projectEnvVarsPromise, + await envVarsPromise, options, ); ctx.span?.log({ event: "startWorkspaceComplete", ...startWorkspaceResult }); @@ -1837,7 +1837,27 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { return []; } + async getWorkspaceEnvVars(ctx: TraceContext, workspaceId: string): Promise { + const user = this.checkUser("getWorkspaceEnvVars"); + const workspace = await this.internalGetWorkspace(workspaceId, this.workspaceDb.trace(ctx)); + await this.guardAccess({ kind: "workspace", subject: workspace }, "get"); + const envVars = await this.envVarService.resolve({ user, workspace }); + + const result: EnvVarWithValue[] = []; + for (const value of envVars.workspace) { + if ( + "repositoryPattern" in value && + !(await this.resourceAccessGuard.canAccess({ kind: "envVar", subject: value }, "get")) + ) { + continue; + } + result.push(value); + } + return result; + } + // Get environment variables (filter by repository pattern precedence) + // TODO remove then latsest gitpod-cli is deployed async getEnvVars(ctx: TraceContext): Promise { const user = this.checkUser("getEnvVars"); const result = new Map(); diff --git a/components/server/src/workspace/workspace-starter.ts b/components/server/src/workspace/workspace-starter.ts index 4b1a78658d5fa9..982363a8bbe7bf 100644 --- a/components/server/src/workspace/workspace-starter.ts +++ b/components/server/src/workspace/workspace-starter.ts @@ -37,9 +37,6 @@ import { SnapshotContext, StartWorkspaceResult, User, - UserEnvVar, - UserEnvVarValue, - WithEnvvarsContext, WithPrebuild, Workspace, WorkspaceContext, @@ -54,10 +51,8 @@ import { DisposableCollection, AdditionalContentContext, ImageConfigFile, - ProjectEnvVar, ImageBuildLogInfo, WithReferrerContext, - EnvVarWithValue, BillingTier, Project, GitpodServer, @@ -127,6 +122,7 @@ import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution"; import { LogContext } from "@gitpod/gitpod-protocol/lib/util/logging"; import { repeat } from "@gitpod/gitpod-protocol/lib/util/repeat"; import { WorkspaceRegion } from "@gitpod/gitpod-protocol/lib/workspace-cluster"; +import { ResolvedEnvVars } from "./env-var-service"; export interface StartWorkspaceOptions extends GitpodServer.StartWorkspaceOptions { rethrow?: boolean; @@ -211,8 +207,7 @@ export class WorkspaceStarter { workspace: Workspace, user: User, project: Project | undefined, - userEnvVars: UserEnvVar[], - projectEnvVars: ProjectEnvVar[], + envVars: ResolvedEnvVars, options?: StartWorkspaceOptions, ): Promise { const span = TraceContext.startSpan("WorkspaceStarter.startWorkspace", ctx); @@ -303,7 +298,7 @@ export class WorkspaceStarter { try { // if we need to build the workspace image we must not wait for actuallyStartWorkspace to return as that would block the // frontend until the image is built. - const additionalAuth = await this.getAdditionalImageAuth(projectEnvVars); + const additionalAuth = await this.getAdditionalImageAuth(envVars); needsImageBuild = forceRebuild || (await this.needsImageBuild({ span }, user, workspace, instance, additionalAuth, options?.region)); @@ -332,8 +327,7 @@ export class WorkspaceStarter { user, lastValidWorkspaceInstance?.id ?? "", ideConfig, - userEnvVars, - projectEnvVars, + envVars, options.rethrow, forceRebuild, options?.region, @@ -348,8 +342,7 @@ export class WorkspaceStarter { user, lastValidWorkspaceInstance?.id ?? "", ideConfig, - userEnvVars, - projectEnvVars, + envVars, options.rethrow, forceRebuild, options?.region, @@ -438,8 +431,7 @@ export class WorkspaceStarter { user: User, lastValidWorkspaceInstanceId: string, ideConfig: IdeServiceApi.ResolveWorkspaceConfigResponse, - userEnvVars: UserEnvVar[], - projectEnvVars: ProjectEnvVar[], + envVars: ResolvedEnvVars, rethrow?: boolean, forceRebuild?: boolean, region?: WorkspaceRegion, @@ -449,7 +441,7 @@ export class WorkspaceStarter { try { // build workspace image - const additionalAuth = await this.getAdditionalImageAuth(projectEnvVars); + const additionalAuth = await this.getAdditionalImageAuth(envVars); instance = await this.buildWorkspaceImage( { span }, user, @@ -475,8 +467,7 @@ export class WorkspaceStarter { instance, lastValidWorkspaceInstanceId, ideConfig, - userEnvVars, - projectEnvVars, + envVars, ); // create start workspace request @@ -686,9 +677,9 @@ export class WorkspaceStarter { return undefined; } - protected async getAdditionalImageAuth(projectEnvVars: ProjectEnvVar[]): Promise> { + protected async getAdditionalImageAuth(envVars: ResolvedEnvVars): Promise> { const res = new Map(); - const imageAuth = projectEnvVars.find((e) => e.name === "GITPOD_IMAGE_AUTH"); + const imageAuth = envVars.project.find((e) => e.name === "GITPOD_IMAGE_AUTH"); if (!imageAuth) { return res; } @@ -1289,47 +1280,17 @@ export class WorkspaceStarter { instance: WorkspaceInstance, lastValidWorkspaceInstanceId: string, ideConfig: IdeServiceApi.ResolveWorkspaceConfigResponse, - userEnvVars: UserEnvVarValue[], - projectEnvVars: ProjectEnvVar[], + envVars: ResolvedEnvVars, ): Promise { const context = workspace.context; - let allEnvVars: EnvVarWithValue[] = []; - if (userEnvVars.length > 0) { - if (CommitContext.is(context)) { - // this is a commit context, thus we can filter the env vars - allEnvVars = allEnvVars.concat( - UserEnvVar.filter(userEnvVars, context.repository.owner, context.repository.name), - ); - } else { - allEnvVars = allEnvVars.concat(userEnvVars); - } - } - if (projectEnvVars.length > 0) { - // Instead of using an access guard for Project environment variables, we let Project owners decide whether - // a variable should be: - // - exposed in all workspaces (even for non-Project members when the repository is public), or - // - censored from all workspaces (even for Project members) - let availablePrjEnvVars = projectEnvVars; - if (workspace.type !== "prebuild") { - availablePrjEnvVars = projectEnvVars.filter((variable) => !variable.censored); - } - const withValues = await this.projectDB.getProjectEnvironmentVariableValues(availablePrjEnvVars); - allEnvVars = allEnvVars.concat(withValues); - } - if (WithEnvvarsContext.is(context)) { - allEnvVars = allEnvVars.concat(context.envvars); - } - - const envvars: EnvironmentVariable[] = []; - // TODO(cw): for the time being we're still pushing the env vars as we did before. // Once everything is running with the latest supervisor, we can stop doing that. - allEnvVars.forEach((e) => { + const envvars = envVars.workspace.map((e) => { const ev = new EnvironmentVariable(); ev.setName(e.name); ev.setValue(e.value); - envvars.push(ev); + return ev; }); const contextUrlEnv = new EnvironmentVariable(); @@ -1534,7 +1495,8 @@ export class WorkspaceStarter { "function:getContentBlobDownloadUrl", "function:accessCodeSyncStorage", "function:guessGitTokenScopes", - "function:getEnvVars", + "function:getWorkspaceEnvVars", + "function:getEnvVars", // TODO remove this after new gitpod-cli is deployed "function:setEnvVar", "function:deleteEnvVar", "function:getTeams",