diff --git a/extension/awscloudwatchlogsprovisionerextension/config.go b/extension/awscloudwatchlogsprovisionerextension/config.go new file mode 100644 index 00000000000..5be6d6a6992 --- /dev/null +++ b/extension/awscloudwatchlogsprovisionerextension/config.go @@ -0,0 +1,67 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package awscloudwatchlogsprovisionerextension // import "github.com/open-telemetry/opentelemetry-collector-contrib/extension/awscloudwatchlogsprovisionerextension" + +import ( + "errors" + "time" + + "go.opentelemetry.io/collector/component" +) + +// Config for the awscloudwatchlogsprovisioner extension. +// +// The extension reads x-aws-log-group and x-aws-log-stream headers from outgoing +// HTTP requests and lazily creates the corresponding CloudWatch log groups and +// streams. Headers can be set by the otlphttp exporter (static) or by the +// headers_setter extension (dynamic, from client.Metadata). +// +// Alternatively, LogGroup and LogStream can be set directly in this config. +// When set, the extension injects them as request headers, avoiding the need +// to pass them through confighttp.ClientConfig.Headers (which uses configopaque.String +// and gets nil'd during YAML serialization). +type Config struct { + // Region is the AWS region for CloudWatch Logs API calls (required). + Region string `mapstructure:"region"` + + // AdditionalAuth is a reference to the inner auth extension (typically sigv4auth) + // that this extension chains with for request signing. Follows the same pattern + // as headers_setter's additional_auth field. + AdditionalAuth *component.ID `mapstructure:"additional_auth"` + + // LogGroup is the CloudWatch log group name. When set, the extension injects + // it as the x-aws-log-group header on outgoing requests. + LogGroup string `mapstructure:"log_group"` + + // LogStream is the CloudWatch log stream name. When set, the extension injects + // it as the x-aws-log-stream header on outgoing requests. + LogStream string `mapstructure:"log_stream"` + + // LogRetention is the log retention in days. When set, the extension injects + // it as the x-aws-log-retention header on outgoing requests. + LogRetention int64 `mapstructure:"log_retention"` + + // LogsProvisionTimeout is the HTTP timeout for each CreateLogGroup/CreateLogStream + // API call (including SDK retries). Bounds how long singleflight waiters block. + // Default: 10s. + LogsProvisionTimeout time.Duration `mapstructure:"logs_provision_timeout"` + + // LogsProvisionFailureBackoff is the TTL for negative cache entries. + // During this period, the extension won't retry creation for the same (group, stream) pair. + // Default: 30s. + LogsProvisionFailureBackoff time.Duration `mapstructure:"logs_provision_failure_backoff"` +} + +func (cfg *Config) Validate() error { + if cfg.Region == "" { + return errors.New("region is required") + } + if cfg.LogsProvisionTimeout <= 0 { + return errors.New("logs_provision_timeout must be positive") + } + if cfg.LogsProvisionFailureBackoff < 0 { + return errors.New("logs_provision_failure_backoff must not be negative") + } + return nil +} diff --git a/extension/awscloudwatchlogsprovisionerextension/config_test.go b/extension/awscloudwatchlogsprovisionerextension/config_test.go new file mode 100644 index 00000000000..b09d228f3ae --- /dev/null +++ b/extension/awscloudwatchlogsprovisionerextension/config_test.go @@ -0,0 +1,57 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package awscloudwatchlogsprovisionerextension + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "go.opentelemetry.io/collector/component" +) + +func TestConfig_Defaults(t *testing.T) { + cfg := createDefaultConfig().(*Config) + + assert.Equal(t, 10*time.Second, cfg.LogsProvisionTimeout) + assert.Equal(t, 30*time.Second, cfg.LogsProvisionFailureBackoff) + assert.Nil(t, cfg.AdditionalAuth) + assert.Empty(t, cfg.Region) +} + +func TestConfig_Validate_RequiresRegion(t *testing.T) { + cfg := &Config{LogsProvisionTimeout: 10 * time.Second} + assert.Error(t, cfg.Validate(), "should require region") + + cfg.Region = "us-east-1" + assert.NoError(t, cfg.Validate()) +} + +func TestConfig_Validate_RequiresPositiveTimeout(t *testing.T) { + cfg := &Config{Region: "us-east-1", LogsProvisionTimeout: 0} + assert.Error(t, cfg.Validate(), "should reject zero timeout") + + cfg.LogsProvisionTimeout = -1 * time.Second + assert.Error(t, cfg.Validate(), "should reject negative timeout") + + cfg.LogsProvisionTimeout = 10 * time.Second + assert.NoError(t, cfg.Validate()) +} + +func TestConfig_WithAdditionalAuth(t *testing.T) { + authID := component.MustNewID("sigv4auth") + cfg := &Config{ + Region: "us-east-1", + LogsProvisionTimeout: 10 * time.Second, + AdditionalAuth: &authID, + } + + assert.Equal(t, "sigv4auth", cfg.AdditionalAuth.String()) + assert.NoError(t, cfg.Validate()) +} + +func TestFactory_Type(t *testing.T) { + f := NewFactory() + assert.Equal(t, component.MustNewType("awscloudwatchlogsprovisioner"), f.Type()) +} diff --git a/extension/awscloudwatchlogsprovisionerextension/cwlogsclient.go b/extension/awscloudwatchlogsprovisionerextension/cwlogsclient.go new file mode 100644 index 00000000000..a490b6b56a9 --- /dev/null +++ b/extension/awscloudwatchlogsprovisionerextension/cwlogsclient.go @@ -0,0 +1,63 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package awscloudwatchlogsprovisionerextension // import "github.com/open-telemetry/opentelemetry-collector-contrib/extension/awscloudwatchlogsprovisionerextension" + +import ( + "context" + "errors" + "net/http" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs" + "github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs/types" +) + +type defaultCWLogsClient struct { + svc *cloudwatchlogs.Client +} + +func newDefaultCWLogsClient(ctx context.Context, region string, timeout time.Duration) (cwLogsClient, error) { + cfg, err := awsconfig.LoadDefaultConfig( + ctx, + awsconfig.WithRegion(region), + awsconfig.WithHTTPClient(&http.Client{Timeout: timeout}), + ) + if err != nil { + return nil, err + } + return &defaultCWLogsClient{svc: cloudwatchlogs.NewFromConfig(cfg)}, nil +} + +func (c *defaultCWLogsClient) CreateLogGroup(ctx context.Context, logGroupName string) error { + _, err := c.svc.CreateLogGroup(ctx, &cloudwatchlogs.CreateLogGroupInput{ + LogGroupName: aws.String(logGroupName), + }) + if err != nil && !isAlreadyExists(err) { + return err + } + return nil +} + +func (c *defaultCWLogsClient) CreateLogStream(ctx context.Context, logGroupName, logStreamName string) error { + _, err := c.svc.CreateLogStream(ctx, &cloudwatchlogs.CreateLogStreamInput{ + LogGroupName: aws.String(logGroupName), + LogStreamName: aws.String(logStreamName), + }) + if err != nil && !isAlreadyExists(err) { + return err + } + return nil +} + +func isAlreadyExists(err error) bool { + var alreadyExists *types.ResourceAlreadyExistsException + return errors.As(err, &alreadyExists) +} + +func isNotFound(err error) bool { + var notFound *types.ResourceNotFoundException + return errors.As(err, ¬Found) +} diff --git a/extension/awscloudwatchlogsprovisionerextension/doc.go b/extension/awscloudwatchlogsprovisionerextension/doc.go new file mode 100644 index 00000000000..f0433f7d0ed --- /dev/null +++ b/extension/awscloudwatchlogsprovisionerextension/doc.go @@ -0,0 +1,9 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +//go:generate mdatagen metadata.yaml + +// Package awscloudwatchlogsprovisionerextension implements extensionauth.HTTPClient +// to dynamically set x-aws-log-group headers and create CloudWatch log groups and +// streams on first encounter. +package awscloudwatchlogsprovisionerextension // import "github.com/open-telemetry/opentelemetry-collector-contrib/extension/awscloudwatchlogsprovisionerextension" diff --git a/extension/awscloudwatchlogsprovisionerextension/extension.go b/extension/awscloudwatchlogsprovisionerextension/extension.go new file mode 100644 index 00000000000..006bbf39947 --- /dev/null +++ b/extension/awscloudwatchlogsprovisionerextension/extension.go @@ -0,0 +1,290 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package awscloudwatchlogsprovisionerextension // import "github.com/open-telemetry/opentelemetry-collector-contrib/extension/awscloudwatchlogsprovisionerextension" + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/extension/extensionauth" + "go.opentelemetry.io/collector/extension/extensioncapabilities" + "go.uber.org/zap" + "golang.org/x/sync/singleflight" +) + +var ( + _ component.Component = (*provisionerExtension)(nil) + _ extensionauth.HTTPClient = (*provisionerExtension)(nil) + _ extensioncapabilities.Dependent = (*provisionerExtension)(nil) +) + +type cacheEntry struct { + success bool + expiresAt time.Time // only used for failed entries +} + +// cwLogsClient abstracts the CloudWatch Logs API for testability. +type cwLogsClient interface { + CreateLogGroup(ctx context.Context, logGroupName string) error + CreateLogStream(ctx context.Context, logGroupName, logStreamName string) error +} + +type provisionerExtension struct { + logger *zap.Logger + cfg *Config + + // host is stored during Start() for lazy resolution of additional_auth. + // Follows the same pattern as headers_setter extension. + host component.Host + client cwLogsClient + + cache sync.Map + sfGroup singleflight.Group +} + +func newExtension(logger *zap.Logger, cfg *Config) *provisionerExtension { + return &provisionerExtension{ + logger: logger, + cfg: cfg, + } +} + +func (e *provisionerExtension) Start(ctx context.Context, host component.Host) error { + if err := e.cfg.Validate(); err != nil { + return fmt.Errorf("invalid config: %w", err) + } + e.host = host + + client, err := newDefaultCWLogsClient(ctx, e.cfg.Region, e.cfg.LogsProvisionTimeout) + if err != nil { + return fmt.Errorf("failed to create CW Logs client: %w", err) + } + e.client = client + + e.logger.Info( + "awscloudwatchlogsprovisioner started", + zap.String("region", e.cfg.Region), + ) + return nil +} + +func (e *provisionerExtension) Shutdown(_ context.Context) error { + return nil +} + +func (e *provisionerExtension) Dependencies() []component.ID { + if e.cfg.AdditionalAuth != nil { + return []component.ID{*e.cfg.AdditionalAuth} + } + return nil +} + +// getAdditionalAuthExtension retrieves the configured additional auth extension if present. +// Returns nil if no additional auth is configured. +// Follows the same pattern as headers_setter: +// https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/v0.150.0/extension/headerssetterextension/extension.go +func (e *provisionerExtension) getAdditionalAuthExtension() (component.Component, error) { + if e.cfg.AdditionalAuth == nil || e.host == nil { + return nil, nil + } + ext := e.host.GetExtensions()[*e.cfg.AdditionalAuth] + if ext == nil { + return nil, fmt.Errorf("additional_auth extension %v not found", e.cfg.AdditionalAuth) + } + return ext, nil +} + +func (e *provisionerExtension) RoundTripper(base http.RoundTripper) (http.RoundTripper, error) { + transport := base + + ext, err := e.getAdditionalAuthExtension() + if err != nil { + return nil, err + } + if ext != nil { + if httpClient, ok := ext.(extensionauth.HTTPClient); ok { + transport, err = httpClient.RoundTripper(base) + if err != nil { + return nil, fmt.Errorf("failed to get RoundTripper from %v: %w", e.cfg.AdditionalAuth, err) + } + } + } + + return &provisionerRoundTripper{ + base: transport, + ext: e, + }, nil +} + +type provisionerRoundTripper struct { + base http.RoundTripper + ext *provisionerExtension +} + +func (rt *provisionerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + // Clone the request before modifying headers to comply with the + // http.RoundTripper contract (must not modify the original request). + // This prevents races under retries or HTTP/2 multiplexing. + req = req.Clone(req.Context()) + + // Inject log group/stream headers from config if set. + // This avoids relying on confighttp.ClientConfig.Headers which uses + // configopaque.String and gets nil'd during YAML serialization. + if rt.ext.cfg.LogGroup != "" { + req.Header.Set("x-aws-log-group", rt.ext.cfg.LogGroup) + } + if rt.ext.cfg.LogStream != "" { + req.Header.Set("x-aws-log-stream", rt.ext.cfg.LogStream) + } + if rt.ext.cfg.LogRetention > 0 { + req.Header.Set("x-aws-log-retention", fmt.Sprintf("%d", rt.ext.cfg.LogRetention)) + } + + // Read headers set by the otlphttp exporter (static) or headers_setter (dynamic). + logGroup := req.Header.Get("x-aws-log-group") + logStream := req.Header.Get("x-aws-log-stream") + + if logGroup != "" && logStream != "" { + wasProvisioned := rt.ext.ensure(req.Context(), logGroup, logStream) + + resp, err := rt.base.RoundTrip(req) + if err != nil { + return resp, err + } + + // If the CW OTLP endpoint returns 400 with "does not exist", evict the + // cache entry. If ensure() returned true (log group was previously known + // to exist), return an error to trigger the exporter's retry logic — on + // retry, ensure() re-provisions the log group/stream. + if resp.StatusCode == http.StatusBadRequest { + body, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr == nil { + if strings.Contains(string(body), "does not exist") { + rt.ext.evictSuccessfulEntry(logGroup, logStream) + if wasProvisioned { + return nil, errors.New("log group/stream no longer exists; evicted cache entry for re-provisioning on retry") + } + } + resp.Body = io.NopCloser(bytes.NewReader(body)) + } + } + + return resp, nil + } + + return rt.base.RoundTrip(req) +} + +func cacheKey(logGroup, logStream string) string { + return logGroup + "\x00" + logStream +} + +// ensure creates the log group and stream if not already cached. +// Returns true if the log group/stream is known to be provisioned (cache hit on +// success entry or newly provisioned). Returns false if provisioning failed or +// is within failure backoff. +// Uses singleflight to deduplicate concurrent creation attempts for the same key. +func (e *provisionerExtension) ensure(ctx context.Context, logGroup, logStream string) bool { + key := cacheKey(logGroup, logStream) + + if entry, ok := e.cache.Load(key); ok { + ce := entry.(cacheEntry) + if ce.success { + return true + } + if time.Now().Before(ce.expiresAt) { + return false + } + } + + // All three return values are intentionally discarded: the func always + // returns (nil, nil), and the shared flag is irrelevant because the cache + // is the authoritative source of truth checked after Do returns. + _, _, _ = e.sfGroup.Do(key, func() (any, error) { + // Double-check cache after acquiring singleflight. + if entry, ok := e.cache.Load(key); ok { + ce := entry.(cacheEntry) + if ce.success || time.Now().Before(ce.expiresAt) { + return nil, nil + } + } + + err := e.provision(ctx, logGroup, logStream) + if err != nil { + // Don't cache failures caused by context cancellation — allow retry for provision + if ctx.Err() != nil { + return nil, nil + } + e.cache.Store(key, cacheEntry{expiresAt: time.Now().Add(e.cfg.LogsProvisionFailureBackoff)}) + e.logger.Warn( + "Failed to create log group/stream", + zap.String("logGroup", logGroup), + zap.String("logStream", logStream), + zap.Duration("backoff", e.cfg.LogsProvisionFailureBackoff), + zap.Error(err), + ) + } else { + e.cache.Store(key, cacheEntry{success: true}) + e.logger.Debug( + "Successfully provisioned log group/stream", + zap.String("logGroup", logGroup), + zap.String("logStream", logStream), + ) + } + return nil, nil + }) + + // Check final cache state after singleflight completes. + if entry, ok := e.cache.Load(key); ok { + return entry.(cacheEntry).success + } + return false +} + +// provision creates the log stream (and log group if needed). +// Tries stream first — if the group doesn't exist, creates it and retries. +func (e *provisionerExtension) provision(ctx context.Context, logGroup, logStream string) error { + err := e.client.CreateLogStream(ctx, logGroup, logStream) + if err == nil { + return nil + } + + if !isNotFound(err) { + return fmt.Errorf("CreateLogStream %q in %q: %w", logStream, logGroup, err) + } + + e.logger.Debug( + "Log group not found, creating", + zap.String("logGroup", logGroup), + ) + if grpErr := e.client.CreateLogGroup(ctx, logGroup); grpErr != nil { + return fmt.Errorf("CreateLogGroup %q: %w", logGroup, grpErr) + } + + if retryErr := e.client.CreateLogStream(ctx, logGroup, logStream); retryErr != nil { + return fmt.Errorf("CreateLogStream %q in %q (retry): %w", logStream, logGroup, retryErr) + } + + return nil +} + +// evict removes the cache entry only if it was previously successful. +// Failed entries retain their backoff TTL. +func (e *provisionerExtension) evictSuccessfulEntry(logGroup, logStream string) { + key := cacheKey(logGroup, logStream) + if entry, ok := e.cache.Load(key); ok { + if entry.(cacheEntry).success { + e.cache.Delete(key) + } + } +} diff --git a/extension/awscloudwatchlogsprovisionerextension/extension_test.go b/extension/awscloudwatchlogsprovisionerextension/extension_test.go new file mode 100644 index 00000000000..4117ef7ee47 --- /dev/null +++ b/extension/awscloudwatchlogsprovisionerextension/extension_test.go @@ -0,0 +1,529 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package awscloudwatchlogsprovisionerextension + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/collector/component" + "go.uber.org/zap/zaptest" +) + +// --- Mock CW Logs client --- + +type mockCWLogsClient struct { + createGroupErr error + createStreamErr error + groupCalls atomic.Int32 + streamCalls atomic.Int32 +} + +func (m *mockCWLogsClient) CreateLogGroup(_ context.Context, _ string) error { + m.groupCalls.Add(1) + return m.createGroupErr +} + +func (m *mockCWLogsClient) CreateLogStream(_ context.Context, _, _ string) error { + m.streamCalls.Add(1) + return m.createStreamErr +} + +// --- Mock inner auth --- + +type mockHTTPClient struct { + component.StartFunc + component.ShutdownFunc +} + +func (m *mockHTTPClient) RoundTripper(base http.RoundTripper) (http.RoundTripper, error) { + return base, nil +} + +// mockAuthWithHeader simulates an auth extension that adds a specific header +// (e.g., sigv4auth adding Authorization). Used to verify auth chaining. +type mockAuthWithHeader struct { + component.StartFunc + component.ShutdownFunc + headerKey string + headerValue string +} + +func (m *mockAuthWithHeader) RoundTripper(base http.RoundTripper) (http.RoundTripper, error) { + return roundTripperFunc(func(req *http.Request) (*http.Response, error) { + req2 := req.Clone(req.Context()) + req2.Header.Set(m.headerKey, m.headerValue) + return base.RoundTrip(req2) + }), nil +} + +// --- Mock host --- + +type mockHost struct { + extensions map[component.ID]component.Component +} + +func (h *mockHost) GetExtensions() map[component.ID]component.Component { + return h.extensions +} + +// --- Helper to build extension --- + +func newTestExtension(t *testing.T, cfg *Config, mockClient *mockCWLogsClient) *provisionerExtension { + if cfg.Region == "" { + cfg.Region = "us-east-1" + } + ext := newExtension(zaptest.NewLogger(t), cfg) + ext.client = mockClient + return ext +} + +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +// --- Tests --- + +func TestRoundTripper_StaticHeaders(t *testing.T) { + mockClient := &mockCWLogsClient{} + ext := newTestExtension(t, &Config{}, mockClient) + ext.host = &mockHost{extensions: map[component.ID]component.Component{}} + + var capturedReq *http.Request + base := roundTripperFunc(func(req *http.Request) (*http.Response, error) { + capturedReq = req + return &http.Response{StatusCode: http.StatusOK}, nil + }) + + rt, err := ext.RoundTripper(base) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "https://logs.us-east-1.amazonaws.com/v1/logs", nil) + req.Header.Set("x-aws-log-group", "/static/my-group") + req.Header.Set("x-aws-log-stream", "my-stream") + + _, err = rt.RoundTrip(req) + require.NoError(t, err) + + assert.Equal(t, "/static/my-group", capturedReq.Header.Get("x-aws-log-group")) + assert.Equal(t, "my-stream", capturedReq.Header.Get("x-aws-log-stream")) + // Stream-first: CreateLogStream succeeds so CreateLogGroup is not called + assert.Equal(t, int32(0), mockClient.groupCalls.Load()) + assert.Equal(t, int32(1), mockClient.streamCalls.Load()) +} + +// Test: no log group at all — request passes through without provisioning +func TestRoundTripper_NoLogGroup_PassesThrough(t *testing.T) { + mockClient := &mockCWLogsClient{} + ext := newTestExtension(t, &Config{}, mockClient) + ext.host = &mockHost{extensions: map[component.ID]component.Component{}} + + base := roundTripperFunc(func(_ *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK}, nil + }) + + rt, err := ext.RoundTripper(base) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "https://logs.us-east-1.amazonaws.com/v1/logs", nil) + // No x-aws-log-group header + + _, err = rt.RoundTrip(req) + require.NoError(t, err) + + assert.Equal(t, int32(0), mockClient.groupCalls.Load(), "should not provision when no log group header") +} + +// Test: missing log stream — skips provisioning (both headers required) +func TestRoundTripper_MissingStream_SkipsProvisioning(t *testing.T) { + mockClient := &mockCWLogsClient{} + ext := newTestExtension(t, &Config{}, mockClient) + ext.host = &mockHost{extensions: map[component.ID]component.Component{}} + + base := roundTripperFunc(func(_ *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK}, nil + }) + + rt, err := ext.RoundTripper(base) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "https://logs.us-east-1.amazonaws.com/v1/logs", nil) + req.Header.Set("x-aws-log-group", "/my/group") + // No x-aws-log-stream header — both required for provisioning + + _, err = rt.RoundTrip(req) + require.NoError(t, err) + + assert.Equal(t, int32(0), mockClient.streamCalls.Load(), "should not provision when stream header missing") +} + +// Test: 400 with "does not exist" evicts cache and returns error for retry +func TestRoundTripper_400DoesNotExist_EvictsAndReturnsError(t *testing.T) { + mockClient := &mockCWLogsClient{} + ext := newTestExtension(t, &Config{}, mockClient) + ext.host = &mockHost{extensions: map[component.ID]component.Component{}} + + base := roundTripperFunc(func(_ *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(strings.NewReader(`{"message":"The specified log group does not exist."}`)), + }, nil + }) + + rt, err := ext.RoundTripper(base) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "https://logs.us-east-1.amazonaws.com/v1/logs", nil) + req.Header.Set("x-aws-log-group", "/test/group") + req.Header.Set("x-aws-log-stream", "default") + + // First call: provisions, gets 400, evicts, returns error for retry + resp, err := rt.RoundTrip(req) + assert.Nil(t, resp) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no longer exists") + assert.Equal(t, int32(1), mockClient.streamCalls.Load()) +} + +// Test: 400 without "does not exist" does NOT evict cache +func TestRoundTripper_400OtherError_NoEviction(t *testing.T) { + mockClient := &mockCWLogsClient{} + ext := newTestExtension(t, &Config{}, mockClient) + ext.host = &mockHost{extensions: map[component.ID]component.Component{}} + + base := roundTripperFunc(func(_ *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(strings.NewReader(`{"message":"Invalid log format"}`)), + }, nil + }) + + rt, err := ext.RoundTripper(base) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "https://logs.us-east-1.amazonaws.com/v1/logs", nil) + req.Header.Set("x-aws-log-group", "/test/group") + req.Header.Set("x-aws-log-stream", "default") + + _, err = rt.RoundTrip(req) + require.NoError(t, err) + + // Only initial provision, no re-provision + assert.Equal(t, int32(1), mockClient.streamCalls.Load(), "should not re-provision for non-existence 400") +} + +// Test: 400 "does not exist" with a failed cache entry does NOT evict (preserves backoff) +func TestRoundTripper_400DoesNotExist_FailedEntry_NoEviction(t *testing.T) { + notFoundErr := &types.ResourceNotFoundException{Message: aws.String("not found")} + mockClient := &mockCWLogsClient{ + createStreamErr: notFoundErr, + createGroupErr: errors.New("access denied"), + } + ext := newTestExtension(t, &Config{ + LogsProvisionFailureBackoff: 60 * time.Second, + }, mockClient) + ext.host = &mockHost{extensions: map[component.ID]component.Component{}} + + base := roundTripperFunc(func(_ *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(strings.NewReader(`{"message":"The specified log group does not exist."}`)), + }, nil + }) + + rt, err := ext.RoundTripper(base) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "https://logs.us-east-1.amazonaws.com/v1/logs", nil) + req.Header.Set("x-aws-log-group", "/test/group") + req.Header.Set("x-aws-log-stream", "default") + + // First call: ensure fails (access denied), caches failure entry + _, err = rt.RoundTrip(req) + require.NoError(t, err) + assert.Equal(t, int32(1), mockClient.groupCalls.Load()) + + // Second call: gets 400 "does not exist" but cache has failed entry — should NOT evict + _, err = rt.RoundTrip(req) + require.NoError(t, err) + // Still only 1 group call — backoff preserved, no retry + assert.Equal(t, int32(1), mockClient.groupCalls.Load(), "should not evict failed entry") +} + +func TestEvictSuccessfulEntry(t *testing.T) { + mockClient := &mockCWLogsClient{} + ext := newTestExtension(t, &Config{}, mockClient) + + t.Run("evicts success entry", func(t *testing.T) { + ext.cache.Store(cacheKey("/group", "stream"), cacheEntry{success: true}) + ext.evictSuccessfulEntry("/group", "stream") + + _, loaded := ext.cache.Load(cacheKey("/group", "stream")) + assert.False(t, loaded, "success entry should be evicted") + }) + + t.Run("preserves failed entry", func(t *testing.T) { + ext.cache.Store(cacheKey("/group2", "stream"), cacheEntry{ + success: false, + expiresAt: time.Now().Add(time.Minute), + }) + ext.evictSuccessfulEntry("/group2", "stream") + + _, loaded := ext.cache.Load(cacheKey("/group2", "stream")) + assert.True(t, loaded, "failed entry should NOT be evicted") + }) + + t.Run("no-op when entry missing", func(_ *testing.T) { + ext.evictSuccessfulEntry("/nonexistent", "stream") + // No panic, no-op + }) +} + +func TestEnsureProvisioned_Success(t *testing.T) { + mockClient := &mockCWLogsClient{} + ext := newTestExtension(t, &Config{}, mockClient) + + ext.ensure(t.Context(), "/test/group", "default") + + // Stream-first: CreateLogStream succeeds, no group creation needed + assert.Equal(t, int32(0), mockClient.groupCalls.Load()) + assert.Equal(t, int32(1), mockClient.streamCalls.Load()) + + // Second call should hit cache + ext.ensure(t.Context(), "/test/group", "default") + assert.Equal(t, int32(0), mockClient.groupCalls.Load(), "should not create again after cache hit") + assert.Equal(t, int32(1), mockClient.streamCalls.Load(), "should not create again after cache hit") +} + +func TestEnsureProvisioned_FailureThenBackoff(t *testing.T) { + notFoundErr := &types.ResourceNotFoundException{Message: aws.String("not found")} + mockClient := &mockCWLogsClient{ + createStreamErr: notFoundErr, + createGroupErr: errors.New("throttled"), + } + ext := newTestExtension(t, &Config{ + LogsProvisionFailureBackoff: 60 * time.Second, + }, mockClient) + + ext.ensure(t.Context(), "/test/group", "default") + // Stream fails (not found) → group creation attempted → fails (throttled) + assert.Equal(t, int32(1), mockClient.groupCalls.Load()) + + ext.ensure(t.Context(), "/test/group", "default") + assert.Equal(t, int32(1), mockClient.groupCalls.Load(), "should not retry during backoff") +} + +func TestEnsureProvisioned_Singleflight(t *testing.T) { + mockClient := &mockCWLogsClient{} + ext := newTestExtension(t, &Config{}, mockClient) + + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + ext.ensure(t.Context(), "/test/singleflight", "default") + }() + } + wg.Wait() + + assert.Equal(t, int32(1), mockClient.streamCalls.Load(), "singleflight should dedup concurrent creation") +} + +func TestStart_StoresHost(t *testing.T) { + authID := component.MustNewID("sigv4auth") + cfg := &Config{ + Region: "us-east-1", + LogsProvisionTimeout: 10 * time.Second, + AdditionalAuth: &authID, + } + ext := newExtension(zaptest.NewLogger(t), cfg) + + host := &mockHost{ + extensions: map[component.ID]component.Component{ + authID: &mockHTTPClient{}, + }, + } + + err := ext.Start(t.Context(), host) + require.NoError(t, err) + assert.NotNil(t, ext.host) +} + +func TestRoundTripper_MissingAdditionalAuth(t *testing.T) { + authID := component.MustNewID("sigv4auth") + cfg := &Config{ + Region: "us-east-1", + LogsProvisionTimeout: 10 * time.Second, + AdditionalAuth: &authID, + } + ext := newExtension(zaptest.NewLogger(t), cfg) + + host := &mockHost{extensions: map[component.ID]component.Component{}} + err := ext.Start(t.Context(), host) + require.NoError(t, err) + + base := roundTripperFunc(func(_ *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK}, nil + }) + + _, err = ext.RoundTripper(base) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +// TestChainingWithAdditionalAuth verifies that the extension properly chains +// with an additional auth extension, ensuring both the provisioner's headers +// (x-aws-log-group) and the inner auth's modifications are present in the request. +func TestChainingWithAdditionalAuth(t *testing.T) { + authID := component.MustNewID("sigv4auth") + mockClient := &mockCWLogsClient{} + + ext := newTestExtension(t, &Config{ + AdditionalAuth: &authID, + }, mockClient) + + mockAuth := &mockAuthWithHeader{ + headerKey: "Authorization", + headerValue: "AWS4-HMAC-SHA256 Credential=...", + } + ext.host = &mockHost{ + extensions: map[component.ID]component.Component{ + authID: mockAuth, + }, + } + + var capturedReq *http.Request + base := roundTripperFunc(func(req *http.Request) (*http.Response, error) { + capturedReq = req + return &http.Response{StatusCode: http.StatusOK}, nil + }) + + rt, err := ext.RoundTripper(base) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "https://logs.us-east-1.amazonaws.com/v1/logs", nil) + req.Header.Set("x-aws-log-group", "/test/my-service") + req.Header.Set("x-aws-log-stream", "default") + + _, err = rt.RoundTrip(req) + require.NoError(t, err) + + assert.Equal(t, "/test/my-service", capturedReq.Header.Get("x-aws-log-group")) + assert.Equal(t, "default", capturedReq.Header.Get("x-aws-log-stream")) + assert.Equal(t, "AWS4-HMAC-SHA256 Credential=...", capturedReq.Header.Get("Authorization")) +} + +func TestDependencies(t *testing.T) { + authID := component.MustNewID("sigv4auth") + + ext := newExtension(zaptest.NewLogger(t), &Config{AdditionalAuth: &authID}) + assert.Equal(t, []component.ID{authID}, ext.Dependencies()) + + ext2 := newExtension(zaptest.NewLogger(t), &Config{}) + assert.Nil(t, ext2.Dependencies()) +} + +func TestEnsureProvisioned_DifferentKeysIndependent(t *testing.T) { + mockClient := &mockCWLogsClient{} + ext := newTestExtension(t, &Config{}, mockClient) + + ext.ensure(t.Context(), "/test/service-a", "default") + ext.ensure(t.Context(), "/test/service-b", "default") + + // Stream-first: both streams succeed without needing group creation + assert.Equal(t, int32(0), mockClient.groupCalls.Load()) + assert.Equal(t, int32(2), mockClient.streamCalls.Load(), "different keys should create independently") +} + +func TestFailureBackoff_ExpiresAndRetries(t *testing.T) { + notFoundErr := &types.ResourceNotFoundException{Message: aws.String("not found")} + mockClient := &mockCWLogsClient{ + createStreamErr: notFoundErr, + createGroupErr: errors.New("throttled"), + } + ext := newTestExtension(t, &Config{ + LogsProvisionFailureBackoff: 1 * time.Second, + }, mockClient) + + ext.ensure(t.Context(), "/test/group", "default") + assert.Equal(t, int32(1), mockClient.groupCalls.Load()) + + time.Sleep(2 * time.Second) + + ext.ensure(t.Context(), "/test/group", "default") + assert.Equal(t, int32(2), mockClient.groupCalls.Load(), "should retry after backoff expires") +} + +// Test: headers injected from config (log_group/log_stream fields) +func TestRoundTripper_ConfigHeaders(t *testing.T) { + mockClient := &mockCWLogsClient{} + ext := newTestExtension(t, &Config{ + LogGroup: "/config/my-group", + LogStream: "config-stream", + }, mockClient) + ext.host = &mockHost{extensions: map[component.ID]component.Component{}} + + var capturedReq *http.Request + base := roundTripperFunc(func(req *http.Request) (*http.Response, error) { + capturedReq = req + return &http.Response{StatusCode: http.StatusOK}, nil + }) + + rt, err := ext.RoundTripper(base) + require.NoError(t, err) + + // Request has NO headers — they should be injected from config + req := httptest.NewRequest(http.MethodPost, "https://logs.us-east-1.amazonaws.com/v1/logs", nil) + + _, err = rt.RoundTrip(req) + require.NoError(t, err) + + assert.Equal(t, "/config/my-group", capturedReq.Header.Get("x-aws-log-group")) + assert.Equal(t, "config-stream", capturedReq.Header.Get("x-aws-log-stream")) + assert.Equal(t, int32(1), mockClient.streamCalls.Load(), "should provision from config headers") +} + +// Test: config headers with retention +func TestRoundTripper_ConfigHeadersWithRetention(t *testing.T) { + mockClient := &mockCWLogsClient{} + ext := newTestExtension(t, &Config{ + LogGroup: "/config/my-group", + LogStream: "config-stream", + LogRetention: 30, + }, mockClient) + ext.host = &mockHost{extensions: map[component.ID]component.Component{}} + + var capturedReq *http.Request + base := roundTripperFunc(func(req *http.Request) (*http.Response, error) { + capturedReq = req + return &http.Response{StatusCode: http.StatusOK}, nil + }) + + rt, err := ext.RoundTripper(base) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "https://logs.us-east-1.amazonaws.com/v1/logs", nil) + + _, err = rt.RoundTrip(req) + require.NoError(t, err) + + assert.Equal(t, "/config/my-group", capturedReq.Header.Get("x-aws-log-group")) + assert.Equal(t, "config-stream", capturedReq.Header.Get("x-aws-log-stream")) + assert.Equal(t, "30", capturedReq.Header.Get("x-aws-log-retention")) +} diff --git a/extension/awscloudwatchlogsprovisionerextension/factory.go b/extension/awscloudwatchlogsprovisionerextension/factory.go new file mode 100644 index 00000000000..b3a38dc49a1 --- /dev/null +++ b/extension/awscloudwatchlogsprovisionerextension/factory.go @@ -0,0 +1,33 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package awscloudwatchlogsprovisionerextension // import "github.com/open-telemetry/opentelemetry-collector-contrib/extension/awscloudwatchlogsprovisionerextension" + +import ( + "context" + "time" + + "github.com/open-telemetry/opentelemetry-collector-contrib/extension/awscloudwatchlogsprovisionerextension/internal/metadata" + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/extension" +) + +func NewFactory() extension.Factory { + return extension.NewFactory( + metadata.Type, + createDefaultConfig, + createExtension, + metadata.ExtensionStability, + ) +} + +func createDefaultConfig() component.Config { + return &Config{ + LogsProvisionTimeout: 10 * time.Second, + LogsProvisionFailureBackoff: 30 * time.Second, + } +} + +func createExtension(_ context.Context, settings extension.Settings, cfg component.Config) (extension.Extension, error) { + return newExtension(settings.Logger, cfg.(*Config)), nil +} diff --git a/extension/awscloudwatchlogsprovisionerextension/factory_test.go b/extension/awscloudwatchlogsprovisionerextension/factory_test.go new file mode 100644 index 00000000000..1f59e056799 --- /dev/null +++ b/extension/awscloudwatchlogsprovisionerextension/factory_test.go @@ -0,0 +1,36 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package awscloudwatchlogsprovisionerextension + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/collector/component/componenttest" + "go.opentelemetry.io/collector/extension/extensiontest" +) + +func TestNewFactory(t *testing.T) { + f := NewFactory() + assert.NotNil(t, f) + assert.Equal(t, "awscloudwatchlogsprovisioner", f.Type().String()) +} + +func TestCreateDefaultConfig(t *testing.T) { + cfg := createDefaultConfig().(*Config) + assert.Equal(t, 10*time.Second, cfg.LogsProvisionTimeout) + assert.Equal(t, 30*time.Second, cfg.LogsProvisionFailureBackoff) + assert.Nil(t, cfg.AdditionalAuth) + assert.NoError(t, componenttest.CheckConfigStruct(cfg)) +} + +func TestCreateExtension(t *testing.T) { + f := NewFactory() + cfg := createDefaultConfig() + ext, err := f.Create(t.Context(), extensiontest.NewNopSettings(f.Type()), cfg) + require.NoError(t, err) + assert.NotNil(t, ext) +} diff --git a/extension/awscloudwatchlogsprovisionerextension/generated_component_test.go b/extension/awscloudwatchlogsprovisionerextension/generated_component_test.go new file mode 100644 index 00000000000..8e4ac21602e --- /dev/null +++ b/extension/awscloudwatchlogsprovisionerextension/generated_component_test.go @@ -0,0 +1,52 @@ +// Code generated by mdatagen. DO NOT EDIT. + +package awscloudwatchlogsprovisionerextension + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/component/componenttest" + "go.opentelemetry.io/collector/confmap/confmaptest" + "go.opentelemetry.io/collector/extension/extensiontest" +) + +var typ = component.MustNewType("awscloudwatchlogsprovisioner") + +func TestComponentFactoryType(t *testing.T) { + require.Equal(t, typ, NewFactory().Type()) +} + +func TestComponentConfigStruct(t *testing.T) { + require.NoError(t, componenttest.CheckConfigStruct(NewFactory().CreateDefaultConfig())) +} + +func TestComponentLifecycle(t *testing.T) { + factory := NewFactory() + + cm, err := confmaptest.LoadConf("metadata.yaml") + require.NoError(t, err) + cfg := factory.CreateDefaultConfig() + sub, err := cm.Sub("tests::config") + require.NoError(t, err) + require.NoError(t, sub.Unmarshal(&cfg)) + t.Run("shutdown", func(t *testing.T) { + e, err := factory.Create(context.Background(), extensiontest.NewNopSettings(typ), cfg) + require.NoError(t, err) + err = e.Shutdown(context.Background()) + require.NoError(t, err) + }) + t.Run("lifecycle", func(t *testing.T) { + firstExt, err := factory.Create(context.Background(), extensiontest.NewNopSettings(typ), cfg) + require.NoError(t, err) + require.NoError(t, firstExt.Start(context.Background(), componenttest.NewNopHost())) + require.NoError(t, firstExt.Shutdown(context.Background())) + + secondExt, err := factory.Create(context.Background(), extensiontest.NewNopSettings(typ), cfg) + require.NoError(t, err) + require.NoError(t, secondExt.Start(context.Background(), componenttest.NewNopHost())) + require.NoError(t, secondExt.Shutdown(context.Background())) + }) +} diff --git a/extension/awscloudwatchlogsprovisionerextension/generated_package_test.go b/extension/awscloudwatchlogsprovisionerextension/generated_package_test.go new file mode 100644 index 00000000000..2b996bd3638 --- /dev/null +++ b/extension/awscloudwatchlogsprovisionerextension/generated_package_test.go @@ -0,0 +1,13 @@ +// Code generated by mdatagen. DO NOT EDIT. + +package awscloudwatchlogsprovisionerextension + +import ( + "testing" + + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} diff --git a/extension/awscloudwatchlogsprovisionerextension/go.mod b/extension/awscloudwatchlogsprovisionerextension/go.mod new file mode 100644 index 00000000000..c6850308dee --- /dev/null +++ b/extension/awscloudwatchlogsprovisionerextension/go.mod @@ -0,0 +1,67 @@ +module github.com/open-telemetry/opentelemetry-collector-contrib/extension/awscloudwatchlogsprovisionerextension + +go 1.24.13 + +require ( + github.com/aws/aws-sdk-go-v2 v1.36.3 + github.com/aws/aws-sdk-go-v2/config v1.29.13 + github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.46.0 + github.com/stretchr/testify v1.10.0 + go.opentelemetry.io/collector/component v1.30.0 + go.opentelemetry.io/collector/component/componenttest v0.124.0 + go.opentelemetry.io/collector/confmap v1.30.0 + go.opentelemetry.io/collector/extension v1.30.0 + go.opentelemetry.io/collector/extension/extensionauth v1.30.0 + go.opentelemetry.io/collector/extension/extensioncapabilities v0.124.0 + go.opentelemetry.io/collector/extension/extensiontest v0.124.0 + go.uber.org/goleak v1.3.0 + go.uber.org/zap v1.27.0 + golang.org/x/sync v0.19.0 +) + +require ( + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.66 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.33.18 // indirect + github.com/aws/smithy-go v1.22.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/go-version v1.7.0 // indirect + github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/knadh/koanf/providers/confmap v0.1.0 // indirect + github.com/knadh/koanf/v2 v2.1.2 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/collector/featuregate v1.30.0 // indirect + go.opentelemetry.io/collector/internal/telemetry v0.124.0 // indirect + go.opentelemetry.io/collector/pdata v1.30.0 // indirect + go.opentelemetry.io/contrib/bridges/otelzap v0.10.0 // indirect + go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/otel/log v0.11.0 // indirect + go.opentelemetry.io/otel/metric v1.35.0 // indirect + go.opentelemetry.io/otel/sdk v1.35.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect + go.opentelemetry.io/otel/trace v1.35.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/net v0.37.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect + google.golang.org/grpc v1.71.1 // indirect + google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/extension/awscloudwatchlogsprovisionerextension/go.sum b/extension/awscloudwatchlogsprovisionerextension/go.sum new file mode 100644 index 00000000000..fa4e4773d07 --- /dev/null +++ b/extension/awscloudwatchlogsprovisionerextension/go.sum @@ -0,0 +1,161 @@ +github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= +github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14= +github.com/aws/aws-sdk-go-v2/config v1.29.13 h1:RgdPqWoE8nPpIekpVpDJsBckbqT4Liiaq9f35pbTh1Y= +github.com/aws/aws-sdk-go-v2/config v1.29.13/go.mod h1:NI28qs/IOUIRhsR7GQ/JdexoqRN9tDxkIrYZq0SOF44= +github.com/aws/aws-sdk-go-v2/credentials v1.17.66 h1:aKpEKaTy6n4CEJeYI1MNj97oSDLi4xro3UzQfwf5RWE= +github.com/aws/aws-sdk-go-v2/credentials v1.17.66/go.mod h1:xQ5SusDmHb/fy55wU0QqTy0yNfLqxzec59YcsRZB+rI= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= +github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.46.0 h1:HPS8ojAC0E1tIPYgH+fWi8y88+LZPZrcDowEfhsVdCM= +github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.46.0/go.mod h1:uo14VBn5cNk/BPGTPz3kyLBxgpgOObgO8lmz+H7Z4Ck= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= +github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8= +github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.18 h1:xz7WvTMfSStb9Y8NpCT82FXLNC3QasqBfuAFHY4Pk5g= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.18/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= +github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= +github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= +github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= +github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/providers/confmap v0.1.0 h1:gOkxhHkemwG4LezxxN8DMOFopOPghxRVp7JbIvdvqzU= +github.com/knadh/koanf/providers/confmap v0.1.0/go.mod h1:2uLhxQzJnyHKfxG927awZC7+fyHFdQkd697K4MdLnIU= +github.com/knadh/koanf/v2 v2.1.2 h1:I2rtLRqXRy1p01m/utEtpZSSA6dcJbgGVuE27kW2PzQ= +github.com/knadh/koanf/v2 v2.1.2/go.mod h1:Gphfaen0q1Fc1HTgJgSTC4oRX9R2R5ErYMZJy8fLJBo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/collector/component v1.30.0 h1:HXjqBHaQ47/EEuWdnkjr4Y3kRWvmyWIDvqa1Q262Fls= +go.opentelemetry.io/collector/component v1.30.0/go.mod h1:vfM9kN+BM6oHBXWibquiprz8CVawxd4/aYy3nbhme3E= +go.opentelemetry.io/collector/component/componenttest v0.124.0 h1:Wsc+DmDrWTFs/aEyjDA3slNwV+h/0NOyIR5Aywvr6Zw= +go.opentelemetry.io/collector/component/componenttest v0.124.0/go.mod h1:NQ4ATOzMFc7QA06B993tq8o27DR0cu/JR/zK7slGJ3E= +go.opentelemetry.io/collector/confmap v1.30.0 h1:Y0MXhjQCdMyJN9xZMWWdNPWs6ncMVf7YVnyAEN2dAcM= +go.opentelemetry.io/collector/confmap v1.30.0/go.mod h1:9DdThVDIC3VsdtTb7DgT+HwusWOocoqDkd/TErEtQgA= +go.opentelemetry.io/collector/extension v1.30.0 h1:AJqntAp1p40Q1az2Vze3OHiMURq56KWnUxaLzs1ghaA= +go.opentelemetry.io/collector/extension v1.30.0/go.mod h1:a21WpypFQp9x0Go7yMOknYmIKvdIoWGzjz+h1WMjzLk= +go.opentelemetry.io/collector/extension/extensionauth v1.30.0 h1:HfNT4F1LDEyuItoHq01LrPiUmMpfc5LnOfE4OYVSghA= +go.opentelemetry.io/collector/extension/extensionauth v1.30.0/go.mod h1:bVWkWyyd0aCYu+x6q4HdezfzL0QAqlq5PO7NwckXe4s= +go.opentelemetry.io/collector/extension/extensioncapabilities v0.124.0 h1:6emRXUQriceBcrwRDf2MPQQMRu7jmP0Z0XaJ4zdjt+I= +go.opentelemetry.io/collector/extension/extensioncapabilities v0.124.0/go.mod h1:ovL3wgZuQ8/U7UxUBTqqGSYQTPxHPHmj4P+kp/zb0hA= +go.opentelemetry.io/collector/extension/extensiontest v0.124.0 h1:pWfKxEqvq5vVdQy+UIerw5j7ZrXezxsfQfuUe8g/xVo= +go.opentelemetry.io/collector/extension/extensiontest v0.124.0/go.mod h1:DLVRyW7tJt8TtYq0Wr5BUsM494YqDiIjN8YCmbVKqjs= +go.opentelemetry.io/collector/featuregate v1.30.0 h1:mx7+iP/FQnY7KO8qw/xE3Qd1MQkWcU8VgcqLNrJ8EU8= +go.opentelemetry.io/collector/featuregate v1.30.0/go.mod h1:Y/KsHbvREENKvvN9RlpiWk/IGBK+CATBYzIIpU7nccc= +go.opentelemetry.io/collector/internal/telemetry v0.124.0 h1:kzd1/ZYhLj4bt2pDB529mL4rIRrRacemXodFNxfhdWk= +go.opentelemetry.io/collector/internal/telemetry v0.124.0/go.mod h1:ZjXjqV0dJ+6D4XGhTOxg/WHjnhdmXsmwmUSgALea66Y= +go.opentelemetry.io/collector/pdata v1.30.0 h1:j3jyq9um436r6WzWySzexP2nLnFdmL5uVBYAlyr9nDM= +go.opentelemetry.io/collector/pdata v1.30.0/go.mod h1:0Bxu1ktuj4wE7PIASNSvd0SdBscQ1PLtYasymJ13/Cs= +go.opentelemetry.io/collector/pipeline v0.124.0 h1:hKvhDyH2GPnNO8LGL34ugf36sY7EOXPjBvlrvBhsOdw= +go.opentelemetry.io/collector/pipeline v0.124.0/go.mod h1:TO02zju/K6E+oFIOdi372Wk0MXd+Szy72zcTsFQwXl4= +go.opentelemetry.io/contrib/bridges/otelzap v0.10.0 h1:ojdSRDvjrnm30beHOmwsSvLpoRF40MlwNCA+Oo93kXU= +go.opentelemetry.io/contrib/bridges/otelzap v0.10.0/go.mod h1:oTTm4g7NEtHSV2i/0FeVdPaPgUIZPfQkFbq0vbzqnv0= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/log v0.11.0 h1:c24Hrlk5WJ8JWcwbQxdBqxZdOK7PcP/LFtOtwpDTe3Y= +go.opentelemetry.io/otel/log v0.11.0/go.mod h1:U/sxQ83FPmT29trrifhQg+Zj2lo1/IPN1PF6RTFqdwc= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= +golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50= +google.golang.org/grpc v1.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI= +google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/extension/awscloudwatchlogsprovisionerextension/internal/metadata/generated_status.go b/extension/awscloudwatchlogsprovisionerextension/internal/metadata/generated_status.go new file mode 100644 index 00000000000..e8a442a374b --- /dev/null +++ b/extension/awscloudwatchlogsprovisionerextension/internal/metadata/generated_status.go @@ -0,0 +1,16 @@ +// Code generated by mdatagen. DO NOT EDIT. + +package metadata + +import ( + "go.opentelemetry.io/collector/component" +) + +var ( + Type = component.MustNewType("awscloudwatchlogsprovisioner") + ScopeName = "github.com/open-telemetry/opentelemetry-collector-contrib/extension/awscloudwatchlogsprovisionerextension" +) + +const ( + ExtensionStability = component.StabilityLevelAlpha +) diff --git a/extension/awscloudwatchlogsprovisionerextension/metadata.yaml b/extension/awscloudwatchlogsprovisionerextension/metadata.yaml new file mode 100644 index 00000000000..517a99acc8f --- /dev/null +++ b/extension/awscloudwatchlogsprovisionerextension/metadata.yaml @@ -0,0 +1,14 @@ +type: awscloudwatchlogsprovisioner + +status: + class: extension + stability: + alpha: [extension] + distributions: [] + codeowners: + active: [jj22ee] + +tests: + config: + region: "us-east-1" + logs_provision_timeout: 10s diff --git a/go.mod b/go.mod index 82006bf14e3..e3d408d40ff 100644 --- a/go.mod +++ b/go.mod @@ -184,6 +184,7 @@ require ( github.com/open-telemetry/opentelemetry-collector-contrib/receiver/kubeletstatsreceiver v0.124.1 github.com/open-telemetry/opentelemetry-collector-contrib/receiver/prometheusreceiver v0.124.1 github.com/open-telemetry/opentelemetry-collector-contrib/receiver/statsdreceiver v0.124.1 + github.com/open-telemetry/opentelemetry-collector-contrib/receiver/syslogreceiver v0.124.1 github.com/open-telemetry/opentelemetry-collector-contrib/receiver/tcplogreceiver v0.124.1 github.com/open-telemetry/opentelemetry-collector-contrib/receiver/udplogreceiver v0.124.1 github.com/open-telemetry/opentelemetry-collector-contrib/receiver/zipkinreceiver v0.124.1 diff --git a/go.sum b/go.sum index b003c79c334..5e41798d421 100644 --- a/go.sum +++ b/go.sum @@ -1238,6 +1238,8 @@ github.com/onsi/gomega v1.13.0/go.mod h1:lRk9szgn8TxENtWd0Tp4c3wjlRfMTMH27I+3Je4 github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= +github.com/open-telemetry/opentelemetry-collector-contrib/connector/routingconnector v0.124.1 h1:xnu/h8g7ekHwGCKPydc3KPZkV5+gB9jsjNZLwP3sM1o= +github.com/open-telemetry/opentelemetry-collector-contrib/connector/routingconnector v0.124.1/go.mod h1:y78Q4wvDnWHuTQge+eXaBJhmGy5Jv0nsOoixxXbj4uI= github.com/open-telemetry/opentelemetry-collector-contrib/exporter/prometheusremotewriteexporter v0.124.1 h1:WMEB86bjqPtbbYLu8vhuy0g9vK+6M5aTy13jwUwQZ1s= github.com/open-telemetry/opentelemetry-collector-contrib/exporter/prometheusremotewriteexporter v0.124.1/go.mod h1:AwoFFkNgtHnOczLkEwlGAPhu54t2LtrOGOg8zxKpPgo= github.com/open-telemetry/opentelemetry-collector-contrib/extension/healthcheckextension v0.124.1 h1:uV7jdTAvpuivYA2w4lGvRf4zSJS5fwhyiLENvVuuTbs= @@ -1338,6 +1340,8 @@ github.com/open-telemetry/opentelemetry-collector-contrib/receiver/kubeletstatsr github.com/open-telemetry/opentelemetry-collector-contrib/receiver/kubeletstatsreceiver v0.124.1/go.mod h1:+1JVHE45uoo4b5UnIZvHvotKtMUOXhdCdL6NlvrZ8Nk= github.com/open-telemetry/opentelemetry-collector-contrib/receiver/statsdreceiver v0.124.1 h1:Ag8P7xYAytcZd3/8RbUxUjcXcoc3vAQ0kTM8POiAWjc= github.com/open-telemetry/opentelemetry-collector-contrib/receiver/statsdreceiver v0.124.1/go.mod h1:m+AC6vDhk0ApXvvPZJCCiCaRlE1ua9hh2FHJdsAfAeI= +github.com/open-telemetry/opentelemetry-collector-contrib/receiver/syslogreceiver v0.124.1 h1:WVFnKW6zxwQNe9jKq0upLSxDOOW7K+ciEHItjxoR2a0= +github.com/open-telemetry/opentelemetry-collector-contrib/receiver/syslogreceiver v0.124.1/go.mod h1:kyNU6LCBkeRo36bC1+PWRH+vKxWOF6bn3K5KHF0U2VI= github.com/open-telemetry/opentelemetry-collector-contrib/receiver/tcplogreceiver v0.124.1 h1:FraAggwubCbTbpBiuIIXxtzcmnUt8Zd1QT97Ay31TZw= github.com/open-telemetry/opentelemetry-collector-contrib/receiver/tcplogreceiver v0.124.1/go.mod h1:cVf9mR1NQUOSxKaG68d1f6J1R1gD+es8N4KlfXzMrzY= github.com/open-telemetry/opentelemetry-collector-contrib/receiver/udplogreceiver v0.124.1 h1:ViSLfZ+rwltr/cJyUzxwH7+Ew/qDs4KyQpMFsDbRd/g= diff --git a/service/defaultcomponents/components.go b/service/defaultcomponents/components.go index 9153c86b581..9969ae66145 100644 --- a/service/defaultcomponents/components.go +++ b/service/defaultcomponents/components.go @@ -52,6 +52,7 @@ import ( "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/kubeletstatsreceiver" "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/prometheusreceiver" "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/statsdreceiver" + "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/syslogreceiver" "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/tcplogreceiver" "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/udplogreceiver" "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/zipkinreceiver" @@ -111,6 +112,7 @@ func Factories() (otelcol.Factories, error) { otlpreceiver.NewFactory(), prometheusreceiver.NewFactory(), statsdreceiver.NewFactory(), + syslogreceiver.NewFactory(), systemmetricsreceiver.NewFactory(), tcplogreceiver.NewFactory(), udplogreceiver.NewFactory(), diff --git a/service/defaultcomponents/components_test.go b/service/defaultcomponents/components_test.go index 828579298f3..c5971ba3e2a 100644 --- a/service/defaultcomponents/components_test.go +++ b/service/defaultcomponents/components_test.go @@ -35,6 +35,7 @@ func TestComponents(t *testing.T) { "otlp", "prometheus", "statsd", + "syslog", "systemmetrics", "tcplog", "udplog", diff --git a/translator/config/schema.json b/translator/config/schema.json index e2cc8173949..1a94188b55b 100644 --- a/translator/config/schema.json +++ b/translator/config/schema.json @@ -659,6 +659,16 @@ "windows_events": { "$ref": "#/definitions/logsDefinition/definitions/logsWindowsEventsDefinition" }, + "syslog": { + "oneOf": [ + { "$ref": "#/definitions/logsDefinition/definitions/syslogDefinition" }, + { + "type": "array", + "items": { "$ref": "#/definitions/logsDefinition/definitions/syslogDefinition" }, + "minItems": 1 + } + ] + }, "application_signals": { "type": "object", "properties": { @@ -1228,6 +1238,146 @@ "type": "string" } } + }, + "syslogDefinition": { + "type": "object", + "description": "Configuration for syslog collection", + "oneOf": [ + { + "properties": { + "listen_address": { + "type": "string", + "description": "Listen address in format tcp://host:port or udp://host:port", + "minLength": 1, + "maxLength": 255 + }, + "protocol": { + "type": "string", + "description": "Syslog message format", + "enum": ["rfc5424", "rfc3164"] + }, + "tls": { + "type": "object", + "properties": { + "cert_file": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "key_file": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "ca_file": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "client_ca_file": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "min_version": { "type": "string", "enum": ["1.0", "1.1", "1.2", "1.3"] } + }, + "additionalProperties": false + }, + "log_group_name": { + "$ref": "#/definitions/logsDefinition/definitions/logGroupNameDefinition" + }, + "log_stream_name": { + "$ref": "#/definitions/logsDefinition/definitions/logStreamNameDefinition" + }, + "retention_in_days": { + "$ref": "#/definitions/logsDefinition/definitions/retentionInDaysDefinition" + }, + "filters": { + "type": "array", + "items": { "$ref": "#/definitions/logsDefinition/definitions/filterDefinition" } + }, + "routing": { + "type": "array", + "items": { "$ref": "#/definitions/logsDefinition/definitions/syslogRoutingRuleDefinition" } + } + }, + "required": ["log_group_name"], + "additionalProperties": false + }, + { + "properties": { + "listeners": { + "type": "array", + "items": { "$ref": "#/definitions/logsDefinition/definitions/syslogListenerDefinition" }, + "minItems": 1 + }, + "log_group_name": { + "$ref": "#/definitions/logsDefinition/definitions/logGroupNameDefinition" + }, + "log_stream_name": { + "$ref": "#/definitions/logsDefinition/definitions/logStreamNameDefinition" + }, + "retention_in_days": { + "$ref": "#/definitions/logsDefinition/definitions/retentionInDaysDefinition" + }, + "filters": { + "type": "array", + "items": { "$ref": "#/definitions/logsDefinition/definitions/filterDefinition" } + }, + "routing": { + "type": "array", + "items": { "$ref": "#/definitions/logsDefinition/definitions/syslogRoutingRuleDefinition" } + } + }, + "required": ["listeners", "log_group_name"], + "additionalProperties": false + } + ] + }, + "syslogListenerDefinition": { + "type": "object", + "description": "Configuration for a syslog listener", + "required": ["listen_address"], + "properties": { + "listen_address": { + "type": "string", + "description": "Listen address in format tcp://host:port or udp://host:port", + "minLength": 1, + "maxLength": 255 + }, + "protocol": { + "type": "string", + "description": "Syslog message format", + "enum": ["rfc5424", "rfc3164"] + }, + "tls": { + "type": "object", + "properties": { + "cert_file": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "key_file": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "ca_file": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "client_ca_file": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "min_version": { "type": "string", "enum": ["1.0", "1.1", "1.2", "1.3"] } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "syslogRoutingRuleDefinition": { + "type": "object", + "description": "Routing rule for syslog messages", + "properties": { + "match": { + "type": "object", + "properties": { + "hostname": { "type": "string", "minLength": 1 }, + "facility": { "type": "integer", "minimum": 0, "maximum": 23 }, + "app_name": { "type": "string", "minLength": 1 } + }, + "minProperties": 1, + "additionalProperties": false + }, + "log_group_name": { + "$ref": "#/definitions/logsDefinition/definitions/logGroupNameDefinition" + }, + "log_stream_name": { + "$ref": "#/definitions/logsDefinition/definitions/logStreamNameDefinition" + }, + "retention_in_days": { + "$ref": "#/definitions/logsDefinition/definitions/retentionInDaysDefinition" + }, + "filters": { + "type": "array", + "items": { "$ref": "#/definitions/logsDefinition/definitions/filterDefinition" } + } + }, + "required": ["match", "log_group_name"], + "additionalProperties": false } } }, diff --git a/translator/config/schema_syslog_test.go b/translator/config/schema_syslog_test.go new file mode 100644 index 00000000000..ba9698b1da2 --- /dev/null +++ b/translator/config/schema_syslog_test.go @@ -0,0 +1,513 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT + +package config + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xeipuuv/gojsonschema" +) + +func validateJSON(t *testing.T, jsonStr string) *gojsonschema.Result { + t.Helper() + var input map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(jsonStr), &input)) + schemaLoader := gojsonschema.NewStringLoader(GetJsonSchema()) + jsonLoader := gojsonschema.NewGoLoader(input) + result, err := gojsonschema.Validate(schemaLoader, jsonLoader) + require.NoError(t, err) + return result +} + +func TestSyslogSchema_SingleListener(t *testing.T) { + result := validateJSON(t, `{ + "logs": { + "logs_collected": { + "syslog": { + "listen_address": "tcp://0.0.0.0:514", + "log_group_name": "/test/syslog", + "log_stream_name": "stream" + } + } + } + }`) + assert.True(t, result.Valid(), "single syslog listener should be valid: %v", result.Errors()) +} + +func TestSyslogSchema_MultipleListeners(t *testing.T) { + result := validateJSON(t, `{ + "logs": { + "logs_collected": { + "syslog": { + "listeners": [ + {"listen_address": "tcp://0.0.0.0:514"}, + {"listen_address": "udp://0.0.0.0:514"} + ], + "log_group_name": "/test/syslog" + } + } + } + }`) + assert.True(t, result.Valid(), "multiple syslog listeners should be valid: %v", result.Errors()) +} + +func TestSyslogSchema_WithRouting(t *testing.T) { + result := validateJSON(t, `{ + "logs": { + "logs_collected": { + "syslog": { + "listen_address": "tcp://0.0.0.0:514", + "log_group_name": "/test/default", + "routing": [ + { + "match": {"hostname": "web-*"}, + "log_group_name": "/test/web" + } + ] + } + } + } + }`) + assert.True(t, result.Valid(), "syslog with routing should be valid: %v", result.Errors()) +} + +func TestSyslogSchema_WithFilters(t *testing.T) { + result := validateJSON(t, `{ + "logs": { + "logs_collected": { + "syslog": { + "listen_address": "tcp://0.0.0.0:514", + "log_group_name": "/test/default", + "filters": [ + {"type": "exclude", "expression": "healthcheck"}, + {"type": "include", "expression": "error|warn"} + ] + } + } + } + }`) + assert.True(t, result.Valid(), "syslog with filters should be valid: %v", result.Errors()) +} + +func TestSyslogSchema_WithTLS(t *testing.T) { + result := validateJSON(t, `{ + "logs": { + "logs_collected": { + "syslog": { + "listeners": [ + { + "listen_address": "tcp://0.0.0.0:514", + "tls": { + "cert_file": "/etc/ssl/cert.pem", + "key_file": "/etc/ssl/key.pem", + "ca_file": "/etc/ssl/ca.pem", + "min_version": "1.3" + } + } + ], + "log_group_name": "/test/tls" + } + } + } + }`) + assert.True(t, result.Valid(), "syslog with TLS should be valid: %v", result.Errors()) +} + +func TestSyslogSchema_WithProtocol(t *testing.T) { + result := validateJSON(t, `{ + "logs": { + "logs_collected": { + "syslog": { + "listeners": [ + { + "listen_address": "udp://0.0.0.0:514", + "protocol": "rfc3164" + } + ], + "log_group_name": "/test/bsd" + } + } + } + }`) + assert.True(t, result.Valid(), "syslog with protocol should be valid: %v", result.Errors()) +} + +func TestSyslogSchema_WithRetention(t *testing.T) { + result := validateJSON(t, `{ + "logs": { + "logs_collected": { + "syslog": { + "listen_address": "tcp://0.0.0.0:514", + "log_group_name": "/test/default", + "retention_in_days": 30, + "routing": [ + { + "match": {"facility": 4}, + "log_group_name": "/test/auth", + "retention_in_days": 7 + } + ] + } + } + } + }`) + assert.True(t, result.Valid(), "syslog with retention should be valid: %v", result.Errors()) +} + +func TestSyslogSchema_FullConfig(t *testing.T) { + result := validateJSON(t, `{ + "logs": { + "logs_collected": { + "syslog": { + "listeners": [ + { + "listen_address": "tcp://0.0.0.0:514" + }, + { + "listen_address": "udp://0.0.0.0:514", + "protocol": "rfc3164" + }, + { + "listen_address": "tcp://0.0.0.0:1514", + "tls": {"cert_file": "/cert.pem", "key_file": "/key.pem"} + } + ], + "log_group_name": "/test/default", + "log_stream_name": "default", + "retention_in_days": 30, + "filters": [{"type": "exclude", "expression": "healthcheck"}], + "routing": [ + { + "match": {"hostname": "web-*", "facility": 1}, + "log_group_name": "/test/web", + "log_stream_name": "web", + "retention_in_days": 7, + "filters": [{"type": "include", "expression": "error|warn"}] + } + ] + } + } + } + }`) + assert.True(t, result.Valid(), "full syslog config should be valid: %v", result.Errors()) +} + +func TestSyslogSchema_InvalidTopLevelKey(t *testing.T) { + result := validateJSON(t, `{ + "logs": { + "logs_collected": { + "syslog": { + "listen_address": "tcp://0.0.0.0:514", + "log_group_name": "/test/default" + } + }, + "invalid_key": true + } + }`) + assert.False(t, result.Valid(), "invalid key under logs should fail validation") +} + +func TestSyslogSchema_MissingListenAddress(t *testing.T) { + result := validateJSON(t, `{ + "logs": { + "logs_collected": { + "syslog": { + "log_group_name": "/test/default" + } + } + } + }`) + assert.True(t, result.Valid(), "missing listen_address should be valid (defaults to tcp://127.0.0.1:5514)") +} + +func TestSyslogSchema_MissingLogGroupName(t *testing.T) { + result := validateJSON(t, `{ + "logs": { + "logs_collected": { + "syslog": { + "listen_address": "tcp://0.0.0.0:514" + } + } + } + }`) + assert.False(t, result.Valid(), "missing log_group_name should fail") +} + +func TestSyslogSchema_InvalidProtocol(t *testing.T) { + result := validateJSON(t, `{ + "logs": { + "logs_collected": { + "syslog": { + "listeners": [ + { + "listen_address": "tcp://0.0.0.0:514", + "protocol": "rfc9999" + } + ], + "log_group_name": "/test/default" + } + } + } + }`) + assert.False(t, result.Valid(), "invalid protocol should fail") +} + +func TestSyslogSchema_InvalidTLSMinVersion(t *testing.T) { + result := validateJSON(t, `{ + "logs": { + "logs_collected": { + "syslog": { + "listeners": [ + { + "listen_address": "tcp://0.0.0.0:514", + "tls": { "min_version": "2.0" } + } + ], + "log_group_name": "/test/default" + } + } + } + }`) + assert.False(t, result.Valid(), "invalid TLS min_version should fail") +} + +func TestSyslogSchema_InvalidRetention(t *testing.T) { + result := validateJSON(t, `{ + "logs": { + "logs_collected": { + "syslog": { + "listen_address": "tcp://0.0.0.0:514", + "log_group_name": "/test/default", + "retention_in_days": 42 + } + } + } + }`) + assert.False(t, result.Valid(), "invalid retention_in_days value should fail") +} + +func TestSyslogSchema_InvalidFacility(t *testing.T) { + result := validateJSON(t, `{ + "logs": { + "logs_collected": { + "syslog": { + "listen_address": "tcp://0.0.0.0:514", + "log_group_name": "/test/default", + "routing": [{ + "match": { "facility": 99 }, + "log_group_name": "/test/bad" + }] + } + } + } + }`) + assert.False(t, result.Valid(), "facility > 23 should fail") +} + +func TestSyslogSchema_RoutingMissingMatch(t *testing.T) { + result := validateJSON(t, `{ + "logs": { + "logs_collected": { + "syslog": { + "listen_address": "tcp://0.0.0.0:514", + "log_group_name": "/test/default", + "routing": [{ + "log_group_name": "/test/norule" + }] + } + } + } + }`) + assert.False(t, result.Valid(), "routing rule missing match should fail") +} + +func TestSyslogSchema_RoutingMissingLogGroup(t *testing.T) { + result := validateJSON(t, `{ + "logs": { + "logs_collected": { + "syslog": { + "listen_address": "tcp://0.0.0.0:514", + "log_group_name": "/test/default", + "routing": [{ + "match": { "hostname": "web-*" } + }] + } + } + } + }`) + assert.False(t, result.Valid(), "routing rule missing log_group_name should fail") +} + +func TestSyslogSchema_RoutingEmptyMatch(t *testing.T) { + result := validateJSON(t, `{ + "logs": { + "logs_collected": { + "syslog": { + "listen_address": "tcp://0.0.0.0:514", + "log_group_name": "/test/default", + "routing": [{ + "match": {}, + "log_group_name": "/test/empty" + }] + } + } + } + }`) + assert.False(t, result.Valid(), "routing rule with empty match should fail") +} + +func TestSyslogSchema_UnknownListenerField(t *testing.T) { + result := validateJSON(t, `{ + "logs": { + "logs_collected": { + "syslog": { + "listen_address": "tcp://0.0.0.0:514", + "log_group_name": "/test/default", + "bogus_field": true + } + } + } + }`) + assert.False(t, result.Valid(), "unknown field on listener should fail") +} + +func TestSyslogSchema_UnknownTLSField(t *testing.T) { + result := validateJSON(t, `{ + "logs": { + "logs_collected": { + "syslog": { + "listeners": [ + { + "listen_address": "tcp://0.0.0.0:514", + "tls": { "cipher_suites": ["TLS_AES_128"] } + } + ], + "log_group_name": "/test/default" + } + } + } + }`) + assert.False(t, result.Valid(), "unknown TLS field should fail") +} + +func TestSyslogSchema_WithClientCAFile(t *testing.T) { + result := validateJSON(t, `{ + "logs": { + "logs_collected": { + "syslog": { + "listeners": [ + { + "listen_address": "tcp://0.0.0.0:6514", + "tls": { + "cert_file": "/etc/ssl/cert.pem", + "key_file": "/etc/ssl/key.pem", + "client_ca_file": "/etc/ssl/client-ca.pem", + "min_version": "1.2" + } + } + ], + "log_group_name": "/test/mtls" + } + } + } + }`) + assert.True(t, result.Valid(), "syslog with client_ca_file should be valid: %v", result.Errors()) +} + +func TestSyslogSchema_UnknownMatchField(t *testing.T) { + result := validateJSON(t, `{ + "logs": { + "logs_collected": { + "syslog": { + "listen_address": "tcp://0.0.0.0:514", + "log_group_name": "/test/default", + "routing": [{ + "match": { "severity": 3 }, + "log_group_name": "/test/bad" + }] + } + } + } + }`) + assert.False(t, result.Valid(), "unknown match field should fail") +} + +func TestSyslogSchema_ArrayForm_SingleSection(t *testing.T) { + result := validateJSON(t, `{ + "logs": { + "logs_collected": { + "syslog": [ + { + "listen_address": "tcp://0.0.0.0:514", + "log_group_name": "/test/syslog" + } + ] + } + } + }`) + assert.True(t, result.Valid(), "array with single syslog section should be valid: %v", result.Errors()) +} + +func TestSyslogSchema_ArrayForm_MultipleSections(t *testing.T) { + result := validateJSON(t, `{ + "logs": { + "logs_collected": { + "syslog": [ + { + "listeners": [ + {"listen_address": "tcp://0.0.0.0:514"} + ], + "log_group_name": "/syslog/infra/default", + "retention_in_days": 30, + "routing": [ + {"match": {"facility": 4}, "log_group_name": "/syslog/infra/auth"} + ] + }, + { + "listeners": [ + {"listen_address": "tcp://0.0.0.0:1514"}, + {"listen_address": "udp://0.0.0.0:1514", "protocol": "rfc3164"} + ], + "log_group_name": "/syslog/apps/default", + "retention_in_days": 7, + "filters": [{"type": "exclude", "expression": "healthcheck"}], + "routing": [ + {"match": {"hostname": "web-*"}, "log_group_name": "/syslog/apps/web"} + ] + } + ] + } + } + }`) + assert.True(t, result.Valid(), "array with multiple syslog sections should be valid: %v", result.Errors()) +} + +func TestSyslogSchema_ArrayForm_EmptyArray(t *testing.T) { + result := validateJSON(t, `{ + "logs": { + "logs_collected": { + "syslog": [] + } + } + }`) + assert.False(t, result.Valid(), "empty syslog array should fail (minItems: 1)") +} + +func TestSyslogSchema_ArrayForm_InvalidSection(t *testing.T) { + result := validateJSON(t, `{ + "logs": { + "logs_collected": { + "syslog": [ + { + "listen_address": "tcp://0.0.0.0:514" + } + ] + } + } + }`) + assert.False(t, result.Valid(), "array section missing log_group_name should fail") +} diff --git a/translator/tocwconfig/sampleConfig/syslog_config.conf b/translator/tocwconfig/sampleConfig/syslog_config.conf new file mode 100644 index 00000000000..36e9eec86fa --- /dev/null +++ b/translator/tocwconfig/sampleConfig/syslog_config.conf @@ -0,0 +1,42 @@ +[agent] + collection_jitter = "0s" + debug = false + flush_interval = "1s" + flush_jitter = "0s" + hostname = "" + interval = "60s" + logfile = "/opt/aws/amazon-cloudwatch-agent/logs/amazon-cloudwatch-agent.log" + logtarget = "lumberjack" + metric_batch_size = 1000 + metric_buffer_limit = 10000 + omit_hostname = false + precision = "" + quiet = false + round_interval = false + +[inputs] + + [[inputs.logfile]] + destination = "cloudwatchlogs" + file_state_folder = "/opt/aws/amazon-cloudwatch-agent/logs/state" + + [[inputs.logfile.file_config]] + deployment_environment = "" + file_path = "/opt/aws/amazon-cloudwatch-agent/logs/amazon-cloudwatch-agent.log" + from_beginning = true + log_group_class = "" + log_group_name = "amazon-cloudwatch-agent.log" + log_stream_name = "amazon-cloudwatch-agent.log" + pipe = false + retention_in_days = -1 + service_name = "" + timezone = "UTC" + +[outputs] + + [[outputs.cloudwatchlogs]] + force_flush_interval = "5s" + log_stream_name = "i-UNKNOWN" + mode = "" + region = "us-west-2" + region_type = "ACJ" diff --git a/translator/tocwconfig/sampleConfig/syslog_config.json b/translator/tocwconfig/sampleConfig/syslog_config.json new file mode 100644 index 00000000000..29df1c5f747 --- /dev/null +++ b/translator/tocwconfig/sampleConfig/syslog_config.json @@ -0,0 +1,46 @@ +{ + "agent": { + "region": "us-west-2" + }, + "logs": { + "logs_collected": { + "files": { + "collect_list": [ + { + "file_path": "/opt/aws/amazon-cloudwatch-agent/logs/amazon-cloudwatch-agent.log", + "log_group_name": "amazon-cloudwatch-agent.log", + "log_stream_name": "amazon-cloudwatch-agent.log", + "timezone": "UTC" + } + ] + }, + "syslog": { + "listeners": [ + {"listen_address": "tcp://0.0.0.0:514"}, + {"listen_address": "udp://0.0.0.0:514", "protocol": "rfc3164"}, + {"listen_address": "tcp://0.0.0.0:1514", "tls": {"cert_file": "/etc/ssl/cert.pem", "key_file": "/etc/ssl/key.pem", "ca_file": "/etc/ssl/ca.pem", "min_version": "1.3"}} + ], + "log_group_name": "/syslog/default", + "log_stream_name": "default", + "retention_in_days": 30, + "filters": [ + {"type": "exclude", "expression": "healthcheck"} + ], + "routing": [ + { + "match": {"hostname": "web-*"}, + "log_group_name": "/syslog/web", + "log_stream_name": "web-stream", + "retention_in_days": 7, + "filters": [{"type": "include", "expression": "error|warn|crit"}] + }, + { + "match": {"facility": 4}, + "log_group_name": "/syslog/auth", + "log_stream_name": "auth-stream" + } + ] + } + } + } +} diff --git a/translator/tocwconfig/sampleConfig/syslog_config.yaml b/translator/tocwconfig/sampleConfig/syslog_config.yaml new file mode 100644 index 00000000000..05ba14c17a8 --- /dev/null +++ b/translator/tocwconfig/sampleConfig/syslog_config.yaml @@ -0,0 +1,277 @@ +connectors: + routing/syslog_0: + default_pipelines: + - logs/syslog_0_default + error_mode: ignore + table: + - condition: IsMatch(attributes["hostname"], "web-*") + context: log + pipelines: + - logs/syslog_0_rule_0 + statement: "" + - condition: attributes["facility"] == 4 + context: log + pipelines: + - logs/syslog_0_rule_1 + statement: "" +exporters: + awscloudwatchlogs/syslog_0_default: + certificate_file_path: "" + endpoint: "" + external_id: "" + imds_retries: 1 + local_mode: false + log_group_name: /syslog/default + log_retention: 30 + log_stream_name: default + max_retries: 2 + middleware: agenthealth/logs + no_verify_ssl: false + num_workers: 8 + profile: "" + proxy_address: "" + raw_log: true + region: us-west-2 + request_timeout_seconds: 30 + resource_arn: "" + retry_on_failure: + enabled: true + initial_interval: 5s + max_elapsed_time: 5m0s + max_interval: 30s + multiplier: 1.5 + randomization_factor: 0.5 + role_arn: "" + sending_queue: + block_on_overflow: false + blocking: false + enabled: true + num_consumers: 1 + queue_size: 1000 + sizer: {} + wait_for_result: false + awscloudwatchlogs/syslog_0_rule_0: + certificate_file_path: "" + endpoint: "" + external_id: "" + imds_retries: 1 + local_mode: false + log_group_name: /syslog/web + log_retention: 7 + log_stream_name: web-stream + max_retries: 2 + middleware: agenthealth/logs + no_verify_ssl: false + num_workers: 8 + profile: "" + proxy_address: "" + raw_log: true + region: us-west-2 + request_timeout_seconds: 30 + resource_arn: "" + retry_on_failure: + enabled: true + initial_interval: 5s + max_elapsed_time: 5m0s + max_interval: 30s + multiplier: 1.5 + randomization_factor: 0.5 + role_arn: "" + sending_queue: + block_on_overflow: false + blocking: false + enabled: true + num_consumers: 1 + queue_size: 1000 + sizer: {} + wait_for_result: false + awscloudwatchlogs/syslog_0_rule_1: + certificate_file_path: "" + endpoint: "" + external_id: "" + imds_retries: 1 + local_mode: false + log_group_name: /syslog/auth + log_retention: 30 + log_stream_name: auth-stream + max_retries: 2 + middleware: agenthealth/logs + no_verify_ssl: false + num_workers: 8 + profile: "" + proxy_address: "" + raw_log: true + region: us-west-2 + request_timeout_seconds: 30 + resource_arn: "" + retry_on_failure: + enabled: true + initial_interval: 5s + max_elapsed_time: 5m0s + max_interval: 30s + multiplier: 1.5 + randomization_factor: 0.5 + role_arn: "" + sending_queue: + block_on_overflow: false + blocking: false + enabled: true + num_consumers: 1 + queue_size: 1000 + sizer: {} + wait_for_result: false +extensions: + agenthealth/logs: + is_usage_data_enabled: true + stats: + operations: + - PutLogEvents + usage_flags: + mode: "" + region_type: ACJ + agenthealth/statuscode: + is_status_code_enabled: true + is_usage_data_enabled: true + stats: + usage_flags: + mode: "" + region_type: ACJ + entitystore: + mode: ec2 + region: us-west-2 +processors: + batch/syslog_0_default: + metadata_cardinality_limit: 1000 + send_batch_max_size: 0 + send_batch_size: 8192 + timeout: 5s + batch/syslog_0_rule_0: + metadata_cardinality_limit: 1000 + send_batch_max_size: 0 + send_batch_size: 8192 + timeout: 5s + batch/syslog_0_rule_1: + metadata_cardinality_limit: 1000 + send_batch_max_size: 0 + send_batch_size: 8192 + timeout: 5s + filter/syslog_0_in: + error_mode: ignore + logs: + log_record: + - IsMatch(body, "healthcheck") + metrics: {} + spans: {} + traces: {} + filter/syslog_0_rule_0: + error_mode: ignore + logs: + log_record: + - not IsMatch(body, "error|warn|crit") + metrics: {} + spans: {} + traces: {} +receivers: + syslog/tcp_0_0_0_0_514: + id: syslog_input + on_error: drop + operators: [] + protocol: rfc5424 + retry_on_failure: + enabled: false + initial_interval: 1s + max_elapsed_time: 5m0s + max_interval: 30s + tcp: + encoding: utf-8 + listen_address: 0.0.0.0:514 + type: syslog_input + syslog/tcp_0_0_0_0_1514: + id: syslog_input + on_error: drop + operators: [] + protocol: rfc5424 + retry_on_failure: + enabled: false + initial_interval: 1s + max_elapsed_time: 5m0s + max_interval: 30s + tcp: + encoding: utf-8 + listen_address: 0.0.0.0:1514 + tls: + ca_file: /etc/ssl/ca.pem + cert_file: /etc/ssl/cert.pem + key_file: /etc/ssl/key.pem + min_version: "1.3" + type: syslog_input + syslog/udp_0_0_0_0_514: + id: syslog_input + on_error: drop + operators: [] + protocol: rfc3164 + retry_on_failure: + enabled: false + initial_interval: 1s + max_elapsed_time: 5m0s + max_interval: 30s + type: syslog_input + udp: + encoding: utf-8 + listen_address: 0.0.0.0:514 + multiline: + line_end_pattern: .^ + line_start_pattern: "" + omit_pattern: false +service: + extensions: + - agenthealth/logs + - agenthealth/statuscode + - entitystore + pipelines: + logs/syslog_0_default: + exporters: + - awscloudwatchlogs/syslog_0_default + processors: + - batch/syslog_0_default + receivers: + - routing/syslog_0 + logs/syslog_0_in: + exporters: + - routing/syslog_0 + processors: + - filter/syslog_0_in + receivers: + - syslog/tcp_0_0_0_0_514 + - syslog/udp_0_0_0_0_514 + - syslog/tcp_0_0_0_0_1514 + logs/syslog_0_rule_0: + exporters: + - awscloudwatchlogs/syslog_0_rule_0 + processors: + - filter/syslog_0_rule_0 + - batch/syslog_0_rule_0 + receivers: + - routing/syslog_0 + logs/syslog_0_rule_1: + exporters: + - awscloudwatchlogs/syslog_0_rule_1 + processors: + - batch/syslog_0_rule_1 + receivers: + - routing/syslog_0 + telemetry: + logs: + encoding: console + level: info + output_paths: + - /opt/aws/amazon-cloudwatch-agent/logs/amazon-cloudwatch-agent.log + sampling: + enabled: true + initial: 2 + thereafter: 500 + tick: 10s + metrics: + level: None + traces: + level: None diff --git a/translator/tocwconfig/tocwconfig_test.go b/translator/tocwconfig/tocwconfig_test.go index fb276d26942..6564b07a7b8 100644 --- a/translator/tocwconfig/tocwconfig_test.go +++ b/translator/tocwconfig/tocwconfig_test.go @@ -1087,3 +1087,11 @@ func checkIfEnvTranslateSucceed(t *testing.T, jsonStr string, targetOs string, e t.Fail() } } + +func TestSyslogConfig(t *testing.T) { + resetContext(t) + originalProvider := translateutil.Ec2MetadataInfoProvider + translateutil.Ec2MetadataInfoProvider = func() *translateutil.Metadata { return &translateutil.Metadata{} } + t.Cleanup(func() { translateutil.Ec2MetadataInfoProvider = originalProvider }) + checkTranslation(t, "syslog_config", "linux", nil, "") +} diff --git a/translator/translate/otel/common/common.go b/translator/translate/otel/common/common.go index 2ecd64efa34..7027087913b 100644 --- a/translator/translate/otel/common/common.go +++ b/translator/translate/otel/common/common.go @@ -73,6 +73,7 @@ const ( EfaKey = "efa" NetKey = "net" Emf = "emf" + SyslogKey = "syslog" StructuredLog = "structuredlog" ServiceAddress = "service_address" UDP = "udp" @@ -149,6 +150,7 @@ var ( SystemMetricsEnabledConfigKey = ConfigKey(AgentKey, SystemMetricsEnabledKey) JmxConfigKey = ConfigKey(MetricsKey, MetricsCollectedKey, JmxKey) ContainerInsightsConfigKey = ConfigKey(LogsKey, MetricsCollectedKey, KubernetesKey) + SyslogConfigKey = ConfigKey(LogsKey, LogsCollectedKey, SyslogKey) JmxTargets = []string{"activemq", "cassandra", "hbase", "hadoop", "jetty", "jvm", "kafka", "kafka-consumer", "kafka-producer", "solr", "tomcat", "wildfly"} diff --git a/translator/translate/otel/pipeline/syslog/exporter.go b/translator/translate/otel/pipeline/syslog/exporter.go new file mode 100644 index 00000000000..cf7f38cede3 --- /dev/null +++ b/translator/translate/otel/pipeline/syslog/exporter.go @@ -0,0 +1,144 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT + +package syslog + +import ( + "fmt" + "os" + + "github.com/open-telemetry/opentelemetry-collector-contrib/exporter/awscloudwatchlogsexporter" + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/confmap" + "go.opentelemetry.io/collector/exporter" + "go.opentelemetry.io/collector/exporter/otlphttpexporter" + + "github.com/aws/amazon-cloudwatch-agent/cfg/envconfig" + "github.com/aws/amazon-cloudwatch-agent/internal/retryer" + "github.com/aws/amazon-cloudwatch-agent/translator/config" + "github.com/aws/amazon-cloudwatch-agent/translator/context" + "github.com/aws/amazon-cloudwatch-agent/translator/translate/agent" + "github.com/aws/amazon-cloudwatch-agent/translator/translate/otel/common" + "github.com/aws/amazon-cloudwatch-agent/translator/translate/otel/extension/agenthealth" +) + +// deliveryModePLE and deliveryModeOTLP are retained to support future OTLP delivery mode. +const ( + deliveryModePLE = "PutLogEvents" + deliveryModeOTLP = "OTLP" +) + +// newExporterTranslator is retained as dead code to support future OTLP delivery mode. +func newExporterTranslator(name, logGroupName, logStreamName string, retentionInDays int64, deliveryMode string, _ *confmap.Conf) common.ComponentTranslator { + if deliveryMode == deliveryModeOTLP { + return newOTLPExporterTranslator(name) + } + return newCWLExporterTranslator(name, logGroupName, logStreamName, retentionInDays) +} + +// CWL (PutLogEvents) exporter + +type cwlExporterTranslator struct { + name string + logGroupName string + logStreamName string + retentionInDays int64 + factory exporter.Factory +} + +var _ common.ComponentTranslator = (*cwlExporterTranslator)(nil) + +func newCWLExporterTranslator(name, logGroupName, logStreamName string, retentionInDays int64) common.ComponentTranslator { + return &cwlExporterTranslator{ + name: name, + logGroupName: logGroupName, + logStreamName: logStreamName, + retentionInDays: retentionInDays, + factory: awscloudwatchlogsexporter.NewFactory(), + } +} + +func (t *cwlExporterTranslator) ID() component.ID { + return component.NewIDWithName(t.factory.Type(), t.name) +} + +func (t *cwlExporterTranslator) Translate(c *confmap.Conf) (component.Config, error) { + cfg := t.factory.CreateDefaultConfig().(*awscloudwatchlogsexporter.Config) + cfg.MiddlewareID = &agenthealth.LogsID + cfg.LogGroupName = t.logGroupName + cfg.LogStreamName = t.logStreamName + cfg.LogRetention = t.retentionInDays + cfg.RawLog = true + + cfg.CertificateFilePath = os.Getenv(envconfig.AWS_CA_BUNDLE) + endpointKey := common.ConfigKey(common.LogsKey, common.EndpointOverrideKey) + if endpoint, ok := common.GetString(c, endpointKey); ok { + cfg.Endpoint = endpoint + cfg.AWSSessionSettings.Endpoint = endpoint + } + cfg.IMDSRetries = retryer.GetDefaultRetryNumber() + if profileKey, ok := agent.Global_Config.Credentials[agent.Profile_Key]; ok { + cfg.Profile = fmt.Sprintf("%v", profileKey) + } + cfg.Region = agent.Global_Config.Region + cfg.RoleARN = agent.Global_Config.Role_arn + roleARNKey := common.ConfigKey(common.LogsKey, common.CredentialsKey, common.RoleARNKey) + if c.IsSet(roleARNKey) { + cfg.RoleARN, _ = common.GetString(c, roleARNKey) + } + if credentialsFileKey, ok := agent.Global_Config.Credentials[agent.CredentialsFile_Key]; ok { + cfg.SharedCredentialsFile = []string{fmt.Sprintf("%v", credentialsFileKey)} + } + if context.CurrentContext().Mode() == config.ModeOnPrem || context.CurrentContext().Mode() == config.ModeOnPremise { + cfg.LocalMode = true + } + return cfg, nil +} + +// OTLP exporter — retained as dead code to support future OTLP delivery mode. + +type otlpExporterTranslator struct { + name string + factory exporter.Factory +} + +var _ common.ComponentTranslator = (*otlpExporterTranslator)(nil) + +func newOTLPExporterTranslator(name string) common.ComponentTranslator { + return &otlpExporterTranslator{ + name: name, + factory: otlphttpexporter.NewFactory(), + } +} + +func (t *otlpExporterTranslator) ID() component.ID { + return component.NewIDWithName(t.factory.Type(), t.name) +} + +func (t *otlpExporterTranslator) Translate(c *confmap.Conf) (component.Config, error) { + region := agent.Global_Config.Region + logsEndpoint := fmt.Sprintf("https://logs.%s.amazonaws.com/v1/logs", region) + endpointKey := common.ConfigKey(common.LogsKey, common.EndpointOverrideKey) + if ep, ok := common.GetString(c, endpointKey); ok { + logsEndpoint = ep + } + + // The auth chain is: otlphttp → headers_setter → provisioner → sigv4auth. + // headers_setter injects x-aws-log-group/stream/retention headers. + headersSetterID := component.NewIDWithName(component.MustNewType("headers_setter"), t.name) + + cfgMap := map[string]any{ + "logs_endpoint": logsEndpoint, + "compression": "gzip", + "auth": map[string]any{ + "authenticator": headersSetterID.String(), + }, + } + + cfg := t.factory.CreateDefaultConfig() + if err := confmap.NewFromStringMap(cfgMap).Unmarshal(cfg); err != nil { + return nil, err + } + + return cfg, nil +} diff --git a/translator/translate/otel/pipeline/syslog/exporter_test.go b/translator/translate/otel/pipeline/syslog/exporter_test.go new file mode 100644 index 00000000000..9d032eda202 --- /dev/null +++ b/translator/translate/otel/pipeline/syslog/exporter_test.go @@ -0,0 +1,256 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT + +package syslog + +import ( + "fmt" + "testing" + + "github.com/open-telemetry/opentelemetry-collector-contrib/exporter/awscloudwatchlogsexporter" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/collector/confmap" + + "github.com/aws/amazon-cloudwatch-agent/cfg/envconfig" + "github.com/aws/amazon-cloudwatch-agent/translator/config" + translatorcontext "github.com/aws/amazon-cloudwatch-agent/translator/context" + "github.com/aws/amazon-cloudwatch-agent/translator/translate/agent" +) + +func TestCWLExporterTranslator_ID(t *testing.T) { + tr := newCWLExporterTranslator("syslog_0_default", "/syslog/default", "{hostname}", 0) + assert.Equal(t, "awscloudwatchlogs/syslog_0_default", tr.ID().String()) +} + +func TestCWLExporterTranslator_Translate(t *testing.T) { + testCases := map[string]struct { + name string + logGroupName string + logStreamName string + retentionInDays int64 + mode string + region string + roleARN string + credentials map[string]any + input map[string]any + envVars map[string]string + wantLogGroup string + wantLogStream string + wantRetention int64 + wantRegion string + wantRoleARN string + wantProfile string + wantCredsFile string + wantEndpoint string + wantLocalMode bool + wantRawLog bool + wantCertFile string + }{ + "BasicConfig": { + name: "syslog_0_default", + logGroupName: "/syslog/default", + logStreamName: "{hostname}", + mode: config.ModeEC2, + region: "us-east-1", + input: map[string]any{}, + wantLogGroup: "/syslog/default", + wantLogStream: "{hostname}", + wantRegion: "us-east-1", + wantRawLog: true, + }, + "WithRetention": { + name: "syslog_0_rule_0", + logGroupName: "/syslog/auth", + logStreamName: "{hostname}", + retentionInDays: 365, + mode: config.ModeEC2, + region: "us-west-2", + input: map[string]any{}, + wantLogGroup: "/syslog/auth", + wantLogStream: "{hostname}", + wantRetention: 365, + wantRegion: "us-west-2", + wantRawLog: true, + }, + "WithCredentials": { + name: "syslog_0_default", + logGroupName: "/syslog/default", + logStreamName: "{hostname}", + mode: config.ModeEC2, + region: "us-east-1", + roleARN: "arn:aws:iam::123456789012:role/GlobalRole", + credentials: map[string]any{ + "profile": "my_profile", + "shared_credential_file": "/home/user/.aws/credentials", + }, + input: map[string]any{}, + wantLogGroup: "/syslog/default", + wantLogStream: "{hostname}", + wantRegion: "us-east-1", + wantRoleARN: "arn:aws:iam::123456789012:role/GlobalRole", + wantProfile: "my_profile", + wantCredsFile: "/home/user/.aws/credentials", + wantRawLog: true, + }, + "WithEndpointOverride": { + name: "syslog_0_default", + logGroupName: "/syslog/default", + logStreamName: "{hostname}", + mode: config.ModeEC2, + region: "us-east-1", + input: map[string]any{ + "logs": map[string]any{ + "endpoint_override": "https://logs.custom.endpoint", + }, + }, + wantLogGroup: "/syslog/default", + wantLogStream: "{hostname}", + wantRegion: "us-east-1", + wantEndpoint: "https://logs.custom.endpoint", + wantRawLog: true, + }, + "WithLogsRoleARNOverride": { + name: "syslog_0_default", + logGroupName: "/syslog/default", + logStreamName: "{hostname}", + mode: config.ModeEC2, + region: "us-east-1", + roleARN: "arn:aws:iam::123456789012:role/GlobalRole", + input: map[string]any{ + "logs": map[string]any{ + "credentials": map[string]any{ + "role_arn": "arn:aws:iam::123456789012:role/LogsRole", + }, + }, + }, + wantLogGroup: "/syslog/default", + wantLogStream: "{hostname}", + wantRegion: "us-east-1", + wantRoleARN: "arn:aws:iam::123456789012:role/LogsRole", + wantRawLog: true, + }, + "OnPremMode": { + name: "syslog_0_default", + logGroupName: "/syslog/default", + logStreamName: "{hostname}", + mode: config.ModeOnPrem, + region: "us-east-1", + input: map[string]any{}, + wantLogGroup: "/syslog/default", + wantLogStream: "{hostname}", + wantRegion: "us-east-1", + wantLocalMode: true, + wantRawLog: true, + }, + "WithCACertBundle": { + name: "syslog_0_default", + logGroupName: "/syslog/default", + logStreamName: "{hostname}", + mode: config.ModeEC2, + region: "us-east-1", + envVars: map[string]string{envconfig.AWS_CA_BUNDLE: "/ca/bundle"}, + input: map[string]any{}, + wantLogGroup: "/syslog/default", + wantLogStream: "{hostname}", + wantRegion: "us-east-1", + wantCertFile: "/ca/bundle", + wantRawLog: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + for k, v := range tc.envVars { + t.Setenv(k, v) + } + agent.Global_Config.Region = tc.region + agent.Global_Config.Role_arn = tc.roleARN + if tc.credentials != nil { + agent.Global_Config.Credentials = tc.credentials + } else { + agent.Global_Config.Credentials = map[string]any{} + } + translatorcontext.CurrentContext().SetMode(tc.mode) + + tr := newCWLExporterTranslator(tc.name, tc.logGroupName, tc.logStreamName, tc.retentionInDays) + conf := confmap.NewFromStringMap(tc.input) + got, err := tr.Translate(conf) + require.NoError(t, err) + require.NotNil(t, got) + + cfg, ok := got.(*awscloudwatchlogsexporter.Config) + require.True(t, ok) + + assert.Equal(t, tc.wantLogGroup, cfg.LogGroupName) + assert.Equal(t, tc.wantLogStream, cfg.LogStreamName) + assert.Equal(t, tc.wantRetention, cfg.LogRetention) + assert.Equal(t, tc.wantRawLog, cfg.RawLog) + assert.Equal(t, tc.wantRegion, cfg.Region) + assert.Equal(t, tc.wantRoleARN, cfg.RoleARN) + assert.Equal(t, tc.wantLocalMode, cfg.LocalMode) + assert.Equal(t, tc.wantCertFile, cfg.CertificateFilePath) + assert.Equal(t, tc.wantEndpoint, cfg.AWSSessionSettings.Endpoint) + if tc.wantEndpoint != "" { + assert.Equal(t, tc.wantEndpoint, cfg.Endpoint) + } + if tc.wantProfile != "" { + assert.Equal(t, tc.wantProfile, cfg.Profile) + } + if tc.wantCredsFile != "" { + assert.Equal(t, []string{tc.wantCredsFile}, cfg.SharedCredentialsFile) + } + }) + } +} + +func TestOTLPExporterTranslator_ID(t *testing.T) { + tr := newOTLPExporterTranslator("syslog_default") + assert.Equal(t, "otlphttp/syslog_default", tr.ID().String()) +} + +func TestOTLPExporterTranslator_Translate(t *testing.T) { + agent.Global_Config.Region = "us-west-2" + tr := newOTLPExporterTranslator("syslog_default") + cfg, err := tr.Translate(confmap.New()) + require.NoError(t, err) + + out := confmap.New() + require.NoError(t, out.Marshal(cfg)) + assert.Equal(t, "https://logs.us-west-2.amazonaws.com/v1/logs", out.Get("logs_endpoint")) + assert.Contains(t, fmt.Sprint(out.Get("compression")), "gzip") + assert.Equal(t, "headers_setter/syslog_default", out.Get("auth::authenticator")) +} + +func TestOTLPExporterTranslator_EndpointOverride(t *testing.T) { + agent.Global_Config.Region = "us-east-1" + tr := newOTLPExporterTranslator("syslog_rule_0") + conf := confmap.NewFromStringMap(map[string]any{ + "logs": map[string]any{"endpoint_override": "https://custom.endpoint.example.com/v1/logs"}, + }) + cfg, err := tr.Translate(conf) + require.NoError(t, err) + + out := confmap.New() + require.NoError(t, out.Marshal(cfg)) + assert.Equal(t, "https://custom.endpoint.example.com/v1/logs", out.Get("logs_endpoint")) +} + +func TestNewExporterTranslator_Dispatch(t *testing.T) { + conf := confmap.New() + + t.Run("PLE mode returns CWL exporter", func(t *testing.T) { + tr := newExporterTranslator("test", "/group", "stream", 7, deliveryModePLE, conf) + assert.Equal(t, "awscloudwatchlogs/test", tr.ID().String()) + }) + + t.Run("OTLP mode returns OTLP exporter", func(t *testing.T) { + tr := newExporterTranslator("test", "/group", "stream", 7, deliveryModeOTLP, conf) + assert.Equal(t, "otlphttp/test", tr.ID().String()) + }) + + t.Run("empty mode defaults to PLE", func(t *testing.T) { + tr := newExporterTranslator("test", "/group", "stream", 7, "", conf) + assert.Equal(t, "awscloudwatchlogs/test", tr.ID().String()) + }) +} diff --git a/translator/translate/otel/pipeline/syslog/filter.go b/translator/translate/otel/pipeline/syslog/filter.go new file mode 100644 index 00000000000..00e5d880722 --- /dev/null +++ b/translator/translate/otel/pipeline/syslog/filter.go @@ -0,0 +1,83 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT + +package syslog + +import ( + "fmt" + "strings" + + "github.com/open-telemetry/opentelemetry-collector-contrib/processor/filterprocessor" + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/confmap" + "go.opentelemetry.io/collector/processor" + + "github.com/aws/amazon-cloudwatch-agent/translator/translate/otel/common" +) + +type filterProcessorTranslator struct { + name string + filters []filter + factory processor.Factory +} + +var _ common.ComponentTranslator = (*filterProcessorTranslator)(nil) + +func newFilterProcessorTranslator(name string, filters []filter) common.ComponentTranslator { + return &filterProcessorTranslator{ + name: name, + filters: filters, + factory: filterprocessor.NewFactory(), + } +} + +func (t *filterProcessorTranslator) ID() component.ID { + return component.NewIDWithName(t.factory.Type(), t.name) +} + +func (t *filterProcessorTranslator) Translate(_ *confmap.Conf) (component.Config, error) { + conditions := buildFilterConditions(t.filters) + if len(conditions) == 0 { + return t.factory.CreateDefaultConfig(), nil + } + + cfgMap := map[string]any{ + "error_mode": "ignore", + "logs": map[string]any{ + "log_record": conditions, + }, + } + + cfg := t.factory.CreateDefaultConfig() + if err := confmap.NewFromStringMap(cfgMap).Unmarshal(cfg); err != nil { + return nil, fmt.Errorf("unable to unmarshal filter processor (%s): %w", t.name, err) + } + return cfg, nil +} + +// buildFilterConditions converts filters to OTTL drop conditions for the filter processor. +// Include filters keep only matching logs (drop non-matching). +// Exclude filters drop matching logs. +func buildFilterConditions(filters []filter) []string { + var conditions []string + for _, f := range filters { + if f.Expression == "" { + continue + } + switch strings.ToLower(f.Type) { + case "exclude": + // Drop logs matching the expression + conditions = append(conditions, fmt.Sprintf(`IsMatch(body, "%s")`, escapeOTTL(f.Expression))) + case "include": + // Drop logs NOT matching the expression + conditions = append(conditions, fmt.Sprintf(`not IsMatch(body, "%s")`, escapeOTTL(f.Expression))) + } + } + return conditions +} + +func escapeOTTL(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `"`, `\"`) + return s +} diff --git a/translator/translate/otel/pipeline/syslog/provisioner.go b/translator/translate/otel/pipeline/syslog/provisioner.go new file mode 100644 index 00000000000..d0054f9dbad --- /dev/null +++ b/translator/translate/otel/pipeline/syslog/provisioner.go @@ -0,0 +1,77 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT + +// provisioner.go is retained as dead code to support future OTLP delivery mode. +// It produces a provisioner extension (region + sigv4 auth) and a headers_setter +// extension that injects log group/stream/retention as HTTP headers, following +// the same pattern as Application Signals logs. + +package syslog + +import ( + "fmt" + + "github.com/open-telemetry/opentelemetry-collector-contrib/extension/awscloudwatchlogsprovisionerextension" + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/confmap" + "go.opentelemetry.io/collector/extension" + + "github.com/aws/amazon-cloudwatch-agent/translator/translate/otel/common" + "github.com/aws/amazon-cloudwatch-agent/translator/translate/otel/extension/headerssetter" +) + +type provisionerTranslator struct { + name string + factory extension.Factory + region string + logGroup string + logStream string + logRetention int64 +} + +var _ common.ComponentTranslator = (*provisionerTranslator)(nil) + +func newProvisionerTranslator(name, region, logGroup, logStream string, logRetention int64) common.ComponentTranslator { + return &provisionerTranslator{ + name: name, + factory: awscloudwatchlogsprovisionerextension.NewFactory(), + region: region, + logGroup: logGroup, + logStream: logStream, + logRetention: logRetention, + } +} + +func (t *provisionerTranslator) ID() component.ID { + return component.NewIDWithName(t.factory.Type(), t.name) +} + +func (t *provisionerTranslator) Translate(_ *confmap.Conf) (component.Config, error) { + cfg := t.factory.CreateDefaultConfig().(*awscloudwatchlogsprovisionerextension.Config) + cfg.Region = t.region + sigv4ID := component.NewIDWithName(component.MustNewType("sigv4auth"), sigv4AuthSyslogName) + cfg.AdditionalAuth = &sigv4ID + return cfg, nil +} + +// newHeadersSetterTranslator creates a headers_setter extension that injects +// log group/stream/retention as HTTP headers. The headers_setter chains to the +// provisioner, which chains to sigv4auth, forming the full auth chain: +// +// otlphttp exporter → headers_setter → provisioner → sigv4auth +func newHeadersSetterTranslator(name string, provisionerID component.ID, logGroup, logStream string, logRetention int64) common.ComponentTranslator { + headers := []headerssetter.HeaderMapping{ + {HeaderName: "x-aws-log-group", Value: logGroup}, + {HeaderName: "x-aws-log-stream", Value: logStream}, + } + if logRetention > 0 { + headers = append(headers, headerssetter.HeaderMapping{ + HeaderName: "x-aws-log-retention", + Value: fmt.Sprintf("%d", logRetention), + }) + } + return headerssetter.NewTranslatorWithName(name, + headerssetter.WithAdditionalAuth(provisionerID), + headerssetter.WithHeaders(headers), + ) +} diff --git a/translator/translate/otel/pipeline/syslog/routing.go b/translator/translate/otel/pipeline/syslog/routing.go new file mode 100644 index 00000000000..bf7040e8fb1 --- /dev/null +++ b/translator/translate/otel/pipeline/syslog/routing.go @@ -0,0 +1,72 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT + +package syslog + +import ( + "github.com/open-telemetry/opentelemetry-collector-contrib/connector/routingconnector" + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/confmap" + "go.opentelemetry.io/collector/connector" + "go.opentelemetry.io/collector/pipeline" + + "github.com/aws/amazon-cloudwatch-agent/translator/translate/otel/common" +) + +type routingConnectorTranslator struct { + name string + factory connector.Factory + defaultPipelines []pipeline.ID + table []routingTableEntry +} + +type routingTableEntry struct { + condition string + pipelines []pipeline.ID +} + +var _ common.ComponentTranslator = (*routingConnectorTranslator)(nil) + +func newRoutingConnectorTranslator(name string, defaultPipelines []pipeline.ID, table []routingTableEntry) common.ComponentTranslator { + return &routingConnectorTranslator{ + name: name, + factory: routingconnector.NewFactory(), + defaultPipelines: defaultPipelines, + table: table, + } +} + +func (t *routingConnectorTranslator) ID() component.ID { + return component.NewIDWithName(t.factory.Type(), t.name) +} + +func (t *routingConnectorTranslator) Translate(_ *confmap.Conf) (component.Config, error) { + var tableItems []map[string]any + for _, entry := range t.table { + tableItems = append(tableItems, map[string]any{ + "context": "log", + "condition": entry.condition, + "pipelines": pipelineIDsToStrings(entry.pipelines), + }) + } + + cfgMap := map[string]any{ + "default_pipelines": pipelineIDsToStrings(t.defaultPipelines), + "error_mode": "ignore", + "table": tableItems, + } + + cfg := t.factory.CreateDefaultConfig() + if err := confmap.NewFromStringMap(cfgMap).Unmarshal(cfg); err != nil { + return nil, err + } + return cfg, nil +} + +func pipelineIDsToStrings(ids []pipeline.ID) []string { + out := make([]string, len(ids)) + for i, id := range ids { + out[i] = id.String() + } + return out +} diff --git a/translator/translate/otel/pipeline/syslog/sigv4auth.go b/translator/translate/otel/pipeline/syslog/sigv4auth.go new file mode 100644 index 00000000000..0b66b48b74b --- /dev/null +++ b/translator/translate/otel/pipeline/syslog/sigv4auth.go @@ -0,0 +1,47 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT + +// sigv4auth.go is retained as dead code to support future OTLP delivery mode. + +package syslog + +import ( + "github.com/open-telemetry/opentelemetry-collector-contrib/extension/sigv4authextension" + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/confmap" + "go.opentelemetry.io/collector/extension" + + "github.com/aws/amazon-cloudwatch-agent/translator/translate/otel/common" +) + +const sigv4AuthSyslogName = "syslog" + +type sigv4AuthTranslator struct { + factory extension.Factory + region string + roleARN string +} + +var _ common.ComponentTranslator = (*sigv4AuthTranslator)(nil) + +func newSigV4AuthTranslator(region, roleARN string) common.ComponentTranslator { + return &sigv4AuthTranslator{ + factory: sigv4authextension.NewFactory(), + region: region, + roleARN: roleARN, + } +} + +func (t *sigv4AuthTranslator) ID() component.ID { + return component.NewIDWithName(t.factory.Type(), sigv4AuthSyslogName) +} + +func (t *sigv4AuthTranslator) Translate(_ *confmap.Conf) (component.Config, error) { + cfg := t.factory.CreateDefaultConfig().(*sigv4authextension.Config) + cfg.Region = t.region + cfg.Service = "logs" + if t.roleARN != "" { + cfg.AssumeRole = sigv4authextension.AssumeRole{ARN: t.roleARN, STSRegion: t.region} + } + return cfg, nil +} diff --git a/translator/translate/otel/pipeline/syslog/translator.go b/translator/translate/otel/pipeline/syslog/translator.go new file mode 100644 index 00000000000..e70c1eff4d5 --- /dev/null +++ b/translator/translate/otel/pipeline/syslog/translator.go @@ -0,0 +1,590 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT + +package syslog + +import ( + "fmt" + "net" + "regexp" + "strings" + + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/confmap" + "go.opentelemetry.io/collector/pipeline" + + "github.com/aws/amazon-cloudwatch-agent/translator/translate/otel/common" + "github.com/aws/amazon-cloudwatch-agent/translator/translate/otel/extension/agenthealth" + "github.com/aws/amazon-cloudwatch-agent/translator/translate/otel/processor/batchprocessor" + syslogreceiver "github.com/aws/amazon-cloudwatch-agent/translator/translate/otel/receiver/syslog" +) + +// simplePipelineTranslator produces a direct pipeline without routing: receivers → processors → exporter +type simplePipelineTranslator struct { + pipelineID pipeline.ID + receivers []common.ComponentTranslator + processors []common.ComponentTranslator + exporter common.ComponentTranslator + extensions []common.ComponentTranslator +} + +var _ common.PipelineTranslator = (*simplePipelineTranslator)(nil) + +func (t *simplePipelineTranslator) ID() pipeline.ID { return t.pipelineID } + +func (t *simplePipelineTranslator) Translate(_ *confmap.Conf) (*common.ComponentTranslators, error) { + receivers := common.NewTranslatorMap[component.Config, component.ID]() + for _, r := range t.receivers { + receivers.Set(r) + } + processors := common.NewTranslatorMap[component.Config, component.ID]() + for _, p := range t.processors { + processors.Set(p) + } + exporters := common.NewTranslatorMap[component.Config, component.ID]() + exporters.Set(t.exporter) + extensions := common.NewTranslatorMap[component.Config, component.ID]() + for _, e := range t.extensions { + extensions.Set(e) + } + return &common.ComponentTranslators{ + Receivers: receivers, + Processors: processors, + Exporters: exporters, + Extensions: extensions, + }, nil +} + +// inputPipelineTranslator produces the input pipeline: receivers → [filter] → routing connector +type inputPipelineTranslator struct { + pipelineID pipeline.ID + receivers []common.ComponentTranslator + processors []common.ComponentTranslator + connectorExporterID common.ComponentTranslator // routing connector appears as exporter in this pipeline + extensions []common.ComponentTranslator + connectors []common.ComponentTranslator +} + +var _ common.PipelineTranslator = (*inputPipelineTranslator)(nil) + +func (t *inputPipelineTranslator) ID() pipeline.ID { return t.pipelineID } + +func (t *inputPipelineTranslator) Translate(_ *confmap.Conf) (*common.ComponentTranslators, error) { + receivers := common.NewTranslatorMap[component.Config, component.ID]() + for _, r := range t.receivers { + receivers.Set(r) + } + processors := common.NewTranslatorMap[component.Config, component.ID]() + for _, p := range t.processors { + processors.Set(p) + } + exporters := common.NewTranslatorMap[component.Config, component.ID]() + exporters.Set(t.connectorExporterID) + extensions := common.NewTranslatorMap[component.Config, component.ID]() + for _, e := range t.extensions { + extensions.Set(e) + } + connectors := common.NewTranslatorMap[component.Config, component.ID]() + for _, c := range t.connectors { + connectors.Set(c) + } + return &common.ComponentTranslators{ + Receivers: receivers, + Processors: processors, + Exporters: exporters, + Extensions: extensions, + Connectors: connectors, + }, nil +} + +// outputPipelineTranslator produces rule/default pipelines: routing connector (as receiver) → [processors] → exporter +type outputPipelineTranslator struct { + pipelineID pipeline.ID + connectorReceiverID common.ComponentTranslator // routing connector appears as receiver + processors []common.ComponentTranslator + exporter common.ComponentTranslator + extensions []common.ComponentTranslator +} + +var _ common.PipelineTranslator = (*outputPipelineTranslator)(nil) + +func (t *outputPipelineTranslator) ID() pipeline.ID { return t.pipelineID } + +func (t *outputPipelineTranslator) Translate(_ *confmap.Conf) (*common.ComponentTranslators, error) { + receivers := common.NewTranslatorMap[component.Config, component.ID]() + receivers.Set(t.connectorReceiverID) + processors := common.NewTranslatorMap[component.Config, component.ID]() + for _, p := range t.processors { + processors.Set(p) + } + exporters := common.NewTranslatorMap[component.Config, component.ID]() + exporters.Set(t.exporter) + extensions := common.NewTranslatorMap[component.Config, component.ID]() + for _, e := range t.extensions { + extensions.Set(e) + } + return &common.ComponentTranslators{ + Receivers: receivers, + Processors: processors, + Exporters: exporters, + Extensions: extensions, + }, nil +} + +// NewTranslators creates pipeline translators for the syslog configuration. +// Supports both single-object and array forms for the syslog config key. +func NewTranslators(conf *confmap.Conf) (common.PipelineTranslatorMap, error) { + translators := common.NewTranslatorMap[*common.ComponentTranslators, pipeline.ID]() + if conf == nil || !conf.IsSet(common.SyslogConfigKey) { + return translators, nil + } + + sections := normalizeSyslogSections(conf.Get(common.SyslogConfigKey)) + if err := validateUniqueListeners(sections); err != nil { + return translators, err + } + for i, section := range sections { + pipelines, err := buildSectionPipelines(section, i, conf) + if err != nil { + return translators, err + } + translators.Merge(pipelines) + } + return translators, nil +} + +// normalizeSyslogSections converts the raw syslog config value into a slice +// of section maps, handling both single-object and array forms. +func normalizeSyslogSections(raw any) []map[string]any { + switch v := raw.(type) { + case map[string]any: + return []map[string]any{v} + case []any: + var sections []map[string]any + for _, item := range v { + if m, ok := item.(map[string]any); ok { + sections = append(sections, m) + } + } + return sections + } + return nil +} + +// validateUniqueListeners checks that no listen address appears in more than +// one syslog section. Returns an error if a duplicate is found. +func validateUniqueListeners(sections []map[string]any) error { + seen := make(map[string]int) // resolved address → section index + for i, section := range sections { + for _, listener := range normalizeListeners(section) { + addr, _ := listener["listen_address"].(string) + if addr == "" { + continue + } + tlsConfig := toTLSConfig(listener) + resolved, err := resolveListenAddress(addr, tlsConfig) + if err != nil { + continue // resolution errors are caught later during translation + } + if prevSection, exists := seen[resolved]; exists { + return fmt.Errorf("syslog listen address %q is defined in both section %d and section %d; each address must be unique across all sections", resolved, prevSection, i) + } + seen[resolved] = i + } + } + return nil +} + +// buildSectionPipelines creates all pipelines for a single syslog section. +func buildSectionPipelines(syslogConf map[string]any, sectionIdx int, _ *confmap.Conf) (common.PipelineTranslatorMap, error) { + translators := common.NewTranslatorMap[*common.ComponentTranslators, pipeline.ID]() + prefix := fmt.Sprintf("syslog_%d", sectionIdx) + + listeners := normalizeListeners(syslogConf) + if len(listeners) == 0 { + return translators, nil + } + + defaultLogGroupName, _ := syslogConf["log_group_name"].(string) + defaultLogStreamName, _ := syslogConf["log_stream_name"].(string) + if defaultLogStreamName == "" { + defaultLogStreamName = "{hostname}" + } + defaultRetention := toInt64(syslogConf, "retention_in_days") + + // Build receivers from listeners + var receiverTranslators []common.ComponentTranslator + for _, listener := range listeners { + listenAddress, _ := listener["listen_address"].(string) + tlsConfig := toTLSConfig(listener) + resolvedAddress, err := resolveListenAddress(listenAddress, tlsConfig) + if err != nil { + return translators, err + } + receiverName := deriveReceiverName(resolvedAddress) + protocol, _ := listener["protocol"].(string) + receiverTranslators = append(receiverTranslators, syslogreceiver.NewTranslator(receiverName, resolvedAddress, protocol, tlsConfig)) + } + + // Parse routing rules + var rules []map[string]any + if rawRules, ok := syslogConf["routing"].([]any); ok { + for _, r := range rawRules { + if rm, ok := r.(map[string]any); ok { + rules = append(rules, rm) + } + } + } + + // Build pipeline IDs + defaultPipelineID := pipeline.NewIDWithName(pipeline.SignalLogs, prefix+"_default") + var rulePipelineIDs []pipeline.ID + for i := range rules { + rulePipelineIDs = append(rulePipelineIDs, pipeline.NewIDWithName(pipeline.SignalLogs, fmt.Sprintf("%s_rule_%d", prefix, i))) + } + + // Global filters → filter processor + globalFilters := toFilters(syslogConf) + + // Extensions + extensions := newExtensionTranslators() + + // No routing rules → simple single pipeline (no connector needed) + if len(rules) == 0 { + var processors []common.ComponentTranslator + if len(globalFilters) > 0 { + processors = append(processors, newFilterProcessorTranslator(prefix+"_default", globalFilters)) + } + processors = append(processors, batchprocessor.NewTranslatorWithNameAndSection(prefix+"_default", common.LogsKey)) + + translators.Set(&simplePipelineTranslator{ + pipelineID: defaultPipelineID, + receivers: receiverTranslators, + processors: processors, + exporter: newCWLExporterTranslator(prefix+"_default", defaultLogGroupName, defaultLogStreamName, defaultRetention), + extensions: extensions, + }) + return translators, nil + } + + // Build routing table entries + var tableEntries []routingTableEntry + for i, rule := range rules { + matchMap, _ := rule["match"].(map[string]any) + condition := buildOTTLCondition(matchMap) + if condition != "" { + tableEntries = append(tableEntries, routingTableEntry{ + condition: condition, + pipelines: []pipeline.ID{rulePipelineIDs[i]}, + }) + } + } + + // Create routing connector translator + routingTranslator := newRoutingConnectorTranslator(prefix, []pipeline.ID{defaultPipelineID}, tableEntries) + + // Global filters → filter processor for input pipeline + inName := prefix + "_in" + var inputProcessors []common.ComponentTranslator + if len(globalFilters) > 0 { + inputProcessors = append(inputProcessors, newFilterProcessorTranslator(inName, globalFilters)) + } + + // Input pipeline: receivers → [filter] → routing connector (as exporter) + translators.Set(&inputPipelineTranslator{ + pipelineID: pipeline.NewIDWithName(pipeline.SignalLogs, inName), + receivers: receiverTranslators, + processors: inputProcessors, + connectorExporterID: routingTranslator, + extensions: extensions, + connectors: []common.ComponentTranslator{routingTranslator}, + }) + + // Rule pipelines + for i, rule := range rules { + ruleLogGroup, _ := rule["log_group_name"].(string) + if ruleLogGroup == "" { + ruleLogGroup = defaultLogGroupName + } + ruleLogStream, _ := rule["log_stream_name"].(string) + if ruleLogStream == "" { + ruleLogStream = defaultLogStreamName + } + ruleRetention := toInt64(rule, "retention_in_days") + if ruleRetention == 0 { + ruleRetention = defaultRetention + } + + pipelineName := fmt.Sprintf("%s_rule_%d", prefix, i) + var processors []common.ComponentTranslator + + // Per-rule filters + ruleFilters := toFilters(rule) + if len(ruleFilters) > 0 { + processors = append(processors, newFilterProcessorTranslator(pipelineName, ruleFilters)) + } + + processors = append(processors, batchprocessor.NewTranslatorWithNameAndSection(pipelineName, common.LogsKey)) + + translators.Set(&outputPipelineTranslator{ + pipelineID: rulePipelineIDs[i], + connectorReceiverID: routingTranslator, + processors: processors, + exporter: newCWLExporterTranslator(pipelineName, ruleLogGroup, ruleLogStream, ruleRetention), + extensions: extensions, + }) + } + + // Default pipeline + defaultPipelineName := prefix + "_default" + var defaultProcessors []common.ComponentTranslator + defaultProcessors = append(defaultProcessors, batchprocessor.NewTranslatorWithNameAndSection(defaultPipelineName, common.LogsKey)) + + translators.Set(&outputPipelineTranslator{ + pipelineID: defaultPipelineID, + connectorReceiverID: routingTranslator, + processors: defaultProcessors, + exporter: newCWLExporterTranslator(defaultPipelineName, defaultLogGroupName, defaultLogStreamName, defaultRetention), + extensions: extensions, + }) + + return translators, nil +} + +func newExtensionTranslators() []common.ComponentTranslator { + return []common.ComponentTranslator{ + agenthealth.NewTranslator(agenthealth.LogsName, []string{agenthealth.OperationPutLogEvents}), + agenthealth.NewTranslatorWithStatusCode(agenthealth.StatusCodeName, nil, true), + } +} + +// normalizeListeners converts the config into a slice of listener maps. +// Supports both "listeners" array and "listen_address" shorthand. +// If neither is provided, a default listener is created. +func normalizeListeners(syslogConf map[string]any) []map[string]any { + if rawListeners, ok := syslogConf["listeners"].([]any); ok { + var listeners []map[string]any + for _, l := range rawListeners { + if lm, ok := l.(map[string]any); ok { + listeners = append(listeners, lm) + } + } + return listeners + } + // Shorthand: single listener from top-level fields + addr, _ := syslogConf["listen_address"].(string) + listener := map[string]any{} + if addr != "" { + listener["listen_address"] = addr + } else { + // No listen_address specified — construct default based on TLS presence + if _, hasTLS := syslogConf["tls"]; hasTLS { + listener["listen_address"] = "tcp://" + } else { + listener["listen_address"] = "tcp://" + } + } + if p, ok := syslogConf["protocol"].(string); ok { + listener["protocol"] = p + } + if t, ok := syslogConf["tls"]; ok { + listener["tls"] = t + } + return []map[string]any{listener} +} + +// buildOTTLCondition converts a match map to an OTTL condition string. +func buildOTTLCondition(matchMap map[string]any) string { + if len(matchMap) == 0 { + return "" + } + var conditions []string + if hostname, ok := matchMap["hostname"].(string); ok && hostname != "" { + conditions = append(conditions, buildAttributeCondition("hostname", hostname)) + } + if appName, ok := matchMap["app_name"].(string); ok && appName != "" { + conditions = append(conditions, buildAttributeCondition("app_name", appName)) + } + if facility, ok := matchMap["facility"]; ok { + var facStr string + switch f := facility.(type) { + case float64: + facStr = fmt.Sprintf("%d", int(f)) + case int: + facStr = fmt.Sprintf("%d", f) + default: + facStr = fmt.Sprintf("%v", f) + } + conditions = append(conditions, fmt.Sprintf(`attributes["facility"] == %s`, facStr)) + } + return strings.Join(conditions, " and ") +} + +func buildAttributeCondition(attr, value string) string { + if isGlobPattern(value) { + regex := globToRegex(value) + return fmt.Sprintf(`IsMatch(attributes["%s"], "%s")`, attr, escapeOTTL(regex)) + } + escaped := escapeOTTL(value) + return fmt.Sprintf(`attributes["%s"] == "%s"`, attr, escaped) +} + +// globToRegex converts a glob pattern to an anchored RE2 regex. +// All regex metacharacters are escaped, then glob wildcards are translated +// to their regex equivalents: * → .*, ? → ., [...] → [...]. +func globToRegex(glob string) string { + escaped := regexp.QuoteMeta(glob) + escaped = strings.ReplaceAll(escaped, `\*`, `.*`) + escaped = strings.ReplaceAll(escaped, `\?`, `.`) + escaped = strings.ReplaceAll(escaped, `\[`, `[`) + escaped = strings.ReplaceAll(escaped, `\]`, `]`) + return "^" + escaped + "$" +} + +// isGlobPattern detects if a string contains glob characters. +func isGlobPattern(s string) bool { + return strings.ContainsAny(s, "*?[") +} + +// resolveListenAddress applies default host, port, and protocol based on the +// listener configuration: +// - If no host is specified, default to 127.0.0.1 (localhost) +// - If no port is specified: use 6514 if TLS is configured, 1514 for UDP, 5514 for TCP +// - If TLS is configured and protocol is UDP, return an error +func resolveListenAddress(listenAddress string, tlsConfig map[string]any) (string, error) { + parts := strings.SplitN(listenAddress, "://", 2) + if len(parts) != 2 { + return "", fmt.Errorf("invalid listen address %q, expected protocol://host:port", listenAddress) + } + protocol := strings.ToLower(parts[0]) + if protocol != "tcp" && protocol != "udp" { + return "", fmt.Errorf("unsupported protocol %q, expected tcp or udp", protocol) + } + hostPort := parts[1] + + // TLS + UDP is invalid + if len(tlsConfig) > 0 && protocol == "udp" { + return "", fmt.Errorf("TLS configuration is not supported with UDP listeners: %q", listenAddress) + } + + host, port := splitHostPort(hostPort) + + // Default host to localhost + if host == "" { + host = "127.0.0.1" + } + + // Default port based on TLS and protocol + if port == "" { + if len(tlsConfig) > 0 { + port = "6514" + } else if protocol == "udp" { + port = "1514" + } else { + port = "5514" + } + } + + return fmt.Sprintf("%s://%s", protocol, net.JoinHostPort(host, port)), nil +} + +// splitHostPort splits a host:port string. Handles IPv6 [host]:port notation. +func splitHostPort(hostPort string) (string, string) { + if hostPort == "" { + return "", "" + } + // Handle IPv6 bracket notation [::1]:port + if strings.HasPrefix(hostPort, "[") { + if i := strings.LastIndex(hostPort, "]:"); i != -1 { + return hostPort[1:i], hostPort[i+2:] + } + // Brackets but no port + return strings.Trim(hostPort, "[]"), "" + } + // Multiple colons without brackets = IPv6 address without port + if strings.Count(hostPort, ":") > 1 { + return hostPort, "" + } + if i := strings.LastIndex(hostPort, ":"); i != -1 { + return hostPort[:i], hostPort[i+1:] + } + return hostPort, "" +} + +func deriveReceiverName(listenAddress string) string { + name := strings.ReplaceAll(listenAddress, "://", "_") + name = strings.ReplaceAll(name, ".", "_") + name = strings.ReplaceAll(name, ":", "_") + // IPv6 addresses are wrapped in brackets (e.g. [::1]); strip them so the + // resulting component name contains only alphanumerics and underscores. + name = strings.ReplaceAll(name, "[", "") + name = strings.ReplaceAll(name, "]", "") + return name +} + +func toFilters(m map[string]any) []filter { + rawFilters, ok := m["filters"].([]any) + if !ok { + return nil + } + var filters []filter + for _, rf := range rawFilters { + fm, ok := rf.(map[string]any) + if !ok { + continue + } + f := filter{} + if v, ok := fm["type"].(string); ok { + f.Type = v + } + if v, ok := fm["expression"].(string); ok { + f.Expression = v + } + filters = append(filters, f) + } + return filters +} + +type filter struct { + Type string + Expression string +} + +func toInt64(m map[string]any, key string) int64 { + switch v := m[key].(type) { + case float64: + return int64(v) + case int: + return int64(v) + case int64: + return v + } + return 0 +} + +func toTLSConfig(m map[string]any) map[string]any { + tlsMap, ok := m["tls"].(map[string]any) + if !ok { + return nil + } + cfg := make(map[string]any) + if v, ok := tlsMap["cert_file"].(string); ok { + cfg["cert_file"] = v + } + if v, ok := tlsMap["key_file"].(string); ok { + cfg["key_file"] = v + } + if v, ok := tlsMap["ca_file"].(string); ok { + cfg["ca_file"] = v + } + if v, ok := tlsMap["client_ca_file"].(string); ok { + cfg["client_ca_file"] = v + } + if v, ok := tlsMap["min_version"].(string); ok { + cfg["min_version"] = v + } + if len(cfg) == 0 { + return nil + } + return cfg +} diff --git a/translator/translate/otel/pipeline/syslog/translator_test.go b/translator/translate/otel/pipeline/syslog/translator_test.go new file mode 100644 index 00000000000..548d65d862e --- /dev/null +++ b/translator/translate/otel/pipeline/syslog/translator_test.go @@ -0,0 +1,859 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT + +package syslog + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/confmap" + "go.opentelemetry.io/collector/pipeline" + + "github.com/aws/amazon-cloudwatch-agent/internal/util/collections" +) + +func TestNewTranslatorsNilConf(t *testing.T) { + translators, _ := NewTranslators(nil) + assert.Equal(t, 0, translators.Len()) +} + +func TestNewTranslatorsNoSyslogKey(t *testing.T) { + conf := confmap.NewFromStringMap(map[string]any{ + "logs": map[string]any{"logs_collected": map[string]any{}}, + }) + translators, err := NewTranslators(conf) + require.NoError(t, err) + assert.Equal(t, 0, translators.Len()) +} + +func TestNewTranslatorsSingleListenerNoRules(t *testing.T) { + conf := confmap.NewFromStringMap(map[string]any{ + "logs": map[string]any{"logs_collected": map[string]any{ + "syslog": map[string]any{ + "listen_address": "tcp://0.0.0.0:514", + "log_group_name": "/default", + }, + }}, + }) + translators, err := NewTranslators(conf) + require.NoError(t, err) + // No routing rules → single direct pipeline + assert.Equal(t, 1, translators.Len()) + keys := collections.MapSlice(translators.Keys(), pipeline.ID.String) + assert.Equal(t, []string{"logs/syslog_0_default"}, keys) +} + +func TestNewTranslatorsWithRoutingRules(t *testing.T) { + conf := confmap.NewFromStringMap(map[string]any{ + "logs": map[string]any{"logs_collected": map[string]any{ + "syslog": map[string]any{ + "listen_address": "tcp://0.0.0.0:514", + "log_group_name": "/default", + "routing": []any{ + map[string]any{ + "match": map[string]any{"hostname": "web-*"}, + "log_group_name": "/web", + }, + map[string]any{ + "match": map[string]any{"facility": float64(4)}, + "log_group_name": "/auth", + }, + }, + }, + }}, + }) + translators, err := NewTranslators(conf) + require.NoError(t, err) + // input + 2 rules + 1 default = 4 pipelines + assert.Equal(t, 4, translators.Len()) + keys := collections.MapSlice(translators.Keys(), pipeline.ID.String) + assert.Equal(t, []string{ + "logs/syslog_0_in", + "logs/syslog_0_rule_0", + "logs/syslog_0_rule_1", + "logs/syslog_0_default", + }, keys) +} + +func TestInputPipelineTranslate(t *testing.T) { + conf := confmap.NewFromStringMap(map[string]any{ + "logs": map[string]any{"logs_collected": map[string]any{ + "syslog": map[string]any{ + "listen_address": "tcp://0.0.0.0:514", + "log_group_name": "/default", + "routing": []any{ + map[string]any{ + "match": map[string]any{"hostname": "web-*"}, + "log_group_name": "/web", + }, + }, + }, + }}, + }) + translators, err := NewTranslators(conf) + require.NoError(t, err) + + inputPipeline, ok := translators.Get(pipeline.NewIDWithName(pipeline.SignalLogs, "syslog_0_in")) + require.True(t, ok) + got, err := inputPipeline.Translate(conf) + require.NoError(t, err) + + // Input pipeline has receiver, routing connector as exporter + assert.Equal(t, []string{"syslog/tcp_0_0_0_0_514"}, collections.MapSlice(got.Receivers.Keys(), component.ID.String)) + assert.Equal(t, []string{"routing/syslog_0"}, collections.MapSlice(got.Exporters.Keys(), component.ID.String)) + // Connector should be registered + assert.Equal(t, []string{"routing/syslog_0"}, collections.MapSlice(got.Connectors.Keys(), component.ID.String)) +} + +func TestOutputPipelineTranslate(t *testing.T) { + conf := confmap.NewFromStringMap(map[string]any{ + "logs": map[string]any{"logs_collected": map[string]any{ + "syslog": map[string]any{ + "listen_address": "tcp://0.0.0.0:514", + "log_group_name": "/default", + "routing": []any{ + map[string]any{ + "match": map[string]any{"hostname": "web-*"}, + "log_group_name": "/web", + }, + }, + }, + }}, + }) + translators, err := NewTranslators(conf) + require.NoError(t, err) + + // Check rule pipeline + rulePipeline, ok := translators.Get(pipeline.NewIDWithName(pipeline.SignalLogs, "syslog_0_rule_0")) + require.True(t, ok) + got, err := rulePipeline.Translate(conf) + require.NoError(t, err) + + // Output pipeline has routing connector as receiver + assert.Equal(t, []string{"routing/syslog_0"}, collections.MapSlice(got.Receivers.Keys(), component.ID.String)) + // Has batch processor + procIDs := collections.MapSlice(got.Processors.Keys(), component.ID.String) + assert.Contains(t, procIDs, "batch/syslog_0_rule_0") + // Has CWL exporter + assert.Equal(t, []string{"awscloudwatchlogs/syslog_0_rule_0"}, collections.MapSlice(got.Exporters.Keys(), component.ID.String)) +} + +func TestDeriveReceiverName(t *testing.T) { + assert.Equal(t, "tcp_0_0_0_0_514", deriveReceiverName("tcp://0.0.0.0:514")) + assert.Equal(t, "udp_127_0_0_1_6515", deriveReceiverName("udp://127.0.0.1:6515")) + assert.Equal(t, "tcp___1_514", deriveReceiverName("tcp://[::1]:514")) +} + +func TestBuildOTTLCondition(t *testing.T) { + tests := []struct { + name string + match map[string]any + want string + }{ + { + name: "hostname glob", + match: map[string]any{"hostname": "web-*"}, + want: `IsMatch(attributes["hostname"], "^web-.*$")`, + }, + { + name: "hostname exact", + match: map[string]any{"hostname": "myhost"}, + want: `attributes["hostname"] == "myhost"`, + }, + { + name: "facility", + match: map[string]any{"facility": float64(4)}, + want: `attributes["facility"] == 4`, + }, + { + name: "multiple conditions", + match: map[string]any{"hostname": "web-*", "app_name": "nginx"}, + want: `IsMatch(attributes["hostname"], "^web-.*$") and attributes["app_name"] == "nginx"`, + }, + { + name: "empty", + match: map[string]any{}, + want: "", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := buildOTTLCondition(tc.match) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestIsGlobPattern(t *testing.T) { + assert.True(t, isGlobPattern("web-*")) + assert.True(t, isGlobPattern("host[0-9]")) + assert.True(t, isGlobPattern("app?")) + assert.False(t, isGlobPattern("exact-match")) +} + +func TestToFilters(t *testing.T) { + testCases := []struct { + name string + input map[string]any + want int + }{ + { + name: "NoFiltersKey", + input: map[string]any{}, + want: 0, + }, + { + name: "FiltersNotSlice", + input: map[string]any{"filters": "invalid"}, + want: 0, + }, + { + name: "ValidFilters", + input: map[string]any{ + "filters": []any{ + map[string]any{"type": "exclude", "expression": "healthcheck"}, + map[string]any{"type": "include", "expression": "error|crit"}, + }, + }, + want: 2, + }, + { + name: "SkipsInvalidEntry", + input: map[string]any{ + "filters": []any{ + "not a map", + map[string]any{"type": "include", "expression": "error"}, + }, + }, + want: 1, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := toFilters(tc.input) + assert.Len(t, got, tc.want) + }) + } +} + +func TestNewTranslatorsWithTLS(t *testing.T) { + conf := confmap.NewFromStringMap(map[string]any{ + "logs": map[string]any{"logs_collected": map[string]any{ + "syslog": map[string]any{ + "listen_address": "tcp://0.0.0.0:6514", + "log_group_name": "/tls/default", + "tls": map[string]any{ + "cert_file": "/etc/ssl/cert.pem", + "key_file": "/etc/ssl/key.pem", + "ca_file": "/etc/ssl/ca.pem", + "min_version": "1.3", + }, + }, + }}, + }) + translators, err := NewTranslators(conf) + require.NoError(t, err) + require.Equal(t, 1, translators.Len()) // single direct pipeline (no rules) + + // Get the default pipeline and verify receiver + defaultPipeline, ok := translators.Get(pipeline.NewIDWithName(pipeline.SignalLogs, "syslog_0_default")) + require.True(t, ok) + + got, err := defaultPipeline.Translate(conf) + require.NoError(t, err) + + receiverIDs := collections.MapSlice(got.Receivers.Keys(), component.ID.String) + assert.Equal(t, []string{"syslog/tcp_0_0_0_0_6514"}, receiverIDs) + + // Translate the receiver and verify TLS fields propagate + receiverTrans, ok := got.Receivers.Get(component.NewIDWithName(component.MustNewType("syslog"), "tcp_0_0_0_0_6514")) + require.True(t, ok) + + receiverCfg, err := receiverTrans.Translate(confmap.New()) + require.NoError(t, err) + + out := confmap.New() + require.NoError(t, out.Marshal(receiverCfg)) + assert.Equal(t, "/etc/ssl/cert.pem", out.Get("tcp::tls::cert_file")) + assert.Equal(t, "/etc/ssl/key.pem", out.Get("tcp::tls::key_file")) + assert.Equal(t, "/etc/ssl/ca.pem", out.Get("tcp::tls::ca_file")) + assert.Equal(t, "1.3", out.Get("tcp::tls::min_version")) +} + +func TestFilterProcessorTranslator_ID(t *testing.T) { + tr := newFilterProcessorTranslator("syslog_in", []filter{{Type: "exclude", Expression: "health"}}) + assert.Equal(t, "filter/syslog_in", tr.ID().String()) +} + +func TestFilterProcessorTranslator_Translate(t *testing.T) { + tests := []struct { + name string + filters []filter + want []string + }{ + { + name: "exclude filter", + filters: []filter{{Type: "exclude", Expression: "healthcheck"}}, + want: []string{`IsMatch(body, "healthcheck")`}, + }, + { + name: "include filter", + filters: []filter{{Type: "include", Expression: "error|warn"}}, + want: []string{`not IsMatch(body, "error|warn")`}, + }, + { + name: "mixed filters", + filters: []filter{ + {Type: "exclude", Expression: "debug"}, + {Type: "include", Expression: "crit"}, + }, + want: []string{`IsMatch(body, "debug")`, `not IsMatch(body, "crit")`}, + }, + { + name: "empty filters", + filters: []filter{}, + want: nil, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tr := newFilterProcessorTranslator("test", tc.filters) + cfg, err := tr.Translate(confmap.New()) + require.NoError(t, err) + + out := confmap.New() + require.NoError(t, out.Marshal(cfg)) + conditions, _ := out.Get("logs::log_record").([]any) + if tc.want == nil { + assert.Nil(t, conditions) + } else { + require.Len(t, conditions, len(tc.want)) + for i, want := range tc.want { + assert.Equal(t, want, conditions[i]) + } + } + }) + } +} + +func TestRoutingConnectorTranslator_ID(t *testing.T) { + tr := newRoutingConnectorTranslator("syslog_0", nil, nil) + assert.Equal(t, "routing/syslog_0", tr.ID().String()) +} + +func TestRoutingConnectorTranslator_Translate(t *testing.T) { + defaultPipelines := []pipeline.ID{pipeline.NewIDWithName(pipeline.SignalLogs, "syslog_0_default")} + table := []routingTableEntry{ + {condition: `IsMatch(attributes["hostname"], "web-*")`, pipelines: []pipeline.ID{pipeline.NewIDWithName(pipeline.SignalLogs, "syslog_0_rule_0")}}, + {condition: `attributes["facility"] == "4"`, pipelines: []pipeline.ID{pipeline.NewIDWithName(pipeline.SignalLogs, "syslog_0_rule_1")}}, + } + tr := newRoutingConnectorTranslator("syslog_0", defaultPipelines, table) + cfg, err := tr.Translate(confmap.New()) + require.NoError(t, err) + + out := confmap.New() + require.NoError(t, out.Marshal(cfg)) + assert.Equal(t, []any{"logs/syslog_0_default"}, out.Get("default_pipelines")) + assert.Contains(t, fmt.Sprint(out.Get("error_mode")), "ignore") + + tableOut, ok := out.Get("table").([]any) + require.True(t, ok) + require.Len(t, tableOut, 2) + + entry0 := tableOut[0].(map[string]any) + assert.Equal(t, "log", entry0["context"]) + assert.Equal(t, `IsMatch(attributes["hostname"], "web-*")`, entry0["condition"]) +} + +func TestSigV4AuthTranslator_ID(t *testing.T) { + tr := newSigV4AuthTranslator("us-east-1", "") + assert.Equal(t, "sigv4auth/syslog", tr.ID().String()) +} + +func TestSigV4AuthTranslator_Translate(t *testing.T) { + tr := newSigV4AuthTranslator("us-west-2", "arn:aws:iam::123456789:role/MyRole") + cfg, err := tr.Translate(confmap.New()) + require.NoError(t, err) + + out := confmap.New() + require.NoError(t, out.Marshal(cfg)) + assert.Equal(t, "us-west-2", out.Get("region")) + assert.Equal(t, "logs", out.Get("service")) + assert.Equal(t, "arn:aws:iam::123456789:role/MyRole", out.Get("assume_role::arn")) + assert.Equal(t, "us-west-2", out.Get("assume_role::sts_region")) +} + +func TestSigV4AuthTranslator_NoRoleARN(t *testing.T) { + tr := newSigV4AuthTranslator("eu-west-1", "") + cfg, err := tr.Translate(confmap.New()) + require.NoError(t, err) + + out := confmap.New() + require.NoError(t, out.Marshal(cfg)) + assert.Equal(t, "eu-west-1", out.Get("region")) + assert.Equal(t, "logs", out.Get("service")) + assert.Empty(t, out.Get("assume_role::arn")) +} + +func TestBuildFilterConditions(t *testing.T) { + tests := []struct { + name string + filters []filter + want []string + }{ + { + name: "empty expression skipped", + filters: []filter{{Type: "exclude", Expression: ""}}, + want: nil, + }, + { + name: "escapes quotes", + filters: []filter{{Type: "exclude", Expression: `say "hello"`}}, + want: []string{`IsMatch(body, "say \"hello\"")`}, + }, + { + name: "escapes backslash", + filters: []filter{{Type: "include", Expression: `path\\file`}}, + want: []string{`not IsMatch(body, "path\\\\file")`}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := buildFilterConditions(tc.filters) + if tc.want == nil { + assert.Nil(t, got) + } else { + assert.Equal(t, tc.want, got) + } + }) + } +} + +func TestNewTranslatorsMultiSection(t *testing.T) { + conf := confmap.NewFromStringMap(map[string]any{ + "logs": map[string]any{"logs_collected": map[string]any{ + "syslog": []any{ + map[string]any{ + "listen_address": "tcp://0.0.0.0:514", + "log_group_name": "/infra/default", + "routing": []any{ + map[string]any{ + "match": map[string]any{"facility": float64(4)}, + "log_group_name": "/infra/auth", + }, + }, + }, + map[string]any{ + "listeners": []any{ + map[string]any{"listen_address": "tcp://0.0.0.0:1514"}, + map[string]any{"listen_address": "udp://0.0.0.0:1514"}, + }, + "log_group_name": "/apps/default", + "routing": []any{ + map[string]any{ + "match": map[string]any{"hostname": "web-*"}, + "log_group_name": "/apps/web", + }, + map[string]any{ + "match": map[string]any{"app_name": "api-*"}, + "log_group_name": "/apps/api", + }, + }, + }, + }, + }}, + }) + translators, err := NewTranslators(conf) + require.NoError(t, err) + + // Section 0: 1 input + 1 rule + 1 default = 3 + // Section 1: 1 input + 2 rules + 1 default = 4 + // Total: 7 + assert.Equal(t, 7, translators.Len()) + + keys := collections.MapSlice(translators.Keys(), pipeline.ID.String) + // Section 0 pipelines + assert.Contains(t, keys, "logs/syslog_0_in") + assert.Contains(t, keys, "logs/syslog_0_rule_0") + assert.Contains(t, keys, "logs/syslog_0_default") + // Section 1 pipelines + assert.Contains(t, keys, "logs/syslog_1_in") + assert.Contains(t, keys, "logs/syslog_1_rule_0") + assert.Contains(t, keys, "logs/syslog_1_rule_1") + assert.Contains(t, keys, "logs/syslog_1_default") + + // Verify section 1 input pipeline has both receivers + inputPipeline, ok := translators.Get(pipeline.NewIDWithName(pipeline.SignalLogs, "syslog_1_in")) + require.True(t, ok) + got, err := inputPipeline.Translate(conf) + require.NoError(t, err) + receiverIDs := collections.MapSlice(got.Receivers.Keys(), component.ID.String) + assert.Contains(t, receiverIDs, "syslog/tcp_0_0_0_0_1514") + assert.Contains(t, receiverIDs, "syslog/udp_0_0_0_0_1514") + + // Verify section 1 uses its own routing connector + assert.Equal(t, []string{"routing/syslog_1"}, collections.MapSlice(got.Exporters.Keys(), component.ID.String)) +} + +func TestNewTranslatorsArrayFormSingleSection(t *testing.T) { + // Array with one element should work the same as single object + conf := confmap.NewFromStringMap(map[string]any{ + "logs": map[string]any{"logs_collected": map[string]any{ + "syslog": []any{ + map[string]any{ + "listen_address": "tcp://0.0.0.0:514", + "log_group_name": "/default", + }, + }, + }}, + }) + translators, err := NewTranslators(conf) + require.NoError(t, err) + assert.Equal(t, 1, translators.Len()) + keys := collections.MapSlice(translators.Keys(), pipeline.ID.String) + assert.Equal(t, []string{"logs/syslog_0_default"}, keys) +} + +func TestNewTranslatorsDuplicateListenerAcrossSections(t *testing.T) { + conf := confmap.NewFromStringMap(map[string]any{ + "logs": map[string]any{"logs_collected": map[string]any{ + "syslog": []any{ + map[string]any{ + "listen_address": "tcp://0.0.0.0:514", + "log_group_name": "/section0", + }, + map[string]any{ + "listen_address": "tcp://0.0.0.0:514", + "log_group_name": "/section1", + }, + }, + }}, + }) + _, err := NewTranslators(conf) + require.Error(t, err) + assert.Contains(t, err.Error(), "tcp://0.0.0.0:514") + assert.Contains(t, err.Error(), "section 0") + assert.Contains(t, err.Error(), "section 1") +} + +func TestNewTranslatorsUniqueListenersAcrossSections(t *testing.T) { + conf := confmap.NewFromStringMap(map[string]any{ + "logs": map[string]any{"logs_collected": map[string]any{ + "syslog": []any{ + map[string]any{ + "listen_address": "tcp://0.0.0.0:514", + "log_group_name": "/section0", + }, + map[string]any{ + "listen_address": "tcp://0.0.0.0:1514", + "log_group_name": "/section1", + }, + }, + }}, + }) + translators, err := NewTranslators(conf) + require.NoError(t, err) + assert.Equal(t, 2, translators.Len()) // one simple pipeline per section +} + +func TestProvisionerTranslator_ID(t *testing.T) { + tr := newProvisionerTranslator("syslog_0_default", "us-east-1", "/syslog/default", "default", 7) + assert.Equal(t, "awscloudwatchlogsprovisioner/syslog_0_default", tr.ID().String()) +} + +func TestProvisionerTranslator_Translate(t *testing.T) { + tr := newProvisionerTranslator("syslog_0_rule_0", "us-west-2", "/syslog/web", "web-stream", 30) + cfg, err := tr.Translate(confmap.New()) + require.NoError(t, err) + + out := confmap.New() + require.NoError(t, out.Marshal(cfg)) + assert.Equal(t, "us-west-2", out.Get("region")) + assert.Equal(t, "sigv4auth/syslog", out.Get("additional_auth")) +} + +func TestProvisionerTranslator_ZeroRetention(t *testing.T) { + tr := newProvisionerTranslator("syslog_0_default", "eu-west-1", "/logs", "stream", 0) + cfg, err := tr.Translate(confmap.New()) + require.NoError(t, err) + + out := confmap.New() + require.NoError(t, out.Marshal(cfg)) + assert.Equal(t, "eu-west-1", out.Get("region")) +} + +func TestHeadersSetterTranslator(t *testing.T) { + provisionerID := component.NewIDWithName(component.MustNewType("awscloudwatchlogsprovisioner"), "syslog_0_rule_0") + tr := newHeadersSetterTranslator("syslog_0_rule_0", provisionerID, "/syslog/web", "web-stream", 30) + + assert.Equal(t, "headers_setter/syslog_0_rule_0", tr.ID().String()) + + cfg, err := tr.Translate(confmap.New()) + require.NoError(t, err) + + out := confmap.New() + require.NoError(t, out.Marshal(cfg)) + assert.Equal(t, "awscloudwatchlogsprovisioner/syslog_0_rule_0", out.Get("additional_auth")) + + // Verify log group, log stream, and retention headers are set + headers, ok := out.Get("headers").([]any) + require.True(t, ok, "headers must be a slice") + require.Len(t, headers, 3) + + h0 := headers[0].(map[string]any) + assert.Equal(t, "x-aws-log-group", h0["key"]) + assert.Equal(t, "/syslog/web", h0["value"]) + + h1 := headers[1].(map[string]any) + assert.Equal(t, "x-aws-log-stream", h1["key"]) + assert.Equal(t, "web-stream", h1["value"]) + + h2 := headers[2].(map[string]any) + assert.Equal(t, "x-aws-log-retention", h2["key"]) + assert.Equal(t, "30", h2["value"]) +} + +func TestHeadersSetterTranslator_ZeroRetention(t *testing.T) { + provisionerID := component.NewIDWithName(component.MustNewType("awscloudwatchlogsprovisioner"), "syslog_0_default") + tr := newHeadersSetterTranslator("syslog_0_default", provisionerID, "/logs", "stream", 0) + + cfg, err := tr.Translate(confmap.New()) + require.NoError(t, err) + + out := confmap.New() + require.NoError(t, out.Marshal(cfg)) + assert.Equal(t, "awscloudwatchlogsprovisioner/syslog_0_default", out.Get("additional_auth")) + + // With zero retention, only log group and log stream headers should be set + headers, ok := out.Get("headers").([]any) + require.True(t, ok, "headers must be a slice") + require.Len(t, headers, 2) + + h0 := headers[0].(map[string]any) + assert.Equal(t, "x-aws-log-group", h0["key"]) + assert.Equal(t, "/logs", h0["value"]) + + h1 := headers[1].(map[string]any) + assert.Equal(t, "x-aws-log-stream", h1["key"]) + assert.Equal(t, "stream", h1["value"]) +} + +func TestSplitHostPort(t *testing.T) { + tests := []struct { + name string + input string + wantHost string + wantPort string + }{ + {"empty", "", "", ""}, + {"host and port", "0.0.0.0:514", "0.0.0.0", "514"}, + {"port only", ":6514", "", "6514"}, + {"host only", "myhost", "myhost", ""}, + {"localhost and port", "127.0.0.1:5514", "127.0.0.1", "5514"}, + {"ipv6 with port", "[::1]:514", "::1", "514"}, + {"ipv6 without port", "[::1]", "::1", ""}, + {"ipv6 no brackets", "::1", "::1", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + host, port := splitHostPort(tt.input) + assert.Equal(t, tt.wantHost, host) + assert.Equal(t, tt.wantPort, port) + }) + } +} + +func TestResolveListenAddress(t *testing.T) { + tests := []struct { + name string + address string + tlsConfig map[string]any + want string + wantErr string + }{ + { + name: "fully specified tcp", + address: "tcp://0.0.0.0:514", + want: "tcp://0.0.0.0:514", + }, + { + name: "fully specified udp", + address: "udp://192.168.1.1:1514", + want: "udp://192.168.1.1:1514", + }, + { + name: "no host defaults to localhost - tcp", + address: "tcp://:6514", + want: "tcp://127.0.0.1:6514", + }, + { + name: "no host defaults to localhost - udp", + address: "udp://:1514", + want: "udp://127.0.0.1:1514", + }, + { + name: "no port defaults to 5514 for tcp", + address: "tcp://myhost", + want: "tcp://myhost:5514", + }, + { + name: "no port defaults to 1514 for udp", + address: "udp://myhost", + want: "udp://myhost:1514", + }, + { + name: "no host or port - tcp", + address: "tcp://", + want: "tcp://127.0.0.1:5514", + }, + { + name: "no host or port - udp", + address: "udp://", + want: "udp://127.0.0.1:1514", + }, + { + name: "tls configured - no port defaults to 6514", + address: "tcp://0.0.0.0", + tlsConfig: map[string]any{"cert_file": "/cert.pem", "key_file": "/key.pem"}, + want: "tcp://0.0.0.0:6514", + }, + { + name: "tls configured - no host or port", + address: "tcp://", + tlsConfig: map[string]any{"cert_file": "/cert.pem", "key_file": "/key.pem"}, + want: "tcp://127.0.0.1:6514", + }, + { + name: "tls configured - explicit port preserved", + address: "tcp://0.0.0.0:9999", + tlsConfig: map[string]any{"cert_file": "/cert.pem"}, + want: "tcp://0.0.0.0:9999", + }, + { + name: "tls with udp - error", + address: "udp://0.0.0.0:514", + tlsConfig: map[string]any{"cert_file": "/cert.pem"}, + wantErr: "TLS configuration is not supported with UDP", + }, + { + name: "invalid - no protocol separator", + address: "badaddress", + wantErr: "invalid listen address", + }, + { + name: "invalid - unsupported protocol", + address: "http://localhost:8080", + wantErr: "unsupported protocol", + }, + { + name: "ipv6 with brackets and port", + address: "tcp://[::1]:514", + want: "tcp://[::1]:514", + }, + { + name: "ipv6 with brackets no port", + address: "tcp://[::1]", + want: "tcp://[::1]:5514", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolveListenAddress(tt.address, tt.tlsConfig) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + }) + } +} + +func TestNewTranslatorsNoListenAddress(t *testing.T) { + // When no listen_address is specified, should default to tcp://127.0.0.1:5514 + conf := confmap.NewFromStringMap(map[string]any{ + "logs": map[string]any{"logs_collected": map[string]any{ + "syslog": map[string]any{ + "log_group_name": "/syslog/default", + }, + }}, + }) + translators, err := NewTranslators(conf) + require.NoError(t, err) + assert.Greater(t, translators.Len(), 0) +} + +func TestNewTranslatorsNoListenAddressWithTLS(t *testing.T) { + // When TLS is specified but no listen_address, should default to tcp://127.0.0.1:6514 + conf := confmap.NewFromStringMap(map[string]any{ + "logs": map[string]any{"logs_collected": map[string]any{ + "syslog": map[string]any{ + "log_group_name": "/syslog/secure", + "tls": map[string]any{ + "cert_file": "/etc/tls/cert.pem", + "key_file": "/etc/tls/key.pem", + }, + }, + }}, + }) + translators, err := NewTranslators(conf) + require.NoError(t, err) + assert.Greater(t, translators.Len(), 0) +} + +func TestNewTranslatorsTLSWithUDPFails(t *testing.T) { + conf := confmap.NewFromStringMap(map[string]any{ + "logs": map[string]any{"logs_collected": map[string]any{ + "syslog": map[string]any{ + "listen_address": "udp://0.0.0.0:514", + "log_group_name": "/syslog/default", + "tls": map[string]any{ + "cert_file": "/etc/tls/cert.pem", + "key_file": "/etc/tls/key.pem", + }, + }, + }}, + }) + _, err := NewTranslators(conf) + require.Error(t, err) + assert.Contains(t, err.Error(), "TLS configuration is not supported with UDP") +} + +func TestNewTranslatorsPortOnlyAddress(t *testing.T) { + // tcp://:6514 should resolve to tcp://127.0.0.1:6514 + conf := confmap.NewFromStringMap(map[string]any{ + "logs": map[string]any{"logs_collected": map[string]any{ + "syslog": map[string]any{ + "listen_address": "tcp://:6514", + "log_group_name": "/syslog/default", + }, + }}, + }) + translators, err := NewTranslators(conf) + require.NoError(t, err) + assert.Greater(t, translators.Len(), 0) +} + +func TestNewTranslatorsListenerWithoutAddress(t *testing.T) { + // A listener in the array form without listen_address should get defaults + conf := confmap.NewFromStringMap(map[string]any{ + "logs": map[string]any{"logs_collected": map[string]any{ + "syslog": map[string]any{ + "listeners": []any{ + map[string]any{ + "listen_address": "tcp://", + }, + }, + "log_group_name": "/syslog/default", + }, + }}, + }) + translators, err := NewTranslators(conf) + require.NoError(t, err) + assert.Greater(t, translators.Len(), 0) +} diff --git a/translator/translate/otel/receiver/adapter/translators.go b/translator/translate/otel/receiver/adapter/translators.go index 4eeafbf34d3..d9b83995a44 100644 --- a/translator/translate/otel/receiver/adapter/translators.go +++ b/translator/translate/otel/receiver/adapter/translators.go @@ -32,7 +32,7 @@ const ( var ( logKey = common.ConfigKey(common.LogsKey, common.LogsCollectedKey) metricKey = common.ConfigKey(common.MetricsKey, common.MetricsCollectedKey) - skipInputSet = collections.NewSet[string](files.SectionKey, windowsevents.SectionKey, common.AppSignals, common.AppSignalsFallback) + skipInputSet = collections.NewSet[string](files.SectionKey, windowsevents.SectionKey, common.AppSignals, common.AppSignalsFallback, common.SyslogKey) multipleInputSet = collections.NewSet[string](procstat.SectionKey) // Order by PidFile, ExeKey, Pattern Key according to the public documents // if multiple configuration is specified diff --git a/translator/translate/otel/receiver/syslog/translator.go b/translator/translate/otel/receiver/syslog/translator.go new file mode 100644 index 00000000000..72c02eb3164 --- /dev/null +++ b/translator/translate/otel/receiver/syslog/translator.go @@ -0,0 +1,89 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT + +package syslog + +import ( + "fmt" + "strings" + + "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/syslogreceiver" + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/confmap" + "go.opentelemetry.io/collector/receiver" + + "github.com/aws/amazon-cloudwatch-agent/translator/translate/otel/common" +) + +type translator struct { + name string + listenAddress string + protocol string + tlsConfig map[string]any + factory receiver.Factory +} + +var _ common.ComponentTranslator = (*translator)(nil) + +// NewTranslator creates a new syslog receiver translator. +func NewTranslator(name string, listenAddress string, protocol string, tlsConfig map[string]any) common.ComponentTranslator { + if protocol == "" { + protocol = "rfc5424" + } + return &translator{ + name: name, + listenAddress: listenAddress, + protocol: protocol, + tlsConfig: tlsConfig, + factory: syslogreceiver.NewFactory(), + } +} + +func (t *translator) ID() component.ID { + return component.NewIDWithName(t.factory.Type(), t.name) +} + +// Translate creates a syslog receiver config based on the listen address. +// The listenAddress is expected in the format "tcp://host:port" or "udp://host:port". +func (t *translator) Translate(_ *confmap.Conf) (component.Config, error) { + protocol, address, err := parseListenAddress(t.listenAddress) + if err != nil { + return nil, fmt.Errorf("syslog translator %s: %w", t.name, err) + } + + cfgMap := map[string]any{ + protocol: map[string]any{ + "listen_address": address, + }, + "protocol": t.protocol, + "on_error": "drop", + } + + // TCP-specific settings + if protocol == "tcp" { + cfgMap[protocol].(map[string]any)["max_log_size"] = 64 * 1024 // 64KB - bounds per-connection buffer allocation + if len(t.tlsConfig) > 0 { + cfgMap[protocol].(map[string]any)["tls"] = t.tlsConfig + } + } + + cfg := t.factory.CreateDefaultConfig() + if err := confmap.NewFromStringMap(cfgMap).Unmarshal(cfg); err != nil { + return nil, fmt.Errorf("syslog translator %s: unable to unmarshal config: %w", t.name, err) + } + return cfg, nil +} + +// parseListenAddress parses "tcp://host:port" or "udp://host:port" into +// the transport protocol and host:port address. +func parseListenAddress(listenAddress string) (string, string, error) { + parts := strings.SplitN(listenAddress, "://", 2) + if len(parts) != 2 { + return "", "", fmt.Errorf("invalid listen address %q, expected protocol://host:port", listenAddress) + } + protocol := strings.ToLower(parts[0]) + if protocol != "tcp" && protocol != "udp" { + return "", "", fmt.Errorf("unsupported protocol %q, expected tcp or udp", protocol) + } + return protocol, parts[1], nil +} diff --git a/translator/translate/otel/receiver/syslog/translator_test.go b/translator/translate/otel/receiver/syslog/translator_test.go new file mode 100644 index 00000000000..d71fb450118 --- /dev/null +++ b/translator/translate/otel/receiver/syslog/translator_test.go @@ -0,0 +1,126 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: MIT + +package syslog + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/collector/confmap" +) + +func TestTranslatorID(t *testing.T) { + tt := NewTranslator("tcp_0_0_0_0_514", "tcp://0.0.0.0:514", "", nil) + assert.Equal(t, "syslog/tcp_0_0_0_0_514", tt.ID().String()) +} + +func TestTranslateTCP(t *testing.T) { + tt := NewTranslator("tcp_test", "tcp://0.0.0.0:514", "", nil) + cfg, err := tt.Translate(confmap.New()) + require.NoError(t, err) + require.NotNil(t, cfg) +} + +func TestTranslateUDP(t *testing.T) { + tt := NewTranslator("udp_test", "udp://0.0.0.0:514", "", nil) + cfg, err := tt.Translate(confmap.New()) + require.NoError(t, err) + require.NotNil(t, cfg) +} + +func TestTranslateInvalidAddress(t *testing.T) { + tt := NewTranslator("bad", "invalid-address", "", nil) + _, err := tt.Translate(confmap.New()) + assert.Error(t, err) +} + +func TestTranslateUnsupportedProtocol(t *testing.T) { + tt := NewTranslator("bad", "ws://0.0.0.0:514", "", nil) + _, err := tt.Translate(confmap.New()) + assert.Error(t, err) +} + +func TestTranslateRFC3164(t *testing.T) { + tt := NewTranslator("tcp_test", "tcp://0.0.0.0:514", "rfc3164", nil) + cfg, err := tt.Translate(confmap.New()) + require.NoError(t, err) + require.NotNil(t, cfg) +} + +func TestTranslateDefaultProtocol(t *testing.T) { + tt := NewTranslator("tcp_test", "tcp://0.0.0.0:514", "", nil) + cfg, err := tt.Translate(confmap.New()) + require.NoError(t, err) + require.NotNil(t, cfg) +} + +func TestTranslateTCPWithTLS(t *testing.T) { + tlsCfg := map[string]any{ + "cert_file": "/path/to/cert.pem", + "key_file": "/path/to/key.pem", + "ca_file": "/path/to/ca.pem", + "min_version": "1.3", + } + tt := NewTranslator("tls_test", "tcp://0.0.0.0:6514", "", tlsCfg) + cfg, err := tt.Translate(confmap.New()) + require.NoError(t, err) + require.NotNil(t, cfg) +} + +func TestTranslateTCPWithClientCAFile(t *testing.T) { + tlsCfg := map[string]any{ + "cert_file": "/path/to/cert.pem", + "key_file": "/path/to/key.pem", + "client_ca_file": "/path/to/client-ca.pem", + } + tt := NewTranslator("mtls_test", "tcp://0.0.0.0:6514", "", tlsCfg) + cfg, err := tt.Translate(confmap.New()) + require.NoError(t, err) + require.NotNil(t, cfg) + + out := confmap.New() + require.NoError(t, out.Marshal(cfg)) + assert.Equal(t, "/path/to/client-ca.pem", out.Get("tcp::tls::client_ca_file")) + assert.Equal(t, "/path/to/cert.pem", out.Get("tcp::tls::cert_file")) + assert.Equal(t, "/path/to/key.pem", out.Get("tcp::tls::key_file")) +} + +func TestTranslateUDPIgnoresTLS(t *testing.T) { + tlsCfg := map[string]any{ + "cert_file": "/path/to/cert.pem", + "key_file": "/path/to/key.pem", + } + tt := NewTranslator("udp_tls_test", "udp://0.0.0.0:514", "", tlsCfg) + cfg, err := tt.Translate(confmap.New()) + require.NoError(t, err) + require.NotNil(t, cfg) +} + +func TestParseListenAddress(t *testing.T) { + testCases := []struct { + name string + input string + protocol string + address string + wantErr bool + }{ + {"TCP", "tcp://0.0.0.0:514", "tcp", "0.0.0.0:514", false}, + {"UDP", "udp://127.0.0.1:6515", "udp", "127.0.0.1:6515", false}, + {"MissingProtocol", "0.0.0.0:514", "", "", true}, + {"UnsupportedProtocol", "ws://0.0.0.0:514", "", "", true}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + protocol, address, err := parseListenAddress(tc.input) + if tc.wantErr { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tc.protocol, protocol) + assert.Equal(t, tc.address, address) + } + }) + } +} diff --git a/translator/translate/otel/translate_otel.go b/translator/translate/otel/translate_otel.go index 809ef98804e..843b199ea41 100644 --- a/translator/translate/otel/translate_otel.go +++ b/translator/translate/otel/translate_otel.go @@ -33,6 +33,7 @@ import ( "github.com/aws/amazon-cloudwatch-agent/translator/translate/otel/pipeline/jmx" "github.com/aws/amazon-cloudwatch-agent/translator/translate/otel/pipeline/nop" "github.com/aws/amazon-cloudwatch-agent/translator/translate/otel/pipeline/prometheus" + "github.com/aws/amazon-cloudwatch-agent/translator/translate/otel/pipeline/syslog" "github.com/aws/amazon-cloudwatch-agent/translator/translate/otel/pipeline/systemmetrics" "github.com/aws/amazon-cloudwatch-agent/translator/translate/otel/pipeline/xray" "github.com/aws/amazon-cloudwatch-agent/translator/util/ecsutil" @@ -77,6 +78,11 @@ func Translate(jsonConfig interface{}, os string) (*otelcol.Config, error) { translators.Merge(prometheus.NewTranslators(conf)) translators.Set(emf_logs.NewTranslator()) + syslogTranslators, err := syslog.NewTranslators(conf) + if err != nil { + return nil, err + } + translators.Merge(syslogTranslators) translators.Set(xray.NewTranslator()) translators.Set(containerinsightsjmx.NewTranslator()) translators.Merge(jmx.NewTranslators(conf))