Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,34 @@
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.scheduling.annotation.EnableScheduling;

/**
* Entry point for the Distributed Secrets Vault application.
* <p>
* This Spring Boot application provides a distributed, fault-tolerant secrets
* management system that uses <b>Shamir's Secret Sharing</b> to split secrets
* into shards distributed across multiple nodes. It relies on a two-phase
* prepare/commit protocol coordinated through Kafka, with ScaleCube for
* service discovery and Redis for shard storage.
* <p>
* Key annotations:
* <ul>
* <li>{@code @ConfigurationPropertiesScan} — auto-discovers configuration
* property classes such as {@link edu.yu.capstone.DistributedSecretsVault.config.ClusterConfig},
* {@link edu.yu.capstone.DistributedSecretsVault.config.NetworkConfig}, etc.</li>
* <li>{@code @EnableScheduling} — enables scheduled tasks such as
* {@link edu.yu.capstone.DistributedSecretsVault.service.internal.PendingActionsBuffer#evictExpired()}.</li>
* </ul>
*/
@SpringBootApplication
@ConfigurationPropertiesScan
@EnableScheduling
public class DistributedSecretsVaultApplication {

/**
* Launches the Spring Boot application.
*
* @param args command-line arguments forwarded to Spring Boot
*/
public static void main(String[] args) {
SpringApplication.run(DistributedSecretsVaultApplication.class, args);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,37 @@

import lombok.Data;

/**
* Configuration properties for cluster topology and distributed operation tuning.
* <p>
* Bound from the {@code cluster.*} prefix in {@code application.yml}.
* These values control how secrets are split, how many nodes must acknowledge
* a write, and how long pending operations remain valid.
*
* @see edu.yu.capstone.DistributedSecretsVault.service.internal.PendingActionsBuffer
*/
@Validated
@ConfigurationProperties(prefix = "cluster")
@Data
public class ClusterConfig {
/** Total number of nodes in the cluster (N in Shamir's scheme). */
private int totalNodes;

/** Minimum number of shards required to reconstruct a secret (K in Shamir's scheme). */
private int thresholdK;

/** Minimum number of peer ACKs required to commit a write (quorum M). */
private int quorumM;

/** Maximum time (ms) a pending action remains buffered before eviction. */
private long lockTimeoutMillis;

/** Maximum time (ms) to wait for all peer responses during a write operation. */
private long writeTimeoutMillis;

/** Whether read-repair is enabled (automatically re-split when shard count is low). */
private boolean repairEnabled = true;

/** Number of extra shards above the threshold before read-repair triggers. */
private int repairTriggerBuffer = 1;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,31 @@
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.config.TopicBuilder;

/**
* Kafka topic configuration for the distributed commit protocol.
* <p>
* Defines the two Kafka topics used by the system:
* <ul>
* <li>{@link #COORDINATION_TOPIC} — reserved for future coordination messages</li>
* <li>{@link #COMMIT_TOPIC} — carries {@link edu.yu.capstone.DistributedSecretsVault.dto.internal.CommitMessage}
* payloads that instruct all nodes to finalize a buffered prepare operation</li>
* </ul>
* Both topics are configured with a 4-hour retention period.
*/
@Configuration
public class KafkaConfig {

/** Topic name for general coordination messages between nodes. */
public static final String COORDINATION_TOPIC = "secrets-coordination";

/** Topic name for commit messages broadcast after quorum is reached. */
public static final String COMMIT_TOPIC = "secrets-commit";

/**
* Creates the coordination Kafka topic if it does not already exist.
*
* @return a {@link NewTopic} with 1 partition, 1 replica, and 4-hour retention
*/
@Bean
public NewTopic coordinationTopic() {
return TopicBuilder.name(COORDINATION_TOPIC)
Expand All @@ -20,6 +39,11 @@ public NewTopic coordinationTopic() {
.build();
}

/**
* Creates the commit Kafka topic if it does not already exist.
*
* @return a {@link NewTopic} with 1 partition, 1 replica, and 4-hour retention
*/
@Bean
public NewTopic commitTopic() {
return TopicBuilder.name(COMMIT_TOPIC)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,32 @@

import lombok.Data;

/**
* Configuration properties for node identity and network settings.
* <p>
* Bound from the {@code network.*} prefix in {@code application.yml}.
* These settings identify the current node and configure network-level
* parameters such as multicast discovery and TCP timeouts.
*/
@Validated
@ConfigurationProperties(prefix = "network")
@Data
public class NetworkConfig {
/** Unique identifier for this node within the cluster. */
private String nodeId;

/** Hostname or IP address the node binds to for incoming connections. */
private String bindHost;

/** Port the node binds to for incoming connections. */
private int bindPort;

/** Multicast group address used for node discovery. */
private String multicastGroup;

/** Port used for multicast discovery. */
private int multicastPort;

/** Timeout (ms) for TCP connections between nodes. */
private int tcpTimeoutMillis;
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@
@Configuration
public class RestClientConfig {

/**
* Creates a {@link RestClient} with connect and read timeouts derived from
* {@link ClusterConfig#getWriteTimeoutMillis()}, falling back to 5000ms.
*
* @param clusterConfig cluster configuration providing timeout values
* @return a configured {@link RestClient} instance
*/
@Bean
public RestClient restClient(ClusterConfig clusterConfig) {
long timeoutMs = clusterConfig.getWriteTimeoutMillis();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@
import java.util.ArrayList;
import java.util.List;

/**
* Spring configuration that bootstraps a ScaleCube Microservices node for
* cluster membership and service discovery.
* <p>
* Each application instance joins a ScaleCube cluster by resolving seed
* members via DNS (headless Kubernetes service) and advertising its pod IP.
* The node exposes a simple {@link PingService} to verify cluster connectivity.
* <p>
* Active only when the {@code test} profile is <b>not</b> active or the
* {@code scalecube-single-node} profile <b>is</b> active.
*
* @see edu.yu.capstone.DistributedSecretsVault.service.internal.NodeClient
* @see edu.yu.capstone.DistributedSecretsVault.health.ScaleCubeHealthIndicator
*/
@Configuration
@Profile("!test | scalecube-single-node")
public class ScaleCubeConfig {
Expand All @@ -27,8 +41,18 @@ public class ScaleCubeConfig {
private static final int DEFAULT_DNS_RESOLVE_MAX_ATTEMPTS = 5;
private static final long DEFAULT_DNS_RESOLVE_RETRY_DELAY_MS = 1000L;

/**
* Functional interface abstracting DNS resolution for testability.
*/
@FunctionalInterface
interface DnsResolver {
/**
* Resolve all IP addresses for the given hostname.
*
* @param host the hostname to resolve
* @return array of resolved {@link InetAddress}es
* @throws UnknownHostException if the host cannot be resolved
*/
InetAddress[] resolveAllByName(String host) throws UnknownHostException;
}

Expand All @@ -42,6 +66,18 @@ public interface PingService {

private Microservices microservices;

/**
* Creates and starts a ScaleCube {@link Microservices} node.
* <p>
* The method reads environment variables ({@code POD_IP}, {@code CLUSTER_PORT},
* {@code SEED_DNS_HOST}, {@code SEED_DNS_PORT}, {@code NODE_NAME}) to configure
* the node's address, cluster port, and seed members. Falls back to safe
* defaults for local (non-Kubernetes) development.
*
* @return a fully started {@link Microservices} instance
* @throws IllegalStateException if {@code SEED_DNS_HOST} is not set or DNS
* resolution fails after all retry attempts
*/
@Bean
public Microservices scalecubeMicroservices() {
// 1. Read environment variables mapped from the k8s manifest
Expand Down Expand Up @@ -104,6 +140,9 @@ public Microservices scalecubeMicroservices() {
return microservices;
}

/**
* Gracefully shuts down the ScaleCube node on application context closure.
*/
@PreDestroy
public void stopScaleCube() {
if (microservices != null) {
Expand All @@ -112,6 +151,17 @@ public void stopScaleCube() {
}
}

/**
* Attempts DNS resolution with retry logic.
*
* @param seedDnsHost the hostname to resolve (e.g. headless k8s service)
* @param seedDnsPort port each seed member listens on
* @param maxAttempts maximum number of resolution attempts
* @param retryDelayMs delay (ms) between retries
* @param dnsResolver abstraction for {@link InetAddress#getAllByName}
* @return array of resolved ScaleCube {@link Address}es
* @throws IllegalStateException if all attempts fail
*/
static Address[] resolveSeedMembersWithRetry(String seedDnsHost, int seedDnsPort, int maxAttempts,
long retryDelayMs, DnsResolver dnsResolver) {
RuntimeException lastFailure = null;
Expand All @@ -134,6 +184,16 @@ static Address[] resolveSeedMembersWithRetry(String seedDnsHost, int seedDnsPort
lastFailure);
}

/**
* Resolves all IP addresses for the given DNS host and converts them to
* ScaleCube {@link Address}es.
*
* @param seedDnsHost the hostname to resolve
* @param seedDnsPort port to pair with each resolved IP
* @param dnsResolver abstraction for DNS lookups
* @return array of ScaleCube addresses
* @throws IllegalStateException if DNS returns no addresses or lookup fails
*/
static Address[] resolveSeedMembers(String seedDnsHost, int seedDnsPort, DnsResolver dnsResolver) {
try {
InetAddress[] addresses = dnsResolver.resolveAllByName(seedDnsHost);
Expand All @@ -150,6 +210,9 @@ static Address[] resolveSeedMembers(String seedDnsHost, int seedDnsPort, DnsReso
}
}

/**
* Sleeps for the specified duration, restoring the interrupt flag if interrupted.
*/
private static void sleepQuietly(long retryDelayMs) {
try {
Thread.sleep(retryDelayMs);
Expand All @@ -159,6 +222,12 @@ private static void sleepQuietly(long retryDelayMs) {
}
}

/**
* Reads a value from environment variables, falling back to system properties.
*
* @param key the environment variable / system property name
* @return the value, or {@code null} if not set
*/
private static String readEnvOrSystemProperty(String key) {
String value = System.getenv(key);
if (value == null || value.isBlank()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,24 @@

import lombok.Data;

/**
* Configuration properties for authentication and authorization.
* <p>
* Bound from the {@code security.*} prefix in {@code application.yml}.
* When {@link #authEnabled} is {@code true}, incoming requests must include
* a valid OAuth token issued by {@link #oauthIssuer} for the configured
* {@link #oauthAudience}.
*/
@Validated
@ConfigurationProperties(prefix = "security")
@Data
public class SecurityConfig {
/** Whether authentication is enforced for incoming API requests. */
private boolean authEnabled;

/** OAuth 2.0 issuer URL used to validate JWT tokens (e.g. {@code https://accounts.google.com}). */
private String oauthIssuer;

/** Expected OAuth audience claim in the JWT. */
private String oauthAudience;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,25 @@

import lombok.Data;

/**
* Configuration properties for the Redis storage backend.
* <p>
* Bound from the {@code storage.*} prefix in {@code application.yml}.
* Each node connects to its own Redis instance (sidecar pattern) to store
* its assigned secret shards.
*
* @see edu.yu.capstone.DistributedSecretsVault.repository.impl.RedisSecretPartRepository
*/
@Validated
@ConfigurationProperties(prefix = "storage")
@Data
public class StorageConfig {
/** Hostname of the Redis instance. */
private String redisHost;

/** Port of the Redis instance. */
private int redisPort;

/** Password for Redis authentication (empty string if unauthenticated). */
private String redisPassword;
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@
import edu.yu.capstone.DistributedSecretsVault.config.ScaleCubeConfig.PingService;
import edu.yu.capstone.DistributedSecretsVault.dto.response.ClusterStatusResponse;

/**
* REST controller for cluster health and diagnostics.
* <p>
* Provides endpoints to verify cluster connectivity, list discovered nodes,
* and retrieve a summary of cluster health. Only available when ScaleCube
* is active (i.e., when a {@link Microservices} bean exists).
*/
@RestController
@RequestMapping("/api/v1/cluster")
@ConditionalOnBean(Microservices.class)
Expand All @@ -22,6 +29,14 @@ public class ClusterController {
@Autowired
private Microservices microservices;

/**
* Sends a ping request through ScaleCube to any available node in the cluster.
* <p>
* Useful for verifying that the ScaleCube service mesh is operational and
* that at least one peer can process requests.
*
* @return a "Pong from {nodeName}" response from the receiving node
*/
@GetMapping("/ping")
public ResponseEntity<String> ping() {
// Creates a proxy that routes the ping request over ScaleCube to any available node
Expand All @@ -30,6 +45,11 @@ public ResponseEntity<String> ping() {
return ResponseEntity.ok(response);
}

/**
* Lists all nodes discovered by ScaleCube's service discovery.
*
* @return list of strings in the format {@code "nodeId @ host:port"}
*/
@GetMapping("/nodes")
public ResponseEntity<List<String>> listNodes() {
List<String> nodes = microservices.serviceEndpoints().stream()
Expand All @@ -38,6 +58,14 @@ public ResponseEntity<List<String>> listNodes() {
return ResponseEntity.ok(nodes);
}

/**
* Returns a summary of the cluster's health.
* <p>
* Currently uses a simplified model where all discovered nodes are
* assumed healthy.
*
* @return a {@link ClusterStatusResponse} with node counts
*/
@GetMapping("/status")
public ResponseEntity<ClusterStatusResponse> status() {
ClusterStatusResponse response = new ClusterStatusResponse();
Expand Down
Loading
Loading