internal/core/container.go (459 lines of code) (raw):

/* * Copyright 2021-2024 JetBrains s.r.o. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package core import ( "context" "encoding/base64" "encoding/json" "fmt" "io" "os" "path/filepath" "regexp" "runtime" "strconv" "strings" "github.com/JetBrains/qodana-cli/internal/cloud" "github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/network" "github.com/JetBrains/qodana-cli/internal/core/corescan" "github.com/JetBrains/qodana-cli/internal/platform/msg" "github.com/JetBrains/qodana-cli/internal/platform/product" "github.com/JetBrains/qodana-cli/internal/platform/qdcontainer" "github.com/JetBrains/qodana-cli/internal/platform/qdenv" "github.com/JetBrains/qodana-cli/internal/platform/strutil" "github.com/JetBrains/qodana-cli/internal/platform/utils" "github.com/JetBrains/qodana-cli/internal/platform/version" "github.com/docker/docker/api/types/backend" "github.com/docker/docker/api/types/registry" "github.com/docker/go-connections/nat" "github.com/pterm/pterm" cliconfig "github.com/docker/cli/cli/config" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/mount" "github.com/docker/docker/client" log "github.com/sirupsen/logrus" ) const ( // officialImagePrefix is the prefix of official Qodana images. officialImagePrefix = "jetbrains/qodana" dockerSpecialCharsLength = 8 containerJvmDebugPort = "5005" ) var ( containerLogsOptions = container.LogsOptions{ ShowStdout: true, ShowStderr: true, Follow: true, Timestamps: false, } containerName = "qodana-cli" ) // runQodanaContainer runs the analysis in a Docker container from a Qodana image. func runQodanaContainer(ctx context.Context, c corescan.Context) int { dockerAnalyzer, ok := c.Analyser().(*product.DockerAnalyzer) if !ok { log.Fatalf("Context is not a DockerAnalyzer") } docker, err := qdcontainer.NewContainerClient(ctx) if err != nil { log.Fatal("Couldn't retrieve Docker daemon information", err) } info, err := docker.Info(ctx) if err != nil { log.Fatal("Couldn't retrieve Docker daemon information", err) } if info.OSType != "linux" { msg.ErrorMessage("Container engine is not running a Linux platform, other platforms are not supported by Qodana") return 1 } fixDarwinCaches(c.CacheDir()) scanStages := getScanStages() dockerImage := dockerAnalyzer.Image CheckImage(dockerImage) if !c.SkipPull() { PullImage(docker, dockerImage) } progress, _ := msg.StartQodanaSpinner(scanStages[0]) dockerConfig := getDockerOptions(c, dockerImage) log.Debugf("docker command to run: %s", generateDebugDockerRunCommand(dockerConfig)) msg.UpdateText(progress, scanStages[1]) runContainer(ctx, docker, dockerConfig) go followLinter(docker, dockerConfig.Name, progress, scanStages) exitCode := getContainerExitCode(ctx, docker, dockerConfig.Name) fixDarwinCaches(c.CacheDir()) if progress != nil { _ = progress.Stop() } return int(exitCode) } // isUnofficialLinter checks if the linter is unofficial. func isUnofficialLinter(linter string) bool { return !strings.HasPrefix(linter, officialImagePrefix) } // hasExactVersionTag checks if the linter has an exact version tag. func hasExactVersionTag(linter string) bool { return strings.Contains(linter, ":") && !strings.Contains(linter, ":latest") } // isCompatibleLinter checks if the linter is compatible with the current CLI. func isCompatibleLinter(linter string) bool { return strings.Contains(linter, product.ReleaseVersion) } // CheckImage checks the linter image and prints warnings if necessary. func CheckImage(linter string) { if strings.Contains(version.Version, "nightly") || strings.Contains(version.Version, "dev") { return } if isUnofficialLinter(linter) { msg.WarningMessageCI("You are using an unofficial Qodana linter: %s\n", linter) } if !hasExactVersionTag(linter) { msg.WarningMessageCI( "You are running a Qodana linter without an exact version tag: %s \n Consider pinning the version in your configuration to ensure version compatibility: %s\n", linter, strings.Join([]string{strutil.SafeSplit(linter, ":", 0), product.ReleaseVersion}, ":"), ) } else if !isCompatibleLinter(linter) { msg.WarningMessageCI( "You are using a non-compatible Qodana linter %s with the current CLI (%s) \n Consider updating CLI or using a compatible linter %s \n", linter, version.Version, strings.Join([]string{strutil.SafeSplit(linter, ":", 0), product.ReleaseVersion}, ":"), ) } } func fixDarwinCaches(cacheDir string) { if //goland:noinspection GoBoolExpressions runtime.GOOS == "darwin" { err := removePortSocket(cacheDir) if err != nil { log.Warnf("Could not remove .port from %s: %s", cacheDir, err) } } } // removePortSocket removes .port from the system dir to resolve QD-7383. func removePortSocket(systemDir string) error { ideaDir := filepath.Join(systemDir, "idea") files, err := os.ReadDir(ideaDir) if err != nil { return nil } for _, file := range files { if file.IsDir() { dotPort := filepath.Join(ideaDir, file.Name(), ".port") if _, err = os.Stat(dotPort); err == nil { err = os.Remove(dotPort) if err != nil { return err } } } } return nil } // encodeAuthToBase64 serializes the auth configuration as JSON base64 payload func encodeAuthToBase64(authConfig registry.AuthConfig) (string, error) { buf, err := json.Marshal(authConfig) if err != nil { return "", err } return base64.URLEncoding.EncodeToString(buf), nil } // PullImage pulls docker image and prints the process. func PullImage(client client.APIClient, image string) { msg.PrintProcess( func(_ *pterm.SpinnerPrinter) { pullImage(context.Background(), client, image) }, fmt.Sprintf("Pulling the image %s", msg.PrimaryBold(image)), "pulling the latest version of linter", ) } func isDockerUnauthorizedError(errMsg string) bool { errMsg = strutil.Lower(errMsg) return strings.Contains(errMsg, "unauthorized") || strings.Contains(errMsg, "denied") || strings.Contains( errMsg, "forbidden", ) } // pullImage pulls docker image. func pullImage(ctx context.Context, client client.APIClient, ref string) { reader, err := client.ImagePull(ctx, ref, image.PullOptions{}) if err != nil && isDockerUnauthorizedError(err.Error()) { cfg, err := cliconfig.Load("") if err != nil { log.Fatal(err) } registryHostname := strutil.SafeSplit(ref, "/", 0) a, err := cfg.GetAuthConfig(registryHostname) if err != nil { log.Fatal("can't load the auth config", err) } encodedAuth, err := encodeAuthToBase64(registry.AuthConfig(a)) if err != nil { log.Fatal("can't encode auth to base64", err) } reader, err = client.ImagePull(ctx, ref, image.PullOptions{RegistryAuth: encodedAuth}) if err != nil { log.Fatal("can't pull image from the private registry", err) } } else if err != nil { log.Fatal("can't pull image ", err) } defer func(pull io.ReadCloser) { err := pull.Close() if err != nil { log.Fatal("can't pull image ", err) } }(reader) if _, err = io.Copy(io.Discard, reader); err != nil { log.Fatal("couldn't read the image pull logs ", err) } } // ContainerCleanup cleans up Qodana containers. func ContainerCleanup() { if containerName != "qodana-cli" { // if containerName is not set, it means that the container was not created! ctx := context.Background() docker, err := qdcontainer.NewContainerClient(ctx) if err != nil { log.Fatal("failed to initialize Docker API:", err) } containers, err := docker.ContainerList(ctx, container.ListOptions{}) if err != nil { log.Fatal("couldn't get the running containers ", err) } for _, c := range containers { if c.Names[0] == fmt.Sprintf("/%s", containerName) { err = docker.ContainerStop(context.Background(), c.Names[0], container.StopOptions{}) if err != nil { log.Fatal("couldn't stop the container ", err) } } } } } // getDockerOptions returns qodana docker container options. func getDockerOptions(c corescan.Context, image string) *backend.ContainerCreateConfig { cmdOpts := GetIdeArgs(c) updateScanContextEnv := func(key string, value string) { c = c.WithEnvExtractedFromOsEnv(key, value) } qdenv.ExtractQodanaEnvironment(updateScanContextEnv) dockerEnv := c.Env() qodanaCloudUploadToken := c.QodanaUploadToken() if qodanaCloudUploadToken != "" { dockerEnv = append(dockerEnv, fmt.Sprintf("%s=%s", qdenv.QodanaToken, qodanaCloudUploadToken)) } qodanaLicenseOnlyToken := os.Getenv(qdenv.QodanaLicenseOnlyToken) if qodanaLicenseOnlyToken != "" && qodanaCloudUploadToken == "" { dockerEnv = append(dockerEnv, fmt.Sprintf("%s=%s", qdenv.QodanaLicenseOnlyToken, qodanaLicenseOnlyToken)) } cachePath, err := filepath.Abs(c.CacheDir()) if err != nil { log.Fatal("couldn't get abs path for cache", err) } repositoryRootPath, err := filepath.Abs(c.RepositoryRoot()) if err != nil { log.Fatal("couldn't get abs path for project", err) } resultsPath, err := filepath.Abs(c.ResultsDir()) if err != nil { log.Fatal("couldn't get abs path for results", err) } reportPath, err := filepath.Abs(c.ReportDir()) if err != nil { log.Fatal("couldn't get abs path for report", err) } containerName = os.Getenv(qdenv.QodanaCliContainerName) if containerName == "" { containerName = fmt.Sprintf("qodana-cli-%s", c.Id()) } volumes := []mount.Mount{ { Type: mount.TypeBind, Source: cachePath, Target: qdcontainer.DataCacheDir, }, { Type: mount.TypeBind, Source: repositoryRootPath, Target: qdcontainer.MountDir, }, { Type: mount.TypeBind, Source: resultsPath, Target: qdcontainer.DataResultsDir, }, { Type: mount.TypeBind, Source: reportPath, Target: qdcontainer.DataResultsReportDir, }, } if c.GlobalConfigurationsDir() != "" { globalConfigDirAbsPath, err := filepath.Abs(c.GlobalConfigurationsDir()) if err != nil { log.Fatalf( "Failed to get absolute path for global configurations file %s: %s", c.GlobalConfigurationsDir(), err, ) } volumes = append( volumes, mount.Mount{ Type: mount.TypeBind, Source: globalConfigDirAbsPath, Target: qdcontainer.DataGlobalConfigDir, }, ) } for _, volume := range c.Volumes() { source, target := extractDockerVolumes(volume) if source != "" && target != "" { volumes = append( volumes, mount.Mount{ Type: mount.TypeBind, Source: source, Target: target, }, ) } else { log.Fatal("couldn't parse volume ", volume) } } log.Debugf("image: %s", image) log.Debugf("container name: %s", containerName) log.Debugf("user: %s", c.User()) log.Debugf("volumes: %v", volumes) log.Debugf("cmd: %v", cmdOpts) portBindings := make(nat.PortMap) exposedPorts := make(nat.PortSet) if c.JvmDebugPort() > 0 { log.Infof("Enabling JVM debug on port %d", c.JvmDebugPort()) portBindings = nat.PortMap{ containerJvmDebugPort: []nat.PortBinding{ { HostIP: "0.0.0.0", HostPort: strconv.Itoa(c.JvmDebugPort()), }, }, } exposedPorts = nat.PortSet{ containerJvmDebugPort: struct{}{}, } } var capAdd []string var securityOpt []string var networkMode container.NetworkMode if strings.Contains(image, "dotnet") { capAdd = []string{"SYS_PTRACE"} securityOpt = []string{"seccomp=unconfined"} } // See QD-11584 for reasoning //goland:noinspection HttpUrlsUsage isLocalHttpCloud := strings.HasPrefix(cloud.GetCloudRootEndpoint().Url, "http://") if isLocalHttpCloud { networkMode = network.NetworkHost } var hostConfig = &container.HostConfig{ AutoRemove: os.Getenv(qdenv.QodanaCliContainerKeep) == "", Mounts: volumes, CapAdd: capAdd, SecurityOpt: securityOpt, PortBindings: portBindings, NetworkMode: networkMode, } return &backend.ContainerCreateConfig{ Name: containerName, Config: &container.Config{ Image: image, Cmd: cmdOpts, Tty: msg.IsInteractive(), AttachStdout: true, AttachStderr: true, Env: dockerEnv, User: selectUser(image, c.User()), ExposedPorts: exposedPorts, }, HostConfig: hostConfig, } } var rePrivilegedImage = regexp.MustCompile(`^(jetbrains|registry.jetbrains.team)/.+-privileged.*$`) func selectUser(image string, userFromContext string) string { if userFromContext == "auto" { if !rePrivilegedImage.MatchString(image) { return utils.GetDefaultUser() } return "" // Do not specify -u on the command line. } return userFromContext // Do not modify explicit user input } func generateDebugDockerRunCommand(cfg *backend.ContainerCreateConfig) string { var cmdBuilder strings.Builder cmdBuilder.WriteString("docker run ") if cfg.HostConfig != nil && cfg.HostConfig.AutoRemove { cmdBuilder.WriteString("--rm ") } if cfg.Config.AttachStdout { cmdBuilder.WriteString("-a stdout ") } if cfg.Config.AttachStderr { cmdBuilder.WriteString("-a stderr ") } if cfg.Config.Tty { cmdBuilder.WriteString("-it ") } if cfg.Config.User != "" { cmdBuilder.WriteString(fmt.Sprintf("-u %s ", cfg.Config.User)) } for _, env := range cfg.Config.Env { if !strings.Contains(env, qdenv.QodanaToken) || strings.Contains( env, qdenv.QodanaLicense, ) || strings.Contains(env, qdenv.QodanaLicenseOnlyToken) { cmdBuilder.WriteString(fmt.Sprintf("-e %s ", env)) } } if cfg.HostConfig != nil { for _, m := range cfg.HostConfig.Mounts { cmdBuilder.WriteString(fmt.Sprintf("-v %s:%s ", m.Source, m.Target)) } for _, capAdd := range cfg.HostConfig.CapAdd { cmdBuilder.WriteString(fmt.Sprintf("--cap-add %s ", capAdd)) } for _, secOpt := range cfg.HostConfig.SecurityOpt { cmdBuilder.WriteString(fmt.Sprintf("--security-opt %s ", secOpt)) } } cmdBuilder.WriteString(cfg.Config.Image + " ") for _, arg := range cfg.Config.Cmd { cmdBuilder.WriteString(fmt.Sprintf("%s ", arg)) } return cmdBuilder.String() } // getContainerExitCode returns the exit code of the docker container. func getContainerExitCode(ctx context.Context, client client.APIClient, id string) int64 { statusCh, errCh := client.ContainerWait(ctx, id, container.WaitConditionNextExit) select { case err := <-errCh: if err != nil { log.Fatal("container hasn't finished ", err) } case status := <-statusCh: return status.StatusCode } return 0 } // runContainer runs the container. func runContainer(ctx context.Context, client client.APIClient, opts *backend.ContainerCreateConfig) { createResp, err := client.ContainerCreate( ctx, opts.Config, opts.HostConfig, nil, nil, opts.Name, ) if err != nil { log.Fatal("couldn't create the container ", err) } if err = client.ContainerStart(ctx, createResp.ID, container.StartOptions{}); err != nil { log.Fatal("couldn't bootstrap the container ", err) } } // extractDockerVolumes extracts the source and target of the volume to mount. func extractDockerVolumes(volume string) (string, string) { if //goland:noinspection GoBoolExpressions runtime.GOOS == "windows" { parts := strings.Split(volume, ":") if len(parts) >= 3 { return fmt.Sprintf("%s:%s", parts[0], parts[1]), parts[2] } } else { source := strutil.SafeSplit(volume, ":", 0) target := strutil.SafeSplit(volume, ":", 1) if source != "" && target != "" { return source, target } } return "", "" }