2023-02-08 18:08:46 +01:00

455 lines
13 KiB
Go

// 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.
package cmd
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/gitpod-io/gitpod/common-go/log"
"github.com/gitpod-io/gitpod/gitpod-cli/pkg/supervisor"
"github.com/gitpod-io/gitpod/gitpod-cli/pkg/utils"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"golang.org/x/xerrors"
"github.com/gitpod-io/gitpod/supervisor/api"
prefixed "github.com/x-cray/logrus-prefixed-formatter"
)
func stopDebugContainer(ctx context.Context, dockerPath string) error {
cmd := exec.CommandContext(ctx, dockerPath, "ps", "-q", "-f", "label=gp-rebuild")
containerIds, err := cmd.Output()
if err != nil {
return nil
}
for _, id := range strings.Split(string(containerIds), "\n") {
if len(id) == 0 {
continue
}
_ = exec.CommandContext(ctx, dockerPath, "stop", id).Run()
_ = exec.CommandContext(ctx, dockerPath, "rm", "-f", id).Run()
}
return nil
}
func runRebuild(ctx context.Context, supervisorClient *supervisor.SupervisorClient) error {
logLevel, err := logrus.ParseLevel(rebuildOpts.LogLevel)
if err != nil {
return GpError{Err: err, OutCome: utils.Outcome_UserErr, ErrorCode: utils.RebuildErrorCode_InvaligLogLevel}
}
// 1. validate configuration
wsInfo, err := supervisorClient.Info.WorkspaceInfo(ctx, &api.WorkspaceInfoRequest{})
if err != nil {
return err
}
checkoutLocation := rebuildOpts.WorkspaceFolder
if checkoutLocation == "" {
checkoutLocation = wsInfo.CheckoutLocation
}
gitpodConfig, err := utils.ParseGitpodConfig(checkoutLocation)
if err != nil {
fmt.Println("The .gitpod.yml file cannot be parsed: please check the file and try again")
fmt.Println("")
fmt.Println("For help check out the reference page:")
fmt.Println("https://www.gitpod.io/docs/references/gitpod-yml#gitpodyml")
return GpError{Err: err, OutCome: utils.Outcome_UserErr, ErrorCode: utils.RebuildErrorCode_MalformedGitpodYaml, Silence: true}
}
if gitpodConfig == nil {
fmt.Println("To test the image build, you need to configure your project with a .gitpod.yml file")
fmt.Println("")
fmt.Println("For a quick start, try running:\n$ gp init -i")
fmt.Println("")
fmt.Println("Alternatively, check out the following docs for getting started configuring your project")
fmt.Println("https://www.gitpod.io/docs/configure#configure-gitpod")
return GpError{Err: err, OutCome: utils.Outcome_UserErr, ErrorCode: utils.RebuildErrorCode_MissingGitpodYaml, Silence: true}
}
var image string
var dockerfilePath string
switch img := gitpodConfig.Image.(type) {
case nil:
image = "gitpod/workspace-full:latest"
case string:
image = img
case map[interface{}]interface{}:
dockerfilePath = filepath.Join(checkoutLocation, img["file"].(string))
if _, err := os.Stat(dockerfilePath); os.IsNotExist(err) {
fmt.Println("Your .gitpod.yml points to a Dockerfile that doesn't exist: " + dockerfilePath)
return GpError{Err: err, OutCome: utils.Outcome_UserErr, Silence: true}
}
dockerfile, err := os.ReadFile(dockerfilePath)
if err != nil {
return err
}
if string(dockerfile) == "" {
fmt.Println("Your Gitpod's Dockerfile is empty")
fmt.Println("")
fmt.Println("To learn how to customize your workspace, check out the following docs:")
fmt.Println("https://www.gitpod.io/docs/configure/workspaces/workspace-image#use-a-custom-dockerfile")
fmt.Println("")
fmt.Println("Once you configure your Dockerfile, re-run this command to validate your changes")
return GpError{Err: err, OutCome: utils.Outcome_UserErr, Silence: true}
}
default:
fmt.Println("Check your .gitpod.yml and make sure the image property is configured correctly")
return GpError{Err: err, OutCome: utils.Outcome_UserErr, ErrorCode: utils.RebuildErrorCode_MalformedGitpodYaml, Silence: true}
}
// 2. build image
fmt.Println("Building the workspace image...")
tmpDir, err := os.MkdirTemp("", "gp-rebuild-*")
if err != nil {
return err
}
defer os.RemoveAll(tmpDir)
dockerPath, err := exec.LookPath("docker")
if err == nil {
// smoke test user docker cli
err = exec.CommandContext(ctx, dockerPath, "--version").Run()
}
if err != nil {
dockerPath = "/.supervisor/gitpod-docker-cli"
}
var dockerCmd *exec.Cmd
if image != "" {
err = exec.CommandContext(ctx, dockerPath, "image", "inspect", image).Run()
if err == nil {
fmt.Printf("%s: image found\n", image)
} else {
dockerCmd = exec.CommandContext(ctx, dockerPath, "image", "pull", image)
}
} else {
image = "gp-rebuild-temp-build"
dockerCmd = exec.CommandContext(ctx, dockerPath, "build", "-t", image, "-f", dockerfilePath, checkoutLocation)
}
if dockerCmd != nil {
dockerCmd.Stdout = os.Stdout
dockerCmd.Stderr = os.Stderr
imageBuildStartTime := time.Now()
err = dockerCmd.Run()
if _, ok := err.(*exec.ExitError); ok {
fmt.Println("Image Build Failed")
return GpError{Err: err, OutCome: utils.Outcome_UserErr, ErrorCode: utils.RebuildErrorCode_ImageBuildFailed, Silence: true}
} else if err != nil {
fmt.Println("Docker error")
return GpError{Err: err, ErrorCode: utils.RebuildErrorCode_DockerErr, Silence: true}
}
utils.TrackCommandUsageEvent.ImageBuildDuration = time.Since(imageBuildStartTime).Milliseconds()
}
// 3. start debug
fmt.Println("")
runLog := log.New()
runLog.Logger.SetLevel(logrus.TraceLevel)
runLog.Logger.SetFormatter(&prefixed.TextFormatter{
TimestampFormat: "2006-01-02 15:04:05",
FullTimestamp: true,
ForceFormatting: true,
ForceColors: true,
})
runLog.Logger.SetOutput(os.Stdout)
runLog.Info("Starting the debug workspace...")
if wsInfo.DebugWorkspaceType != api.DebugWorkspaceType_noDebug {
runLog.Error("It is not possible to restart the debug workspace while you are currently inside it.")
return GpError{Err: err, OutCome: utils.Outcome_UserErr, ErrorCode: utils.RebuildErrorCode_AlreadyInDebug, Silence: true}
}
stopDebugContainer(ctx, dockerPath)
workspaceUrl, err := url.Parse(wsInfo.WorkspaceUrl)
if err != nil {
return err
}
workspaceUrl.Host = "debug-" + workspaceUrl.Host
// TODO validate that checkout and workspace locations don't leave /workspace folder
workspaceLocation := gitpodConfig.WorkspaceLocation
if workspaceLocation != "" {
if !filepath.IsAbs(workspaceLocation) {
workspaceLocation = filepath.Join("/workspace", workspaceLocation)
}
} else {
workspaceLocation = checkoutLocation
}
// TODO what about auto derived by server, i.e. JB for prebuilds? we should move them into the workspace then
tasks, err := json.Marshal(gitpodConfig.Tasks)
if err != nil {
return err
}
workspaceType := api.DebugWorkspaceType_regular
contentSource := api.ContentSource_from_other
if rebuildOpts.Prebuild {
workspaceType = api.DebugWorkspaceType_prebuild
} else if rebuildOpts.From == "prebuild" {
contentSource = api.ContentSource_from_prebuild
} else if rebuildOpts.From == "snapshot" {
contentSource = api.ContentSource_from_backup
}
debugEnvs, err := supervisorClient.Control.CreateDebugEnv(ctx, &api.CreateDebugEnvRequest{
WorkspaceType: workspaceType,
ContentSource: contentSource,
WorkspaceUrl: workspaceUrl.String(),
CheckoutLocation: checkoutLocation,
WorkspaceLocation: workspaceLocation,
Tasks: string(tasks),
LogLevel: logLevel.String(),
})
if err != nil {
return err
}
// TODO project? - should not it be covered by gp env?
// Should we allow to provide additiona envs to test such, i.e. gp rebuild -e foo=bar
userEnvs, err := exec.CommandContext(ctx, "gp", "env").CombinedOutput()
if err != nil {
return err
}
var envs string
for _, env := range debugEnvs.Envs {
envs += env + "\n"
}
envs += string(userEnvs)
envFile := filepath.Join(tmpDir, ".env")
err = os.WriteFile(envFile, []byte(envs), 0644)
if err != nil {
return err
}
// we don't pass context on purporse to handle graceful shutdown
// and output all logs properly below
runCmd := exec.Command(
dockerPath,
"run",
"--rm",
"--user", "root",
"--privileged",
"--label", "gp-rebuild=true",
"--env-file", envFile,
// ports
"-p", "24999:22999", // supervisor
"-p", "25000:23000", // Web IDE
"-p", "25001:23001", // SSH
// 23002 dekstop IDE port, but it is covered by debug workspace proxy
"-p", "25003:23003", // debug workspace proxy
// volumes
"-v", "/workspace:/workspace",
"-v", "/.supervisor:/.supervisor",
"-v", "/var/run/docker.sock:/var/run/docker.sock",
"-v", "/ide:/ide",
// "-v", "/ide-desktop:/ide-desktop", // TODO fix desktop IDEs later
// "-v", "/ide-desktop-plugins:/ide-desktop-plugins", // TODO refactor to keep all IDE deps under ide or ide-desktop
image,
"/.supervisor/supervisor", "init",
)
debugSupervisor, err := supervisor.New(ctx, &supervisor.SupervisorClientOption{
Address: "localhost:24999",
})
if err != nil {
return err
}
go func() {
debugSupervisor.WaitForIDEReady(ctx)
if ctx.Err() != nil {
return
}
ssh := "ssh 'debug-" + wsInfo.WorkspaceId + "@" + workspaceUrl.Host + ".ssh." + wsInfo.WorkspaceClusterHost + "'"
sep := strings.Repeat("=", len(ssh))
runLog.Infof(`The Debug Workspace is UP!
%s
Open in Browser at:
%s
Connect using SSH keys (https://gitpod.io/keys):
%s
%s`, sep, workspaceUrl, ssh, sep)
err := notify(ctx, supervisorClient, workspaceUrl.String(), "The Debug Workspace is UP.")
if err != nil && ctx.Err() == nil {
log.WithError(err).Error("failed to notify")
}
}()
pipeLogs := func(input io.Reader, ideLevel logrus.Level) {
reader := bufio.NewReader(input)
for {
line, _, err := reader.ReadLine()
if err != nil {
return
}
if len(line) == 0 {
continue
}
msg := make(logrus.Fields)
err = json.Unmarshal(line, &msg)
if err != nil {
if ideLevel > logLevel {
continue
}
runLog.WithFields(msg).Log(ideLevel, string(line))
} else {
wsLevel, err := logrus.ParseLevel(fmt.Sprintf("%v", msg["level"]))
if err != nil {
wsLevel = logrus.DebugLevel
}
if wsLevel == logrus.FatalLevel {
wsLevel = logrus.ErrorLevel
}
if wsLevel > logLevel {
continue
}
message := fmt.Sprintf("%v", msg["message"])
delete(msg, "message")
delete(msg, "level")
delete(msg, "time")
delete(msg, "severity")
delete(msg, "@type")
component := fmt.Sprintf("%v", msg["component"])
if wsLevel != logrus.DebugLevel && wsLevel != logrus.TraceLevel {
delete(msg, "file")
delete(msg, "func")
delete(msg, "serviceContext")
delete(msg, "component")
} else if component == "grpc" {
continue
}
runLog.WithFields(msg).Log(wsLevel, message)
}
}
}
stdout, err := runCmd.StdoutPipe()
if err != nil {
return err
}
go pipeLogs(stdout, logrus.InfoLevel)
stderr, err := runCmd.StderrPipe()
if err != nil {
return err
}
go pipeLogs(stderr, logrus.ErrorLevel)
err = runCmd.Start()
if err != nil {
fmt.Println("Failed to run a debug workspace")
return GpError{Err: err, OutCome: utils.Outcome_UserErr, ErrorCode: utils.RebuildErrorCode_DockerRunFailed, Silence: true}
}
stopped := make(chan struct{})
go func() {
_ = runCmd.Wait()
close(stopped)
}()
select {
case <-ctx.Done():
fmt.Println("")
logrus.Info("Gracefully stopping the debug workspace...")
stopDebugContainer(context.Background(), dockerPath)
case <-stopped:
}
return nil
}
func notify(ctx context.Context, supervisorClient *supervisor.SupervisorClient, workspaceUrl, message string) error {
response, err := supervisorClient.Notification.Notify(ctx, &api.NotifyRequest{
Level: api.NotifyRequest_INFO,
Message: message,
Actions: []string{"Open"},
})
if err != nil {
return err
}
if response.Action == "Open" {
gpPath, err := exec.LookPath("gp")
if err != nil {
return err
}
gpCmd := exec.CommandContext(ctx, gpPath, "preview", "--external", workspaceUrl)
gpCmd.Stdout = os.Stdout
gpCmd.Stderr = os.Stderr
return gpCmd.Run()
}
return nil
}
var rebuildOpts struct {
WorkspaceFolder string
LogLevel string
From string
Prebuild bool
}
var rebuildCmd = &cobra.Command{
Use: "rebuild",
Short: "[experimental] Re-builds the workspace (useful to debug a workspace configuration)",
Hidden: false,
RunE: func(cmd *cobra.Command, args []string) error {
supervisorClient, err := supervisor.New(cmd.Context())
if err != nil {
return xerrors.Errorf("Could not get workspace info required to build: %w", err)
}
defer supervisorClient.Close()
return runRebuild(cmd.Context(), supervisorClient)
},
}
func init() {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
var workspaceFolder string
client, err := supervisor.New(ctx)
if err == nil {
wsInfo, err := client.Info.WorkspaceInfo(ctx, &api.WorkspaceInfoRequest{})
if err == nil {
workspaceFolder = wsInfo.CheckoutLocation
}
}
rootCmd.AddCommand(rebuildCmd)
rebuildCmd.PersistentFlags().StringVarP(&rebuildOpts.WorkspaceFolder, "workspace-folder", "w", workspaceFolder, "Path to the workspace folder.")
rebuildCmd.PersistentFlags().StringVarP(&rebuildOpts.LogLevel, "log", "", "error", "Log level to use. Allowed values are 'error', 'warn', 'info', 'debug', 'trace'.")
rebuildCmd.PersistentFlags().StringVarP(&rebuildOpts.From, "from", "", "", "Starts from 'prebuild' or 'snapshot'.")
rebuildCmd.PersistentFlags().BoolVarP(&rebuildOpts.Prebuild, "prebuild", "", false, "starts as a prebuild workspace (--from is ignored).")
}