2023-02-20 16:10:50 +01:00

440 lines
12 KiB
Go

// Copyright (c) 2022 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 server
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"time"
"github.com/gitpod-io/gitpod/common-go/baseserver"
"github.com/gitpod-io/gitpod/common-go/experiments"
"github.com/gitpod-io/gitpod/common-go/log"
"github.com/gitpod-io/gitpod/common-go/watch"
gitpodapi "github.com/gitpod-io/gitpod/gitpod-protocol"
api "github.com/gitpod-io/gitpod/ide-service-api"
"github.com/gitpod-io/gitpod/ide-service-api/config"
"github.com/heptiolabs/healthcheck"
"github.com/prometheus/client_golang/prometheus"
"github.com/sirupsen/logrus"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/health/grpc_health_v1"
)
type IDEServiceServer struct {
config *config.ServiceConfiguration
originIDEConfig []byte
parsedIDEConfigContent string
ideConfig *config.IDEConfig
ideConfigFileName string
experiemntsClient experiments.Client
api.UnimplementedIDEServiceServer
}
func Start(logger *logrus.Entry, cfg *config.ServiceConfiguration) error {
ctx := context.Background()
logger.WithField("config", cfg).Info("Starting ide-service.")
registry := prometheus.NewRegistry()
health := healthcheck.NewHandler()
srv, err := baseserver.New("ide-service",
baseserver.WithLogger(logger),
baseserver.WithConfig(cfg.Server),
baseserver.WithMetricsRegistry(registry),
baseserver.WithHealthHandler(health),
)
if err != nil {
return fmt.Errorf("failed to initialize ide-service: %w", err)
}
s := New(cfg)
go s.watchIDEConfig(ctx)
go s.scheduleUpdate(ctx)
s.register(srv.GRPC())
health.AddReadinessCheck("ide-service", func() error {
if s.ideConfig == nil {
return fmt.Errorf("ide config is not ready")
}
return nil
})
health.AddReadinessCheck("grpc-server", grpcProbe(*cfg.Server.Services.GRPC))
if err := srv.ListenAndServe(); err != nil {
return fmt.Errorf("failed to serve ide-service: %w", err)
}
return nil
}
func New(cfg *config.ServiceConfiguration) *IDEServiceServer {
fn, err := filepath.Abs(cfg.IDEConfigPath)
if err != nil {
log.WithField("path", cfg.IDEConfigPath).WithError(err).Fatal("cannot convert ide config path to abs path")
}
s := &IDEServiceServer{
config: cfg,
ideConfigFileName: fn,
experiemntsClient: experiments.NewClient(),
}
return s
}
func (s *IDEServiceServer) register(grpcServer *grpc.Server) {
api.RegisterIDEServiceServer(grpcServer, s)
}
func (s *IDEServiceServer) GetConfig(ctx context.Context, req *api.GetConfigRequest) (*api.GetConfigResponse, error) {
return &api.GetConfigResponse{
Content: s.parsedIDEConfigContent,
}, nil
}
func (s *IDEServiceServer) readIDEConfig(ctx context.Context, isInit bool) {
b, err := os.ReadFile(s.ideConfigFileName)
if err != nil {
log.WithError(err).Warn("cannot read ide config file")
return
}
if config, err := ParseConfig(ctx, b); err != nil {
if !isInit {
log.WithError(err).Fatal("cannot parse ide config")
}
log.WithError(err).Error("cannot parse ide config")
return
} else {
parsedConfig, err := json.Marshal(config)
if err != nil {
log.WithError(err).Error("cannot marshal ide config")
return
}
s.parsedIDEConfigContent = string(parsedConfig)
s.ideConfig = config
s.originIDEConfig = b
log.Info("ide config updated")
}
}
func (s *IDEServiceServer) watchIDEConfig(ctx context.Context) {
go s.readIDEConfig(ctx, true)
// `watch.File` only watch for create and remove event
// so with locally debugging, we cannot watch example ide config file change
// but in k8s, configmap change will create/remove file to replace it
if err := watch.File(ctx, s.ideConfigFileName, func() {
s.readIDEConfig(ctx, false)
}); err != nil {
log.WithError(err).Fatal("cannot start watch of ide config file")
}
}
func (s *IDEServiceServer) scheduleUpdate(ctx context.Context) {
t := time.NewTicker(time.Hour * 1)
for {
select {
case <-t.C:
log.Info("schedule update config")
s.readIDEConfig(ctx, false)
case <-ctx.Done():
t.Stop()
return
}
}
}
func grpcProbe(cfg baseserver.ServerConfiguration) func() error {
return func() error {
creds := insecure.NewCredentials()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn, err := grpc.DialContext(ctx, cfg.Address, grpc.WithTransportCredentials(creds))
if err != nil {
return err
}
defer conn.Close()
client := grpc_health_v1.NewHealthClient(conn)
check, err := client.Check(ctx, &grpc_health_v1.HealthCheckRequest{})
if err != nil {
return err
}
if check.Status == grpc_health_v1.HealthCheckResponse_SERVING {
return nil
}
return fmt.Errorf("grpc service not ready")
}
}
type IDESettings struct {
DefaultIde string `json:"defaultIde,omitempty"`
UseLatestVersion bool `json:"useLatestVersion,omitempty"`
}
type WorkspaceContext struct {
Referrer string `json:"referrer,omitempty"`
ReferrerIde string `json:"referrerIde,omitempty"`
}
func (s *IDEServiceServer) resolveReferrerIDE(ideConfig *config.IDEConfig, wsCtx *WorkspaceContext, chosenIDEName string) (ideName string, ideOption *config.IDEOption) {
if wsCtx == nil || wsCtx.Referrer == "" {
return
}
client, ok := ideConfig.IdeOptions.Clients[wsCtx.Referrer]
if !ok {
return
}
getValidIDEOption := func(ideName string) (*config.IDEOption, bool) {
optionToCheck, ok := ideConfig.IdeOptions.Options[ideName]
if !ok {
return nil, false
}
for _, ide := range client.DesktopIDEs {
if ide == ideName {
return &optionToCheck, true
}
}
return nil, false
}
ideOption, ok = getValidIDEOption(wsCtx.ReferrerIde)
if ok {
ideName = wsCtx.ReferrerIde
return
}
ideOption, ok = getValidIDEOption(chosenIDEName)
if ok {
ideName = chosenIDEName
return
}
ideOption, ok = getValidIDEOption(client.DefaultDesktopIDE)
if ok {
ideName = client.DefaultDesktopIDE
return
}
return
}
func (s *IDEServiceServer) ResolveWorkspaceConfig(ctx context.Context, req *api.ResolveWorkspaceConfigRequest) (resp *api.ResolveWorkspaceConfigResponse, err error) {
log.WithField("req", req).Debug("receive ResolveWorkspaceConfig request")
// make a copy for ref ideConfig, it's safe because we replace ref in update config
ideConfig := s.ideConfig
var defaultIde *config.IDEOption
if ide, ok := ideConfig.IdeOptions.Options[ideConfig.IdeOptions.DefaultIde]; !ok {
// I think it never happen, we have a check to make sure all DefaultIDE should be in Options
log.WithError(err).WithField("defaultIDE", ideConfig.IdeOptions.DefaultIde).Error("IDE configuration corrupt, cannot found defaultIDE")
return nil, fmt.Errorf("IDE configuration corrupt")
} else {
defaultIde = &ide
}
resp = &api.ResolveWorkspaceConfigResponse{
SupervisorImage: ideConfig.SupervisorImage,
WebImage: defaultIde.Image,
IdeImageLayers: defaultIde.ImageLayers,
}
var wsConfig *gitpodapi.GitpodConfig
if req.WorkspaceConfig != "" {
if err := json.Unmarshal([]byte(req.WorkspaceConfig), &wsConfig); err != nil {
log.WithError(err).WithField("workspaceConfig", req.WorkspaceConfig).Error("failed to parse workspace config")
}
}
if req.Type == api.WorkspaceType_REGULAR {
var ideSettings *IDESettings
var wsContext *WorkspaceContext
if req.IdeSettings != "" {
if err := json.Unmarshal([]byte(req.IdeSettings), &ideSettings); err != nil {
log.WithError(err).WithField("ideSetting", req.IdeSettings).Error("failed to parse ide settings")
}
}
if req.Context != "" {
if err := json.Unmarshal([]byte(req.Context), &wsContext); err != nil {
log.WithError(err).WithField("context", req.Context).Error("failed to parse context")
}
}
userIdeName := ""
useLatest := false
if ideSettings != nil {
userIdeName = ideSettings.DefaultIde
useLatest = ideSettings.UseLatestVersion
}
chosenIDE := defaultIde
getUserIDEImage := func(ideOption *config.IDEOption) string {
if useLatest && ideOption.LatestImage != "" {
return ideOption.LatestImage
}
return ideOption.Image
}
getUserImageLayers := func(ideOption *config.IDEOption) []string {
if useLatest {
return ideOption.LatestImageLayers
}
return ideOption.ImageLayers
}
if userIdeName != "" {
if ide, ok := ideConfig.IdeOptions.Options[userIdeName]; ok {
chosenIDE = &ide
// TODO: Currently this variable reflects the IDE selected in
// user's settings for backward compatibility but in the future
// we want to make it represent the actual IDE.
ideAlias := api.EnvironmentVariable{
Name: "GITPOD_IDE_ALIAS",
Value: userIdeName,
}
resp.Envvars = append(resp.Envvars, &ideAlias)
}
}
// we always need WebImage for when the user chooses a desktop ide
resp.WebImage = getUserIDEImage(defaultIde)
resp.IdeImageLayers = getUserImageLayers(defaultIde)
var desktopImageLayer string
var desktopUserImageLayers []string
if chosenIDE.Type == config.IDETypeDesktop {
desktopImageLayer = getUserIDEImage(chosenIDE)
desktopUserImageLayers = getUserImageLayers(chosenIDE)
} else {
resp.WebImage = getUserIDEImage(chosenIDE)
resp.IdeImageLayers = getUserImageLayers(chosenIDE)
}
ideName, referrer := s.resolveReferrerIDE(ideConfig, wsContext, userIdeName)
if ideName != "" {
resp.RefererIde = ideName
desktopImageLayer = getUserIDEImage(referrer)
desktopUserImageLayers = getUserImageLayers(referrer)
}
if desktopImageLayer != "" {
resp.IdeImageLayers = append(resp.IdeImageLayers, desktopImageLayer)
resp.IdeImageLayers = append(resp.IdeImageLayers, desktopUserImageLayers...)
}
}
jbGW, ok := ideConfig.IdeOptions.Clients["jetbrains-gateway"]
if req.Type == api.WorkspaceType_PREBUILD && ok {
warmUpTask := ""
imageLayers := make(map[string]struct{})
for _, alias := range jbGW.DesktopIDEs {
prebuilds := getPrebuilds(wsConfig, alias)
if prebuilds != nil {
if prebuilds.Version != "latest" {
if ide, ok := ideConfig.IdeOptions.Options[alias]; ok {
for _, ideImageLayer := range ide.ImageLayers {
if _, ok := imageLayers[ideImageLayer]; !ok {
imageLayers[ideImageLayer] = struct{}{}
resp.IdeImageLayers = append(resp.IdeImageLayers, ideImageLayer)
}
}
resp.IdeImageLayers = append(resp.IdeImageLayers, ide.Image)
template := `
echo 'warming up stable release of ${key}...'
JETBRAINS_BACKEND_QUALIFIER=stable /ide-desktop/jb-launcher warmup ${key}
`
template = strings.ReplaceAll(template, "${key}", alias)
warmUpTask += template
}
}
if prebuilds.Version != "stable" {
if ide, ok := ideConfig.IdeOptions.Options[alias]; ok {
for _, latestIdeImageLayer := range ide.LatestImageLayers {
if _, ok := imageLayers[latestIdeImageLayer]; !ok {
imageLayers[latestIdeImageLayer] = struct{}{}
resp.IdeImageLayers = append(resp.IdeImageLayers, latestIdeImageLayer)
}
}
resp.IdeImageLayers = append(resp.IdeImageLayers, ide.LatestImage)
template := `
echo 'warming up latest release of ${key}...'
JETBRAINS_BACKEND_QUALIFIER=latest /ide-desktop/jb-launcher warmup ${key}
`
template = strings.ReplaceAll(template, "${key}", alias)
warmUpTask += template
}
}
}
}
if warmUpTask != "" {
warmUpEncoded := new(bytes.Buffer)
enc := json.NewEncoder(warmUpEncoded)
enc.SetEscapeHTML(false)
err := enc.Encode(&[]gitpodapi.TaskConfig{{
Init: strings.TrimSpace(warmUpTask),
Name: "GITPOD_JB_WARMUP_TASK",
}})
if err != nil {
log.WithError(err).Error("cannot marshal warm up task")
}
resp.Tasks = warmUpEncoded.String()
}
}
return
}
func getPrebuilds(config *gitpodapi.GitpodConfig, alias string) *gitpodapi.Prebuilds {
if config == nil || config.Jetbrains == nil {
return nil
}
productConfig := getProductConfig(config, alias)
if productConfig == nil {
return nil
}
return productConfig.Prebuilds
}
func getProductConfig(config *gitpodapi.GitpodConfig, alias string) *gitpodapi.JetbrainsProduct {
defer func() {
if err := recover(); err != nil {
log.WithField("error", err).WithField("alias", alias).Error("failed to extract JB product config")
}
}()
v := reflect.ValueOf(*config.Jetbrains).FieldByNameFunc(func(s string) bool {
return strings.ToLower(s) == alias
}).Interface()
productConfig, ok := v.(*gitpodapi.JetbrainsProduct)
if !ok {
return nil
}
return productConfig
}