callbacks service draft
This commit is contained in:
182
api/edge/callbacks/internal/config/module.go
Normal file
182
api/edge/callbacks/internal/config/module.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/db"
|
||||
"github.com/tech/sendico/pkg/messaging"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultShutdownTimeoutSeconds = 15
|
||||
defaultMetricsAddress = ":9420"
|
||||
defaultIngestStream = "CALLBACKS"
|
||||
defaultIngestSubject = "callbacks.events"
|
||||
defaultIngestDurable = "callbacks-ingest"
|
||||
defaultIngestBatchSize = 32
|
||||
defaultIngestFetchTimeoutMS = 2000
|
||||
defaultIngestIdleSleepMS = 500
|
||||
defaultTaskCollection = "callback_tasks"
|
||||
defaultInboxCollection = "callback_inbox"
|
||||
defaultEndpointsCollection = "webhook_endpoints"
|
||||
defaultWorkerConcurrency = 8
|
||||
defaultWorkerPollIntervalMS = 200
|
||||
defaultLockTTLSeconds = 30
|
||||
defaultRequestTimeoutMS = 10000
|
||||
defaultMaxAttempts = 8
|
||||
defaultMinDelayMS = 1000
|
||||
defaultMaxDelayMS = 300000
|
||||
defaultJitterRatio = 0.20
|
||||
defaultDNSResolveTimeoutMS = 2000
|
||||
defaultSecretsVaultField = "value"
|
||||
)
|
||||
|
||||
// Loader parses callbacks service configuration.
|
||||
type Loader interface {
|
||||
Load(path string) (*Config, error)
|
||||
}
|
||||
|
||||
// Config is the full callbacks service configuration.
|
||||
type Config struct {
|
||||
Runtime *RuntimeConfig `yaml:"runtime"`
|
||||
Metrics *MetricsConfig `yaml:"metrics"`
|
||||
Database *db.Config `yaml:"database"`
|
||||
Messaging *messaging.Config `yaml:"messaging"`
|
||||
Ingest IngestConfig `yaml:"ingest"`
|
||||
Delivery DeliveryConfig `yaml:"delivery"`
|
||||
Security SecurityConfig `yaml:"security"`
|
||||
Secrets SecretsConfig `yaml:"secrets"`
|
||||
}
|
||||
|
||||
// RuntimeConfig contains process lifecycle settings.
|
||||
type RuntimeConfig struct {
|
||||
ShutdownTimeoutSeconds int `yaml:"shutdown_timeout_seconds"`
|
||||
}
|
||||
|
||||
func (c *RuntimeConfig) ShutdownTimeout() time.Duration {
|
||||
if c == nil || c.ShutdownTimeoutSeconds <= 0 {
|
||||
return defaultShutdownTimeoutSeconds * time.Second
|
||||
}
|
||||
return time.Duration(c.ShutdownTimeoutSeconds) * time.Second
|
||||
}
|
||||
|
||||
// MetricsConfig configures observability endpoints.
|
||||
type MetricsConfig struct {
|
||||
Address string `yaml:"address"`
|
||||
}
|
||||
|
||||
func (c *MetricsConfig) ListenAddress() string {
|
||||
if c == nil || c.Address == "" {
|
||||
return defaultMetricsAddress
|
||||
}
|
||||
return c.Address
|
||||
}
|
||||
|
||||
// IngestConfig configures JetStream ingestion.
|
||||
type IngestConfig struct {
|
||||
Stream string `yaml:"stream"`
|
||||
Subject string `yaml:"subject"`
|
||||
Durable string `yaml:"durable"`
|
||||
BatchSize int `yaml:"batch_size"`
|
||||
FetchTimeoutMS int `yaml:"fetch_timeout_ms"`
|
||||
IdleSleepMS int `yaml:"idle_sleep_ms"`
|
||||
}
|
||||
|
||||
func (c *IngestConfig) FetchTimeout() time.Duration {
|
||||
if c.FetchTimeoutMS <= 0 {
|
||||
return time.Duration(defaultIngestFetchTimeoutMS) * time.Millisecond
|
||||
}
|
||||
return time.Duration(c.FetchTimeoutMS) * time.Millisecond
|
||||
}
|
||||
|
||||
func (c *IngestConfig) IdleSleep() time.Duration {
|
||||
if c.IdleSleepMS <= 0 {
|
||||
return time.Duration(defaultIngestIdleSleepMS) * time.Millisecond
|
||||
}
|
||||
return time.Duration(c.IdleSleepMS) * time.Millisecond
|
||||
}
|
||||
|
||||
// DeliveryConfig controls dispatcher behavior.
|
||||
type DeliveryConfig struct {
|
||||
WorkerConcurrency int `yaml:"worker_concurrency"`
|
||||
WorkerPollMS int `yaml:"worker_poll_ms"`
|
||||
LockTTLSeconds int `yaml:"lock_ttl_seconds"`
|
||||
RequestTimeoutMS int `yaml:"request_timeout_ms"`
|
||||
MaxAttempts int `yaml:"max_attempts"`
|
||||
MinDelayMS int `yaml:"min_delay_ms"`
|
||||
MaxDelayMS int `yaml:"max_delay_ms"`
|
||||
JitterRatio float64 `yaml:"jitter_ratio"`
|
||||
}
|
||||
|
||||
func (c *DeliveryConfig) WorkerPollInterval() time.Duration {
|
||||
if c.WorkerPollMS <= 0 {
|
||||
return time.Duration(defaultWorkerPollIntervalMS) * time.Millisecond
|
||||
}
|
||||
return time.Duration(c.WorkerPollMS) * time.Millisecond
|
||||
}
|
||||
|
||||
func (c *DeliveryConfig) LockTTL() time.Duration {
|
||||
if c.LockTTLSeconds <= 0 {
|
||||
return time.Duration(defaultLockTTLSeconds) * time.Second
|
||||
}
|
||||
return time.Duration(c.LockTTLSeconds) * time.Second
|
||||
}
|
||||
|
||||
func (c *DeliveryConfig) RequestTimeout() time.Duration {
|
||||
if c.RequestTimeoutMS <= 0 {
|
||||
return time.Duration(defaultRequestTimeoutMS) * time.Millisecond
|
||||
}
|
||||
return time.Duration(c.RequestTimeoutMS) * time.Millisecond
|
||||
}
|
||||
|
||||
func (c *DeliveryConfig) MinDelay() time.Duration {
|
||||
if c.MinDelayMS <= 0 {
|
||||
return time.Duration(defaultMinDelayMS) * time.Millisecond
|
||||
}
|
||||
return time.Duration(c.MinDelayMS) * time.Millisecond
|
||||
}
|
||||
|
||||
func (c *DeliveryConfig) MaxDelay() time.Duration {
|
||||
if c.MaxDelayMS <= 0 {
|
||||
return time.Duration(defaultMaxDelayMS) * time.Millisecond
|
||||
}
|
||||
return time.Duration(c.MaxDelayMS) * time.Millisecond
|
||||
}
|
||||
|
||||
// SecurityConfig controls outbound callback safety checks.
|
||||
type SecurityConfig struct {
|
||||
RequireHTTPS bool `yaml:"require_https"`
|
||||
AllowedHosts []string `yaml:"allowed_hosts"`
|
||||
AllowedPorts []int `yaml:"allowed_ports"`
|
||||
DNSResolveTimeout int `yaml:"dns_resolve_timeout_ms"`
|
||||
}
|
||||
|
||||
func (c *SecurityConfig) DNSResolveTimeoutMS() time.Duration {
|
||||
if c.DNSResolveTimeout <= 0 {
|
||||
return time.Duration(defaultDNSResolveTimeoutMS) * time.Millisecond
|
||||
}
|
||||
return time.Duration(c.DNSResolveTimeout) * time.Millisecond
|
||||
}
|
||||
|
||||
// SecretsConfig controls secret lookup behavior.
|
||||
type SecretsConfig struct {
|
||||
CacheTTLSeconds int `yaml:"cache_ttl_seconds"`
|
||||
Static map[string]string `yaml:"static"`
|
||||
Vault VaultSecretsConfig `yaml:"vault"`
|
||||
}
|
||||
|
||||
// VaultSecretsConfig controls Vault KV secret resolution.
|
||||
type VaultSecretsConfig struct {
|
||||
Address string `yaml:"address"`
|
||||
TokenEnv string `yaml:"token_env"`
|
||||
Namespace string `yaml:"namespace"`
|
||||
MountPath string `yaml:"mount_path"`
|
||||
DefaultField string `yaml:"default_field"`
|
||||
}
|
||||
|
||||
func (c *SecretsConfig) CacheTTL() time.Duration {
|
||||
if c == nil || c.CacheTTLSeconds <= 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(c.CacheTTLSeconds) * time.Second
|
||||
}
|
||||
162
api/edge/callbacks/internal/config/service.go
Normal file
162
api/edge/callbacks/internal/config/service.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type service struct {
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
// New creates a configuration loader.
|
||||
func New(logger mlogger.Logger) Loader {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &service{logger: logger.Named("config")}
|
||||
}
|
||||
|
||||
func (s *service) Load(path string) (*Config, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil, merrors.InvalidArgument("config path is required", "path")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to read config file", zap.String("path", path), zap.Error(err))
|
||||
return nil, merrors.InternalWrap(err, "failed to read callbacks config")
|
||||
}
|
||||
|
||||
cfg := &Config{}
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
s.logger.Error("Failed to parse config yaml", zap.String("path", path), zap.Error(err))
|
||||
return nil, merrors.InternalWrap(err, "failed to parse callbacks config")
|
||||
}
|
||||
|
||||
s.applyDefaults(cfg)
|
||||
if err := s.validate(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *service) applyDefaults(cfg *Config) {
|
||||
if cfg.Runtime == nil {
|
||||
cfg.Runtime = &RuntimeConfig{ShutdownTimeoutSeconds: defaultShutdownTimeoutSeconds}
|
||||
}
|
||||
|
||||
if cfg.Metrics == nil {
|
||||
cfg.Metrics = &MetricsConfig{Address: defaultMetricsAddress}
|
||||
} else if strings.TrimSpace(cfg.Metrics.Address) == "" {
|
||||
cfg.Metrics.Address = defaultMetricsAddress
|
||||
}
|
||||
|
||||
if strings.TrimSpace(cfg.Ingest.Stream) == "" {
|
||||
cfg.Ingest.Stream = defaultIngestStream
|
||||
}
|
||||
if strings.TrimSpace(cfg.Ingest.Subject) == "" {
|
||||
cfg.Ingest.Subject = defaultIngestSubject
|
||||
}
|
||||
if strings.TrimSpace(cfg.Ingest.Durable) == "" {
|
||||
cfg.Ingest.Durable = defaultIngestDurable
|
||||
}
|
||||
if cfg.Ingest.BatchSize <= 0 {
|
||||
cfg.Ingest.BatchSize = defaultIngestBatchSize
|
||||
}
|
||||
if cfg.Ingest.FetchTimeoutMS <= 0 {
|
||||
cfg.Ingest.FetchTimeoutMS = defaultIngestFetchTimeoutMS
|
||||
}
|
||||
if cfg.Ingest.IdleSleepMS <= 0 {
|
||||
cfg.Ingest.IdleSleepMS = defaultIngestIdleSleepMS
|
||||
}
|
||||
|
||||
if cfg.Delivery.WorkerConcurrency <= 0 {
|
||||
cfg.Delivery.WorkerConcurrency = defaultWorkerConcurrency
|
||||
}
|
||||
if cfg.Delivery.WorkerPollMS <= 0 {
|
||||
cfg.Delivery.WorkerPollMS = defaultWorkerPollIntervalMS
|
||||
}
|
||||
if cfg.Delivery.LockTTLSeconds <= 0 {
|
||||
cfg.Delivery.LockTTLSeconds = defaultLockTTLSeconds
|
||||
}
|
||||
if cfg.Delivery.RequestTimeoutMS <= 0 {
|
||||
cfg.Delivery.RequestTimeoutMS = defaultRequestTimeoutMS
|
||||
}
|
||||
if cfg.Delivery.MaxAttempts <= 0 {
|
||||
cfg.Delivery.MaxAttempts = defaultMaxAttempts
|
||||
}
|
||||
if cfg.Delivery.MinDelayMS <= 0 {
|
||||
cfg.Delivery.MinDelayMS = defaultMinDelayMS
|
||||
}
|
||||
if cfg.Delivery.MaxDelayMS <= 0 {
|
||||
cfg.Delivery.MaxDelayMS = defaultMaxDelayMS
|
||||
}
|
||||
if cfg.Delivery.JitterRatio <= 0 {
|
||||
cfg.Delivery.JitterRatio = defaultJitterRatio
|
||||
}
|
||||
if cfg.Delivery.JitterRatio > 1 {
|
||||
cfg.Delivery.JitterRatio = 1
|
||||
}
|
||||
|
||||
if cfg.Security.DNSResolveTimeout <= 0 {
|
||||
cfg.Security.DNSResolveTimeout = defaultDNSResolveTimeoutMS
|
||||
}
|
||||
if len(cfg.Security.AllowedPorts) == 0 {
|
||||
cfg.Security.AllowedPorts = []int{443}
|
||||
}
|
||||
if !cfg.Security.RequireHTTPS {
|
||||
cfg.Security.RequireHTTPS = true
|
||||
}
|
||||
|
||||
if cfg.Secrets.Static == nil {
|
||||
cfg.Secrets.Static = map[string]string{}
|
||||
}
|
||||
if strings.TrimSpace(cfg.Secrets.Vault.DefaultField) == "" {
|
||||
cfg.Secrets.Vault.DefaultField = defaultSecretsVaultField
|
||||
}
|
||||
}
|
||||
|
||||
func (s *service) validate(cfg *Config) error {
|
||||
if cfg.Database == nil {
|
||||
return merrors.InvalidArgument("database configuration is required", "database")
|
||||
}
|
||||
if cfg.Messaging == nil {
|
||||
return merrors.InvalidArgument("messaging configuration is required", "messaging")
|
||||
}
|
||||
if strings.TrimSpace(string(cfg.Messaging.Driver)) == "" {
|
||||
return merrors.InvalidArgument("messaging.driver is required", "messaging.driver")
|
||||
}
|
||||
if cfg.Delivery.MinDelay() > cfg.Delivery.MaxDelay() {
|
||||
return merrors.InvalidArgument("delivery min delay must be <= max delay", "delivery.min_delay_ms", "delivery.max_delay_ms")
|
||||
}
|
||||
if cfg.Delivery.MaxAttempts < 1 {
|
||||
return merrors.InvalidArgument("delivery.max_attempts must be > 0", "delivery.max_attempts")
|
||||
}
|
||||
if cfg.Ingest.BatchSize < 1 {
|
||||
return merrors.InvalidArgument("ingest.batch_size must be > 0", "ingest.batch_size")
|
||||
}
|
||||
vaultAddress := strings.TrimSpace(cfg.Secrets.Vault.Address)
|
||||
vaultTokenEnv := strings.TrimSpace(cfg.Secrets.Vault.TokenEnv)
|
||||
vaultMountPath := strings.TrimSpace(cfg.Secrets.Vault.MountPath)
|
||||
hasVault := vaultAddress != "" || vaultTokenEnv != "" || vaultMountPath != ""
|
||||
if hasVault {
|
||||
if vaultAddress == "" {
|
||||
return merrors.InvalidArgument("secrets.vault.address is required when vault settings are configured", "secrets.vault.address")
|
||||
}
|
||||
if vaultTokenEnv == "" {
|
||||
return merrors.InvalidArgument("secrets.vault.token_env is required when vault settings are configured", "secrets.vault.token_env")
|
||||
}
|
||||
if vaultMountPath == "" {
|
||||
return merrors.InvalidArgument("secrets.vault.mount_path is required when vault settings are configured", "secrets.vault.mount_path")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user