Skip to content

Commit a970509

Browse files
committed
Artifact changes
1 parent 20e7451 commit a970509

File tree

5 files changed

+320
-7
lines changed

5 files changed

+320
-7
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts
5+
6+
import com.intellij.util.io.createDirectories
7+
import software.aws.toolkits.core.utils.deleteIfExists
8+
import software.aws.toolkits.core.utils.error
9+
import software.aws.toolkits.core.utils.exists
10+
import software.aws.toolkits.core.utils.getLogger
11+
import software.aws.toolkits.core.utils.info
12+
import software.aws.toolkits.core.utils.warn
13+
import software.aws.toolkits.jetbrains.core.saveFileFromUrl
14+
import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager
15+
import java.nio.file.Path
16+
import java.util.concurrent.atomic.AtomicInteger
17+
18+
class ArtifactHelper(private val lspArtifactsPath: Path = DEFAULT_ARTIFACT_PATH) {
19+
20+
companion object {
21+
private val DEFAULT_ARTIFACT_PATH = getToolkitsCommonCacheRoot().resolve("aws").resolve("toolkits").resolve("language-servers")
22+
private val logger = getLogger<ArtifactHelper>()
23+
private const val MAX_DOWNLOAD_ATTEMPTS = 3
24+
private val currentAttempt = AtomicInteger(0)
25+
}
26+
27+
fun removeDeListedVersions(deListedVersions: List<ManifestManager.Version>) {
28+
val localFolders: List<Path> = getSubFolders(lspArtifactsPath)
29+
30+
deListedVersions.forEach { deListedVersion ->
31+
val versionToDelete = deListedVersion.serverVersion ?: return
32+
33+
localFolders
34+
.filter { folder -> folder.fileName.toString() == versionToDelete }
35+
.forEach { folder ->
36+
try {
37+
folder.toFile().deleteRecursively()
38+
logger.info { "Successfully deleted deListed version: ${folder.fileName}" }
39+
} catch (e: Exception) {
40+
logger.error(e) { "Failed to delete deListed version ${folder.fileName}: ${e.message}" }
41+
}
42+
}
43+
}
44+
}
45+
46+
fun getExistingLSPArtifacts(versions: List<ManifestManager.Version>, target: ManifestManager.VersionTarget?): Boolean {
47+
if (versions.isEmpty() || target?.contents == null) return false
48+
49+
val localLSPPath = lspArtifactsPath.resolve(versions.first().serverVersion.toString())
50+
if (!localLSPPath.exists()) return false
51+
52+
val hasInvalidFiles = target.contents.any { content ->
53+
content.filename?.let { filename ->
54+
val filePath = localLSPPath.resolve(filename)
55+
!filePath.exists() || generateMD5Hash(filePath) != content.hashes?.firstOrNull()
56+
} ?: false
57+
}
58+
59+
if (hasInvalidFiles) {
60+
try {
61+
localLSPPath.toFile().deleteRecursively()
62+
logger.info { "Deleted mismatched LSP artifacts at: $localLSPPath" }
63+
} catch (e: Exception) {
64+
logger.error(e) { "Failed to delete mismatched LSP artifacts at: $localLSPPath" }
65+
}
66+
}
67+
return hasInvalidFiles
68+
}
69+
70+
fun tryDownloadLspArtifacts(versions: List<ManifestManager.Version>, target: ManifestManager.VersionTarget?) {
71+
val temporaryDownloadPath = lspArtifactsPath.resolve("temp")
72+
val downloadPath = lspArtifactsPath.resolve(versions.first().serverVersion.toString())
73+
74+
while (currentAttempt.get() < MAX_DOWNLOAD_ATTEMPTS) {
75+
currentAttempt.incrementAndGet()
76+
logger.info { "Attempt ${currentAttempt.get()} of $MAX_DOWNLOAD_ATTEMPTS to download LSP artifacts" }
77+
78+
try {
79+
if (downloadLspArtifacts(temporaryDownloadPath, target)) {
80+
moveFilesFromSourceToDestination(temporaryDownloadPath, downloadPath)
81+
logger.info { "Successfully downloaded and moved LSP artifacts to $downloadPath" }
82+
return
83+
}
84+
} catch (e: Exception) {
85+
logger.error(e) { "Failed to download/move LSP artifacts on attempt ${currentAttempt.get()}" }
86+
temporaryDownloadPath.toFile().deleteRecursively()
87+
88+
if (currentAttempt.get() >= MAX_DOWNLOAD_ATTEMPTS) {
89+
throw LspException("Failed to download LSP artifacts after $MAX_DOWNLOAD_ATTEMPTS attempts", LspException.ErrorCode.DOWNLOAD_FAILED)
90+
}
91+
}
92+
}
93+
}
94+
95+
private fun downloadLspArtifacts(downloadPath: Path, target: ManifestManager.VersionTarget?): Boolean {
96+
if (target == null || target.contents.isNullOrEmpty()) {
97+
logger.warn { "No target contents available for download" }
98+
return false
99+
}
100+
try {
101+
downloadPath.createDirectories()
102+
target.contents.forEach { content ->
103+
if (content.url == null || content.filename == null) {
104+
logger.warn { "Missing URL or filename in content" }
105+
return@forEach
106+
}
107+
val filePath = downloadPath.resolve(content.filename)
108+
val contentHash = content.hashes?.firstOrNull() ?: run {
109+
logger.warn { "No hash available for ${content.filename}" }
110+
return@forEach
111+
}
112+
downloadAndValidateFile(content.url, filePath, contentHash)
113+
}
114+
validateDownloadedFiles(downloadPath, target.contents)
115+
} catch (e: Exception) {
116+
logger.error(e) { "Failed to download LSP artifacts: ${e.message}" }
117+
downloadPath.toFile().deleteRecursively()
118+
return false
119+
}
120+
return true
121+
}
122+
123+
private fun downloadAndValidateFile(url: String, filePath: Path, expectedHash: String) {
124+
try {
125+
if (!filePath.exists()) {
126+
logger.info { "Downloading file: ${filePath.fileName}" }
127+
saveFileFromUrl(url, filePath)
128+
}
129+
if (!validateFileHash(filePath, expectedHash)) {
130+
logger.warn { "Hash mismatch for ${filePath.fileName}, re-downloading" }
131+
filePath.deleteIfExists()
132+
saveFileFromUrl(url, filePath)
133+
if (!validateFileHash(filePath, expectedHash)) {
134+
throw LspException("Hash mismatch after re-download for ${filePath.fileName}", LspException.ErrorCode.HASH_MISMATCH)
135+
}
136+
}
137+
} catch (e: Exception) {
138+
throw IllegalStateException("Failed to download/validate file: ${filePath.fileName}", e)
139+
}
140+
}
141+
142+
private fun validateFileHash(filePath: Path, expectedHash: String): Boolean = generateMD5Hash(filePath) == expectedHash
143+
144+
private fun validateDownloadedFiles(downloadPath: Path, contents: List<ManifestManager.TargetContent>) {
145+
val missingFiles = contents
146+
.mapNotNull { it.filename }
147+
.filter { filename ->
148+
!downloadPath.resolve(filename).exists()
149+
}
150+
if (missingFiles.isNotEmpty()) {
151+
val errorMessage = "Missing required files: ${missingFiles.joinToString(", ")}"
152+
logger.error { errorMessage }
153+
throw LspException(errorMessage, LspException.ErrorCode.DOWNLOAD_FAILED)
154+
}
155+
}
156+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts
5+
6+
import com.intellij.util.text.SemVer
7+
import org.assertj.core.util.VisibleForTesting
8+
import software.aws.toolkits.core.utils.getLogger
9+
import software.aws.toolkits.core.utils.info
10+
import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager
11+
12+
class ArtifactManager {
13+
14+
data class SupportedManifestVersionRange(
15+
val startVersion: SemVer,
16+
val endVersion: SemVer,
17+
)
18+
data class LSPVersions(
19+
val deListedVersions: List<ManifestManager.Version>,
20+
val inRangeVersions: List<ManifestManager.Version>,
21+
)
22+
23+
private val manifestFetcher: ManifestFetcher
24+
private val artifactHelper: ArtifactHelper
25+
private val manifestVersionRanges: SupportedManifestVersionRange
26+
27+
// Primary constructor with config
28+
constructor(
29+
manifestFetcher: ManifestFetcher = ManifestFetcher(),
30+
artifactFetcher: ArtifactHelper = ArtifactHelper(),
31+
manifestRange: SupportedManifestVersionRange?,
32+
) {
33+
manifestVersionRanges = manifestRange ?: DEFAULT_VERSION_RANGE
34+
this.manifestFetcher = manifestFetcher
35+
this.artifactHelper = artifactFetcher
36+
}
37+
38+
// Secondary constructor with no parameters
39+
constructor() : this(ManifestFetcher(), ArtifactHelper(), null)
40+
41+
companion object {
42+
private val DEFAULT_VERSION_RANGE = SupportedManifestVersionRange(
43+
startVersion = SemVer("3.0.0", 3, 0, 0),
44+
endVersion = SemVer("4.0.0", 4, 0, 0)
45+
)
46+
private val logger = getLogger<ArtifactManager>()
47+
}
48+
49+
fun fetchArtifact() {
50+
val manifest = manifestFetcher.fetch() ?: throw LspException(
51+
"Language Support is not available, as manifest is missing.",
52+
LspException.ErrorCode.MANIFEST_FETCH_FAILED
53+
)
54+
val lspVersions = getLSPVersionsFromManifestWithSpecifiedRange(manifest)
55+
56+
this.artifactHelper.removeDeListedVersions(lspVersions.deListedVersions)
57+
58+
if (lspVersions.inRangeVersions.isEmpty()) {
59+
// No versions are found which are in the given range.
60+
throw LspException("Language server versions not found in manifest.", LspException.ErrorCode.NO_COMPATIBLE_LSP_VERSION)
61+
}
62+
63+
// If there is an LSP Manifest with the same version
64+
val target = getTargetFromLspManifest(lspVersions.inRangeVersions)
65+
66+
// Get Local LSP files and check if we can re-use existing LSP Artifacts
67+
if (this.artifactHelper.getExistingLSPArtifacts(lspVersions.inRangeVersions, target)) {
68+
return
69+
}
70+
71+
this.artifactHelper.tryDownloadLspArtifacts(lspVersions.inRangeVersions, target)
72+
logger.info { "Success" }
73+
}
74+
75+
@VisibleForTesting
76+
internal fun getLSPVersionsFromManifestWithSpecifiedRange(manifest: ManifestManager.Manifest): LSPVersions {
77+
if (manifest.versions.isNullOrEmpty()) return LSPVersions(emptyList(), emptyList())
78+
79+
val (deListed, inRange) = manifest.versions.mapNotNull { version ->
80+
version.serverVersion?.let { serverVersion ->
81+
SemVer.parseFromText(serverVersion)?.let { semVer ->
82+
when {
83+
version.isDelisted != false -> Pair(version, true) // Is deListed
84+
semVer in manifestVersionRanges.startVersion..manifestVersionRanges.endVersion -> Pair(version, false) // Is in range
85+
else -> null
86+
}
87+
}
88+
}
89+
}.partition { it.second }
90+
91+
return LSPVersions(
92+
deListedVersions = deListed.map { it.first },
93+
inRangeVersions = inRange.map { it.first }.sortedByDescending { (_, semVer) -> semVer }
94+
)
95+
}
96+
97+
private fun getTargetFromLspManifest(versions: List<ManifestManager.Version>): ManifestManager.VersionTarget {
98+
val currentOS = getCurrentOS()
99+
val currentArchitecture = getCurrentArchitecture()
100+
101+
val currentTarget = versions.first().targets?.find { target ->
102+
target.platform == currentOS && target.arch == currentArchitecture
103+
}
104+
if (currentTarget == null) {
105+
throw LspException("Target not found in the current Version: ${versions.first().serverVersion}", LspException.ErrorCode.TARGET_NOT_FOUND)
106+
}
107+
return currentTarget
108+
}
109+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts
5+
6+
class LspException(message: String, private val errorCode: ErrorCode, cause: Throwable? = null) : Exception(message, cause) {
7+
8+
enum class ErrorCode {
9+
MANIFEST_INVALID,
10+
MANIFEST_FETCH_FAILED,
11+
DOWNLOAD_FAILED,
12+
HASH_MISMATCH,
13+
TARGET_NOT_FOUND,
14+
NO_COMPATIBLE_LSP_VERSION,
15+
}
16+
17+
override fun toString(): String = buildString {
18+
append("LSP Error [$errorCode]: $message")
19+
cause?.let { append(", Cause: ${it.message}") }
20+
}
21+
}

plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspUtils.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@ import com.intellij.openapi.util.SystemInfo
77
import com.intellij.openapi.util.text.StringUtil
88
import com.intellij.util.io.DigestUtil
99
import com.intellij.util.system.CpuArch
10+
import java.nio.file.Files
1011
import java.nio.file.Path
1112
import java.nio.file.Paths
13+
import java.nio.file.StandardCopyOption
14+
import kotlin.io.path.isDirectory
15+
import kotlin.io.path.listDirectoryEntries
1216

1317
fun getToolkitsCommonCacheRoot(): Path = when {
1418
SystemInfo.isWindows -> {
@@ -38,3 +42,19 @@ fun generateMD5Hash(filePath: Path): String {
3842
DigestUtil.updateContentHash(messageDigest, filePath)
3943
return StringUtil.toHexString(messageDigest.digest())
4044
}
45+
46+
fun getSubFolders(basePath: Path): List<Path> = try {
47+
basePath.listDirectoryEntries()
48+
.filter { it.isDirectory() }
49+
} catch (e: Exception) {
50+
emptyList()
51+
}
52+
53+
fun moveFilesFromSourceToDestination(sourceDir: Path, targetDir: Path) {
54+
try {
55+
Files.createDirectories(targetDir.parent)
56+
Files.move(sourceDir, targetDir, StandardCopyOption.REPLACE_EXISTING)
57+
} catch (e: Exception) {
58+
throw IllegalStateException("Failed to move files from $sourceDir to $targetDir", e)
59+
}
60+
}

plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcher.kt

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,22 @@ import software.aws.toolkits.jetbrains.core.saveFileFromUrl
1616
import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager
1717
import java.nio.file.Path
1818

19-
class ManifestFetcher {
20-
21-
private val lspManifestUrl = "https://aws-toolkit-language-servers.amazonaws.com/codewhisperer/0/manifest.json"
22-
private val manifestManager = ManifestManager()
23-
private val lspManifestFilePath: Path = getToolkitsCommonCacheRoot().resolve("aws").resolve("toolkits").resolve("language-servers")
24-
.resolve("jetbrains-lsp-manifest.json")
25-
19+
class ManifestFetcher(
20+
private val lspManifestUrl: String = DEFAULT_MANIFEST_URL,
21+
private val manifestManager: ManifestManager = ManifestManager(),
22+
private val lspManifestFilePath: Path = DEFAULT_MANIFEST_PATH,
23+
) {
2624
companion object {
2725
private val logger = getLogger<ManifestFetcher>()
26+
27+
private const val DEFAULT_MANIFEST_URL =
28+
"https://aws-toolkit-language-servers.amazonaws.com/codewhisperer/0/manifest.json"
29+
30+
private val DEFAULT_MANIFEST_PATH: Path = getToolkitsCommonCacheRoot()
31+
.resolve("aws")
32+
.resolve("toolkits")
33+
.resolve("language-servers")
34+
.resolve("jetbrains-lsp-manifest.json")
2835
}
2936

3037
/**

0 commit comments

Comments
 (0)