diff --git a/docs/proto/proto.md b/docs/proto/proto.md index 2f92aca70d..702dd2518b 100644 --- a/docs/proto/proto.md +++ b/docs/proto/proto.md @@ -18,6 +18,9 @@ - [AgentConnectStatus.StatusCode](#f5-nginx-agent-sdk-AgentConnectStatus-StatusCode) - [AgentLogging.Level](#f5-nginx-agent-sdk-AgentLogging-Level) +- [command_svc.proto](#command_svc-proto) + - [Commander](#f5-nginx-agent-sdk-Commander) + - [command.proto](#command-proto) - [AgentActivityStatus](#f5-nginx-agent-sdk-AgentActivityStatus) - [ChunkedResourceChunk](#f5-nginx-agent-sdk-ChunkedResourceChunk) @@ -39,9 +42,6 @@ - [NginxConfigStatus.Status](#f5-nginx-agent-sdk-NginxConfigStatus-Status) - [UploadStatus.TransferStatus](#f5-nginx-agent-sdk-UploadStatus-TransferStatus) -- [command_svc.proto](#command_svc-proto) - - [Commander](#f5-nginx-agent-sdk-Commander) - - [common.proto](#common-proto) - [CertificateDates](#f5-nginx-agent-sdk-CertificateDates) - [CertificateName](#f5-nginx-agent-sdk-CertificateName) @@ -341,6 +341,34 @@ Log level enum + +

Top

+ +## command_svc.proto + + + + + + + + + + + +### Commander +Represents a service used to sent command messages between the management server and the agent. + +| Method Name | Request Type | Response Type | Description | +| ----------- | ------------ | ------------- | ------------| +| CommandChannel | [Command](#f5-nginx-agent-sdk-Command) stream | [Command](#f5-nginx-agent-sdk-Command) stream | A Bidirectional streaming RPC established by the agent and is kept open | +| Download | [DownloadRequest](#f5-nginx-agent-sdk-DownloadRequest) | [DataChunk](#f5-nginx-agent-sdk-DataChunk) stream | A streaming RPC established by the agent and is used to download resources associated with commands The download stream will be kept open for the duration of the data transfer and will be closed when its done. The transfer is a stream of chunks as follows: header -> data chunk 1 -> data chunk N. Each data chunk is of a size smaller than the maximum gRPC payload | +| Upload | [DataChunk](#f5-nginx-agent-sdk-DataChunk) stream | [UploadStatus](#f5-nginx-agent-sdk-UploadStatus) | A streaming RPC established by the agent and is used to upload resources associated with commands | + + + + +

Top

@@ -652,34 +680,6 @@ Transfer status enum - -

Top

- -## command_svc.proto - - - - - - - - - - - -### Commander -Represents a service used to sent command messages between the management server and the agent. - -| Method Name | Request Type | Response Type | Description | -| ----------- | ------------ | ------------- | ------------| -| CommandChannel | [Command](#f5-nginx-agent-sdk-Command) stream | [Command](#f5-nginx-agent-sdk-Command) stream | A Bidirectional streaming RPC established by the agent and is kept open | -| Download | [DownloadRequest](#f5-nginx-agent-sdk-DownloadRequest) | [DataChunk](#f5-nginx-agent-sdk-DataChunk) stream | A streaming RPC established by the agent and is used to download resources associated with commands The download stream will be kept open for the duration of the data transfer and will be closed when its done. The transfer is a stream of chunks as follows: header -> data chunk 1 -> data chunk N. Each data chunk is of a size smaller than the maximum gRPC payload | -| Upload | [DataChunk](#f5-nginx-agent-sdk-DataChunk) stream | [UploadStatus](#f5-nginx-agent-sdk-UploadStatus) | A streaming RPC established by the agent and is used to upload resources associated with commands | - - - - -

Top

diff --git a/sdk/grpc/meta_test.go b/sdk/grpc/meta_test.go new file mode 100644 index 0000000000..28dbe3aa69 --- /dev/null +++ b/sdk/grpc/meta_test.go @@ -0,0 +1,133 @@ +/** + * Copyright (c) F5, Inc. + * + * This source code is licensed under the Apache License, Version 2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +package grpc + +import ( + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func resetMetaForTest() { + meta.ClientId = "" + meta.CloudAccountId = "" +} + +func TestInitMeta_PopulatesGlobal(t *testing.T) { + resetMetaForTest() + t.Cleanup(resetMetaForTest) + + InitMeta("client-1", "cloud-1") + + assert.Equal(t, "client-1", meta.ClientId) + assert.Equal(t, "cloud-1", meta.CloudAccountId) +} + +func TestInitMeta_OverwritesPreviousValues(t *testing.T) { + resetMetaForTest() + t.Cleanup(resetMetaForTest) + + InitMeta("first", "cloud-first") + InitMeta("second", "cloud-second") + + assert.Equal(t, "second", meta.ClientId) + assert.Equal(t, "cloud-second", meta.CloudAccountId) +} + +func TestNewMessageMeta_UsesGlobalMeta(t *testing.T) { + resetMetaForTest() + t.Cleanup(resetMetaForTest) + + InitMeta("global-client", "global-cloud") + m := NewMessageMeta("msg-123") + + require.NotNil(t, m) + assert.Equal(t, "msg-123", m.MessageId) + assert.Equal(t, "global-client", m.ClientId) + assert.Equal(t, "global-cloud", m.CloudAccountId) + require.NotNil(t, m.Timestamp) +} + +func TestNewMessageMeta_TimestampIsRecent(t *testing.T) { + resetMetaForTest() + t.Cleanup(resetMetaForTest) + + before := time.Now().Add(-1 * time.Second) + m := NewMessageMeta("msg-time") + after := time.Now().Add(1 * time.Second) + + require.NotNil(t, m.Timestamp) + ts := time.Unix(m.Timestamp.Seconds, int64(m.Timestamp.Nanos)) + assert.True(t, ts.After(before), "timestamp should be after %v, got %v", before, ts) + assert.True(t, ts.Before(after), "timestamp should be before %v, got %v", after, ts) +} + +func TestNewMessageMeta_BeforeInitMeta(t *testing.T) { + resetMetaForTest() + t.Cleanup(resetMetaForTest) + + m := NewMessageMeta("msg-no-init") + require.NotNil(t, m) + assert.Empty(t, m.ClientId) + assert.Empty(t, m.CloudAccountId) + assert.Equal(t, "msg-no-init", m.MessageId) +} + +func TestNewMeta_SetsAllFields(t *testing.T) { + m := NewMeta("client-x", "msg-x", "cloud-x") + + require.NotNil(t, m) + assert.Equal(t, "client-x", m.ClientId) + assert.Equal(t, "msg-x", m.MessageId) + assert.Equal(t, "cloud-x", m.CloudAccountId) + require.NotNil(t, m.Timestamp) +} + +func TestNewMeta_DoesNotMutateGlobal(t *testing.T) { + resetMetaForTest() + t.Cleanup(resetMetaForTest) + + InitMeta("global", "global-cloud") + _ = NewMeta("other-client", "msg", "other-cloud") + + assert.Equal(t, "global", meta.ClientId) + assert.Equal(t, "global-cloud", meta.CloudAccountId) +} + +func TestNewMeta_EmptyArgs(t *testing.T) { + m := NewMeta("", "", "") + require.NotNil(t, m) + assert.Empty(t, m.ClientId) + assert.Empty(t, m.MessageId) + assert.Empty(t, m.CloudAccountId) + assert.NotNil(t, m.Timestamp) +} + +func TestNewMessageMeta_ConcurrentReads(t *testing.T) { + resetMetaForTest() + t.Cleanup(resetMetaForTest) + + InitMeta("concurrent-client", "concurrent-cloud") + + const goroutines = 50 + var wg sync.WaitGroup + wg.Add(goroutines) + + for range goroutines { + go func() { + defer wg.Done() + m := NewMessageMeta("msg") + assert.Equal(t, "concurrent-client", m.ClientId) + assert.Equal(t, "concurrent-cloud", m.CloudAccountId) + }() + } + wg.Wait() +} diff --git a/sdk/interceptors/client_test.go b/sdk/interceptors/client_test.go new file mode 100644 index 0000000000..5e01edaabf --- /dev/null +++ b/sdk/interceptors/client_test.go @@ -0,0 +1,166 @@ +/** + * Copyright (c) F5, Inc. + * + * This source code is licensed under the Apache License, Version 2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +package interceptors + +import ( + "context" + "errors" + "testing" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +const ( + testUUID = "test-uuid-1234" + testToken = "test-token-abcd" + testBearer = "test-bearer-xyz" +) + +func TestNewClientAuth_DefaultLogger(t *testing.T) { + ci := NewClientAuth(testUUID, testToken) + + require.NotNil(t, ci) + assert.Equal(t, testUUID, ci.uuid) + assert.Equal(t, testToken, ci.token) + assert.Empty(t, ci.bearer) + assert.NotNil(t, ci.log, "default logger should be assigned") +} + +func TestNewClientAuth_WithBearerToken(t *testing.T) { + ci := NewClientAuth(testUUID, testToken, WithBearerToken(testBearer)) + + require.NotNil(t, ci) + assert.Equal(t, testBearer, ci.bearer) +} + +func TestNewClientAuth_MultipleOptions(t *testing.T) { + // Applying option multiple times: last one wins. + ci := NewClientAuth(testUUID, testToken, + WithBearerToken("first"), + WithBearerToken("second"), + ) + + require.NotNil(t, ci) + assert.Equal(t, "second", ci.bearer) +} + +func TestClientInterceptor_GetRequestMetadata(t *testing.T) { + ci := NewClientAuth(testUUID, testToken, WithBearerToken(testBearer)) + + md, err := ci.GetRequestMetadata(context.Background()) + require.NoError(t, err) + + assert.Equal(t, testToken, md[TokenHeader]) + assert.Equal(t, testUUID, md[IDHeader]) + assert.Equal(t, testBearer, md[BearerHeader]) + assert.Len(t, md, 3) +} + +func TestClientInterceptor_GetRequestMetadata_IgnoresURIArgs(t *testing.T) { + ci := NewClientAuth(testUUID, testToken) + + md, err := ci.GetRequestMetadata(context.Background(), "ignored", "args") + require.NoError(t, err) + assert.Equal(t, testToken, md[TokenHeader]) +} + +func TestClientInterceptor_RequireTransportSecurity_ReturnsFalse(t *testing.T) { + ci := NewClientAuth(testUUID, testToken) + assert.False(t, ci.RequireTransportSecurity()) +} + +func TestClientInterceptor_Unary_AttachesMetadata(t *testing.T) { + ci := NewClientAuth(testUUID, testToken, WithBearerToken(testBearer)) + unary := ci.Unary() + require.NotNil(t, unary) + + var capturedCtx context.Context + invoker := func(ctx context.Context, _ string, _, _ interface{}, + _ *grpc.ClientConn, _ ...grpc.CallOption, + ) error { + capturedCtx = ctx + return nil + } + + err := unary(context.Background(), "/svc/Method", nil, nil, nil, invoker) + require.NoError(t, err) + + md, ok := metadata.FromOutgoingContext(capturedCtx) + require.True(t, ok, "outgoing metadata should be present") + assert.Equal(t, []string{testToken}, md.Get(TokenHeader)) + assert.Equal(t, []string{testUUID}, md.Get(IDHeader)) + assert.Equal(t, []string{testBearer}, md.Get(BearerHeader)) +} + +func TestClientInterceptor_Unary_PropagatesInvokerError(t *testing.T) { + ci := NewClientAuth(testUUID, testToken) + wantErr := errors.New("invoker exploded") + + invoker := func(_ context.Context, _ string, _, _ interface{}, + _ *grpc.ClientConn, _ ...grpc.CallOption, + ) error { + return wantErr + } + + err := ci.Unary()(context.Background(), "/svc/Method", nil, nil, nil, invoker) + assert.ErrorIs(t, err, wantErr) +} + +func TestClientInterceptor_Stream_AttachesMetadata(t *testing.T) { + ci := NewClientAuth(testUUID, testToken, WithBearerToken(testBearer)) + stream := ci.Stream() + require.NotNil(t, stream) + + var capturedCtx context.Context + streamer := func(ctx context.Context, _ *grpc.StreamDesc, _ *grpc.ClientConn, + _ string, _ ...grpc.CallOption, + ) (grpc.ClientStream, error) { + capturedCtx = ctx + return nil, nil + } + + _, err := stream(context.Background(), &grpc.StreamDesc{}, nil, "/svc/Stream", streamer) + require.NoError(t, err) + + md, ok := metadata.FromOutgoingContext(capturedCtx) + require.True(t, ok) + assert.Equal(t, []string{testToken}, md.Get(TokenHeader)) + assert.Equal(t, []string{testUUID}, md.Get(IDHeader)) + assert.Equal(t, []string{testBearer}, md.Get(BearerHeader)) +} + +func TestClientInterceptor_AttachToken_PreservesExistingMetadata(t *testing.T) { + ci := NewClientAuth(testUUID, testToken) + + existing := metadata.Pairs("custom-key", "custom-value") + ctx := metadata.NewOutgoingContext(context.Background(), existing) + + out := ci.attachToken(ctx) + md, ok := metadata.FromOutgoingContext(out) + require.True(t, ok) + + assert.Equal(t, []string{"custom-value"}, md.Get("custom-key")) + assert.Equal(t, []string{testToken}, md.Get(TokenHeader)) +} + +func TestClientInterceptor_CustomLogger(t *testing.T) { + customLogger := log.New() + ci := NewClientAuth(testUUID, testToken, withLoggerForTest(customLogger)) + + assert.Same(t, customLogger, ci.log) +} + +func withLoggerForTest(l *log.Logger) Option { + return func(opt *option) { + opt.client.log = l + } +} diff --git a/sdk/interceptors/interceptors_test.go b/sdk/interceptors/interceptors_test.go new file mode 100644 index 0000000000..b1a750b67a --- /dev/null +++ b/sdk/interceptors/interceptors_test.go @@ -0,0 +1,24 @@ +/** + * Copyright (c) F5, Inc. + * + * This source code is licensed under the Apache License, Version 2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +package interceptors + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestClientInterceptor_ImplementsInterfaces(t *testing.T) { + var ci interface{} = NewClientAuth(testUUID, testToken) + + _, okInterceptor := ci.(Interceptor) + assert.True(t, okInterceptor, "clientInterceptor must implement Interceptor") + + _, okClient := ci.(ClientInterceptor) + assert.True(t, okClient, "clientInterceptor must implement ClientInterceptor") +} diff --git a/sdk/traverser_test.go b/sdk/traverser_test.go new file mode 100644 index 0000000000..8564b6a01b --- /dev/null +++ b/sdk/traverser_test.go @@ -0,0 +1,231 @@ +/** + * Copyright (c) F5, Inc. + * + * This source code is licensed under the Apache License, Version 2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +package sdk + +import ( + "errors" + "testing" + + crossplane "github.com/nginxinc/nginx-go-crossplane" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// buildSampleConfig constructs a minimal crossplane.Config with two top-level +// directives: "events" containing one nested directive, and "http" containing +// a nested "server" with a "listen" directive. +// +// events { +// worker_connections 1024; +// } +// http { +// server { +// listen 80; +// } +// } +func buildSampleConfig() *crossplane.Config { + listen := &crossplane.Directive{Directive: "listen", Args: []string{"80"}} + server := &crossplane.Directive{Directive: "server", Block: []*crossplane.Directive{listen}} + http := &crossplane.Directive{Directive: "http", Block: []*crossplane.Directive{server}} + + workerConn := &crossplane.Directive{Directive: "worker_connections", Args: []string{"1024"}} + events := &crossplane.Directive{Directive: "events", Block: []*crossplane.Directive{workerConn}} + + return &crossplane.Config{Parsed: []*crossplane.Directive{events, http}} +} + +func TestCrossplaneConfigTraverse_VisitsEveryDirective(t *testing.T) { + cfg := buildSampleConfig() + + visited := make([]string, 0) + err := CrossplaneConfigTraverse(cfg, func(_, current *crossplane.Directive) (bool, error) { + visited = append(visited, current.Directive) + return true, nil + }) + require.NoError(t, err) + assert.ElementsMatch(t, + []string{"events", "worker_connections", "http", "server", "listen"}, + visited, + ) +} + +func TestCrossplaneConfigTraverse_StopsOnFalse(t *testing.T) { + cfg := buildSampleConfig() + + count := 0 + err := CrossplaneConfigTraverse(cfg, func(_, current *crossplane.Directive) (bool, error) { + count++ + return false, nil + }) + require.NoError(t, err) + assert.Equal(t, 1, count, "callback should be invoked exactly once before stopping") +} + +func TestCrossplaneConfigTraverse_StopsOnFalseInsideBlock(t *testing.T) { + cfg := buildSampleConfig() + + visited := make([]string, 0) + err := CrossplaneConfigTraverse(cfg, func(_, current *crossplane.Directive) (bool, error) { + visited = append(visited, current.Directive) + if current.Directive == "server" { + return false, nil + } + return true, nil + }) + require.NoError(t, err) + assert.NotContains(t, visited, "listen") + assert.Contains(t, visited, "server") +} + +func TestCrossplaneConfigTraverse_PropagatesError(t *testing.T) { + cfg := buildSampleConfig() + wantErr := errors.New("callback boom") + + calls := 0 + err := CrossplaneConfigTraverse(cfg, func(_, current *crossplane.Directive) (bool, error) { + calls++ + if current.Directive == "worker_connections" { + return false, wantErr + } + return true, nil + }) + + require.ErrorIs(t, err, wantErr) + assert.Greater(t, calls, 0) +} + +func TestCrossplaneConfigTraverse_PropagatesErrorFromTopLevel(t *testing.T) { + cfg := buildSampleConfig() + wantErr := errors.New("top error") + + err := CrossplaneConfigTraverse(cfg, func(parent, _ *crossplane.Directive) (bool, error) { + if parent == nil { + return false, wantErr + } + return true, nil + }) + assert.ErrorIs(t, err, wantErr) +} + +func TestCrossplaneConfigTraverse_EmptyConfig(t *testing.T) { + cfg := &crossplane.Config{Parsed: nil} + + called := false + err := CrossplaneConfigTraverse(cfg, func(_, _ *crossplane.Directive) (bool, error) { + called = true + return true, nil + }) + + require.NoError(t, err) + assert.False(t, called, "callback should not be invoked for empty config") +} + +func TestCrossplaneConfigTraverse_DeepNesting(t *testing.T) { + leaf := &crossplane.Directive{Directive: "e"} + cur := leaf + for _, name := range []string{"d", "c", "b", "a"} { + cur = &crossplane.Directive{Directive: name, Block: []*crossplane.Directive{cur}} + } + cfg := &crossplane.Config{Parsed: []*crossplane.Directive{cur}} + + visited := make([]string, 0) + err := CrossplaneConfigTraverse(cfg, func(_, current *crossplane.Directive) (bool, error) { + visited = append(visited, current.Directive) + return true, nil + }) + require.NoError(t, err) + assert.Equal(t, []string{"a", "b", "c", "d", "e"}, visited) +} + +func TestCrossplaneConfigTraverse_ParentArgPopulated(t *testing.T) { + cfg := buildSampleConfig() + + pairs := make(map[string]string) + err := CrossplaneConfigTraverse(cfg, func(parent, current *crossplane.Directive) (bool, error) { + if parent != nil { + pairs[current.Directive] = parent.Directive + } else { + pairs[current.Directive] = "" + } + return true, nil + }) + require.NoError(t, err) + assert.Equal(t, "", pairs["events"], "top-level directives have nil parent") + assert.Equal(t, "", pairs["http"], "top-level directives have nil parent") + assert.Equal(t, "events", pairs["worker_connections"]) + assert.Equal(t, "http", pairs["server"]) + assert.Equal(t, "server", pairs["listen"]) +} + +func TestCrossplaneConfigTraverseStr_ReturnsFirstMatch(t *testing.T) { + cfg := buildSampleConfig() + + got := CrossplaneConfigTraverseStr(cfg, func(_, current *crossplane.Directive) string { + if current.Directive == "listen" { + return "found-listen" + } + return "" + }) + + assert.Equal(t, "found-listen", got) +} + +func TestCrossplaneConfigTraverseStr_ReturnsTopLevelMatchEarly(t *testing.T) { + cfg := buildSampleConfig() + + visited := 0 + got := CrossplaneConfigTraverseStr(cfg, func(_, current *crossplane.Directive) string { + visited++ + if current.Directive == "events" { + return "got-events" + } + return "" + }) + + assert.Equal(t, "got-events", got) + assert.Equal(t, 1, visited, "should stop after first non-empty match") +} + +func TestCrossplaneConfigTraverseStr_NoMatchReturnsEmpty(t *testing.T) { + cfg := buildSampleConfig() + + got := CrossplaneConfigTraverseStr(cfg, func(_, _ *crossplane.Directive) string { + return "" + }) + + assert.Empty(t, got) +} + +func TestCrossplaneConfigTraverseStr_EmptyConfig(t *testing.T) { + cfg := &crossplane.Config{Parsed: nil} + + called := false + got := CrossplaneConfigTraverseStr(cfg, func(_, _ *crossplane.Directive) string { + called = true + return "x" + }) + + assert.Empty(t, got) + assert.False(t, called) +} + +func TestCrossplaneConfigTraverseStr_SearchesAcrossSiblings(t *testing.T) { + first := &crossplane.Directive{Directive: "first"} + matchTarget := &crossplane.Directive{Directive: "target"} + second := &crossplane.Directive{Directive: "second", Block: []*crossplane.Directive{matchTarget}} + cfg := &crossplane.Config{Parsed: []*crossplane.Directive{first, second}} + + got := CrossplaneConfigTraverseStr(cfg, func(_, current *crossplane.Directive) string { + if current.Directive == "target" { + return "hit" + } + return "" + }) + + assert.Equal(t, "hit", got) +} diff --git a/src/core/config/types_test.go b/src/core/config/types_test.go new file mode 100644 index 0000000000..313e1f88bc --- /dev/null +++ b/src/core/config/types_test.go @@ -0,0 +1,218 @@ +/** + * Copyright (c) F5, Inc. + * + * This source code is licensed under the Apache License, Version 2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +package config + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestConfig_AllowedDirectories(t *testing.T) { + tests := []struct { + name string + m map[string]struct{} + want []string + }{ + { + name: "empty map returns empty slice", + m: map[string]struct{}{}, + want: []string{}, + }, + { + name: "single dir", + m: map[string]struct{}{"/etc/nginx": {}}, + want: []string{"/etc/nginx"}, + }, + { + name: "multiple dirs", + m: map[string]struct{}{ + "/etc/nginx": {}, + "/usr/share/nms": {}, + "/var/log/nginx": {}, + "/usr/local/etc": {}, + }, + want: []string{"/etc/nginx", "/usr/share/nms", "/var/log/nginx", "/usr/local/etc"}, + }, + { + name: "nil map", + m: nil, + want: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Config{AllowedDirectoriesMap: tt.m} + got := c.AllowedDirectories() + assert.ElementsMatch(t, tt.want, got) + }) + } +} + +func TestConfig_IsGrpcServerConfigured(t *testing.T) { + tests := []struct { + name string + host string + port int + expect bool + }{ + {"both set", "localhost", 1234, true}, + {"host empty", "", 1234, false}, + {"port zero", "localhost", 0, false}, + {"both zero", "", 0, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Config{Server: Server{Host: tt.host, GrpcPort: tt.port}} + assert.Equal(t, tt.expect, c.IsGrpcServerConfigured()) + }) + } +} + +func TestConfig_IsFeatureEnabled(t *testing.T) { + tests := []struct { + name string + features []string + query string + expect bool + }{ + {"present", []string{"metrics", "events"}, "metrics", true}, + {"present in middle", []string{"a", "b", "c"}, "b", true}, + {"absent", []string{"metrics"}, "events", false}, + {"empty list", []string{}, "metrics", false}, + {"nil list", nil, "metrics", false}, + {"empty query against empty list", []string{}, "", false}, + {"case sensitive", []string{"Metrics"}, "metrics", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Config{Features: tt.features} + assert.Equal(t, tt.expect, c.IsFeatureEnabled(tt.query)) + }) + } +} + +func TestConfig_IsExtensionEnabled(t *testing.T) { + tests := []struct { + name string + extensions []string + query string + expect bool + }{ + {"present", []string{"nginx-app-protect"}, "nginx-app-protect", true}, + {"absent", []string{"nginx-app-protect"}, "advanced-metrics", false}, + {"empty list", []string{}, "advanced-metrics", false}, + {"nil list", nil, "advanced-metrics", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Config{Extensions: tt.extensions} + assert.Equal(t, tt.expect, c.IsExtensionEnabled(tt.query)) + }) + } +} + +func TestConfig_GetServerBackoffSettings_MapsAllFields(t *testing.T) { + c := &Config{ + Server: Server{ + Backoff: Backoff{ + InitialInterval: 1 * time.Second, + RandomizationFactor: 0.25, + Multiplier: 2.0, + MaxInterval: 30 * time.Second, + MaxElapsedTime: 5 * time.Minute, + }, + }, + } + + got := c.GetServerBackoffSettings() + + assert.Equal(t, 1*time.Second, got.InitialInterval) + assert.Equal(t, 30*time.Second, got.MaxInterval) + assert.Equal(t, 5*time.Minute, got.MaxElapsedTime) + assert.InDelta(t, 2.0, got.Multiplier, 0) + assert.InDelta(t, 0.25, got.Jitter, 0) +} + +func TestConfig_GetMetricsBackoffSettings_MapsAllFields(t *testing.T) { + c := &Config{ + AgentMetrics: AgentMetrics{ + Backoff: Backoff{ + InitialInterval: 500 * time.Millisecond, + RandomizationFactor: 0.1, + Multiplier: 1.5, + MaxInterval: 10 * time.Second, + MaxElapsedTime: 1 * time.Minute, + }, + }, + } + + got := c.GetMetricsBackoffSettings() + + assert.Equal(t, 500*time.Millisecond, got.InitialInterval) + assert.Equal(t, 10*time.Second, got.MaxInterval) + assert.Equal(t, 1*time.Minute, got.MaxElapsedTime) + assert.InDelta(t, 1.5, got.Multiplier, 0) + assert.InDelta(t, 0.1, got.Jitter, 0) +} + +func TestConfig_GetServerBackoffSettings_ZeroValues(t *testing.T) { + c := &Config{} + got := c.GetServerBackoffSettings() + + assert.Zero(t, got.InitialInterval) + assert.Zero(t, got.MaxInterval) + assert.Zero(t, got.MaxElapsedTime) + assert.InDelta(t, 0.0, got.Multiplier, 0) + assert.InDelta(t, 0.0, got.Jitter, 0) +} + +func TestConfig_IsFileAllowed(t *testing.T) { + allowed := map[string]struct{}{ + "/etc/nginx": {}, + "/var/log/nginx": {}, + } + + tests := []struct { + name string + path string + expect bool + }{ + {"absolute path inside allowed dir", "/etc/nginx/nginx.conf", true}, + {"absolute path inside second allowed dir", "/var/log/nginx/access.log", true}, + {"absolute path matches dir prefix exactly", "/etc/nginx", true}, + {"absolute path outside allowed dirs", "/etc/passwd", false}, + {"multiple rooted directories in allowed dir", "/etc/nginx/conf.d/default.conf", true}, + {"relative path is rejected", "etc/nginx/nginx.conf", false}, + {"empty path", "", false}, + {"dot-relative", "./nginx.conf", false}, + {"prefix-only match (current behaviour)", "/etc/nginxfoo/x.conf", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Config{AllowedDirectoriesMap: allowed} + assert.Equal(t, tt.expect, c.IsFileAllowed(tt.path)) + }) + } +} + +func TestConfig_IsFileAllowed_EmptyAllowList(t *testing.T) { + c := &Config{AllowedDirectoriesMap: map[string]struct{}{}} + assert.False(t, c.IsFileAllowed("/etc/nginx/nginx.conf")) +} + +func TestConfig_IsFileAllowed_NilAllowList(t *testing.T) { + c := &Config{AllowedDirectoriesMap: nil} + assert.False(t, c.IsFileAllowed("/etc/nginx/nginx.conf")) +} diff --git a/src/core/logger/log_test.go b/src/core/logger/log_test.go new file mode 100644 index 0000000000..12323a3285 --- /dev/null +++ b/src/core/logger/log_test.go @@ -0,0 +1,138 @@ +/** + * Copyright (c) F5, Inc. + * + * This source code is licensed under the Apache License, Version 2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +package logger + +import ( + "os" + "path/filepath" + "testing" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func resetLogger(t *testing.T) { + t.Helper() + prevLevel := log.GetLevel() + prevOut := log.StandardLogger().Out + t.Cleanup(func() { + log.SetLevel(prevLevel) + log.SetOutput(prevOut) + }) +} + +func TestSetLogLevel_ValidLevels(t *testing.T) { + tests := []struct { + input string + want log.Level + }{ + {"trace", log.TraceLevel}, + {"debug", log.DebugLevel}, + {"info", log.InfoLevel}, + {"warn", log.WarnLevel}, + {"warning", log.WarnLevel}, + {"error", log.ErrorLevel}, + {"fatal", log.FatalLevel}, + {"panic", log.PanicLevel}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + resetLogger(t) + SetLogLevel(tt.input) + assert.Equal(t, tt.want, log.GetLevel()) + }) + } +} + +func TestSetLogLevel_EmptyIsNoOp(t *testing.T) { + resetLogger(t) + log.SetLevel(log.WarnLevel) + SetLogLevel(" ") + assert.Equal(t, log.WarnLevel, log.GetLevel(), "empty log level input must not change level") +} + +func TestSetLogLevel_TrailingSpaces(t *testing.T) { + resetLogger(t) + log.SetLevel(log.WarnLevel) + SetLogLevel("INFO ") + assert.Equal(t, log.WarnLevel, log.GetLevel(), "log level input with trailing spaces must be trimmed and applied") +} + +func TestSetLogLevel_InvalidLeavesLevelUnchanged(t *testing.T) { + resetLogger(t) + log.SetLevel(log.InfoLevel) + SetLogLevel("not-a-level") + assert.Equal(t, log.InfoLevel, log.GetLevel()) +} + +func TestSetLogFile_EmptyReturnsNil(t *testing.T) { + resetLogger(t) + got := SetLogFile("") + assert.Nil(t, got) +} + +func TestSetLogFile_NonExistentFilePathReturnsNil(t *testing.T) { + resetLogger(t) + missing := filepath.Join(t.TempDir(), "filepath-does-not-exist", "agent.log") + got := SetLogFile(missing) + assert.Nil(t, got) +} + +func TestSetLogFile_ExistingFileIsAppended(t *testing.T) { + resetLogger(t) + dir := t.TempDir() + logPath := filepath.Join(dir, "agent.log") + + require.NoError(t, os.WriteFile(logPath, []byte("existing\n"), 0o600)) + + fh := SetLogFile(logPath) + require.NotNil(t, fh) + t.Cleanup(func() { _ = fh.Close() }) + + log.Info("appended") + + require.NoError(t, fh.Close()) + + contents, err := os.ReadFile(logPath) + require.NoError(t, err) + assert.Contains(t, string(contents), "existing", "previous content should be preserved (O_APPEND)") + assert.Contains(t, string(contents), "appended", "new log line should be present") +} + +func TestSetLogFile_DirectoryAppendsDefaultFileName(t *testing.T) { + resetLogger(t) + dir := t.TempDir() + + fh := SetLogFile(dir) + require.NotNil(t, fh) + t.Cleanup(func() { _ = fh.Close() }) + + expectedPath := filepath.Join(dir, "agent.log") + _, err := os.Stat(expectedPath) + assert.NoError(t, err, "expected default agent.log to be created in the directory") + + assert.Equal(t, expectedPath, fh.Name()) +} + +func TestSetLogFile_NewLogFileCreated(t *testing.T) { + resetLogger(t) + dir := t.TempDir() + logPath := filepath.Join(dir, "fresh.log") + + f, err := os.Create(logPath) + require.NoError(t, err) + require.NoError(t, f.Close()) + + fh := SetLogFile(logPath) + require.NotNil(t, fh) + t.Cleanup(func() { _ = fh.Close() }) + + assert.Equal(t, logPath, fh.Name()) +} diff --git a/src/core/metrics/metrics_util_test.go b/src/core/metrics/metrics_util_test.go new file mode 100644 index 0000000000..0a5378934b --- /dev/null +++ b/src/core/metrics/metrics_util_test.go @@ -0,0 +1,241 @@ +/** + * Copyright (c) F5, Inc. + * + * This source code is licensed under the Apache License, Version 2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +package metrics + +import ( + "testing" + + "github.com/nginx/agent/sdk/v2/proto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetTimeMetrics_EmptyReturnsZero(t *testing.T) { + for _, mt := range []string{"time", "count", "max", "median", "pctl95"} { + t.Run(mt, func(t *testing.T) { + assert.InDelta(t, 0.0, GetTimeMetrics(nil, mt), 0) + assert.InDelta(t, 0.0, GetTimeMetrics([]float64{}, mt), 0) + }) + } +} + +func TestGetTimeMetrics_UnknownMetricTypeReturnsZero(t *testing.T) { + got := GetTimeMetrics([]float64{1, 2, 3}, "not-a-real-type") + assert.InDelta(t, 0.0, got, 0) +} + +func TestGetTimeMetrics_TimeAverage(t *testing.T) { + tests := []struct { + name string + input []float64 + want float64 + }{ + {"single value", []float64{4.0}, 4.0}, + {"simple average", []float64{2.0, 4.0, 6.0}, 4.0}, + {"average of two", []float64{1.0, 2.0}, 1.5}, + {"rounded to 3dp before division", []float64{0.111, 0.222, 0.333}, 0.222}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GetTimeMetrics(tt.input, "time") + assert.InDelta(t, tt.want, got, 1e-9) + }) + } +} + +func TestGetTimeMetrics_Count(t *testing.T) { + got := GetTimeMetrics([]float64{1.5, 2.5, 3.5, 4.5}, "count") + assert.InDelta(t, 4.0, got, 0) +} + +func TestGetTimeMetrics_Max(t *testing.T) { + tests := []struct { + name string + input []float64 + want float64 + }{ + {"single", []float64{42.0}, 42.0}, + {"already sorted", []float64{1, 2, 3, 4, 5}, 5}, + {"unsorted", []float64{3, 1, 4, 1, 5, 9, 2, 6}, 9}, + {"negatives", []float64{-3, -1, -2}, -1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input := append([]float64{}, tt.input...) + got := GetTimeMetrics(input, "max") + assert.InDelta(t, tt.want, got, 0) + }) + } +} + +func TestGetTimeMetrics_Median(t *testing.T) { + tests := []struct { + name string + input []float64 + want float64 + }{ + {"odd count", []float64{1, 2, 3, 4, 5}, 3}, + {"odd count unsorted", []float64{5, 1, 3, 4, 2}, 3}, + {"even count", []float64{1, 2, 3, 4}, 2.5}, + {"even count unsorted", []float64{4, 1, 3, 2}, 2.5}, + {"single value", []float64{7.5}, 7.5}, + {"two values", []float64{2, 4}, 3}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input := append([]float64{}, tt.input...) + got := GetTimeMetrics(input, "median") + assert.InDelta(t, tt.want, got, 0) + }) + } +} + +func TestGetTimeMetrics_Pctl95(t *testing.T) { + tests := []struct { + name string + input []float64 + want float64 + }{ + {"twenty values", makeRange(1, 20), 19}, + {"ten values - banker rounds up", makeRange(1, 10), 10}, + {"two values", []float64{1, 2}, 2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input := append([]float64{}, tt.input...) + got := GetTimeMetrics(input, "pctl95") + assert.InDelta(t, tt.want, got, 0) + }) + } +} + +func makeRange(lo, hi int) []float64 { + out := make([]float64, 0, hi-lo+1) + for i := lo; i <= hi; i++ { + out = append(out, float64(i)) + } + return out +} + +func TestNewStatsEntityWrapper_PreservesType(t *testing.T) { + dims := []*proto.Dimension{{Name: "host", Value: "h1"}} + samples := []*proto.SimpleMetric{{Name: "cpu", Value: 0.5}} + + w := NewStatsEntityWrapper(dims, samples, proto.MetricsReport_SYSTEM) + + require.NotNil(t, w) + assert.Equal(t, proto.MetricsReport_SYSTEM, w.Type) + require.NotNil(t, w.Data) + assert.Equal(t, dims, w.Data.Dimensions) + assert.Equal(t, samples, w.Data.Simplemetrics) + assert.NotNil(t, w.Data.Timestamp) +} + +func TestNewStatsEntity_PopulatesFields(t *testing.T) { + dims := []*proto.Dimension{{Name: "host", Value: "h1"}} + samples := []*proto.SimpleMetric{{Name: "cpu", Value: 0.5}} + + se := NewStatsEntity(dims, samples) + + require.NotNil(t, se) + assert.Equal(t, dims, se.Dimensions) + assert.Equal(t, samples, se.Simplemetrics) + assert.NotNil(t, se.Timestamp) +} + +func TestGetCalculationMap_ContainsExpectedKeys(t *testing.T) { + m := GetCalculationMap() + require.NotNil(t, m) + + cases := map[string]string{ + "system.cpu.user": "avg", + "system.io.iops_r": "sum", + "nginx.status": "boolean", + "nginx.http.status.2xx": "sum", + "nginx.http.request.time": "avg", + "plus.http.request.count": "sum", + "container.cpu.cores": "avg", + } + + for key, want := range cases { + got, ok := m[key] + assert.True(t, ok, "key %q missing from calculation map", key) + assert.Equal(t, want, got, "key %q has wrong calc type", key) + } +} + +func TestGetCalculationMap_ReturnsConsistentSnapshots(t *testing.T) { + a := GetCalculationMap() + b := GetCalculationMap() + assert.Equal(t, len(a), len(b)) +} + +func TestGenerateMetricsReportBundle_NilWhenEmpty(t *testing.T) { + got := GenerateMetricsReportBundle(nil) + assert.Nil(t, got) + + got = GenerateMetricsReportBundle([]*StatsEntityWrapper{}) + assert.Nil(t, got) +} + +func TestGenerateMetricsReportBundle_SkipsNilEntities(t *testing.T) { + got := GenerateMetricsReportBundle([]*StatsEntityWrapper{nil, nil}) + assert.Nil(t, got, "all-nil input should produce a nil bundle") +} + +func TestGenerateMetricsReportBundle_SkipsEntitiesWithNilData(t *testing.T) { + entities := []*StatsEntityWrapper{ + {Type: proto.MetricsReport_SYSTEM, Data: nil}, + } + got := GenerateMetricsReportBundle(entities) + assert.Nil(t, got) +} + +func TestGenerateMetricsReportBundle_GroupsByType(t *testing.T) { + systemDims := []*proto.Dimension{{Name: "host", Value: "h"}} + systemSamples := []*proto.SimpleMetric{{Name: "cpu", Value: 1}} + instanceDims := []*proto.Dimension{{Name: "instance", Value: "i"}} + instanceSamples := []*proto.SimpleMetric{{Name: "rps", Value: 2}} + + entities := []*StatsEntityWrapper{ + NewStatsEntityWrapper(systemDims, systemSamples, proto.MetricsReport_SYSTEM), + NewStatsEntityWrapper(systemDims, systemSamples, proto.MetricsReport_SYSTEM), + NewStatsEntityWrapper(instanceDims, instanceSamples, proto.MetricsReport_INSTANCE), + } + + got := GenerateMetricsReportBundle(entities) + require.NotNil(t, got) + + bundle, ok := got.(*MetricsReportBundle) + require.True(t, ok, "expected *MetricsReportBundle, got %T", got) + require.Len(t, bundle.Data, 2, "expected one MetricsReport per type") + + byType := make(map[proto.MetricsReport_Type]int) + for _, r := range bundle.Data { + byType[r.Type] = len(r.Data) + assert.NotNil(t, r.Meta) + assert.NotNil(t, r.Meta.Timestamp) + } + assert.Equal(t, 2, byType[proto.MetricsReport_SYSTEM]) + assert.Equal(t, 1, byType[proto.MetricsReport_INSTANCE]) +} + +func TestGetTimeMetrics_DoesNotMutateInputForTime(t *testing.T) { + original := []float64{3, 1, 4, 1, 5, 9, 2, 6} + copyIn := append([]float64{}, original...) + + _ = GetTimeMetrics(copyIn, "time") + assert.Equal(t, original, copyIn) + + _ = GetTimeMetrics(copyIn, "count") + assert.Equal(t, original, copyIn) +}