Skip to content

Commit d2999a9

Browse files
committed
Add dotfile repo support
1 parent 81372bc commit d2999a9

File tree

5 files changed

+171
-1
lines changed

5 files changed

+171
-1
lines changed

components/dashboard/src/settings/Preferences.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,14 @@ export default function Preferences() {
8585
const browserIdeOptions = ideOptions && orderedIdeOptions(ideOptions, "browser");
8686
const desktopIdeOptions = ideOptions && orderedIdeOptions(ideOptions, "desktop");
8787

88+
const [dotfileRepo, setDotfileRepo] = useState<string>(user?.additionalData?.dotfileRepo || "");
89+
const actuallySetDotfileRepo = async (value: string) => {
90+
const additionalData = user?.additionalData || {};
91+
additionalData.dotfileRepo = value;
92+
await getGitpodService().server.updateLoggedInUser({ additionalData });
93+
setDotfileRepo(value);
94+
};
95+
8896
return <div>
8997
<PageWithSubMenu subMenu={settingsMenu} title='Preferences' subtitle='Configure user preferences.'>
9098
{ideOptions && browserIdeOptions && <>
@@ -153,6 +161,12 @@ export default function Preferences() {
153161
</div>
154162
</SelectableCard>
155163
</div>
164+
<h3 className="mt-12">Dotfiles</h3>
165+
<p className="text-base text-gray-500 dark:text-gray-400">Customise every workspace using dotfiles. Add a repo below which gets cloned and installed during workspace startup.</p>
166+
<div className="mt-4">
167+
<h4>Repo</h4>
168+
<input type="text" value={dotfileRepo} onChange={(e) => actuallySetDotfileRepo(e.target.value)} className="w-full" />
169+
</div>
156170
</PageWithSubMenu>
157171
</div>;
158172
}

components/gitpod-protocol/src/protocol.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ export interface AdditionalUserData {
104104
oauthClientsApproved?: { [key: string]: string }
105105
// to remember GH Orgs the user installed/updated the GH App for
106106
knownGitHubOrgs?: string[];
107+
108+
// Git clone URL pointing to the user's dotfile repo
109+
dotfileRepo?: string;
107110
}
108111

109112
export interface EmailNotificationSettings {

components/server/src/workspace/workspace-starter.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,12 @@ export class WorkspaceStarter {
653653
vsxRegistryUrl.setValue(this.config.vsxRegistryUrl);
654654
envvars.push(vsxRegistryUrl);
655655

656+
// supervisor ensures dotfiles are only used if the workspace is a regular workspace
657+
const dotfileEnv = new EnvironmentVariable();
658+
dotfileEnv.setName("SUPERVISOR_DOTFILE_REPO");
659+
dotfileEnv.setValue(user.additionalData?.dotfileRepo || "");
660+
envvars.push(dotfileEnv);
661+
656662
const createGitpodTokenPromise = (async () => {
657663
const scopes = this.createDefaultGitpodAPITokenScopes(workspace, instance);
658664
const token = crypto.randomBytes(30).toString('hex');

components/supervisor/pkg/supervisor/config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,10 @@ type WorkspaceConfig struct {
236236

237237
// WorkspaceClusterHost is a host under which this workspace is served, e.g. ws-eu11.gitpod.io
238238
WorkspaceClusterHost string `env:"GITPOD_WORKSPACE_CLUSTER_HOST"`
239+
240+
// DotfileRepo is a user-configurable repository which contains their dotfiles to customise
241+
// the in-workspace epxerience.
242+
DotfileRepo string `env:"SUPERVISOR_DOTFILE_REPO"`
239243
}
240244

241245
// WorkspaceGitpodToken is a list of tokens that should be added to supervisor's token service.

components/supervisor/pkg/supervisor/supervisor.go

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"errors"
1414
"fmt"
1515
"io"
16+
"io/fs"
1617
"io/ioutil"
1718
"net"
1819
"net/http"
@@ -35,6 +36,7 @@ import (
3536
"github.com/soheilhy/cmux"
3637
"golang.org/x/crypto/ssh"
3738
"golang.org/x/sys/unix"
39+
"golang.org/x/xerrors"
3840
"google.golang.org/grpc"
3941
"google.golang.org/protobuf/proto"
4042

@@ -278,6 +280,10 @@ func Run(options ...RunOption) {
278280
// - in headless task we can not use reaper, because it breaks headlessTaskFailed report
279281
if !cfg.isHeadless() {
280282
go reaper(terminatingReaper)
283+
284+
// We need to checkout dotfiles first, because they may be changing the path which affects the IDE.
285+
// TODO(cw): provide better feedback if the IDE start fails because of the dotfiles (provide any feedback at all).
286+
installDotfiles(ctx, termMuxSrv, cfg)
281287
}
282288

283289
var ideWG sync.WaitGroup
@@ -364,6 +370,143 @@ func Run(options ...RunOption) {
364370
wg.Wait()
365371
}
366372

373+
func installDotfiles(ctx context.Context, term *terminal.MuxTerminalService, cfg *Config) {
374+
repo := cfg.DotfileRepo
375+
if repo == "" {
376+
return
377+
}
378+
379+
const dotfilePath = "/home/gitpod/.dotfiles"
380+
if _, err := os.Stat(dotfilePath); err == nil {
381+
// dotfile path exists already - nothing to do here
382+
return
383+
}
384+
385+
prep := func(cfg *Config, out io.Writer, name string, args ...string) *exec.Cmd {
386+
cmd := exec.Command(name, args...)
387+
cmd.Dir = "/home/gitpod"
388+
cmd.Env = buildChildProcEnv(cfg, nil)
389+
cmd.SysProcAttr = &syscall.SysProcAttr{
390+
// All supervisor children run as gitpod user. The environment variables we produce are also
391+
// gitpod user specific.
392+
Credential: &syscall.Credential{
393+
Uid: gitpodUID,
394+
Gid: gitpodGID,
395+
},
396+
}
397+
cmd.Stdout = out
398+
cmd.Stderr = out
399+
return cmd
400+
}
401+
402+
err := func() (err error) {
403+
out, err := os.OpenFile("/home/gitpod/.dotfiles.log", os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
404+
if err != nil {
405+
return err
406+
}
407+
defer out.Close()
408+
409+
defer func() {
410+
if err != nil {
411+
out.WriteString(fmt.Sprintf("# dotfile init failed: %s\n", err.Error()))
412+
}
413+
}()
414+
415+
done := make(chan error, 1)
416+
go func() {
417+
done <- prep(cfg, out, "git", "clone", "--depth=1", repo, "/home/gitpod/.dotfiles").Run()
418+
close(done)
419+
}()
420+
select {
421+
case err := <-done:
422+
if err != nil {
423+
return err
424+
}
425+
case <-time.After(120 * time.Second):
426+
return xerrors.Errorf("dotfiles repo clone did not finish within two minutes")
427+
}
428+
429+
// at this point we have the dotfile repo cloned, let's try and install it
430+
var candidates = []string{
431+
"install.sh",
432+
"install",
433+
"bootstrap.sh",
434+
"bootstrap",
435+
"script/bootstrap",
436+
"setup.sh",
437+
"setup",
438+
"script/setup",
439+
}
440+
for _, c := range candidates {
441+
fn := filepath.Join(dotfilePath, c)
442+
stat, err := os.Stat(fn)
443+
if err != nil {
444+
_, _ = out.WriteString(fmt.Sprintf("# installation script candidate %s is not available\n", fn))
445+
continue
446+
}
447+
if stat.IsDir() {
448+
_, _ = out.WriteString(fmt.Sprintf("# installation script candidate %s is a directory\n", fn))
449+
continue
450+
}
451+
if stat.Mode()&0111 == 0 {
452+
_, _ = out.WriteString(fmt.Sprintf("# installation script candidate %s is not executable\n", fn))
453+
continue
454+
}
455+
456+
_, _ = out.WriteString(fmt.Sprintf("# executing installation script candidate %s\n", fn))
457+
458+
// looks like we've found a candidate, let's run it
459+
cmd := prep(cfg, out, "/bin/sh", "-c", "exec "+fn)
460+
err = cmd.Start()
461+
if err != nil {
462+
return err
463+
}
464+
done := make(chan error, 1)
465+
go func() {
466+
done <- cmd.Wait()
467+
close(done)
468+
}()
469+
470+
select {
471+
case err = <-done:
472+
return err
473+
case <-time.After(120 * time.Second):
474+
cmd.Process.Kill()
475+
return xerrors.Errorf("installation process %s tool longer than 120 seconds", fn)
476+
}
477+
}
478+
479+
// no installation script candidate was found, let's try and symlink this stuff
480+
err = filepath.Walk(dotfilePath, func(path string, info fs.FileInfo, err error) error {
481+
homeFN := filepath.Join("/home/gitpod", strings.TrimPrefix(path, dotfilePath))
482+
if _, err := os.Stat(homeFN); err == nil {
483+
// homeFN exists already - do nothing
484+
return nil
485+
}
486+
487+
if info.IsDir() {
488+
err = os.MkdirAll(homeFN, info.Mode().Perm())
489+
if err != nil {
490+
return err
491+
}
492+
return nil
493+
}
494+
495+
// write some feedback to the terminal
496+
out.WriteString(fmt.Sprintf("# echo linking %s -> %s\n", path, homeFN))
497+
498+
return os.Symlink(path, homeFN)
499+
})
500+
501+
return nil
502+
}()
503+
if err != nil {
504+
// installing the dotfiles failed for some reason - we must tell the user
505+
// TODO(cw): tell the user
506+
log.WithError(err).Warn("installing dotfiles failed")
507+
}
508+
}
509+
367510
func createGitpodService(cfg *Config, tknsrv api.TokenServiceServer) *gitpod.APIoverJSONRPC {
368511
endpoint, host, err := cfg.GitpodAPIEndpoint()
369512
if err != nil {
@@ -1223,7 +1366,7 @@ func socketActivationForDocker(ctx context.Context, wg *sync.WaitGroup, term *te
12231366
cmd.ExtraFiles = []*os.File{socketFD}
12241367
alias, err := term.Start(cmd, terminal.TermOptions{
12251368
Annotations: map[string]string{
1226-
"supervisor": "true",
1369+
"gitpod.supervisor": "true",
12271370
},
12281371
LogToStdout: true,
12291372
})

0 commit comments

Comments
 (0)