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 "", ""
}