internal/core/startup/installers.go (337 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 startup
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"math/rand"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"github.com/JetBrains/qodana-cli/internal/platform"
"github.com/JetBrains/qodana-cli/internal/platform/msg"
"github.com/JetBrains/qodana-cli/internal/platform/product"
"github.com/JetBrains/qodana-cli/internal/platform/strutil"
"github.com/JetBrains/qodana-cli/internal/platform/utils"
cp "github.com/otiai10/copy"
"github.com/pterm/pterm"
log "github.com/sirupsen/logrus"
)
func downloadAndInstallIDE(
analyser product.Analyzer,
baseDir string,
spinner *pterm.SpinnerPrinter,
) string {
var ideUrl string
checkSumUrl := ""
releaseDownloadInfo := getIde(analyser)
if releaseDownloadInfo == nil {
log.Fatalf("Error while obtaining the URL for the supplied IDE, exiting")
} else {
ideUrl = releaseDownloadInfo.Link
checkSumUrl = releaseDownloadInfo.ChecksumLink
}
fileName := filepath.Base(ideUrl)
fileExt := filepath.Ext(fileName)
installDir := filepath.Join(baseDir, strings.TrimSuffix(fileName, fileExt))
if _, err := os.Stat(installDir); err == nil {
switch runtime.GOOS {
case "windows":
if dirs, err := filepath.Glob(filepath.Join(installDir, "*")); err == nil && len(dirs) == 1 {
installDir = dirs[0]
}
case "darwin":
if dirs, err := filepath.Glob(filepath.Join(installDir, "*.app")); err == nil && len(dirs) == 1 {
installDir = filepath.Join(dirs[0], "Contents")
}
}
log.Debugf("IDE already installed to %s, skipping download", installDir)
return installDir
}
downloadedIdePath := filepath.Join(baseDir, fileName)
err := utils.DownloadFile(downloadedIdePath, ideUrl, getInternalAuth(), spinner)
if err != nil {
log.Fatalf("Error while downloading linter: %v", err)
}
defer func(filePath string) {
err = os.Remove(filePath)
if err != nil {
log.Warning("Error while removing temporary file: " + err.Error())
}
}(downloadedIdePath)
if checkSumUrl != "" {
checksumFilePath := filepath.Join(baseDir, strings.TrimSuffix(fileName, fileExt)+".sha256")
verifySha256(checksumFilePath, checkSumUrl, downloadedIdePath)
}
switch fileExt {
case ".sit":
err = installIdeFromZip(downloadedIdePath, installDir)
case ".zip":
err = installIdeFromZip(downloadedIdePath, installDir)
case ".exe":
err = installIdeWindowsExe(downloadedIdePath, installDir)
case ".gz":
err = installIdeFromTar(downloadedIdePath, installDir)
case ".dmg":
err = installIdeMacOS(downloadedIdePath, installDir)
default:
log.Fatalf("Unsupported file extension: %s", fileExt)
}
if err != nil {
log.Fatalf("Error while unpacking: %v", err)
}
switch runtime.GOOS {
case "windows":
if dirs, err := filepath.Glob(filepath.Join(installDir, "*")); err == nil && len(dirs) == 1 {
installDir = dirs[0]
}
case "darwin":
if dirs, err := filepath.Glob(filepath.Join(installDir, "*.app")); err == nil && len(dirs) == 1 {
installDir = filepath.Join(dirs[0], "Contents")
}
}
return installDir
}
//goland:noinspection GoBoolExpressions
func getIde(analyzer product.Analyzer) *ReleaseDownloadInfo {
dist := product.ReleaseVer
if analyzer.IsEAP() {
dist = product.EapVer
}
name := analyzer.GetLinter().Name
if !analyzer.GetLinter().SupportNative {
msg.ErrorMessage("Native mode for linter %s is not supported", name)
return nil
}
linterProperties := product.FindLinterProperties(analyzer.GetLinter())
if linterProperties == nil {
msg.ErrorMessage("Native mode for linter %s is not supported", name)
return nil
}
feedProductCode := linterProperties.FeedProductCode
prod, err := GetProductByCode(feedProductCode)
if err != nil {
msg.ErrorMessage("Error while obtaining the product info: %s", err)
return nil
}
if prod == nil {
msg.ErrorMessage("Product info is not found for code: %s", feedProductCode)
return nil
}
release := SelectLatestCompatibleRelease(prod, dist)
if release == nil {
msg.ErrorMessage("Could not find a %s version for '%s'", dist, linterProperties.PresentableName)
return nil
}
var downloadType string
switch runtime.GOOS {
case "darwin":
downloadType = "macSit"
_, ok := (*release.Downloads)[downloadType]
if !ok {
downloadType = "mac"
}
if runtime.GOARCH == "arm64" {
downloadType = "macSitM1"
_, ok := (*release.Downloads)[downloadType]
if !ok {
downloadType = "macM1"
}
}
case "windows":
downloadType = "windowsZip"
_, ok := (*release.Downloads)[downloadType]
if !ok {
downloadType = "windows"
}
if runtime.GOARCH == "arm64" {
downloadType = "windowsZipARM64"
_, ok := (*release.Downloads)[downloadType]
if !ok {
downloadType = "windowsARM64"
}
}
default:
downloadType = "linux"
if runtime.GOARCH == "arm64" {
downloadType = "linuxARM64"
}
}
res, ok := (*release.Downloads)[downloadType]
if !ok {
msg.ErrorMessage(
"%s %s (%s) is not available or not supported for the current platform",
feedProductCode,
*release.Version,
dist,
)
return nil
}
log.Debug(fmt.Sprintf("%s %s %s %s URL: %s", feedProductCode, dist, *release.Version, downloadType, res.Link))
return &res
}
// installIdeWindowsExe is used as a fallback, since it needs installation privileges and alters the registry
func installIdeWindowsExe(archivePath string, targetDir string) error {
stdout, stderr, _, err := utils.RunCmdRedirectOutput(
"",
strutil.QuoteForWindows(archivePath),
"/S",
fmt.Sprintf("/D=%s", strutil.QuoteForWindows(targetDir)),
)
if err != nil {
return fmt.Errorf("%s: %s. Stdout: %s. Stderr: %s", archivePath, err, stdout, stderr)
}
return nil
}
func extractArchive(archivePath string, targetDir string, stripComponents int) error {
targetBasename := filepath.Base(targetDir)
if targetBasename == "." || targetBasename == "/" {
// filepath.Base returns either "." or "/" when the path has no basename.
return fmt.Errorf("invalid target directory")
}
tempDir, err := os.MkdirTemp("", fmt.Sprintf("%s-*.partial", targetBasename))
if err != nil {
return fmt.Errorf("failed to create temporary directory for extraction: %w", err)
}
defer func() {
if err := os.RemoveAll(tempDir); err != nil {
log.Warningf("failed to remove temporary directory: %s", err.Error())
}
}()
tarExe, err := exec.LookPath("tar")
if err != nil {
return fmt.Errorf("could not find 'tar': %w", err)
}
tarArgv := []string{tarExe, "-xf", strutil.GetQuotedPath(archivePath), "-C", strutil.GetQuotedPath(tempDir)}
if stripComponents > 0 {
tarArgv = append(tarArgv, "--strip-components", strconv.Itoa(stripComponents))
}
stdout, stderr, _, err := utils.RunCmdRedirectOutput("", tarArgv...)
if err != nil {
return fmt.Errorf("failed to extract: %w. Stdout: %s. Stderr: %s", err, stdout, stderr)
}
if err := os.RemoveAll(targetDir); err != nil {
return fmt.Errorf("failed to remove existing target directory: %w", err)
}
err = os.Rename(tempDir, targetDir)
if err != nil { // Trying to fallback if rename failed https://youtrack.jetbrains.com/issue/QD-12252
log.Warningf("Error moving linter files from temp to target: %s. Trying to copy instead.", err)
err = cp.Copy(tempDir, targetDir)
if err != nil {
return fmt.Errorf("error copying linter files from temp to target: %w", err)
}
}
return nil
}
func installIdeFromZip(archivePath string, targetDir string) error {
err := extractArchive(archivePath, targetDir, 0)
if err != nil {
return err
}
if runtime.GOOS != "windows" {
err = platform.ChangePermissionsRecursivelyUnix(targetDir, 0755)
if err != nil {
return fmt.Errorf("error moving linter files from temp to target: %w", err)
}
}
return nil
}
func installIdeFromTar(archivePath string, targetDir string) error {
return extractArchive(archivePath, targetDir, 1)
}
func installIdeMacOS(archivePath string, targetDir string) error {
mountDir := fmt.Sprintf("/Volumes/MyTempMount%d", rand.Intn(10000))
_, err := exec.Command("hdiutil", "attach", "-nobrowse", "-mountpoint", mountDir, archivePath).Output()
if err != nil {
return fmt.Errorf("hdiutil attach: %s", err)
}
defer func(command *exec.Cmd) {
err := command.Run()
if err != nil {
log.Fatal(fmt.Errorf("hdiutil eject: %s", err))
}
}(exec.Command("hdiutil", "eject", mountDir, "-force"))
matches, err := filepath.Glob(mountDir + "/*.app")
if err != nil {
return fmt.Errorf("filepath.Glob: %s", err)
}
if len(matches) == 0 {
return fmt.Errorf("no .app found in dmg")
}
err = cp.Copy(matches[0], targetDir)
if err != nil {
return fmt.Errorf("cp.Copy: %s", err)
}
return nil
}
func verifySha256(checksumFile string, checkSumUrl string, filePath string) {
err := utils.DownloadFile(checksumFile, checkSumUrl, getInternalAuth(), nil)
if err != nil {
log.Fatalf("Error while downloading checksum for IDE: %v", err)
}
defer func(filePath string) {
err = os.Remove(filePath)
if err != nil {
log.Warning("Error while removing temporary file: " + err.Error())
}
}(checksumFile)
checksum, err := os.ReadFile(checksumFile)
if err != nil {
log.Fatalf("Error occurred during reading checksum file: %v", err)
}
file, err := os.Open(filePath)
if err != nil {
log.Fatalf("Error while opening IDE archive: %v", err)
}
defer func(file *os.File) {
err := file.Close()
if err != nil {
log.Fatalf("Error while closing IDE archive: %v", err)
}
}(file)
h := sha256.New()
if _, err := io.Copy(h, file); err != nil {
log.Fatalf("Error while computing checksum of IDE archive: %v", err)
}
actual := hex.EncodeToString(h.Sum(nil))
expected := strings.SplitN(string(checksum), " ", 2)[0]
if actual != expected {
err = os.Remove(filePath)
if err != nil {
log.Warning("Error while removing temporary file: " + err.Error())
}
log.Fatalf("Checksums doesn't match. Expected: %s, Actual: %s", expected, actual)
}
log.Info("Checksum of downloaded IDE was verified")
}
func downloadCustomPlugins(ideUrl string, targetDir string, spinner *pterm.SpinnerPrinter) error {
pluginsUrl := getPluginsURL(ideUrl)
log.Debugf("Downloading custom plugins from %s to %s", pluginsUrl, targetDir)
if err := os.MkdirAll(targetDir, os.ModePerm); err != nil {
return fmt.Errorf("couldn't create a directory %s: %v", targetDir, err)
}
archivePath := filepath.Join(targetDir, "custom-plugins.zip")
err := utils.DownloadFile(archivePath, pluginsUrl, getInternalAuth(), spinner)
if err != nil {
return fmt.Errorf("error while downloading plugins: %v", err)
}
_, err = utils.RunCmd("", "tar", "-xf", strutil.GetQuotedPath(archivePath), "-C", strutil.GetQuotedPath(targetDir))
if err != nil {
return fmt.Errorf("tar: %s", err)
}
disabledPluginsPath := filepath.Join(targetDir, "custom-plugins", "disabled_plugins.txt")
err = cp.Copy(disabledPluginsPath, filepath.Join(targetDir, "disabled_plugins.txt"))
if err != nil {
return fmt.Errorf("error while copying plugins: %s", err)
}
return nil
}
func getPluginsURL(ideUrl string) string {
pluginsUrl := strings.Replace(ideUrl, "-aarch64", "", 1)
if strings.Contains(pluginsUrl, ".sit") {
return strings.Replace(pluginsUrl, ".sit", "-custom-plugins.zip", 1)
} else if strings.Contains(pluginsUrl, ".win.zip") {
return strings.Replace(pluginsUrl, ".win.zip", "-custom-plugins.zip", 1)
}
return strings.Replace(pluginsUrl, ".tar.gz", "-custom-plugins.zip", 1)
}