aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-server-common/src/main/java
diff options
context:
space:
mode:
authorLukasz Jarocki <lukasz.jarocki@sonarsource.com>2025-01-30 14:31:36 +0100
committersonartech <sonartech@sonarsource.com>2025-01-30 20:03:07 +0000
commitc93341579d9ca9459962bdc7aa2ff7a9bdd025c5 (patch)
treee593f4b9da0a678f1eee910727263169773cdeac /server/sonar-server-common/src/main/java
parent8db63c7e8c138ef4df6787f1f36fbed1701d698d (diff)
downloadsonarqube-c93341579d9ca9459962bdc7aa2ff7a9bdd025c5.tar.gz
sonarqube-c93341579d9ca9459962bdc7aa2ff7a9bdd025c5.zip
SONAR-23111 api/system/logs now correctly return logs for data center edition
Diffstat (limited to 'server/sonar-server-common/src/main/java')
-rw-r--r--server/sonar-server-common/src/main/java/org/sonar/server/log/DistributedServerLogging.java236
-rw-r--r--server/sonar-server-common/src/main/java/org/sonar/server/log/ServerLogging.java67
2 files changed, 300 insertions, 3 deletions
diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/log/DistributedServerLogging.java b/server/sonar-server-common/src/main/java/org/sonar/server/log/DistributedServerLogging.java
new file mode 100644
index 00000000000..22dd9886b42
--- /dev/null
+++ b/server/sonar-server-common/src/main/java/org/sonar/server/log/DistributedServerLogging.java
@@ -0,0 +1,236 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.log;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.hazelcast.cluster.Member;
+import jakarta.inject.Inject;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.Serializable;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.SecureRandom;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+import okio.Buffer;
+import okio.BufferedSource;
+import org.apache.commons.codec.binary.Hex;
+import org.apache.commons.io.FileUtils;
+import org.jetbrains.annotations.NotNull;
+import org.sonar.api.config.Configuration;
+import org.sonar.db.Database;
+import org.sonar.process.ProcessId;
+import org.sonar.process.cluster.hz.DistributedAnswer;
+import org.sonar.process.cluster.hz.DistributedCall;
+import org.sonar.process.cluster.hz.HazelcastMember;
+import org.sonar.process.cluster.hz.HazelcastMemberSelectors;
+import org.sonarqube.ws.client.OkHttpClientBuilder;
+
+import static org.sonar.process.cluster.hz.HazelcastMember.Attribute.PROCESS_KEY;
+import static org.sonar.process.cluster.hz.HazelcastObjects.AUTH_SECRET;
+import static org.sonar.process.cluster.hz.HazelcastObjects.SECRETS;
+
+public class DistributedServerLogging extends ServerLogging {
+
+ public static final String NODE_TO_NODE_SECRET = "node_to_node_secret";
+ private final HazelcastMember hazelcastMember;
+ private final OkHttpClient client;
+
+ @Inject
+ public DistributedServerLogging(Configuration config, ServerProcessLogging serverProcessLogging, Database database,
+ HazelcastMember hazelcastMember) {
+ super(config, serverProcessLogging, database);
+ this.hazelcastMember = hazelcastMember;
+ this.client = new OkHttpClientBuilder()
+ .setConnectTimeoutMs(30000)
+ .setReadTimeoutMs(30000)
+ .setFollowRedirects(false)
+ .build();
+ }
+
+ @VisibleForTesting
+ public DistributedServerLogging(Configuration config, ServerProcessLogging serverProcessLogging, Database database,
+ HazelcastMember hazelcastMember, OkHttpClient client) {
+ super(config, serverProcessLogging, database);
+ this.hazelcastMember = hazelcastMember;
+ this.client = client;
+ }
+
+ private WebAddress retrieveWebAddressOfAMember(Member member) {
+ try {
+ DistributedAnswer<WebAddress> answerWithAddress = hazelcastMember.call(askForWebAPIAddress(),
+ HazelcastMemberSelectors.selectorForMember(member), 5000L);
+ Optional<WebAddress> singleAnswer = answerWithAddress.getSingleAnswer();
+ if (singleAnswer.isEmpty()) {
+ throw new IllegalStateException("No web API address found for member with UUID " + member.getUuid());
+ }
+ return singleAnswer.get();
+ } catch (InterruptedException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ @Override
+ public boolean isValidNodeToNodeCall(Map<String, String> headers) {
+ String requestSecret = headers.get(NODE_TO_NODE_SECRET);
+ if (requestSecret == null || requestSecret.isEmpty()) {
+ return false;
+ }
+ Object savedSecret = tryGetSecretFromMap();
+ return Objects.equals(savedSecret, requestSecret);
+ }
+
+ private Object tryGetSecretFromMap() {
+ final int maxRetries = 5;
+ for (int i = 0; i < maxRetries; i++) {
+ Map<Object, Object> secrets = hazelcastMember.getReplicatedMap(SECRETS);
+ if (!secrets.containsKey(AUTH_SECRET)) {
+ sleep(2000);
+ continue;
+ }
+ return secrets.get(AUTH_SECRET);
+ }
+ return null;
+ }
+
+ private static void sleep(int miliseconds) {
+ try {
+ Thread.sleep(miliseconds);
+ } catch (InterruptedException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ private static String generateOneTimeToken() {
+ SecureRandom random = new SecureRandom();
+ byte[] randomBytes = new byte[20];
+ random.nextBytes(randomBytes);
+ return Hex.encodeHexString(randomBytes);
+ }
+
+ @Override
+ public File getDistributedLogs(String filePrefix, String logName) {
+ File zipFile = createZipFile();
+ if (filePrefix.equals(ProcessId.ELASTICSEARCH.getLogFilenamePrefix())) {
+ // Elasticsearch logs are not distributed, something to implement in the future
+ throw new IllegalArgumentException("Elasticsearch logs are not distributed");
+ }
+
+ String oneTimeToken = generateOneTimeToken();
+ setHazelcastAuthSecret(oneTimeToken);
+ try (ZipOutputStream out = new ZipOutputStream(new FileOutputStream(zipFile))) {
+ Set<Member> members = findMembersWithLogs();
+ WebAddress localWebAPIAddress = askForWebAPIAddress().call();
+
+ // add data from other nodes
+ for (Member member : members) {
+ processMember(filePrefix, logName, member, localWebAPIAddress, oneTimeToken, out);
+ }
+
+ // and add data from the current node
+ out.putNextEntry(createZipEntry(filePrefix, hazelcastMember.getUuid().toString()));
+ FileUtils.copyFile(getLogsForSingleNode(filePrefix), out);
+ } catch (Exception e) {
+ throw new IllegalStateException("Failed to collect logs from the nodes", e);
+ } finally {
+ resetHazelcastAuthSecret();
+ }
+ return zipFile;
+ }
+
+ private static File createZipFile() {
+ try {
+ Path tempDir = Files.createTempDirectory("logs");
+ return new File(tempDir.toFile(), "logs.zip");
+ } catch (IOException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ @NotNull
+ private Set<Member> findMembersWithLogs() {
+ return hazelcastMember.getCluster().getMembers().stream()
+ .filter(member -> member.getAttribute(PROCESS_KEY.getKey()).equals(ProcessId.WEB_SERVER.getKey()))
+ .collect(Collectors.toSet());
+ }
+
+ private void processMember(String filePrefix, String logName, Member member, WebAddress localWebAPIAddress,
+ String oneTimeToken, ZipOutputStream out) throws IOException {
+ WebAddress addressAndPort = retrieveWebAddressOfAMember(member);
+ String address = addressAndPort.address;
+ int port = addressAndPort.port;
+ if (address.equals(localWebAPIAddress.address) && port == localWebAPIAddress.port) {
+ return;
+ }
+
+ String url = String.format("http://%s:%d/api/system/logs?name=%s", address, port, logName);
+ Request request = new Request.Builder().url(url)
+ .addHeader(NODE_TO_NODE_SECRET, oneTimeToken)
+ .build();
+
+ try (Response response = client.newCall(request).execute()) {
+ out.putNextEntry(createZipEntry(filePrefix, member.getUuid().toString()));
+ addLogFromResponseToZip(response, out);
+ }
+ }
+
+ @VisibleForTesting
+ static void addLogFromResponseToZip(Response response, ZipOutputStream out) throws IOException {
+ BufferedSource source = response.body().source();
+ try (Buffer buffer = new Buffer()) {
+ while (!source.exhausted()) {
+ // we read up to 1MB at a time
+ long count = source.read(buffer, 1_048_576);
+ byte[] buf = new byte[(int) count];
+ buffer.read(buf, 0, (int) count);
+ out.write(buf);
+ }
+ }
+ }
+
+ private static ZipEntry createZipEntry(String filePrefix, String uuid) {
+ return new ZipEntry(filePrefix + "-" + uuid + ".log");
+ }
+
+ private void setHazelcastAuthSecret(String someSecret) {
+ hazelcastMember.getReplicatedMap(SECRETS).putIfAbsent(AUTH_SECRET, someSecret);
+ }
+
+ private void resetHazelcastAuthSecret() {
+ hazelcastMember.getReplicatedMap(SECRETS).remove(AUTH_SECRET);
+ }
+
+ private static DistributedCall<WebAddress> askForWebAPIAddress() {
+ return () -> new WebAddress(ServerLogging.getWebAPIAddressFromHazelcastQuery(), ServerLogging.getWebAPIPortFromHazelcastQuery());
+ }
+
+ public record WebAddress(String address, int port) implements Serializable {
+ }
+}
diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/log/ServerLogging.java b/server/sonar-server-common/src/main/java/org/sonar/server/log/ServerLogging.java
index ae07279b670..21efc145238 100644
--- a/server/sonar-server-common/src/main/java/org/sonar/server/log/ServerLogging.java
+++ b/server/sonar-server-common/src/main/java/org/sonar/server/log/ServerLogging.java
@@ -22,16 +22,26 @@ package org.sonar.server.log;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import com.google.common.annotations.VisibleForTesting;
-import java.io.File;
import jakarta.inject.Inject;
-import org.sonar.api.Startable;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Comparator;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Predicate;
+import java.util.stream.Stream;
import org.slf4j.LoggerFactory;
+import org.sonar.api.Startable;
import org.sonar.api.ce.ComputeEngineSide;
import org.sonar.api.config.Configuration;
import org.sonar.api.server.ServerSide;
import org.sonar.api.utils.log.LoggerLevel;
import org.sonar.api.utils.log.Loggers;
import org.sonar.db.Database;
+import org.sonar.process.ProcessProperties;
import org.sonar.process.logging.LogbackHelper;
import static org.sonar.api.utils.log.LoggerLevel.TRACE;
@@ -40,7 +50,6 @@ import static org.sonar.process.ProcessProperties.Property.PATH_LOGS;
@ServerSide
@ComputeEngineSide
public class ServerLogging implements Startable {
-
/** Used for Hazelcast's distributed queries in cluster mode */
private static ServerLogging instance;
private final LogbackHelper helper;
@@ -75,6 +84,17 @@ public class ServerLogging implements Startable {
instance.changeLevel(level);
}
+ public static int getWebAPIPortFromHazelcastQuery() {
+ Optional<String> port = instance.config.get(ProcessProperties.Property.WEB_PORT.getKey());
+ return port.map(Integer::parseInt).orElse(9000);
+ }
+
+ public static String getWebAPIAddressFromHazelcastQuery() {
+ return instance.config.get(ProcessProperties.Property.WEB_HOST.getKey())
+ .orElseGet(() -> instance.config.get(ProcessProperties.Property.CLUSTER_NODE_HOST.getKey())
+ .orElseThrow(() -> new IllegalStateException("No web host found in configuration")));
+ }
+
public void changeLevel(LoggerLevel level) {
Level logbackLevel = Level.toLevel(level.name());
database.enableSqlLogging(level == TRACE);
@@ -93,4 +113,45 @@ public class ServerLogging implements Startable {
return new File(config.get(PATH_LOGS.getKey()).get());
}
+ public Optional<Path> getLogFilePath(String filePrefix, File logsDir) throws IOException {
+ try (Stream<Path> stream = Files.list(Paths.get(logsDir.getPath()))) {
+ return stream
+ .filter(hasMatchingLogFiles(filePrefix))
+ .max(Comparator.comparing(Path::toString));
+ }
+ }
+
+ public Predicate<Path> hasMatchingLogFiles(String filePrefix) {
+ return p -> {
+ String stringPath = p.getFileName().toString();
+ return stringPath.startsWith(filePrefix) && stringPath.endsWith(".log");
+ };
+ }
+
+ public File getLogsForSingleNode(String filePrefix) throws IOException {
+ File logsDir = getLogsDir();
+ Optional<Path> path = getLogFilePath(filePrefix, logsDir);
+
+ if (path.isEmpty()) {
+ return null;
+ }
+
+ File file = new File(logsDir, path.get().getFileName().toString());
+
+ // filenames are defined in the enum LogProcess. Still to prevent any vulnerability,
+ // path is double-checked to prevent returning any file present on the file system.
+ if (file.exists() && file.getParentFile().equals(logsDir)) {
+ return file;
+ } else {
+ return null;
+ }
+ }
+
+ public File getDistributedLogs(String filePrefix, String logName) {
+ throw new UnsupportedOperationException("This method should not be called on a standalone instance of SonarQube");
+ }
+
+ public boolean isValidNodeToNodeCall(Map<String, String> headers) {
+ return false;
+ }
}