diff --git a/components/dashboard/src/settings/Preferences.tsx b/components/dashboard/src/settings/Preferences.tsx index 2db7aec61490d8..53afcd3075b435 100644 --- a/components/dashboard/src/settings/Preferences.tsx +++ b/components/dashboard/src/settings/Preferences.tsx @@ -85,6 +85,13 @@ export default function Preferences() { const browserIdeOptions = ideOptions && orderedIdeOptions(ideOptions, "browser"); const desktopIdeOptions = ideOptions && orderedIdeOptions(ideOptions, "desktop"); + const [dotfileRepo, setDotfileRepo] = useState(user?.additionalData?.dotfileRepo || ""); + const actuallySetDotfileRepo = async (value: string) => { + const additionalData = user?.additionalData || {}; + additionalData.dotfileRepo = value; + await getGitpodService().server.updateLoggedInUser({ additionalData }); + }; + return
{ideOptions && browserIdeOptions && <> @@ -153,6 +160,17 @@ export default function Preferences() {
+ +

Dotfiles Beta

+

Customize workspaces using dotfiles.

+
+

Repository URL

+ setDotfileRepo(e.target.value)} /> +

Add a repository URL that includes dotfiles. Gitpod will clone and install your dotfiles for every new workspace.

+
+ +
+
; } diff --git a/components/gitpod-protocol/src/protocol.ts b/components/gitpod-protocol/src/protocol.ts index 4f235923a4b0a6..3ebc6714af89f6 100644 --- a/components/gitpod-protocol/src/protocol.ts +++ b/components/gitpod-protocol/src/protocol.ts @@ -104,6 +104,9 @@ export interface AdditionalUserData { oauthClientsApproved?: { [key: string]: string } // to remember GH Orgs the user installed/updated the GH App for knownGitHubOrgs?: string[]; + + // Git clone URL pointing to the user's dotfile repo + dotfileRepo?: string; } export interface EmailNotificationSettings { diff --git a/components/server/src/workspace/workspace-starter.ts b/components/server/src/workspace/workspace-starter.ts index 8a6940f46e8396..221070326297f8 100644 --- a/components/server/src/workspace/workspace-starter.ts +++ b/components/server/src/workspace/workspace-starter.ts @@ -653,6 +653,12 @@ export class WorkspaceStarter { vsxRegistryUrl.setValue(this.config.vsxRegistryUrl); envvars.push(vsxRegistryUrl); + // supervisor ensures dotfiles are only used if the workspace is a regular workspace + const dotfileEnv = new EnvironmentVariable(); + dotfileEnv.setName("SUPERVISOR_DOTFILE_REPO"); + dotfileEnv.setValue(user.additionalData?.dotfileRepo || ""); + envvars.push(dotfileEnv); + const createGitpodTokenPromise = (async () => { const scopes = this.createDefaultGitpodAPITokenScopes(workspace, instance); const token = crypto.randomBytes(30).toString('hex'); diff --git a/components/supervisor/pkg/supervisor/config.go b/components/supervisor/pkg/supervisor/config.go index cbff2439d7954e..00d9a8d00d40d8 100644 --- a/components/supervisor/pkg/supervisor/config.go +++ b/components/supervisor/pkg/supervisor/config.go @@ -236,6 +236,10 @@ type WorkspaceConfig struct { // WorkspaceClusterHost is a host under which this workspace is served, e.g. ws-eu11.gitpod.io WorkspaceClusterHost string `env:"GITPOD_WORKSPACE_CLUSTER_HOST"` + + // DotfileRepo is a user-configurable repository which contains their dotfiles to customise + // the in-workspace epxerience. + DotfileRepo string `env:"SUPERVISOR_DOTFILE_REPO"` } // WorkspaceGitpodToken is a list of tokens that should be added to supervisor's token service. diff --git a/components/supervisor/pkg/supervisor/supervisor.go b/components/supervisor/pkg/supervisor/supervisor.go index f11918d2c3736d..0e319166bc45ca 100644 --- a/components/supervisor/pkg/supervisor/supervisor.go +++ b/components/supervisor/pkg/supervisor/supervisor.go @@ -13,6 +13,7 @@ import ( "errors" "fmt" "io" + "io/fs" "io/ioutil" "net" "net/http" @@ -35,6 +36,7 @@ import ( "github.com/soheilhy/cmux" "golang.org/x/crypto/ssh" "golang.org/x/sys/unix" + "golang.org/x/xerrors" "google.golang.org/grpc" "google.golang.org/protobuf/proto" @@ -278,6 +280,10 @@ func Run(options ...RunOption) { // - in headless task we can not use reaper, because it breaks headlessTaskFailed report if !cfg.isHeadless() { go reaper(terminatingReaper) + + // We need to checkout dotfiles first, because they may be changing the path which affects the IDE. + // TODO(cw): provide better feedback if the IDE start fails because of the dotfiles (provide any feedback at all). + installDotfiles(ctx, termMuxSrv, cfg) } var ideWG sync.WaitGroup @@ -364,6 +370,148 @@ func Run(options ...RunOption) { wg.Wait() } +func installDotfiles(ctx context.Context, term *terminal.MuxTerminalService, cfg *Config) { + repo := cfg.DotfileRepo + if repo == "" { + return + } + + const dotfilePath = "/home/gitpod/.dotfiles" + if _, err := os.Stat(dotfilePath); err == nil { + // dotfile path exists already - nothing to do here + return + } + + prep := func(cfg *Config, out io.Writer, name string, args ...string) *exec.Cmd { + cmd := exec.Command(name, args...) + cmd.Dir = "/home/gitpod" + cmd.Env = buildChildProcEnv(cfg, nil) + cmd.SysProcAttr = &syscall.SysProcAttr{ + // All supervisor children run as gitpod user. The environment variables we produce are also + // gitpod user specific. + Credential: &syscall.Credential{ + Uid: gitpodUID, + Gid: gitpodGID, + }, + } + cmd.Stdout = out + cmd.Stderr = out + return cmd + } + + err := func() (err error) { + out, err := os.OpenFile("/home/gitpod/.dotfiles.log", os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer out.Close() + + defer func() { + if err != nil { + out.WriteString(fmt.Sprintf("# dotfile init failed: %s\n", err.Error())) + } + }() + + done := make(chan error, 1) + go func() { + done <- prep(cfg, out, "git", "clone", "--depth=1", repo, "/home/gitpod/.dotfiles").Run() + close(done) + }() + select { + case err := <-done: + if err != nil { + return err + } + case <-time.After(120 * time.Second): + return xerrors.Errorf("dotfiles repo clone did not finish within two minutes") + } + + // at this point we have the dotfile repo cloned, let's try and install it + var candidates = []string{ + "install.sh", + "install", + "bootstrap.sh", + "bootstrap", + "script/bootstrap", + "setup.sh", + "setup", + "script/setup", + } + for _, c := range candidates { + fn := filepath.Join(dotfilePath, c) + stat, err := os.Stat(fn) + if err != nil { + _, _ = out.WriteString(fmt.Sprintf("# installation script candidate %s is not available\n", fn)) + continue + } + if stat.IsDir() { + _, _ = out.WriteString(fmt.Sprintf("# installation script candidate %s is a directory\n", fn)) + continue + } + if stat.Mode()&0111 == 0 { + _, _ = out.WriteString(fmt.Sprintf("# installation script candidate %s is not executable\n", fn)) + continue + } + + _, _ = out.WriteString(fmt.Sprintf("# executing installation script candidate %s\n", fn)) + + // looks like we've found a candidate, let's run it + cmd := prep(cfg, out, "/bin/sh", "-c", "exec "+fn) + err = cmd.Start() + if err != nil { + return err + } + done := make(chan error, 1) + go func() { + done <- cmd.Wait() + close(done) + }() + + select { + case err = <-done: + return err + case <-time.After(120 * time.Second): + cmd.Process.Kill() + return xerrors.Errorf("installation process %s tool longer than 120 seconds", fn) + } + } + + // no installation script candidate was found, let's try and symlink this stuff + err = filepath.Walk(dotfilePath, func(path string, info fs.FileInfo, err error) error { + if strings.Contains(path, "/.git") { + // don't symlink the .git directory or any of its content + return nil + } + + homeFN := filepath.Join("/home/gitpod", strings.TrimPrefix(path, dotfilePath)) + if _, err := os.Stat(homeFN); err == nil { + // homeFN exists already - do nothing + return nil + } + + if info.IsDir() { + err = os.MkdirAll(homeFN, info.Mode().Perm()) + if err != nil { + return err + } + return nil + } + + // write some feedback to the terminal + out.WriteString(fmt.Sprintf("# echo linking %s -> %s\n", path, homeFN)) + + return os.Symlink(path, homeFN) + }) + + return nil + }() + if err != nil { + // installing the dotfiles failed for some reason - we must tell the user + // TODO(cw): tell the user + log.WithError(err).Warn("installing dotfiles failed") + } +} + func createGitpodService(cfg *Config, tknsrv api.TokenServiceServer) *gitpod.APIoverJSONRPC { endpoint, host, err := cfg.GitpodAPIEndpoint() if err != nil { @@ -1223,7 +1371,7 @@ func socketActivationForDocker(ctx context.Context, wg *sync.WaitGroup, term *te cmd.ExtraFiles = []*os.File{socketFD} alias, err := term.Start(cmd, terminal.TermOptions{ Annotations: map[string]string{ - "supervisor": "true", + "gitpod.supervisor": "true", }, LogToStdout: true, })