Skip to content

Commit 052e50d

Browse files
Krosovoknquinquenel
authored andcommitted
SLCORE-2243 Add connected mode and on-demand artifact resolvers (#1925)
1 parent 7e234c7 commit 052e50d

19 files changed

Lines changed: 1795 additions & 56 deletions

backend/core/src/main/java/org/sonarsource/sonarlint/core/event/PluginStatusChangedEvent.java renamed to backend/core/src/main/java/org/sonarsource/sonarlint/core/event/PluginStatusUpdateEvent.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@
1919
*/
2020
package org.sonarsource.sonarlint.core.event;
2121

22+
import java.util.Collection;
23+
import javax.annotation.Nullable;
2224
import org.sonarsource.sonarlint.core.plugin.PluginStatus;
2325

24-
public record PluginStatusChangedEvent(PluginStatus newStatus) {
26+
public record PluginStatusUpdateEvent(
27+
@Nullable String connectionId,
28+
Collection<PluginStatus> newStatuses) {
2529
}

backend/core/src/main/java/org/sonarsource/sonarlint/core/plugin/PluginStatus.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ public static PluginStatus unsupported(SonarLanguage language) {
5959
return forLanguage(language, ArtifactState.UNSUPPORTED, null, null, null, null, null);
6060
}
6161

62+
public static PluginStatus failed(SonarLanguage language) {
63+
return forLanguage(language, ArtifactState.FAILED, null, null, null, null);
64+
}
65+
6266
public String pluginName() {
6367
return language != null ? language.getName() : pluginKey;
6468
}

backend/core/src/main/java/org/sonarsource/sonarlint/core/plugin/PluginsService.java

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ private PluginStatus getPluginStatus(@Nullable String connectionId, SonarLanguag
101101
var pluginKey = language.getPluginKey();
102102
if (isFromConnectedMode(connectionId, pluginKey)) {
103103
var state = getPlugins(connectionId).hasDisabledPlugin(pluginKey) ? ArtifactState.FAILED : ArtifactState.SYNCED;
104-
if (isSonarCloud(connectionId)) {
104+
if (isSonarQubeCloud(connectionId)) {
105105
return PluginStatus.forLanguage(language, state, ArtifactSource.SONARQUBE_CLOUD, null, null, null, null);
106106
} else {
107107
var serverVersion = storageService.connection(connectionId).serverInfo().read()
@@ -205,26 +205,25 @@ private Map<String, Path> getEmbeddedPluginPathsByKey(String connectionId) {
205205
}
206206

207207
public boolean supportsIaCEnterprise(String connectionId) {
208-
return isSonarQubeCloudOrVersionHigherThan(ENTERPRISE_IAC_MIN_SQ_VERSION, connectionId);
208+
return isSonarQubeCloudOrVersionAtLeast(connectionConfigurationRepository, storageService, ENTERPRISE_IAC_MIN_SQ_VERSION, connectionId);
209209
}
210210

211211
public boolean supportsCustomSecrets(String connectionId) {
212-
return isSonarQubeCloudOrVersionHigherThan(CUSTOM_SECRETS_MIN_SQ_VERSION, connectionId);
212+
return isSonarQubeCloudOrVersionAtLeast(connectionConfigurationRepository, storageService, CUSTOM_SECRETS_MIN_SQ_VERSION, connectionId);
213213
}
214214

215215
public boolean supportsGoEnterprise(String connectionId) {
216-
return isSonarQubeCloudOrVersionHigherThan(ENTERPRISE_GO_MIN_SQ_VERSION, connectionId);
216+
return isSonarQubeCloudOrVersionAtLeast(connectionConfigurationRepository, storageService, ENTERPRISE_GO_MIN_SQ_VERSION, connectionId);
217217
}
218218

219-
private boolean isSonarQubeCloudOrVersionHigherThan(Version version, String connectionId) {
220-
var connection = connectionConfigurationRepository.getConnectionById(connectionId);
219+
public static boolean isSonarQubeCloudOrVersionAtLeast(ConnectionConfigurationRepository connectionRepository,
220+
StorageService storageService, Version minVersion, String connectionId) {
221+
var connection = connectionRepository.getConnectionById(connectionId);
221222
if (connection == null) {
222-
// Connection is gone
223223
return false;
224224
}
225-
// when storage is not present, assume that server version is lower than requested
226225
return connection.getKind() == ConnectionKind.SONARCLOUD || storageService.connection(connectionId).serverInfo().read()
227-
.map(serverInfo -> serverInfo.version().compareToIgnoreQualifier(version) >= 0)
226+
.map(serverInfo -> serverInfo.version().compareToIgnoreQualifier(minVersion) >= 0)
228227
.orElse(false);
229228
}
230229

@@ -247,7 +246,7 @@ public boolean shouldUseEnterpriseCSharpAnalyzer(String connectionId) {
247246
}
248247

249248
private boolean shouldUseEnterpriseDotNetAnalyzer(String connectionId, String analyzerName) {
250-
if (isSonarCloud(connectionId)) {
249+
if (isSonarQubeCloud(connectionId)) {
251250
return true;
252251
} else {
253252
var connectionStorage = storageService.connection(connectionId);
@@ -265,7 +264,7 @@ private boolean shouldUseEnterpriseDotNetAnalyzer(String connectionId, String an
265264
}
266265
}
267266

268-
private boolean isSonarCloud(String connectionId) {
267+
private boolean isSonarQubeCloud(String connectionId) {
269268
var connection = connectionConfigurationRepository.getConnectionById(connectionId);
270269
return connection != null && connection.getKind() == ConnectionKind.SONARCLOUD;
271270
}
@@ -314,4 +313,5 @@ static class CSharpSupport {
314313
}
315314
}
316315
}
316+
317317
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/*
2+
* SonarLint Core - Implementation
3+
* Copyright (C) 2016-2025 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
package org.sonarsource.sonarlint.core.plugin.resolvers;
21+
22+
import java.nio.file.Files;
23+
import java.nio.file.Path;
24+
import java.util.Map;
25+
import java.util.Optional;
26+
import java.util.Set;
27+
import javax.annotation.Nullable;
28+
import org.sonarsource.sonarlint.core.commons.Version;
29+
import org.sonarsource.sonarlint.core.commons.api.SonarLanguage;
30+
import org.sonarsource.sonarlint.core.commons.log.SonarLintLogger;
31+
import org.sonarsource.sonarlint.core.plugin.ArtifactState;
32+
import org.sonarsource.sonarlint.core.plugin.PluginJarUtils;
33+
import org.sonarsource.sonarlint.core.plugin.ResolvedArtifact;
34+
import org.sonarsource.sonarlint.core.plugin.ServerPluginsCache;
35+
import org.sonarsource.sonarlint.core.repository.connection.ConnectionConfigurationRepository;
36+
import org.sonarsource.sonarlint.core.serverapi.exception.ServerRequestException;
37+
import org.sonarsource.sonarlint.core.serverapi.plugins.ServerPlugin;
38+
import org.sonarsource.sonarlint.core.serverconnection.StoredPlugin;
39+
import org.sonarsource.sonarlint.core.storage.StorageService;
40+
41+
import static org.sonarsource.sonarlint.core.plugin.PluginsService.isSonarQubeCloudOrVersionAtLeast;
42+
43+
/**
44+
* Resolves analyzer plugins from the local storage of a SQS or SQC server connection.
45+
*
46+
* <p>{@link #resolve} handles language plugins. It returns immediately: if the locally stored copy is up-to-date its path is returned as
47+
* {@link ArtifactState#SYNCED}; otherwise a background download is scheduled and
48+
* {@link ArtifactState#DOWNLOADING} is returned. Concurrent downloads for the same
49+
* connection + plugin key are de-duplicated by the underlying downloader.</p>
50+
*
51+
* <p><b>Events:</b> when a background download completes, a {@link org.sonarsource.sonarlint.core.event.PluginStatusUpdateEvent}
52+
* is published — {@link ArtifactState#SYNCED} on success, {@link ArtifactState#FAILED} on
53+
* error.</p>
54+
*/
55+
public class ConnectedModeArtifactResolver implements ArtifactResolver {
56+
57+
private static final SonarLintLogger LOG = SonarLintLogger.get();
58+
private static final String PLUGIN_FETCH_ERROR = "Could not fetch server plugin list for connection '{}'";
59+
60+
public static final Version CUSTOM_SECRETS_MIN_SQ_VERSION = Version.create("10.4");
61+
public static final Version ENTERPRISE_IAC_MIN_SQ_VERSION = Version.create("2025.1");
62+
public static final Version ENTERPRISE_GO_MIN_SQ_VERSION = Version.create("2025.2");
63+
64+
/** Languages where a new enough server version overrides the embedded plugin. */
65+
private static final Map<SonarLanguage, Version> FORCE_OVERRIDES_SINCE_VERSION = Map.of(
66+
SonarLanguage.SECRETS, CUSTOM_SECRETS_MIN_SQ_VERSION,
67+
SonarLanguage.AZURERESOURCEMANAGER, ENTERPRISE_IAC_MIN_SQ_VERSION,
68+
SonarLanguage.GO, ENTERPRISE_GO_MIN_SQ_VERSION);
69+
70+
private final StorageService storageService;
71+
private final ConnectionConfigurationRepository connectionConfigurationRepository;
72+
private final ServerPluginsCache serverPluginsCache;
73+
private final ServerPluginDownloader downloader;
74+
private final Set<String> skipSyncPluginKeys;
75+
76+
public ConnectedModeArtifactResolver(StorageService storageService,
77+
ConnectionConfigurationRepository connectionConfigurationRepository,
78+
ServerPluginsCache serverPluginsCache,
79+
ServerPluginDownloader downloader,
80+
Set<String> skipSyncPluginKeys) {
81+
this.storageService = storageService;
82+
this.connectionConfigurationRepository = connectionConfigurationRepository;
83+
this.serverPluginsCache = serverPluginsCache;
84+
this.downloader = downloader;
85+
this.skipSyncPluginKeys = skipSyncPluginKeys;
86+
}
87+
88+
@Override
89+
public Optional<ResolvedArtifact> resolve(SonarLanguage language, @Nullable String connectionId) {
90+
if (connectionId == null) {
91+
return Optional.empty();
92+
}
93+
if (!passesLanguageGate(language, connectionId)) {
94+
if (skipSyncPluginKeys.contains(language.getPluginKey())) {
95+
LOG.debug("[SYNC] Code analyzer '{}' is embedded in SonarLint. Skip downloading it.", language.getPluginKey());
96+
}
97+
return Optional.empty();
98+
}
99+
var pluginKey = language.getPluginKey();
100+
var fallbackPluginKey = "iacenterprise".equals(pluginKey) ? "iac" : null;
101+
try {
102+
return serverPluginsCache.getPlugins(connectionId)
103+
.flatMap(plugins -> {
104+
var match = plugins.stream().filter(p -> p.getKey().equals(pluginKey)).findFirst();
105+
if (match.isEmpty() && fallbackPluginKey != null) {
106+
match = plugins.stream().filter(p -> p.getKey().equals(fallbackPluginKey)).findFirst();
107+
}
108+
return match;
109+
})
110+
.map(serverPlugin -> resolveFromStorageOrSchedule(connectionId, serverPlugin, language))
111+
.or(() -> resolveFromStorageWithFallback(connectionId, pluginKey, fallbackPluginKey));
112+
} catch (ServerRequestException e) {
113+
LOG.debug(PLUGIN_FETCH_ERROR, connectionId);
114+
return resolveFromStorageWithFallback(connectionId, pluginKey, fallbackPluginKey);
115+
}
116+
}
117+
118+
private ResolvedArtifact resolveFromStorageOrSchedule(String connectionId, ServerPlugin serverPlugin, SonarLanguage language) {
119+
var fromStorage = resolveFromStorage(connectionId, serverPlugin);
120+
if (fromStorage.isPresent()) {
121+
LOG.debug("[SYNC] Code analyzer '{}' is up-to-date. Skip downloading it.", serverPlugin.getKey());
122+
return fromStorage.get();
123+
}
124+
downloader.scheduleLanguagePluginDownload(connectionId, serverPlugin, language);
125+
return new ResolvedArtifact(ArtifactState.DOWNLOADING, null, null, null);
126+
}
127+
128+
private Optional<ResolvedArtifact> resolveFromStorage(String connectionId, ServerPlugin serverPlugin) {
129+
return findStoredPlugin(connectionId, serverPlugin.getKey())
130+
.filter(s -> s.hasSameHash(serverPlugin))
131+
.map(s -> toResolvedArtifact(s.getJarPath(), connectionId));
132+
}
133+
134+
private Optional<ResolvedArtifact> resolveFromStorageByKey(String connectionId, String pluginKey) {
135+
return findStoredPlugin(connectionId, pluginKey)
136+
.map(s -> toResolvedArtifact(s.getJarPath(), connectionId));
137+
}
138+
139+
private Optional<ResolvedArtifact> resolveFromStorageWithFallback(String connectionId, String pluginKey, @Nullable String fallbackPluginKey) {
140+
return resolveFromStorageByKey(connectionId, pluginKey)
141+
.or(() -> fallbackPluginKey != null ? resolveFromStorageByKey(connectionId, fallbackPluginKey) : Optional.empty());
142+
}
143+
144+
private Optional<StoredPlugin> findStoredPlugin(String connectionId, String pluginKey) {
145+
var stored = storageService.connection(connectionId).plugins().getStoredPluginsByKey().get(pluginKey);
146+
if (stored == null || !Files.exists(stored.getJarPath())) {
147+
return Optional.empty();
148+
}
149+
return Optional.of(stored);
150+
}
151+
152+
private boolean passesLanguageGate(SonarLanguage language, String connectionId) {
153+
if (FORCE_OVERRIDES_SINCE_VERSION.containsKey(language)) {
154+
var minVersion = FORCE_OVERRIDES_SINCE_VERSION.get(language);
155+
return isSonarQubeCloudOrVersionAtLeast(connectionConfigurationRepository, storageService, minVersion, connectionId);
156+
}
157+
return !skipSyncPluginKeys.contains(language.getPluginKey());
158+
}
159+
160+
private ResolvedArtifact toResolvedArtifact(Path pluginPath, String connectionId) {
161+
var source = downloader.sourceFor(connectionId);
162+
return new ResolvedArtifact(ArtifactState.SYNCED, pluginPath, source, PluginJarUtils.readVersion(pluginPath));
163+
}
164+
165+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/*
2+
* SonarLint Core - Implementation
3+
* Copyright (C) 2016-2025 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
package org.sonarsource.sonarlint.core.plugin.resolvers;
21+
22+
import java.nio.file.Files;
23+
import java.util.Map;
24+
import java.util.Optional;
25+
import java.util.concurrent.ConcurrentHashMap;
26+
import javax.annotation.Nullable;
27+
import org.sonarsource.sonarlint.core.commons.api.SonarLanguage;
28+
import org.sonarsource.sonarlint.core.commons.log.SonarLintLogger;
29+
import org.sonarsource.sonarlint.core.languages.LanguageSupportRepository;
30+
import org.sonarsource.sonarlint.core.plugin.ArtifactState;
31+
import org.sonarsource.sonarlint.core.plugin.PluginStatus;
32+
import org.sonarsource.sonarlint.core.plugin.ServerPluginsCache;
33+
import org.sonarsource.sonarlint.core.serverapi.exception.ServerRequestException;
34+
import org.sonarsource.sonarlint.core.serverapi.plugins.ServerPlugin;
35+
import org.sonarsource.sonarlint.core.serverconnection.StoredPlugin;
36+
import org.sonarsource.sonarlint.core.storage.StorageService;
37+
38+
/**
39+
* Resolves companion plugins (plugins that are not strictly language analyzers but
40+
* provide additional features required by other analyzers, like sonarlint-omnisharp).
41+
* It delegates actual plugin downloading to the {@link ServerPluginDownloader} but
42+
* orchestrates the specific logic required to ensure companion plugins are available
43+
* and up-to-date in Connected Mode.
44+
* <p>
45+
* Also includes companions that are present in local storage but absent from the server list,
46+
* so that already-downloaded plugins remain usable when the server is unreachable.
47+
* </p>
48+
*/
49+
public class ConnectedModeCompanionPluginResolver implements CompanionPluginResolver {
50+
private static final SonarLintLogger LOG = SonarLintLogger.get();
51+
private static final String LEGACY_TYPESCRIPT_PLUGIN_KEY = "typescript";
52+
private static final String PLUGIN_FETCH_ERROR = "Could not fetch server plugin list for connection '{}'";
53+
54+
private final StorageService storageService;
55+
private final ServerPluginsCache serverPluginsCache;
56+
private final ServerPluginDownloader downloader;
57+
private final LanguageSupportRepository languageSupportRepository;
58+
59+
public ConnectedModeCompanionPluginResolver(StorageService storageService,
60+
ServerPluginsCache serverPluginsCache,
61+
ServerPluginDownloader downloader,
62+
LanguageSupportRepository languageSupportRepository) {
63+
this.storageService = storageService;
64+
this.serverPluginsCache = serverPluginsCache;
65+
this.downloader = downloader;
66+
this.languageSupportRepository = languageSupportRepository;
67+
}
68+
69+
@Override
70+
public Map<String, PluginStatus> resolveCompanionPlugins(@Nullable String connectionId) {
71+
if (connectionId == null) {
72+
return Map.of();
73+
}
74+
var result = new ConcurrentHashMap<String, PluginStatus>();
75+
var storedPluginsByKey = storageService.connection(connectionId).plugins().getStoredPluginsByKey();
76+
fetchServerPluginsSafely(connectionId).ifPresent(plugins ->
77+
plugins.stream()
78+
.filter(p -> isCompanionPlugin(p.getKey()))
79+
.forEach(p -> resolveOrScheduleCompanion(connectionId, p, storedPluginsByKey, result))
80+
);
81+
// Include stored companions not already resolved: covers server-unreachable and companions removed from server
82+
storedPluginsByKey.entrySet().stream()
83+
.filter(e -> isCompanionPlugin(e.getKey()))
84+
.filter(e -> !result.containsKey(e.getKey()))
85+
.filter(e -> Files.exists(e.getValue().getJarPath()))
86+
.forEach(e -> addStoredCompanion(connectionId, e.getKey(), e.getValue(), result));
87+
return result;
88+
}
89+
90+
private void resolveOrScheduleCompanion(String connectionId, ServerPlugin plugin,
91+
Map<String, StoredPlugin> storedPluginsByKey, ConcurrentHashMap<String, PluginStatus> result) {
92+
var stored = storedPluginsByKey.get(plugin.getKey());
93+
if (stored != null && Files.exists(stored.getJarPath()) && stored.hasSameHash(plugin)) {
94+
addStoredCompanion(connectionId, plugin.getKey(), stored, result);
95+
} else {
96+
processCompanionPlugin(connectionId, plugin, result);
97+
}
98+
}
99+
100+
private void addStoredCompanion(String connectionId, String key, StoredPlugin stored, ConcurrentHashMap<String, PluginStatus> result) {
101+
var source = downloader.sourceFor(connectionId);
102+
result.put(key, PluginStatus.forCompanion(key, ArtifactState.SYNCED, source, stored.getJarPath()));
103+
}
104+
105+
private void processCompanionPlugin(String connectionId, ServerPlugin plugin, ConcurrentHashMap<String, PluginStatus> result) {
106+
if (LEGACY_TYPESCRIPT_PLUGIN_KEY.equals(plugin.getKey())
107+
&& !languageSupportRepository.getEnabledLanguagesInConnectedMode().contains(SonarLanguage.TS)) {
108+
LOG.debug("[SYNC] Code analyzer '{}' is disabled in SonarLint (language not enabled). Skip downloading it.", plugin.getKey());
109+
return;
110+
}
111+
if (!plugin.isSonarLintSupported() && !isForceSynchronized(plugin.getKey())) {
112+
LOG.debug("[SYNC] Code analyzer '{}' does not support SonarLint. Skip downloading it.", plugin.getKey());
113+
return;
114+
}
115+
downloader.scheduleCompanionPluginDownload(connectionId, plugin);
116+
result.put(plugin.getKey(), PluginStatus.forCompanion(plugin.getKey(), ArtifactState.DOWNLOADING, null, null));
117+
}
118+
119+
private boolean isForceSynchronized(String pluginKey) {
120+
if ("csharpenterprise".equals(pluginKey)) {
121+
return languageSupportRepository.getEnabledLanguagesInConnectedMode().contains(SonarLanguage.CS);
122+
}
123+
if ("goenterprise".equals(pluginKey)) {
124+
return languageSupportRepository.getEnabledLanguagesInConnectedMode().contains(SonarLanguage.GO);
125+
}
126+
return false;
127+
}
128+
129+
private static boolean isCompanionPlugin(String pluginKey) {
130+
return !SonarLanguage.containsPlugin(pluginKey);
131+
}
132+
133+
private Optional<java.util.List<ServerPlugin>> fetchServerPluginsSafely(String connectionId) {
134+
try {
135+
return serverPluginsCache.getPlugins(connectionId);
136+
} catch (ServerRequestException e) {
137+
LOG.debug(PLUGIN_FETCH_ERROR, connectionId);
138+
return Optional.empty();
139+
}
140+
}
141+
}

0 commit comments

Comments
 (0)