Files
go-caatsm/internal/infra/nats/jetstream.go
T

173 lines
5.6 KiB
Go

package nats
import (
"caatsm/internal/infra/config"
"crypto/tls"
"crypto/x509"
"fmt"
"os"
"strings"
"github.com/nats-io/nats.go"
"go.uber.org/zap"
)
// ProvideNATSConn creates a reusable NATS connection with optional authentication.
func ProvideNATSConn(cfg *config.Config, logger *zap.Logger) (*nats.Conn, error) {
opts := []nats.Option{
nats.RetryOnFailedConnect(true),
nats.Timeout(cfg.Timeouts.Server),
nats.ReconnectWait(cfg.Timeouts.ReconnectWait),
// Use infinite reconnects so the app survives long NATS outages (e.g. docker compose down/up).
nats.MaxReconnects(-1),
nats.DisconnectErrHandler(func(nc *nats.Conn, err error) {
if err != nil {
logger.Warn("NATS disconnected", zap.Error(err))
}
}),
nats.ReconnectHandler(func(nc *nats.Conn) {
safeURL := sanitizeURLForLogging(nc.ConnectedUrl())
logger.Info("NATS reconnected", zap.String("url", safeURL))
}),
}
// Apply authentication options
authOpts, err := buildAuthOptions(&cfg.NATS.Auth, logger)
if err != nil {
return nil, fmt.Errorf("failed to build auth options: %w", err)
}
opts = append(opts, authOpts...)
nc, err := nats.Connect(cfg.NATS.URL, opts...)
if err != nil {
safeURL := sanitizeURLForLogging(cfg.NATS.URL)
logger.Error("failed to connect to NATS",
zap.String("url", safeURL),
zap.Duration("timeout", cfg.Timeouts.Server),
zap.Duration("reconnect_wait", cfg.Timeouts.ReconnectWait),
zap.Error(err),
)
return nil, fmt.Errorf("failed to connect to NATS: %w", err)
}
return nc, nil
}
// buildAuthOptions builds NATS connection options based on authentication configuration.
func buildAuthOptions(auth *config.NATSAuthConfig, logger *zap.Logger) ([]nats.Option, error) {
var opts []nats.Option
authMethods := 0
// Token authentication (highest priority)
if auth.Token != "" {
authMethods++
logger.Debug("Using NATS token authentication")
opts = append(opts, nats.Token(auth.Token))
}
// Credentials file authentication
if auth.CredentialsFile != "" {
authMethods++
if authMethods > 1 {
return nil, fmt.Errorf("multiple authentication methods specified: only one of token, credentials_file, or user/password can be used")
}
logger.Debug("Using NATS credentials file authentication", zap.String("file", auth.CredentialsFile))
opts = append(opts, nats.UserCredentials(auth.CredentialsFile))
}
// User/Password authentication
if auth.User != "" || auth.Password != "" {
authMethods++
if authMethods > 1 {
return nil, fmt.Errorf("multiple authentication methods specified: only one of token, credentials_file, or user/password can be used")
}
if auth.User == "" || auth.Password == "" {
return nil, fmt.Errorf("both user and password must be specified for user/password authentication")
}
logger.Debug("Using NATS user/password authentication", zap.String("user", auth.User))
opts = append(opts, nats.UserInfo(auth.User, auth.Password))
}
// TLS configuration
if auth.TLSEnabled {
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
}
// Load client certificate and key if provided
if auth.TLSCertFile != "" && auth.TLSKeyFile != "" {
cert, err := tls.LoadX509KeyPair(auth.TLSCertFile, auth.TLSKeyFile)
if err != nil {
return nil, fmt.Errorf("failed to load TLS certificate: %w", err)
}
tlsConfig.Certificates = []tls.Certificate{cert}
logger.Debug("Loaded TLS client certificate", zap.String("cert", auth.TLSCertFile))
}
// Load CA certificate for server verification if provided
if auth.TLSCAFile != "" {
caCert, err := os.ReadFile(auth.TLSCAFile)
if err != nil {
return nil, fmt.Errorf("failed to read CA certificate file: %w", err)
}
caCertPool := x509.NewCertPool()
if !caCertPool.AppendCertsFromPEM(caCert) {
return nil, fmt.Errorf("failed to parse CA certificate from %s", auth.TLSCAFile)
}
tlsConfig.RootCAs = caCertPool
logger.Debug("Loaded TLS CA certificate", zap.String("ca_file", auth.TLSCAFile))
}
opts = append(opts, nats.Secure(tlsConfig))
logger.Debug("TLS enabled for NATS connection")
}
return opts, nil
}
// ProvideJetStream creates a NATS JetStream context using an existing connection.
// Returns nil, nil when cfg.NATS.Mode == "core" to support plain NATS servers without JetStream.
func ProvideJetStream(nc *nats.Conn, cfg *config.Config, logger *zap.Logger) (nats.JetStreamContext, error) {
mode := strings.ToLower(cfg.NATS.Mode)
if mode == "core" {
logger.Info("Skipping JetStream initialization for core NATS mode")
return nil, nil
}
// Get JetStream context
js, err := nc.JetStream()
if err != nil {
safeURL := sanitizeURLForLogging(cfg.NATS.URL)
logger.Error("failed to get JetStream context",
zap.String("url", safeURL),
zap.Error(err),
)
nc.Close()
return nil, fmt.Errorf("failed to get JetStream context: %w", err)
}
// Ensure the stream exists using StreamManager
streamName := cfg.NATS.Stream
consumerSubject := cfg.EffectiveSubscriptionTopic()
publisherSubject := strings.TrimSpace(cfg.Publisher.Topic)
streamSubjects := dedupeSubjects([]string{consumerSubject, publisherSubject})
if len(streamSubjects) == 0 {
logger.Error("no subjects configured for JetStream stream",
zap.String("stream", streamName),
zap.String("consumer_subject", consumerSubject),
zap.String("publisher_subject", publisherSubject),
)
nc.Close()
return nil, fmt.Errorf("no subjects configured for JetStream stream %s", streamName)
}
streamManager := NewStreamManagerWithConfig(js, streamName, streamSubjects, &cfg.NATS.StreamLimits, logger)
if err := streamManager.EnsureStream(); err != nil {
nc.Close()
return nil, err
}
return js, nil
}