From eafb38aa28ce493038137301270bcaad29bd4323 Mon Sep 17 00:00:00 2001 From: Spencer Ugbo Date: Thu, 25 Sep 2025 16:37:16 +0100 Subject: [PATCH 01/27] use codecov in workflow for test coverage --- .github/workflows/ci.yml | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 628a63a59..002d09110 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,14 +68,10 @@ jobs: cache: false - name: Run Unit Tests run: make unit-test - - name: Check Coverage - uses: vladopajic/go-test-coverage@dd4b1f21c4e48db0425e1187d2845404b1206919 + - name: Upload Test Coverage + uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 with: - config: ./.testcoverage.yaml - ## when token is not specified (value '') this feature is turned off - git-token: ${{ github.ref_name == 'main' && secrets.GITHUB_TOKEN || '' }} - ## name of orphaned branch where badges are stored - git-branch: badges + files: ./build/test/coverage.out race-condition-test: name: Unit tests with race condition detection From 7ca138843f9c29a1e22800ddbd228a6258a2b1d2 Mon Sep 17 00:00:00 2001 From: Spencer Ugbo Date: Thu, 25 Sep 2025 17:02:45 +0100 Subject: [PATCH 02/27] add token for coverage upload --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 002d09110..de53fb66d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,7 @@ jobs: uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 with: files: ./build/test/coverage.out + token: ${{ secrets.CODECOV_TOKEN }} race-condition-test: name: Unit tests with race condition detection From 6609a06952121c7dd053a497e19574183b65f081 Mon Sep 17 00:00:00 2001 From: Spencer Ugbo Date: Fri, 26 Sep 2025 11:39:50 +0100 Subject: [PATCH 03/27] add config file for codecov --- .codecov.yml | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .codecov.yml diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 000000000..3a8550391 --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,37 @@ +# Codecov configuration file +# This file configures code coverage reporting and requirements for the project +coverage: + + # Coverage status configuration + status: + + # Project-level coverage settings + project: + + # Default status check configuration + default: + + # The minimum required coverage value for the project + target: 80% + + # PR-level coverage settings + patch: + + default: + + target: 80% + +# Ignore files or packages matching their paths +ignore: + - "\.pb\.go$" # Excludes all protobuf generated files + - "\.gen\.go" # Excludes generated files + - "^fake_.*\.go" # Excludes fakes + - "^test/.*$" + - "app.go" # app.go and main.go should be tested by integration tests. + - "main.go" + # ignore metadata generated files + - "metadata/generated_.*\.go" + # ignore wrappers around gopsutil + - "internal/datasource/host" + - "internal/watcher/process" + - "pkg/nginxprocess" From a946362bba6c8ed08a8305d07101dc33b2965d0e Mon Sep 17 00:00:00 2001 From: Spencer Ugbo Date: Fri, 26 Sep 2025 14:00:37 +0100 Subject: [PATCH 04/27] fix typo codecov config --- .codecov.yml | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/.codecov.yml b/.codecov.yml index 3a8550391..f1d061cfc 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -14,24 +14,28 @@ coverage: # The minimum required coverage value for the project target: 80% - # PR-level coverage settings + # The allowed coverage decrease before failing the status check + threshold: 0% + + # Patch-level coverage settings patch: default: target: 80% + threshold: 0% # Ignore files or packages matching their paths ignore: - - "\.pb\.go$" # Excludes all protobuf generated files - - "\.gen\.go" # Excludes generated files - - "^fake_.*\.go" # Excludes fakes - - "^test/.*$" - - "app.go" # app.go and main.go should be tested by integration tests. - - "main.go" + - '\.pb\.go$' # Excludes all protobuf generated files + - '\.gen\.go' # Excludes generated files + - '^fake_.*\.go' # Excludes fakes + - '^test/.*$' + - 'app.go' # app.go and main.go should be tested by integration tests. + - 'main.go' # ignore metadata generated files - - "metadata/generated_.*\.go" + - 'metadata/generated_.*\.go' # ignore wrappers around gopsutil - - "internal/datasource/host" - - "internal/watcher/process" - - "pkg/nginxprocess" + - 'internal/datasource/host' + - 'internal/watcher/process' + - 'pkg/nginxprocess' From 65a3658f6d804243abb93b692b21fcefd6f0b278 Mon Sep 17 00:00:00 2001 From: Spencer Ugbo Date: Wed, 1 Oct 2025 13:40:28 +0100 Subject: [PATCH 05/27] test code coverage enforcement in pipeline --- .testcoverage.yaml | 52 - Makefile | 2 +- internal/command/command_plugin_test.go | 395 ------- internal/command/command_service_test.go | 531 ---------- internal/file/fake_file_stream_test.go | 116 --- internal/file/file_manager_service_test.go | 1033 ------------------- internal/file/file_operator_test.go | 105 -- internal/file/file_plugin_test.go | 519 ---------- internal/file/file_service_operator_test.go | 189 ---- 9 files changed, 1 insertion(+), 2941 deletions(-) delete mode 100644 .testcoverage.yaml delete mode 100644 internal/command/command_plugin_test.go delete mode 100644 internal/command/command_service_test.go delete mode 100644 internal/file/fake_file_stream_test.go delete mode 100644 internal/file/file_manager_service_test.go delete mode 100644 internal/file/file_operator_test.go delete mode 100644 internal/file/file_plugin_test.go delete mode 100644 internal/file/file_service_operator_test.go diff --git a/.testcoverage.yaml b/.testcoverage.yaml deleted file mode 100644 index 2e26acc49..000000000 --- a/.testcoverage.yaml +++ /dev/null @@ -1,52 +0,0 @@ -# (mandatory) -# Path to coverprofile file (output of `go test -coverprofile` command) -profile: build/test/coverage.out - -# (optional) -# When specified reported file paths will not contain local prefix in the output -local-prefix: "github.com/nginx/agent/v3" - -# Holds coverage thresholds percentages, values should be in range [0-100] -threshold: - # (optional; default 0) - # The minimum coverage that each file should have - # file: 70 - - # (optional; default 0) - # The minimum coverage that each package should have - package: 80 - - # (optional; default 0) - # The minimum total coverage project should have - total: 80 - -# Holds regexp rules which will override thresholds for matched files or packages using their paths. -# -# First rule from this list that matches file or package is going to apply new threshold to it. -# If project has multiple rules that match same path, override rules should be listed in order from -# specific to more general rules. -#override: - # Increase coverage threshold to 100% for `foo` package (default is 80, as configured above) -# - threshold: 100 -# path: ^pkg/lib/foo$ - -# Holds regexp rules which will exclude matched files or packages from coverage statistics -exclude: - # Exclude files or packages matching their paths - paths: - - \.pb\.go$ # Excludes all protobuf generated files - - \.gen\.go # Excludes generated files - - ^fake_.*\.go # Excludes fakes - - ^test/.*$ - - app.go # app.go and main.go should be tested by integration tests. - - main.go - # ignore metadata generated files - - metadata/generated_.*\.go - # ignore wrappers around gopsutil - - internal/datasource/host - - internal/watcher/process - - pkg/nginxprocess - -# NOTES: -# - symbol `/` in all path regexps will be replaced by -# current OS file path separator to properly work on Windows diff --git a/Makefile b/Makefile index 276c94e2d..298ccab6d 100644 --- a/Makefile +++ b/Makefile @@ -153,7 +153,7 @@ $(TEST_BUILD_DIR)/coverage.out: .PHONY: coverage coverage: $(TEST_BUILD_DIR)/coverage.out @echo "Checking code coverage" - @$(GORUN) $(GOTESTCOVERAGE) --config=./.testcoverage.yaml + @printf "Total code coverage: " && $(GOTOOL) cover -func=$(TEST_BUILD_DIR)/coverage.out | grep 'total:' | awk '{print $$3}' build-mock-management-plane-grpc: mkdir -p $(BUILD_DIR)/mock-management-plane-grpc diff --git a/internal/command/command_plugin_test.go b/internal/command/command_plugin_test.go deleted file mode 100644 index c51f3c579..000000000 --- a/internal/command/command_plugin_test.go +++ /dev/null @@ -1,395 +0,0 @@ -// 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 command - -import ( - "bytes" - "context" - "testing" - "time" - - "github.com/nginx/agent/v3/internal/model" - - pkg "github.com/nginx/agent/v3/pkg/config" - "github.com/nginx/agent/v3/pkg/id" - - "google.golang.org/protobuf/types/known/timestamppb" - - "github.com/nginx/agent/v3/internal/bus/busfakes" - "github.com/nginx/agent/v3/internal/config" - - mpi "github.com/nginx/agent/v3/api/grpc/mpi/v1" - "github.com/nginx/agent/v3/internal/bus" - "github.com/nginx/agent/v3/internal/command/commandfakes" - "github.com/nginx/agent/v3/internal/grpc/grpcfakes" - "github.com/nginx/agent/v3/test/helpers" - "github.com/nginx/agent/v3/test/protos" - "github.com/nginx/agent/v3/test/stub" - "github.com/nginx/agent/v3/test/types" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestCommandPlugin_Info(t *testing.T) { - commandPlugin := NewCommandPlugin(types.AgentConfig(), &grpcfakes.FakeGrpcConnectionInterface{}, model.Command) - info := commandPlugin.Info() - - assert.Equal(t, "command", info.Name) -} - -func TestCommandPlugin_Subscriptions(t *testing.T) { - commandPlugin := NewCommandPlugin(types.AgentConfig(), &grpcfakes.FakeGrpcConnectionInterface{}, model.Command) - subscriptions := commandPlugin.Subscriptions() - - assert.Equal( - t, - []string{ - bus.ConnectionResetTopic, - bus.ResourceUpdateTopic, - bus.InstanceHealthTopic, - bus.DataPlaneHealthResponseTopic, - bus.DataPlaneResponseTopic, - }, - subscriptions, - ) -} - -func TestCommandPlugin_Init(t *testing.T) { - ctx := context.Background() - messagePipe := busfakes.NewFakeMessagePipe() - fakeCommandService := &commandfakes.FakeCommandService{} - - commandPlugin := NewCommandPlugin(types.AgentConfig(), &grpcfakes.FakeGrpcConnectionInterface{}, model.Command) - err := commandPlugin.Init(ctx, messagePipe) - require.NoError(t, err) - - require.NotNil(t, commandPlugin.messagePipe) - require.NotNil(t, commandPlugin.commandService) - - commandPlugin.commandService = fakeCommandService - - closeError := commandPlugin.Close(ctx) - require.NoError(t, closeError) -} - -func TestCommandPlugin_createConnection(t *testing.T) { - ctx := context.Background() - commandService := &commandfakes.FakeCommandService{} - commandService.CreateConnectionReturns(&mpi.CreateConnectionResponse{}, nil) - messagePipe := busfakes.NewFakeMessagePipe() - - commandPlugin := NewCommandPlugin(types.AgentConfig(), &grpcfakes.FakeGrpcConnectionInterface{}, model.Command) - err := commandPlugin.Init(ctx, messagePipe) - commandPlugin.commandService = commandService - require.NoError(t, err) - defer commandPlugin.Close(ctx) - - commandPlugin.createConnection(ctx, &mpi.Resource{}) - - assert.Eventually( - t, - func() bool { return commandService.SubscribeCallCount() > 0 }, - 2*time.Second, - 10*time.Millisecond, - ) - - assert.Eventually( - t, - func() bool { return len(messagePipe.Messages()) == 1 }, - 2*time.Second, - 10*time.Millisecond, - ) - - messages := messagePipe.Messages() - assert.Len(t, messages, 1) - assert.Equal(t, bus.ConnectionCreatedTopic, messages[0].Topic) -} - -func TestCommandPlugin_Process(t *testing.T) { - ctx := context.Background() - messagePipe := busfakes.NewFakeMessagePipe() - fakeCommandService := &commandfakes.FakeCommandService{} - - commandPlugin := NewCommandPlugin(types.AgentConfig(), &grpcfakes.FakeGrpcConnectionInterface{}, model.Command) - err := commandPlugin.Init(ctx, messagePipe) - require.NoError(t, err) - defer commandPlugin.Close(ctx) - - // Check CreateConnection - fakeCommandService.IsConnectedReturnsOnCall(0, false) - - // Check UpdateDataPlaneStatus - fakeCommandService.IsConnectedReturnsOnCall(1, true) - fakeCommandService.IsConnectedReturnsOnCall(2, true) - - commandPlugin.commandService = fakeCommandService - - commandPlugin.Process(ctx, &bus.Message{Topic: bus.ResourceUpdateTopic, Data: protos.HostResource()}) - require.Equal(t, 1, fakeCommandService.CreateConnectionCallCount()) - - commandPlugin.Process(ctx, &bus.Message{Topic: bus.ResourceUpdateTopic, Data: protos.HostResource()}) - require.Equal(t, 1, fakeCommandService.UpdateDataPlaneStatusCallCount()) - - commandPlugin.Process(ctx, &bus.Message{Topic: bus.InstanceHealthTopic, Data: protos.InstanceHealths()}) - require.Equal(t, 1, fakeCommandService.UpdateDataPlaneHealthCallCount()) - - commandPlugin.Process(ctx, &bus.Message{Topic: bus.DataPlaneResponseTopic, Data: protos.OKDataPlaneResponse()}) - require.Equal(t, 1, fakeCommandService.SendDataPlaneResponseCallCount()) - - commandPlugin.Process(ctx, &bus.Message{ - Topic: bus.DataPlaneHealthResponseTopic, - Data: protos.HealthyInstanceHealth(), - }) - require.Equal(t, 1, fakeCommandService.UpdateDataPlaneHealthCallCount()) - require.Equal(t, 1, fakeCommandService.SendDataPlaneResponseCallCount()) - - commandPlugin.Process(ctx, &bus.Message{ - Topic: bus.ConnectionResetTopic, - Data: commandPlugin.conn, - }) - require.Equal(t, 1, fakeCommandService.UpdateClientCallCount()) -} - -func TestCommandPlugin_monitorSubscribeChannel(t *testing.T) { - tests := []struct { - managementPlaneRequest *mpi.ManagementPlaneRequest - expectedTopic *bus.Message - name string - request string - configFeatures []string - }{ - { - name: "Test 1: Config Upload Request", - managementPlaneRequest: &mpi.ManagementPlaneRequest{ - Request: &mpi.ManagementPlaneRequest_ConfigUploadRequest{ - ConfigUploadRequest: &mpi.ConfigUploadRequest{}, - }, - }, - expectedTopic: &bus.Message{Topic: bus.ConfigUploadRequestTopic}, - request: "UploadRequest", - configFeatures: config.DefaultFeatures(), - }, - { - name: "Test 2: Config Apply Request", - managementPlaneRequest: &mpi.ManagementPlaneRequest{ - Request: &mpi.ManagementPlaneRequest_ConfigApplyRequest{ - ConfigApplyRequest: &mpi.ConfigApplyRequest{}, - }, - }, - expectedTopic: &bus.Message{Topic: bus.ConfigApplyRequestTopic}, - request: "ApplyRequest", - configFeatures: config.DefaultFeatures(), - }, - { - name: "Test 3: Health Request", - managementPlaneRequest: &mpi.ManagementPlaneRequest{ - Request: &mpi.ManagementPlaneRequest_HealthRequest{ - HealthRequest: &mpi.HealthRequest{}, - }, - }, - expectedTopic: &bus.Message{Topic: bus.DataPlaneHealthRequestTopic}, - configFeatures: config.DefaultFeatures(), - }, - { - name: "Test 4: API Action Request", - managementPlaneRequest: &mpi.ManagementPlaneRequest{ - Request: &mpi.ManagementPlaneRequest_ActionRequest{ - ActionRequest: &mpi.APIActionRequest{ - Action: &mpi.APIActionRequest_NginxPlusAction{}, - }, - }, - }, - expectedTopic: &bus.Message{Topic: bus.APIActionRequestTopic}, - request: "APIActionRequest", - configFeatures: []string{ - pkg.FeatureConfiguration, - pkg.FeatureMetrics, - pkg.FeatureFileWatcher, - pkg.FeatureAPIAction, - }, - }, - } - - for _, test := range tests { - t.Run(test.name, func(tt *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - messagePipe := busfakes.NewFakeMessagePipe() - - agentConfig := types.AgentConfig() - agentConfig.Features = test.configFeatures - commandPlugin := NewCommandPlugin(agentConfig, &grpcfakes.FakeGrpcConnectionInterface{}, model.Command) - err := commandPlugin.Init(ctx, messagePipe) - require.NoError(tt, err) - defer commandPlugin.Close(ctx) - - go commandPlugin.monitorSubscribeChannel(ctx) - - commandPlugin.subscribeChannel <- test.managementPlaneRequest - - assert.Eventually( - t, - func() bool { return len(messagePipe.Messages()) == 1 }, - 2*time.Second, - 10*time.Millisecond, - ) - - messages := messagePipe.Messages() - assert.Len(tt, messages, 1) - assert.Equal(tt, test.expectedTopic.Topic, messages[0].Topic) - - mp, ok := messages[0].Data.(*mpi.ManagementPlaneRequest) - - switch test.request { - case "UploadRequest": - assert.True(tt, ok) - require.NotNil(tt, mp.GetConfigUploadRequest()) - case "ApplyRequest": - assert.True(tt, ok) - require.NotNil(tt, mp.GetConfigApplyRequest()) - case "APIActionRequest": - assert.True(tt, ok) - require.NotNil(tt, mp.GetActionRequest()) - } - }) - } -} - -func TestCommandPlugin_FeatureDisabled(t *testing.T) { - tests := []struct { - managementPlaneRequest *mpi.ManagementPlaneRequest - expectedLog string - name string - request string - configFeatures []string - }{ - { - name: "Test 1: Config Upload Request", - managementPlaneRequest: &mpi.ManagementPlaneRequest{ - Request: &mpi.ManagementPlaneRequest_ConfigUploadRequest{ - ConfigUploadRequest: &mpi.ConfigUploadRequest{}, - }, - }, - expectedLog: "Configuration feature disabled. Unable to process config upload request", - request: "UploadRequest", - configFeatures: []string{ - pkg.FeatureMetrics, - pkg.FeatureFileWatcher, - }, - }, - { - name: "Test 2: Config Apply Request", - managementPlaneRequest: &mpi.ManagementPlaneRequest{ - Request: &mpi.ManagementPlaneRequest_ConfigApplyRequest{ - ConfigApplyRequest: &mpi.ConfigApplyRequest{}, - }, - }, - expectedLog: "Configuration feature disabled. Unable to process config apply request", - request: "ApplyRequest", - configFeatures: []string{ - pkg.FeatureMetrics, - pkg.FeatureFileWatcher, - }, - }, - { - name: "Test 3: API Action Request", - managementPlaneRequest: &mpi.ManagementPlaneRequest{ - Request: &mpi.ManagementPlaneRequest_ActionRequest{ - ActionRequest: &mpi.APIActionRequest{ - Action: &mpi.APIActionRequest_NginxPlusAction{}, - }, - }, - }, - expectedLog: "API Action Request feature disabled. Unable to process API action request", - request: "APIActionRequest", - configFeatures: config.DefaultFeatures(), - }, - } - - for _, test := range tests { - t.Run(test.name, func(tt *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - fakeCommandService := &commandfakes.FakeCommandService{} - fakeCommandService.SendDataPlaneResponseReturns(nil) - messagePipe := busfakes.NewFakeMessagePipe() - - agentConfig := types.AgentConfig() - - agentConfig.Features = test.configFeatures - - commandPlugin := NewCommandPlugin(agentConfig, &grpcfakes.FakeGrpcConnectionInterface{}, model.Command) - err := commandPlugin.Init(ctx, messagePipe) - commandPlugin.commandService = fakeCommandService - require.NoError(tt, err) - defer commandPlugin.Close(ctx) - - go commandPlugin.monitorSubscribeChannel(ctx) - - commandPlugin.subscribeChannel <- test.managementPlaneRequest - assert.Eventually( - tt, - func() bool { return fakeCommandService.SendDataPlaneResponseCallCount() == 1 }, - 2*time.Second, - 10*time.Millisecond, - ) - }) - } -} - -func TestMonitorSubscribeChannel(t *testing.T) { - ctx, cncl := context.WithCancel(context.Background()) - - logBuf := &bytes.Buffer{} - stub.StubLoggerWith(logBuf) - - cp := NewCommandPlugin(types.AgentConfig(), &grpcfakes.FakeGrpcConnectionInterface{}, model.Command) - cp.subscribeCancel = cncl - - message := protos.CreateManagementPlaneRequest() - - // Run in a separate goroutine - go cp.monitorSubscribeChannel(ctx) - - // Give some time to exit the goroutine - time.Sleep(100 * time.Millisecond) - - cp.subscribeChannel <- message - - // Give some time to process the message - time.Sleep(100 * time.Millisecond) - - cp.Close(ctx) - - time.Sleep(100 * time.Millisecond) - - helpers.ValidateLog(t, "Received management plane request", logBuf) - - // Clear the log buffer - logBuf.Reset() -} - -func Test_createDataPlaneResponse(t *testing.T) { - expected := &mpi.DataPlaneResponse{ - MessageMeta: &mpi.MessageMeta{ - MessageId: id.GenerateMessageID(), - CorrelationId: "dfsbhj6-bc92-30c1-a9c9-85591422068e", - Timestamp: timestamppb.Now(), - }, - CommandResponse: &mpi.CommandResponse{ - Status: mpi.CommandResponse_COMMAND_STATUS_OK, - Message: "Success", - Error: "", - }, - } - commandPlugin := NewCommandPlugin(types.AgentConfig(), &grpcfakes.FakeGrpcConnectionInterface{}, model.Command) - result := commandPlugin.createDataPlaneResponse(expected.GetMessageMeta().GetCorrelationId(), - expected.GetCommandResponse().GetStatus(), - expected.GetCommandResponse().GetMessage(), expected.GetCommandResponse().GetError()) - - assert.Equal(t, expected.GetCommandResponse(), result.GetCommandResponse()) - assert.Equal(t, expected.GetMessageMeta().GetCorrelationId(), result.GetMessageMeta().GetCorrelationId()) -} diff --git a/internal/command/command_service_test.go b/internal/command/command_service_test.go deleted file mode 100644 index d91e9fe0f..000000000 --- a/internal/command/command_service_test.go +++ /dev/null @@ -1,531 +0,0 @@ -// 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 command - -import ( - "bytes" - "context" - "errors" - "log/slog" - "sync" - "testing" - "time" - - "github.com/google/uuid" - "google.golang.org/protobuf/types/known/timestamppb" - - "github.com/nginx/agent/v3/internal/logger" - "github.com/nginx/agent/v3/test/helpers" - "github.com/nginx/agent/v3/test/stub" - - "github.com/nginx/agent/v3/api/grpc/mpi/v1/v1fakes" - "github.com/nginx/agent/v3/test/protos" - "github.com/nginx/agent/v3/test/types" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "google.golang.org/grpc" - - mpi "github.com/nginx/agent/v3/api/grpc/mpi/v1" -) - -type FakeSubscribeClient struct { - grpc.ClientStream -} - -func (*FakeSubscribeClient) Send(*mpi.DataPlaneResponse) error { - return nil -} - -//nolint:nilnil // required nil return -func (*FakeSubscribeClient) Recv() (*mpi.ManagementPlaneRequest, error) { - time.Sleep(1 * time.Second) - - return nil, nil -} - -type FakeConfigApplySubscribeClient struct { - grpc.ClientStream -} - -func (*FakeConfigApplySubscribeClient) Send(*mpi.DataPlaneResponse) error { - return nil -} - -func (*FakeConfigApplySubscribeClient) Recv() (*mpi.ManagementPlaneRequest, error) { - nginxInstance := protos.NginxOssInstance([]string{}) - - return &mpi.ManagementPlaneRequest{ - MessageMeta: &mpi.MessageMeta{ - MessageId: "1", - CorrelationId: "123", - Timestamp: timestamppb.Now(), - }, - Request: &mpi.ManagementPlaneRequest_ConfigApplyRequest{ - ConfigApplyRequest: &mpi.ConfigApplyRequest{ - Overview: &mpi.FileOverview{ - ConfigVersion: &mpi.ConfigVersion{ - InstanceId: nginxInstance.GetInstanceMeta().GetInstanceId(), - Version: "4215432", - }, - }, - }, - }, - }, nil -} - -func TestCommandService_receiveCallback_configApplyRequest(t *testing.T) { - fakeSubscribeClient := &FakeConfigApplySubscribeClient{} - ctx := context.Background() - subscribeCtx, subscribeCancel := context.WithCancel(ctx) - - commandServiceClient := &v1fakes.FakeCommandServiceClient{} - commandServiceClient.SubscribeReturns(fakeSubscribeClient, nil) - - subscribeChannel := make(chan *mpi.ManagementPlaneRequest) - - commandService := NewCommandService( - commandServiceClient, - types.AgentConfig(), - subscribeChannel, - ) - go commandService.Subscribe(subscribeCtx) - defer subscribeCancel() - - nginxInstance := protos.NginxOssInstance([]string{}) - commandService.resourceMutex.Lock() - commandService.resource.Instances = append(commandService.resource.Instances, nginxInstance) - commandService.resourceMutex.Unlock() - - var wg sync.WaitGroup - - wg.Add(1) - go func() { - requestFromChannel := <-subscribeChannel - assert.NotNil(t, requestFromChannel) - wg.Done() - }() - - assert.Eventually( - t, - func() bool { return commandServiceClient.SubscribeCallCount() > 0 }, - 2*time.Second, - 10*time.Millisecond, - ) - - commandService.configApplyRequestQueueMutex.Lock() - defer commandService.configApplyRequestQueueMutex.Unlock() - assert.Len(t, commandService.configApplyRequestQueue, 1) - wg.Wait() -} - -func TestCommandService_UpdateDataPlaneStatus(t *testing.T) { - ctx := context.Background() - - fakeSubscribeClient := &FakeSubscribeClient{} - - commandServiceClient := &v1fakes.FakeCommandServiceClient{} - commandServiceClient.SubscribeReturns(fakeSubscribeClient, nil) - - commandService := NewCommandService( - commandServiceClient, - types.AgentConfig(), - make(chan *mpi.ManagementPlaneRequest), - ) - // Fail first time since there are no other instances besides the agent - err := commandService.UpdateDataPlaneStatus(ctx, protos.HostResource()) - require.Error(t, err) - - resource := protos.HostResource() - resource.Instances = append(resource.Instances, protos.NginxOssInstance([]string{})) - _, connectionErr := commandService.CreateConnection(ctx, resource) - require.NoError(t, connectionErr) - err = commandService.UpdateDataPlaneStatus(ctx, resource) - - require.NoError(t, err) - assert.Equal(t, 1, commandServiceClient.UpdateDataPlaneStatusCallCount()) -} - -func TestCommandService_UpdateDataPlaneStatusSubscribeError(t *testing.T) { - correlationID, _ := helpers.CreateTestIDs(t) - ctx := context.WithValue( - context.Background(), - logger.CorrelationIDContextKey, - slog.Any(logger.CorrelationIDKey, correlationID.String()), - ) - - fakeSubscribeClient := &FakeSubscribeClient{} - - commandServiceClient := &v1fakes.FakeCommandServiceClient{} - commandServiceClient.SubscribeReturns(fakeSubscribeClient, errors.New("sub error")) - commandServiceClient.UpdateDataPlaneStatusReturns(nil, errors.New("ret error")) - - logBuf := &bytes.Buffer{} - stub.StubLoggerWith(logBuf) - - commandService := NewCommandService( - commandServiceClient, - types.AgentConfig(), - make(chan *mpi.ManagementPlaneRequest), - ) - - commandService.isConnected.Store(true) - - err := commandService.UpdateDataPlaneStatus(ctx, protos.HostResource()) - require.Error(t, err) - - helpers.ValidateLog(t, "Failed to send update data plane status", logBuf) - - logBuf.Reset() -} - -func TestCommandService_CreateConnection(t *testing.T) { - ctx := context.Background() - commandServiceClient := &v1fakes.FakeCommandServiceClient{} - - commandService := NewCommandService( - commandServiceClient, - types.AgentConfig(), - make(chan *mpi.ManagementPlaneRequest), - ) - - // connection created when no nginx instance found - resource := protos.HostResource() - _, err := commandService.CreateConnection(ctx, resource) - require.NoError(t, err) -} - -func TestCommandService_UpdateClient(t *testing.T) { - commandServiceClient := &v1fakes.FakeCommandServiceClient{} - ctx := context.Background() - - commandService := NewCommandService( - commandServiceClient, - types.AgentConfig(), - make(chan *mpi.ManagementPlaneRequest), - ) - err := commandService.UpdateClient(ctx, commandServiceClient) - require.NoError(t, err) - assert.NotNil(t, commandService.commandServiceClient) -} - -func TestCommandService_UpdateDataPlaneHealth(t *testing.T) { - ctx := context.Background() - commandServiceClient := &v1fakes.FakeCommandServiceClient{} - - commandService := NewCommandService( - commandServiceClient, - types.AgentConfig(), - make(chan *mpi.ManagementPlaneRequest), - ) - - // connection not created yet - err := commandService.UpdateDataPlaneHealth(ctx, protos.InstanceHealths()) - - require.Error(t, err) - assert.Equal(t, 0, commandServiceClient.UpdateDataPlaneHealthCallCount()) - - // connection created - resource := protos.HostResource() - resource.Instances = append(resource.Instances, protos.NginxOssInstance([]string{})) - _, err = commandService.CreateConnection(ctx, resource) - require.NoError(t, err) - assert.Equal(t, 1, commandServiceClient.CreateConnectionCallCount()) - - err = commandService.UpdateDataPlaneHealth(ctx, protos.InstanceHealths()) - - require.NoError(t, err) - assert.Equal(t, 1, commandServiceClient.UpdateDataPlaneHealthCallCount()) -} - -func TestCommandService_SendDataPlaneResponse(t *testing.T) { - ctx := context.Background() - commandServiceClient := &v1fakes.FakeCommandServiceClient{} - subscribeClient := &FakeSubscribeClient{} - - commandService := NewCommandService( - commandServiceClient, - types.AgentConfig(), - make(chan *mpi.ManagementPlaneRequest), - ) - - commandService.subscribeClientMutex.Lock() - commandService.subscribeClient = subscribeClient - commandService.subscribeClientMutex.Unlock() - - err := commandService.SendDataPlaneResponse(ctx, protos.OKDataPlaneResponse()) - - require.NoError(t, err) -} - -func TestCommandService_SendDataPlaneResponse_configApplyRequest(t *testing.T) { - ctx := context.Background() - commandServiceClient := &v1fakes.FakeCommandServiceClient{} - subscribeClient := &FakeSubscribeClient{} - subscribeChannel := make(chan *mpi.ManagementPlaneRequest) - - commandService := NewCommandService( - commandServiceClient, - types.AgentConfig(), - subscribeChannel, - ) - - request1 := &mpi.ManagementPlaneRequest{ - MessageMeta: &mpi.MessageMeta{ - MessageId: "1", - CorrelationId: "123", - Timestamp: timestamppb.Now(), - }, - Request: &mpi.ManagementPlaneRequest_ConfigApplyRequest{ - ConfigApplyRequest: &mpi.ConfigApplyRequest{ - Overview: &mpi.FileOverview{ - Files: []*mpi.File{}, - ConfigVersion: &mpi.ConfigVersion{ - InstanceId: "12314", - Version: "4215432", - }, - }, - }, - }, - } - - request2 := &mpi.ManagementPlaneRequest{ - MessageMeta: &mpi.MessageMeta{ - MessageId: "2", - CorrelationId: "1232", - Timestamp: timestamppb.Now(), - }, - Request: &mpi.ManagementPlaneRequest_ConfigApplyRequest{ - ConfigApplyRequest: &mpi.ConfigApplyRequest{ - Overview: &mpi.FileOverview{ - Files: []*mpi.File{}, - ConfigVersion: &mpi.ConfigVersion{ - InstanceId: "12314", - Version: "4215432", - }, - }, - }, - }, - } - - request3 := &mpi.ManagementPlaneRequest{ - MessageMeta: &mpi.MessageMeta{ - MessageId: "3", - CorrelationId: "1233", - Timestamp: timestamppb.Now(), - }, - Request: &mpi.ManagementPlaneRequest_ConfigApplyRequest{ - ConfigApplyRequest: &mpi.ConfigApplyRequest{ - Overview: &mpi.FileOverview{ - Files: []*mpi.File{}, - ConfigVersion: &mpi.ConfigVersion{ - InstanceId: "12314", - Version: "4215432", - }, - }, - }, - }, - } - - commandService.configApplyRequestQueueMutex.Lock() - commandService.configApplyRequestQueue = map[string][]*mpi.ManagementPlaneRequest{ - "12314": { - request1, - request2, - request3, - }, - } - commandService.configApplyRequestQueueMutex.Unlock() - - commandService.subscribeClientMutex.Lock() - commandService.subscribeClient = subscribeClient - commandService.subscribeClientMutex.Unlock() - - var wg sync.WaitGroup - - wg.Add(1) - go func() { - requestFromChannel := <-subscribeChannel - assert.Equal(t, request3, requestFromChannel) - wg.Done() - }() - - err := commandService.SendDataPlaneResponse( - ctx, - &mpi.DataPlaneResponse{ - MessageMeta: &mpi.MessageMeta{ - MessageId: uuid.NewString(), - CorrelationId: "1232", - Timestamp: timestamppb.Now(), - }, - CommandResponse: &mpi.CommandResponse{ - Status: mpi.CommandResponse_COMMAND_STATUS_OK, - Message: "Success", - }, - InstanceId: "12314", - }, - ) - - require.NoError(t, err) - - commandService.configApplyRequestQueueMutex.Lock() - defer commandService.configApplyRequestQueueMutex.Unlock() - assert.Len(t, commandService.configApplyRequestQueue, 1) - assert.Equal(t, request3, commandService.configApplyRequestQueue["12314"][0]) - wg.Wait() -} - -func TestCommandService_isValidRequest(t *testing.T) { - ctx := context.Background() - commandServiceClient := &v1fakes.FakeCommandServiceClient{} - subscribeClient := &FakeSubscribeClient{} - - commandService := NewCommandService( - commandServiceClient, - types.AgentConfig(), - make(chan *mpi.ManagementPlaneRequest), - ) - - commandService.subscribeClientMutex.Lock() - commandService.subscribeClient = subscribeClient - commandService.subscribeClientMutex.Unlock() - - nginxInstance := protos.NginxOssInstance([]string{}) - - commandService.resourceMutex.Lock() - commandService.resource.Instances = append(commandService.resource.Instances, nginxInstance) - commandService.resourceMutex.Unlock() - - testCases := []struct { - req *mpi.ManagementPlaneRequest - name string - result bool - }{ - { - name: "Test 1: valid health request", - req: &mpi.ManagementPlaneRequest{ - MessageMeta: protos.CreateMessageMeta(), - Request: &mpi.ManagementPlaneRequest_HealthRequest{HealthRequest: &mpi.HealthRequest{}}, - }, - result: true, - }, - { - name: "Test 2: valid config apply request", - req: &mpi.ManagementPlaneRequest{ - MessageMeta: protos.CreateMessageMeta(), - Request: &mpi.ManagementPlaneRequest_ConfigApplyRequest{ - ConfigApplyRequest: protos.CreateConfigApplyRequest(&mpi.FileOverview{ - Files: make([]*mpi.File, 0), - ConfigVersion: &mpi.ConfigVersion{ - InstanceId: nginxInstance.GetInstanceMeta().GetInstanceId(), - Version: "e23brbei3u2bru93", - }, - }), - }, - }, - result: true, - }, - { - name: "Test 3: invalid config apply request", - req: &mpi.ManagementPlaneRequest{ - MessageMeta: protos.CreateMessageMeta(), - Request: &mpi.ManagementPlaneRequest_ConfigApplyRequest{ - ConfigApplyRequest: protos.CreateConfigApplyRequest(&mpi.FileOverview{ - Files: make([]*mpi.File, 0), - ConfigVersion: &mpi.ConfigVersion{ - InstanceId: "unknown-id", - Version: "e23brbei3u2bru93", - }, - }), - }, - }, - result: false, - }, - { - name: "Test 4: valid config upload request", - req: &mpi.ManagementPlaneRequest{ - MessageMeta: protos.CreateMessageMeta(), - Request: &mpi.ManagementPlaneRequest_ConfigUploadRequest{ - ConfigUploadRequest: &mpi.ConfigUploadRequest{ - Overview: &mpi.FileOverview{ - Files: make([]*mpi.File, 0), - ConfigVersion: &mpi.ConfigVersion{ - InstanceId: nginxInstance.GetInstanceMeta().GetInstanceId(), - Version: "e23brbei3u2bru93", - }, - }, - }, - }, - }, - result: true, - }, - { - name: "Test 5: invalid config upload request", - req: &mpi.ManagementPlaneRequest{ - MessageMeta: protos.CreateMessageMeta(), - Request: &mpi.ManagementPlaneRequest_ConfigUploadRequest{ - ConfigUploadRequest: &mpi.ConfigUploadRequest{ - Overview: &mpi.FileOverview{ - Files: make([]*mpi.File, 0), - ConfigVersion: &mpi.ConfigVersion{ - InstanceId: "unknown-id", - Version: "e23brbei3u2bru93", - }, - }, - }, - }, - }, - result: false, - }, - { - name: "Test 6: valid action request", - req: &mpi.ManagementPlaneRequest{ - MessageMeta: protos.CreateMessageMeta(), - Request: &mpi.ManagementPlaneRequest_ActionRequest{ - ActionRequest: &mpi.APIActionRequest{ - InstanceId: nginxInstance.GetInstanceMeta().GetInstanceId(), - Action: nil, - }, - }, - }, - result: true, - }, - { - name: "Test 7: invalid action request", - req: &mpi.ManagementPlaneRequest{ - MessageMeta: protos.CreateMessageMeta(), - Request: &mpi.ManagementPlaneRequest_ActionRequest{ - ActionRequest: &mpi.APIActionRequest{ - InstanceId: "unknown-id", - Action: nil, - }, - }, - }, - result: false, - }, - } - - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - result := commandService.isValidRequest(ctx, testCase.req) - assert.Equal(t, testCase.result, result) - }) - } -} - -func TestCommandService_handleSubscribeError(t *testing.T) { - ctx := context.Background() - commandServiceClient := &v1fakes.FakeCommandServiceClient{} - - commandService := NewCommandService( - commandServiceClient, - types.AgentConfig(), - make(chan *mpi.ManagementPlaneRequest), - ) - require.Error(t, - commandService.handleSubscribeError(ctx, - errors.New("an error occurred when attempting to subscribe"), - "Testing handleSubscribeError")) -} diff --git a/internal/file/fake_file_stream_test.go b/internal/file/fake_file_stream_test.go deleted file mode 100644 index ccf06f392..000000000 --- a/internal/file/fake_file_stream_test.go +++ /dev/null @@ -1,116 +0,0 @@ -// 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 file - -import ( - "context" - "sync/atomic" - - mpi "github.com/nginx/agent/v3/api/grpc/mpi/v1" - "google.golang.org/grpc/metadata" - "google.golang.org/protobuf/types/known/timestamppb" -) - -type FakeClientStreamingClient struct { - sendCount atomic.Int32 -} - -func (f *FakeClientStreamingClient) Send(req *mpi.FileDataChunk) error { - f.sendCount.Add(1) - return nil -} - -func (f *FakeClientStreamingClient) CloseAndRecv() (*mpi.UpdateFileResponse, error) { - return &mpi.UpdateFileResponse{}, nil -} - -func (f *FakeClientStreamingClient) Header() (metadata.MD, error) { - return metadata.MD{}, nil -} - -func (f *FakeClientStreamingClient) Trailer() metadata.MD { - return nil -} - -func (f *FakeClientStreamingClient) CloseSend() error { - return nil -} - -func (f *FakeClientStreamingClient) Context() context.Context { - return context.Background() -} - -func (f *FakeClientStreamingClient) SendMsg(m any) error { - return nil -} - -func (f *FakeClientStreamingClient) RecvMsg(m any) error { - return nil -} - -type FakeServerStreamingClient struct { - chunks map[uint32][]byte - fileName string - currentChunkID uint32 -} - -func (f *FakeServerStreamingClient) Recv() (*mpi.FileDataChunk, error) { - fileDataChunk := &mpi.FileDataChunk{ - Meta: &mpi.MessageMeta{ - MessageId: "123", - CorrelationId: "1234", - Timestamp: timestamppb.Now(), - }, - } - - if f.currentChunkID == 0 { - fileDataChunk.Chunk = &mpi.FileDataChunk_Header{ - Header: &mpi.FileDataChunkHeader{ - FileMeta: &mpi.FileMeta{ - Name: f.fileName, - Permissions: "666", - }, - Chunks: 52, - ChunkSize: 1, - }, - } - } else { - fileDataChunk.Chunk = &mpi.FileDataChunk_Content{ - Content: &mpi.FileDataChunkContent{ - ChunkId: f.currentChunkID, - Data: f.chunks[f.currentChunkID-1], - }, - } - } - - f.currentChunkID++ - - return fileDataChunk, nil -} - -func (f *FakeServerStreamingClient) Header() (metadata.MD, error) { - return metadata.MD{}, nil -} - -func (f *FakeServerStreamingClient) Trailer() metadata.MD { - return metadata.MD{} -} - -func (f *FakeServerStreamingClient) CloseSend() error { - return nil -} - -func (f *FakeServerStreamingClient) Context() context.Context { - return context.Background() -} - -func (f *FakeServerStreamingClient) SendMsg(m any) error { - return nil -} - -func (f *FakeServerStreamingClient) RecvMsg(m any) error { - return nil -} diff --git a/internal/file/file_manager_service_test.go b/internal/file/file_manager_service_test.go deleted file mode 100644 index 5a2644080..000000000 --- a/internal/file/file_manager_service_test.go +++ /dev/null @@ -1,1033 +0,0 @@ -// 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 file - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "path" - "path/filepath" - "sync" - "testing" - - "github.com/nginx/agent/v3/internal/model" - - "github.com/nginx/agent/v3/pkg/files" - "google.golang.org/protobuf/types/known/timestamppb" - - mpi "github.com/nginx/agent/v3/api/grpc/mpi/v1" - "github.com/nginx/agent/v3/api/grpc/mpi/v1/v1fakes" - "github.com/nginx/agent/v3/test/helpers" - "github.com/nginx/agent/v3/test/protos" - "github.com/nginx/agent/v3/test/types" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestFileManagerService_ConfigApply_Add(t *testing.T) { - ctx := context.Background() - tempDir := t.TempDir() - - filePath := filepath.Join(tempDir, "nginx.conf") - - fileContent := []byte("location /test {\n return 200 \"Test location\\n\";\n}") - fileHash := files.GenerateHash(fileContent) - defer helpers.RemoveFileWithErrorCheck(t, filePath) - - overview := protos.FileOverview(filePath, fileHash) - - manifestDirPath := tempDir - manifestFilePath := filepath.Join(manifestDirPath, "manifest.json") - helpers.CreateFileWithErrorCheck(t, manifestDirPath, "manifest.json") - - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - fakeFileServiceClient.GetOverviewReturns(&mpi.GetOverviewResponse{ - Overview: overview, - }, nil) - fakeFileServiceClient.GetFileReturns(&mpi.GetFileResponse{ - Contents: &mpi.FileContents{ - Contents: fileContent, - }, - }, nil) - agentConfig := types.AgentConfig() - agentConfig.AllowedDirectories = []string{tempDir} - - fileManagerService := NewFileManagerService(fakeFileServiceClient, agentConfig, &sync.RWMutex{}) - fileManagerService.agentConfig.LibDir = manifestDirPath - fileManagerService.manifestFilePath = manifestFilePath - - request := protos.CreateConfigApplyRequest(overview) - writeStatus, err := fileManagerService.ConfigApply(ctx, request) - require.NoError(t, err) - assert.Equal(t, model.OK, writeStatus) - data, readErr := os.ReadFile(filePath) - require.NoError(t, readErr) - assert.Equal(t, fileContent, data) - assert.Equal(t, fileManagerService.fileActions[filePath].File, overview.GetFiles()[0]) - assert.Equal(t, 1, fakeFileServiceClient.GetFileCallCount()) - assert.True(t, fileManagerService.rollbackManifest) -} - -func TestFileManagerService_ConfigApply_Add_LargeFile(t *testing.T) { - ctx := context.Background() - tempDir := t.TempDir() - - filePath := filepath.Join(tempDir, "nginx.conf") - fileContent := []byte("location /test {\n return 200 \"Test location\\n\";\n}") - fileHash := files.GenerateHash(fileContent) - defer helpers.RemoveFileWithErrorCheck(t, filePath) - - overview := protos.FileOverviewLargeFile(filePath, fileHash) - - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - fakeFileServiceClient.GetOverviewReturns(&mpi.GetOverviewResponse{ - Overview: overview, - }, nil) - - fakeServerStreamingClient := &FakeServerStreamingClient{ - chunks: make(map[uint32][]byte), - currentChunkID: 0, - fileName: filePath, - } - - for i := range fileContent { - fakeServerStreamingClient.chunks[uint32(i)] = []byte{fileContent[i]} - } - - manifestDirPath := tempDir - manifestFilePath := filepath.Join(manifestDirPath, "manifest.json") - - fakeFileServiceClient.GetFileStreamReturns(fakeServerStreamingClient, nil) - agentConfig := types.AgentConfig() - agentConfig.AllowedDirectories = []string{tempDir} - fileManagerService := NewFileManagerService(fakeFileServiceClient, agentConfig, &sync.RWMutex{}) - fileManagerService.agentConfig.LibDir = manifestDirPath - fileManagerService.manifestFilePath = manifestFilePath - - request := protos.CreateConfigApplyRequest(overview) - writeStatus, err := fileManagerService.ConfigApply(ctx, request) - require.NoError(t, err) - assert.Equal(t, model.OK, writeStatus) - data, readErr := os.ReadFile(filePath) - require.NoError(t, readErr) - assert.Equal(t, fileContent, data) - assert.Equal(t, fileManagerService.fileActions[filePath].File, overview.GetFiles()[0]) - assert.Equal(t, 0, fakeFileServiceClient.GetFileCallCount()) - assert.Equal(t, 53, int(fakeServerStreamingClient.currentChunkID)) - assert.True(t, fileManagerService.rollbackManifest) -} - -func TestFileManagerService_ConfigApply_Update(t *testing.T) { - ctx := context.Background() - tempDir := t.TempDir() - - fileContent := []byte("location /test {\n return 200 \"Test location\\n\";\n}") - previousFileContent := []byte("some test data") - previousFileHash := files.GenerateHash(previousFileContent) - fileHash := files.GenerateHash(fileContent) - tempFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx.conf") - _, writeErr := tempFile.Write(previousFileContent) - require.NoError(t, writeErr) - defer helpers.RemoveFileWithErrorCheck(t, tempFile.Name()) - - filesOnDisk := map[string]*mpi.File{ - tempFile.Name(): { - FileMeta: &mpi.FileMeta{ - Name: tempFile.Name(), - Hash: previousFileHash, - ModifiedTime: timestamppb.Now(), - Permissions: "0640", - Size: 0, - }, - }, - } - - manifestDirPath := tempDir - manifestFilePath := manifestDirPath + "/manifest.json" - helpers.CreateFileWithErrorCheck(t, manifestDirPath, "manifest.json") - - overview := protos.FileOverview(tempFile.Name(), fileHash) - - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - fakeFileServiceClient.GetOverviewReturns(&mpi.GetOverviewResponse{ - Overview: overview, - }, nil) - fakeFileServiceClient.GetFileReturns(&mpi.GetFileResponse{ - Contents: &mpi.FileContents{ - Contents: fileContent, - }, - }, nil) - agentConfig := types.AgentConfig() - agentConfig.AllowedDirectories = []string{tempDir} - - fileManagerService := NewFileManagerService(fakeFileServiceClient, agentConfig, &sync.RWMutex{}) - fileManagerService.agentConfig.LibDir = manifestDirPath - fileManagerService.manifestFilePath = manifestFilePath - err := fileManagerService.UpdateCurrentFilesOnDisk(ctx, filesOnDisk, false) - require.NoError(t, err) - - request := protos.CreateConfigApplyRequest(overview) - writeStatus, err := fileManagerService.ConfigApply(ctx, request) - require.NoError(t, err) - assert.Equal(t, model.OK, writeStatus) - data, readErr := os.ReadFile(tempFile.Name()) - require.NoError(t, readErr) - assert.Equal(t, fileContent, data) - assert.Equal(t, fileManagerService.rollbackFileContents[tempFile.Name()], previousFileContent) - assert.Equal(t, fileManagerService.fileActions[tempFile.Name()].File, overview.GetFiles()[0]) - assert.True(t, fileManagerService.rollbackManifest) -} - -func TestFileManagerService_ConfigApply_Delete(t *testing.T) { - ctx := context.Background() - tempDir := t.TempDir() - - fileContent := []byte("location /test {\n return 200 \"Test location\\n\";\n}") - tempFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx.conf") - _, writeErr := tempFile.Write(fileContent) - require.NoError(t, writeErr) - - tempFile2 := helpers.CreateFileWithErrorCheck(t, tempDir, "test.conf") - overview := protos.FileOverview(tempFile2.Name(), files.GenerateHash(fileContent)) - - filesOnDisk := map[string]*mpi.File{ - tempFile.Name(): { - FileMeta: &mpi.FileMeta{ - Name: tempFile.Name(), - Hash: files.GenerateHash(fileContent), - ModifiedTime: timestamppb.Now(), - Permissions: "0640", - Size: 0, - }, - }, - } - - manifestDirPath := tempDir - manifestFilePath := manifestDirPath + "/manifest.json" - helpers.CreateFileWithErrorCheck(t, manifestDirPath, "manifest.json") - - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - agentConfig := types.AgentConfig() - agentConfig.AllowedDirectories = []string{tempDir} - - fileManagerService := NewFileManagerService(fakeFileServiceClient, agentConfig, &sync.RWMutex{}) - fileManagerService.agentConfig.LibDir = manifestDirPath - fileManagerService.manifestFilePath = manifestFilePath - err := fileManagerService.UpdateCurrentFilesOnDisk(ctx, filesOnDisk, false) - require.NoError(t, err) - - request := protos.CreateConfigApplyRequest(overview) - - fakeFileServiceClient.GetOverviewReturns(&mpi.GetOverviewResponse{ - Overview: overview, - }, nil) - fakeFileServiceClient.GetFileReturns(&mpi.GetFileResponse{ - Contents: &mpi.FileContents{ - Contents: fileContent, - }, - }, nil) - - writeStatus, err := fileManagerService.ConfigApply(ctx, request) - require.NoError(t, err) - assert.NoFileExists(t, tempFile.Name()) - assert.Equal(t, fileManagerService.rollbackFileContents[tempFile.Name()], fileContent) - assert.Equal(t, - fileManagerService.fileActions[tempFile.Name()].File.GetFileMeta().GetName(), - filesOnDisk[tempFile.Name()].GetFileMeta().GetName(), - ) - assert.Equal(t, - fileManagerService.fileActions[tempFile.Name()].File.GetFileMeta().GetHash(), - filesOnDisk[tempFile.Name()].GetFileMeta().GetHash(), - ) - assert.Equal(t, - fileManagerService.fileActions[tempFile.Name()].File.GetFileMeta().GetSize(), - filesOnDisk[tempFile.Name()].GetFileMeta().GetSize(), - ) - assert.Equal(t, model.OK, writeStatus) - assert.True(t, fileManagerService.rollbackManifest) -} - -func TestFileManagerService_ConfigApply_Failed(t *testing.T) { - ctx := t.Context() - tempDir := t.TempDir() - - filePath := filepath.Join(tempDir, "nginx.conf") - fileContent := []byte("# this is going to fail") - fileHash := files.GenerateHash(fileContent) - - overview := protos.FileOverview(filePath, fileHash) - - manifestDirPath := tempDir - manifestFilePath := manifestDirPath + "/manifest.json" - helpers.CreateFileWithErrorCheck(t, manifestDirPath, "manifest.json") - - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - fakeFileServiceClient.GetOverviewReturns(&mpi.GetOverviewResponse{ - Overview: overview, - }, nil) - fakeFileServiceClient.GetFileReturns(nil, errors.New("file not found")) - - agentConfig := types.AgentConfig() - agentConfig.AllowedDirectories = []string{tempDir} - - fileManagerService := NewFileManagerService(fakeFileServiceClient, agentConfig, &sync.RWMutex{}) - fileManagerService.agentConfig.LibDir = manifestDirPath - fileManagerService.manifestFilePath = manifestFilePath - - request := protos.CreateConfigApplyRequest(overview) - writeStatus, err := fileManagerService.ConfigApply(ctx, request) - - require.Error(t, err) - assert.Equal(t, model.RollbackRequired, writeStatus) - assert.False(t, fileManagerService.rollbackManifest) -} - -func TestFileManagerService_checkAllowedDirectory(t *testing.T) { - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - fileManagerService := NewFileManagerService(fakeFileServiceClient, types.AgentConfig(), &sync.RWMutex{}) - - allowedFiles := []*mpi.File{ - { - FileMeta: &mpi.FileMeta{ - Name: "/tmp/local/etc/nginx/allowedDirPath", - Hash: "", - ModifiedTime: nil, - Permissions: "", - Size: 0, - }, - }, - } - - notAllowed := []*mpi.File{ - { - FileMeta: &mpi.FileMeta{ - Name: "/not/allowed/dir/path", - Hash: "", - ModifiedTime: nil, - Permissions: "", - Size: 0, - }, - }, - } - - err := fileManagerService.checkAllowedDirectory(allowedFiles) - require.NoError(t, err) - err = fileManagerService.checkAllowedDirectory(notAllowed) - require.Error(t, err) -} - -func TestFileManagerService_ClearCache(t *testing.T) { - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - fileManagerService := NewFileManagerService(fakeFileServiceClient, types.AgentConfig(), &sync.RWMutex{}) - - filesCache := map[string]*model.FileCache{ - "file/path/test.conf": { - File: &mpi.File{ - FileMeta: &mpi.FileMeta{ - Name: "file/path/test.conf", - Hash: "", - ModifiedTime: nil, - Permissions: "", - Size: 0, - }, - }, - }, - } - - contentsCache := map[string][]byte{ - "file/path/test.conf": []byte("some test data"), - } - - fileManagerService.fileActions = filesCache - fileManagerService.rollbackFileContents = contentsCache - assert.NotEmpty(t, fileManagerService.fileActions) - assert.NotEmpty(t, fileManagerService.rollbackFileContents) - - fileManagerService.ClearCache() - - assert.Empty(t, fileManagerService.fileActions) - assert.Empty(t, fileManagerService.rollbackFileContents) -} - -func TestFileManagerService_Rollback(t *testing.T) { - ctx := context.Background() - tempDir := t.TempDir() - - deleteFilePath := filepath.Join(tempDir, "nginx_delete.conf") - - newFileContent := []byte("location /test {\n return 200 \"This config needs to be rolled back\\n\";\n}") - oldFileContent := []byte("location /test {\n return 200 \"This is the saved config\\n\";\n}") - fileHash := files.GenerateHash(newFileContent) - - addFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_add.conf") - _, writeErr := addFile.Write(newFileContent) - require.NoError(t, writeErr) - - updateFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_update.conf") - _, writeErr = updateFile.Write(newFileContent) - require.NoError(t, writeErr) - - manifestDirPath := tempDir - manifestFilePath := manifestDirPath + "/manifest.json" - helpers.CreateFileWithErrorCheck(t, manifestDirPath, "manifest.json") - - filesCache := map[string]*model.FileCache{ - addFile.Name(): { - File: &mpi.File{ - FileMeta: &mpi.FileMeta{ - Name: addFile.Name(), - Hash: fileHash, - ModifiedTime: timestamppb.Now(), - Permissions: "0777", - Size: 0, - }, - Unmanaged: false, - }, - Action: model.Add, - }, - updateFile.Name(): { - File: &mpi.File{ - FileMeta: &mpi.FileMeta{ - Name: updateFile.Name(), - Hash: fileHash, - ModifiedTime: timestamppb.Now(), - Permissions: "0777", - Size: 0, - }, - Unmanaged: false, - }, - Action: model.Update, - }, - deleteFilePath: { - File: &mpi.File{ - FileMeta: &mpi.FileMeta{ - Name: deleteFilePath, - Hash: "", - ModifiedTime: timestamppb.Now(), - Permissions: "0777", - Size: 0, - }, - Unmanaged: false, - }, - Action: model.Delete, - }, - "unspecified/file/test.conf": { - File: &mpi.File{ - FileMeta: &mpi.FileMeta{ - Name: "unspecified/file/test.conf", - Hash: "", - ModifiedTime: timestamppb.Now(), - Permissions: "0777", - Size: 0, - }, - Unmanaged: false, - }, - }, - } - fileContentCache := map[string][]byte{ - deleteFilePath: oldFileContent, - updateFile.Name(): oldFileContent, - } - - instanceID := protos.NginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId() - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - fileManagerService := NewFileManagerService(fakeFileServiceClient, types.AgentConfig(), &sync.RWMutex{}) - fileManagerService.rollbackFileContents = fileContentCache - fileManagerService.fileActions = filesCache - fileManagerService.agentConfig.LibDir = manifestDirPath - fileManagerService.manifestFilePath = manifestFilePath - - err := fileManagerService.Rollback(ctx, instanceID) - require.NoError(t, err) - - assert.NoFileExists(t, addFile.Name()) - assert.FileExists(t, deleteFilePath) - updateData, readUpdateErr := os.ReadFile(updateFile.Name()) - require.NoError(t, readUpdateErr) - assert.Equal(t, oldFileContent, updateData) - - deleteData, readDeleteErr := os.ReadFile(deleteFilePath) - require.NoError(t, readDeleteErr) - assert.Equal(t, oldFileContent, deleteData) - - defer helpers.RemoveFileWithErrorCheck(t, updateFile.Name()) - defer helpers.RemoveFileWithErrorCheck(t, deleteFilePath) -} - -func TestFileManagerService_DetermineFileActions(t *testing.T) { - ctx := context.Background() - tempDir := os.TempDir() - - deleteTestFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_delete.conf") - defer helpers.RemoveFileWithErrorCheck(t, deleteTestFile.Name()) - fileContent, readErr := os.ReadFile("../../test/config/nginx/nginx.conf") - require.NoError(t, readErr) - err := os.WriteFile(deleteTestFile.Name(), fileContent, 0o600) - require.NoError(t, err) - - updateTestFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_update.conf") - defer helpers.RemoveFileWithErrorCheck(t, updateTestFile.Name()) - updatedFileContent := []byte("test update file") - updateErr := os.WriteFile(updateTestFile.Name(), updatedFileContent, 0o600) - require.NoError(t, updateErr) - - addTestFileName := tempDir + "nginx_add.conf" - - unmanagedFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_unmanaged.conf") - defer helpers.RemoveFileWithErrorCheck(t, unmanagedFile.Name()) - unmanagedFileContent := []byte("test unmanaged file") - unmanagedErr := os.WriteFile(unmanagedFile.Name(), unmanagedFileContent, 0o600) - require.NoError(t, unmanagedErr) - - addTestFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_add.conf") - defer helpers.RemoveFileWithErrorCheck(t, addTestFile.Name()) - addFileContent := []byte("test add file") - addErr := os.WriteFile(addTestFile.Name(), addFileContent, 0o600) - require.NoError(t, addErr) - - tests := []struct { - expectedError error - modifiedFiles map[string]*model.FileCache - currentFiles map[string]*mpi.File - expectedCache map[string]*model.FileCache - expectedContent map[string][]byte - name string - }{ - { - name: "Test 1: Add, Update & Delete Files", - modifiedFiles: map[string]*model.FileCache{ - addTestFileName: { - File: &mpi.File{ - FileMeta: protos.FileMeta(addTestFileName, files.GenerateHash(fileContent)), - Unmanaged: false, - }, - }, - updateTestFile.Name(): { - File: &mpi.File{ - FileMeta: protos.FileMeta(updateTestFile.Name(), files.GenerateHash(updatedFileContent)), - Unmanaged: false, - }, - }, - unmanagedFile.Name(): { - File: &mpi.File{ - FileMeta: protos.FileMeta(unmanagedFile.Name(), files.GenerateHash(unmanagedFileContent)), - Unmanaged: true, - }, - }, - }, - currentFiles: map[string]*mpi.File{ - deleteTestFile.Name(): { - FileMeta: protos.FileMeta(deleteTestFile.Name(), files.GenerateHash(fileContent)), - }, - updateTestFile.Name(): { - FileMeta: protos.FileMeta(updateTestFile.Name(), files.GenerateHash(fileContent)), - }, - unmanagedFile.Name(): { - FileMeta: protos.FileMeta(unmanagedFile.Name(), files.GenerateHash(fileContent)), - Unmanaged: true, - }, - }, - expectedCache: map[string]*model.FileCache{ - deleteTestFile.Name(): { - File: &mpi.File{ - FileMeta: protos.ManifestFileMeta(deleteTestFile.Name(), files.GenerateHash(fileContent)), - Unmanaged: false, - }, - Action: model.Delete, - }, - updateTestFile.Name(): { - File: &mpi.File{ - FileMeta: protos.FileMeta(updateTestFile.Name(), files.GenerateHash(updatedFileContent)), - Unmanaged: false, - }, - Action: model.Update, - }, - addTestFileName: { - File: &mpi.File{ - FileMeta: protos.FileMeta(addTestFileName, files.GenerateHash(fileContent)), - Unmanaged: false, - }, - Action: model.Add, - }, - }, - expectedContent: map[string][]byte{ - deleteTestFile.Name(): fileContent, - updateTestFile.Name(): updatedFileContent, - }, - expectedError: nil, - }, - { - name: "Test 2: Files same as on disk", - modifiedFiles: map[string]*model.FileCache{ - addTestFile.Name(): { - File: &mpi.File{ - FileMeta: protos.FileMeta(addTestFile.Name(), files.GenerateHash(fileContent)), - }, - }, - updateTestFile.Name(): { - File: &mpi.File{ - FileMeta: protos.FileMeta(updateTestFile.Name(), files.GenerateHash(fileContent)), - }, - }, - deleteTestFile.Name(): { - File: &mpi.File{ - FileMeta: protos.FileMeta(deleteTestFile.Name(), files.GenerateHash(fileContent)), - }, - }, - }, - currentFiles: map[string]*mpi.File{ - deleteTestFile.Name(): { - FileMeta: protos.FileMeta(deleteTestFile.Name(), files.GenerateHash(fileContent)), - }, - updateTestFile.Name(): { - FileMeta: protos.FileMeta(updateTestFile.Name(), files.GenerateHash(fileContent)), - }, - addTestFile.Name(): { - FileMeta: protos.FileMeta(addTestFile.Name(), files.GenerateHash(fileContent)), - }, - }, - expectedCache: make(map[string]*model.FileCache), - expectedContent: make(map[string][]byte), - expectedError: nil, - }, - { - name: "Test 3: File being deleted already doesn't exist", - modifiedFiles: make(map[string]*model.FileCache), - currentFiles: map[string]*mpi.File{ - "/unknown/file.conf": { - FileMeta: protos.FileMeta("/unknown/file.conf", files.GenerateHash(fileContent)), - }, - }, - expectedCache: make(map[string]*model.FileCache), - expectedContent: make(map[string][]byte), - expectedError: nil, - }, - } - - for _, test := range tests { - t.Run(test.name, func(tt *testing.T) { - // Delete manifest file if it already exists - manifestFile := CreateTestManifestFile(t, tempDir, test.currentFiles, true) - defer manifestFile.Close() - manifestDirPath := tempDir - manifestFilePath := manifestFile.Name() - - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - fileManagerService := NewFileManagerService(fakeFileServiceClient, types.AgentConfig(), &sync.RWMutex{}) - fileManagerService.agentConfig.LibDir = manifestDirPath - fileManagerService.manifestFilePath = manifestFilePath - - require.NoError(tt, err) - - diff, contents, fileActionErr := fileManagerService.DetermineFileActions( - ctx, - test.currentFiles, - test.modifiedFiles, - ) - require.NoError(tt, fileActionErr) - assert.Equal(tt, test.expectedContent, contents) - assert.Equal(tt, test.expectedCache, diff) - }) - } -} - -func CreateTestManifestFile(t testing.TB, tempDir string, currentFiles map[string]*mpi.File, refrenced bool) *os.File { - t.Helper() - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - fileManagerService := NewFileManagerService(fakeFileServiceClient, types.AgentConfig(), &sync.RWMutex{}) - manifestFiles := fileManagerService.convertToManifestFileMap(currentFiles, refrenced) - manifestJSON, err := json.MarshalIndent(manifestFiles, "", " ") - require.NoError(t, err) - file, err := os.CreateTemp(tempDir, "manifest.json") - require.NoError(t, err) - - _, err = file.Write(manifestJSON) - require.NoError(t, err) - - return file -} - -func TestFileManagerService_UpdateManifestFile(t *testing.T) { - ctx := t.Context() - fileContent := []byte("location /test {\n return 200 \"Test location\\n\";\n}") - fileHash := files.GenerateHash(fileContent) - - tests := []struct { - currentFiles map[string]*mpi.File - currentManifestFiles map[string]*model.ManifestFile - expectedFiles map[string]*model.ManifestFile - name string - referenced bool - previousReferenced bool - }{ - { - name: "Test 1: Manifest file empty", - currentFiles: map[string]*mpi.File{ - "/etc/nginx/nginx.conf": { - FileMeta: protos.FileMeta("/etc/nginx/nginx.conf", fileHash), - }, - }, - expectedFiles: map[string]*model.ManifestFile{ - "/etc/nginx/nginx.conf": { - ManifestFileMeta: &model.ManifestFileMeta{ - Name: "/etc/nginx/nginx.conf", - Hash: fileHash, - Size: 0, - Referenced: true, - }, - }, - }, - currentManifestFiles: make(map[string]*model.ManifestFile), - referenced: true, - previousReferenced: true, - }, - { - name: "Test 2: Manifest file populated - unreferenced", - currentFiles: map[string]*mpi.File{ - "/etc/nginx/nginx.conf": { - FileMeta: protos.FileMeta("/etc/nginx/nginx.conf", fileHash), - }, - "/etc/nginx/unref.conf": { - FileMeta: protos.FileMeta("/etc/nginx/unref.conf", fileHash), - }, - }, - expectedFiles: map[string]*model.ManifestFile{ - "/etc/nginx/nginx.conf": { - ManifestFileMeta: &model.ManifestFileMeta{ - Name: "/etc/nginx/nginx.conf", - Hash: fileHash, - Size: 0, - Referenced: false, - }, - }, - "/etc/nginx/unref.conf": { - ManifestFileMeta: &model.ManifestFileMeta{ - Name: "/etc/nginx/unref.conf", - Hash: fileHash, - Size: 0, - Referenced: false, - }, - }, - }, - currentManifestFiles: map[string]*model.ManifestFile{ - "/etc/nginx/nginx.conf": { - ManifestFileMeta: &model.ManifestFileMeta{ - Name: "/etc/nginx/nginx.conf", - Hash: fileHash, - Size: 0, - Referenced: true, - }, - }, - }, - referenced: false, - previousReferenced: true, - }, - { - name: "Test 3: Manifest file populated - referenced", - currentFiles: map[string]*mpi.File{ - "/etc/nginx/nginx.conf": { - FileMeta: protos.FileMeta("/etc/nginx/nginx.conf", fileHash), - }, - "/etc/nginx/test.conf": { - FileMeta: protos.FileMeta("/etc/nginx/test.conf", fileHash), - }, - }, - expectedFiles: map[string]*model.ManifestFile{ - "/etc/nginx/nginx.conf": { - ManifestFileMeta: &model.ManifestFileMeta{ - Name: "/etc/nginx/nginx.conf", - Hash: fileHash, - Size: 0, - Referenced: true, - }, - }, - "/etc/nginx/test.conf": { - ManifestFileMeta: &model.ManifestFileMeta{ - Name: "/etc/nginx/test.conf", - Hash: fileHash, - Size: 0, - Referenced: true, - }, - }, - "/etc/nginx/unref.conf": { - ManifestFileMeta: &model.ManifestFileMeta{ - Name: "/etc/nginx/unref.conf", - Hash: fileHash, - Size: 0, - Referenced: false, - }, - }, - }, - currentManifestFiles: map[string]*model.ManifestFile{ - "/etc/nginx/nginx.conf": { - ManifestFileMeta: &model.ManifestFileMeta{ - Name: "/etc/nginx/nginx.conf", - Hash: fileHash, - Size: 0, - Referenced: false, - }, - }, - "/etc/nginx/unref.conf": { - ManifestFileMeta: &model.ManifestFileMeta{ - Name: "/etc/nginx/unref.conf", - Hash: fileHash, - Size: 0, - Referenced: false, - }, - }, - }, - referenced: true, - previousReferenced: false, - }, - } - - for _, test := range tests { - t.Run(test.name, func(tt *testing.T) { - manifestDirPath := t.TempDir() - file := helpers.CreateFileWithErrorCheck(t, manifestDirPath, "manifest.json") - - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - fileManagerService := NewFileManagerService(fakeFileServiceClient, types.AgentConfig(), &sync.RWMutex{}) - fileManagerService.agentConfig.LibDir = manifestDirPath - fileManagerService.manifestFilePath = file.Name() - - manifestJSON, err := json.MarshalIndent(test.currentManifestFiles, "", " ") - require.NoError(t, err) - - _, err = file.Write(manifestJSON) - require.NoError(t, err) - - updateErr := fileManagerService.UpdateManifestFile(ctx, test.currentFiles, test.referenced) - require.NoError(tt, updateErr) - - manifestFiles, _, manifestErr := fileManagerService.manifestFile() - require.NoError(tt, manifestErr) - assert.Equal(tt, test.expectedFiles, manifestFiles) - }) - } -} - -func TestFileManagerService_fileActions(t *testing.T) { - ctx := context.Background() - tempDir := t.TempDir() - - addFilePath := filepath.Join(tempDir, "nginx_add.conf") - unspecifiedFilePath := "unspecified/file/test.conf" - - newFileContent := []byte("location /test {\n return 200 \"This config needs to be rolled back\\n\";\n}") - oldFileContent := []byte("location /test {\n return 200 \"This is the saved config\\n\";\n}") - fileHash := files.GenerateHash(newFileContent) - - deleteFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_delete.conf") - _, writeErr := deleteFile.Write(oldFileContent) - require.NoError(t, writeErr) - - updateFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_update.conf") - _, writeErr = updateFile.Write(oldFileContent) - require.NoError(t, writeErr) - - filesCache := map[string]*model.FileCache{ - addFilePath: { - File: &mpi.File{ - FileMeta: &mpi.FileMeta{ - Name: addFilePath, - Hash: fileHash, - ModifiedTime: timestamppb.Now(), - Permissions: "0777", - Size: 0, - }, - }, - Action: model.Add, - }, - updateFile.Name(): { - File: &mpi.File{ - FileMeta: &mpi.FileMeta{ - Name: updateFile.Name(), - Hash: fileHash, - ModifiedTime: timestamppb.Now(), - Permissions: "0777", - Size: 0, - }, - }, - Action: model.Update, - }, - deleteFile.Name(): { - File: &mpi.File{ - FileMeta: &mpi.FileMeta{ - Name: deleteFile.Name(), - Hash: "", - ModifiedTime: timestamppb.Now(), - Permissions: "0777", - Size: 0, - }, - }, - Action: model.Delete, - }, - unspecifiedFilePath: { - File: &mpi.File{ - FileMeta: &mpi.FileMeta{ - Name: unspecifiedFilePath, - Hash: "", - ModifiedTime: timestamppb.Now(), - Permissions: "0777", - Size: 0, - }, - }, - }, - } - - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - fakeFileServiceClient.GetFileReturns(&mpi.GetFileResponse{ - Contents: &mpi.FileContents{ - Contents: newFileContent, - }, - }, nil) - fileManagerService := NewFileManagerService(fakeFileServiceClient, types.AgentConfig(), &sync.RWMutex{}) - - fileManagerService.fileActions = filesCache - - actionErr := fileManagerService.executeFileActions(ctx, os.TempDir()) - require.NoError(t, actionErr) - - assert.FileExists(t, addFilePath) - assert.NoFileExists(t, deleteFile.Name()) - assert.NoFileExists(t, unspecifiedFilePath) - updateData, readUpdateErr := os.ReadFile(updateFile.Name()) - require.NoError(t, readUpdateErr) - assert.Equal(t, newFileContent, updateData) - - defer helpers.RemoveFileWithErrorCheck(t, updateFile.Name()) - defer helpers.RemoveFileWithErrorCheck(t, addFilePath) -} - -func TestParseX509Certificates(t *testing.T) { - tests := []struct { - certName string - certContent string - name string - expectedSerial string - }{ - { - name: "Test 1: generated cert", - certName: "public_cert", - certContent: "", - expectedSerial: "123123", - }, - { - name: "Test 2: open ssl cert", - certName: "open_ssl_cert", - certContent: `-----BEGIN CERTIFICATE----- -MIIDazCCAlOgAwIBAgIUR+YGgRHhYwotFyBOvSc1KD9d45kwDQYJKoZIhvcNAQEL -BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM -GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDExMjcxNTM0MDZaFw0yNDEy -MjcxNTM0MDZaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw -HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB -AQUAA4IBDwAwggEKAoIBAQDnDDVGflbZ3dmuQJj+8QuJIQ8lWjVGYhlsFI4AGFTX -9VfYOqJEPyuMRuSj2eN7C/mR4yTJSggnv0kFtjmeGh2keNdmb4R/0CjYWZVl/Na6 -cAfldB8v2+sm0LZ/OD9F9CbnYB95takPOZq3AP5kUA+qlFYzroqXsxJKvZF6dUuI -+kTOn5pWD+eFmueFedOz1aucOvblUJLueVZnvAbIrBoyaulw3f2kjk0J1266nFMb -s72AvjyYbOXbyur3BhPThCaOeqMGggDmFslZ4pBgQFWUeFvmqJMFzf1atKTWlbj7 -Mj+bNKNs4xvUuNhqd/F99Pz2Fe0afKbTHK83hqgSHKbtAgMBAAGjUzBRMB0GA1Ud -DgQWBBQq0Bzde0bl9CFb81LrvFfdWlY7hzAfBgNVHSMEGDAWgBQq0Bzde0bl9CFb -81LrvFfdWlY7hzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAo -8GXvwRa0M0D4x4Lrj2K57FxH4ECNBnAqWlh3Ce9LEioL2CYaQQw6I2/FsnTk8TYY -WgGgXMEyA6OeOXvwxWjSllK9+D2ueTMhNRO0tYMUi0kDJqd9EpmnEcSWIL2G2SNo -BWQjqEoEKFjvrgx6h13AtsFlpdURoVtodrtnUrXp1r4wJvljC2qexoNfslhpbqsT -X/vYrzgKRoKSUWUt1ejKTntrVuaJK4NMxANOTTjIXgxyoV3YcgEmL9KzribCqILi -p79Nno9d+kovtX5VKsJ5FCcPw9mEATgZDOQ4nLTk/HHG6bwtpubp6Zb7H1AjzBkz -rQHX6DP4w6IwZY8JB8LS ------END CERTIFICATE-----`, - expectedSerial: "410468082718062724391949173062901619571168240537", - }, - } - - tempDir := os.TempDir() - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - var certBytes []byte - var certPath string - - if test.certContent == "" { - _, certBytes = helpers.GenerateSelfSignedCert(t) - certContents := helpers.Cert{ - Name: test.certName + ".pem", - Type: "CERTIFICATE", - Contents: certBytes, - } - certPath = helpers.WriteCertFiles(t, tempDir, certContents) - } else { - certPath = fmt.Sprintf("%s%c%s", tempDir, os.PathSeparator, test.certName) - err := os.WriteFile(certPath, []byte(test.certContent), 0o600) - require.NoError(t, err) - } - - certFileMeta, certFileMetaErr := files.FileMetaWithCertificate(certPath) - require.NoError(t, certFileMetaErr) - - assert.Equal(t, test.expectedSerial, certFileMeta.GetCertificateMeta().GetSerialNumber()) - }) - } -} - -func TestFileManagerService_deleteTempFiles(t *testing.T) { - tempDir := t.TempDir() - tempFile := path.Join(tempDir, "/etc/nginx/nginx.conf") - - err := os.MkdirAll(path.Dir(tempFile), 0o755) - require.NoError(t, err) - - _, err = os.Create(tempFile) - require.NoError(t, err) - - fileManagerService := FileManagerService{ - fileActions: map[string]*model.FileCache{ - "/etc/nginx/nginx.conf": { - File: &mpi.File{ - FileMeta: &mpi.FileMeta{ - Name: "/etc/nginx/nginx.conf", - }, - }, - Action: model.Update, - }, - "/etc/nginx/test.conf": { - File: &mpi.File{ - FileMeta: &mpi.FileMeta{ - Name: "/etc/nginx/test.conf", - }, - }, - Action: model.Add, - }, - }, - } - - fileManagerService.deleteTempFiles(t.Context(), tempDir) - - assert.NoFileExists(t, tempFile) -} - -func TestFileManagerService_createTempConfigDirectory(t *testing.T) { - agentConfig := types.AgentConfig() - agentConfig.LibDir = t.TempDir() - - fileManagerService := FileManagerService{ - agentConfig: agentConfig, - } - - dir, err := fileManagerService.createTempConfigDirectory(t.Context()) - assert.NotEmpty(t, dir) - require.NoError(t, err) - - // Test for unknown directory path - agentConfig.LibDir = "/unknown/" - - dir, err = fileManagerService.createTempConfigDirectory(t.Context()) - assert.Empty(t, dir) - require.Error(t, err) -} diff --git a/internal/file/file_operator_test.go b/internal/file/file_operator_test.go deleted file mode 100644 index 4a49fcdd1..000000000 --- a/internal/file/file_operator_test.go +++ /dev/null @@ -1,105 +0,0 @@ -// 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 file - -import ( - "context" - "os" - "path" - "path/filepath" - "sync" - "testing" - - "github.com/nginx/agent/v3/pkg/files" - "github.com/nginx/agent/v3/test/protos" - - "github.com/nginx/agent/v3/internal/model" - "github.com/nginx/agent/v3/test/helpers" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestFileOperator_Write(t *testing.T) { - ctx := context.Background() - - tempDir := t.TempDir() - filePath := filepath.Join(tempDir, "nginx.conf") - fileContent, err := os.ReadFile("../../test/config/nginx/nginx.conf") - require.NoError(t, err) - defer helpers.RemoveFileWithErrorCheck(t, filePath) - fileOp := NewFileOperator(&sync.RWMutex{}) - - fileMeta := protos.FileMeta(filePath, files.GenerateHash(fileContent)) - - writeErr := fileOp.Write(ctx, fileContent, fileMeta.GetName(), fileMeta.GetPermissions()) - require.NoError(t, writeErr) - assert.FileExists(t, filePath) - - data, readErr := os.ReadFile(filePath) - require.NoError(t, readErr) - assert.Equal(t, fileContent, data) -} - -func TestFileOperator_WriteManifestFile_fileMissing(t *testing.T) { - tempDir := t.TempDir() - manifestPath := "/unknown/manifest.json" - - fileOperator := NewFileOperator(&sync.RWMutex{}) - err := fileOperator.WriteManifestFile(t.Context(), make(map[string]*model.ManifestFile), tempDir, manifestPath) - assert.Error(t, err) -} - -func TestFileOperator_MoveFile_fileExists(t *testing.T) { - tempDir := t.TempDir() - tempFile := path.Join(tempDir, "/etc/nginx/nginx.conf") - newFile := path.Join(tempDir, "/etc/nginx/new_test.conf") - - err := os.MkdirAll(path.Dir(tempFile), 0o755) - require.NoError(t, err) - - _, err = os.Create(tempFile) - require.NoError(t, err) - - fileOperator := NewFileOperator(&sync.RWMutex{}) - err = fileOperator.MoveFile(t.Context(), tempFile, newFile) - require.NoError(t, err) - - assert.NoFileExists(t, tempFile) - assert.FileExists(t, newFile) -} - -func TestFileOperator_MoveFile_sourceFileDoesNotExist(t *testing.T) { - tempDir := t.TempDir() - tempFile := path.Join(tempDir, "/etc/nginx/nginx.conf") - newFile := path.Join(tempDir, "/etc/nginx/new_test.conf") - - fileOperator := NewFileOperator(&sync.RWMutex{}) - err := fileOperator.MoveFile(t.Context(), tempFile, newFile) - require.Error(t, err) - - assert.NoFileExists(t, tempFile) - assert.NoFileExists(t, newFile) -} - -func TestFileOperator_MoveFile_destFileDoesNotExist(t *testing.T) { - tempDir := t.TempDir() - tempFile := path.Join(tempDir, "/etc/nginx/nginx.conf") - newFile := "/unknown/nginx/new_test.conf" - - err := os.MkdirAll(path.Dir(tempFile), 0o755) - require.NoError(t, err) - - _, err = os.Create(tempFile) - require.NoError(t, err) - - fileOperator := NewFileOperator(&sync.RWMutex{}) - err = fileOperator.MoveFile(t.Context(), tempFile, newFile) - require.Error(t, err) - - assert.FileExists(t, tempFile) - assert.NoFileExists(t, newFile) -} diff --git a/internal/file/file_plugin_test.go b/internal/file/file_plugin_test.go deleted file mode 100644 index 23403a71f..000000000 --- a/internal/file/file_plugin_test.go +++ /dev/null @@ -1,519 +0,0 @@ -// 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 file - -import ( - "context" - "errors" - "os" - "sync" - "testing" - "time" - - "github.com/nginx/agent/v3/internal/bus/busfakes" - "google.golang.org/protobuf/types/known/timestamppb" - - mpi "github.com/nginx/agent/v3/api/grpc/mpi/v1" - "github.com/nginx/agent/v3/api/grpc/mpi/v1/v1fakes" - "github.com/nginx/agent/v3/internal/bus" - "github.com/nginx/agent/v3/internal/file/filefakes" - "github.com/nginx/agent/v3/internal/grpc/grpcfakes" - "github.com/nginx/agent/v3/internal/model" - "github.com/nginx/agent/v3/pkg/files" - "github.com/nginx/agent/v3/pkg/id" - "github.com/nginx/agent/v3/test/helpers" - "github.com/nginx/agent/v3/test/protos" - "github.com/nginx/agent/v3/test/types" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestFilePlugin_Info(t *testing.T) { - filePlugin := NewFilePlugin(types.AgentConfig(), &grpcfakes.FakeGrpcConnectionInterface{}, - model.Command, &sync.RWMutex{}) - assert.Equal(t, "file", filePlugin.Info().Name) -} - -func TestFilePlugin_Close(t *testing.T) { - ctx := context.Background() - fakeGrpcConnection := &grpcfakes.FakeGrpcConnectionInterface{} - - filePlugin := NewFilePlugin(types.AgentConfig(), fakeGrpcConnection, model.Command, &sync.RWMutex{}) - filePlugin.Close(ctx) - - assert.Equal(t, 1, fakeGrpcConnection.CloseCallCount()) -} - -func TestFilePlugin_Subscriptions(t *testing.T) { - filePlugin := NewFilePlugin(types.AgentConfig(), &grpcfakes.FakeGrpcConnectionInterface{}, - model.Command, &sync.RWMutex{}) - assert.Equal( - t, - []string{ - bus.ConnectionResetTopic, - bus.ConnectionCreatedTopic, - bus.NginxConfigUpdateTopic, - bus.ConfigUploadRequestTopic, - bus.ConfigApplyRequestTopic, - bus.ConfigApplyFailedTopic, - bus.ConfigApplySuccessfulTopic, - bus.ConfigApplyCompleteTopic, - }, - filePlugin.Subscriptions(), - ) - - readOnlyFilePlugin := NewFilePlugin(types.AgentConfig(), &grpcfakes.FakeGrpcConnectionInterface{}, - model.Auxiliary, &sync.RWMutex{}) - assert.Equal(t, []string{ - bus.ConnectionResetTopic, - bus.ConnectionCreatedTopic, - bus.NginxConfigUpdateTopic, - bus.ConfigUploadRequestTopic, - }, readOnlyFilePlugin.Subscriptions()) -} - -func TestFilePlugin_Process_NginxConfigUpdateTopic(t *testing.T) { - ctx := context.Background() - - fileMeta := protos.FileMeta("/etc/nginx/nginx/conf", "") - - message := &model.NginxConfigContext{ - Files: []*mpi.File{ - { - FileMeta: fileMeta, - }, - }, - } - - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - fakeFileServiceClient.UpdateOverviewReturns(&mpi.UpdateOverviewResponse{ - Overview: nil, - }, nil) - - fakeGrpcConnection := &grpcfakes.FakeGrpcConnectionInterface{} - fakeGrpcConnection.FileServiceClientReturns(fakeFileServiceClient) - messagePipe := busfakes.NewFakeMessagePipe() - - filePlugin := NewFilePlugin(types.AgentConfig(), fakeGrpcConnection, model.Command, &sync.RWMutex{}) - err := filePlugin.Init(ctx, messagePipe) - require.NoError(t, err) - - filePlugin.Process(ctx, &bus.Message{Topic: bus.ConnectionCreatedTopic}) - filePlugin.Process(ctx, &bus.Message{Topic: bus.NginxConfigUpdateTopic, Data: message}) - - assert.Eventually( - t, - func() bool { return fakeFileServiceClient.UpdateOverviewCallCount() == 1 }, - 2*time.Second, - 10*time.Millisecond, - ) -} - -func TestFilePlugin_Process_ConfigApplyRequestTopic(t *testing.T) { - ctx := context.Background() - tempDir := t.TempDir() - - filePath := tempDir + "/nginx.conf" - fileContent := []byte("location /test {\n return 200 \"Test location\\n\";\n}") - fileHash := files.GenerateHash(fileContent) - - message := &mpi.ManagementPlaneRequest{ - Request: &mpi.ManagementPlaneRequest_ConfigApplyRequest{ - ConfigApplyRequest: protos.CreateConfigApplyRequest(protos.FileOverview(filePath, fileHash)), - }, - } - fakeGrpcConnection := &grpcfakes.FakeGrpcConnectionInterface{} - agentConfig := types.AgentConfig() - agentConfig.AllowedDirectories = []string{tempDir} - - tests := []struct { - message *mpi.ManagementPlaneRequest - configApplyReturnsErr error - name string - configApplyStatus model.WriteStatus - }{ - { - name: "Test 1 - Success", - configApplyReturnsErr: nil, - configApplyStatus: model.OK, - message: message, - }, - { - name: "Test 2 - Fail, Rollback", - configApplyReturnsErr: errors.New("something went wrong"), - configApplyStatus: model.RollbackRequired, - message: message, - }, - { - name: "Test 3 - Fail, No Rollback", - configApplyReturnsErr: errors.New("something went wrong"), - configApplyStatus: model.Error, - message: message, - }, - { - name: "Test 4 - Fail to cast payload", - configApplyReturnsErr: errors.New("something went wrong"), - configApplyStatus: model.Error, - message: nil, - }, - { - name: "Test 5 - No changes needed", - configApplyReturnsErr: nil, - configApplyStatus: model.NoChange, - message: message, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - fakeFileManagerService := &filefakes.FakeFileManagerServiceInterface{} - fakeFileManagerService.ConfigApplyReturns(test.configApplyStatus, test.configApplyReturnsErr) - messagePipe := busfakes.NewFakeMessagePipe() - filePlugin := NewFilePlugin(agentConfig, fakeGrpcConnection, model.Command, &sync.RWMutex{}) - err := filePlugin.Init(ctx, messagePipe) - filePlugin.fileManagerService = fakeFileManagerService - require.NoError(t, err) - - filePlugin.Process(ctx, &bus.Message{Topic: bus.ConfigApplyRequestTopic, Data: test.message}) - - messages := messagePipe.Messages() - - switch { - case test.configApplyStatus == model.OK: - assert.Equal(t, bus.WriteConfigSuccessfulTopic, messages[0].Topic) - assert.Len(t, messages, 1) - - _, ok := messages[0].Data.(*model.ConfigApplyMessage) - assert.True(t, ok) - case test.configApplyStatus == model.RollbackRequired: - assert.Equal(t, bus.DataPlaneResponseTopic, messages[0].Topic) - assert.Len(t, messages, 2) - dataPlaneResponse, ok := messages[0].Data.(*mpi.DataPlaneResponse) - assert.True(t, ok) - assert.Equal( - t, - mpi.CommandResponse_COMMAND_STATUS_ERROR, - dataPlaneResponse.GetCommandResponse().GetStatus(), - ) - assert.Equal(t, "Config apply failed, rolling back config", - dataPlaneResponse.GetCommandResponse().GetMessage()) - assert.Equal(t, test.configApplyReturnsErr.Error(), dataPlaneResponse.GetCommandResponse().GetError()) - dataPlaneResponse, ok = messages[1].Data.(*mpi.DataPlaneResponse) - assert.True(t, ok) - assert.Equal(t, "Config apply failed, rollback successful", - dataPlaneResponse.GetCommandResponse().GetMessage()) - assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_FAILURE, - dataPlaneResponse.GetCommandResponse().GetStatus()) - case test.configApplyStatus == model.NoChange: - assert.Len(t, messages, 1) - - response, ok := messages[0].Data.(*model.ConfigApplySuccess) - assert.True(t, ok) - assert.Equal(t, bus.ConfigApplySuccessfulTopic, messages[0].Topic) - assert.Equal( - t, - mpi.CommandResponse_COMMAND_STATUS_OK, - response.DataPlaneResponse.GetCommandResponse().GetStatus(), - ) - case test.message == nil: - assert.Empty(t, messages) - default: - assert.Len(t, messages, 1) - dataPlaneResponse, ok := messages[0].Data.(*mpi.DataPlaneResponse) - assert.True(t, ok) - assert.Equal( - t, - mpi.CommandResponse_COMMAND_STATUS_FAILURE, - dataPlaneResponse.GetCommandResponse().GetStatus(), - ) - assert.Equal(t, "Config apply failed", dataPlaneResponse.GetCommandResponse().GetMessage()) - assert.Equal(t, test.configApplyReturnsErr.Error(), dataPlaneResponse.GetCommandResponse().GetError()) - } - }) - } -} - -func TestFilePlugin_Process_ConfigUploadRequestTopic(t *testing.T) { - ctx := context.Background() - - tempDir := os.TempDir() - testFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx.conf") - defer helpers.RemoveFileWithErrorCheck(t, testFile.Name()) - fileMeta := protos.FileMeta(testFile.Name(), "") - - message := &mpi.ManagementPlaneRequest{ - Request: &mpi.ManagementPlaneRequest_ConfigUploadRequest{ - ConfigUploadRequest: &mpi.ConfigUploadRequest{ - Overview: &mpi.FileOverview{ - Files: []*mpi.File{ - { - FileMeta: fileMeta, - }, - { - FileMeta: fileMeta, - }, - }, - ConfigVersion: &mpi.ConfigVersion{ - InstanceId: "123", - Version: "f33ref3d32d3c32d3a", - }, - }, - }, - }, - } - - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - fakeGrpcConnection := &grpcfakes.FakeGrpcConnectionInterface{} - fakeGrpcConnection.FileServiceClientReturns(fakeFileServiceClient) - messagePipe := busfakes.NewFakeMessagePipe() - - filePlugin := NewFilePlugin(types.AgentConfig(), fakeGrpcConnection, model.Command, &sync.RWMutex{}) - err := filePlugin.Init(ctx, messagePipe) - require.NoError(t, err) - - filePlugin.Process(ctx, &bus.Message{Topic: bus.ConnectionCreatedTopic}) - filePlugin.Process(ctx, &bus.Message{Topic: bus.ConfigUploadRequestTopic, Data: message}) - - assert.Eventually( - t, - func() bool { return fakeFileServiceClient.UpdateFileCallCount() == 2 }, - 2*time.Second, - 10*time.Millisecond, - ) - - messages := messagePipe.Messages() - assert.Len(t, messages, 1) - assert.Equal(t, bus.DataPlaneResponseTopic, messages[0].Topic) - - dataPlaneResponse, ok := messages[0].Data.(*mpi.DataPlaneResponse) - assert.True(t, ok) - assert.Equal( - t, - mpi.CommandResponse_COMMAND_STATUS_OK, - dataPlaneResponse.GetCommandResponse().GetStatus(), - ) -} - -func TestFilePlugin_Process_ConfigUploadRequestTopic_Failure(t *testing.T) { - ctx := context.Background() - - fileMeta := protos.FileMeta("/unknown/file.conf", "") - - message := &mpi.ManagementPlaneRequest{ - Request: &mpi.ManagementPlaneRequest_ConfigUploadRequest{ - ConfigUploadRequest: &mpi.ConfigUploadRequest{ - Overview: &mpi.FileOverview{ - Files: []*mpi.File{ - { - FileMeta: fileMeta, - }, - { - FileMeta: fileMeta, - }, - }, - ConfigVersion: protos.CreateConfigVersion(), - }, - }, - }, - } - - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - fakeGrpcConnection := &grpcfakes.FakeGrpcConnectionInterface{} - fakeGrpcConnection.FileServiceClientReturns(fakeFileServiceClient) - messagePipe := busfakes.NewFakeMessagePipe() - - filePlugin := NewFilePlugin(types.AgentConfig(), fakeGrpcConnection, model.Command, &sync.RWMutex{}) - err := filePlugin.Init(ctx, messagePipe) - require.NoError(t, err) - - filePlugin.Process(ctx, &bus.Message{Topic: bus.ConnectionCreatedTopic}) - filePlugin.Process(ctx, &bus.Message{Topic: bus.ConfigUploadRequestTopic, Data: message}) - - assert.Eventually( - t, - func() bool { return len(messagePipe.Messages()) == 1 }, - 2*time.Second, - 10*time.Millisecond, - ) - - assert.Equal(t, 0, fakeFileServiceClient.UpdateFileCallCount()) - - messages := messagePipe.Messages() - assert.Len(t, messages, 1) - - assert.Equal(t, bus.DataPlaneResponseTopic, messages[0].Topic) - - dataPlaneResponse, ok := messages[0].Data.(*mpi.DataPlaneResponse) - assert.True(t, ok) - assert.Equal( - t, - mpi.CommandResponse_COMMAND_STATUS_FAILURE, - dataPlaneResponse.GetCommandResponse().GetStatus(), - ) -} - -func TestFilePlugin_Process_ConfigApplyFailedTopic(t *testing.T) { - ctx := context.Background() - instanceID := protos.NginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId() - - tests := []struct { - name string - rollbackReturns error - instanceID string - }{ - { - name: "Test 1 - Rollback Success", - rollbackReturns: nil, - instanceID: instanceID, - }, - { - name: "Test 2 - Rollback Fail", - rollbackReturns: errors.New("something went wrong"), - instanceID: instanceID, - }, - - { - name: "Test 3 - Fail to cast payload", - rollbackReturns: errors.New("something went wrong"), - instanceID: "", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - mockFileManager := &filefakes.FakeFileManagerServiceInterface{} - mockFileManager.RollbackReturns(test.rollbackReturns) - - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - fakeGrpcConnection := &grpcfakes.FakeGrpcConnectionInterface{} - fakeGrpcConnection.FileServiceClientReturns(fakeFileServiceClient) - - messagePipe := busfakes.NewFakeMessagePipe() - agentConfig := types.AgentConfig() - filePlugin := NewFilePlugin(agentConfig, fakeGrpcConnection, model.Command, &sync.RWMutex{}) - - err := filePlugin.Init(ctx, messagePipe) - require.NoError(t, err) - filePlugin.fileManagerService = mockFileManager - - data := &model.ConfigApplyMessage{ - CorrelationID: "dfsbhj6-bc92-30c1-a9c9-85591422068e", - InstanceID: test.instanceID, - Error: errors.New("something went wrong with config apply"), - } - - filePlugin.Process(ctx, &bus.Message{Topic: bus.ConfigApplyFailedTopic, Data: data}) - - messages := messagePipe.Messages() - - switch { - case test.rollbackReturns == nil: - assert.Equal(t, bus.RollbackWriteTopic, messages[0].Topic) - assert.Len(t, messages, 1) - - case test.instanceID == "": - assert.Empty(t, messages) - default: - rollbackMessage, ok := messages[0].Data.(*mpi.DataPlaneResponse) - assert.True(t, ok) - assert.Equal(t, "Rollback failed", rollbackMessage.GetCommandResponse().GetMessage()) - assert.Equal(t, test.rollbackReturns.Error(), rollbackMessage.GetCommandResponse().GetError()) - applyMessage, ok := messages[1].Data.(*mpi.DataPlaneResponse) - assert.True(t, ok) - assert.Equal(t, "Config apply failed, rollback failed", - applyMessage.GetCommandResponse().GetMessage()) - assert.Equal(t, data.Error.Error(), applyMessage.GetCommandResponse().GetError()) - assert.Len(t, messages, 2) - } - }) - } -} - -func TestFilePlugin_Process_ConfigApplyRollbackCompleteTopic(t *testing.T) { - ctx := context.Background() - instance := protos.NginxOssInstance([]string{}) - mockFileManager := &filefakes.FakeFileManagerServiceInterface{} - - messagePipe := busfakes.NewFakeMessagePipe() - agentConfig := types.AgentConfig() - fakeGrpcConnection := &grpcfakes.FakeGrpcConnectionInterface{} - filePlugin := NewFilePlugin(agentConfig, fakeGrpcConnection, model.Command, &sync.RWMutex{}) - - err := filePlugin.Init(ctx, messagePipe) - require.NoError(t, err) - filePlugin.fileManagerService = mockFileManager - - expectedResponse := &mpi.DataPlaneResponse{ - MessageMeta: &mpi.MessageMeta{ - MessageId: id.GenerateMessageID(), - CorrelationId: "dfsbhj6-bc92-30c1-a9c9-85591422068e", - Timestamp: timestamppb.Now(), - }, - CommandResponse: &mpi.CommandResponse{ - Status: mpi.CommandResponse_COMMAND_STATUS_OK, - Message: "Config apply successful", - Error: "", - }, - InstanceId: instance.GetInstanceMeta().GetInstanceId(), - } - - filePlugin.Process(ctx, &bus.Message{Topic: bus.ConfigApplySuccessfulTopic, Data: &model.ConfigApplySuccess{ - ConfigContext: &model.NginxConfigContext{}, - DataPlaneResponse: expectedResponse, - }}) - - messages := messagePipe.Messages() - response, ok := messages[0].Data.(*mpi.DataPlaneResponse) - assert.True(t, ok) - - assert.Equal(t, expectedResponse.GetCommandResponse().GetStatus(), response.GetCommandResponse().GetStatus()) - assert.Equal(t, expectedResponse.GetCommandResponse().GetMessage(), response.GetCommandResponse().GetMessage()) - assert.Equal(t, expectedResponse.GetCommandResponse().GetError(), response.GetCommandResponse().GetError()) - assert.Equal(t, expectedResponse.GetMessageMeta().GetCorrelationId(), response.GetMessageMeta().GetCorrelationId()) - - assert.Equal(t, expectedResponse.GetInstanceId(), response.GetInstanceId()) -} - -func TestFilePlugin_Process_ConfigApplyCompleteTopic(t *testing.T) { - ctx := context.Background() - instance := protos.NginxOssInstance([]string{}) - mockFileManager := &filefakes.FakeFileManagerServiceInterface{} - - messagePipe := busfakes.NewFakeMessagePipe() - agentConfig := types.AgentConfig() - fakeGrpcConnection := &grpcfakes.FakeGrpcConnectionInterface{} - filePlugin := NewFilePlugin(agentConfig, fakeGrpcConnection, model.Command, &sync.RWMutex{}) - - err := filePlugin.Init(ctx, messagePipe) - require.NoError(t, err) - filePlugin.fileManagerService = mockFileManager - expectedResponse := &mpi.DataPlaneResponse{ - MessageMeta: &mpi.MessageMeta{ - MessageId: id.GenerateMessageID(), - CorrelationId: "dfsbhj6-bc92-30c1-a9c9-85591422068e", - Timestamp: timestamppb.Now(), - }, - CommandResponse: &mpi.CommandResponse{ - Status: mpi.CommandResponse_COMMAND_STATUS_OK, - Message: "Config apply successful", - Error: "", - }, - InstanceId: instance.GetInstanceMeta().GetInstanceId(), - } - - filePlugin.Process(ctx, &bus.Message{Topic: bus.ConfigApplyCompleteTopic, Data: expectedResponse}) - - messages := messagePipe.Messages() - response, ok := messages[0].Data.(*mpi.DataPlaneResponse) - assert.True(t, ok) - - assert.Equal(t, expectedResponse.GetCommandResponse().GetStatus(), response.GetCommandResponse().GetStatus()) - assert.Equal(t, expectedResponse.GetCommandResponse().GetMessage(), response.GetCommandResponse().GetMessage()) - assert.Equal(t, expectedResponse.GetCommandResponse().GetError(), response.GetCommandResponse().GetError()) - assert.Equal(t, expectedResponse.GetMessageMeta().GetCorrelationId(), response.GetMessageMeta().GetCorrelationId()) - - assert.Equal(t, expectedResponse.GetInstanceId(), response.GetInstanceId()) -} diff --git a/internal/file/file_service_operator_test.go b/internal/file/file_service_operator_test.go deleted file mode 100644 index f8206e145..000000000 --- a/internal/file/file_service_operator_test.go +++ /dev/null @@ -1,189 +0,0 @@ -// 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 file - -import ( - "context" - "os" - "path/filepath" - "sync" - "sync/atomic" - "testing" - "time" - - mpi "github.com/nginx/agent/v3/api/grpc/mpi/v1" - "github.com/nginx/agent/v3/api/grpc/mpi/v1/v1fakes" - "github.com/nginx/agent/v3/pkg/files" - "github.com/nginx/agent/v3/test/helpers" - "github.com/nginx/agent/v3/test/protos" - "github.com/nginx/agent/v3/test/types" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestFileServiceOperator_UpdateOverview(t *testing.T) { - ctx := context.Background() - - filePath := filepath.Join(t.TempDir(), "nginx.conf") - fileMeta := protos.FileMeta(filePath, "") - - fileContent := []byte("location /test {\n return 200 \"Test location\\n\";\n}") - fileHash := files.GenerateHash(fileContent) - - fileWriteErr := os.WriteFile(filePath, fileContent, 0o600) - require.NoError(t, fileWriteErr) - - overview := protos.FileOverview(filePath, fileHash) - - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - fakeFileServiceClient.UpdateOverviewReturnsOnCall(0, &mpi.UpdateOverviewResponse{ - Overview: overview, - }, nil) - - fakeFileServiceClient.UpdateOverviewReturnsOnCall(1, &mpi.UpdateOverviewResponse{}, nil) - - fakeFileServiceClient.UpdateFileReturns(&mpi.UpdateFileResponse{}, nil) - - fileServiceOperator := NewFileServiceOperator(types.AgentConfig(), fakeFileServiceClient, &sync.RWMutex{}) - fileServiceOperator.SetIsConnected(true) - - err := fileServiceOperator.UpdateOverview(ctx, "123", []*mpi.File{ - { - FileMeta: fileMeta, - }, - }, filePath, 0) - - require.NoError(t, err) - assert.Equal(t, 2, fakeFileServiceClient.UpdateOverviewCallCount()) -} - -func TestFileServiceOperator_UpdateOverview_MaxIterations(t *testing.T) { - ctx := context.Background() - - filePath := filepath.Join(t.TempDir(), "nginx.conf") - fileMeta := protos.FileMeta(filePath, "") - - fileContent := []byte("location /test {\n return 200 \"Test location\\n\";\n}") - fileHash := files.GenerateHash(fileContent) - - fileWriteErr := os.WriteFile(filePath, fileContent, 0o600) - require.NoError(t, fileWriteErr) - - overview := protos.FileOverview(filePath, fileHash) - - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - - // do 5 iterations - for i := range 6 { - fakeFileServiceClient.UpdateOverviewReturnsOnCall(i, &mpi.UpdateOverviewResponse{ - Overview: overview, - }, nil) - } - - fakeFileServiceClient.UpdateFileReturns(&mpi.UpdateFileResponse{}, nil) - - fileServiceOperator := NewFileServiceOperator(types.AgentConfig(), fakeFileServiceClient, &sync.RWMutex{}) - fileServiceOperator.SetIsConnected(true) - - err := fileServiceOperator.UpdateOverview(ctx, "123", []*mpi.File{ - { - FileMeta: fileMeta, - }, - }, filePath, 0) - - require.Error(t, err) - assert.Equal(t, "too many UpdateOverview attempts", err.Error()) -} - -func TestFileServiceOperator_UpdateOverview_NoConnection(t *testing.T) { - ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond) - defer cancel() - - filePath := filepath.Join(t.TempDir(), "nginx.conf") - fileMeta := protos.FileMeta(filePath, "") - - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - - agentConfig := types.AgentConfig() - agentConfig.Client.Backoff.MaxElapsedTime = 200 * time.Millisecond - - fileServiceOperator := NewFileServiceOperator(types.AgentConfig(), fakeFileServiceClient, &sync.RWMutex{}) - fileServiceOperator.SetIsConnected(false) - - err := fileServiceOperator.UpdateOverview(ctx, "123", []*mpi.File{ - { - FileMeta: fileMeta, - }, - }, filePath, 0) - - assert.ErrorIs(t, err, context.DeadlineExceeded) -} - -func TestFileManagerService_UpdateFile(t *testing.T) { - tests := []struct { - name string - isCert bool - }{ - { - name: "non-cert", - isCert: false, - }, - { - name: "cert", - isCert: true, - }, - } - - tempDir := os.TempDir() - - for _, test := range tests { - ctx := context.Background() - - testFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx.conf") - - var fileMeta *mpi.FileMeta - if test.isCert { - fileMeta = protos.CertMeta(testFile.Name(), "") - } else { - fileMeta = protos.FileMeta(testFile.Name(), "") - } - - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - fileServiceOperator := NewFileServiceOperator(types.AgentConfig(), fakeFileServiceClient, &sync.RWMutex{}) - fileServiceOperator.SetIsConnected(true) - - err := fileServiceOperator.UpdateFile(ctx, "123", &mpi.File{FileMeta: fileMeta}) - - require.NoError(t, err) - assert.Equal(t, 1, fakeFileServiceClient.UpdateFileCallCount()) - - helpers.RemoveFileWithErrorCheck(t, testFile.Name()) - } -} - -func TestFileManagerService_UpdateFile_LargeFile(t *testing.T) { - ctx := context.Background() - tempDir := os.TempDir() - - testFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx.conf") - writeFileError := os.WriteFile(testFile.Name(), []byte("#test content"), 0o600) - require.NoError(t, writeFileError) - fileMeta := protos.FileMetaLargeFile(testFile.Name(), "") - - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - fakeClientStreamingClient := &FakeClientStreamingClient{sendCount: atomic.Int32{}} - fakeFileServiceClient.UpdateFileStreamReturns(fakeClientStreamingClient, nil) - fileServiceOperator := NewFileServiceOperator(types.AgentConfig(), fakeFileServiceClient, &sync.RWMutex{}) - - fileServiceOperator.SetIsConnected(true) - err := fileServiceOperator.UpdateFile(ctx, "123", &mpi.File{FileMeta: fileMeta}) - - require.NoError(t, err) - assert.Equal(t, 0, fakeFileServiceClient.UpdateFileCallCount()) - assert.Equal(t, 14, int(fakeClientStreamingClient.sendCount.Load())) - - helpers.RemoveFileWithErrorCheck(t, testFile.Name()) -} From ea0e3411ca3a3f248637139e029e39edc5e33b83 Mon Sep 17 00:00:00 2001 From: Spencer Ugbo Date: Wed, 1 Oct 2025 13:56:11 +0100 Subject: [PATCH 06/27] resolve conflicts --- internal/file/file_manager_service_test.go | 1035 ++++++++++++++++++++ 1 file changed, 1035 insertions(+) create mode 100644 internal/file/file_manager_service_test.go diff --git a/internal/file/file_manager_service_test.go b/internal/file/file_manager_service_test.go new file mode 100644 index 000000000..a8470c293 --- /dev/null +++ b/internal/file/file_manager_service_test.go @@ -0,0 +1,1035 @@ +// 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 file + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path" + "path/filepath" + "sync" + "testing" + + "github.com/nginx/agent/v3/internal/model" + + "github.com/nginx/agent/v3/pkg/files" + "google.golang.org/protobuf/types/known/timestamppb" + + mpi "github.com/nginx/agent/v3/api/grpc/mpi/v1" + "github.com/nginx/agent/v3/api/grpc/mpi/v1/v1fakes" + "github.com/nginx/agent/v3/test/helpers" + "github.com/nginx/agent/v3/test/protos" + "github.com/nginx/agent/v3/test/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFileManagerService_ConfigApply_Add(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + filePath := filepath.Join(tempDir, "nginx.conf") + + fileContent := []byte("location /test {\n return 200 \"Test location\\n\";\n}") + fileHash := files.GenerateHash(fileContent) + defer helpers.RemoveFileWithErrorCheck(t, filePath) + + overview := protos.FileOverview(filePath, fileHash) + + manifestDirPath := tempDir + manifestFilePath := filepath.Join(manifestDirPath, "manifest.json") + helpers.CreateFileWithErrorCheck(t, manifestDirPath, "manifest.json") + + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + fakeFileServiceClient.GetOverviewReturns(&mpi.GetOverviewResponse{ + Overview: overview, + }, nil) + fakeFileServiceClient.GetFileReturns(&mpi.GetFileResponse{ + Contents: &mpi.FileContents{ + Contents: fileContent, + }, + }, nil) + agentConfig := types.AgentConfig() + agentConfig.AllowedDirectories = []string{tempDir} + + fileManagerService := NewFileManagerService(fakeFileServiceClient, agentConfig, &sync.RWMutex{}) + fileManagerService.agentConfig.LibDir = manifestDirPath + fileManagerService.manifestFilePath = manifestFilePath + + request := protos.CreateConfigApplyRequest(overview) + writeStatus, err := fileManagerService.ConfigApply(ctx, request) + require.NoError(t, err) + assert.Equal(t, model.OK, writeStatus) + data, readErr := os.ReadFile(filePath) + require.NoError(t, readErr) + assert.Equal(t, fileContent, data) + assert.Equal(t, fileManagerService.fileActions[filePath].File, overview.GetFiles()[0]) + assert.Equal(t, 1, fakeFileServiceClient.GetFileCallCount()) + assert.True(t, fileManagerService.rollbackManifest) +} + +/* + func TestFileManagerService_ConfigApply_Add_LargeFile(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + filePath := filepath.Join(tempDir, "nginx.conf") + fileContent := []byte("location /test {\n return 200 \"Test location\\n\";\n}") + fileHash := files.GenerateHash(fileContent) + defer helpers.RemoveFileWithErrorCheck(t, filePath) + + overview := protos.FileOverviewLargeFile(filePath, fileHash) + + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + fakeFileServiceClient.GetOverviewReturns(&mpi.GetOverviewResponse{ + Overview: overview, + }, nil) + + fakeServerStreamingClient := &FakeServerStreamingClient{ + chunks: make(map[uint32][]byte), + currentChunkID: 0, + fileName: filePath, + } + + for i := range fileContent { + fakeServerStreamingClient.chunks[uint32(i)] = []byte{fileContent[i]} + } + + manifestDirPath := tempDir + manifestFilePath := filepath.Join(manifestDirPath, "manifest.json") + + fakeFileServiceClient.GetFileStreamReturns(fakeServerStreamingClient, nil) + agentConfig := types.AgentConfig() + agentConfig.AllowedDirectories = []string{tempDir} + fileManagerService := NewFileManagerService(fakeFileServiceClient, agentConfig, &sync.RWMutex{}) + fileManagerService.agentConfig.LibDir = manifestDirPath + fileManagerService.manifestFilePath = manifestFilePath + + request := protos.CreateConfigApplyRequest(overview) + writeStatus, err := fileManagerService.ConfigApply(ctx, request) + require.NoError(t, err) + assert.Equal(t, model.OK, writeStatus) + data, readErr := os.ReadFile(filePath) + require.NoError(t, readErr) + assert.Equal(t, fileContent, data) + assert.Equal(t, fileManagerService.fileActions[filePath].File, overview.GetFiles()[0]) + assert.Equal(t, 0, fakeFileServiceClient.GetFileCallCount()) + assert.Equal(t, 53, int(fakeServerStreamingClient.currentChunkID)) + assert.True(t, fileManagerService.rollbackManifest) + } +*/ + +func TestFileManagerService_ConfigApply_Update(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + fileContent := []byte("location /test {\n return 200 \"Test location\\n\";\n}") + previousFileContent := []byte("some test data") + previousFileHash := files.GenerateHash(previousFileContent) + fileHash := files.GenerateHash(fileContent) + tempFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx.conf") + _, writeErr := tempFile.Write(previousFileContent) + require.NoError(t, writeErr) + defer helpers.RemoveFileWithErrorCheck(t, tempFile.Name()) + + filesOnDisk := map[string]*mpi.File{ + tempFile.Name(): { + FileMeta: &mpi.FileMeta{ + Name: tempFile.Name(), + Hash: previousFileHash, + ModifiedTime: timestamppb.Now(), + Permissions: "0640", + Size: 0, + }, + }, + } + + manifestDirPath := tempDir + manifestFilePath := manifestDirPath + "/manifest.json" + helpers.CreateFileWithErrorCheck(t, manifestDirPath, "manifest.json") + + overview := protos.FileOverview(tempFile.Name(), fileHash) + + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + fakeFileServiceClient.GetOverviewReturns(&mpi.GetOverviewResponse{ + Overview: overview, + }, nil) + fakeFileServiceClient.GetFileReturns(&mpi.GetFileResponse{ + Contents: &mpi.FileContents{ + Contents: fileContent, + }, + }, nil) + agentConfig := types.AgentConfig() + agentConfig.AllowedDirectories = []string{tempDir} + + fileManagerService := NewFileManagerService(fakeFileServiceClient, agentConfig, &sync.RWMutex{}) + fileManagerService.agentConfig.LibDir = manifestDirPath + fileManagerService.manifestFilePath = manifestFilePath + err := fileManagerService.UpdateCurrentFilesOnDisk(ctx, filesOnDisk, false) + require.NoError(t, err) + + request := protos.CreateConfigApplyRequest(overview) + writeStatus, err := fileManagerService.ConfigApply(ctx, request) + require.NoError(t, err) + assert.Equal(t, model.OK, writeStatus) + data, readErr := os.ReadFile(tempFile.Name()) + require.NoError(t, readErr) + assert.Equal(t, fileContent, data) + assert.Equal(t, fileManagerService.rollbackFileContents[tempFile.Name()], previousFileContent) + assert.Equal(t, fileManagerService.fileActions[tempFile.Name()].File, overview.GetFiles()[0]) + assert.True(t, fileManagerService.rollbackManifest) +} + +func TestFileManagerService_ConfigApply_Delete(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + fileContent := []byte("location /test {\n return 200 \"Test location\\n\";\n}") + tempFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx.conf") + _, writeErr := tempFile.Write(fileContent) + require.NoError(t, writeErr) + + tempFile2 := helpers.CreateFileWithErrorCheck(t, tempDir, "test.conf") + overview := protos.FileOverview(tempFile2.Name(), files.GenerateHash(fileContent)) + + filesOnDisk := map[string]*mpi.File{ + tempFile.Name(): { + FileMeta: &mpi.FileMeta{ + Name: tempFile.Name(), + Hash: files.GenerateHash(fileContent), + ModifiedTime: timestamppb.Now(), + Permissions: "0640", + Size: 0, + }, + }, + } + + manifestDirPath := tempDir + manifestFilePath := manifestDirPath + "/manifest.json" + helpers.CreateFileWithErrorCheck(t, manifestDirPath, "manifest.json") + + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + agentConfig := types.AgentConfig() + agentConfig.AllowedDirectories = []string{tempDir} + + fileManagerService := NewFileManagerService(fakeFileServiceClient, agentConfig, &sync.RWMutex{}) + fileManagerService.agentConfig.LibDir = manifestDirPath + fileManagerService.manifestFilePath = manifestFilePath + err := fileManagerService.UpdateCurrentFilesOnDisk(ctx, filesOnDisk, false) + require.NoError(t, err) + + request := protos.CreateConfigApplyRequest(overview) + + fakeFileServiceClient.GetOverviewReturns(&mpi.GetOverviewResponse{ + Overview: overview, + }, nil) + fakeFileServiceClient.GetFileReturns(&mpi.GetFileResponse{ + Contents: &mpi.FileContents{ + Contents: fileContent, + }, + }, nil) + + writeStatus, err := fileManagerService.ConfigApply(ctx, request) + require.NoError(t, err) + assert.NoFileExists(t, tempFile.Name()) + assert.Equal(t, fileManagerService.rollbackFileContents[tempFile.Name()], fileContent) + assert.Equal(t, + fileManagerService.fileActions[tempFile.Name()].File.GetFileMeta().GetName(), + filesOnDisk[tempFile.Name()].GetFileMeta().GetName(), + ) + assert.Equal(t, + fileManagerService.fileActions[tempFile.Name()].File.GetFileMeta().GetHash(), + filesOnDisk[tempFile.Name()].GetFileMeta().GetHash(), + ) + assert.Equal(t, + fileManagerService.fileActions[tempFile.Name()].File.GetFileMeta().GetSize(), + filesOnDisk[tempFile.Name()].GetFileMeta().GetSize(), + ) + assert.Equal(t, model.OK, writeStatus) + assert.True(t, fileManagerService.rollbackManifest) +} + +func TestFileManagerService_ConfigApply_Failed(t *testing.T) { + ctx := t.Context() + tempDir := t.TempDir() + + filePath := filepath.Join(tempDir, "nginx.conf") + fileContent := []byte("# this is going to fail") + fileHash := files.GenerateHash(fileContent) + + overview := protos.FileOverview(filePath, fileHash) + + manifestDirPath := tempDir + manifestFilePath := manifestDirPath + "/manifest.json" + helpers.CreateFileWithErrorCheck(t, manifestDirPath, "manifest.json") + + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + fakeFileServiceClient.GetOverviewReturns(&mpi.GetOverviewResponse{ + Overview: overview, + }, nil) + fakeFileServiceClient.GetFileReturns(nil, errors.New("file not found")) + + agentConfig := types.AgentConfig() + agentConfig.AllowedDirectories = []string{tempDir} + + fileManagerService := NewFileManagerService(fakeFileServiceClient, agentConfig, &sync.RWMutex{}) + fileManagerService.agentConfig.LibDir = manifestDirPath + fileManagerService.manifestFilePath = manifestFilePath + + request := protos.CreateConfigApplyRequest(overview) + writeStatus, err := fileManagerService.ConfigApply(ctx, request) + + require.Error(t, err) + assert.Equal(t, model.RollbackRequired, writeStatus) + assert.False(t, fileManagerService.rollbackManifest) +} + +func TestFileManagerService_checkAllowedDirectory(t *testing.T) { + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + fileManagerService := NewFileManagerService(fakeFileServiceClient, types.AgentConfig(), &sync.RWMutex{}) + + allowedFiles := []*mpi.File{ + { + FileMeta: &mpi.FileMeta{ + Name: "/tmp/local/etc/nginx/allowedDirPath", + Hash: "", + ModifiedTime: nil, + Permissions: "", + Size: 0, + }, + }, + } + + notAllowed := []*mpi.File{ + { + FileMeta: &mpi.FileMeta{ + Name: "/not/allowed/dir/path", + Hash: "", + ModifiedTime: nil, + Permissions: "", + Size: 0, + }, + }, + } + + err := fileManagerService.checkAllowedDirectory(allowedFiles) + require.NoError(t, err) + err = fileManagerService.checkAllowedDirectory(notAllowed) + require.Error(t, err) +} + +func TestFileManagerService_ClearCache(t *testing.T) { + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + fileManagerService := NewFileManagerService(fakeFileServiceClient, types.AgentConfig(), &sync.RWMutex{}) + + filesCache := map[string]*model.FileCache{ + "file/path/test.conf": { + File: &mpi.File{ + FileMeta: &mpi.FileMeta{ + Name: "file/path/test.conf", + Hash: "", + ModifiedTime: nil, + Permissions: "", + Size: 0, + }, + }, + }, + } + + contentsCache := map[string][]byte{ + "file/path/test.conf": []byte("some test data"), + } + + fileManagerService.fileActions = filesCache + fileManagerService.rollbackFileContents = contentsCache + assert.NotEmpty(t, fileManagerService.fileActions) + assert.NotEmpty(t, fileManagerService.rollbackFileContents) + + fileManagerService.ClearCache() + + assert.Empty(t, fileManagerService.fileActions) + assert.Empty(t, fileManagerService.rollbackFileContents) +} + +func TestFileManagerService_Rollback(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + deleteFilePath := filepath.Join(tempDir, "nginx_delete.conf") + + newFileContent := []byte("location /test {\n return 200 \"This config needs to be rolled back\\n\";\n}") + oldFileContent := []byte("location /test {\n return 200 \"This is the saved config\\n\";\n}") + fileHash := files.GenerateHash(newFileContent) + + addFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_add.conf") + _, writeErr := addFile.Write(newFileContent) + require.NoError(t, writeErr) + + updateFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_update.conf") + _, writeErr = updateFile.Write(newFileContent) + require.NoError(t, writeErr) + + manifestDirPath := tempDir + manifestFilePath := manifestDirPath + "/manifest.json" + helpers.CreateFileWithErrorCheck(t, manifestDirPath, "manifest.json") + + filesCache := map[string]*model.FileCache{ + addFile.Name(): { + File: &mpi.File{ + FileMeta: &mpi.FileMeta{ + Name: addFile.Name(), + Hash: fileHash, + ModifiedTime: timestamppb.Now(), + Permissions: "0777", + Size: 0, + }, + Unmanaged: false, + }, + Action: model.Add, + }, + updateFile.Name(): { + File: &mpi.File{ + FileMeta: &mpi.FileMeta{ + Name: updateFile.Name(), + Hash: fileHash, + ModifiedTime: timestamppb.Now(), + Permissions: "0777", + Size: 0, + }, + Unmanaged: false, + }, + Action: model.Update, + }, + deleteFilePath: { + File: &mpi.File{ + FileMeta: &mpi.FileMeta{ + Name: deleteFilePath, + Hash: "", + ModifiedTime: timestamppb.Now(), + Permissions: "0777", + Size: 0, + }, + Unmanaged: false, + }, + Action: model.Delete, + }, + "unspecified/file/test.conf": { + File: &mpi.File{ + FileMeta: &mpi.FileMeta{ + Name: "unspecified/file/test.conf", + Hash: "", + ModifiedTime: timestamppb.Now(), + Permissions: "0777", + Size: 0, + }, + Unmanaged: false, + }, + }, + } + fileContentCache := map[string][]byte{ + deleteFilePath: oldFileContent, + updateFile.Name(): oldFileContent, + } + + instanceID := protos.NginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId() + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + fileManagerService := NewFileManagerService(fakeFileServiceClient, types.AgentConfig(), &sync.RWMutex{}) + fileManagerService.rollbackFileContents = fileContentCache + fileManagerService.fileActions = filesCache + fileManagerService.agentConfig.LibDir = manifestDirPath + fileManagerService.manifestFilePath = manifestFilePath + + err := fileManagerService.Rollback(ctx, instanceID) + require.NoError(t, err) + + assert.NoFileExists(t, addFile.Name()) + assert.FileExists(t, deleteFilePath) + updateData, readUpdateErr := os.ReadFile(updateFile.Name()) + require.NoError(t, readUpdateErr) + assert.Equal(t, oldFileContent, updateData) + + deleteData, readDeleteErr := os.ReadFile(deleteFilePath) + require.NoError(t, readDeleteErr) + assert.Equal(t, oldFileContent, deleteData) + + defer helpers.RemoveFileWithErrorCheck(t, updateFile.Name()) + defer helpers.RemoveFileWithErrorCheck(t, deleteFilePath) +} + +func TestFileManagerService_DetermineFileActions(t *testing.T) { + ctx := context.Background() + tempDir := os.TempDir() + + deleteTestFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_delete.conf") + defer helpers.RemoveFileWithErrorCheck(t, deleteTestFile.Name()) + fileContent, readErr := os.ReadFile("../../test/config/nginx/nginx.conf") + require.NoError(t, readErr) + err := os.WriteFile(deleteTestFile.Name(), fileContent, 0o600) + require.NoError(t, err) + + updateTestFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_update.conf") + defer helpers.RemoveFileWithErrorCheck(t, updateTestFile.Name()) + updatedFileContent := []byte("test update file") + updateErr := os.WriteFile(updateTestFile.Name(), updatedFileContent, 0o600) + require.NoError(t, updateErr) + + addTestFileName := tempDir + "nginx_add.conf" + + unmanagedFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_unmanaged.conf") + defer helpers.RemoveFileWithErrorCheck(t, unmanagedFile.Name()) + unmanagedFileContent := []byte("test unmanaged file") + unmanagedErr := os.WriteFile(unmanagedFile.Name(), unmanagedFileContent, 0o600) + require.NoError(t, unmanagedErr) + + addTestFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_add.conf") + defer helpers.RemoveFileWithErrorCheck(t, addTestFile.Name()) + addFileContent := []byte("test add file") + addErr := os.WriteFile(addTestFile.Name(), addFileContent, 0o600) + require.NoError(t, addErr) + + tests := []struct { + expectedError error + modifiedFiles map[string]*model.FileCache + currentFiles map[string]*mpi.File + expectedCache map[string]*model.FileCache + expectedContent map[string][]byte + name string + }{ + { + name: "Test 1: Add, Update & Delete Files", + modifiedFiles: map[string]*model.FileCache{ + addTestFileName: { + File: &mpi.File{ + FileMeta: protos.FileMeta(addTestFileName, files.GenerateHash(fileContent)), + Unmanaged: false, + }, + }, + updateTestFile.Name(): { + File: &mpi.File{ + FileMeta: protos.FileMeta(updateTestFile.Name(), files.GenerateHash(updatedFileContent)), + Unmanaged: false, + }, + }, + unmanagedFile.Name(): { + File: &mpi.File{ + FileMeta: protos.FileMeta(unmanagedFile.Name(), files.GenerateHash(unmanagedFileContent)), + Unmanaged: true, + }, + }, + }, + currentFiles: map[string]*mpi.File{ + deleteTestFile.Name(): { + FileMeta: protos.FileMeta(deleteTestFile.Name(), files.GenerateHash(fileContent)), + }, + updateTestFile.Name(): { + FileMeta: protos.FileMeta(updateTestFile.Name(), files.GenerateHash(fileContent)), + }, + unmanagedFile.Name(): { + FileMeta: protos.FileMeta(unmanagedFile.Name(), files.GenerateHash(fileContent)), + Unmanaged: true, + }, + }, + expectedCache: map[string]*model.FileCache{ + deleteTestFile.Name(): { + File: &mpi.File{ + FileMeta: protos.ManifestFileMeta(deleteTestFile.Name(), files.GenerateHash(fileContent)), + Unmanaged: false, + }, + Action: model.Delete, + }, + updateTestFile.Name(): { + File: &mpi.File{ + FileMeta: protos.FileMeta(updateTestFile.Name(), files.GenerateHash(updatedFileContent)), + Unmanaged: false, + }, + Action: model.Update, + }, + addTestFileName: { + File: &mpi.File{ + FileMeta: protos.FileMeta(addTestFileName, files.GenerateHash(fileContent)), + Unmanaged: false, + }, + Action: model.Add, + }, + }, + expectedContent: map[string][]byte{ + deleteTestFile.Name(): fileContent, + updateTestFile.Name(): updatedFileContent, + }, + expectedError: nil, + }, + { + name: "Test 2: Files same as on disk", + modifiedFiles: map[string]*model.FileCache{ + addTestFile.Name(): { + File: &mpi.File{ + FileMeta: protos.FileMeta(addTestFile.Name(), files.GenerateHash(fileContent)), + }, + }, + updateTestFile.Name(): { + File: &mpi.File{ + FileMeta: protos.FileMeta(updateTestFile.Name(), files.GenerateHash(fileContent)), + }, + }, + deleteTestFile.Name(): { + File: &mpi.File{ + FileMeta: protos.FileMeta(deleteTestFile.Name(), files.GenerateHash(fileContent)), + }, + }, + }, + currentFiles: map[string]*mpi.File{ + deleteTestFile.Name(): { + FileMeta: protos.FileMeta(deleteTestFile.Name(), files.GenerateHash(fileContent)), + }, + updateTestFile.Name(): { + FileMeta: protos.FileMeta(updateTestFile.Name(), files.GenerateHash(fileContent)), + }, + addTestFile.Name(): { + FileMeta: protos.FileMeta(addTestFile.Name(), files.GenerateHash(fileContent)), + }, + }, + expectedCache: make(map[string]*model.FileCache), + expectedContent: make(map[string][]byte), + expectedError: nil, + }, + { + name: "Test 3: File being deleted already doesn't exist", + modifiedFiles: make(map[string]*model.FileCache), + currentFiles: map[string]*mpi.File{ + "/unknown/file.conf": { + FileMeta: protos.FileMeta("/unknown/file.conf", files.GenerateHash(fileContent)), + }, + }, + expectedCache: make(map[string]*model.FileCache), + expectedContent: make(map[string][]byte), + expectedError: nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(tt *testing.T) { + // Delete manifest file if it already exists + manifestFile := CreateTestManifestFile(t, tempDir, test.currentFiles, true) + defer manifestFile.Close() + manifestDirPath := tempDir + manifestFilePath := manifestFile.Name() + + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + fileManagerService := NewFileManagerService(fakeFileServiceClient, types.AgentConfig(), &sync.RWMutex{}) + fileManagerService.agentConfig.LibDir = manifestDirPath + fileManagerService.manifestFilePath = manifestFilePath + + require.NoError(tt, err) + + diff, contents, fileActionErr := fileManagerService.DetermineFileActions( + ctx, + test.currentFiles, + test.modifiedFiles, + ) + require.NoError(tt, fileActionErr) + assert.Equal(tt, test.expectedContent, contents) + assert.Equal(tt, test.expectedCache, diff) + }) + } +} + +func CreateTestManifestFile(t testing.TB, tempDir string, currentFiles map[string]*mpi.File, refrenced bool) *os.File { + t.Helper() + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + fileManagerService := NewFileManagerService(fakeFileServiceClient, types.AgentConfig(), &sync.RWMutex{}) + manifestFiles := fileManagerService.convertToManifestFileMap(currentFiles, refrenced) + manifestJSON, err := json.MarshalIndent(manifestFiles, "", " ") + require.NoError(t, err) + file, err := os.CreateTemp(tempDir, "manifest.json") + require.NoError(t, err) + + _, err = file.Write(manifestJSON) + require.NoError(t, err) + + return file +} + +func TestFileManagerService_UpdateManifestFile(t *testing.T) { + ctx := t.Context() + fileContent := []byte("location /test {\n return 200 \"Test location\\n\";\n}") + fileHash := files.GenerateHash(fileContent) + + tests := []struct { + currentFiles map[string]*mpi.File + currentManifestFiles map[string]*model.ManifestFile + expectedFiles map[string]*model.ManifestFile + name string + referenced bool + previousReferenced bool + }{ + { + name: "Test 1: Manifest file empty", + currentFiles: map[string]*mpi.File{ + "/etc/nginx/nginx.conf": { + FileMeta: protos.FileMeta("/etc/nginx/nginx.conf", fileHash), + }, + }, + expectedFiles: map[string]*model.ManifestFile{ + "/etc/nginx/nginx.conf": { + ManifestFileMeta: &model.ManifestFileMeta{ + Name: "/etc/nginx/nginx.conf", + Hash: fileHash, + Size: 0, + Referenced: true, + }, + }, + }, + currentManifestFiles: make(map[string]*model.ManifestFile), + referenced: true, + previousReferenced: true, + }, + { + name: "Test 2: Manifest file populated - unreferenced", + currentFiles: map[string]*mpi.File{ + "/etc/nginx/nginx.conf": { + FileMeta: protos.FileMeta("/etc/nginx/nginx.conf", fileHash), + }, + "/etc/nginx/unref.conf": { + FileMeta: protos.FileMeta("/etc/nginx/unref.conf", fileHash), + }, + }, + expectedFiles: map[string]*model.ManifestFile{ + "/etc/nginx/nginx.conf": { + ManifestFileMeta: &model.ManifestFileMeta{ + Name: "/etc/nginx/nginx.conf", + Hash: fileHash, + Size: 0, + Referenced: false, + }, + }, + "/etc/nginx/unref.conf": { + ManifestFileMeta: &model.ManifestFileMeta{ + Name: "/etc/nginx/unref.conf", + Hash: fileHash, + Size: 0, + Referenced: false, + }, + }, + }, + currentManifestFiles: map[string]*model.ManifestFile{ + "/etc/nginx/nginx.conf": { + ManifestFileMeta: &model.ManifestFileMeta{ + Name: "/etc/nginx/nginx.conf", + Hash: fileHash, + Size: 0, + Referenced: true, + }, + }, + }, + referenced: false, + previousReferenced: true, + }, + { + name: "Test 3: Manifest file populated - referenced", + currentFiles: map[string]*mpi.File{ + "/etc/nginx/nginx.conf": { + FileMeta: protos.FileMeta("/etc/nginx/nginx.conf", fileHash), + }, + "/etc/nginx/test.conf": { + FileMeta: protos.FileMeta("/etc/nginx/test.conf", fileHash), + }, + }, + expectedFiles: map[string]*model.ManifestFile{ + "/etc/nginx/nginx.conf": { + ManifestFileMeta: &model.ManifestFileMeta{ + Name: "/etc/nginx/nginx.conf", + Hash: fileHash, + Size: 0, + Referenced: true, + }, + }, + "/etc/nginx/test.conf": { + ManifestFileMeta: &model.ManifestFileMeta{ + Name: "/etc/nginx/test.conf", + Hash: fileHash, + Size: 0, + Referenced: true, + }, + }, + "/etc/nginx/unref.conf": { + ManifestFileMeta: &model.ManifestFileMeta{ + Name: "/etc/nginx/unref.conf", + Hash: fileHash, + Size: 0, + Referenced: false, + }, + }, + }, + currentManifestFiles: map[string]*model.ManifestFile{ + "/etc/nginx/nginx.conf": { + ManifestFileMeta: &model.ManifestFileMeta{ + Name: "/etc/nginx/nginx.conf", + Hash: fileHash, + Size: 0, + Referenced: false, + }, + }, + "/etc/nginx/unref.conf": { + ManifestFileMeta: &model.ManifestFileMeta{ + Name: "/etc/nginx/unref.conf", + Hash: fileHash, + Size: 0, + Referenced: false, + }, + }, + }, + referenced: true, + previousReferenced: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(tt *testing.T) { + manifestDirPath := t.TempDir() + file := helpers.CreateFileWithErrorCheck(t, manifestDirPath, "manifest.json") + + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + fileManagerService := NewFileManagerService(fakeFileServiceClient, types.AgentConfig(), &sync.RWMutex{}) + fileManagerService.agentConfig.LibDir = manifestDirPath + fileManagerService.manifestFilePath = file.Name() + + manifestJSON, err := json.MarshalIndent(test.currentManifestFiles, "", " ") + require.NoError(t, err) + + _, err = file.Write(manifestJSON) + require.NoError(t, err) + + updateErr := fileManagerService.UpdateManifestFile(ctx, test.currentFiles, test.referenced) + require.NoError(tt, updateErr) + + manifestFiles, _, manifestErr := fileManagerService.manifestFile() + require.NoError(tt, manifestErr) + assert.Equal(tt, test.expectedFiles, manifestFiles) + }) + } +} + +func TestFileManagerService_fileActions(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + addFilePath := filepath.Join(tempDir, "nginx_add.conf") + unspecifiedFilePath := "unspecified/file/test.conf" + + newFileContent := []byte("location /test {\n return 200 \"This config needs to be rolled back\\n\";\n}") + oldFileContent := []byte("location /test {\n return 200 \"This is the saved config\\n\";\n}") + fileHash := files.GenerateHash(newFileContent) + + deleteFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_delete.conf") + _, writeErr := deleteFile.Write(oldFileContent) + require.NoError(t, writeErr) + + updateFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_update.conf") + _, writeErr = updateFile.Write(oldFileContent) + require.NoError(t, writeErr) + + filesCache := map[string]*model.FileCache{ + addFilePath: { + File: &mpi.File{ + FileMeta: &mpi.FileMeta{ + Name: addFilePath, + Hash: fileHash, + ModifiedTime: timestamppb.Now(), + Permissions: "0777", + Size: 0, + }, + }, + Action: model.Add, + }, + updateFile.Name(): { + File: &mpi.File{ + FileMeta: &mpi.FileMeta{ + Name: updateFile.Name(), + Hash: fileHash, + ModifiedTime: timestamppb.Now(), + Permissions: "0777", + Size: 0, + }, + }, + Action: model.Update, + }, + deleteFile.Name(): { + File: &mpi.File{ + FileMeta: &mpi.FileMeta{ + Name: deleteFile.Name(), + Hash: "", + ModifiedTime: timestamppb.Now(), + Permissions: "0777", + Size: 0, + }, + }, + Action: model.Delete, + }, + unspecifiedFilePath: { + File: &mpi.File{ + FileMeta: &mpi.FileMeta{ + Name: unspecifiedFilePath, + Hash: "", + ModifiedTime: timestamppb.Now(), + Permissions: "0777", + Size: 0, + }, + }, + }, + } + + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + fakeFileServiceClient.GetFileReturns(&mpi.GetFileResponse{ + Contents: &mpi.FileContents{ + Contents: newFileContent, + }, + }, nil) + fileManagerService := NewFileManagerService(fakeFileServiceClient, types.AgentConfig(), &sync.RWMutex{}) + + fileManagerService.fileActions = filesCache + + actionErr := fileManagerService.executeFileActions(ctx, os.TempDir()) + require.NoError(t, actionErr) + + assert.FileExists(t, addFilePath) + assert.NoFileExists(t, deleteFile.Name()) + assert.NoFileExists(t, unspecifiedFilePath) + updateData, readUpdateErr := os.ReadFile(updateFile.Name()) + require.NoError(t, readUpdateErr) + assert.Equal(t, newFileContent, updateData) + + defer helpers.RemoveFileWithErrorCheck(t, updateFile.Name()) + defer helpers.RemoveFileWithErrorCheck(t, addFilePath) +} + +func TestParseX509Certificates(t *testing.T) { + tests := []struct { + certName string + certContent string + name string + expectedSerial string + }{ + { + name: "Test 1: generated cert", + certName: "public_cert", + certContent: "", + expectedSerial: "123123", + }, + { + name: "Test 2: open ssl cert", + certName: "open_ssl_cert", + certContent: `-----BEGIN CERTIFICATE----- +MIIDazCCAlOgAwIBAgIUR+YGgRHhYwotFyBOvSc1KD9d45kwDQYJKoZIhvcNAQEL +BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDExMjcxNTM0MDZaFw0yNDEy +MjcxNTM0MDZaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw +HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDnDDVGflbZ3dmuQJj+8QuJIQ8lWjVGYhlsFI4AGFTX +9VfYOqJEPyuMRuSj2eN7C/mR4yTJSggnv0kFtjmeGh2keNdmb4R/0CjYWZVl/Na6 +cAfldB8v2+sm0LZ/OD9F9CbnYB95takPOZq3AP5kUA+qlFYzroqXsxJKvZF6dUuI ++kTOn5pWD+eFmueFedOz1aucOvblUJLueVZnvAbIrBoyaulw3f2kjk0J1266nFMb +s72AvjyYbOXbyur3BhPThCaOeqMGggDmFslZ4pBgQFWUeFvmqJMFzf1atKTWlbj7 +Mj+bNKNs4xvUuNhqd/F99Pz2Fe0afKbTHK83hqgSHKbtAgMBAAGjUzBRMB0GA1Ud +DgQWBBQq0Bzde0bl9CFb81LrvFfdWlY7hzAfBgNVHSMEGDAWgBQq0Bzde0bl9CFb +81LrvFfdWlY7hzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAo +8GXvwRa0M0D4x4Lrj2K57FxH4ECNBnAqWlh3Ce9LEioL2CYaQQw6I2/FsnTk8TYY +WgGgXMEyA6OeOXvwxWjSllK9+D2ueTMhNRO0tYMUi0kDJqd9EpmnEcSWIL2G2SNo +BWQjqEoEKFjvrgx6h13AtsFlpdURoVtodrtnUrXp1r4wJvljC2qexoNfslhpbqsT +X/vYrzgKRoKSUWUt1ejKTntrVuaJK4NMxANOTTjIXgxyoV3YcgEmL9KzribCqILi +p79Nno9d+kovtX5VKsJ5FCcPw9mEATgZDOQ4nLTk/HHG6bwtpubp6Zb7H1AjzBkz +rQHX6DP4w6IwZY8JB8LS +-----END CERTIFICATE-----`, + expectedSerial: "410468082718062724391949173062901619571168240537", + }, + } + + tempDir := os.TempDir() + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var certBytes []byte + var certPath string + + if test.certContent == "" { + _, certBytes = helpers.GenerateSelfSignedCert(t) + certContents := helpers.Cert{ + Name: test.certName + ".pem", + Type: "CERTIFICATE", + Contents: certBytes, + } + certPath = helpers.WriteCertFiles(t, tempDir, certContents) + } else { + certPath = fmt.Sprintf("%s%c%s", tempDir, os.PathSeparator, test.certName) + err := os.WriteFile(certPath, []byte(test.certContent), 0o600) + require.NoError(t, err) + } + + certFileMeta, certFileMetaErr := files.FileMetaWithCertificate(certPath) + require.NoError(t, certFileMetaErr) + + assert.Equal(t, test.expectedSerial, certFileMeta.GetCertificateMeta().GetSerialNumber()) + }) + } +} + +func TestFileManagerService_deleteTempFiles(t *testing.T) { + tempDir := t.TempDir() + tempFile := path.Join(tempDir, "/etc/nginx/nginx.conf") + + err := os.MkdirAll(path.Dir(tempFile), 0o755) + require.NoError(t, err) + + _, err = os.Create(tempFile) + require.NoError(t, err) + + fileManagerService := FileManagerService{ + fileActions: map[string]*model.FileCache{ + "/etc/nginx/nginx.conf": { + File: &mpi.File{ + FileMeta: &mpi.FileMeta{ + Name: "/etc/nginx/nginx.conf", + }, + }, + Action: model.Update, + }, + "/etc/nginx/test.conf": { + File: &mpi.File{ + FileMeta: &mpi.FileMeta{ + Name: "/etc/nginx/test.conf", + }, + }, + Action: model.Add, + }, + }, + } + + fileManagerService.deleteTempFiles(t.Context(), tempDir) + + assert.NoFileExists(t, tempFile) +} + +func TestFileManagerService_createTempConfigDirectory(t *testing.T) { + agentConfig := types.AgentConfig() + agentConfig.LibDir = t.TempDir() + + fileManagerService := FileManagerService{ + agentConfig: agentConfig, + } + + dir, err := fileManagerService.createTempConfigDirectory(t.Context()) + assert.NotEmpty(t, dir) + require.NoError(t, err) + + // Test for unknown directory path + agentConfig.LibDir = "/unknown/" + + dir, err = fileManagerService.createTempConfigDirectory(t.Context()) + assert.Empty(t, dir) + require.Error(t, err) +} From ca108fe350f8b5f68a8dd30323a54c40d2d6e043 Mon Sep 17 00:00:00 2001 From: spencerugbo <102359791+spencerugbo@users.noreply.github.com> Date: Wed, 1 Oct 2025 14:17:08 +0100 Subject: [PATCH 07/27] test code coverage enforcement --- internal/file/file_manager_service_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/file/file_manager_service_test.go b/internal/file/file_manager_service_test.go index a8470c293..616dd6a4f 100644 --- a/internal/file/file_manager_service_test.go +++ b/internal/file/file_manager_service_test.go @@ -29,7 +29,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) - +/* func TestFileManagerService_ConfigApply_Add(t *testing.T) { ctx := context.Background() tempDir := t.TempDir() @@ -74,7 +74,7 @@ func TestFileManagerService_ConfigApply_Add(t *testing.T) { assert.True(t, fileManagerService.rollbackManifest) } -/* + func TestFileManagerService_ConfigApply_Add_LargeFile(t *testing.T) { ctx := context.Background() tempDir := t.TempDir() @@ -123,7 +123,7 @@ func TestFileManagerService_ConfigApply_Add(t *testing.T) { assert.Equal(t, 53, int(fakeServerStreamingClient.currentChunkID)) assert.True(t, fileManagerService.rollbackManifest) } -*/ + func TestFileManagerService_ConfigApply_Update(t *testing.T) { ctx := context.Background() @@ -814,6 +814,7 @@ func TestFileManagerService_UpdateManifestFile(t *testing.T) { }) } } +*/ func TestFileManagerService_fileActions(t *testing.T) { ctx := context.Background() From a587d43de7ebfbd3189947c080be6cae77d79a61 Mon Sep 17 00:00:00 2001 From: spencerugbo <102359791+spencerugbo@users.noreply.github.com> Date: Wed, 1 Oct 2025 14:27:18 +0100 Subject: [PATCH 08/27] resolve lint issues --- internal/file/file_manager_service_test.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/internal/file/file_manager_service_test.go b/internal/file/file_manager_service_test.go index 616dd6a4f..5338c091d 100644 --- a/internal/file/file_manager_service_test.go +++ b/internal/file/file_manager_service_test.go @@ -7,8 +7,6 @@ package file import ( "context" - "encoding/json" - "errors" "fmt" "os" "path" @@ -24,7 +22,6 @@ import ( mpi "github.com/nginx/agent/v3/api/grpc/mpi/v1" "github.com/nginx/agent/v3/api/grpc/mpi/v1/v1fakes" "github.com/nginx/agent/v3/test/helpers" - "github.com/nginx/agent/v3/test/protos" "github.com/nginx/agent/v3/test/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" From 6e87b0f87dd68a88c4fc15e1a03cb0699e145b20 Mon Sep 17 00:00:00 2001 From: Spencer Ugbo Date: Wed, 1 Oct 2025 14:36:26 +0100 Subject: [PATCH 09/27] resolve remaining lint issues --- internal/file/file_manager_service_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/file/file_manager_service_test.go b/internal/file/file_manager_service_test.go index 5338c091d..3cccd0843 100644 --- a/internal/file/file_manager_service_test.go +++ b/internal/file/file_manager_service_test.go @@ -26,6 +26,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) + /* func TestFileManagerService_ConfigApply_Add(t *testing.T) { ctx := context.Background() From d40b1ca20375f758154adfc2f1c21f80f2b22260 Mon Sep 17 00:00:00 2001 From: Spencer Ugbo Date: Wed, 1 Oct 2025 15:58:09 +0100 Subject: [PATCH 10/27] test with config file in .github directory --- .github/codecov.yml | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/codecov.yml diff --git a/.github/codecov.yml b/.github/codecov.yml new file mode 100644 index 000000000..f1d061cfc --- /dev/null +++ b/.github/codecov.yml @@ -0,0 +1,41 @@ +# Codecov configuration file +# This file configures code coverage reporting and requirements for the project +coverage: + + # Coverage status configuration + status: + + # Project-level coverage settings + project: + + # Default status check configuration + default: + + # The minimum required coverage value for the project + target: 80% + + # The allowed coverage decrease before failing the status check + threshold: 0% + + # Patch-level coverage settings + patch: + + default: + + target: 80% + threshold: 0% + +# Ignore files or packages matching their paths +ignore: + - '\.pb\.go$' # Excludes all protobuf generated files + - '\.gen\.go' # Excludes generated files + - '^fake_.*\.go' # Excludes fakes + - '^test/.*$' + - 'app.go' # app.go and main.go should be tested by integration tests. + - 'main.go' + # ignore metadata generated files + - 'metadata/generated_.*\.go' + # ignore wrappers around gopsutil + - 'internal/datasource/host' + - 'internal/watcher/process' + - 'pkg/nginxprocess' From 7a8105b3e737805d74a0af703519106166576641 Mon Sep 17 00:00:00 2001 From: Spencer Ugbo Date: Thu, 2 Oct 2025 13:05:41 +0100 Subject: [PATCH 11/27] add coverge checks for each commit --- .github/codecov.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/codecov.yml b/.github/codecov.yml index f1d061cfc..920aa891c 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -17,6 +17,12 @@ coverage: # The allowed coverage decrease before failing the status check threshold: 0% + # Code coverage check behaviour if the CI fails + if_ci_failed: error + + # Whether to run coverage checks only on pull requests + only_pulls: false + # Patch-level coverage settings patch: @@ -24,6 +30,8 @@ coverage: target: 80% threshold: 0% + if_ci_failed: error + only_pulls: false # Ignore files or packages matching their paths ignore: From e663f0bcb077b59fe292a67ef4e8c8352a1ac6e4 Mon Sep 17 00:00:00 2001 From: Spencer Ugbo Date: Thu, 2 Oct 2025 13:31:36 +0100 Subject: [PATCH 12/27] test code coverage below threshold --- internal/file/file_manager_service.go | 5 + internal/file/file_manager_service_test.go | 1206 -------------------- internal/file/file_operator_test.go | 0 internal/file/file_plugin_test.go | 0 4 files changed, 5 insertions(+), 1206 deletions(-) delete mode 100644 internal/file/file_manager_service_test.go delete mode 100644 internal/file/file_operator_test.go delete mode 100644 internal/file/file_plugin_test.go diff --git a/internal/file/file_manager_service.go b/internal/file/file_manager_service.go index fd1602e49..351e94c6c 100644 --- a/internal/file/file_manager_service.go +++ b/internal/file/file_manager_service.go @@ -167,6 +167,11 @@ func (fms *FileManagerService) ConfigApply(ctx context.Context, return model.Error, allowedErr } + permissionErr := fms.validateAndUpdateFilePermissions(ctx, fileOverview.GetFiles()) + if permissionErr != nil { + return model.RollbackRequired, permissionErr + } + diffFiles, compareErr := fms.DetermineFileActions( ctx, fms.currentFilesOnDisk, diff --git a/internal/file/file_manager_service_test.go b/internal/file/file_manager_service_test.go deleted file mode 100644 index 80c2d9e2f..000000000 --- a/internal/file/file_manager_service_test.go +++ /dev/null @@ -1,1206 +0,0 @@ -// 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 file - -import ( - "context" - "fmt" - "os" - "path" - "path/filepath" - "sync" - "testing" - - "github.com/nginx/agent/v3/internal/model" - - "github.com/nginx/agent/v3/pkg/files" - "google.golang.org/protobuf/types/known/timestamppb" - - mpi "github.com/nginx/agent/v3/api/grpc/mpi/v1" - "github.com/nginx/agent/v3/api/grpc/mpi/v1/v1fakes" - "github.com/nginx/agent/v3/test/helpers" - "github.com/nginx/agent/v3/test/types" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -/* -func TestFileManagerService_ConfigApply_Add(t *testing.T) { - ctx := context.Background() - tempDir := t.TempDir() - - filePath := filepath.Join(tempDir, "nginx.conf") - - fileContent := []byte("location /test {\n return 200 \"Test location\\n\";\n}") - fileHash := files.GenerateHash(fileContent) - defer helpers.RemoveFileWithErrorCheck(t, filePath) - - overview := protos.FileOverview(filePath, fileHash) - - manifestDirPath := tempDir - manifestFilePath := filepath.Join(manifestDirPath, "manifest.json") - helpers.CreateFileWithErrorCheck(t, manifestDirPath, "manifest.json") - - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - fakeFileServiceClient.GetOverviewReturns(&mpi.GetOverviewResponse{ - Overview: overview, - }, nil) - fakeFileServiceClient.GetFileReturns(&mpi.GetFileResponse{ - Contents: &mpi.FileContents{ - Contents: fileContent, - }, - }, nil) - agentConfig := types.AgentConfig() - agentConfig.AllowedDirectories = []string{tempDir} - - fileManagerService := NewFileManagerService(fakeFileServiceClient, agentConfig, &sync.RWMutex{}) - fileManagerService.configPath = filepath.Dir(filePath) - fileManagerService.agentConfig.LibDir = manifestDirPath - fileManagerService.manifestFilePath = manifestFilePath - - request := protos.CreateConfigApplyRequest(overview) - writeStatus, err := fileManagerService.ConfigApply(ctx, request) - require.NoError(t, err) - assert.Equal(t, model.OK, writeStatus) - data, readErr := os.ReadFile(filePath) - require.NoError(t, readErr) - assert.Equal(t, fileContent, data) - assert.Equal(t, fileManagerService.fileActions[filePath].File, overview.GetFiles()[0]) - assert.Equal(t, 1, fakeFileServiceClient.GetFileCallCount()) - assert.True(t, fileManagerService.rollbackManifest) -} - -func TestFileManagerService_ConfigApply_Add_LargeFile(t *testing.T) { - ctx := context.Background() - tempDir := t.TempDir() - - filePath := filepath.Join(tempDir, "nginx.conf") - fileContent := []byte("location /test {\n return 200 \"Test location\\n\";\n}") - fileHash := files.GenerateHash(fileContent) - defer helpers.RemoveFileWithErrorCheck(t, filePath) - - overview := protos.FileOverviewLargeFile(filePath, fileHash) - - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - fakeFileServiceClient.GetOverviewReturns(&mpi.GetOverviewResponse{ - Overview: overview, - }, nil) - - fakeServerStreamingClient := &FakeServerStreamingClient{ - chunks: make(map[uint32][]byte), - currentChunkID: 0, - fileName: filePath, - } - - for i := range fileContent { - fakeServerStreamingClient.chunks[uint32(i)] = []byte{fileContent[i]} - } - - manifestDirPath := tempDir - manifestFilePath := filepath.Join(manifestDirPath, "manifest.json") - - fakeFileServiceClient.GetFileStreamReturns(fakeServerStreamingClient, nil) - agentConfig := types.AgentConfig() - agentConfig.AllowedDirectories = []string{tempDir} - fileManagerService := NewFileManagerService(fakeFileServiceClient, agentConfig, &sync.RWMutex{}) - fileManagerService.agentConfig.LibDir = manifestDirPath - fileManagerService.configPath = filepath.Dir(filePath) - fileManagerService.manifestFilePath = manifestFilePath - - request := protos.CreateConfigApplyRequest(overview) - writeStatus, err := fileManagerService.ConfigApply(ctx, request) - require.NoError(t, err) - assert.Equal(t, model.OK, writeStatus) - data, readErr := os.ReadFile(filePath) - require.NoError(t, readErr) - assert.Equal(t, fileContent, data) - assert.Equal(t, fileManagerService.fileActions[filePath].File, overview.GetFiles()[0]) - assert.Equal(t, 0, fakeFileServiceClient.GetFileCallCount()) - assert.Equal(t, 53, int(fakeServerStreamingClient.currentChunkID)) - assert.True(t, fileManagerService.rollbackManifest) -} - -func TestFileManagerService_ConfigApply_Update(t *testing.T) { - ctx := context.Background() - tempDir := t.TempDir() - - fileContent := []byte("location /test {\n return 200 \"Test location\\n\";\n}") - previousFileContent := []byte("some test data") - previousFileHash := files.GenerateHash(previousFileContent) - fileHash := files.GenerateHash(fileContent) - tempFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx.conf") - _, writeErr := tempFile.Write(previousFileContent) - require.NoError(t, writeErr) - defer helpers.RemoveFileWithErrorCheck(t, tempFile.Name()) - - filesOnDisk := map[string]*mpi.File{ - tempFile.Name(): { - FileMeta: &mpi.FileMeta{ - Name: tempFile.Name(), - Hash: previousFileHash, - ModifiedTime: timestamppb.Now(), - Permissions: "0640", - Size: 0, - }, - }, - } - - manifestDirPath := tempDir - manifestFilePath := manifestDirPath + "/manifest.json" - helpers.CreateFileWithErrorCheck(t, manifestDirPath, "manifest.json") - - overview := protos.FileOverview(tempFile.Name(), fileHash) - - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - fakeFileServiceClient.GetOverviewReturns(&mpi.GetOverviewResponse{ - Overview: overview, - }, nil) - fakeFileServiceClient.GetFileReturns(&mpi.GetFileResponse{ - Contents: &mpi.FileContents{ - Contents: fileContent, - }, - }, nil) - agentConfig := types.AgentConfig() - agentConfig.AllowedDirectories = []string{tempDir} - - fileManagerService := NewFileManagerService(fakeFileServiceClient, agentConfig, &sync.RWMutex{}) - fileManagerService.agentConfig.LibDir = manifestDirPath - fileManagerService.configPath = filepath.Dir(tempFile.Name()) - fileManagerService.manifestFilePath = manifestFilePath - err := fileManagerService.UpdateCurrentFilesOnDisk(ctx, filesOnDisk, false) - require.NoError(t, err) - - request := protos.CreateConfigApplyRequest(overview) - writeStatus, err := fileManagerService.ConfigApply(ctx, request) - require.NoError(t, err) - assert.Equal(t, model.OK, writeStatus) - data, readErr := os.ReadFile(tempFile.Name()) - require.NoError(t, readErr) - assert.Equal(t, fileContent, data) - - content, err := os.ReadFile(fileManagerService.tempRollbackDir + tempFile.Name()) - require.NoError(t, err) - assert.Equal(t, previousFileContent, content) - - assert.Equal(t, fileManagerService.fileActions[tempFile.Name()].File, overview.GetFiles()[0]) - assert.True(t, fileManagerService.rollbackManifest) -} - -func TestFileManagerService_ConfigApply_Delete(t *testing.T) { - ctx := context.Background() - tempDir := t.TempDir() - - fileContent := []byte("location /test {\n return 200 \"Test location\\n\";\n}") - tempFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx.conf") - _, writeErr := tempFile.Write(fileContent) - require.NoError(t, writeErr) - - tempFile2 := helpers.CreateFileWithErrorCheck(t, tempDir, "test.conf") - overview := protos.FileOverview(tempFile2.Name(), files.GenerateHash(fileContent)) - - filesOnDisk := map[string]*mpi.File{ - tempFile.Name(): { - FileMeta: &mpi.FileMeta{ - Name: tempFile.Name(), - Hash: files.GenerateHash(fileContent), - ModifiedTime: timestamppb.Now(), - Permissions: "0640", - Size: 0, - }, - }, - } - - manifestDirPath := tempDir - manifestFilePath := manifestDirPath + "/manifest.json" - helpers.CreateFileWithErrorCheck(t, manifestDirPath, "manifest.json") - - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - agentConfig := types.AgentConfig() - agentConfig.AllowedDirectories = []string{tempDir} - - fileManagerService := NewFileManagerService(fakeFileServiceClient, agentConfig, &sync.RWMutex{}) - fileManagerService.agentConfig.LibDir = manifestDirPath - fileManagerService.manifestFilePath = manifestFilePath - fileManagerService.configPath = filepath.Dir(tempFile.Name()) - err := fileManagerService.UpdateCurrentFilesOnDisk(ctx, filesOnDisk, false) - require.NoError(t, err) - - request := protos.CreateConfigApplyRequest(overview) - - fakeFileServiceClient.GetOverviewReturns(&mpi.GetOverviewResponse{ - Overview: overview, - }, nil) - fakeFileServiceClient.GetFileReturns(&mpi.GetFileResponse{ - Contents: &mpi.FileContents{ - Contents: fileContent, - }, - }, nil) - - writeStatus, err := fileManagerService.ConfigApply(ctx, request) - require.NoError(t, err) - assert.NoFileExists(t, tempFile.Name()) - - content, err := os.ReadFile(fileManagerService.tempRollbackDir + tempFile.Name()) - require.NoError(t, err) - assert.Equal(t, fileContent, content) - - assert.Equal(t, - fileManagerService.fileActions[tempFile.Name()].File.GetFileMeta().GetName(), - filesOnDisk[tempFile.Name()].GetFileMeta().GetName(), - ) - assert.Equal(t, - fileManagerService.fileActions[tempFile.Name()].File.GetFileMeta().GetHash(), - filesOnDisk[tempFile.Name()].GetFileMeta().GetHash(), - ) - assert.Equal(t, - fileManagerService.fileActions[tempFile.Name()].File.GetFileMeta().GetSize(), - filesOnDisk[tempFile.Name()].GetFileMeta().GetSize(), - ) - assert.Equal(t, model.OK, writeStatus) - assert.True(t, fileManagerService.rollbackManifest) -} - -func TestFileManagerService_ConfigApply_Failed(t *testing.T) { - ctx := t.Context() - tempDir := t.TempDir() - - filePath := filepath.Join(tempDir, "nginx.conf") - fileContent := []byte("# this is going to fail") - fileHash := files.GenerateHash(fileContent) - - overview := protos.FileOverview(filePath, fileHash) - - manifestDirPath := tempDir - manifestFilePath := manifestDirPath + "/manifest.json" - helpers.CreateFileWithErrorCheck(t, manifestDirPath, "manifest.json") - - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - fakeFileServiceClient.GetOverviewReturns(&mpi.GetOverviewResponse{ - Overview: overview, - }, nil) - fakeFileServiceClient.GetFileReturns(nil, errors.New("file not found")) - - agentConfig := types.AgentConfig() - agentConfig.AllowedDirectories = []string{tempDir} - - fileManagerService := NewFileManagerService(fakeFileServiceClient, agentConfig, &sync.RWMutex{}) - fileManagerService.agentConfig.LibDir = manifestDirPath - fileManagerService.configPath = filepath.Dir(filePath) - fileManagerService.manifestFilePath = manifestFilePath - - request := protos.CreateConfigApplyRequest(overview) - writeStatus, err := fileManagerService.ConfigApply(ctx, request) - - require.Error(t, err) - assert.Equal(t, model.RollbackRequired, writeStatus) - assert.False(t, fileManagerService.rollbackManifest) -} - -func TestFileManagerService_checkAllowedDirectory(t *testing.T) { - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - fileManagerService := NewFileManagerService(fakeFileServiceClient, types.AgentConfig(), &sync.RWMutex{}) - - allowedFiles := []*mpi.File{ - { - FileMeta: &mpi.FileMeta{ - Name: "/tmp/local/etc/nginx/allowedDirPath", - Hash: "", - ModifiedTime: nil, - Permissions: "", - Size: 0, - }, - }, - } - - notAllowed := []*mpi.File{ - { - FileMeta: &mpi.FileMeta{ - Name: "/not/allowed/dir/path", - Hash: "", - ModifiedTime: nil, - Permissions: "", - Size: 0, - }, - }, - } - - err := fileManagerService.checkAllowedDirectory(allowedFiles) - require.NoError(t, err) - err = fileManagerService.checkAllowedDirectory(notAllowed) - require.Error(t, err) -} - -func TestFileManagerService_validateAndUpdateFilePermissions(t *testing.T) { - ctx := context.Background() - fileManagerService := NewFileManagerService(nil, types.AgentConfig(), &sync.RWMutex{}) - - testFiles := []*mpi.File{ - { - FileMeta: &mpi.FileMeta{ - Name: "exec.conf", - Permissions: "0700", - }, - }, - { - FileMeta: &mpi.FileMeta{ - Name: "normal.conf", - Permissions: "0620", - }, - }, - } - - err := fileManagerService.validateAndUpdateFilePermissions(ctx, testFiles) - require.NoError(t, err) - assert.Equal(t, "0600", testFiles[0].GetFileMeta().GetPermissions()) - assert.Equal(t, "0620", testFiles[1].GetFileMeta().GetPermissions()) -} - -func TestFileManagerService_areExecuteFilePermissionsSet(t *testing.T) { - fileManagerService := NewFileManagerService(nil, types.AgentConfig(), &sync.RWMutex{}) - - tests := []struct { - name string - permissions string - expectBool bool - }{ - { - name: "Test 1: File with read and write permissions for owner", - permissions: "0600", - expectBool: false, - }, - { - name: "Test 2: File with read/write and execute permissions for owner", - permissions: "0700", - expectBool: true, - }, - { - name: "Test 3: File with read/write and execute permissions for owner and group", - permissions: "0770", - expectBool: true, - }, - { - name: "Test 4: File with read and execute permissions for everyone", - permissions: "0555", - expectBool: true, - }, - { - name: "Test 5: File with malformed permissions", - permissions: "abcde", - expectBool: false, - }, - { - name: "Test 6: File with invalid permissions", - permissions: "000070", - expectBool: false, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - file := &mpi.File{ - FileMeta: &mpi.FileMeta{ - Name: "test.conf", - Permissions: test.permissions, - }, - } - - got := fileManagerService.areExecuteFilePermissionsSet(file) - assert.Equal(t, test.expectBool, got) - }) - } -} - -func TestFileManagerService_removeExecuteFilePermissions(t *testing.T) { - fileManagerService := NewFileManagerService(nil, types.AgentConfig(), &sync.RWMutex{}) - - tests := []struct { - name string - permissions string - errorMsg string - expectPermissions string - expectError bool - }{ - { - name: "Test 1: File with execute permissions for owner and others", - permissions: "0703", - expectError: false, - expectPermissions: "0602", - }, - { - name: "Test 2: File with malformed permissions", - permissions: "abcde", - expectError: true, - errorMsg: "falied to parse file permissions", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - file := &mpi.File{ - FileMeta: &mpi.FileMeta{ - Name: "test.conf", - Permissions: test.permissions, - }, - } - - parseErr := fileManagerService.removeExecuteFilePermissions(t.Context(), file) - - if test.expectError { - require.Error(t, parseErr) - assert.Contains(t, parseErr.Error(), test.errorMsg) - } else { - require.NoError(t, parseErr) - assert.Equal(t, test.expectPermissions, file.GetFileMeta().GetPermissions()) - } - }) - } -} - -//nolint:usetesting // need to use MkDirTemp instead of t.tempDir for rollback as t.tempDir does not accept a pattern -func TestFileManagerService_ClearCache(t *testing.T) { - tempDir := t.TempDir() - rollbackDir, err := os.MkdirTemp(tempDir, "rollback") - require.NoError(t, err) - configDir, err := os.MkdirTemp(tempDir, "config") - require.NoError(t, err) - - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - fileManagerService := NewFileManagerService(fakeFileServiceClient, types.AgentConfig(), &sync.RWMutex{}) - fileManagerService.tempConfigDir = configDir - fileManagerService.tempRollbackDir = rollbackDir - - filesCache := map[string]*model.FileCache{ - "file/path/test.conf": { - File: &mpi.File{ - FileMeta: &mpi.FileMeta{ - Name: "file/path/test.conf", - Hash: "", - ModifiedTime: nil, - Permissions: "", - Size: 0, - }, - }, - }, - } - - fileManagerService.fileActions = filesCache - assert.NotEmpty(t, fileManagerService.fileActions) - - fileManagerService.ClearCache() - - assert.Empty(t, fileManagerService.fileActions) - - _, statErr := os.Stat(fileManagerService.tempRollbackDir) - assert.True(t, os.IsNotExist(statErr)) - _, statConfigErr := os.Stat(fileManagerService.tempConfigDir) - assert.True(t, os.IsNotExist(statConfigErr)) -} - -//nolint:usetesting // need to use MkDirTemp instead of t.tempDir for rollback as t.tempDir does not accept a pattern -func TestFileManagerService_Rollback(t *testing.T) { - ctx := context.Background() - tempDir := t.TempDir() - - rollbackDir, mkdirErr := os.MkdirTemp(tempDir, "rollback") - require.NoError(t, mkdirErr) - - deleteFilePath := filepath.Join(tempDir, "nginx_delete.conf") - - newFileContent := []byte("location /test {\n return 200 \"This config needs to be rolled back\\n\";\n}") - oldFileContent := []byte("location /test {\n return 200 \"This is the saved config\\n\";\n}") - fileHash := files.GenerateHash(newFileContent) - - addFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_add.conf") - _, writeErr := addFile.Write(newFileContent) - require.NoError(t, writeErr) - - updateFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_update.conf") - _, writeErr = updateFile.Write(newFileContent) - require.NoError(t, writeErr) - - helpers.CreateDirWithErrorCheck(t, rollbackDir+tempDir) - - tempAddFile, createErr := os.Create(rollbackDir + addFile.Name()) - require.NoError(t, createErr) - _, writeErr = tempAddFile.Write(oldFileContent) - require.NoError(t, writeErr) - - tempUpdateFile, createErr := os.Create(rollbackDir + updateFile.Name()) - require.NoError(t, createErr) - _, writeErr = tempUpdateFile.Write(oldFileContent) - require.NoError(t, writeErr) - t.Log(tempUpdateFile.Name()) - - tempDeleteFile, createErr := os.Create(rollbackDir + tempDir + "/nginx_delete.conf") - require.NoError(t, createErr) - _, writeErr = tempDeleteFile.Write(oldFileContent) - require.NoError(t, writeErr) - t.Log(tempDeleteFile.Name()) - - manifestDirPath := tempDir - manifestFilePath := manifestDirPath + "/manifest.json" - helpers.CreateFileWithErrorCheck(t, manifestDirPath, "manifest.json") - - filesCache := map[string]*model.FileCache{ - addFile.Name(): { - File: &mpi.File{ - FileMeta: &mpi.FileMeta{ - Name: addFile.Name(), - Hash: fileHash, - ModifiedTime: timestamppb.Now(), - Permissions: "0777", - Size: 0, - }, - Unmanaged: false, - }, - Action: model.Add, - }, - updateFile.Name(): { - File: &mpi.File{ - FileMeta: &mpi.FileMeta{ - Name: updateFile.Name(), - Hash: fileHash, - ModifiedTime: timestamppb.Now(), - Permissions: "0777", - Size: 0, - }, - Unmanaged: false, - }, - Action: model.Update, - }, - deleteFilePath: { - File: &mpi.File{ - FileMeta: &mpi.FileMeta{ - Name: deleteFilePath, - Hash: "", - ModifiedTime: timestamppb.Now(), - Permissions: "0777", - Size: 0, - }, - Unmanaged: false, - }, - Action: model.Delete, - }, - "unspecified/file/test.conf": { - File: &mpi.File{ - FileMeta: &mpi.FileMeta{ - Name: "unspecified/file/test.conf", - Hash: "", - ModifiedTime: timestamppb.Now(), - Permissions: "0777", - Size: 0, - }, - Unmanaged: false, - }, - }, - } - - instanceID := protos.NginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId() - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - fileManagerService := NewFileManagerService(fakeFileServiceClient, types.AgentConfig(), &sync.RWMutex{}) - fileManagerService.fileActions = filesCache - fileManagerService.agentConfig.LibDir = manifestDirPath - fileManagerService.tempRollbackDir = rollbackDir - fileManagerService.configPath = filepath.Dir(updateFile.Name()) - fileManagerService.manifestFilePath = manifestFilePath - - err := fileManagerService.Rollback(ctx, instanceID) - require.NoError(t, err) - - assert.NoFileExists(t, addFile.Name()) - assert.FileExists(t, deleteFilePath) - updateData, readUpdateErr := os.ReadFile(updateFile.Name()) - require.NoError(t, readUpdateErr) - assert.Equal(t, oldFileContent, updateData) - - deleteData, readDeleteErr := os.ReadFile(deleteFilePath) - require.NoError(t, readDeleteErr) - assert.Equal(t, oldFileContent, deleteData) - - defer helpers.RemoveFileWithErrorCheck(t, updateFile.Name()) - defer helpers.RemoveFileWithErrorCheck(t, deleteFilePath) -} - -func TestFileManagerService_DetermineFileActions(t *testing.T) { - ctx := context.Background() - tempDir := filepath.Clean(os.TempDir()) - - deleteTestFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_delete.conf") - defer helpers.RemoveFileWithErrorCheck(t, deleteTestFile.Name()) - fileContent, readErr := os.ReadFile("../../test/config/nginx/nginx.conf") - require.NoError(t, readErr) - err := os.WriteFile(deleteTestFile.Name(), fileContent, 0o600) - require.NoError(t, err) - - updateTestFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_update.conf") - defer helpers.RemoveFileWithErrorCheck(t, updateTestFile.Name()) - updatedFileContent := []byte("test update file") - updateErr := os.WriteFile(updateTestFile.Name(), updatedFileContent, 0o600) - require.NoError(t, updateErr) - - addTestFileName := tempDir + "nginx_add.conf" - - unmanagedFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_unmanaged.conf") - defer helpers.RemoveFileWithErrorCheck(t, unmanagedFile.Name()) - unmanagedFileContent := []byte("test unmanaged file") - unmanagedErr := os.WriteFile(unmanagedFile.Name(), unmanagedFileContent, 0o600) - require.NoError(t, unmanagedErr) - - addTestFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_add.conf") - defer helpers.RemoveFileWithErrorCheck(t, addTestFile.Name()) - addFileContent := []byte("test add file") - addErr := os.WriteFile(addTestFile.Name(), addFileContent, 0o600) - require.NoError(t, addErr) - - tests := []struct { - expectedError error - modifiedFiles map[string]*model.FileCache - currentFiles map[string]*mpi.File - expectedCache map[string]*model.FileCache - expectedContent map[string][]byte - name string - allowedDirs []string - }{ - { - name: "Test 1: Add, Update & Delete Files", - allowedDirs: []string{tempDir}, - modifiedFiles: map[string]*model.FileCache{ - addTestFileName: { - File: &mpi.File{ - FileMeta: protos.FileMeta(addTestFileName, files.GenerateHash(fileContent)), - Unmanaged: false, - }, - }, - updateTestFile.Name(): { - File: &mpi.File{ - FileMeta: protos.FileMeta(updateTestFile.Name(), files.GenerateHash(updatedFileContent)), - Unmanaged: false, - }, - }, - unmanagedFile.Name(): { - File: &mpi.File{ - FileMeta: protos.FileMeta(unmanagedFile.Name(), files.GenerateHash(unmanagedFileContent)), - Unmanaged: true, - }, - }, - }, - currentFiles: map[string]*mpi.File{ - deleteTestFile.Name(): { - FileMeta: protos.FileMeta(deleteTestFile.Name(), files.GenerateHash(fileContent)), - }, - updateTestFile.Name(): { - FileMeta: protos.FileMeta(updateTestFile.Name(), files.GenerateHash(fileContent)), - }, - unmanagedFile.Name(): { - FileMeta: protos.FileMeta(unmanagedFile.Name(), files.GenerateHash(fileContent)), - Unmanaged: true, - }, - }, - expectedCache: map[string]*model.FileCache{ - deleteTestFile.Name(): { - File: &mpi.File{ - FileMeta: protos.ManifestFileMeta(deleteTestFile.Name(), files.GenerateHash(fileContent)), - Unmanaged: false, - }, - Action: model.Delete, - }, - updateTestFile.Name(): { - File: &mpi.File{ - FileMeta: protos.FileMeta(updateTestFile.Name(), files.GenerateHash(updatedFileContent)), - Unmanaged: false, - }, - Action: model.Update, - }, - addTestFileName: { - File: &mpi.File{ - FileMeta: protos.FileMeta(addTestFileName, files.GenerateHash(fileContent)), - Unmanaged: false, - }, - Action: model.Add, - }, - }, - expectedContent: map[string][]byte{ - deleteTestFile.Name(): fileContent, - updateTestFile.Name(): updatedFileContent, - }, - expectedError: nil, - }, - { - name: "Test 2: Files same as on disk", - allowedDirs: []string{tempDir}, - modifiedFiles: map[string]*model.FileCache{ - addTestFile.Name(): { - File: &mpi.File{ - FileMeta: protos.FileMeta(addTestFile.Name(), files.GenerateHash(fileContent)), - }, - }, - updateTestFile.Name(): { - File: &mpi.File{ - FileMeta: protos.FileMeta(updateTestFile.Name(), files.GenerateHash(fileContent)), - }, - }, - deleteTestFile.Name(): { - File: &mpi.File{ - FileMeta: protos.FileMeta(deleteTestFile.Name(), files.GenerateHash(fileContent)), - }, - }, - }, - currentFiles: map[string]*mpi.File{ - deleteTestFile.Name(): { - FileMeta: protos.FileMeta(deleteTestFile.Name(), files.GenerateHash(fileContent)), - }, - updateTestFile.Name(): { - FileMeta: protos.FileMeta(updateTestFile.Name(), files.GenerateHash(fileContent)), - }, - addTestFile.Name(): { - FileMeta: protos.FileMeta(addTestFile.Name(), files.GenerateHash(fileContent)), - }, - }, - expectedCache: make(map[string]*model.FileCache), - expectedContent: make(map[string][]byte), - expectedError: nil, - }, - { - name: "Test 3: File being deleted already doesn't exist", - allowedDirs: []string{tempDir, "/unknown"}, - modifiedFiles: make(map[string]*model.FileCache), - currentFiles: map[string]*mpi.File{ - "/unknown/file.conf": { - FileMeta: protos.FileMeta("/unknown/file.conf", files.GenerateHash(fileContent)), - }, - }, - expectedCache: make(map[string]*model.FileCache), - expectedContent: make(map[string][]byte), - expectedError: nil, - }, - } - - for _, test := range tests { - t.Run(test.name, func(tt *testing.T) { - // Delete manifest file if it already exists - manifestFile := CreateTestManifestFile(t, tempDir, test.currentFiles, true) - defer manifestFile.Close() - manifestDirPath := tempDir - manifestFilePath := manifestFile.Name() - - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - fileManagerService := NewFileManagerService(fakeFileServiceClient, types.AgentConfig(), &sync.RWMutex{}) - fileManagerService.agentConfig.AllowedDirectories = test.allowedDirs - fileManagerService.agentConfig.LibDir = manifestDirPath - fileManagerService.manifestFilePath = manifestFilePath - fileManagerService.configPath = filepath.Dir(updateTestFile.Name()) - - require.NoError(tt, err) - - diff, fileActionErr := fileManagerService.DetermineFileActions( - ctx, - test.currentFiles, - test.modifiedFiles, - ) - require.NoError(tt, fileActionErr) - assert.Equal(tt, test.expectedCache, diff) - }) - } -} - -func CreateTestManifestFile(t testing.TB, tempDir string, currentFiles map[string]*mpi.File, refrenced bool) *os.File { - t.Helper() - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - fileManagerService := NewFileManagerService(fakeFileServiceClient, types.AgentConfig(), &sync.RWMutex{}) - manifestFiles := fileManagerService.convertToManifestFileMap(currentFiles, refrenced) - manifestJSON, err := json.MarshalIndent(manifestFiles, "", " ") - require.NoError(t, err) - file, err := os.CreateTemp(tempDir, "manifest.json") - require.NoError(t, err) - - _, err = file.Write(manifestJSON) - require.NoError(t, err) - - return file -} - -func TestFileManagerService_UpdateManifestFile(t *testing.T) { - ctx := t.Context() - fileContent := []byte("location /test {\n return 200 \"Test location\\n\";\n}") - fileHash := files.GenerateHash(fileContent) - - tests := []struct { - currentFiles map[string]*mpi.File - currentManifestFiles map[string]*model.ManifestFile - expectedFiles map[string]*model.ManifestFile - name string - referenced bool - previousReferenced bool - }{ - { - name: "Test 1: Manifest file empty", - currentFiles: map[string]*mpi.File{ - "/etc/nginx/nginx.conf": { - FileMeta: protos.FileMeta("/etc/nginx/nginx.conf", fileHash), - }, - }, - expectedFiles: map[string]*model.ManifestFile{ - "/etc/nginx/nginx.conf": { - ManifestFileMeta: &model.ManifestFileMeta{ - Name: "/etc/nginx/nginx.conf", - Hash: fileHash, - Size: 0, - Referenced: true, - }, - }, - }, - currentManifestFiles: make(map[string]*model.ManifestFile), - referenced: true, - previousReferenced: true, - }, - { - name: "Test 2: Manifest file populated - unreferenced", - currentFiles: map[string]*mpi.File{ - "/etc/nginx/nginx.conf": { - FileMeta: protos.FileMeta("/etc/nginx/nginx.conf", fileHash), - }, - "/etc/nginx/unref.conf": { - FileMeta: protos.FileMeta("/etc/nginx/unref.conf", fileHash), - }, - }, - expectedFiles: map[string]*model.ManifestFile{ - "/etc/nginx/nginx.conf": { - ManifestFileMeta: &model.ManifestFileMeta{ - Name: "/etc/nginx/nginx.conf", - Hash: fileHash, - Size: 0, - Referenced: false, - }, - }, - "/etc/nginx/unref.conf": { - ManifestFileMeta: &model.ManifestFileMeta{ - Name: "/etc/nginx/unref.conf", - Hash: fileHash, - Size: 0, - Referenced: false, - }, - }, - }, - currentManifestFiles: map[string]*model.ManifestFile{ - "/etc/nginx/nginx.conf": { - ManifestFileMeta: &model.ManifestFileMeta{ - Name: "/etc/nginx/nginx.conf", - Hash: fileHash, - Size: 0, - Referenced: true, - }, - }, - }, - referenced: false, - previousReferenced: true, - }, - { - name: "Test 3: Manifest file populated - referenced", - currentFiles: map[string]*mpi.File{ - "/etc/nginx/nginx.conf": { - FileMeta: protos.FileMeta("/etc/nginx/nginx.conf", fileHash), - }, - "/etc/nginx/test.conf": { - FileMeta: protos.FileMeta("/etc/nginx/test.conf", fileHash), - }, - }, - expectedFiles: map[string]*model.ManifestFile{ - "/etc/nginx/nginx.conf": { - ManifestFileMeta: &model.ManifestFileMeta{ - Name: "/etc/nginx/nginx.conf", - Hash: fileHash, - Size: 0, - Referenced: true, - }, - }, - "/etc/nginx/test.conf": { - ManifestFileMeta: &model.ManifestFileMeta{ - Name: "/etc/nginx/test.conf", - Hash: fileHash, - Size: 0, - Referenced: true, - }, - }, - "/etc/nginx/unref.conf": { - ManifestFileMeta: &model.ManifestFileMeta{ - Name: "/etc/nginx/unref.conf", - Hash: fileHash, - Size: 0, - Referenced: false, - }, - }, - }, - currentManifestFiles: map[string]*model.ManifestFile{ - "/etc/nginx/nginx.conf": { - ManifestFileMeta: &model.ManifestFileMeta{ - Name: "/etc/nginx/nginx.conf", - Hash: fileHash, - Size: 0, - Referenced: false, - }, - }, - "/etc/nginx/unref.conf": { - ManifestFileMeta: &model.ManifestFileMeta{ - Name: "/etc/nginx/unref.conf", - Hash: fileHash, - Size: 0, - Referenced: false, - }, - }, - }, - referenced: true, - previousReferenced: false, - }, - } - - for _, test := range tests { - t.Run(test.name, func(tt *testing.T) { - manifestDirPath := t.TempDir() - file := helpers.CreateFileWithErrorCheck(t, manifestDirPath, "manifest.json") - - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - fileManagerService := NewFileManagerService(fakeFileServiceClient, types.AgentConfig(), &sync.RWMutex{}) - fileManagerService.agentConfig.AllowedDirectories = []string{"manifestDirPath"} - fileManagerService.agentConfig.LibDir = manifestDirPath - fileManagerService.manifestFilePath = file.Name() - - manifestJSON, err := json.MarshalIndent(test.currentManifestFiles, "", " ") - require.NoError(t, err) - - _, err = file.Write(manifestJSON) - require.NoError(t, err) - - updateErr := fileManagerService.UpdateManifestFile(ctx, test.currentFiles, test.referenced) - require.NoError(tt, updateErr) - - manifestFiles, _, manifestErr := fileManagerService.manifestFile() - require.NoError(tt, manifestErr) - assert.Equal(tt, test.expectedFiles, manifestFiles) - }) - } -} -*/ - -func TestFileManagerService_fileActions(t *testing.T) { - ctx := context.Background() - tempDir := t.TempDir() - - addFilePath := filepath.Join(tempDir, "nginx_add.conf") - unspecifiedFilePath := "unspecified/file/test.conf" - - newFileContent := []byte("location /test {\n return 200 \"This config needs to be rolled back\\n\";\n}") - oldFileContent := []byte("location /test {\n return 200 \"This is the saved config\\n\";\n}") - fileHash := files.GenerateHash(newFileContent) - - deleteFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_delete.conf") - _, writeErr := deleteFile.Write(oldFileContent) - require.NoError(t, writeErr) - - updateFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_update.conf") - _, writeErr = updateFile.Write(oldFileContent) - require.NoError(t, writeErr) - - filesCache := map[string]*model.FileCache{ - addFilePath: { - File: &mpi.File{ - FileMeta: &mpi.FileMeta{ - Name: addFilePath, - Hash: fileHash, - ModifiedTime: timestamppb.Now(), - Permissions: "0777", - Size: 0, - }, - }, - Action: model.Add, - }, - updateFile.Name(): { - File: &mpi.File{ - FileMeta: &mpi.FileMeta{ - Name: updateFile.Name(), - Hash: fileHash, - ModifiedTime: timestamppb.Now(), - Permissions: "0777", - Size: 0, - }, - }, - Action: model.Update, - }, - deleteFile.Name(): { - File: &mpi.File{ - FileMeta: &mpi.FileMeta{ - Name: deleteFile.Name(), - Hash: "", - ModifiedTime: timestamppb.Now(), - Permissions: "0777", - Size: 0, - }, - }, - Action: model.Delete, - }, - unspecifiedFilePath: { - File: &mpi.File{ - FileMeta: &mpi.FileMeta{ - Name: unspecifiedFilePath, - Hash: "", - ModifiedTime: timestamppb.Now(), - Permissions: "0777", - Size: 0, - }, - }, - }, - } - - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} - fakeFileServiceClient.GetFileReturns(&mpi.GetFileResponse{ - Contents: &mpi.FileContents{ - Contents: newFileContent, - }, - }, nil) - fileManagerService := NewFileManagerService(fakeFileServiceClient, types.AgentConfig(), &sync.RWMutex{}) - - fileManagerService.fileActions = filesCache - - actionErr := fileManagerService.executeFileActions(ctx) - require.NoError(t, actionErr) - - assert.FileExists(t, addFilePath) - assert.NoFileExists(t, deleteFile.Name()) - assert.NoFileExists(t, unspecifiedFilePath) - updateData, readUpdateErr := os.ReadFile(updateFile.Name()) - require.NoError(t, readUpdateErr) - assert.Equal(t, newFileContent, updateData) - - defer helpers.RemoveFileWithErrorCheck(t, updateFile.Name()) - defer helpers.RemoveFileWithErrorCheck(t, addFilePath) -} - -func TestParseX509Certificates(t *testing.T) { - tests := []struct { - certName string - certContent string - name string - expectedSerial string - }{ - { - name: "Test 1: generated cert", - certName: "public_cert", - certContent: "", - expectedSerial: "123123", - }, - { - name: "Test 2: open ssl cert", - certName: "open_ssl_cert", - certContent: `-----BEGIN CERTIFICATE----- -MIIDazCCAlOgAwIBAgIUR+YGgRHhYwotFyBOvSc1KD9d45kwDQYJKoZIhvcNAQEL -BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM -GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDExMjcxNTM0MDZaFw0yNDEy -MjcxNTM0MDZaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw -HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB -AQUAA4IBDwAwggEKAoIBAQDnDDVGflbZ3dmuQJj+8QuJIQ8lWjVGYhlsFI4AGFTX -9VfYOqJEPyuMRuSj2eN7C/mR4yTJSggnv0kFtjmeGh2keNdmb4R/0CjYWZVl/Na6 -cAfldB8v2+sm0LZ/OD9F9CbnYB95takPOZq3AP5kUA+qlFYzroqXsxJKvZF6dUuI -+kTOn5pWD+eFmueFedOz1aucOvblUJLueVZnvAbIrBoyaulw3f2kjk0J1266nFMb -s72AvjyYbOXbyur3BhPThCaOeqMGggDmFslZ4pBgQFWUeFvmqJMFzf1atKTWlbj7 -Mj+bNKNs4xvUuNhqd/F99Pz2Fe0afKbTHK83hqgSHKbtAgMBAAGjUzBRMB0GA1Ud -DgQWBBQq0Bzde0bl9CFb81LrvFfdWlY7hzAfBgNVHSMEGDAWgBQq0Bzde0bl9CFb -81LrvFfdWlY7hzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAo -8GXvwRa0M0D4x4Lrj2K57FxH4ECNBnAqWlh3Ce9LEioL2CYaQQw6I2/FsnTk8TYY -WgGgXMEyA6OeOXvwxWjSllK9+D2ueTMhNRO0tYMUi0kDJqd9EpmnEcSWIL2G2SNo -BWQjqEoEKFjvrgx6h13AtsFlpdURoVtodrtnUrXp1r4wJvljC2qexoNfslhpbqsT -X/vYrzgKRoKSUWUt1ejKTntrVuaJK4NMxANOTTjIXgxyoV3YcgEmL9KzribCqILi -p79Nno9d+kovtX5VKsJ5FCcPw9mEATgZDOQ4nLTk/HHG6bwtpubp6Zb7H1AjzBkz -rQHX6DP4w6IwZY8JB8LS ------END CERTIFICATE-----`, - expectedSerial: "410468082718062724391949173062901619571168240537", - }, - } - - tempDir := os.TempDir() - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - var certBytes []byte - var certPath string - - if test.certContent == "" { - _, certBytes = helpers.GenerateSelfSignedCert(t) - certContents := helpers.Cert{ - Name: test.certName + ".pem", - Type: "CERTIFICATE", - Contents: certBytes, - } - certPath = helpers.WriteCertFiles(t, tempDir, certContents) - } else { - certPath = fmt.Sprintf("%s%c%s", tempDir, os.PathSeparator, test.certName) - err := os.WriteFile(certPath, []byte(test.certContent), 0o600) - require.NoError(t, err) - } - - certFileMeta, certFileMetaErr := files.FileMetaWithCertificate(certPath) - require.NoError(t, certFileMetaErr) - - assert.Equal(t, test.expectedSerial, certFileMeta.GetCertificateMeta().GetSerialNumber()) - }) - } -} - -func TestFileManagerService_deleteTempFiles(t *testing.T) { - tempDir := t.TempDir() - tempFile := path.Join(tempDir, "/etc/nginx/nginx.conf") - - err := os.MkdirAll(path.Dir(tempFile), 0o755) - require.NoError(t, err) - - _, err = os.Create(tempFile) - require.NoError(t, err) - - fileManagerService := FileManagerService{ - fileActions: map[string]*model.FileCache{ - "/etc/nginx/nginx.conf": { - File: &mpi.File{ - FileMeta: &mpi.FileMeta{ - Name: "/etc/nginx/nginx.conf", - }, - }, - Action: model.Update, - }, - "/etc/nginx/test.conf": { - File: &mpi.File{ - FileMeta: &mpi.FileMeta{ - Name: "/etc/nginx/test.conf", - }, - }, - Action: model.Add, - }, - }, - } - - fileManagerService.deleteTempFiles(t.Context(), tempDir) - - assert.NoFileExists(t, tempFile) -} - -func TestFileManagerService_createTempConfigDirectory(t *testing.T) { - agentConfig := types.AgentConfig() - tempDir := t.TempDir() - configPath := tempDir - - fileManagerService := FileManagerService{ - agentConfig: agentConfig, - configPath: configPath, - } - - dir, err := fileManagerService.createTempConfigDirectory("config") - assert.NotEmpty(t, dir) - require.NoError(t, err) - - // Test for unknown directory path - fileManagerService.configPath = "/unknown/" - - dir, err = fileManagerService.createTempConfigDirectory("config") - assert.Empty(t, dir) - require.Error(t, err) -} diff --git a/internal/file/file_operator_test.go b/internal/file/file_operator_test.go deleted file mode 100644 index e69de29bb..000000000 diff --git a/internal/file/file_plugin_test.go b/internal/file/file_plugin_test.go deleted file mode 100644 index e69de29bb..000000000 From 6056c5aa0545ab211ee0909068628e2dd78da0bb Mon Sep 17 00:00:00 2001 From: Spencer Ugbo Date: Thu, 2 Oct 2025 14:53:32 +0100 Subject: [PATCH 13/27] test codecov conf in root directory --- .codecov.yml | 10 ++++++++- .github/codecov.yml | 49 --------------------------------------------- 2 files changed, 9 insertions(+), 50 deletions(-) delete mode 100644 .github/codecov.yml diff --git a/.codecov.yml b/.codecov.yml index f1d061cfc..8e9de7256 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -12,11 +12,17 @@ coverage: default: # The minimum required coverage value for the project - target: 80% + target: 90% # The allowed coverage decrease before failing the status check threshold: 0% + # Code coverage check behaviour if the CI fails + if_ci_failed: error + + # Whether to run coverage checks only on pull requests + only_pulls: false + # Patch-level coverage settings patch: @@ -24,6 +30,8 @@ coverage: target: 80% threshold: 0% + if_ci_failed: error + only_pulls: false # Ignore files or packages matching their paths ignore: diff --git a/.github/codecov.yml b/.github/codecov.yml deleted file mode 100644 index 920aa891c..000000000 --- a/.github/codecov.yml +++ /dev/null @@ -1,49 +0,0 @@ -# Codecov configuration file -# This file configures code coverage reporting and requirements for the project -coverage: - - # Coverage status configuration - status: - - # Project-level coverage settings - project: - - # Default status check configuration - default: - - # The minimum required coverage value for the project - target: 80% - - # The allowed coverage decrease before failing the status check - threshold: 0% - - # Code coverage check behaviour if the CI fails - if_ci_failed: error - - # Whether to run coverage checks only on pull requests - only_pulls: false - - # Patch-level coverage settings - patch: - - default: - - target: 80% - threshold: 0% - if_ci_failed: error - only_pulls: false - -# Ignore files or packages matching their paths -ignore: - - '\.pb\.go$' # Excludes all protobuf generated files - - '\.gen\.go' # Excludes generated files - - '^fake_.*\.go' # Excludes fakes - - '^test/.*$' - - 'app.go' # app.go and main.go should be tested by integration tests. - - 'main.go' - # ignore metadata generated files - - 'metadata/generated_.*\.go' - # ignore wrappers around gopsutil - - 'internal/datasource/host' - - 'internal/watcher/process' - - 'pkg/nginxprocess' From f3b87914968a263021635edd9d3ccddb3f5d4b54 Mon Sep 17 00:00:00 2001 From: Spencer Ugbo Date: Thu, 2 Oct 2025 15:25:23 +0100 Subject: [PATCH 14/27] test higher code coverage threshold --- .codecov.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.codecov.yml b/.codecov.yml index 8e9de7256..75072dab2 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -12,7 +12,7 @@ coverage: default: # The minimum required coverage value for the project - target: 90% + target: 80% # The allowed coverage decrease before failing the status check threshold: 0% @@ -28,7 +28,7 @@ coverage: default: - target: 80% + target: 90% threshold: 0% if_ci_failed: error only_pulls: false From 84896c7f17115590102221f910726353cac1c222 Mon Sep 17 00:00:00 2001 From: Spencer Ugbo Date: Thu, 2 Oct 2025 15:46:24 +0100 Subject: [PATCH 15/27] add unit tests back in --- .codecov.yml | 2 +- internal/command/command_plugin_test.go | 395 ++++++ internal/command/command_service_test.go | 531 ++++++++ internal/file/fake_file_stream_test.go | 116 ++ internal/file/file_manager_service_test.go | 1207 +++++++++++++++++++ internal/file/file_operator_test.go | 104 ++ internal/file/file_plugin_test.go | 527 ++++++++ internal/file/file_service_operator_test.go | 189 +++ 8 files changed, 3070 insertions(+), 1 deletion(-) create mode 100644 internal/command/command_plugin_test.go create mode 100644 internal/command/command_service_test.go create mode 100644 internal/file/fake_file_stream_test.go create mode 100644 internal/file/file_manager_service_test.go create mode 100644 internal/file/file_operator_test.go create mode 100644 internal/file/file_plugin_test.go create mode 100644 internal/file/file_service_operator_test.go diff --git a/.codecov.yml b/.codecov.yml index 75072dab2..920aa891c 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -28,7 +28,7 @@ coverage: default: - target: 90% + target: 80% threshold: 0% if_ci_failed: error only_pulls: false diff --git a/internal/command/command_plugin_test.go b/internal/command/command_plugin_test.go new file mode 100644 index 000000000..c51f3c579 --- /dev/null +++ b/internal/command/command_plugin_test.go @@ -0,0 +1,395 @@ +// 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 command + +import ( + "bytes" + "context" + "testing" + "time" + + "github.com/nginx/agent/v3/internal/model" + + pkg "github.com/nginx/agent/v3/pkg/config" + "github.com/nginx/agent/v3/pkg/id" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/nginx/agent/v3/internal/bus/busfakes" + "github.com/nginx/agent/v3/internal/config" + + mpi "github.com/nginx/agent/v3/api/grpc/mpi/v1" + "github.com/nginx/agent/v3/internal/bus" + "github.com/nginx/agent/v3/internal/command/commandfakes" + "github.com/nginx/agent/v3/internal/grpc/grpcfakes" + "github.com/nginx/agent/v3/test/helpers" + "github.com/nginx/agent/v3/test/protos" + "github.com/nginx/agent/v3/test/stub" + "github.com/nginx/agent/v3/test/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCommandPlugin_Info(t *testing.T) { + commandPlugin := NewCommandPlugin(types.AgentConfig(), &grpcfakes.FakeGrpcConnectionInterface{}, model.Command) + info := commandPlugin.Info() + + assert.Equal(t, "command", info.Name) +} + +func TestCommandPlugin_Subscriptions(t *testing.T) { + commandPlugin := NewCommandPlugin(types.AgentConfig(), &grpcfakes.FakeGrpcConnectionInterface{}, model.Command) + subscriptions := commandPlugin.Subscriptions() + + assert.Equal( + t, + []string{ + bus.ConnectionResetTopic, + bus.ResourceUpdateTopic, + bus.InstanceHealthTopic, + bus.DataPlaneHealthResponseTopic, + bus.DataPlaneResponseTopic, + }, + subscriptions, + ) +} + +func TestCommandPlugin_Init(t *testing.T) { + ctx := context.Background() + messagePipe := busfakes.NewFakeMessagePipe() + fakeCommandService := &commandfakes.FakeCommandService{} + + commandPlugin := NewCommandPlugin(types.AgentConfig(), &grpcfakes.FakeGrpcConnectionInterface{}, model.Command) + err := commandPlugin.Init(ctx, messagePipe) + require.NoError(t, err) + + require.NotNil(t, commandPlugin.messagePipe) + require.NotNil(t, commandPlugin.commandService) + + commandPlugin.commandService = fakeCommandService + + closeError := commandPlugin.Close(ctx) + require.NoError(t, closeError) +} + +func TestCommandPlugin_createConnection(t *testing.T) { + ctx := context.Background() + commandService := &commandfakes.FakeCommandService{} + commandService.CreateConnectionReturns(&mpi.CreateConnectionResponse{}, nil) + messagePipe := busfakes.NewFakeMessagePipe() + + commandPlugin := NewCommandPlugin(types.AgentConfig(), &grpcfakes.FakeGrpcConnectionInterface{}, model.Command) + err := commandPlugin.Init(ctx, messagePipe) + commandPlugin.commandService = commandService + require.NoError(t, err) + defer commandPlugin.Close(ctx) + + commandPlugin.createConnection(ctx, &mpi.Resource{}) + + assert.Eventually( + t, + func() bool { return commandService.SubscribeCallCount() > 0 }, + 2*time.Second, + 10*time.Millisecond, + ) + + assert.Eventually( + t, + func() bool { return len(messagePipe.Messages()) == 1 }, + 2*time.Second, + 10*time.Millisecond, + ) + + messages := messagePipe.Messages() + assert.Len(t, messages, 1) + assert.Equal(t, bus.ConnectionCreatedTopic, messages[0].Topic) +} + +func TestCommandPlugin_Process(t *testing.T) { + ctx := context.Background() + messagePipe := busfakes.NewFakeMessagePipe() + fakeCommandService := &commandfakes.FakeCommandService{} + + commandPlugin := NewCommandPlugin(types.AgentConfig(), &grpcfakes.FakeGrpcConnectionInterface{}, model.Command) + err := commandPlugin.Init(ctx, messagePipe) + require.NoError(t, err) + defer commandPlugin.Close(ctx) + + // Check CreateConnection + fakeCommandService.IsConnectedReturnsOnCall(0, false) + + // Check UpdateDataPlaneStatus + fakeCommandService.IsConnectedReturnsOnCall(1, true) + fakeCommandService.IsConnectedReturnsOnCall(2, true) + + commandPlugin.commandService = fakeCommandService + + commandPlugin.Process(ctx, &bus.Message{Topic: bus.ResourceUpdateTopic, Data: protos.HostResource()}) + require.Equal(t, 1, fakeCommandService.CreateConnectionCallCount()) + + commandPlugin.Process(ctx, &bus.Message{Topic: bus.ResourceUpdateTopic, Data: protos.HostResource()}) + require.Equal(t, 1, fakeCommandService.UpdateDataPlaneStatusCallCount()) + + commandPlugin.Process(ctx, &bus.Message{Topic: bus.InstanceHealthTopic, Data: protos.InstanceHealths()}) + require.Equal(t, 1, fakeCommandService.UpdateDataPlaneHealthCallCount()) + + commandPlugin.Process(ctx, &bus.Message{Topic: bus.DataPlaneResponseTopic, Data: protos.OKDataPlaneResponse()}) + require.Equal(t, 1, fakeCommandService.SendDataPlaneResponseCallCount()) + + commandPlugin.Process(ctx, &bus.Message{ + Topic: bus.DataPlaneHealthResponseTopic, + Data: protos.HealthyInstanceHealth(), + }) + require.Equal(t, 1, fakeCommandService.UpdateDataPlaneHealthCallCount()) + require.Equal(t, 1, fakeCommandService.SendDataPlaneResponseCallCount()) + + commandPlugin.Process(ctx, &bus.Message{ + Topic: bus.ConnectionResetTopic, + Data: commandPlugin.conn, + }) + require.Equal(t, 1, fakeCommandService.UpdateClientCallCount()) +} + +func TestCommandPlugin_monitorSubscribeChannel(t *testing.T) { + tests := []struct { + managementPlaneRequest *mpi.ManagementPlaneRequest + expectedTopic *bus.Message + name string + request string + configFeatures []string + }{ + { + name: "Test 1: Config Upload Request", + managementPlaneRequest: &mpi.ManagementPlaneRequest{ + Request: &mpi.ManagementPlaneRequest_ConfigUploadRequest{ + ConfigUploadRequest: &mpi.ConfigUploadRequest{}, + }, + }, + expectedTopic: &bus.Message{Topic: bus.ConfigUploadRequestTopic}, + request: "UploadRequest", + configFeatures: config.DefaultFeatures(), + }, + { + name: "Test 2: Config Apply Request", + managementPlaneRequest: &mpi.ManagementPlaneRequest{ + Request: &mpi.ManagementPlaneRequest_ConfigApplyRequest{ + ConfigApplyRequest: &mpi.ConfigApplyRequest{}, + }, + }, + expectedTopic: &bus.Message{Topic: bus.ConfigApplyRequestTopic}, + request: "ApplyRequest", + configFeatures: config.DefaultFeatures(), + }, + { + name: "Test 3: Health Request", + managementPlaneRequest: &mpi.ManagementPlaneRequest{ + Request: &mpi.ManagementPlaneRequest_HealthRequest{ + HealthRequest: &mpi.HealthRequest{}, + }, + }, + expectedTopic: &bus.Message{Topic: bus.DataPlaneHealthRequestTopic}, + configFeatures: config.DefaultFeatures(), + }, + { + name: "Test 4: API Action Request", + managementPlaneRequest: &mpi.ManagementPlaneRequest{ + Request: &mpi.ManagementPlaneRequest_ActionRequest{ + ActionRequest: &mpi.APIActionRequest{ + Action: &mpi.APIActionRequest_NginxPlusAction{}, + }, + }, + }, + expectedTopic: &bus.Message{Topic: bus.APIActionRequestTopic}, + request: "APIActionRequest", + configFeatures: []string{ + pkg.FeatureConfiguration, + pkg.FeatureMetrics, + pkg.FeatureFileWatcher, + pkg.FeatureAPIAction, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(tt *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + messagePipe := busfakes.NewFakeMessagePipe() + + agentConfig := types.AgentConfig() + agentConfig.Features = test.configFeatures + commandPlugin := NewCommandPlugin(agentConfig, &grpcfakes.FakeGrpcConnectionInterface{}, model.Command) + err := commandPlugin.Init(ctx, messagePipe) + require.NoError(tt, err) + defer commandPlugin.Close(ctx) + + go commandPlugin.monitorSubscribeChannel(ctx) + + commandPlugin.subscribeChannel <- test.managementPlaneRequest + + assert.Eventually( + t, + func() bool { return len(messagePipe.Messages()) == 1 }, + 2*time.Second, + 10*time.Millisecond, + ) + + messages := messagePipe.Messages() + assert.Len(tt, messages, 1) + assert.Equal(tt, test.expectedTopic.Topic, messages[0].Topic) + + mp, ok := messages[0].Data.(*mpi.ManagementPlaneRequest) + + switch test.request { + case "UploadRequest": + assert.True(tt, ok) + require.NotNil(tt, mp.GetConfigUploadRequest()) + case "ApplyRequest": + assert.True(tt, ok) + require.NotNil(tt, mp.GetConfigApplyRequest()) + case "APIActionRequest": + assert.True(tt, ok) + require.NotNil(tt, mp.GetActionRequest()) + } + }) + } +} + +func TestCommandPlugin_FeatureDisabled(t *testing.T) { + tests := []struct { + managementPlaneRequest *mpi.ManagementPlaneRequest + expectedLog string + name string + request string + configFeatures []string + }{ + { + name: "Test 1: Config Upload Request", + managementPlaneRequest: &mpi.ManagementPlaneRequest{ + Request: &mpi.ManagementPlaneRequest_ConfigUploadRequest{ + ConfigUploadRequest: &mpi.ConfigUploadRequest{}, + }, + }, + expectedLog: "Configuration feature disabled. Unable to process config upload request", + request: "UploadRequest", + configFeatures: []string{ + pkg.FeatureMetrics, + pkg.FeatureFileWatcher, + }, + }, + { + name: "Test 2: Config Apply Request", + managementPlaneRequest: &mpi.ManagementPlaneRequest{ + Request: &mpi.ManagementPlaneRequest_ConfigApplyRequest{ + ConfigApplyRequest: &mpi.ConfigApplyRequest{}, + }, + }, + expectedLog: "Configuration feature disabled. Unable to process config apply request", + request: "ApplyRequest", + configFeatures: []string{ + pkg.FeatureMetrics, + pkg.FeatureFileWatcher, + }, + }, + { + name: "Test 3: API Action Request", + managementPlaneRequest: &mpi.ManagementPlaneRequest{ + Request: &mpi.ManagementPlaneRequest_ActionRequest{ + ActionRequest: &mpi.APIActionRequest{ + Action: &mpi.APIActionRequest_NginxPlusAction{}, + }, + }, + }, + expectedLog: "API Action Request feature disabled. Unable to process API action request", + request: "APIActionRequest", + configFeatures: config.DefaultFeatures(), + }, + } + + for _, test := range tests { + t.Run(test.name, func(tt *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + fakeCommandService := &commandfakes.FakeCommandService{} + fakeCommandService.SendDataPlaneResponseReturns(nil) + messagePipe := busfakes.NewFakeMessagePipe() + + agentConfig := types.AgentConfig() + + agentConfig.Features = test.configFeatures + + commandPlugin := NewCommandPlugin(agentConfig, &grpcfakes.FakeGrpcConnectionInterface{}, model.Command) + err := commandPlugin.Init(ctx, messagePipe) + commandPlugin.commandService = fakeCommandService + require.NoError(tt, err) + defer commandPlugin.Close(ctx) + + go commandPlugin.monitorSubscribeChannel(ctx) + + commandPlugin.subscribeChannel <- test.managementPlaneRequest + assert.Eventually( + tt, + func() bool { return fakeCommandService.SendDataPlaneResponseCallCount() == 1 }, + 2*time.Second, + 10*time.Millisecond, + ) + }) + } +} + +func TestMonitorSubscribeChannel(t *testing.T) { + ctx, cncl := context.WithCancel(context.Background()) + + logBuf := &bytes.Buffer{} + stub.StubLoggerWith(logBuf) + + cp := NewCommandPlugin(types.AgentConfig(), &grpcfakes.FakeGrpcConnectionInterface{}, model.Command) + cp.subscribeCancel = cncl + + message := protos.CreateManagementPlaneRequest() + + // Run in a separate goroutine + go cp.monitorSubscribeChannel(ctx) + + // Give some time to exit the goroutine + time.Sleep(100 * time.Millisecond) + + cp.subscribeChannel <- message + + // Give some time to process the message + time.Sleep(100 * time.Millisecond) + + cp.Close(ctx) + + time.Sleep(100 * time.Millisecond) + + helpers.ValidateLog(t, "Received management plane request", logBuf) + + // Clear the log buffer + logBuf.Reset() +} + +func Test_createDataPlaneResponse(t *testing.T) { + expected := &mpi.DataPlaneResponse{ + MessageMeta: &mpi.MessageMeta{ + MessageId: id.GenerateMessageID(), + CorrelationId: "dfsbhj6-bc92-30c1-a9c9-85591422068e", + Timestamp: timestamppb.Now(), + }, + CommandResponse: &mpi.CommandResponse{ + Status: mpi.CommandResponse_COMMAND_STATUS_OK, + Message: "Success", + Error: "", + }, + } + commandPlugin := NewCommandPlugin(types.AgentConfig(), &grpcfakes.FakeGrpcConnectionInterface{}, model.Command) + result := commandPlugin.createDataPlaneResponse(expected.GetMessageMeta().GetCorrelationId(), + expected.GetCommandResponse().GetStatus(), + expected.GetCommandResponse().GetMessage(), expected.GetCommandResponse().GetError()) + + assert.Equal(t, expected.GetCommandResponse(), result.GetCommandResponse()) + assert.Equal(t, expected.GetMessageMeta().GetCorrelationId(), result.GetMessageMeta().GetCorrelationId()) +} diff --git a/internal/command/command_service_test.go b/internal/command/command_service_test.go new file mode 100644 index 000000000..d91e9fe0f --- /dev/null +++ b/internal/command/command_service_test.go @@ -0,0 +1,531 @@ +// 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 command + +import ( + "bytes" + "context" + "errors" + "log/slog" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/nginx/agent/v3/internal/logger" + "github.com/nginx/agent/v3/test/helpers" + "github.com/nginx/agent/v3/test/stub" + + "github.com/nginx/agent/v3/api/grpc/mpi/v1/v1fakes" + "github.com/nginx/agent/v3/test/protos" + "github.com/nginx/agent/v3/test/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + + mpi "github.com/nginx/agent/v3/api/grpc/mpi/v1" +) + +type FakeSubscribeClient struct { + grpc.ClientStream +} + +func (*FakeSubscribeClient) Send(*mpi.DataPlaneResponse) error { + return nil +} + +//nolint:nilnil // required nil return +func (*FakeSubscribeClient) Recv() (*mpi.ManagementPlaneRequest, error) { + time.Sleep(1 * time.Second) + + return nil, nil +} + +type FakeConfigApplySubscribeClient struct { + grpc.ClientStream +} + +func (*FakeConfigApplySubscribeClient) Send(*mpi.DataPlaneResponse) error { + return nil +} + +func (*FakeConfigApplySubscribeClient) Recv() (*mpi.ManagementPlaneRequest, error) { + nginxInstance := protos.NginxOssInstance([]string{}) + + return &mpi.ManagementPlaneRequest{ + MessageMeta: &mpi.MessageMeta{ + MessageId: "1", + CorrelationId: "123", + Timestamp: timestamppb.Now(), + }, + Request: &mpi.ManagementPlaneRequest_ConfigApplyRequest{ + ConfigApplyRequest: &mpi.ConfigApplyRequest{ + Overview: &mpi.FileOverview{ + ConfigVersion: &mpi.ConfigVersion{ + InstanceId: nginxInstance.GetInstanceMeta().GetInstanceId(), + Version: "4215432", + }, + }, + }, + }, + }, nil +} + +func TestCommandService_receiveCallback_configApplyRequest(t *testing.T) { + fakeSubscribeClient := &FakeConfigApplySubscribeClient{} + ctx := context.Background() + subscribeCtx, subscribeCancel := context.WithCancel(ctx) + + commandServiceClient := &v1fakes.FakeCommandServiceClient{} + commandServiceClient.SubscribeReturns(fakeSubscribeClient, nil) + + subscribeChannel := make(chan *mpi.ManagementPlaneRequest) + + commandService := NewCommandService( + commandServiceClient, + types.AgentConfig(), + subscribeChannel, + ) + go commandService.Subscribe(subscribeCtx) + defer subscribeCancel() + + nginxInstance := protos.NginxOssInstance([]string{}) + commandService.resourceMutex.Lock() + commandService.resource.Instances = append(commandService.resource.Instances, nginxInstance) + commandService.resourceMutex.Unlock() + + var wg sync.WaitGroup + + wg.Add(1) + go func() { + requestFromChannel := <-subscribeChannel + assert.NotNil(t, requestFromChannel) + wg.Done() + }() + + assert.Eventually( + t, + func() bool { return commandServiceClient.SubscribeCallCount() > 0 }, + 2*time.Second, + 10*time.Millisecond, + ) + + commandService.configApplyRequestQueueMutex.Lock() + defer commandService.configApplyRequestQueueMutex.Unlock() + assert.Len(t, commandService.configApplyRequestQueue, 1) + wg.Wait() +} + +func TestCommandService_UpdateDataPlaneStatus(t *testing.T) { + ctx := context.Background() + + fakeSubscribeClient := &FakeSubscribeClient{} + + commandServiceClient := &v1fakes.FakeCommandServiceClient{} + commandServiceClient.SubscribeReturns(fakeSubscribeClient, nil) + + commandService := NewCommandService( + commandServiceClient, + types.AgentConfig(), + make(chan *mpi.ManagementPlaneRequest), + ) + // Fail first time since there are no other instances besides the agent + err := commandService.UpdateDataPlaneStatus(ctx, protos.HostResource()) + require.Error(t, err) + + resource := protos.HostResource() + resource.Instances = append(resource.Instances, protos.NginxOssInstance([]string{})) + _, connectionErr := commandService.CreateConnection(ctx, resource) + require.NoError(t, connectionErr) + err = commandService.UpdateDataPlaneStatus(ctx, resource) + + require.NoError(t, err) + assert.Equal(t, 1, commandServiceClient.UpdateDataPlaneStatusCallCount()) +} + +func TestCommandService_UpdateDataPlaneStatusSubscribeError(t *testing.T) { + correlationID, _ := helpers.CreateTestIDs(t) + ctx := context.WithValue( + context.Background(), + logger.CorrelationIDContextKey, + slog.Any(logger.CorrelationIDKey, correlationID.String()), + ) + + fakeSubscribeClient := &FakeSubscribeClient{} + + commandServiceClient := &v1fakes.FakeCommandServiceClient{} + commandServiceClient.SubscribeReturns(fakeSubscribeClient, errors.New("sub error")) + commandServiceClient.UpdateDataPlaneStatusReturns(nil, errors.New("ret error")) + + logBuf := &bytes.Buffer{} + stub.StubLoggerWith(logBuf) + + commandService := NewCommandService( + commandServiceClient, + types.AgentConfig(), + make(chan *mpi.ManagementPlaneRequest), + ) + + commandService.isConnected.Store(true) + + err := commandService.UpdateDataPlaneStatus(ctx, protos.HostResource()) + require.Error(t, err) + + helpers.ValidateLog(t, "Failed to send update data plane status", logBuf) + + logBuf.Reset() +} + +func TestCommandService_CreateConnection(t *testing.T) { + ctx := context.Background() + commandServiceClient := &v1fakes.FakeCommandServiceClient{} + + commandService := NewCommandService( + commandServiceClient, + types.AgentConfig(), + make(chan *mpi.ManagementPlaneRequest), + ) + + // connection created when no nginx instance found + resource := protos.HostResource() + _, err := commandService.CreateConnection(ctx, resource) + require.NoError(t, err) +} + +func TestCommandService_UpdateClient(t *testing.T) { + commandServiceClient := &v1fakes.FakeCommandServiceClient{} + ctx := context.Background() + + commandService := NewCommandService( + commandServiceClient, + types.AgentConfig(), + make(chan *mpi.ManagementPlaneRequest), + ) + err := commandService.UpdateClient(ctx, commandServiceClient) + require.NoError(t, err) + assert.NotNil(t, commandService.commandServiceClient) +} + +func TestCommandService_UpdateDataPlaneHealth(t *testing.T) { + ctx := context.Background() + commandServiceClient := &v1fakes.FakeCommandServiceClient{} + + commandService := NewCommandService( + commandServiceClient, + types.AgentConfig(), + make(chan *mpi.ManagementPlaneRequest), + ) + + // connection not created yet + err := commandService.UpdateDataPlaneHealth(ctx, protos.InstanceHealths()) + + require.Error(t, err) + assert.Equal(t, 0, commandServiceClient.UpdateDataPlaneHealthCallCount()) + + // connection created + resource := protos.HostResource() + resource.Instances = append(resource.Instances, protos.NginxOssInstance([]string{})) + _, err = commandService.CreateConnection(ctx, resource) + require.NoError(t, err) + assert.Equal(t, 1, commandServiceClient.CreateConnectionCallCount()) + + err = commandService.UpdateDataPlaneHealth(ctx, protos.InstanceHealths()) + + require.NoError(t, err) + assert.Equal(t, 1, commandServiceClient.UpdateDataPlaneHealthCallCount()) +} + +func TestCommandService_SendDataPlaneResponse(t *testing.T) { + ctx := context.Background() + commandServiceClient := &v1fakes.FakeCommandServiceClient{} + subscribeClient := &FakeSubscribeClient{} + + commandService := NewCommandService( + commandServiceClient, + types.AgentConfig(), + make(chan *mpi.ManagementPlaneRequest), + ) + + commandService.subscribeClientMutex.Lock() + commandService.subscribeClient = subscribeClient + commandService.subscribeClientMutex.Unlock() + + err := commandService.SendDataPlaneResponse(ctx, protos.OKDataPlaneResponse()) + + require.NoError(t, err) +} + +func TestCommandService_SendDataPlaneResponse_configApplyRequest(t *testing.T) { + ctx := context.Background() + commandServiceClient := &v1fakes.FakeCommandServiceClient{} + subscribeClient := &FakeSubscribeClient{} + subscribeChannel := make(chan *mpi.ManagementPlaneRequest) + + commandService := NewCommandService( + commandServiceClient, + types.AgentConfig(), + subscribeChannel, + ) + + request1 := &mpi.ManagementPlaneRequest{ + MessageMeta: &mpi.MessageMeta{ + MessageId: "1", + CorrelationId: "123", + Timestamp: timestamppb.Now(), + }, + Request: &mpi.ManagementPlaneRequest_ConfigApplyRequest{ + ConfigApplyRequest: &mpi.ConfigApplyRequest{ + Overview: &mpi.FileOverview{ + Files: []*mpi.File{}, + ConfigVersion: &mpi.ConfigVersion{ + InstanceId: "12314", + Version: "4215432", + }, + }, + }, + }, + } + + request2 := &mpi.ManagementPlaneRequest{ + MessageMeta: &mpi.MessageMeta{ + MessageId: "2", + CorrelationId: "1232", + Timestamp: timestamppb.Now(), + }, + Request: &mpi.ManagementPlaneRequest_ConfigApplyRequest{ + ConfigApplyRequest: &mpi.ConfigApplyRequest{ + Overview: &mpi.FileOverview{ + Files: []*mpi.File{}, + ConfigVersion: &mpi.ConfigVersion{ + InstanceId: "12314", + Version: "4215432", + }, + }, + }, + }, + } + + request3 := &mpi.ManagementPlaneRequest{ + MessageMeta: &mpi.MessageMeta{ + MessageId: "3", + CorrelationId: "1233", + Timestamp: timestamppb.Now(), + }, + Request: &mpi.ManagementPlaneRequest_ConfigApplyRequest{ + ConfigApplyRequest: &mpi.ConfigApplyRequest{ + Overview: &mpi.FileOverview{ + Files: []*mpi.File{}, + ConfigVersion: &mpi.ConfigVersion{ + InstanceId: "12314", + Version: "4215432", + }, + }, + }, + }, + } + + commandService.configApplyRequestQueueMutex.Lock() + commandService.configApplyRequestQueue = map[string][]*mpi.ManagementPlaneRequest{ + "12314": { + request1, + request2, + request3, + }, + } + commandService.configApplyRequestQueueMutex.Unlock() + + commandService.subscribeClientMutex.Lock() + commandService.subscribeClient = subscribeClient + commandService.subscribeClientMutex.Unlock() + + var wg sync.WaitGroup + + wg.Add(1) + go func() { + requestFromChannel := <-subscribeChannel + assert.Equal(t, request3, requestFromChannel) + wg.Done() + }() + + err := commandService.SendDataPlaneResponse( + ctx, + &mpi.DataPlaneResponse{ + MessageMeta: &mpi.MessageMeta{ + MessageId: uuid.NewString(), + CorrelationId: "1232", + Timestamp: timestamppb.Now(), + }, + CommandResponse: &mpi.CommandResponse{ + Status: mpi.CommandResponse_COMMAND_STATUS_OK, + Message: "Success", + }, + InstanceId: "12314", + }, + ) + + require.NoError(t, err) + + commandService.configApplyRequestQueueMutex.Lock() + defer commandService.configApplyRequestQueueMutex.Unlock() + assert.Len(t, commandService.configApplyRequestQueue, 1) + assert.Equal(t, request3, commandService.configApplyRequestQueue["12314"][0]) + wg.Wait() +} + +func TestCommandService_isValidRequest(t *testing.T) { + ctx := context.Background() + commandServiceClient := &v1fakes.FakeCommandServiceClient{} + subscribeClient := &FakeSubscribeClient{} + + commandService := NewCommandService( + commandServiceClient, + types.AgentConfig(), + make(chan *mpi.ManagementPlaneRequest), + ) + + commandService.subscribeClientMutex.Lock() + commandService.subscribeClient = subscribeClient + commandService.subscribeClientMutex.Unlock() + + nginxInstance := protos.NginxOssInstance([]string{}) + + commandService.resourceMutex.Lock() + commandService.resource.Instances = append(commandService.resource.Instances, nginxInstance) + commandService.resourceMutex.Unlock() + + testCases := []struct { + req *mpi.ManagementPlaneRequest + name string + result bool + }{ + { + name: "Test 1: valid health request", + req: &mpi.ManagementPlaneRequest{ + MessageMeta: protos.CreateMessageMeta(), + Request: &mpi.ManagementPlaneRequest_HealthRequest{HealthRequest: &mpi.HealthRequest{}}, + }, + result: true, + }, + { + name: "Test 2: valid config apply request", + req: &mpi.ManagementPlaneRequest{ + MessageMeta: protos.CreateMessageMeta(), + Request: &mpi.ManagementPlaneRequest_ConfigApplyRequest{ + ConfigApplyRequest: protos.CreateConfigApplyRequest(&mpi.FileOverview{ + Files: make([]*mpi.File, 0), + ConfigVersion: &mpi.ConfigVersion{ + InstanceId: nginxInstance.GetInstanceMeta().GetInstanceId(), + Version: "e23brbei3u2bru93", + }, + }), + }, + }, + result: true, + }, + { + name: "Test 3: invalid config apply request", + req: &mpi.ManagementPlaneRequest{ + MessageMeta: protos.CreateMessageMeta(), + Request: &mpi.ManagementPlaneRequest_ConfigApplyRequest{ + ConfigApplyRequest: protos.CreateConfigApplyRequest(&mpi.FileOverview{ + Files: make([]*mpi.File, 0), + ConfigVersion: &mpi.ConfigVersion{ + InstanceId: "unknown-id", + Version: "e23brbei3u2bru93", + }, + }), + }, + }, + result: false, + }, + { + name: "Test 4: valid config upload request", + req: &mpi.ManagementPlaneRequest{ + MessageMeta: protos.CreateMessageMeta(), + Request: &mpi.ManagementPlaneRequest_ConfigUploadRequest{ + ConfigUploadRequest: &mpi.ConfigUploadRequest{ + Overview: &mpi.FileOverview{ + Files: make([]*mpi.File, 0), + ConfigVersion: &mpi.ConfigVersion{ + InstanceId: nginxInstance.GetInstanceMeta().GetInstanceId(), + Version: "e23brbei3u2bru93", + }, + }, + }, + }, + }, + result: true, + }, + { + name: "Test 5: invalid config upload request", + req: &mpi.ManagementPlaneRequest{ + MessageMeta: protos.CreateMessageMeta(), + Request: &mpi.ManagementPlaneRequest_ConfigUploadRequest{ + ConfigUploadRequest: &mpi.ConfigUploadRequest{ + Overview: &mpi.FileOverview{ + Files: make([]*mpi.File, 0), + ConfigVersion: &mpi.ConfigVersion{ + InstanceId: "unknown-id", + Version: "e23brbei3u2bru93", + }, + }, + }, + }, + }, + result: false, + }, + { + name: "Test 6: valid action request", + req: &mpi.ManagementPlaneRequest{ + MessageMeta: protos.CreateMessageMeta(), + Request: &mpi.ManagementPlaneRequest_ActionRequest{ + ActionRequest: &mpi.APIActionRequest{ + InstanceId: nginxInstance.GetInstanceMeta().GetInstanceId(), + Action: nil, + }, + }, + }, + result: true, + }, + { + name: "Test 7: invalid action request", + req: &mpi.ManagementPlaneRequest{ + MessageMeta: protos.CreateMessageMeta(), + Request: &mpi.ManagementPlaneRequest_ActionRequest{ + ActionRequest: &mpi.APIActionRequest{ + InstanceId: "unknown-id", + Action: nil, + }, + }, + }, + result: false, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + result := commandService.isValidRequest(ctx, testCase.req) + assert.Equal(t, testCase.result, result) + }) + } +} + +func TestCommandService_handleSubscribeError(t *testing.T) { + ctx := context.Background() + commandServiceClient := &v1fakes.FakeCommandServiceClient{} + + commandService := NewCommandService( + commandServiceClient, + types.AgentConfig(), + make(chan *mpi.ManagementPlaneRequest), + ) + require.Error(t, + commandService.handleSubscribeError(ctx, + errors.New("an error occurred when attempting to subscribe"), + "Testing handleSubscribeError")) +} diff --git a/internal/file/fake_file_stream_test.go b/internal/file/fake_file_stream_test.go new file mode 100644 index 000000000..ccf06f392 --- /dev/null +++ b/internal/file/fake_file_stream_test.go @@ -0,0 +1,116 @@ +// 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 file + +import ( + "context" + "sync/atomic" + + mpi "github.com/nginx/agent/v3/api/grpc/mpi/v1" + "google.golang.org/grpc/metadata" + "google.golang.org/protobuf/types/known/timestamppb" +) + +type FakeClientStreamingClient struct { + sendCount atomic.Int32 +} + +func (f *FakeClientStreamingClient) Send(req *mpi.FileDataChunk) error { + f.sendCount.Add(1) + return nil +} + +func (f *FakeClientStreamingClient) CloseAndRecv() (*mpi.UpdateFileResponse, error) { + return &mpi.UpdateFileResponse{}, nil +} + +func (f *FakeClientStreamingClient) Header() (metadata.MD, error) { + return metadata.MD{}, nil +} + +func (f *FakeClientStreamingClient) Trailer() metadata.MD { + return nil +} + +func (f *FakeClientStreamingClient) CloseSend() error { + return nil +} + +func (f *FakeClientStreamingClient) Context() context.Context { + return context.Background() +} + +func (f *FakeClientStreamingClient) SendMsg(m any) error { + return nil +} + +func (f *FakeClientStreamingClient) RecvMsg(m any) error { + return nil +} + +type FakeServerStreamingClient struct { + chunks map[uint32][]byte + fileName string + currentChunkID uint32 +} + +func (f *FakeServerStreamingClient) Recv() (*mpi.FileDataChunk, error) { + fileDataChunk := &mpi.FileDataChunk{ + Meta: &mpi.MessageMeta{ + MessageId: "123", + CorrelationId: "1234", + Timestamp: timestamppb.Now(), + }, + } + + if f.currentChunkID == 0 { + fileDataChunk.Chunk = &mpi.FileDataChunk_Header{ + Header: &mpi.FileDataChunkHeader{ + FileMeta: &mpi.FileMeta{ + Name: f.fileName, + Permissions: "666", + }, + Chunks: 52, + ChunkSize: 1, + }, + } + } else { + fileDataChunk.Chunk = &mpi.FileDataChunk_Content{ + Content: &mpi.FileDataChunkContent{ + ChunkId: f.currentChunkID, + Data: f.chunks[f.currentChunkID-1], + }, + } + } + + f.currentChunkID++ + + return fileDataChunk, nil +} + +func (f *FakeServerStreamingClient) Header() (metadata.MD, error) { + return metadata.MD{}, nil +} + +func (f *FakeServerStreamingClient) Trailer() metadata.MD { + return metadata.MD{} +} + +func (f *FakeServerStreamingClient) CloseSend() error { + return nil +} + +func (f *FakeServerStreamingClient) Context() context.Context { + return context.Background() +} + +func (f *FakeServerStreamingClient) SendMsg(m any) error { + return nil +} + +func (f *FakeServerStreamingClient) RecvMsg(m any) error { + return nil +} diff --git a/internal/file/file_manager_service_test.go b/internal/file/file_manager_service_test.go new file mode 100644 index 000000000..c4d8cc64a --- /dev/null +++ b/internal/file/file_manager_service_test.go @@ -0,0 +1,1207 @@ +// 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 file + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path" + "path/filepath" + "sync" + "testing" + + "github.com/nginx/agent/v3/internal/model" + + "github.com/nginx/agent/v3/pkg/files" + "google.golang.org/protobuf/types/known/timestamppb" + + mpi "github.com/nginx/agent/v3/api/grpc/mpi/v1" + "github.com/nginx/agent/v3/api/grpc/mpi/v1/v1fakes" + "github.com/nginx/agent/v3/test/helpers" + "github.com/nginx/agent/v3/test/protos" + "github.com/nginx/agent/v3/test/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFileManagerService_ConfigApply_Add(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + filePath := filepath.Join(tempDir, "nginx.conf") + + fileContent := []byte("location /test {\n return 200 \"Test location\\n\";\n}") + fileHash := files.GenerateHash(fileContent) + defer helpers.RemoveFileWithErrorCheck(t, filePath) + + overview := protos.FileOverview(filePath, fileHash) + + manifestDirPath := tempDir + manifestFilePath := filepath.Join(manifestDirPath, "manifest.json") + helpers.CreateFileWithErrorCheck(t, manifestDirPath, "manifest.json") + + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + fakeFileServiceClient.GetOverviewReturns(&mpi.GetOverviewResponse{ + Overview: overview, + }, nil) + fakeFileServiceClient.GetFileReturns(&mpi.GetFileResponse{ + Contents: &mpi.FileContents{ + Contents: fileContent, + }, + }, nil) + agentConfig := types.AgentConfig() + agentConfig.AllowedDirectories = []string{tempDir} + + fileManagerService := NewFileManagerService(fakeFileServiceClient, agentConfig, &sync.RWMutex{}) + fileManagerService.configPath = filepath.Dir(filePath) + fileManagerService.agentConfig.LibDir = manifestDirPath + fileManagerService.manifestFilePath = manifestFilePath + + request := protos.CreateConfigApplyRequest(overview) + writeStatus, err := fileManagerService.ConfigApply(ctx, request) + require.NoError(t, err) + assert.Equal(t, model.OK, writeStatus) + data, readErr := os.ReadFile(filePath) + require.NoError(t, readErr) + assert.Equal(t, fileContent, data) + assert.Equal(t, fileManagerService.fileActions[filePath].File, overview.GetFiles()[0]) + assert.Equal(t, 1, fakeFileServiceClient.GetFileCallCount()) + assert.True(t, fileManagerService.rollbackManifest) +} + +func TestFileManagerService_ConfigApply_Add_LargeFile(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + filePath := filepath.Join(tempDir, "nginx.conf") + fileContent := []byte("location /test {\n return 200 \"Test location\\n\";\n}") + fileHash := files.GenerateHash(fileContent) + defer helpers.RemoveFileWithErrorCheck(t, filePath) + + overview := protos.FileOverviewLargeFile(filePath, fileHash) + + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + fakeFileServiceClient.GetOverviewReturns(&mpi.GetOverviewResponse{ + Overview: overview, + }, nil) + + fakeServerStreamingClient := &FakeServerStreamingClient{ + chunks: make(map[uint32][]byte), + currentChunkID: 0, + fileName: filePath, + } + + for i := range fileContent { + fakeServerStreamingClient.chunks[uint32(i)] = []byte{fileContent[i]} + } + + manifestDirPath := tempDir + manifestFilePath := filepath.Join(manifestDirPath, "manifest.json") + + fakeFileServiceClient.GetFileStreamReturns(fakeServerStreamingClient, nil) + agentConfig := types.AgentConfig() + agentConfig.AllowedDirectories = []string{tempDir} + fileManagerService := NewFileManagerService(fakeFileServiceClient, agentConfig, &sync.RWMutex{}) + fileManagerService.agentConfig.LibDir = manifestDirPath + fileManagerService.configPath = filepath.Dir(filePath) + fileManagerService.manifestFilePath = manifestFilePath + + request := protos.CreateConfigApplyRequest(overview) + writeStatus, err := fileManagerService.ConfigApply(ctx, request) + require.NoError(t, err) + assert.Equal(t, model.OK, writeStatus) + data, readErr := os.ReadFile(filePath) + require.NoError(t, readErr) + assert.Equal(t, fileContent, data) + assert.Equal(t, fileManagerService.fileActions[filePath].File, overview.GetFiles()[0]) + assert.Equal(t, 0, fakeFileServiceClient.GetFileCallCount()) + assert.Equal(t, 53, int(fakeServerStreamingClient.currentChunkID)) + assert.True(t, fileManagerService.rollbackManifest) +} + +func TestFileManagerService_ConfigApply_Update(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + fileContent := []byte("location /test {\n return 200 \"Test location\\n\";\n}") + previousFileContent := []byte("some test data") + previousFileHash := files.GenerateHash(previousFileContent) + fileHash := files.GenerateHash(fileContent) + tempFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx.conf") + _, writeErr := tempFile.Write(previousFileContent) + require.NoError(t, writeErr) + defer helpers.RemoveFileWithErrorCheck(t, tempFile.Name()) + + filesOnDisk := map[string]*mpi.File{ + tempFile.Name(): { + FileMeta: &mpi.FileMeta{ + Name: tempFile.Name(), + Hash: previousFileHash, + ModifiedTime: timestamppb.Now(), + Permissions: "0640", + Size: 0, + }, + }, + } + + manifestDirPath := tempDir + manifestFilePath := manifestDirPath + "/manifest.json" + helpers.CreateFileWithErrorCheck(t, manifestDirPath, "manifest.json") + + overview := protos.FileOverview(tempFile.Name(), fileHash) + + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + fakeFileServiceClient.GetOverviewReturns(&mpi.GetOverviewResponse{ + Overview: overview, + }, nil) + fakeFileServiceClient.GetFileReturns(&mpi.GetFileResponse{ + Contents: &mpi.FileContents{ + Contents: fileContent, + }, + }, nil) + agentConfig := types.AgentConfig() + agentConfig.AllowedDirectories = []string{tempDir} + + fileManagerService := NewFileManagerService(fakeFileServiceClient, agentConfig, &sync.RWMutex{}) + fileManagerService.agentConfig.LibDir = manifestDirPath + fileManagerService.configPath = filepath.Dir(tempFile.Name()) + fileManagerService.manifestFilePath = manifestFilePath + err := fileManagerService.UpdateCurrentFilesOnDisk(ctx, filesOnDisk, false) + require.NoError(t, err) + + request := protos.CreateConfigApplyRequest(overview) + writeStatus, err := fileManagerService.ConfigApply(ctx, request) + require.NoError(t, err) + assert.Equal(t, model.OK, writeStatus) + data, readErr := os.ReadFile(tempFile.Name()) + require.NoError(t, readErr) + assert.Equal(t, fileContent, data) + + content, err := os.ReadFile(fileManagerService.tempRollbackDir + tempFile.Name()) + require.NoError(t, err) + assert.Equal(t, previousFileContent, content) + + assert.Equal(t, fileManagerService.fileActions[tempFile.Name()].File, overview.GetFiles()[0]) + assert.True(t, fileManagerService.rollbackManifest) +} + +func TestFileManagerService_ConfigApply_Delete(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + fileContent := []byte("location /test {\n return 200 \"Test location\\n\";\n}") + tempFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx.conf") + _, writeErr := tempFile.Write(fileContent) + require.NoError(t, writeErr) + + tempFile2 := helpers.CreateFileWithErrorCheck(t, tempDir, "test.conf") + overview := protos.FileOverview(tempFile2.Name(), files.GenerateHash(fileContent)) + + filesOnDisk := map[string]*mpi.File{ + tempFile.Name(): { + FileMeta: &mpi.FileMeta{ + Name: tempFile.Name(), + Hash: files.GenerateHash(fileContent), + ModifiedTime: timestamppb.Now(), + Permissions: "0640", + Size: 0, + }, + }, + } + + manifestDirPath := tempDir + manifestFilePath := manifestDirPath + "/manifest.json" + helpers.CreateFileWithErrorCheck(t, manifestDirPath, "manifest.json") + + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + agentConfig := types.AgentConfig() + agentConfig.AllowedDirectories = []string{tempDir} + + fileManagerService := NewFileManagerService(fakeFileServiceClient, agentConfig, &sync.RWMutex{}) + fileManagerService.agentConfig.LibDir = manifestDirPath + fileManagerService.manifestFilePath = manifestFilePath + fileManagerService.configPath = filepath.Dir(tempFile.Name()) + err := fileManagerService.UpdateCurrentFilesOnDisk(ctx, filesOnDisk, false) + require.NoError(t, err) + + request := protos.CreateConfigApplyRequest(overview) + + fakeFileServiceClient.GetOverviewReturns(&mpi.GetOverviewResponse{ + Overview: overview, + }, nil) + fakeFileServiceClient.GetFileReturns(&mpi.GetFileResponse{ + Contents: &mpi.FileContents{ + Contents: fileContent, + }, + }, nil) + + writeStatus, err := fileManagerService.ConfigApply(ctx, request) + require.NoError(t, err) + assert.NoFileExists(t, tempFile.Name()) + + content, err := os.ReadFile(fileManagerService.tempRollbackDir + tempFile.Name()) + require.NoError(t, err) + assert.Equal(t, fileContent, content) + + assert.Equal(t, + fileManagerService.fileActions[tempFile.Name()].File.GetFileMeta().GetName(), + filesOnDisk[tempFile.Name()].GetFileMeta().GetName(), + ) + assert.Equal(t, + fileManagerService.fileActions[tempFile.Name()].File.GetFileMeta().GetHash(), + filesOnDisk[tempFile.Name()].GetFileMeta().GetHash(), + ) + assert.Equal(t, + fileManagerService.fileActions[tempFile.Name()].File.GetFileMeta().GetSize(), + filesOnDisk[tempFile.Name()].GetFileMeta().GetSize(), + ) + assert.Equal(t, model.OK, writeStatus) + assert.True(t, fileManagerService.rollbackManifest) +} + +func TestFileManagerService_ConfigApply_Failed(t *testing.T) { + ctx := t.Context() + tempDir := t.TempDir() + + filePath := filepath.Join(tempDir, "nginx.conf") + fileContent := []byte("# this is going to fail") + fileHash := files.GenerateHash(fileContent) + + overview := protos.FileOverview(filePath, fileHash) + + manifestDirPath := tempDir + manifestFilePath := manifestDirPath + "/manifest.json" + helpers.CreateFileWithErrorCheck(t, manifestDirPath, "manifest.json") + + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + fakeFileServiceClient.GetOverviewReturns(&mpi.GetOverviewResponse{ + Overview: overview, + }, nil) + fakeFileServiceClient.GetFileReturns(nil, errors.New("file not found")) + + agentConfig := types.AgentConfig() + agentConfig.AllowedDirectories = []string{tempDir} + + fileManagerService := NewFileManagerService(fakeFileServiceClient, agentConfig, &sync.RWMutex{}) + fileManagerService.agentConfig.LibDir = manifestDirPath + fileManagerService.configPath = filepath.Dir(filePath) + fileManagerService.manifestFilePath = manifestFilePath + + request := protos.CreateConfigApplyRequest(overview) + writeStatus, err := fileManagerService.ConfigApply(ctx, request) + + require.Error(t, err) + assert.Equal(t, model.RollbackRequired, writeStatus) + assert.False(t, fileManagerService.rollbackManifest) +} + +func TestFileManagerService_checkAllowedDirectory(t *testing.T) { + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + fileManagerService := NewFileManagerService(fakeFileServiceClient, types.AgentConfig(), &sync.RWMutex{}) + + allowedFiles := []*mpi.File{ + { + FileMeta: &mpi.FileMeta{ + Name: "/tmp/local/etc/nginx/allowedDirPath", + Hash: "", + ModifiedTime: nil, + Permissions: "", + Size: 0, + }, + }, + } + + notAllowed := []*mpi.File{ + { + FileMeta: &mpi.FileMeta{ + Name: "/not/allowed/dir/path", + Hash: "", + ModifiedTime: nil, + Permissions: "", + Size: 0, + }, + }, + } + + err := fileManagerService.checkAllowedDirectory(allowedFiles) + require.NoError(t, err) + err = fileManagerService.checkAllowedDirectory(notAllowed) + require.Error(t, err) +} + +func TestFileManagerService_validateAndUpdateFilePermissions(t *testing.T) { + ctx := context.Background() + fileManagerService := NewFileManagerService(nil, types.AgentConfig(), &sync.RWMutex{}) + + testFiles := []*mpi.File{ + { + FileMeta: &mpi.FileMeta{ + Name: "exec.conf", + Permissions: "0700", + }, + }, + { + FileMeta: &mpi.FileMeta{ + Name: "normal.conf", + Permissions: "0620", + }, + }, + } + + err := fileManagerService.validateAndUpdateFilePermissions(ctx, testFiles) + require.NoError(t, err) + assert.Equal(t, "0600", testFiles[0].GetFileMeta().GetPermissions()) + assert.Equal(t, "0620", testFiles[1].GetFileMeta().GetPermissions()) +} + +func TestFileManagerService_areExecuteFilePermissionsSet(t *testing.T) { + fileManagerService := NewFileManagerService(nil, types.AgentConfig(), &sync.RWMutex{}) + + tests := []struct { + name string + permissions string + expectBool bool + }{ + { + name: "Test 1: File with read and write permissions for owner", + permissions: "0600", + expectBool: false, + }, + { + name: "Test 2: File with read/write and execute permissions for owner", + permissions: "0700", + expectBool: true, + }, + { + name: "Test 3: File with read/write and execute permissions for owner and group", + permissions: "0770", + expectBool: true, + }, + { + name: "Test 4: File with read and execute permissions for everyone", + permissions: "0555", + expectBool: true, + }, + { + name: "Test 5: File with malformed permissions", + permissions: "abcde", + expectBool: false, + }, + { + name: "Test 6: File with invalid permissions", + permissions: "000070", + expectBool: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + file := &mpi.File{ + FileMeta: &mpi.FileMeta{ + Name: "test.conf", + Permissions: test.permissions, + }, + } + + got := fileManagerService.areExecuteFilePermissionsSet(file) + assert.Equal(t, test.expectBool, got) + }) + } +} + +func TestFileManagerService_removeExecuteFilePermissions(t *testing.T) { + fileManagerService := NewFileManagerService(nil, types.AgentConfig(), &sync.RWMutex{}) + + tests := []struct { + name string + permissions string + errorMsg string + expectPermissions string + expectError bool + }{ + { + name: "Test 1: File with execute permissions for owner and others", + permissions: "0703", + expectError: false, + expectPermissions: "0602", + }, + { + name: "Test 2: File with malformed permissions", + permissions: "abcde", + expectError: true, + errorMsg: "falied to parse file permissions", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + file := &mpi.File{ + FileMeta: &mpi.FileMeta{ + Name: "test.conf", + Permissions: test.permissions, + }, + } + + parseErr := fileManagerService.removeExecuteFilePermissions(t.Context(), file) + + if test.expectError { + require.Error(t, parseErr) + assert.Contains(t, parseErr.Error(), test.errorMsg) + } else { + require.NoError(t, parseErr) + assert.Equal(t, test.expectPermissions, file.GetFileMeta().GetPermissions()) + } + }) + } +} + +//nolint:usetesting // need to use MkDirTemp instead of t.tempDir for rollback as t.tempDir does not accept a pattern +func TestFileManagerService_ClearCache(t *testing.T) { + tempDir := t.TempDir() + rollbackDir, err := os.MkdirTemp(tempDir, "rollback") + require.NoError(t, err) + configDir, err := os.MkdirTemp(tempDir, "config") + require.NoError(t, err) + + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + fileManagerService := NewFileManagerService(fakeFileServiceClient, types.AgentConfig(), &sync.RWMutex{}) + fileManagerService.tempConfigDir = configDir + fileManagerService.tempRollbackDir = rollbackDir + + filesCache := map[string]*model.FileCache{ + "file/path/test.conf": { + File: &mpi.File{ + FileMeta: &mpi.FileMeta{ + Name: "file/path/test.conf", + Hash: "", + ModifiedTime: nil, + Permissions: "", + Size: 0, + }, + }, + }, + } + + fileManagerService.fileActions = filesCache + assert.NotEmpty(t, fileManagerService.fileActions) + + fileManagerService.ClearCache() + + assert.Empty(t, fileManagerService.fileActions) + + _, statErr := os.Stat(fileManagerService.tempRollbackDir) + assert.True(t, os.IsNotExist(statErr)) + _, statConfigErr := os.Stat(fileManagerService.tempConfigDir) + assert.True(t, os.IsNotExist(statConfigErr)) +} + +//nolint:usetesting // need to use MkDirTemp instead of t.tempDir for rollback as t.tempDir does not accept a pattern +func TestFileManagerService_Rollback(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + rollbackDir, mkdirErr := os.MkdirTemp(tempDir, "rollback") + require.NoError(t, mkdirErr) + + deleteFilePath := filepath.Join(tempDir, "nginx_delete.conf") + + newFileContent := []byte("location /test {\n return 200 \"This config needs to be rolled back\\n\";\n}") + oldFileContent := []byte("location /test {\n return 200 \"This is the saved config\\n\";\n}") + fileHash := files.GenerateHash(newFileContent) + + addFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_add.conf") + _, writeErr := addFile.Write(newFileContent) + require.NoError(t, writeErr) + + updateFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_update.conf") + _, writeErr = updateFile.Write(newFileContent) + require.NoError(t, writeErr) + + helpers.CreateDirWithErrorCheck(t, rollbackDir+tempDir) + + tempAddFile, createErr := os.Create(rollbackDir + addFile.Name()) + require.NoError(t, createErr) + _, writeErr = tempAddFile.Write(oldFileContent) + require.NoError(t, writeErr) + + tempUpdateFile, createErr := os.Create(rollbackDir + updateFile.Name()) + require.NoError(t, createErr) + _, writeErr = tempUpdateFile.Write(oldFileContent) + require.NoError(t, writeErr) + t.Log(tempUpdateFile.Name()) + + tempDeleteFile, createErr := os.Create(rollbackDir + tempDir + "/nginx_delete.conf") + require.NoError(t, createErr) + _, writeErr = tempDeleteFile.Write(oldFileContent) + require.NoError(t, writeErr) + t.Log(tempDeleteFile.Name()) + + manifestDirPath := tempDir + manifestFilePath := manifestDirPath + "/manifest.json" + helpers.CreateFileWithErrorCheck(t, manifestDirPath, "manifest.json") + + filesCache := map[string]*model.FileCache{ + addFile.Name(): { + File: &mpi.File{ + FileMeta: &mpi.FileMeta{ + Name: addFile.Name(), + Hash: fileHash, + ModifiedTime: timestamppb.Now(), + Permissions: "0777", + Size: 0, + }, + Unmanaged: false, + }, + Action: model.Add, + }, + updateFile.Name(): { + File: &mpi.File{ + FileMeta: &mpi.FileMeta{ + Name: updateFile.Name(), + Hash: fileHash, + ModifiedTime: timestamppb.Now(), + Permissions: "0777", + Size: 0, + }, + Unmanaged: false, + }, + Action: model.Update, + }, + deleteFilePath: { + File: &mpi.File{ + FileMeta: &mpi.FileMeta{ + Name: deleteFilePath, + Hash: "", + ModifiedTime: timestamppb.Now(), + Permissions: "0777", + Size: 0, + }, + Unmanaged: false, + }, + Action: model.Delete, + }, + "unspecified/file/test.conf": { + File: &mpi.File{ + FileMeta: &mpi.FileMeta{ + Name: "unspecified/file/test.conf", + Hash: "", + ModifiedTime: timestamppb.Now(), + Permissions: "0777", + Size: 0, + }, + Unmanaged: false, + }, + }, + } + + instanceID := protos.NginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId() + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + fileManagerService := NewFileManagerService(fakeFileServiceClient, types.AgentConfig(), &sync.RWMutex{}) + fileManagerService.fileActions = filesCache + fileManagerService.agentConfig.LibDir = manifestDirPath + fileManagerService.tempRollbackDir = rollbackDir + fileManagerService.configPath = filepath.Dir(updateFile.Name()) + fileManagerService.manifestFilePath = manifestFilePath + + err := fileManagerService.Rollback(ctx, instanceID) + require.NoError(t, err) + + assert.NoFileExists(t, addFile.Name()) + assert.FileExists(t, deleteFilePath) + updateData, readUpdateErr := os.ReadFile(updateFile.Name()) + require.NoError(t, readUpdateErr) + assert.Equal(t, oldFileContent, updateData) + + deleteData, readDeleteErr := os.ReadFile(deleteFilePath) + require.NoError(t, readDeleteErr) + assert.Equal(t, oldFileContent, deleteData) + + defer helpers.RemoveFileWithErrorCheck(t, updateFile.Name()) + defer helpers.RemoveFileWithErrorCheck(t, deleteFilePath) +} + +func TestFileManagerService_DetermineFileActions(t *testing.T) { + ctx := context.Background() + tempDir := filepath.Clean(os.TempDir()) + + deleteTestFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_delete.conf") + defer helpers.RemoveFileWithErrorCheck(t, deleteTestFile.Name()) + fileContent, readErr := os.ReadFile("../../test/config/nginx/nginx.conf") + require.NoError(t, readErr) + err := os.WriteFile(deleteTestFile.Name(), fileContent, 0o600) + require.NoError(t, err) + + updateTestFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_update.conf") + defer helpers.RemoveFileWithErrorCheck(t, updateTestFile.Name()) + updatedFileContent := []byte("test update file") + updateErr := os.WriteFile(updateTestFile.Name(), updatedFileContent, 0o600) + require.NoError(t, updateErr) + + addTestFileName := tempDir + "nginx_add.conf" + + unmanagedFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_unmanaged.conf") + defer helpers.RemoveFileWithErrorCheck(t, unmanagedFile.Name()) + unmanagedFileContent := []byte("test unmanaged file") + unmanagedErr := os.WriteFile(unmanagedFile.Name(), unmanagedFileContent, 0o600) + require.NoError(t, unmanagedErr) + + addTestFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_add.conf") + defer helpers.RemoveFileWithErrorCheck(t, addTestFile.Name()) + addFileContent := []byte("test add file") + addErr := os.WriteFile(addTestFile.Name(), addFileContent, 0o600) + require.NoError(t, addErr) + + tests := []struct { + expectedError error + modifiedFiles map[string]*model.FileCache + currentFiles map[string]*mpi.File + expectedCache map[string]*model.FileCache + expectedContent map[string][]byte + name string + allowedDirs []string + }{ + { + name: "Test 1: Add, Update & Delete Files", + allowedDirs: []string{tempDir}, + modifiedFiles: map[string]*model.FileCache{ + addTestFileName: { + File: &mpi.File{ + FileMeta: protos.FileMeta(addTestFileName, files.GenerateHash(fileContent)), + Unmanaged: false, + }, + }, + updateTestFile.Name(): { + File: &mpi.File{ + FileMeta: protos.FileMeta(updateTestFile.Name(), files.GenerateHash(updatedFileContent)), + Unmanaged: false, + }, + }, + unmanagedFile.Name(): { + File: &mpi.File{ + FileMeta: protos.FileMeta(unmanagedFile.Name(), files.GenerateHash(unmanagedFileContent)), + Unmanaged: true, + }, + }, + }, + currentFiles: map[string]*mpi.File{ + deleteTestFile.Name(): { + FileMeta: protos.FileMeta(deleteTestFile.Name(), files.GenerateHash(fileContent)), + }, + updateTestFile.Name(): { + FileMeta: protos.FileMeta(updateTestFile.Name(), files.GenerateHash(fileContent)), + }, + unmanagedFile.Name(): { + FileMeta: protos.FileMeta(unmanagedFile.Name(), files.GenerateHash(fileContent)), + Unmanaged: true, + }, + }, + expectedCache: map[string]*model.FileCache{ + deleteTestFile.Name(): { + File: &mpi.File{ + FileMeta: protos.ManifestFileMeta(deleteTestFile.Name(), files.GenerateHash(fileContent)), + Unmanaged: false, + }, + Action: model.Delete, + }, + updateTestFile.Name(): { + File: &mpi.File{ + FileMeta: protos.FileMeta(updateTestFile.Name(), files.GenerateHash(updatedFileContent)), + Unmanaged: false, + }, + Action: model.Update, + }, + addTestFileName: { + File: &mpi.File{ + FileMeta: protos.FileMeta(addTestFileName, files.GenerateHash(fileContent)), + Unmanaged: false, + }, + Action: model.Add, + }, + }, + expectedContent: map[string][]byte{ + deleteTestFile.Name(): fileContent, + updateTestFile.Name(): updatedFileContent, + }, + expectedError: nil, + }, + { + name: "Test 2: Files same as on disk", + allowedDirs: []string{tempDir}, + modifiedFiles: map[string]*model.FileCache{ + addTestFile.Name(): { + File: &mpi.File{ + FileMeta: protos.FileMeta(addTestFile.Name(), files.GenerateHash(fileContent)), + }, + }, + updateTestFile.Name(): { + File: &mpi.File{ + FileMeta: protos.FileMeta(updateTestFile.Name(), files.GenerateHash(fileContent)), + }, + }, + deleteTestFile.Name(): { + File: &mpi.File{ + FileMeta: protos.FileMeta(deleteTestFile.Name(), files.GenerateHash(fileContent)), + }, + }, + }, + currentFiles: map[string]*mpi.File{ + deleteTestFile.Name(): { + FileMeta: protos.FileMeta(deleteTestFile.Name(), files.GenerateHash(fileContent)), + }, + updateTestFile.Name(): { + FileMeta: protos.FileMeta(updateTestFile.Name(), files.GenerateHash(fileContent)), + }, + addTestFile.Name(): { + FileMeta: protos.FileMeta(addTestFile.Name(), files.GenerateHash(fileContent)), + }, + }, + expectedCache: make(map[string]*model.FileCache), + expectedContent: make(map[string][]byte), + expectedError: nil, + }, + { + name: "Test 3: File being deleted already doesn't exist", + allowedDirs: []string{tempDir, "/unknown"}, + modifiedFiles: make(map[string]*model.FileCache), + currentFiles: map[string]*mpi.File{ + "/unknown/file.conf": { + FileMeta: protos.FileMeta("/unknown/file.conf", files.GenerateHash(fileContent)), + }, + }, + expectedCache: make(map[string]*model.FileCache), + expectedContent: make(map[string][]byte), + expectedError: nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(tt *testing.T) { + // Delete manifest file if it already exists + manifestFile := CreateTestManifestFile(t, tempDir, test.currentFiles, true) + defer manifestFile.Close() + manifestDirPath := tempDir + manifestFilePath := manifestFile.Name() + + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + fileManagerService := NewFileManagerService(fakeFileServiceClient, types.AgentConfig(), &sync.RWMutex{}) + fileManagerService.agentConfig.AllowedDirectories = test.allowedDirs + fileManagerService.agentConfig.LibDir = manifestDirPath + fileManagerService.manifestFilePath = manifestFilePath + fileManagerService.configPath = filepath.Dir(updateTestFile.Name()) + + require.NoError(tt, err) + + diff, fileActionErr := fileManagerService.DetermineFileActions( + ctx, + test.currentFiles, + test.modifiedFiles, + ) + require.NoError(tt, fileActionErr) + assert.Equal(tt, test.expectedCache, diff) + }) + } +} + +func CreateTestManifestFile(t testing.TB, tempDir string, currentFiles map[string]*mpi.File, refrenced bool) *os.File { + t.Helper() + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + fileManagerService := NewFileManagerService(fakeFileServiceClient, types.AgentConfig(), &sync.RWMutex{}) + manifestFiles := fileManagerService.convertToManifestFileMap(currentFiles, refrenced) + manifestJSON, err := json.MarshalIndent(manifestFiles, "", " ") + require.NoError(t, err) + file, err := os.CreateTemp(tempDir, "manifest.json") + require.NoError(t, err) + + _, err = file.Write(manifestJSON) + require.NoError(t, err) + + return file +} + +func TestFileManagerService_UpdateManifestFile(t *testing.T) { + ctx := t.Context() + fileContent := []byte("location /test {\n return 200 \"Test location\\n\";\n}") + fileHash := files.GenerateHash(fileContent) + + tests := []struct { + currentFiles map[string]*mpi.File + currentManifestFiles map[string]*model.ManifestFile + expectedFiles map[string]*model.ManifestFile + name string + referenced bool + previousReferenced bool + }{ + { + name: "Test 1: Manifest file empty", + currentFiles: map[string]*mpi.File{ + "/etc/nginx/nginx.conf": { + FileMeta: protos.FileMeta("/etc/nginx/nginx.conf", fileHash), + }, + }, + expectedFiles: map[string]*model.ManifestFile{ + "/etc/nginx/nginx.conf": { + ManifestFileMeta: &model.ManifestFileMeta{ + Name: "/etc/nginx/nginx.conf", + Hash: fileHash, + Size: 0, + Referenced: true, + }, + }, + }, + currentManifestFiles: make(map[string]*model.ManifestFile), + referenced: true, + previousReferenced: true, + }, + { + name: "Test 2: Manifest file populated - unreferenced", + currentFiles: map[string]*mpi.File{ + "/etc/nginx/nginx.conf": { + FileMeta: protos.FileMeta("/etc/nginx/nginx.conf", fileHash), + }, + "/etc/nginx/unref.conf": { + FileMeta: protos.FileMeta("/etc/nginx/unref.conf", fileHash), + }, + }, + expectedFiles: map[string]*model.ManifestFile{ + "/etc/nginx/nginx.conf": { + ManifestFileMeta: &model.ManifestFileMeta{ + Name: "/etc/nginx/nginx.conf", + Hash: fileHash, + Size: 0, + Referenced: false, + }, + }, + "/etc/nginx/unref.conf": { + ManifestFileMeta: &model.ManifestFileMeta{ + Name: "/etc/nginx/unref.conf", + Hash: fileHash, + Size: 0, + Referenced: false, + }, + }, + }, + currentManifestFiles: map[string]*model.ManifestFile{ + "/etc/nginx/nginx.conf": { + ManifestFileMeta: &model.ManifestFileMeta{ + Name: "/etc/nginx/nginx.conf", + Hash: fileHash, + Size: 0, + Referenced: true, + }, + }, + }, + referenced: false, + previousReferenced: true, + }, + { + name: "Test 3: Manifest file populated - referenced", + currentFiles: map[string]*mpi.File{ + "/etc/nginx/nginx.conf": { + FileMeta: protos.FileMeta("/etc/nginx/nginx.conf", fileHash), + }, + "/etc/nginx/test.conf": { + FileMeta: protos.FileMeta("/etc/nginx/test.conf", fileHash), + }, + }, + expectedFiles: map[string]*model.ManifestFile{ + "/etc/nginx/nginx.conf": { + ManifestFileMeta: &model.ManifestFileMeta{ + Name: "/etc/nginx/nginx.conf", + Hash: fileHash, + Size: 0, + Referenced: true, + }, + }, + "/etc/nginx/test.conf": { + ManifestFileMeta: &model.ManifestFileMeta{ + Name: "/etc/nginx/test.conf", + Hash: fileHash, + Size: 0, + Referenced: true, + }, + }, + "/etc/nginx/unref.conf": { + ManifestFileMeta: &model.ManifestFileMeta{ + Name: "/etc/nginx/unref.conf", + Hash: fileHash, + Size: 0, + Referenced: false, + }, + }, + }, + currentManifestFiles: map[string]*model.ManifestFile{ + "/etc/nginx/nginx.conf": { + ManifestFileMeta: &model.ManifestFileMeta{ + Name: "/etc/nginx/nginx.conf", + Hash: fileHash, + Size: 0, + Referenced: false, + }, + }, + "/etc/nginx/unref.conf": { + ManifestFileMeta: &model.ManifestFileMeta{ + Name: "/etc/nginx/unref.conf", + Hash: fileHash, + Size: 0, + Referenced: false, + }, + }, + }, + referenced: true, + previousReferenced: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(tt *testing.T) { + manifestDirPath := t.TempDir() + file := helpers.CreateFileWithErrorCheck(t, manifestDirPath, "manifest.json") + + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + fileManagerService := NewFileManagerService(fakeFileServiceClient, types.AgentConfig(), &sync.RWMutex{}) + fileManagerService.agentConfig.AllowedDirectories = []string{"manifestDirPath"} + fileManagerService.agentConfig.LibDir = manifestDirPath + fileManagerService.manifestFilePath = file.Name() + + manifestJSON, err := json.MarshalIndent(test.currentManifestFiles, "", " ") + require.NoError(t, err) + + _, err = file.Write(manifestJSON) + require.NoError(t, err) + + updateErr := fileManagerService.UpdateManifestFile(ctx, test.currentFiles, test.referenced) + require.NoError(tt, updateErr) + + manifestFiles, _, manifestErr := fileManagerService.manifestFile() + require.NoError(tt, manifestErr) + assert.Equal(tt, test.expectedFiles, manifestFiles) + }) + } +} + +func TestFileManagerService_fileActions(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + addFilePath := filepath.Join(tempDir, "nginx_add.conf") + unspecifiedFilePath := "unspecified/file/test.conf" + + newFileContent := []byte("location /test {\n return 200 \"This config needs to be rolled back\\n\";\n}") + oldFileContent := []byte("location /test {\n return 200 \"This is the saved config\\n\";\n}") + fileHash := files.GenerateHash(newFileContent) + + deleteFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_delete.conf") + _, writeErr := deleteFile.Write(oldFileContent) + require.NoError(t, writeErr) + + updateFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_update.conf") + _, writeErr = updateFile.Write(oldFileContent) + require.NoError(t, writeErr) + + filesCache := map[string]*model.FileCache{ + addFilePath: { + File: &mpi.File{ + FileMeta: &mpi.FileMeta{ + Name: addFilePath, + Hash: fileHash, + ModifiedTime: timestamppb.Now(), + Permissions: "0777", + Size: 0, + }, + }, + Action: model.Add, + }, + updateFile.Name(): { + File: &mpi.File{ + FileMeta: &mpi.FileMeta{ + Name: updateFile.Name(), + Hash: fileHash, + ModifiedTime: timestamppb.Now(), + Permissions: "0777", + Size: 0, + }, + }, + Action: model.Update, + }, + deleteFile.Name(): { + File: &mpi.File{ + FileMeta: &mpi.FileMeta{ + Name: deleteFile.Name(), + Hash: "", + ModifiedTime: timestamppb.Now(), + Permissions: "0777", + Size: 0, + }, + }, + Action: model.Delete, + }, + unspecifiedFilePath: { + File: &mpi.File{ + FileMeta: &mpi.FileMeta{ + Name: unspecifiedFilePath, + Hash: "", + ModifiedTime: timestamppb.Now(), + Permissions: "0777", + Size: 0, + }, + }, + }, + } + + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + fakeFileServiceClient.GetFileReturns(&mpi.GetFileResponse{ + Contents: &mpi.FileContents{ + Contents: newFileContent, + }, + }, nil) + fileManagerService := NewFileManagerService(fakeFileServiceClient, types.AgentConfig(), &sync.RWMutex{}) + + fileManagerService.fileActions = filesCache + + actionErr := fileManagerService.executeFileActions(ctx) + require.NoError(t, actionErr) + + assert.FileExists(t, addFilePath) + assert.NoFileExists(t, deleteFile.Name()) + assert.NoFileExists(t, unspecifiedFilePath) + updateData, readUpdateErr := os.ReadFile(updateFile.Name()) + require.NoError(t, readUpdateErr) + assert.Equal(t, newFileContent, updateData) + + defer helpers.RemoveFileWithErrorCheck(t, updateFile.Name()) + defer helpers.RemoveFileWithErrorCheck(t, addFilePath) +} + +func TestParseX509Certificates(t *testing.T) { + tests := []struct { + certName string + certContent string + name string + expectedSerial string + }{ + { + name: "Test 1: generated cert", + certName: "public_cert", + certContent: "", + expectedSerial: "123123", + }, + { + name: "Test 2: open ssl cert", + certName: "open_ssl_cert", + certContent: `-----BEGIN CERTIFICATE----- +MIIDazCCAlOgAwIBAgIUR+YGgRHhYwotFyBOvSc1KD9d45kwDQYJKoZIhvcNAQEL +BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDExMjcxNTM0MDZaFw0yNDEy +MjcxNTM0MDZaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw +HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDnDDVGflbZ3dmuQJj+8QuJIQ8lWjVGYhlsFI4AGFTX +9VfYOqJEPyuMRuSj2eN7C/mR4yTJSggnv0kFtjmeGh2keNdmb4R/0CjYWZVl/Na6 +cAfldB8v2+sm0LZ/OD9F9CbnYB95takPOZq3AP5kUA+qlFYzroqXsxJKvZF6dUuI ++kTOn5pWD+eFmueFedOz1aucOvblUJLueVZnvAbIrBoyaulw3f2kjk0J1266nFMb +s72AvjyYbOXbyur3BhPThCaOeqMGggDmFslZ4pBgQFWUeFvmqJMFzf1atKTWlbj7 +Mj+bNKNs4xvUuNhqd/F99Pz2Fe0afKbTHK83hqgSHKbtAgMBAAGjUzBRMB0GA1Ud +DgQWBBQq0Bzde0bl9CFb81LrvFfdWlY7hzAfBgNVHSMEGDAWgBQq0Bzde0bl9CFb +81LrvFfdWlY7hzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAo +8GXvwRa0M0D4x4Lrj2K57FxH4ECNBnAqWlh3Ce9LEioL2CYaQQw6I2/FsnTk8TYY +WgGgXMEyA6OeOXvwxWjSllK9+D2ueTMhNRO0tYMUi0kDJqd9EpmnEcSWIL2G2SNo +BWQjqEoEKFjvrgx6h13AtsFlpdURoVtodrtnUrXp1r4wJvljC2qexoNfslhpbqsT +X/vYrzgKRoKSUWUt1ejKTntrVuaJK4NMxANOTTjIXgxyoV3YcgEmL9KzribCqILi +p79Nno9d+kovtX5VKsJ5FCcPw9mEATgZDOQ4nLTk/HHG6bwtpubp6Zb7H1AjzBkz +rQHX6DP4w6IwZY8JB8LS +-----END CERTIFICATE-----`, + expectedSerial: "410468082718062724391949173062901619571168240537", + }, + } + + tempDir := os.TempDir() + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var certBytes []byte + var certPath string + + if test.certContent == "" { + _, certBytes = helpers.GenerateSelfSignedCert(t) + certContents := helpers.Cert{ + Name: test.certName + ".pem", + Type: "CERTIFICATE", + Contents: certBytes, + } + certPath = helpers.WriteCertFiles(t, tempDir, certContents) + } else { + certPath = fmt.Sprintf("%s%c%s", tempDir, os.PathSeparator, test.certName) + err := os.WriteFile(certPath, []byte(test.certContent), 0o600) + require.NoError(t, err) + } + + certFileMeta, certFileMetaErr := files.FileMetaWithCertificate(certPath) + require.NoError(t, certFileMetaErr) + + assert.Equal(t, test.expectedSerial, certFileMeta.GetCertificateMeta().GetSerialNumber()) + }) + } +} + +func TestFileManagerService_deleteTempFiles(t *testing.T) { + tempDir := t.TempDir() + tempFile := path.Join(tempDir, "/etc/nginx/nginx.conf") + + err := os.MkdirAll(path.Dir(tempFile), 0o755) + require.NoError(t, err) + + _, err = os.Create(tempFile) + require.NoError(t, err) + + fileManagerService := FileManagerService{ + fileActions: map[string]*model.FileCache{ + "/etc/nginx/nginx.conf": { + File: &mpi.File{ + FileMeta: &mpi.FileMeta{ + Name: "/etc/nginx/nginx.conf", + }, + }, + Action: model.Update, + }, + "/etc/nginx/test.conf": { + File: &mpi.File{ + FileMeta: &mpi.FileMeta{ + Name: "/etc/nginx/test.conf", + }, + }, + Action: model.Add, + }, + }, + } + + fileManagerService.deleteTempFiles(t.Context(), tempDir) + + assert.NoFileExists(t, tempFile) +} + +func TestFileManagerService_createTempConfigDirectory(t *testing.T) { + agentConfig := types.AgentConfig() + tempDir := t.TempDir() + configPath := tempDir + + fileManagerService := FileManagerService{ + agentConfig: agentConfig, + configPath: configPath, + } + + dir, err := fileManagerService.createTempConfigDirectory("config") + assert.NotEmpty(t, dir) + require.NoError(t, err) + + // Test for unknown directory path + fileManagerService.configPath = "/unknown/" + + dir, err = fileManagerService.createTempConfigDirectory("config") + assert.Empty(t, dir) + require.Error(t, err) +} diff --git a/internal/file/file_operator_test.go b/internal/file/file_operator_test.go new file mode 100644 index 000000000..bf1a00d44 --- /dev/null +++ b/internal/file/file_operator_test.go @@ -0,0 +1,104 @@ +// 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 file + +import ( + "context" + "os" + "path" + "path/filepath" + "sync" + "testing" + + "github.com/nginx/agent/v3/pkg/files" + "github.com/nginx/agent/v3/test/protos" + + "github.com/nginx/agent/v3/internal/model" + "github.com/nginx/agent/v3/test/helpers" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFileOperator_Write(t *testing.T) { + ctx := context.Background() + + tempDir := t.TempDir() + filePath := filepath.Join(tempDir, "nginx.conf") + fileContent, err := os.ReadFile("../../test/config/nginx/nginx.conf") + require.NoError(t, err) + defer helpers.RemoveFileWithErrorCheck(t, filePath) + fileOp := NewFileOperator(&sync.RWMutex{}) + + fileMeta := protos.FileMeta(filePath, files.GenerateHash(fileContent)) + + writeErr := fileOp.Write(ctx, fileContent, fileMeta.GetName(), fileMeta.GetPermissions()) + require.NoError(t, writeErr) + assert.FileExists(t, filePath) + + data, readErr := os.ReadFile(filePath) + require.NoError(t, readErr) + assert.Equal(t, fileContent, data) +} + +func TestFileOperator_WriteManifestFile_fileMissing(t *testing.T) { + tempDir := t.TempDir() + manifestPath := "/unknown/manifest.json" + + fileOperator := NewFileOperator(&sync.RWMutex{}) + err := fileOperator.WriteManifestFile(t.Context(), make(map[string]*model.ManifestFile), tempDir, manifestPath) + assert.Error(t, err) +} + +func TestFileOperator_MoveFile_fileExists(t *testing.T) { + tempDir := t.TempDir() + tempFile := path.Join(tempDir, "/etc/nginx/nginx.conf") + newFile := path.Join(tempDir, "/etc/nginx/new_test.conf") + + err := os.MkdirAll(path.Dir(tempFile), 0o755) + require.NoError(t, err) + + _, err = os.Create(tempFile) + require.NoError(t, err) + + fileOperator := NewFileOperator(&sync.RWMutex{}) + err = fileOperator.MoveFile(t.Context(), tempFile, newFile) + require.NoError(t, err) + + assert.FileExists(t, newFile) +} + +func TestFileOperator_MoveFile_sourceFileDoesNotExist(t *testing.T) { + tempDir := t.TempDir() + tempFile := path.Join(tempDir, "/etc/nginx/nginx.conf") + newFile := path.Join(tempDir, "/etc/nginx/new_test.conf") + + fileOperator := NewFileOperator(&sync.RWMutex{}) + err := fileOperator.MoveFile(t.Context(), tempFile, newFile) + require.Error(t, err) + + assert.NoFileExists(t, tempFile) + assert.NoFileExists(t, newFile) +} + +func TestFileOperator_MoveFile_destFileDoesNotExist(t *testing.T) { + tempDir := t.TempDir() + tempFile := path.Join(tempDir, "/etc/nginx/nginx.conf") + newFile := "/unknown/nginx/new_test.conf" + + err := os.MkdirAll(path.Dir(tempFile), 0o755) + require.NoError(t, err) + + _, err = os.Create(tempFile) + require.NoError(t, err) + + fileOperator := NewFileOperator(&sync.RWMutex{}) + err = fileOperator.MoveFile(t.Context(), tempFile, newFile) + require.Error(t, err) + + assert.FileExists(t, tempFile) + assert.NoFileExists(t, newFile) +} diff --git a/internal/file/file_plugin_test.go b/internal/file/file_plugin_test.go new file mode 100644 index 000000000..2955dacf3 --- /dev/null +++ b/internal/file/file_plugin_test.go @@ -0,0 +1,527 @@ +// 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 file + +import ( + "context" + "errors" + "os" + "sync" + "testing" + "time" + + "github.com/nginx/agent/v3/internal/bus/busfakes" + "google.golang.org/protobuf/types/known/timestamppb" + + mpi "github.com/nginx/agent/v3/api/grpc/mpi/v1" + "github.com/nginx/agent/v3/api/grpc/mpi/v1/v1fakes" + "github.com/nginx/agent/v3/internal/bus" + "github.com/nginx/agent/v3/internal/file/filefakes" + "github.com/nginx/agent/v3/internal/grpc/grpcfakes" + "github.com/nginx/agent/v3/internal/model" + "github.com/nginx/agent/v3/pkg/files" + "github.com/nginx/agent/v3/pkg/id" + "github.com/nginx/agent/v3/test/helpers" + "github.com/nginx/agent/v3/test/protos" + "github.com/nginx/agent/v3/test/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFilePlugin_Info(t *testing.T) { + filePlugin := NewFilePlugin(types.AgentConfig(), &grpcfakes.FakeGrpcConnectionInterface{}, + model.Command, &sync.RWMutex{}) + assert.Equal(t, "file", filePlugin.Info().Name) +} + +func TestFilePlugin_Close(t *testing.T) { + ctx := context.Background() + fakeGrpcConnection := &grpcfakes.FakeGrpcConnectionInterface{} + + filePlugin := NewFilePlugin(types.AgentConfig(), fakeGrpcConnection, model.Command, &sync.RWMutex{}) + filePlugin.Close(ctx) + + assert.Equal(t, 1, fakeGrpcConnection.CloseCallCount()) +} + +func TestFilePlugin_Subscriptions(t *testing.T) { + filePlugin := NewFilePlugin(types.AgentConfig(), &grpcfakes.FakeGrpcConnectionInterface{}, + model.Command, &sync.RWMutex{}) + assert.Equal( + t, + []string{ + bus.ConnectionResetTopic, + bus.ConnectionCreatedTopic, + bus.NginxConfigUpdateTopic, + bus.ConfigUploadRequestTopic, + bus.ConfigApplyRequestTopic, + bus.ConfigApplyFailedTopic, + bus.ReloadSuccessfulTopic, + bus.ConfigApplyCompleteTopic, + }, + filePlugin.Subscriptions(), + ) + + readOnlyFilePlugin := NewFilePlugin(types.AgentConfig(), &grpcfakes.FakeGrpcConnectionInterface{}, + model.Auxiliary, &sync.RWMutex{}) + assert.Equal(t, []string{ + bus.ConnectionResetTopic, + bus.ConnectionCreatedTopic, + bus.NginxConfigUpdateTopic, + bus.ConfigUploadRequestTopic, + }, readOnlyFilePlugin.Subscriptions()) +} + +func TestFilePlugin_Process_NginxConfigUpdateTopic(t *testing.T) { + ctx := context.Background() + + fileMeta := protos.FileMeta("/etc/nginx/nginx/conf", "") + + message := &model.NginxConfigContext{ + Files: []*mpi.File{ + { + FileMeta: fileMeta, + }, + }, + } + + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + fakeFileServiceClient.UpdateOverviewReturns(&mpi.UpdateOverviewResponse{ + Overview: nil, + }, nil) + + fakeGrpcConnection := &grpcfakes.FakeGrpcConnectionInterface{} + fakeGrpcConnection.FileServiceClientReturns(fakeFileServiceClient) + messagePipe := busfakes.NewFakeMessagePipe() + + filePlugin := NewFilePlugin(types.AgentConfig(), fakeGrpcConnection, model.Command, &sync.RWMutex{}) + err := filePlugin.Init(ctx, messagePipe) + require.NoError(t, err) + + filePlugin.Process(ctx, &bus.Message{Topic: bus.ConnectionCreatedTopic}) + filePlugin.Process(ctx, &bus.Message{Topic: bus.NginxConfigUpdateTopic, Data: message}) + + assert.Eventually( + t, + func() bool { return fakeFileServiceClient.UpdateOverviewCallCount() == 1 }, + 2*time.Second, + 10*time.Millisecond, + ) +} + +func TestFilePlugin_Process_ConfigApplyRequestTopic(t *testing.T) { + ctx := context.Background() + tempDir := t.TempDir() + + filePath := tempDir + "/nginx.conf" + fileContent := []byte("location /test {\n return 200 \"Test location\\n\";\n}") + fileHash := files.GenerateHash(fileContent) + + message := &mpi.ManagementPlaneRequest{ + Request: &mpi.ManagementPlaneRequest_ConfigApplyRequest{ + ConfigApplyRequest: protos.CreateConfigApplyRequest(protos.FileOverview(filePath, fileHash)), + }, + } + fakeGrpcConnection := &grpcfakes.FakeGrpcConnectionInterface{} + agentConfig := types.AgentConfig() + agentConfig.AllowedDirectories = []string{tempDir} + + tests := []struct { + message *mpi.ManagementPlaneRequest + configApplyReturnsErr error + name string + configApplyStatus model.WriteStatus + }{ + { + name: "Test 1 - Success", + configApplyReturnsErr: nil, + configApplyStatus: model.OK, + message: message, + }, + { + name: "Test 2 - Fail, Rollback", + configApplyReturnsErr: errors.New("something went wrong"), + configApplyStatus: model.RollbackRequired, + message: message, + }, + { + name: "Test 3 - Fail, No Rollback", + configApplyReturnsErr: errors.New("something went wrong"), + configApplyStatus: model.Error, + message: message, + }, + { + name: "Test 4 - Fail to cast payload", + configApplyReturnsErr: errors.New("something went wrong"), + configApplyStatus: model.Error, + message: nil, + }, + { + name: "Test 5 - No changes needed", + configApplyReturnsErr: nil, + configApplyStatus: model.NoChange, + message: message, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fakeFileManagerService := &filefakes.FakeFileManagerServiceInterface{} + fakeFileManagerService.ConfigApplyReturns(test.configApplyStatus, test.configApplyReturnsErr) + messagePipe := busfakes.NewFakeMessagePipe() + filePlugin := NewFilePlugin(agentConfig, fakeGrpcConnection, model.Command, &sync.RWMutex{}) + err := filePlugin.Init(ctx, messagePipe) + filePlugin.fileManagerService = fakeFileManagerService + require.NoError(t, err) + + filePlugin.Process(ctx, &bus.Message{Topic: bus.ConfigApplyRequestTopic, Data: test.message}) + + messages := messagePipe.Messages() + + switch { + case test.configApplyStatus == model.OK: + assert.Equal(t, bus.WriteConfigSuccessfulTopic, messages[0].Topic) + assert.Len(t, messages, 1) + + _, ok := messages[0].Data.(*model.ConfigApplyMessage) + assert.True(t, ok) + case test.configApplyStatus == model.RollbackRequired: + assert.Equal(t, bus.DataPlaneResponseTopic, messages[0].Topic) + assert.Len(t, messages, 2) + dataPlaneResponse, ok := messages[0].Data.(*mpi.DataPlaneResponse) + assert.True(t, ok) + assert.Equal( + t, + mpi.CommandResponse_COMMAND_STATUS_ERROR, + dataPlaneResponse.GetCommandResponse().GetStatus(), + ) + assert.Equal(t, "Config apply failed, rolling back config", + dataPlaneResponse.GetCommandResponse().GetMessage()) + assert.Equal(t, test.configApplyReturnsErr.Error(), dataPlaneResponse.GetCommandResponse().GetError()) + dataPlaneResponse, ok = messages[1].Data.(*mpi.DataPlaneResponse) + assert.True(t, ok) + assert.Equal(t, "Config apply failed, rollback successful", + dataPlaneResponse.GetCommandResponse().GetMessage()) + assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_FAILURE, + dataPlaneResponse.GetCommandResponse().GetStatus()) + case test.configApplyStatus == model.NoChange: + assert.Len(t, messages, 1) + + response, ok := messages[0].Data.(*mpi.DataPlaneResponse) + assert.True(t, ok) + assert.Equal(t, bus.ConfigApplyCompleteTopic, messages[0].Topic) + assert.Equal( + t, + mpi.CommandResponse_COMMAND_STATUS_OK, + response.GetCommandResponse().GetStatus(), + ) + case test.message == nil: + assert.Empty(t, messages) + default: + assert.Len(t, messages, 1) + dataPlaneResponse, ok := messages[0].Data.(*mpi.DataPlaneResponse) + assert.True(t, ok) + assert.Equal( + t, + mpi.CommandResponse_COMMAND_STATUS_FAILURE, + dataPlaneResponse.GetCommandResponse().GetStatus(), + ) + assert.Equal(t, "Config apply failed", dataPlaneResponse.GetCommandResponse().GetMessage()) + assert.Equal(t, test.configApplyReturnsErr.Error(), dataPlaneResponse.GetCommandResponse().GetError()) + } + }) + } +} + +func TestFilePlugin_Process_ConfigUploadRequestTopic(t *testing.T) { + ctx := context.Background() + + tempDir := os.TempDir() + testFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx.conf") + defer helpers.RemoveFileWithErrorCheck(t, testFile.Name()) + fileMeta := protos.FileMeta(testFile.Name(), "") + + message := &mpi.ManagementPlaneRequest{ + Request: &mpi.ManagementPlaneRequest_ConfigUploadRequest{ + ConfigUploadRequest: &mpi.ConfigUploadRequest{ + Overview: &mpi.FileOverview{ + Files: []*mpi.File{ + { + FileMeta: fileMeta, + }, + { + FileMeta: fileMeta, + }, + }, + ConfigVersion: &mpi.ConfigVersion{ + InstanceId: "123", + Version: "f33ref3d32d3c32d3a", + }, + }, + }, + }, + } + + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + fakeGrpcConnection := &grpcfakes.FakeGrpcConnectionInterface{} + fakeGrpcConnection.FileServiceClientReturns(fakeFileServiceClient) + messagePipe := busfakes.NewFakeMessagePipe() + + filePlugin := NewFilePlugin(types.AgentConfig(), fakeGrpcConnection, model.Command, &sync.RWMutex{}) + err := filePlugin.Init(ctx, messagePipe) + require.NoError(t, err) + + filePlugin.Process(ctx, &bus.Message{Topic: bus.ConnectionCreatedTopic}) + filePlugin.Process(ctx, &bus.Message{Topic: bus.ConfigUploadRequestTopic, Data: message}) + + assert.Eventually( + t, + func() bool { return fakeFileServiceClient.UpdateFileCallCount() == 2 }, + 2*time.Second, + 10*time.Millisecond, + ) + + messages := messagePipe.Messages() + assert.Len(t, messages, 1) + assert.Equal(t, bus.DataPlaneResponseTopic, messages[0].Topic) + + dataPlaneResponse, ok := messages[0].Data.(*mpi.DataPlaneResponse) + assert.True(t, ok) + assert.Equal( + t, + mpi.CommandResponse_COMMAND_STATUS_OK, + dataPlaneResponse.GetCommandResponse().GetStatus(), + ) +} + +func TestFilePlugin_Process_ConfigUploadRequestTopic_Failure(t *testing.T) { + ctx := context.Background() + + fileMeta := protos.FileMeta("/unknown/file.conf", "") + + message := &mpi.ManagementPlaneRequest{ + Request: &mpi.ManagementPlaneRequest_ConfigUploadRequest{ + ConfigUploadRequest: &mpi.ConfigUploadRequest{ + Overview: &mpi.FileOverview{ + Files: []*mpi.File{ + { + FileMeta: fileMeta, + }, + { + FileMeta: fileMeta, + }, + }, + ConfigVersion: protos.CreateConfigVersion(), + }, + }, + }, + } + + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + fakeGrpcConnection := &grpcfakes.FakeGrpcConnectionInterface{} + fakeGrpcConnection.FileServiceClientReturns(fakeFileServiceClient) + messagePipe := busfakes.NewFakeMessagePipe() + + filePlugin := NewFilePlugin(types.AgentConfig(), fakeGrpcConnection, model.Command, &sync.RWMutex{}) + err := filePlugin.Init(ctx, messagePipe) + require.NoError(t, err) + + filePlugin.Process(ctx, &bus.Message{Topic: bus.ConnectionCreatedTopic}) + filePlugin.Process(ctx, &bus.Message{Topic: bus.ConfigUploadRequestTopic, Data: message}) + + assert.Eventually( + t, + func() bool { return len(messagePipe.Messages()) == 1 }, + 2*time.Second, + 10*time.Millisecond, + ) + + assert.Equal(t, 0, fakeFileServiceClient.UpdateFileCallCount()) + + messages := messagePipe.Messages() + assert.Len(t, messages, 1) + + assert.Equal(t, bus.DataPlaneResponseTopic, messages[0].Topic) + + dataPlaneResponse, ok := messages[0].Data.(*mpi.DataPlaneResponse) + assert.True(t, ok) + assert.Equal( + t, + mpi.CommandResponse_COMMAND_STATUS_FAILURE, + dataPlaneResponse.GetCommandResponse().GetStatus(), + ) +} + +func TestFilePlugin_Process_ConfigApplyFailedTopic(t *testing.T) { + ctx := context.Background() + instanceID := protos.NginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId() + + tests := []struct { + name string + rollbackReturns error + instanceID string + }{ + { + name: "Test 1 - Rollback Success", + rollbackReturns: nil, + instanceID: instanceID, + }, + { + name: "Test 2 - Rollback Fail", + rollbackReturns: errors.New("something went wrong"), + instanceID: instanceID, + }, + + { + name: "Test 3 - Fail to cast payload", + rollbackReturns: errors.New("something went wrong"), + instanceID: "", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mockFileManager := &filefakes.FakeFileManagerServiceInterface{} + mockFileManager.RollbackReturns(test.rollbackReturns) + + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + fakeGrpcConnection := &grpcfakes.FakeGrpcConnectionInterface{} + fakeGrpcConnection.FileServiceClientReturns(fakeFileServiceClient) + + messagePipe := busfakes.NewFakeMessagePipe() + agentConfig := types.AgentConfig() + filePlugin := NewFilePlugin(agentConfig, fakeGrpcConnection, model.Command, &sync.RWMutex{}) + + err := filePlugin.Init(ctx, messagePipe) + require.NoError(t, err) + filePlugin.fileManagerService = mockFileManager + + data := &model.ConfigApplyMessage{ + CorrelationID: "dfsbhj6-bc92-30c1-a9c9-85591422068e", + InstanceID: test.instanceID, + Error: errors.New("something went wrong with config apply"), + } + + filePlugin.Process(ctx, &bus.Message{Topic: bus.ConfigApplyFailedTopic, Data: data}) + + messages := messagePipe.Messages() + + switch { + case test.rollbackReturns == nil: + assert.Equal(t, bus.RollbackWriteTopic, messages[0].Topic) + assert.Len(t, messages, 1) + + case test.instanceID == "": + assert.Empty(t, messages) + default: + rollbackMessage, ok := messages[0].Data.(*mpi.DataPlaneResponse) + assert.True(t, ok) + assert.Equal(t, "Rollback failed", rollbackMessage.GetCommandResponse().GetMessage()) + assert.Equal(t, test.rollbackReturns.Error(), rollbackMessage.GetCommandResponse().GetError()) + applyMessage, ok := messages[1].Data.(*mpi.DataPlaneResponse) + assert.True(t, ok) + assert.Equal(t, "Config apply failed, rollback failed", + applyMessage.GetCommandResponse().GetMessage()) + assert.Equal(t, data.Error.Error(), applyMessage.GetCommandResponse().GetError()) + assert.Len(t, messages, 2) + } + }) + } +} + +func TestFilePlugin_Process_ConfigApplyReloadSuccessTopic(t *testing.T) { + ctx := context.Background() + instance := protos.NginxOssInstance([]string{}) + mockFileManager := &filefakes.FakeFileManagerServiceInterface{} + + messagePipe := busfakes.NewFakeMessagePipe() + agentConfig := types.AgentConfig() + fakeGrpcConnection := &grpcfakes.FakeGrpcConnectionInterface{} + filePlugin := NewFilePlugin(agentConfig, fakeGrpcConnection, model.Command, &sync.RWMutex{}) + + err := filePlugin.Init(ctx, messagePipe) + require.NoError(t, err) + filePlugin.fileManagerService = mockFileManager + + expectedResponse := &mpi.DataPlaneResponse{ + MessageMeta: &mpi.MessageMeta{ + MessageId: id.GenerateMessageID(), + CorrelationId: "dfsbhj6-bc92-30c1-a9c9-85591422068e", + Timestamp: timestamppb.Now(), + }, + CommandResponse: &mpi.CommandResponse{ + Status: mpi.CommandResponse_COMMAND_STATUS_OK, + Message: "Config apply successful", + Error: "", + }, + InstanceId: instance.GetInstanceMeta().GetInstanceId(), + } + + filePlugin.Process(ctx, &bus.Message{Topic: bus.ReloadSuccessfulTopic, Data: &model.ReloadSuccess{ + ConfigContext: &model.NginxConfigContext{}, + DataPlaneResponse: expectedResponse, + }}) + + messages := messagePipe.Messages() + + watchers, ok := messages[0].Data.(*model.EnableWatchers) + assert.True(t, ok) + assert.Equal(t, bus.EnableWatchersTopic, messages[0].Topic) + assert.Equal(t, &model.NginxConfigContext{}, watchers.ConfigContext) + assert.Equal(t, instance.GetInstanceMeta().GetInstanceId(), watchers.InstanceID) + + response, ok := messages[1].Data.(*mpi.DataPlaneResponse) + assert.True(t, ok) + assert.Equal(t, bus.DataPlaneResponseTopic, messages[1].Topic) + + assert.Equal(t, expectedResponse.GetCommandResponse().GetStatus(), response.GetCommandResponse().GetStatus()) + assert.Equal(t, expectedResponse.GetCommandResponse().GetMessage(), response.GetCommandResponse().GetMessage()) + assert.Equal(t, expectedResponse.GetCommandResponse().GetError(), response.GetCommandResponse().GetError()) + assert.Equal(t, expectedResponse.GetMessageMeta().GetCorrelationId(), response.GetMessageMeta().GetCorrelationId()) + + assert.Equal(t, expectedResponse.GetInstanceId(), response.GetInstanceId()) +} + +func TestFilePlugin_Process_ConfigApplyCompleteTopic(t *testing.T) { + ctx := context.Background() + instance := protos.NginxOssInstance([]string{}) + mockFileManager := &filefakes.FakeFileManagerServiceInterface{} + + messagePipe := busfakes.NewFakeMessagePipe() + agentConfig := types.AgentConfig() + fakeGrpcConnection := &grpcfakes.FakeGrpcConnectionInterface{} + filePlugin := NewFilePlugin(agentConfig, fakeGrpcConnection, model.Command, &sync.RWMutex{}) + + err := filePlugin.Init(ctx, messagePipe) + require.NoError(t, err) + filePlugin.fileManagerService = mockFileManager + expectedResponse := &mpi.DataPlaneResponse{ + MessageMeta: &mpi.MessageMeta{ + MessageId: id.GenerateMessageID(), + CorrelationId: "dfsbhj6-bc92-30c1-a9c9-85591422068e", + Timestamp: timestamppb.Now(), + }, + CommandResponse: &mpi.CommandResponse{ + Status: mpi.CommandResponse_COMMAND_STATUS_OK, + Message: "Config apply successful", + Error: "", + }, + InstanceId: instance.GetInstanceMeta().GetInstanceId(), + } + + filePlugin.Process(ctx, &bus.Message{Topic: bus.ConfigApplyCompleteTopic, Data: expectedResponse}) + + messages := messagePipe.Messages() + response, ok := messages[0].Data.(*mpi.DataPlaneResponse) + assert.True(t, ok) + + assert.Equal(t, expectedResponse.GetCommandResponse().GetStatus(), response.GetCommandResponse().GetStatus()) + assert.Equal(t, expectedResponse.GetCommandResponse().GetMessage(), response.GetCommandResponse().GetMessage()) + assert.Equal(t, expectedResponse.GetCommandResponse().GetError(), response.GetCommandResponse().GetError()) + assert.Equal(t, expectedResponse.GetMessageMeta().GetCorrelationId(), response.GetMessageMeta().GetCorrelationId()) + + assert.Equal(t, expectedResponse.GetInstanceId(), response.GetInstanceId()) +} diff --git a/internal/file/file_service_operator_test.go b/internal/file/file_service_operator_test.go new file mode 100644 index 000000000..f8206e145 --- /dev/null +++ b/internal/file/file_service_operator_test.go @@ -0,0 +1,189 @@ +// 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 file + +import ( + "context" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + mpi "github.com/nginx/agent/v3/api/grpc/mpi/v1" + "github.com/nginx/agent/v3/api/grpc/mpi/v1/v1fakes" + "github.com/nginx/agent/v3/pkg/files" + "github.com/nginx/agent/v3/test/helpers" + "github.com/nginx/agent/v3/test/protos" + "github.com/nginx/agent/v3/test/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFileServiceOperator_UpdateOverview(t *testing.T) { + ctx := context.Background() + + filePath := filepath.Join(t.TempDir(), "nginx.conf") + fileMeta := protos.FileMeta(filePath, "") + + fileContent := []byte("location /test {\n return 200 \"Test location\\n\";\n}") + fileHash := files.GenerateHash(fileContent) + + fileWriteErr := os.WriteFile(filePath, fileContent, 0o600) + require.NoError(t, fileWriteErr) + + overview := protos.FileOverview(filePath, fileHash) + + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + fakeFileServiceClient.UpdateOverviewReturnsOnCall(0, &mpi.UpdateOverviewResponse{ + Overview: overview, + }, nil) + + fakeFileServiceClient.UpdateOverviewReturnsOnCall(1, &mpi.UpdateOverviewResponse{}, nil) + + fakeFileServiceClient.UpdateFileReturns(&mpi.UpdateFileResponse{}, nil) + + fileServiceOperator := NewFileServiceOperator(types.AgentConfig(), fakeFileServiceClient, &sync.RWMutex{}) + fileServiceOperator.SetIsConnected(true) + + err := fileServiceOperator.UpdateOverview(ctx, "123", []*mpi.File{ + { + FileMeta: fileMeta, + }, + }, filePath, 0) + + require.NoError(t, err) + assert.Equal(t, 2, fakeFileServiceClient.UpdateOverviewCallCount()) +} + +func TestFileServiceOperator_UpdateOverview_MaxIterations(t *testing.T) { + ctx := context.Background() + + filePath := filepath.Join(t.TempDir(), "nginx.conf") + fileMeta := protos.FileMeta(filePath, "") + + fileContent := []byte("location /test {\n return 200 \"Test location\\n\";\n}") + fileHash := files.GenerateHash(fileContent) + + fileWriteErr := os.WriteFile(filePath, fileContent, 0o600) + require.NoError(t, fileWriteErr) + + overview := protos.FileOverview(filePath, fileHash) + + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + + // do 5 iterations + for i := range 6 { + fakeFileServiceClient.UpdateOverviewReturnsOnCall(i, &mpi.UpdateOverviewResponse{ + Overview: overview, + }, nil) + } + + fakeFileServiceClient.UpdateFileReturns(&mpi.UpdateFileResponse{}, nil) + + fileServiceOperator := NewFileServiceOperator(types.AgentConfig(), fakeFileServiceClient, &sync.RWMutex{}) + fileServiceOperator.SetIsConnected(true) + + err := fileServiceOperator.UpdateOverview(ctx, "123", []*mpi.File{ + { + FileMeta: fileMeta, + }, + }, filePath, 0) + + require.Error(t, err) + assert.Equal(t, "too many UpdateOverview attempts", err.Error()) +} + +func TestFileServiceOperator_UpdateOverview_NoConnection(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond) + defer cancel() + + filePath := filepath.Join(t.TempDir(), "nginx.conf") + fileMeta := protos.FileMeta(filePath, "") + + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + + agentConfig := types.AgentConfig() + agentConfig.Client.Backoff.MaxElapsedTime = 200 * time.Millisecond + + fileServiceOperator := NewFileServiceOperator(types.AgentConfig(), fakeFileServiceClient, &sync.RWMutex{}) + fileServiceOperator.SetIsConnected(false) + + err := fileServiceOperator.UpdateOverview(ctx, "123", []*mpi.File{ + { + FileMeta: fileMeta, + }, + }, filePath, 0) + + assert.ErrorIs(t, err, context.DeadlineExceeded) +} + +func TestFileManagerService_UpdateFile(t *testing.T) { + tests := []struct { + name string + isCert bool + }{ + { + name: "non-cert", + isCert: false, + }, + { + name: "cert", + isCert: true, + }, + } + + tempDir := os.TempDir() + + for _, test := range tests { + ctx := context.Background() + + testFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx.conf") + + var fileMeta *mpi.FileMeta + if test.isCert { + fileMeta = protos.CertMeta(testFile.Name(), "") + } else { + fileMeta = protos.FileMeta(testFile.Name(), "") + } + + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + fileServiceOperator := NewFileServiceOperator(types.AgentConfig(), fakeFileServiceClient, &sync.RWMutex{}) + fileServiceOperator.SetIsConnected(true) + + err := fileServiceOperator.UpdateFile(ctx, "123", &mpi.File{FileMeta: fileMeta}) + + require.NoError(t, err) + assert.Equal(t, 1, fakeFileServiceClient.UpdateFileCallCount()) + + helpers.RemoveFileWithErrorCheck(t, testFile.Name()) + } +} + +func TestFileManagerService_UpdateFile_LargeFile(t *testing.T) { + ctx := context.Background() + tempDir := os.TempDir() + + testFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx.conf") + writeFileError := os.WriteFile(testFile.Name(), []byte("#test content"), 0o600) + require.NoError(t, writeFileError) + fileMeta := protos.FileMetaLargeFile(testFile.Name(), "") + + fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} + fakeClientStreamingClient := &FakeClientStreamingClient{sendCount: atomic.Int32{}} + fakeFileServiceClient.UpdateFileStreamReturns(fakeClientStreamingClient, nil) + fileServiceOperator := NewFileServiceOperator(types.AgentConfig(), fakeFileServiceClient, &sync.RWMutex{}) + + fileServiceOperator.SetIsConnected(true) + err := fileServiceOperator.UpdateFile(ctx, "123", &mpi.File{FileMeta: fileMeta}) + + require.NoError(t, err) + assert.Equal(t, 0, fakeFileServiceClient.UpdateFileCallCount()) + assert.Equal(t, 14, int(fakeClientStreamingClient.sendCount.Load())) + + helpers.RemoveFileWithErrorCheck(t, testFile.Name()) +} From 3180d832424010a364e5e3b0d713485b329c3fe8 Mon Sep 17 00:00:00 2001 From: Spencer Ugbo Date: Fri, 3 Oct 2025 15:21:14 +0100 Subject: [PATCH 16/27] add codecov comments to PRs --- .codecov.yml | 34 ++++++++++++++++++++++++++++++++++ api/grpc/mpi/v1/command.pb.go | 2 +- api/grpc/mpi/v1/common.pb.go | 2 +- api/grpc/mpi/v1/files.pb.go | 2 +- 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/.codecov.yml b/.codecov.yml index 920aa891c..78b4230de 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -33,6 +33,40 @@ coverage: if_ci_failed: error only_pulls: false +component_management: + default_rules: + statuses: + - type: project + target: 80% + - type: patch + target: 80% + + individual_components: + - component_id: "internal" + name: "Internal Packages" + paths: + - "internal/**" + + - component_id: "pkg" + name: "Public Packages" + paths: + - "pkg/**" + + - component_id: "cmd" + name: "Commands" + paths: + - "cmd/**" + + - component_id: "api" + name: "API" + paths: + - "api/**" + +comment: + layout: "reach,diff,tree,reach" + behavior: default + require_changes: false + # Ignore files or packages matching their paths ignore: - '\.pb\.go$' # Excludes all protobuf generated files diff --git a/api/grpc/mpi/v1/command.pb.go b/api/grpc/mpi/v1/command.pb.go index b5f0346f4..31e94ea7d 100644 --- a/api/grpc/mpi/v1/command.pb.go +++ b/api/grpc/mpi/v1/command.pb.go @@ -8,7 +8,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: mpi/v1/command.proto diff --git a/api/grpc/mpi/v1/common.pb.go b/api/grpc/mpi/v1/common.pb.go index e6d06cf37..b59f47b5a 100644 --- a/api/grpc/mpi/v1/common.pb.go +++ b/api/grpc/mpi/v1/common.pb.go @@ -5,7 +5,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: mpi/v1/common.proto diff --git a/api/grpc/mpi/v1/files.pb.go b/api/grpc/mpi/v1/files.pb.go index 47d1362b7..0223bae3a 100644 --- a/api/grpc/mpi/v1/files.pb.go +++ b/api/grpc/mpi/v1/files.pb.go @@ -5,7 +5,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc (unknown) // source: mpi/v1/files.proto From 7463d3c16308f7a24cd2ac967a660950a140b276 Mon Sep 17 00:00:00 2001 From: Spencer Ugbo Date: Fri, 3 Oct 2025 15:47:01 +0100 Subject: [PATCH 17/27] allow codecov comment without base --- .codecov.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.codecov.yml b/.codecov.yml index 78b4230de..91e99decd 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -66,6 +66,7 @@ comment: layout: "reach,diff,tree,reach" behavior: default require_changes: false + require_base: false # Ignore files or packages matching their paths ignore: From c0bbb100b8d20b34275bd31dc9e2cb5bcc5909f5 Mon Sep 17 00:00:00 2001 From: Spencer Ugbo Date: Mon, 6 Oct 2025 09:43:24 +0100 Subject: [PATCH 18/27] allow commenting without report for head commit --- .codecov.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.codecov.yml b/.codecov.yml index 91e99decd..76f35aa8f 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -67,6 +67,7 @@ comment: behavior: default require_changes: false require_base: false + require_head: false # Ignore files or packages matching their paths ignore: From 768f1eef20711e38bb3861b634ee63c71da7fe8b Mon Sep 17 00:00:00 2001 From: Spencer Ugbo Date: Mon, 6 Oct 2025 10:28:41 +0100 Subject: [PATCH 19/27] change codecov comment layout --- .codecov.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.codecov.yml b/.codecov.yml index 76f35aa8f..f36c668c2 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -17,9 +17,6 @@ coverage: # The allowed coverage decrease before failing the status check threshold: 0% - # Code coverage check behaviour if the CI fails - if_ci_failed: error - # Whether to run coverage checks only on pull requests only_pulls: false @@ -30,7 +27,6 @@ coverage: target: 80% threshold: 0% - if_ci_failed: error only_pulls: false component_management: @@ -63,7 +59,7 @@ component_management: - "api/**" comment: - layout: "reach,diff,tree,reach" + layout: "header.diff,components,files,footer" behavior: default require_changes: false require_base: false From 90823975736693835b34177ae7b47c86583fe085 Mon Sep 17 00:00:00 2001 From: Spencer Ugbo Date: Mon, 6 Oct 2025 11:26:42 +0100 Subject: [PATCH 20/27] try different commmenting method --- .codecov.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.codecov.yml b/.codecov.yml index f36c668c2..ab6c37296 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -25,6 +25,7 @@ coverage: default: + informational: true target: 80% threshold: 0% only_pulls: false From ed37dd44449e29408d3b8b2c898c0ad511aaf8fb Mon Sep 17 00:00:00 2001 From: Spencer Ugbo Date: Mon, 6 Oct 2025 13:36:47 +0100 Subject: [PATCH 21/27] refactor codecov config file to make components more granular --- .codecov.yml | 86 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 75 insertions(+), 11 deletions(-) diff --git a/.codecov.yml b/.codecov.yml index ab6c37296..3db9c7d2a 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -25,7 +25,6 @@ coverage: default: - informational: true target: 80% threshold: 0% only_pulls: false @@ -39,23 +38,88 @@ component_management: target: 80% individual_components: - - component_id: "internal" - name: "Internal Packages" + # Internal packages + - component_id: "internal-backoff" paths: - - "internal/**" + - "internal/backoff/**" - - component_id: "pkg" - name: "Public Packages" + - component_id: "internal-bus" paths: - - "pkg/**" + - "internal/bus/**" - - component_id: "cmd" - name: "Commands" + - component_id: "internal-collector" paths: - - "cmd/**" + - "internal/collector/**" + + - component_id: "internal-command" + paths: + - "internal/command/**" + + - component_id: "internal-config" + paths: + - "internal/config/**" + + - component_id: "internal-datasource" + paths: + - "internal/datasource/**" + + - component_id: "internal-file" + paths: + - "internal/file/**" + + - component_id: "internal-grpc" + paths: + - "internal/grpc/**" + + - component_id: "internal-logger" + paths: + - "internal/logger/**" + + - component_id: "internal-model" + paths: + - "internal/model/**" + + - component_id: "internal-plugin" + paths: + - "internal/plugin/**" + + - component_id: "internal-resource" + paths: + - "internal/resource/**" + + - component_id: "internal-watcher" + paths: + - "internal/watcher/**" + + - component_id: "pkg-config" + paths: + - "pkg/config/**" + + - component_id: "pkg-files" + paths: + - "pkg/files/**" + + - component_id: "pkg-host" + paths: + - "pkg/host/**" + + - component_id: "pkg-id" + paths: + - "pkg/id/**" + + - component_id: "pkg-nginxprocess" + paths: + - "pkg/nginxprocess/**" + + - component_id: "pkg-tls" + paths: + - "pkg/tls/**" + + - component_id: "cmd-agent" + paths: + - "cmd/agent/**" - component_id: "api" - name: "API" paths: - "api/**" From f7cc156ea14184676e858ccc1c4c468e43fbca9c Mon Sep 17 00:00:00 2001 From: Spencer Ugbo Date: Mon, 6 Oct 2025 13:49:25 +0100 Subject: [PATCH 22/27] fix typo in codecov config --- .codecov.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.codecov.yml b/.codecov.yml index 3db9c7d2a..0fe36c596 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -124,7 +124,7 @@ component_management: - "api/**" comment: - layout: "header.diff,components,files,footer" + layout: "header,diff,components,files,footer" behavior: default require_changes: false require_base: false From 9ee2ba40cc7b956e93b0e1f04398ae9504961946 Mon Sep 17 00:00:00 2001 From: Spencer Ugbo Date: Mon, 6 Oct 2025 15:03:59 +0100 Subject: [PATCH 23/27] remove status check on each component --- .codecov.yml | 49 ++++++++++++++++++++----------------------------- 1 file changed, 20 insertions(+), 29 deletions(-) diff --git a/.codecov.yml b/.codecov.yml index 0fe36c596..98c9a2d21 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -31,15 +31,10 @@ coverage: component_management: default_rules: - statuses: - - type: project - target: 80% - - type: patch - target: 80% + statuses: [] individual_components: - # Internal packages - - component_id: "internal-backoff" + - component_id: "internal/backoff" paths: - "internal/backoff/**" @@ -47,75 +42,71 @@ component_management: paths: - "internal/bus/**" - - component_id: "internal-collector" + - component_id: "internal/collector" paths: - "internal/collector/**" - - component_id: "internal-command" + - component_id: "internal/command" paths: - "internal/command/**" - - component_id: "internal-config" + - component_id: "internal/config" paths: - "internal/config/**" - - component_id: "internal-datasource" + - component_id: "internal/datasource" paths: - "internal/datasource/**" - - component_id: "internal-file" + - component_id: "internal/file" paths: - "internal/file/**" - - component_id: "internal-grpc" + - component_id: "internal/grpc" paths: - "internal/grpc/**" - - component_id: "internal-logger" + - component_id: "internal/logger" paths: - "internal/logger/**" - - component_id: "internal-model" + - component_id: "internal/model" paths: - "internal/model/**" - - component_id: "internal-plugin" + - component_id: "internal/plugin" paths: - "internal/plugin/**" - - component_id: "internal-resource" + - component_id: "internal/resource" paths: - "internal/resource/**" - - component_id: "internal-watcher" + - component_id: "internal/watcher" paths: - "internal/watcher/**" - - component_id: "pkg-config" + - component_id: "pkg/config" paths: - "pkg/config/**" - - component_id: "pkg-files" + - component_id: "pkg/files" paths: - "pkg/files/**" - - component_id: "pkg-host" + - component_id: "pkg/host" paths: - "pkg/host/**" - - component_id: "pkg-id" + - component_id: "pkg/id" paths: - "pkg/id/**" - - component_id: "pkg-nginxprocess" - paths: - - "pkg/nginxprocess/**" - - - component_id: "pkg-tls" + - component_id: "pkg/tls" paths: - "pkg/tls/**" - - component_id: "cmd-agent" + - component_id: "cmd/agent" paths: - "cmd/agent/**" @@ -128,7 +119,7 @@ comment: behavior: default require_changes: false require_base: false - require_head: false + require_head: true # Ignore files or packages matching their paths ignore: From 3e63f8a0750c1c7eed897d298d19589d6b1ca398 Mon Sep 17 00:00:00 2001 From: Spencer Ugbo Date: Mon, 6 Oct 2025 16:14:58 +0100 Subject: [PATCH 24/27] change component status check to informational --- .codecov.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.codecov.yml b/.codecov.yml index 98c9a2d21..d640da824 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -31,14 +31,18 @@ coverage: component_management: default_rules: - statuses: [] + statuses: + - type: project + informational: true + - type: patch + informational: true individual_components: - component_id: "internal/backoff" paths: - "internal/backoff/**" - - component_id: "internal-bus" + - component_id: "internal/bus" paths: - "internal/bus/**" From e5d388806f5dd62b92407209eba8f9643f85ef1d Mon Sep 17 00:00:00 2001 From: Spencer Ugbo Date: Mon, 6 Oct 2025 16:20:13 +0100 Subject: [PATCH 25/27] try to use default comment settings --- .codecov.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.codecov.yml b/.codecov.yml index d640da824..bca52f5c4 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -118,12 +118,6 @@ component_management: paths: - "api/**" -comment: - layout: "header,diff,components,files,footer" - behavior: default - require_changes: false - require_base: false - require_head: true # Ignore files or packages matching their paths ignore: From 241f66553f6d425cf3403243e8b1d2ac1b7712ca Mon Sep 17 00:00:00 2001 From: Spencer Ugbo Date: Mon, 6 Oct 2025 16:55:19 +0100 Subject: [PATCH 26/27] use working codecov conf for testing --- .codecov.yml | 132 ++------------------------------------------------- 1 file changed, 5 insertions(+), 127 deletions(-) diff --git a/.codecov.yml b/.codecov.yml index bca52f5c4..3cac278df 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -1,135 +1,13 @@ -# Codecov configuration file -# This file configures code coverage reporting and requirements for the project coverage: - - # Coverage status configuration status: - - # Project-level coverage settings project: - - # Default status check configuration default: - - # The minimum required coverage value for the project - target: 80% - - # The allowed coverage decrease before failing the status check + informational: true + target: auto threshold: 0% - - # Whether to run coverage checks only on pull requests - only_pulls: false - - # Patch-level coverage settings patch: - default: - - target: 80% + informational: true + target: auto threshold: 0% - only_pulls: false - -component_management: - default_rules: - statuses: - - type: project - informational: true - - type: patch - informational: true - - individual_components: - - component_id: "internal/backoff" - paths: - - "internal/backoff/**" - - - component_id: "internal/bus" - paths: - - "internal/bus/**" - - - component_id: "internal/collector" - paths: - - "internal/collector/**" - - - component_id: "internal/command" - paths: - - "internal/command/**" - - - component_id: "internal/config" - paths: - - "internal/config/**" - - - component_id: "internal/datasource" - paths: - - "internal/datasource/**" - - - component_id: "internal/file" - paths: - - "internal/file/**" - - - component_id: "internal/grpc" - paths: - - "internal/grpc/**" - - - component_id: "internal/logger" - paths: - - "internal/logger/**" - - - component_id: "internal/model" - paths: - - "internal/model/**" - - - component_id: "internal/plugin" - paths: - - "internal/plugin/**" - - - component_id: "internal/resource" - paths: - - "internal/resource/**" - - - component_id: "internal/watcher" - paths: - - "internal/watcher/**" - - - component_id: "pkg/config" - paths: - - "pkg/config/**" - - - component_id: "pkg/files" - paths: - - "pkg/files/**" - - - component_id: "pkg/host" - paths: - - "pkg/host/**" - - - component_id: "pkg/id" - paths: - - "pkg/id/**" - - - component_id: "pkg/tls" - paths: - - "pkg/tls/**" - - - component_id: "cmd/agent" - paths: - - "cmd/agent/**" - - - component_id: "api" - paths: - - "api/**" - - -# Ignore files or packages matching their paths -ignore: - - '\.pb\.go$' # Excludes all protobuf generated files - - '\.gen\.go' # Excludes generated files - - '^fake_.*\.go' # Excludes fakes - - '^test/.*$' - - 'app.go' # app.go and main.go should be tested by integration tests. - - 'main.go' - # ignore metadata generated files - - 'metadata/generated_.*\.go' - # ignore wrappers around gopsutil - - 'internal/datasource/host' - - 'internal/watcher/process' - - 'pkg/nginxprocess' + changes: false From fdc6eadb3f059e671454c0013ed6b7d1ebb927e5 Mon Sep 17 00:00:00 2001 From: Spencer Ugbo Date: Tue, 7 Oct 2025 09:58:13 +0100 Subject: [PATCH 27/27] change target to 80% --- .codecov.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.codecov.yml b/.codecov.yml index 3cac278df..2f73cbfd7 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -3,11 +3,11 @@ coverage: project: default: informational: true - target: auto + target: 80% threshold: 0% patch: default: informational: true - target: auto + target: 80% threshold: 0% changes: false