diff --git a/build.gradle b/build.gradle index 9a33213..9fc6e77 100644 --- a/build.gradle +++ b/build.gradle @@ -1,16 +1,9 @@ plugins { id("com.github.johnrengelman.shadow") version "8.1.1" apply(false) - id("io.micronaut.application") version "${micronautGradlePluginVersion}" apply(false) - id("io.micronaut.aot") version "${micronautGradlePluginVersion}" apply(false) - id("io.micronaut.docker") version "${micronautGradlePluginVersion}" apply(false) - id("com.google.protobuf") version "0.9.4" apply(false) id("maven-publish") id("com.diffplug.spotless") version "7.0.3" - id("com.autonomousapps.dependency-analysis") version "2.17.0" } - - repositories { mavenCentral() } @@ -19,7 +12,7 @@ allprojects { apply plugin: 'java' apply plugin: 'maven-publish' apply plugin: 'com.diffplug.spotless' - apply plugin: 'com.autonomousapps.dependency-analysis' + repositories { mavenCentral() maven { @@ -28,9 +21,12 @@ allprojects { } } + java { + sourceCompatibility = JavaVersion.toVersion("21") + targetCompatibility = JavaVersion.toVersion("21") + } + dependencies { - implementation 'ch.qos.logback:logback-classic:1.5.17' - implementation 'ch.qos.logback:logback-core:1.5.17' implementation 'org.slf4j:slf4j-api:2.0.17' testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2' @@ -38,6 +34,11 @@ allprojects { testImplementation 'org.junit.jupiter:junit-jupiter-params:5.8.2' testImplementation 'org.mockito:mockito-core:4.0.0' } + + test { + useJUnitPlatform() + } + publishing { publications { maven(MavenPublication) { @@ -56,17 +57,16 @@ allprojects { } } } + jar { manifest { attributes 'Implementation-Version': project.version } } - spotless { + spotless { format 'misc', { - // define the files to apply `misc` to target '*.gradle', '.gitattributes', '.gitignore' - trimTrailingWhitespace() leadingSpacesToTabs() endWithNewline() @@ -74,22 +74,7 @@ allprojects { java { googleJavaFormat() formatAnnotations() - licenseHeader('''/* - * (C) 2017-$YEAR TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */''') + targetExclude("build/**") } } } diff --git a/config/build.gradle b/config/build.gradle new file mode 100644 index 0000000..72e62ce --- /dev/null +++ b/config/build.gradle @@ -0,0 +1,11 @@ +plugins { + id("java-library") + id("maven-publish") +} + +dependencies { + api(project(':core')) + + // File watching — the only external dependency beyond core. + implementation("io.methvin:directory-watcher:0.18.0") +} diff --git a/config/src/main/java/com/tcn/exile/config/CertificateRotator.java b/config/src/main/java/com/tcn/exile/config/CertificateRotator.java new file mode 100644 index 0000000..d95185a --- /dev/null +++ b/config/src/main/java/com/tcn/exile/config/CertificateRotator.java @@ -0,0 +1,113 @@ +package com.tcn.exile.config; + +import com.tcn.exile.ExileConfig; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.HexFormat; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Checks certificate expiration and rotates via the gate config service. + * + *

Intended to be called periodically (e.g., every hour). If the certificate expires within the + * configured threshold (default 10 days), it calls the gate service to rotate and writes the new + * certificate to the config file. + */ +public final class CertificateRotator { + + private static final Logger log = LoggerFactory.getLogger(CertificateRotator.class); + private static final int DEFAULT_RENEWAL_DAYS = 10; + + private final ExileClientManager manager; + private final int renewalDays; + + public CertificateRotator(ExileClientManager manager) { + this(manager, DEFAULT_RENEWAL_DAYS); + } + + public CertificateRotator(ExileClientManager manager, int renewalDays) { + this.manager = manager; + this.renewalDays = renewalDays; + } + + /** + * Check and rotate if needed. Returns true if rotation was performed. + * + *

Call this periodically (e.g., hourly). + */ + public boolean checkAndRotate() { + var client = manager.client(); + if (client == null) return false; + + var config = client.config(); + Instant expiration = getCertExpiration(config); + if (expiration == null) { + log.warn("Could not determine certificate expiration date"); + return false; + } + + var now = Instant.now(); + if (expiration.isBefore(now)) { + log.error("Certificate has expired ({}). Manual renewal required.", expiration); + manager.stop(); + return false; + } + + if (expiration.isBefore(now.plus(renewalDays, ChronoUnit.DAYS))) { + log.info("Certificate expires at {}, attempting rotation", expiration); + try { + var hash = getCertFingerprint(config); + var newCert = client.config_().rotateCertificate(hash); + if (newCert != null && !newCert.isEmpty()) { + manager.configWatcher().writeConfig(newCert); + log.info("Certificate rotated successfully"); + return true; + } else { + log.warn("Certificate rotation returned empty response"); + } + } catch (Exception e) { + log.error("Certificate rotation failed: {}", e.getMessage()); + } + } else { + log.debug( + "Certificate valid until {}, no rotation needed ({} day threshold)", + expiration, + renewalDays); + } + return false; + } + + static Instant getCertExpiration(ExileConfig config) { + try { + var cf = CertificateFactory.getInstance("X.509"); + var cert = + (X509Certificate) + cf.generateCertificate( + new ByteArrayInputStream(config.publicCert().getBytes(StandardCharsets.UTF_8))); + return cert.getNotAfter().toInstant(); + } catch (Exception e) { + return null; + } + } + + static String getCertFingerprint(ExileConfig config) { + try { + var cf = CertificateFactory.getInstance("X.509"); + var cert = + (X509Certificate) + cf.generateCertificate( + new ByteArrayInputStream(config.publicCert().getBytes(StandardCharsets.UTF_8))); + var digest = MessageDigest.getInstance("SHA-256"); + var hash = digest.digest(cert.getEncoded()); + return HexFormat.of().withDelimiter(":").formatHex(hash); + } catch (Exception e) { + return ""; + } + } +} diff --git a/config/src/main/java/com/tcn/exile/config/ConfigFileWatcher.java b/config/src/main/java/com/tcn/exile/config/ConfigFileWatcher.java new file mode 100644 index 0000000..08ae746 --- /dev/null +++ b/config/src/main/java/com/tcn/exile/config/ConfigFileWatcher.java @@ -0,0 +1,168 @@ +package com.tcn.exile.config; + +import com.tcn.exile.ExileConfig; +import io.methvin.watcher.DirectoryWatcher; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Watches the config directory for changes to the exile config file and invokes a callback. + * + *

Replaces the 400+ line ConfigChangeWatcher that was copy-pasted across all integrations. + * + *

The watcher monitors the standard config file ({@code com.tcn.exiles.sati.config.cfg}) in the + * standard directories ({@code /workdir/config} and {@code workdir/config}). On create/modify, it + * parses the file and calls the configured {@link Listener}. On delete, it calls {@link + * Listener#onConfigRemoved()}. + */ +public final class ConfigFileWatcher implements AutoCloseable { + + private static final Logger log = LoggerFactory.getLogger(ConfigFileWatcher.class); + static final String CONFIG_FILE_NAME = "com.tcn.exiles.sati.config.cfg"; + private static final List DEFAULT_WATCH_DIRS = + List.of(Path.of("/workdir/config"), Path.of("workdir/config")); + + private final List watchDirs; + private final Listener listener; + private final AtomicBoolean started = new AtomicBoolean(false); + private volatile DirectoryWatcher watcher; + private volatile Path configDir; + + /** Callback interface for config change events. */ + public interface Listener { + /** Called when a new or updated config is detected. */ + void onConfigChanged(ExileConfig config); + + /** Called when the config file is deleted. */ + void onConfigRemoved(); + } + + private ConfigFileWatcher(List watchDirs, Listener listener) { + this.watchDirs = watchDirs; + this.listener = listener; + } + + public static ConfigFileWatcher create(Listener listener) { + return new ConfigFileWatcher(DEFAULT_WATCH_DIRS, listener); + } + + public static ConfigFileWatcher create(List watchDirs, Listener listener) { + return new ConfigFileWatcher(watchDirs, listener); + } + + /** + * Start watching. Reads any existing config file immediately, then watches for changes + * asynchronously. Call this once. + */ + public void start() throws IOException { + if (!started.compareAndSet(false, true)) { + throw new IllegalStateException("Already started"); + } + + // Find or create the config directory. + configDir = watchDirs.stream().filter(p -> p.toFile().exists()).findFirst().orElse(null); + if (configDir == null) { + var fallback = watchDirs.get(0); + if (fallback.toFile().mkdirs()) { + configDir = fallback; + log.info("Created config directory: {}", configDir); + } else { + log.warn("Could not find or create config directory from: {}", watchDirs); + } + } else { + log.info("Using config directory: {}", configDir); + } + + // Read existing config if present. + loadExistingConfig(); + + // Start watching. + var existingDirs = watchDirs.stream().filter(p -> p.toFile().exists()).toList(); + if (existingDirs.isEmpty()) { + log.warn("No config directories exist to watch"); + return; + } + + watcher = + DirectoryWatcher.builder() + .paths(existingDirs) + .fileHashing(false) + .listener( + event -> { + if (!event.path().getFileName().toString().equals(CONFIG_FILE_NAME)) return; + switch (event.eventType()) { + case CREATE, MODIFY -> handleConfigFileChange(event.path()); + case DELETE -> { + log.info("Config file deleted"); + listener.onConfigRemoved(); + } + default -> log.debug("Ignoring event: {}", event.eventType()); + } + }) + .build(); + watcher.watchAsync(); + log.info("Config file watcher started"); + } + + /** Returns the directory where the config file was found or created. */ + public Path configDir() { + return configDir; + } + + /** + * Write a config string (e.g., a rotated certificate) to the config file. This triggers the + * watcher to reload. + */ + public void writeConfig(String content) throws IOException { + if (configDir == null) { + throw new IllegalStateException("No config directory available"); + } + var file = configDir.resolve(CONFIG_FILE_NAME); + Files.writeString(file, content); + log.info("Wrote config file: {}", file); + } + + private void loadExistingConfig() { + if (configDir == null) return; + var file = configDir.resolve(CONFIG_FILE_NAME); + if (file.toFile().exists()) { + ConfigParser.parse(file) + .ifPresent( + config -> { + log.info("Loaded existing config for org={}", config.org()); + listener.onConfigChanged(config); + }); + } + } + + private void handleConfigFileChange(Path path) { + if (!path.toFile().canRead()) { + log.warn("Config file not readable: {}", path); + return; + } + ConfigParser.parse(path) + .ifPresentOrElse( + config -> { + log.info("Config changed for org={}", config.org()); + listener.onConfigChanged(config); + }, + () -> log.warn("Failed to parse config file: {}", path)); + } + + @Override + public void close() { + if (watcher != null) { + try { + watcher.close(); + log.info("Config file watcher closed"); + } catch (IOException e) { + log.warn("Error closing config file watcher", e); + } + } + } +} diff --git a/config/src/main/java/com/tcn/exile/config/ConfigParser.java b/config/src/main/java/com/tcn/exile/config/ConfigParser.java new file mode 100644 index 0000000..50a108f --- /dev/null +++ b/config/src/main/java/com/tcn/exile/config/ConfigParser.java @@ -0,0 +1,177 @@ +package com.tcn.exile.config; + +import com.tcn.exile.ExileConfig; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; +import java.util.Map; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Parses the Base64-encoded JSON config file used by all exile integrations. + * + *

The file format is a Base64-encoded JSON object with fields: {@code ca_certificate}, {@code + * certificate}, {@code private_key}, and {@code api_endpoint} (as {@code hostname:port}). + */ +public final class ConfigParser { + + private static final Logger log = LoggerFactory.getLogger(ConfigParser.class); + + private ConfigParser() {} + + /** Parse a config file at the given path. Returns empty if the file can't be parsed. */ + public static Optional parse(Path path) { + try { + var bytes = Files.readAllBytes(path); + return parse(bytes); + } catch (Exception e) { + log.warn("Failed to read config file {}: {}", path, e.getMessage()); + return Optional.empty(); + } + } + + /** Parse config from raw bytes (Base64-encoded JSON). */ + public static Optional parse(byte[] raw) { + try { + // Trim trailing whitespace/newline that some editors add. + var trimmed = new String(raw, StandardCharsets.UTF_8).trim().getBytes(StandardCharsets.UTF_8); + + // Try Base64 decode; if it fails, try as-is (already decoded). + byte[] json; + try { + json = Base64.getDecoder().decode(trimmed); + } catch (IllegalArgumentException e) { + json = trimmed; + } + + var map = parseJson(json); + if (map == null) return Optional.empty(); + + var rootCert = (String) map.get("ca_certificate"); + var publicCert = (String) map.get("certificate"); + var privateKey = (String) map.get("private_key"); + var endpoint = (String) map.get("api_endpoint"); + + if (rootCert == null || publicCert == null || privateKey == null) { + log.warn("Config missing required certificate fields"); + return Optional.empty(); + } + + var builder = + ExileConfig.builder().rootCert(rootCert).publicCert(publicCert).privateKey(privateKey); + + var certName = (String) map.get("certificate_name"); + if (certName != null) builder.certificateName(certName); + + if (endpoint != null && !endpoint.isEmpty()) { + // Handle both "host:port" and URL formats like "https://host" or "https://host:port". + String host = endpoint; + int port = 443; + if (host.contains("://")) { + // Strip scheme (https://, http://). + host = host.substring(host.indexOf("://") + 3); + } + // Strip trailing slashes. + if (host.endsWith("/")) { + host = host.substring(0, host.length() - 1); + } + // Split host:port. + int colonIdx = host.lastIndexOf(':'); + if (colonIdx > 0) { + try { + port = Integer.parseInt(host.substring(colonIdx + 1)); + host = host.substring(0, colonIdx); + } catch (NumberFormatException e) { + // No valid port — use host as-is with default port. + } + } + builder.apiHostname(host); + builder.apiPort(port); + } + + return Optional.of(builder.build()); + } catch (Exception e) { + log.warn("Failed to parse config: {}", e.getMessage()); + return Optional.empty(); + } + } + + /** + * Minimal JSON object parser for the config file. Returns a flat Map of string keys to string + * values. Avoids pulling in Jackson/Gson as a dependency for this single use case. + */ + @SuppressWarnings("unchecked") + private static Map parseJson(byte[] json) { + // Use the built-in scripting approach: parse as key-value pairs. + // The config JSON is simple: {"key": "value", ...} with string values only. + try { + var str = new String(json, StandardCharsets.UTF_8).trim(); + if (!str.startsWith("{") || !str.endsWith("}")) return null; + str = str.substring(1, str.length() - 1); + + var result = new java.util.LinkedHashMap(); + int i = 0; + while (i < str.length()) { + // Skip whitespace and commas. + while (i < str.length() + && (str.charAt(i) == ' ' + || str.charAt(i) == ',' + || str.charAt(i) == '\n' + || str.charAt(i) == '\r' + || str.charAt(i) == '\t')) i++; + if (i >= str.length()) break; + + // Parse key. + if (str.charAt(i) != '"') break; + int keyStart = ++i; + while (i < str.length() && str.charAt(i) != '"') { + if (str.charAt(i) == '\\') i++; // skip escaped char + i++; + } + var key = str.substring(keyStart, i); + i++; // closing quote + + // Skip colon. + while (i < str.length() && (str.charAt(i) == ' ' || str.charAt(i) == ':')) i++; + + // Parse value. + if (i >= str.length()) break; + if (str.charAt(i) == '"') { + int valStart = ++i; + var sb = new StringBuilder(); + while (i < str.length() && str.charAt(i) != '"') { + if (str.charAt(i) == '\\' && i + 1 < str.length()) { + i++; + sb.append( + switch (str.charAt(i)) { + case 'n' -> '\n'; + case 'r' -> '\r'; + case 't' -> '\t'; + case '\\' -> '\\'; + case '"' -> '"'; + default -> str.charAt(i); + }); + } else { + sb.append(str.charAt(i)); + } + i++; + } + result.put(key, sb.toString()); + i++; // closing quote + } else { + // Non-string value (number, boolean, null) — read until comma/brace. + int valStart = i; + while (i < str.length() && str.charAt(i) != ',' && str.charAt(i) != '}') i++; + result.put(key, str.substring(valStart, i).trim()); + } + } + return result; + } catch (Exception e) { + log.warn("JSON parse error: {}", e.getMessage()); + return null; + } + } +} diff --git a/config/src/main/java/com/tcn/exile/config/ExileClientManager.java b/config/src/main/java/com/tcn/exile/config/ExileClientManager.java new file mode 100644 index 0000000..177ade2 --- /dev/null +++ b/config/src/main/java/com/tcn/exile/config/ExileClientManager.java @@ -0,0 +1,230 @@ +package com.tcn.exile.config; + +import com.tcn.exile.ExileClient; +import com.tcn.exile.ExileConfig; +import com.tcn.exile.StreamStatus; +import com.tcn.exile.handler.Plugin; +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Manages the lifecycle of a single-tenant {@link ExileClient} driven by a config file. + * + *

Usage: + * + *

{@code
+ * var manager = ExileClientManager.builder()
+ *     .clientName("sati-finvi")
+ *     .clientVersion("3.0.0")
+ *     .maxConcurrency(5)
+ *     .plugin(new FinviPlugin(dataSource))
+ *     .build();
+ *
+ * manager.start();
+ * }
+ */ +public final class ExileClientManager implements AutoCloseable { + + private static final Logger log = LoggerFactory.getLogger(ExileClientManager.class); + + private final String clientName; + private final String clientVersion; + private final int maxConcurrency; + private final Plugin plugin; + private final List watchDirs; + private final int certRotationHours; + + private volatile ExileClient activeClient; + private volatile ExileConfig activeConfig; + private volatile String activeOrg; + private ConfigFileWatcher watcher; + private ScheduledExecutorService scheduler; + + private ExileClientManager(Builder builder) { + this.clientName = builder.clientName; + this.clientVersion = builder.clientVersion; + this.maxConcurrency = builder.maxConcurrency; + this.plugin = builder.plugin; + this.watchDirs = builder.watchDirs; + this.certRotationHours = builder.certRotationHours; + } + + /** Start watching the config file and managing the client lifecycle. */ + public void start() throws IOException { + watcher = + watchDirs != null + ? ConfigFileWatcher.create(watchDirs, new WatcherListener()) + : ConfigFileWatcher.create(new WatcherListener()); + watcher.start(); + + scheduler = + Executors.newSingleThreadScheduledExecutor( + r -> { + var t = new Thread(r, "exile-cert-rotator"); + t.setDaemon(true); + return t; + }); + var rotator = new CertificateRotator(this); + scheduler.scheduleAtFixedRate( + () -> { + try { + rotator.checkAndRotate(); + } catch (Exception e) { + log.warn("Certificate rotation check failed: {}", e.getMessage()); + } + }, + certRotationHours, + certRotationHours, + TimeUnit.HOURS); + + log.info( + "ExileClientManager started (clientName={}, plugin={})", clientName, plugin.pluginName()); + } + + /** Returns the currently active client, or null if no config is loaded. */ + public ExileClient client() { + return activeClient; + } + + /** Returns a snapshot of the work stream status, or null if no client is active. */ + public StreamStatus streamStatus() { + var c = activeClient; + return c != null ? c.streamStatus() : null; + } + + ConfigFileWatcher configWatcher() { + return watcher; + } + + public void stop() { + log.info("Stopping ExileClientManager"); + destroyClient(); + if (watcher != null) watcher.close(); + if (scheduler != null) scheduler.shutdownNow(); + } + + @Override + public void close() { + stop(); + } + + private void createClient(ExileConfig config) { + log.info( + "Creating ExileClient (endpoint={}:{}, org={}, plugin={})", + config.apiHostname(), + config.apiPort(), + config.org(), + plugin.pluginName()); + var newOrg = config.org(); + if (activeOrg != null && !activeOrg.equals(newOrg)) { + log.info("Org changed from {} to {}, destroying old client", activeOrg, newOrg); + destroyClient(); + } + + destroyClient(); + + try { + activeClient = + ExileClient.builder() + .config(config) + .clientName(clientName) + .clientVersion(clientVersion) + .maxConcurrency(maxConcurrency) + .plugin(plugin) + .build(); + activeClient.start(); + activeConfig = config; + activeOrg = newOrg; + log.info("ExileClient started for org={}", newOrg); + } catch (Exception e) { + log.error("Failed to create ExileClient for org={}: {}", newOrg, e.getMessage(), e); + destroyClient(); + } + } + + private void destroyClient() { + var c = activeClient; + if (c != null) { + try { + c.close(); + log.info("ExileClient closed for org={}", activeOrg); + } catch (Exception e) { + log.warn("Error closing ExileClient: {}", e.getMessage()); + } + activeClient = null; + activeConfig = null; + } + } + + private class WatcherListener implements ConfigFileWatcher.Listener { + @Override + public void onConfigChanged(ExileConfig config) { + createClient(config); + } + + @Override + public void onConfigRemoved() { + log.info("Config removed, destroying client"); + destroyClient(); + activeOrg = null; + } + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private String clientName = "sati"; + private String clientVersion = "unknown"; + private int maxConcurrency = 100; + private Plugin plugin; + private List watchDirs; + private int certRotationHours = 1; + + private Builder() {} + + public Builder clientName(String clientName) { + this.clientName = Objects.requireNonNull(clientName); + return this; + } + + public Builder clientVersion(String clientVersion) { + this.clientVersion = Objects.requireNonNull(clientVersion); + return this; + } + + public Builder maxConcurrency(int maxConcurrency) { + this.maxConcurrency = maxConcurrency; + return this; + } + + /** The plugin that handles jobs, events, and config validation. */ + public Builder plugin(Plugin plugin) { + this.plugin = Objects.requireNonNull(plugin); + return this; + } + + public Builder watchDirs(List watchDirs) { + this.watchDirs = watchDirs; + return this; + } + + public Builder certRotationHours(int hours) { + this.certRotationHours = hours; + return this; + } + + public ExileClientManager build() { + Objects.requireNonNull(plugin, "plugin is required"); + return new ExileClientManager(this); + } + } +} diff --git a/config/src/main/java/com/tcn/exile/config/MultiTenantManager.java b/config/src/main/java/com/tcn/exile/config/MultiTenantManager.java new file mode 100644 index 0000000..08962ab --- /dev/null +++ b/config/src/main/java/com/tcn/exile/config/MultiTenantManager.java @@ -0,0 +1,213 @@ +package com.tcn.exile.config; + +import com.tcn.exile.ExileClient; +import com.tcn.exile.ExileConfig; +import com.tcn.exile.StreamStatus; +import java.util.Collections; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.function.Supplier; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Manages multiple {@link ExileClient} instances for multi-tenant deployments. + * + *

Polls a tenant provider for the current set of tenants and their configs. Creates clients for + * new tenants, destroys clients for removed tenants, and updates clients when config changes. + * + *

Usage: + * + *

{@code
+ * var manager = MultiTenantManager.builder()
+ *     .tenantProvider(() -> velosidyApi.listTenants())
+ *     .clientFactory(tenantConfig -> ExileClient.builder()
+ *         .config(tenantConfig)
+ *         .jobHandler(new VelosidyJobHandler(tenantConfig))
+ *         .eventHandler(new VelosidyEventHandler())
+ *         .build())
+ *     .pollInterval(Duration.ofSeconds(30))
+ *     .build();
+ *
+ * manager.start();
+ *
+ * // Access a specific tenant's client.
+ * manager.client("tenant-org-123").agents().listAgents(...);
+ *
+ * // List all active tenants.
+ * manager.tenantIds();
+ *
+ * manager.stop();
+ * }
+ */ +public final class MultiTenantManager implements AutoCloseable { + + private static final Logger log = LoggerFactory.getLogger(MultiTenantManager.class); + + private final Supplier> tenantProvider; + private final Function clientFactory; + private final long pollIntervalSeconds; + + private final ConcurrentHashMap clients = new ConcurrentHashMap<>(); + private final ConcurrentHashMap configs = new ConcurrentHashMap<>(); + private volatile ScheduledExecutorService scheduler; + + private MultiTenantManager(Builder builder) { + this.tenantProvider = builder.tenantProvider; + this.clientFactory = builder.clientFactory; + this.pollIntervalSeconds = builder.pollIntervalSeconds; + } + + /** Start polling for tenants. */ + public void start() { + scheduler = + Executors.newSingleThreadScheduledExecutor( + r -> { + var t = new Thread(r, "exile-tenant-poller"); + t.setDaemon(true); + return t; + }); + scheduler.scheduleAtFixedRate(this::reconcile, 0, pollIntervalSeconds, TimeUnit.SECONDS); + log.info("MultiTenantManager started (poll={}s)", pollIntervalSeconds); + } + + /** Get the client for a specific tenant. Returns null if tenant not active. */ + public ExileClient client(String tenantId) { + return clients.get(tenantId); + } + + /** Returns all active tenant IDs. */ + public Set tenantIds() { + return Collections.unmodifiableSet(clients.keySet()); + } + + /** Returns stream status for all tenants. */ + public Map allStatuses() { + var result = new ConcurrentHashMap(); + clients.forEach((id, client) -> result.put(id, client.streamStatus())); + return result; + } + + /** Stop all tenants and the polling loop. */ + public void stop() { + log.info("Stopping MultiTenantManager ({} tenants)", clients.size()); + if (scheduler != null) scheduler.shutdownNow(); + clients.keySet().forEach(this::destroyTenant); + } + + @Override + public void close() { + stop(); + } + + private void reconcile() { + try { + var desired = tenantProvider.get(); + if (desired == null) { + log.warn("Tenant provider returned null"); + return; + } + + // Remove tenants no longer in the desired set. + for (var existingId : clients.keySet()) { + if (!desired.containsKey(existingId)) { + log.info("Tenant {} removed, destroying client", existingId); + destroyTenant(existingId); + } + } + + // Create or update tenants. + for (var entry : desired.entrySet()) { + var tenantId = entry.getKey(); + var newConfig = entry.getValue(); + var existingConfig = configs.get(tenantId); + + if (existingConfig == null) { + // New tenant. + createTenant(tenantId, newConfig); + } else if (!existingConfig.org().equals(newConfig.org())) { + // Config changed (different org/certs). + log.info("Tenant {} config changed, recreating client", tenantId); + destroyTenant(tenantId); + createTenant(tenantId, newConfig); + } + } + } catch (Exception e) { + log.warn("Tenant reconciliation failed: {}", e.getMessage()); + } + } + + private void createTenant(String tenantId, ExileConfig config) { + try { + var client = clientFactory.apply(config); + client.start(); + clients.put(tenantId, client); + configs.put(tenantId, config); + log.info("Created client for tenant {} (org={})", tenantId, config.org()); + } catch (Exception e) { + log.error("Failed to create client for tenant {}: {}", tenantId, e.getMessage()); + } + } + + private void destroyTenant(String tenantId) { + var client = clients.remove(tenantId); + configs.remove(tenantId); + if (client != null) { + try { + client.close(); + log.info("Destroyed client for tenant {}", tenantId); + } catch (Exception e) { + log.warn("Error destroying client for tenant {}: {}", tenantId, e.getMessage()); + } + } + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private Supplier> tenantProvider; + private Function clientFactory; + private long pollIntervalSeconds = 30; + + private Builder() {} + + /** + * Required. Provides the current set of tenants and their configs. Called on each poll cycle. + * The map key is the tenant ID, value is the ExileConfig for that tenant. + */ + public Builder tenantProvider(Supplier> tenantProvider) { + this.tenantProvider = Objects.requireNonNull(tenantProvider); + return this; + } + + /** + * Required. Factory that creates an ExileClient for a given config. The factory should call + * {@code ExileClient.builder()...build()} but NOT call {@code start()} — the manager handles + * that. + */ + public Builder clientFactory(Function clientFactory) { + this.clientFactory = Objects.requireNonNull(clientFactory); + return this; + } + + /** How often to poll the tenant provider (seconds). Default: 30. */ + public Builder pollIntervalSeconds(long seconds) { + this.pollIntervalSeconds = seconds; + return this; + } + + public MultiTenantManager build() { + Objects.requireNonNull(tenantProvider, "tenantProvider required"); + Objects.requireNonNull(clientFactory, "clientFactory required"); + return new MultiTenantManager(this); + } + } +} diff --git a/config/src/test/java/com/tcn/exile/config/ConfigParserTest.java b/config/src/test/java/com/tcn/exile/config/ConfigParserTest.java new file mode 100644 index 0000000..0fa367f --- /dev/null +++ b/config/src/test/java/com/tcn/exile/config/ConfigParserTest.java @@ -0,0 +1,120 @@ +package com.tcn.exile.config; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import org.junit.jupiter.api.Test; + +class ConfigParserTest { + + private static final String VALID_JSON = + """ + { + "ca_certificate": "-----BEGIN CERTIFICATE-----\\nROOT\\n-----END CERTIFICATE-----", + "certificate": "-----BEGIN CERTIFICATE-----\\nPUBLIC\\n-----END CERTIFICATE-----", + "private_key": "-----BEGIN PRIVATE KEY-----\\nKEY\\n-----END PRIVATE KEY-----", + "api_endpoint": "gate.example.com:8443" + } + """; + + @Test + void parsesBase64EncodedJson() { + var base64 = Base64.getEncoder().encode(VALID_JSON.getBytes(StandardCharsets.UTF_8)); + var result = ConfigParser.parse(base64); + assertTrue(result.isPresent()); + var config = result.get(); + assertTrue(config.rootCert().contains("ROOT")); + assertTrue(config.publicCert().contains("PUBLIC")); + assertTrue(config.privateKey().contains("KEY")); + assertEquals("gate.example.com", config.apiHostname()); + assertEquals(8443, config.apiPort()); + } + + @Test + void parsesRawJson() { + var raw = VALID_JSON.getBytes(StandardCharsets.UTF_8); + var result = ConfigParser.parse(raw); + assertTrue(result.isPresent()); + assertEquals("gate.example.com", result.get().apiHostname()); + } + + @Test + void parsesJsonWithoutPort() { + var json = + """ + { + "ca_certificate": "root", + "certificate": "pub", + "private_key": "key", + "api_endpoint": "gate.example.com" + } + """; + var result = ConfigParser.parse(json.getBytes(StandardCharsets.UTF_8)); + assertTrue(result.isPresent()); + assertEquals("gate.example.com", result.get().apiHostname()); + assertEquals(443, result.get().apiPort()); + } + + @Test + void parsesJsonWithoutEndpoint() { + var json = + """ + { + "ca_certificate": "root", + "certificate": "pub", + "private_key": "key" + } + """; + var result = ConfigParser.parse(json.getBytes(StandardCharsets.UTF_8)); + // Should fail because apiHostname is required in ExileConfig + // but the JSON itself is valid + assertFalse(result.isPresent()); + } + + @Test + void rejectsMissingCertFields() { + var json = + """ + {"api_endpoint": "gate.example.com:443"} + """; + var result = ConfigParser.parse(json.getBytes(StandardCharsets.UTF_8)); + assertFalse(result.isPresent()); + } + + @Test + void rejectsGarbage() { + var result = ConfigParser.parse("not-json-or-base64".getBytes(StandardCharsets.UTF_8)); + assertFalse(result.isPresent()); + } + + @Test + void rejectsEmptyInput() { + var result = ConfigParser.parse(new byte[0]); + assertFalse(result.isPresent()); + } + + @Test + void handlesBase64WithTrailingNewline() { + var base64 = + Base64.getEncoder().encodeToString(VALID_JSON.getBytes(StandardCharsets.UTF_8)) + "\n"; + var result = ConfigParser.parse(base64.getBytes(StandardCharsets.UTF_8)); + assertTrue(result.isPresent()); + } + + @Test + void handlesEscapedNewlinesInCerts() { + var json = + """ + { + "ca_certificate": "line1\\nline2\\nline3", + "certificate": "pub", + "private_key": "key", + "api_endpoint": "host:443" + } + """; + var result = ConfigParser.parse(json.getBytes(StandardCharsets.UTF_8)); + assertTrue(result.isPresent()); + assertTrue(result.get().rootCert().contains("\n")); + } +} diff --git a/core/build.gradle b/core/build.gradle index 66800ae..62eaaa1 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -1,91 +1,46 @@ plugins { + id("java-library") id("com.github.johnrengelman.shadow") - id("io.micronaut.library") - id("io.micronaut.aot") id("maven-publish") id("jacoco") } -repositories { - mavenLocal() - mavenCentral() -} - -dependencies { - annotationProcessor("io.micronaut:micronaut-http-validation") - annotationProcessor("io.micronaut.serde:micronaut-serde-processor") - annotationProcessor("io.micronaut.validation:micronaut-validation-processor") - runtimeOnly("io.micronaut:micronaut-http-client") - runtimeOnly("io.micronaut:micronaut-http-server-netty") - runtimeOnly("io.micronaut.validation:micronaut-validation") - - // Reactor for reactive streams - implementation("io.projectreactor:reactor-core:3.5.12") - api("jakarta.annotation:jakarta.annotation-api") - api("jakarta.validation:jakarta.validation-api") - - runtimeOnly("ch.qos.logback:logback-classic") - - // uses exileapi v1.0.1 - NOTE: you must use api to propagate the proto files to the other projects +dependencies { + // exileapi v3 pre-built stubs from buf.build BSR api("build.buf.gen:tcn_exileapi_grpc_java:${exileapiGrpcVersion}") + api("build.buf.gen:tcn_exileapi_protocolbuffers_java:${exileapiProtobufVersion}") - implementation("com.zaxxer:HikariCP:6.3.0") - - // gRPC client dependencies only (no server) + // gRPC — client only + api("io.grpc:grpc-api:${grpcVersion}") api("io.grpc:grpc-protobuf:${grpcVersion}") - api("io.grpc:grpc-services:${grpcVersion}") { - exclude group: "io.grpc", module: "grpc-health-service" - } api("io.grpc:grpc-stub:${grpcVersion}") - compileOnly("org.apache.tomcat:annotations-api:6.0.53") - runtimeOnly("io.grpc:grpc-netty-shaded:${grpcVersion}") - api("com.google.protobuf:protobuf-java-util:${protobufVersion}") + implementation("io.grpc:grpc-netty-shaded:${grpcVersion}") - // Add SnakeYAML for YAML configuration support - runtimeOnly("org.yaml:snakeyaml") + // protobuf runtime + api("com.google.protobuf:protobuf-java:${protobufVersion}") - testRuntimeOnly("io.micronaut:micronaut-http-client") + // OpenTelemetry SDK — metrics, tracing, and custom exporter + implementation("io.opentelemetry:opentelemetry-api:1.46.0") + implementation("io.opentelemetry:opentelemetry-sdk:1.46.0") + implementation("io.opentelemetry:opentelemetry-sdk-metrics:1.46.0") + implementation("io.opentelemetry:opentelemetry-sdk-trace:1.46.0") - api('io.micronaut.serde:micronaut-serde-api:2.12.1') - implementation('com.google.protobuf:protobuf-java:4.28.3') - api('io.micronaut:micronaut-context:4.7.14') - api('io.micronaut:micronaut-core:4.7.14') - api('com.fasterxml.jackson.core:jackson-annotations:2.17.2') - api("build.buf.gen:tcn_exileapi_protocolbuffers_java:${exileapiProtobufVersion}") - api("io.grpc:grpc-api:${grpcVersion}") -} + // PKCS#1 → PKCS#8 key conversion for mTLS + implementation("org.bouncycastle:bcpkix-jdk18on:1.79") -java { - sourceCompatibility = JavaVersion.toVersion("21") - targetCompatibility = JavaVersion.toVersion("21") -} + // In-memory log buffer for listTenantLogs/setLogLevel + implementation(project(':logback-ext')) -graalvmNative.toolchainDetection = false + // javax.annotation for @Generated in gRPC stubs + compileOnly("org.apache.tomcat:annotations-api:6.0.53") -micronaut { - runtime("netty") - testRuntime("junit5") - processing { - incremental(true) - annotations("com.tcn.exile.*") - } - aot { - // Please review carefully the optimizations enabled below - // Check https://micronaut-projects.github.io/micronaut-aot/latest/guide/ for more details - optimizeServiceLoading = false - convertYamlToJava = false - precomputeOperations = true - cacheEnvironment = true - optimizeClassLoading = true - deduceEnvironment = true - optimizeNetty = true - replaceLogbackXml = true - } + // test + testImplementation("io.grpc:grpc-testing:${grpcVersion}") + testImplementation("io.grpc:grpc-inprocess:${grpcVersion}") } -// Configure JaCoCo for test coverage jacoco { toolVersion = "0.8.11" } @@ -95,23 +50,10 @@ jacocoTestReport { xml.required = true html.required = true } - - afterEvaluate { - classDirectories.setFrom(files(classDirectories.files.collect { - fileTree(dir: it, exclude: [ - '**/*$Lambda$*/**', - '**/*$$*/**', - '**/*_Micronaut*/**', - '**/micronaut/**' - ]) - })) - } + dependsOn test } test { + exclude '**/LiveBenchmark*' finalizedBy jacocoTestReport } - -jacocoTestReport { - dependsOn test -} diff --git a/core/src/main/java/com/tcn/exile/ExileClient.java b/core/src/main/java/com/tcn/exile/ExileClient.java new file mode 100644 index 0000000..bfa0b60 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/ExileClient.java @@ -0,0 +1,309 @@ +package com.tcn.exile; + +import com.tcn.exile.handler.Plugin; +import com.tcn.exile.internal.ChannelFactory; +import com.tcn.exile.internal.GrpcLogShipper; +import com.tcn.exile.internal.MetricsManager; +import com.tcn.exile.internal.WorkStreamClient; +import com.tcn.exile.memlogger.MemoryAppender; +import com.tcn.exile.memlogger.MemoryAppenderInstance; +import com.tcn.exile.service.*; +import io.grpc.ManagedChannel; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.api.trace.Span; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Main entry point for the Exile client library. + * + *

On {@link #start()}, only the config poller begins. The WorkStream does not open until: + * + *

    + *
  1. The first successful config poll from the gate + *
  2. The {@link Plugin#onConfig} returns {@code true} + *
+ */ +public final class ExileClient implements AutoCloseable { + + private static final Logger log = LoggerFactory.getLogger(ExileClient.class); + + private final ExileConfig config; + private final Plugin plugin; + private final WorkStreamClient workStream; + private final ManagedChannel serviceChannel; + private final ScheduledExecutorService configPoller; + private final Duration configPollInterval; + + private final AgentService agentService; + private final CallService callService; + private final RecordingService recordingService; + private final ScrubListService scrubListService; + private final ConfigService configService; + private final JourneyService journeyService; + private final TelemetryService telemetryService; + private final String telemetryClientId; + private volatile MetricsManager metricsManager; + + private volatile ConfigService.ClientConfiguration lastConfig; + private final AtomicBoolean pluginReady = new AtomicBoolean(false); + private final AtomicBoolean workStreamStarted = new AtomicBoolean(false); + + private ExileClient(Builder builder) { + this.config = builder.config; + this.plugin = builder.plugin; + this.configPollInterval = builder.configPollInterval; + + this.workStream = + new WorkStreamClient( + config, + plugin, + plugin, + builder.clientName, + builder.clientVersion, + builder.maxConcurrency, + builder.capabilities); + + this.serviceChannel = ChannelFactory.create(config); + var services = ServiceFactory.create(serviceChannel); + this.agentService = services.agent(); + this.callService = services.call(); + this.recordingService = services.recording(); + this.scrubListService = services.scrubList(); + this.configService = services.config(); + this.journeyService = services.journey(); + this.telemetryService = services.telemetry(); + + // Telemetry client ID (stable across reconnects). + this.telemetryClientId = + builder.clientName + "-" + java.util.UUID.randomUUID().toString().substring(0, 8); + + // Wire OTel trace context into log events so each LogRecord carries trace_id/span_id. + MemoryAppender.setTraceContextExtractor( + new MemoryAppender.TraceContextExtractor() { + @Override + public String traceId() { + var ctx = Span.current().getSpanContext(); + return ctx.isValid() ? ctx.getTraceId() : null; + } + + @Override + public String spanId() { + var ctx = Span.current().getSpanContext(); + return ctx.isValid() ? ctx.getSpanId() : null; + } + }); + + // Structured log shipping starts immediately (doesn't need org_id). + var appender = MemoryAppenderInstance.getInstance(); + if (appender != null) { + appender.enableLogShipper(new GrpcLogShipper(telemetryService, telemetryClientId)); + } + + // MetricsManager is created after first config poll (needs org_id + configName). + + this.configPoller = + Executors.newSingleThreadScheduledExecutor( + r -> { + var t = new Thread(r, "exile-config-poller"); + t.setDaemon(true); + return t; + }); + } + + /** + * Start the client. Only the config poller begins immediately. The WorkStream opens after the + * plugin accepts the first config via {@link Plugin#onConfig}. + */ + public void start() { + log.info( + "Starting ExileClient for org={} (plugin={}, waiting for config)", + config.org(), + plugin.pluginName()); + + configPoller.scheduleAtFixedRate( + this::pollConfig, 0, configPollInterval.toSeconds(), TimeUnit.SECONDS); + } + + private void pollConfig() { + try { + var newConfig = configService.getClientConfiguration(); + if (newConfig == null) return; + + boolean changed = lastConfig == null || !newConfig.equals(lastConfig); + lastConfig = newConfig; + + if (changed) { + log.info( + "Config received from gate (org={}, configName={})", + newConfig.orgId(), + newConfig.configName()); + + boolean ready; + try { + ready = plugin.onConfig(newConfig); + } catch (Exception e) { + log.warn("Plugin {} rejected config: {}", plugin.pluginName(), e.getMessage()); + pluginReady.set(false); + return; + } + + if (!ready) { + log.warn("Plugin {} not ready — WorkStream will not start yet", plugin.pluginName()); + pluginReady.set(false); + return; + } + + pluginReady.set(true); + } + + // Only start WorkStream + metrics once plugin has explicitly accepted a config. + if (pluginReady.get() && workStreamStarted.compareAndSet(false, true)) { + // Initialize MetricsManager now that we have org_id and certificate_name. + this.metricsManager = + new MetricsManager( + telemetryService, + telemetryClientId, + newConfig.orgId(), + config.certificateName(), + workStream::status); + workStream.setDurationRecorder(metricsManager::recordWorkDuration); + workStream.setMethodRecorder(metricsManager::recordMethodCall); + workStream.setReconnectRecorder(metricsManager::recordReconnectDuration); + + log.info("Plugin {} ready, starting WorkStream", plugin.pluginName()); + workStream.start(); + } + } catch (Exception e) { + log.debug("Config poll failed ({}): {}", e.getClass().getSimpleName(), e.getMessage()); + } + } + + /** Returns the last polled config from the gate, or null if never polled. */ + public ConfigService.ClientConfiguration lastPolledConfig() { + return lastConfig; + } + + public StreamStatus streamStatus() { + return workStream.status(); + } + + public ExileConfig config() { + return config; + } + + public Plugin plugin() { + return plugin; + } + + public AgentService agents() { + return agentService; + } + + public CallService calls() { + return callService; + } + + public RecordingService recordings() { + return recordingService; + } + + public ScrubListService scrubLists() { + return scrubListService; + } + + public ConfigService config_() { + return configService; + } + + public JourneyService journey() { + return journeyService; + } + + /** + * The OTel Meter for registering custom metrics from plugins. Instruments created on this meter + * are exported alongside sati's built-in metrics to the gate TelemetryService. Returns null if + * the first config poll has not completed yet. + */ + public Meter meter() { + var mm = metricsManager; + return mm != null ? mm.meter() : null; + } + + @Override + public void close() { + log.info("Shutting down ExileClient"); + configPoller.shutdownNow(); + var appender = MemoryAppenderInstance.getInstance(); + if (appender != null) { + appender.disableLogShipper(); + } + var mm = metricsManager; + if (mm != null) mm.close(); + workStream.close(); + ChannelFactory.shutdown(serviceChannel); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private ExileConfig config; + private Plugin plugin; + private String clientName = "sati"; + private String clientVersion = "unknown"; + private int maxConcurrency = 5; + private List capabilities = new ArrayList<>(); + private Duration configPollInterval = Duration.ofSeconds(10); + + private Builder() {} + + public Builder config(ExileConfig config) { + this.config = Objects.requireNonNull(config); + return this; + } + + /** The plugin that handles jobs, events, and config validation. */ + public Builder plugin(Plugin plugin) { + this.plugin = Objects.requireNonNull(plugin); + return this; + } + + public Builder clientName(String clientName) { + this.clientName = Objects.requireNonNull(clientName); + return this; + } + + public Builder clientVersion(String clientVersion) { + this.clientVersion = Objects.requireNonNull(clientVersion); + return this; + } + + public Builder maxConcurrency(int maxConcurrency) { + if (maxConcurrency < 1) throw new IllegalArgumentException("maxConcurrency must be >= 1"); + this.maxConcurrency = maxConcurrency; + return this; + } + + /** How often to poll the gate for config updates. Default: 10 seconds. */ + public Builder configPollInterval(Duration interval) { + this.configPollInterval = Objects.requireNonNull(interval); + return this; + } + + public ExileClient build() { + Objects.requireNonNull(config, "config is required"); + Objects.requireNonNull(plugin, "plugin is required"); + return new ExileClient(this); + } + } +} diff --git a/core/src/main/java/com/tcn/exile/ExileConfig.java b/core/src/main/java/com/tcn/exile/ExileConfig.java new file mode 100644 index 0000000..5c10a65 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/ExileConfig.java @@ -0,0 +1,143 @@ +package com.tcn.exile; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.Objects; + +/** + * Immutable configuration for connecting to the Exile gate server. + * + *

Holds mTLS credentials and server endpoint information. Built via {@link #builder()}. + */ +public final class ExileConfig { + + private final String rootCert; + private final String publicCert; + private final String privateKey; + private final String apiHostname; + private final int apiPort; + private final String certificateName; + + // Lazily derived from certificate. + private volatile String org; + + private ExileConfig(Builder builder) { + this.rootCert = Objects.requireNonNull(builder.rootCert, "rootCert"); + this.publicCert = Objects.requireNonNull(builder.publicCert, "publicCert"); + this.privateKey = Objects.requireNonNull(builder.privateKey, "privateKey"); + this.apiHostname = Objects.requireNonNull(builder.apiHostname, "apiHostname"); + this.apiPort = builder.apiPort > 0 ? builder.apiPort : 443; + this.certificateName = builder.certificateName != null ? builder.certificateName : ""; + } + + public String rootCert() { + return rootCert; + } + + public String publicCert() { + return publicCert; + } + + public String privateKey() { + return privateKey; + } + + public String apiHostname() { + return apiHostname; + } + + public int apiPort() { + return apiPort; + } + + /** The certificate name from the config file (may be empty). */ + public String certificateName() { + return certificateName; + } + + /** Extracts the organization name from the certificate CN field. Thread-safe. */ + public String org() { + String result = org; + if (result == null) { + synchronized (this) { + result = org; + if (result == null) { + result = parseOrgFromCert(); + org = result; + } + } + } + return result; + } + + private String parseOrgFromCert() { + try { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + X509Certificate cert = + (X509Certificate) + cf.generateCertificate( + new ByteArrayInputStream(publicCert.getBytes(StandardCharsets.UTF_8))); + String dn = cert.getSubjectX500Principal().getName(); + for (String part : dn.split(",")) { + String trimmed = part.trim(); + if (trimmed.startsWith("O=")) { + return trimmed.substring(2); + } + } + return ""; + } catch (Exception e) { + throw new IllegalStateException("Failed to parse organization from certificate", e); + } + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private String rootCert; + private String publicCert; + private String privateKey; + private String apiHostname; + private int apiPort; + private String certificateName; + + private Builder() {} + + public Builder rootCert(String rootCert) { + this.rootCert = rootCert; + return this; + } + + public Builder publicCert(String publicCert) { + this.publicCert = publicCert; + return this; + } + + public Builder privateKey(String privateKey) { + this.privateKey = privateKey; + return this; + } + + public Builder apiHostname(String apiHostname) { + this.apiHostname = apiHostname; + return this; + } + + public Builder certificateName(String certificateName) { + this.certificateName = certificateName; + return this; + } + + public Builder apiPort(int apiPort) { + this.apiPort = apiPort; + return this; + } + + public ExileConfig build() { + return new ExileConfig(this); + } + } +} diff --git a/core/src/main/java/com/tcn/exile/StreamStatus.java b/core/src/main/java/com/tcn/exile/StreamStatus.java new file mode 100644 index 0000000..dcf030f --- /dev/null +++ b/core/src/main/java/com/tcn/exile/StreamStatus.java @@ -0,0 +1,49 @@ +package com.tcn.exile; + +import java.time.Instant; + +/** + * Snapshot of the bidirectional work stream's current state. + * + *

Obtain via {@link ExileClient#streamStatus()}. + */ +public record StreamStatus( + /** Current connection phase. */ + Phase phase, + /** Server-assigned client ID (set after REGISTERED, null before). */ + String clientId, + /** When the current stream connection was established (null if not connected). */ + Instant connectedSince, + /** When the last disconnect occurred (null if never disconnected). */ + Instant lastDisconnect, + /** Last error message (null if no error). */ + String lastError, + /** Number of work items currently being processed. */ + int inflight, + /** Total work items completed (results + acks) since start. */ + long completedTotal, + /** Total work items that failed since start. */ + long failedTotal, + /** Total stream reconnection attempts since start. */ + long reconnectAttempts) { + + public enum Phase { + /** Client created but start() not yet called. */ + IDLE, + /** Connecting to the gate server. */ + CONNECTING, + /** Stream open, Register sent, waiting for Registered response. */ + REGISTERING, + /** Registered and actively pulling/processing work. */ + ACTIVE, + /** Stream disconnected, waiting to reconnect (backoff). */ + RECONNECTING, + /** Client has been closed. */ + CLOSED + } + + /** True if the stream is connected and processing work. */ + public boolean isHealthy() { + return phase == Phase.ACTIVE; + } +} diff --git a/core/src/main/java/com/tcn/exile/config/Config.java b/core/src/main/java/com/tcn/exile/config/Config.java deleted file mode 100644 index 8d27bdb..0000000 --- a/core/src/main/java/com/tcn/exile/config/Config.java +++ /dev/null @@ -1,483 +0,0 @@ -/* - * (C) 2017-2024 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.config; - -import com.tcn.exile.gateclients.UnconfiguredException; -import io.micronaut.core.type.Argument; -import io.micronaut.serde.ObjectMapper; -import io.micronaut.serde.annotation.Serdeable; -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.NotNull; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.net.MalformedURLException; -import java.net.URL; -import java.security.cert.CertificateException; -import java.security.cert.CertificateFactory; -import java.security.cert.X509Certificate; -import java.util.*; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** Concrete implementation of ConfigInterface that stores configuration data. */ -@Serdeable -public class Config { - private static final List fieldNames = - List.of( - "ca_certificate", - "certificate", - "private_key", - "fingerprint_sha256", - "fingerprint_sha256_string", - "api_endpoint", - "certificate_name", - "certificate_description"); - private static final Logger log = LoggerFactory.getLogger(Config.class); - - private boolean unconfigured; - private String rootCert; - private String publicCert; - private String privateKey; - private String fingerprintSha256; - private String fingerprintSha256String; - private String apiEndpoint; - private String certificateName; - private String certificateDescription; - private String org = null; - - /** Default constructor - initializes as unconfigured. */ - public Config() { - this.unconfigured = true; - } - - /** - * Copy constructor - * - * @param source The source config to copy from - */ - public Config(Config source) { - if (source != null) { - this.unconfigured = source.isUnconfigured(); - this.rootCert = source.getRootCert(); - this.publicCert = source.getPublicCert(); - this.privateKey = source.getPrivateKey(); - this.fingerprintSha256 = source.getFingerprintSha256(); - this.fingerprintSha256String = source.getFingerprintSha256String(); - this.apiEndpoint = source.getApiEndpoint(); - this.certificateName = source.getCertificateName(); - this.certificateDescription = source.getCertificateDescription(); - } - } - - public static Optional of(@NotNull byte[] data, @NotNull ObjectMapper objectMapper) { - try { - var map = - objectMapper.readValue( - Base64.getDecoder().decode(data), Argument.mapOf(String.class, String.class)); - if (!map.keySet().containsAll(fieldNames)) { - log.error("Invalid certificate data format"); - return Optional.empty(); - } - var ret = - Optional.of( - Config.builder() - .rootCert(map.get("ca_certificate")) - .publicCert(map.get("certificate")) - .privateKey(map.get("private_key")) - .fingerprintSha256(map.get("fingerprint_sha256")) - .fingerprintSha256String(map.get("fingerprint_sha256_string")) - .apiEndpoint(map.get("api_endpoint")) - .certificateName(map.get("certificate_name")) - .certificateDescription(map.get("certificate_description")) - .build()); - if (ret.get().getOrg() == null) { - log.error("Parsing certificate failed"); - return Optional.empty(); - } - return ret; - } catch (IOException e) { - log.debug("Parsing error", e); - return Optional.empty(); - } - } - - /** - * Constructor that takes a base64 encoded JSON string and parses it. - * - * @param base64EncodedJson The base64 encoded JSON string to parse - * @param objectMapper The ObjectMapper to use for JSON deserialization - * @throws UnconfiguredException If parsing fails - */ - public Config(String base64EncodedJson, ObjectMapper objectMapper) throws UnconfiguredException { - if (base64EncodedJson == null || base64EncodedJson.isEmpty()) { - throw new UnconfiguredException("Base64 encoded JSON string cannot be null or empty"); - } - - try { - byte[] jsonBytes = Base64.getDecoder().decode(base64EncodedJson); - @SuppressWarnings("unchecked") // Suppress warning for Map cast - Map jsonMap = objectMapper.readValue(jsonBytes, Map.class); - - this.rootCert = jsonMap.get("ca_certificate"); - this.publicCert = jsonMap.get("certificate"); - this.privateKey = jsonMap.get("private_key"); - this.fingerprintSha256 = jsonMap.get("fingerprint_sha256"); - this.fingerprintSha256String = jsonMap.get("fingerprint_sha256_string"); - this.apiEndpoint = jsonMap.get("api_endpoint"); - this.certificateName = jsonMap.get("certificate_name"); - this.certificateDescription = jsonMap.get("certificate_description"); - this.unconfigured = false; - - log.debug("Parsed base64 encoded JSON successfully"); - } catch (IOException e) { - throw new UnconfiguredException("Failed to parse JSON", e); - } catch (IllegalArgumentException e) { - throw new UnconfiguredException("Invalid base64 string", e); - } - } - - /** - * Private constructor for the Builder pattern. - * - * @param builder The builder instance to initialize from. - */ - private Config(Builder builder) { - this.unconfigured = builder.unconfigured; - this.rootCert = builder.rootCert; - this.publicCert = builder.publicCert; - this.privateKey = builder.privateKey; - this.fingerprintSha256 = builder.fingerprintSha256; - this.fingerprintSha256String = builder.fingerprintSha256String; - this.apiEndpoint = builder.apiEndpoint; - this.certificateName = builder.certificateName; - this.certificateDescription = builder.certificateDescription; - } - - /** - * Static factory method to create a Config from a base64 encoded JSON string. - * - * @param base64EncodedJson The base64 encoded JSON string to parse - * @param objectMapper The ObjectMapper to use for JSON deserialization - * @return A new Config instance - * @throws UnconfiguredException If parsing fails - */ - public static Config fromBase64Json(String base64EncodedJson, ObjectMapper objectMapper) - throws UnconfiguredException { - return new Config(base64EncodedJson, objectMapper); - } - - /** - * Static factory method to get a new Builder instance. - * - * @return A new Builder instance. - */ - public static Builder builder() { - return new Builder(); - } - - // --- Getters --- - - public String getCertificateDescription() { - return certificateDescription; - } - - public String getCertificateName() { - return certificateName; - } - - public String getApiEndpoint() { - return apiEndpoint; - } - - public String getFingerprintSha256() { - return fingerprintSha256; - } - - public String getFingerprintSha256String() { - return fingerprintSha256String; - } - - public boolean isUnconfigured() { - return unconfigured; - } - - public String getRootCert() { - return rootCert; - } - - public String getPublicCert() { - return publicCert; - } - - public String getPrivateKey() { - return privateKey; - } - - // --- Setters (Potentially make these package-private or remove if Builder is preferred) --- - // Note: Setters are kept public for now to maintain compatibility with ConfigEvent.Builder and - // direct usage. - // Consider changing visibility if strict immutability via Builder is desired. - - public void setCertificateDescription(String certificateDescription) { - this.certificateDescription = certificateDescription; - } - - public void setCertificateName(String certificateName) { - this.certificateName = certificateName; - } - - public void setApiEndpoint(String apiEndpoint) { - this.apiEndpoint = apiEndpoint; - } - - public void setFingerprintSha256(String fingerprintSha256) { - this.fingerprintSha256 = fingerprintSha256; - } - - public void setFingerprintSha256String(String fingerprintSha256String) { - this.fingerprintSha256String = fingerprintSha256String; - } - - public void setUnconfigured(boolean unconfigured) { - this.unconfigured = unconfigured; - } - - public void setRootCert(String rootCert) { - this.rootCert = rootCert; - } - - public void setPublicCert(String publicCert) { - this.publicCert = publicCert; - } - - public void setPrivateKey(String privateKey) { - this.privateKey = privateKey; - } - - // --- Derived Data Methods --- - - public String getOrg() { - if (this.org != null) { - return this.org; - } - try { - if (publicCert == null || publicCert.isBlank()) { - return null; - } - CertificateFactory cf = CertificateFactory.getInstance("X.509"); - ByteArrayInputStream bais = new ByteArrayInputStream(publicCert.getBytes()); - X509Certificate cert = (X509Certificate) cf.generateCertificate(bais); - String dn = cert.getSubjectX500Principal().getName(); - log.debug("Certificate Subject: " + dn); - - // Extract CN from DN - for (String part : dn.split(",")) { - if (part.trim().startsWith("O=")) { - this.org = part.substring(2).trim(); - return this.org; - } - } - return null; - } catch (CertificateException e) { - log.error("Error parsing certificate for getOrg: {}", e.getMessage()); - return null; - } catch (Exception e) { - log.error("Unexpected error in getOrg", e); - return null; - } - } - - public String getApiHostname() throws UnconfiguredException { - if (apiEndpoint == null || apiEndpoint.isBlank()) { - throw new UnconfiguredException("API endpoint is not set"); - } - try { - return new URL(apiEndpoint).getHost(); - } catch (MalformedURLException e) { - throw new UnconfiguredException("Invalid API endpoint URL: " + apiEndpoint, e); - } - } - - public int getApiPort() throws UnconfiguredException { - if (apiEndpoint == null || apiEndpoint.isBlank()) { - throw new UnconfiguredException("API endpoint is not set"); - } - try { - var url = new URL(apiEndpoint); - if (url.getPort() == -1) { - // Use default HTTPS port if not specified - return 443; - } - return url.getPort(); - } catch (MalformedURLException e) { - throw new UnconfiguredException("Invalid API endpoint URL: " + apiEndpoint, e); - } - } - - public Date getExpirationDate() { - var certStr = this.getPublicCert(); - if (certStr == null || certStr.isBlank()) { - return null; - } - try { - CertificateFactory cf = CertificateFactory.getInstance("X.509"); - X509Certificate myCert = - (X509Certificate) cf.generateCertificate(new ByteArrayInputStream(certStr.getBytes())); - return myCert.getNotAfter(); - } catch (CertificateException e) { - log.error("Error parsing certificate for expiration date: {}", e.getMessage()); - } catch (Exception e) { - log.error("Unexpected error getting expiration date", e); - } - return null; - } - - // --- Object Methods --- - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || !(o instanceof Config)) return false; - - Config that = (Config) o; - - // Consider adding more fields for a more robust equality check if needed - if (unconfigured != that.isUnconfigured()) return false; - if (!Objects.equals(apiEndpoint, that.getApiEndpoint())) return false; - if (!Objects.equals(getOrg(), that.getOrg())) return false; // Derived, might be slow - return Objects.equals(publicCert, that.getPublicCert()); - } - - @Override - public int hashCode() { - // Consider adding more fields for a more robust hash code if needed - return Objects.hash(unconfigured, apiEndpoint, getOrg(), publicCert); - } - - @Override - public String toString() { - return "Config{" - + "unconfigured=" - + unconfigured - + ", apiEndpoint='" - + apiEndpoint - + '\'' - + ", certificateName='" - + certificateName - + '\'' - + ", org='" - + getOrg() - + "',..." - + // Be mindful of potential null from getOrg() - '}'; - } - - // --- Builder Class --- - - public static class Builder { - private boolean unconfigured = true; // Default to unconfigured - private String rootCert; - private boolean rootCertSet = false; - private String publicCert; - private boolean publicCertSet = false; - private String privateKey; - private boolean privateKeySet = false; - private String fingerprintSha256; - private boolean fingerprintSha256Set = false; - private String fingerprintSha256String; - private boolean fingerprintSha256StringSet = false; - private String apiEndpoint; - private boolean apiEndpointSet = false; - private String certificateName; - private boolean certificateNameSet = false; - private String certificateDescription; - private boolean certificateDescriptionSet = false; - - public Builder rootCert(@NotEmpty String rootCert) { - this.rootCert = rootCert; - this.rootCertSet = true; - return this; - } - - public Builder publicCert(@NotEmpty String publicCert) { - this.publicCert = publicCert; - this.publicCertSet = true; - return this; - } - - public Builder privateKey(@NotEmpty String privateKey) { - this.privateKey = privateKey; - this.privateKeySet = true; - return this; - } - - public Builder fingerprintSha256(@NotEmpty String fingerprintSha256) { - this.fingerprintSha256 = fingerprintSha256; - this.fingerprintSha256Set = true; - return this; - } - - public Builder fingerprintSha256String(@NotEmpty String fingerprintSha256String) { - this.fingerprintSha256String = fingerprintSha256String; - this.fingerprintSha256Set = true; - return this; - } - - public Builder apiEndpoint(@NotEmpty String apiEndpoint) { - this.apiEndpoint = apiEndpoint; - this.apiEndpointSet = true; - return this; - } - - public Builder certificateName(@NotEmpty String certificateName) { - this.certificateName = certificateName; - this.certificateNameSet = true; - return this; - } - - public Builder certificateDescription(String certificateDescription) { - this.certificateDescription = certificateDescription; // Can be null or empty - this.certificateDescriptionSet = true; - return this; - } - - /** - * Builds the Config object. It automatically sets unconfigured to false if essential fields - * (e.g., apiEndpoint, publicCert) are set. Add more validation here if needed. - * - * @return A new Config instance. - */ - public Config build() { - this.unconfigured = - !(this.rootCertSet - && this.publicCertSet - && this.privateKeySet - && this.fingerprintSha256Set - && this.fingerprintSha256StringSet - && this.apiEndpointSet - && this.certificateNameSet - && this.certificateDescriptionSet); - - // Automatically mark as configured if key fields are provided - if (this.unconfigured && apiEndpoint != null && publicCert != null) { - log.debug("Builder automatically marking config as configured based on provided fields."); - this.unconfigured = false; - } - return new Config(this); - } - } -} diff --git a/core/src/main/java/com/tcn/exile/config/DiagnosticsService.java b/core/src/main/java/com/tcn/exile/config/DiagnosticsService.java deleted file mode 100644 index 2f6b7a2..0000000 --- a/core/src/main/java/com/tcn/exile/config/DiagnosticsService.java +++ /dev/null @@ -1,1652 +0,0 @@ -/* - * (C) 2017-2025 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.config; - -import build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.DiagnosticsResult; -import build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.DiagnosticsResult.*; -import ch.qos.logback.classic.LoggerContext; -import com.google.protobuf.Timestamp; -import com.tcn.exile.plugin.PluginInterface; -import io.micronaut.context.ApplicationContext; -import jakarta.inject.Singleton; -import java.io.File; -import java.io.IOException; -import java.lang.management.*; -import java.lang.reflect.Method; -import java.net.InetAddress; -import java.net.UnknownHostException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.time.Instant; -import java.util.*; -import java.util.Base64; -import java.util.regex.Pattern; -import javax.management.MBeanServer; -import javax.management.ObjectName; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -@Singleton -public class DiagnosticsService { - private static final Logger log = LoggerFactory.getLogger(DiagnosticsService.class); - - private static final Pattern DOCKER_PATTERN = - Pattern.compile(".*docker.*", Pattern.CASE_INSENSITIVE); - private static final Pattern KUBERNETES_PATTERN = - Pattern.compile(".*k8s.*|.*kube.*", Pattern.CASE_INSENSITIVE); - - private final ApplicationContext applicationContext; - - public DiagnosticsService(ApplicationContext applicationContext) { - this.applicationContext = applicationContext; - } - - // Helper class to hold log collection results - private static class LogCollectionResult { - final List logs; - final boolean success; - final String errorMessage; - - LogCollectionResult(List logs, boolean success, String errorMessage) { - this.logs = logs != null ? logs : new ArrayList<>(); - this.success = success; - this.errorMessage = errorMessage; - } - - static LogCollectionResult success(List logs) { - return new LogCollectionResult(logs, true, null); - } - - static LogCollectionResult failure(String errorMessage) { - return new LogCollectionResult(null, false, errorMessage); - } - } - - public DiagnosticsResult collectSystemDiagnostics() { - log.info("Collecting comprehensive system diagnostics..."); - - try { - Instant now = Instant.now(); - Timestamp timestamp = - Timestamp.newBuilder().setSeconds(now.getEpochSecond()).setNanos(now.getNano()).build(); - - // Collect Hikari metrics, config details, and event stream stats - List hikariPoolMetrics = collectHikariMetrics(); - ConfigDetails configDetails = collectConfigDetails(); - EventStreamStats eventStreamStats = collectEventStreamStats(); - - return DiagnosticsResult.newBuilder() - .setTimestamp(timestamp) - .setHostname(getHostname()) - .setOperatingSystem(collectOperatingSystemInfo()) - .setJavaRuntime(collectJavaRuntimeInfo()) - .setHardware(collectHardwareInfo()) - .setMemory(collectMemoryInfo()) - .addAllStorage(collectStorageInfo()) - .setContainer(collectContainerInfo()) - .setEnvironmentVariables(collectEnvironmentVariables()) - .setSystemProperties(collectSystemProperties()) - .addAllHikariPoolMetrics(hikariPoolMetrics) - .setConfigDetails(configDetails) - .setEventStreamStats(eventStreamStats) - .build(); - } catch (Exception e) { - log.error("Error collecting system diagnostics", e); - throw new RuntimeException("Failed to collect system diagnostics", e); - } - } - - /** - * Collects tenant logs and returns them in protobuf format for plugin responses. This method - * handles time range filtering and log level detection. - * - * @param listTenantLogsRequest The request containing optional time range filtering - * @return ListTenantLogsResult in protobuf format ready for submission to gate - */ - public build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult - collectTenantLogs( - build.buf.gen.tcnapi.exile.gate.v2.StreamJobsResponse.ListTenantLogsRequest - listTenantLogsRequest) { - log.debug("Collecting tenant logs from memory appender..."); - - long startTime = System.currentTimeMillis(); - - try { - // Extract time range from request if provided - Long startTimeMs = null; - Long endTimeMs = null; - if (listTenantLogsRequest.hasTimeRange()) { - // Use the exact same timestamp conversion logic that was working before - startTimeMs = - listTenantLogsRequest.getTimeRange().getStartTime().getSeconds() * 1000 - + listTenantLogsRequest.getTimeRange().getStartTime().getNanos() / 1000000; - endTimeMs = - listTenantLogsRequest.getTimeRange().getEndTime().getSeconds() * 1000 - + listTenantLogsRequest.getTimeRange().getEndTime().getNanos() / 1000000; - log.debug( - "Filtering logs with time range: {} to {} (timestamps: {} to {})", - listTenantLogsRequest.getTimeRange().getStartTime(), - listTenantLogsRequest.getTimeRange().getEndTime(), - startTimeMs, - endTimeMs); - } - - // Collect logs using common method - LogCollectionResult result = collectLogsFromMemoryAppender(startTimeMs, endTimeMs); - - // Create protobuf result - build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult.Builder - resultBuilder = - build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult - .newBuilder(); - - if (result.success && !result.logs.isEmpty()) { - // Create a single log group with all logs - build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult.LogGroup - .Builder - logGroupBuilder = - build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult - .LogGroup.newBuilder() - .setName("logGroups/memory-logs") - .addAllLogs(result.logs); - - // Set time range - if (listTenantLogsRequest.hasTimeRange()) { - logGroupBuilder.setTimeRange(listTenantLogsRequest.getTimeRange()); - } else { - // Fallback to current time - long now = System.currentTimeMillis(); - logGroupBuilder.setTimeRange( - build.buf.gen.tcnapi.exile.gate.v2.TimeRange.newBuilder() - .setStartTime(createTimestamp(now)) - .setEndTime(createTimestamp(now)) - .build()); - } - - // Detect log levels from the actual logs - Map< - String, - build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult - .LogGroup.LogLevel> - detectedLevels = detectLogLevelsByLogger(); - - // If no loggers were detected, provide a default - if (detectedLevels.isEmpty()) { - detectedLevels.put( - "memory", - build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult - .LogGroup.LogLevel.INFO); - } - - detectedLevels.forEach(logGroupBuilder::putLogLevels); - - resultBuilder.addLogGroups(logGroupBuilder.build()); - } - - long duration = System.currentTimeMillis() - startTime; - log.debug("Successfully collected tenant logs in {}ms", duration); - - return resultBuilder.build(); - } catch (Exception e) { - log.error("Error collecting tenant logs: {}", e.getMessage(), e); - return build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult - .newBuilder() - .build(); - } - } - - /** - * Collects tenant logs from the MemoryAppender and returns them as a serializable result. Uses - * reflection to access MemoryAppender classes since they're in a different module. - * - * @return TenantLogsResult containing the collected logs - */ - public com.tcn.exile.models.TenantLogsResult collectSerdeableTenantLogs() { - return collectSerdeableTenantLogs(null, null); - } - - /** - * Collects tenant logs from the MemoryAppender within a specific time range. - * - * @param startTimeMs Start time in milliseconds since epoch, or null for no start limit - * @param endTimeMs End time in milliseconds since epoch, or null for no end limit - * @return TenantLogsResult containing the collected logs - */ - public com.tcn.exile.models.TenantLogsResult collectSerdeableTenantLogs( - Long startTimeMs, Long endTimeMs) { - log.debug("Collecting tenant logs from memory appender..."); - - try { - // Collect logs using common method - LogCollectionResult result = collectLogsFromMemoryAppender(startTimeMs, endTimeMs); - - // Create the result object - com.tcn.exile.models.TenantLogsResult tenantLogsResult = - new com.tcn.exile.models.TenantLogsResult(); - List logGroups = new ArrayList<>(); - - if (result.success && !result.logs.isEmpty()) { - // Create a single log group with all logs - com.tcn.exile.models.TenantLogsResult.LogGroup logGroup = - new com.tcn.exile.models.TenantLogsResult.LogGroup(); - logGroup.setName("logGroups/memory-logs"); - logGroup.setLogs(result.logs); - - // Set time range based on actual parameters used for filtering - com.tcn.exile.models.TenantLogsResult.LogGroup.TimeRange timeRange = - new com.tcn.exile.models.TenantLogsResult.LogGroup.TimeRange(); - - if (startTimeMs != null && endTimeMs != null) { - // Use the actual time parameters that were used for filtering - timeRange.setStartTime(java.time.Instant.ofEpochMilli(startTimeMs)); - timeRange.setEndTime(java.time.Instant.ofEpochMilli(endTimeMs)); - } else { - java.time.Instant now = java.time.Instant.now(); - java.time.Instant oneHourAgo = now.minusSeconds(600); - timeRange.setStartTime(oneHourAgo); - timeRange.setEndTime(now); - } - logGroup.setTimeRange(timeRange); - - // Detect log levels by logger name from the actual logs - Map logLevels = - detectSerializableLogLevelsByLogger(); - - logGroup.setLogLevels(logLevels); - - logGroups.add(logGroup); - log.info("Created log group with {} log entries", result.logs.size()); - } else { - log.info("No logs found in memory appender"); - } - - tenantLogsResult.setLogGroups(logGroups); - tenantLogsResult.setNextPageToken(""); // No pagination for now - - return tenantLogsResult; - } catch (Exception e) { - log.error("Error collecting tenant logs", e); - // Return empty result on error - com.tcn.exile.models.TenantLogsResult result = new com.tcn.exile.models.TenantLogsResult(); - result.setLogGroups(new ArrayList<>()); - result.setNextPageToken(""); - return result; - } - } - - /** - * Detects log levels by logger name from the LoggerContext system configuration. This method - * retrieves actual configured log levels rather than parsing from log output. - * - * @return Map of logger names to their configured log levels - */ - private Map< - String, - build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult.LogGroup - .LogLevel> - detectLogLevelsByLogger() { - Map< - String, - build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult.LogGroup - .LogLevel> - result = new HashMap<>(); - - try { - // Get the logback logger context - LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); - - // Get all configured loggers - List loggers = loggerContext.getLoggerList(); - - for (ch.qos.logback.classic.Logger logger : loggers) { - String loggerName = logger.getName(); - ch.qos.logback.classic.Level level = logger.getLevel(); - - // Skip loggers without explicit levels (they inherit from parent) - if (level != null) { - build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult.LogGroup - .LogLevel - protobufLevel = convertLogbackLevelToProtobuf(level); - result.put(loggerName, protobufLevel); - } else { - // For loggers without explicit levels, check effective level - ch.qos.logback.classic.Level effectiveLevel = logger.getEffectiveLevel(); - if (effectiveLevel != null) { - build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult.LogGroup - .LogLevel - protobufLevel = convertLogbackLevelToProtobuf(effectiveLevel); - result.put(loggerName + " (inherited)", protobufLevel); - } - } - } - - // Always include root logger information - ch.qos.logback.classic.Logger rootLogger = - loggerContext.getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME); - if (rootLogger != null) { - ch.qos.logback.classic.Level rootLevel = rootLogger.getLevel(); - if (rootLevel != null) { - build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult.LogGroup - .LogLevel - protobufLevel = convertLogbackLevelToProtobuf(rootLevel); - result.put("ROOT", protobufLevel); - } - } - - } catch (Exception e) { - log.warn("Error detecting log levels from LoggerContext: {}", e.getMessage(), e); - // Fallback to default - result.put( - "system.default", - build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult.LogGroup - .LogLevel.INFO); - } - - return result; - } - - /** - * Detects log levels by logger name from the LoggerContext system configuration for serializable - * results. This method retrieves actual configured log levels rather than parsing from log - * output. - * - * @return Map of logger names to their configured log levels - */ - private Map - detectSerializableLogLevelsByLogger() { - Map result = new HashMap<>(); - - try { - // Get the logback logger context - LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); - - // Get all configured loggers - List loggers = loggerContext.getLoggerList(); - - for (ch.qos.logback.classic.Logger logger : loggers) { - String loggerName = logger.getName(); - ch.qos.logback.classic.Level level = logger.getLevel(); - - // Skip loggers without explicit levels (they inherit from parent) - if (level != null) { - com.tcn.exile.models.TenantLogsResult.LogGroup.LogLevel serializableLevel = - convertLogbackLevelToSerializable(level); - result.put(loggerName, serializableLevel); - } else { - // For loggers without explicit levels, check effective level - ch.qos.logback.classic.Level effectiveLevel = logger.getEffectiveLevel(); - if (effectiveLevel != null) { - com.tcn.exile.models.TenantLogsResult.LogGroup.LogLevel serializableLevel = - convertLogbackLevelToSerializable(effectiveLevel); - result.put(loggerName + " (inherited)", serializableLevel); - } - } - } - - // Always include root logger information - ch.qos.logback.classic.Logger rootLogger = - loggerContext.getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME); - if (rootLogger != null) { - ch.qos.logback.classic.Level rootLevel = rootLogger.getLevel(); - if (rootLevel != null) { - com.tcn.exile.models.TenantLogsResult.LogGroup.LogLevel serializableLevel = - convertLogbackLevelToSerializable(rootLevel); - result.put("ROOT", serializableLevel); - } - } - - } catch (Exception e) { - log.warn("Error detecting log levels from LoggerContext: {}", e.getMessage(), e); - // Fallback to default - result.put("system.default", com.tcn.exile.models.TenantLogsResult.LogGroup.LogLevel.INFO); - } - - return result; - } - - /** Converts logback Level to protobuf LogLevel enum. */ - private build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult.LogGroup - .LogLevel - convertLogbackLevelToProtobuf(ch.qos.logback.classic.Level logbackLevel) { - switch (logbackLevel.toInt()) { - case ch.qos.logback.classic.Level.TRACE_INT: - case ch.qos.logback.classic.Level.DEBUG_INT: - return build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult - .LogGroup.LogLevel.DEBUG; - case ch.qos.logback.classic.Level.INFO_INT: - return build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult - .LogGroup.LogLevel.INFO; - case ch.qos.logback.classic.Level.WARN_INT: - return build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult - .LogGroup.LogLevel.WARNING; - case ch.qos.logback.classic.Level.ERROR_INT: - return build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult - .LogGroup.LogLevel.ERROR; - default: - return build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult - .LogGroup.LogLevel.INFO; - } - } - - /** Converts logback Level to serializable LogLevel enum. */ - private com.tcn.exile.models.TenantLogsResult.LogGroup.LogLevel convertLogbackLevelToSerializable( - ch.qos.logback.classic.Level logbackLevel) { - switch (logbackLevel.toInt()) { - case ch.qos.logback.classic.Level.TRACE_INT: - case ch.qos.logback.classic.Level.DEBUG_INT: - return com.tcn.exile.models.TenantLogsResult.LogGroup.LogLevel.DEBUG; - case ch.qos.logback.classic.Level.INFO_INT: - return com.tcn.exile.models.TenantLogsResult.LogGroup.LogLevel.INFO; - case ch.qos.logback.classic.Level.WARN_INT: - return com.tcn.exile.models.TenantLogsResult.LogGroup.LogLevel.WARNING; - case ch.qos.logback.classic.Level.ERROR_INT: - return com.tcn.exile.models.TenantLogsResult.LogGroup.LogLevel.ERROR; - default: - return com.tcn.exile.models.TenantLogsResult.LogGroup.LogLevel.INFO; - } - } - - /** - * Sets the log level for a specific logger and returns the result in protobuf format for plugin - * responses. This method handles dynamic log level changes during runtime and includes proper - * tenant information. - * - * @param setLogLevelRequest The request containing the logger name and new log level - * @param tenantKey The tenant identifier for proper tenant information - * @return SetLogLevelResult in protobuf format ready for submission to gate - */ - public build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.SetLogLevelResult - setLogLevelWithTenant( - build.buf.gen.tcnapi.exile.gate.v2.StreamJobsResponse.SetLogLevelRequest - setLogLevelRequest, - String tenantKey) { - log.debug( - "Setting log level for logger: {} to level: {} (tenant: {})", - setLogLevelRequest.getLog(), - setLogLevelRequest.getLogLevel(), - tenantKey); - - try { - // Get the logback logger context - LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); - - String loggerName = setLogLevelRequest.getLog(); - - // Handle special case for root logger - if ("ROOT".equalsIgnoreCase(loggerName) || loggerName.isEmpty()) { - loggerName = ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME; - } - - // Get the target logger - ch.qos.logback.classic.Logger targetLogger = loggerContext.getLogger(loggerName); - - if (targetLogger == null) { - log.warn("Logger '{}' not found", loggerName); - return build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.SetLogLevelResult - .newBuilder() - .setTenant(createTenantInfoWithKey(tenantKey, "Logger not found: " + loggerName)) - .build(); - } - - // Convert the protobuf log level to logback level - ch.qos.logback.classic.Level newLevel; - try { - newLevel = convertProtobufLevelToLogbackLevel(setLogLevelRequest.getLogLevel()); - } catch (IllegalArgumentException e) { - log.warn("Invalid log level: {}", setLogLevelRequest.getLogLevel()); - return build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.SetLogLevelResult - .newBuilder() - .setTenant( - createTenantInfoWithKey( - tenantKey, "Invalid log level: " + setLogLevelRequest.getLogLevel())) - .build(); - } - - // Get the old level for logging - String oldLevel = - targetLogger.getLevel() != null ? targetLogger.getLevel().toString() : "INHERITED"; - - // Set the new level - targetLogger.setLevel(newLevel); - - log.info( - "Successfully changed log level for '{}' from '{}' to '{}' (tenant: {})", - loggerName, - oldLevel, - newLevel.toString(), - tenantKey); - - return build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.SetLogLevelResult - .newBuilder() - .setTenant( - createTenantInfoWithKey( - tenantKey, - "Successfully changed log level for '" - + loggerName - + "' from '" - + oldLevel - + "' to '" - + newLevel.toString() - + "'")) - .build(); - - } catch (Exception e) { - log.error( - "Error setting log level for logger '{}' (tenant: {}): {}", - setLogLevelRequest.getLog(), - tenantKey, - e.getMessage(), - e); - return build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.SetLogLevelResult - .newBuilder() - .setTenant( - createTenantInfoWithKey(tenantKey, "Failed to set log level: " + e.getMessage())) - .build(); - } - } - - /** - * Sets the log level for a specific logger and returns the result in protobuf format for plugin - * responses. This method handles dynamic log level changes during runtime. - * - * @param setLogLevelRequest The request containing the logger name and new log level - * @return SetLogLevelResult in protobuf format ready for submission to gate - * @deprecated Use setLogLevelWithTenant instead for proper tenant information - */ - @Deprecated - public build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.SetLogLevelResult setLogLevel( - build.buf.gen.tcnapi.exile.gate.v2.StreamJobsResponse.SetLogLevelRequest setLogLevelRequest) { - return setLogLevelWithTenant(setLogLevelRequest, getHostname()); - } - - /** - * Creates a Tenant object for the SetLogLevelResult with proper tenant key. - * - * @param tenantKey The tenant identifier - * @param statusMessage A status message to include in the name field - * @return Tenant object with system information - */ - private build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.SetLogLevelResult.Tenant - createTenantInfoWithKey(String tenantKey, String statusMessage) { - Instant now = Instant.now(); - Timestamp updateTime = - Timestamp.newBuilder().setSeconds(now.getEpochSecond()).setNanos(now.getNano()).build(); - - // Get version information - String satiVersion = "Unknown"; - String pluginVersion = "Unknown"; - try { - satiVersion = com.tcn.exile.gateclients.v2.BuildVersion.getBuildVersion(); - } catch (Exception e) { - log.debug("Could not get SATI version: {}", e.getMessage()); - } - - try { - // Try to get plugin version from system properties or manifest - String implVersion = this.getClass().getPackage().getImplementationVersion(); - if (implVersion != null) { - pluginVersion = implVersion; - } - } catch (Exception e) { - log.debug("Could not get plugin version: {}", e.getMessage()); - } - - // Use tenant key as the primary identifier - String tenantName = tenantKey != null ? tenantKey : getHostname(); - if (statusMessage != null && !statusMessage.isEmpty()) { - tenantName = tenantName + " (" + statusMessage + ")"; - } - - return build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.SetLogLevelResult.Tenant - .newBuilder() - .setName(tenantName) - .setSatiVersion(satiVersion) - .setPluginVersion(pluginVersion) - .setUpdateTime(updateTime) - .setConnectedGate(getHostname()) // Use hostname as connected gate info - .build(); - } - - /** - * Creates a Tenant object for the SetLogLevelResult. Since we don't have access to the actual - * tenant context in DiagnosticsService, we create a basic tenant info with available system - * information. - * - * @param statusMessage A status message to include in the name field - * @return Tenant object with system information - * @deprecated Use createTenantInfoWithKey instead for proper tenant information - */ - @Deprecated - private build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.SetLogLevelResult.Tenant - createTenantInfo(String statusMessage) { - return createTenantInfoWithKey(null, statusMessage); - } - - /** Converts protobuf LogLevel enum to logback Level. */ - private ch.qos.logback.classic.Level convertProtobufLevelToLogbackLevel( - build.buf.gen.tcnapi.exile.gate.v2.StreamJobsResponse.SetLogLevelRequest.LogLevel - protobufLevel) { - switch (protobufLevel) { - case DEBUG: - return ch.qos.logback.classic.Level.DEBUG; - case INFO: - return ch.qos.logback.classic.Level.INFO; - case WARNING: - return ch.qos.logback.classic.Level.WARN; - case ERROR: - return ch.qos.logback.classic.Level.ERROR; - case FATAL: - return ch.qos.logback.classic.Level.ERROR; // Logback doesn't have FATAL, map to ERROR - default: - throw new IllegalArgumentException("Unknown protobuf log level: " + protobufLevel); - } - } - - private String getHostname() { - try { - return InetAddress.getLocalHost().getHostName(); - } catch (UnknownHostException e) { - return System.getenv("HOSTNAME") != null ? System.getenv("HOSTNAME") : "unknown"; - } - } - - private OperatingSystem collectOperatingSystemInfo() { - OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean(); - - long totalPhysicalMemory = -1; - long availablePhysicalMemory = -1; - long totalSwapSpace = -1; - long availableSwapSpace = -1; - - // Try to get extended info if available - try { - if (osBean.getClass().getName().contains("com.sun.management")) { - // Use reflection to access com.sun.management.OperatingSystemMXBean methods - // Handle each method call separately to avoid one failure affecting others - try { - totalPhysicalMemory = - (Long) osBean.getClass().getMethod("getTotalPhysicalMemorySize").invoke(osBean); - } catch (Exception e) { - // Suppress: fall back to default -1 when restricted - } - - try { - availablePhysicalMemory = - (Long) osBean.getClass().getMethod("getFreePhysicalMemorySize").invoke(osBean); - } catch (Exception e) { - // Suppress: fall back to default -1 when restricted - } - - try { - totalSwapSpace = - (Long) osBean.getClass().getMethod("getTotalSwapSpaceSize").invoke(osBean); - } catch (Exception e) { - // Suppress: fall back to default -1 when restricted - } - - try { - availableSwapSpace = - (Long) osBean.getClass().getMethod("getFreeSwapSpaceSize").invoke(osBean); - } catch (Exception e) { - // Suppress: fall back to default -1 when restricted - } - } - } catch (Exception e) { - // Suppress overarching reflection access issues - } - - return OperatingSystem.newBuilder() - .setName(osBean.getName()) - .setVersion(osBean.getVersion()) - .setArchitecture(osBean.getArch()) - .setManufacturer(System.getProperty("os.name")) - .setAvailableProcessors(osBean.getAvailableProcessors()) - .setSystemUptime(getSystemUptime()) - .setSystemLoadAverage(osBean.getSystemLoadAverage()) - .setTotalPhysicalMemory(totalPhysicalMemory) - .setAvailablePhysicalMemory(availablePhysicalMemory) - .setTotalSwapSpace(totalSwapSpace) - .setAvailableSwapSpace(availableSwapSpace) - .build(); - } - - private long getSystemUptime() { - try { - RuntimeMXBean runtimeBean = ManagementFactory.getRuntimeMXBean(); - return runtimeBean.getUptime(); - } catch (Exception e) { - log.debug("Could not get system uptime", e); - return -1; - } - } - - private JavaRuntime collectJavaRuntimeInfo() { - RuntimeMXBean runtimeBean = ManagementFactory.getRuntimeMXBean(); - - JavaRuntime.Builder builder = - JavaRuntime.newBuilder() - .setVersion(System.getProperty("java.version", "")) - .setVendor(System.getProperty("java.vendor", "")) - .setRuntimeName(System.getProperty("java.runtime.name", "")) - .setVmName(System.getProperty("java.vm.name", "")) - .setVmVersion(System.getProperty("java.vm.version", "")) - .setVmVendor(System.getProperty("java.vm.vendor", "")) - .setSpecificationName(System.getProperty("java.specification.name", "")) - .setSpecificationVersion(System.getProperty("java.specification.version", "")) - .setClassPath(System.getProperty("java.class.path", "")) - .setLibraryPath(System.getProperty("java.library.path", "")) - .setUptime(runtimeBean.getUptime()) - .setStartTime(runtimeBean.getStartTime()) - .setManagementSpecVersion(runtimeBean.getManagementSpecVersion()); - - runtimeBean.getInputArguments().forEach(builder::addInputArguments); - - return builder.build(); - } - - private Hardware collectHardwareInfo() { - String model = System.getenv("HOSTNAME") != null ? System.getenv("HOSTNAME") : "Unknown"; - String manufacturer = "Unknown"; - String serialNumber = "Unknown"; - String uuid = "Unknown"; - - // Try to get hardware info from system files (Linux) - try { - if (System.getProperty("os.name").toLowerCase().contains("linux")) { - model = readFileContent("/sys/devices/virtual/dmi/id/product_name", model); - manufacturer = readFileContent("/sys/devices/virtual/dmi/id/sys_vendor", manufacturer); - serialNumber = readFileContent("/sys/devices/virtual/dmi/id/product_serial", serialNumber); - uuid = readFileContent("/sys/devices/virtual/dmi/id/product_uuid", uuid); - } - } catch (Exception e) { - // Suppress diagnostic noise when hardware sysfs is inaccessible in containers - } - - return Hardware.newBuilder() - .setModel(model) - .setManufacturer(manufacturer) - .setSerialNumber(serialNumber) - .setUuid(uuid) - .setProcessor(collectProcessorInfo()) - .build(); - } - - private Processor collectProcessorInfo() { - OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean(); - - String processorName = System.getenv("PROCESSOR_IDENTIFIER"); - if (processorName == null) { - processorName = readFileContent("/proc/cpuinfo", "Unknown"); - if (!"Unknown".equals(processorName)) { - processorName = - Arrays.stream(processorName.split("\n")) - .filter(line -> line.startsWith("model name")) - .findFirst() - .map(line -> line.substring(line.indexOf(':') + 1).trim()) - .orElse("Unknown"); - } - } - - return Processor.newBuilder() - .setName(processorName) - .setIdentifier(System.getProperty("java.vm.name", "")) - .setArchitecture(System.getProperty("os.arch", "")) - .setPhysicalProcessorCount(osBean.getAvailableProcessors()) - .setLogicalProcessorCount(Runtime.getRuntime().availableProcessors()) - .setMaxFrequency(-1L) - .setCpu64Bit( - System.getProperty("os.arch", "") - .contains("64")) // Changed from setCpu64bit to setCpu64Bit - .build(); - } - - private Memory collectMemoryInfo() { - MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean(); - MemoryUsage heapUsage = memoryBean.getHeapMemoryUsage(); - MemoryUsage nonHeapUsage = memoryBean.getNonHeapMemoryUsage(); - - Memory.Builder builder = - Memory.newBuilder() - .setHeapMemoryUsed(heapUsage.getUsed()) - .setHeapMemoryMax(heapUsage.getMax()) - .setHeapMemoryCommitted(heapUsage.getCommitted()) - .setNonHeapMemoryUsed(nonHeapUsage.getUsed()) - .setNonHeapMemoryMax(nonHeapUsage.getMax()) - .setNonHeapMemoryCommitted(nonHeapUsage.getCommitted()); - - ManagementFactory.getMemoryPoolMXBeans() - .forEach( - pool -> { - MemoryUsage usage = pool.getUsage(); - builder.addMemoryPools( - MemoryPool.newBuilder() - .setName(pool.getName()) - .setType(pool.getType().toString()) - .setUsed(usage.getUsed()) - .setMax(usage.getMax()) - .setCommitted(usage.getCommitted()) - .build()); - }); - - return builder.build(); - } - - private List collectStorageInfo() { - List storageList = new ArrayList<>(); - - File[] roots = File.listRoots(); - for (File root : roots) { - storageList.add( - Storage.newBuilder() - .setName(root.getAbsolutePath()) - .setType("disk") - .setModel("Unknown") - .setSerialNumber("Unknown") - .setSize(root.getTotalSpace()) - .build()); - } - - return storageList; - } - - private Container collectContainerInfo() { - boolean isContainer = detectContainer(); - String containerType = detectContainerType(); - String containerId = getContainerId(); - String containerName = System.getenv("HOSTNAME"); - String imageName = getImageName(); - - Container.Builder builder = - Container.newBuilder() - .setIsContainer(isContainer) - .setContainerType(containerType) - .setContainerId(containerId) - .setContainerName(containerName != null ? containerName : "") - .setImageName(imageName); - - Map resourceLimits = new HashMap<>(); - Long memoryLimit = - parseMemoryLimit(readFileContent("/sys/fs/cgroup/memory/memory.limit_in_bytes", null)); - if (memoryLimit != null) { - resourceLimits.put("memory_limit", String.valueOf(memoryLimit)); - } - - Long cpuLimit = parseCpuLimit(readFileContent("/sys/fs/cgroup/cpu/cpu.cfs_quota_us", null)); - if (cpuLimit != null) { - resourceLimits.put("cpu_limit", String.valueOf(cpuLimit)); - } - - String cpuRequest = System.getenv("CPU_REQUEST"); - if (cpuRequest != null) { - resourceLimits.put("cpu_request", cpuRequest); - } - - String memoryRequest = System.getenv("MEMORY_REQUEST"); - if (memoryRequest != null) { - resourceLimits.put("memory_request", memoryRequest); - } - - builder.putAllResourceLimits(resourceLimits); - - return builder.build(); - } - - private boolean detectContainer() { - if (Files.exists(Paths.get("/.dockerenv"))) { - return true; - } - - String cgroupContent = readFileContent("/proc/1/cgroup", ""); - return DOCKER_PATTERN.matcher(cgroupContent).find() - || KUBERNETES_PATTERN.matcher(cgroupContent).find(); - } - - private String detectContainerType() { - if (System.getenv("KUBERNETES_SERVICE_HOST") != null) { - return "kubernetes"; - } - if (Files.exists(Paths.get("/.dockerenv"))) { - return "docker"; - } - - String cgroupContent = readFileContent("/proc/1/cgroup", ""); - if (KUBERNETES_PATTERN.matcher(cgroupContent).find()) { - return "kubernetes"; - } - if (DOCKER_PATTERN.matcher(cgroupContent).find()) { - return "docker"; - } - - return "unknown"; - } - - private String getContainerId() { - String cgroupContent = readFileContent("/proc/self/cgroup", ""); - if (!cgroupContent.isEmpty()) { - String[] lines = cgroupContent.split("\n"); - for (String line : lines) { - if (line.contains("docker") || line.contains("containerd")) { - String[] parts = line.split("/"); - if (parts.length > 0) { - String lastPart = parts[parts.length - 1]; - if (lastPart.length() >= 12) { - return lastPart.substring(0, 12); - } - } - } - } - } - return System.getenv("HOSTNAME") != null ? System.getenv("HOSTNAME") : ""; - } - - private String getImageName() { - String[] imageEnvVars = { - "IMAGE_NAME", "DOCKER_IMAGE", "CONTAINER_IMAGE", "POD_CONTAINER_IMAGE" - }; - - for (String envVar : imageEnvVars) { - String value = System.getenv(envVar); - if (value != null && !value.isEmpty()) { - return value; - } - } - - return "unknown"; - } - - private Map collectKubernetesLabels() { - Map labels = new HashMap<>(); - System.getenv().entrySet().stream() - .filter(entry -> entry.getKey().startsWith("LABEL_")) - .forEach(entry -> labels.put(entry.getKey().substring(6).toLowerCase(), entry.getValue())); - return labels; - } - - private Map collectKubernetesAnnotations() { - Map annotations = new HashMap<>(); - System.getenv().entrySet().stream() - .filter(entry -> entry.getKey().startsWith("ANNOTATION_")) - .forEach( - entry -> annotations.put(entry.getKey().substring(11).toLowerCase(), entry.getValue())); - return annotations; - } - - private Long parseMemoryLimit(String content) { - if (content == null || content.trim().isEmpty()) { - return null; - } - try { - long limit = Long.parseLong(content.trim()); - return limit > (1024L * 1024L * 1024L * 1024L) ? null : limit; - } catch (NumberFormatException e) { - return null; - } - } - - private Long parseCpuLimit(String content) { - if (content == null || content.trim().isEmpty()) { - return null; - } - try { - return Long.parseLong(content.trim()); - } catch (NumberFormatException e) { - return null; - } - } - - private EnvironmentVariables collectEnvironmentVariables() { - Set sensitiveKeys = - Set.of( - "PASSWORD", - "SECRET", - "TOKEN", - "KEY", - "CREDENTIAL", - "API_KEY", - "ACCESS_TOKEN", - "PRIVATE_KEY"); - - Map env = System.getenv(); - - return EnvironmentVariables.newBuilder() - .setLanguage(env.getOrDefault("LANGUAGE", "")) - .setPath(env.getOrDefault("PATH", "")) - .setHostname(env.getOrDefault("HOSTNAME", "")) - .setLcAll(env.getOrDefault("LC_ALL", "")) - .setJavaHome(env.getOrDefault("JAVA_HOME", "")) - .setJavaVersion(env.getOrDefault("JAVA_VERSION", "")) - .setLang(env.getOrDefault("LANG", "")) - .setHome(env.getOrDefault("HOME", "")) - .build(); - } - - private SystemProperties collectSystemProperties() { - Properties props = System.getProperties(); - - return SystemProperties.newBuilder() - // Java Specification - .setJavaSpecificationVersion(props.getProperty("java.specification.version", "")) - .setJavaSpecificationVendor(props.getProperty("java.specification.vendor", "")) - .setJavaSpecificationName(props.getProperty("java.specification.name", "")) - .setJavaSpecificationMaintenanceVersion( - props.getProperty("java.specification.maintenance.version", "")) - - // Java Version Info - .setJavaVersion(props.getProperty("java.version", "")) - .setJavaVersionDate(props.getProperty("java.version.date", "")) - .setJavaVendor(props.getProperty("java.vendor", "")) - .setJavaVendorVersion(props.getProperty("java.vendor.version", "")) - .setJavaVendorUrl(props.getProperty("java.vendor.url", "")) - .setJavaVendorUrlBug(props.getProperty("java.vendor.url.bug", "")) - - // Java Runtime - .setJavaRuntimeName(props.getProperty("java.runtime.name", "")) - .setJavaRuntimeVersion(props.getProperty("java.runtime.version", "")) - .setJavaHome(props.getProperty("java.home", "")) - .setJavaClassPath(props.getProperty("java.class.path", "")) - .setJavaLibraryPath(props.getProperty("java.library.path", "")) - .setJavaClassVersion(props.getProperty("java.class.version", "")) - - // Java VM - .setJavaVmName(props.getProperty("java.vm.name", "")) - .setJavaVmVersion(props.getProperty("java.vm.version", "")) - .setJavaVmVendor(props.getProperty("java.vm.vendor", "")) - .setJavaVmInfo(props.getProperty("java.vm.info", "")) - .setJavaVmSpecificationVersion(props.getProperty("java.vm.specification.version", "")) - .setJavaVmSpecificationVendor(props.getProperty("java.vm.specification.vendor", "")) - .setJavaVmSpecificationName(props.getProperty("java.vm.specification.name", "")) - .setJavaVmCompressedOopsMode(props.getProperty("java.vm.compressedOopsMode", "")) - - // Operating System - .setOsName(props.getProperty("os.name", "")) - .setOsVersion(props.getProperty("os.version", "")) - .setOsArch(props.getProperty("os.arch", "")) - - // User Info - .setUserName(props.getProperty("user.name", "")) - .setUserHome(props.getProperty("user.home", "")) - .setUserDir(props.getProperty("user.dir", "")) - .setUserTimezone(props.getProperty("user.timezone", "")) - .setUserCountry(props.getProperty("user.country", "")) - .setUserLanguage(props.getProperty("user.language", "")) - - // File System - .setFileSeparator(props.getProperty("file.separator", "")) - .setPathSeparator(props.getProperty("path.separator", "")) - .setLineSeparator(props.getProperty("line.separator", "")) - .setFileEncoding(props.getProperty("file.encoding", "")) - .setNativeEncoding(props.getProperty("native.encoding", "")) - - // Sun/Oracle Specific - .setSunJnuEncoding(props.getProperty("sun.jnu.encoding", "")) - .setSunArchDataModel(props.getProperty("sun.arch.data.model", "")) - .setSunJavaLauncher(props.getProperty("sun.java.launcher", "")) - .setSunBootLibraryPath(props.getProperty("sun.boot.library.path", "")) - .setSunJavaCommand(props.getProperty("sun.java.command", "")) - .setSunCpuEndian(props.getProperty("sun.cpu.endian", "")) - .setSunManagementCompiler(props.getProperty("sun.management.compiler", "")) - .setSunIoUnicodeEncoding(props.getProperty("sun.io.unicode.encoding", "")) - - // JDK/Debug - .setJdkDebug(props.getProperty("jdk.debug", "")) - .setJavaIoTmpdir(props.getProperty("java.io.tmpdir", "")) - - // Application Specific - .setEnv(props.getProperty("env", "")) - .setMicronautClassloaderLogging(props.getProperty("micronaut.classloader.logging", "")) - - // Third Party Libraries - .setIoNettyAllocatorMaxOrder(props.getProperty("io.netty.allocator.maxOrder", "")) - .setIoNettyProcessId(props.getProperty("io.netty.processId", "")) - .setIoNettyMachineId(props.getProperty("io.netty.machineId", "")) - .setComZaxxerHikariPoolNumber(props.getProperty("com.zaxxer.hikari.pool_number", "")) - .build(); - } - - private String readFileContent(String filePath, String defaultValue) { - try { - Path path = Paths.get(filePath); - if (Files.exists(path)) { - return Files.readString(path).trim(); - } - } catch (IOException e) { - // Intentionally suppress logs for unreadable or restricted system files - } - return defaultValue; - } - - public com.tcn.exile.models.DiagnosticsResult collectSerdeableDiagnostics() { - return com.tcn.exile.models.DiagnosticsResult.fromProto(collectSystemDiagnostics()); - } - - /** - * Common method to collect logs from MemoryAppender using reflection. This eliminates code - * duplication between different collection methods. - * - * @param startTimeMs Start time in milliseconds since epoch, or null for no start limit - * @param endTimeMs End time in milliseconds since epoch, or null for no end limit - * @return LogCollectionResult containing logs and success status - */ - private LogCollectionResult collectLogsFromMemoryAppender(Long startTimeMs, Long endTimeMs) { - try { - // Use reflection to access MemoryAppenderInstance.getInstance() - Class memoryAppenderInstanceClass = - Class.forName("com.tcn.exile.memlogger.MemoryAppenderInstance"); - Method getInstanceMethod = memoryAppenderInstanceClass.getMethod("getInstance"); - Object memoryAppenderInstance = getInstanceMethod.invoke(null); - - if (memoryAppenderInstance == null) { - log.warn("MemoryAppender instance is null - no in-memory logs available"); - return LogCollectionResult.success(new ArrayList<>()); - } - - List logs; - - // Check if time filtering is requested - if (startTimeMs != null && endTimeMs != null) { - // Use reflection to call getEventsInTimeRange() on the MemoryAppender instance - Method getEventsInTimeRangeMethod = - memoryAppenderInstance - .getClass() - .getMethod("getEventsInTimeRange", long.class, long.class); - @SuppressWarnings("unchecked") - List retrievedLogs = - (List) - getEventsInTimeRangeMethod.invoke(memoryAppenderInstance, startTimeMs, endTimeMs); - - logs = retrievedLogs != null ? retrievedLogs : new ArrayList<>(); - log.debug( - "Retrieved {} logs within time range {} to {}", logs.size(), startTimeMs, endTimeMs); - } else { - // Use reflection to call getEventsAsList() on the MemoryAppender instance - Method getEventsAsListMethod = - memoryAppenderInstance.getClass().getMethod("getEventsAsList"); - @SuppressWarnings("unchecked") - List retrievedLogs = - (List) getEventsAsListMethod.invoke(memoryAppenderInstance); - - logs = retrievedLogs != null ? retrievedLogs : new ArrayList<>(); - log.debug("Retrieved {} logs (no time range specified)", logs.size()); - } - - return LogCollectionResult.success(logs); - - } catch (ClassNotFoundException e) { - String errorMsg = - "MemoryAppender classes not found - memory logging not available: " + e.getMessage(); - log.warn(errorMsg); - return LogCollectionResult.failure(errorMsg); - } catch (Exception e) { - String errorMsg = - "Failed to retrieve logs from memory appender using reflection: " + e.getMessage(); - log.warn(errorMsg); - return LogCollectionResult.failure(errorMsg); - } - } - - /** Helper method to convert protobuf Timestamp to milliseconds since epoch. */ - private long convertTimestampToMs(com.google.protobuf.Timestamp timestamp) { - return timestamp.getSeconds() * 1000 + timestamp.getNanos() / 1000000; - } - - /** Helper method to create a protobuf Timestamp from milliseconds since epoch. */ - private com.google.protobuf.Timestamp createTimestamp(long timeMs) { - return com.google.protobuf.Timestamp.newBuilder() - .setSeconds(timeMs / 1000) - .setNanos((int) ((timeMs % 1000) * 1000000)) - .build(); - } - - /** - * Collects metrics for all HikariCP connection pools in the application. - * - * @return List of HikariPoolMetrics objects with data about each connection pool - */ - private List collectHikariMetrics() { - log.info("Collecting HikariCP connection pool metrics..."); - List poolMetrics = new ArrayList<>(); - - try { - // Get the platform MBean server - MBeanServer mBeanServer = ManagementFactory.getPlatformMBeanServer(); - - // Search for all HikariCP pools - Set hikariPoolNames = - mBeanServer.queryNames(new ObjectName("com.zaxxer.hikari:type=Pool *"), null); - - if (hikariPoolNames.isEmpty()) { - log.info("No HikariCP connection pools found."); - return poolMetrics; - } - - log.info("Found {} HikariCP connection pool(s)", hikariPoolNames.size()); - - // For each pool, collect metrics - for (ObjectName poolName : hikariPoolNames) { - String poolNameStr = poolName.getKeyProperty("type").split(" ")[1]; - if (poolNameStr.startsWith("(") && poolNameStr.endsWith(")")) { - poolNameStr = poolNameStr.substring(1, poolNameStr.length() - 1); - } - - try { - // Basic metrics - int activeConnections = (Integer) mBeanServer.getAttribute(poolName, "ActiveConnections"); - int idleConnections = (Integer) mBeanServer.getAttribute(poolName, "IdleConnections"); - int totalConnections = (Integer) mBeanServer.getAttribute(poolName, "TotalConnections"); - int threadsAwaitingConnection = - (Integer) mBeanServer.getAttribute(poolName, "ThreadsAwaitingConnection"); - - // Create builder for this pool - HikariPoolMetrics.Builder poolMetricBuilder = - HikariPoolMetrics.newBuilder() - .setPoolName(poolNameStr) - .setActiveConnections(activeConnections) - .setIdleConnections(idleConnections) - .setTotalConnections(totalConnections) - .setThreadsAwaitingConnection(threadsAwaitingConnection); - - // Try to get configuration metrics - try { - ObjectName configName = - new ObjectName("com.zaxxer.hikari:type=PoolConfig (" + poolNameStr + ")"); - if (mBeanServer.isRegistered(configName)) { - long connectionTimeout = - (Long) mBeanServer.getAttribute(configName, "ConnectionTimeout"); - long validationTimeout = - (Long) mBeanServer.getAttribute(configName, "ValidationTimeout"); - long idleTimeout = (Long) mBeanServer.getAttribute(configName, "IdleTimeout"); - long maxLifetime = (Long) mBeanServer.getAttribute(configName, "MaxLifetime"); - int minimumIdle = (Integer) mBeanServer.getAttribute(configName, "MinimumIdle"); - int maximumPoolSize = - (Integer) mBeanServer.getAttribute(configName, "MaximumPoolSize"); - long leakDetectionThreshold = - (Long) mBeanServer.getAttribute(configName, "LeakDetectionThreshold"); - String poolName_ = (String) mBeanServer.getAttribute(configName, "PoolName"); - - // Build pool config - HikariPoolMetrics.PoolConfig.Builder configBuilder = - HikariPoolMetrics.PoolConfig.newBuilder() - .setPoolName(poolName_) - .setConnectionTimeout(connectionTimeout) - .setValidationTimeout(validationTimeout) - .setIdleTimeout(idleTimeout) - .setMaxLifetime(maxLifetime) - .setMinimumIdle(minimumIdle) - .setMaximumPoolSize(maximumPoolSize) - .setLeakDetectionThreshold(leakDetectionThreshold); - - // Get JDBC URL and username from plugin configuration - String jdbcUrl = ""; - String username = ""; - try { - Collection plugins = - applicationContext.getBeansOfType(PluginInterface.class); - for (PluginInterface plugin : plugins) { - var pluginStatus = plugin.getPluginStatus(); - Map internalConfig = pluginStatus.internalConfig(); - - if (internalConfig != null && !internalConfig.isEmpty()) { - Object jdbcUrlObj = internalConfig.get("jdbc_url"); - Object jdbcUserObj = internalConfig.get("jdbc_user"); - - if (jdbcUrlObj != null && jdbcUserObj != null) { - jdbcUrl = jdbcUrlObj.toString(); - username = jdbcUserObj.toString(); - log.debug( - "Retrieved JDBC config from plugin: url={}, user={}", jdbcUrl, username); - break; - } - } - } - } catch (Exception e) { - log.warn("Failed to get JDBC config from plugins: {}", e.getMessage()); - } - - configBuilder.setJdbcUrl(jdbcUrl).setUsername(username); - - poolMetricBuilder.setPoolConfig(configBuilder); - } - } catch (Exception e) { - log.debug("Error accessing pool configuration: {}", e.getMessage()); - } - - // Collect extended metrics if available - Map extendedMetrics = new HashMap<>(); - try { - Set metricNames = - mBeanServer.queryNames( - new ObjectName("metrics:name=hikaricp." + poolNameStr + ".*"), null); - - for (ObjectName metricName : metricNames) { - String metricType = metricName.getKeyProperty("name"); - try { - Object value = mBeanServer.getAttribute(metricName, "Value"); - extendedMetrics.put(metricType, String.valueOf(value)); - } catch (Exception e) { - log.debug("Could not get metric value for {}: {}", metricType, e.getMessage()); - } - } - } catch (Exception e) { - log.debug("Error accessing Dropwizard metrics: {}", e.getMessage()); - } - - poolMetricBuilder.putAllExtendedMetrics(extendedMetrics); - poolMetrics.add(poolMetricBuilder.build()); - - } catch (Exception e) { - log.error("Error getting metrics for pool {}: {}", poolNameStr, e.getMessage()); - } - } - } catch (Exception e) { - log.error("Error collecting HikariCP metrics", e); - } - - return poolMetrics; - } - - /** - * Collects configuration details from the config file. - * - * @return ConfigDetails object with API endpoint and certificate information - */ - private ConfigDetails collectConfigDetails() { - log.info("Collecting configuration file details..."); - ConfigDetails.Builder configBuilder = ConfigDetails.newBuilder(); - - // Define the config paths and filename - final String CONFIG_FILE_NAME = "com.tcn.exiles.sati.config.cfg"; - final List watchList = List.of(Path.of("/workdir/config"), Path.of("workdir/config")); - - try { - // Find the first valid directory - Optional configDir = - watchList.stream().filter(path -> path.toFile().exists()).findFirst(); - - if (configDir.isEmpty()) { - log.error("No valid config directory found"); - return configBuilder.build(); - } - - Path configFile = configDir.get().resolve(CONFIG_FILE_NAME); - if (!configFile.toFile().exists()) { - log.error("Config file not found: {}", configFile); - return configBuilder.build(); - } - - log.info("Found config file: {}", configFile); - byte[] base64EncodedData = Files.readAllBytes(configFile); - String dataString = new String(base64EncodedData); - - // Trim and ensure proper base64 length - byte[] data = dataString.trim().getBytes(); - byte[] decodedData; - - try { - decodedData = Base64.getDecoder().decode(data); - } catch (IllegalArgumentException e) { - // Try removing the last byte which might be a newline - log.debug("Failed to decode base64, trying with truncated data"); - decodedData = Base64.getDecoder().decode(Arrays.copyOf(data, data.length - 1)); - } - - // Convert to string for processing - String jsonString = new String(decodedData); - - // Extract JSON values - String apiEndpoint = extractJsonValue(jsonString, "api_endpoint"); - String certificateName = extractJsonValue(jsonString, "certificate_name"); - String certificateDescription = extractJsonValue(jsonString, "certificate_description"); - - // Build Config Details - configBuilder - .setApiEndpoint(apiEndpoint) - .setCertificateName(certificateName) - .setCertificateDescription(certificateDescription); - - } catch (IOException e) { - log.error("Error reading or parsing config file", e); - } catch (Exception e) { - log.error("Unexpected error processing config file", e); - } - - return configBuilder.build(); - } - - /** - * Simple method to extract a JSON value by key from a JSON string. This is a basic implementation - * that works for first-level string values in JSON. - */ - private String extractJsonValue(String jsonString, String key) { - String searchPattern = "\"" + key + "\"\\s*:\\s*\"([^\"]*)\""; - java.util.regex.Pattern pattern = java.util.regex.Pattern.compile(searchPattern); - java.util.regex.Matcher matcher = pattern.matcher(jsonString); - - if (matcher.find()) { - return matcher.group(1); - } - return "Not found"; - } - - /** - * Get JDBC connection details for diagnostic purposes. - * - * @return Map containing JDBC connection information - */ - public Map getJdbcConnectionDetails() { - Map jdbcDetails = new HashMap<>(); - - try { - MBeanServer mBeanServer = ManagementFactory.getPlatformMBeanServer(); - Set hikariPoolNames = - mBeanServer.queryNames(new ObjectName("com.zaxxer.hikari:type=Pool *"), null); - - for (ObjectName poolName : hikariPoolNames) { - String poolNameStr = poolName.getKeyProperty("type").split(" ")[1]; - if (poolNameStr.startsWith("(") && poolNameStr.endsWith(")")) { - poolNameStr = poolNameStr.substring(1, poolNameStr.length() - 1); - } - - Map poolInfo = new HashMap<>(); - - // Get basic connection metrics - poolInfo.put("activeConnections", mBeanServer.getAttribute(poolName, "ActiveConnections")); - poolInfo.put("idleConnections", mBeanServer.getAttribute(poolName, "IdleConnections")); - poolInfo.put("totalConnections", mBeanServer.getAttribute(poolName, "TotalConnections")); - - // Get configuration details - try { - ObjectName configName = - new ObjectName("com.zaxxer.hikari:type=PoolConfig (" + poolNameStr + ")"); - if (mBeanServer.isRegistered(configName)) { - poolInfo.put("jdbcUrl", mBeanServer.getAttribute(configName, "JdbcUrl")); - poolInfo.put("username", mBeanServer.getAttribute(configName, "Username")); - - // Try to get driver class name - try { - poolInfo.put( - "driverClassName", mBeanServer.getAttribute(configName, "DriverClassName")); - } catch (Exception e) { - log.debug("Driver class name not available: {}", e.getMessage()); - poolInfo.put("driverClassName", "Unknown"); - } - } - } catch (Exception e) { - log.debug("Error accessing pool configuration: {}", e.getMessage()); - } - - jdbcDetails.put(poolNameStr, poolInfo); - } - } catch (Exception e) { - log.error("Error collecting JDBC connection details", e); - } - - return jdbcDetails; - } - - /** - * Get event stream statistics from Plugin instances directly. - * - * @return Map containing event stream stats - */ - public Map getEventStreamStats() { - Map eventStreamStats = new HashMap<>(); - - try { - // Get all Plugin instances from the application context - Collection plugins = - applicationContext.getBeansOfType(PluginInterface.class); - - if (plugins.isEmpty()) { - log.info("No Plugin instances found"); - eventStreamStats.put("status", "Not available"); - return eventStreamStats; - } - - log.info("Found {} plugin instance(s)", plugins.size()); - - for (PluginInterface plugin : plugins) { - try { - var pluginStatus = plugin.getPluginStatus(); - String pluginName = pluginStatus.name(); // PluginStatus is a record - - Map streamInfo = new HashMap<>(); - streamInfo.put("status", pluginStatus.running() ? "running" : "stopped"); - streamInfo.put("maxJobs", pluginStatus.queueMaxSize()); - streamInfo.put("runningJobs", pluginStatus.queueActiveCount()); - streamInfo.put("completedJobs", pluginStatus.queueCompletedJobs()); - streamInfo.put("queuedJobs", pluginStatus.queueActiveCount()); - - eventStreamStats.put(pluginName + "-plugin", streamInfo); - - log.debug( - "Collected stats for plugin {}: running={}, maxJobs={}, activeJobs={}, completedJobs={}", - pluginName, - pluginStatus.running(), - pluginStatus.queueMaxSize(), - pluginStatus.queueActiveCount(), - pluginStatus.queueCompletedJobs()); - - } catch (Exception e) { - log.error("Error getting stats for plugin: {}", e.getMessage(), e); - } - } - - } catch (Exception e) { - log.error("Error collecting plugin-based event stream stats", e); - eventStreamStats.put("error", e.getMessage()); - } - - return eventStreamStats; - } - - /** - * Collects event stream statistics for the application's event stream. - * - * @return EventStreamStats object with aggregated data about the application's event stream - */ - private EventStreamStats collectEventStreamStats() { - log.info("Collecting event stream statistics..."); - - try { - // Get all Plugin instances from the application context - Collection plugins = - applicationContext.getBeansOfType(PluginInterface.class); - - if (plugins.isEmpty()) { - log.info("No Plugin instances found"); - return EventStreamStats.newBuilder() - .setStreamName("application") - .setStatus("not available") - .setMaxJobs(-1) - .setRunningJobs(-1) - .setCompletedJobs(-1) - .setQueuedJobs(-1) - .build(); - } - - log.info("Found {} plugin instance(s)", plugins.size()); - - // Aggregate stats from all plugins - String overallStatus = "stopped"; - int totalMaxJobs = 0; - int totalRunningJobs = 0; - long totalCompletedJobs = 0; - int totalQueuedJobs = 0; - boolean anyPluginRunning = false; - - for (PluginInterface plugin : plugins) { - try { - var pluginStatus = plugin.getPluginStatus(); - - if (pluginStatus.running()) { - anyPluginRunning = true; - } - - totalMaxJobs += pluginStatus.queueMaxSize(); - totalRunningJobs += pluginStatus.queueActiveCount(); - totalCompletedJobs += pluginStatus.queueCompletedJobs(); - - // Calculate queued jobs as max - active - int queuedJobs = - Math.max(0, pluginStatus.queueMaxSize() - pluginStatus.queueActiveCount()); - totalQueuedJobs += queuedJobs; - - log.debug( - "Plugin stats: running={}, maxJobs={}, activeJobs={}, completedJobs={}", - pluginStatus.running(), - pluginStatus.queueMaxSize(), - pluginStatus.queueActiveCount(), - pluginStatus.queueCompletedJobs()); - - } catch (Exception e) { - log.error("Error getting stats for plugin: {}", e.getMessage(), e); - } - } - - // Determine overall status - if (anyPluginRunning) { - overallStatus = "running"; - } else if (totalMaxJobs > 0) { - overallStatus = "stopped"; - } else { - overallStatus = "not configured"; - } - - // Create aggregated EventStreamStats - return EventStreamStats.newBuilder() - .setStreamName("application") - .setStatus(overallStatus) - .setMaxJobs(totalMaxJobs) - .setRunningJobs(totalRunningJobs) - .setCompletedJobs(totalCompletedJobs) - .setQueuedJobs(totalQueuedJobs) - .build(); - - } catch (Exception e) { - log.error("Error collecting event stream statistics", e); - return EventStreamStats.newBuilder() - .setStreamName("application") - .setStatus("error: " + e.getMessage()) - .setMaxJobs(-1) - .setRunningJobs(-1) - .setCompletedJobs(-1) - .setQueuedJobs(-1) - .build(); - } - } -} diff --git a/core/src/main/java/com/tcn/exile/gateclients/UnconfiguredException.java b/core/src/main/java/com/tcn/exile/gateclients/UnconfiguredException.java deleted file mode 100644 index 1ce2785..0000000 --- a/core/src/main/java/com/tcn/exile/gateclients/UnconfiguredException.java +++ /dev/null @@ -1,35 +0,0 @@ -/* - * (C) 2017-2024 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.gateclients; - -public class UnconfiguredException extends Exception { - public UnconfiguredException() { - super(); - } - - public UnconfiguredException(String message) { - super(message); - } - - public UnconfiguredException(String message, Throwable cause) { - super(message, cause); - } - - public UnconfiguredException(Throwable cause) { - super(cause); - } -} diff --git a/core/src/main/java/com/tcn/exile/gateclients/v2/BuildVersion.java b/core/src/main/java/com/tcn/exile/gateclients/v2/BuildVersion.java deleted file mode 100644 index c69030c..0000000 --- a/core/src/main/java/com/tcn/exile/gateclients/v2/BuildVersion.java +++ /dev/null @@ -1,23 +0,0 @@ -/* - * (C) 2017-2024 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.gateclients.v2; - -public class BuildVersion { - public static String getBuildVersion() { - return BuildVersion.class.getPackage().getImplementationVersion(); - } -} diff --git a/core/src/main/java/com/tcn/exile/gateclients/v2/GateClient.java b/core/src/main/java/com/tcn/exile/gateclients/v2/GateClient.java deleted file mode 100644 index 218c4a7..0000000 --- a/core/src/main/java/com/tcn/exile/gateclients/v2/GateClient.java +++ /dev/null @@ -1,328 +0,0 @@ -/* - * (C) 2017-2025 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.gateclients.v2; - -import build.buf.gen.tcnapi.exile.gate.v2.*; -import com.tcn.exile.config.Config; -import com.tcn.exile.gateclients.UnconfiguredException; -import com.tcn.exile.log.LogCategory; -import com.tcn.exile.log.StructuredLogger; -import com.tcn.exile.models.OrgInfo; -import io.grpc.ManagedChannel; -import io.grpc.StatusRuntimeException; -import java.util.Iterator; -import java.util.concurrent.TimeUnit; - -public class GateClient extends GateClientAbstract { - private static final StructuredLogger log = new StructuredLogger(GateClient.class); - private static final int DEFAULT_TIMEOUT_SECONDS = 30; - - public GateClient(String tenant, Config currentConfig) { - super(tenant, currentConfig); - } - - @Override - public void start() { - // this does not need any implementation - } - - protected GateServiceGrpc.GateServiceBlockingStub getStub(ManagedChannel channel) { - return GateServiceGrpc.newBlockingStub(channel) - .withDeadlineAfter(DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS); - } - - public T executeRequest(String operationName, GrpcOperation grpcOperation) { - try { - var result = grpcOperation.execute(getChannel()); - if (result == null) { - throw new RuntimeException("Received null response from " + operationName); - } - return result; - } catch (UnconfiguredException e) { - log.error( - LogCategory.GRPC, - "OperationFailed", - "Failed to execute %s operation: %s", - operationName, - e.getMessage()); - throw new RuntimeException(e); - } catch (StatusRuntimeException e) { - if (handleStatusRuntimeException(e)) { - log.warn( - LogCategory.GRPC, - "ConnectionIssue", - "Connection issue during %s operation, channel reset: %s", - operationName, - e.getMessage()); - throw new RuntimeException( - "Connection issue during " + operationName + ", please retry", e); - } - log.error( - LogCategory.GRPC, - "GrpcError", - "gRPC error during %s operation: %s (%s)", - operationName, - e.getMessage(), - e.getStatus().getCode()); - throw new RuntimeException("Failed to execute " + operationName, e); - } catch (Exception e) { - log.error( - LogCategory.GRPC, - "UnexpectedError", - "Unexpected error during %s operation: %s", - operationName, - e.getMessage()); - throw new RuntimeException("Failed to execute " + operationName, e); - } - } - - @FunctionalInterface - private interface GrpcOperation { - T execute(ManagedChannel channel); - } - - // Organization details retrieval - public OrgInfo getOrganizationInfo() { - return executeRequest( - "getOrganizationInfo", - client -> { - var result = - getStub(client).getOrganizationInfo(GetOrganizationInfoRequest.newBuilder().build()); - return new OrgInfo(result.getOrgName(), result.getOrgName()); - }); - } - - // Job results submission (max 2MB) - public SubmitJobResultsResponse submitJobResults(SubmitJobResultsRequest request) { - log.info( - LogCategory.GRPC, - "SubmitJobResults", - "GateClient submit job results request: %s", - request.getJobId()); - try { - return executeRequest( - "submitJobResults", - client -> { - var response = getStub(client).submitJobResults(request); - if (response == null) { - throw new RuntimeException("Received null response from submitJobResults"); - } - return response; - }); - } catch (Exception e) { - log.error( - LogCategory.GRPC, - "SubmitJobResultsFailed", - "Failed to submit job results for job %s: %s", - request.getJobId(), - e.getMessage()); - throw new RuntimeException("Failed to submit job results", e); - } - } - - // Agent state management - public GetAgentStatusResponse getAgentStatus(GetAgentStatusRequest request) { - return executeRequest("getAgentStatus", client -> getStub(client).getAgentStatus(request)); - } - - public UpdateAgentStatusResponse updateAgentStatus(UpdateAgentStatusRequest request) { - return executeRequest( - "updateAgentStatus", client -> getStub(client).updateAgentStatus(request)); - } - - public Iterator listAgents(ListAgentsRequest request) { - return executeRequest("listAgents", client -> getStub(client).listAgents(request)); - } - - public UpsertAgentResponse upsertAgent(UpsertAgentRequest request) { - return executeRequest("upsertAgent", client -> getStub(client).upsertAgent(request)); - } - - public GetAgentByIdResponse getAgentById(GetAgentByIdRequest request) { - return executeRequest("getAgentById", client -> getStub(client).getAgentById(request)); - } - - public GetAgentByPartnerIdResponse getAgentByPartnerId(GetAgentByPartnerIdRequest request) { - return executeRequest( - "getAgentByPartnerId", client -> getStub(client).getAgentByPartnerId(request)); - } - - // Telephony operations - public DialResponse dial(DialRequest request) { - return executeRequest("dial", client -> getStub(client).dial(request)); - } - - public ListNCLRulesetNamesResponse listNCLRulesetNames(ListNCLRulesetNamesRequest request) { - return executeRequest( - "listNCLRulesetNames", client -> getStub(client).listNCLRulesetNames(request)); - } - - // Recording controls - public StartCallRecordingResponse startCallRecording(StartCallRecordingRequest request) { - return executeRequest( - "startCallRecording", client -> getStub(client).startCallRecording(request)); - } - - public StopCallRecordingResponse stopCallRecording(StopCallRecordingRequest request) { - return executeRequest( - "stopCallRecording", client -> getStub(client).stopCallRecording(request)); - } - - public GetRecordingStatusResponse getRecordingStatus(GetRecordingStatusRequest request) { - return executeRequest( - "getRecordingStatus", client -> getStub(client).getRecordingStatus(request)); - } - - // Scrub list management - public ListScrubListsResponse listScrubLists(ListScrubListsRequest request) { - return executeRequest("listScrubLists", client -> getStub(client).listScrubLists(request)); - } - - public AddScrubListEntriesResponse addScrubListEntries(AddScrubListEntriesRequest request) { - return executeRequest( - "addScrubListEntries", client -> getStub(client).addScrubListEntries(request)); - } - - public UpdateScrubListEntryResponse updateScrubListEntry(UpdateScrubListEntryRequest request) { - return executeRequest( - "updateScrubListEntry", client -> getStub(client).updateScrubListEntry(request)); - } - - public RemoveScrubListEntriesResponse removeScrubListEntries( - RemoveScrubListEntriesRequest request) { - return executeRequest( - "removeScrubListEntries", client -> getStub(client).removeScrubListEntries(request)); - } - - public LogResponse log(LogRequest request) { - return executeRequest("log", client -> getStub(client).log(request)); - } - - public AddAgentCallResponseResponse addAgentCallResponse(AddAgentCallResponseRequest request) { - return executeRequest( - "addAgentCallResponse", client -> getStub(client).addAgentCallResponse(request)); - } - - public ListHuntGroupPauseCodesResponse listHuntGroupPauseCodes( - ListHuntGroupPauseCodesRequest request) { - return executeRequest( - "listHuntGroupPauseCodes", client -> getStub(client).listHuntGroupPauseCodes(request)); - } - - public PutCallOnSimpleHoldResponse putCallOnSimpleHold(PutCallOnSimpleHoldRequest request) { - return executeRequest( - "putCallOnSimpleHold", client -> getStub(client).putCallOnSimpleHold(request)); - } - - public TakeCallOffSimpleHoldResponse takeCallOffSimpleHold(TakeCallOffSimpleHoldRequest request) { - return executeRequest( - "takeCallOffSimpleHold", client -> getStub(client).takeCallOffSimpleHold(request)); - } - - public HoldTransferMemberCallerResponse holdTransferMemberCaller( - HoldTransferMemberCallerRequest request) { - return executeRequest( - "holdTransferMemberCaller", client -> getStub(client).holdTransferMemberCaller(request)); - } - - public UnholdTransferMemberCallerResponse unholdTransferMemberCaller( - UnholdTransferMemberCallerRequest request) { - return executeRequest( - "unholdTransferMemberCaller", - client -> getStub(client).unholdTransferMemberCaller(request)); - } - - public HoldTransferMemberAgentResponse holdTransferMemberAgent( - HoldTransferMemberAgentRequest request) { - return executeRequest( - "holdTransferMemberAgent", client -> getStub(client).holdTransferMemberAgent(request)); - } - - public UnholdTransferMemberAgentResponse unholdTransferMemberAgent( - UnholdTransferMemberAgentRequest request) { - return executeRequest( - "unholdTransferMemberAgent", client -> getStub(client).unholdTransferMemberAgent(request)); - } - - public MuteAgentResponse muteAgent(MuteAgentRequest request) { - return executeRequest("muteAgent", client -> getStub(client).muteAgent(request)); - } - - public UnmuteAgentResponse unmuteAgent(UnmuteAgentRequest request) { - return executeRequest("unmuteAgent", client -> getStub(client).unmuteAgent(request)); - } - - public RotateCertificateResponse rotateCertificate(RotateCertificateRequest request) { - return executeRequest( - "rotateCertificate", client -> getStub(client).rotateCertificate(request)); - } - - public Iterator searchVoiceRecordings( - SearchVoiceRecordingsRequest request) { - return executeRequest( - "searchVoiceRecordings", client -> getStub(client).searchVoiceRecordings(request)); - } - - public GetVoiceRecordingDownloadLinkResponse getVoiceRecordingDownloadLink( - GetVoiceRecordingDownloadLinkRequest request) { - return executeRequest( - "getVoiceRecordingDownloadLink", - client -> getStub(client).getVoiceRecordingDownloadLink(request)); - } - - public ListSearchableRecordingFieldsResponse listSearchableRecordingFields( - ListSearchableRecordingFieldsRequest request) { - return executeRequest( - "listSearchableRecordingFields", - client -> getStub(client).listSearchableRecordingFields(request)); - } - - public CreateRecordingLabelResponse createRecordingLabel(CreateRecordingLabelRequest request) { - return executeRequest( - "createRecordingLabel", client -> getStub(client).createRecordingLabel(request)); - } - - public ListSkillsResponse ListSkills(ListSkillsRequest request) { - return executeRequest("listSkills", client -> getStub(client).listSkills(request)); - } - - // List all skills assigned to an agent, and their proficiency - public ListAgentSkillsResponse ListAgentSkills(ListAgentSkillsRequest request) { - return executeRequest("listAgentSkills", client -> getStub(client).listAgentSkills(request)); - } - - // Assign a skill to an agent - public AssignAgentSkillResponse AssignAgentSkill(AssignAgentSkillRequest request) { - return executeRequest("assignAgentSkill", client -> getStub(client).assignAgentSkill(request)); - } - - // Unassign a skill from an agent - public UnassignAgentSkillResponse UnassignAgentSkill(UnassignAgentSkillRequest request) { - return executeRequest( - "unassignAgentSkill", client -> getStub(client).unassignAgentSkill(request)); - } - - public TransferResponse transfer(TransferRequest request) { - return executeRequest("transfer", client -> getStub(client).transfer(request)); - } - - public AddRecordToJourneyBufferResponse addRecordToJourneyBuffer( - AddRecordToJourneyBufferRequest request) { - return executeRequest( - "addRecordToJourneyBuffer", client -> getStub(client).addRecordToJourneyBuffer(request)); - } -} diff --git a/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientAbstract.java b/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientAbstract.java deleted file mode 100644 index 36dc224..0000000 --- a/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientAbstract.java +++ /dev/null @@ -1,578 +0,0 @@ -/* - * (C) 2017-2026 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.gateclients.v2; - -import com.tcn.exile.config.Config; -import com.tcn.exile.gateclients.UnconfiguredException; -import io.grpc.Grpc; -import io.grpc.ManagedChannel; -import io.grpc.Status; -import io.grpc.StatusRuntimeException; -import io.grpc.TlsChannelCredentials; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.time.Duration; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ThreadLocalRandom; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.atomic.AtomicReference; -import java.util.concurrent.locks.ReentrantLock; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Base class for bidirectional gRPC stream clients. - * - *

Provides shared infrastructure for stream lifecycle management including exponential backoff - * with jitter, hung connection detection, connection tracking, and graceful shutdown. Subclasses - * implement {@link #runStream()} for stream-specific logic and {@link #getStreamName()} for - * logging. - */ -public abstract class GateClientAbstract { - private static final Logger log = LoggerFactory.getLogger(GateClientAbstract.class); - - // Stream lifecycle constants - protected static final long STREAM_TIMEOUT_MINUTES = 5; - protected static final long HUNG_CONNECTION_THRESHOLD_SECONDS = 45; - - // Backoff configuration - private static final long BACKOFF_BASE_MS = 2000; - private static final long BACKOFF_MAX_MS = 30000; - private static final double BACKOFF_JITTER = 0.2; - - // Channel management - private ManagedChannel sharedChannel; - private final ReentrantLock lock = new ReentrantLock(); - - private Config currentConfig = null; - protected final String tenant; - - // Connection tracking — shared across all stream subclasses - protected final AtomicReference lastMessageTime = new AtomicReference<>(); - protected final AtomicReference lastDisconnectTime = new AtomicReference<>(); - protected final AtomicReference reconnectionStartTime = new AtomicReference<>(); - protected final AtomicReference connectionEstablishedTime = new AtomicReference<>(); - protected final AtomicLong totalReconnectionAttempts = new AtomicLong(0); - protected final AtomicLong successfulReconnections = new AtomicLong(0); - protected final AtomicReference lastErrorType = new AtomicReference<>(); - protected final AtomicLong consecutiveFailures = new AtomicLong(0); - protected final AtomicBoolean isRunning = new AtomicBoolean(false); - - public GateClientAbstract(String tenant, Config currentConfig) { - this.currentConfig = currentConfig; - this.tenant = tenant; - - Runtime.getRuntime() - .addShutdownHook( - new Thread( - () -> { - log.info("Shutdown hook triggered - cleaning up gRPC channels"); - forceShutdownSharedChannel(); - }, - "gRPC-Channel-Cleanup")); - } - - // --------------------------------------------------------------------------- - // Stream lifecycle — template method pattern - // --------------------------------------------------------------------------- - - /** - * Human-readable name for this stream, used in log messages. Override in subclasses that use the - * template {@link #start()} method (e.g. "EventStream", "JobQueue"). - */ - protected String getStreamName() { - return getClass().getSimpleName(); - } - - /** - * Run a single stream session. Called by the template {@link #start()} method. Subclasses that - * use the shared lifecycle must override this. Legacy subclasses that override start() directly - * can ignore it. - */ - protected void runStream() - throws UnconfiguredException, InterruptedException, HungConnectionException { - throw new UnsupportedOperationException( - getStreamName() + " must override runStream() or start()"); - } - - /** - * Template method that handles the full stream lifecycle: backoff, try/catch, error tracking. - * Each invocation represents one connection attempt. - */ - public void start() { - if (isUnconfigured()) { - log.warn("{} is unconfigured, cannot start", getStreamName()); - return; - } - - // Prevent concurrent invocations from the fixed-rate scheduler - if (!isRunning.compareAndSet(false, true)) { - return; - } - - // Exponential backoff: sleep before retrying if we have consecutive failures - long backoffMs = computeBackoffMs(); - if (backoffMs > 0) { - log.debug( - "[{}] Waiting {}ms before reconnect (consecutive failures: {})", - getStreamName(), - backoffMs, - consecutiveFailures.get()); - try { - Thread.sleep(backoffMs); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - isRunning.set(false); - return; - } - } - - try { - log.debug("[{}] Starting, checking configuration status", getStreamName()); - reconnectionStartTime.set(Instant.now()); - resetChannelBackoff(); - runStream(); - } catch (HungConnectionException e) { - log.warn("[{}] Connection appears hung: {}", getStreamName(), e.getMessage()); - lastErrorType.set("HungConnection"); - consecutiveFailures.incrementAndGet(); - } catch (UnconfiguredException e) { - log.error("[{}] Configuration error: {}", getStreamName(), e.getMessage()); - lastErrorType.set("UnconfiguredException"); - consecutiveFailures.incrementAndGet(); - } catch (InterruptedException e) { - log.info("[{}] Interrupted", getStreamName()); - Thread.currentThread().interrupt(); - } catch (Exception e) { - if (isRoutineStreamClosure(e)) { - log.debug("[{}] Stream closed by server (routine reconnect)", getStreamName()); - } else { - log.error("[{}] Error: {}", getStreamName(), e.getMessage(), e); - } - lastErrorType.set(e.getClass().getSimpleName()); - consecutiveFailures.incrementAndGet(); - } finally { - totalReconnectionAttempts.incrementAndGet(); - lastDisconnectTime.set(Instant.now()); - isRunning.set(false); - onStreamDisconnected(); - log.debug("[{}] Stream session ended", getStreamName()); - } - } - - /** Hook called in the finally block of start(). Subclasses can clear observer refs here. */ - protected void onStreamDisconnected() {} - - // --------------------------------------------------------------------------- - // Shared stream helpers - // --------------------------------------------------------------------------- - - /** Record that the first server response was received and the connection is established. */ - protected void onConnectionEstablished() { - connectionEstablishedTime.set(Instant.now()); - successfulReconnections.incrementAndGet(); - consecutiveFailures.set(0); - lastErrorType.set(null); - - log.info( - "[{}] Connection established (took {})", - getStreamName(), - Duration.between(reconnectionStartTime.get(), connectionEstablishedTime.get())); - } - - /** Check if the connection is hung (no messages received within threshold). */ - protected void checkForHungConnection() throws HungConnectionException { - Instant lastMsg = lastMessageTime.get(); - if (lastMsg == null) { - lastMsg = connectionEstablishedTime.get(); - } - if (lastMsg != null - && lastMsg.isBefore( - Instant.now().minus(HUNG_CONNECTION_THRESHOLD_SECONDS, ChronoUnit.SECONDS))) { - throw new HungConnectionException( - "No messages received since " + lastMsg + " - connection appears hung"); - } - } - - /** - * Wait for a stream latch to complete, periodically checking for hung connections. Returns when - * the latch counts down, the timeout expires, or a hung connection is detected. - */ - protected void awaitStreamWithHungDetection(CountDownLatch latch) - throws InterruptedException, HungConnectionException { - long startTime = System.currentTimeMillis(); - long maxDurationMs = TimeUnit.MINUTES.toMillis(STREAM_TIMEOUT_MINUTES); - - while ((System.currentTimeMillis() - startTime) < maxDurationMs) { - if (latch.await(HUNG_CONNECTION_THRESHOLD_SECONDS, TimeUnit.SECONDS)) { - return; // Stream completed - } - checkForHungConnection(); - } - } - - /** Compute backoff delay with jitter based on consecutive failure count. */ - private long computeBackoffMs() { - long failures = consecutiveFailures.get(); - if (failures <= 0) { - return 0; - } - long delayMs = BACKOFF_BASE_MS * (1L << Math.min(failures - 1, 10)); - double jitter = 1.0 + (ThreadLocalRandom.current().nextDouble() * 2 - 1) * BACKOFF_JITTER; - return Math.min((long) (delayMs * jitter), BACKOFF_MAX_MS); - } - - /** - * Build base stream status map with connection tracking fields. Subclasses should call this and - * add their own stream-specific counters. - */ - protected Map buildStreamStatus() { - Instant lastDisconnect = lastDisconnectTime.get(); - Instant connectionEstablished = connectionEstablishedTime.get(); - Instant reconnectStart = reconnectionStartTime.get(); - Instant lastMessage = lastMessageTime.get(); - - Map status = new HashMap<>(); - status.put("isRunning", isRunning.get()); - status.put("totalReconnectionAttempts", totalReconnectionAttempts.get()); - status.put("successfulReconnections", successfulReconnections.get()); - status.put("consecutiveFailures", consecutiveFailures.get()); - status.put("lastDisconnectTime", lastDisconnect != null ? lastDisconnect.toString() : null); - status.put( - "connectionEstablishedTime", - connectionEstablished != null ? connectionEstablished.toString() : null); - status.put("reconnectionStartTime", reconnectStart != null ? reconnectStart.toString() : null); - status.put("lastErrorType", lastErrorType.get()); - status.put("lastMessageTime", lastMessage != null ? lastMessage.toString() : null); - return status; - } - - // --------------------------------------------------------------------------- - // Graceful shutdown - // --------------------------------------------------------------------------- - - /** Override in subclasses to add stream-specific stop logic (e.g. closing observers). */ - public void stop() { - doStop(); - } - - /** Shared shutdown logic: set running flag, shut down channel. */ - protected void doStop() { - isRunning.set(false); - shutdown(); - } - - // --------------------------------------------------------------------------- - // Configuration and channel management - // --------------------------------------------------------------------------- - - public Config getConfig() { - return currentConfig; - } - - public boolean isUnconfigured() { - if (currentConfig == null) { - return true; - } - return currentConfig.isUnconfigured(); - } - - public Map getStatus() { - return Map.of( - "running", true, - "configured", !getConfig().isUnconfigured(), - "api_endpoint", getConfig().getApiEndpoint(), - "org", getConfig().getOrg(), - "expiration_date", getConfig().getExpirationDate(), - "certificate_expiration_date", getConfig().getExpirationDate(), - "certificate_description", getConfig().getCertificateDescription(), - "channel_active", isChannelActive()); - } - - private boolean isChannelActive() { - ManagedChannel channel = sharedChannel; - return channel != null && !channel.isShutdown() && !channel.isTerminated(); - } - - protected void shutdown() { - log.debug("Tenant: {} - Attempting shutdown of static shared gRPC channel.", tenant); - - lock.lock(); - try { - ManagedChannel channelToShutdown = sharedChannel; - if (channelToShutdown != null - && !channelToShutdown.isShutdown() - && !channelToShutdown.isTerminated()) { - log.debug( - "Tenant: {} - Attempting graceful shutdown of shared channel {}", - tenant, - channelToShutdown); - - channelToShutdown.shutdown(); - try { - if (!channelToShutdown.awaitTermination(10, TimeUnit.SECONDS)) { - log.warn( - "Tenant: {} - Shared channel {} did not terminate gracefully after 10s, forcing shutdown.", - tenant, - channelToShutdown); - channelToShutdown.shutdownNow(); - if (!channelToShutdown.awaitTermination(5, TimeUnit.SECONDS)) { - log.error( - "Tenant: {} - Shared channel {} failed to terminate even after forced shutdown.", - tenant, - channelToShutdown); - } - } - log.info( - "Tenant: {} - Successfully shut down shared channel {}", tenant, channelToShutdown); - } catch (InterruptedException e) { - log.error( - "Tenant: {} - Interrupted while waiting for shared channel shutdown, forcing shutdown now.", - tenant, - e); - channelToShutdown.shutdownNow(); - Thread.currentThread().interrupt(); - } - - sharedChannel = null; - } else { - log.debug("Tenant: {} - Shared channel is already null, shut down, or terminated.", tenant); - if (sharedChannel != null && (sharedChannel.isShutdown() || sharedChannel.isTerminated())) { - sharedChannel = null; - } - } - } finally { - lock.unlock(); - } - } - - private void forceShutdownSharedChannel() { - log.info("forceShudown called, aquiring lock"); - lock.lock(); - try { - ManagedChannel channelToShutdown = sharedChannel; - if (channelToShutdown != null) { - log.info("Force shutting down shared gRPC channel during application shutdown"); - channelToShutdown.shutdownNow(); - try { - if (!channelToShutdown.awaitTermination(5, TimeUnit.SECONDS)) { - log.warn("Shared channel did not terminate within 5 seconds during force shutdown"); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.warn("Interrupted during force shutdown of shared channel"); - } - sharedChannel = null; - } - } finally { - lock.unlock(); - } - } - - public ManagedChannel getChannel() throws UnconfiguredException { - long getChannelStartTime = System.currentTimeMillis(); - ManagedChannel localChannel = sharedChannel; - if (localChannel == null || localChannel.isShutdown() || localChannel.isTerminated()) { - - long beforeLockAcquisition = System.currentTimeMillis(); - log.debug( - "[LOCK-TIMING] getChannel attempting to acquire lock for tenant: {} at {}ms", - tenant, - beforeLockAcquisition); - - lock.lock(); - - long afterLockAcquisition = System.currentTimeMillis(); - long lockWaitTime = afterLockAcquisition - beforeLockAcquisition; - if (lockWaitTime > 100) { - log.warn( - "[LOCK-TIMING] getChannel lock acquired for tenant: {} after {}ms WAIT (potential contention!)", - tenant, - lockWaitTime); - } else { - log.debug( - "[LOCK-TIMING] getChannel lock acquired for tenant: {} after {}ms", - tenant, - lockWaitTime); - } - try { - localChannel = sharedChannel; - - var shutdown = localChannel == null || localChannel.isShutdown(); - var terminated = localChannel == null || localChannel.isTerminated(); - - log.debug( - "localChannel is null: {}, isShutdown: {}, isTerminated: {}", - localChannel == null, - shutdown, - terminated); - - if (localChannel == null || localChannel.isShutdown() || localChannel.isTerminated()) { - long beforeCreateChannel = System.currentTimeMillis(); - log.debug( - "[LOCK-TIMING] getChannel creating new channel for tenant: {} at {}ms (lock held for {}ms so far)", - tenant, - beforeCreateChannel, - beforeCreateChannel - afterLockAcquisition); - - localChannel = createNewChannel(); - - long afterCreateChannel = System.currentTimeMillis(); - log.debug( - "[LOCK-TIMING] getChannel channel created for tenant: {} at {}ms (createNewChannel took: {}ms, lock held total: {}ms)", - tenant, - afterCreateChannel, - afterCreateChannel - beforeCreateChannel, - afterCreateChannel - afterLockAcquisition); - - sharedChannel = localChannel; - } - } catch (Exception e) { - log.error( - "Tenant: {} - Error creating new static shared gRPC channel for config: {}", - tenant, - getConfig(), - e); - throw new UnconfiguredException("Error creating new static shared gRPC channel", e); - } finally { - long beforeUnlock = System.currentTimeMillis(); - lock.unlock(); - long afterUnlock = System.currentTimeMillis(); - long totalLockHeldTime = afterUnlock - afterLockAcquisition; - log.debug( - "[LOCK-TIMING] getChannel lock released for tenant: {} at {}ms (lock held for {}ms, total getChannel: {}ms)", - tenant, - afterUnlock, - totalLockHeldTime, - afterUnlock - getChannelStartTime); - } - } else { - log.debug( - "getChannel no new channel needed, returning localChannel null: {}, isShutdown: {}, isTerminated: {}", - localChannel == null, - localChannel.isShutdown(), - localChannel.isTerminated()); - } - - return localChannel; - } - - private ManagedChannel createNewChannel() throws UnconfiguredException { - try { - var channelCredentials = - TlsChannelCredentials.newBuilder() - .trustManager(new ByteArrayInputStream(getConfig().getRootCert().getBytes())) - .keyManager( - new ByteArrayInputStream(getConfig().getPublicCert().getBytes()), - new ByteArrayInputStream(getConfig().getPrivateKey().getBytes())) - .build(); - - var hostname = getConfig().getApiHostname(); - var port = getConfig().getApiPort(); - var chan = - Grpc.newChannelBuilderForAddress(hostname, port, channelCredentials) - .keepAliveTime(32, TimeUnit.SECONDS) - .keepAliveTimeout(30, TimeUnit.SECONDS) - .keepAliveWithoutCalls(true) - .idleTimeout(30, TimeUnit.MINUTES) - .overrideAuthority("exile-proxy") - .build(); - log.info("Managed Channel created for {}:{}", hostname, port); - - return chan; - - } catch (IOException e) { - log.error("Tenant: {} - IOException during shared channel creation", tenant, e); - throw new UnconfiguredException( - "TCN Gate client configuration error during channel creation", e); - } catch (UnconfiguredException e) { - log.error("Tenant: {} - Configuration error during shared channel creation", tenant, e); - throw e; - } catch (Exception e) { - log.error("Tenant: {} - Unexpected error during shared channel creation", tenant, e); - throw new UnconfiguredException("Unexpected error configuring TCN Gate client channel", e); - } - } - - /** - * Reset gRPC's internal connect backoff on the shared channel. This forces the name resolver to - * immediately retry DNS resolution instead of waiting for its own exponential backoff timer. - */ - protected void resetChannelBackoff() { - ManagedChannel channel = sharedChannel; - if (channel != null && !channel.isShutdown() && !channel.isTerminated()) { - channel.resetConnectBackoff(); - } - } - - /** Resets the gRPC channel after a connection failure. */ - protected void resetChannel() { - log.info("Tenant: {} - Resetting static shared gRPC channel after connection failure.", tenant); - shutdown(); - } - - /** Handle StatusRuntimeException, resetting channel on UNAVAILABLE. */ - protected boolean handleStatusRuntimeException(StatusRuntimeException e) { - if (e.getStatus().getCode() == Status.Code.UNAVAILABLE) { - log.warn( - "Tenant: {} - Connection unavailable, resetting channel: {}", tenant, e.getMessage()); - resetChannel(); - return true; - } - return false; - } - - /** Get channel statistics for monitoring. */ - public Map getChannelStats() { - ManagedChannel channel = sharedChannel; - if (channel != null) { - return Map.of( - "isShutdown", channel.isShutdown(), - "isTerminated", channel.isTerminated(), - "authority", channel.authority(), - "state", channel.getState(false).toString()); - } - return Map.of("channel", "null"); - } - - /** Exception for hung connection detection. */ - public static class HungConnectionException extends Exception { - public HungConnectionException(String message) { - super(message); - } - } - - private boolean isRoutineStreamClosure(Exception e) { - Throwable cause = e; - while (cause != null) { - if (cause instanceof StatusRuntimeException sre - && sre.getStatus().getCode() == Status.Code.UNAVAILABLE - && sre.getMessage() != null - && sre.getMessage().contains("NO_ERROR")) { - return true; - } - cause = cause.getCause(); - } - return false; - } -} diff --git a/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientConfiguration.java b/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientConfiguration.java deleted file mode 100644 index 33c7ed4..0000000 --- a/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientConfiguration.java +++ /dev/null @@ -1,150 +0,0 @@ -/* - * (C) 2017-2025 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.gateclients.v2; - -import build.buf.gen.tcnapi.exile.gate.v2.GateServiceGrpc; -import build.buf.gen.tcnapi.exile.gate.v2.GetClientConfigurationRequest; -import build.buf.gen.tcnapi.exile.gate.v2.GetClientConfigurationResponse; -import com.tcn.exile.config.Config; -import com.tcn.exile.gateclients.UnconfiguredException; -import com.tcn.exile.models.PluginConfigEvent; -import com.tcn.exile.plugin.PluginInterface; -import io.grpc.StatusRuntimeException; -import io.micronaut.scheduling.annotation.Scheduled; -import java.util.concurrent.TimeUnit; - -public class GateClientConfiguration extends GateClientAbstract { - PluginConfigEvent event = null; - - PluginInterface plugin; - - protected static final org.slf4j.Logger log = - org.slf4j.LoggerFactory.getLogger(GateClientConfiguration.class); - - /** Redacts sensitive information from JSON configuration payload. */ - private String redactConfigPayloadForLogging(String payload) { - if (payload == null || payload.isBlank()) { - return payload; - } - - // Redact password field - String redacted = - payload.replaceAll( - "\"database_password\"\\s*:\\s*\"[^\"]*\"", "\"database_password\":\"***REDACTED***\""); - - // Redact username field - redacted = - redacted.replaceAll( - "\"database_username\"\\s*:\\s*\"[^\"]*\"", "\"database_username\":\"***REDACTED***\""); - - // Redact jdbcUser field - redacted = - redacted.replaceAll("\"jdbcUser\"\\s*:\\s*\"[^\"]*\"", "\"jdbcUser\":\"***REDACTED***\""); - - // Redact certificate fields - redacted = - redacted.replaceAll( - "\"trust_store_cert\"\\s*:\\s*\"[^\"]*\"", - "\"trust_store_cert\":\"***CERTIFICATE_REDACTED***\""); - redacted = - redacted.replaceAll( - "\"key_store_cert\"\\s*:\\s*\"[^\"]*\"", - "\"key_store_cert\":\"***CERTIFICATE_REDACTED***\""); - - return redacted; - } - - /** Creates a redacted version of PluginConfigEvent for safe logging. */ - private String redactEventForLogging(PluginConfigEvent event) { - if (event == null) { - return "null"; - } - - return String.format( - "PluginConfigEvent{orgId='%s', orgName='%s', configurationName='%s', configurationPayload='%s', unconfigured=%s}", - event.getOrgId(), - event.getOrgName(), - event.getConfigurationName(), - redactConfigPayloadForLogging(event.getConfigurationPayload()), - event.isUnconfigured()); - } - - /** Creates a redacted version of GetClientConfigurationResponse for safe logging. */ - private String redactResponseForLogging(GetClientConfigurationResponse response) { - if (response == null) { - return "null"; - } - - return String.format( - "GetClientConfigurationResponse{orgId='%s', orgName='%s', configName='%s', configPayload='%s'}", - response.getOrgId(), - response.getOrgName(), - response.getConfigName(), - redactConfigPayloadForLogging(response.getConfigPayload())); - } - - public GateClientConfiguration(String tenant, Config currentConfig, PluginInterface plugin) { - super(tenant, currentConfig); - this.plugin = plugin; - this.event = new PluginConfigEvent(this).setOrgId(currentConfig.getOrg()).setUnconfigured(true); - } - - @Override - @Scheduled(fixedDelay = "10s") - public void start() { - try { - var client = - GateServiceGrpc.newBlockingStub(getChannel()) - .withDeadlineAfter(300, TimeUnit.SECONDS) - .withWaitForReady(); - GetClientConfigurationResponse response = - client.getClientConfiguration(GetClientConfigurationRequest.newBuilder().build()); - - log.debug( - "Tenant: {} got config response: {} current event: {}", - this.tenant, - redactResponseForLogging(response), - redactEventForLogging(this.event)); - var newEvent = - new PluginConfigEvent(this) - .setConfigurationName(response.getConfigName()) - .setConfigurationPayload(response.getConfigPayload()) - .setOrgId(response.getOrgId()) - .setOrgName(response.getOrgName()) - .setUnconfigured(false); - if (!newEvent.equals(event)) { - log.debug( - "Tenant: {} - Received new configuration event: {}, update plugin config", - tenant, - redactEventForLogging(newEvent)); - event = newEvent; - this.plugin.setConfig(event); - } - } catch (UnconfiguredException e) { - log.debug("Tenant: {} - Configuration not set, skipping get client configuration", tenant); - } catch (StatusRuntimeException e) { - if (!handleStatusRuntimeException(e)) { - log.error("Tenant: {} - Failed to get client configuration", tenant, e); - } else { - log.warn( - "Tenant: {} - Transient gRPC error while getting client configuration, will retry", - tenant, - e); - } - } - } -} diff --git a/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientEventStream.java b/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientEventStream.java deleted file mode 100644 index 39e6011..0000000 --- a/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientEventStream.java +++ /dev/null @@ -1,413 +0,0 @@ -/* - * (C) 2017-2026 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.gateclients.v2; - -import build.buf.gen.tcnapi.exile.gate.v2.Event; -import build.buf.gen.tcnapi.exile.gate.v2.EventStreamRequest; -import build.buf.gen.tcnapi.exile.gate.v2.EventStreamResponse; -import build.buf.gen.tcnapi.exile.gate.v2.GateServiceGrpc; -import com.tcn.exile.config.Config; -import com.tcn.exile.gateclients.UnconfiguredException; -import com.tcn.exile.log.LogCategory; -import com.tcn.exile.log.StructuredLogger; -import com.tcn.exile.plugin.PluginInterface; -import io.grpc.stub.StreamObserver; -import jakarta.annotation.PreDestroy; -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.atomic.AtomicReference; - -/** - * Bidirectional event streaming with acknowledgment using the EventStream API. - * - *

The client drives the loop: sends a request (with ACK IDs + event count), server responds with - * events, client processes them and sends the next request with ACKs. This repeats until the - * server's 5-minute context timeout expires. - */ -public class GateClientEventStream extends GateClientAbstract { - private static final StructuredLogger log = new StructuredLogger(GateClientEventStream.class); - - private static final int BATCH_SIZE = 100; - - private final PluginInterface plugin; - private final AtomicLong eventsProcessed = new AtomicLong(0); - private final AtomicLong eventsFailed = new AtomicLong(0); - - // Pending ACKs buffer — survives stream reconnections. - // Event IDs are added after successful processing and removed only after - // successful send to the server. If a stream breaks before ACKs are sent, - // they carry over to the next connection attempt. - private final List pendingAcks = new ArrayList<>(); - - // Stream observer for sending requests/ACKs - private final AtomicReference> requestObserverRef = - new AtomicReference<>(); - - public GateClientEventStream(String tenant, Config currentConfig, PluginInterface plugin) { - super(tenant, currentConfig); - this.plugin = plugin; - } - - @Override - protected String getStreamName() { - return "EventStream"; - } - - @Override - protected void onStreamDisconnected() { - requestObserverRef.set(null); - } - - @Override - protected void runStream() - throws UnconfiguredException, InterruptedException, HungConnectionException { - var latch = new CountDownLatch(1); - var errorRef = new AtomicReference(); - var firstResponseReceived = new AtomicBoolean(false); - - var responseObserver = - new StreamObserver() { - @Override - public void onNext(EventStreamResponse response) { - lastMessageTime.set(Instant.now()); - - if (firstResponseReceived.compareAndSet(false, true)) { - onConnectionEstablished(); - } - - if (response.getEventsCount() == 0) { - log.debug( - LogCategory.GRPC, "EmptyBatch", "Received empty event batch, requesting next"); - // Brief pause to avoid hot-looping when no events are available. - // The server responds instantly to empty polls, so without this - // delay we'd loop every ~40ms burning CPU and network. - try { - Thread.sleep(10000); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return; - } - sendNextRequest(); - return; - } - - log.debug( - LogCategory.GRPC, - "EventsReceived", - "Received %d events from stream", - response.getEventsCount()); - - processBatch(response.getEventsList()); - sendNextRequest(); - } - - @Override - public void onError(Throwable t) { - log.warn(LogCategory.GRPC, "StreamError", "Event stream error: %s", t.getMessage()); - errorRef.set(t); - latch.countDown(); - } - - @Override - public void onCompleted() { - log.info(LogCategory.GRPC, "StreamCompleted", "Event stream completed by server"); - latch.countDown(); - } - }; - - // Open bidirectional stream - var requestObserver = GateServiceGrpc.newStub(getChannel()).eventStream(responseObserver); - requestObserverRef.set(requestObserver); - - // Send initial request — include any pending ACKs from a previous broken stream - List carryOverAcks; - synchronized (pendingAcks) { - carryOverAcks = new ArrayList<>(pendingAcks); - } - if (!carryOverAcks.isEmpty()) { - log.info( - LogCategory.GRPC, - "CarryOverAcks", - "Sending %d pending ACKs from previous stream session", - carryOverAcks.size()); - } - log.debug(LogCategory.GRPC, "Init", "Sending initial event stream request..."); - requestObserver.onNext( - EventStreamRequest.newBuilder() - .setEventCount(BATCH_SIZE) - .addAllAckEventIds(carryOverAcks) - .build()); - if (!carryOverAcks.isEmpty()) { - synchronized (pendingAcks) { - pendingAcks.removeAll(carryOverAcks); - } - } - - awaitStreamWithHungDetection(latch); - - var error = errorRef.get(); - if (error != null) { - throw new RuntimeException("Stream error", error); - } - } - - // --------------------------------------------------------------------------- - // Request & ACK management - // --------------------------------------------------------------------------- - - /** - * Send the next request on the stream, draining pendingAcks into the request. If the send fails, - * the ACKs remain in pendingAcks for the next attempt or reconnection. - */ - private void sendNextRequest() { - var observer = requestObserverRef.get(); - if (observer == null) { - log.warn(LogCategory.GRPC, "RequestFailed", "Cannot send next request - no active observer"); - return; - } - - List acksToSend; - synchronized (pendingAcks) { - acksToSend = new ArrayList<>(pendingAcks); - } - - try { - var request = - EventStreamRequest.newBuilder() - .setEventCount(BATCH_SIZE) - .addAllAckEventIds(acksToSend) - .build(); - observer.onNext(request); - - if (!acksToSend.isEmpty()) { - synchronized (pendingAcks) { - pendingAcks.removeAll(acksToSend); - } - log.debug( - LogCategory.GRPC, - "AckSent", - "Sent ACK for %d events, requesting next batch", - acksToSend.size()); - } - } catch (Exception e) { - log.error( - LogCategory.GRPC, - "RequestFailed", - "Failed to send next event stream request (keeping %d pending ACKs): %s", - acksToSend.size(), - e.getMessage()); - } - } - - // --------------------------------------------------------------------------- - // Event processing - // --------------------------------------------------------------------------- - - /** Process a batch of events, adding successful ACK IDs to the pending buffer. */ - private void processBatch(List events) { - long batchStart = System.currentTimeMillis(); - int successCount = 0; - - for (Event event : events) { - String eventId = getEventId(event); - if (processEvent(event)) { - synchronized (pendingAcks) { - pendingAcks.add(eventId); - } - successCount++; - eventsProcessed.incrementAndGet(); - } else { - eventsFailed.incrementAndGet(); - log.warn( - LogCategory.GRPC, - "EventNotAcked", - "Event %s NOT acknowledged - will be redelivered", - eventId); - } - } - - long elapsed = System.currentTimeMillis() - batchStart; - if (successCount > 0 && (elapsed / successCount) > 1000) { - log.warn( - LogCategory.GRPC, - "SlowBatch", - "Event batch completed %d events in %dms, avg %dms per event", - successCount, - elapsed, - elapsed / successCount); - } - } - - /** - * Process a single event by dispatching to the plugin. Returns true if successfully processed. - */ - private boolean processEvent(Event event) { - try { - if (!plugin.isRunning()) { - log.debug( - LogCategory.GRPC, - "PluginNotRunning", - "Plugin is not running, skipping event processing"); - return false; - } - - switch (event.getEntityCase()) { - case AGENT_CALL: - log.debug( - LogCategory.GRPC, - "EventProcessed", - "Received agent call event %d - %s", - event.getAgentCall().getCallSid(), - event.getAgentCall().getCallType()); - plugin.handleAgentCall(event.getAgentCall()); - break; - case AGENT_RESPONSE: - log.debug( - LogCategory.GRPC, - "EventProcessed", - "Received agent response event %s", - event.getAgentResponse().getAgentCallResponseSid()); - plugin.handleAgentResponse(event.getAgentResponse()); - break; - case TELEPHONY_RESULT: - log.debug( - LogCategory.GRPC, - "EventProcessed", - "Received telephony result event %d - %s", - event.getTelephonyResult().getCallSid(), - event.getTelephonyResult().getCallType()); - plugin.handleTelephonyResult(event.getTelephonyResult()); - break; - case TASK: - log.debug( - LogCategory.GRPC, - "EventProcessed", - "Received task event %s", - event.getTask().getTaskSid()); - plugin.handleTask(event.getTask()); - break; - case TRANSFER_INSTANCE: - log.debug( - LogCategory.GRPC, - "EventProcessed", - "Received transfer instance event %s", - event.getTransferInstance().getTransferInstanceId()); - plugin.handleTransferInstance(event.getTransferInstance()); - break; - case CALL_RECORDING: - log.debug( - LogCategory.GRPC, - "EventProcessed", - "Received call recording event %s", - event.getCallRecording().getRecordingId()); - plugin.handleCallRecording(event.getCallRecording()); - break; - default: - log.warn( - LogCategory.GRPC, "UnknownEvent", "Unknown event type: %s", event.getEntityCase()); - break; - } - - return true; - } catch (Exception e) { - log.error( - LogCategory.GRPC, - "EventProcessingError", - "Failed to process event: %s", - e.getMessage(), - e); - return false; - } - } - - /** - * Extract the server-assigned entity ID for ACK purposes. The server sets exile_entity_id on each - * Event from the DB's entity_id, which may be a composite key (e.g. "call_type,call_sid" for - * telephony). We must echo this exact value back for the ACK to match. - */ - private String getEventId(Event event) { - String entityId = event.getExileEntityId(); - if (entityId == null || entityId.isEmpty()) { - log.warn( - LogCategory.GRPC, - "MissingEntityId", - "Event has no exile_entity_id, cannot ACK. Event type: %s", - event.getEntityCase()); - return "unknown"; - } - return entityId; - } - - // --------------------------------------------------------------------------- - // Lifecycle - // --------------------------------------------------------------------------- - - @Override - public void stop() { - log.info( - LogCategory.GRPC, - "Stopping", - "Stopping GateClientEventStream (total attempts: %d, successful: %d, events: %d/%d)", - totalReconnectionAttempts.get(), - successfulReconnections.get(), - eventsProcessed.get(), - eventsFailed.get()); - - synchronized (pendingAcks) { - if (!pendingAcks.isEmpty()) { - log.warn( - LogCategory.GRPC, - "UnsentAcks", - "Shutting down with %d un-sent ACKs (these events will be redelivered): %s", - pendingAcks.size(), - pendingAcks); - } - } - - var observer = requestObserverRef.get(); - if (observer != null) { - try { - observer.onCompleted(); - } catch (Exception e) { - log.debug(LogCategory.GRPC, "CloseError", "Error closing stream: %s", e.getMessage()); - } - } - - doStop(); - log.info(LogCategory.GRPC, "Stopped", "GateClientEventStream stopped"); - } - - @PreDestroy - public void destroy() { - stop(); - } - - public Map getStreamStatus() { - Map status = buildStreamStatus(); - status.put("eventsProcessed", eventsProcessed.get()); - status.put("eventsFailed", eventsFailed.get()); - synchronized (pendingAcks) { - status.put("pendingAckCount", pendingAcks.size()); - } - return status; - } -} diff --git a/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientJobQueue.java b/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientJobQueue.java deleted file mode 100644 index b9b2784..0000000 --- a/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientJobQueue.java +++ /dev/null @@ -1,364 +0,0 @@ -/* - * (C) 2017-2026 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.gateclients.v2; - -import build.buf.gen.tcnapi.exile.gate.v2.GateServiceGrpc; -import build.buf.gen.tcnapi.exile.gate.v2.JobQueueStreamRequest; -import build.buf.gen.tcnapi.exile.gate.v2.JobQueueStreamResponse; -import build.buf.gen.tcnapi.exile.gate.v2.StreamJobsResponse; -import build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest; -import com.tcn.exile.config.Config; -import com.tcn.exile.gateclients.UnconfiguredException; -import com.tcn.exile.log.LogCategory; -import com.tcn.exile.log.StructuredLogger; -import com.tcn.exile.plugin.PluginInterface; -import io.grpc.stub.StreamObserver; -import jakarta.annotation.PreDestroy; -import java.time.Instant; -import java.util.Map; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.atomic.AtomicReference; - -/** Bidirectional job streaming with acknowledgment using the JobQueueStream API. */ -public class GateClientJobQueue extends GateClientAbstract { - private static final StructuredLogger log = new StructuredLogger(GateClientJobQueue.class); - private static final int DEFAULT_TIMEOUT_SECONDS = 300; - private static final String KEEPALIVE_JOB_ID = "keepalive"; - - private final PluginInterface plugin; - private final AtomicLong jobsProcessed = new AtomicLong(0); - private final AtomicLong jobsFailed = new AtomicLong(0); - - // Stream observer for sending ACKs - private final AtomicReference> requestObserverRef = - new AtomicReference<>(); - - public GateClientJobQueue(String tenant, Config currentConfig, PluginInterface plugin) { - super(tenant, currentConfig); - this.plugin = plugin; - } - - @Override - protected String getStreamName() { - return "JobQueue"; - } - - @Override - protected void onStreamDisconnected() { - requestObserverRef.set(null); - } - - @Override - protected void runStream() - throws UnconfiguredException, InterruptedException, HungConnectionException { - var latch = new CountDownLatch(1); - var errorRef = new AtomicReference(); - var firstResponseReceived = new AtomicBoolean(false); - - var responseObserver = - new StreamObserver() { - @Override - public void onNext(JobQueueStreamResponse response) { - lastMessageTime.set(Instant.now()); - - if (firstResponseReceived.compareAndSet(false, true)) { - onConnectionEstablished(); - } - - if (!response.hasJob()) { - log.debug(LogCategory.GRPC, "Heartbeat", "Received heartbeat from server"); - return; - } - - var job = response.getJob(); - String jobId = job.getJobId(); - - // Handle keepalive — must ACK to register with presence store - if (KEEPALIVE_JOB_ID.equals(jobId)) { - log.debug( - LogCategory.GRPC, "Keepalive", "Received keepalive, sending ACK to register"); - sendAck(KEEPALIVE_JOB_ID); - if (job.hasInfo()) { - try { - plugin.info(KEEPALIVE_JOB_ID, job.getInfo()); - } catch (Exception e) { - log.warn( - LogCategory.GRPC, - "KeepaliveInfo", - "Failed to dispatch info on keepalive: %s", - e.getMessage()); - } - } - return; - } - - log.debug( - LogCategory.GRPC, - "JobReceived", - "Received job: %s (type: %s)", - jobId, - getJobType(job)); - - if (processJob(job)) { - sendAck(jobId); - jobsProcessed.incrementAndGet(); - } else { - jobsFailed.incrementAndGet(); - log.warn( - LogCategory.GRPC, - "JobNotAcked", - "Job %s NOT acknowledged - will be redelivered to another client", - jobId); - } - } - - @Override - public void onError(Throwable t) { - log.warn(LogCategory.GRPC, "StreamError", "Job queue stream error: %s", t.getMessage()); - errorRef.set(t); - latch.countDown(); - } - - @Override - public void onCompleted() { - log.info(LogCategory.GRPC, "StreamCompleted", "Job queue stream completed by server"); - latch.countDown(); - } - }; - - // Open bidirectional stream - var requestObserver = GateServiceGrpc.newStub(getChannel()).jobQueueStream(responseObserver); - requestObserverRef.set(requestObserver); - - // Send initial keepalive to register with server - log.debug(LogCategory.GRPC, "Init", "Sending initial keepalive to job queue..."); - requestObserver.onNext(JobQueueStreamRequest.newBuilder().setJobId(KEEPALIVE_JOB_ID).build()); - - awaitStreamWithHungDetection(latch); - - var error = errorRef.get(); - if (error != null) { - throw new RuntimeException("Stream error", error); - } - } - - // --------------------------------------------------------------------------- - // ACK management - // --------------------------------------------------------------------------- - - /** Send acknowledgment for a job. */ - private void sendAck(String jobId) { - var observer = requestObserverRef.get(); - if (observer == null) { - log.warn( - LogCategory.GRPC, "AckFailed", "Cannot send ACK for job %s - no active observer", jobId); - return; - } - try { - observer.onNext(JobQueueStreamRequest.newBuilder().setJobId(jobId).build()); - log.debug(LogCategory.GRPC, "AckSent", "Sent ACK for job: %s", jobId); - } catch (Exception e) { - log.error( - LogCategory.GRPC, - "AckFailed", - "Failed to send ACK for job %s: %s", - jobId, - e.getMessage()); - } - } - - // --------------------------------------------------------------------------- - // Job processing - // --------------------------------------------------------------------------- - - /** Process a job by dispatching to the plugin. Returns true if successfully processed. */ - private boolean processJob(StreamJobsResponse value) { - long jobStartTime = System.currentTimeMillis(); - - try { - boolean adminJob = isAdminJob(value); - if (!adminJob && !plugin.isRunning()) { - log.warn( - LogCategory.GRPC, - "JobRejected", - "Skipping job %s because database is unavailable (only admin jobs can run)", - value.getJobId()); - submitJobError(value.getJobId(), "Database unavailable; only admin jobs can run"); - return false; - } - - if (value.hasListPools()) { - plugin.listPools(value.getJobId(), value.getListPools()); - } else if (value.hasGetPoolStatus()) { - plugin.getPoolStatus(value.getJobId(), value.getGetPoolStatus()); - } else if (value.hasGetPoolRecords()) { - plugin.getPoolRecords(value.getJobId(), value.getGetPoolRecords()); - } else if (value.hasSearchRecords()) { - plugin.searchRecords(value.getJobId(), value.getSearchRecords()); - } else if (value.hasGetRecordFields()) { - plugin.readFields(value.getJobId(), value.getGetRecordFields()); - } else if (value.hasSetRecordFields()) { - plugin.writeFields(value.getJobId(), value.getSetRecordFields()); - } else if (value.hasCreatePayment()) { - plugin.createPayment(value.getJobId(), value.getCreatePayment()); - } else if (value.hasPopAccount()) { - plugin.popAccount(value.getJobId(), value.getPopAccount()); - } else if (value.hasInfo()) { - plugin.info(value.getJobId(), value.getInfo()); - } else if (value.hasShutdown()) { - plugin.shutdown(value.getJobId(), value.getShutdown()); - } else if (value.hasLogging()) { - plugin.logger(value.getJobId(), value.getLogging()); - } else if (value.hasExecuteLogic()) { - plugin.executeLogic(value.getJobId(), value.getExecuteLogic()); - } else if (value.hasDiagnostics()) { - plugin.runDiagnostics(value.getJobId(), value.getDiagnostics()); - } else if (value.hasListTenantLogs()) { - plugin.listTenantLogs(value.getJobId(), value.getListTenantLogs()); - } else if (value.hasSetLogLevel()) { - plugin.setLogLevel(value.getJobId(), value.getSetLogLevel()); - } else { - log.error( - LogCategory.GRPC, "UnknownJobType", "Unknown job type: %s", value.getUnknownFields()); - } - - long jobDuration = System.currentTimeMillis() - jobStartTime; - log.debug( - LogCategory.GRPC, - "JobCompleted", - "Processed job %s in %d ms", - value.getJobId(), - jobDuration); - return true; - - } catch (UnconfiguredException e) { - long jobDuration = System.currentTimeMillis() - jobStartTime; - log.error( - LogCategory.GRPC, - "JobHandlingError", - "Error while handling job: %s (took %d ms)", - value.getJobId(), - jobDuration, - e); - return false; - } catch (Exception e) { - long jobDuration = System.currentTimeMillis() - jobStartTime; - log.error( - LogCategory.GRPC, - "UnexpectedJobError", - "Unexpected error while handling job: %s (took %d ms)", - value.getJobId(), - jobDuration, - e); - return false; - } - } - - private String getJobType(StreamJobsResponse job) { - if (job.hasListPools()) return "listPools"; - if (job.hasGetPoolStatus()) return "getPoolStatus"; - if (job.hasGetPoolRecords()) return "getPoolRecords"; - if (job.hasSearchRecords()) return "searchRecords"; - if (job.hasGetRecordFields()) return "getRecordFields"; - if (job.hasSetRecordFields()) return "setRecordFields"; - if (job.hasCreatePayment()) return "createPayment"; - if (job.hasPopAccount()) return "popAccount"; - if (job.hasInfo()) return "info"; - if (job.hasShutdown()) return "shutdown"; - if (job.hasLogging()) return "logging"; - if (job.hasExecuteLogic()) return "executeLogic"; - if (job.hasDiagnostics()) return "diagnostics"; - if (job.hasListTenantLogs()) return "listTenantLogs"; - if (job.hasSetLogLevel()) return "setLogLevel"; - return "unknown"; - } - - private boolean isAdminJob(StreamJobsResponse value) { - return value.hasDiagnostics() - || value.hasListTenantLogs() - || value.hasSetLogLevel() - || value.hasShutdown() - || value.hasInfo(); - } - - private void submitJobError(String jobId, String message) { - try { - SubmitJobResultsRequest request = - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setErrorResult( - SubmitJobResultsRequest.ErrorResult.newBuilder().setMessage(message).build()) - .build(); - - GateServiceGrpc.newBlockingStub(getChannel()) - .withDeadlineAfter(DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .withWaitForReady() - .submitJobResults(request); - } catch (Exception e) { - log.error( - LogCategory.GRPC, - "SubmitJobErrorFailed", - "Failed to submit error for job %s: %s", - jobId, - e.getMessage()); - } - } - - // --------------------------------------------------------------------------- - // Lifecycle - // --------------------------------------------------------------------------- - - @Override - public void stop() { - log.info( - LogCategory.GRPC, - "Stopping", - "Stopping GateClientJobQueue (total attempts: %d, successful: %d, jobs: %d/%d)", - totalReconnectionAttempts.get(), - successfulReconnections.get(), - jobsProcessed.get(), - jobsFailed.get()); - - var observer = requestObserverRef.get(); - if (observer != null) { - try { - observer.onCompleted(); - } catch (Exception e) { - log.debug(LogCategory.GRPC, "CloseError", "Error closing stream: %s", e.getMessage()); - } - } - - doStop(); - log.info(LogCategory.GRPC, "Stopped", "GateClientJobQueue stopped"); - } - - @PreDestroy - public void destroy() { - stop(); - } - - public Map getStreamStatus() { - Map status = buildStreamStatus(); - status.put("jobsProcessed", jobsProcessed.get()); - status.put("jobsFailed", jobsFailed.get()); - return status; - } -} diff --git a/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientJobStream.java b/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientJobStream.java deleted file mode 100644 index 3ea6f87..0000000 --- a/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientJobStream.java +++ /dev/null @@ -1,351 +0,0 @@ -/* - * (C) 2017-2025 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.gateclients.v2; - -import build.buf.gen.tcnapi.exile.gate.v2.GateServiceGrpc; -import build.buf.gen.tcnapi.exile.gate.v2.StreamJobsRequest; -import build.buf.gen.tcnapi.exile.gate.v2.StreamJobsResponse; -import build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest; -import com.tcn.exile.config.Config; -import com.tcn.exile.gateclients.UnconfiguredException; -import com.tcn.exile.log.LogCategory; -import com.tcn.exile.log.StructuredLogger; -import com.tcn.exile.plugin.PluginInterface; -import io.grpc.ConnectivityState; -import io.grpc.ManagedChannel; -import io.grpc.stub.StreamObserver; -import jakarta.annotation.PreDestroy; -import java.time.Duration; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.ExecutorCompletionService; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.atomic.AtomicReference; - -/** - * @deprecated Use {@link GateClientJobQueue} instead. This class uses the deprecated StreamJobs - * server-streaming API which has been replaced by the JobQueueStream bidirectional API with - * acknowledgment support. - */ -@Deprecated -public class GateClientJobStream extends GateClientAbstract - implements StreamObserver { - private static final StructuredLogger log = new StructuredLogger(GateClientJobStream.class); - private static final int DEFAULT_TIMEOUT_SECONDS = 300; - - private final PluginInterface plugin; - private final AtomicBoolean establishedForCurrentAttempt = new AtomicBoolean(false); - private final AtomicReference lastMessageTime = new AtomicReference<>(); - - // Connection timing tracking - private final AtomicReference lastDisconnectTime = new AtomicReference<>(); - private final AtomicReference reconnectionStartTime = new AtomicReference<>(); - private final AtomicReference connectionEstablishedTime = new AtomicReference<>(); - private final AtomicLong totalReconnectionAttempts = new AtomicLong(0); - private final AtomicLong successfulReconnections = new AtomicLong(0); - private final AtomicReference lastErrorType = new AtomicReference<>(); - private final AtomicLong consecutiveFailures = new AtomicLong(0); - private final AtomicBoolean isRunning = new AtomicBoolean(false); - - public GateClientJobStream(String tenant, Config currentConfig, PluginInterface plugin) { - super(tenant, currentConfig); - this.plugin = plugin; - } - - private boolean channelIsDead(ConnectivityState state) { - return state.compareTo(ConnectivityState.TRANSIENT_FAILURE) == 0 - || state.compareTo(ConnectivityState.SHUTDOWN) == 0; - } - - private boolean lastJobReceivedTooLongAgo(Instant lastMessageTime) { - return lastMessageTime != null - && lastMessageTime.isBefore(Instant.now().minus(45, ChronoUnit.SECONDS)); - } - - private void blowupIfOurConnectionIsHung(ManagedChannel channel) throws Exception { - var state = channel.getState(false); - if (channelIsDead(state)) { - throw new Exception("JobStream channel is in state " + state.toString()); - } - var lastJobTime = lastMessageTime.get(); - var startOfStream = connectionEstablishedTime.get(); - - // if we haven't received a job ever, then test when we started the stream - if (lastJobTime == null && lastJobReceivedTooLongAgo(startOfStream)) { - throw new Exception( - "JobStream never received any messages since start time at: " - + startOfStream.toString() - + " connection is hung"); - } - if (lastJobReceivedTooLongAgo(lastJobTime)) { - throw new Exception( - "JobStream received last job too long ago at: " - + lastJobTime.toString() - + " connection is hung"); - } - } - - public void start() { - if (isUnconfigured()) { - log.warn(LogCategory.GRPC, "NOOP", "JobStream is unconfigured, cannot stream jobs"); - return; - } - - ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor(); - ExecutorCompletionService completionService = - new ExecutorCompletionService<>(executorService); - - try { - log.debug(LogCategory.GRPC, "Init", "JobStream task started, checking configuration status"); - - log.debug(LogCategory.GRPC, "Start", "Starting JobStream"); - reconnectionStartTime.set(Instant.now()); - ManagedChannel channel = getChannel(); - - var client = - GateServiceGrpc.newBlockingStub(channel) - .withWaitForReady() - .streamJobs(StreamJobsRequest.newBuilder().build()); - - connectionEstablishedTime.set(Instant.now()); - successfulReconnections.incrementAndGet(); - isRunning.set(true); - consecutiveFailures.set(0); - lastErrorType.set(null); - - log.info( - LogCategory.GRPC, - "ConnectionEstablished", - "Job stream connection took {}", - Duration.between(reconnectionStartTime.get(), connectionEstablishedTime.get())); - - completionService.submit( - () -> { - client.forEachRemaining(this::onNext); - return null; - }); - completionService.submit( - () -> { - while (isRunning.get()) { - Thread.sleep(45000); - blowupIfOurConnectionIsHung(channel); - } - return null; - }); - // blow up or wait - completionService.take(); - - } catch (Exception e) { - log.error(LogCategory.GRPC, "JobStream", "error streaming jobs from server: {}", e); - lastErrorType.set(e.getClass().getSimpleName()); - if (connectionEstablishedTime.get() == null) { - consecutiveFailures.incrementAndGet(); - } - } finally { - totalReconnectionAttempts.incrementAndGet(); - lastDisconnectTime.set(Instant.now()); - isRunning.set(false); - log.debug(LogCategory.GRPC, "Complete", "Job stream done"); - - executorService.shutdownNow(); - } - } - - @Override - public void onNext(StreamJobsResponse value) { - long jobStartTime = System.currentTimeMillis(); - log.debug(LogCategory.GRPC, "JobReceived", "Received job: %s", value.getJobId()); - lastMessageTime.set(Instant.now()); - - try { - boolean adminJob = isAdminJob(value); - if (!adminJob && !plugin.isRunning()) { - log.warn( - LogCategory.GRPC, - "JobRejected", - "Skipping job %s because database is unavailable (only admin jobs can run)", - value.getJobId()); - submitJobError(value.getJobId(), "Database unavailable; only admin jobs can run"); - return; - } - - if (value.hasListPools()) { - plugin.listPools(value.getJobId(), value.getListPools()); - } else if (value.hasGetPoolStatus()) { - plugin.getPoolStatus(value.getJobId(), value.getGetPoolStatus()); - } else if (value.hasGetPoolRecords()) { - plugin.getPoolRecords(value.getJobId(), value.getGetPoolRecords()); - } else if (value.hasSearchRecords()) { - plugin.searchRecords(value.getJobId(), value.getSearchRecords()); - } else if (value.hasGetRecordFields()) { - plugin.readFields(value.getJobId(), value.getGetRecordFields()); - } else if (value.hasSetRecordFields()) { - plugin.writeFields(value.getJobId(), value.getSetRecordFields()); - } else if (value.hasCreatePayment()) { - plugin.createPayment(value.getJobId(), value.getCreatePayment()); - } else if (value.hasPopAccount()) { - plugin.popAccount(value.getJobId(), value.getPopAccount()); - } else if (value.hasInfo()) { - plugin.info(value.getJobId(), value.getInfo()); - } else if (value.hasShutdown()) { - plugin.shutdown(value.getJobId(), value.getShutdown()); - } else if (value.hasLogging()) { - plugin.logger(value.getJobId(), value.getLogging()); - } else if (value.hasExecuteLogic()) { - plugin.executeLogic(value.getJobId(), value.getExecuteLogic()); - } else if (value.hasDiagnostics()) { - plugin.runDiagnostics(value.getJobId(), value.getDiagnostics()); - } else if (value.hasListTenantLogs()) { - plugin.listTenantLogs(value.getJobId(), value.getListTenantLogs()); - } else if (value.hasSetLogLevel()) { - plugin.setLogLevel(value.getJobId(), value.getSetLogLevel()); - } else { - log.error( - LogCategory.GRPC, "UnknownJobType", "Unknown job type: %s", value.getUnknownFields()); - } - } catch (UnconfiguredException e) { - long jobDuration = System.currentTimeMillis() - jobStartTime; - log.error( - LogCategory.GRPC, - "JobHandlingError", - "Error while handling job: %s (took %d ms)", - value.getJobId(), - jobDuration, - e); - } catch (Exception e) { - long jobDuration = System.currentTimeMillis() - jobStartTime; - log.error( - LogCategory.GRPC, - "UnexpectedJobError", - "Unexpected error while handling job: %s (took %d ms)", - value.getJobId(), - jobDuration, - e); - } - } - - @Override - public void onError(Throwable t) { - log.error(LogCategory.GRPC, "JobStreamError", "onError received: {}", t); - } - - @Override - public void onCompleted() { - Instant disconnectTime = Instant.now(); - lastDisconnectTime.set(disconnectTime); - lastMessageTime.set(disconnectTime); - - // Calculate connection uptime if we have connection establishment time - Instant connectionTime = connectionEstablishedTime.get(); - String uptimeInfo = ""; - if (connectionTime != null) { - Duration uptime = Duration.between(connectionTime, disconnectTime); - uptimeInfo = - String.format(" (connection was up for %.3f seconds)", uptime.toMillis() / 1000.0); - } - - log.info( - LogCategory.GRPC, - "StreamCompleted", - "Job stream onCompleted: Server closed the stream gracefully%s (total attempts: %d, successful: %d)", - uptimeInfo, - totalReconnectionAttempts.get(), - successfulReconnections.get()); - } - - public void stop() { - log.info( - LogCategory.GRPC, - "Stopping", - "Stopping GateClientJobStream (total attempts: %d, successful: %d)", - totalReconnectionAttempts.get(), - successfulReconnections.get()); - - shutdown(); - log.info(LogCategory.GRPC, "GateClientJobStreamStopped", "GateClientJobStream stopped"); - } - - private boolean isAdminJob(StreamJobsResponse value) { - return value.hasDiagnostics() - || value.hasListTenantLogs() - || value.hasSetLogLevel() - || value.hasShutdown() - || value.hasInfo(); - } - - private void submitJobError(String jobId, String message) { - try { - SubmitJobResultsRequest request = - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setErrorResult( - SubmitJobResultsRequest.ErrorResult.newBuilder().setMessage(message).build()) - .build(); - - GateServiceGrpc.newBlockingStub(getChannel()) - .withDeadlineAfter(DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .withWaitForReady() - .submitJobResults(request); - } catch (Exception e) { - log.error( - LogCategory.GRPC, - "SubmitJobErrorFailed", - "Failed to submit error for job %s: %s", - jobId, - e.getMessage()); - } - } - - @PreDestroy - public void destroy() { - log.info( - LogCategory.GRPC, - "GateClientJobStream@PreDestroyCalled", - "GateClientJobStream @PreDestroy called"); - stop(); - } - - public Map getStreamStatus() { - // Add timing information to status - Instant lastDisconnect = lastDisconnectTime.get(); - Instant connectionEstablished = connectionEstablishedTime.get(); - Instant reconnectStart = reconnectionStartTime.get(); - Instant lastMessage = lastMessageTime.get(); - - Map status = new HashMap<>(); - status.put("isRunning", isRunning.get()); - status.put("totalReconnectionAttempts", totalReconnectionAttempts.get()); - status.put("successfulReconnections", successfulReconnections.get()); - status.put("consecutiveFailures", consecutiveFailures.get()); - status.put("lastDisconnectTime", lastDisconnect != null ? lastDisconnect.toString() : null); - status.put( - "connectionEstablishedTime", - connectionEstablished != null ? connectionEstablished.toString() : null); - status.put("reconnectionStartTime", reconnectStart != null ? reconnectStart.toString() : null); - status.put("lastErrorType", lastErrorType.get()); - status.put("lastMessageTime", lastMessage != null ? lastMessage.toString() : null); - - return status; - } -} diff --git a/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientPollEvents.java b/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientPollEvents.java deleted file mode 100644 index c955370..0000000 --- a/core/src/main/java/com/tcn/exile/gateclients/v2/GateClientPollEvents.java +++ /dev/null @@ -1,181 +0,0 @@ -/* - * (C) 2017-2025 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.gateclients.v2; - -import build.buf.gen.tcnapi.exile.gate.v2.GateServiceGrpc; -import build.buf.gen.tcnapi.exile.gate.v2.PollEventsRequest; -import com.tcn.exile.config.Config; -import com.tcn.exile.gateclients.UnconfiguredException; -import com.tcn.exile.plugin.PluginInterface; -import io.grpc.StatusRuntimeException; -import java.util.concurrent.TimeUnit; - -/** - * @deprecated Use {@link GateClientEventStream} instead. This class uses the deprecated PollEvents - * unary API which has been replaced by the EventStream bidirectional API with acknowledgment - * support. - */ -@Deprecated -public class GateClientPollEvents extends GateClientAbstract { - protected static final org.slf4j.Logger log = - org.slf4j.LoggerFactory.getLogger(GateClientPollEvents.class); - - PluginInterface plugin; - - public GateClientPollEvents(String tenant, Config currentConfig, PluginInterface plugin) { - super(tenant, currentConfig); - this.plugin = plugin; - } - - // Batch size for polling events - Gate supports up to 1000 - private static final int BATCH_SIZE = 100; - - @Override - public void start() { - try { - if (isUnconfigured()) { - log.debug("Tenant: {} - Configuration not set, skipping poll events", tenant); - return; - } - if (!plugin.isRunning()) { - log.debug( - "Tenant: {} - Plugin is not running (possibly due to database disconnection), skipping poll events", - tenant); - return; - } - - int eventsReceived; - int totalProcessed = 0; - long cycleStart = System.currentTimeMillis(); - - // Keep polling as long as we receive a full batch (indicating more events may - // be waiting) - do { - var client = - GateServiceGrpc.newBlockingStub(getChannel()) - .withDeadlineAfter(300, TimeUnit.SECONDS) - .withWaitForReady(); - var response = - client.pollEvents(PollEventsRequest.newBuilder().setEventCount(BATCH_SIZE).build()); - - eventsReceived = response.getEventsCount(); - - if (eventsReceived == 0) { - if (totalProcessed == 0) { - log.debug( - "Tenant: {} - Poll events request completed successfully but no events were received", - tenant); - } - break; - } - - long batchStart = System.currentTimeMillis(); - response - .getEventsList() - .forEach( - event -> { - if (event.hasAgentCall()) { - log.debug( - "Tenant: {} - Received agent call event {} - {}", - tenant, - event.getAgentCall().getCallSid(), - event.getAgentCall().getCallType()); - plugin.handleAgentCall(event.getAgentCall()); - } - if (event.hasAgentResponse()) { - log.debug( - "Tenant: {} - Received agent response event {}", - tenant, - event.getAgentResponse().getAgentCallResponseSid()); - plugin.handleAgentResponse(event.getAgentResponse()); - } - - if (event.hasTelephonyResult()) { - log.debug( - "Tenant: {} - Received telephony result event {} - {}", - tenant, - event.getTelephonyResult().getCallSid(), - event.getTelephonyResult().getCallType()); - plugin.handleTelephonyResult(event.getTelephonyResult()); - } - - if (event.hasTask()) { - log.debug( - "Tenant: {} - Received task event {}", - tenant, - event.getTask().getTaskSid()); - plugin.handleTask(event.getTask()); - } - - if (event.hasTransferInstance()) { - log.debug( - "Tenant: {} - Received transfer instance event {}", - tenant, - event.getTransferInstance().getTransferInstanceId()); - plugin.handleTransferInstance(event.getTransferInstance()); - } - - if (event.hasCallRecording()) { - log.debug( - "Tenant: {} - Received call recording event {}", - tenant, - event.getCallRecording().getRecordingId()); - plugin.handleCallRecording(event.getCallRecording()); - } - }); - - long batchEnd = System.currentTimeMillis(); - totalProcessed += eventsReceived; - - // Warn if individual batch processing is slow - if (eventsReceived > 0) { - long avg = (batchEnd - batchStart) / eventsReceived; - if (avg > 1000) { - log.warn( - "Tenant: {} - Poll events batch completed {} events in {}ms, average time per event: {}ms, this is long", - tenant, - eventsReceived, - batchEnd - batchStart, - avg); - } - } - - } while (eventsReceived >= BATCH_SIZE); - - // Log summary if we processed events across multiple batches - if (totalProcessed > 0) { - long cycleEnd = System.currentTimeMillis(); - log.info( - "Tenant: {} - Poll cycle completed, processed {} total events in {}ms", - tenant, - totalProcessed, - cycleEnd - cycleStart); - } - - } catch (StatusRuntimeException e) { - if (handleStatusRuntimeException(e)) { - // Already handled in parent class method - } else { - log.error("Tenant: {} - Error in poll events: {}", tenant, e.getMessage()); - } - } catch (UnconfiguredException e) { - log.error("Tenant: {} - Error while getting client configuration {}", tenant, e.getMessage()); - } catch (Exception e) { - log.error("Tenant: {} - Unexpected error in poll events", tenant, e); - } - } -} diff --git a/core/src/main/java/com/tcn/exile/handler/EventHandler.java b/core/src/main/java/com/tcn/exile/handler/EventHandler.java new file mode 100644 index 0000000..745bd73 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/handler/EventHandler.java @@ -0,0 +1,28 @@ +package com.tcn.exile.handler; + +import com.tcn.exile.model.event.*; + +/** + * Handles events dispatched by the gate server. Events are informational — the server only needs an + * acknowledgment, not a result. + * + *

If a method throws, the work item is nacked and will be redelivered. + * + *

Methods run on virtual threads — blocking I/O is fine. + * + *

Default implementations are no-ops. Override only what you need. + */ +public interface EventHandler { + + default void onAgentCall(AgentCallEvent event) throws Exception {} + + default void onTelephonyResult(TelephonyResultEvent event) throws Exception {} + + default void onAgentResponse(AgentResponseEvent event) throws Exception {} + + default void onTransferInstance(TransferInstanceEvent event) throws Exception {} + + default void onCallRecording(CallRecordingEvent event) throws Exception {} + + default void onTask(TaskEvent event) throws Exception {} +} diff --git a/core/src/main/java/com/tcn/exile/handler/JobHandler.java b/core/src/main/java/com/tcn/exile/handler/JobHandler.java new file mode 100644 index 0000000..dd34eb4 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/handler/JobHandler.java @@ -0,0 +1,95 @@ +package com.tcn.exile.handler; + +import com.tcn.exile.model.*; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +/** + * Handles jobs dispatched by the gate server. Each method receives plain Java parameters and + * returns plain Java types. Proto conversion is handled internally by the library. + * + *

Methods run on virtual threads — blocking I/O (JDBC, HTTP) is fine. + * + *

Default implementations throw {@link UnsupportedOperationException}. Integrations override + * only the jobs they handle and declare those as capabilities at registration. + */ +public interface JobHandler { + + default List listPools() throws Exception { + throw new UnsupportedOperationException("listPools not implemented"); + } + + default Pool getPoolStatus(String poolId) throws Exception { + throw new UnsupportedOperationException("getPoolStatus not implemented"); + } + + default Page getPoolRecords(String poolId, String pageToken, int pageSize) + throws Exception { + throw new UnsupportedOperationException("getPoolRecords not implemented"); + } + + default Page searchRecords(List filters, String pageToken, int pageSize) + throws Exception { + throw new UnsupportedOperationException("searchRecords not implemented"); + } + + default List getRecordFields(String poolId, String recordId, List fieldNames) + throws Exception { + throw new UnsupportedOperationException("getRecordFields not implemented"); + } + + default boolean setRecordFields(String poolId, String recordId, List fields) + throws Exception { + throw new UnsupportedOperationException("setRecordFields not implemented"); + } + + default String createPayment(String poolId, String recordId, Map paymentData) + throws Exception { + throw new UnsupportedOperationException("createPayment not implemented"); + } + + default DataRecord popAccount(String poolId, String recordId) throws Exception { + throw new UnsupportedOperationException("popAccount not implemented"); + } + + default Map executeLogic(String logicName, Map parameters) + throws Exception { + throw new UnsupportedOperationException("executeLogic not implemented"); + } + + /** Return client info. Keys: appName, appVersion, plus any custom metadata. */ + default Map info() throws Exception { + throw new UnsupportedOperationException("info not implemented"); + } + + default void shutdown(String reason) throws Exception { + throw new UnsupportedOperationException("shutdown not implemented"); + } + + default void processLog(String payload) throws Exception { + throw new UnsupportedOperationException("processLog not implemented"); + } + + /** Return system diagnostics as structured sections. */ + default DiagnosticsInfo diagnostics() throws Exception { + throw new UnsupportedOperationException("diagnostics not implemented"); + } + + default Page listTenantLogs( + Instant startTime, Instant endTime, String pageToken, int pageSize) throws Exception { + throw new UnsupportedOperationException("listTenantLogs not implemented"); + } + + default void setLogLevel(String loggerName, String level) throws Exception { + throw new UnsupportedOperationException("setLogLevel not implemented"); + } + + record DiagnosticsInfo( + Map systemInfo, + Map runtimeInfo, + Map databaseInfo, + Map custom) {} + + record LogEntry(Instant timestamp, String level, String logger, String message) {} +} diff --git a/core/src/main/java/com/tcn/exile/handler/Plugin.java b/core/src/main/java/com/tcn/exile/handler/Plugin.java new file mode 100644 index 0000000..0718cb7 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/handler/Plugin.java @@ -0,0 +1,38 @@ +package com.tcn.exile.handler; + +import com.tcn.exile.service.ConfigService; + +/** + * The integration point for CRM plugins. Implementations provide job handling, event handling, and + * config validation. + * + *

Extend {@link PluginBase} for default implementations of common operations (logs, diagnostics, + * info, shutdown, log level control). Only override the CRM-specific methods you need. + * + *

Lifecycle: + * + *

    + *
  1. Config is polled from the gate + *
  2. {@link #onConfig} is called — plugin validates and initializes resources + *
  3. If {@code onConfig} returns {@code true}, the WorkStream opens + *
  4. Jobs arrive → {@link JobHandler} methods are called + *
  5. Events arrive → {@link EventHandler} methods are called + *
+ */ +public interface Plugin extends JobHandler, EventHandler { + + /** + * Called when the gate returns a new or changed config. The plugin should validate the config + * payload and initialize its resources (database connections, HTTP clients, etc.). + * + *

Return {@code true} if the plugin is ready to handle work. The WorkStream opens only after + * the first {@code true} return. Return {@code false} to reject the config — the poller will + * retry on the next cycle. + */ + boolean onConfig(ConfigService.ClientConfiguration config); + + /** Human-readable plugin name for diagnostics. */ + default String pluginName() { + return getClass().getSimpleName(); + } +} diff --git a/core/src/main/java/com/tcn/exile/handler/PluginBase.java b/core/src/main/java/com/tcn/exile/handler/PluginBase.java new file mode 100644 index 0000000..891063e --- /dev/null +++ b/core/src/main/java/com/tcn/exile/handler/PluginBase.java @@ -0,0 +1,215 @@ +package com.tcn.exile.handler; + +import com.tcn.exile.memlogger.MemoryAppender; +import com.tcn.exile.memlogger.MemoryAppenderInstance; +import com.tcn.exile.model.Page; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Base class for plugins that provides default implementations for common operations: log + * retrieval, log level control, diagnostics, info, shutdown, and remote logging. + * + *

Extend this class and override only the CRM-specific methods (pool/record operations, event + * handlers, config validation). + * + *

Usage: + * + *

{@code
+ * public class FinviPlugin extends PluginBase {
+ *     private HikariDataSource dataSource;
+ *
+ *     @Override
+ *     public boolean onConfig(ClientConfiguration config) {
+ *         dataSource = initDataSource(config.configPayload());
+ *         return dataSource != null;
+ *     }
+ *
+ *     @Override
+ *     public List listPools() {
+ *         return db.query("SELECT ...");
+ *     }
+ * }
+ * }
+ */ +public abstract class PluginBase implements Plugin { + + private static final Logger log = LoggerFactory.getLogger(PluginBase.class); + + @Override + public String pluginName() { + return getClass().getSimpleName(); + } + + // --- Logs --- + + @Override + public Page listTenantLogs( + Instant startTime, Instant endTime, String pageToken, int pageSize) throws Exception { + MemoryAppender appender = MemoryAppenderInstance.getInstance(); + if (appender == null) { + return new Page<>(List.of(), ""); + } + + long startMs = startTime != null ? startTime.toEpochMilli() : 0; + long endMs = endTime != null ? endTime.toEpochMilli() : System.currentTimeMillis(); + + var logEvents = appender.getEventsWithTimestamps(); + log.info( + "listTenantLogs: appender has {} events, range {}..{}, filtering", + logEvents.size(), + startMs, + endMs); + var entries = + logEvents.stream() + .filter(e -> e.timestamp >= startMs && e.timestamp <= endMs) + .map( + e -> + new JobHandler.LogEntry( + Instant.ofEpochMilli(e.timestamp), "INFO", "memlogger", e.message)) + .limit(pageSize > 0 ? pageSize : 100) + .toList(); + log.info("listTenantLogs: returning {} entries", entries.size()); + + return new Page<>(entries, ""); + } + + @Override + public void setLogLevel(String loggerName, String level) throws Exception { + var loggerContext = + (ch.qos.logback.classic.LoggerContext) org.slf4j.LoggerFactory.getILoggerFactory(); + var logger = loggerContext.getLogger(loggerName); + if (logger != null) { + var newLevel = ch.qos.logback.classic.Level.valueOf(level); + logger.setLevel(newLevel); + log.info("Log level changed: {}={}", loggerName, newLevel); + } + } + + @Override + public void processLog(String payload) throws Exception { + log.info("Remote log: {}", payload); + } + + // --- Info & Diagnostics --- + + @Override + public Map info() throws Exception { + return Map.of( + "appName", pluginName(), + "runtime", System.getProperty("java.version"), + "os", System.getProperty("os.name")); + } + + @Override + public JobHandler.DiagnosticsInfo diagnostics() throws Exception { + var rt = Runtime.getRuntime(); + var mem = java.lang.management.ManagementFactory.getMemoryMXBean(); + var heap = mem.getHeapMemoryUsage(); + var nonHeap = mem.getNonHeapMemoryUsage(); + var os = java.lang.management.ManagementFactory.getOperatingSystemMXBean(); + var thread = java.lang.management.ManagementFactory.getThreadMXBean(); + + // System info. + var systemInfo = new java.util.LinkedHashMap(); + systemInfo.put("os.name", System.getProperty("os.name")); + systemInfo.put("os.version", System.getProperty("os.version")); + systemInfo.put("os.arch", System.getProperty("os.arch")); + systemInfo.put("processors", rt.availableProcessors()); + systemInfo.put("system.load.average", os.getSystemLoadAverage()); + systemInfo.put("hostname", getHostname()); + + // Detect container environment. + var containerFile = new java.io.File("/.dockerenv"); + if (containerFile.exists()) { + systemInfo.put("container", "docker"); + } + var cgroupFile = new java.io.File("/proc/1/cgroup"); + if (cgroupFile.exists()) { + systemInfo.put("cgroup", true); + } + var podName = System.getenv("HOSTNAME"); + if (podName != null) { + systemInfo.put("pod.name", podName); + } + + // Storage. + for (var root : java.io.File.listRoots()) { + systemInfo.put( + "storage." + root.getAbsolutePath().replace("/", "root"), + Map.of( + "total", root.getTotalSpace(), + "free", root.getFreeSpace(), + "usable", root.getUsableSpace())); + } + + // Runtime info. + var runtimeInfo = new java.util.LinkedHashMap(); + runtimeInfo.put("java.version", System.getProperty("java.version")); + runtimeInfo.put("java.vendor", System.getProperty("java.vendor")); + runtimeInfo.put("java.vm.name", System.getProperty("java.vm.name")); + runtimeInfo.put("java.vm.version", System.getProperty("java.vm.version")); + runtimeInfo.put("heap.init", heap.getInit()); + runtimeInfo.put("heap.used", heap.getUsed()); + runtimeInfo.put("heap.committed", heap.getCommitted()); + runtimeInfo.put("heap.max", heap.getMax()); + runtimeInfo.put("non_heap.used", nonHeap.getUsed()); + runtimeInfo.put("non_heap.committed", nonHeap.getCommitted()); + runtimeInfo.put("threads.live", thread.getThreadCount()); + runtimeInfo.put("threads.daemon", thread.getDaemonThreadCount()); + runtimeInfo.put("threads.peak", thread.getPeakThreadCount()); + runtimeInfo.put("threads.total_started", thread.getTotalStartedThreadCount()); + runtimeInfo.put( + "uptime.ms", java.lang.management.ManagementFactory.getRuntimeMXBean().getUptime()); + + // GC info. + for (var gc : java.lang.management.ManagementFactory.getGarbageCollectorMXBeans()) { + runtimeInfo.put("gc." + gc.getName() + ".count", gc.getCollectionCount()); + runtimeInfo.put("gc." + gc.getName() + ".time_ms", gc.getCollectionTime()); + } + + // Database info — empty by default, plugins override to add connection pool stats. + var databaseInfo = new java.util.LinkedHashMap(); + + // Custom — plugin name and memory appender stats. + var custom = new java.util.LinkedHashMap(); + custom.put("plugin.name", pluginName()); + var appender = MemoryAppenderInstance.getInstance(); + if (appender != null) { + custom.put("memlogger.events", appender.getEventsWithTimestamps().size()); + } + + return new JobHandler.DiagnosticsInfo(systemInfo, runtimeInfo, databaseInfo, custom); + } + + private static String getHostname() { + try { + return java.net.InetAddress.getLocalHost().getHostName(); + } catch (Exception e) { + var hostname = System.getenv("HOSTNAME"); + return hostname != null ? hostname : "unknown"; + } + } + + // --- Shutdown --- + + @Override + public void shutdown(String reason) throws Exception { + log.warn("Shutdown requested: {}. Exiting in 2 seconds.", reason); + Thread.ofVirtual() + .name("exile-shutdown") + .start( + () -> { + try { + Thread.sleep(2000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + log.info("Exiting now (reason: {})", reason); + System.exit(0); + }); + } +} diff --git a/core/src/main/java/com/tcn/exile/internal/Backoff.java b/core/src/main/java/com/tcn/exile/internal/Backoff.java new file mode 100644 index 0000000..17b9dc1 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/internal/Backoff.java @@ -0,0 +1,41 @@ +package com.tcn.exile.internal; + +import java.util.concurrent.ThreadLocalRandom; + +/** Exponential backoff with jitter for reconnection. */ +public final class Backoff { + + private static final long BASE_MS = 500; + private static final long MAX_MS = 10_000; + private static final double JITTER = 0.2; + + private int failures; + + public Backoff() { + this.failures = 0; + } + + public void reset() { + failures = 0; + } + + public void recordFailure() { + failures++; + } + + /** Compute the next backoff delay in milliseconds. Returns 0 on first attempt. */ + public long nextDelayMs() { + if (failures <= 0) return 0; + long delay = BASE_MS * (1L << Math.min(failures - 1, 10)); + double jitter = 1.0 + (ThreadLocalRandom.current().nextDouble() * 2 - 1) * JITTER; + return Math.min((long) (delay * jitter), MAX_MS); + } + + /** Sleep for the computed backoff delay. */ + public void sleep() throws InterruptedException { + long ms = nextDelayMs(); + if (ms > 0) { + Thread.sleep(ms); + } + } +} diff --git a/core/src/main/java/com/tcn/exile/internal/ChannelFactory.java b/core/src/main/java/com/tcn/exile/internal/ChannelFactory.java new file mode 100644 index 0000000..94f37d4 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/internal/ChannelFactory.java @@ -0,0 +1,99 @@ +package com.tcn.exile.internal; + +import com.tcn.exile.ExileConfig; +import io.grpc.ManagedChannel; +import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts; +import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; +import java.io.ByteArrayInputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.spec.RSAPrivateCrtKeySpec; +import java.util.Base64; +import java.util.concurrent.TimeUnit; + +/** Creates mTLS gRPC channels from {@link ExileConfig}. */ +public final class ChannelFactory { + + private ChannelFactory() {} + + /** Create a new mTLS channel to the gate server. Caller owns the channel lifecycle. */ + public static ManagedChannel create(ExileConfig config) { + try { + // Handle PKCS#1 (BEGIN RSA PRIVATE KEY) → PKCS#8 (BEGIN PRIVATE KEY) conversion. + var keyPem = config.privateKey(); + if (keyPem.contains("BEGIN RSA PRIVATE KEY")) { + keyPem = convertPkcs1ToPkcs8Pem(keyPem); + } + + var sslContext = + GrpcSslContexts.forClient() + .trustManager( + new ByteArrayInputStream(config.rootCert().getBytes(StandardCharsets.UTF_8))) + .keyManager( + new ByteArrayInputStream(config.publicCert().getBytes(StandardCharsets.UTF_8)), + new ByteArrayInputStream(keyPem.getBytes(StandardCharsets.UTF_8))) + .build(); + + // Use InetSocketAddress to avoid the unix domain socket name resolver on macOS. + return NettyChannelBuilder.forAddress( + new InetSocketAddress(config.apiHostname(), config.apiPort())) + .sslContext(sslContext) + .keepAliveTime(10, TimeUnit.SECONDS) + .keepAliveTimeout(5, TimeUnit.SECONDS) + .keepAliveWithoutCalls(true) + .idleTimeout(30, TimeUnit.MINUTES) + .flowControlWindow(4 * 1024 * 1024) // 4MB — match envoy upstream window + .build(); + } catch (Exception e) { + throw new IllegalStateException("Failed to create gRPC channel", e); + } + } + + /** Gracefully shut down a channel, forcing after timeout. */ + public static void shutdown(ManagedChannel channel) { + if (channel == null) return; + channel.shutdown(); + try { + if (!channel.awaitTermination(10, TimeUnit.SECONDS)) { + channel.shutdownNow(); + channel.awaitTermination(5, TimeUnit.SECONDS); + } + } catch (InterruptedException e) { + channel.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + + /** + * Converts a PKCS#1 RSA private key PEM to PKCS#8 PEM format. Netty's SSL only accepts PKCS#8 + * (BEGIN PRIVATE KEY), but exile certificates use PKCS#1 (BEGIN RSA PRIVATE KEY). + */ + private static String convertPkcs1ToPkcs8Pem(String pkcs1Pem) throws Exception { + var base64 = + pkcs1Pem + .replace("-----BEGIN RSA PRIVATE KEY-----", "") + .replace("-----END RSA PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + var pkcs1Bytes = Base64.getDecoder().decode(base64); + + var keyFactory = KeyFactory.getInstance("RSA"); + var rsaKey = org.bouncycastle.asn1.pkcs.RSAPrivateKey.getInstance(pkcs1Bytes); + var spec = + new RSAPrivateCrtKeySpec( + rsaKey.getModulus(), + rsaKey.getPublicExponent(), + rsaKey.getPrivateExponent(), + rsaKey.getPrime1(), + rsaKey.getPrime2(), + rsaKey.getExponent1(), + rsaKey.getExponent2(), + rsaKey.getCoefficient()); + PrivateKey privateKey = keyFactory.generatePrivate(spec); + var pkcs8Bytes = privateKey.getEncoded(); + + var pkcs8Base64 = Base64.getMimeEncoder(64, "\n".getBytes()).encodeToString(pkcs8Bytes); + return "-----BEGIN PRIVATE KEY-----\n" + pkcs8Base64 + "\n-----END PRIVATE KEY-----\n"; + } +} diff --git a/core/src/main/java/com/tcn/exile/internal/GrpcLogShipper.java b/core/src/main/java/com/tcn/exile/internal/GrpcLogShipper.java new file mode 100644 index 0000000..e8c1c0d --- /dev/null +++ b/core/src/main/java/com/tcn/exile/internal/GrpcLogShipper.java @@ -0,0 +1,147 @@ +package com.tcn.exile.internal; + +import build.buf.gen.tcnapi.exile.gate.v3.LogLevel; +import build.buf.gen.tcnapi.exile.gate.v3.LogRecord; +import com.google.protobuf.Timestamp; +import com.tcn.exile.memlogger.LogShipper; +import com.tcn.exile.memlogger.MemoryAppender; +import com.tcn.exile.service.TelemetryService; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * LogShipper implementation that sends structured log records to the gate via TelemetryService. All + * exceptions are caught — telemetry must never break the application. + */ +public final class GrpcLogShipper implements LogShipper { + + private static final Logger log = LoggerFactory.getLogger(GrpcLogShipper.class); + + private final TelemetryService telemetryService; + private final String clientId; + + public GrpcLogShipper(TelemetryService telemetryService, String clientId) { + this.telemetryService = telemetryService; + this.clientId = clientId; + } + + @Override + public void shipLogs(List payload) { + // Legacy string-only path: wrap as INFO-level records. + var records = new ArrayList(); + var now = Timestamp.newBuilder().setSeconds(System.currentTimeMillis() / 1000).build(); + for (var msg : payload) { + records.add( + LogRecord.newBuilder() + .setTime(now) + .setLevel(LogLevel.LOG_LEVEL_INFO) + .setMessage(msg) + .build()); + } + sendRecords(records); + } + + @Override + public void shipStructuredLogs(List events) { + var records = new ArrayList(); + for (var event : events) { + var builder = + LogRecord.newBuilder() + .setTime( + Timestamp.newBuilder() + .setSeconds(event.timestamp / 1000) + .setNanos((int) ((event.timestamp % 1000) * 1_000_000))) + .setLevel(mapLevel(event.level)) + .setMessage(toJson(event)); + + if (event.loggerName != null) builder.setLoggerName(event.loggerName); + if (event.threadName != null) builder.setThreadName(event.threadName); + if (event.stackTrace != null) builder.setStackTrace(event.stackTrace); + if (event.mdc != null) builder.putAllMdc(event.mdc); + if (event.traceId != null) builder.setTraceId(event.traceId); + if (event.spanId != null) builder.setSpanId(event.spanId); + + records.add(builder.build()); + } + sendRecords(records); + } + + /** Serialize the log event as a JSON string for structured gate-side processing. */ + private static String toJson(MemoryAppender.LogEvent event) { + var map = new LinkedHashMap(); + map.put( + "timestamp", + DateTimeFormatter.ISO_INSTANT.format( + Instant.ofEpochMilli(event.timestamp).atOffset(ZoneOffset.UTC))); + if (event.level != null) map.put("level", event.level); + if (event.loggerName != null) map.put("logger", event.loggerName); + if (event.message != null) map.put("message", event.message); + if (event.threadName != null) map.put("thread", event.threadName); + if (event.mdc != null && !event.mdc.isEmpty()) map.put("mdc", event.mdc); + if (event.stackTrace != null) map.put("stackTrace", event.stackTrace); + if (event.traceId != null) map.put("traceId", event.traceId); + if (event.spanId != null) map.put("spanId", event.spanId); + return toJsonString(map); + } + + /** Minimal JSON serializer — no external dependency needed for simple maps. */ + @SuppressWarnings("unchecked") + private static String toJsonString(Object obj) { + if (obj == null) return "null"; + if (obj instanceof String s) return "\"" + escapeJson(s) + "\""; + if (obj instanceof Number n) return n.toString(); + if (obj instanceof Boolean b) return b.toString(); + if (obj instanceof java.util.Map m) { + var sb = new StringBuilder("{"); + boolean first = true; + for (var entry : m.entrySet()) { + if (!first) sb.append(","); + sb.append("\"").append(escapeJson(entry.getKey().toString())).append("\":"); + sb.append(toJsonString(entry.getValue())); + first = false; + } + return sb.append("}").toString(); + } + return "\"" + escapeJson(obj.toString()) + "\""; + } + + private static String escapeJson(String s) { + return s.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } + + private void sendRecords(List records) { + try { + int accepted = telemetryService.reportLogs(clientId, records); + log.debug("Shipped {} log records ({} accepted)", records.size(), accepted); + } catch (Exception e) { + log.debug("Failed to ship logs: {}", e.getMessage()); + } + } + + private static LogLevel mapLevel(String level) { + if (level == null) return LogLevel.LOG_LEVEL_INFO; + return switch (level.toUpperCase()) { + case "TRACE" -> LogLevel.LOG_LEVEL_TRACE; + case "DEBUG" -> LogLevel.LOG_LEVEL_DEBUG; + case "INFO" -> LogLevel.LOG_LEVEL_INFO; + case "WARN" -> LogLevel.LOG_LEVEL_WARN; + case "ERROR" -> LogLevel.LOG_LEVEL_ERROR; + default -> LogLevel.LOG_LEVEL_INFO; + }; + } + + @Override + public void stop() { + // No-op — channel lifecycle is managed by ExileClient. + } +} diff --git a/core/src/main/java/com/tcn/exile/internal/GrpcMetricExporter.java b/core/src/main/java/com/tcn/exile/internal/GrpcMetricExporter.java new file mode 100644 index 0000000..6264fcc --- /dev/null +++ b/core/src/main/java/com/tcn/exile/internal/GrpcMetricExporter.java @@ -0,0 +1,55 @@ +package com.tcn.exile.internal; + +import com.tcn.exile.service.TelemetryService; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.metrics.InstrumentType; +import io.opentelemetry.sdk.metrics.data.AggregationTemporality; +import io.opentelemetry.sdk.metrics.data.MetricData; +import io.opentelemetry.sdk.metrics.export.MetricExporter; +import java.util.Collection; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * OTel MetricExporter that sends metric data to the gate via the TelemetryService gRPC endpoint. + * All exceptions are caught and logged — telemetry must never break the application. + */ +public final class GrpcMetricExporter implements MetricExporter { + + private static final Logger log = LoggerFactory.getLogger(GrpcMetricExporter.class); + + private final TelemetryService telemetryService; + private final String clientId; + + public GrpcMetricExporter(TelemetryService telemetryService, String clientId) { + this.telemetryService = telemetryService; + this.clientId = clientId; + } + + @Override + public CompletableResultCode export(Collection metrics) { + try { + int accepted = telemetryService.reportMetrics(clientId, metrics); + log.debug("Exported {} metric data points ({} accepted)", metrics.size(), accepted); + return CompletableResultCode.ofSuccess(); + } catch (Exception e) { + log.debug("Failed to export metrics: {}", e.getMessage()); + return CompletableResultCode.ofFailure(); + } + } + + @Override + public CompletableResultCode flush() { + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode shutdown() { + return CompletableResultCode.ofSuccess(); + } + + @Override + public AggregationTemporality getAggregationTemporality(InstrumentType instrumentType) { + return AggregationTemporality.CUMULATIVE; + } +} diff --git a/core/src/main/java/com/tcn/exile/internal/MetricsManager.java b/core/src/main/java/com/tcn/exile/internal/MetricsManager.java new file mode 100644 index 0000000..1848a0f --- /dev/null +++ b/core/src/main/java/com/tcn/exile/internal/MetricsManager.java @@ -0,0 +1,207 @@ +package com.tcn.exile.internal; + +import com.tcn.exile.StreamStatus; +import com.tcn.exile.service.TelemetryService; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.metrics.DoubleHistogram; +import io.opentelemetry.api.metrics.LongCounter; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.metrics.export.PeriodicMetricReader; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import java.lang.management.ManagementFactory; +import java.time.Duration; +import java.util.function.Supplier; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Sets up OTel SDK metric collection with built-in exile instruments and a custom gRPC exporter. + * Exposes the {@link Meter} so plugin developers can register their own instruments. + */ +public final class MetricsManager implements AutoCloseable { + + private static final Logger log = LoggerFactory.getLogger(MetricsManager.class); + + private final OpenTelemetrySdk openTelemetry; + private final SdkMeterProvider meterProvider; + private final Meter meter; + private final DoubleHistogram workDuration; + private final DoubleHistogram methodDuration; + private final LongCounter methodCalls; + private final DoubleHistogram reconnectDuration; + + private static final AttributeKey METHOD_KEY = AttributeKey.stringKey("method"); + private static final AttributeKey STATUS_KEY = AttributeKey.stringKey("status"); + + /** + * @param telemetryService gRPC stub for reporting metrics + * @param clientId unique client identifier + * @param orgId organization ID (from config poll) + * @param certificateName certificate name from the config file + * @param statusSupplier supplies current WorkStream status snapshot + */ + public MetricsManager( + TelemetryService telemetryService, + String clientId, + String orgId, + String certificateName, + Supplier statusSupplier) { + + var exporter = new GrpcMetricExporter(telemetryService, clientId); + var reader = PeriodicMetricReader.builder(exporter).setInterval(Duration.ofSeconds(60)).build(); + + var resource = + Resource.getDefault() + .merge( + Resource.create( + Attributes.of( + AttributeKey.stringKey("exile.org_id"), orgId, + AttributeKey.stringKey("exile.certificate_name"), certificateName, + AttributeKey.stringKey("exile.client_id"), clientId))); + + this.meterProvider = + SdkMeterProvider.builder().setResource(resource).registerMetricReader(reader).build(); + + // TracerProvider generates valid trace/span IDs for log correlation. + var tracerProvider = SdkTracerProvider.builder().setResource(resource).build(); + + var sdkBuilder = + OpenTelemetrySdk.builder() + .setMeterProvider(meterProvider) + .setTracerProvider(tracerProvider); + + OpenTelemetrySdk sdk; + try { + sdk = sdkBuilder.buildAndRegisterGlobal(); + } catch (IllegalStateException e) { + // Already registered (e.g. multi-tenant or restart) — build without registering. + sdk = sdkBuilder.build(); + } + this.openTelemetry = sdk; + + this.meter = meterProvider.get("com.tcn.exile.sati"); + + // --- Built-in instruments --- + + // WorkStream counters (cumulative, read from StreamStatus) + meter + .counterBuilder("exile.work.completed") + .setDescription("Total work items completed since start") + .setUnit("1") + .buildWithCallback(obs -> obs.record(statusSupplier.get().completedTotal())); + + meter + .counterBuilder("exile.work.failed") + .setDescription("Total work items that failed since start") + .setUnit("1") + .buildWithCallback(obs -> obs.record(statusSupplier.get().failedTotal())); + + meter + .counterBuilder("exile.work.reconnects") + .setDescription("Total stream reconnection attempts since start") + .setUnit("1") + .buildWithCallback(obs -> obs.record(statusSupplier.get().reconnectAttempts())); + + // WorkStream gauges + meter + .gaugeBuilder("exile.work.inflight") + .ofLongs() + .setDescription("Work items currently being processed") + .setUnit("1") + .buildWithCallback(obs -> obs.record(statusSupplier.get().inflight())); + + meter + .gaugeBuilder("exile.work.phase") + .ofLongs() + .setDescription( + "WorkStream phase (0=IDLE, 1=CONNECTING, 2=REGISTERING, 3=ACTIVE, 4=RECONNECTING, 5=CLOSED)") + .setUnit("1") + .buildWithCallback(obs -> obs.record(statusSupplier.get().phase().ordinal())); + + // JVM gauges + var memoryBean = ManagementFactory.getMemoryMXBean(); + var threadBean = ManagementFactory.getThreadMXBean(); + + meter + .gaugeBuilder("exile.jvm.heap_used") + .ofLongs() + .setDescription("JVM heap memory used") + .setUnit("bytes") + .buildWithCallback(obs -> obs.record(memoryBean.getHeapMemoryUsage().getUsed())); + + meter + .gaugeBuilder("exile.jvm.threads") + .ofLongs() + .setDescription("JVM thread count") + .setUnit("1") + .buildWithCallback(obs -> obs.record(threadBean.getThreadCount())); + + // Work duration histogram (recorded externally via recordWorkDuration) + this.workDuration = + meter + .histogramBuilder("exile.work.duration") + .setDescription("Time to process a work item (job or event)") + .setUnit("s") + .build(); + + // Per-method metrics (method name as attribute) + this.methodDuration = + meter + .histogramBuilder("exile.plugin.duration") + .setDescription("Time to execute a plugin method") + .setUnit("s") + .build(); + + this.methodCalls = + meter + .counterBuilder("exile.plugin.calls") + .setDescription("Plugin method invocations") + .setUnit("1") + .build(); + + this.reconnectDuration = + meter + .histogramBuilder("exile.work.reconnect_duration") + .setDescription("Time from stream disconnect to successful re-registration") + .setUnit("s") + .build(); + + log.info( + "MetricsManager initialized (export interval=60s, clientId={}, orgId={}, certificateName={})", + clientId, + orgId, + certificateName); + } + + /** The OTel Meter for plugin developers to create custom instruments. */ + public Meter meter() { + return meter; + } + + /** Record the duration of a completed work item. Called from WorkStreamClient. */ + public void recordWorkDuration(double seconds) { + workDuration.record(seconds); + } + + /** Record the time from disconnect to successful re-registration. */ + public void recordReconnectDuration(double seconds) { + reconnectDuration.record(seconds); + } + + /** Record a plugin method call with duration and success/failure status. */ + public void recordMethodCall(String method, double durationSeconds, boolean success) { + var attrs = Attributes.of(METHOD_KEY, method, STATUS_KEY, success ? "ok" : "error"); + methodCalls.add(1, attrs); + methodDuration.record(durationSeconds, attrs); + } + + @Override + public void close() { + openTelemetry.close(); + log.info("MetricsManager shut down"); + } +} diff --git a/core/src/main/java/com/tcn/exile/internal/ProtoConverter.java b/core/src/main/java/com/tcn/exile/internal/ProtoConverter.java new file mode 100644 index 0000000..05cf911 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/internal/ProtoConverter.java @@ -0,0 +1,395 @@ +package com.tcn.exile.internal; + +import com.tcn.exile.model.*; +import com.tcn.exile.model.DataRecord; +import com.tcn.exile.model.event.*; +import java.time.Duration; +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Converts between proto types and the public Java model. This is the only place in the library + * that touches generated proto classes directly. + */ +public final class ProtoConverter { + + private ProtoConverter() {} + + // ---- Duration / Timestamp ---- + + public static Duration toDuration(com.google.protobuf.Duration d) { + if (d == null || d.equals(com.google.protobuf.Duration.getDefaultInstance())) + return Duration.ZERO; + return Duration.ofSeconds(d.getSeconds(), d.getNanos()); + } + + public static com.google.protobuf.Duration fromDuration(Duration d) { + if (d == null) return com.google.protobuf.Duration.getDefaultInstance(); + return com.google.protobuf.Duration.newBuilder() + .setSeconds(d.getSeconds()) + .setNanos(d.getNano()) + .build(); + } + + public static Instant toInstant(com.google.protobuf.Timestamp t) { + if (t == null || t.equals(com.google.protobuf.Timestamp.getDefaultInstance())) return null; + return Instant.ofEpochSecond(t.getSeconds(), t.getNanos()); + } + + public static com.google.protobuf.Timestamp fromInstant(Instant i) { + if (i == null) return com.google.protobuf.Timestamp.getDefaultInstance(); + return com.google.protobuf.Timestamp.newBuilder() + .setSeconds(i.getEpochSecond()) + .setNanos(i.getNano()) + .build(); + } + + // ---- Enums ---- + + public static CallType toCallType(build.buf.gen.tcnapi.exile.gate.v3.CallType ct) { + return switch (ct) { + case CALL_TYPE_INBOUND -> CallType.INBOUND; + case CALL_TYPE_OUTBOUND -> CallType.OUTBOUND; + case CALL_TYPE_PREVIEW -> CallType.PREVIEW; + case CALL_TYPE_MANUAL -> CallType.MANUAL; + case CALL_TYPE_MAC -> CallType.MAC; + default -> CallType.UNSPECIFIED; + }; + } + + public static AgentState toAgentState(build.buf.gen.tcnapi.exile.gate.v3.AgentState as) { + try { + return AgentState.valueOf(as.name().replace("AGENT_STATE_", "")); + } catch (IllegalArgumentException e) { + return AgentState.UNSPECIFIED; + } + } + + public static build.buf.gen.tcnapi.exile.gate.v3.AgentState fromAgentState(AgentState as) { + try { + return build.buf.gen.tcnapi.exile.gate.v3.AgentState.valueOf("AGENT_STATE_" + as.name()); + } catch (IllegalArgumentException e) { + return build.buf.gen.tcnapi.exile.gate.v3.AgentState.AGENT_STATE_UNSPECIFIED; + } + } + + // ---- Core types ---- + + public static Pool toPool(build.buf.gen.tcnapi.exile.gate.v3.Pool p) { + return new Pool( + p.getPoolId(), + p.getDescription(), + switch (p.getStatus()) { + case POOL_STATUS_READY -> Pool.PoolStatus.READY; + case POOL_STATUS_NOT_READY -> Pool.PoolStatus.NOT_READY; + case POOL_STATUS_BUSY -> Pool.PoolStatus.BUSY; + default -> Pool.PoolStatus.UNSPECIFIED; + }, + p.getRecordCount()); + } + + public static build.buf.gen.tcnapi.exile.gate.v3.Pool fromPool(Pool p) { + return build.buf.gen.tcnapi.exile.gate.v3.Pool.newBuilder() + .setPoolId(p.poolId()) + .setDescription(p.description()) + .setStatus( + switch (p.status()) { + case READY -> build.buf.gen.tcnapi.exile.gate.v3.Pool.PoolStatus.POOL_STATUS_READY; + case NOT_READY -> + build.buf.gen.tcnapi.exile.gate.v3.Pool.PoolStatus.POOL_STATUS_NOT_READY; + case BUSY -> build.buf.gen.tcnapi.exile.gate.v3.Pool.PoolStatus.POOL_STATUS_BUSY; + default -> build.buf.gen.tcnapi.exile.gate.v3.Pool.PoolStatus.POOL_STATUS_UNSPECIFIED; + }) + .setRecordCount(p.recordCount()) + .build(); + } + + public static DataRecord toRecord(build.buf.gen.tcnapi.exile.gate.v3.Record r) { + return new DataRecord(r.getPoolId(), r.getRecordId(), structToMap(r.getPayload())); + } + + public static build.buf.gen.tcnapi.exile.gate.v3.Record fromRecord(DataRecord r) { + return build.buf.gen.tcnapi.exile.gate.v3.Record.newBuilder() + .setPoolId(r.poolId()) + .setRecordId(r.recordId()) + .setPayload(mapToStruct(r.payload())) + .build(); + } + + public static Field toField(build.buf.gen.tcnapi.exile.gate.v3.Field f) { + return new Field(f.getFieldName(), f.getFieldValue(), f.getPoolId(), f.getRecordId()); + } + + public static build.buf.gen.tcnapi.exile.gate.v3.Field fromField(Field f) { + return build.buf.gen.tcnapi.exile.gate.v3.Field.newBuilder() + .setFieldName(f.fieldName()) + .setFieldValue(f.fieldValue()) + .setPoolId(f.poolId()) + .setRecordId(f.recordId()) + .build(); + } + + public static Filter toFilter(build.buf.gen.tcnapi.exile.gate.v3.Filter f) { + return new Filter( + f.getField(), + switch (f.getOperator()) { + case OPERATOR_EQUAL -> Filter.Operator.EQUAL; + case OPERATOR_NOT_EQUAL -> Filter.Operator.NOT_EQUAL; + case OPERATOR_CONTAINS -> Filter.Operator.CONTAINS; + case OPERATOR_GREATER_THAN -> Filter.Operator.GREATER_THAN; + case OPERATOR_LESS_THAN -> Filter.Operator.LESS_THAN; + case OPERATOR_IN -> Filter.Operator.IN; + case OPERATOR_EXISTS -> Filter.Operator.EXISTS; + default -> Filter.Operator.UNSPECIFIED; + }, + f.getValue()); + } + + public static build.buf.gen.tcnapi.exile.gate.v3.Filter fromFilter(Filter f) { + return build.buf.gen.tcnapi.exile.gate.v3.Filter.newBuilder() + .setField(f.field()) + .setOperator( + switch (f.operator()) { + case EQUAL -> build.buf.gen.tcnapi.exile.gate.v3.Filter.Operator.OPERATOR_EQUAL; + case NOT_EQUAL -> + build.buf.gen.tcnapi.exile.gate.v3.Filter.Operator.OPERATOR_NOT_EQUAL; + case CONTAINS -> build.buf.gen.tcnapi.exile.gate.v3.Filter.Operator.OPERATOR_CONTAINS; + case GREATER_THAN -> + build.buf.gen.tcnapi.exile.gate.v3.Filter.Operator.OPERATOR_GREATER_THAN; + case LESS_THAN -> + build.buf.gen.tcnapi.exile.gate.v3.Filter.Operator.OPERATOR_LESS_THAN; + case IN -> build.buf.gen.tcnapi.exile.gate.v3.Filter.Operator.OPERATOR_IN; + case EXISTS -> build.buf.gen.tcnapi.exile.gate.v3.Filter.Operator.OPERATOR_EXISTS; + default -> build.buf.gen.tcnapi.exile.gate.v3.Filter.Operator.OPERATOR_UNSPECIFIED; + }) + .setValue(f.value()) + .build(); + } + + public static List toTaskData(List list) { + return list.stream() + .map(td -> new TaskData(td.getKey(), valueToObject(td.getValue()))) + .collect(Collectors.toList()); + } + + // ---- Agent ---- + + public static Agent toAgent(build.buf.gen.tcnapi.exile.gate.v3.Agent a) { + Optional cp = + a.hasConnectedParty() + ? Optional.of( + new Agent.ConnectedParty( + a.getConnectedParty().getCallSid(), + toCallType(a.getConnectedParty().getCallType()), + a.getConnectedParty().getIsInbound())) + : Optional.empty(); + return new Agent( + a.getUserId(), + a.getOrgId(), + a.getFirstName(), + a.getLastName(), + a.getUsername(), + a.getPartnerAgentId(), + a.getCurrentSessionId(), + toAgentState(a.getAgentState()), + a.getIsLoggedIn(), + a.getIsMuted(), + a.getIsRecording(), + cp); + } + + public static Skill toSkill(build.buf.gen.tcnapi.exile.gate.v3.Skill s) { + return new Skill( + s.getSkillId(), + s.getName(), + s.getDescription(), + s.hasProficiency() ? OptionalLong.of(s.getProficiency()) : OptionalLong.empty()); + } + + // ---- Events ---- + + public static AgentCallEvent toAgentCallEvent(build.buf.gen.tcnapi.exile.gate.v3.AgentCall ac) { + return new AgentCallEvent( + ac.getAgentCallSid(), + ac.getCallSid(), + toCallType(ac.getCallType()), + ac.getOrgId(), + ac.getUserId(), + ac.getPartnerAgentId(), + ac.getInternalKey(), + toDuration(ac.getTalkDuration()), + toDuration(ac.getWaitDuration()), + toDuration(ac.getWrapupDuration()), + toDuration(ac.getPauseDuration()), + toDuration(ac.getTransferDuration()), + toDuration(ac.getManualDuration()), + toDuration(ac.getPreviewDuration()), + toDuration(ac.getHoldDuration()), + toDuration(ac.getAgentWaitDuration()), + toDuration(ac.getSuspendedDuration()), + toDuration(ac.getExternalTransferDuration()), + toInstant(ac.getCreateTime()), + toInstant(ac.getUpdateTime()), + toTaskData(ac.getTaskDataList())); + } + + public static TelephonyResultEvent toTelephonyResultEvent( + build.buf.gen.tcnapi.exile.gate.v3.TelephonyResult tr) { + return new TelephonyResultEvent( + tr.getCallSid(), + toCallType(tr.getCallType()), + tr.getOrgId(), + tr.getInternalKey(), + tr.getCallerId(), + tr.getPhoneNumber(), + tr.getPoolId(), + tr.getRecordId(), + tr.getClientSid(), + tr.getStatus().name(), + tr.hasOutcome() ? tr.getOutcome().getCategory().name() : "", + tr.hasOutcome() ? tr.getOutcome().getDetail().name() : "", + toDuration(tr.getDeliveryLength()), + toDuration(tr.getLinkbackLength()), + toInstant(tr.getCreateTime()), + toInstant(tr.getUpdateTime()), + toInstant(tr.getStartTime()), + toInstant(tr.getEndTime()), + toTaskData(tr.getTaskDataList())); + } + + public static AgentResponseEvent toAgentResponseEvent( + build.buf.gen.tcnapi.exile.gate.v3.AgentResponse ar) { + return new AgentResponseEvent( + ar.getAgentCallResponseSid(), + ar.getCallSid(), + toCallType(ar.getCallType()), + ar.getOrgId(), + ar.getUserId(), + ar.getPartnerAgentId(), + ar.getInternalKey(), + ar.getAgentSid(), + ar.getClientSid(), + ar.getResponseKey(), + ar.getResponseValue(), + toInstant(ar.getCreateTime()), + toInstant(ar.getUpdateTime())); + } + + public static CallRecordingEvent toCallRecordingEvent( + build.buf.gen.tcnapi.exile.gate.v3.CallRecording cr) { + return new CallRecordingEvent( + cr.getRecordingId(), + cr.getOrgId(), + cr.getCallSid(), + toCallType(cr.getCallType()), + toDuration(cr.getDuration()), + cr.getRecordingType().name(), + toInstant(cr.getStartTime())); + } + + public static TransferInstanceEvent toTransferInstanceEvent( + build.buf.gen.tcnapi.exile.gate.v3.TransferInstance ti) { + var src = ti.getSource(); + return new TransferInstanceEvent( + ti.getClientSid(), + ti.getOrgId(), + ti.getTransferInstanceId(), + new TransferInstanceEvent.Source( + src.getCallSid(), + toCallType(src.getCallType()), + src.getPartnerAgentId(), + src.getUserId(), + src.getConversationId(), + src.getSessionSid(), + src.getAgentCallSid()), + ti.getTransferType().name(), + ti.getTransferResult().name(), + ti.getInitiation().name(), + toInstant(ti.getCreateTime()), + toInstant(ti.getTransferTime()), + toInstant(ti.getAcceptTime()), + toInstant(ti.getHangupTime()), + toInstant(ti.getEndTime()), + toInstant(ti.getUpdateTime()), + toDuration(ti.getPendingDuration()), + toDuration(ti.getExternalDuration()), + toDuration(ti.getFullDuration())); + } + + public static TaskEvent toTaskEvent(build.buf.gen.tcnapi.exile.gate.v3.ExileTask t) { + return new TaskEvent( + t.getTaskSid(), + t.getTaskGroupSid(), + t.getOrgId(), + t.getClientSid(), + t.getPoolId(), + t.getRecordId(), + t.getAttempts(), + t.getStatus().name(), + toInstant(t.getCreateTime()), + toInstant(t.getUpdateTime())); + } + + // ---- Struct ↔ Map ---- + + public static Map structToMap(com.google.protobuf.Struct s) { + if (s == null) return Map.of(); + Map map = new LinkedHashMap<>(); + s.getFieldsMap().forEach((k, v) -> map.put(k, valueToObject(v))); + return map; + } + + public static com.google.protobuf.Struct mapToStruct(Map map) { + if (map == null || map.isEmpty()) return com.google.protobuf.Struct.getDefaultInstance(); + var builder = com.google.protobuf.Struct.newBuilder(); + map.forEach((k, v) -> builder.putFields(k, objectToValue(v))); + return builder.build(); + } + + @SuppressWarnings("unchecked") + public static Object valueToObject(com.google.protobuf.Value v) { + if (v == null) return null; + return switch (v.getKindCase()) { + case STRING_VALUE -> v.getStringValue(); + case NUMBER_VALUE -> v.getNumberValue(); + case BOOL_VALUE -> v.getBoolValue(); + case NULL_VALUE -> null; + case STRUCT_VALUE -> structToMap(v.getStructValue()); + case LIST_VALUE -> + v.getListValue().getValuesList().stream() + .map(ProtoConverter::valueToObject) + .collect(Collectors.toList()); + default -> null; + }; + } + + @SuppressWarnings("unchecked") + public static com.google.protobuf.Value objectToValue(Object obj) { + if (obj == null) { + return com.google.protobuf.Value.newBuilder() + .setNullValue(com.google.protobuf.NullValue.NULL_VALUE) + .build(); + } + if (obj instanceof String s) { + return com.google.protobuf.Value.newBuilder().setStringValue(s).build(); + } + if (obj instanceof Number n) { + return com.google.protobuf.Value.newBuilder().setNumberValue(n.doubleValue()).build(); + } + if (obj instanceof Boolean b) { + return com.google.protobuf.Value.newBuilder().setBoolValue(b).build(); + } + if (obj instanceof Map m) { + return com.google.protobuf.Value.newBuilder() + .setStructValue(mapToStruct((Map) m)) + .build(); + } + if (obj instanceof List l) { + var lb = com.google.protobuf.ListValue.newBuilder(); + for (Object item : l) lb.addValues(objectToValue(item)); + return com.google.protobuf.Value.newBuilder().setListValue(lb).build(); + } + return com.google.protobuf.Value.newBuilder().setStringValue(obj.toString()).build(); + } +} diff --git a/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java new file mode 100644 index 0000000..90b3b7c --- /dev/null +++ b/core/src/main/java/com/tcn/exile/internal/WorkStreamClient.java @@ -0,0 +1,644 @@ +package com.tcn.exile.internal; + +import static com.tcn.exile.internal.ProtoConverter.*; + +import build.buf.gen.tcnapi.exile.gate.v3.*; +import com.tcn.exile.ExileConfig; +import com.tcn.exile.StreamStatus; +import com.tcn.exile.StreamStatus.Phase; +import com.tcn.exile.handler.EventHandler; +import com.tcn.exile.handler.JobHandler; +import com.tcn.exile.model.*; +import io.grpc.ManagedChannel; +import io.grpc.stub.StreamObserver; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.api.trace.TraceFlags; +import io.opentelemetry.api.trace.TraceState; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.Scope; +import java.time.Instant; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; + +/** + * Implements the v3 WorkStream protocol over a single bidirectional gRPC stream. + * + *

This class is internal. The public API is {@link com.tcn.exile.ExileClient}. All proto types + * are converted to/from plain Java types at the boundary — handlers never see proto classes. + */ +public final class WorkStreamClient implements AutoCloseable { + + private static final Logger log = LoggerFactory.getLogger(WorkStreamClient.class); + + private final ExileConfig config; + private final JobHandler jobHandler; + private final EventHandler eventHandler; + private final String clientName; + private final String clientVersion; + private final int maxConcurrency; + private final List capabilities; + + private final AtomicBoolean running = new AtomicBoolean(false); + private final AtomicReference> requestObserver = + new AtomicReference<>(); + private final AtomicInteger inflight = new AtomicInteger(0); + private final ExecutorService workerPool = Executors.newVirtualThreadPerTaskExecutor(); + + // Maps work_id → SpanContext so async responses (ResultAccepted, etc.) can log with trace + // context. + private final java.util.concurrent.ConcurrentHashMap workSpanContexts = + new java.util.concurrent.ConcurrentHashMap<>(); + + // Status tracking. + private volatile Phase phase = Phase.IDLE; + private volatile String clientId; + private volatile Instant connectedSince; + private volatile Instant lastDisconnect; + private volatile String lastError; + private final AtomicLong completedTotal = new AtomicLong(0); + private final AtomicLong failedTotal = new AtomicLong(0); + private final AtomicLong reconnectAttempts = new AtomicLong(0); + + private volatile ManagedChannel channel; + private volatile Thread streamThread; + private volatile java.util.function.DoubleConsumer durationRecorder; + private volatile java.util.function.DoubleConsumer reconnectRecorder; + private volatile boolean lastDisconnectGraceful; + private volatile MethodRecorder methodRecorder; + + /** Callback to record per-method metrics (name, duration, success). */ + @FunctionalInterface + public interface MethodRecorder { + void record(String method, double durationSeconds, boolean success); + } + + public WorkStreamClient( + ExileConfig config, + JobHandler jobHandler, + EventHandler eventHandler, + String clientName, + String clientVersion, + int maxConcurrency, + List capabilities) { + this.config = config; + this.jobHandler = jobHandler; + this.eventHandler = eventHandler; + this.clientName = clientName; + this.clientVersion = clientVersion; + this.maxConcurrency = maxConcurrency; + this.capabilities = capabilities; + } + + /** Returns a snapshot of the stream's current state. */ + public StreamStatus status() { + return new StreamStatus( + phase, + clientId, + connectedSince, + lastDisconnect, + lastError, + inflight.get(), + completedTotal.get(), + failedTotal.get(), + reconnectAttempts.get()); + } + + public void start() { + if (!running.compareAndSet(false, true)) { + throw new IllegalStateException("Already started"); + } + log.debug("Creating gRPC channel to {}:{}", config.apiHostname(), config.apiPort()); + channel = ChannelFactory.create(config); + log.debug("Channel created"); + streamThread = + Thread.ofPlatform().name("exile-work-stream").daemon(true).start(this::reconnectLoop); + } + + private void reconnectLoop() { + var backoff = new Backoff(); + while (running.get()) { + try { + long delayMs = backoff.nextDelayMs(); + if (delayMs > 0) { + phase = Phase.RECONNECTING; + log.info("Reconnecting in {}ms (attempt #{})", delayMs, reconnectAttempts.get() + 1); + } + backoff.sleep(); + long attempt = reconnectAttempts.incrementAndGet(); + log.info( + "Connecting to {}:{} (attempt #{})", config.apiHostname(), config.apiPort(), attempt); + runStream(); + // Only reset backoff if the stream ended gracefully (RST_STREAM NO_ERROR or server close). + // UNAVAILABLE and other errors should trigger backoff to avoid hammering the server. + if (lastDisconnectGraceful) { + backoff.reset(); + } else { + backoff.recordFailure(); + } + lastDisconnectGraceful = false; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } catch (Exception e) { + backoff.recordFailure(); + lastDisconnect = Instant.now(); + lastError = e.getClass().getSimpleName() + ": " + e.getMessage(); + connectedSince = null; + clientId = null; + lastDisconnectGraceful = false; + log.warn("Stream disconnected ({}): {}", e.getClass().getSimpleName(), e.getMessage()); + } + } + phase = Phase.CLOSED; + log.info("Work stream loop exited"); + } + + private void runStream() throws InterruptedException { + phase = Phase.CONNECTING; + log.debug("Opening WorkStream on existing channel"); + try { + var stub = WorkerServiceGrpc.newStub(channel); + var latch = new CountDownLatch(1); + + var observer = + stub.workStream( + new StreamObserver<>() { + @Override + public void onNext(WorkResponse response) { + handleResponse(response); + } + + @Override + public void onError(Throwable t) { + lastError = t.getClass().getSimpleName() + ": " + t.getMessage(); + lastDisconnect = Instant.now(); + connectedSince = null; + // RST_STREAM with NO_ERROR is envoy recycling the stream, not a real failure. + String msg = t.getMessage(); + lastDisconnectGraceful = + msg != null && msg.contains("RST_STREAM") && msg.contains("NO_ERROR"); + log.warn("Stream error ({}): {}", t.getClass().getSimpleName(), t.getMessage()); + // Log the full cause chain for SSL errors to aid debugging. + for (Throwable cause = t.getCause(); cause != null; cause = cause.getCause()) { + log.warn( + " caused by ({}): {}", + cause.getClass().getSimpleName(), + cause.getMessage()); + } + latch.countDown(); + } + + @Override + public void onCompleted() { + lastDisconnect = Instant.now(); + connectedSince = null; + lastDisconnectGraceful = true; + log.info("Stream completed by server"); + latch.countDown(); + } + }); + + requestObserver.set(observer); + + // Register. + phase = Phase.REGISTERING; + send( + WorkRequest.newBuilder() + .setRegister( + Register.newBuilder() + .setClientName(clientName) + .setClientVersion(clientVersion) + .addAllCapabilities(capabilities)) + .build()); + + // Wait until stream ends. + latch.await(); + } finally { + requestObserver.set(null); + inflight.set(0); + // Channel is reused across reconnects — only shut down on close(). + } + } + + private void handleResponse(WorkResponse response) { + switch (response.getPayloadCase()) { + case REGISTERED -> { + var reg = response.getRegistered(); + clientId = reg.getClientId(); + var now = Instant.now(); + // Record reconnect duration if this is a re-registration. + var disconnectTime = lastDisconnect; + if (disconnectTime != null) { + double reconnectSec = java.time.Duration.between(disconnectTime, now).toMillis() / 1000.0; + var rr = reconnectRecorder; + if (rr != null) rr.accept(reconnectSec); + log.info( + "Reconnected in {}ms", java.time.Duration.between(disconnectTime, now).toMillis()); + lastDisconnect = null; + } + connectedSince = now; + phase = Phase.ACTIVE; + log.info( + "Registered as {} (heartbeat={}s, lease={}s, max_inflight={})", + reg.getClientId(), + reg.getHeartbeatInterval().getSeconds(), + reg.getDefaultLease().getSeconds(), + reg.getMaxInflight()); + // Signal the gate to start sending events. The gate pushes continuously; + // gRPC HTTP/2 flow control handles backpressure if we can't keep up. + pull(Integer.MAX_VALUE); + } + case WORK_ITEM -> { + inflight.incrementAndGet(); + workerPool.submit(() -> processWorkItem(response.getWorkItem())); + } + case RESULT_ACCEPTED -> { + var workId = response.getResultAccepted().getWorkId(); + withWorkSpan(workId, () -> log.debug("Result accepted: {}", workId)); + workSpanContexts.remove(workId); + } + case LEASE_EXPIRING -> { + var w = response.getLeaseExpiring(); + withWorkSpan( + w.getWorkId(), + () -> + log.debug( + "Lease expiring for {}, {}s remaining", + w.getWorkId(), + w.getRemaining().getSeconds())); + send( + WorkRequest.newBuilder() + .setExtendLease( + ExtendLease.newBuilder() + .setWorkId(w.getWorkId()) + .setExtension(com.google.protobuf.Duration.newBuilder().setSeconds(300))) + .build()); + } + case HEARTBEAT -> + send( + WorkRequest.newBuilder() + .setHeartbeat( + Heartbeat.newBuilder() + .setClientTime( + com.google.protobuf.Timestamp.newBuilder() + .setSeconds(Instant.now().getEpochSecond()))) + .build()); + case ERROR -> { + var err = response.getError(); + lastError = err.getCode() + ": " + err.getMessage(); + withWorkSpan( + err.getWorkId(), + () -> + log.warn( + "Stream error for {}: {} - {}", + err.getWorkId(), + err.getCode(), + err.getMessage())); + } + default -> {} + } + } + + /** Set a callback to record work item processing duration (in seconds). */ + public void setDurationRecorder(java.util.function.DoubleConsumer recorder) { + this.durationRecorder = recorder; + } + + /** Set a callback to record per-method metrics. */ + public void setMethodRecorder(MethodRecorder recorder) { + this.methodRecorder = recorder; + } + + /** Set a callback to record reconnect duration (in seconds). */ + public void setReconnectRecorder(java.util.function.DoubleConsumer recorder) { + this.reconnectRecorder = recorder; + } + + private static final Tracer tracer = GlobalOpenTelemetry.getTracer("com.tcn.exile.sati", "1.0.0"); + + /** Run a block with the span context of a work item temporarily set as current. */ + private void withWorkSpan(String workId, Runnable action) { + var sc = workSpanContexts.get(workId); + if (sc != null) { + try (Scope ignored = Context.current().with(Span.wrap(sc)).makeCurrent()) { + MDC.put("traceId", sc.getTraceId()); + MDC.put("spanId", sc.getSpanId()); + action.run(); + } finally { + MDC.remove("traceId"); + MDC.remove("spanId"); + } + } else { + action.run(); + } + } + + /** Parse a W3C traceparent string ("00-traceId-spanId-flags") into a SpanContext. */ + private static SpanContext parseTraceParent(String traceParent) { + if (traceParent == null || traceParent.isEmpty()) return null; + String[] parts = traceParent.split("-"); + if (parts.length < 4) return null; + try { + return SpanContext.createFromRemoteParent( + parts[1], + parts[2], + TraceFlags.fromByte(Byte.parseByte(parts[3], 16)), + TraceState.getDefault()); + } catch (Exception e) { + return null; + } + } + + private void processWorkItem(WorkItem item) { + long startNanos = System.nanoTime(); + String workId = item.getWorkId(); + String category = item.getCategory() == WorkCategory.WORK_CATEGORY_JOB ? "job" : "event"; + + var spanBuilder = + tracer + .spanBuilder("exile.work." + category) + .setSpanKind(SpanKind.CONSUMER) + .setAttribute("exile.work_id", workId) + .setAttribute("exile.work_category", category); + + // Link to the upstream trace from the gate if trace_parent is set. + var parentCtx = parseTraceParent(item.getTraceParent()); + if (parentCtx != null) { + spanBuilder.setParent(Context.current().with(Span.wrap(parentCtx))); + } + + Span span = spanBuilder.startSpan(); + workSpanContexts.put(workId, span.getSpanContext()); + + try (Scope ignored = span.makeCurrent()) { + MDC.put("traceId", span.getSpanContext().getTraceId()); + MDC.put("spanId", span.getSpanContext().getSpanId()); + if (item.getCategory() == WorkCategory.WORK_CATEGORY_JOB) { + var result = dispatchJob(item); + send(WorkRequest.newBuilder().setResult(result).build()); + } else { + dispatchEvent(item); + send(WorkRequest.newBuilder().setAck(Ack.newBuilder().addWorkIds(workId)).build()); + } + completedTotal.incrementAndGet(); + } catch (Exception e) { + span.setStatus(StatusCode.ERROR, e.getMessage()); + span.recordException(e); + failedTotal.incrementAndGet(); + log.warn("Work item {} failed: {}", workId, e.getMessage()); + if (item.getCategory() == WorkCategory.WORK_CATEGORY_JOB) { + send( + WorkRequest.newBuilder() + .setResult( + Result.newBuilder() + .setWorkId(workId) + .setFinal(true) + .setError(ErrorResult.newBuilder().setMessage(e.getMessage()))) + .build()); + } else { + send( + WorkRequest.newBuilder() + .setNack(Nack.newBuilder().setWorkId(workId).setReason(e.getMessage())) + .build()); + } + } finally { + MDC.remove("traceId"); + MDC.remove("spanId"); + span.end(); + // For events (ACK), clean up now — no RESULT_ACCEPTED will come. + // For jobs, clean up in handleResponse when RESULT_ACCEPTED is received. + if (item.getCategory() != WorkCategory.WORK_CATEGORY_JOB) { + workSpanContexts.remove(workId); + } + var recorder = durationRecorder; + if (recorder != null) { + recorder.accept((System.nanoTime() - startNanos) / 1_000_000_000.0); + } + inflight.decrementAndGet(); + // Periodic pull thread handles capacity signaling. + } + } + + private Result.Builder dispatchJob(WorkItem item) throws Exception { + var b = Result.newBuilder().setWorkId(item.getWorkId()).setFinal(true); + var methodName = item.getTaskCase().name().toLowerCase(); + long methodStart = System.nanoTime(); + boolean methodSuccess = false; + try { + switch (item.getTaskCase()) { + case LIST_POOLS -> { + var pools = jobHandler.listPools(); + b.setListPools( + ListPoolsResult.newBuilder() + .addAllPools(pools.stream().map(ProtoConverter::fromPool).toList())); + } + case GET_POOL_STATUS -> { + var pool = jobHandler.getPoolStatus(item.getGetPoolStatus().getPoolId()); + b.setGetPoolStatus(GetPoolStatusResult.newBuilder().setPool(fromPool(pool))); + } + case GET_POOL_RECORDS -> { + var task = item.getGetPoolRecords(); + var page = + jobHandler.getPoolRecords(task.getPoolId(), task.getPageToken(), task.getPageSize()); + b.setGetPoolRecords( + GetPoolRecordsResult.newBuilder() + .addAllRecords( + page.items().stream() + .map( + r -> ProtoConverter.fromRecord(r)) + .toList()) + .setNextPageToken(page.nextPageToken() != null ? page.nextPageToken() : "")); + } + case SEARCH_RECORDS -> { + var task = item.getSearchRecords(); + var filters = task.getFiltersList().stream().map(ProtoConverter::toFilter).toList(); + var page = jobHandler.searchRecords(filters, task.getPageToken(), task.getPageSize()); + b.setSearchRecords( + SearchRecordsResult.newBuilder() + .addAllRecords( + page.items().stream() + .map( + r -> ProtoConverter.fromRecord(r)) + .toList()) + .setNextPageToken(page.nextPageToken() != null ? page.nextPageToken() : "")); + } + case GET_RECORD_FIELDS -> { + var task = item.getGetRecordFields(); + var fields = + jobHandler.getRecordFields( + task.getPoolId(), task.getRecordId(), task.getFieldNamesList()); + b.setGetRecordFields( + GetRecordFieldsResult.newBuilder() + .addAllFields(fields.stream().map(ProtoConverter::fromField).toList())); + } + case SET_RECORD_FIELDS -> { + var task = item.getSetRecordFields(); + var fields = task.getFieldsList().stream().map(ProtoConverter::toField).toList(); + var ok = jobHandler.setRecordFields(task.getPoolId(), task.getRecordId(), fields); + b.setSetRecordFields(SetRecordFieldsResult.newBuilder().setSuccess(ok)); + } + case CREATE_PAYMENT -> { + var task = item.getCreatePayment(); + var paymentId = + jobHandler.createPayment( + task.getPoolId(), task.getRecordId(), structToMap(task.getPaymentData())); + b.setCreatePayment( + CreatePaymentResult.newBuilder().setSuccess(true).setPaymentId(paymentId)); + } + case POP_ACCOUNT -> { + var task = item.getPopAccount(); + var record = jobHandler.popAccount(task.getPoolId(), task.getRecordId()); + b.setPopAccount(PopAccountResult.newBuilder().setRecord(fromRecord(record))); + } + case EXECUTE_LOGIC -> { + var task = item.getExecuteLogic(); + var output = + jobHandler.executeLogic(task.getLogicName(), structToMap(task.getParameters())); + b.setExecuteLogic(ExecuteLogicResult.newBuilder().setOutput(mapToStruct(output))); + } + case INFO -> { + var info = jobHandler.info(); + var ib = InfoResult.newBuilder(); + if (info.containsKey("appName")) ib.setAppName((String) info.get("appName")); + if (info.containsKey("appVersion")) ib.setAppVersion((String) info.get("appVersion")); + ib.setMetadata(mapToStruct(info)); + b.setInfo(ib); + } + case SHUTDOWN -> { + jobHandler.shutdown(item.getShutdown().getReason()); + b.setShutdown(ShutdownResult.getDefaultInstance()); + } + case LOGGING -> { + jobHandler.processLog(item.getLogging().getPayload()); + b.setLogging(LoggingResult.getDefaultInstance()); + } + case DIAGNOSTICS -> { + var diag = jobHandler.diagnostics(); + b.setDiagnostics( + DiagnosticsResult.newBuilder() + .setSystemInfo(mapToStruct(diag.systemInfo())) + .setRuntimeInfo(mapToStruct(diag.runtimeInfo())) + .setDatabaseInfo(mapToStruct(diag.databaseInfo())) + .setCustom(mapToStruct(diag.custom()))); + } + case LIST_TENANT_LOGS -> { + var task = item.getListTenantLogs(); + var page = + jobHandler.listTenantLogs( + toInstant(task.getStartTime()), + toInstant(task.getEndTime()), + task.getPageToken(), + task.getPageSize()); + var rb = + ListTenantLogsResult.newBuilder() + .setNextPageToken(page.nextPageToken() != null ? page.nextPageToken() : ""); + for (var entry : page.items()) { + rb.addEntries( + ListTenantLogsResult.LogEntry.newBuilder() + .setTimestamp(fromInstant(entry.timestamp())) + .setLevel(entry.level()) + .setLogger(entry.logger()) + .setMessage(entry.message())); + } + b.setListTenantLogs(rb); + } + case SET_LOG_LEVEL -> { + var task = item.getSetLogLevel(); + jobHandler.setLogLevel(task.getLoggerName(), task.getLevel()); + b.setSetLogLevel(SetLogLevelResult.getDefaultInstance()); + } + default -> throw new UnsupportedOperationException("Unknown job: " + item.getTaskCase()); + } + methodSuccess = true; + return b; + } finally { + var mr = methodRecorder; + if (mr != null) { + mr.record(methodName, (System.nanoTime() - methodStart) / 1_000_000_000.0, methodSuccess); + } + } + } + + private void dispatchEvent(WorkItem item) throws Exception { + var methodName = item.getTaskCase().name().toLowerCase(); + long methodStart = System.nanoTime(); + boolean methodSuccess = false; + try { + switch (item.getTaskCase()) { + case AGENT_CALL -> eventHandler.onAgentCall(toAgentCallEvent(item.getAgentCall())); + case TELEPHONY_RESULT -> + eventHandler.onTelephonyResult(toTelephonyResultEvent(item.getTelephonyResult())); + case AGENT_RESPONSE -> + eventHandler.onAgentResponse(toAgentResponseEvent(item.getAgentResponse())); + case TRANSFER_INSTANCE -> + eventHandler.onTransferInstance(toTransferInstanceEvent(item.getTransferInstance())); + case CALL_RECORDING -> + eventHandler.onCallRecording(toCallRecordingEvent(item.getCallRecording())); + case EXILE_TASK -> eventHandler.onTask(toTaskEvent(item.getExileTask())); + default -> throw new UnsupportedOperationException("Unknown event: " + item.getTaskCase()); + } + methodSuccess = true; + } finally { + var mr = methodRecorder; + if (mr != null) { + mr.record(methodName, (System.nanoTime() - methodStart) / 1_000_000_000.0, methodSuccess); + } + } + } + + private void pull(int count) { + send(WorkRequest.newBuilder().setPull(Pull.newBuilder().setMaxItems(count)).build()); + } + + private void send(WorkRequest request) { + var observer = requestObserver.get(); + if (observer != null) { + try { + synchronized (observer) { + observer.onNext(request); + } + } catch (Exception e) { + log.warn("Failed to send {}: {}", request.getActionCase(), e.getMessage()); + // Stream is broken — cancel it so the reconnect loop picks up immediately + // instead of waiting for the next Recv to fail. + var current = requestObserver.getAndSet(null); + if (current != null) { + try { + current.onError(e); + } catch (Exception ignored) { + } + } + } + } + } + + @Override + public void close() { + running.set(false); + phase = Phase.CLOSED; + if (streamThread != null) streamThread.interrupt(); + var observer = requestObserver.getAndSet(null); + if (observer != null) { + try { + observer.onCompleted(); + } catch (Exception ignored) { + } + } + workerPool.close(); + if (channel != null) ChannelFactory.shutdown(channel); + } +} diff --git a/core/src/main/java/com/tcn/exile/log/LogCategory.java b/core/src/main/java/com/tcn/exile/log/LogCategory.java deleted file mode 100644 index 009e4fd..0000000 --- a/core/src/main/java/com/tcn/exile/log/LogCategory.java +++ /dev/null @@ -1,48 +0,0 @@ -/* - * (C) 2017-2025 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.log; - -/** - * Defines the categories of log messages for structured logging. Each category represents a - * specific area of the application. - */ -public enum LogCategory { - STARTUP("startup"), // Application lifecycle - CONFIG("config"), // Configuration changes - DATABASE("database"), // Database operations - GRPC("grpc"), // gRPC communications - PLUGIN("plugin"), // Plugin lifecycle and jobs - QUEUE("queue"), // Job queue operations - API("api"), // REST API operations - SECURITY("security"), // Authentication/authorization - PERFORMANCE("performance"); // Performance metrics - - private final String value; - - LogCategory(String value) { - this.value = value; - } - - public String getValue() { - return value; - } - - @Override - public String toString() { - return value; - } -} diff --git a/core/src/main/java/com/tcn/exile/log/LoggerLevels.java b/core/src/main/java/com/tcn/exile/log/LoggerLevels.java deleted file mode 100644 index 6d5a3be..0000000 --- a/core/src/main/java/com/tcn/exile/log/LoggerLevels.java +++ /dev/null @@ -1,36 +0,0 @@ -/* - * (C) 2017-2025 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.log; - -import io.micronaut.context.annotation.ConfigurationProperties; -import io.micronaut.serde.annotation.Serdeable; -import java.util.HashMap; - -@Serdeable -@ConfigurationProperties("logger") -public class LoggerLevels { - private HashMap levels = new HashMap<>(); - - public HashMap getLevels() { - return levels; - } - - public LoggerLevels setLevels(HashMap levels) { - this.levels = levels; - return this; - } -} diff --git a/core/src/main/java/com/tcn/exile/log/StructuredLogger.java b/core/src/main/java/com/tcn/exile/log/StructuredLogger.java deleted file mode 100644 index a57f4f9..0000000 --- a/core/src/main/java/com/tcn/exile/log/StructuredLogger.java +++ /dev/null @@ -1,192 +0,0 @@ -/* - * (C) 2017-2025 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.log; - -import java.util.UUID; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.slf4j.MDC; - -/** - * Utility class for structured logging with standardized message formats. Provides methods for - * logging with consistent context and formatting. - */ -public class StructuredLogger { - private final Logger logger; - private final String component; - - /** - * Creates a new StructuredLogger for the specified class. - * - * @param clazz The class to create a logger for - */ - public StructuredLogger(Class clazz) { - this.logger = LoggerFactory.getLogger(clazz); - this.component = clazz.getSimpleName(); - } - - /** - * Creates a new StructuredLogger with the specified name. - * - * @param name The name for the logger - */ - public StructuredLogger(String name) { - this.logger = LoggerFactory.getLogger(name); - this.component = name; - } - - /** - * Sets up the MDC context for a request. - * - * @param tenant The tenant ID - * @param userId The user ID (optional) - * @param operation The operation name (optional) - * @param jobId The job ID (optional) - */ - public static void setupRequestContext( - String tenant, String userId, String operation, String jobId) { - MDC.put("tenant", tenant); - MDC.put("requestId", UUID.randomUUID().toString()); - - if (userId != null) { - MDC.put("userId", userId); - } - - if (operation != null) { - MDC.put("operation", operation); - } - - if (jobId != null) { - MDC.put("jobId", jobId); - } - } - - /** Clears the MDC context. */ - public static void clearContext() { - MDC.clear(); - } - - /** - * Logs a message with the specified category and level. - * - * @param category The log category - * @param level The log level - * @param action The action being performed - * @param message The log message - * @param args The message arguments - */ - private void log( - LogCategory category, String level, String action, String message, Object... args) { - String formattedMessage = - String.format( - "[%s] [%s] - %s", - category.getValue(), action, args.length > 0 ? String.format(message, args) : message); - - switch (level) { - case "DEBUG": - logger.debug(formattedMessage); - break; - case "INFO": - logger.info(formattedMessage); - break; - case "WARN": - logger.warn(formattedMessage); - break; - case "ERROR": - logger.error(formattedMessage); - break; - default: - logger.info(formattedMessage); - } - } - - /** - * Logs an info message. - * - * @param category The log category - * @param action The action being performed - * @param message The log message - * @param args The message arguments - */ - public void info(LogCategory category, String action, String message, Object... args) { - log(category, "INFO", action, message, args); - } - - /** - * Logs a debug message. - * - * @param category The log category - * @param action The action being performed - * @param message The log message - * @param args The message arguments - */ - public void debug(LogCategory category, String action, String message, Object... args) { - log(category, "DEBUG", action, message, args); - } - - /** - * Logs a warning message. - * - * @param category The log category - * @param action The action being performed - * @param message The log message - * @param args The message arguments - */ - public void warn(LogCategory category, String action, String message, Object... args) { - log(category, "WARN", action, message, args); - } - - /** - * Logs an error message. - * - * @param category The log category - * @param action The action being performed - * @param message The log message - * @param args The message arguments - */ - public void error(LogCategory category, String action, String message, Object... args) { - log(category, "ERROR", action, message, args); - } - - /** - * Logs an error message with an exception. - * - * @param category The log category - * @param action The action being performed - * @param message The log message - * @param throwable The exception - */ - public void error(LogCategory category, String action, String message, Throwable throwable) { - String formattedMessage = String.format("[%s] [%s] - %s", category.getValue(), action, message); - logger.error(formattedMessage, throwable); - } - - /** - * Logs a performance metric. - * - * @param metricName The name of the metric - * @param value The metric value - * @param unit The unit of measurement - */ - public void metric(String metricName, double value, String unit) { - String formattedMessage = - String.format( - "[%s] [METRIC] - %s: %.2f %s", - LogCategory.PERFORMANCE.getValue(), metricName, value, unit); - logger.info(formattedMessage); - } -} diff --git a/core/src/main/java/com/tcn/exile/model/Agent.java b/core/src/main/java/com/tcn/exile/model/Agent.java new file mode 100644 index 0000000..496582c --- /dev/null +++ b/core/src/main/java/com/tcn/exile/model/Agent.java @@ -0,0 +1,20 @@ +package com.tcn.exile.model; + +import java.util.Optional; + +public record Agent( + String userId, + String orgId, + String firstName, + String lastName, + String username, + String partnerAgentId, + String currentSessionId, + AgentState state, + boolean loggedIn, + boolean muted, + boolean recording, + Optional connectedParty) { + + public record ConnectedParty(long callSid, CallType callType, boolean inbound) {} +} diff --git a/core/src/main/java/com/tcn/exile/model/AgentState.java b/core/src/main/java/com/tcn/exile/model/AgentState.java new file mode 100644 index 0000000..afea282 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/model/AgentState.java @@ -0,0 +1,23 @@ +package com.tcn.exile.model; + +public enum AgentState { + UNSPECIFIED, + IDLE, + READY, + PEERED, + PAUSED, + WRAPUP, + PREPARING_PREVIEW, + PREVIEWING, + PREPARING_MANUAL, + DIALING, + INTERCOM, + WARM_TRANSFER_PENDING, + WARM_TRANSFER_ACTIVE, + COLD_TRANSFER_ACTIVE, + CONFERENCE_ACTIVE, + LOGGED_OUT, + SUSPENDED, + EXTERNAL_TRANSFER, + CALLBACK_SUSPENDED +} diff --git a/core/src/main/java/com/tcn/exile/model/CallType.java b/core/src/main/java/com/tcn/exile/model/CallType.java new file mode 100644 index 0000000..83db387 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/model/CallType.java @@ -0,0 +1,10 @@ +package com.tcn.exile.model; + +public enum CallType { + UNSPECIFIED, + INBOUND, + OUTBOUND, + PREVIEW, + MANUAL, + MAC +} diff --git a/core/src/main/java/com/tcn/exile/model/DataRecord.java b/core/src/main/java/com/tcn/exile/model/DataRecord.java new file mode 100644 index 0000000..45cf955 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/model/DataRecord.java @@ -0,0 +1,5 @@ +package com.tcn.exile.model; + +import java.util.Map; + +public record DataRecord(String poolId, String recordId, Map payload) {} diff --git a/core/src/main/java/com/tcn/exile/model/Field.java b/core/src/main/java/com/tcn/exile/model/Field.java new file mode 100644 index 0000000..2279289 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/model/Field.java @@ -0,0 +1,3 @@ +package com.tcn.exile.model; + +public record Field(String fieldName, String fieldValue, String poolId, String recordId) {} diff --git a/core/src/main/java/com/tcn/exile/model/Filter.java b/core/src/main/java/com/tcn/exile/model/Filter.java new file mode 100644 index 0000000..97c7df0 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/model/Filter.java @@ -0,0 +1,15 @@ +package com.tcn.exile.model; + +public record Filter(String field, Operator operator, String value) { + + public enum Operator { + UNSPECIFIED, + EQUAL, + NOT_EQUAL, + CONTAINS, + GREATER_THAN, + LESS_THAN, + IN, + EXISTS + } +} diff --git a/core/src/main/java/com/tcn/exile/model/Page.java b/core/src/main/java/com/tcn/exile/model/Page.java new file mode 100644 index 0000000..1a6ef99 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/model/Page.java @@ -0,0 +1,11 @@ +package com.tcn.exile.model; + +import java.util.List; + +/** A page of results with an optional continuation token. */ +public record Page(List items, String nextPageToken) { + + public boolean hasMore() { + return nextPageToken != null && !nextPageToken.isEmpty(); + } +} diff --git a/core/src/main/java/com/tcn/exile/model/Pool.java b/core/src/main/java/com/tcn/exile/model/Pool.java new file mode 100644 index 0000000..ebc7ad9 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/model/Pool.java @@ -0,0 +1,11 @@ +package com.tcn.exile.model; + +public record Pool(String poolId, String description, PoolStatus status, long recordCount) { + + public enum PoolStatus { + UNSPECIFIED, + READY, + NOT_READY, + BUSY + } +} diff --git a/core/src/main/java/com/tcn/exile/model/Skill.java b/core/src/main/java/com/tcn/exile/model/Skill.java new file mode 100644 index 0000000..c6d9100 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/model/Skill.java @@ -0,0 +1,5 @@ +package com.tcn.exile.model; + +import java.util.OptionalLong; + +public record Skill(String skillId, String name, String description, OptionalLong proficiency) {} diff --git a/core/src/main/java/com/tcn/exile/model/TaskData.java b/core/src/main/java/com/tcn/exile/model/TaskData.java new file mode 100644 index 0000000..0bfa84e --- /dev/null +++ b/core/src/main/java/com/tcn/exile/model/TaskData.java @@ -0,0 +1,3 @@ +package com.tcn.exile.model; + +public record TaskData(String key, Object value) {} diff --git a/core/src/main/java/com/tcn/exile/model/event/AgentCallEvent.java b/core/src/main/java/com/tcn/exile/model/event/AgentCallEvent.java new file mode 100644 index 0000000..9e16a02 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/model/event/AgentCallEvent.java @@ -0,0 +1,30 @@ +package com.tcn.exile.model.event; + +import com.tcn.exile.model.CallType; +import com.tcn.exile.model.TaskData; +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +public record AgentCallEvent( + long agentCallSid, + long callSid, + CallType callType, + String orgId, + String userId, + String partnerAgentId, + String internalKey, + Duration talkDuration, + Duration waitDuration, + Duration wrapupDuration, + Duration pauseDuration, + Duration transferDuration, + Duration manualDuration, + Duration previewDuration, + Duration holdDuration, + Duration agentWaitDuration, + Duration suspendedDuration, + Duration externalTransferDuration, + Instant createTime, + Instant updateTime, + List taskData) {} diff --git a/core/src/main/java/com/tcn/exile/model/event/AgentResponseEvent.java b/core/src/main/java/com/tcn/exile/model/event/AgentResponseEvent.java new file mode 100644 index 0000000..12c9faf --- /dev/null +++ b/core/src/main/java/com/tcn/exile/model/event/AgentResponseEvent.java @@ -0,0 +1,19 @@ +package com.tcn.exile.model.event; + +import com.tcn.exile.model.CallType; +import java.time.Instant; + +public record AgentResponseEvent( + long agentCallResponseSid, + long callSid, + CallType callType, + String orgId, + String userId, + String partnerAgentId, + String internalKey, + long agentSid, + long clientSid, + String responseKey, + String responseValue, + Instant createTime, + Instant updateTime) {} diff --git a/core/src/main/java/com/tcn/exile/model/event/CallRecordingEvent.java b/core/src/main/java/com/tcn/exile/model/event/CallRecordingEvent.java new file mode 100644 index 0000000..7794ddc --- /dev/null +++ b/core/src/main/java/com/tcn/exile/model/event/CallRecordingEvent.java @@ -0,0 +1,14 @@ +package com.tcn.exile.model.event; + +import com.tcn.exile.model.CallType; +import java.time.Duration; +import java.time.Instant; + +public record CallRecordingEvent( + String recordingId, + String orgId, + long callSid, + CallType callType, + Duration duration, + String recordingType, + Instant startTime) {} diff --git a/core/src/main/java/com/tcn/exile/model/event/TaskEvent.java b/core/src/main/java/com/tcn/exile/model/event/TaskEvent.java new file mode 100644 index 0000000..27764d6 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/model/event/TaskEvent.java @@ -0,0 +1,15 @@ +package com.tcn.exile.model.event; + +import java.time.Instant; + +public record TaskEvent( + long taskSid, + long taskGroupSid, + String orgId, + long clientSid, + String poolId, + String recordId, + long attempts, + String status, + Instant createTime, + Instant updateTime) {} diff --git a/core/src/main/java/com/tcn/exile/model/event/TelephonyResultEvent.java b/core/src/main/java/com/tcn/exile/model/event/TelephonyResultEvent.java new file mode 100644 index 0000000..11a36d6 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/model/event/TelephonyResultEvent.java @@ -0,0 +1,28 @@ +package com.tcn.exile.model.event; + +import com.tcn.exile.model.CallType; +import com.tcn.exile.model.TaskData; +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +public record TelephonyResultEvent( + long callSid, + CallType callType, + String orgId, + String internalKey, + String callerId, + String phoneNumber, + String poolId, + String recordId, + long clientSid, + String status, + String outcomeCategory, + String outcomeDetail, + Duration deliveryLength, + Duration linkbackLength, + Instant createTime, + Instant updateTime, + Instant startTime, + Instant endTime, + List taskData) {} diff --git a/core/src/main/java/com/tcn/exile/model/event/TransferInstanceEvent.java b/core/src/main/java/com/tcn/exile/model/event/TransferInstanceEvent.java new file mode 100644 index 0000000..5dba985 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/model/event/TransferInstanceEvent.java @@ -0,0 +1,33 @@ +package com.tcn.exile.model.event; + +import com.tcn.exile.model.CallType; +import java.time.Duration; +import java.time.Instant; + +public record TransferInstanceEvent( + long clientSid, + String orgId, + long transferInstanceId, + Source source, + String transferType, + String transferResult, + String initiation, + Instant createTime, + Instant transferTime, + Instant acceptTime, + Instant hangupTime, + Instant endTime, + Instant updateTime, + Duration pendingDuration, + Duration externalDuration, + Duration fullDuration) { + + public record Source( + long callSid, + CallType callType, + String partnerAgentId, + String userId, + String conversationId, + long sessionSid, + long agentCallSid) {} +} diff --git a/core/src/main/java/com/tcn/exile/models/Agent.java b/core/src/main/java/com/tcn/exile/models/Agent.java deleted file mode 100644 index 6c1fa19..0000000 --- a/core/src/main/java/com/tcn/exile/models/Agent.java +++ /dev/null @@ -1,35 +0,0 @@ -/* - * (C) 2017-2024 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.models; - -import io.micronaut.core.annotation.Nullable; -import io.micronaut.serde.annotation.Serdeable; -import jakarta.validation.constraints.NotEmpty; - -@Serdeable -public record Agent( - @NotEmpty String userId, - @Nullable String partnerAgentId, - @NotEmpty String username, - @Nullable String firstName, - @Nullable String lastName, - @Nullable Long currentSessionId, - @Nullable AgentState agentState, - @Nullable Boolean isLoggedIn, - @Nullable Boolean isRecording, - @Nullable Boolean agentIsMuted, - @Nullable ConnectedParty connectedParty) {} diff --git a/core/src/main/java/com/tcn/exile/models/AgentState.java b/core/src/main/java/com/tcn/exile/models/AgentState.java deleted file mode 100644 index 403f6dd..0000000 --- a/core/src/main/java/com/tcn/exile/models/AgentState.java +++ /dev/null @@ -1,78 +0,0 @@ -/* - * (C) 2017-2024 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.models; - -import io.micronaut.serde.annotation.Serdeable; - -@Serdeable -public enum AgentState { - AGENT_STATE_UNAVAILABLE(0), - AGENT_STATE_IDLE(1), - AGENT_STATE_READY(2), - AGENT_STATE_HUNGUP(3), - AGENT_STATE_DESTROYED(4), - AGENT_STATE_PEERED(5), - AGENT_STATE_PAUSED(6), - AGENT_STATE_WRAPUP(7), - AGENT_STATE_PREPARING_AFTER_IDLE(8), - AGENT_STATE_PREPARING_AFTER_WRAPUP(9), - AGENT_STATE_PREPARING_AFTER_PAUSE(10), - AGENT_STATE_PREPARING_AFTER_DIAL_CANCEL(11), - AGENT_STATE_PREPARING_AFTER_PBX_REJECT(12), - AGENT_STATE_PREPARING_AFTER_PBX_HANGUP(13), - AGENT_STATE_PREPARING_AFTER_PBX_WAS_TAKEN(14), - AGENT_STATE_PREPARING_AFTER_GUI_BUSY(15), - AGENT_STATE_MANUAL_DIAL_PREPARED(16), - AGENT_STATE_PREVIEW_DIAL_PREPARED(17), - AGENT_STATE_MANUAL_DIAL_STARTED(18), - AGENT_STATE_PREVIEW_DIAL_STARTED(19), - AGENT_STATE_OUTBOUND_LOCKED(20), - AGENT_STATE_WARM_AGENT_TRANSFER_STARTED_SOURCE(21), - AGENT_STATE_WARM_AGENT_TRANSFER_STARTED_DESTINATION(22), - AGENT_STATE_WARM_OUTBOUND_TRANSFER_STARTED(23), - AGENT_STATE_WARM_OUTBOUND_TRANSFER_PEER_LOST(24), - AGENT_STATE_PBX_POPUP_LOCKED(25), - AGENT_STATE_PEERED_WITH_CALL_ON_HOLD(26), - AGENT_STATE_CALLBACK_RESUMING(27), - AGENT_STATE_GUI_BUSY(28), - AGENT_STATE_INTERCOM(29), - AGENT_STATE_INTERCOM_RINGING_SOURCE(30), - AGENT_STATE_INTERCOM_RINGING_DESTINATION(31), - AGENT_STATE_WARM_OUTBOUND_TRANSFER_OUTBOUND_LOST(32), - AGENT_STATE_PREPARED_TO_PEER(33), - AGENT_STATE_WARM_SKILL_TRANSFER_SOURCE_PENDING(34), - AGENT_STATE_CALLER_TRANSFER_STARTED(35), - AGENT_STATE_CALLER_TRANSFER_LOST_PEER(36), - AGENT_STATE_CALLER_TRANSFER_LOST_MERGED_CALLER(37), - AGENT_STATE_COLD_OUTBOUND_TRANSFER_STARTED(38), - AGENT_STATE_COLD_AGENT_TRANSFER_STARTED(39), - ; - - private int value; - - AgentState(int i) { - value = 1; - } - - public static AgentState forNumber(int value) { - return AgentState.values()[value]; - } - - public int getValue() { - return value; - } -} diff --git a/core/src/main/java/com/tcn/exile/models/AgentStatus.java b/core/src/main/java/com/tcn/exile/models/AgentStatus.java deleted file mode 100644 index bec32a6..0000000 --- a/core/src/main/java/com/tcn/exile/models/AgentStatus.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * (C) 2017-2024 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.models; - -import com.fasterxml.jackson.annotation.JsonProperty; -import io.micronaut.serde.annotation.Serdeable; -import jakarta.annotation.Nullable; - -@Serdeable -public record AgentStatus( - @JsonProperty("user_id") String userId, - @JsonProperty("state") AgentState agentState, - @JsonProperty("current_session_id") @Nullable Long currentSessionId, - @JsonProperty("connected_party") @Nullable ConnectedParty connectedParty, - @JsonProperty("agent_is_muted") boolean agentIsMuted, - @JsonProperty("is_recording") boolean isRecording) {} diff --git a/core/src/main/java/com/tcn/exile/models/AgentUpsertRequest.java b/core/src/main/java/com/tcn/exile/models/AgentUpsertRequest.java deleted file mode 100644 index 9bfae99..0000000 --- a/core/src/main/java/com/tcn/exile/models/AgentUpsertRequest.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * (C) 2017-2024 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.models; - -import io.micronaut.serde.annotation.Serdeable; -import jakarta.annotation.Nullable; -import jakarta.validation.constraints.NotEmpty; - -@Serdeable -public record AgentUpsertRequest( - @NotEmpty String username, - @Nullable String firstName, - @Nullable String lastName, - @Nullable String partnerAgentId, - @Nullable String password) {} diff --git a/core/src/main/java/com/tcn/exile/models/CallType.java b/core/src/main/java/com/tcn/exile/models/CallType.java deleted file mode 100644 index e04fe5d..0000000 --- a/core/src/main/java/com/tcn/exile/models/CallType.java +++ /dev/null @@ -1,38 +0,0 @@ -/* - * (C) 2017-2024 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.models; - -import io.micronaut.serde.annotation.Serdeable; - -@Serdeable -public enum CallType { - inbound(0), - outbound(1), - preview(2), - manual(3), - mac(4); - - private final int value; - - CallType(int i) { - value = i; - } - - public int getValue() { - return value; - } -} diff --git a/core/src/main/java/com/tcn/exile/models/ConnectedParty.java b/core/src/main/java/com/tcn/exile/models/ConnectedParty.java deleted file mode 100644 index b23be69..0000000 --- a/core/src/main/java/com/tcn/exile/models/ConnectedParty.java +++ /dev/null @@ -1,26 +0,0 @@ -/* - * (C) 2017-2025 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.models; - -import com.fasterxml.jackson.annotation.JsonProperty; -import io.micronaut.serde.annotation.Serdeable; - -@Serdeable -public record ConnectedParty( - @JsonProperty("call_sid") String callSid, - @JsonProperty("call_type") CallType callType, - @JsonProperty("inbound") boolean inbound) {} diff --git a/core/src/main/java/com/tcn/exile/models/DiagnosticsResult.java b/core/src/main/java/com/tcn/exile/models/DiagnosticsResult.java deleted file mode 100644 index fc39edc..0000000 --- a/core/src/main/java/com/tcn/exile/models/DiagnosticsResult.java +++ /dev/null @@ -1,1710 +0,0 @@ -/* - * (C) 2017-2025 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.models; - -import io.micronaut.serde.annotation.Serdeable; -import java.time.Instant; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -@Serdeable -public class DiagnosticsResult { - private Instant timestamp; - private String hostname; - private OperatingSystem operatingSystem; - private JavaRuntime javaRuntime; - private Hardware hardware; - private Memory memory; - private List storage; - private Container container; - private EnvironmentVariables environmentVariables; - private SystemProperties systemProperties; - private List hikariPoolMetrics; - private ConfigDetails configDetails; - - // Getters and setters - public Instant getTimestamp() { - return timestamp; - } - - public void setTimestamp(Instant timestamp) { - this.timestamp = timestamp; - } - - public String getHostname() { - return hostname; - } - - public void setHostname(String hostname) { - this.hostname = hostname; - } - - public OperatingSystem getOperatingSystem() { - return operatingSystem; - } - - public void setOperatingSystem(OperatingSystem operatingSystem) { - this.operatingSystem = operatingSystem; - } - - public JavaRuntime getJavaRuntime() { - return javaRuntime; - } - - public void setJavaRuntime(JavaRuntime javaRuntime) { - this.javaRuntime = javaRuntime; - } - - public Hardware getHardware() { - return hardware; - } - - public void setHardware(Hardware hardware) { - this.hardware = hardware; - } - - public Memory getMemory() { - return memory; - } - - public void setMemory(Memory memory) { - this.memory = memory; - } - - public List getStorage() { - return storage; - } - - public void setStorage(List storage) { - this.storage = storage; - } - - public Container getContainer() { - return container; - } - - public void setContainer(Container container) { - this.container = container; - } - - public EnvironmentVariables getEnvironmentVariables() { - return environmentVariables; - } - - public void setEnvironmentVariables(EnvironmentVariables environmentVariables) { - this.environmentVariables = environmentVariables; - } - - public SystemProperties getSystemProperties() { - return systemProperties; - } - - public void setSystemProperties(SystemProperties systemProperties) { - this.systemProperties = systemProperties; - } - - public List getHikariPoolMetrics() { - return hikariPoolMetrics; - } - - public void setHikariPoolMetrics(List hikariPoolMetrics) { - this.hikariPoolMetrics = hikariPoolMetrics; - } - - public ConfigDetails getConfigDetails() { - return configDetails; - } - - public void setConfigDetails(ConfigDetails configDetails) { - this.configDetails = configDetails; - } - - @Serdeable - public static class OperatingSystem { - private String name; - private String version; - private String architecture; - private String manufacturer; - private int availableProcessors; - private long systemUptime; - private double systemLoadAverage; - private long totalPhysicalMemory; - private long availablePhysicalMemory; - private long totalSwapSpace; - private long availableSwapSpace; - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getVersion() { - return version; - } - - public void setVersion(String version) { - this.version = version; - } - - public String getArchitecture() { - return architecture; - } - - public void setArchitecture(String architecture) { - this.architecture = architecture; - } - - public String getManufacturer() { - return manufacturer; - } - - public void setManufacturer(String manufacturer) { - this.manufacturer = manufacturer; - } - - public int getAvailableProcessors() { - return availableProcessors; - } - - public void setAvailableProcessors(int availableProcessors) { - this.availableProcessors = availableProcessors; - } - - public long getSystemUptime() { - return systemUptime; - } - - public void setSystemUptime(long systemUptime) { - this.systemUptime = systemUptime; - } - - public double getSystemLoadAverage() { - return systemLoadAverage; - } - - public void setSystemLoadAverage(double systemLoadAverage) { - this.systemLoadAverage = systemLoadAverage; - } - - public long getTotalPhysicalMemory() { - return totalPhysicalMemory; - } - - public void setTotalPhysicalMemory(long totalPhysicalMemory) { - this.totalPhysicalMemory = totalPhysicalMemory; - } - - public long getAvailablePhysicalMemory() { - return availablePhysicalMemory; - } - - public void setAvailablePhysicalMemory(long availablePhysicalMemory) { - this.availablePhysicalMemory = availablePhysicalMemory; - } - - public long getTotalSwapSpace() { - return totalSwapSpace; - } - - public void setTotalSwapSpace(long totalSwapSpace) { - this.totalSwapSpace = totalSwapSpace; - } - - public long getAvailableSwapSpace() { - return availableSwapSpace; - } - - public void setAvailableSwapSpace(long availableSwapSpace) { - this.availableSwapSpace = availableSwapSpace; - } - } - - @Serdeable - public static class JavaRuntime { - private String version; - private String vendor; - private String runtimeName; - private String vmName; - private String vmVersion; - private String vmVendor; - private String specificationName; - private String specificationVersion; - private String classPath; - private String libraryPath; - private List inputArguments; - private long uptime; - private long startTime; - private String managementSpecVersion; - - public String getVersion() { - return version; - } - - public void setVersion(String version) { - this.version = version; - } - - public String getVendor() { - return vendor; - } - - public void setVendor(String vendor) { - this.vendor = vendor; - } - - public String getRuntimeName() { - return runtimeName; - } - - public void setRuntimeName(String runtimeName) { - this.runtimeName = runtimeName; - } - - public String getVmName() { - return vmName; - } - - public void setVmName(String vmName) { - this.vmName = vmName; - } - - public String getVmVersion() { - return vmVersion; - } - - public void setVmVersion(String vmVersion) { - this.vmVersion = vmVersion; - } - - public String getVmVendor() { - return vmVendor; - } - - public void setVmVendor(String vmVendor) { - this.vmVendor = vmVendor; - } - - public String getSpecificationName() { - return specificationName; - } - - public void setSpecificationName(String specificationName) { - this.specificationName = specificationName; - } - - public String getSpecificationVersion() { - return specificationVersion; - } - - public void setSpecificationVersion(String specificationVersion) { - this.specificationVersion = specificationVersion; - } - - public String getClassPath() { - return classPath; - } - - public void setClassPath(String classPath) { - this.classPath = classPath; - } - - public String getLibraryPath() { - return libraryPath; - } - - public void setLibraryPath(String libraryPath) { - this.libraryPath = libraryPath; - } - - public List getInputArguments() { - return inputArguments; - } - - public void setInputArguments(List inputArguments) { - this.inputArguments = inputArguments; - } - - public long getUptime() { - return uptime; - } - - public void setUptime(long uptime) { - this.uptime = uptime; - } - - public long getStartTime() { - return startTime; - } - - public void setStartTime(long startTime) { - this.startTime = startTime; - } - - public String getManagementSpecVersion() { - return managementSpecVersion; - } - - public void setManagementSpecVersion(String managementSpecVersion) { - this.managementSpecVersion = managementSpecVersion; - } - } - - @Serdeable - public static class Hardware { - private String model; - private String manufacturer; - private String serialNumber; - private String uuid; - private Processor processor; - - public String getModel() { - return model; - } - - public void setModel(String model) { - this.model = model; - } - - public String getManufacturer() { - return manufacturer; - } - - public void setManufacturer(String manufacturer) { - this.manufacturer = manufacturer; - } - - public String getSerialNumber() { - return serialNumber; - } - - public void setSerialNumber(String serialNumber) { - this.serialNumber = serialNumber; - } - - public String getUuid() { - return uuid; - } - - public void setUuid(String uuid) { - this.uuid = uuid; - } - - public Processor getProcessor() { - return processor; - } - - public void setProcessor(Processor processor) { - this.processor = processor; - } - } - - @Serdeable - public static class Processor { - private String name; - private String identifier; - private String architecture; - private int physicalProcessorCount; - private int logicalProcessorCount; - private long maxFrequency; - private boolean cpu64bit; - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getIdentifier() { - return identifier; - } - - public void setIdentifier(String identifier) { - this.identifier = identifier; - } - - public String getArchitecture() { - return architecture; - } - - public void setArchitecture(String architecture) { - this.architecture = architecture; - } - - public int getPhysicalProcessorCount() { - return physicalProcessorCount; - } - - public void setPhysicalProcessorCount(int physicalProcessorCount) { - this.physicalProcessorCount = physicalProcessorCount; - } - - public int getLogicalProcessorCount() { - return logicalProcessorCount; - } - - public void setLogicalProcessorCount(int logicalProcessorCount) { - this.logicalProcessorCount = logicalProcessorCount; - } - - public long getMaxFrequency() { - return maxFrequency; - } - - public void setMaxFrequency(long maxFrequency) { - this.maxFrequency = maxFrequency; - } - - public boolean isCpu64bit() { - return cpu64bit; - } - - public void setCpu64bit(boolean cpu64bit) { - this.cpu64bit = cpu64bit; - } - } - - @Serdeable - public static class Memory { - private long heapMemoryUsed; - private long heapMemoryMax; - private long heapMemoryCommitted; - private long nonHeapMemoryUsed; - private long nonHeapMemoryMax; - private long nonHeapMemoryCommitted; - private List memoryPools; - - public long getHeapMemoryUsed() { - return heapMemoryUsed; - } - - public void setHeapMemoryUsed(long heapMemoryUsed) { - this.heapMemoryUsed = heapMemoryUsed; - } - - public long getHeapMemoryMax() { - return heapMemoryMax; - } - - public void setHeapMemoryMax(long heapMemoryMax) { - this.heapMemoryMax = heapMemoryMax; - } - - public long getHeapMemoryCommitted() { - return heapMemoryCommitted; - } - - public void setHeapMemoryCommitted(long heapMemoryCommitted) { - this.heapMemoryCommitted = heapMemoryCommitted; - } - - public long getNonHeapMemoryUsed() { - return nonHeapMemoryUsed; - } - - public void setNonHeapMemoryUsed(long nonHeapMemoryUsed) { - this.nonHeapMemoryUsed = nonHeapMemoryUsed; - } - - public long getNonHeapMemoryMax() { - return nonHeapMemoryMax; - } - - public void setNonHeapMemoryMax(long nonHeapMemoryMax) { - this.nonHeapMemoryMax = nonHeapMemoryMax; - } - - public long getNonHeapMemoryCommitted() { - return nonHeapMemoryCommitted; - } - - public void setNonHeapMemoryCommitted(long nonHeapMemoryCommitted) { - this.nonHeapMemoryCommitted = nonHeapMemoryCommitted; - } - - public List getMemoryPools() { - return memoryPools; - } - - public void setMemoryPools(List memoryPools) { - this.memoryPools = memoryPools; - } - } - - @Serdeable - public static class MemoryPool { - private String name; - private String type; - private long used; - private long max; - private long committed; - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getType() { - return type; - } - - public void setType(String type) { - this.type = type; - } - - public long getUsed() { - return used; - } - - public void setUsed(long used) { - this.used = used; - } - - public long getMax() { - return max; - } - - public void setMax(long max) { - this.max = max; - } - - public long getCommitted() { - return committed; - } - - public void setCommitted(long committed) { - this.committed = committed; - } - } - - @Serdeable - public static class Storage { - private String name; - private String type; - private String model; - private String serialNumber; - private long size; - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getType() { - return type; - } - - public void setType(String type) { - this.type = type; - } - - public String getModel() { - return model; - } - - public void setModel(String model) { - this.model = model; - } - - public String getSerialNumber() { - return serialNumber; - } - - public void setSerialNumber(String serialNumber) { - this.serialNumber = serialNumber; - } - - public long getSize() { - return size; - } - - public void setSize(long size) { - this.size = size; - } - } - - @Serdeable - public static class Container { - private boolean isContainer; - private String containerType; - private String containerId; - private String containerName; - private String imageName; - private Map resourceLimits; - - public boolean isContainer() { - return isContainer; - } - - public void setContainer(boolean isContainer) { - this.isContainer = isContainer; - } - - public String getContainerType() { - return containerType; - } - - public void setContainerType(String containerType) { - this.containerType = containerType; - } - - public String getContainerId() { - return containerId; - } - - public void setContainerId(String containerId) { - this.containerId = containerId; - } - - public String getContainerName() { - return containerName; - } - - public void setContainerName(String containerName) { - this.containerName = containerName; - } - - public String getImageName() { - return imageName; - } - - public void setImageName(String imageName) { - this.imageName = imageName; - } - - public Map getResourceLimits() { - return resourceLimits; - } - - public void setResourceLimits(Map resourceLimits) { - this.resourceLimits = resourceLimits; - } - } - - @Serdeable - public static class EnvironmentVariables { - private String language; - private String path; - private String hostname; - private String lcAll; - private String javaHome; - private String javaVersion; - private String lang; - private String home; - - public String getLanguage() { - return language; - } - - public void setLanguage(String language) { - this.language = language; - } - - public String getPath() { - return path; - } - - public void setPath(String path) { - this.path = path; - } - - public String getHostname() { - return hostname; - } - - public void setHostname(String hostname) { - this.hostname = hostname; - } - - public String getLcAll() { - return lcAll; - } - - public void setLcAll(String lcAll) { - this.lcAll = lcAll; - } - - public String getJavaHome() { - return javaHome; - } - - public void setJavaHome(String javaHome) { - this.javaHome = javaHome; - } - - public String getJavaVersion() { - return javaVersion; - } - - public void setJavaVersion(String javaVersion) { - this.javaVersion = javaVersion; - } - - public String getLang() { - return lang; - } - - public void setLang(String lang) { - this.lang = lang; - } - - public String getHome() { - return home; - } - - public void setHome(String home) { - this.home = home; - } - } - - @Serdeable - public static class SystemProperties { - private String javaSpecificationVersion; - private String javaSpecificationVendor; - private String javaSpecificationName; - private String javaSpecificationMaintenanceVersion; - private String javaVersion; - private String javaVersionDate; - private String javaVendor; - private String javaVendorVersion; - private String javaVendorUrl; - private String javaVendorUrlBug; - private String javaRuntimeName; - private String javaRuntimeVersion; - private String javaHome; - private String javaClassPath; - private String javaLibraryPath; - private String javaClassVersion; - private String javaVmName; - private String javaVmVersion; - private String javaVmVendor; - private String javaVmInfo; - private String javaVmSpecificationVersion; - private String javaVmSpecificationVendor; - private String javaVmSpecificationName; - private String javaVmCompressedOopsMode; - private String osName; - private String osVersion; - private String osArch; - private String userName; - private String userHome; - private String userDir; - private String userTimezone; - private String userCountry; - private String userLanguage; - private String fileSeparator; - private String pathSeparator; - private String lineSeparator; - private String fileEncoding; - private String nativeEncoding; - private String sunJnuEncoding; - private String sunArchDataModel; - private String sunJavaLauncher; - private String sunBootLibraryPath; - private String sunJavaCommand; - private String sunCpuEndian; - private String sunManagementCompiler; - private String sunIoUnicodeEncoding; - private String jdkDebug; - private String javaIoTmpdir; - private String env; - private String micronautClassloaderLogging; - private String ioNettyAllocatorMaxOrder; - private String ioNettyProcessId; - private String ioNettyMachineId; - private String comZaxxerHikariPoolNumber; - - public String getJavaSpecificationVersion() { - return javaSpecificationVersion; - } - - public void setJavaSpecificationVersion(String javaSpecificationVersion) { - this.javaSpecificationVersion = javaSpecificationVersion; - } - - public String getJavaSpecificationVendor() { - return javaSpecificationVendor; - } - - public void setJavaSpecificationVendor(String javaSpecificationVendor) { - this.javaSpecificationVendor = javaSpecificationVendor; - } - - public String getJavaSpecificationName() { - return javaSpecificationName; - } - - public void setJavaSpecificationName(String javaSpecificationName) { - this.javaSpecificationName = javaSpecificationName; - } - - public String getJavaSpecificationMaintenanceVersion() { - return javaSpecificationMaintenanceVersion; - } - - public void setJavaSpecificationMaintenanceVersion(String javaSpecificationMaintenanceVersion) { - this.javaSpecificationMaintenanceVersion = javaSpecificationMaintenanceVersion; - } - - public String getJavaVersion() { - return javaVersion; - } - - public void setJavaVersion(String javaVersion) { - this.javaVersion = javaVersion; - } - - public String getJavaVersionDate() { - return javaVersionDate; - } - - public void setJavaVersionDate(String javaVersionDate) { - this.javaVersionDate = javaVersionDate; - } - - public String getJavaVendor() { - return javaVendor; - } - - public void setJavaVendor(String javaVendor) { - this.javaVendor = javaVendor; - } - - public String getJavaVendorVersion() { - return javaVendorVersion; - } - - public void setJavaVendorVersion(String javaVendorVersion) { - this.javaVendorVersion = javaVendorVersion; - } - - public String getJavaVendorUrl() { - return javaVendorUrl; - } - - public void setJavaVendorUrl(String javaVendorUrl) { - this.javaVendorUrl = javaVendorUrl; - } - - public String getJavaVendorUrlBug() { - return javaVendorUrlBug; - } - - public void setJavaVendorUrlBug(String javaVendorUrlBug) { - this.javaVendorUrlBug = javaVendorUrlBug; - } - - public String getJavaRuntimeName() { - return javaRuntimeName; - } - - public void setJavaRuntimeName(String javaRuntimeName) { - this.javaRuntimeName = javaRuntimeName; - } - - public String getJavaRuntimeVersion() { - return javaRuntimeVersion; - } - - public void setJavaRuntimeVersion(String javaRuntimeVersion) { - this.javaRuntimeVersion = javaRuntimeVersion; - } - - public String getJavaHome() { - return javaHome; - } - - public void setJavaHome(String javaHome) { - this.javaHome = javaHome; - } - - public String getJavaClassPath() { - return javaClassPath; - } - - public void setJavaClassPath(String javaClassPath) { - this.javaClassPath = javaClassPath; - } - - public String getJavaLibraryPath() { - return javaLibraryPath; - } - - public void setJavaLibraryPath(String javaLibraryPath) { - this.javaLibraryPath = javaLibraryPath; - } - - public String getJavaClassVersion() { - return javaClassVersion; - } - - public void setJavaClassVersion(String javaClassVersion) { - this.javaClassVersion = javaClassVersion; - } - - public String getJavaVmName() { - return javaVmName; - } - - public void setJavaVmName(String javaVmName) { - this.javaVmName = javaVmName; - } - - public String getJavaVmVersion() { - return javaVmVersion; - } - - public void setJavaVmVersion(String javaVmVersion) { - this.javaVmVersion = javaVmVersion; - } - - public String getJavaVmVendor() { - return javaVmVendor; - } - - public void setJavaVmVendor(String javaVmVendor) { - this.javaVmVendor = javaVmVendor; - } - - public String getJavaVmInfo() { - return javaVmInfo; - } - - public void setJavaVmInfo(String javaVmInfo) { - this.javaVmInfo = javaVmInfo; - } - - public String getJavaVmSpecificationVersion() { - return javaVmSpecificationVersion; - } - - public void setJavaVmSpecificationVersion(String javaVmSpecificationVersion) { - this.javaVmSpecificationVersion = javaVmSpecificationVersion; - } - - public String getJavaVmSpecificationVendor() { - return javaVmSpecificationVendor; - } - - public void setJavaVmSpecificationVendor(String javaVmSpecificationVendor) { - this.javaVmSpecificationVendor = javaVmSpecificationVendor; - } - - public String getJavaVmSpecificationName() { - return javaVmSpecificationName; - } - - public void setJavaVmSpecificationName(String javaVmSpecificationName) { - this.javaVmSpecificationName = javaVmSpecificationName; - } - - public String getJavaVmCompressedOopsMode() { - return javaVmCompressedOopsMode; - } - - public void setJavaVmCompressedOopsMode(String javaVmCompressedOopsMode) { - this.javaVmCompressedOopsMode = javaVmCompressedOopsMode; - } - - public String getOsName() { - return osName; - } - - public void setOsName(String osName) { - this.osName = osName; - } - - public String getOsVersion() { - return osVersion; - } - - public void setOsVersion(String osVersion) { - this.osVersion = osVersion; - } - - public String getOsArch() { - return osArch; - } - - public void setOsArch(String osArch) { - this.osArch = osArch; - } - - public String getUserName() { - return userName; - } - - public void setUserName(String userName) { - this.userName = userName; - } - - public String getUserHome() { - return userHome; - } - - public void setUserHome(String userHome) { - this.userHome = userHome; - } - - public String getUserDir() { - return userDir; - } - - public void setUserDir(String userDir) { - this.userDir = userDir; - } - - public String getUserTimezone() { - return userTimezone; - } - - public void setUserTimezone(String userTimezone) { - this.userTimezone = userTimezone; - } - - public String getUserCountry() { - return userCountry; - } - - public void setUserCountry(String userCountry) { - this.userCountry = userCountry; - } - - public String getUserLanguage() { - return userLanguage; - } - - public void setUserLanguage(String userLanguage) { - this.userLanguage = userLanguage; - } - - public String getFileSeparator() { - return fileSeparator; - } - - public void setFileSeparator(String fileSeparator) { - this.fileSeparator = fileSeparator; - } - - public String getPathSeparator() { - return pathSeparator; - } - - public void setPathSeparator(String pathSeparator) { - this.pathSeparator = pathSeparator; - } - - public String getLineSeparator() { - return lineSeparator; - } - - public void setLineSeparator(String lineSeparator) { - this.lineSeparator = lineSeparator; - } - - public String getFileEncoding() { - return fileEncoding; - } - - public void setFileEncoding(String fileEncoding) { - this.fileEncoding = fileEncoding; - } - - public String getNativeEncoding() { - return nativeEncoding; - } - - public void setNativeEncoding(String nativeEncoding) { - this.nativeEncoding = nativeEncoding; - } - - public String getSunJnuEncoding() { - return sunJnuEncoding; - } - - public void setSunJnuEncoding(String sunJnuEncoding) { - this.sunJnuEncoding = sunJnuEncoding; - } - - public String getSunArchDataModel() { - return sunArchDataModel; - } - - public void setSunArchDataModel(String sunArchDataModel) { - this.sunArchDataModel = sunArchDataModel; - } - - public String getSunJavaLauncher() { - return sunJavaLauncher; - } - - public void setSunJavaLauncher(String sunJavaLauncher) { - this.sunJavaLauncher = sunJavaLauncher; - } - - public String getSunBootLibraryPath() { - return sunBootLibraryPath; - } - - public void setSunBootLibraryPath(String sunBootLibraryPath) { - this.sunBootLibraryPath = sunBootLibraryPath; - } - - public String getSunJavaCommand() { - return sunJavaCommand; - } - - public void setSunJavaCommand(String sunJavaCommand) { - this.sunJavaCommand = sunJavaCommand; - } - - public String getSunCpuEndian() { - return sunCpuEndian; - } - - public void setSunCpuEndian(String sunCpuEndian) { - this.sunCpuEndian = sunCpuEndian; - } - - public String getSunManagementCompiler() { - return sunManagementCompiler; - } - - public void setSunManagementCompiler(String sunManagementCompiler) { - this.sunManagementCompiler = sunManagementCompiler; - } - - public String getSunIoUnicodeEncoding() { - return sunIoUnicodeEncoding; - } - - public void setSunIoUnicodeEncoding(String sunIoUnicodeEncoding) { - this.sunIoUnicodeEncoding = sunIoUnicodeEncoding; - } - - public String getJdkDebug() { - return jdkDebug; - } - - public void setJdkDebug(String jdkDebug) { - this.jdkDebug = jdkDebug; - } - - public String getJavaIoTmpdir() { - return javaIoTmpdir; - } - - public void setJavaIoTmpdir(String javaIoTmpdir) { - this.javaIoTmpdir = javaIoTmpdir; - } - - public String getEnv() { - return env; - } - - public void setEnv(String env) { - this.env = env; - } - - public String getMicronautClassloaderLogging() { - return micronautClassloaderLogging; - } - - public void setMicronautClassloaderLogging(String micronautClassloaderLogging) { - this.micronautClassloaderLogging = micronautClassloaderLogging; - } - - public String getIoNettyAllocatorMaxOrder() { - return ioNettyAllocatorMaxOrder; - } - - public void setIoNettyAllocatorMaxOrder(String ioNettyAllocatorMaxOrder) { - this.ioNettyAllocatorMaxOrder = ioNettyAllocatorMaxOrder; - } - - public String getIoNettyProcessId() { - return ioNettyProcessId; - } - - public void setIoNettyProcessId(String ioNettyProcessId) { - this.ioNettyProcessId = ioNettyProcessId; - } - - public String getIoNettyMachineId() { - return ioNettyMachineId; - } - - public void setIoNettyMachineId(String ioNettyMachineId) { - this.ioNettyMachineId = ioNettyMachineId; - } - - public String getComZaxxerHikariPoolNumber() { - return comZaxxerHikariPoolNumber; - } - - public void setComZaxxerHikariPoolNumber(String comZaxxerHikariPoolNumber) { - this.comZaxxerHikariPoolNumber = comZaxxerHikariPoolNumber; - } - } - - @Serdeable - public static class HikariPoolMetrics { - private String poolName; - private int activeConnections; - private int idleConnections; - private int totalConnections; - private int threadsAwaitingConnection; - private PoolConfig poolConfig; - private Map extendedMetrics; - - public String getPoolName() { - return poolName; - } - - public void setPoolName(String poolName) { - this.poolName = poolName; - } - - public int getActiveConnections() { - return activeConnections; - } - - public void setActiveConnections(int activeConnections) { - this.activeConnections = activeConnections; - } - - public int getIdleConnections() { - return idleConnections; - } - - public void setIdleConnections(int idleConnections) { - this.idleConnections = idleConnections; - } - - public int getTotalConnections() { - return totalConnections; - } - - public void setTotalConnections(int totalConnections) { - this.totalConnections = totalConnections; - } - - public int getThreadsAwaitingConnection() { - return threadsAwaitingConnection; - } - - public void setThreadsAwaitingConnection(int threadsAwaitingConnection) { - this.threadsAwaitingConnection = threadsAwaitingConnection; - } - - public PoolConfig getPoolConfig() { - return poolConfig; - } - - public void setPoolConfig(PoolConfig poolConfig) { - this.poolConfig = poolConfig; - } - - public Map getExtendedMetrics() { - return extendedMetrics; - } - - public void setExtendedMetrics(Map extendedMetrics) { - this.extendedMetrics = extendedMetrics; - } - - @Serdeable - public static class PoolConfig { - private String poolName; - private long connectionTimeout; - private long validationTimeout; - private long idleTimeout; - private long maxLifetime; - private int minimumIdle; - private int maximumPoolSize; - private long leakDetectionThreshold; - private String jdbcUrl; - private String username; - - public String getPoolName() { - return poolName; - } - - public void setPoolName(String poolName) { - this.poolName = poolName; - } - - public long getConnectionTimeout() { - return connectionTimeout; - } - - public void setConnectionTimeout(long connectionTimeout) { - this.connectionTimeout = connectionTimeout; - } - - public long getValidationTimeout() { - return validationTimeout; - } - - public void setValidationTimeout(long validationTimeout) { - this.validationTimeout = validationTimeout; - } - - public long getIdleTimeout() { - return idleTimeout; - } - - public void setIdleTimeout(long idleTimeout) { - this.idleTimeout = idleTimeout; - } - - public long getMaxLifetime() { - return maxLifetime; - } - - public void setMaxLifetime(long maxLifetime) { - this.maxLifetime = maxLifetime; - } - - public int getMinimumIdle() { - return minimumIdle; - } - - public void setMinimumIdle(int minimumIdle) { - this.minimumIdle = minimumIdle; - } - - public int getMaximumPoolSize() { - return maximumPoolSize; - } - - public void setMaximumPoolSize(int maximumPoolSize) { - this.maximumPoolSize = maximumPoolSize; - } - - public long getLeakDetectionThreshold() { - return leakDetectionThreshold; - } - - public void setLeakDetectionThreshold(long leakDetectionThreshold) { - this.leakDetectionThreshold = leakDetectionThreshold; - } - - public String getJdbcUrl() { - return jdbcUrl; - } - - public void setJdbcUrl(String jdbcUrl) { - this.jdbcUrl = jdbcUrl; - } - - public String getUsername() { - return username; - } - - public void setUsername(String username) { - this.username = username; - } - } - } - - @Serdeable - public static class ConfigDetails { - private String apiEndpoint; - private String certificateName; - private String certificateDescription; - - public String getApiEndpoint() { - return apiEndpoint; - } - - public void setApiEndpoint(String apiEndpoint) { - this.apiEndpoint = apiEndpoint; - } - - public String getCertificateName() { - return certificateName; - } - - public void setCertificateName(String certificateName) { - this.certificateName = certificateName; - } - - public String getCertificateDescription() { - return certificateDescription; - } - - public void setCertificateDescription(String certificateDescription) { - this.certificateDescription = certificateDescription; - } - } - - // Convert from protobuf DiagnosticsResult to our Serdeable model - public static DiagnosticsResult fromProto( - build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.DiagnosticsResult proto) { - DiagnosticsResult result = new DiagnosticsResult(); - - // Set basic fields - result.setTimestamp( - Instant.ofEpochSecond(proto.getTimestamp().getSeconds(), proto.getTimestamp().getNanos())); - result.setHostname(proto.getHostname()); - - // Convert OperatingSystem - if (proto.hasOperatingSystem()) { - OperatingSystem os = new OperatingSystem(); - os.setName(proto.getOperatingSystem().getName()); - os.setVersion(proto.getOperatingSystem().getVersion()); - os.setArchitecture(proto.getOperatingSystem().getArchitecture()); - os.setManufacturer(proto.getOperatingSystem().getManufacturer()); - os.setAvailableProcessors(proto.getOperatingSystem().getAvailableProcessors()); - os.setSystemUptime(proto.getOperatingSystem().getSystemUptime()); - os.setSystemLoadAverage(proto.getOperatingSystem().getSystemLoadAverage()); - os.setTotalPhysicalMemory(proto.getOperatingSystem().getTotalPhysicalMemory()); - os.setAvailablePhysicalMemory(proto.getOperatingSystem().getAvailablePhysicalMemory()); - os.setTotalSwapSpace(proto.getOperatingSystem().getTotalSwapSpace()); - os.setAvailableSwapSpace(proto.getOperatingSystem().getAvailableSwapSpace()); - result.setOperatingSystem(os); - } - - // Convert JavaRuntime - if (proto.hasJavaRuntime()) { - JavaRuntime runtime = new JavaRuntime(); - runtime.setVersion(proto.getJavaRuntime().getVersion()); - runtime.setVendor(proto.getJavaRuntime().getVendor()); - runtime.setRuntimeName(proto.getJavaRuntime().getRuntimeName()); - runtime.setVmName(proto.getJavaRuntime().getVmName()); - runtime.setVmVersion(proto.getJavaRuntime().getVmVersion()); - runtime.setVmVendor(proto.getJavaRuntime().getVmVendor()); - runtime.setSpecificationName(proto.getJavaRuntime().getSpecificationName()); - runtime.setSpecificationVersion(proto.getJavaRuntime().getSpecificationVersion()); - runtime.setClassPath(proto.getJavaRuntime().getClassPath()); - runtime.setLibraryPath(proto.getJavaRuntime().getLibraryPath()); - runtime.setInputArguments(new ArrayList<>(proto.getJavaRuntime().getInputArgumentsList())); - runtime.setUptime(proto.getJavaRuntime().getUptime()); - runtime.setStartTime(proto.getJavaRuntime().getStartTime()); - runtime.setManagementSpecVersion(proto.getJavaRuntime().getManagementSpecVersion()); - result.setJavaRuntime(runtime); - } - - // Convert Hardware - if (proto.hasHardware()) { - Hardware hardware = new Hardware(); - hardware.setModel(proto.getHardware().getModel()); - hardware.setManufacturer(proto.getHardware().getManufacturer()); - hardware.setSerialNumber(proto.getHardware().getSerialNumber()); - hardware.setUuid(proto.getHardware().getUuid()); - - if (proto.getHardware().hasProcessor()) { - Processor processor = new Processor(); - processor.setName(proto.getHardware().getProcessor().getName()); - processor.setIdentifier(proto.getHardware().getProcessor().getIdentifier()); - processor.setArchitecture(proto.getHardware().getProcessor().getArchitecture()); - processor.setPhysicalProcessorCount( - proto.getHardware().getProcessor().getPhysicalProcessorCount()); - processor.setLogicalProcessorCount( - proto.getHardware().getProcessor().getLogicalProcessorCount()); - processor.setMaxFrequency(proto.getHardware().getProcessor().getMaxFrequency()); - processor.setCpu64bit(proto.getHardware().getProcessor().getCpu64Bit()); - hardware.setProcessor(processor); - } - - result.setHardware(hardware); - } - - // Convert Memory - if (proto.hasMemory()) { - Memory memory = new Memory(); - memory.setHeapMemoryUsed(proto.getMemory().getHeapMemoryUsed()); - memory.setHeapMemoryMax(proto.getMemory().getHeapMemoryMax()); - memory.setHeapMemoryCommitted(proto.getMemory().getHeapMemoryCommitted()); - memory.setNonHeapMemoryUsed(proto.getMemory().getNonHeapMemoryUsed()); - memory.setNonHeapMemoryMax(proto.getMemory().getNonHeapMemoryMax()); - memory.setNonHeapMemoryCommitted(proto.getMemory().getNonHeapMemoryCommitted()); - - List pools = new ArrayList<>(); - for (var protoPool : proto.getMemory().getMemoryPoolsList()) { - MemoryPool pool = new MemoryPool(); - pool.setName(protoPool.getName()); - pool.setType(protoPool.getType()); - pool.setUsed(protoPool.getUsed()); - pool.setMax(protoPool.getMax()); - pool.setCommitted(protoPool.getCommitted()); - pools.add(pool); - } - memory.setMemoryPools(pools); - - result.setMemory(memory); - } - - // Convert Storage - List storageList = new ArrayList<>(); - for (var protoStorage : proto.getStorageList()) { - Storage storage = new Storage(); - storage.setName(protoStorage.getName()); - storage.setType(protoStorage.getType()); - storage.setModel(protoStorage.getModel()); - storage.setSerialNumber(protoStorage.getSerialNumber()); - storage.setSize(protoStorage.getSize()); - storageList.add(storage); - } - result.setStorage(storageList); - - // Convert Container - if (proto.hasContainer()) { - Container container = new Container(); - container.setContainer(proto.getContainer().getIsContainer()); - container.setContainerType(proto.getContainer().getContainerType()); - container.setContainerId(proto.getContainer().getContainerId()); - container.setContainerName(proto.getContainer().getContainerName()); - container.setImageName(proto.getContainer().getImageName()); - container.setResourceLimits(new HashMap<>(proto.getContainer().getResourceLimitsMap())); - result.setContainer(container); - } - - // Convert EnvironmentVariables - if (proto.hasEnvironmentVariables()) { - EnvironmentVariables env = new EnvironmentVariables(); - env.setLanguage(proto.getEnvironmentVariables().getLanguage()); - env.setPath(proto.getEnvironmentVariables().getPath()); - env.setHostname(proto.getEnvironmentVariables().getHostname()); - env.setLcAll(proto.getEnvironmentVariables().getLcAll()); - env.setJavaHome(proto.getEnvironmentVariables().getJavaHome()); - env.setJavaVersion(proto.getEnvironmentVariables().getJavaVersion()); - env.setLang(proto.getEnvironmentVariables().getLang()); - env.setHome(proto.getEnvironmentVariables().getHome()); - result.setEnvironmentVariables(env); - } - - // Convert SystemProperties - if (proto.hasSystemProperties()) { - SystemProperties props = new SystemProperties(); - props.setJavaSpecificationVersion(proto.getSystemProperties().getJavaSpecificationVersion()); - props.setJavaSpecificationVendor(proto.getSystemProperties().getJavaSpecificationVendor()); - props.setJavaSpecificationName(proto.getSystemProperties().getJavaSpecificationName()); - props.setJavaSpecificationMaintenanceVersion( - proto.getSystemProperties().getJavaSpecificationMaintenanceVersion()); - props.setJavaVersion(proto.getSystemProperties().getJavaVersion()); - props.setJavaVersionDate(proto.getSystemProperties().getJavaVersionDate()); - props.setJavaVendor(proto.getSystemProperties().getJavaVendor()); - props.setJavaVendorVersion(proto.getSystemProperties().getJavaVendorVersion()); - props.setJavaVendorUrl(proto.getSystemProperties().getJavaVendorUrl()); - props.setJavaVendorUrlBug(proto.getSystemProperties().getJavaVendorUrlBug()); - props.setJavaRuntimeName(proto.getSystemProperties().getJavaRuntimeName()); - props.setJavaRuntimeVersion(proto.getSystemProperties().getJavaRuntimeVersion()); - props.setJavaHome(proto.getSystemProperties().getJavaHome()); - props.setJavaClassPath(proto.getSystemProperties().getJavaClassPath()); - props.setJavaLibraryPath(proto.getSystemProperties().getJavaLibraryPath()); - props.setJavaClassVersion(proto.getSystemProperties().getJavaClassVersion()); - props.setJavaVmName(proto.getSystemProperties().getJavaVmName()); - props.setJavaVmVersion(proto.getSystemProperties().getJavaVmVersion()); - props.setJavaVmVendor(proto.getSystemProperties().getJavaVmVendor()); - props.setJavaVmInfo(proto.getSystemProperties().getJavaVmInfo()); - props.setJavaVmSpecificationVersion( - proto.getSystemProperties().getJavaVmSpecificationVersion()); - props.setJavaVmSpecificationVendor( - proto.getSystemProperties().getJavaVmSpecificationVendor()); - props.setJavaVmSpecificationName(proto.getSystemProperties().getJavaVmSpecificationName()); - props.setJavaVmCompressedOopsMode(proto.getSystemProperties().getJavaVmCompressedOopsMode()); - props.setOsName(proto.getSystemProperties().getOsName()); - props.setOsVersion(proto.getSystemProperties().getOsVersion()); - props.setOsArch(proto.getSystemProperties().getOsArch()); - props.setUserName(proto.getSystemProperties().getUserName()); - props.setUserHome(proto.getSystemProperties().getUserHome()); - props.setUserDir(proto.getSystemProperties().getUserDir()); - props.setUserTimezone(proto.getSystemProperties().getUserTimezone()); - props.setUserCountry(proto.getSystemProperties().getUserCountry()); - props.setUserLanguage(proto.getSystemProperties().getUserLanguage()); - props.setFileSeparator(proto.getSystemProperties().getFileSeparator()); - props.setPathSeparator(proto.getSystemProperties().getPathSeparator()); - props.setLineSeparator(proto.getSystemProperties().getLineSeparator()); - props.setFileEncoding(proto.getSystemProperties().getFileEncoding()); - props.setNativeEncoding(proto.getSystemProperties().getNativeEncoding()); - props.setSunJnuEncoding(proto.getSystemProperties().getSunJnuEncoding()); - props.setSunArchDataModel(proto.getSystemProperties().getSunArchDataModel()); - props.setSunJavaLauncher(proto.getSystemProperties().getSunJavaLauncher()); - props.setSunBootLibraryPath(proto.getSystemProperties().getSunBootLibraryPath()); - props.setSunJavaCommand(proto.getSystemProperties().getSunJavaCommand()); - props.setSunCpuEndian(proto.getSystemProperties().getSunCpuEndian()); - props.setSunManagementCompiler(proto.getSystemProperties().getSunManagementCompiler()); - props.setSunIoUnicodeEncoding(proto.getSystemProperties().getSunIoUnicodeEncoding()); - props.setJdkDebug(proto.getSystemProperties().getJdkDebug()); - props.setJavaIoTmpdir(proto.getSystemProperties().getJavaIoTmpdir()); - props.setEnv(proto.getSystemProperties().getEnv()); - props.setMicronautClassloaderLogging( - proto.getSystemProperties().getMicronautClassloaderLogging()); - props.setIoNettyAllocatorMaxOrder(proto.getSystemProperties().getIoNettyAllocatorMaxOrder()); - props.setIoNettyProcessId(proto.getSystemProperties().getIoNettyProcessId()); - props.setIoNettyMachineId(proto.getSystemProperties().getIoNettyMachineId()); - props.setComZaxxerHikariPoolNumber( - proto.getSystemProperties().getComZaxxerHikariPoolNumber()); - result.setSystemProperties(props); - } - - // Convert HikariPoolMetrics - List hikariPools = new ArrayList<>(); - for (var protoPool : proto.getHikariPoolMetricsList()) { - HikariPoolMetrics pool = new HikariPoolMetrics(); - pool.setPoolName(protoPool.getPoolName()); - pool.setActiveConnections(protoPool.getActiveConnections()); - pool.setIdleConnections(protoPool.getIdleConnections()); - pool.setTotalConnections(protoPool.getTotalConnections()); - pool.setThreadsAwaitingConnection(protoPool.getThreadsAwaitingConnection()); - pool.setExtendedMetrics(new HashMap<>(protoPool.getExtendedMetricsMap())); - - if (protoPool.hasPoolConfig()) { - HikariPoolMetrics.PoolConfig config = new HikariPoolMetrics.PoolConfig(); - config.setPoolName(protoPool.getPoolConfig().getPoolName()); - config.setConnectionTimeout(protoPool.getPoolConfig().getConnectionTimeout()); - config.setValidationTimeout(protoPool.getPoolConfig().getValidationTimeout()); - config.setIdleTimeout(protoPool.getPoolConfig().getIdleTimeout()); - config.setMaxLifetime(protoPool.getPoolConfig().getMaxLifetime()); - config.setMinimumIdle(protoPool.getPoolConfig().getMinimumIdle()); - config.setMaximumPoolSize(protoPool.getPoolConfig().getMaximumPoolSize()); - config.setLeakDetectionThreshold(protoPool.getPoolConfig().getLeakDetectionThreshold()); - config.setJdbcUrl(protoPool.getPoolConfig().getJdbcUrl()); - config.setUsername(protoPool.getPoolConfig().getUsername()); - pool.setPoolConfig(config); - } - - hikariPools.add(pool); - } - result.setHikariPoolMetrics(hikariPools); - - // Convert ConfigDetails - if (proto.hasConfigDetails()) { - ConfigDetails config = new ConfigDetails(); - config.setApiEndpoint(proto.getConfigDetails().getApiEndpoint()); - config.setCertificateName(proto.getConfigDetails().getCertificateName()); - config.setCertificateDescription(proto.getConfigDetails().getCertificateDescription()); - result.setConfigDetails(config); - } - - return result; - } -} diff --git a/core/src/main/java/com/tcn/exile/models/DialRequest.java b/core/src/main/java/com/tcn/exile/models/DialRequest.java deleted file mode 100644 index 9028af7..0000000 --- a/core/src/main/java/com/tcn/exile/models/DialRequest.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * (C) 2017-2024 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.models; - -import io.micronaut.serde.annotation.Serdeable; -import jakarta.annotation.Nullable; -import jakarta.validation.constraints.NotEmpty; - -@Serdeable -public record DialRequest( - @NotEmpty String phoneNumber, - @Nullable String callerId, - @Nullable String recordId, - @Nullable String poolId, - @Nullable String rulesetName, - @Nullable Boolean skipComplianceChecks, - @Nullable Boolean recordCall) {} diff --git a/core/src/main/java/com/tcn/exile/models/DialResponse.java b/core/src/main/java/com/tcn/exile/models/DialResponse.java deleted file mode 100644 index 2d5ba01..0000000 --- a/core/src/main/java/com/tcn/exile/models/DialResponse.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * (C) 2017-2025 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.models; - -import com.fasterxml.jackson.annotation.JsonProperty; -import io.micronaut.serde.annotation.Serdeable; - -@Serdeable -public record DialResponse( - @JsonProperty("phone_number") String phoneNumber, - @JsonProperty("caller_id") String callerId, - @JsonProperty("call_sid") String callSid, - @JsonProperty("call_type") CallType callType, - @JsonProperty("org_id") String orgId, - @JsonProperty("partner_agent_id") String partnerAgentId) {} diff --git a/core/src/main/java/com/tcn/exile/models/GateClientState.java b/core/src/main/java/com/tcn/exile/models/GateClientState.java deleted file mode 100644 index 3c468f5..0000000 --- a/core/src/main/java/com/tcn/exile/models/GateClientState.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * (C) 2017-2024 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.models; - -import io.micronaut.serde.annotation.Serdeable; - -@Serdeable -public enum GateClientState { - RUNNING, - STOPPED; -} diff --git a/core/src/main/java/com/tcn/exile/models/GateClientStatus.java b/core/src/main/java/com/tcn/exile/models/GateClientStatus.java deleted file mode 100644 index 0ddc5ce..0000000 --- a/core/src/main/java/com/tcn/exile/models/GateClientStatus.java +++ /dev/null @@ -1,22 +0,0 @@ -/* - * (C) 2017-2024 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.models; - -import io.micronaut.serde.annotation.Serdeable; - -@Serdeable -public record GateClientStatus(GateClientState status) {} diff --git a/core/src/main/java/com/tcn/exile/models/LookupType.java b/core/src/main/java/com/tcn/exile/models/LookupType.java deleted file mode 100644 index 6f61347..0000000 --- a/core/src/main/java/com/tcn/exile/models/LookupType.java +++ /dev/null @@ -1,34 +0,0 @@ -/* - * (C) 2017-2024 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.models; - -public enum LookupType { - RECORD(0), - PHONE(1), - CLIENT_REF(1), - ACCOUNT_NUMBER(2); - - private final int type; - - LookupType(int i) { - this.type = i; - } - - public int getType() { - return type; - } -} diff --git a/core/src/main/java/com/tcn/exile/models/ManualDialResult.java b/core/src/main/java/com/tcn/exile/models/ManualDialResult.java deleted file mode 100644 index e53a93c..0000000 --- a/core/src/main/java/com/tcn/exile/models/ManualDialResult.java +++ /dev/null @@ -1,77 +0,0 @@ -/* - * (C) 2017-2025 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.models; - -import build.buf.gen.tcnapi.exile.gate.v2.DialResponse; -import java.util.Objects; - -public record ManualDialResult( - String phoneNumber, - String callerId, - long callSid, - String callType, - String orgId, - String partnerAgentId) { - public ManualDialResult { - - Objects.nonNull(phoneNumber); - Objects.nonNull(callerId); - Objects.nonNull(callSid); - - Objects.nonNull(callType); - Objects.nonNull(orgId); - Objects.nonNull(partnerAgentId); - } - - public static ManualDialResult fromProto(DialResponse result) { - if (result == null) { - throw new IllegalArgumentException("result cannot be null"); - } - String callType = ""; - - switch (result.getCallType()) { - case CALL_TYPE_INBOUND: - callType = "inbound"; - break; - case CALL_TYPE_OUTBOUND: - callType = "outbound"; - break; - case CALL_TYPE_MANUAL: - callType = "manual"; - break; - case CALL_TYPE_MAC: - callType = "outbound"; - break; - case CALL_TYPE_PREVIEW: - callType = "outbound"; - break; - default: - throw new IllegalArgumentException("Invalid call type: " + result.getCallType()); - } - try { - return new ManualDialResult( - result.getPhoneNumber(), - result.getCallerId(), - Long.parseLong(result.getCallSid()), - callType, - result.getOrgId(), - result.getPartnerAgentId()); - } catch (NumberFormatException e) { - throw new IllegalArgumentException("Invalid call_sid format: " + result.getCallSid(), e); - } - } -} diff --git a/core/src/main/java/com/tcn/exile/models/OrgInfo.java b/core/src/main/java/com/tcn/exile/models/OrgInfo.java deleted file mode 100644 index c3101e3..0000000 --- a/core/src/main/java/com/tcn/exile/models/OrgInfo.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * (C) 2017-2025 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.models; - -import build.buf.gen.tcnapi.exile.gate.v2.GetOrganizationInfoResponse; -import jakarta.validation.constraints.NotEmpty; - -public record OrgInfo(@NotEmpty String orgId, String orgName) { - public OrgInfo(GetOrganizationInfoResponse response) { - this(response.getOrgName(), response.getOrgName()); - } - - public String getName() { - return orgName; - } -} diff --git a/core/src/main/java/com/tcn/exile/models/PluginConfigEvent.java b/core/src/main/java/com/tcn/exile/models/PluginConfigEvent.java deleted file mode 100644 index 02dae39..0000000 --- a/core/src/main/java/com/tcn/exile/models/PluginConfigEvent.java +++ /dev/null @@ -1,120 +0,0 @@ -/* - * (C) 2017-2024 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.models; - -import io.micronaut.context.event.ApplicationEvent; -import io.micronaut.serde.annotation.Serdeable; -import java.util.Objects; - -@Serdeable -public class PluginConfigEvent extends ApplicationEvent { - private String orgId; - - public String getOrgId() { - return orgId; - } - - public String getOrgName() { - return orgName; - } - - public String getConfigurationName() { - return configurationName; - } - - public String getConfigurationPayload() { - return configurationPayload; - } - - private String orgName; - private String configurationName; - private String configurationPayload; - private boolean unconfigured = Boolean.TRUE; - - public PluginConfigEvent setOrgId(String orgId) { - this.orgId = orgId; - return this; - } - - public boolean isUnconfigured() { - return this.unconfigured; - } - - public PluginConfigEvent setUnconfigured(boolean unconfigured) { - this.unconfigured = unconfigured; - return this; - } - - public PluginConfigEvent setOrgName(String orgName) { - this.orgName = orgName; - return this; - } - - public PluginConfigEvent setConfigurationName(String configurationName) { - this.configurationName = configurationName; - return this; - } - - public PluginConfigEvent setConfigurationPayload(String configurationPayload) { - this.configurationPayload = configurationPayload; - return this; - } - - /** - * Constructs a prototypical Event. - * - * @param source The object on which the Event initially occurred. - * @throws IllegalArgumentException if source is null. - */ - public PluginConfigEvent(Object source) { - super(source); - } - - @Override - public String toString() { - return "PluginConfigEvent{" - + "orgId='" - + orgId - + '\'' - + ", orgName='" - + orgName - + '\'' - + ", configurationName='" - + configurationName - + '\'' - + ", configurationPayload='" - + configurationPayload - + '\'' - + ", unconfigured=" - + unconfigured - + '}'; - } - - public boolean equals(PluginConfigEvent o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - return Objects.equals(orgId, (o).orgId) - && Objects.equals(orgName, (o).orgName) - && Objects.equals(configurationName, (o).configurationName) - && Objects.equals(configurationPayload, (o).configurationPayload) - && Objects.equals(unconfigured, (o).unconfigured); - } - - public int hashCode() { - return Objects.hash(orgId, orgName, configurationName, configurationPayload, unconfigured); - } -} diff --git a/core/src/main/java/com/tcn/exile/models/Pool.java b/core/src/main/java/com/tcn/exile/models/Pool.java deleted file mode 100644 index 463dc7a..0000000 --- a/core/src/main/java/com/tcn/exile/models/Pool.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * (C) 2017-2024 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.models; - -import io.micronaut.core.annotation.Nullable; -import io.micronaut.serde.annotation.Serdeable; - -@Serdeable -public record Pool( - String satiPoolId, String displayName, PoolStatus status, @Nullable Integer size) {} diff --git a/core/src/main/java/com/tcn/exile/models/PoolStatus.java b/core/src/main/java/com/tcn/exile/models/PoolStatus.java deleted file mode 100644 index 02c37a9..0000000 --- a/core/src/main/java/com/tcn/exile/models/PoolStatus.java +++ /dev/null @@ -1,34 +0,0 @@ -/* - * (C) 2017-2025 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.models; - -public enum PoolStatus { - READY(0), - NOT_READY(1), - NOT_AVAILABLE(1), - BUSY(2); - - private final int status; - - PoolStatus(int i) { - this.status = i; - } - - public int getStatus() { - return status; - } -} diff --git a/core/src/main/java/com/tcn/exile/models/Record.java b/core/src/main/java/com/tcn/exile/models/Record.java deleted file mode 100644 index 4bb01d0..0000000 --- a/core/src/main/java/com/tcn/exile/models/Record.java +++ /dev/null @@ -1,26 +0,0 @@ -/* - * (C) 2017-2024 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.models; - -import io.micronaut.core.annotation.Nullable; -import jakarta.validation.constraints.NotNull; - -public record Record( - @NotNull String recordId, - @NotNull String satiParentId, - @Nullable String satiPoolId, - @Nullable String jsonRecordPayload) {} diff --git a/core/src/main/java/com/tcn/exile/models/RecordingResponse.java b/core/src/main/java/com/tcn/exile/models/RecordingResponse.java deleted file mode 100644 index c015594..0000000 --- a/core/src/main/java/com/tcn/exile/models/RecordingResponse.java +++ /dev/null @@ -1,23 +0,0 @@ -/* - * (C) 2017-2024 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.models; - -import com.fasterxml.jackson.annotation.JsonProperty; -import io.micronaut.serde.annotation.Serdeable; - -@Serdeable -public record RecordingResponse(@JsonProperty("recording") boolean recording) {} diff --git a/core/src/main/java/com/tcn/exile/models/ScrubList.java b/core/src/main/java/com/tcn/exile/models/ScrubList.java deleted file mode 100644 index 76f0833..0000000 --- a/core/src/main/java/com/tcn/exile/models/ScrubList.java +++ /dev/null @@ -1,22 +0,0 @@ -/* - * (C) 2017-2024 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.models; - -import io.micronaut.serde.annotation.Serdeable; - -@Serdeable -public record ScrubList(String scrubListId, boolean readOnly, ScrubListType contentType) {} diff --git a/core/src/main/java/com/tcn/exile/models/ScrubListEntry.java b/core/src/main/java/com/tcn/exile/models/ScrubListEntry.java deleted file mode 100644 index 44dea38..0000000 --- a/core/src/main/java/com/tcn/exile/models/ScrubListEntry.java +++ /dev/null @@ -1,32 +0,0 @@ -/* - * (C) 2017-2024 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.models; - -import com.fasterxml.jackson.annotation.JsonProperty; -import io.micronaut.core.annotation.Introspected; -import io.micronaut.core.annotation.Nullable; -import io.micronaut.serde.annotation.Serdeable; -import jakarta.validation.constraints.NotEmpty; -import java.util.Date; - -@Serdeable -@Introspected -public record ScrubListEntry( - @NotEmpty @JsonProperty("content") String content, - @JsonProperty("expiration_date") @Nullable Date expirationDate, - @Nullable @JsonProperty("notes") String notes, - @Nullable @JsonProperty("country_code") String countryCode) {} diff --git a/core/src/main/java/com/tcn/exile/models/ScrubListType.java b/core/src/main/java/com/tcn/exile/models/ScrubListType.java deleted file mode 100644 index a70ed82..0000000 --- a/core/src/main/java/com/tcn/exile/models/ScrubListType.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * (C) 2017-2024 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.models; - -public enum ScrubListType { - phone_number(0), - email(1), - sms(2), - other(3); - - private int value; - - public int getValue() { - return value; - } - - ScrubListType(int value) { - this.value = value; - } - - public static ScrubListType create(int value) { - if (value >= ScrubListType.values().length) return other; - return ScrubListType.values()[value]; - } -} diff --git a/core/src/main/java/com/tcn/exile/models/SetAgentState.java b/core/src/main/java/com/tcn/exile/models/SetAgentState.java deleted file mode 100644 index c45af02..0000000 --- a/core/src/main/java/com/tcn/exile/models/SetAgentState.java +++ /dev/null @@ -1,70 +0,0 @@ -/* - * (C) 2017-2024 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.models; - -public enum SetAgentState { - // UNAVALIABLE(0), - // IDLE(1), - READY(2), - HUNGUP(3), - // DESTROYED(4), - // PEERED(5), - PAUSED(6), - WRAPUP(7), -// PREPARING_AFTER_IDLE(8), -// PREPARING_AFTER_WRAPUP(9), -// PREPARING_AFTER_PAUSE(10), -// PREPARING_AFTER_DIAL_CANCEL(11), -// PREPARING_AFTER_PBX_REJECT(12), -// PREPARING_AFTER_PBX_HANGUP(13), -// PREPARING_AFTER_PBX_WAS_TAKEN(14), -// PREPARING_AFTER_GUI_BUSY(15), -// MANUAL_DIAL_PREPARED(16), -// PREVIEW_DIAL_PREPARED(17), -// MANUAL_DIAL_STARTED(18), -// PREVIEW_DIAL_STARTED(19), -// OUTBOUND_LOCKED(20), -// WARM_AGENT_TRANSFER_STARTED_SOURCE(21), -// WARM_AGENT_TRANSFER_STARTED_DESTINATION(22), -// WARM_OUTBOUND_TRANSFER_STARTED(23), -// WARM_OUTBOUND_TRANSFER_PEER_LOST(24), -// PBX_POPUP_LOCKED(25), -// PEERED_WITH_CALL_ON_HOLD(26), -// CALLBACK_RESUMING(27), -// GUI_BUSY(28), -// INTERCOM(29), -// INTERCOM_RINGING_SOURCE(30), -// INTERCOM_RINGING_DESTINATION(31), -// WARM_OUTBOUND_TRANSFER_OUTBOUND_LOST(32), -// PREPARED_TO_PEER(33), -// WARM_SKILL_TRANSFER_SOURCE_PENDING(34), -// CALLER_TRANSFER_STARTED(35), -// CALLER_TRANSFER_LOST_PEER(36), -// CALLER_TRANSFER_LOST_MERGED_CALLER(37), -// COLD_OUTBOUND_TRANSFER_STARTED(38), -// COLD_AGENT_TRANSFER_STARTED(39), -; - private int value; - - SetAgentState(int i) { - value = i; - } - - public int getValue() { - return value; - } -} diff --git a/core/src/main/java/com/tcn/exile/models/SetAgentStatusResponse.java b/core/src/main/java/com/tcn/exile/models/SetAgentStatusResponse.java deleted file mode 100644 index 373ca0f..0000000 --- a/core/src/main/java/com/tcn/exile/models/SetAgentStatusResponse.java +++ /dev/null @@ -1,22 +0,0 @@ -/* - * (C) 2017-2024 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.models; - -import io.micronaut.serde.annotation.Serdeable; - -@Serdeable -public record SetAgentStatusResponse() {} diff --git a/core/src/main/java/com/tcn/exile/models/TenantLogsResult.java b/core/src/main/java/com/tcn/exile/models/TenantLogsResult.java deleted file mode 100644 index 9e427a6..0000000 --- a/core/src/main/java/com/tcn/exile/models/TenantLogsResult.java +++ /dev/null @@ -1,174 +0,0 @@ -/* - * (C) 2017-2025 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.models; - -import io.micronaut.serde.annotation.Serdeable; -import java.time.Instant; -import java.util.List; -import java.util.Map; - -@Serdeable -public class TenantLogsResult { - private List logGroups; - private String nextPageToken; - - public List getLogGroups() { - return logGroups; - } - - public void setLogGroups(List logGroups) { - this.logGroups = logGroups; - } - - public String getNextPageToken() { - return nextPageToken; - } - - public void setNextPageToken(String nextPageToken) { - this.nextPageToken = nextPageToken; - } - - @Serdeable - public static class LogGroup { - private String name; - private List logs; - private TimeRange timeRange; - private Map logLevels; - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public List getLogs() { - return logs; - } - - public void setLogs(List logs) { - this.logs = logs; - } - - public TimeRange getTimeRange() { - return timeRange; - } - - public void setTimeRange(TimeRange timeRange) { - this.timeRange = timeRange; - } - - public Map getLogLevels() { - return logLevels; - } - - public void setLogLevels(Map logLevels) { - this.logLevels = logLevels; - } - - @Serdeable - public static class TimeRange { - private Instant startTime; - private Instant endTime; - - public Instant getStartTime() { - return startTime; - } - - public void setStartTime(Instant startTime) { - this.startTime = startTime; - } - - public Instant getEndTime() { - return endTime; - } - - public void setEndTime(Instant endTime) { - this.endTime = endTime; - } - } - - @Serdeable - public enum LogLevel { - DEBUG, - INFO, - WARNING, - ERROR, - FATAL - } - } - - // Convert from protobuf ListTenantLogsResult to our Serdeable model - public static TenantLogsResult fromProto( - build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.ListTenantLogsResult proto) { - TenantLogsResult result = new TenantLogsResult(); - result.setNextPageToken(proto.getNextPageToken()); - - java.util.List logGroups = new java.util.ArrayList<>(); - for (var protoLogGroup : proto.getLogGroupsList()) { - LogGroup logGroup = new LogGroup(); - logGroup.setName(protoLogGroup.getName()); - logGroup.setLogs(protoLogGroup.getLogsList()); - - if (protoLogGroup.hasTimeRange()) { - LogGroup.TimeRange timeRange = new LogGroup.TimeRange(); - timeRange.setStartTime( - Instant.ofEpochSecond( - protoLogGroup.getTimeRange().getStartTime().getSeconds(), - protoLogGroup.getTimeRange().getStartTime().getNanos())); - timeRange.setEndTime( - Instant.ofEpochSecond( - protoLogGroup.getTimeRange().getEndTime().getSeconds(), - protoLogGroup.getTimeRange().getEndTime().getNanos())); - logGroup.setTimeRange(timeRange); - } - - // Convert log levels map - Map logLevelsMap = new java.util.HashMap<>(); - for (var entry : protoLogGroup.getLogLevelsMap().entrySet()) { - LogGroup.LogLevel level; - switch (entry.getValue()) { - case DEBUG: - level = LogGroup.LogLevel.DEBUG; - break; - case INFO: - level = LogGroup.LogLevel.INFO; - break; - case WARNING: - level = LogGroup.LogLevel.WARNING; - break; - case ERROR: - level = LogGroup.LogLevel.ERROR; - break; - case FATAL: - level = LogGroup.LogLevel.FATAL; - break; - default: - level = LogGroup.LogLevel.INFO; - } - logLevelsMap.put(entry.getKey(), level); - } - logGroup.setLogLevels(logLevelsMap); - - logGroups.add(logGroup); - } - result.setLogGroups(logGroups); - - return result; - } -} diff --git a/core/src/main/java/com/tcn/exile/plugin/Job.java b/core/src/main/java/com/tcn/exile/plugin/Job.java deleted file mode 100644 index 231658e..0000000 --- a/core/src/main/java/com/tcn/exile/plugin/Job.java +++ /dev/null @@ -1,19 +0,0 @@ -/* - * (C) 2017-2024 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.plugin; - -public interface Job extends Runnable {} diff --git a/core/src/main/java/com/tcn/exile/plugin/PluginInterface.java b/core/src/main/java/com/tcn/exile/plugin/PluginInterface.java deleted file mode 100644 index b33d5b9..0000000 --- a/core/src/main/java/com/tcn/exile/plugin/PluginInterface.java +++ /dev/null @@ -1,137 +0,0 @@ -/* - * (C) 2017-2025 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.plugin; - -import build.buf.gen.tcnapi.exile.gate.v2.*; -import com.tcn.exile.gateclients.UnconfiguredException; -import com.tcn.exile.models.PluginConfigEvent; - -public interface PluginInterface { - String getName(); - - boolean isRunning(); - - PluginStatus getPluginStatus(); - - /** - * List available pools of data for interogation - * - * @param jobId - * @param listPools - * @throws UnconfiguredException - */ - void listPools(String jobId, StreamJobsResponse.ListPoolsRequest listPools) - throws UnconfiguredException; - - /** - * Get pool status - * - * @param jobId - * @param satiPoolId - * @throws UnconfiguredException - */ - void getPoolStatus(String jobId, StreamJobsResponse.GetPoolStatusRequest satiPoolId) - throws UnconfiguredException; - - /** - * Stream the records belonging to a pool - * - * @param jobId - * @param satiPoolId - * @throws UnconfiguredException - */ - void getPoolRecords(String jobId, StreamJobsResponse.GetPoolRecordsRequest satiPoolId) - throws UnconfiguredException; - - /** - * Handle agent call - * - * @param exileAgentCall - */ - void handleAgentCall(ExileAgentCall exileAgentCall); - - /** - * Handle telephony result - * - * @param exileTelephonyResult - */ - void handleTelephonyResult(ExileTelephonyResult exileTelephonyResult); - - /** handle task */ - void handleTask(ExileTask exileTask); - - /** - * Handle agent response - * - * @param exileAgentResponse - */ - void handleAgentResponse(ExileAgentResponse exileAgentResponse); - - void searchRecords(String jobId, StreamJobsResponse.SearchRecordsRequest searchRecords); - - void readFields(String jobId, StreamJobsResponse.GetRecordFieldsRequest getRecordFields); - - void writeFields(String jobId, StreamJobsResponse.SetRecordFieldsRequest setRecordFields); - - void createPayment(String jobId, StreamJobsResponse.CreatePaymentRequest createPayment); - - void popAccount(String jobId, StreamJobsResponse.PopAccountRequest popAccount); - - void info(String jobId, StreamJobsResponse.InfoRequest info); - - SubmitJobResultsRequest.InfoResult info(); - - void shutdown(String jobId, StreamJobsResponse.SeppukuRequest shutdown); - - void logger(String jobId, StreamJobsResponse.LoggingRequest log); - - void executeLogic(String jobId, StreamJobsResponse.ExecuteLogicRequest executeLogic); - - /** - * Run system diagnostics and collect detailed information about the system environment, JVM - * settings, memory usage, container details, and database connections. The resulting diagnostics - * data is submitted back to the gate service. - * - * @param jobId The ID of the job - * @param diagnosticsRequest The diagnostics request details - */ - void runDiagnostics(String jobId, StreamJobsResponse.DiagnosticsRequest diagnosticsRequest); - - /** - * List tenant logs by retrieving logs from memory and formatting them into log groups. The logs - * are retrieved from the MemoryAppender and submitted back to the gate service. - * - * @param jobId The ID of the job - * @param listTenantLogsRequest The list tenant logs request details - */ - void listTenantLogs(String jobId, StreamJobsResponse.ListTenantLogsRequest listTenantLogsRequest); - - /** - * Set the log level for a specific logger dynamically. This allows changing log levels at runtime - * without restarting the application. - * - * @param jobId The ID of the job - * @param setLogLevelRequest The set log level request details - */ - void setLogLevel(String jobId, StreamJobsResponse.SetLogLevelRequest setLogLevelRequest); - - void setConfig(PluginConfigEvent config); - - void handleTransferInstance(ExileTransferInstance exileTransferInstance); - - void handleCallRecording(ExileCallRecording exileCallRecording); -} diff --git a/core/src/main/java/com/tcn/exile/plugin/PluginStatus.java b/core/src/main/java/com/tcn/exile/plugin/PluginStatus.java deleted file mode 100644 index 04ab50f..0000000 --- a/core/src/main/java/com/tcn/exile/plugin/PluginStatus.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * (C) 2017-2024 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.plugin; - -import io.micronaut.serde.annotation.Serdeable; -import java.util.Map; - -@Serdeable -public record PluginStatus( - String name, - boolean running, - int queueMaxSize, - long queueCompletedJobs, - int queueActiveCount, - Map internalConfig, - Map internalStatus) {} diff --git a/core/src/main/java/com/tcn/exile/service/AgentService.java b/core/src/main/java/com/tcn/exile/service/AgentService.java new file mode 100644 index 0000000..07bfeef --- /dev/null +++ b/core/src/main/java/com/tcn/exile/service/AgentService.java @@ -0,0 +1,151 @@ +package com.tcn.exile.service; + +import static com.tcn.exile.internal.ProtoConverter.*; + +import com.tcn.exile.internal.ProtoConverter; +import com.tcn.exile.model.*; +import io.grpc.ManagedChannel; +import java.util.List; +import java.util.stream.Collectors; + +/** Agent management operations. No proto types in the public API. */ +public final class AgentService { + + private final build.buf.gen.tcnapi.exile.gate.v3.AgentServiceGrpc.AgentServiceBlockingStub stub; + + AgentService(ManagedChannel channel) { + this.stub = build.buf.gen.tcnapi.exile.gate.v3.AgentServiceGrpc.newBlockingStub(channel); + } + + public Agent getAgentByPartnerId(String partnerAgentId) { + var resp = + stub.getAgent( + build.buf.gen.tcnapi.exile.gate.v3.GetAgentRequest.newBuilder() + .setPartnerAgentId(partnerAgentId) + .build()); + return toAgent(resp.getAgent()); + } + + public Agent getAgentByUserId(String userId) { + var resp = + stub.getAgent( + build.buf.gen.tcnapi.exile.gate.v3.GetAgentRequest.newBuilder() + .setUserId(userId) + .build()); + return toAgent(resp.getAgent()); + } + + public Page listAgents( + Boolean loggedIn, + AgentState state, + boolean includeRecordingStatus, + String pageToken, + int pageSize) { + var req = + build.buf.gen.tcnapi.exile.gate.v3.ListAgentsRequest.newBuilder() + .setIncludeRecordingStatus(includeRecordingStatus) + .setPageSize(pageSize); + if (loggedIn != null) req.setLoggedIn(loggedIn); + if (state != null) req.setState(fromAgentState(state)); + if (pageToken != null) req.setPageToken(pageToken); + var resp = stub.listAgents(req.build()); + return new Page<>( + resp.getAgentsList().stream().map(ProtoConverter::toAgent).collect(Collectors.toList()), + resp.getNextPageToken()); + } + + public Agent upsertAgent( + String partnerAgentId, String username, String firstName, String lastName) { + var resp = + stub.upsertAgent( + build.buf.gen.tcnapi.exile.gate.v3.UpsertAgentRequest.newBuilder() + .setPartnerAgentId(partnerAgentId) + .setUsername(username) + .setFirstName(firstName) + .setLastName(lastName) + .build()); + return toAgent(resp.getAgent()); + } + + public void setAgentCredentials(String partnerAgentId, String password) { + stub.setAgentCredentials( + build.buf.gen.tcnapi.exile.gate.v3.SetAgentCredentialsRequest.newBuilder() + .setPartnerAgentId(partnerAgentId) + .setPassword(password) + .build()); + } + + public void updateAgentStatus(String partnerAgentId, AgentState newState, String reason) { + stub.updateAgentStatus( + build.buf.gen.tcnapi.exile.gate.v3.UpdateAgentStatusRequest.newBuilder() + .setPartnerAgentId(partnerAgentId) + .setNewState(fromAgentState(newState)) + .setReason(reason != null ? reason : "") + .build()); + } + + public void muteAgent(String partnerAgentId) { + stub.muteAgent( + build.buf.gen.tcnapi.exile.gate.v3.MuteAgentRequest.newBuilder() + .setPartnerAgentId(partnerAgentId) + .build()); + } + + public void unmuteAgent(String partnerAgentId) { + stub.unmuteAgent( + build.buf.gen.tcnapi.exile.gate.v3.UnmuteAgentRequest.newBuilder() + .setPartnerAgentId(partnerAgentId) + .build()); + } + + public void addAgentCallResponse( + String partnerAgentId, + long callSid, + CallType callType, + String sessionId, + String key, + String value) { + stub.addAgentCallResponse( + build.buf.gen.tcnapi.exile.gate.v3.AddAgentCallResponseRequest.newBuilder() + .setPartnerAgentId(partnerAgentId) + .setCallSid(callSid) + .setCallType( + build.buf.gen.tcnapi.exile.gate.v3.CallType.valueOf("CALL_TYPE_" + callType.name())) + .setCurrentSessionId(sessionId) + .setKey(key) + .setValue(value) + .build()); + } + + public List listSkills() { + var resp = + stub.listSkills(build.buf.gen.tcnapi.exile.gate.v3.ListSkillsRequest.getDefaultInstance()); + return resp.getSkillsList().stream().map(ProtoConverter::toSkill).collect(Collectors.toList()); + } + + public List listAgentSkills(String partnerAgentId) { + var resp = + stub.listAgentSkills( + build.buf.gen.tcnapi.exile.gate.v3.ListAgentSkillsRequest.newBuilder() + .setPartnerAgentId(partnerAgentId) + .build()); + return resp.getSkillsList().stream().map(ProtoConverter::toSkill).collect(Collectors.toList()); + } + + public void assignAgentSkill(String partnerAgentId, String skillId, long proficiency) { + stub.assignAgentSkill( + build.buf.gen.tcnapi.exile.gate.v3.AssignAgentSkillRequest.newBuilder() + .setPartnerAgentId(partnerAgentId) + .setSkillId(skillId) + .setProficiency(proficiency) + .build()); + } + + public void unassignAgentSkill(String partnerAgentId, String skillId) { + stub.unassignAgentSkill( + build.buf.gen.tcnapi.exile.gate.v3.UnassignAgentSkillRequest.newBuilder() + .setPartnerAgentId(partnerAgentId) + .setSkillId(skillId) + .build()); + } +} diff --git a/core/src/main/java/com/tcn/exile/service/CallService.java b/core/src/main/java/com/tcn/exile/service/CallService.java new file mode 100644 index 0000000..f663bab --- /dev/null +++ b/core/src/main/java/com/tcn/exile/service/CallService.java @@ -0,0 +1,140 @@ +package com.tcn.exile.service; + +import com.tcn.exile.model.CallType; +import io.grpc.ManagedChannel; +import java.util.Map; + +/** Call control operations. No proto types in the public API. */ +public final class CallService { + + private final build.buf.gen.tcnapi.exile.gate.v3.CallServiceGrpc.CallServiceBlockingStub stub; + + CallService(ManagedChannel channel) { + this.stub = build.buf.gen.tcnapi.exile.gate.v3.CallServiceGrpc.newBlockingStub(channel); + } + + public record DialResult( + String phoneNumber, + String callerId, + long callSid, + CallType callType, + String orgId, + String partnerAgentId, + boolean attempted, + String status) {} + + public DialResult dial( + String partnerAgentId, + String phoneNumber, + String callerId, + String poolId, + String recordId, + String rulesetName, + Boolean skipCompliance, + Boolean recordCall) { + var req = + build.buf.gen.tcnapi.exile.gate.v3.DialRequest.newBuilder() + .setPartnerAgentId(partnerAgentId) + .setPhoneNumber(phoneNumber); + if (callerId != null) req.setCallerId(callerId); + if (poolId != null) req.setPoolId(poolId); + if (recordId != null) req.setRecordId(recordId); + if (rulesetName != null) req.setRulesetName(rulesetName); + if (skipCompliance != null) req.setSkipComplianceChecks(skipCompliance); + if (recordCall != null) req.setRecordCall(recordCall); + var resp = stub.dial(req.build()); + return new DialResult( + resp.getPhoneNumber(), + resp.getCallerId(), + resp.getCallSid(), + com.tcn.exile.internal.ProtoConverter.toCallType(resp.getCallType()), + resp.getOrgId(), + resp.getPartnerAgentId(), + resp.getAttempted(), + resp.getStatus()); + } + + public void transfer( + String partnerAgentId, + String kind, + String action, + String destAgentId, + String destPhone, + Map destSkills) { + var req = + build.buf.gen.tcnapi.exile.gate.v3.TransferRequest.newBuilder() + .setPartnerAgentId(partnerAgentId); + req.setKind( + build.buf.gen.tcnapi.exile.gate.v3.TransferRequest.TransferKind.valueOf( + "TRANSFER_KIND_" + kind)); + req.setAction( + build.buf.gen.tcnapi.exile.gate.v3.TransferRequest.TransferAction.valueOf( + "TRANSFER_ACTION_" + action)); + if (destAgentId != null) { + req.setAgent( + build.buf.gen.tcnapi.exile.gate.v3.TransferRequest.AgentDestination.newBuilder() + .setPartnerAgentId(destAgentId)); + } else if (destPhone != null) { + req.setOutbound( + build.buf.gen.tcnapi.exile.gate.v3.TransferRequest.OutboundDestination.newBuilder() + .setPhoneNumber(destPhone)); + } else if (destSkills != null) { + req.setQueue( + build.buf.gen.tcnapi.exile.gate.v3.TransferRequest.QueueDestination.newBuilder() + .putAllRequiredSkills(destSkills)); + } + stub.transfer(req.build()); + } + + public enum HoldTarget { + CALL, + TRANSFER_CALLER, + TRANSFER_AGENT + } + + public enum HoldAction { + HOLD, + UNHOLD + } + + public void setHoldState(String partnerAgentId, HoldTarget target, HoldAction action) { + stub.setHoldState( + build.buf.gen.tcnapi.exile.gate.v3.SetHoldStateRequest.newBuilder() + .setPartnerAgentId(partnerAgentId) + .setTarget( + build.buf.gen.tcnapi.exile.gate.v3.SetHoldStateRequest.HoldTarget.valueOf( + "HOLD_TARGET_" + target.name())) + .setAction( + build.buf.gen.tcnapi.exile.gate.v3.SetHoldStateRequest.HoldAction.valueOf( + "HOLD_ACTION_" + action.name())) + .build()); + } + + public void startCallRecording(String partnerAgentId) { + stub.startCallRecording( + build.buf.gen.tcnapi.exile.gate.v3.StartCallRecordingRequest.newBuilder() + .setPartnerAgentId(partnerAgentId) + .build()); + } + + public void stopCallRecording(String partnerAgentId) { + stub.stopCallRecording( + build.buf.gen.tcnapi.exile.gate.v3.StopCallRecordingRequest.newBuilder() + .setPartnerAgentId(partnerAgentId) + .build()); + } + + public boolean getRecordingStatus(String partnerAgentId) { + return stub.getRecordingStatus( + build.buf.gen.tcnapi.exile.gate.v3.GetRecordingStatusRequest.newBuilder() + .setPartnerAgentId(partnerAgentId) + .build()) + .getIsRecording(); + } + + public java.util.List listComplianceRulesets() { + return stub.listComplianceRulesets( + build.buf.gen.tcnapi.exile.gate.v3.ListComplianceRulesetsRequest.getDefaultInstance()) + .getRulesetNamesList(); + } +} diff --git a/core/src/main/java/com/tcn/exile/service/ConfigService.java b/core/src/main/java/com/tcn/exile/service/ConfigService.java new file mode 100644 index 0000000..c0b8a2c --- /dev/null +++ b/core/src/main/java/com/tcn/exile/service/ConfigService.java @@ -0,0 +1,52 @@ +package com.tcn.exile.service; + +import com.tcn.exile.internal.ProtoConverter; +import io.grpc.ManagedChannel; +import java.util.Map; + +/** Configuration and lifecycle operations. No proto types in the public API. */ +public final class ConfigService { + + private final build.buf.gen.tcnapi.exile.gate.v3.ConfigServiceGrpc.ConfigServiceBlockingStub stub; + + ConfigService(ManagedChannel channel) { + this.stub = build.buf.gen.tcnapi.exile.gate.v3.ConfigServiceGrpc.newBlockingStub(channel); + } + + public record ClientConfiguration( + String orgId, String orgName, String configName, Map configPayload) {} + + public record OrgInfo(String orgId, String orgName) {} + + public ClientConfiguration getClientConfiguration() { + var resp = + stub.getClientConfiguration( + build.buf.gen.tcnapi.exile.gate.v3.GetClientConfigurationRequest.getDefaultInstance()); + return new ClientConfiguration( + resp.getOrgId(), + resp.getOrgName(), + resp.getConfigName(), + ProtoConverter.structToMap(resp.getConfigPayload())); + } + + public OrgInfo getOrganizationInfo() { + var resp = + stub.getOrganizationInfo( + build.buf.gen.tcnapi.exile.gate.v3.GetOrganizationInfoRequest.getDefaultInstance()); + return new OrgInfo(resp.getOrgId(), resp.getOrgName()); + } + + public String rotateCertificate(String certificateHash) { + var resp = + stub.rotateCertificate( + build.buf.gen.tcnapi.exile.gate.v3.RotateCertificateRequest.newBuilder() + .setCertificateHash(certificateHash) + .build()); + return resp.getEncodedCertificate(); + } + + public void log(String payload) { + stub.log( + build.buf.gen.tcnapi.exile.gate.v3.LogRequest.newBuilder().setPayload(payload).build()); + } +} diff --git a/core/src/main/java/com/tcn/exile/service/JourneyService.java b/core/src/main/java/com/tcn/exile/service/JourneyService.java new file mode 100644 index 0000000..d2580ec --- /dev/null +++ b/core/src/main/java/com/tcn/exile/service/JourneyService.java @@ -0,0 +1,39 @@ +package com.tcn.exile.service; + +import com.tcn.exile.internal.ProtoConverter; +import com.tcn.exile.model.DataRecord; +import io.grpc.ManagedChannel; + +/** Journey buffer operations. No proto types in the public API. */ +public final class JourneyService { + + private final build.buf.gen.tcnapi.exile.gate.v3.JourneyServiceGrpc.JourneyServiceBlockingStub + stub; + + JourneyService(ManagedChannel channel) { + this.stub = build.buf.gen.tcnapi.exile.gate.v3.JourneyServiceGrpc.newBlockingStub(channel); + } + + public enum JourneyBufferStatus { + INSERTED, + UPDATED, + IGNORED, + REJECTED, + UNSPECIFIED + } + + public JourneyBufferStatus addRecordToJourneyBuffer(DataRecord record) { + var resp = + stub.addRecordToJourneyBuffer( + build.buf.gen.tcnapi.exile.gate.v3.AddRecordToJourneyBufferRequest.newBuilder() + .setRecord(ProtoConverter.fromRecord(record)) + .build()); + return switch (resp.getStatus()) { + case JOURNEY_BUFFER_STATUS_INSERTED -> JourneyBufferStatus.INSERTED; + case JOURNEY_BUFFER_STATUS_UPDATED -> JourneyBufferStatus.UPDATED; + case JOURNEY_BUFFER_STATUS_IGNORED -> JourneyBufferStatus.IGNORED; + case JOURNEY_BUFFER_STATUS_REJECTED -> JourneyBufferStatus.REJECTED; + default -> JourneyBufferStatus.UNSPECIFIED; + }; + } +} diff --git a/core/src/main/java/com/tcn/exile/service/RecordingService.java b/core/src/main/java/com/tcn/exile/service/RecordingService.java new file mode 100644 index 0000000..b866efb --- /dev/null +++ b/core/src/main/java/com/tcn/exile/service/RecordingService.java @@ -0,0 +1,94 @@ +package com.tcn.exile.service; + +import com.tcn.exile.internal.ProtoConverter; +import com.tcn.exile.model.*; +import io.grpc.ManagedChannel; +import java.time.Duration; +import java.util.List; +import java.util.stream.Collectors; + +/** Voice recording search and retrieval. No proto types in the public API. */ +public final class RecordingService { + + private final build.buf.gen.tcnapi.exile.gate.v3.RecordingServiceGrpc.RecordingServiceBlockingStub + stub; + + RecordingService(ManagedChannel channel) { + this.stub = build.buf.gen.tcnapi.exile.gate.v3.RecordingServiceGrpc.newBlockingStub(channel); + } + + public record VoiceRecording( + String recordingId, + long callSid, + CallType callType, + Duration startOffset, + Duration endOffset, + java.time.Instant startTime, + Duration duration, + String agentPhone, + String clientPhone, + String campaign, + List partnerAgentIds, + String label, + String value) {} + + public record DownloadLinks(String downloadLink, String playbackLink) {} + + public Page searchVoiceRecordings( + List filters, String pageToken, int pageSize) { + var req = + build.buf.gen.tcnapi.exile.gate.v3.SearchVoiceRecordingsRequest.newBuilder() + .setPageSize(pageSize); + if (pageToken != null) req.setPageToken(pageToken); + for (var f : filters) req.addFilters(ProtoConverter.fromFilter(f)); + var resp = stub.searchVoiceRecordings(req.build()); + var recordings = + resp.getRecordingsList().stream() + .map( + r -> + new VoiceRecording( + r.getRecordingId(), + r.getCallSid(), + ProtoConverter.toCallType(r.getCallType()), + ProtoConverter.toDuration(r.getStartOffset()), + ProtoConverter.toDuration(r.getEndOffset()), + ProtoConverter.toInstant(r.getStartTime()), + ProtoConverter.toDuration(r.getDuration()), + r.getAgentPhone(), + r.getClientPhone(), + r.getCampaign(), + r.getPartnerAgentIdsList(), + r.getLabel(), + r.getValue())) + .collect(Collectors.toList()); + return new Page<>(recordings, resp.getNextPageToken()); + } + + public DownloadLinks getDownloadLink( + String recordingId, Duration startOffset, Duration endOffset) { + var req = + build.buf.gen.tcnapi.exile.gate.v3.GetDownloadLinkRequest.newBuilder() + .setRecordingId(recordingId); + if (startOffset != null) req.setStartOffset(ProtoConverter.fromDuration(startOffset)); + if (endOffset != null) req.setEndOffset(ProtoConverter.fromDuration(endOffset)); + var resp = stub.getDownloadLink(req.build()); + return new DownloadLinks(resp.getDownloadLink(), resp.getPlaybackLink()); + } + + public List listSearchableFields() { + return stub.listSearchableFields( + build.buf.gen.tcnapi.exile.gate.v3.ListSearchableFieldsRequest.getDefaultInstance()) + .getFieldsList(); + } + + public void createLabel(long callSid, CallType callType, String key, String value) { + stub.createLabel( + build.buf.gen.tcnapi.exile.gate.v3.CreateLabelRequest.newBuilder() + .setCallSid(callSid) + .setCallType( + build.buf.gen.tcnapi.exile.gate.v3.CallType.valueOf("CALL_TYPE_" + callType.name())) + .setKey(key) + .setValue(value) + .build()); + } +} diff --git a/core/src/main/java/com/tcn/exile/service/ScrubListService.java b/core/src/main/java/com/tcn/exile/service/ScrubListService.java new file mode 100644 index 0000000..e0fc83a --- /dev/null +++ b/core/src/main/java/com/tcn/exile/service/ScrubListService.java @@ -0,0 +1,76 @@ +package com.tcn.exile.service; + +import io.grpc.ManagedChannel; +import java.time.Instant; +import java.util.List; + +/** Scrub list management. No proto types in the public API. */ +public final class ScrubListService { + + private final build.buf.gen.tcnapi.exile.gate.v3.ScrubListServiceGrpc.ScrubListServiceBlockingStub + stub; + + ScrubListService(ManagedChannel channel) { + this.stub = build.buf.gen.tcnapi.exile.gate.v3.ScrubListServiceGrpc.newBlockingStub(channel); + } + + public record ScrubList(String scrubListId, boolean readOnly, String contentType) {} + + public record ScrubListEntry( + String content, Instant expiration, String notes, String countryCode) {} + + public List listScrubLists() { + return stub + .listScrubLists( + build.buf.gen.tcnapi.exile.gate.v3.ListScrubListsRequest.getDefaultInstance()) + .getScrubListsList() + .stream() + .map(sl -> new ScrubList(sl.getScrubListId(), sl.getReadOnly(), sl.getContentType().name())) + .toList(); + } + + public void addEntries( + String scrubListId, List entries, String defaultCountryCode) { + var req = + build.buf.gen.tcnapi.exile.gate.v3.AddEntriesRequest.newBuilder() + .setScrubListId(scrubListId); + if (defaultCountryCode != null) req.setDefaultCountryCode(defaultCountryCode); + for (var e : entries) { + var eb = + build.buf.gen.tcnapi.exile.gate.v3.ScrubListEntry.newBuilder().setContent(e.content()); + if (e.expiration() != null) { + eb.setExpiration( + com.google.protobuf.Timestamp.newBuilder().setSeconds(e.expiration().getEpochSecond())); + } + if (e.notes() != null) eb.setNotes(e.notes()); + if (e.countryCode() != null) eb.setCountryCode(e.countryCode()); + req.addEntries(eb); + } + stub.addEntries(req.build()); + } + + public void updateEntry(String scrubListId, ScrubListEntry entry) { + var eb = + build.buf.gen.tcnapi.exile.gate.v3.ScrubListEntry.newBuilder().setContent(entry.content()); + if (entry.expiration() != null) { + eb.setExpiration( + com.google.protobuf.Timestamp.newBuilder() + .setSeconds(entry.expiration().getEpochSecond())); + } + if (entry.notes() != null) eb.setNotes(entry.notes()); + if (entry.countryCode() != null) eb.setCountryCode(entry.countryCode()); + stub.updateEntry( + build.buf.gen.tcnapi.exile.gate.v3.UpdateEntryRequest.newBuilder() + .setScrubListId(scrubListId) + .setEntry(eb) + .build()); + } + + public void removeEntries(String scrubListId, List entries) { + stub.removeEntries( + build.buf.gen.tcnapi.exile.gate.v3.RemoveEntriesRequest.newBuilder() + .setScrubListId(scrubListId) + .addAllEntries(entries) + .build()); + } +} diff --git a/core/src/main/java/com/tcn/exile/service/ServiceFactory.java b/core/src/main/java/com/tcn/exile/service/ServiceFactory.java new file mode 100644 index 0000000..15d3cb8 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/service/ServiceFactory.java @@ -0,0 +1,28 @@ +package com.tcn.exile.service; + +import io.grpc.ManagedChannel; + +/** Factory for creating all domain service clients. Keeps ManagedChannel out of public API. */ +public final class ServiceFactory { + private ServiceFactory() {} + + public record Services( + AgentService agent, + CallService call, + RecordingService recording, + ScrubListService scrubList, + ConfigService config, + JourneyService journey, + TelemetryService telemetry) {} + + public static Services create(ManagedChannel channel) { + return new Services( + new AgentService(channel), + new CallService(channel), + new RecordingService(channel), + new ScrubListService(channel), + new ConfigService(channel), + new JourneyService(channel), + new TelemetryService(channel)); + } +} diff --git a/core/src/main/java/com/tcn/exile/service/TelemetryService.java b/core/src/main/java/com/tcn/exile/service/TelemetryService.java new file mode 100644 index 0000000..e865b69 --- /dev/null +++ b/core/src/main/java/com/tcn/exile/service/TelemetryService.java @@ -0,0 +1,161 @@ +package com.tcn.exile.service; + +import build.buf.gen.tcnapi.exile.gate.v3.*; +import com.google.protobuf.Timestamp; +import io.grpc.ManagedChannel; +import io.opentelemetry.sdk.metrics.data.*; +import java.time.Instant; +import java.util.Collection; +import java.util.List; + +/** Reports client metrics and logs to the gate TelemetryService. */ +public final class TelemetryService { + + private final TelemetryServiceGrpc.TelemetryServiceBlockingStub stub; + + TelemetryService(ManagedChannel channel) { + this.stub = TelemetryServiceGrpc.newBlockingStub(channel); + } + + /** Send a batch of OTel metric data to the gate. Returns accepted count. */ + public int reportMetrics(String clientId, Collection metrics) { + var now = Instant.now(); + var builder = + ReportMetricsRequest.newBuilder().setClientId(clientId).setCollectionTime(toTimestamp(now)); + + for (var metric : metrics) { + var name = metric.getName(); + var description = metric.getDescription(); + var unit = metric.getUnit(); + // Resource attributes (org_id, config_name, client_id) are merged into every data point. + var resourceAttrs = attributesToMap(metric.getResource().getAttributes()); + + switch (metric.getType()) { + case LONG_GAUGE -> { + for (var point : metric.getLongGaugeData().getPoints()) { + var attrs = mergeAttributes(resourceAttrs, point.getAttributes()); + builder.addDataPoints( + MetricDataPoint.newBuilder() + .setName(name) + .setDescription(description) + .setUnit(unit) + .setType(MetricType.METRIC_TYPE_GAUGE) + .putAllAttributes(attrs) + .setTime(toTimestamp(point.getEpochNanos())) + .setDoubleValue(point.getValue()) + .build()); + } + } + case DOUBLE_GAUGE -> { + for (var point : metric.getDoubleGaugeData().getPoints()) { + var attrs = mergeAttributes(resourceAttrs, point.getAttributes()); + builder.addDataPoints( + MetricDataPoint.newBuilder() + .setName(name) + .setDescription(description) + .setUnit(unit) + .setType(MetricType.METRIC_TYPE_GAUGE) + .putAllAttributes(attrs) + .setTime(toTimestamp(point.getEpochNanos())) + .setDoubleValue(point.getValue()) + .build()); + } + } + case LONG_SUM -> { + for (var point : metric.getLongSumData().getPoints()) { + var attrs = mergeAttributes(resourceAttrs, point.getAttributes()); + builder.addDataPoints( + MetricDataPoint.newBuilder() + .setName(name) + .setDescription(description) + .setUnit(unit) + .setType(MetricType.METRIC_TYPE_SUM) + .putAllAttributes(attrs) + .setTime(toTimestamp(point.getEpochNanos())) + .setIntValue(point.getValue()) + .build()); + } + } + case DOUBLE_SUM -> { + for (var point : metric.getDoubleSumData().getPoints()) { + var attrs = mergeAttributes(resourceAttrs, point.getAttributes()); + builder.addDataPoints( + MetricDataPoint.newBuilder() + .setName(name) + .setDescription(description) + .setUnit(unit) + .setType(MetricType.METRIC_TYPE_SUM) + .putAllAttributes(attrs) + .setTime(toTimestamp(point.getEpochNanos())) + .setDoubleValue(point.getValue()) + .build()); + } + } + case HISTOGRAM -> { + for (var point : metric.getHistogramData().getPoints()) { + var attrs = mergeAttributes(resourceAttrs, point.getAttributes()); + var hv = + HistogramValue.newBuilder() + .setCount(point.getCount()) + .setSum(point.getSum()) + .addAllBoundaries(point.getBoundaries()) + .addAllBucketCounts(point.getCounts()) + .setMin(point.getMin()) + .setMax(point.getMax()); + builder.addDataPoints( + MetricDataPoint.newBuilder() + .setName(name) + .setDescription(description) + .setUnit(unit) + .setType(MetricType.METRIC_TYPE_HISTOGRAM) + .putAllAttributes(attrs) + .setTime(toTimestamp(point.getEpochNanos())) + .setHistogramValue(hv) + .build()); + } + } + default -> {} // skip unsupported types + } + } + + var resp = stub.reportMetrics(builder.build()); + return resp.getAcceptedCount(); + } + + /** Send a batch of structured log records to the gate. Returns accepted count. */ + public int reportLogs(String clientId, List records) { + var builder = ReportLogsRequest.newBuilder().setClientId(clientId).addAllRecords(records); + var resp = stub.reportLogs(builder.build()); + return resp.getAcceptedCount(); + } + + private static java.util.Map attributesToMap( + io.opentelemetry.api.common.Attributes attrs) { + var map = new java.util.HashMap(); + attrs.forEach((k, v) -> map.put(k.getKey(), String.valueOf(v))); + return map; + } + + /** Merge resource attributes with point attributes (point attrs take precedence). */ + private static java.util.Map mergeAttributes( + java.util.Map resourceAttrs, + io.opentelemetry.api.common.Attributes pointAttrs) { + var merged = new java.util.HashMap<>(resourceAttrs); + pointAttrs.forEach((k, v) -> merged.put(k.getKey(), String.valueOf(v))); + return merged; + } + + private static Timestamp toTimestamp(Instant instant) { + return Timestamp.newBuilder() + .setSeconds(instant.getEpochSecond()) + .setNanos(instant.getNano()) + .build(); + } + + private static Timestamp toTimestamp(long epochNanos) { + return Timestamp.newBuilder() + .setSeconds(epochNanos / 1_000_000_000L) + .setNanos((int) (epochNanos % 1_000_000_000L)) + .build(); + } +} diff --git a/core/src/main/resources/application.properties b/core/src/main/resources/application.properties deleted file mode 100644 index d807bb0..0000000 --- a/core/src/main/resources/application.properties +++ /dev/null @@ -1,2 +0,0 @@ -#Fri Sep 20 01:51:42 UTC 2024 -micronaut.application.name=core diff --git a/core/src/main/resources/application.yml b/core/src/main/resources/application.yml deleted file mode 100644 index d93fec7..0000000 --- a/core/src/main/resources/application.yml +++ /dev/null @@ -1,30 +0,0 @@ -micronaut: - application: - name: sati - server: - port: 8080 - -# Disable both health checks and the gRPC server -endpoints: - health: - enabled: true - details-visible: ANONYMOUS - grpc: - enabled: false - disk-space: - enabled: true - jdbc: - enabled: false - -# Ensure gRPC server is disabled -grpc: - server: - enabled: false - health: - enabled: false - -# For health monitoring -jackson: - serialization: - indentOutput: true - writeDatesAsTimestamps: false \ No newline at end of file diff --git a/core/src/main/resources/logback.xml b/core/src/main/resources/logback.xml deleted file mode 100644 index b70e42a..0000000 --- a/core/src/main/resources/logback.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - - - - - - diff --git a/core/src/test/java/com/tcn/exile/ExileConfigTest.java b/core/src/test/java/com/tcn/exile/ExileConfigTest.java new file mode 100644 index 0000000..3284c60 --- /dev/null +++ b/core/src/test/java/com/tcn/exile/ExileConfigTest.java @@ -0,0 +1,45 @@ +package com.tcn.exile; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class ExileConfigTest { + + @Test + void builderSetsFields() { + var config = + ExileConfig.builder() + .rootCert("root") + .publicCert("pub") + .privateKey("key") + .apiHostname("gate.example.com") + .apiPort(8443) + .build(); + assertEquals("root", config.rootCert()); + assertEquals("pub", config.publicCert()); + assertEquals("key", config.privateKey()); + assertEquals("gate.example.com", config.apiHostname()); + assertEquals(8443, config.apiPort()); + } + + @Test + void defaultPort() { + var config = + ExileConfig.builder() + .rootCert("root") + .publicCert("pub") + .privateKey("key") + .apiHostname("gate.example.com") + .build(); + assertEquals(443, config.apiPort()); + } + + @Test + void nullFieldsThrow() { + assertThrows(NullPointerException.class, () -> ExileConfig.builder().build()); + assertThrows( + NullPointerException.class, + () -> ExileConfig.builder().rootCert("r").publicCert("p").build()); + } +} diff --git a/core/src/test/java/com/tcn/exile/StreamStatusTest.java b/core/src/test/java/com/tcn/exile/StreamStatusTest.java new file mode 100644 index 0000000..3404b2a --- /dev/null +++ b/core/src/test/java/com/tcn/exile/StreamStatusTest.java @@ -0,0 +1,49 @@ +package com.tcn.exile; + +import static org.junit.jupiter.api.Assertions.*; + +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class StreamStatusTest { + + @Test + void isHealthyWhenActive() { + var status = + new StreamStatus(StreamStatus.Phase.ACTIVE, "c-1", Instant.now(), null, null, 3, 100, 2, 1); + assertTrue(status.isHealthy()); + } + + @Test + void isNotHealthyWhenReconnecting() { + var status = + new StreamStatus( + StreamStatus.Phase.RECONNECTING, + null, + null, + Instant.now(), + "connection refused", + 0, + 50, + 5, + 3); + assertFalse(status.isHealthy()); + } + + @Test + void isNotHealthyWhenIdle() { + var status = new StreamStatus(StreamStatus.Phase.IDLE, null, null, null, null, 0, 0, 0, 0); + assertFalse(status.isHealthy()); + } + + @Test + void isNotHealthyWhenClosed() { + var status = new StreamStatus(StreamStatus.Phase.CLOSED, null, null, null, null, 0, 100, 0, 5); + assertFalse(status.isHealthy()); + } + + @Test + void phaseEnumValues() { + assertEquals(6, StreamStatus.Phase.values().length); + } +} diff --git a/core/src/test/java/com/tcn/exile/internal/BackoffTest.java b/core/src/test/java/com/tcn/exile/internal/BackoffTest.java new file mode 100644 index 0000000..e93bb1e --- /dev/null +++ b/core/src/test/java/com/tcn/exile/internal/BackoffTest.java @@ -0,0 +1,56 @@ +package com.tcn.exile.internal; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class BackoffTest { + + @Test + void firstAttemptReturnsZero() { + var b = new Backoff(); + assertEquals(0, b.nextDelayMs()); + } + + @Test + void delayIncreasesExponentially() { + var b = new Backoff(); + b.recordFailure(); + long d1 = b.nextDelayMs(); + assertTrue(d1 >= 400 && d1 <= 600, "First failure ~500ms, got " + d1); + + b.recordFailure(); + long d2 = b.nextDelayMs(); + assertTrue(d2 >= 800 && d2 <= 1200, "Second failure ~1s, got " + d2); + + b.recordFailure(); + long d3 = b.nextDelayMs(); + assertTrue(d3 >= 1600 && d3 <= 2400, "Third failure ~2s, got " + d3); + } + + @Test + void delayCapsAtMax() { + var b = new Backoff(); + for (int i = 0; i < 20; i++) b.recordFailure(); + long d = b.nextDelayMs(); + assertTrue(d <= 10_000, "Should cap at 10s, got " + d); + } + + @Test + void resetResetsToZero() { + var b = new Backoff(); + b.recordFailure(); + b.recordFailure(); + b.reset(); + assertEquals(0, b.nextDelayMs()); + } + + @Test + void sleepDoesNotSleepOnFirstAttempt() throws InterruptedException { + var b = new Backoff(); + long start = System.currentTimeMillis(); + b.sleep(); + long elapsed = System.currentTimeMillis() - start; + assertTrue(elapsed < 100, "Should not sleep on first attempt, took " + elapsed); + } +} diff --git a/core/src/test/java/com/tcn/exile/internal/ChannelBenchmark.java b/core/src/test/java/com/tcn/exile/internal/ChannelBenchmark.java new file mode 100644 index 0000000..cfd84a0 --- /dev/null +++ b/core/src/test/java/com/tcn/exile/internal/ChannelBenchmark.java @@ -0,0 +1,368 @@ +package com.tcn.exile.internal; + +import static org.junit.jupiter.api.Assertions.*; + +import build.buf.gen.tcnapi.exile.gate.v3.*; +import io.grpc.ManagedChannel; +import io.grpc.Server; +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.inprocess.InProcessServerBuilder; +import io.grpc.stub.StreamObserver; +import java.util.ArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Benchmarks comparing single reused channel vs new channel per stream. Uses in-process transport + * to isolate the channel/stream overhead from network latency. + */ +class ChannelBenchmark { + + private static final String SERVER_NAME = "benchmark-server"; + private Server server; + + @BeforeEach + void setUp() throws Exception { + server = + InProcessServerBuilder.forName(SERVER_NAME) + .directExecutor() + .addService(new BenchmarkWorkerService()) + .build() + .start(); + } + + @AfterEach + void tearDown() throws Exception { + if (server != null) { + server.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); + } + } + + @Test + void benchmarkReusedChannel() throws Exception { + int iterations = 100; + var channel = InProcessChannelBuilder.forName(SERVER_NAME).directExecutor().build(); + + var times = new ArrayList(); + for (int i = 0; i < iterations; i++) { + long start = System.nanoTime(); + runSingleStream(channel); + long elapsed = System.nanoTime() - start; + times.add(elapsed); + } + + channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); + + long avg = times.stream().mapToLong(Long::longValue).sum() / iterations; + long min = times.stream().mapToLong(Long::longValue).min().orElse(0); + long max = times.stream().mapToLong(Long::longValue).max().orElse(0); + long p50 = times.stream().sorted().skip(iterations / 2).findFirst().orElse(0L); + long p99 = times.stream().sorted().skip((long) (iterations * 0.99)).findFirst().orElse(0L); + + System.out.println("=== REUSED CHANNEL (" + iterations + " iterations) ==="); + System.out.printf(" avg: %,d ns (%.2f ms)%n", avg, avg / 1_000_000.0); + System.out.printf(" min: %,d ns (%.2f ms)%n", min, min / 1_000_000.0); + System.out.printf(" max: %,d ns (%.2f ms)%n", max, max / 1_000_000.0); + System.out.printf(" p50: %,d ns (%.2f ms)%n", p50, p50 / 1_000_000.0); + System.out.printf(" p99: %,d ns (%.2f ms)%n", p99, p99 / 1_000_000.0); + } + + @Test + void benchmarkNewChannelPerStream() throws Exception { + int iterations = 100; + + var times = new ArrayList(); + for (int i = 0; i < iterations; i++) { + long start = System.nanoTime(); + var channel = InProcessChannelBuilder.forName(SERVER_NAME).directExecutor().build(); + runSingleStream(channel); + channel.shutdownNow().awaitTermination(1, TimeUnit.SECONDS); + long elapsed = System.nanoTime() - start; + times.add(elapsed); + } + + long avg = times.stream().mapToLong(Long::longValue).sum() / iterations; + long min = times.stream().mapToLong(Long::longValue).min().orElse(0); + long max = times.stream().mapToLong(Long::longValue).max().orElse(0); + long p50 = times.stream().sorted().skip(iterations / 2).findFirst().orElse(0L); + long p99 = times.stream().sorted().skip((long) (iterations * 0.99)).findFirst().orElse(0L); + + System.out.println("=== NEW CHANNEL PER STREAM (" + iterations + " iterations) ==="); + System.out.printf(" avg: %,d ns (%.2f ms)%n", avg, avg / 1_000_000.0); + System.out.printf(" min: %,d ns (%.2f ms)%n", min, min / 1_000_000.0); + System.out.printf(" max: %,d ns (%.2f ms)%n", max, max / 1_000_000.0); + System.out.printf(" p50: %,d ns (%.2f ms)%n", p50, p50 / 1_000_000.0); + System.out.printf(" p99: %,d ns (%.2f ms)%n", p99, p99 / 1_000_000.0); + } + + @Test + void benchmarkConcurrentStreamsOnSingleChannel() throws Exception { + int concurrency = 10; + int streamsPerThread = 50; + var channel = InProcessChannelBuilder.forName(SERVER_NAME).directExecutor().build(); + + var totalStreams = new AtomicInteger(0); + long start = System.nanoTime(); + + var threads = new ArrayList(); + for (int t = 0; t < concurrency; t++) { + var thread = + Thread.ofVirtual() + .start( + () -> { + for (int i = 0; i < streamsPerThread; i++) { + try { + runSingleStream(channel); + totalStreams.incrementAndGet(); + } catch (Exception e) { + e.printStackTrace(); + } + } + }); + threads.add(thread); + } + for (var thread : threads) thread.join(); + + long elapsed = System.nanoTime() - start; + channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); + + System.out.println( + "=== CONCURRENT STREAMS (" + concurrency + " threads x " + streamsPerThread + ") ==="); + System.out.printf(" total streams: %d%n", totalStreams.get()); + System.out.printf(" total time: %.2f ms%n", elapsed / 1_000_000.0); + System.out.printf(" per stream: %.2f ms%n", (elapsed / 1_000_000.0) / totalStreams.get()); + System.out.printf( + " throughput: %.0f streams/sec%n", totalStreams.get() / (elapsed / 1_000_000_000.0)); + } + + /** + * Opens a bidirectional WorkStream, sends Register + Pull, receives one WorkItem response, sends + * an Ack, and closes. Simulates one reconnect cycle. + */ + private void runSingleStream(ManagedChannel channel) throws Exception { + var stub = WorkerServiceGrpc.newStub(channel); + var latch = new CountDownLatch(1); + var received = new AtomicInteger(0); + + var observer = + stub.workStream( + new StreamObserver() { + @Override + public void onNext(WorkResponse response) { + received.incrementAndGet(); + if (response.hasRegistered()) { + // Got Registered — done. + latch.countDown(); + } + } + + @Override + public void onError(Throwable t) { + latch.countDown(); + } + + @Override + public void onCompleted() { + latch.countDown(); + } + }); + + // Send Register. + observer.onNext( + WorkRequest.newBuilder() + .setRegister(Register.newBuilder().setClientName("benchmark").setClientVersion("1.0")) + .build()); + + // Wait for Registered response. + assertTrue(latch.await(5, TimeUnit.SECONDS), "Timed out waiting for Registered"); + assertTrue(received.get() >= 1, "Expected at least 1 response"); + + // Close stream gracefully. + observer.onCompleted(); + } + + @Test + void benchmarkMessageThroughput() throws Exception { + int messageCount = 10_000; + var channel = InProcessChannelBuilder.forName(SERVER_NAME).directExecutor().build(); + var stub = WorkerServiceGrpc.newStub(channel); + var received = new AtomicInteger(0); + var allReceived = new CountDownLatch(1); + + var observer = + stub.workStream( + new StreamObserver() { + @Override + public void onNext(WorkResponse response) { + if (received.incrementAndGet() >= messageCount + 1) { // +1 for Registered + allReceived.countDown(); + } + } + + @Override + public void onError(Throwable t) { + allReceived.countDown(); + } + + @Override + public void onCompleted() { + allReceived.countDown(); + } + }); + + // Register first. + observer.onNext( + WorkRequest.newBuilder() + .setRegister(Register.newBuilder().setClientName("bench").setClientVersion("1.0")) + .build()); + + // Now send Pull messages as fast as possible — server responds with WorkItem for each. + long start = System.nanoTime(); + for (int i = 0; i < messageCount; i++) { + observer.onNext(WorkRequest.newBuilder().setPull(Pull.newBuilder().setMaxItems(1)).build()); + } + + assertTrue(allReceived.await(10, TimeUnit.SECONDS), "Timed out waiting for all messages"); + long elapsed = System.nanoTime() - start; + + observer.onCompleted(); + channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); + + double elapsedMs = elapsed / 1_000_000.0; + double msgsPerSec = messageCount / (elapsed / 1_000_000_000.0); + double usPerMsg = (elapsed / 1_000.0) / messageCount; + + System.out.println("=== MESSAGE THROUGHPUT (" + messageCount + " messages) ==="); + System.out.printf(" total time: %.2f ms%n", elapsedMs); + System.out.printf(" msgs/sec: %,.0f%n", msgsPerSec); + System.out.printf(" us/msg: %.2f%n", usPerMsg); + System.out.printf(" received: %d%n", received.get()); + } + + @Test + void benchmarkRoundTripLatency() throws Exception { + int iterations = 1000; + var channel = InProcessChannelBuilder.forName(SERVER_NAME).directExecutor().build(); + var stub = WorkerServiceGrpc.newStub(channel); + var times = new ArrayList(); + + for (int i = 0; i < iterations; i++) { + var latch = new CountDownLatch(1); + var responseObserver = + stub.workStream( + new StreamObserver() { + boolean registered = false; + + @Override + public void onNext(WorkResponse response) { + if (!registered) { + registered = true; + return; // skip Registered + } + latch.countDown(); // got the WorkItem response + } + + @Override + public void onError(Throwable t) { + latch.countDown(); + } + + @Override + public void onCompleted() { + latch.countDown(); + } + }); + + // Register. + responseObserver.onNext( + WorkRequest.newBuilder() + .setRegister(Register.newBuilder().setClientName("bench").setClientVersion("1.0")) + .build()); + + // Measure round-trip: send Pull → receive WorkItem. + long start = System.nanoTime(); + responseObserver.onNext( + WorkRequest.newBuilder().setPull(Pull.newBuilder().setMaxItems(1)).build()); + latch.await(5, TimeUnit.SECONDS); + long elapsed = System.nanoTime() - start; + times.add(elapsed); + + responseObserver.onCompleted(); + } + + channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); + + long avg = times.stream().mapToLong(Long::longValue).sum() / iterations; + long min = times.stream().mapToLong(Long::longValue).min().orElse(0); + long max = times.stream().mapToLong(Long::longValue).max().orElse(0); + long p50 = times.stream().sorted().skip(iterations / 2).findFirst().orElse(0L); + long p99 = times.stream().sorted().skip((long) (iterations * 0.99)).findFirst().orElse(0L); + + System.out.println("=== ROUND-TRIP LATENCY (" + iterations + " iterations) ==="); + System.out.printf(" avg: %,d ns (%.3f ms)%n", avg, avg / 1_000_000.0); + System.out.printf(" min: %,d ns (%.3f ms)%n", min, min / 1_000_000.0); + System.out.printf(" max: %,d ns (%.3f ms)%n", max, max / 1_000_000.0); + System.out.printf(" p50: %,d ns (%.3f ms)%n", p50, p50 / 1_000_000.0); + System.out.printf(" p99: %,d ns (%.3f ms)%n", p99, p99 / 1_000_000.0); + } + + /** WorkerService that responds to Register with Registered and to Pull with a WorkItem. */ + static class BenchmarkWorkerService extends WorkerServiceGrpc.WorkerServiceImplBase { + @Override + public StreamObserver workStream(StreamObserver responseObserver) { + return new StreamObserver<>() { + int seq = 0; + + @Override + public void onNext(WorkRequest request) { + if (request.hasRegister()) { + responseObserver.onNext( + WorkResponse.newBuilder() + .setRegistered( + Registered.newBuilder() + .setClientId("bench-" + System.nanoTime()) + .setHeartbeatInterval( + com.google.protobuf.Duration.newBuilder().setSeconds(30)) + .setDefaultLease( + com.google.protobuf.Duration.newBuilder().setSeconds(300)) + .setMaxInflight(20)) + .build()); + } else if (request.hasPull()) { + // Respond with a WorkItem for each Pull. + responseObserver.onNext( + WorkResponse.newBuilder() + .setWorkItem( + WorkItem.newBuilder() + .setWorkId("w-" + (seq++)) + .setCategory(WorkCategory.WORK_CATEGORY_EVENT) + .setAttempt(1) + .setAgentCall( + build.buf.gen.tcnapi.exile.gate.v3.AgentCall.newBuilder() + .setCallSid(seq) + .setAgentCallSid(seq))) + .build()); + } else if (request.hasResult() || request.hasAck()) { + // Accept results/acks silently. + responseObserver.onNext( + WorkResponse.newBuilder() + .setResultAccepted( + ResultAccepted.newBuilder().setWorkId(request.getResult().getWorkId())) + .build()); + } + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + } +} diff --git a/core/src/test/java/com/tcn/exile/internal/LiveBenchmark.java b/core/src/test/java/com/tcn/exile/internal/LiveBenchmark.java new file mode 100644 index 0000000..c9a5f0a --- /dev/null +++ b/core/src/test/java/com/tcn/exile/internal/LiveBenchmark.java @@ -0,0 +1,349 @@ +package com.tcn.exile.internal; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.*; + +import build.buf.gen.tcnapi.exile.gate.v3.*; +import com.google.protobuf.ByteString; +import com.google.protobuf.Timestamp; +import com.tcn.exile.ExileConfig; +import io.grpc.ManagedChannel; +import io.grpc.stub.StreamObserver; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Base64; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.*; + +/** + * Live benchmarks against a real exile gate v3 deployment. Reads mTLS config from the standard sati + * config file. Skipped if config is not present. + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class LiveBenchmark { + + private static final Path CONFIG_PATH = + Path.of( + System.getProperty("user.home"), + "dev/tcncloud/workdir/config/com.tcn.exiles.sati.config.cfg"); + + private static ManagedChannel channel; + + @BeforeAll + static void connect() throws Exception { + assumeTrue( + Files.exists(CONFIG_PATH), + "No sati config found at " + CONFIG_PATH + " — skipping live benchmarks"); + + var raw = new String(Files.readAllBytes(CONFIG_PATH), StandardCharsets.UTF_8).trim(); + byte[] json; + try { + json = Base64.getDecoder().decode(raw); + } catch (IllegalArgumentException e) { + json = raw.getBytes(StandardCharsets.UTF_8); + } + var jsonStr = new String(json, StandardCharsets.UTF_8); + + // Minimal JSON extraction for the 4 fields we need. + var config = + ExileConfig.builder() + .rootCert(extractJsonString(jsonStr, "ca_certificate")) + .publicCert(extractJsonString(jsonStr, "certificate")) + .privateKey(extractJsonString(jsonStr, "private_key")) + .apiHostname(parseHost(extractJsonString(jsonStr, "api_endpoint"))) + .apiPort(parsePort(extractJsonString(jsonStr, "api_endpoint"))) + .build(); + + channel = ChannelFactory.create(config); + System.out.println("Connected to " + config.apiHostname() + ":" + config.apiPort()); + } + + private static String extractJsonString(String json, String key) { + var search = "\"" + key + "\""; + int idx = json.indexOf(search); + if (idx < 0) return ""; + idx = json.indexOf("\"", idx + search.length() + 1); // skip colon, find opening quote + if (idx < 0) return ""; + int start = idx + 1; + var sb = new StringBuilder(); + for (int i = start; i < json.length(); i++) { + char c = json.charAt(i); + if (c == '"') break; + if (c == '\\' && i + 1 < json.length()) { + i++; + sb.append( + switch (json.charAt(i)) { + case 'n' -> '\n'; + case 'r' -> '\r'; + case 't' -> '\t'; + case '\\' -> '\\'; + case '"' -> '"'; + default -> json.charAt(i); + }); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + private static String parseHost(String endpoint) { + if (endpoint.contains("://")) endpoint = endpoint.substring(endpoint.indexOf("://") + 3); + if (endpoint.endsWith("/")) endpoint = endpoint.substring(0, endpoint.length() - 1); + int colon = endpoint.lastIndexOf(':'); + return colon > 0 ? endpoint.substring(0, colon) : endpoint; + } + + private static int parsePort(String endpoint) { + if (endpoint.contains("://")) endpoint = endpoint.substring(endpoint.indexOf("://") + 3); + if (endpoint.endsWith("/")) endpoint = endpoint.substring(0, endpoint.length() - 1); + int colon = endpoint.lastIndexOf(':'); + if (colon > 0) { + try { + return Integer.parseInt(endpoint.substring(colon + 1)); + } catch (NumberFormatException e) { + /* fall through */ + } + } + return 443; + } + + @AfterAll + static void disconnect() { + ChannelFactory.shutdown(channel); + } + + @Test + @Order(1) + void pingLatency() { + var stub = BenchmarkServiceGrpc.newBlockingStub(channel); + int iterations = 1_000; + var times = new ArrayList(iterations); + + // Warmup. + for (int i = 0; i < 20; i++) { + stub.ping(PingRequest.newBuilder().build()); + } + + for (int i = 0; i < iterations; i++) { + long start = System.nanoTime(); + var resp = stub.ping(PingRequest.newBuilder().setClientTime(toTimestamp(start)).build()); + long elapsed = System.nanoTime() - start; + times.add(elapsed); + } + + times.sort(Long::compareTo); + long avg = times.stream().mapToLong(Long::longValue).sum() / iterations; + + System.out.println("=== LIVE PING LATENCY (" + iterations + " iterations) ==="); + System.out.printf(" avg: %,d ns (%.3f ms)%n", avg, avg / 1_000_000.0); + System.out.printf( + " min: %,d ns (%.3f ms)%n", times.getFirst(), times.getFirst() / 1_000_000.0); + System.out.printf( + " max: %,d ns (%.3f ms)%n", times.getLast(), times.getLast() / 1_000_000.0); + System.out.printf( + " p50: %,d ns (%.3f ms)%n", + times.get(iterations / 2), times.get(iterations / 2) / 1_000_000.0); + System.out.printf( + " p99: %,d ns (%.3f ms)%n", + times.get((int) (iterations * 0.99)), times.get((int) (iterations * 0.99)) / 1_000_000.0); + } + + @Test + @Order(2) + void pingWithPayload() { + var stub = BenchmarkServiceGrpc.newBlockingStub(channel); + int iterations = 500; + int payloadSize = 1024; + var payload = ByteString.copyFrom(new byte[payloadSize]); + var times = new ArrayList(iterations); + + // Warmup. + for (int i = 0; i < 10; i++) { + stub.ping(PingRequest.newBuilder().setPayload(payload).build()); + } + + for (int i = 0; i < iterations; i++) { + long start = System.nanoTime(); + stub.ping( + PingRequest.newBuilder().setClientTime(toTimestamp(start)).setPayload(payload).build()); + long elapsed = System.nanoTime() - start; + times.add(elapsed); + } + + times.sort(Long::compareTo); + long avg = times.stream().mapToLong(Long::longValue).sum() / iterations; + + System.out.println("=== LIVE PING WITH 1KB PAYLOAD (" + iterations + " iterations) ==="); + System.out.printf(" avg: %,d ns (%.3f ms)%n", avg, avg / 1_000_000.0); + System.out.printf( + " min: %,d ns (%.3f ms)%n", times.getFirst(), times.getFirst() / 1_000_000.0); + System.out.printf( + " max: %,d ns (%.3f ms)%n", times.getLast(), times.getLast() / 1_000_000.0); + System.out.printf( + " p50: %,d ns (%.3f ms)%n", + times.get(iterations / 2), times.get(iterations / 2) / 1_000_000.0); + System.out.printf( + " p99: %,d ns (%.3f ms)%n", + times.get((int) (iterations * 0.99)), times.get((int) (iterations * 0.99)) / 1_000_000.0); + } + + @Test + @Order(3) + void streamThroughputMinimal() throws Exception { + var stub = BenchmarkServiceGrpc.newStub(channel); + int maxMessages = 10_000; + + var result = runStreamBenchmark(stub, 0, maxMessages, 0); + + System.out.println("=== LIVE STREAM THROUGHPUT (" + maxMessages + " msgs, 0B payload) ==="); + printStreamStats(result); + } + + @Test + @Order(4) + void streamThroughputWithPayload() throws Exception { + var stub = BenchmarkServiceGrpc.newStub(channel); + int maxMessages = 1_000; + int payloadSize = 1024; + + var result = runStreamBenchmark(stub, payloadSize, maxMessages, 0); + + System.out.println("=== LIVE STREAM THROUGHPUT (" + maxMessages + " msgs, 1KB payload) ==="); + printStreamStats(result); + } + + @Test + @Order(5) + void streamFlowControlled() throws Exception { + var stub = BenchmarkServiceGrpc.newStub(channel); + int maxMessages = 10_000; + int batchSize = 100; + + var result = runStreamBenchmark(stub, 0, maxMessages, batchSize); + + System.out.println( + "=== LIVE FLOW-CONTROLLED (" + maxMessages + " msgs, batch=" + batchSize + ") ==="); + printStreamStats(result); + } + + private record StreamResult(BenchmarkStats stats, long clientReceived, long clientElapsedNs) {} + + private StreamResult runStreamBenchmark( + BenchmarkServiceGrpc.BenchmarkServiceStub stub, + int payloadSize, + int maxMessages, + int batchSize) + throws Exception { + + var received = new AtomicLong(0); + var statsRef = new AtomicReference(); + var done = new CountDownLatch(1); + var senderRef = new AtomicReference>(); + + long clientStart = System.nanoTime(); + + var requestObserver = + stub.streamBenchmark( + new StreamObserver<>() { + @Override + public void onNext(BenchmarkResponse response) { + if (response.hasMessage()) { + long count = received.incrementAndGet(); + if (batchSize > 0 && count % batchSize == 0) { + senderRef + .get() + .onNext( + BenchmarkRequest.newBuilder() + .setAck(BenchmarkAck.newBuilder().setCount(batchSize)) + .build()); + } + } else if (response.hasStats()) { + statsRef.set(response.getStats()); + done.countDown(); + } + } + + @Override + public void onError(Throwable t) { + System.err.println("Stream error: " + t.getMessage()); + done.countDown(); + } + + @Override + public void onCompleted() { + done.countDown(); + } + }); + + senderRef.set(requestObserver); + + requestObserver.onNext( + BenchmarkRequest.newBuilder() + .setStart( + StartBenchmark.newBuilder() + .setPayloadSize(payloadSize) + .setMaxMessages(maxMessages) + .setBatchSize(batchSize)) + .build()); + + assertTrue(done.await(60, TimeUnit.SECONDS), "Timed out waiting for stream benchmark"); + + long clientElapsed = System.nanoTime() - clientStart; + + // Half-close client side and give the connection a moment to settle before the next test. + try { + requestObserver.onCompleted(); + } catch (Exception ignored) { + } + Thread.sleep(200); + + var stats = statsRef.get(); + if (stats == null && received.get() > 0) { + // Server sent messages but the stats frame was lost (RST_STREAM from envoy). + // Build approximate stats from client-side measurements. + double clientSec = clientElapsed / 1_000_000_000.0; + stats = + BenchmarkStats.newBuilder() + .setTotalMessages(received.get()) + .setTotalBytes(received.get() * payloadSize) + .setDurationMs(clientElapsed / 1_000_000) + .setMessagesPerSecond(received.get() / clientSec) + .setMegabytesPerSecond(received.get() * payloadSize / clientSec / (1024 * 1024)) + .build(); + System.err.println("(stats estimated from client side — server stats lost to RST_STREAM)"); + } + assertNotNull(stats, "Should have received stats"); + + return new StreamResult(stats, received.get(), clientElapsed); + } + + private void printStreamStats(StreamResult r) { + var s = r.stats(); + double clientSeconds = r.clientElapsedNs() / 1_000_000_000.0; + double clientMps = r.clientReceived() / clientSeconds; + + System.out.printf(" server msgs sent: %,d%n", s.getTotalMessages()); + System.out.printf(" client msgs received: %,d%n", r.clientReceived()); + System.out.printf(" server duration: %,d ms%n", s.getDurationMs()); + System.out.printf(" client duration: %.0f ms%n", r.clientElapsedNs() / 1_000_000.0); + System.out.printf(" server msgs/sec: %,.0f%n", s.getMessagesPerSecond()); + System.out.printf(" client msgs/sec: %,.0f%n", clientMps); + if (s.getTotalBytes() > 0) { + System.out.printf(" server MB/sec: %.2f%n", s.getMegabytesPerSecond()); + System.out.printf(" total bytes: %,d%n", s.getTotalBytes()); + } + } + + private static Timestamp toTimestamp(long nanos) { + return Timestamp.newBuilder() + .setSeconds(nanos / 1_000_000_000L) + .setNanos((int) (nanos % 1_000_000_000L)) + .build(); + } +} diff --git a/core/src/test/java/com/tcn/exile/internal/ProtoConverterTest.java b/core/src/test/java/com/tcn/exile/internal/ProtoConverterTest.java new file mode 100644 index 0000000..a9d7f82 --- /dev/null +++ b/core/src/test/java/com/tcn/exile/internal/ProtoConverterTest.java @@ -0,0 +1,409 @@ +package com.tcn.exile.internal; + +import static org.junit.jupiter.api.Assertions.*; + +import com.tcn.exile.model.*; +import com.tcn.exile.model.event.*; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ProtoConverterTest { + + // ---- Duration ---- + + @Test + void durationRoundTrip() { + var java = Duration.ofSeconds(90, 500_000_000); + var proto = ProtoConverter.fromDuration(java); + assertEquals(90, proto.getSeconds()); + assertEquals(500_000_000, proto.getNanos()); + assertEquals(java, ProtoConverter.toDuration(proto)); + } + + @Test + void durationNullReturnsDefault() { + var proto = ProtoConverter.fromDuration(null); + assertEquals(0, proto.getSeconds()); + assertEquals(Duration.ZERO, ProtoConverter.toDuration(null)); + } + + @Test + void durationDefaultInstanceReturnsZero() { + assertEquals( + Duration.ZERO, + ProtoConverter.toDuration(com.google.protobuf.Duration.getDefaultInstance())); + } + + // ---- Timestamp ---- + + @Test + void instantRoundTrip() { + var java = Instant.parse("2026-01-15T10:30:00Z"); + var proto = ProtoConverter.fromInstant(java); + assertEquals(java.getEpochSecond(), proto.getSeconds()); + assertEquals(java, ProtoConverter.toInstant(proto)); + } + + @Test + void instantNullReturnsNull() { + assertNull(ProtoConverter.toInstant(null)); + var proto = ProtoConverter.fromInstant(null); + assertEquals(0, proto.getSeconds()); + } + + // ---- CallType ---- + + @Test + void callTypeMapping() { + assertEquals( + CallType.INBOUND, + ProtoConverter.toCallType(build.buf.gen.tcnapi.exile.gate.v3.CallType.CALL_TYPE_INBOUND)); + assertEquals( + CallType.OUTBOUND, + ProtoConverter.toCallType(build.buf.gen.tcnapi.exile.gate.v3.CallType.CALL_TYPE_OUTBOUND)); + assertEquals( + CallType.PREVIEW, + ProtoConverter.toCallType(build.buf.gen.tcnapi.exile.gate.v3.CallType.CALL_TYPE_PREVIEW)); + assertEquals( + CallType.MANUAL, + ProtoConverter.toCallType(build.buf.gen.tcnapi.exile.gate.v3.CallType.CALL_TYPE_MANUAL)); + assertEquals( + CallType.MAC, + ProtoConverter.toCallType(build.buf.gen.tcnapi.exile.gate.v3.CallType.CALL_TYPE_MAC)); + assertEquals( + CallType.UNSPECIFIED, + ProtoConverter.toCallType( + build.buf.gen.tcnapi.exile.gate.v3.CallType.CALL_TYPE_UNSPECIFIED)); + } + + // ---- AgentState ---- + + @Test + void agentStateRoundTrip() { + var proto = build.buf.gen.tcnapi.exile.gate.v3.AgentState.AGENT_STATE_READY; + var java = ProtoConverter.toAgentState(proto); + assertEquals(AgentState.READY, java); + assertEquals(proto, ProtoConverter.fromAgentState(java)); + } + + @Test + void agentStateUnknownReturnsUnspecified() { + assertEquals( + AgentState.UNSPECIFIED, + ProtoConverter.toAgentState( + build.buf.gen.tcnapi.exile.gate.v3.AgentState.AGENT_STATE_UNSPECIFIED)); + } + + // ---- Pool ---- + + @Test + void poolRoundTrip() { + var java = new Pool("P-1", "Test Pool", Pool.PoolStatus.READY, 42); + var proto = ProtoConverter.fromPool(java); + assertEquals("P-1", proto.getPoolId()); + assertEquals("Test Pool", proto.getDescription()); + assertEquals(42, proto.getRecordCount()); + assertEquals( + build.buf.gen.tcnapi.exile.gate.v3.Pool.PoolStatus.POOL_STATUS_READY, proto.getStatus()); + + var back = ProtoConverter.toPool(proto); + assertEquals(java, back); + } + + @Test + void poolAllStatuses() { + for (var status : Pool.PoolStatus.values()) { + var java = new Pool("P-1", "desc", status, 0); + var back = ProtoConverter.toPool(ProtoConverter.fromPool(java)); + assertEquals(status, back.status()); + } + } + + // ---- Record ---- + + @Test + void dataRecordRoundTrip() { + var java = new DataRecord("P-1", "R-1", Map.of("name", "John", "age", 30.0)); + var proto = ProtoConverter.fromRecord(java); + assertEquals("P-1", proto.getPoolId()); + assertEquals("R-1", proto.getRecordId()); + assertTrue(proto.hasPayload()); + + var back = ProtoConverter.toRecord(proto); + assertEquals("P-1", back.poolId()); + assertEquals("R-1", back.recordId()); + assertEquals("John", back.payload().get("name")); + assertEquals(30.0, back.payload().get("age")); + } + + // ---- Field ---- + + @Test + void fieldRoundTrip() { + var java = new Field("fname", "John", "P-1", "R-1"); + var proto = ProtoConverter.fromField(java); + var back = ProtoConverter.toField(proto); + assertEquals(java, back); + } + + // ---- Filter ---- + + @Test + void filterRoundTrip() { + for (var op : Filter.Operator.values()) { + var java = new Filter("field1", op, "value1"); + var proto = ProtoConverter.fromFilter(java); + var back = ProtoConverter.toFilter(proto); + assertEquals(java, back); + } + } + + // ---- Struct / Map ---- + + @Test + void structMapRoundTrip() { + var map = Map.of("string", (Object) "hello", "number", 42.0, "bool", true); + var struct = ProtoConverter.mapToStruct(map); + var back = ProtoConverter.structToMap(struct); + assertEquals("hello", back.get("string")); + assertEquals(42.0, back.get("number")); + assertEquals(true, back.get("bool")); + } + + @Test + void structMapHandlesNull() { + var map = ProtoConverter.structToMap(null); + assertTrue(map.isEmpty()); + var struct = ProtoConverter.mapToStruct(null); + assertEquals(com.google.protobuf.Struct.getDefaultInstance(), struct); + } + + @Test + void structMapHandlesNestedStructs() { + var nested = Map.of("inner", (Object) "value"); + var map = Map.of("outer", (Object) nested); + var struct = ProtoConverter.mapToStruct(map); + var back = ProtoConverter.structToMap(struct); + @SuppressWarnings("unchecked") + var innerBack = (Map) back.get("outer"); + assertEquals("value", innerBack.get("inner")); + } + + @Test + void structMapHandlesLists() { + var list = List.of("a", "b", "c"); + var map = Map.of("items", (Object) list); + var struct = ProtoConverter.mapToStruct(map); + var back = ProtoConverter.structToMap(struct); + @SuppressWarnings("unchecked") + var listBack = (List) back.get("items"); + assertEquals(3, listBack.size()); + assertEquals("a", listBack.get(0)); + } + + @Test + void valueToObjectHandlesNull() { + assertNull(ProtoConverter.valueToObject(null)); + var nullValue = + com.google.protobuf.Value.newBuilder() + .setNullValue(com.google.protobuf.NullValue.NULL_VALUE) + .build(); + assertNull(ProtoConverter.valueToObject(nullValue)); + } + + // ---- TaskData ---- + + @Test + void taskDataConversion() { + var protoList = + List.of( + build.buf.gen.tcnapi.exile.gate.v3.TaskData.newBuilder() + .setKey("pool_id") + .setValue(com.google.protobuf.Value.newBuilder().setStringValue("P-1").build()) + .build(), + build.buf.gen.tcnapi.exile.gate.v3.TaskData.newBuilder() + .setKey("count") + .setValue(com.google.protobuf.Value.newBuilder().setNumberValue(5.0).build()) + .build()); + var java = ProtoConverter.toTaskData(protoList); + assertEquals(2, java.size()); + assertEquals("pool_id", java.get(0).key()); + assertEquals("P-1", java.get(0).value()); + assertEquals("count", java.get(1).key()); + assertEquals(5.0, java.get(1).value()); + } + + // ---- Agent ---- + + @Test + void agentConversion() { + var proto = + build.buf.gen.tcnapi.exile.gate.v3.Agent.newBuilder() + .setUserId("U-1") + .setOrgId("O-1") + .setFirstName("John") + .setLastName("Doe") + .setUsername("jdoe") + .setPartnerAgentId("PA-1") + .setCurrentSessionId("S-1") + .setAgentState(build.buf.gen.tcnapi.exile.gate.v3.AgentState.AGENT_STATE_READY) + .setIsLoggedIn(true) + .setIsMuted(false) + .setIsRecording(true) + .setConnectedParty( + build.buf.gen.tcnapi.exile.gate.v3.Agent.ConnectedParty.newBuilder() + .setCallSid(42) + .setCallType(build.buf.gen.tcnapi.exile.gate.v3.CallType.CALL_TYPE_INBOUND) + .setIsInbound(true)) + .build(); + + var java = ProtoConverter.toAgent(proto); + assertEquals("U-1", java.userId()); + assertEquals("O-1", java.orgId()); + assertEquals("John", java.firstName()); + assertEquals("jdoe", java.username()); + assertEquals(AgentState.READY, java.state()); + assertTrue(java.loggedIn()); + assertFalse(java.muted()); + assertTrue(java.recording()); + assertTrue(java.connectedParty().isPresent()); + assertEquals(42, java.connectedParty().get().callSid()); + assertEquals(CallType.INBOUND, java.connectedParty().get().callType()); + } + + @Test + void agentWithoutConnectedParty() { + var proto = + build.buf.gen.tcnapi.exile.gate.v3.Agent.newBuilder() + .setUserId("U-1") + .setOrgId("O-1") + .build(); + var java = ProtoConverter.toAgent(proto); + assertTrue(java.connectedParty().isEmpty()); + } + + // ---- Skill ---- + + @Test + void skillWithProficiency() { + var proto = + build.buf.gen.tcnapi.exile.gate.v3.Skill.newBuilder() + .setSkillId("SK-1") + .setName("Spanish") + .setDescription("Spanish language") + .setProficiency(8) + .build(); + var java = ProtoConverter.toSkill(proto); + assertEquals("SK-1", java.skillId()); + assertEquals("Spanish", java.name()); + assertTrue(java.proficiency().isPresent()); + assertEquals(8, java.proficiency().getAsLong()); + } + + // ---- Events ---- + + @Test + void agentCallEventConversion() { + var proto = + build.buf.gen.tcnapi.exile.gate.v3.AgentCall.newBuilder() + .setAgentCallSid(1) + .setCallSid(2) + .setCallType(build.buf.gen.tcnapi.exile.gate.v3.CallType.CALL_TYPE_OUTBOUND) + .setOrgId("O-1") + .setUserId("U-1") + .setPartnerAgentId("PA-1") + .setTalkDuration(com.google.protobuf.Duration.newBuilder().setSeconds(120)) + .setCreateTime(com.google.protobuf.Timestamp.newBuilder().setSeconds(1700000000)) + .addTaskData( + build.buf.gen.tcnapi.exile.gate.v3.TaskData.newBuilder() + .setKey("pool_id") + .setValue(com.google.protobuf.Value.newBuilder().setStringValue("P-1"))) + .build(); + + var java = ProtoConverter.toAgentCallEvent(proto); + assertEquals(1, java.agentCallSid()); + assertEquals(2, java.callSid()); + assertEquals(CallType.OUTBOUND, java.callType()); + assertEquals("O-1", java.orgId()); + assertEquals("PA-1", java.partnerAgentId()); + assertEquals(Duration.ofSeconds(120), java.talkDuration()); + assertNotNull(java.createTime()); + assertEquals(1, java.taskData().size()); + assertEquals("pool_id", java.taskData().get(0).key()); + } + + @Test + void telephonyResultEventConversion() { + var proto = + build.buf.gen.tcnapi.exile.gate.v3.TelephonyResult.newBuilder() + .setCallSid(42) + .setCallType(build.buf.gen.tcnapi.exile.gate.v3.CallType.CALL_TYPE_INBOUND) + .setOrgId("O-1") + .setCallerId("+15551234567") + .setPhoneNumber("+15559876543") + .setPoolId("P-1") + .setRecordId("R-1") + .setStatus( + build.buf.gen.tcnapi.exile.gate.v3.TelephonyStatus.TELEPHONY_STATUS_COMPLETED) + .setOutcome( + build.buf.gen.tcnapi.exile.gate.v3.TelephonyOutcome.newBuilder() + .setCategory( + build.buf.gen.tcnapi.exile.gate.v3.TelephonyOutcome.Category + .CATEGORY_ANSWERED) + .setDetail( + build.buf.gen.tcnapi.exile.gate.v3.TelephonyOutcome.Detail.DETAIL_GENERIC)) + .build(); + + var java = ProtoConverter.toTelephonyResultEvent(proto); + assertEquals(42, java.callSid()); + assertEquals(CallType.INBOUND, java.callType()); + assertEquals("+15551234567", java.callerId()); + assertEquals("TELEPHONY_STATUS_COMPLETED", java.status()); + assertEquals("CATEGORY_ANSWERED", java.outcomeCategory()); + assertEquals("DETAIL_GENERIC", java.outcomeDetail()); + } + + @Test + void callRecordingEventConversion() { + var proto = + build.buf.gen.tcnapi.exile.gate.v3.CallRecording.newBuilder() + .setRecordingId("REC-1") + .setOrgId("O-1") + .setCallSid(42) + .setCallType(build.buf.gen.tcnapi.exile.gate.v3.CallType.CALL_TYPE_INBOUND) + .setDuration(com.google.protobuf.Duration.newBuilder().setSeconds(300)) + .setRecordingType( + build.buf.gen.tcnapi.exile.gate.v3.CallRecording.RecordingType.RECORDING_TYPE_TCN) + .build(); + + var java = ProtoConverter.toCallRecordingEvent(proto); + assertEquals("REC-1", java.recordingId()); + assertEquals(42, java.callSid()); + assertEquals(Duration.ofSeconds(300), java.duration()); + assertEquals("RECORDING_TYPE_TCN", java.recordingType()); + } + + @Test + void taskEventConversion() { + var proto = + build.buf.gen.tcnapi.exile.gate.v3.ExileTask.newBuilder() + .setTaskSid(1) + .setTaskGroupSid(2) + .setOrgId("O-1") + .setPoolId("P-1") + .setRecordId("R-1") + .setAttempts(3) + .setStatus(build.buf.gen.tcnapi.exile.gate.v3.ExileTask.TaskStatus.TASK_STATUS_RUNNING) + .build(); + + var java = ProtoConverter.toTaskEvent(proto); + assertEquals(1, java.taskSid()); + assertEquals(2, java.taskGroupSid()); + assertEquals("P-1", java.poolId()); + assertEquals(3, java.attempts()); + assertEquals("TASK_STATUS_RUNNING", java.status()); + } +} diff --git a/core/src/test/java/com/tcn/exile/internal/StreamBenchmark.java b/core/src/test/java/com/tcn/exile/internal/StreamBenchmark.java new file mode 100644 index 0000000..424677a --- /dev/null +++ b/core/src/test/java/com/tcn/exile/internal/StreamBenchmark.java @@ -0,0 +1,450 @@ +package com.tcn.exile.internal; + +import static org.junit.jupiter.api.Assertions.*; + +import build.buf.gen.tcnapi.exile.gate.v3.*; +import com.google.protobuf.ByteString; +import com.google.protobuf.Timestamp; +import io.grpc.Server; +import io.grpc.inprocess.InProcessChannelBuilder; +import io.grpc.inprocess.InProcessServerBuilder; +import io.grpc.stub.StreamObserver; +import java.util.ArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Benchmarks using the BenchmarkService proto for streaming throughput and ping latency. Uses an + * in-process server that implements BenchmarkService to isolate gRPC overhead. + */ +class StreamBenchmark { + + private static final String SERVER_NAME = "stream-benchmark"; + private Server server; + + @BeforeEach + void setUp() throws Exception { + server = + InProcessServerBuilder.forName(SERVER_NAME) + .directExecutor() + .addService(new InProcessBenchmarkService()) + .build() + .start(); + } + + @AfterEach + void tearDown() throws Exception { + if (server != null) { + server.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); + } + } + + @Test + void benchmarkUnlimitedThroughput() throws Exception { + var channel = InProcessChannelBuilder.forName(SERVER_NAME).directExecutor().build(); + var stub = BenchmarkServiceGrpc.newStub(channel); + + int maxMessages = 100_000; + var received = new AtomicLong(0); + var statsRef = new AtomicReference(); + var done = new CountDownLatch(1); + + var requestObserver = + stub.streamBenchmark( + new StreamObserver<>() { + @Override + public void onNext(BenchmarkResponse response) { + if (response.hasMessage()) { + received.incrementAndGet(); + } else if (response.hasStats()) { + statsRef.set(response.getStats()); + done.countDown(); + } + } + + @Override + public void onError(Throwable t) { + t.printStackTrace(); + done.countDown(); + } + + @Override + public void onCompleted() { + done.countDown(); + } + }); + + // Start with no flow control, minimal payload, limited messages. + requestObserver.onNext( + BenchmarkRequest.newBuilder() + .setStart( + StartBenchmark.newBuilder() + .setPayloadSize(0) + .setMaxMessages(maxMessages) + .setBatchSize(0)) + .build()); + + assertTrue(done.await(30, TimeUnit.SECONDS), "Timed out waiting for benchmark to complete"); + + var stats = statsRef.get(); + assertNotNull(stats, "Should have received stats"); + + System.out.println( + "=== UNLIMITED THROUGHPUT (" + maxMessages + " messages, 0 byte payload) ==="); + System.out.printf(" server msgs sent: %,d%n", stats.getTotalMessages()); + System.out.printf(" client msgs received: %,d%n", received.get()); + System.out.printf(" duration: %,d ms%n", stats.getDurationMs()); + System.out.printf(" msgs/sec (server): %,.0f%n", stats.getMessagesPerSecond()); + System.out.printf(" MB/sec (server): %.2f%n", stats.getMegabytesPerSecond()); + + requestObserver.onCompleted(); + channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); + } + + @Test + void benchmarkWithPayload() throws Exception { + var channel = InProcessChannelBuilder.forName(SERVER_NAME).directExecutor().build(); + var stub = BenchmarkServiceGrpc.newStub(channel); + + int maxMessages = 50_000; + int payloadSize = 1024; // 1KB per message + var received = new AtomicLong(0); + var statsRef = new AtomicReference(); + var done = new CountDownLatch(1); + + var requestObserver = + stub.streamBenchmark( + new StreamObserver<>() { + @Override + public void onNext(BenchmarkResponse response) { + if (response.hasMessage()) { + received.incrementAndGet(); + } else if (response.hasStats()) { + statsRef.set(response.getStats()); + done.countDown(); + } + } + + @Override + public void onError(Throwable t) { + t.printStackTrace(); + done.countDown(); + } + + @Override + public void onCompleted() { + done.countDown(); + } + }); + + requestObserver.onNext( + BenchmarkRequest.newBuilder() + .setStart( + StartBenchmark.newBuilder() + .setPayloadSize(payloadSize) + .setMaxMessages(maxMessages) + .setBatchSize(0)) + .build()); + + assertTrue(done.await(30, TimeUnit.SECONDS), "Timed out"); + + var stats = statsRef.get(); + assertNotNull(stats); + + System.out.println( + "=== THROUGHPUT WITH PAYLOAD (" + + maxMessages + + " messages, " + + payloadSize + + " byte payload) ==="); + System.out.printf(" server msgs sent: %,d%n", stats.getTotalMessages()); + System.out.printf(" client msgs received: %,d%n", received.get()); + System.out.printf(" duration: %,d ms%n", stats.getDurationMs()); + System.out.printf(" msgs/sec (server): %,.0f%n", stats.getMessagesPerSecond()); + System.out.printf(" MB/sec (server): %.2f%n", stats.getMegabytesPerSecond()); + System.out.printf(" total bytes: %,d%n", stats.getTotalBytes()); + + requestObserver.onCompleted(); + channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); + } + + @Test + void benchmarkFlowControlled() throws Exception { + var channel = InProcessChannelBuilder.forName(SERVER_NAME).directExecutor().build(); + var stub = BenchmarkServiceGrpc.newStub(channel); + + int maxMessages = 100_000; + int batchSize = 100; + var received = new AtomicLong(0); + var statsRef = new AtomicReference(); + var done = new CountDownLatch(1); + var senderRef = new AtomicReference>(); + + var requestObserver = + stub.streamBenchmark( + new StreamObserver<>() { + @Override + public void onNext(BenchmarkResponse response) { + if (response.hasMessage()) { + long count = received.incrementAndGet(); + // Send ack every batchSize messages — drives the next batch immediately. + if (count % batchSize == 0) { + senderRef + .get() + .onNext( + BenchmarkRequest.newBuilder() + .setAck(BenchmarkAck.newBuilder().setCount(batchSize)) + .build()); + } + } else if (response.hasStats()) { + statsRef.set(response.getStats()); + done.countDown(); + } + } + + @Override + public void onError(Throwable t) { + t.printStackTrace(); + done.countDown(); + } + + @Override + public void onCompleted() { + done.countDown(); + } + }); + + senderRef.set(requestObserver); + + requestObserver.onNext( + BenchmarkRequest.newBuilder() + .setStart( + StartBenchmark.newBuilder() + .setPayloadSize(0) + .setMaxMessages(maxMessages) + .setBatchSize(batchSize)) + .build()); + + assertTrue(done.await(30, TimeUnit.SECONDS), "Timed out waiting for flow-controlled benchmark"); + + var stats = statsRef.get(); + assertNotNull(stats); + + System.out.println( + "=== FLOW-CONTROLLED (" + maxMessages + " messages, batch_size=" + batchSize + ") ==="); + System.out.printf(" server msgs sent: %,d%n", stats.getTotalMessages()); + System.out.printf(" client msgs received: %,d%n", received.get()); + System.out.printf(" duration: %,d ms%n", stats.getDurationMs()); + System.out.printf(" msgs/sec (server): %,.0f%n", stats.getMessagesPerSecond()); + + requestObserver.onCompleted(); + channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); + } + + @Test + void benchmarkPingLatency() throws Exception { + var channel = InProcessChannelBuilder.forName(SERVER_NAME).directExecutor().build(); + var stub = BenchmarkServiceGrpc.newBlockingStub(channel); + + int iterations = 10_000; + var times = new ArrayList(iterations); + + // Warmup. + for (int i = 0; i < 100; i++) { + stub.ping(PingRequest.newBuilder().build()); + } + + for (int i = 0; i < iterations; i++) { + long now = System.nanoTime(); + var ts = + Timestamp.newBuilder() + .setSeconds(now / 1_000_000_000L) + .setNanos((int) (now % 1_000_000_000L)); + long start = System.nanoTime(); + var resp = stub.ping(PingRequest.newBuilder().setClientTime(ts).build()); + long elapsed = System.nanoTime() - start; + times.add(elapsed); + } + + channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); + + times.sort(Long::compareTo); + long avg = times.stream().mapToLong(Long::longValue).sum() / iterations; + long min = times.getFirst(); + long max = times.getLast(); + long p50 = times.get(iterations / 2); + long p99 = times.get((int) (iterations * 0.99)); + + System.out.println("=== PING LATENCY (" + iterations + " iterations) ==="); + System.out.printf(" avg: %,d ns (%.3f us)%n", avg, avg / 1_000.0); + System.out.printf(" min: %,d ns (%.3f us)%n", min, min / 1_000.0); + System.out.printf(" max: %,d ns (%.3f us)%n", max, max / 1_000.0); + System.out.printf(" p50: %,d ns (%.3f us)%n", p50, p50 / 1_000.0); + System.out.printf(" p99: %,d ns (%.3f us)%n", p99, p99 / 1_000.0); + } + + @Test + void benchmarkPingWithPayload() throws Exception { + var channel = InProcessChannelBuilder.forName(SERVER_NAME).directExecutor().build(); + var stub = BenchmarkServiceGrpc.newBlockingStub(channel); + + int iterations = 5_000; + byte[] payload = new byte[1024]; // 1KB payload + var bsPayload = ByteString.copyFrom(payload); + var times = new ArrayList(iterations); + + // Warmup. + for (int i = 0; i < 100; i++) { + stub.ping(PingRequest.newBuilder().setPayload(bsPayload).build()); + } + + for (int i = 0; i < iterations; i++) { + long start = System.nanoTime(); + stub.ping(PingRequest.newBuilder().setPayload(bsPayload).build()); + long elapsed = System.nanoTime() - start; + times.add(elapsed); + } + + channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); + + times.sort(Long::compareTo); + long avg = times.stream().mapToLong(Long::longValue).sum() / iterations; + long min = times.getFirst(); + long max = times.getLast(); + long p50 = times.get(iterations / 2); + long p99 = times.get((int) (iterations * 0.99)); + + System.out.println("=== PING WITH 1KB PAYLOAD (" + iterations + " iterations) ==="); + System.out.printf(" avg: %,d ns (%.3f us)%n", avg, avg / 1_000.0); + System.out.printf(" min: %,d ns (%.3f us)%n", min, min / 1_000.0); + System.out.printf(" max: %,d ns (%.3f us)%n", max, max / 1_000.0); + System.out.printf(" p50: %,d ns (%.3f us)%n", p50, p50 / 1_000.0); + System.out.printf(" p99: %,d ns (%.3f us)%n", p99, p99 / 1_000.0); + } + + /** + * In-process BenchmarkService that implements the streaming and ping protocols for local + * benchmarking. + */ + static class InProcessBenchmarkService extends BenchmarkServiceGrpc.BenchmarkServiceImplBase { + + @Override + public StreamObserver streamBenchmark( + StreamObserver responseObserver) { + return new StreamObserver<>() { + int payloadSize; + long maxMessages; + int batchSize; + byte[] payload; + long totalMessages; + long totalBytes; + long startNanos; + boolean stopped; + + @Override + public void onNext(BenchmarkRequest request) { + if (request.hasStart()) { + var start = request.getStart(); + payloadSize = start.getPayloadSize(); + maxMessages = start.getMaxMessages(); + batchSize = start.getBatchSize(); + payload = new byte[payloadSize]; + startNanos = System.nanoTime(); + stopped = false; + + if (batchSize > 0) { + sendBatch(batchSize); + } else { + // Unlimited: send all at once. + long toSend = maxMessages > 0 ? maxMessages : Long.MAX_VALUE; + for (long i = 0; i < toSend && !stopped; i++) { + sendOne(); + } + sendStats(); + } + } else if (request.hasAck()) { + // Flow-controlled: send next batch. + if (!stopped && (maxMessages == 0 || totalMessages < maxMessages)) { + sendBatch(batchSize); + } + } else if (request.hasStop()) { + stopped = true; + sendStats(); + } + } + + private void sendBatch(int count) { + for (int i = 0; i < count && (maxMessages == 0 || totalMessages < maxMessages); i++) { + sendOne(); + } + if (maxMessages > 0 && totalMessages >= maxMessages) { + sendStats(); + } + } + + private void sendOne() { + var now = System.nanoTime(); + responseObserver.onNext( + BenchmarkResponse.newBuilder() + .setMessage( + BenchmarkMessage.newBuilder() + .setSequence(totalMessages) + .setSendTime( + Timestamp.newBuilder() + .setSeconds(now / 1_000_000_000L) + .setNanos((int) (now % 1_000_000_000L))) + .setData(ByteString.copyFrom(payload))) + .build()); + totalMessages++; + totalBytes += payloadSize; + } + + private void sendStats() { + long elapsed = System.nanoTime() - startNanos; + double seconds = elapsed / 1_000_000_000.0; + double mps = seconds > 0 ? totalMessages / seconds : 0; + double mbps = seconds > 0 ? totalBytes / seconds / (1024 * 1024) : 0; + + responseObserver.onNext( + BenchmarkResponse.newBuilder() + .setStats( + BenchmarkStats.newBuilder() + .setTotalMessages(totalMessages) + .setTotalBytes(totalBytes) + .setDurationMs(elapsed / 1_000_000) + .setMessagesPerSecond(mps) + .setMegabytesPerSecond(mbps)) + .build()); + } + + @Override + public void onError(Throwable t) {} + + @Override + public void onCompleted() { + responseObserver.onCompleted(); + } + }; + } + + @Override + public void ping(PingRequest request, StreamObserver responseObserver) { + long now = System.nanoTime(); + responseObserver.onNext( + PingResponse.newBuilder() + .setClientTime(request.getClientTime()) + .setServerTime( + Timestamp.newBuilder() + .setSeconds(now / 1_000_000_000L) + .setNanos((int) (now % 1_000_000_000L))) + .setPayload(request.getPayload()) + .build()); + responseObserver.onCompleted(); + } + } +} diff --git a/core/src/test/java/com/tcn/exile/model/ModelTest.java b/core/src/test/java/com/tcn/exile/model/ModelTest.java new file mode 100644 index 0000000..f45e4c0 --- /dev/null +++ b/core/src/test/java/com/tcn/exile/model/ModelTest.java @@ -0,0 +1,66 @@ +package com.tcn.exile.model; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ModelTest { + + @Test + void pageHasMore() { + var page = new Page<>(List.of("a", "b"), "next-token"); + assertTrue(page.hasMore()); + assertEquals(2, page.items().size()); + } + + @Test + void pageNoMore() { + var page = new Page<>(List.of("a"), ""); + assertFalse(page.hasMore()); + } + + @Test + void pageNullToken() { + var page = new Page<>(List.of("a"), null); + assertFalse(page.hasMore()); + } + + @Test + void dataRecordEquality() { + var r1 = new DataRecord("P-1", "R-1", Map.of("k", "v")); + var r2 = new DataRecord("P-1", "R-1", Map.of("k", "v")); + assertEquals(r1, r2); + } + + @Test + void poolEquality() { + var p1 = new Pool("P-1", "desc", Pool.PoolStatus.READY, 10); + var p2 = new Pool("P-1", "desc", Pool.PoolStatus.READY, 10); + assertEquals(p1, p2); + assertNotEquals(p1, new Pool("P-2", "desc", Pool.PoolStatus.READY, 10)); + } + + @Test + void filterOperatorValues() { + assertEquals(8, Filter.Operator.values().length); + } + + @Test + void callTypeValues() { + assertEquals(6, CallType.values().length); + } + + @Test + void agentStateValues() { + assertEquals(19, AgentState.values().length); + } + + @Test + void taskDataRecord() { + var td = new TaskData("key", "value"); + assertEquals("key", td.key()); + assertEquals("value", td.value()); + } +} diff --git a/demo/build.gradle b/demo/build.gradle index 6b430f1..b30cc44 100644 --- a/demo/build.gradle +++ b/demo/build.gradle @@ -1,112 +1,22 @@ plugins { + id("java") + id("application") id("com.github.johnrengelman.shadow") - id("io.micronaut.application") - id("io.micronaut.aot") -} - -repositories { - mavenCentral() -} - -dependencies { - // Add Micronaut Platform BOM - implementation(platform("io.micronaut.platform:micronaut-platform:${micronautVersion}")) - - annotationProcessor("io.micronaut:micronaut-http-validation") - annotationProcessor("io.micronaut.serde:micronaut-serde-processor") - annotationProcessor("io.micronaut.validation:micronaut-validation-processor") - - implementation("io.micronaut.serde:micronaut-serde-jackson") - implementation("io.micronaut:micronaut-http-client") - implementation("io.micronaut:micronaut-http-server-netty") - implementation("io.micronaut.validation:micronaut-validation") - implementation("org.bouncycastle:bcpkix-jdk18on:1.78.1") - - implementation("io.grpc:grpc-core:${grpcVersion}") - implementation("io.grpc:grpc-protobuf:${grpcVersion}") - implementation("io.grpc:grpc-services:${grpcVersion}") - - // Management and monitoring - implementation("io.micronaut:micronaut-management") - implementation("io.micronaut.micrometer:micronaut-micrometer-core") - - implementation('ch.qos.logback:logback-classic:1.5.17') - implementation('ch.qos.logback:logback-core:1.5.17') - implementation('org.slf4j:slf4j-api:2.0.17') - - // Import multitenant module - implementation(project(":core")) - implementation(project(":logback-ext")) - - // Reactor for reactive streams - implementation("io.projectreactor:reactor-core:3.5.12") - - implementation("jakarta.annotation:jakarta.annotation-api") - implementation("jakarta.validation:jakarta.validation-api") - - implementation("ch.qos.logback:logback-classic") - - // Add jansi for colored console logging - implementation("org.fusesource.jansi:jansi:2.4.1") - - // Add directory watcher dependency - implementation("io.methvin:directory-watcher") - - // Add snakeyaml for YAML configuration parsing - runtimeOnly("org.yaml:snakeyaml") - - // Add Micronaut OpenAPI and Swagger UI support - implementation("io.micronaut.openapi:micronaut-openapi") - - testImplementation("io.micronaut:micronaut-http-client") - testImplementation("org.mockito:mockito-core:5.15.2") - - developmentOnly("io.micronaut.controlpanel:micronaut-control-panel-management") - developmentOnly("io.micronaut.controlpanel:micronaut-control-panel-ui") } application { - mainClass.set("com.tcn.exile.demo.single.Application") + mainClass = 'com.tcn.exile.demo.Main' } -java { - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 +shadowJar { + mergeServiceFiles() } -tasks.withType(JavaCompile) { - options.encoding = "UTF-8" - options.compilerArgs += [ - "-Amicronaut.openapi.views.spec=swagger-ui.enabled=true" - ] - options.compilerArgs += ["-Xlint:unchecked", "-Xlint:deprecation"] -} - -tasks.withType(Test) { - useJUnitPlatform() -} - -micronaut { - runtime("netty") - testRuntime("junit5") - processing { - incremental(true) - annotations("com.tcn.*") - } - aot { - optimizeServiceLoading = false - convertYamlToJava = false - precomputeOperations = true - cacheEnvironment = true - optimizeClassLoading = true - deduceEnvironment = true - optimizeNetty = true - replaceLogbackXml = true - } -} +dependencies { + implementation(project(':core')) + implementation(project(':config')) + implementation(project(':logback-ext')) -jar { - manifest { - attributes 'Implementation-Version': project.version - } + // Logging runtime + runtimeOnly('ch.qos.logback:logback-classic:1.5.17') } diff --git a/demo/src/main/java/com/tcn/exile/demo/DemoPlugin.java b/demo/src/main/java/com/tcn/exile/demo/DemoPlugin.java new file mode 100644 index 0000000..6b66460 --- /dev/null +++ b/demo/src/main/java/com/tcn/exile/demo/DemoPlugin.java @@ -0,0 +1,174 @@ +package com.tcn.exile.demo; + +import com.tcn.exile.handler.PluginBase; +import com.tcn.exile.model.*; +import com.tcn.exile.model.event.*; +import com.tcn.exile.service.ConfigService; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Demo plugin that validates config, returns stub job data, and logs events. Extends {@link + * PluginBase} which provides default implementations for logs, diagnostics, info, shutdown, and log + * level control. + */ +public class DemoPlugin extends PluginBase { + + private static final Logger log = LoggerFactory.getLogger(DemoPlugin.class); + private volatile boolean configured = false; + + // --- Config --- + + @Override + public boolean onConfig(ConfigService.ClientConfiguration config) { + log.info( + "Plugin received config (org={}, configName={}, payloadKeys={})", + config.orgId(), + config.configName(), + config.configPayload() != null ? config.configPayload().keySet() : "null"); + // In a real plugin, you would parse config.configPayload() for DB credentials, + // initialize the connection pool, and return false if it fails. + if (config.configPayload() == null || config.configPayload().isEmpty()) { + configured = false; + return false; + } + configured = true; + return true; + } + + @Override + public String pluginName() { + return "demo"; + } + + // --- Jobs --- + + @Override + public List listPools() { + log.info("listPools called"); + return List.of( + new Pool("pool-1", "Demo Campaign A", Pool.PoolStatus.READY, 150), + new Pool("pool-2", "Demo Campaign B", Pool.PoolStatus.NOT_READY, 0)); + } + + @Override + public Pool getPoolStatus(String poolId) { + log.info("getPoolStatus called for {}", poolId); + return new Pool(poolId, "Demo Pool", Pool.PoolStatus.READY, 42); + } + + @Override + public Page getPoolRecords(String poolId, String pageToken, int pageSize) { + log.info("getPoolRecords called for pool={} page={} size={}", poolId, pageToken, pageSize); + return new Page<>( + List.of( + new DataRecord(poolId, "rec-1", Map.of("name", "John Doe", "phone", "+15551234567")), + new DataRecord(poolId, "rec-2", Map.of("name", "Jane Smith", "phone", "+15559876543"))), + ""); + } + + @Override + public Page searchRecords(List filters, String pageToken, int pageSize) { + log.info("searchRecords called with {} filters", filters.size()); + return new Page<>( + List.of( + new DataRecord("pool-1", "rec-1", Map.of("name", "Search Result", "matched", true))), + ""); + } + + @Override + public List getRecordFields(String poolId, String recordId, List fieldNames) { + log.info("getRecordFields called for {}/{} fields={}", poolId, recordId, fieldNames); + return List.of( + new Field("first_name", "John", poolId, recordId), + new Field("last_name", "Doe", poolId, recordId), + new Field("balance", "1250.00", poolId, recordId)); + } + + @Override + public boolean setRecordFields(String poolId, String recordId, List fields) { + log.info("setRecordFields called for {}/{} with {} fields", poolId, recordId, fields.size()); + return true; + } + + @Override + public String createPayment(String poolId, String recordId, Map paymentData) { + log.info("createPayment called for {}/{}: {}", poolId, recordId, paymentData); + return "PAY-" + System.currentTimeMillis(); + } + + @Override + public DataRecord popAccount(String poolId, String recordId) { + log.info("popAccount called for {}/{}", poolId, recordId); + return new DataRecord(poolId, recordId, Map.of("name", "Popped Account", "status", "active")); + } + + @Override + public Map executeLogic(String logicName, Map parameters) { + log.info("executeLogic called: {} params={}", logicName, parameters); + return Map.of("result", "ok", "logic", logicName); + } + + // --- Events --- + + @Override + public void onAgentCall(AgentCallEvent event) { + log.info( + "AgentCall: callSid={} type={} agent={} talk={}s", + event.callSid(), + event.callType(), + event.partnerAgentId(), + event.talkDuration().toSeconds()); + } + + @Override + public void onTelephonyResult(TelephonyResultEvent event) { + log.info( + "TelephonyResult: callSid={} type={} status={} outcome={} phone={}", + event.callSid(), + event.callType(), + event.status(), + event.outcomeCategory(), + event.phoneNumber()); + } + + @Override + public void onAgentResponse(AgentResponseEvent event) { + log.info( + "AgentResponse: callSid={} agent={} key={} value={}", + event.callSid(), + event.partnerAgentId(), + event.responseKey(), + event.responseValue()); + } + + @Override + public void onTransferInstance(TransferInstanceEvent event) { + log.info( + "TransferInstance: id={} type={} result={}", + event.transferInstanceId(), + event.transferType(), + event.transferResult()); + } + + @Override + public void onCallRecording(CallRecordingEvent event) { + log.info( + "CallRecording: id={} callSid={} duration={}s", + event.recordingId(), + event.callSid(), + event.duration().toSeconds()); + } + + @Override + public void onTask(TaskEvent event) { + log.info( + "Task: sid={} pool={} record={} status={}", + event.taskSid(), + event.poolId(), + event.recordId(), + event.status()); + } +} diff --git a/demo/src/main/java/com/tcn/exile/demo/Main.java b/demo/src/main/java/com/tcn/exile/demo/Main.java new file mode 100644 index 0000000..34f4fca --- /dev/null +++ b/demo/src/main/java/com/tcn/exile/demo/Main.java @@ -0,0 +1,90 @@ +package com.tcn.exile.demo; + +import com.tcn.exile.config.ExileClientManager; +import java.nio.file.Path; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Demo application showing how to use the sati client library. + * + *

This is a minimal, plain-Java application that: + * + *

    + *
  • Watches a config file for gate credentials + *
  • Connects to the gate server via the v3 WorkStream protocol + *
  • Handles jobs with stub responses (DemoJobHandler) + *
  • Logs all received events (DemoEventHandler) + *
  • Exposes /health and /status HTTP endpoints + *
+ * + *

Usage: + * + *

+ *   # Place a config file (Base64-encoded JSON with certs):
+ *   echo "$BASE64_CONFIG" > workdir/config/com.tcn.exiles.sati.config.cfg
+ *
+ *   # Run the demo:
+ *   ./gradlew :demo:run
+ *
+ *   # Or with shadow jar:
+ *   java -jar demo/build/libs/demo-all.jar
+ *
+ *   # Check status:
+ *   curl http://localhost:8080/health
+ *   curl http://localhost:8080/status
+ * 
+ */ +public class Main { + + static final String VERSION = "3.0.0-demo"; + private static final Logger log = LoggerFactory.getLogger(Main.class); + + public static void main(String[] args) throws Exception { + int port = Integer.parseInt(System.getenv().getOrDefault("PORT", "8080")); + String configDir = System.getenv().getOrDefault("CONFIG_DIR", ""); + + log.info("Starting sati-demo v{}", VERSION); + + // Build the client manager. It watches for config file changes, + // creates/destroys the ExileClient automatically, and rotates + // certificates before they expire. + var builder = + ExileClientManager.builder() + .clientName("sati-demo") + .clientVersion(VERSION) + .maxConcurrency(5) + .plugin(new DemoPlugin()); + + if (!configDir.isEmpty()) { + builder.watchDirs(List.of(Path.of(configDir))); + } + + var manager = builder.build(); + + // Start the status HTTP server. + var statusServer = new StatusServer(manager, port); + statusServer.start(); + + // Start watching for config and managing the client. + manager.start(); + + // Register shutdown hook for graceful cleanup. + Runtime.getRuntime() + .addShutdownHook( + new Thread( + () -> { + log.info("Shutting down..."); + manager.stop(); + statusServer.close(); + }, + "shutdown")); + + log.info("sati-demo running on port {} — waiting for config file", port); + log.info("Place config at: workdir/config/com.tcn.exiles.sati.config.cfg"); + + // Keep main thread alive. + Thread.currentThread().join(); + } +} diff --git a/demo/src/main/java/com/tcn/exile/demo/StatusServer.java b/demo/src/main/java/com/tcn/exile/demo/StatusServer.java new file mode 100644 index 0000000..75ffb45 --- /dev/null +++ b/demo/src/main/java/com/tcn/exile/demo/StatusServer.java @@ -0,0 +1,168 @@ +package com.tcn.exile.demo; + +import com.sun.net.httpserver.HttpServer; +import com.tcn.exile.StreamStatus; +import com.tcn.exile.config.ExileClientManager; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.Executors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Lightweight HTTP server for health checks and status inspection. Uses Java's built-in HttpServer + * — no framework dependency. + * + *

Endpoints: + * + *

    + *
  • {@code GET /health} — returns "ok" or "unhealthy" + *
  • {@code GET /status} — returns JSON with stream status details + *
+ */ +public class StatusServer implements AutoCloseable { + + private static final Logger log = LoggerFactory.getLogger(StatusServer.class); + + private final HttpServer server; + private final ExileClientManager manager; + + public StatusServer(ExileClientManager manager, int port) throws IOException { + this.manager = manager; + this.server = HttpServer.create(new InetSocketAddress(port), 0); + this.server.setExecutor(Executors.newVirtualThreadPerTaskExecutor()); + + server.createContext( + "/health", + exchange -> { + var status = manager.streamStatus(); + boolean healthy = status != null && status.isHealthy(); + int code = healthy ? 200 : 503; + var body = healthy ? "ok\n" : "unhealthy\n"; + exchange.sendResponseHeaders(code, body.length()); + try (var os = exchange.getResponseBody()) { + os.write(body.getBytes(StandardCharsets.UTF_8)); + } + }); + + server.createContext( + "/status", + exchange -> { + var status = manager.streamStatus(); + var json = formatStatus(status); + var body = json.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + try (var os = exchange.getResponseBody()) { + os.write(body); + } + }); + + server.createContext( + "/logs", + exchange -> { + var appender = com.tcn.exile.memlogger.MemoryAppenderInstance.getInstance(); + var sb = new StringBuilder(); + sb.append("{\"appender_exists\":"); + sb.append(appender != null); + if (appender != null) { + var events = appender.getEventsWithTimestamps(); + sb.append(",\"event_count\":").append(events.size()); + sb.append(",\"entries\":["); + int limit = Math.min(events.size(), 50); + for (int i = 0; i < limit; i++) { + if (i > 0) sb.append(","); + var e = events.get(i); + sb.append("{\"ts\":").append(e.timestamp); + sb.append(",\"msg\":\"") + .append( + e.message.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n")) + .append("\"}"); + } + sb.append("]"); + } + sb.append("}"); + var body = sb.toString().getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + try (var os = exchange.getResponseBody()) { + os.write(body); + } + }); + + server.createContext( + "/", + exchange -> { + var body = + """ + + + sati-demo + +

sati-demo

+
    +
  • /health — health check
  • +
  • /status — stream status (JSON)
  • +
  • /logs — in-memory log buffer (JSON)
  • +
+ + + """ + .getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "text/html"); + exchange.sendResponseHeaders(200, body.length); + try (var os = exchange.getResponseBody()) { + os.write(body); + } + }); + } + + public void start() { + server.start(); + log.info("Status server listening on port {}", server.getAddress().getPort()); + } + + @Override + public void close() { + server.stop(1); + log.info("Status server stopped"); + } + + private String formatStatus(StreamStatus status) { + if (status == null) { + return """ + {"phase":"NO_CLIENT","healthy":false}"""; + } + // Simple JSON without any library. + return String.format( + """ + { + "phase": "%s", + "healthy": %s, + "client_id": %s, + "connected_since": %s, + "last_disconnect": %s, + "last_error": %s, + "inflight": %d, + "completed_total": %d, + "failed_total": %d, + "reconnect_attempts": %d + }""", + status.phase(), + status.isHealthy(), + jsonString(status.clientId()), + jsonString(status.connectedSince()), + jsonString(status.lastDisconnect()), + jsonString(status.lastError()), + status.inflight(), + status.completedTotal(), + status.failedTotal(), + status.reconnectAttempts()); + } + + private static String jsonString(Object value) { + if (value == null) return "null"; + return "\"" + value.toString().replace("\"", "\\\"") + "\""; + } +} diff --git a/demo/src/main/java/com/tcn/exile/demo/single/AdminController.java b/demo/src/main/java/com/tcn/exile/demo/single/AdminController.java deleted file mode 100644 index 29301c1..0000000 --- a/demo/src/main/java/com/tcn/exile/demo/single/AdminController.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * (C) 2017-2025 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.demo.single; - -import com.tcn.exile.config.DiagnosticsService; -import com.tcn.exile.models.DiagnosticsResult; -import io.micronaut.context.annotation.Requires; -import io.micronaut.context.env.Environment; -import io.micronaut.http.HttpResponse; -import io.micronaut.http.HttpStatus; -import io.micronaut.http.MediaType; -import io.micronaut.http.annotation.Controller; -import io.micronaut.http.annotation.Get; -import io.micronaut.http.annotation.Produces; -import io.micronaut.http.exceptions.HttpStatusException; -import io.swagger.v3.oas.annotations.OpenAPIDefinition; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.inject.Inject; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -@Controller("/api/admin") -@Requires(bean = Environment.class) -@OpenAPIDefinition(tags = {@Tag(name = "admin")}) -public class AdminController { - - private static final Logger log = LoggerFactory.getLogger(AdminController.class); - - @Inject private DiagnosticsService diagnosticsService; - @Inject private ConfigChangeWatcher configChangeWatcher; - - /** - * Returns system diagnostics information. - * - * @return DiagnosticsResult containing system diagnostics - */ - @Get("/diagnostics") - @Tag(name = "admin") - @Produces(MediaType.APPLICATION_JSON) - public HttpResponse getDiagnostics() { - log.info("Collecting diagnostics information"); - try { - // Then get the serializable diagnostics result - DiagnosticsResult result = diagnosticsService.collectSerdeableDiagnostics(); - return HttpResponse.ok(result); - } catch (Exception e) { - log.error("Failed to collect diagnostics information", e); - throw new HttpStatusException( - HttpStatus.INTERNAL_SERVER_ERROR, "Failed to collect diagnostics: " + e.getMessage()); - } - } -} diff --git a/demo/src/main/java/com/tcn/exile/demo/single/AgentsController.java b/demo/src/main/java/com/tcn/exile/demo/single/AgentsController.java deleted file mode 100644 index eacb5b8..0000000 --- a/demo/src/main/java/com/tcn/exile/demo/single/AgentsController.java +++ /dev/null @@ -1,384 +0,0 @@ -/* - * (C) 2017-2025 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.demo.single; - -import build.buf.gen.tcnapi.exile.gate.v2.*; -import com.google.protobuf.StringValue; -import com.tcn.exile.gateclients.UnconfiguredException; -import com.tcn.exile.models.*; -import com.tcn.exile.models.Agent; -import com.tcn.exile.models.AgentState; -import com.tcn.exile.models.CallType; -import com.tcn.exile.models.ConnectedParty; -import com.tcn.exile.models.DialRequest; -import com.tcn.exile.models.DialResponse; -import io.micronaut.http.HttpResponse; -import io.micronaut.http.HttpStatus; -import io.micronaut.http.MediaType; -import io.micronaut.http.annotation.*; -import io.micronaut.scheduling.TaskExecutors; -import io.micronaut.scheduling.annotation.ExecuteOn; -import io.swagger.v3.oas.annotations.OpenAPIDefinition; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.inject.Inject; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -@Controller("/api/agents") -@ExecuteOn(TaskExecutors.BLOCKING) -@OpenAPIDefinition(tags = {@Tag(name = "agents")}) -public class AgentsController { - private static final Logger log = LoggerFactory.getLogger(AgentsController.class); - @Inject ConfigChangeWatcher configChangeWatcher; - - @Get - @Tag(name = "agents") - public HttpResponse listAgents( - @QueryValue(value = "logged_in", defaultValue = "") Optional loggedIn, - @QueryValue(value = "state", defaultValue = "") Optional stateParam, - @QueryValue(value = "fetch_recording_status", defaultValue = "false") - boolean fetchRecordingStatus) - throws UnconfiguredException { - log.debug("listAgents with logged_in={}, state={}", loggedIn, stateParam); - - // Parse and validate state parameter - build.buf.gen.tcnapi.exile.gate.v2.AgentState stateFilter = null; - if (stateParam.isPresent() && !stateParam.get().isEmpty()) { - try { - stateFilter = parseState(stateParam.get()); - } catch (IllegalArgumentException e) { - return HttpResponse.status(HttpStatus.BAD_REQUEST) - .body( - Map.of( - "error", e.getMessage(), - "validStates", getValidStateNames())); - } - } - - // Build request with optional filters - var requestBuilder = ListAgentsRequest.newBuilder(); - - if (loggedIn.isPresent()) { - requestBuilder.setLoggedIn(loggedIn.get()); - } - - if (stateFilter != null) { - requestBuilder.setState(stateFilter); - } - - requestBuilder.setFetchRecordingStatus(fetchRecordingStatus); - - // Call gRPC service - var ret = configChangeWatcher.getGateClient().listAgents(requestBuilder.build()); - - // Collect and transform results - List agents = new ArrayList<>(); - while (ret.hasNext()) { - var agentResponse = ret.next(); - var agent = agentResponse.getAgent(); - ConnectedParty cp = null; - if (agent.hasConnectedParty()) { - cp = - new ConnectedParty( - agent.getConnectedParty().getCallSid(), - CallType.values()[agent.getConnectedParty().getCallType().getNumber()], - agent.getConnectedParty().getIsInbound()); - } - agents.add( - new Agent( - agent.getUserId(), - agent.getPartnerAgentId(), - agent.getUsername(), - agent.getFirstName(), - agent.getLastName(), - agent.getCurrentSessionId() != 0 ? agent.getCurrentSessionId() : null, - agent.getAgentState() - != build.buf.gen.tcnapi.exile.gate.v2.AgentState.AGENT_STATE_UNAVAILABLE - ? AgentState.values()[agent.getAgentState().getNumber()] - : null, - agent.getIsLoggedIn(), - fetchRecordingStatus ? agent.getIsRecording() : null, - agent.getAgentIsMuted(), - cp)); - } - - return HttpResponse.ok(agents); - } - - /** - * Parse and validate the state parameter. Accepts case-insensitive state names with or without - * "AGENT_STATE_" prefix. - * - * @param stateParam The state parameter from the request - * @return The corresponding AgentState protobuf enum - * @throws IllegalArgumentException if the state is invalid - */ - private build.buf.gen.tcnapi.exile.gate.v2.AgentState parseState(String stateParam) { - if (stateParam == null || stateParam.trim().isEmpty()) { - throw new IllegalArgumentException("State parameter cannot be empty"); - } - - // Normalize: uppercase and add prefix if not present - String normalized = stateParam.trim().toUpperCase(); - if (!normalized.startsWith("AGENT_STATE_")) { - normalized = "AGENT_STATE_" + normalized; - } - - // Try to find matching enum value - try { - return build.buf.gen.tcnapi.exile.gate.v2.AgentState.valueOf(normalized); - } catch (IllegalArgumentException e) { - throw new IllegalArgumentException( - String.format( - "Invalid state '%s'. Valid states are: %s", - stateParam, String.join(", ", getValidStateNames()))); - } - } - - /** - * Get list of valid state names (without AGENT_STATE_ prefix) for error messages. - * - * @return List of valid state names - */ - private List getValidStateNames() { - return Arrays.stream(build.buf.gen.tcnapi.exile.gate.v2.AgentState.values()) - .map(state -> state.name().replace("AGENT_STATE_", "")) - .collect(Collectors.toList()); - } - - @Post - @Consumes(MediaType.APPLICATION_JSON) - @Tag(name = "agents") - public Agent createAgent(@Body AgentUpsertRequest agent) throws UnconfiguredException { - log.debug("createAgent"); - // find - var req = UpsertAgentRequest.newBuilder().setUsername(agent.username()); - - if (agent.firstName() != null) { - req.setFirstName(agent.firstName()); - } - if (agent.lastName() != null) { - req.setLastName(agent.lastName()); - } - if (agent.partnerAgentId() != null) { - req.setPartnerAgentId(agent.partnerAgentId()); - } - if (agent.password() != null) { - req.setPassword(agent.password()); - } - var ret = configChangeWatcher.getGateClient().upsertAgent(req.build()); - if (ret != null) { - return new Agent( - ret.getAgent().getUserId(), - ret.getAgent().getPartnerAgentId(), - ret.getAgent().getUsername(), - ret.getAgent().getFirstName(), - ret.getAgent().getLastName(), - ret.getAgent().getCurrentSessionId() != 0 ? ret.getAgent().getCurrentSessionId() : null, - ret.getAgent().getAgentState() - != build.buf.gen.tcnapi.exile.gate.v2.AgentState.AGENT_STATE_UNAVAILABLE - ? AgentState.values()[ret.getAgent().getAgentState().getNumber()] - : null, - ret.getAgent().getIsLoggedIn(), - null, - null, - null); - } - throw new RuntimeException("Failed to create agent"); - } - - @Put("{partnerAgentId}/dial") - @Tag(name = "agents") - public DialResponse dial(@PathVariable String partnerAgentId, @Body DialRequest req) - throws UnconfiguredException { - log.debug("dial {}", req); - - var dialReq = - build.buf.gen.tcnapi.exile.gate.v2.DialRequest.newBuilder() - .setPartnerAgentId(partnerAgentId) - .setPhoneNumber(req.phoneNumber()); - if (req.callerId() != null) { - dialReq.setCallerId(StringValue.of(req.callerId())); - } - if (req.poolId() != null) { - dialReq.setPoolId(StringValue.of(req.poolId())); - } - if (req.recordId() != null) { - dialReq.setRecordId(StringValue.of(req.recordId())); - } - - var res = configChangeWatcher.getGateClient().dial(dialReq.build()); - if (res != null) { - return new DialResponse( - res.getPhoneNumber(), - res.getCallerId(), - res.getCallSid(), - CallType.valueOf(res.getCallType().name()), - res.getOrgId(), - res.getPartnerAgentId()); - } - throw new RuntimeException("Failed to dial"); - } - - @Get("{partnerAgentId}/recording") - @Tag(name = "agents") - public RecordingResponse getRecording(@PathVariable String partnerAgentId) - throws UnconfiguredException { - log.debug("getRecording"); - var res = - configChangeWatcher - .getGateClient() - .getRecordingStatus( - GetRecordingStatusRequest.newBuilder().setPartnerAgentId(partnerAgentId).build()); - return new RecordingResponse(res.getIsRecording()); - } - - @Put("{partnerAgentId}/recording/{status}") - @Tag(name = "agents") - public RecordingResponse setRecording( - @PathVariable String partnerAgentId, @PathVariable String status) - throws UnconfiguredException { - log.debug("setRecording"); - boolean res = false; - if (status.equalsIgnoreCase("on") - || status.equalsIgnoreCase("resume") - || status.equalsIgnoreCase("start") - || status.equalsIgnoreCase("true")) { - configChangeWatcher - .getGateClient() - .startCallRecording( - StartCallRecordingRequest.newBuilder().setPartnerAgentId(partnerAgentId).build()); - return new RecordingResponse(true); - } else if (status.equalsIgnoreCase("off") - || status.equalsIgnoreCase("stop") - || status.equalsIgnoreCase("pause") - || status.equalsIgnoreCase("paused") - || status.equalsIgnoreCase("false")) { - configChangeWatcher - .getGateClient() - .stopCallRecording( - StopCallRecordingRequest.newBuilder().setPartnerAgentId(partnerAgentId).build()); - return new RecordingResponse(false); - } - throw new RuntimeException("Invalid status"); - } - - @Get("{partnerAgentId}/state") - @Tag(name = "agents") - public AgentStatus getState(@PathVariable String partnerAgentId) throws UnconfiguredException { - log.debug("getState"); - var res = - configChangeWatcher - .getGateClient() - .getAgentStatus( - GetAgentStatusRequest.newBuilder().setPartnerAgentId(partnerAgentId).build()); - if (res.hasConnectedParty()) { - return new AgentStatus( - res.getPartnerAgentId(), - AgentState.values()[res.getAgentState().getNumber()], - res.getCurrentSessionId(), - new ConnectedParty( - res.getConnectedParty().getCallSid(), - CallType.values()[res.getConnectedParty().getCallType().getNumber()], - res.getConnectedParty().getIsInbound()), - res.getAgentIsMuted(), - res.getIsRecording()); - } else { - return new AgentStatus( - res.getPartnerAgentId(), - AgentState.values()[res.getAgentState().getNumber()], - res.getCurrentSessionId(), - null, - res.getAgentIsMuted(), - res.getIsRecording()); - } - } - - @Put("{partnerAgentId}/state/{state}") - @Tag(name = "agents") - public SetAgentStatusResponse setState( - @PathVariable String partnerAgentId, - @PathVariable SetAgentState state /* , @Body PauseCodeReason pauseCodeReason */) - throws UnconfiguredException { - log.debug("setState"); - var request = - UpdateAgentStatusRequest.newBuilder() - .setPartnerAgentId(partnerAgentId) - .setNewState(build.buf.gen.tcnapi.exile.gate.v2.AgentState.values()[state.getValue()]); - // if (pauseCodeReason != null && pauseCodeReason.reason() != null) { - // request.setReason(pauseCodeReason.reason()); - // // request.setPauseCodeReason(pauseCodeReason.reason()); - // } - var res = configChangeWatcher.getGateClient().updateAgentStatus(request.build()); - return new SetAgentStatusResponse(); - } - - @Get("{partnerAgentId}/pausecodes") - @Tag(name = "agents") - public List listPauseCodes(@PathVariable String partnerAgentId) { - log.debug("listPauseCodes"); - var res = - configChangeWatcher - .getGateClient() - .listHuntGroupPauseCodes( - ListHuntGroupPauseCodesRequest.newBuilder() - .setPartnerAgentId(partnerAgentId) - .build()); - return res.getPauseCodesList().stream().toList(); - } - - @Get("{partnerAgentId}/simplehold") - @Tag(name = "agents") - public Map putCallOnSimpleHold(@PathVariable String partnerAgentId) { - var res = - configChangeWatcher - .getGateClient() - .putCallOnSimpleHold( - PutCallOnSimpleHoldRequest.newBuilder().setPartnerAgentId(partnerAgentId).build()); - return Map.of("success", true); - } - - @Get("{partnerAgentId}/simpleunhold") - @Tag(name = "agents") - public Map removeCallFromSimpleHold(@PathVariable String partnerAgentId) { - var res = - configChangeWatcher - .getGateClient() - .takeCallOffSimpleHold( - TakeCallOffSimpleHoldRequest.newBuilder() - .setPartnerAgentId(partnerAgentId) - .build()); - return Map.of("success", true); - } - - @Put("{partnerAgentId}/callresponse") - @Tag(name = "agents") - public Map addAgentCallResponse( - @PathVariable String partnerAgentId, @Body AddAgentCallResponseRequest req) { - var res = - configChangeWatcher - .getGateClient() - .addAgentCallResponse(req.toBuilder().setPartnerAgentId(partnerAgentId).build()); - return Map.of("success", true); - } -} diff --git a/demo/src/main/java/com/tcn/exile/demo/single/Application.java b/demo/src/main/java/com/tcn/exile/demo/single/Application.java deleted file mode 100644 index ed0595d..0000000 --- a/demo/src/main/java/com/tcn/exile/demo/single/Application.java +++ /dev/null @@ -1,38 +0,0 @@ -/* - * (C) 2017-2025 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.demo.single; - -import io.micronaut.runtime.Micronaut; -import io.swagger.v3.oas.annotations.OpenAPIDefinition; -import io.swagger.v3.oas.annotations.info.Info; -import java.util.TimeZone; - -@OpenAPIDefinition( - info = - @Info( - title = "Sati Demo API", - version = "1.0", - description = "Demo API for Sati with Swagger UI")) -public class Application { - public static void main(String[] args) { - // Set timezone to UTC for consistency with containerized deployments - System.setProperty("user.timezone", "UTC"); - TimeZone.setDefault(TimeZone.getTimeZone("UTC")); - - Micronaut.run(Application.class, args); - } -} diff --git a/demo/src/main/java/com/tcn/exile/demo/single/ConfigChangeWatcher.java b/demo/src/main/java/com/tcn/exile/demo/single/ConfigChangeWatcher.java deleted file mode 100644 index 752f9e4..0000000 --- a/demo/src/main/java/com/tcn/exile/demo/single/ConfigChangeWatcher.java +++ /dev/null @@ -1,267 +0,0 @@ -/* - * (C) 2017-2025 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.demo.single; - -import com.tcn.exile.config.Config; -import com.tcn.exile.gateclients.v2.GateClient; -import com.tcn.exile.gateclients.v2.GateClientConfiguration; -import com.tcn.exile.gateclients.v2.GateClientJobStream; -import com.tcn.exile.gateclients.v2.GateClientPollEvents; -import com.tcn.exile.plugin.PluginInterface; -import io.methvin.watcher.DirectoryWatcher; -import io.micronaut.context.ApplicationContext; -import io.micronaut.context.event.ApplicationEventListener; -import io.micronaut.context.event.StartupEvent; -import io.micronaut.core.type.Argument; -import io.micronaut.inject.qualifiers.Qualifiers; -import io.micronaut.scheduling.TaskScheduler; -import io.micronaut.serde.ObjectMapper; -import jakarta.inject.Inject; -import jakarta.inject.Singleton; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Duration; -import java.util.Arrays; -import java.util.List; -import java.util.Optional; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -@Singleton -public class ConfigChangeWatcher implements ApplicationEventListener { - private Config currentConfig = null; - @Inject ApplicationContext context; - - @Inject ObjectMapper objectMapper; - - private static final Logger log = LoggerFactory.getLogger(ConfigChangeWatcher.class); - - private static final String CONFIG_FILE_NAME = "com.tcn.exiles.sati.config.cfg"; - private final DirectoryWatcher watcher; - - private static final List watchList = - List.of(Path.of("/workdir/config"), Path.of("workdir/config")); - - private void findValidConfigDir() { - Optional validDir = watchList.stream().filter(path -> path.toFile().exists()).findFirst(); - if (!validDir.isPresent()) { - // try to create a local workdir/config directory - var dir = Path.of("workdir/config"); - if (!dir.toFile().mkdirs()) { - log.error( - "No valid config directory found, and we don't have permissions to create one! Please create one in ./workdir/config or /workdir/config"); - } - } else { - log.info("Found valid config directory: {}", validDir.get()); - } - } - - private Optional getCurrentConfig() { - log.debug("Current config: {}", currentConfig); - Optional configDir = - watchList.stream().filter(path -> path.toFile().exists()).findFirst(); - if (configDir.isPresent()) { - if (configDir.get().resolve(Path.of(CONFIG_FILE_NAME)).toFile().exists()) { - try { - var file = configDir.get().resolve(Path.of(CONFIG_FILE_NAME)); - log.debug("Found valid config file: {}", file); - var data = Files.readAllBytes(file); - // var newConfig = Config.of(Arrays.copyOf(data, data.length-1), - // objectMapper); - var newConfig = Config.of(data, objectMapper); - if (newConfig.isPresent()) { - return newConfig; - } - } catch (IOException e) { - e.printStackTrace(); - } - } - } else { - log.debug("No valid config file found"); - } - log.debug("Return empty config"); - return Optional.empty(); - } - - public ConfigChangeWatcher() throws IOException { - log.info("creating watcher"); - // try to find valid config directories - findValidConfigDir(); - - this.watcher = - DirectoryWatcher.builder() - .paths(watchList) - .fileHashing(false) - .listener( - event -> { - log.debug("Event: {}", event); - if (!event.path().getFileName().toString().equals(CONFIG_FILE_NAME)) { - log.debug("Event path: {}", event.path()); - return; - } - - switch (event.eventType()) { - case CREATE: - case MODIFY: - if (event.path().toFile().canRead()) { - log.debug("reading config file: {}", event.path()); - var base64encodedjson = Files.readAllBytes(event.path()); - var newConfig = - Config.of( - Arrays.copyOf(base64encodedjson, base64encodedjson.length - 1), - objectMapper); - if (newConfig.isPresent()) { - log.debug("New config: {}", newConfig); - if (this.currentConfig == null) { - log.debug("Current config is null"); - // this means we will create the currentConfig - this.currentConfig = newConfig.get(); - createBeans(); - } else { - log.debug("Current config: {}", this.currentConfig); - if (!this.currentConfig.getOrg().equals(newConfig.get().getOrg())) { - // not the same org - destroyBeans(); - } else { - log.debug("New config has the same org {}", currentConfig.getOrg()); - } - this.currentConfig = newConfig.get(); - createBeans(); - } - } else { - log.debug("Can't read config file: {}", event.path()); - } - } else { - log.error("Can't read config file {}", event.path()); - } - break; - case DELETE: - if (this.currentConfig != null) { - destroyBeans(); - } - break; - default: - log.info("Unexpected event: {}", event.eventType()); - break; - } - }) - .build(); - } - - private static final String gateClientPrefix = "gate-client-"; - private static final String gateClientJobStreamPrefix = "gate-client-job-stream-"; - private static final String gateClientPollEventsPrefix = "gate-client-poll-events-"; - private static final String gateClientConfigurationPrefix = "gate-client-config-"; - private static final String pluginPrefix = "plugin-"; - - private void destroyBeans() { - context.destroyBean( - Argument.of(GateClient.class), - Qualifiers.byName(gateClientPrefix + this.currentConfig.getOrg())); - context.destroyBean( - Argument.of(GateClientJobStream.class), - Qualifiers.byName(gateClientJobStreamPrefix + this.currentConfig.getOrg())); - context.destroyBean( - Argument.of(GateClientPollEvents.class), - Qualifiers.byName(gateClientPollEventsPrefix + this.currentConfig.getOrg())); - context.destroyBean( - Argument.of(GateClientConfiguration.class), - Qualifiers.byName(gateClientConfigurationPrefix + this.currentConfig.getOrg())); - context.destroyBean( - Argument.of(PluginInterface.class), - Qualifiers.byName(pluginPrefix + this.currentConfig.getOrg())); - } - - private void createBeans() { - log.info("creating bean for org {}", this.currentConfig.getOrg()); - var tenantKey = this.currentConfig.getOrg(); - var gateClient = new GateClient(tenantKey, this.currentConfig); - var demoPlugin = new DemoPlugin(tenantKey, gateClient, context); - var gateClientJobStream = new GateClientJobStream(tenantKey, this.currentConfig, demoPlugin); - - var gateClientPollEvents = new GateClientPollEvents(tenantKey, this.currentConfig, demoPlugin); - var gateClientConfiguration = - new GateClientConfiguration(tenantKey, this.currentConfig, demoPlugin); - - context.registerSingleton( - GateClient.class, - gateClient, - Qualifiers.byName(gateClientPrefix + this.currentConfig.getOrg()), - true); - context.registerSingleton( - GateClientJobStream.class, - gateClientJobStream, - Qualifiers.byName(gateClientJobStreamPrefix + this.currentConfig.getOrg()), - true); - context.registerSingleton( - GateClientPollEvents.class, - gateClientPollEvents, - Qualifiers.byName(gateClientPollEventsPrefix + this.currentConfig.getOrg()), - true); - context.registerSingleton( - GateClientConfiguration.class, - gateClientConfiguration, - Qualifiers.byName(gateClientConfigurationPrefix + this.currentConfig.getOrg()), - true); - context.registerSingleton( - PluginInterface.class, - demoPlugin, - Qualifiers.byName(pluginPrefix + this.currentConfig.getOrg()), - true); - TaskScheduler taskScheduler = context.getBean(TaskScheduler.class); - taskScheduler.scheduleAtFixedRate( - Duration.ZERO, Duration.ofSeconds(1), gateClientJobStream::start); - taskScheduler.scheduleAtFixedRate( - Duration.ZERO, Duration.ofSeconds(10), gateClientPollEvents::start); - taskScheduler.scheduleAtFixedRate( - Duration.ZERO, Duration.ofSeconds(30), gateClientConfiguration::start); - log.debug( - "test client-pool-events bean is created: {}", - context - .findBean( - GateClientPollEvents.class, - Qualifiers.byName("gate-client-poll-events-" + this.currentConfig.getOrg())) - .isPresent()); - } - - @Override - public void onApplicationEvent(StartupEvent event) { - log.info("Starting config change watcher"); - getCurrentConfig() - .ifPresent( - config -> { - log.info("Current config: {}", config); - this.currentConfig = config; - createBeans(); - }); - this.watcher.watchAsync(); - } - - public GateClient getGateClient() { - if (this.currentConfig == null) { - throw new IllegalStateException("No current config loaded"); - } - String beanName = gateClientPrefix + this.currentConfig.getOrg(); - return context.getBean(GateClient.class, Qualifiers.byName(beanName)); - } - - public PluginInterface getPlugin() { - return context.getBean( - PluginInterface.class, Qualifiers.byName(pluginPrefix + this.currentConfig.getOrg())); - } -} diff --git a/demo/src/main/java/com/tcn/exile/demo/single/DemoPlugin.java b/demo/src/main/java/com/tcn/exile/demo/single/DemoPlugin.java deleted file mode 100644 index 11d5b7e..0000000 --- a/demo/src/main/java/com/tcn/exile/demo/single/DemoPlugin.java +++ /dev/null @@ -1,593 +0,0 @@ -/* - * (C) 2017-2025 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.demo.single; - -import build.buf.gen.tcnapi.exile.gate.v2.ExileAgentCall; -import build.buf.gen.tcnapi.exile.gate.v2.ExileAgentResponse; -import build.buf.gen.tcnapi.exile.gate.v2.ExileTask; -import build.buf.gen.tcnapi.exile.gate.v2.ExileTelephonyResult; -import build.buf.gen.tcnapi.exile.gate.v2.LogRequest; -import build.buf.gen.tcnapi.exile.gate.v2.StreamJobsResponse; -import build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest; -import ch.qos.logback.classic.LoggerContext; -import com.tcn.exile.config.DiagnosticsService; -import com.tcn.exile.gateclients.UnconfiguredException; -import com.tcn.exile.gateclients.v2.GateClient; -import com.tcn.exile.memlogger.LogShipper; -import com.tcn.exile.memlogger.MemoryAppenderInstance; -import com.tcn.exile.models.PluginConfigEvent; -import com.tcn.exile.plugin.PluginInterface; -import com.tcn.exile.plugin.PluginStatus; -import io.micronaut.context.ApplicationContext; -import java.net.InetAddress; -import java.util.HashMap; -import java.util.List; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class DemoPlugin implements PluginInterface, LogShipper { - private static final Logger log = LoggerFactory.getLogger(DemoPlugin.class); - private boolean running = false; - - GateClient gateClient; - private PluginConfigEvent pluginConfig; - private String tenantKey; - private DiagnosticsService diagnosticsService; - - public DemoPlugin( - String tenantKey, GateClient gateClient, ApplicationContext applicationContext) { - this.gateClient = gateClient; - this.running = true; - this.tenantKey = tenantKey; - this.diagnosticsService = new DiagnosticsService(applicationContext); - } - - @Override - public String getName() { - return "DemoPlugin"; - } - - @Override - public boolean isRunning() { - return running; - } - - @Override - public PluginStatus getPluginStatus() { - return new PluginStatus( - getName(), - running, - 100, // queueMaxSize - 0, // queueCompletedJobs - 0, // queueActiveCount - new HashMap<>(), // internalConfig - new HashMap<>() // internalStatus - ); - } - - @Override - public void listPools(String jobId, StreamJobsResponse.ListPoolsRequest listPools) - throws UnconfiguredException { - log.info("Tenant: {} - Listing pools for job {}", tenantKey, jobId); - gateClient.submitJobResults( - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setListPoolsResult( - SubmitJobResultsRequest.ListPoolsResult.newBuilder() - .addPools( - build.buf.gen.tcnapi.exile.core.v2.Pool.newBuilder() - .setPoolId("A") - .setDescription("Pool with id A") - .setStatus(build.buf.gen.tcnapi.exile.core.v2.Pool.PoolStatus.READY) - .build()) - .build()) - .build()); - } - - @Override - public void getPoolStatus(String jobId, StreamJobsResponse.GetPoolStatusRequest request) - throws UnconfiguredException { - log.info("Tenant: {} - Getting pool status for job={} and pool={}", tenantKey, jobId, request); - gateClient.submitJobResults( - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setGetPoolStatusResult( - SubmitJobResultsRequest.GetPoolStatusResult.newBuilder() - .setPool( - build.buf.gen.tcnapi.exile.core.v2.Pool.newBuilder() - .setPoolId(request.getPoolId()) - .setStatus(build.buf.gen.tcnapi.exile.core.v2.Pool.PoolStatus.READY) - .build()) - .build()) - .build()); - } - - @Override - public void getPoolRecords(String jobId, StreamJobsResponse.GetPoolRecordsRequest request) - throws UnconfiguredException { - log.info("Tenant: {} - Getting pool records for job {} and pool {}", tenantKey, jobId, request); - gateClient.submitJobResults( - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setGetPoolRecordsResult( - SubmitJobResultsRequest.GetPoolRecordsResult.newBuilder() - .addRecords( - build.buf.gen.tcnapi.exile.core.v2.Record.newBuilder() - .setPoolId(request.getPoolId()) - .setRecordId("blue") - .setJsonRecordPayload("{\"f1\": \"foo\"}") - .build()) - .addRecords( - build.buf.gen.tcnapi.exile.core.v2.Record.newBuilder() - .setPoolId(request.getPoolId()) - .setRecordId("red") - .setJsonRecordPayload("{\"f2\": \"bar\"}") - .build()) - .build()) - .build()); - } - - @Override - public void handleAgentCall(ExileAgentCall exileAgentCall) { - log.info("Tenant: {} - Handling agent call for job {}", tenantKey, exileAgentCall); - } - - @Override - public void handleTelephonyResult(ExileTelephonyResult exileTelephonyResult) { - log.info("Tenant: {} - Handling telephony result for job {}", tenantKey, exileTelephonyResult); - } - - @Override - public void handleTask(ExileTask exileTask) { - log.info("Tenant: {} - Handling task for job {}", tenantKey, exileTask); - } - - @Override - public void handleAgentResponse(ExileAgentResponse exileAgentResponse) { - log.info("Tenant: {} - Handling agent response for {}", tenantKey, exileAgentResponse); - } - - @Override - public void handleTransferInstance( - build.buf.gen.tcnapi.exile.gate.v2.ExileTransferInstance exileTransferInstance) { - log.info("Tenant: {} - Handling transfer instance for {}", tenantKey, exileTransferInstance); - } - - @Override - public void handleCallRecording( - build.buf.gen.tcnapi.exile.gate.v2.ExileCallRecording exileCallRecording) { - log.info( - "Tenant: {} - Handling call recording for {}", - tenantKey, - exileCallRecording.getRecordingId()); - } - - @Override - public void searchRecords(String jobId, StreamJobsResponse.SearchRecordsRequest searchRecords) {} - - @Override - public void readFields(String jobId, StreamJobsResponse.GetRecordFieldsRequest getRecordFields) { - log.info( - "Tenant: {} - Reading fields for job {} and record {}", - tenantKey, - jobId, - getRecordFields.getRecordId()); - gateClient.submitJobResults( - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setGetRecordFieldsResult( - SubmitJobResultsRequest.GetRecordFieldsResult.newBuilder() - .addFields( - build.buf.gen.tcnapi.exile.core.v2.Field.newBuilder() - .setFieldName("foo") - .setFieldValue("bar") - .setRecordId(getRecordFields.getRecordId()) - .build()) - .build()) - .build()); - } - - @Override - public void writeFields(String jobId, StreamJobsResponse.SetRecordFieldsRequest setRecordFields) { - log.info( - "Tenant: {} - Writing fields for job {} and record {}", - tenantKey, - jobId, - setRecordFields.getRecordId()); - gateClient.submitJobResults( - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setSetRecordFieldsResult( - SubmitJobResultsRequest.SetRecordFieldsResult.newBuilder().build()) - .build()); - } - - @Override - public void createPayment(String jobId, StreamJobsResponse.CreatePaymentRequest createPayment) { - log.info( - "Tenant: {} - Creating payment for job {} and record {}", - tenantKey, - jobId, - createPayment.getRecordId()); - gateClient.submitJobResults( - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setCreatePaymentResult( - SubmitJobResultsRequest.CreatePaymentResult.newBuilder().build()) - .build()); - } - - @Override - public void popAccount(String jobId, StreamJobsResponse.PopAccountRequest popAccount) { - log.info( - "Tenant: {} - Popping account for job {} and record {}", - tenantKey, - jobId, - popAccount.getRecordId()); - gateClient.submitJobResults( - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setPopAccountResult(SubmitJobResultsRequest.PopAccountResult.newBuilder().build()) - .build()); - } - - private String getServerName() { - try { - return InetAddress.getLocalHost().getHostName(); - } catch (Exception e) { - return "Unknown"; - } - } - - private String getVersion() { - var ret = this.getClass().getPackage().getImplementationVersion(); - return ret == null ? "Unknown" : ret; - } - - @Override - public void info(String jobId, StreamJobsResponse.InfoRequest info) { - log.info("Tenant: {} - Info for job {}", tenantKey, jobId); - gateClient.submitJobResults( - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setInfoResult( - SubmitJobResultsRequest.InfoResult.newBuilder() - .setServerName(getServerName()) - .setCoreVersion(com.tcn.exile.gateclients.v2.BuildVersion.getBuildVersion()) - .setPluginName("DemoPlugin") - .setPluginVersion(getVersion()) - .build()) - .build()); - } - - public SubmitJobResultsRequest.InfoResult info() { - return SubmitJobResultsRequest.InfoResult.newBuilder() - .setServerName(getServerName()) - .setCoreVersion(com.tcn.exile.gateclients.v2.BuildVersion.getBuildVersion()) - .setPluginName("DemoPlugin") - .setPluginVersion(getVersion()) - .build(); - } - - @Override - public void shutdown(String jobId, StreamJobsResponse.SeppukuRequest shutdown) { - log.warn("Tenant: {} - Seppuku requested for job: {}", tenantKey, jobId); - - // Try to submit acknowledgment, but don't let failure prevent shutdown - try { - gateClient.submitJobResults( - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setShutdownResult(SubmitJobResultsRequest.SeppukuResult.newBuilder().build()) - .build()); - log.warn( - "Tenant: {} - Seppuku acknowledged for job: {}, initiating graceful shutdown", - tenantKey, - jobId); - } catch (Exception e) { - log.warn( - "Tenant: {} - Failed to acknowledge seppuku for job: {}, but proceeding with shutdown anyway: {}", - tenantKey, - jobId, - e.getMessage()); - } - - // Execute shutdown in separate thread to allow any gRPC response to be sent - try { - Thread shutdownThread = - new Thread( - () -> { - try { - log.warn( - "Tenant: {} - Shutdown thread started, waiting 2 seconds before termination", - tenantKey); - - // Give time for any response to be sent back to the server - Thread.sleep(2000); - - log.warn("Tenant: {} - Executing seppuku - terminating application", tenantKey); - - // Perform graceful shutdown - System.exit(0); - - } catch (InterruptedException e) { - log.error( - "Tenant: {} - Seppuku interrupted, forcing immediate shutdown", tenantKey); - Thread.currentThread().interrupt(); - System.exit(1); - } catch (Exception e) { - log.error( - "Tenant: {} - Unexpected error during seppuku, forcing immediate shutdown: {}", - tenantKey, - e.getMessage()); - System.exit(1); - } - }, - "SeppukuThread-" + tenantKey); - - shutdownThread.setDaemon(false); // Ensure JVM waits for this thread - shutdownThread.start(); - - log.warn("Tenant: {} - Seppuku thread started, shutdown sequence initiated", tenantKey); - } catch (Exception e) { - log.error( - "Tenant: {} - Failed to start shutdown thread, forcing immediate shutdown: {}", - tenantKey, - e.getMessage()); - // If we can't even start the thread, exit immediately - System.exit(1); - } - } - - @Override - public void logger(String jobId, StreamJobsResponse.LoggingRequest logRequest) { - log.debug( - "Tenant: {} - Received log request {} stream {} payload: {}", - tenantKey, - jobId, - logRequest.getStreamLogs(), - logRequest.getLoggerLevelsList()); - LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); - - for (var logger : logRequest.getLoggerLevelsList()) { - log.debug( - "Tenant: {} - Setting logger {} to level {}", - tenantKey, - logger.getLoggerName(), - logger.getLoggerLevel()); - var v = loggerContext.getLogger(logger.getLoggerName()); - if (v != null) { - if (logger.getLoggerLevel() - == StreamJobsResponse.LoggingRequest.LoggerLevel.Level.DISABLED) { - v.setLevel(ch.qos.logback.classic.Level.OFF); - } else { - v.setLevel(ch.qos.logback.classic.Level.toLevel(logger.getLoggerLevel().name())); - } - } else { - log.warn("Tenant: {} - Logger {} not found", tenantKey, logger.getLoggerName()); - } - } - if (logRequest.getStreamLogs()) { - MemoryAppenderInstance.getInstance().enableLogShipper(this); - } else { - MemoryAppenderInstance.getInstance().disableLogShipper(); - } - - gateClient.submitJobResults( - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setLoggingResult(SubmitJobResultsRequest.LoggingResult.newBuilder().build()) - .build()); - } - - /** Helper method to list all available loggers for debugging */ - private void listAvailableLoggers() { - try { - LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); - log.info("Tenant: {} - Available loggers:", tenantKey); - - // List some common loggers - for (ch.qos.logback.classic.Logger logger : loggerContext.getLoggerList()) { - if (logger.getLevel() != null - || logger.getName().contains("tcn") - || logger.getName().contains("exile") - || logger.getName().equals("ROOT")) { - log.info( - "Tenant: {} - Logger: '{}' - Level: {}", - tenantKey, - logger.getName(), - logger.getLevel() != null ? logger.getLevel().toString() : "INHERITED"); - } - } - } catch (Exception e) { - log.warn("Tenant: {} - Error listing loggers: {}", tenantKey, e.getMessage()); - } - } - - @Override - public void executeLogic(String jobId, StreamJobsResponse.ExecuteLogicRequest executeLogic) {} - - @Override - public void setConfig(PluginConfigEvent config) { - this.pluginConfig = config; - if (this.pluginConfig == null) { - this.running = false; - } - if (config.isUnconfigured()) { - running = false; - } - running = true; - } - - @Override - public void shipLogs(List payload) { - log.info("Tenant: {} - Ship logs", tenantKey); - if (payload == null || payload.isEmpty()) { - return; - } - String combinedPayload = String.join("\n", payload); - gateClient.log(LogRequest.newBuilder().setPayload(combinedPayload).build()); - } - - @Override - public void stop() { - log.info("Tenant: {} - Stopping shipping logs plugin", tenantKey); - MemoryAppenderInstance.getInstance().disableLogShipper(); - } - - @Override - public void runDiagnostics( - String jobId, StreamJobsResponse.DiagnosticsRequest diagnosticsRequest) { - log.info("Tenant: {} - Running diagnostics for job {}", tenantKey, jobId); - - try { - build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.DiagnosticsResult diagnostics = - null; - if (diagnosticsService != null) { - // Then collect system diagnostics for the response - diagnostics = diagnosticsService.collectSystemDiagnostics(); - } else { - log.warn("DiagnosticsService is null, cannot collect system diagnostics"); - // Create empty diagnostics result if service is unavailable - diagnostics = SubmitJobResultsRequest.DiagnosticsResult.newBuilder().build(); - } - - // Submit diagnostics results back to gate - gateClient.submitJobResults( - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setDiagnosticsResult(diagnostics) - .build()); - } catch (Exception e) { - log.error("Error running diagnostics", e); - // Return empty diagnostics result on error - gateClient.submitJobResults( - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setDiagnosticsResult(SubmitJobResultsRequest.DiagnosticsResult.newBuilder().build()) - .build()); - } - } - - @Override - public void listTenantLogs( - String jobId, StreamJobsResponse.ListTenantLogsRequest listTenantLogsRequest) { - log.info("Tenant: {} - Listing tenant logs for job {}", tenantKey, jobId); - - try { - // Use DiagnosticsService to collect tenant logs with time range filtering - SubmitJobResultsRequest.ListTenantLogsResult tenantLogsResult = - diagnosticsService.collectTenantLogs(listTenantLogsRequest); - - // Submit tenant logs results back to gate - gateClient.submitJobResults( - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setListTenantLogsResult(tenantLogsResult) - .build()); - } catch (Exception e) { - log.error("Error listing tenant logs for job {}: {}", jobId, e.getMessage(), e); - - // Return empty log result on error - try { - gateClient.submitJobResults( - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setListTenantLogsResult( - SubmitJobResultsRequest.ListTenantLogsResult.newBuilder().build()) - .build()); - log.debug("Submitted empty result for failed job: {}", jobId); - } catch (Exception submitError) { - log.error( - "Failed to submit error result for job {}: {}", - jobId, - submitError.getMessage(), - submitError); - } - } - } - - @Override - public void setLogLevel(String jobId, StreamJobsResponse.SetLogLevelRequest setLogLevelRequest) { - log.info( - "Tenant: {} - Setting log level for job {} and logger {} to level {}", - tenantKey, - jobId, - setLogLevelRequest.getLog(), - setLogLevelRequest.getLogLevel()); - - try { - build.buf.gen.tcnapi.exile.gate.v2.SubmitJobResultsRequest.SetLogLevelResult - setLogLevelResult = null; - if (diagnosticsService != null) { - setLogLevelResult = diagnosticsService.setLogLevelWithTenant(setLogLevelRequest, tenantKey); - } else { - log.warn("DiagnosticsService is null, cannot set log level"); - - java.time.Instant now = java.time.Instant.now(); - com.google.protobuf.Timestamp updateTime = - com.google.protobuf.Timestamp.newBuilder() - .setSeconds(now.getEpochSecond()) - .setNanos(now.getNano()) - .build(); - - SubmitJobResultsRequest.SetLogLevelResult.Tenant tenant = - SubmitJobResultsRequest.SetLogLevelResult.Tenant.newBuilder() - .setName(tenantKey) - .setSatiVersion(com.tcn.exile.gateclients.v2.BuildVersion.getBuildVersion()) - .setPluginVersion(getVersion()) - .setUpdateTime(updateTime) - .setConnectedGate(getServerName()) - .build(); - - setLogLevelResult = - SubmitJobResultsRequest.SetLogLevelResult.newBuilder().setTenant(tenant).build(); - } - - // Submit results back to gate - gateClient.submitJobResults( - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setSetLogLevelResult(setLogLevelResult) - .build()); - } catch (Exception e) { - log.error("Error setting log level for job {}: {}", jobId, e.getMessage(), e); - // Return empty result on error - gateClient.submitJobResults( - SubmitJobResultsRequest.newBuilder() - .setJobId(jobId) - .setEndOfTransmission(true) - .setSetLogLevelResult(SubmitJobResultsRequest.SetLogLevelResult.newBuilder().build()) - .build()); - } - } -} diff --git a/demo/src/main/java/com/tcn/exile/demo/single/LogsController.java b/demo/src/main/java/com/tcn/exile/demo/single/LogsController.java deleted file mode 100644 index 891a123..0000000 --- a/demo/src/main/java/com/tcn/exile/demo/single/LogsController.java +++ /dev/null @@ -1,87 +0,0 @@ -/* - * (C) 2017-2025 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.demo.single; - -import ch.qos.logback.classic.LoggerContext; -import com.tcn.exile.memlogger.MemoryAppender; -import com.tcn.exile.memlogger.MemoryAppenderInstance; -import io.micronaut.context.annotation.Requires; -import io.micronaut.context.env.Environment; -import io.micronaut.http.MediaType; -import io.micronaut.http.annotation.Controller; -import io.micronaut.http.annotation.Get; -import io.micronaut.http.annotation.Produces; -import io.micronaut.serde.ObjectMapper; -import jakarta.inject.Inject; -import java.io.IOException; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -@Controller("/logs") -@Requires(bean = Environment.class) -public class LogsController { - - static final String LOGGER_PROPERTY_PREFIX = "logger"; - static final String LOGGER_LEVELS_PROPERTY_PREFIX = LOGGER_PROPERTY_PREFIX + ".levels"; - private static final Logger log = LoggerFactory.getLogger(LogsController.class); - - @Inject ObjectMapper objectMapper; - - @Inject Environment environment; - - @Get - @Produces(MediaType.APPLICATION_JSON) - public List index() throws IOException { - MemoryAppender instance = MemoryAppenderInstance.getInstance(); - if (instance == null) { - return new ArrayList<>(); - } - return List.of(); // instance.getEvents(); - } - - @Get("/loggers") - public Map loggers() { - LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); - Map loggers = new HashMap<>(); - for (var logger : loggerContext.getLoggerList()) { - - if (logger.getLevel() == null) { - loggers.put(logger.getName(), "null"); - continue; - } - loggers.put(logger.getName(), logger.getLevel().levelStr); - } - return loggers; - } - - @Get("/loggers/{logger}/level/{level}") - public String setLoggerLevel(String logger, String level) { - LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); - var loggers = loggerContext.getLoggerList(); - for (var l : loggers) { - if (l.getName().equals(logger)) { - l.setLevel(ch.qos.logback.classic.Level.toLevel(level)); - return "OK"; - } - } - return "Logger not found"; - } -} diff --git a/demo/src/main/java/com/tcn/exile/demo/single/VersionController.java b/demo/src/main/java/com/tcn/exile/demo/single/VersionController.java deleted file mode 100644 index d9614e0..0000000 --- a/demo/src/main/java/com/tcn/exile/demo/single/VersionController.java +++ /dev/null @@ -1,33 +0,0 @@ -/* - * (C) 2017-2025 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.demo.single; - -import io.micronaut.http.annotation.Controller; -import io.micronaut.http.annotation.Get; -import jakarta.inject.Inject; - -@Controller("/version") -public class VersionController { - @Inject ConfigChangeWatcher configChangeWatcher; - - @Get - public VersionInfo index() { - var ver = configChangeWatcher.getPlugin().info(); - return new VersionInfo( - ver.getCoreVersion(), ver.getServerName(), ver.getPluginVersion(), ver.getPluginName()); - } -} diff --git a/demo/src/main/java/com/tcn/exile/demo/single/VersionInfo.java b/demo/src/main/java/com/tcn/exile/demo/single/VersionInfo.java deleted file mode 100644 index 082f4ec..0000000 --- a/demo/src/main/java/com/tcn/exile/demo/single/VersionInfo.java +++ /dev/null @@ -1,23 +0,0 @@ -/* - * (C) 2017-2025 TCN Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ -package com.tcn.exile.demo.single; - -import io.micronaut.serde.annotation.Serdeable; - -@Serdeable -public record VersionInfo( - String coreVersion, String serverName, String pluginVersion, String pluginName) {} diff --git a/demo/src/main/resources/META-INF/openapi.properties b/demo/src/main/resources/META-INF/openapi.properties deleted file mode 100644 index ec10ef3..0000000 --- a/demo/src/main/resources/META-INF/openapi.properties +++ /dev/null @@ -1 +0,0 @@ -swagger-ui.enabled=true \ No newline at end of file diff --git a/demo/src/main/resources/application.yml b/demo/src/main/resources/application.yml deleted file mode 100644 index 02368ae..0000000 --- a/demo/src/main/resources/application.yml +++ /dev/null @@ -1,49 +0,0 @@ -micronaut: - control-panel: - enabled: true - allowed-environments: dev - application: - name: sati-demo - views: - dir: views - router: - static-resources: - enabled: true - css: - mapping: /css/*.css - paths: classpath:static/css - images: - mapping: /images/** - paths: classpath:static/images - swagger: - paths: classpath:META-INF/swagger - mapping: /swagger/** - swagger-ui: - paths: classpath:META-INF/swagger/views/swagger-ui - mapping: /swagger-ui/** - server: - port: 8080 - -# Disable gRPC server health check since we don't need an embedded gRPC server -endpoints: - health: - enabled: true - details-visible: ANONYMOUS - grpc: - enabled: false - -logger: - levels: - com.tcn.exile: DEBUG - com.zaxxer.hikari: DEBUG - io.micronaut.http.client: TRACE - -http: - server: - enabled: true - - -# Enable single tenant mode -sati: - tenant: - type: single \ No newline at end of file diff --git a/demo/src/main/resources/logback.xml b/demo/src/main/resources/logback.xml index c713892..f3d0ce5 100644 --- a/demo/src/main/resources/logback.xml +++ b/demo/src/main/resources/logback.xml @@ -1,22 +1,22 @@ + + + %d{HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%replace( [traceId=%mdc{traceId} spanId=%mdc{spanId}]){' \[traceId= spanId=\]', ''}%n + + - - - - %cyan(%d{HH:mm:ss.SSS}) %gray([%thread]) %highlight(%-5level) %magenta(%logger{36}) - %msg%n - - - - - - %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - true - - + + + %d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n + + - - - - + + + + + + + + diff --git a/gradle.properties b/gradle.properties index ef47cf4..5a230f0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,10 +1,8 @@ -micronautVersion=4.7.6 -micronautGradlePluginVersion=4.4.4 +grpcVersion=1.80.0 +protobufVersion=4.28.3 -grpcVersion=1.68.1 -protobufVersion=3.25.5 -exileapiProtobufVersion=34.1.0.1.20260323182149.1e342050752a -exileapiGrpcVersion=1.80.0.1.20260323182149.1e342050752a +exileapiProtobufVersion=34.1.0.1.20260409155737.b9aa1a8dba14 +exileapiGrpcVersion=1.80.0.1.20260409155737.b9aa1a8dba14 org.gradle.jvmargs=-Xmx4G diff --git a/logback-ext/build.gradle b/logback-ext/build.gradle index 2de30bc..95226f2 100644 --- a/logback-ext/build.gradle +++ b/logback-ext/build.gradle @@ -1,6 +1,6 @@ plugins { - id 'java' + id 'java-library' } repositories { @@ -12,8 +12,8 @@ dependencies { implementation 'org.slf4j:slf4j-api:2.0.9' // Logback dependencies - implementation 'ch.qos.logback:logback-classic:1.4.14' - implementation 'ch.qos.logback:logback-core:1.4.14' + api 'ch.qos.logback:logback-classic:1.4.14' + api 'ch.qos.logback:logback-core:1.4.14' // Testing testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.0' diff --git a/logback-ext/src/main/java/com/tcn/exile/memlogger/LogShipper.java b/logback-ext/src/main/java/com/tcn/exile/memlogger/LogShipper.java index 16b0a92..343799c 100644 --- a/logback-ext/src/main/java/com/tcn/exile/memlogger/LogShipper.java +++ b/logback-ext/src/main/java/com/tcn/exile/memlogger/LogShipper.java @@ -21,5 +21,10 @@ public interface LogShipper { void shipLogs(List payload); + /** Ship structured log events with level, logger, MDC, and stack trace. */ + default void shipStructuredLogs(List events) { + shipLogs(events.stream().map(e -> e.message).toList()); + } + void stop(); } diff --git a/logback-ext/src/main/java/com/tcn/exile/memlogger/MemoryAppender.java b/logback-ext/src/main/java/com/tcn/exile/memlogger/MemoryAppender.java index b5ef33e..84b8625 100644 --- a/logback-ext/src/main/java/com/tcn/exile/memlogger/MemoryAppender.java +++ b/logback-ext/src/main/java/com/tcn/exile/memlogger/MemoryAppender.java @@ -17,22 +17,42 @@ package com.tcn.exile.memlogger; import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.ThrowableProxyUtil; import ch.qos.logback.core.OutputStreamAppender; import java.io.OutputStream; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; public class MemoryAppender extends OutputStreamAppender { - private static final int MAX_SIZE = 100; + private static final int MAX_SIZE = 1000; private static final long MAX_EVENT_AGE_MS = 3600000; // 1 hour private final BlockingQueue events; - private LogShipper shipper = null; + private volatile LogShipper shipper = null; private final AtomicBoolean isStarted = new AtomicBoolean(false); private Thread cleanupThread; + private Thread shipperThread; + + /** + * Pluggable trace context extractor. Called at append time to capture the current trace/span IDs. + * Set from the core module after OTel SDK is initialized. + */ + public interface TraceContextExtractor { + String traceId(); + + String spanId(); + } + + private static volatile TraceContextExtractor traceContextExtractor; + + public static void setTraceContextExtractor(TraceContextExtractor extractor) { + traceContextExtractor = extractor; + } public MemoryAppender() { this.events = new ArrayBlockingQueue<>(MAX_SIZE); @@ -110,22 +130,39 @@ protected void append(ILoggingEvent event) { } subAppend(event); + String stackTrace = null; + if (event.getThrowableProxy() != null) { + stackTrace = ThrowableProxyUtil.asString(event.getThrowableProxy()); + } + Map mdc = + event.getMDCPropertyMap() != null ? new HashMap<>(event.getMDCPropertyMap()) : null; + + String traceId = null; + String spanId = null; + var extractor = traceContextExtractor; + if (extractor != null) { + traceId = extractor.traceId(); + spanId = extractor.spanId(); + } + LogEvent logEvent = - new LogEvent(new String(this.encoder.encode(event)), System.currentTimeMillis()); + new LogEvent( + event.getFormattedMessage(), + new String(this.encoder.encode(event)), + System.currentTimeMillis(), + event.getLevel() != null ? event.getLevel().toString() : null, + event.getLoggerName(), + event.getThreadName(), + mdc, + stackTrace, + traceId, + spanId); if (!events.offer(logEvent)) { // If queue is full, remove oldest and try again events.poll(); events.offer(logEvent); } - - if (shipper != null) { - List eventsToShip = getEventsAsList(); - if (!eventsToShip.isEmpty()) { - shipper.shipLogs(eventsToShip); - events.clear(); - } - } } public List getEventsAsList() { @@ -135,7 +172,7 @@ public List getEventsAsList() { List snapshot = new ArrayList<>(events); for (LogEvent event : snapshot) { - result.add(event.message); + result.add(event.formattedMessage != null ? event.formattedMessage : event.message); } return result; @@ -156,7 +193,7 @@ public List getEventsInTimeRange(long startTimeMs, long endTimeMs) { for (LogEvent event : snapshot) { if (event.timestamp >= startTimeMs && event.timestamp <= endTimeMs) { - result.add(event.message); + result.add(event.formattedMessage != null ? event.formattedMessage : event.message); } } @@ -175,7 +212,18 @@ public List getEventsWithTimestamps() { List snapshot = new ArrayList<>(events); for (LogEvent event : snapshot) { - result.add(new LogEvent(event.message, event.timestamp)); + result.add( + new LogEvent( + event.message, + event.formattedMessage, + event.timestamp, + event.level, + event.loggerName, + event.threadName, + event.mdc, + event.stackTrace, + event.traceId, + event.spanId)); } return result; @@ -185,33 +233,99 @@ public void enableLogShipper(LogShipper shipper) { addInfo("Log shipper enabled"); if (this.shipper == null) { this.shipper = shipper; - List eventsToShip = getEventsAsList(); - if (!eventsToShip.isEmpty()) { - shipper.shipLogs(eventsToShip); - events.clear(); - } + startShipperThread(); } } public void disableLogShipper() { addInfo("Log shipper disabled"); + stopShipperThread(); if (this.shipper != null) { this.shipper.stop(); this.shipper = null; } } + private void startShipperThread() { + shipperThread = + new Thread( + () -> { + while (isStarted.get() && shipper != null) { + try { + TimeUnit.SECONDS.sleep(10); + drainToShipper(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + }); + shipperThread.setDaemon(true); + shipperThread.setName("exile-log-shipper"); + shipperThread.start(); + } + + private void stopShipperThread() { + if (shipperThread != null) { + shipperThread.interrupt(); + shipperThread = null; + } + } + + private void drainToShipper() { + if (shipper == null || events.isEmpty()) return; + List batch = new ArrayList<>(); + events.drainTo(batch); + if (!batch.isEmpty()) { + shipper.shipStructuredLogs(batch); + } + } + public void clearEvents() { events.clear(); } public static class LogEvent { + /** Raw log message (no pattern formatting). */ public final String message; + + /** Formatted log line from the encoder pattern (for display/legacy). */ + public final String formattedMessage; + public final long timestamp; + public final String level; + public final String loggerName; + public final String threadName; + public final Map mdc; + public final String stackTrace; + public final String traceId; + public final String spanId; public LogEvent(String message, long timestamp) { + this(message, null, timestamp, null, null, null, null, null, null, null); + } + + public LogEvent( + String message, + String formattedMessage, + long timestamp, + String level, + String loggerName, + String threadName, + Map mdc, + String stackTrace, + String traceId, + String spanId) { this.message = message; + this.formattedMessage = formattedMessage; this.timestamp = timestamp; + this.level = level; + this.loggerName = loggerName; + this.threadName = threadName; + this.mdc = mdc; + this.stackTrace = stackTrace; + this.traceId = traceId; + this.spanId = spanId; } } } diff --git a/settings.gradle b/settings.gradle index 2b0c278..2991efa 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,15 +1,3 @@ - -// pluginManagement { -// repositories { -// // releases -// mavenCentral() -// // snapshots -// maven { url "https://oss.sonatype.org/content/repositories/snapshots/" } -// // Once you start using pluginManagement, you should explicitly add this, unless -// // you NEVER want to use this repository -// gradlePluginPortal() -// } -// } rootProject.name="sati" -include('core', 'logback-ext', 'demo') +include('core', 'config', 'logback-ext', 'demo')