mirror of
https://github.com/gitpod-io/gitpod.git
synced 2025-12-08 17:36:30 +00:00
1689 lines
47 KiB
Go
1689 lines
47 KiB
Go
// Copyright (c) 2020 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.
|
|
|
|
package supervisor
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"io/ioutil"
|
|
"math"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
grpc_logrus "github.com/grpc-ecosystem/go-grpc-middleware/logging/logrus"
|
|
grpcruntime "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
|
|
"github.com/improbable-eng/grpc-web/go/grpcweb"
|
|
"github.com/prometheus/common/route"
|
|
"github.com/prometheus/procfs"
|
|
"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"
|
|
|
|
"github.com/gitpod-io/gitpod/common-go/analytics"
|
|
"github.com/gitpod-io/gitpod/common-go/log"
|
|
"github.com/gitpod-io/gitpod/common-go/pprof"
|
|
csapi "github.com/gitpod-io/gitpod/content-service/api"
|
|
"github.com/gitpod-io/gitpod/content-service/pkg/executor"
|
|
"github.com/gitpod-io/gitpod/content-service/pkg/git"
|
|
"github.com/gitpod-io/gitpod/content-service/pkg/initializer"
|
|
gitpod "github.com/gitpod-io/gitpod/gitpod-protocol"
|
|
"github.com/gitpod-io/gitpod/supervisor/api"
|
|
"github.com/gitpod-io/gitpod/supervisor/pkg/activation"
|
|
"github.com/gitpod-io/gitpod/supervisor/pkg/config"
|
|
"github.com/gitpod-io/gitpod/supervisor/pkg/dropwriter"
|
|
"github.com/gitpod-io/gitpod/supervisor/pkg/ports"
|
|
"github.com/gitpod-io/gitpod/supervisor/pkg/terminal"
|
|
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
dto "github.com/prometheus/client_model/go"
|
|
"github.com/prometheus/pushgateway/handler"
|
|
"github.com/prometheus/pushgateway/storage"
|
|
)
|
|
|
|
const (
|
|
gitpodUID = 33333
|
|
gitpodUserName = "gitpod"
|
|
gitpodGID = 33333
|
|
gitpodGroupName = "gitpod"
|
|
desktopIDEPort = 24000
|
|
)
|
|
|
|
var (
|
|
additionalServices []RegisterableService
|
|
apiEndpointOpts []grpc.ServerOption
|
|
Version = ""
|
|
)
|
|
|
|
// RegisterAdditionalService registers additional services for the API endpoint
|
|
// of supervisor.
|
|
func RegisterAdditionalService(services ...RegisterableService) {
|
|
additionalServices = append(additionalServices, services...)
|
|
}
|
|
|
|
// AddAPIEndpointOpts adds additional grpc server options for the API endpoint.
|
|
func AddAPIEndpointOpts(opts ...grpc.ServerOption) {
|
|
apiEndpointOpts = append(apiEndpointOpts, opts...)
|
|
}
|
|
|
|
type runOptions struct {
|
|
Args []string
|
|
RunGP bool
|
|
}
|
|
|
|
// RunOption customizes the run behaviour.
|
|
type RunOption func(*runOptions)
|
|
|
|
// WithArgs sets the arguments passed to Run.
|
|
func WithArgs(args []string) RunOption {
|
|
return func(r *runOptions) {
|
|
r.Args = args
|
|
}
|
|
}
|
|
|
|
// WithRunGP disables some functionality for use with run-gp
|
|
func WithRunGP(enable bool) RunOption {
|
|
return func(r *runOptions) {
|
|
r.RunGP = enable
|
|
}
|
|
}
|
|
|
|
// The sum of those timeBudget* times has to fit within the terminationGracePeriod of the workspace pod.
|
|
const (
|
|
timeBudgetIDEShutdown = 15 * time.Second
|
|
)
|
|
|
|
const (
|
|
// KindGitpod marks tokens that provide access to the Gitpod server API.
|
|
KindGitpod = "gitpod"
|
|
|
|
// KindGit marks any kind of Git access token.
|
|
KindGit = "git"
|
|
)
|
|
|
|
type ShutdownReason int16
|
|
|
|
const (
|
|
ShutdownReasonSuccess ShutdownReason = 0
|
|
ShutdownReasonExecutionError ShutdownReason = 1
|
|
)
|
|
|
|
type IDEKind int64
|
|
|
|
const (
|
|
WebIDE IDEKind = iota
|
|
DesktopIDE
|
|
)
|
|
|
|
func (s IDEKind) String() string {
|
|
switch s {
|
|
case WebIDE:
|
|
return "IDE"
|
|
case DesktopIDE:
|
|
return "Desktop IDE"
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
// Run serves as main entrypoint to the supervisor.
|
|
func Run(options ...RunOption) {
|
|
exitCode := 0
|
|
defer handleExit(&exitCode)
|
|
|
|
opts := runOptions{
|
|
Args: os.Args,
|
|
}
|
|
for _, o := range options {
|
|
o(&opts)
|
|
}
|
|
|
|
cfg, err := GetConfig()
|
|
if err != nil {
|
|
log.WithError(err).Fatal("configuration error")
|
|
}
|
|
if len(os.Args) < 2 || os.Args[1] != "run" {
|
|
fmt.Println("supervisor makes sure your workspace/IDE keeps running smoothly.\nYou don't have to call this thing, Gitpod calls it for you.")
|
|
return
|
|
}
|
|
|
|
// BEWARE: we can only call buildChildProcEnv once, because it might download env vars from a one-time-secret
|
|
// URL, which would fail if we tried another time.
|
|
childProcEnvvars := buildChildProcEnv(cfg, nil)
|
|
|
|
err = AddGitpodUserIfNotExists()
|
|
if err != nil {
|
|
log.WithError(err).Fatal("cannot ensure Gitpod user exists")
|
|
}
|
|
symlinkBinaries(cfg)
|
|
configureGit(cfg, childProcEnvvars)
|
|
|
|
tokenService := NewInMemoryTokenService()
|
|
tkns, err := cfg.GetTokens(true)
|
|
if err != nil {
|
|
log.WithError(err).Warn("cannot prepare tokens")
|
|
}
|
|
for i := range tkns {
|
|
_, err = tokenService.SetToken(context.Background(), &tkns[i].SetTokenRequest)
|
|
if err != nil {
|
|
log.WithError(err).Warn("cannot prepare tokens")
|
|
}
|
|
}
|
|
|
|
tunneledPortsService := ports.NewTunneledPortsService(cfg.DebugEnable)
|
|
_, err = tunneledPortsService.Tunnel(context.Background(),
|
|
&ports.TunnelOptions{
|
|
SkipIfExists: false,
|
|
},
|
|
&ports.PortTunnelDescription{
|
|
LocalPort: uint32(cfg.APIEndpointPort),
|
|
TargetPort: uint32(cfg.APIEndpointPort),
|
|
Visibility: api.TunnelVisiblity_host,
|
|
},
|
|
&ports.PortTunnelDescription{
|
|
LocalPort: uint32(cfg.SSHPort),
|
|
TargetPort: uint32(cfg.SSHPort),
|
|
Visibility: api.TunnelVisiblity_host,
|
|
},
|
|
)
|
|
if err != nil {
|
|
log.WithError(err).Warn("cannot tunnel internal ports")
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
internalPorts := []uint32{uint32(cfg.IDEPort), uint32(cfg.APIEndpointPort), uint32(cfg.SSHPort)}
|
|
if cfg.DesktopIDE != nil {
|
|
internalPorts = append(internalPorts, desktopIDEPort)
|
|
}
|
|
|
|
var (
|
|
ideReady = &ideReadyState{cond: sync.NewCond(&sync.Mutex{})}
|
|
desktopIdeReady *ideReadyState = nil
|
|
|
|
cstate = NewInMemoryContentState(cfg.RepoRoot)
|
|
gitpodService = createGitpodService(cfg, tokenService)
|
|
|
|
notificationService = NewNotificationService()
|
|
)
|
|
if cfg.DesktopIDE != nil {
|
|
desktopIdeReady = &ideReadyState{cond: sync.NewCond(&sync.Mutex{})}
|
|
}
|
|
if !cfg.isHeadless() {
|
|
go trackReadiness(ctx, gitpodService, cfg, cstate, ideReady, desktopIdeReady)
|
|
}
|
|
tokenService.provider[KindGit] = []tokenProvider{NewGitTokenProvider(gitpodService, cfg.WorkspaceConfig, notificationService)}
|
|
|
|
gitpodConfigService := config.NewConfigService(cfg.RepoRoot+"/.gitpod.yml", cstate.ContentReady(), log.Log)
|
|
go gitpodConfigService.Watch(ctx)
|
|
|
|
portMgmt := ports.NewManager(
|
|
createExposedPortsImpl(cfg, gitpodService),
|
|
&ports.PollingServedPortsObserver{
|
|
RefreshInterval: 2 * time.Second,
|
|
},
|
|
ports.NewConfigService(cfg.WorkspaceID, gitpodConfigService, gitpodService),
|
|
tunneledPortsService,
|
|
internalPorts...,
|
|
)
|
|
|
|
topService := NewTopService()
|
|
topService.Observe(ctx)
|
|
|
|
if opts.RunGP {
|
|
cstate.MarkContentReady(csapi.WorkspaceInitFromOther)
|
|
} else {
|
|
analytics := analytics.NewFromEnvironment()
|
|
defer analytics.Close()
|
|
go analyseConfigChanges(ctx, cfg, analytics, gitpodConfigService, gitpodService)
|
|
go analysePerfChanges(ctx, cfg, analytics, topService, gitpodService)
|
|
}
|
|
|
|
termMux := terminal.NewMux()
|
|
termMuxSrv := terminal.NewMuxTerminalService(termMux)
|
|
termMuxSrv.DefaultWorkdir = cfg.RepoRoot
|
|
if cfg.WorkspaceRoot != "" {
|
|
termMuxSrv.DefaultWorkdirProvider = func() string {
|
|
<-cstate.ContentReady()
|
|
stat, err := os.Stat(cfg.WorkspaceRoot)
|
|
if err != nil {
|
|
log.WithError(err).Error("default workdir provider: cannot resolve the workspace root")
|
|
} else if stat.IsDir() {
|
|
return cfg.WorkspaceRoot
|
|
}
|
|
return ""
|
|
}
|
|
}
|
|
termMuxSrv.Env = childProcEnvvars
|
|
termMuxSrv.DefaultCreds = &syscall.Credential{
|
|
Uid: gitpodUID,
|
|
Gid: gitpodGID,
|
|
}
|
|
|
|
taskManager := newTasksManager(cfg, termMuxSrv, cstate, nil)
|
|
|
|
apiServices := []RegisterableService{
|
|
&statusService{
|
|
ContentState: cstate,
|
|
Ports: portMgmt,
|
|
Tasks: taskManager,
|
|
ideReady: ideReady,
|
|
desktopIdeReady: desktopIdeReady,
|
|
topService: topService,
|
|
},
|
|
termMuxSrv,
|
|
RegistrableTokenService{Service: tokenService},
|
|
notificationService,
|
|
&InfoService{cfg: cfg, ContentState: cstate},
|
|
&ControlService{portsManager: portMgmt},
|
|
&portService{portsManager: portMgmt},
|
|
}
|
|
apiServices = append(apiServices, additionalServices...)
|
|
|
|
if !cfg.isHeadless() {
|
|
// 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, cfg, tokenService, childProcEnvvars)
|
|
}
|
|
|
|
var ideWG sync.WaitGroup
|
|
ideWG.Add(1)
|
|
go startAndWatchIDE(ctx, cfg, &cfg.IDE, childProcEnvvars, &ideWG, cstate, ideReady, WebIDE)
|
|
if cfg.DesktopIDE != nil {
|
|
ideWG.Add(1)
|
|
go startAndWatchIDE(ctx, cfg, cfg.DesktopIDE, childProcEnvvars, &ideWG, cstate, desktopIdeReady, DesktopIDE)
|
|
}
|
|
|
|
var (
|
|
wg sync.WaitGroup
|
|
shutdown = make(chan ShutdownReason, 1)
|
|
)
|
|
wg.Add(1)
|
|
go startContentInit(ctx, cfg, &wg, cstate)
|
|
wg.Add(1)
|
|
go startAPIEndpoint(ctx, cfg, &wg, apiServices, tunneledPortsService, apiEndpointOpts...)
|
|
wg.Add(1)
|
|
go startSSHServer(ctx, cfg, &wg, childProcEnvvars)
|
|
wg.Add(1)
|
|
tasksSuccessChan := make(chan taskSuccess, 1)
|
|
go taskManager.Run(ctx, &wg, tasksSuccessChan)
|
|
wg.Add(1)
|
|
go socketActivationForDocker(ctx, &wg, termMux)
|
|
|
|
if cfg.isHeadless() {
|
|
wg.Add(1)
|
|
go stopWhenTasksAreDone(ctx, &wg, shutdown, tasksSuccessChan)
|
|
} else if !opts.RunGP {
|
|
wg.Add(1)
|
|
go portMgmt.Run(ctx, &wg)
|
|
}
|
|
|
|
if cfg.PreventMetadataAccess {
|
|
go func() {
|
|
if !hasMetadataAccess() {
|
|
return
|
|
}
|
|
|
|
log.Error("metadata access is possible - shutting down")
|
|
shutdown <- ShutdownReasonExecutionError
|
|
}()
|
|
}
|
|
|
|
if !cfg.isHeadless() && !opts.RunGP {
|
|
go func() {
|
|
for _, repoRoot := range strings.Split(cfg.RepoRoots, ",") {
|
|
<-cstate.ContentReady()
|
|
|
|
start := time.Now()
|
|
defer func() {
|
|
log.Debugf("unshallow of local repository took %v", time.Since(start))
|
|
}()
|
|
|
|
if !isShallowRepository(repoRoot, childProcEnvvars) {
|
|
return
|
|
}
|
|
|
|
cmd := runAsGitpodUser(exec.Command("git", "fetch", "--unshallow", "--tags"))
|
|
cmd.Env = childProcEnvvars
|
|
cmd.Dir = repoRoot
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
err := cmd.Run()
|
|
if err != nil {
|
|
log.WithError(err).Error("git fetch error")
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
sigChan := make(chan os.Signal, 1)
|
|
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
|
select {
|
|
case <-sigChan:
|
|
case shutdownReason := <-shutdown:
|
|
exitCode = int(shutdownReason)
|
|
}
|
|
|
|
log.Info("received SIGTERM (or shutdown) - tearing down")
|
|
cancel()
|
|
err = termMux.Close()
|
|
if err != nil {
|
|
log.WithError(err).Error("terminal closure failed")
|
|
}
|
|
|
|
// terminate all child processes once the IDE is gone
|
|
ideWG.Wait()
|
|
terminateChildProcesses()
|
|
|
|
wg.Wait()
|
|
}
|
|
|
|
func isShallowRepository(rootDir string, env []string) bool {
|
|
cmd := runAsGitpodUser(exec.Command("git", "rev-parse", "--is-shallow-repository"))
|
|
cmd.Env = env
|
|
cmd.Dir = rootDir
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
log.WithError(err).Error("unexpected error checking if git repository is shallow")
|
|
return true
|
|
}
|
|
|
|
isShallow, err := strconv.ParseBool(strings.TrimSpace(string(out)))
|
|
if err != nil {
|
|
log.WithError(err).WithField("input", string(out)).Error("unexpected error parsing bool")
|
|
return true
|
|
}
|
|
|
|
return isShallow
|
|
}
|
|
|
|
func installDotfiles(ctx context.Context, cfg *Config, tokenService *InMemoryTokenService, childProcEnvvars []string) {
|
|
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 = childProcEnvvars
|
|
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() {
|
|
repoUrl, err := url.Parse(repo)
|
|
if err != nil {
|
|
done <- err
|
|
close(done)
|
|
return
|
|
}
|
|
authProvider := func() (username string, password string, err error) {
|
|
resp, err := tokenService.GetToken(ctx, &api.GetTokenRequest{
|
|
Host: repoUrl.Host,
|
|
Kind: KindGit,
|
|
})
|
|
if err != nil {
|
|
return
|
|
}
|
|
username = resp.User
|
|
password = resp.Token
|
|
return
|
|
}
|
|
client := &git.Client{
|
|
AuthProvider: authProvider,
|
|
AuthMethod: git.BasicAuth,
|
|
Location: dotfilePath,
|
|
RemoteURI: repo,
|
|
}
|
|
done <- client.Clone(ctx)
|
|
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")
|
|
}
|
|
|
|
filepath.Walk(dotfilePath, func(name string, info os.FileInfo, err error) error {
|
|
if err == nil {
|
|
err = os.Chown(name, gitpodUID, gitpodGID)
|
|
}
|
|
return err
|
|
})
|
|
|
|
// 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 {
|
|
log.WithError(err).Fatal("cannot find Gitpod API endpoint")
|
|
return nil
|
|
}
|
|
tknres, err := tknsrv.GetToken(context.Background(), &api.GetTokenRequest{
|
|
Kind: KindGitpod,
|
|
Host: host,
|
|
Scope: []string{
|
|
"function:getToken",
|
|
"function:openPort",
|
|
"function:getOpenPorts",
|
|
"function:guessGitTokenScopes",
|
|
},
|
|
})
|
|
if err != nil {
|
|
log.WithError(err).Error("cannot get token for Gitpod API")
|
|
return nil
|
|
}
|
|
|
|
gitpodService, err := gitpod.ConnectToServer(endpoint, gitpod.ConnectToServerOpts{
|
|
Token: tknres.Token,
|
|
Log: log.Log,
|
|
ExtraHeaders: map[string]string{
|
|
"User-Agent": "gitpod/supervisor",
|
|
"X-Workspace-Instance-Id": cfg.WorkspaceInstanceID,
|
|
"X-Client-Version": Version,
|
|
},
|
|
})
|
|
if err != nil {
|
|
log.WithError(err).Error("cannot connect to Gitpod API")
|
|
return nil
|
|
}
|
|
return gitpodService
|
|
}
|
|
|
|
func createExposedPortsImpl(cfg *Config, gitpodService *gitpod.APIoverJSONRPC) ports.ExposedPortsInterface {
|
|
if gitpodService == nil {
|
|
log.Error("auto-port exposure won't work")
|
|
return &ports.NoopExposedPorts{}
|
|
}
|
|
return ports.NewGitpodExposedPorts(cfg.WorkspaceID, cfg.WorkspaceInstanceID, gitpodService)
|
|
}
|
|
|
|
// supervisor ships some binaries we want in the PATH. We could just add some directory to the path, but
|
|
// instead of producing a strange path setup, we symlink the binary to /usr/bin.
|
|
func symlinkBinaries(cfg *Config) {
|
|
bin, err := os.Executable()
|
|
if err != nil {
|
|
log.WithError(err).Error("cannot get executable path - hence cannot symlink binaries")
|
|
return
|
|
}
|
|
base := filepath.Dir(bin)
|
|
|
|
binaries := map[string]string{
|
|
"gitpod-cli": "gp",
|
|
}
|
|
for k, v := range binaries {
|
|
var (
|
|
from = filepath.Join(base, k)
|
|
to = filepath.Join("/usr/bin", v)
|
|
)
|
|
|
|
// remove possibly existing symlink target
|
|
if err = os.Remove(to); err != nil && !os.IsNotExist(err) {
|
|
log.WithError(err).WithField("to", to).Warn("cannot remove possibly existing symlink target")
|
|
}
|
|
|
|
err = os.Symlink(from, to)
|
|
if err != nil {
|
|
log.WithError(err).WithField("from", from).WithField("to", to).Warn("cannot create symlink")
|
|
}
|
|
}
|
|
}
|
|
|
|
func configureGit(cfg *Config, childProcEnvvars []string) {
|
|
settings := [][]string{
|
|
{"push.default", "simple"},
|
|
{"alias.lg", "log --color --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset' --abbrev-commit"},
|
|
{"credential.helper", "/usr/bin/gp credential-helper"},
|
|
{"safe.directory", "*"},
|
|
}
|
|
if cfg.GitUsername != "" {
|
|
settings = append(settings, []string{"user.name", cfg.GitUsername})
|
|
}
|
|
if cfg.GitEmail != "" {
|
|
settings = append(settings, []string{"user.email", cfg.GitEmail})
|
|
}
|
|
|
|
for _, s := range settings {
|
|
cmd := exec.Command("git", append([]string{"config", "--global"}, s...)...)
|
|
cmd = runAsGitpodUser(cmd)
|
|
cmd.Env = childProcEnvvars
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
err := cmd.Run()
|
|
if err != nil {
|
|
log.WithError(err).WithField("args", s).Warn("git config error")
|
|
}
|
|
}
|
|
}
|
|
|
|
func hasMetadataAccess() bool {
|
|
// curl --connect-timeout 10 -s -H "Metadata-Flavor: Google" 'http://169.254.169.254/computeMetadata/v1/instance/'
|
|
client := &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
}
|
|
req, err := http.NewRequest("GET", "http://169.254.169.254/computeMetadata/v1/instance/", nil)
|
|
if err != nil {
|
|
log.WithError(err).Error("cannot check metadata access - this should never happen")
|
|
return true
|
|
}
|
|
req.Header.Add("Metadata-Flavor", "Google")
|
|
|
|
resp, err := client.Do(req)
|
|
// We did not see an error. That's a problem because that means that users can reach the metadata endpoint.
|
|
if err == nil {
|
|
resp.Body.Close()
|
|
return true
|
|
}
|
|
|
|
// if we see any error here we're good because then the request timed out or failed for some other reason.
|
|
return false
|
|
}
|
|
|
|
type ideStatus int
|
|
|
|
const (
|
|
statusNeverRan ideStatus = iota
|
|
statusShouldRun
|
|
statusShouldShutdown
|
|
)
|
|
|
|
var (
|
|
errSignalTerminated = errors.New("signal: terminated")
|
|
)
|
|
|
|
func startAndWatchIDE(ctx context.Context, cfg *Config, ideConfig *IDEConfig, childProcEnvvars []string, wg *sync.WaitGroup, cstate *InMemoryContentState, ideReady *ideReadyState, ide IDEKind) {
|
|
defer wg.Done()
|
|
defer log.WithField("ide", ide.String()).Debug("startAndWatchIDE shutdown")
|
|
|
|
if cfg.isHeadless() {
|
|
ideReady.Set(true, nil)
|
|
return
|
|
}
|
|
|
|
// Wait until content ready to launch IDE
|
|
<-cstate.ContentReady()
|
|
|
|
ideStatus := statusNeverRan
|
|
|
|
var (
|
|
cmd *exec.Cmd
|
|
ideStopped chan struct{}
|
|
)
|
|
supervisorLoop:
|
|
for {
|
|
if ideStatus == statusShouldShutdown {
|
|
break
|
|
}
|
|
|
|
ideStopped = make(chan struct{}, 1)
|
|
cmd = prepareIDELaunch(cfg, ideConfig, childProcEnvvars)
|
|
launchIDE(cfg, ideConfig, cmd, ideStopped, ideReady, &ideStatus, ide)
|
|
|
|
select {
|
|
case <-ideStopped:
|
|
// kill all processes in same pgid
|
|
_ = syscall.Kill(-1*cmd.Process.Pid, syscall.SIGKILL)
|
|
// IDE was stopped - let's just restart it after a small delay (in case the IDE doesn't start at all) in the next round
|
|
if ideStatus == statusShouldShutdown {
|
|
break supervisorLoop
|
|
}
|
|
time.Sleep(1 * time.Second)
|
|
case <-ctx.Done():
|
|
// we've been asked to shut down
|
|
ideStatus = statusShouldShutdown
|
|
if cmd == nil || cmd.Process == nil {
|
|
log.WithField("ide", ide.String()).Error("cmd or cmd.Process is nil, cannot send SIGTERM signal")
|
|
} else {
|
|
_ = cmd.Process.Signal(syscall.SIGTERM)
|
|
}
|
|
break supervisorLoop
|
|
}
|
|
}
|
|
|
|
log.WithField("ide", ide.String()).WithField("budget", timeBudgetIDEShutdown.String()).Info("IDE supervisor loop ended - waiting for IDE to come down")
|
|
select {
|
|
case <-ideStopped:
|
|
log.WithField("ide", ide.String()).WithField("budget", timeBudgetIDEShutdown.String()).Info("IDE has been stopped in time")
|
|
return
|
|
case <-time.After(timeBudgetIDEShutdown):
|
|
log.WithField("ide", ide.String()).WithField("timeBudgetIDEShutdown", timeBudgetIDEShutdown.String()).Error("IDE did not stop in time - sending SIGKILL")
|
|
if cmd == nil || cmd.Process == nil {
|
|
log.WithField("ide", ide.String()).Error("cmd or cmd.Process is nil, cannot send SIGKILL")
|
|
} else {
|
|
_ = cmd.Process.Signal(syscall.SIGKILL)
|
|
}
|
|
}
|
|
}
|
|
|
|
func launchIDE(cfg *Config, ideConfig *IDEConfig, cmd *exec.Cmd, ideStopped chan struct{}, ideReady *ideReadyState, s *ideStatus, ide IDEKind) {
|
|
go func() {
|
|
// prepareIDELaunch sets Pdeathsig, which on on Linux, will kill the
|
|
// child process when the thread dies, not when the process dies.
|
|
// runtime.LockOSThread ensures that as long as this function is
|
|
// executing that OS thread will still be around.
|
|
//
|
|
// see https://github.com/golang/go/issues/27505#issuecomment-713706104
|
|
runtime.LockOSThread()
|
|
defer runtime.UnlockOSThread()
|
|
|
|
err := cmd.Start()
|
|
if err != nil {
|
|
if s == func() *ideStatus { i := statusNeverRan; return &i }() {
|
|
log.WithField("ide", ide.String()).WithError(err).Fatal("IDE failed to start")
|
|
}
|
|
|
|
return
|
|
}
|
|
s = func() *ideStatus { i := statusShouldRun; return &i }()
|
|
|
|
go func() {
|
|
IDEStatus := runIDEReadinessProbe(cfg, ideConfig, ide)
|
|
ideReady.Set(true, IDEStatus)
|
|
}()
|
|
|
|
err = cmd.Wait()
|
|
if err != nil {
|
|
if errSignalTerminated.Error() != err.Error() {
|
|
log.WithField("ide", ide.String()).WithError(err).Warn("IDE was stopped")
|
|
}
|
|
|
|
ideWasReady, _ := ideReady.Get()
|
|
if !ideWasReady {
|
|
log.WithField("ide", ide.String()).WithError(err).Fatal("IDE failed to start")
|
|
return
|
|
}
|
|
}
|
|
|
|
ideReady.Set(false, nil)
|
|
close(ideStopped)
|
|
}()
|
|
}
|
|
|
|
func prepareIDELaunch(cfg *Config, ideConfig *IDEConfig, childProcEnvvars []string) *exec.Cmd {
|
|
args := ideConfig.EntrypointArgs
|
|
|
|
// Add default args for IDE (not desktop IDE) to be backwards compatible
|
|
if ideConfig.Entrypoint == "/ide/startup.sh" && len(args) == 0 {
|
|
args = append(args, "--port", "{IDEPORT}")
|
|
}
|
|
|
|
for i := range args {
|
|
args[i] = strings.ReplaceAll(args[i], "{IDEPORT}", strconv.Itoa(cfg.IDEPort))
|
|
args[i] = strings.ReplaceAll(args[i], "{DESKTOPIDEPORT}", strconv.Itoa(desktopIDEPort))
|
|
}
|
|
log.WithField("args", args).WithField("entrypoint", ideConfig.Entrypoint).Info("preparing IDE launch")
|
|
|
|
cmd := exec.Command(ideConfig.Entrypoint, args...)
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
|
// We need the child process to run in its own process group, s.t. we can suspend and resume
|
|
// IDE and its children.
|
|
Setpgid: true,
|
|
Pdeathsig: syscall.SIGKILL,
|
|
|
|
// 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.Env = childProcEnvvars
|
|
|
|
// Here we must resist the temptation to "neaten up" the IDE output for headless builds.
|
|
// This would break the JSON parsing of the headless builds.
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if lrr := cfg.IDELogRateLimit(ideConfig); lrr > 0 {
|
|
limit := int64(lrr)
|
|
cmd.Stdout = dropwriter.Writer(cmd.Stdout, dropwriter.NewBucket(limit*1024*3, limit*1024))
|
|
cmd.Stderr = dropwriter.Writer(cmd.Stderr, dropwriter.NewBucket(limit*1024*3, limit*1024))
|
|
log.WithField("limit_kb_per_sec", limit).Info("rate limiting IDE log output")
|
|
}
|
|
|
|
return cmd
|
|
}
|
|
|
|
// buildChildProcEnv computes the environment variables passed to a child process, based on the total list
|
|
// of envvars. If envvars is nil, os.Environ() is used.
|
|
//
|
|
// Beware: if config contains an OTS URL the results may differ on subsequent calls.
|
|
func buildChildProcEnv(cfg *Config, envvars []string) []string {
|
|
if envvars == nil {
|
|
envvars = os.Environ()
|
|
}
|
|
|
|
envs := make(map[string]string)
|
|
for _, e := range envvars {
|
|
segs := strings.SplitN(e, "=", 2)
|
|
if len(segs) < 2 {
|
|
log.Printf("\"%s\" has invalid format, not including in IDE environment", e)
|
|
continue
|
|
}
|
|
nme, val := segs[0], segs[1]
|
|
|
|
if isBlacklistedEnvvar(nme) {
|
|
continue
|
|
}
|
|
|
|
envs[nme] = val
|
|
}
|
|
envs["SUPERVISOR_ADDR"] = fmt.Sprintf("localhost:%d", cfg.APIEndpointPort)
|
|
|
|
if cfg.EnvvarOTS != "" {
|
|
es, err := downloadEnvvarOTS(cfg.EnvvarOTS)
|
|
if err != nil {
|
|
log.WithError(err).Warn("unable to download environment variables from OTS")
|
|
}
|
|
for k, v := range es {
|
|
if isBlacklistedEnvvar(k) {
|
|
continue
|
|
}
|
|
|
|
envs[k] = v
|
|
}
|
|
}
|
|
|
|
// We're forcing basic environment variables here, because supervisor acts like a login process at this point.
|
|
// The gitpod user might not have existed when supervisor was started, hence the HOME coming
|
|
// from the container runtime is probably wrong ("/" to be exact).
|
|
//
|
|
// Wait, how does this env var stuff work on Linux?
|
|
// First, the kernel does not care or set environment variables, it's all userland.
|
|
// It's the login process (e.g. /bin/login called by e.g. getty) that sets conventional
|
|
// environment variables such as HOME and sometimes PATH or TERM.
|
|
//
|
|
// Where can I read up on this, e.g. how others do it?
|
|
// BusyBox is a good place to start, because it's small enough to be easy to understand.
|
|
// Start here:
|
|
// - https://github.com/mirror/busybox/blob/24198f652f10dca5603df7c704263358ca21f5ce/libbb/setup_environment.c#L32
|
|
// - https://github.com/mirror/busybox/blob/24198f652f10dca5603df7c704263358ca21f5ce/libbb/login.c#L140-L170
|
|
//
|
|
envs["HOME"] = "/home/gitpod"
|
|
envs["USER"] = "gitpod"
|
|
|
|
// Particular Java optimisation: Java pre v10 did not gauge it's available memory correctly, and needed explicitly setting "-Xmx" for all Hotspot/openJDK VMs
|
|
if mem, ok := envs["GITPOD_MEMORY"]; ok {
|
|
envs["JAVA_TOOL_OPTIONS"] += fmt.Sprintf(" -Xmx%sm", mem)
|
|
}
|
|
|
|
var env, envn []string
|
|
for nme, val := range envs {
|
|
log.WithField("envvar", nme).Debug("passing environment variable to IDE")
|
|
env = append(env, fmt.Sprintf("%s=%s", nme, val))
|
|
envn = append(envn, nme)
|
|
}
|
|
|
|
log.WithField("envvar", envn).Debug("passing environment variables to IDE")
|
|
|
|
return env
|
|
}
|
|
|
|
func downloadEnvvarOTS(url string) (res map[string]string, err error) {
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
resp, err := client.Get(url)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
defer resp.Body.Close()
|
|
|
|
var dl []struct {
|
|
Name string `json:"name"`
|
|
Value string `json:"value"`
|
|
}
|
|
err = json.NewDecoder(resp.Body).Decode(&dl)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
res = make(map[string]string)
|
|
for _, e := range dl {
|
|
res[e.Name] = e.Value
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
func runIDEReadinessProbe(cfg *Config, ideConfig *IDEConfig, ide IDEKind) (desktopIDEStatus *DesktopIDEStatus) {
|
|
defer log.WithField("ide", ide.String()).Info("IDE is ready")
|
|
|
|
defaultIfEmpty := func(value, defaultValue string) string {
|
|
if len(value) == 0 {
|
|
return defaultValue
|
|
}
|
|
return value
|
|
}
|
|
|
|
defaultIfZero := func(value, defaultValue int) int {
|
|
if value == 0 {
|
|
return defaultValue
|
|
}
|
|
return value
|
|
}
|
|
|
|
defaultProbePort := cfg.IDEPort
|
|
if ide == DesktopIDE {
|
|
defaultProbePort = desktopIDEPort
|
|
}
|
|
|
|
switch ideConfig.ReadinessProbe.Type {
|
|
case ReadinessProcessProbe:
|
|
return
|
|
|
|
case ReadinessHTTPProbe:
|
|
var (
|
|
schema = defaultIfEmpty(ideConfig.ReadinessProbe.HTTPProbe.Schema, "http")
|
|
host = defaultIfEmpty(ideConfig.ReadinessProbe.HTTPProbe.Host, "localhost")
|
|
port = defaultIfZero(ideConfig.ReadinessProbe.HTTPProbe.Port, defaultProbePort)
|
|
url = fmt.Sprintf("%s://%s:%d/%s", schema, host, port, strings.TrimPrefix(ideConfig.ReadinessProbe.HTTPProbe.Path, "/"))
|
|
)
|
|
|
|
t0 := time.Now()
|
|
|
|
var body []byte
|
|
for range time.Tick(250 * time.Millisecond) {
|
|
var err error
|
|
body, err = ideStatusRequest(url)
|
|
if err != nil {
|
|
log.WithField("ide", ide.String()).WithError(err).Debug("Error running IDE readiness probe")
|
|
continue
|
|
}
|
|
|
|
break
|
|
}
|
|
|
|
log.WithField("ide", ide.String()).Infof("IDE readiness took %.3f seconds", time.Since(t0).Seconds())
|
|
|
|
if ide != DesktopIDE {
|
|
return
|
|
}
|
|
|
|
err := json.Unmarshal(body, &desktopIDEStatus)
|
|
if err != nil {
|
|
log.WithField("ide", ide.String()).WithError(err).WithField("body", body).Debugf("Error parsing JSON body from IDE status probe.")
|
|
return
|
|
}
|
|
|
|
log.WithField("ide", ide.String()).Infof("Desktop IDE status: %s", desktopIDEStatus)
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func ideStatusRequest(url string) ([]byte, error) {
|
|
client := http.Client{Timeout: 1 * time.Second}
|
|
|
|
resp, err := client.Get(url)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, xerrors.Errorf("IDE readiness probe came back with non-200 status code (%v)", resp.StatusCode)
|
|
}
|
|
|
|
body, err := ioutil.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return body, nil
|
|
}
|
|
|
|
func isBlacklistedEnvvar(name string) bool {
|
|
// exclude blacklisted
|
|
prefixBlacklist := []string{
|
|
"THEIA_SUPERVISOR_",
|
|
"GITPOD_TOKENS",
|
|
// The following vars are meant to filter out the kubernetes-injected env vars that we do not know how to turn of (yet)
|
|
"KUBERNETES_SERVICE",
|
|
"KUBERNETES_PORT",
|
|
// This is a magic env var is set to /theia/supervisor. We do not want to point users at it.
|
|
" ", // 3 spaces
|
|
}
|
|
for _, wep := range prefixBlacklist {
|
|
if strings.HasPrefix(name, wep) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func startAPIEndpoint(ctx context.Context, cfg *Config, wg *sync.WaitGroup, services []RegisterableService, tunneled *ports.TunneledPortsService, opts ...grpc.ServerOption) {
|
|
defer wg.Done()
|
|
defer log.Debug("startAPIEndpoint shutdown")
|
|
|
|
l, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.APIEndpointPort))
|
|
if err != nil {
|
|
log.WithError(err).Fatal("cannot start health endpoint")
|
|
}
|
|
|
|
if cfg.DebugEnable {
|
|
opts = append(opts,
|
|
grpc.UnaryInterceptor(grpc_logrus.UnaryServerInterceptor(log.Log)),
|
|
grpc.StreamInterceptor(grpc_logrus.StreamServerInterceptor(log.Log)),
|
|
)
|
|
}
|
|
|
|
m := cmux.New(l)
|
|
restMux := grpcruntime.NewServeMux()
|
|
grpcMux := m.MatchWithWriters(cmux.HTTP2MatchHeaderFieldSendSettings("content-type", "application/grpc"))
|
|
grpcServer := grpc.NewServer(opts...)
|
|
grpcEndpoint := fmt.Sprintf("localhost:%d", cfg.APIEndpointPort)
|
|
for _, reg := range services {
|
|
if reg, ok := reg.(RegisterableGRPCService); ok {
|
|
reg.RegisterGRPC(grpcServer)
|
|
}
|
|
if reg, ok := reg.(RegisterableRESTService); ok {
|
|
err := reg.RegisterREST(restMux, grpcEndpoint)
|
|
if err != nil {
|
|
log.WithError(err).Fatal("cannot register REST service")
|
|
}
|
|
}
|
|
}
|
|
go grpcServer.Serve(grpcMux)
|
|
|
|
httpMux := m.Match(cmux.HTTP1Fast())
|
|
routes := http.NewServeMux()
|
|
grpcWebServer := grpcweb.WrapServer(grpcServer, grpcweb.WithWebsockets(true), grpcweb.WithWebsocketOriginFunc(func(req *http.Request) bool {
|
|
return true
|
|
}))
|
|
|
|
metricStore := storage.NewDiskMetricStore("", time.Minute*5, prometheus.DefaultGatherer, nil)
|
|
metricsGatherer := prometheus.Gatherers{prometheus.GathererFunc(func() ([]*dto.MetricFamily, error) {
|
|
return metricStore.GetMetricFamilies(), nil
|
|
})}
|
|
|
|
metrics := route.New().WithPrefix("/metrics")
|
|
metrics.Put("/job/:job/*labels", handler.Push(metricStore, true, true, false, nil))
|
|
metrics.Post("/job/:job/*labels", handler.Push(metricStore, false, true, false, nil))
|
|
metrics.Del("/job/:job/*labels", handler.Delete(metricStore, false, nil))
|
|
metrics.Put("/job/:job", handler.Push(metricStore, true, true, false, nil))
|
|
metrics.Post("/job/:job", handler.Push(metricStore, false, true, false, nil))
|
|
routes.Handle("/metrics", promhttp.HandlerFor(metricsGatherer, promhttp.HandlerOpts{}))
|
|
routes.Handle("/metrics/", metrics)
|
|
|
|
ideURL, _ := url.Parse(fmt.Sprintf("http://localhost:%d", cfg.IDEPort))
|
|
routes.Handle("/", httputil.NewSingleHostReverseProxy(ideURL))
|
|
routes.Handle("/_supervisor/frontend/", http.StripPrefix("/_supervisor/frontend", http.FileServer(http.Dir(cfg.StaticConfig.FrontendLocation))))
|
|
|
|
routes.Handle("/_supervisor/v1/", http.StripPrefix("/_supervisor", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.Contains(r.Header.Get("Content-Type"), "application/grpc") ||
|
|
websocket.IsWebSocketUpgrade(r) {
|
|
http.StripPrefix("/v1", grpcWebServer).ServeHTTP(w, r)
|
|
} else {
|
|
restMux.ServeHTTP(w, r)
|
|
}
|
|
})))
|
|
upgrader := websocket.Upgrader{}
|
|
routes.Handle("/_supervisor/tunnel", http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
wsConn, err := upgrader.Upgrade(rw, r, nil)
|
|
if err != nil {
|
|
log.WithError(err).Error("tunnel: upgrade to the WebSocket protocol failed")
|
|
return
|
|
}
|
|
conn, err := gitpod.NewWebsocketConnection(ctx, wsConn, func(staleErr error) {
|
|
log.WithError(staleErr).Error("tunnel: closing stale connection")
|
|
})
|
|
if err != nil {
|
|
log.WithError(err).Error("tunnel: upgrade to the WebSocket protocol failed")
|
|
return
|
|
}
|
|
tunnelOverWebSocket(tunneled, conn)
|
|
}))
|
|
routes.Handle("/_supervisor/frontend", http.FileServer(http.Dir(cfg.FrontendLocation)))
|
|
if cfg.DebugEnable {
|
|
routes.Handle("/_supervisor/debug/tunnels", http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
rw.Header().Set("X-Content-Type-Options", "nosniff")
|
|
rw.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
tunneled.Snapshot(rw)
|
|
}))
|
|
routes.Handle("/_supervisor"+pprof.Path, http.StripPrefix("/_supervisor", pprof.Handler()))
|
|
}
|
|
go http.Serve(httpMux, routes)
|
|
|
|
go m.Serve()
|
|
|
|
<-ctx.Done()
|
|
log.Info("shutting down API endpoint")
|
|
l.Close()
|
|
}
|
|
|
|
func tunnelOverWebSocket(tunneled *ports.TunneledPortsService, conn *gitpod.WebsocketConnection) {
|
|
hostKey, err := generateHostKey()
|
|
if err != nil {
|
|
log.WithError(err).Error("tunnel: failed to generate host key")
|
|
conn.Close()
|
|
return
|
|
}
|
|
config := &ssh.ServerConfig{
|
|
NoClientAuth: true,
|
|
}
|
|
config.AddHostKey(hostKey)
|
|
sshConn, chans, reqs, err := ssh.NewServerConn(conn, config)
|
|
if err != nil {
|
|
log.WithError(err).Error("tunnel: ssh connection handshake failed")
|
|
return
|
|
}
|
|
go func() {
|
|
_ = conn.Wait()
|
|
sshConn.Close()
|
|
}()
|
|
go ssh.DiscardRequests(reqs)
|
|
go func() {
|
|
for ch := range chans {
|
|
go tunnelOverSSH(conn.Ctx, tunneled, ch)
|
|
}
|
|
}()
|
|
err = sshConn.Wait()
|
|
if err != nil {
|
|
log.WithError(err).Error("tunnel: ssh connection failed")
|
|
}
|
|
}
|
|
|
|
func generateHostKey() (ssh.Signer, error) {
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ssh.NewSignerFromKey(key)
|
|
}
|
|
|
|
func tunnelOverSSH(ctx context.Context, tunneled *ports.TunneledPortsService, newCh ssh.NewChannel) {
|
|
tunnelReq := &api.TunnelPortRequest{}
|
|
err := proto.Unmarshal(newCh.ExtraData(), tunnelReq)
|
|
if err != nil {
|
|
log.WithError(err).Error("tunnel: invalid ssh chan request")
|
|
_ = newCh.Reject(ssh.Prohibited, err.Error())
|
|
return
|
|
}
|
|
|
|
tunnel, err := tunneled.EstablishTunnel(ctx, tunnelReq.ClientId, tunnelReq.Port, tunnelReq.TargetPort)
|
|
if err != nil {
|
|
log.WithError(err).Error("tunnel: failed to establish")
|
|
_ = newCh.Reject(ssh.Prohibited, err.Error())
|
|
return
|
|
}
|
|
log.Debug("tunnel: accepted new connection")
|
|
defer log.Debug("tunnel: connection closed")
|
|
defer tunnel.Close()
|
|
|
|
sshChan, reqs, err := newCh.Accept()
|
|
if err != nil {
|
|
log.WithError(err).Error("tunnel: accepting ssh channel failed")
|
|
return
|
|
}
|
|
defer sshChan.Close()
|
|
go ssh.DiscardRequests(reqs)
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
go func() {
|
|
_, _ = io.Copy(sshChan, tunnel)
|
|
cancel()
|
|
}()
|
|
go func() {
|
|
_, _ = io.Copy(tunnel, sshChan)
|
|
cancel()
|
|
}()
|
|
<-ctx.Done()
|
|
}
|
|
|
|
func stopWhenTasksAreDone(ctx context.Context, wg *sync.WaitGroup, shutdown chan ShutdownReason, successChan <-chan taskSuccess) {
|
|
defer wg.Done()
|
|
defer close(shutdown)
|
|
|
|
success := <-successChan
|
|
if success.Failed() {
|
|
// we signal task failure via kubernetes termination log
|
|
msg := []byte("headless task failed: " + string(success))
|
|
err := ioutil.WriteFile("/dev/termination-log", msg, 0o644)
|
|
if err != nil {
|
|
log.WithError(err).Error("err while writing termination log")
|
|
}
|
|
}
|
|
shutdown <- ShutdownReasonSuccess
|
|
}
|
|
|
|
func startSSHServer(ctx context.Context, cfg *Config, wg *sync.WaitGroup, childProcEnvvars []string) {
|
|
defer wg.Done()
|
|
|
|
if cfg.isHeadless() {
|
|
return
|
|
}
|
|
|
|
go func() {
|
|
ssh, err := newSSHServer(ctx, cfg, childProcEnvvars)
|
|
if err != nil {
|
|
log.WithError(err).Error("err creating SSH server")
|
|
return
|
|
}
|
|
configureSSHDefaultDir(cfg)
|
|
configureSSHMessageOfTheDay()
|
|
err = ssh.listenAndServe()
|
|
if err != nil {
|
|
log.WithError(err).Error("err starting SSH server")
|
|
}
|
|
}()
|
|
}
|
|
|
|
func startContentInit(ctx context.Context, cfg *Config, wg *sync.WaitGroup, cst ContentState) {
|
|
defer wg.Done()
|
|
defer log.Info("supervisor: workspace content available")
|
|
|
|
var err error
|
|
defer func() {
|
|
if err == nil {
|
|
return
|
|
}
|
|
|
|
ferr := os.WriteFile("/dev/termination-log", []byte(err.Error()), 0o644)
|
|
if ferr != nil {
|
|
log.WithError(err).Error("cannot write termination log")
|
|
}
|
|
|
|
log.WithError(err).Fatal("content initialization failed")
|
|
}()
|
|
|
|
fn := "/workspace/.gitpod/content.json"
|
|
fnReady := "/workspace/.gitpod/ready"
|
|
if _, err := os.Stat("/.workspace/.gitpod/pvc"); !os.IsNotExist(err) {
|
|
fn = "/.workspace/.gitpod/content.json"
|
|
fnReady = "/.workspace/.gitpod/ready"
|
|
log.Info("Detected pvc file in /.workspace folder, assuming PVC feature enabled")
|
|
} else if _, err := os.Stat("/.workspace/.gitpod/content.json"); !os.IsNotExist(err) {
|
|
// todo(pavel): remove this after gen65 has shipped
|
|
fn = "/.workspace/.gitpod/content.json"
|
|
log.Info("[deprecated] Detected content.json in /.workspace folder, assuming PVC feature enabled")
|
|
}
|
|
|
|
var contentFile *os.File
|
|
|
|
contentFile, err = os.Open(fn)
|
|
if err != nil {
|
|
if !os.IsNotExist(err) {
|
|
log.WithError(err).Error("cannot open init descriptor")
|
|
return
|
|
}
|
|
|
|
log.Infof("%s does not exist, going to wait for %s", fn, fnReady)
|
|
|
|
// If there is no content descriptor the content must have come from somewhere (i.e. a layer or ws-daemon).
|
|
// Let's wait for that to happen.
|
|
// TODO: rewrite using fsnotify
|
|
t := time.NewTicker(100 * time.Millisecond)
|
|
for range t.C {
|
|
b, err := os.ReadFile(fnReady)
|
|
if err != nil {
|
|
if !os.IsNotExist(err) {
|
|
log.WithError(err).Error("cannot read content ready file")
|
|
}
|
|
continue
|
|
}
|
|
|
|
var m csapi.WorkspaceReadyMessage
|
|
err = json.Unmarshal(b, &m)
|
|
if err != nil {
|
|
log.WithError(err).Fatal("cannot unmarshal content ready file")
|
|
continue
|
|
}
|
|
|
|
log.WithField("source", m.Source).Info("supervisor: workspace content available")
|
|
cst.MarkContentReady(m.Source)
|
|
t.Stop()
|
|
break
|
|
}
|
|
|
|
err = nil
|
|
return
|
|
}
|
|
|
|
defer contentFile.Close()
|
|
|
|
log.Info("supervisor: running content service executor with content descriptor")
|
|
var src csapi.WorkspaceInitSource
|
|
src, err = executor.Execute(ctx, "/workspace", contentFile, true)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
err = os.Remove(fn)
|
|
if os.IsNotExist(err) {
|
|
// file is gone - we're good
|
|
err = nil
|
|
}
|
|
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
log.WithField("source", src).Info("supervisor: workspace content init finished")
|
|
cst.MarkContentReady(src)
|
|
}
|
|
|
|
func terminateChildProcesses() {
|
|
parent := os.Getpid()
|
|
|
|
children, err := processesWithParent(parent)
|
|
if err != nil {
|
|
log.WithError(err).WithField("pid", parent).Warn("cannot find children processes")
|
|
return
|
|
}
|
|
|
|
for pid, uid := range children {
|
|
privileged := false
|
|
if initializer.GitpodUID != uid {
|
|
privileged = true
|
|
}
|
|
|
|
terminateProcess(pid, privileged)
|
|
}
|
|
}
|
|
|
|
func terminateProcess(pid int, privileged bool) {
|
|
var err error
|
|
if privileged {
|
|
cmd := exec.Command("kill", "-SIGTERM", fmt.Sprintf("%v", pid))
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
err = cmd.Run()
|
|
} else {
|
|
err = syscall.Kill(pid, unix.SIGTERM)
|
|
}
|
|
|
|
if err != nil {
|
|
log.WithError(err).WithField("pid", pid).Debug("child process is already terminated")
|
|
return
|
|
}
|
|
|
|
log.WithField("pid", pid).Debug("SIGTERM'ed child process")
|
|
}
|
|
|
|
func processesWithParent(ppid int) (map[int]int, error) {
|
|
procs, err := procfs.AllProcs()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
children := make(map[int]int)
|
|
for _, proc := range procs {
|
|
stat, err := proc.Stat()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if stat.PPID != ppid {
|
|
continue
|
|
}
|
|
|
|
status, err := proc.NewStatus()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
uid, err := strconv.Atoi(status.UIDs[0])
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
children[proc.PID] = uid
|
|
}
|
|
|
|
return children, nil
|
|
}
|
|
|
|
func socketActivationForDocker(ctx context.Context, wg *sync.WaitGroup, term *terminal.Mux) {
|
|
defer wg.Done()
|
|
|
|
fn := "/var/run/docker.sock"
|
|
l, err := net.Listen("unix", fn)
|
|
if err != nil {
|
|
log.WithError(err).Error("cannot provide Docker activation socket")
|
|
return
|
|
}
|
|
|
|
go func() {
|
|
<-ctx.Done()
|
|
l.Close()
|
|
}()
|
|
|
|
_ = os.Chown(fn, gitpodUID, gitpodGID)
|
|
for ctx.Err() == nil {
|
|
err = activation.Listen(ctx, l, func(socketFD *os.File) error {
|
|
defer socketFD.Close()
|
|
cmd := exec.Command("/usr/bin/docker-up")
|
|
cmd.Env = append(os.Environ(), "LISTEN_FDS=1")
|
|
cmd.ExtraFiles = []*os.File{socketFD}
|
|
alias, err := term.Start(cmd, terminal.TermOptions{
|
|
Annotations: map[string]string{
|
|
"gitpod.supervisor": "true",
|
|
},
|
|
LogToStdout: true,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pty, ok := term.Get(alias)
|
|
if !ok {
|
|
return errors.New("cannot find pty")
|
|
}
|
|
ptyCtx, cancel := context.WithCancel(context.Background())
|
|
go func(ptyCtx context.Context) {
|
|
select {
|
|
case <-ctx.Done():
|
|
_ = pty.Command.Process.Signal(syscall.SIGTERM)
|
|
case <-ptyCtx.Done():
|
|
}
|
|
}(ptyCtx)
|
|
_, err = pty.Wait()
|
|
cancel()
|
|
return err
|
|
})
|
|
if err != nil && !errors.Is(err, context.Canceled) && err.Error() != "signal: killed" {
|
|
log.WithError(err).Error("cannot provide Docker activation socket")
|
|
}
|
|
}
|
|
}
|
|
|
|
type PerfAnalyzer struct {
|
|
label string
|
|
defs []int
|
|
buckets []int
|
|
}
|
|
|
|
func (a *PerfAnalyzer) analyze(used float64) bool {
|
|
var buckets []int
|
|
usedBucket := int(math.Ceil(used))
|
|
for _, bucket := range a.defs {
|
|
if usedBucket >= bucket {
|
|
buckets = append(buckets, bucket)
|
|
}
|
|
}
|
|
if len(buckets) <= len(a.buckets) {
|
|
return false
|
|
}
|
|
a.buckets = buckets
|
|
return true
|
|
}
|
|
|
|
func analysePerfChanges(ctx context.Context, wscfg *Config, w analytics.Writer, topService *TopService, gitpodAPI gitpod.APIInterface) {
|
|
info, err := gitpodAPI.GetWorkspace(ctx, wscfg.WorkspaceID)
|
|
if err != nil {
|
|
log.WithError(err).Error("gitpod perf analytics: failed to resolve workspace info")
|
|
return
|
|
}
|
|
|
|
analyze := func(analyzer *PerfAnalyzer, used float64) {
|
|
if !analyzer.analyze(used) {
|
|
return
|
|
}
|
|
log.WithField("buckets", analyzer.buckets).WithField("used", used).WithField("label", analyzer.label).Debug("gitpod perf analytics: changed")
|
|
w.Track(analytics.TrackMessage{
|
|
Identity: analytics.Identity{UserID: info.Workspace.OwnerID},
|
|
Event: "gitpod_" + analyzer.label + "_changed",
|
|
Properties: map[string]interface{}{
|
|
"used": used,
|
|
"buckets": analyzer.buckets,
|
|
"instanceId": wscfg.WorkspaceInstanceID,
|
|
"workspaceId": wscfg.WorkspaceID,
|
|
},
|
|
})
|
|
}
|
|
|
|
cpuAnalyzer := &PerfAnalyzer{label: "cpu", defs: []int{1, 2, 3, 4, 5, 6, 7, 8}}
|
|
memoryAnalyzer := &PerfAnalyzer{label: "memory", defs: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}}
|
|
ticker := time.NewTicker(1 * time.Second)
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
data := topService.data
|
|
if data == nil {
|
|
continue
|
|
}
|
|
analyze(cpuAnalyzer, float64(data.Cpu.Used)/1000)
|
|
analyze(memoryAnalyzer, float64(data.Memory.Used)/(1024*1024*1024))
|
|
}
|
|
}
|
|
}
|
|
|
|
func analyseConfigChanges(ctx context.Context, wscfg *Config, w analytics.Writer, cfgobs config.ConfigInterface, gitpodAPI gitpod.APIInterface) {
|
|
info, err := gitpodAPI.GetWorkspace(ctx, wscfg.WorkspaceID)
|
|
if err != nil {
|
|
log.WithError(err).Error("gitpod config analytics: failed to resolve workspace info")
|
|
return
|
|
}
|
|
|
|
var analyzer *config.ConfigAnalyzer
|
|
log.Debug("gitpod config analytics: watching...")
|
|
|
|
cfgs := cfgobs.Observe(ctx)
|
|
for {
|
|
select {
|
|
case cfg, ok := <-cfgs:
|
|
if !ok {
|
|
return
|
|
}
|
|
if analyzer != nil {
|
|
analyzer.Analyse(cfg)
|
|
} else {
|
|
analyzer = config.NewConfigAnalyzer(log.Log, 5*time.Second, func(field string) {
|
|
w.Track(analytics.TrackMessage{
|
|
Identity: analytics.Identity{UserID: info.Workspace.OwnerID},
|
|
Event: "gitpod_config_changed",
|
|
Properties: map[string]interface{}{
|
|
"key": field,
|
|
"instanceId": wscfg.WorkspaceInstanceID,
|
|
"workspaceId": wscfg.WorkspaceID,
|
|
},
|
|
})
|
|
}, cfg)
|
|
}
|
|
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func trackReadiness(ctx context.Context, gitpodService *gitpod.APIoverJSONRPC, cfg *Config, cstate *InMemoryContentState, ideReady *ideReadyState, desktopIdeReady *ideReadyState) {
|
|
type SupervisorReadiness struct {
|
|
Kind string `json:"kind,omitempty"`
|
|
WorkspaceId string `json:"workspaceId,omitempty"`
|
|
WorkspaceInstanceId string `json:"instanceId,omitempty"`
|
|
Timestamp int64 `json:"timestamp,omitempty"`
|
|
}
|
|
trackFn := func(ctx context.Context, gitpodService *gitpod.APIoverJSONRPC, cfg *Config, kind string) {
|
|
err := gitpodService.TrackEvent(ctx, &gitpod.RemoteTrackMessage{
|
|
Event: "supervisor_readiness",
|
|
Properties: SupervisorReadiness{
|
|
Kind: kind,
|
|
WorkspaceId: cfg.WorkspaceID,
|
|
WorkspaceInstanceId: cfg.WorkspaceInstanceID,
|
|
Timestamp: time.Now().UnixMilli(),
|
|
},
|
|
})
|
|
if err != nil {
|
|
log.WithError(err).Error("error tracking supervisor_readiness")
|
|
}
|
|
}
|
|
const (
|
|
readinessKindContent = "content"
|
|
readinessKindIDE = "ide"
|
|
readinessKindDesktopIDE = "ide-desktop"
|
|
)
|
|
go func() {
|
|
<-cstate.ContentReady()
|
|
trackFn(ctx, gitpodService, cfg, readinessKindContent)
|
|
}()
|
|
go func() {
|
|
<-ideReady.Wait()
|
|
trackFn(ctx, gitpodService, cfg, readinessKindIDE)
|
|
}()
|
|
if cfg.DesktopIDE != nil {
|
|
go func() {
|
|
<-desktopIdeReady.Wait()
|
|
trackFn(ctx, gitpodService, cfg, readinessKindDesktopIDE)
|
|
}()
|
|
}
|
|
}
|
|
|
|
func runAsGitpodUser(cmd *exec.Cmd) *exec.Cmd {
|
|
if cmd.SysProcAttr == nil {
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
|
}
|
|
if cmd.SysProcAttr.Credential == nil {
|
|
cmd.SysProcAttr.Credential = &syscall.Credential{}
|
|
}
|
|
cmd.SysProcAttr.Credential.Uid = gitpodUID
|
|
cmd.SysProcAttr.Credential.Gid = gitpodGID
|
|
return cmd
|
|
}
|
|
|
|
func handleExit(ec *int) {
|
|
exitCode := *ec
|
|
log.WithField("exitCode", exitCode).Debug("supervisor exit")
|
|
os.Exit(exitCode)
|
|
}
|