Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
f4cd32f
Redesign sati as plain Java client for v3 protocol
namtzigla Apr 8, 2026
bca52f3
Hide protobuf types behind plain Java model layer
namtzigla Apr 8, 2026
b64e0a1
Add StreamStatus to expose work stream health
namtzigla Apr 8, 2026
4ef34a7
Add sati-config module for config lifecycle management
namtzigla Apr 8, 2026
edabb7e
Add java-library plugin to core and config modules
namtzigla Apr 8, 2026
fa3b7a2
Integrate buf gradle plugin for local proto generation
namtzigla Apr 8, 2026
d2365fb
Add test coverage for core and config modules
namtzigla Apr 8, 2026
0aa0901
Switch to pre-built buf.build Maven deps for v3 protos
namtzigla Apr 8, 2026
0f6974f
Add demo module — plain Java reference application
namtzigla Apr 8, 2026
61dfa13
Update proto package from tcnapi.exile.v3 to tcnapi.exile.gate.v3
namtzigla Apr 8, 2026
9513fdc
Bump exileapi to gate/v3 published artifacts (f723bbfb92f3)
namtzigla Apr 8, 2026
8e6ec40
Add JourneyService, remove journey RPC from ConfigService
namtzigla Apr 8, 2026
c969ed7
Add config polling to ExileClient
namtzigla Apr 8, 2026
ccbd74a
Improve debug logging for connection lifecycle
namtzigla Apr 8, 2026
72ac3ba
Fix config parsing, PKCS#1 keys, gRPC channel creation
namtzigla Apr 8, 2026
a4707a9
Gate WorkStream until first successful config poll
namtzigla Apr 8, 2026
273c40a
Unify JobHandler + EventHandler + config into Plugin interface
namtzigla Apr 8, 2026
56e7e15
Fix Map vs String comparison in DemoPlugin.onConfig
namtzigla Apr 8, 2026
542f303
Fix WorkStream starting without plugin approval
namtzigla Apr 8, 2026
5381e25
Add default listTenantLogs and setLogLevel in Plugin using logback-ext
namtzigla Apr 8, 2026
7c2d835
Extract PluginBase abstract class for common operations
namtzigla Apr 8, 2026
c270376
Add /logs endpoint and debug logging for MemoryAppender
namtzigla Apr 8, 2026
277dc2c
Implement comprehensive RunDiagnostics in PluginBase
namtzigla Apr 8, 2026
bb6eed6
Implement seppuku shutdown in PluginBase
namtzigla Apr 8, 2026
e90a2d2
Reuse gRPC channel across stream reconnects
namtzigla Apr 8, 2026
c39d99a
Add channel reuse benchmark
namtzigla Apr 8, 2026
4bbdc7b
Add message throughput and round-trip latency benchmarks
namtzigla Apr 8, 2026
977ac59
add flow control window tuning and benchmark tests
namtzigla Apr 9, 2026
1830233
add OTel metrics and structured log shipping to gate TelemetryService
namtzigla Apr 9, 2026
0c75e4f
add org_id and config_name to metric attributes, send raw log messages
namtzigla Apr 9, 2026
c600891
capture trace_id and span_id in log records at append time
namtzigla Apr 9, 2026
b22ae4a
ship log messages as JSON payload with all structured fields
namtzigla Apr 9, 2026
4e7374d
add OTel tracing spans to work item processing for log trace correlation
namtzigla Apr 9, 2026
cdde02e
fix GlobalOpenTelemetry.set already called on repeated MetricsManager…
namtzigla Apr 9, 2026
4d365f5
parse trace_parent from WorkItem and create child spans for work proc…
namtzigla Apr 9, 2026
891f6b9
propagate trace context to async response logs (ResultAccepted, Lease…
namtzigla Apr 9, 2026
2082dc4
show traceId and spanId in logback console output
namtzigla Apr 9, 2026
03fd850
hide trace context from log output when not available
namtzigla Apr 9, 2026
64f454d
add per-method metrics for all plugin methods
namtzigla Apr 9, 2026
0c2af36
aggressive reconnect: faster keepalive, smarter backoff, reconnect me…
namtzigla Apr 9, 2026
07d8bed
fix reconnect storm: apply backoff on UNAVAILABLE errors
namtzigla Apr 9, 2026
0c7897d
fix reconnect duration: clear lastDisconnect after recording to avoid…
namtzigla Apr 10, 2026
99b7446
use certificate_name from config file for metric grouping
namtzigla Apr 10, 2026
af8b0d3
periodic pull every 2s: sati tells gate its available capacity
namtzigla Apr 10, 2026
0210456
send Nack instead of Result(error) for failed events
namtzigla Apr 10, 2026
a075ea5
increase throughput: maxConcurrency 5→20, pull interval 2s→1s
namtzigla Apr 10, 2026
29d1171
increase maxConcurrency from 20 to 100 for higher event throughput
namtzigla Apr 10, 2026
cbfd0fb
single Pull(MAX_VALUE) on registration, no periodic pulling
namtzigla Apr 10, 2026
02c3e22
apply spotless formatting
namtzigla Apr 11, 2026
45c5a95
fix BackoffTest: update expected values for 500ms base / 10s max
namtzigla Apr 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 14 additions & 29 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -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()
}
Expand All @@ -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 {
Expand All @@ -28,16 +21,24 @@ 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'
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.8.2'
testImplementation 'org.junit.jupiter:junit-jupiter-params:5.8.2'
testImplementation 'org.mockito:mockito-core:4.0.0'
}

test {
useJUnitPlatform()
}

publishing {
publications {
maven(MavenPublication) {
Expand All @@ -56,40 +57,24 @@ 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()
}
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/**")
}
}
}
11 changes: 11 additions & 0 deletions config/build.gradle
Original file line number Diff line number Diff line change
@@ -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")
}
113 changes: 113 additions & 0 deletions config/src/main/java/com/tcn/exile/config/CertificateRotator.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>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 "";
}
}
}
168 changes: 168 additions & 0 deletions config/src/main/java/com/tcn/exile/config/ConfigFileWatcher.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>Replaces the 400+ line ConfigChangeWatcher that was copy-pasted across all integrations.
*
* <p>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<Path> DEFAULT_WATCH_DIRS =
List.of(Path.of("/workdir/config"), Path.of("workdir/config"));

private final List<Path> 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<Path> 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<Path> 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);
}
}
}
}
Loading
Loading