Skip to content

Commit

Permalink
feat(amazonq): Added LSP Manifest manager related changes (#5387)
Browse files Browse the repository at this point in the history
* Added Manifest Fetcher

* Addressing code review comments

* Added unit test cases

* Fixing lint issues

* Addressing code review comments

* Addressing code review comments

* Fixing lint issues

* Addressing code review comments

* Fixing detektMain lint issues

* Added unit test cases

* Updating code according to spec.

* detekt

* Fixing typo

* Artifact changes

* Fixing validation function

* Addressing code review comments

* Fixing Detekt
  • Loading branch information
LokeshDogga13 authored Feb 26, 2025
1 parent 6d593c3 commit 179aea2
Show file tree
Hide file tree
Showing 9 changed files with 609 additions and 13 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts

import com.intellij.util.io.createDirectories
import com.intellij.util.text.SemVer
import software.aws.toolkits.core.utils.deleteIfExists
import software.aws.toolkits.core.utils.error
import software.aws.toolkits.core.utils.exists
import software.aws.toolkits.core.utils.getLogger
import software.aws.toolkits.core.utils.info
import software.aws.toolkits.core.utils.warn
import software.aws.toolkits.jetbrains.core.saveFileFromUrl
import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager
import java.nio.file.Path
import java.util.concurrent.atomic.AtomicInteger

class ArtifactHelper(private val lspArtifactsPath: Path = DEFAULT_ARTIFACT_PATH, private val maxDownloadAttempts: Int = MAX_DOWNLOAD_ATTEMPTS) {

companion object {
private val DEFAULT_ARTIFACT_PATH = getToolkitsCommonCacheRoot().resolve("aws").resolve("toolkits").resolve("language-servers")
private val logger = getLogger<ArtifactHelper>()
private const val MAX_DOWNLOAD_ATTEMPTS = 3
}
private val currentAttempt = AtomicInteger(0)

fun removeDelistedVersions(delistedVersions: List<ManifestManager.Version>) {
val localFolders = getSubFolders(lspArtifactsPath)

delistedVersions.forEach { delistedVersion ->
val versionToDelete = delistedVersion.serverVersion ?: return@forEach

localFolders
.filter { folder -> folder.fileName.toString() == versionToDelete }
.forEach { folder ->
try {
folder.toFile().deleteRecursively()
logger.info { "Successfully deleted deListed version: ${folder.fileName}" }
} catch (e: Exception) {
logger.error(e) { "Failed to delete deListed version ${folder.fileName}: ${e.message}" }
}
}
}
}

fun deleteOlderLspArtifacts(manifestVersionRanges: ArtifactManager.SupportedManifestVersionRange) {
val localFolders = getSubFolders(lspArtifactsPath)

val validVersions = localFolders
.mapNotNull { localFolder ->
SemVer.parseFromText(localFolder.fileName.toString())?.let { semVer ->
if (semVer in manifestVersionRanges.startVersion..manifestVersionRanges.endVersion) {
localFolder to semVer
} else {
null
}
}
}
.sortedByDescending { (_, semVer) -> semVer }

// Keep the latest 2 versions, delete others
validVersions.drop(2).forEach { (folder, _) ->
try {
folder.toFile().deleteRecursively()
logger.info { "Deleted older LSP artifact: ${folder.fileName}" }
} catch (e: Exception) {
logger.error(e) { "Failed to delete older LSP artifact: ${folder.fileName}" }
}
}
}

fun getExistingLspArtifacts(versions: List<ManifestManager.Version>, target: ManifestManager.VersionTarget?): Boolean {
if (versions.isEmpty() || target?.contents == null) return false

val localLSPPath = lspArtifactsPath.resolve(versions.first().serverVersion.toString())
if (!localLSPPath.exists()) return false

val hasInvalidFiles = target.contents.any { content ->
content.filename?.let { filename ->
val filePath = localLSPPath.resolve(filename)
!filePath.exists() || !validateFileHash(filePath, content.hashes?.firstOrNull())
} ?: false
}

if (hasInvalidFiles) {
try {
localLSPPath.toFile().deleteRecursively()
logger.info { "Deleted mismatched LSP artifacts at: $localLSPPath" }
} catch (e: Exception) {
logger.error(e) { "Failed to delete mismatched LSP artifacts at: $localLSPPath" }
}
}
return !hasInvalidFiles
}

fun tryDownloadLspArtifacts(versions: List<ManifestManager.Version>, target: ManifestManager.VersionTarget?) {
val temporaryDownloadPath = lspArtifactsPath.resolve("temp")
val downloadPath = lspArtifactsPath.resolve(versions.first().serverVersion.toString())

while (currentAttempt.get() < maxDownloadAttempts) {
currentAttempt.incrementAndGet()
logger.info { "Attempt ${currentAttempt.get()} of $maxDownloadAttempts to download LSP artifacts" }

try {
if (downloadLspArtifacts(temporaryDownloadPath, target)) {
moveFilesFromSourceToDestination(temporaryDownloadPath, downloadPath)
logger.info { "Successfully downloaded and moved LSP artifacts to $downloadPath" }
return
}
} catch (e: Exception) {
logger.error(e) { "Failed to download/move LSP artifacts on attempt ${currentAttempt.get()}" }
temporaryDownloadPath.toFile().deleteRecursively()

if (currentAttempt.get() >= maxDownloadAttempts) {
throw LspException("Failed to download LSP artifacts after $maxDownloadAttempts attempts", LspException.ErrorCode.DOWNLOAD_FAILED)
}
}
}
}

private fun downloadLspArtifacts(downloadPath: Path, target: ManifestManager.VersionTarget?): Boolean {
if (target == null || target.contents.isNullOrEmpty()) {
logger.warn { "No target contents available for download" }
return false
}
try {
downloadPath.createDirectories()
target.contents.forEach { content ->
if (content.url == null || content.filename == null) {
logger.warn { "Missing URL or filename in content" }
return@forEach
}
val filePath = downloadPath.resolve(content.filename)
val contentHash = content.hashes?.firstOrNull() ?: run {
logger.warn { "No hash available for ${content.filename}" }
return@forEach
}
downloadAndValidateFile(content.url, filePath, contentHash)
}
validateDownloadedFiles(downloadPath, target.contents)
} catch (e: Exception) {
logger.error(e) { "Failed to download LSP artifacts: ${e.message}" }
downloadPath.toFile().deleteRecursively()
return false
}
return true
}

private fun downloadAndValidateFile(url: String, filePath: Path, expectedHash: String) {
try {
if (!filePath.exists()) {
logger.info { "Downloading file: ${filePath.fileName}" }
saveFileFromUrl(url, filePath)
}
if (!validateFileHash(filePath, expectedHash)) {
logger.warn { "Hash mismatch for ${filePath.fileName}, re-downloading" }
filePath.deleteIfExists()
saveFileFromUrl(url, filePath)
if (!validateFileHash(filePath, expectedHash)) {
throw LspException("Hash mismatch after re-download for ${filePath.fileName}", LspException.ErrorCode.HASH_MISMATCH)
}
}
} catch (e: Exception) {
throw IllegalStateException("Failed to download/validate file: ${filePath.fileName}", e)
}
}

private fun validateFileHash(filePath: Path, expectedHash: String?): Boolean {
if (expectedHash == null) return false
val contentHash = generateSHA384Hash(filePath)
return "sha384:$contentHash" == expectedHash
}

private fun validateDownloadedFiles(downloadPath: Path, contents: List<ManifestManager.TargetContent>) {
val missingFiles = contents
.mapNotNull { it.filename }
.filter { filename ->
!downloadPath.resolve(filename).exists()
}
if (missingFiles.isNotEmpty()) {
val errorMessage = "Missing required files: ${missingFiles.joinToString(", ")}"
logger.error { errorMessage }
throw LspException(errorMessage, LspException.ErrorCode.DOWNLOAD_FAILED)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts

import com.intellij.util.text.SemVer
import org.assertj.core.util.VisibleForTesting
import software.aws.toolkits.core.utils.error
import software.aws.toolkits.core.utils.getLogger
import software.aws.toolkits.core.utils.info
import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager

class ArtifactManager {

data class SupportedManifestVersionRange(
val startVersion: SemVer,
val endVersion: SemVer,
)
data class LSPVersions(
val deListedVersions: List<ManifestManager.Version>,
val inRangeVersions: List<ManifestManager.Version>,
)

private val manifestFetcher: ManifestFetcher
private val artifactHelper: ArtifactHelper
private val manifestVersionRanges: SupportedManifestVersionRange

// Primary constructor with config
constructor(
manifestFetcher: ManifestFetcher = ManifestFetcher(),
artifactFetcher: ArtifactHelper = ArtifactHelper(),
manifestRange: SupportedManifestVersionRange?,
) {
manifestVersionRanges = manifestRange ?: DEFAULT_VERSION_RANGE
this.manifestFetcher = manifestFetcher
this.artifactHelper = artifactFetcher
}

// Secondary constructor with no parameters
constructor() : this(ManifestFetcher(), ArtifactHelper(), null)

companion object {
private val DEFAULT_VERSION_RANGE = SupportedManifestVersionRange(
startVersion = SemVer("3.0.0", 3, 0, 0),
endVersion = SemVer("4.0.0", 4, 0, 0)
)
private val logger = getLogger<ArtifactManager>()
}

fun fetchArtifact() {
val manifest = manifestFetcher.fetch() ?: throw LspException(
"Language Support is not available, as manifest is missing.",
LspException.ErrorCode.MANIFEST_FETCH_FAILED
)
val lspVersions = getLSPVersionsFromManifestWithSpecifiedRange(manifest)

this.artifactHelper.removeDelistedVersions(lspVersions.deListedVersions)

if (lspVersions.inRangeVersions.isEmpty()) {
// No versions are found which are in the given range.
throw LspException("Language server versions not found in manifest.", LspException.ErrorCode.NO_COMPATIBLE_LSP_VERSION)
}

// If there is an LSP Manifest with the same version
val target = getTargetFromLspManifest(lspVersions.inRangeVersions)

// Get Local LSP files and check if we can re-use existing LSP Artifacts
if (!this.artifactHelper.getExistingLspArtifacts(lspVersions.inRangeVersions, target)) {
this.artifactHelper.tryDownloadLspArtifacts(lspVersions.inRangeVersions, target)
}

this.artifactHelper.deleteOlderLspArtifacts(manifestVersionRanges)
}

@VisibleForTesting
internal fun getLSPVersionsFromManifestWithSpecifiedRange(manifest: ManifestManager.Manifest): LSPVersions {
if (manifest.versions.isNullOrEmpty()) return LSPVersions(emptyList(), emptyList())

val (deListed, inRange) = manifest.versions.mapNotNull { version ->
version.serverVersion?.let { serverVersion ->
SemVer.parseFromText(serverVersion)?.let { semVer ->
when {
version.isDelisted != false -> Pair(version, true) // Is deListed
semVer in manifestVersionRanges.startVersion..manifestVersionRanges.endVersion -> Pair(version, false) // Is in range
else -> null
}
}
}
}.partition { it.second }

return LSPVersions(
deListedVersions = deListed.map { it.first },
inRangeVersions = inRange.map { it.first }.sortedByDescending { (_, semVer) -> semVer }
)
}

private fun getTargetFromLspManifest(versions: List<ManifestManager.Version>): ManifestManager.VersionTarget {
val currentOS = getCurrentOS()
val currentArchitecture = getCurrentArchitecture()

val currentTarget = versions.first().targets?.find { target ->
target.platform == currentOS && target.arch == currentArchitecture
}
if (currentTarget == null) {
logger.error { "Failed to obtain target for $currentOS and $currentArchitecture" }
throw LspException("Target not found in the current Version: ${versions.first().serverVersion}", LspException.ErrorCode.TARGET_NOT_FOUND)
}
logger.info { "Target found in the current Version: ${versions.first().serverVersion}" }
return currentTarget
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts

class LspException(message: String, private val errorCode: ErrorCode, cause: Throwable? = null) : Exception(message, cause) {

enum class ErrorCode {
MANIFEST_FETCH_FAILED,
DOWNLOAD_FAILED,
HASH_MISMATCH,
TARGET_NOT_FOUND,
NO_COMPATIBLE_LSP_VERSION,
}

override fun toString(): String = buildString {
append("LSP Error [$errorCode]: $message")
cause?.let { append(", Cause: ${it.message}") }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts

import com.intellij.openapi.util.SystemInfo
import com.intellij.openapi.util.text.StringUtil
import com.intellij.util.io.DigestUtil
import com.intellij.util.system.CpuArch
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.StandardCopyOption
import java.security.MessageDigest
import kotlin.io.path.isDirectory
import kotlin.io.path.listDirectoryEntries

fun getToolkitsCommonCacheRoot(): Path = when {
SystemInfo.isWindows -> {
Paths.get(System.getenv("LOCALAPPDATA"))
}
SystemInfo.isMac -> {
Paths.get(System.getProperty("user.home"), "Library", "Caches")
}
else -> {
Paths.get(System.getProperty("user.home"), ".cache")
}
}

fun getCurrentOS(): String = when {
SystemInfo.isWindows -> "windows"
SystemInfo.isMac -> "darwin"
else -> "linux"
}

fun getCurrentArchitecture() = when (CpuArch.CURRENT) {
CpuArch.X86_64 -> "x64"
CpuArch.ARM64 -> "arm64"
else -> "unknown"
}

fun generateMD5Hash(filePath: Path): String {
val messageDigest = DigestUtil.md5()
DigestUtil.updateContentHash(messageDigest, filePath)
return StringUtil.toHexString(messageDigest.digest())
}

fun generateSHA384Hash(filePath: Path): String {
val messageDigest = MessageDigest.getInstance("SHA-384")
DigestUtil.updateContentHash(messageDigest, filePath)
return StringUtil.toHexString(messageDigest.digest())
}

fun getSubFolders(basePath: Path): List<Path> = try {
basePath.listDirectoryEntries()
.filter { it.isDirectory() }
} catch (e: Exception) {
emptyList()
}

fun moveFilesFromSourceToDestination(sourceDir: Path, targetDir: Path) {
try {
Files.createDirectories(targetDir.parent)
Files.move(sourceDir, targetDir, StandardCopyOption.REPLACE_EXISTING)
} catch (e: Exception) {
throw IllegalStateException("Failed to move files from $sourceDir to $targetDir", e)
}
}
Loading

0 comments on commit 179aea2

Please sign in to comment.