Major improvements: multi-stage Docker, security hardening, host metrics, tests

- Multi-stage Docker build: Dockerfile compiles frontend (Node.js) and
  backend (Gradle) inside Docker; no local Java/Node.js required
- Security hardening: replaced full host root mount with specific
  directory mounts (/proc, /sys, /etc, /var, /home, /opt, /tmp, /srv, /usr)
- Host metrics consistency: LinuxMemoryService and CpuDetailsService now
  read from /host/proc instead of container OSHI
- Concurrency: replaced synchronized with AtomicReference in 3 services
- Global error handling: added @RestControllerAdvice for JSON error responses
- Unit tests: added 32 tests for /proc parsers, CPU usage, process uptime
- Docker security: non-root user, read-only FS, no-new-privileges, healthcheck
- Debug logging: added log.debug() in silent catch blocks
- Documentation: updated README, INSTALLATION, DEVELOPMENT, CONFIGURATION,
  ARCHITECTURE, CHANGELOG

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-10-28 23:18:01 +01:00
co-authored by Junie
parent 539175654c
commit 597d8f380a
23 changed files with 1122 additions and 137 deletions
@@ -16,18 +16,19 @@ import java.nio.file.Path;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
@Service
public class SystemMetricsService {
private static final Path HOST_ROOT = Path.of("/host/root");
private static final Path HOST_PROC = HOST_ROOT.resolve("proc");
private static final Path HOST_PROC = Path.of("/host/proc");
private static final Path HOST_ETC = Path.of("/host/etc");
private final OperatingSystem _operatingSystem;
private final CentralProcessor _processor;
private final GlobalMemory _memory;
private CpuTicks _previousHostCpuTicks;
private final AtomicReference<CpuTicks> _previousHostCpuTicks = new AtomicReference<>();
private static final Logger log =
LoggerFactory.getLogger(SystemMetricsService.class);
@@ -40,16 +41,17 @@ public class SystemMetricsService {
_processor = hardware.getProcessor();
_memory = hardware.getMemory();
_previousHostCpuTicks = readHostCpuTicks();
_previousHostCpuTicks.set(readHostCpuTicks());
}
public synchronized SystemMetricsResponse getMetrics() {
public SystemMetricsResponse getMetrics() {
CpuTicks currentTicks = readHostCpuTicks();
double cpuUsage = 0.0;
if (currentTicks != null && _previousHostCpuTicks != null) {
long totalDelta = currentTicks.total() - _previousHostCpuTicks.total();
long idleDelta = currentTicks.idle() - _previousHostCpuTicks.idle();
CpuTicks previous = _previousHostCpuTicks.get();
if (currentTicks != null && previous != null) {
long totalDelta = currentTicks.total() - previous.total();
long idleDelta = currentTicks.idle() - previous.idle();
if (totalDelta > 0 && idleDelta >= 0 && idleDelta <= totalDelta) {
cpuUsage = (double) (totalDelta - idleDelta) / totalDelta;
@@ -57,7 +59,7 @@ public class SystemMetricsService {
}
if (currentTicks != null) {
_previousHostCpuTicks = currentTicks;
_previousHostCpuTicks.set(currentTicks);
}
Map<String, Long> hostMemory = readHostMemory();
@@ -165,8 +167,8 @@ public class SystemMetricsService {
values.put(key, Long.parseLong(parts[0]) * 1024L);
}
}
} catch (IOException | NumberFormatException ignored) {
// Use OSHI values when host meminfo is unavailable.
} catch (IOException | NumberFormatException e) {
log.debug("Failed to read host memory info: {}", e.getMessage());
}
return values;
@@ -189,8 +191,8 @@ public class SystemMetricsService {
return count;
}
}
} catch (IOException ignored) {
// Fall back to OSHI.
} catch (IOException e) {
log.debug("Failed to read host processor count: {}", e.getMessage());
}
return _processor.getLogicalProcessorCount();
@@ -198,7 +200,7 @@ public class SystemMetricsService {
private String readHostHostname() {
try {
Path file = HOST_ROOT.resolve("etc/hostname");
Path file = HOST_ETC.resolve("hostname");
if (Files.isRegularFile(file)) {
String hostname = Files.readString(file).trim();
@@ -207,8 +209,8 @@ public class SystemMetricsService {
return hostname;
}
}
} catch (IOException ignored) {
// Fall back to OSHI.
} catch (IOException e) {
log.debug("Failed to read host hostname: {}", e.getMessage());
}
return _operatingSystem.getNetworkParams().getHostName();
@@ -218,7 +220,7 @@ public class SystemMetricsService {
Map<String, String> values = new HashMap<>();
try {
Path file = HOST_ROOT.resolve("etc/os-release");
Path file = HOST_ETC.resolve("os-release");
if (!Files.isRegularFile(file)) {
return values;
@@ -242,8 +244,8 @@ public class SystemMetricsService {
values.put(key, value);
}
} catch (IOException ignored) {
// Fall back to OSHI.
} catch (IOException e) {
log.debug("Failed to read host os-release: {}", e.getMessage());
}
return values;