From 48d4f4d602def84bfb77f66be2b54580aef377f0 Mon Sep 17 00:00:00 2001 From: Donal Hurley Date: Thu, 5 Jun 2025 10:17:55 +0100 Subject: [PATCH 01/28] Update branch name references from v3 to dev-v2 (#1113) --- .github/release-drafter.yml | 32 +++++++ .github/release.yml | 26 ++++++ .github/workflows/ci.yml | 2 +- .github/workflows/codeql.yml | 111 ++++++++++++++++++++++++ .github/workflows/dependency-review.yml | 30 +++++++ .github/workflows/scorecards.yml | 61 +++++++++++++ 6 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 .github/release-drafter.yml create mode 100644 .github/release.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/dependency-review.yml create mode 100644 .github/workflows/scorecards.yml diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 000000000..63a127a5e --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,32 @@ +autolabeler: + - label: 'documentation' + files: + - '*.md' + branch: + - '/docs{0,1}\/.+/' + - label: 'chore' + branch: + - '/chore\/.+/' + files: + - '*.go' + - label: 'bug' + branch: + - '/fix\/.+/' + title: + - '/fix/i' + - label: 'enhancement' + branch: + - '/enh\/.+/' + - '/enhancement\/.+/' + - '/feat\/.+/' + - '/feature\/.+/' + title: + - '/feat/i' + - label: 'dependencies' + files: + - 'go.mod' + - 'go.sum' + - 'vendor*' + branch: + - '/deps\/.+/' +template: "not used, but required" diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 000000000..00b750438 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,26 @@ +changelog: + exclude: + labels: + - skip-changelog + categories: + - title: 🌟 Highlights + labels: + - highlights + - title: 🚀 Features + labels: + - enhancement + - title: đŸ’Ŗ Breaking Changes + labels: + - change + - title: 🐛 Bug Fixes + labels: + - bug + - title: 📝 Documentation + labels: + - documentation + - title: 🔨 Maintenance + labels: + - chore + - title: âŦ†ī¸ Dependencies + labels: + - dependencies diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e1559765..d6b4ce93b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI on: push: branches: - - 'v3' + - 'main' - 'release-*' paths-ignore: - "**.md" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..d8f17cb1b --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,111 @@ +name: "CodeQL" + +on: + push: + branches: + - main + - release-* + - dev-v2 + pull_request: + # The branches below must be a subset of the branches above + branches: + - main + - dev-v2 + paths-ignore: + - '**/vendor' + merge_group: + schedule: + - cron: "36 6 * * 4" # run every Thursday at 06:36 UTC + +concurrency: + group: ${{ github.ref_name }}-codeql + cancel-in-progress: true + +permissions: + contents: read + +jobs: + checks: + name: Checks and variables + runs-on: ubuntu-24.04 + outputs: + docs_only: ${{ github.event.pull_request && steps.docs.outputs.docs_only == 'true' }} + steps: + - name: Checkout Repository + uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + with: + fetch-depth: 0 + + - name: Filter only docs changes + id: docs + run: | + files=$(git diff --name-only HEAD^ | egrep -v "^site/" | egrep -v "^examples/" | egrep -v "^README.md") + if [ -z "$files" ]; then + echo "docs_only=true" >> $GITHUB_OUTPUT + else + echo "docs_only=false" >> $GITHUB_OUTPUT + fi + echo $files + cat $GITHUB_OUTPUT + shell: bash --noprofile --norc -o pipefail {0} + + analyze: + if: ${{ needs.checks.outputs.docs_only != 'true' }} + needs: [checks] + permissions: + actions: read # for github/codeql-action/init to get workflow details + contents: read # for actions/checkout to fetch code + security-events: write # for github/codeql-action/autobuild to send a status report + name: Analyze + runs-on: ubuntu-24.04 + + strategy: + fail-fast: false + matrix: + language: ["go"] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby', 'swift' ] + # Use only 'java' to analyze code written in Java, Kotlin or both + # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + - name: Setup Golang Environment + uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 + with: + go-version-file: go.mod + if: matrix.language == 'go' + + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3 + + # â„šī¸ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 000000000..77374be6f --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,30 @@ +name: "Dependency Review" +on: + pull_request: + branches: + - main + - release-* + - dev-v2 + merge_group: + +concurrency: + group: ${{ github.ref_name }}-deps-review + cancel-in-progress: true + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-24.04 + permissions: + contents: read # for actions/checkout + pull-requests: write # for actions/dependency-review-action to post comments + steps: + - name: "Checkout Repository" + uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + + - name: "Dependency Review" + uses: actions/dependency-review-action@5bbc3ba658137598168acb2ab73b21c432dd411b # v4.2.5 + with: + config-file: "nginxinc/k8s-common/dependency-review-config.yml@main" diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml new file mode 100644 index 000000000..4478fb7df --- /dev/null +++ b/.github/workflows/scorecards.yml @@ -0,0 +1,61 @@ + +name: OpenSSF Scorecards +on: + # For Branch-Protection check. Only the default branch is supported. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection + branch_protection_rule: + # To guarantee Maintained check is occasionally updated. See + # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained + schedule: + - cron: "43 20 * * 0" # run every Sunday at 20:43 UTC + push: + branches: + - main + - dev-v2 + +# Declare default permissions as read only. +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-24.04 + permissions: + # Needed to upload the results to code-scanning dashboard. + security-events: write + # Needed to publish results and get a badge (see publish_results below). + id-token: write + + steps: + - name: "Checkout code" + uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 + with: + persist-credentials: false + + - name: "Run analysis" + uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1 + with: + results_file: results.sarif + results_format: sarif + repo_token: ${{ secrets.SCORECARD_TOKEN }} + + # Publish the results for public repositories to enable scorecard badges. For more details, see + # https://github.com/ossf/scorecard-action#publishing-results. + # For private repositories, `publish_results` will automatically be set to `false`, regardless + # of the value entered here. + publish_results: true + + # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF + # format to the repository Actions tab. + - name: "Upload artifact" + uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 + with: + name: SARIF file + path: results.sarif + retention-days: 5 + + # Upload the results to GitHub's code scanning dashboard. + - name: "Upload to code-scanning" + uses: github/codeql-action/upload-sarif@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3 + with: + sarif_file: results.sarif From 30b6a16e3403ddc21ed0981e5de8d49c3c26e26d Mon Sep 17 00:00:00 2001 From: Nutsa Bidzishvili Date: Thu, 5 Jun 2025 11:27:54 +0100 Subject: [PATCH 02/28] Cleanup integration tests (#1066) Co-authored-by: Aphral Griffin --- Makefile | 4 +- test/helpers/test_containers_utils.go | 8 +- .../grpc_management_plane_api_test.go | 822 ------------------ .../install_uninstall_test.go | 6 +- .../managementplane/config_apply_test.go | 157 ++++ .../managementplane/config_upload_test.go | 67 ++ .../managementplane/file_watcher_test.go | 43 + .../grpc_management_plane_api_test.go | 98 +++ .../nginx_less_mpi_connection_test.go | 9 +- test/integration/utils/config_apply_utils.go | 86 ++ .../utils/grpc_management_plane_utils.go | 486 +++++++++++ 11 files changed, 952 insertions(+), 834 deletions(-) delete mode 100644 test/integration/grpc_management_plane_api_test.go rename test/integration/{ => installuninstall}/install_uninstall_test.go (98%) create mode 100644 test/integration/managementplane/config_apply_test.go create mode 100644 test/integration/managementplane/config_upload_test.go create mode 100644 test/integration/managementplane/file_watcher_test.go create mode 100644 test/integration/managementplane/grpc_management_plane_api_test.go rename test/integration/{ => nginxless}/nginx_less_mpi_connection_test.go (67%) create mode 100644 test/integration/utils/config_apply_utils.go create mode 100644 test/integration/utils/grpc_management_plane_utils.go diff --git a/Makefile b/Makefile index d63874f5a..f2c0bb40a 100644 --- a/Makefile +++ b/Makefile @@ -159,13 +159,13 @@ integration-test: $(SELECTED_PACKAGE) build-mock-management-plane-grpc TEST_ENV="Container" CONTAINER_OS_TYPE=$(CONTAINER_OS_TYPE) BUILD_TARGET="install-agent-local" CONTAINER_NGINX_IMAGE_REGISTRY=${CONTAINER_NGINX_IMAGE_REGISTRY} \ PACKAGES_REPO=$(OSS_PACKAGES_REPO) PACKAGE_NAME=$(PACKAGE_NAME) BASE_IMAGE=$(BASE_IMAGE) DOCKERFILE_PATH=$(DOCKERFILE_PATH) IMAGE_PATH=$(IMAGE_PATH) TAG=${IMAGE_TAG} \ OS_VERSION=$(OS_VERSION) OS_RELEASE=$(OS_RELEASE) \ - go test -v ./test/integration + go test -v ./test/integration/installuninstall ./test/integration/managementplane ./test/integration/nginxless official-image-integration-test: $(SELECTED_PACKAGE) build-mock-management-plane-grpc TEST_ENV="Container" CONTAINER_OS_TYPE=$(CONTAINER_OS_TYPE) CONTAINER_NGINX_IMAGE_REGISTRY=${CONTAINER_NGINX_IMAGE_REGISTRY} BUILD_TARGET="install" \ PACKAGES_REPO=$(OSS_PACKAGES_REPO) TAG=${TAG} PACKAGE_NAME=$(PACKAGE_NAME) BASE_IMAGE=$(BASE_IMAGE) DOCKERFILE_PATH=$(OFFICIAL_IMAGE_DOCKERFILE_PATH) \ OS_VERSION=$(OS_VERSION) OS_RELEASE=$(OS_RELEASE) IMAGE_PATH=$(IMAGE_PATH) \ - go test -v ./test/integration/grpc_management_plane_api_test.go + go test -v ./test/integration/managementplane performance-test: @mkdir -p $(TEST_BUILD_DIR) diff --git a/test/helpers/test_containers_utils.go b/test/helpers/test_containers_utils.go index 98a156fa8..60f4c3000 100644 --- a/test/helpers/test_containers_utils.go +++ b/test/helpers/test_containers_utils.go @@ -46,7 +46,7 @@ func StartContainer( req := testcontainers.ContainerRequest{ FromDockerfile: testcontainers.FromDockerfile{ - Context: "../../", + Context: "../../../", Dockerfile: dockerfilePath, KeepImage: false, PrintBuildLog: true, @@ -118,7 +118,7 @@ func StartAgentlessContainer( req := testcontainers.ContainerRequest{ FromDockerfile: testcontainers.FromDockerfile{ - Context: "../../", + Context: "../../../", Dockerfile: dockerfilePath, KeepImage: false, PrintBuildLog: true, @@ -179,7 +179,7 @@ func StartNginxLessContainer( req := testcontainers.ContainerRequest{ FromDockerfile: testcontainers.FromDockerfile{ - Context: "../../", + Context: "../../../", Dockerfile: dockerfilePath, KeepImage: false, PrintBuildLog: true, @@ -236,7 +236,7 @@ func StartMockManagementPlaneGrpcContainer( req := testcontainers.ContainerRequest{ FromDockerfile: testcontainers.FromDockerfile{ - Context: "../../", + Context: "../../../", Dockerfile: "./test/mock/grpc/Dockerfile", KeepImage: false, PrintBuildLog: true, diff --git a/test/integration/grpc_management_plane_api_test.go b/test/integration/grpc_management_plane_api_test.go deleted file mode 100644 index 10ffb622d..000000000 --- a/test/integration/grpc_management_plane_api_test.go +++ /dev/null @@ -1,822 +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 integration - -import ( - "context" - "encoding/json" - "fmt" - "net" - "net/http" - "os" - "slices" - "sort" - "testing" - "time" - - "github.com/go-resty/resty/v2" - mpi "github.com/nginx/agent/v3/api/grpc/mpi/v1" - "github.com/nginx/agent/v3/test/helpers" - mockGrpc "github.com/nginx/agent/v3/test/mock/grpc" - "google.golang.org/grpc" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/network" - "google.golang.org/protobuf/encoding/protojson" -) - -const ( - configApplyErrorMessage = "failed to parse config invalid " + - "number of arguments in \"worker_processes\" directive in /etc/nginx/nginx.conf:1" - - retryCount = 8 - retryWaitTime = 5 * time.Second - retryMaxWaitTime = 6 * time.Second -) - -var ( - container testcontainers.Container - mockManagementPlaneGrpcContainer testcontainers.Container - mockManagementPlaneGrpcAddress string - mockManagementPlaneAPIAddress string -) - -type ( - ConnectionRequest struct { - ConnectionRequest *mpi.CreateConnectionRequest `json:"connectionRequest"` - } - Instance struct { - InstanceMeta *mpi.InstanceMeta `json:"instance_meta"` - InstanceRuntime *mpi.InstanceRuntime `json:"instance_runtime"` - } - NginxUpdateDataPlaneHealthRequest struct { - MessageMeta *mpi.MessageMeta `json:"message_meta"` - Instances []Instance `json:"instances"` - } - UpdateDataPlaneStatusRequest struct { - UpdateDataPlaneStatusRequest NginxUpdateDataPlaneHealthRequest `json:"updateDataPlaneStatusRequest"` - } -) - -func setupConnectionTest(tb testing.TB, expectNoErrorsInLogs, nginxless bool, agentConfig string) func(tb testing.TB) { - tb.Helper() - ctx := context.Background() - - if os.Getenv("TEST_ENV") == "Container" { - setupContainerEnvironment(ctx, tb, nginxless, agentConfig) - } else { - setupLocalEnvironment(tb) - } - - return func(tb testing.TB) { - tb.Helper() - - if os.Getenv("TEST_ENV") == "Container" { - helpers.LogAndTerminateContainers( - ctx, - tb, - mockManagementPlaneGrpcContainer, - container, - expectNoErrorsInLogs, - ) - } - } -} - -// setupContainerEnvironment sets up the container environment for testing. -func setupContainerEnvironment(ctx context.Context, tb testing.TB, nginxless bool, agentConfig string) { - tb.Helper() - tb.Log("Running tests in a container environment") - - containerNetwork := createContainerNetwork(ctx, tb) - setupMockManagementPlaneGrpc(ctx, tb, containerNetwork) - - params := &helpers.Parameters{ - NginxAgentConfigPath: agentConfig, - LogMessage: "Agent connected", - } - switch nginxless { - case true: - container = helpers.StartNginxLessContainer(ctx, tb, containerNetwork, params) - case false: - setupNginxContainer(ctx, tb, containerNetwork, params) - } -} - -// createContainerNetwork creates and configures a container network. -func createContainerNetwork(ctx context.Context, tb testing.TB) *testcontainers.DockerNetwork { - tb.Helper() - containerNetwork, err := network.New(ctx, network.WithAttachable()) - require.NoError(tb, err) - tb.Cleanup(func() { - networkErr := containerNetwork.Remove(ctx) - tb.Logf("Error removing container network: %v", networkErr) - }) - - return containerNetwork -} - -// setupMockManagementPlaneGrpc initializes the mock management plane gRPC container. -func setupMockManagementPlaneGrpc(ctx context.Context, tb testing.TB, containerNetwork *testcontainers.DockerNetwork) { - tb.Helper() - mockManagementPlaneGrpcContainer = helpers.StartMockManagementPlaneGrpcContainer(ctx, tb, containerNetwork) - mockManagementPlaneGrpcAddress = "managementPlane:9092" - tb.Logf("Mock management gRPC server running on %s", mockManagementPlaneGrpcAddress) - - ipAddress, err := mockManagementPlaneGrpcContainer.Host(ctx) - require.NoError(tb, err) - ports, err := mockManagementPlaneGrpcContainer.Ports(ctx) - require.NoError(tb, err) - - mockManagementPlaneAPIAddress = net.JoinHostPort(ipAddress, ports["9093/tcp"][0].HostPort) - tb.Logf("Mock management API server running on %s", mockManagementPlaneAPIAddress) -} - -// setupNginxContainer configures and starts the NGINX container. -func setupNginxContainer( - ctx context.Context, - tb testing.TB, - containerNetwork *testcontainers.DockerNetwork, - params *helpers.Parameters, -) { - tb.Helper() - nginxConfPath := "../config/nginx/nginx.conf" - if os.Getenv("IMAGE_PATH") == "/nginx-plus/agent" { - nginxConfPath = "../config/nginx/nginx-plus.conf" - } - params.NginxConfigPath = nginxConfPath - - container = helpers.StartContainer(ctx, tb, containerNetwork, params) -} - -// setupLocalEnvironment configures the local testing environment. -func setupLocalEnvironment(tb testing.TB) { - tb.Helper() - tb.Log("Running tests on local machine") - - requestChan := make(chan *mpi.ManagementPlaneRequest) - server := mockGrpc.NewCommandService(requestChan, os.TempDir()) - - go func(tb testing.TB) { - tb.Helper() - - listener, err := net.Listen("tcp", "localhost:0") - assert.NoError(tb, err) - - mockManagementPlaneAPIAddress = listener.Addr().String() - - server.StartServer(listener) - }(tb) - - go func(tb testing.TB) { - tb.Helper() - - listener, err := net.Listen("tcp", "localhost:0") - assert.NoError(tb, err) - var opts []grpc.ServerOption - - grpcServer := grpc.NewServer(opts...) - mpi.RegisterCommandServiceServer(grpcServer, server) - err = grpcServer.Serve(listener) - assert.NoError(tb, err) - - mockManagementPlaneGrpcAddress = listener.Addr().String() - }(tb) -} - -func TestGrpc_Reconnection(t *testing.T) { - ctx := context.Background() - teardownTest := setupConnectionTest(t, false, false, "../config/agent/nginx-config-with-grpc-client.conf") - defer teardownTest(t) - - timeout := 15 * time.Second - - originalID := verifyConnection(t, 2) - - stopErr := mockManagementPlaneGrpcContainer.Stop(ctx, &timeout) - - require.NoError(t, stopErr) - - startErr := mockManagementPlaneGrpcContainer.Start(ctx) - require.NoError(t, startErr) - - ipAddress, err := mockManagementPlaneGrpcContainer.Host(ctx) - require.NoError(t, err) - ports, err := mockManagementPlaneGrpcContainer.Ports(ctx) - require.NoError(t, err) - mockManagementPlaneAPIAddress = net.JoinHostPort(ipAddress, ports["9093/tcp"][0].HostPort) - - currentID := verifyConnection(t, 2) - assert.Equal(t, originalID, currentID) -} - -// Verify that the agent sends a connection request and an update data plane status request -func TestGrpc_StartUp(t *testing.T) { - teardownTest := setupConnectionTest(t, true, false, "../config/agent/nginx-config-with-grpc-client.conf") - defer teardownTest(t) - - verifyConnection(t, 2) - assert.False(t, t.Failed()) - verifyUpdateDataPlaneHealth(t) -} - -func TestGrpc_ConfigUpload(t *testing.T) { - teardownTest := setupConnectionTest(t, true, false, "../config/agent/nginx-config-with-grpc-client.conf") - defer teardownTest(t) - - nginxInstanceID := verifyConnection(t, 2) - assert.False(t, t.Failed()) - - responses := getManagementPlaneResponses(t, 1) - - assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_OK, responses[0].GetCommandResponse().GetStatus()) - assert.Equal(t, "Successfully updated all files", responses[0].GetCommandResponse().GetMessage()) - - request := fmt.Sprintf(`{ - "message_meta": { - "message_id": "5d0fa83e-351c-4009-90cd-1f2acce2d184", - "correlation_id": "79794c1c-8e91-47c1-a92c-b9a0c3f1a263", - "timestamp": "2023-01-15T01:30:15.01Z" - }, - "config_upload_request": { - "overview" : { - "config_version": { - "instance_id": "%s" - } - } - } -}`, nginxInstanceID) - - t.Logf("Sending config upload request: %s", request) - - client := resty.New() - client.SetRetryCount(retryCount).SetRetryWaitTime(retryWaitTime).SetRetryMaxWaitTime(retryMaxWaitTime) - - url := fmt.Sprintf("http://%s/api/v1/requests", mockManagementPlaneAPIAddress) - resp, err := client.R().EnableTrace().SetBody(request).Post(url) - - require.NoError(t, err) - assert.Equal(t, http.StatusOK, resp.StatusCode()) - - responses = getManagementPlaneResponses(t, 2) - - assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_OK, responses[0].GetCommandResponse().GetStatus()) - assert.Equal(t, "Successfully updated all files", responses[0].GetCommandResponse().GetMessage()) - assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_OK, responses[1].GetCommandResponse().GetStatus()) - assert.Equal(t, "Successfully updated all files", responses[1].GetCommandResponse().GetMessage()) -} - -func TestGrpc_ConfigApply(t *testing.T) { - ctx := context.Background() - teardownTest := setupConnectionTest(t, false, false, "../config/agent/nginx-config-with-grpc-client.conf") - defer teardownTest(t) - - nginxInstanceID := verifyConnection(t, 2) - - responses := getManagementPlaneResponses(t, 1) - assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_OK, responses[0].GetCommandResponse().GetStatus()) - assert.Equal(t, "Successfully updated all files", responses[0].GetCommandResponse().GetMessage()) - - t.Run("Test 1: No config changes", func(t *testing.T) { - clearManagementPlaneResponses(t) - performConfigApply(t, nginxInstanceID) - responses = getManagementPlaneResponses(t, 1) - t.Logf("Config apply responses: %v", responses) - - assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_OK, responses[0].GetCommandResponse().GetStatus()) - assert.Equal(t, "Config apply successful, no files to change", responses[0].GetCommandResponse().GetMessage()) - }) - - t.Run("Test 2: Valid config", func(t *testing.T) { - clearManagementPlaneResponses(t) - newConfigFile := "../config/nginx/nginx-with-test-location.conf" - - if os.Getenv("IMAGE_PATH") == "/nginx-plus/agent" { - newConfigFile = "../config/nginx/nginx-plus-with-test-location.conf" - } - - err := mockManagementPlaneGrpcContainer.CopyFileToContainer( - ctx, - newConfigFile, - fmt.Sprintf("/mock-management-plane-grpc/config/%s/etc/nginx/nginx.conf", nginxInstanceID), - 0o666, - ) - require.NoError(t, err) - - performConfigApply(t, nginxInstanceID) - - responses = getManagementPlaneResponses(t, 2) - t.Logf("Config apply responses: %v", responses) - - sort.Slice(responses, func(i, j int) bool { - return responses[i].GetCommandResponse().GetMessage() < responses[j].GetCommandResponse().GetMessage() - }) - - assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_OK, responses[0].GetCommandResponse().GetStatus()) - assert.Equal(t, "Config apply successful", responses[0].GetCommandResponse().GetMessage()) - assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_OK, responses[1].GetCommandResponse().GetStatus()) - assert.Equal(t, "Successfully updated all files", responses[1].GetCommandResponse().GetMessage()) - }) - - t.Run("Test 3: Invalid config", func(t *testing.T) { - clearManagementPlaneResponses(t) - err := mockManagementPlaneGrpcContainer.CopyFileToContainer( - ctx, - "../config/nginx/invalid-nginx.conf", - fmt.Sprintf("/mock-management-plane-grpc/config/%s/etc/nginx/nginx.conf", nginxInstanceID), - 0o666, - ) - require.NoError(t, err) - - performConfigApply(t, nginxInstanceID) - - responses = getManagementPlaneResponses(t, 2) - t.Logf("Config apply responses: %v", responses) - - assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_ERROR, responses[0].GetCommandResponse().GetStatus()) - assert.Equal(t, "Config apply failed, rolling back config", responses[0].GetCommandResponse().GetMessage()) - assert.Equal(t, configApplyErrorMessage, responses[0].GetCommandResponse().GetError()) - assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_FAILURE, responses[1].GetCommandResponse().GetStatus()) - assert.Equal(t, "Config apply failed, rollback successful", responses[1].GetCommandResponse().GetMessage()) - assert.Equal(t, configApplyErrorMessage, responses[1].GetCommandResponse().GetError()) - }) - - t.Run("Test 4: File not in allowed directory", func(t *testing.T) { - clearManagementPlaneResponses(t) - performInvalidConfigApply(t, nginxInstanceID) - - responses = getManagementPlaneResponses(t, 1) - t.Logf("Config apply responses: %v", responses) - - assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_FAILURE, responses[0].GetCommandResponse().GetStatus()) - assert.Equal(t, "Config apply failed", responses[0].GetCommandResponse().GetMessage()) - assert.Equal( - t, - "file not in allowed directories /unknown/nginx.conf", - responses[0].GetCommandResponse().GetError(), - ) - }) -} - -func TestGrpc_FileWatcher(t *testing.T) { - ctx := context.Background() - teardownTest := setupConnectionTest(t, true, false, "../config/agent/nginx-config-with-grpc-client.conf") - defer teardownTest(t) - - verifyConnection(t, 2) - assert.False(t, t.Failed()) - - err := container.CopyFileToContainer( - ctx, - "../config/nginx/nginx-with-server-block-access-log.conf", - "/etc/nginx/nginx.conf", - 0o666, - ) - require.NoError(t, err) - - responses := getManagementPlaneResponses(t, 2) - assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_OK, responses[0].GetCommandResponse().GetStatus()) - assert.Equal(t, "Successfully updated all files", responses[0].GetCommandResponse().GetMessage()) - assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_OK, responses[1].GetCommandResponse().GetStatus()) - assert.Equal(t, "Successfully updated all files", responses[1].GetCommandResponse().GetMessage()) - - verifyUpdateDataPlaneStatus(t) -} - -func TestGrpc_DataplaneHealthRequest(t *testing.T) { - teardownTest := setupConnectionTest(t, true, false, "../config/agent/nginx-config-with-grpc-client.conf") - defer teardownTest(t) - - verifyConnection(t, 2) - - responses := getManagementPlaneResponses(t, 1) - assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_OK, responses[0].GetCommandResponse().GetStatus()) - assert.Equal(t, "Successfully updated all files", responses[0].GetCommandResponse().GetMessage()) - - assert.False(t, t.Failed()) - - request := `{ - "message_meta": { - "message_id": "5d0fa83e-351c-4009-90cd-1f2acce2d184", - "correlation_id": "79794c1c-8e91-47c1-a92c-b9a0c3f1a263", - "timestamp": "2023-01-15T01:30:15.01Z" - }, - "health_request": {} - }` - - client := resty.New() - client.SetRetryCount(retryCount).SetRetryWaitTime(retryWaitTime).SetRetryMaxWaitTime(retryMaxWaitTime) - - url := fmt.Sprintf("http://%s/api/v1/requests", mockManagementPlaneAPIAddress) - resp, err := client.R().EnableTrace().SetBody(request).Post(url) - - require.NoError(t, err) - assert.Equal(t, http.StatusOK, resp.StatusCode()) - - responses = getManagementPlaneResponses(t, 2) - - assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_OK, responses[1].GetCommandResponse().GetStatus()) - assert.Equal(t, "Successfully sent the health status update", responses[1].GetCommandResponse().GetMessage()) -} - -func TestGrpc_ConfigApply_Chunking(t *testing.T) { - ctx := context.Background() - teardownTest := setupConnectionTest(t, false, false, - "../config/agent/nginx-config-with-max-file-size.conf") - defer teardownTest(t) - - nginxInstanceID := verifyConnection(t, 2) - - responses := getManagementPlaneResponses(t, 1) - assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_OK, responses[0].GetCommandResponse().GetStatus()) - assert.Equal(t, "Successfully updated all files", responses[0].GetCommandResponse().GetMessage()) - - clearManagementPlaneResponses(t) - newConfigFile := "../config/nginx/nginx-1mb-file.conf" - - err := mockManagementPlaneGrpcContainer.CopyFileToContainer( - ctx, - newConfigFile, - fmt.Sprintf("/mock-management-plane-grpc/config/%s/etc/nginx/nginx.conf", nginxInstanceID), - 0o666, - ) - require.NoError(t, err) - - performConfigApply(t, nginxInstanceID) - - responses = getManagementPlaneResponses(t, 2) - t.Logf("Config apply responses: %v", responses) - - sort.Slice(responses, func(i, j int) bool { - return responses[i].GetCommandResponse().GetMessage() < responses[j].GetCommandResponse().GetMessage() - }) - - assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_OK, responses[0].GetCommandResponse().GetStatus()) - assert.Equal(t, "Config apply successful", responses[0].GetCommandResponse().GetMessage()) - assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_OK, responses[1].GetCommandResponse().GetStatus()) - assert.Equal(t, "Successfully updated all files", responses[1].GetCommandResponse().GetMessage()) -} - -func performConfigApply(t *testing.T, nginxInstanceID string) { - t.Helper() - - client := resty.New() - client.SetRetryCount(retryCount).SetRetryWaitTime(retryWaitTime).SetRetryMaxWaitTime(retryMaxWaitTime) - - url := fmt.Sprintf("http://%s/api/v1/instance/%s/config/apply", mockManagementPlaneAPIAddress, nginxInstanceID) - resp, err := client.R().EnableTrace().Post(url) - - require.NoError(t, err) - assert.Equal(t, http.StatusOK, resp.StatusCode()) -} - -func performInvalidConfigApply(t *testing.T, nginxInstanceID string) { - t.Helper() - - client := resty.New() - client.SetRetryCount(retryCount).SetRetryWaitTime(retryWaitTime).SetRetryMaxWaitTime(retryMaxWaitTime) - - body := fmt.Sprintf(`{ - "message_meta": { - "message_id": "e2254df9-8edd-4900-91ce-88782473bcb9", - "correlation_id": "9673f3b4-bf33-4d98-ade1-ded9266f6818", - "timestamp": "2023-01-15T01:30:15.01Z" - }, - "config_apply_request": { - "overview": { - "files": [{ - "file_meta": { - "name": "/etc/nginx/nginx.conf", - "hash": "ea57e443-e968-3a50-b842-f37112acde71", - "modifiedTime": "2023-01-15T01:30:15.01Z", - "permissions": "0644", - "size": 0 - }, - "action": "FILE_ACTION_UPDATE" - }, - { - "file_meta": { - "name": "/unknown/nginx.conf", - "hash": "bd1f337d-6874-35ea-9d4d-2b543efd42cf", - "modifiedTime": "2023-01-15T01:30:15.01Z", - "permissions": "0644", - "size": 0 - }, - "action": "FILE_ACTION_ADD" - }], - "config_version": { - "instance_id": "%s", - "version": "6f343257-55e3-309e-a2eb-bb13af5f80f4" - } - } - } - }`, nginxInstanceID) - url := fmt.Sprintf("http://%s/api/v1/requests", mockManagementPlaneAPIAddress) - resp, err := client.R().EnableTrace().SetBody(body).Post(url) - require.NoError(t, err) - assert.Equal(t, http.StatusOK, resp.StatusCode()) -} - -func getManagementPlaneResponses(t *testing.T, numberOfExpectedResponses int) []*mpi.DataPlaneResponse { - t.Helper() - - client := resty.New() - client.SetRetryCount(retryCount).SetRetryWaitTime(retryWaitTime).SetRetryMaxWaitTime(retryMaxWaitTime) - client.AddRetryCondition( - func(r *resty.Response, err error) bool { - responseData := r.Body() - assert.True(t, json.Valid(responseData)) - - response := []*mpi.DataPlaneResponse{} - unmarshalErr := json.Unmarshal(responseData, &response) - require.NoError(t, unmarshalErr) - - return len(response) != numberOfExpectedResponses || r.StatusCode() == http.StatusNotFound - }, - ) - - url := fmt.Sprintf("http://%s/api/v1/responses", mockManagementPlaneAPIAddress) - resp, err := client.R().EnableTrace().Get(url) - - require.NoError(t, err) - assert.Equal(t, http.StatusOK, resp.StatusCode()) - - responseData := resp.Body() - t.Logf("Responses: %s", string(responseData)) - assert.True(t, json.Valid(responseData)) - - response := []*mpi.DataPlaneResponse{} - unmarshalErr := json.Unmarshal(responseData, &response) - require.NoError(t, unmarshalErr) - - assert.Len(t, response, numberOfExpectedResponses) - - slices.SortFunc(response, func(a, b *mpi.DataPlaneResponse) int { - return a.GetMessageMeta().GetTimestamp().AsTime().Compare(b.GetMessageMeta().GetTimestamp().AsTime()) - }) - - return response -} - -func clearManagementPlaneResponses(t *testing.T) { - t.Helper() - - client := resty.New() - - url := fmt.Sprintf("http://%s/api/v1/responses", mockManagementPlaneAPIAddress) - resp, err := client.R().EnableTrace().Delete(url) - - require.NoError(t, err) - assert.Equal(t, http.StatusOK, resp.StatusCode()) -} - -func verifyConnection(t *testing.T, instancesLength int) string { - t.Helper() - - client := resty.New() - client.SetRetryCount(retryCount).SetRetryWaitTime(retryWaitTime).SetRetryMaxWaitTime(retryMaxWaitTime) - connectionRequest := mpi.CreateConnectionRequest{} - client.AddRetryCondition( - func(r *resty.Response, err error) bool { - responseData := r.Body() - - pb := protojson.UnmarshalOptions{DiscardUnknown: true} - unmarshalErr := pb.Unmarshal(responseData, &connectionRequest) - - return r.StatusCode() == http.StatusNotFound || unmarshalErr != nil - }, - ) - url := fmt.Sprintf("http://%s/api/v1/connection", mockManagementPlaneAPIAddress) - t.Logf("Connecting to %s", url) - resp, err := client.R().EnableTrace().Get(url) - - require.NoError(t, err) - assert.Equal(t, http.StatusOK, resp.StatusCode()) - - responseData := resp.Body() - t.Logf("Response: %s", string(responseData)) - assert.True(t, json.Valid(responseData)) - - pb := protojson.UnmarshalOptions{DiscardUnknown: true} - unmarshalErr := pb.Unmarshal(responseData, &connectionRequest) - require.NoError(t, unmarshalErr) - - t.Logf("ConnectionRequest: %v", &connectionRequest) - - resource := connectionRequest.GetResource() - - assert.NotNil(t, resource.GetResourceId()) - assert.NotNil(t, resource.GetContainerInfo().GetContainerId()) - - assert.Len(t, resource.GetInstances(), instancesLength) - - var nginxInstanceID string - - for _, instance := range resource.GetInstances() { - switch instance.GetInstanceMeta().GetInstanceType() { - case mpi.InstanceMeta_INSTANCE_TYPE_AGENT: - agentInstanceMeta := instance.GetInstanceMeta() - - assert.NotEmpty(t, agentInstanceMeta.GetInstanceId()) - assert.NotEmpty(t, agentInstanceMeta.GetVersion()) - - assert.NotEmpty(t, instance.GetInstanceRuntime().GetBinaryPath()) - - assert.Equal(t, "/etc/nginx-agent/nginx-agent.conf", instance.GetInstanceRuntime().GetConfigPath()) - case mpi.InstanceMeta_INSTANCE_TYPE_NGINX: - nginxInstanceMeta := instance.GetInstanceMeta() - - nginxInstanceID = nginxInstanceMeta.GetInstanceId() - assert.NotEmpty(t, nginxInstanceID) - assert.NotEmpty(t, nginxInstanceMeta.GetVersion()) - - assert.NotEmpty(t, instance.GetInstanceRuntime().GetBinaryPath()) - - assert.Equal(t, "/etc/nginx/nginx.conf", instance.GetInstanceRuntime().GetConfigPath()) - case mpi.InstanceMeta_INSTANCE_TYPE_NGINX_PLUS: - nginxInstanceMeta := instance.GetInstanceMeta() - - nginxInstanceID = nginxInstanceMeta.GetInstanceId() - assert.NotEmpty(t, nginxInstanceID) - assert.NotEmpty(t, nginxInstanceMeta.GetVersion()) - - assert.NotEmpty(t, instance.GetInstanceRuntime().GetBinaryPath()) - - assert.Equal(t, "/etc/nginx/nginx.conf", instance.GetInstanceRuntime().GetConfigPath()) - case mpi.InstanceMeta_INSTANCE_TYPE_NGINX_APP_PROTECT: - instanceMeta := instance.GetInstanceMeta() - assert.NotEmpty(t, instanceMeta.GetInstanceId()) - assert.NotEmpty(t, instanceMeta.GetVersion()) - - instanceRuntimeInfo := instance.GetInstanceRuntime().GetNginxAppProtectRuntimeInfo() - assert.NotEmpty(t, instanceRuntimeInfo.GetRelease()) - assert.NotEmpty(t, instanceRuntimeInfo.GetAttackSignatureVersion()) - assert.NotEmpty(t, instanceRuntimeInfo.GetThreatCampaignVersion()) - case mpi.InstanceMeta_INSTANCE_TYPE_UNIT, - mpi.InstanceMeta_INSTANCE_TYPE_UNSPECIFIED: - fallthrough - default: - t.Fail() - } - } - - return nginxInstanceID -} - -func verifyUpdateDataPlaneHealth(t *testing.T) { - t.Helper() - - client := resty.New() - - client.SetRetryCount(retryCount).SetRetryWaitTime(retryWaitTime).SetRetryMaxWaitTime(retryMaxWaitTime) - - client.AddRetryCondition( - - func(r *resty.Response, err error) bool { - return r.StatusCode() == http.StatusNotFound - }, - ) - - url := fmt.Sprintf("http://%s/api/v1/health", mockManagementPlaneAPIAddress) - - resp, err := client.R().EnableTrace().Get(url) - - require.NoError(t, err) - - assert.Equal(t, http.StatusOK, resp.StatusCode()) - - responseData := resp.Body() - - t.Logf("Response: %s", string(responseData)) - - assert.True(t, json.Valid(responseData)) - - pb := protojson.UnmarshalOptions{DiscardUnknown: true} - - updateDataPlaneHealthRequest := mpi.UpdateDataPlaneHealthRequest{} - - unmarshalErr := pb.Unmarshal(responseData, &updateDataPlaneHealthRequest) - - require.NoError(t, unmarshalErr) - - t.Logf("UpdateDataPlaneHealthRequest: %v", &updateDataPlaneHealthRequest) - - assert.NotNil(t, &updateDataPlaneHealthRequest) - - // Verify message metadata - - messageMeta := updateDataPlaneHealthRequest.GetMessageMeta() - - assert.NotEmpty(t, messageMeta.GetCorrelationId()) - - assert.NotEmpty(t, messageMeta.GetMessageId()) - - assert.NotEmpty(t, messageMeta.GetTimestamp()) - - healths := updateDataPlaneHealthRequest.GetInstanceHealths() - - assert.Len(t, healths, 1) - - // Verify health metadata - - assert.NotEmpty(t, healths[0].GetInstanceId()) - - assert.Equal(t, mpi.InstanceHealth_INSTANCE_HEALTH_STATUS_HEALTHY, healths[0].GetInstanceHealthStatus()) -} - -func verifyUpdateDataPlaneStatus(t *testing.T) { - t.Helper() - - client := resty.New() - - client.SetRetryCount(3).SetRetryWaitTime(50 * time.Millisecond).SetRetryMaxWaitTime(200 * time.Millisecond) - - url := fmt.Sprintf("http://%s/api/v1/status", mockManagementPlaneAPIAddress) - - resp, err := client.R().EnableTrace().Get(url) - - require.NoError(t, err) - - assert.Equal(t, http.StatusOK, resp.StatusCode()) - - updateDataPlaneStatusRequest := mpi.UpdateDataPlaneStatusRequest{} - - responseData := resp.Body() - - t.Logf("Response: %s", string(responseData)) - - assert.True(t, json.Valid(responseData)) - - pb := protojson.UnmarshalOptions{DiscardUnknown: true} - - unmarshalErr := pb.Unmarshal(responseData, &updateDataPlaneStatusRequest) - - require.NoError(t, unmarshalErr) - - t.Logf("UpdateDataPlaneStatusRequest: %v", &updateDataPlaneStatusRequest) - - assert.NotNil(t, &updateDataPlaneStatusRequest) - - // Verify message metadata - - messageMeta := updateDataPlaneStatusRequest.GetMessageMeta() - - assert.NotEmpty(t, messageMeta.GetCorrelationId()) - - assert.NotEmpty(t, messageMeta.GetMessageId()) - - assert.NotEmpty(t, messageMeta.GetTimestamp()) - - instances := updateDataPlaneStatusRequest.GetResource().GetInstances() - - sort.Slice(instances, func(i, j int) bool { - return instances[i].GetInstanceMeta().GetInstanceType() < instances[j].GetInstanceMeta().GetInstanceType() - }) - - assert.Len(t, instances, 2) - - // Verify agent instance metadata - - assert.NotEmpty(t, instances[0].GetInstanceMeta().GetInstanceId()) - - assert.Equal(t, mpi.InstanceMeta_INSTANCE_TYPE_AGENT, instances[0].GetInstanceMeta().GetInstanceType()) - - assert.NotEmpty(t, instances[0].GetInstanceMeta().GetVersion()) - - // Verify agent instance configuration - - assert.Empty(t, instances[0].GetInstanceConfig().GetActions()) - - assert.NotEmpty(t, instances[0].GetInstanceRuntime().GetProcessId()) - - assert.Equal(t, "/usr/bin/nginx-agent", instances[0].GetInstanceRuntime().GetBinaryPath()) - - assert.Equal(t, "/etc/nginx-agent/nginx-agent.conf", instances[0].GetInstanceRuntime().GetConfigPath()) - - // Verify NGINX instance metadata - - assert.NotEmpty(t, instances[1].GetInstanceMeta().GetInstanceId()) - - if os.Getenv("IMAGE_PATH") == "/nginx-plus/agent" { - assert.Equal(t, mpi.InstanceMeta_INSTANCE_TYPE_NGINX_PLUS, instances[1].GetInstanceMeta().GetInstanceType()) - } else { - assert.Equal(t, mpi.InstanceMeta_INSTANCE_TYPE_NGINX, instances[1].GetInstanceMeta().GetInstanceType()) - } - - assert.NotEmpty(t, instances[1].GetInstanceMeta().GetVersion()) - - // Verify NGINX instance configuration - - assert.Empty(t, instances[1].GetInstanceConfig().GetActions()) - - assert.NotEmpty(t, instances[1].GetInstanceRuntime().GetProcessId()) - - assert.Equal(t, "/usr/sbin/nginx", instances[1].GetInstanceRuntime().GetBinaryPath()) - - assert.Equal(t, "/etc/nginx/nginx.conf", instances[1].GetInstanceRuntime().GetConfigPath()) -} diff --git a/test/integration/install_uninstall_test.go b/test/integration/installuninstall/install_uninstall_test.go similarity index 98% rename from test/integration/install_uninstall_test.go rename to test/integration/installuninstall/install_uninstall_test.go index 95f8444a3..a220e0091 100644 --- a/test/integration/install_uninstall_test.go +++ b/test/integration/installuninstall/install_uninstall_test.go @@ -3,7 +3,7 @@ // 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 integration +package installuninstall import ( "context" @@ -47,7 +47,7 @@ func installUninstallSetup(tb testing.TB, expectNoErrorsInLogs bool) (testcontai ctx := context.Background() params := &helpers.Parameters{ - NginxConfigPath: "../config/nginx/nginx.conf", + NginxConfigPath: "../../config/nginx/nginx.conf", LogMessage: "nginx_pid", } @@ -90,7 +90,7 @@ func TestInstallUninstall(t *testing.T) { func verifyAgentPackage(tb testing.TB, testContainer testcontainers.Container) string { tb.Helper() - agentPkgPath, filePathErr := filepath.Abs("../../build/") + agentPkgPath, filePathErr := filepath.Abs("../../../build/") require.NoError(tb, filePathErr, "Error finding local agent package build dir") localAgentPkg, packageErr := os.Stat(getPackagePath(agentPkgPath, osRelease)) diff --git a/test/integration/managementplane/config_apply_test.go b/test/integration/managementplane/config_apply_test.go new file mode 100644 index 000000000..a221ceb51 --- /dev/null +++ b/test/integration/managementplane/config_apply_test.go @@ -0,0 +1,157 @@ +// 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 managementplane + +import ( + "context" + "fmt" + "os" + "sort" + "testing" + + "github.com/nginx/agent/v3/test/integration/utils" + + mpi "github.com/nginx/agent/v3/api/grpc/mpi/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + configApplyErrorMessage = "failed to parse config invalid " + + "number of arguments in \"worker_processes\" directive in /etc/nginx/nginx.conf:1" +) + +func TestGrpc_ConfigApply(t *testing.T) { + ctx := context.Background() + teardownTest := utils.SetupConnectionTest(t, false, false, + "../../config/agent/nginx-config-with-grpc-client.conf") + defer teardownTest(t) + + nginxInstanceID := utils.VerifyConnection(t, 2) + + responses := utils.ManagementPlaneResponses(t, 1) + assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_OK, responses[0].GetCommandResponse().GetStatus()) + assert.Equal(t, "Successfully updated all files", responses[0].GetCommandResponse().GetMessage()) + + t.Run("Test 1: No config changes", func(t *testing.T) { + utils.ClearManagementPlaneResponses(t) + utils.PerformConfigApply(t, nginxInstanceID) + responses = utils.ManagementPlaneResponses(t, 1) + t.Logf("Config apply responses: %v", responses) + + assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_OK, responses[0].GetCommandResponse().GetStatus()) + assert.Equal(t, "Config apply successful, no files to change", responses[0].GetCommandResponse().GetMessage()) + }) + + t.Run("Test 2: Valid config", func(t *testing.T) { + utils.ClearManagementPlaneResponses(t) + newConfigFile := "../../config/nginx/nginx-with-test-location.conf" + + if os.Getenv("IMAGE_PATH") == "/nginx-plus/agent" { + newConfigFile = "../../config/nginx/nginx-plus-with-test-location.conf" + } + + err := utils.MockManagementPlaneGrpcContainer.CopyFileToContainer( + ctx, + newConfigFile, + fmt.Sprintf("/mock-management-plane-grpc/config/%s/etc/nginx/nginx.conf", nginxInstanceID), + 0o666, + ) + require.NoError(t, err) + + utils.PerformConfigApply(t, nginxInstanceID) + + responses = utils.ManagementPlaneResponses(t, 2) + t.Logf("Config apply responses: %v", responses) + + sort.Slice(responses, func(i, j int) bool { + return responses[i].GetCommandResponse().GetMessage() < responses[j].GetCommandResponse().GetMessage() + }) + + assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_OK, responses[0].GetCommandResponse().GetStatus()) + assert.Equal(t, "Config apply successful", responses[0].GetCommandResponse().GetMessage()) + assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_OK, responses[1].GetCommandResponse().GetStatus()) + assert.Equal(t, "Successfully updated all files", responses[1].GetCommandResponse().GetMessage()) + }) + + t.Run("Test 3: Invalid config", func(t *testing.T) { + utils.ClearManagementPlaneResponses(t) + err := utils.MockManagementPlaneGrpcContainer.CopyFileToContainer( + ctx, + "../../config/nginx/invalid-nginx.conf", + fmt.Sprintf("/mock-management-plane-grpc/config/%s/etc/nginx/nginx.conf", nginxInstanceID), + 0o666, + ) + require.NoError(t, err) + + utils.PerformConfigApply(t, nginxInstanceID) + + responses = utils.ManagementPlaneResponses(t, 2) + t.Logf("Config apply responses: %v", responses) + + assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_ERROR, responses[0].GetCommandResponse().GetStatus()) + assert.Equal(t, "Config apply failed, rolling back config", responses[0].GetCommandResponse().GetMessage()) + assert.Equal(t, configApplyErrorMessage, responses[0].GetCommandResponse().GetError()) + assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_FAILURE, responses[1].GetCommandResponse().GetStatus()) + assert.Equal(t, "Config apply failed, rollback successful", responses[1].GetCommandResponse().GetMessage()) + assert.Equal(t, configApplyErrorMessage, responses[1].GetCommandResponse().GetError()) + }) + + t.Run("Test 4: File not in allowed directory", func(t *testing.T) { + utils.ClearManagementPlaneResponses(t) + utils.PerformInvalidConfigApply(t, nginxInstanceID) + + responses = utils.ManagementPlaneResponses(t, 1) + t.Logf("Config apply responses: %v", responses) + + assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_FAILURE, responses[0].GetCommandResponse().GetStatus()) + assert.Equal(t, "Config apply failed", responses[0].GetCommandResponse().GetMessage()) + assert.Equal( + t, + "file not in allowed directories /unknown/nginx.conf", + responses[0].GetCommandResponse().GetError(), + ) + }) +} + +func TestGrpc_ConfigApply_Chunking(t *testing.T) { + ctx := context.Background() + teardownTest := utils.SetupConnectionTest(t, false, false, + "../../config/agent/nginx-config-with-max-file-size.conf") + defer teardownTest(t) + + nginxInstanceID := utils.VerifyConnection(t, 2) + + responses := utils.ManagementPlaneResponses(t, 1) + assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_OK, responses[0].GetCommandResponse().GetStatus()) + assert.Equal(t, "Successfully updated all files", responses[0].GetCommandResponse().GetMessage()) + + utils.ClearManagementPlaneResponses(t) + + newConfigFile := "../../config/nginx/nginx-1mb-file.conf" + + err := utils.MockManagementPlaneGrpcContainer.CopyFileToContainer( + ctx, + newConfigFile, + fmt.Sprintf("/mock-management-plane-grpc/config/%s/etc/nginx/nginx.conf", nginxInstanceID), + 0o666, + ) + require.NoError(t, err) + + utils.PerformConfigApply(t, nginxInstanceID) + + responses = utils.ManagementPlaneResponses(t, 2) + t.Logf("Config apply responses: %v", responses) + + sort.Slice(responses, func(i, j int) bool { + return responses[i].GetCommandResponse().GetMessage() < responses[j].GetCommandResponse().GetMessage() + }) + + assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_OK, responses[0].GetCommandResponse().GetStatus()) + assert.Equal(t, "Config apply successful", responses[0].GetCommandResponse().GetMessage()) + assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_OK, responses[1].GetCommandResponse().GetStatus()) + assert.Equal(t, "Successfully updated all files", responses[1].GetCommandResponse().GetMessage()) +} diff --git a/test/integration/managementplane/config_upload_test.go b/test/integration/managementplane/config_upload_test.go new file mode 100644 index 000000000..3841f7d1a --- /dev/null +++ b/test/integration/managementplane/config_upload_test.go @@ -0,0 +1,67 @@ +// 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 managementplane + +import ( + "fmt" + "net/http" + "testing" + + "github.com/nginx/agent/v3/test/integration/utils" + + "github.com/go-resty/resty/v2" + mpi "github.com/nginx/agent/v3/api/grpc/mpi/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGrpc_ConfigUpload(t *testing.T) { + teardownTest := utils.SetupConnectionTest(t, true, false, + "../../config/agent/nginx-config-with-grpc-client.conf") + defer teardownTest(t) + + nginxInstanceID := utils.VerifyConnection(t, 2) + assert.False(t, t.Failed()) + + responses := utils.ManagementPlaneResponses(t, 1) + + assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_OK, responses[0].GetCommandResponse().GetStatus()) + assert.Equal(t, "Successfully updated all files", responses[0].GetCommandResponse().GetMessage()) + + request := fmt.Sprintf(`{ + "message_meta": { + "message_id": "5d0fa83e-351c-4009-90cd-1f2acce2d184", + "correlation_id": "79794c1c-8e91-47c1-a92c-b9a0c3f1a263", + "timestamp": "2023-01-15T01:30:15.01Z" + }, + "config_upload_request": { + "overview" : { + "config_version": { + "instance_id": "%s" + } + } + } +}`, nginxInstanceID) + + t.Logf("Sending config upload request: %s", request) + + client := resty.New() + client.SetRetryCount(utils.RetryCount).SetRetryWaitTime(utils.RetryWaitTime).SetRetryMaxWaitTime( + utils.RetryMaxWaitTime) + + url := fmt.Sprintf("http://%s/api/v1/requests", utils.MockManagementPlaneAPIAddress) + resp, err := client.R().EnableTrace().SetBody(request).Post(url) + + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode()) + + responses = utils.ManagementPlaneResponses(t, 2) + + assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_OK, responses[0].GetCommandResponse().GetStatus()) + assert.Equal(t, "Successfully updated all files", responses[0].GetCommandResponse().GetMessage()) + assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_OK, responses[1].GetCommandResponse().GetStatus()) + assert.Equal(t, "Successfully updated all files", responses[1].GetCommandResponse().GetMessage()) +} diff --git a/test/integration/managementplane/file_watcher_test.go b/test/integration/managementplane/file_watcher_test.go new file mode 100644 index 000000000..40e823997 --- /dev/null +++ b/test/integration/managementplane/file_watcher_test.go @@ -0,0 +1,43 @@ +// 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 managementplane + +import ( + "context" + "testing" + + "github.com/nginx/agent/v3/test/integration/utils" + + mpi "github.com/nginx/agent/v3/api/grpc/mpi/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGrpc_FileWatcher(t *testing.T) { + ctx := context.Background() + teardownTest := utils.SetupConnectionTest(t, true, false, + "../../config/agent/nginx-config-with-grpc-client.conf") + defer teardownTest(t) + + utils.VerifyConnection(t, 2) + assert.False(t, t.Failed()) + + err := utils.Container.CopyFileToContainer( + ctx, + "../../config/nginx/nginx-with-server-block-access-log.conf", + "/etc/nginx/nginx.conf", + 0o666, + ) + require.NoError(t, err) + + responses := utils.ManagementPlaneResponses(t, 2) + assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_OK, responses[0].GetCommandResponse().GetStatus()) + assert.Equal(t, "Successfully updated all files", responses[0].GetCommandResponse().GetMessage()) + assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_OK, responses[1].GetCommandResponse().GetStatus()) + assert.Equal(t, "Successfully updated all files", responses[1].GetCommandResponse().GetMessage()) + + utils.VerifyUpdateDataPlaneStatus(t) +} diff --git a/test/integration/managementplane/grpc_management_plane_api_test.go b/test/integration/managementplane/grpc_management_plane_api_test.go new file mode 100644 index 000000000..be49a7a47 --- /dev/null +++ b/test/integration/managementplane/grpc_management_plane_api_test.go @@ -0,0 +1,98 @@ +// 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 managementplane + +import ( + "context" + "fmt" + "net" + "net/http" + "testing" + "time" + + "github.com/nginx/agent/v3/test/integration/utils" + + "github.com/go-resty/resty/v2" + mpi "github.com/nginx/agent/v3/api/grpc/mpi/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGrpc_Reconnection(t *testing.T) { + ctx := context.Background() + teardownTest := utils.SetupConnectionTest(t, false, false, + "../../config/agent/nginx-config-with-grpc-client.conf") + defer teardownTest(t) + + timeout := 15 * time.Second + + originalID := utils.VerifyConnection(t, 2) + + stopErr := utils.MockManagementPlaneGrpcContainer.Stop(ctx, &timeout) + + require.NoError(t, stopErr) + + startErr := utils.MockManagementPlaneGrpcContainer.Start(ctx) + require.NoError(t, startErr) + + ipAddress, err := utils.MockManagementPlaneGrpcContainer.Host(ctx) + require.NoError(t, err) + ports, err := utils.MockManagementPlaneGrpcContainer.Ports(ctx) + require.NoError(t, err) + utils.MockManagementPlaneAPIAddress = net.JoinHostPort(ipAddress, ports["9093/tcp"][0].HostPort) + + currentID := utils.VerifyConnection(t, 2) + assert.Equal(t, originalID, currentID) +} + +// Verify that the agent sends a connection request and an update data plane status request +func TestGrpc_StartUp(t *testing.T) { + teardownTest := utils.SetupConnectionTest(t, true, false, + "../../config/agent/nginx-config-with-grpc-client.conf") + defer teardownTest(t) + + utils.VerifyConnection(t, 2) + assert.False(t, t.Failed()) + utils.VerifyUpdateDataPlaneHealth(t) +} + +func TestGrpc_DataplaneHealthRequest(t *testing.T) { + teardownTest := utils.SetupConnectionTest(t, true, false, + "../../config/agent/nginx-config-with-grpc-client.conf") + defer teardownTest(t) + + utils.VerifyConnection(t, 2) + + responses := utils.ManagementPlaneResponses(t, 1) + assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_OK, responses[0].GetCommandResponse().GetStatus()) + assert.Equal(t, "Successfully updated all files", responses[0].GetCommandResponse().GetMessage()) + + assert.False(t, t.Failed()) + + request := `{ + "message_meta": { + "message_id": "5d0fa83e-351c-4009-90cd-1f2acce2d184", + "correlation_id": "79794c1c-8e91-47c1-a92c-b9a0c3f1a263", + "timestamp": "2023-01-15T01:30:15.01Z" + }, + "health_request": {} + }` + + client := resty.New() + client.SetRetryCount(utils.RetryCount).SetRetryWaitTime(utils.RetryWaitTime).SetRetryMaxWaitTime( + utils.RetryMaxWaitTime) + + url := fmt.Sprintf("http://%s/api/v1/requests", utils.MockManagementPlaneAPIAddress) + resp, err := client.R().EnableTrace().SetBody(request).Post(url) + + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode()) + + responses = utils.ManagementPlaneResponses(t, 2) + + assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_OK, responses[1].GetCommandResponse().GetStatus()) + assert.Equal(t, "Successfully sent the health status update", responses[1].GetCommandResponse().GetMessage()) +} diff --git a/test/integration/nginx_less_mpi_connection_test.go b/test/integration/nginxless/nginx_less_mpi_connection_test.go similarity index 67% rename from test/integration/nginx_less_mpi_connection_test.go rename to test/integration/nginxless/nginx_less_mpi_connection_test.go index cc656248c..7e626521c 100644 --- a/test/integration/nginx_less_mpi_connection_test.go +++ b/test/integration/nginxless/nginx_less_mpi_connection_test.go @@ -3,19 +3,22 @@ // 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 integration +package nginxless import ( "testing" + "github.com/nginx/agent/v3/test/integration/utils" + "github.com/stretchr/testify/assert" ) // Verify that the agent sends a connection request to Management Plane even when Nginx is not present func TestNginxLessGrpc_Connection(t *testing.T) { - teardownTest := setupConnectionTest(t, true, true, "../config/agent/nginx-config-with-max-file-size.conf") + teardownTest := utils.SetupConnectionTest(t, true, true, + "../../config/agent/nginx-config-with-grpc-client.conf") defer teardownTest(t) - verifyConnection(t, 1) + utils.VerifyConnection(t, 1) assert.False(t, t.Failed()) } diff --git a/test/integration/utils/config_apply_utils.go b/test/integration/utils/config_apply_utils.go new file mode 100644 index 000000000..ac86e3227 --- /dev/null +++ b/test/integration/utils/config_apply_utils.go @@ -0,0 +1,86 @@ +// 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 utils + +import ( + "fmt" + "net/http" + "testing" + "time" + + "github.com/go-resty/resty/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + RetryCount = 8 + RetryWaitTime = 5 * time.Second + RetryMaxWaitTime = 6 * time.Second +) + +var MockManagementPlaneAPIAddress string + +func PerformConfigApply(t *testing.T, nginxInstanceID string) { + t.Helper() + + client := resty.New() + client.SetRetryCount(RetryCount).SetRetryWaitTime(RetryWaitTime).SetRetryMaxWaitTime(RetryMaxWaitTime) + + url := fmt.Sprintf("http://%s/api/v1/instance/%s/config/apply", MockManagementPlaneAPIAddress, nginxInstanceID) + resp, err := client.R().EnableTrace().Post(url) + + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode()) +} + +func PerformInvalidConfigApply(t *testing.T, nginxInstanceID string) { + t.Helper() + + client := resty.New() + + client.SetRetryCount(RetryCount).SetRetryWaitTime(RetryWaitTime).SetRetryMaxWaitTime(RetryMaxWaitTime) + + body := fmt.Sprintf(`{ + "message_meta": { + "message_id": "e2254df9-8edd-4900-91ce-88782473bcb9", + "correlation_id": "9673f3b4-bf33-4d98-ade1-ded9266f6818", + "timestamp": "2023-01-15T01:30:15.01Z" + }, + "config_apply_request": { + "overview": { + "files": [{ + "file_meta": { + "name": "/etc/nginx/nginx.conf", + "hash": "ea57e443-e968-3a50-b842-f37112acde71", + "modifiedTime": "2023-01-15T01:30:15.01Z", + "permissions": "0644", + "size": 0 + }, + "action": "FILE_ACTION_UPDATE" + }, + { + "file_meta": { + "name": "/unknown/nginx.conf", + "hash": "bd1f337d-6874-35ea-9d4d-2b543efd42cf", + "modifiedTime": "2023-01-15T01:30:15.01Z", + "permissions": "0644", + "size": 0 + }, + "action": "FILE_ACTION_ADD" + }], + "config_version": { + "instance_id": "%s", + "version": "6f343257-55e3-309e-a2eb-bb13af5f80f4" + } + } + } + }`, nginxInstanceID) + url := fmt.Sprintf("http://%s/api/v1/requests", MockManagementPlaneAPIAddress) + resp, err := client.R().EnableTrace().SetBody(body).Post(url) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode()) +} diff --git a/test/integration/utils/grpc_management_plane_utils.go b/test/integration/utils/grpc_management_plane_utils.go new file mode 100644 index 000000000..cd2e9d894 --- /dev/null +++ b/test/integration/utils/grpc_management_plane_utils.go @@ -0,0 +1,486 @@ +// 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 utils + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "slices" + "sort" + "testing" + "time" + + "github.com/go-resty/resty/v2" + mpi "github.com/nginx/agent/v3/api/grpc/mpi/v1" + "github.com/nginx/agent/v3/test/helpers" + mockGrpc "github.com/nginx/agent/v3/test/mock/grpc" + "google.golang.org/grpc" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/network" + "google.golang.org/protobuf/encoding/protojson" +) + +var ( + Container testcontainers.Container + MockManagementPlaneGrpcContainer testcontainers.Container + MockManagementPlaneGrpcAddress string +) + +const ( + instanceLen = 2 + statusRetryCount = 3 + retryWait = 50 * time.Millisecond + retryMaxWait = 200 * time.Millisecond +) + +type ( + ConnectionRequest struct { + ConnectionRequest *mpi.CreateConnectionRequest `json:"connectionRequest"` + } + Instance struct { + InstanceMeta *mpi.InstanceMeta `json:"instance_meta"` + InstanceRuntime *mpi.InstanceRuntime `json:"instance_runtime"` + } + NginxUpdateDataPlaneHealthRequest struct { + MessageMeta *mpi.MessageMeta `json:"message_meta"` + Instances []Instance `json:"instances"` + } + UpdateDataPlaneStatusRequest struct { + UpdateDataPlaneStatusRequest NginxUpdateDataPlaneHealthRequest `json:"updateDataPlaneStatusRequest"` + } +) + +func SetupConnectionTest(tb testing.TB, expectNoErrorsInLogs, nginxless bool, agentConfig string) func(tb testing.TB) { + tb.Helper() + ctx := context.Background() + + if os.Getenv("TEST_ENV") == "Container" { + setupContainerEnvironment(ctx, tb, nginxless, agentConfig) + } else { + setupLocalEnvironment(tb) + } + + return func(tb testing.TB) { + tb.Helper() + + if os.Getenv("TEST_ENV") == "Container" { + helpers.LogAndTerminateContainers( + ctx, + tb, + MockManagementPlaneGrpcContainer, + Container, + expectNoErrorsInLogs, + ) + } + } +} + +// setupContainerEnvironment sets up the container environment for testing. +func setupContainerEnvironment(ctx context.Context, tb testing.TB, nginxless bool, agentConfig string) { + tb.Helper() + tb.Log("Running tests in a container environment") + + containerNetwork := createContainerNetwork(ctx, tb) + setupMockManagementPlaneGrpc(ctx, tb, containerNetwork) + + params := &helpers.Parameters{ + NginxAgentConfigPath: agentConfig, + LogMessage: "Agent connected", + } + switch nginxless { + case true: + Container = helpers.StartNginxLessContainer(ctx, tb, containerNetwork, params) + case false: + setupNginxContainer(ctx, tb, containerNetwork, params) + } +} + +// createContainerNetwork creates and configures a container network. +func createContainerNetwork(ctx context.Context, tb testing.TB) *testcontainers.DockerNetwork { + tb.Helper() + containerNetwork, err := network.New(ctx, network.WithAttachable()) + require.NoError(tb, err) + tb.Cleanup(func() { + networkErr := containerNetwork.Remove(ctx) + tb.Logf("Error removing container network: %v", networkErr) + }) + + return containerNetwork +} + +// setupMockManagementPlaneGrpc initializes the mock management plane gRPC container. +func setupMockManagementPlaneGrpc(ctx context.Context, tb testing.TB, containerNetwork *testcontainers.DockerNetwork) { + tb.Helper() + MockManagementPlaneGrpcContainer = helpers.StartMockManagementPlaneGrpcContainer(ctx, tb, containerNetwork) + MockManagementPlaneGrpcAddress = "managementPlane:9092" + tb.Logf("Mock management gRPC server running on %s", MockManagementPlaneGrpcAddress) + + ipAddress, err := MockManagementPlaneGrpcContainer.Host(ctx) + require.NoError(tb, err) + ports, err := MockManagementPlaneGrpcContainer.Ports(ctx) + require.NoError(tb, err) + + MockManagementPlaneAPIAddress = net.JoinHostPort(ipAddress, ports["9093/tcp"][0].HostPort) + tb.Logf("Mock management API server running on %s", MockManagementPlaneAPIAddress) +} + +// setupNginxContainer configures and starts the NGINX container. +func setupNginxContainer( + ctx context.Context, + tb testing.TB, + containerNetwork *testcontainers.DockerNetwork, + params *helpers.Parameters, +) { + tb.Helper() + nginxConfPath := "../../config/nginx/nginx.conf" + if os.Getenv("IMAGE_PATH") == "/nginx-plus/agent" { + nginxConfPath = "../../config/nginx/nginx-plus.conf" + } + params.NginxConfigPath = nginxConfPath + + Container = helpers.StartContainer(ctx, tb, containerNetwork, params) +} + +// setupLocalEnvironment configures the local testing environment. +func setupLocalEnvironment(tb testing.TB) { + tb.Helper() + tb.Log("Running tests on local machine") + + requestChan := make(chan *mpi.ManagementPlaneRequest) + server := mockGrpc.NewCommandService(requestChan, os.TempDir()) + + go func(tb testing.TB) { + tb.Helper() + + listener, err := net.Listen("tcp", "localhost:0") + assert.NoError(tb, err) + + MockManagementPlaneAPIAddress = listener.Addr().String() + + server.StartServer(listener) + }(tb) + + go func(tb testing.TB) { + tb.Helper() + + listener, err := net.Listen("tcp", "localhost:0") + assert.NoError(tb, err) + var opts []grpc.ServerOption + + grpcServer := grpc.NewServer(opts...) + mpi.RegisterCommandServiceServer(grpcServer, server) + err = grpcServer.Serve(listener) + assert.NoError(tb, err) + + MockManagementPlaneGrpcAddress = listener.Addr().String() + }(tb) +} + +func ManagementPlaneResponses(t *testing.T, numberOfExpectedResponses int) []*mpi.DataPlaneResponse { + t.Helper() + + client := resty.New() + client.SetRetryCount(RetryCount).SetRetryWaitTime(RetryWaitTime).SetRetryMaxWaitTime(RetryMaxWaitTime) + client.AddRetryCondition( + func(r *resty.Response, err error) bool { + responseData := r.Body() + assert.True(t, json.Valid(responseData)) + + response := []*mpi.DataPlaneResponse{} + unmarshalErr := json.Unmarshal(responseData, &response) + require.NoError(t, unmarshalErr) + + return len(response) != numberOfExpectedResponses || r.StatusCode() == http.StatusNotFound + }, + ) + + url := fmt.Sprintf("http://%s/api/v1/responses", MockManagementPlaneAPIAddress) + resp, err := client.R().EnableTrace().Get(url) + + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode()) + + responseData := resp.Body() + t.Logf("Responses: %s", string(responseData)) + assert.True(t, json.Valid(responseData)) + + response := []*mpi.DataPlaneResponse{} + unmarshalErr := json.Unmarshal(responseData, &response) + require.NoError(t, unmarshalErr) + + assert.Len(t, response, numberOfExpectedResponses) + + slices.SortFunc(response, func(a, b *mpi.DataPlaneResponse) int { + return a.GetMessageMeta().GetTimestamp().AsTime().Compare(b.GetMessageMeta().GetTimestamp().AsTime()) + }) + + return response +} + +func ClearManagementPlaneResponses(t *testing.T) { + t.Helper() + + client := resty.New() + + url := fmt.Sprintf("http://%s/api/v1/responses", MockManagementPlaneAPIAddress) + resp, err := client.R().EnableTrace().Delete(url) + + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode()) +} + +func VerifyConnection(t *testing.T, instancesLength int) string { + t.Helper() + + client := resty.New() + client.SetRetryCount(RetryCount).SetRetryWaitTime(RetryWaitTime).SetRetryMaxWaitTime(RetryMaxWaitTime) + connectionRequest := mpi.CreateConnectionRequest{} + client.AddRetryCondition( + func(r *resty.Response, err error) bool { + responseData := r.Body() + + pb := protojson.UnmarshalOptions{DiscardUnknown: true} + unmarshalErr := pb.Unmarshal(responseData, &connectionRequest) + + return r.StatusCode() == http.StatusNotFound || unmarshalErr != nil + }, + ) + url := fmt.Sprintf("http://%s/api/v1/connection", MockManagementPlaneAPIAddress) + t.Logf("Connecting to %s", url) + resp, err := client.R().EnableTrace().Get(url) + + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode()) + + responseData := resp.Body() + t.Logf("Response: %s", string(responseData)) + assert.True(t, json.Valid(responseData)) + + pb := protojson.UnmarshalOptions{DiscardUnknown: true} + unmarshalErr := pb.Unmarshal(responseData, &connectionRequest) + require.NoError(t, unmarshalErr) + + t.Logf("ConnectionRequest: %v", &connectionRequest) + + resource := connectionRequest.GetResource() + + assert.NotNil(t, resource.GetResourceId()) + assert.NotNil(t, resource.GetContainerInfo().GetContainerId()) + + assert.Len(t, resource.GetInstances(), instancesLength) + + var nginxInstanceID string + + for _, instance := range resource.GetInstances() { + switch instance.GetInstanceMeta().GetInstanceType() { + case mpi.InstanceMeta_INSTANCE_TYPE_AGENT: + agentInstanceMeta := instance.GetInstanceMeta() + + assert.NotEmpty(t, agentInstanceMeta.GetInstanceId()) + assert.NotEmpty(t, agentInstanceMeta.GetVersion()) + + assert.NotEmpty(t, instance.GetInstanceRuntime().GetBinaryPath()) + + assert.Equal(t, "/etc/nginx-agent/nginx-agent.conf", instance.GetInstanceRuntime().GetConfigPath()) + case mpi.InstanceMeta_INSTANCE_TYPE_NGINX: + nginxInstanceMeta := instance.GetInstanceMeta() + + nginxInstanceID = nginxInstanceMeta.GetInstanceId() + assert.NotEmpty(t, nginxInstanceID) + assert.NotEmpty(t, nginxInstanceMeta.GetVersion()) + + assert.NotEmpty(t, instance.GetInstanceRuntime().GetBinaryPath()) + + assert.Equal(t, "/etc/nginx/nginx.conf", instance.GetInstanceRuntime().GetConfigPath()) + case mpi.InstanceMeta_INSTANCE_TYPE_NGINX_PLUS: + nginxInstanceMeta := instance.GetInstanceMeta() + + nginxInstanceID = nginxInstanceMeta.GetInstanceId() + assert.NotEmpty(t, nginxInstanceID) + assert.NotEmpty(t, nginxInstanceMeta.GetVersion()) + + assert.NotEmpty(t, instance.GetInstanceRuntime().GetBinaryPath()) + + assert.Equal(t, "/etc/nginx/nginx.conf", instance.GetInstanceRuntime().GetConfigPath()) + case mpi.InstanceMeta_INSTANCE_TYPE_NGINX_APP_PROTECT: + instanceMeta := instance.GetInstanceMeta() + assert.NotEmpty(t, instanceMeta.GetInstanceId()) + assert.NotEmpty(t, instanceMeta.GetVersion()) + + instanceRuntimeInfo := instance.GetInstanceRuntime().GetNginxAppProtectRuntimeInfo() + assert.NotEmpty(t, instanceRuntimeInfo.GetRelease()) + assert.NotEmpty(t, instanceRuntimeInfo.GetAttackSignatureVersion()) + assert.NotEmpty(t, instanceRuntimeInfo.GetThreatCampaignVersion()) + case mpi.InstanceMeta_INSTANCE_TYPE_UNIT, + mpi.InstanceMeta_INSTANCE_TYPE_UNSPECIFIED: + fallthrough + default: + t.Fail() + } + } + + return nginxInstanceID +} + +func VerifyUpdateDataPlaneHealth(t *testing.T) { + t.Helper() + + client := resty.New() + + client.SetRetryCount(RetryCount).SetRetryWaitTime(RetryWaitTime).SetRetryMaxWaitTime(RetryMaxWaitTime) + + client.AddRetryCondition( + + func(r *resty.Response, err error) bool { + return r.StatusCode() == http.StatusNotFound + }, + ) + + url := fmt.Sprintf("http://%s/api/v1/health", MockManagementPlaneAPIAddress) + + resp, err := client.R().EnableTrace().Get(url) + + require.NoError(t, err) + + assert.Equal(t, http.StatusOK, resp.StatusCode()) + + responseData := resp.Body() + + t.Logf("Response: %s", string(responseData)) + + assert.True(t, json.Valid(responseData)) + + pb := protojson.UnmarshalOptions{DiscardUnknown: true} + + updateDataPlaneHealthRequest := mpi.UpdateDataPlaneHealthRequest{} + + unmarshalErr := pb.Unmarshal(responseData, &updateDataPlaneHealthRequest) + + require.NoError(t, unmarshalErr) + + t.Logf("UpdateDataPlaneHealthRequest: %v", &updateDataPlaneHealthRequest) + + assert.NotNil(t, &updateDataPlaneHealthRequest) + + // Verify message metadata + + messageMeta := updateDataPlaneHealthRequest.GetMessageMeta() + + assert.NotEmpty(t, messageMeta.GetCorrelationId()) + + assert.NotEmpty(t, messageMeta.GetMessageId()) + + assert.NotEmpty(t, messageMeta.GetTimestamp()) + + healths := updateDataPlaneHealthRequest.GetInstanceHealths() + + assert.Len(t, healths, 1) + + // Verify health metadata + + assert.NotEmpty(t, healths[0].GetInstanceId()) + + assert.Equal(t, mpi.InstanceHealth_INSTANCE_HEALTH_STATUS_HEALTHY, healths[0].GetInstanceHealthStatus()) +} + +func VerifyUpdateDataPlaneStatus(t *testing.T) { + t.Helper() + + client := resty.New() + + client.SetRetryCount(statusRetryCount).SetRetryWaitTime(retryWait).SetRetryMaxWaitTime(retryMaxWait) + + url := fmt.Sprintf("http://%s/api/v1/status", MockManagementPlaneAPIAddress) + + resp, err := client.R().EnableTrace().Get(url) + + require.NoError(t, err) + + assert.Equal(t, http.StatusOK, resp.StatusCode()) + + updateDataPlaneStatusRequest := mpi.UpdateDataPlaneStatusRequest{} + + responseData := resp.Body() + + t.Logf("Response: %s", string(responseData)) + + assert.True(t, json.Valid(responseData)) + + pb := protojson.UnmarshalOptions{DiscardUnknown: true} + + unmarshalErr := pb.Unmarshal(responseData, &updateDataPlaneStatusRequest) + + require.NoError(t, unmarshalErr) + + t.Logf("UpdateDataPlaneStatusRequest: %v", &updateDataPlaneStatusRequest) + + assert.NotNil(t, &updateDataPlaneStatusRequest) + + // Verify message metadata + + messageMeta := updateDataPlaneStatusRequest.GetMessageMeta() + + assert.NotEmpty(t, messageMeta.GetCorrelationId()) + + assert.NotEmpty(t, messageMeta.GetMessageId()) + + assert.NotEmpty(t, messageMeta.GetTimestamp()) + + instances := updateDataPlaneStatusRequest.GetResource().GetInstances() + + sort.Slice(instances, func(i, j int) bool { + return instances[i].GetInstanceMeta().GetInstanceType() < instances[j].GetInstanceMeta().GetInstanceType() + }) + + assert.Len(t, instances, instanceLen) + + // Verify agent instance metadata + + assert.NotEmpty(t, instances[0].GetInstanceMeta().GetInstanceId()) + + assert.Equal(t, mpi.InstanceMeta_INSTANCE_TYPE_AGENT, instances[0].GetInstanceMeta().GetInstanceType()) + + assert.NotEmpty(t, instances[0].GetInstanceMeta().GetVersion()) + + // Verify agent instance configuration + + assert.Empty(t, instances[0].GetInstanceConfig().GetActions()) + + assert.NotEmpty(t, instances[0].GetInstanceRuntime().GetProcessId()) + + assert.Equal(t, "/usr/bin/nginx-agent", instances[0].GetInstanceRuntime().GetBinaryPath()) + + assert.Equal(t, "/etc/nginx-agent/nginx-agent.conf", instances[0].GetInstanceRuntime().GetConfigPath()) + + // Verify NGINX instance metadata + + assert.NotEmpty(t, instances[1].GetInstanceMeta().GetInstanceId()) + + if os.Getenv("IMAGE_PATH") == "/nginx-plus/agent" { + assert.Equal(t, mpi.InstanceMeta_INSTANCE_TYPE_NGINX_PLUS, instances[1].GetInstanceMeta().GetInstanceType()) + } else { + assert.Equal(t, mpi.InstanceMeta_INSTANCE_TYPE_NGINX, instances[1].GetInstanceMeta().GetInstanceType()) + } + + assert.NotEmpty(t, instances[1].GetInstanceMeta().GetVersion()) + + // Verify NGINX instance configuration + + assert.Empty(t, instances[1].GetInstanceConfig().GetActions()) + + assert.NotEmpty(t, instances[1].GetInstanceRuntime().GetProcessId()) + + assert.Equal(t, "/usr/sbin/nginx", instances[1].GetInstanceRuntime().GetBinaryPath()) + + assert.Equal(t, "/etc/nginx/nginx.conf", instances[1].GetInstanceRuntime().GetConfigPath()) +} From 30cd8db33f46a49981e083e3b03c0ded94cd7258 Mon Sep 17 00:00:00 2001 From: Nutsa Bidzishvili Date: Thu, 5 Jun 2025 12:02:30 +0100 Subject: [PATCH 03/28] Add Plus metrics receiver log messages (#1078) --- internal/collector/nginxplusreceiver/config.go | 1 + internal/collector/otel_collector_plugin.go | 13 +++++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/internal/collector/nginxplusreceiver/config.go b/internal/collector/nginxplusreceiver/config.go index b07653205..a05dd6d6d 100644 --- a/internal/collector/nginxplusreceiver/config.go +++ b/internal/collector/nginxplusreceiver/config.go @@ -32,6 +32,7 @@ type APIDetails struct { } // Validate checks if the receiver configuration is valid +// nolint: ireturn func (cfg *Config) Validate() error { if cfg.APIDetails.URL == "" { return errors.New("endpoint cannot be empty for nginxplusreceiver") diff --git a/internal/collector/otel_collector_plugin.go b/internal/collector/otel_collector_plugin.go index 8a308c3fd..6b28b4a20 100644 --- a/internal/collector/otel_collector_plugin.go +++ b/internal/collector/otel_collector_plugin.go @@ -263,7 +263,7 @@ func (oc *Collector) handleNginxConfigUpdate(ctx context.Context, msg *bus.Messa return } - reloadCollector := oc.checkForNewReceivers(nginxConfigContext) + reloadCollector := oc.checkForNewReceivers(ctx, nginxConfigContext) if reloadCollector { slog.InfoContext(ctx, "Reloading OTel collector config, nginx config updated") @@ -394,7 +394,7 @@ func (oc *Collector) restartCollector(ctx context.Context) { } } -func (oc *Collector) checkForNewReceivers(nginxConfigContext *model.NginxConfigContext) bool { +func (oc *Collector) checkForNewReceivers(ctx context.Context, nginxConfigContext *model.NginxConfigContext) bool { nginxReceiverFound, reloadCollector := oc.updateExistingNginxPlusReceiver(nginxConfigContext) if !nginxReceiverFound && nginxConfigContext.PlusAPI.URL != "" { @@ -410,10 +410,12 @@ func (oc *Collector) checkForNewReceivers(nginxConfigContext *model.NginxConfigC CollectionInterval: defaultCollectionInterval, }, ) + slog.DebugContext(ctx, "NGINX Plus API found, NGINX Plus receiver enabled to scrape metrics") reloadCollector = true } else if nginxConfigContext.PlusAPI.URL == "" { - reloadCollector = oc.addNginxOssReceiver(nginxConfigContext) + slog.WarnContext(ctx, "NGINX Plus API is not configured, searching for stub status endpoint") + reloadCollector = oc.addNginxOssReceiver(ctx, nginxConfigContext) } if oc.config.IsFeatureEnabled(pkgConfig.FeatureLogsNap) { @@ -428,7 +430,7 @@ func (oc *Collector) checkForNewReceivers(nginxConfigContext *model.NginxConfigC return reloadCollector } -func (oc *Collector) addNginxOssReceiver(nginxConfigContext *model.NginxConfigContext) bool { +func (oc *Collector) addNginxOssReceiver(ctx context.Context, nginxConfigContext *model.NginxConfigContext) bool { nginxReceiverFound, reloadCollector := oc.updateExistingNginxOSSReceiver(nginxConfigContext) if !nginxReceiverFound && nginxConfigContext.StubStatus.URL != "" { @@ -445,8 +447,11 @@ func (oc *Collector) addNginxOssReceiver(nginxConfigContext *model.NginxConfigCo CollectionInterval: defaultCollectionInterval, }, ) + slog.DebugContext(ctx, "Stub status endpoint found, OSS receiver enabled to scrape metrics") reloadCollector = true + } else if nginxConfigContext.StubStatus.URL == "" { + slog.WarnContext(ctx, "Stub status endpoint not found, NGINX metrics not available") } return reloadCollector From 4757aeddb085048d1c15f893d686a2965189b968 Mon Sep 17 00:00:00 2001 From: aphralG <108004222+aphralG@users.noreply.github.com> Date: Thu, 5 Jun 2025 12:14:28 +0100 Subject: [PATCH 04/28] clean up docs (#1109) --- site/.gitignore | 3 - site/README.md | 136 --- site/config/_default/config.toml | 69 -- site/config/development/config.toml | 4 - site/config/production/config.toml | 4 - site/config/staging/config.toml | 4 - site/content/_index.md | 6 - site/content/about.md | 84 -- site/content/changelog.md | 12 - site/content/contribute/_index.md | 6 - site/content/contribute/community.md | 22 - .../contribute/dev-environment-setup.md | 61 -- .../contribute/start-mock-interface.md | 90 -- site/content/how-to/_index.md | 6 - site/content/how-to/configuration-overview.md | 290 ------- site/content/how-to/configure-agent-group.md | 87 -- .../how-to/connect-management-plane.md | 114 --- site/content/how-to/enable-interfaces.md | 73 -- site/content/how-to/encrypt-communication.md | 126 --- site/content/how-to/export-metrics.md | 45 - site/content/install-upgrade/_index.md | 6 - site/content/install-upgrade/install.md | 784 ------------------ site/content/install-upgrade/migrate-v3.md | 44 - site/content/install-upgrade/uninstall.md | 145 ---- site/content/install-upgrade/upgrade.md | 78 -- site/content/otel/_index.md | 4 - site/content/otel/metrics.md | 5 - site/content/support.md | 15 - site/content/technical-specifications.md | 60 -- site/content/v2/_index.md | 7 - site/content/v2/changelog.md | 282 ------- site/content/v2/configuration/_index.md | 6 - .../configuration/configuration-overview.md | 274 ------ .../configure-nginx-agent-group.md | 87 -- .../v2/configuration/encrypt-communication.md | 128 --- .../content/v2/configuration/health-checks.md | 62 -- .../content/v2/installation-upgrade/_index.md | 6 - .../container-environments/_index.md | 6 - .../container-environments/docker-images.md | 220 ----- .../container-environments/docker-support.md | 83 -- .../installation-upgrade/getting-started.md | 181 ---- .../installation-github.md | 54 -- .../installation-upgrade/installation-oss.md | 411 --------- .../installation-upgrade/installation-plus.md | 511 ------------ .../v2/installation-upgrade/uninstall.md | 162 ---- .../v2/installation-upgrade/upgrade.md | 84 -- site/content/v2/metrics.md | 10 - site/content/v2/technical-specifications.md | 54 -- site/data/.gitkeep | 1 - site/data/layouts/.gitkeep | 1 - site/go.mod | 5 - site/go.sum | 14 - site/layouts/agent-v2-migration/list.html | 10 - site/layouts/agent-v2-migration/single.html | 49 -- site/layouts/catalogs/single.html | 14 - .../agent-v2-migration/list-main.html | 53 -- site/layouts/shortcodes/v2-metrics.html | 56 -- site/makefile | 94 --- site/md-linkcheck-config.json | 14 - site/mdlint.json | 10 - site/netlify.toml | 39 - site/static/.gitkeep | 1 - site/static/agent-flow.png | Bin 95908 -> 0 bytes site/static/grafana-dashboard-example.png | Bin 370502 -> 0 bytes 64 files changed, 5372 deletions(-) delete mode 100644 site/.gitignore delete mode 100644 site/README.md delete mode 100644 site/config/_default/config.toml delete mode 100644 site/config/development/config.toml delete mode 100644 site/config/production/config.toml delete mode 100644 site/config/staging/config.toml delete mode 100644 site/content/_index.md delete mode 100644 site/content/about.md delete mode 100644 site/content/changelog.md delete mode 100644 site/content/contribute/_index.md delete mode 100644 site/content/contribute/community.md delete mode 100644 site/content/contribute/dev-environment-setup.md delete mode 100644 site/content/contribute/start-mock-interface.md delete mode 100644 site/content/how-to/_index.md delete mode 100644 site/content/how-to/configuration-overview.md delete mode 100644 site/content/how-to/configure-agent-group.md delete mode 100644 site/content/how-to/connect-management-plane.md delete mode 100644 site/content/how-to/enable-interfaces.md delete mode 100644 site/content/how-to/encrypt-communication.md delete mode 100644 site/content/how-to/export-metrics.md delete mode 100644 site/content/install-upgrade/_index.md delete mode 100644 site/content/install-upgrade/install.md delete mode 100644 site/content/install-upgrade/migrate-v3.md delete mode 100644 site/content/install-upgrade/uninstall.md delete mode 100644 site/content/install-upgrade/upgrade.md delete mode 100644 site/content/otel/_index.md delete mode 100644 site/content/otel/metrics.md delete mode 100644 site/content/support.md delete mode 100644 site/content/technical-specifications.md delete mode 100644 site/content/v2/_index.md delete mode 100644 site/content/v2/changelog.md delete mode 100644 site/content/v2/configuration/_index.md delete mode 100644 site/content/v2/configuration/configuration-overview.md delete mode 100644 site/content/v2/configuration/configure-nginx-agent-group.md delete mode 100644 site/content/v2/configuration/encrypt-communication.md delete mode 100644 site/content/v2/configuration/health-checks.md delete mode 100644 site/content/v2/installation-upgrade/_index.md delete mode 100644 site/content/v2/installation-upgrade/container-environments/_index.md delete mode 100644 site/content/v2/installation-upgrade/container-environments/docker-images.md delete mode 100644 site/content/v2/installation-upgrade/container-environments/docker-support.md delete mode 100644 site/content/v2/installation-upgrade/getting-started.md delete mode 100644 site/content/v2/installation-upgrade/installation-github.md delete mode 100644 site/content/v2/installation-upgrade/installation-oss.md delete mode 100644 site/content/v2/installation-upgrade/installation-plus.md delete mode 100644 site/content/v2/installation-upgrade/uninstall.md delete mode 100644 site/content/v2/installation-upgrade/upgrade.md delete mode 100644 site/content/v2/metrics.md delete mode 100644 site/content/v2/technical-specifications.md delete mode 100644 site/data/.gitkeep delete mode 100644 site/data/layouts/.gitkeep delete mode 100644 site/go.mod delete mode 100644 site/go.sum delete mode 100644 site/layouts/agent-v2-migration/list.html delete mode 100644 site/layouts/agent-v2-migration/single.html delete mode 100644 site/layouts/catalogs/single.html delete mode 100644 site/layouts/partials/agent-v2-migration/list-main.html delete mode 100644 site/layouts/shortcodes/v2-metrics.html delete mode 100644 site/makefile delete mode 100644 site/md-linkcheck-config.json delete mode 100644 site/mdlint.json delete mode 100644 site/netlify.toml delete mode 100644 site/static/.gitkeep delete mode 100644 site/static/agent-flow.png delete mode 100644 site/static/grafana-dashboard-example.png diff --git a/site/.gitignore b/site/.gitignore deleted file mode 100644 index 254e0fc16..000000000 --- a/site/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -.hugo_build.lock -public -.DS_Store \ No newline at end of file diff --git a/site/README.md b/site/README.md deleted file mode 100644 index 7bc4416de..000000000 --- a/site/README.md +++ /dev/null @@ -1,136 +0,0 @@ -# NGINX Agent Docs - -This directory contains all of the user documentation for NGINX Agent, as well as the requirements for linting, building, and publishing the documentation. - -Docs are written in Markdown. We build the docs using [Hugo](https://gohugo.io) and host them on [Netlify](https://www.netlify.com/). - -## Setup - -1. To install Hugo locally, refer to the [Hugo installation instructions](https://gohugo.io/getting-started/installing/). - - > **NOTE**: We are currently running [Hugo v0.115.3](https://github.com/gohugoio/hugo/releases/tag/v0.115.3) in production. - -2. We use markdownlint to check that Markdown files are correct. Use `npm` to install markdownlint-cli: - - ```shell - npm install -g markdownlint-cli - ``` - -## Local Docs Development - -To build the docs locally, run the desired `make` command from the docs directory: - -```text -make clean - removes the local `public` directory, which is the default output path used by Hugo -make docs - runs a local hugo server so you can view docs in your browser while you work -make hugo-mod - cleans the Hugo module cache and fetches the latest version of the theme module -make docs-drafts - runs the local hugo server and includes all docs marked with `draft: true` -``` - -## Linting - -- To run the markdownlint check, run the following command from the docs directory: - - ```bash - markdownlint -c docs/mdlint_conf.json content - ``` - - Note: You can run this tool on an entire directory or on an individual file. - -## Add new docs - -### Generate a new doc file using Hugo - -To create a new doc file that contains all of the pre-configured Hugo front-matter and the docs task template, **run the following command in the docs directory**: - -`hugo new /.` - -For example: - -```shell -hugo new getting-started/install.md -``` - -The default template -- task -- should be used for most docs. To create docs using the other content templates, you can use the `--kind` flag: - -```shell -hugo new tutorials/deploy.md --kind tutorial -``` - -The available content types (`kind`) are: - -- concept: Helps a customer learn about a specific feature or feature set. -- tutorial: Walks a customer through an example use case scenario; results in a functional PoC environment. -- reference: Describes an API, command line tool, config options, etc.; should be generated automatically from source code. -- troubleshooting: Helps a customer solve a specific problem. -- openapi: Contains front-matter and shortcode for rendering an openapi.yaml spec - -## How to format docs - -### How to format internal links - -Format links as [Hugo refs](https://gohugo.io/content-management/cross-references/). - -- File extensions are optional. -- You can use relative paths or just the filename. (**Paths without a leading / are first resolved relative to the current page, then to the remainder of the site.**) -- Anchors are supported. - -For example: - -```md -To install , refer to the [installation instructions]({{< ref "install" >}}). -``` - -### How to include images - -You can use the `img` [shortcode](#how-to-use-hugo-shortcodes) to add images into your documentation. - -1. Add the image to the static/img directory, or to the same directory as the doc you want to use it in. - - - **DO NOT include a forward slash at the beginning of the file path.** This will break the image when it's rendered. - See the docs for the [Hugo relURL Function](https://gohugo.io/functions/relurl/#input-begins-with-a-slash) to learn more. - -1. Add the img shortcode: - - {{< img src="" >}} - -> Note: The shortcode accepts all of the same parameters as the [Hugo figure shortcode](https://gohugo.io/content-management/shortcodes/#figure). - -### How to use Hugo shortcodes - -You can use [Hugo shortcodes](/docs/themes/f5-hugo/layouts/shortcodes/) to do things like format callouts, add images, and reuse content across different docs. - -For example, to use the note callout: - -```md -{{< note >}}Provide the text of the note here. {{< /note >}} -``` - -The callout shortcodes also support multi-line blocks: - -```md -{{< caution >}} -You should probably never do this specific thing in a production environment. - -If you do, and things break, don't say we didn't warn you. -{{< /caution >}} -``` - -Supported callouts: - -- `caution` -- `important` -- `note` -- `see-also` -- `tip` -- `warning` - -A few more fun shortcodes: - -- `fa`: inserts a Font Awesome icon -- `img`: include an image and define things like alt text and dimensions -- `include`: include the content of a file in another file; the included file must be present in the content/includes directory -- `link`: makes it possible to link to a file and prepend the path with the Hugo baseUrl -- `openapi`: loads an OpenAPI spec and renders as HTML using ReDoc -- `raw-html`: makes it possible to include a block of raw HTML -- `readfile`: includes the content of another file in the current file; does not require the included file to be in a specific location diff --git a/site/config/_default/config.toml b/site/config/_default/config.toml deleted file mode 100644 index beef81917..000000000 --- a/site/config/_default/config.toml +++ /dev/null @@ -1,69 +0,0 @@ -title = "NGINX Agent" -enableGitInfo = false -baseURL = "/" -publishDir = "public/nginx-agent" -staticDir = ["static"] -languageCode = "en-us" -description = "NGINX Agent is a companion daemon for your NGINX Open Source or NGINX Plus instance." -refLinksErrorLevel = "ERROR" -enableRobotsTXT = "true" -#canonifyURLs = true -pluralizeListTitles = false -pygmentsCodeFences = true -pygmentsUseClasses = true - -[caches] - [caches.modules] - dir = "/tmp/hugo_cache/modules" - maxAge = -1 - -[module] -[[module.imports]] - path="github.com/nginxinc/nginx-hugo-theme" - -[markup] - [markup.highlight] - codeFences = true - guessSyntax = true - hl_Lines = "" - lineNoStart = 1 - lineNos = false - lineNumbersInTable = true - noClasses = true - style = "monokai" - tabWidth = 4 - [markup.goldmark] - [markup.goldmark.extensions] - definitionList = true - footnote = true - linkify = true - strikethrough = true - table = true - taskList = true - typographer = true - [markup.goldmark.parser] - attribute = true - autoHeadingID = true - autoHeadingIDType = "gitlab" - [markup.goldmark.renderer] - hardWraps = false - unsafe = true - xhtml = false - -[params] - useSectionPageLists = "false" - buildtype = "webdocs" - RSSLink = "/index.xml" - author = "NGINX Inc." # add your company name - github = "nginxinc" # add your github profile name - twitter = "@nginx" # add your twitter profile - #email = "" - noindex_kinds = [ - "taxonomy", - "taxonomyTerm" - ] - logo = "NGINX-product-icon.svg" - -sectionPagesMenu = "docs" - -ignoreFiles = [ "\\.sh$", "\\.DS_Store$", "\\.git.*$", "\\.txt$", "\\/config\\/.*", "README\\.*"] diff --git a/site/config/development/config.toml b/site/config/development/config.toml deleted file mode 100644 index 804894168..000000000 --- a/site/config/development/config.toml +++ /dev/null @@ -1,4 +0,0 @@ -baseURL = "https://docs-dev.nginx.com/nginx-agent" -title = "NGINX Agent" -publishDir = "public/nginx-agent" -canonifyURLs = false diff --git a/site/config/production/config.toml b/site/config/production/config.toml deleted file mode 100644 index 3ab72e8fa..000000000 --- a/site/config/production/config.toml +++ /dev/null @@ -1,4 +0,0 @@ -baseURL = "https://docs.nginx.com/nginx-agent" -title = "NGINX Agent" -publishDir = "public/nginx-agent" -canonifyURLs = false diff --git a/site/config/staging/config.toml b/site/config/staging/config.toml deleted file mode 100644 index ebed6b3b8..000000000 --- a/site/config/staging/config.toml +++ /dev/null @@ -1,4 +0,0 @@ -baseURL = "https://docs-staging.nginx.com/nginx-agent" -title = "NGINX Agent" -publishDir = "public/nginx-agent" -canonifyURLs = false \ No newline at end of file diff --git a/site/content/_index.md b/site/content/_index.md deleted file mode 100644 index b3764d916..000000000 --- a/site/content/_index.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "NGINX Agent Documentation" -weight: 900 ---- - -NGINX Agent is a companion daemon for your NGINX Open Source or NGINX Plus instance \ No newline at end of file diff --git a/site/content/about.md b/site/content/about.md deleted file mode 100644 index a793c0264..000000000 --- a/site/content/about.md +++ /dev/null @@ -1,84 +0,0 @@ ---- -title: "Overview" -weight: 100 -toc: true -docs: DOCS-000 ---- - -NGINX Agent is a companion daemon for your NGINX Open Source or NGINX Plus instance. It enables: - -- Remote management of NGINX configurations -- Collection and reporting of real-time NGINX performance and operating system metrics -- Notifications of NGINX events - -[OpenTelemetry](https://opentelemetry.io/) support comes with NGINX Agent v3, and the ability to [export the metrics data]({{< relref "/how-to/export-metrics.md" >}}) for use in other applications. - -For an overview of the metrics available from NGINX Agent, read the following topics: - -- [OpenTelemetry metrics]({{< relref "/otel/metrics.md" >}}) (Agent v3) -- [Metrics]({{< relref "/v2/metrics.md" >}}) (Agent v2) - - -{{< img src="grafana-dashboard-example.png" caption="A Grafana dashboard displaying metrics reported by NGINX Agent." alt="A Grafana dashboard displaying metrics reported by NGINX Agent.">}} - ---- - -## How it works - -NGINX Agent runs as a companion process on a system running NGINX. It provides gRPC and REST interfaces for configuration management and metrics collection from the NGINX process and operating system. - -NGINX Agent enables remote interaction with NGINX using common Linux tools and unlocks the ability to build sophisticated monitoring and control systems that can manage large collections of NGINX instances. - -{{< img src="agent-flow.png" caption="How Agent works" alt="How NGINX Agent works" width="99%">}} - - -## Configuration management - -NGINX Agent provides an API interface for submission of updated configuration files. Upon receipt of a new file, it checks the output of `nginx -V` to determine the location of existing configurations. It then validates the new configuration with `nginx -t` before applying it via a signal HUP to the NGINX master process. - -For additional information, view the [Configuration overview]({{< relref "/how-to/configuration-overview.md" >}}) topic. - ---- - -## Collecting metrics - -NGINX Agent interfaces with NGINX process information and parses NGINX logs to calculate and report metrics. When interfacing with NGINX Plus, NGINX Agent pulls relevant information from the NGINX Plus API. Reported metrics may be aggregated by [Prometheus](https://prometheus.io/) and visualized with tools like [Grafana](https://grafana.com/). - ---- - -### NGINX Open Source - -When running alongside an open source instance of NGINX, NGINX Agent requires that NGINX Access and Error logs are turned on and contain all default variables. - ---- - -### NGINX Plus - -For NGINX Agent to work properly with an NGINX Plus instance, the API needs to be configured in that instance's nginx.conf. View the [Instance Metrics Overview](https://docs.nginx.com/nginx-management-suite/nim/about/overview-metrics/) topic for more details. Once NGINX Plus is configured with the `/api/` endpoint, the Agent will automatically use it on startup. - ---- - -## Event notifications - -NGINX Agent allows a gRPC connected control system to register a listener for a specific event. The control mechanism is then invoked when NGINX Agent sends an associated system signal. The source of a notification can be either the NGINX instance or NGINX Agent itself. Here's a list of currently supported events: - -{{< raw-html>}}
{{}} -{{}} -| Event | Description | -| -------------------------------- | -------------------------------------------- | -| AGENT_START_MESSAGE | Agent process started | -| AGENT_STOP_MESSAGE | Agent process stopped | -| NGINX_FOUND_MESSAGE | NGINX master process detected on system | -| NGINX_STOP_MESSAGE | NGINX master process stopped | -| NGINX_RELOAD_SUCCESS_MESSAGE | NGINX master process reloaded successfully | -| NGINX_RELOAD_FAILED_MESSAGE | NGINX master process failed to reload | -| NGINX_WORKER_START_MESSAGE | New NGINX worker process started | -| NGINX_WORKER_STOP_MESSAGE | NGINX worker process stopped | -| CONFIG_APPLY_SUCCESS_MESSAGE | Successfully applied new NGINX configuration | -| CONFIG_APPLY_FAILURE_MESSAGE | Failed to apply new NGINX configuration | -| CONFIG_ROLLBACK_SUCCESS_MESSAGE | Successfully rolled back NGINX configuration | -| CONFIG_ROLLBACK_FAILURE_MESSAGE | Failed to roll back NGINX configuration | -{{}} -{{< raw-html>}}
{{}} - - diff --git a/site/content/changelog.md b/site/content/changelog.md deleted file mode 100644 index 07f23553f..000000000 --- a/site/content/changelog.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: "Changelog" -weight: 700 -toc: true ---- - -{{< note >}}You can find the full changelog, contributor list and assets for NGINX Agent in the [GitHub repository](https://github.com/nginx/agent/releases).{{< /note >}} - -See the list of supported Operating Systems and architectures in the [Technical Specifications]({{< relref "./technical-specifications.md" >}}). - ---- -## Release [v3.0.0](https//github.com/nginx/agent/releases/tag/v3.0.0) diff --git a/site/content/contribute/_index.md b/site/content/contribute/_index.md deleted file mode 100644 index 9eebf5403..000000000 --- a/site/content/contribute/_index.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "Contribute" -weight: 600 ---- - -Learn about the NGINX Agent community and how to contribute to the project. \ No newline at end of file diff --git a/site/content/contribute/community.md b/site/content/contribute/community.md deleted file mode 100644 index 3fbf8f161..000000000 --- a/site/content/contribute/community.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -title: "Community and contribution" -toc: true -weight: 100 -docs: DOCS-000 ---- - -This topic describes the various ways you can get involved with the F5 NGINX Agent project. - -# Community - -- Our [Slack channel #nginx-agent](https://nginxcommunity.slack.com/), is the go-to place to start asking questions and sharing your thoughts. - -- Our [GitHub issues page](https://github.com/nginx/agent/issues) offers space for a more technical discussion at your own pace. - -# Contribute - -Get involved with the project by contributing! Please see our [contributing guide](https://github.com/nginx/agent/blob/main/CONTRIBUTING.md) for details. - -# License - -[Apache License, Version 2.0](https://github.com/nginx/agent/blob/main/LICENSE) diff --git a/site/content/contribute/dev-environment-setup.md b/site/content/contribute/dev-environment-setup.md deleted file mode 100644 index d56c064cb..000000000 --- a/site/content/contribute/dev-environment-setup.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -title: "Development environment setup" -toc: true -weight: 200 -docs: DOCS-000 ---- - -## Overview - -This page describes how to configure a development environment for F5 NGINX Agent. - -While most Linux or FreeBSD operating systems can be used to contribute to the NGINX Agent project, the following steps have been designed for Ubuntu. - -Ubuntu is the recommended operating system for development, as it comes with most packages requires to build and run NGINX Agent. - -## Before you begin - -To begin this task, you will require the following: - -- A [working NGINX Agent instance]({{< ref "/install-upgrade/install.md" >}}). -- A [Go installation](https://go.dev/dl/) of version 1.22.2 or newer. -- A [Protocol Buffer Compiler](https://grpc.io/docs/protoc-installation/) installation. - -You will also need a copy of the NGINX Agent repository, which you can clone using `git`: - -```shell -git clone git@github.com:nginx/agent.git -``` - -Read [Cloning a repository](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) for more information - -Follow the steps in the [Installation]({{< relref "/install-upgrade/install.md" >}}) topic to install NGINX Agent. - -## Install prerequisite packages -Depending on the operating system distribution, it may be necessary to install the following packages in order to build NGINX Agent. - -Change to the NGINX Agent source directory: -```shell -cd /agent -``` - -Install Make: -```shell -sudo apt install make -``` - -Install NGINX Agent tools and dependencies: - -Before starting development on NGINX Agent, it is important to download and install the necessary tool and dependencies required by NGINX Agent. You can do this by running the following `make` command: -```shell -make install-tools deps -``` - -## Build NGINX Agent from source code - -Run the following commands to build and run NGINX Agent: - -```shell -make build -sudo make run -``` diff --git a/site/content/contribute/start-mock-interface.md b/site/content/contribute/start-mock-interface.md deleted file mode 100644 index 2c148de78..000000000 --- a/site/content/contribute/start-mock-interface.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -title: Start mock control plane interface -toc: true -weight: 300 -docs: DOCS-000 ---- - -This document describes how to configure and run F5 NGINX Agent using a mock interface ("control plane") for NGINX Agent to report to. - -The mock interface is useful when developing NGINX Agent, as it allows you to view what metrics are being reported. - -## Before you begin - -To begin this task, you will require the following: - -- A [working NGINX Agent instance]({{< ref "/install-upgrade/install.md" >}}). -- A [Go installation](https://go.dev/dl/) of version 1.22.2 or newer. -- A [go-swagger](https://goswagger.io/go-swagger/install/) installation. - -You will also need a copy of the NGINX Agent repository, which you can clone using `git`: - -```shell -git clone git@github.com:nginx/agent.git -``` - -Read [Cloning a repository](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) for more information. - -## Start the gRPC mock control plane - -Start the mock control plane by running the following command from the `agent` source code root directory: - -```shell -go run sdk/examples/server.go -``` -```text -INFO[0000] http listening at 54790 # mock control plane port -INFO[0000] grpc listening at 54789 # grpc control plane port which NGINX Agent will report to -``` - -The mock control plane can use either gRPC or REST protocols to communicate with NGINX Agent. - -To enable them, view the [Enable gRPC and REST interfaces]({{< relref "/how-to/enable-interfaces.md" >}}) topic. - -## Launch Swagger UI - -To launch the Swagger UI for the REST interface run the following command: - -```shell -make launch-swagger-ui -``` - -## Start NGINX Agent - -Open another terminal window and start NGINX Agent. Issue the following command from the `agent` source code root directory. - -```shell -sudo make run -``` -```text -WARN[0000] Log level is info -INFO[0000] setting displayName to XXX -INFO[0000] NGINX Agent at with pid 12345, clientID=XXXXXX-XXXXXX-XXXXXX-XXXXXX-XXXXXX name=XXX -INFO[0000] NginxBinary initializing -INFO[0000] Commander initializing -INFO[0000] Comms initializing -INFO[0000] OneTimeRegistration initializing -INFO[0000] Registering XXXXXX-XXXXXX-XXXXXX-XXXXXX-XXXXXX -INFO[0000] Metrics initializing -INFO[0000] MetricsThrottle initializing -INFO[0000] DataPlaneStatus initializing -INFO[0000] MetricsThrottle waiting for report ready -INFO[0000] Metrics waiting for handshake to be completed -INFO[0000] ProcessWatcher initializing -INFO[0000] Extensions initializing -INFO[0000] FileWatcher initializing -INFO[0000] FileWatchThrottle initializing -INFO[0001] Events initializing -INFO[0001] OneTimeRegistration completed -``` - -Open a web browser to view the mock control plane at [http://localhost:54790](http://localhost:54790). The following links will be shown in the web interface: - -- **registered** - shows registration information of the data plane -- **nginxes** - lists the nginx instances on the data plane -- **configs** - shows the protobuf payload for NGINX configuration sent to the management plane -- **configs/chunked** - shows the split-up payloads sent to the management plane -- **configs/raw** - shows the actual configuration as it would live on the data plane -- **metrics** - shows a buffer of metrics sent to the management plane (similar to what will be sent back in the REST API) - -For more NGINX Agent use cases, refer to the [NGINX Agent SDK examples](https://github.com/nginx/agent/tree/main/sdk/examples). \ No newline at end of file diff --git a/site/content/how-to/_index.md b/site/content/how-to/_index.md deleted file mode 100644 index 3f01dc82b..000000000 --- a/site/content/how-to/_index.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "How-to guides" -weight: 500 ---- - -Learn how to configure NGINX Agent \ No newline at end of file diff --git a/site/content/how-to/configuration-overview.md b/site/content/how-to/configuration-overview.md deleted file mode 100644 index 26c2a41b2..000000000 --- a/site/content/how-to/configuration-overview.md +++ /dev/null @@ -1,290 +0,0 @@ ---- -title: "Configuration overview" -toc: true -weight: 100 -docs: DOCS-1229 ---- - -This page describes how to configure F5 NGINX Agent using configuration files, CLI (Command line interface) flags, and environment variables. - -{{}} - -- NGINX Agent interprets configuration values set by configuration files, CLI flags, and environment variables in the following priorities: - - 1. CLI flags overwrite configuration files and environment variable values. - 2. Environment variables overwrite configuration file values. - 3. Config files are the lowest priority and config settings are superseded if either of the other options is used. - -- You must open any required firewall ports or add SELinux/AppArmor rules for the ports and IPs you want to use. - -{{}} - -## Configuration files - -The default locations of configuration files for NGINX Agent are `/etc/nginx-agent/nginx-agent.conf` and `/var/lib/nginx-agent/agent-dynamic.conf`. The `agent-dynamic.conf` file default location is different for FreeBSD which is located `/var/db/nginx-agent/agent-dynamic.conf`. These files have comments at the top indicating their purpose. - -Examples of the configuration files are provided below: - -
- example nginx-agent.conf - -{{}} -In the following example `nginx-agent.conf` file, you can change the `server.host` and `server.grpcPort` to connect to the control plane. -{{}} - -```nginx {hl_lines=[13]} -# -# /etc/nginx-agent/nginx-agent.conf -# -# Configuration file for NGINX Agent. -# -# This file tracks agent configuration values that are meant to be statically set. There -# are additional NGINX Agent configuration values that are set via the API and agent install script -# which can be found in /etc/nginx-agent/agent-dynamic.conf. - -# specify the server grpc port to connect to -server: - # host of the control plane - host: - grpcPort: 443 - backoff: # note: default values are prepopulated - initial_interval: 100ms # Add the appropriate duration value here, e.g., "100ms" for 100 milliseconds, "5s" for 5 seconds, "1m" for 1 minute, "1h" for 1 hour - randomization_factor: 0.10 # Add the appropriate float value here, e.g., 0.10 - multiplier: 1.5 # Add the appropriate float value here, e.g., 1.5 - max_interval: 1m # Add the appropriate duration value here, e.g., "100ms" for 100 milliseconds, "5s" for 5 seconds, "1m" for 1 minute, "1h" for 1 hour - max_elapsed_time: 0 # Add the appropriate duration value here, e.g., "0" for indefinite "100ms" for 100 milliseconds, "5s" for 5 seconds, "1m" for 1 minute, "1h" for 1 hour -# tls options -tls: - # enable tls in the nginx-agent setup for grpcs - # default to enable to connect with secure connection but without client cert for mtls - enable: true - # controls whether the server certificate chain and host name are verified. - # for production use, see instructions for configuring TLS - skip_verify: false -log: - # set log level (panic, fatal, error, info, debug, trace; default "info") - level: info - # set log path. if empty, don't log to file. - path: /var/log/nginx-agent/ -nginx: - # path of NGINX logs to exclude - exclude_logs: "" - # Set to true when NGINX configuration should contain no warnings when performing a configuration apply (nginx -t is used to carry out this check) - treat_warnings_as_errors: false # Default is false -# data plane status message / 'heartbeat' -dataplane: - status: - # poll interval for dataplane status - the frequency the NGINX Agent will query the dataplane for changes - poll_interval: 30s - # report interval for dataplane status - the maximum duration to wait before syncing dataplane information if no updates have been observed - report_interval: 24h -metrics: - # specify the size of a buffer to build before sending metrics - bulk_size: 20 - # specify metrics poll interval - report_interval: 1m - collection_interval: 15s - mode: aggregated - backoff: # note: default values are prepopulated - initial_interval: 100ms # Add the appropriate duration value here, e.g., "100ms" for 100 milliseconds, "5s" for 5 seconds, "1m" for 1 minute, "1h" for 1 hour - randomization_factor: 0.10 # Add the appropriate float value here, e.g., 0.10 - multiplier: 1.5 # Add the appropriate float value here, e.g., 1.5 - max_interval: 1m # Add the appropriate duration value here, e.g., "100ms" for 100 milliseconds, "5s" for 5 seconds, "1m" for 1 minute, "1h" for 1 hour - max_elapsed_time: 0 # Add the appropriate duration value here, e.g., "0" for indefinite "100ms" for 100 milliseconds, "5s" for 5 seconds, "1m" for 1 minute, "1h" for 1 hour - -# OSS NGINX default config path -# path to aux file dirs can also be added -config_dirs: "/etc/nginx:/usr/local/etc/nginx" - -# Internal queue size -queue_size: 100 - -extensions: - - nginx-app-protect - -# Enable reporting NGINX App Protect details to the control plane. -nginx_app_protect: - # Report interval for NGINX App Protect details - the frequency NGINX Agent checks NGINX App Protect for changes. - report_interval: 15s - # Enable precompiled publication from the NGINX Management Suite (true) or perform compilation on the data plane host (false). - precompiled_publication: true -``` - -
- - -
- example dynamic-agent.conf - -{{}} -Default location in Linux environments: `/var/lib/nginx-agent/agent-dynamic.conf` - -Default location in FreeBSD environments: `/var/db/nginx-agent/agent-dynamic.conf` -{{}} - -```yaml -# Dynamic configuration file for NGINX Agent. -# -# The purpose of this file is to track agent configuration -# values that can be dynamically changed via the API and the agent install script. -# You may edit this file, but API calls that modify the tags on this system will -# overwrite the tag values in this file. -# -# The agent configuration values that API calls can modify are as follows: -# tags: -# - dev -# - qa -# -# The agent configuration value that the agent install script can modify are as follows: -# instance_group: my-instance-group - -instance_group: my-instance-group -tags: - - dev - - qa -``` - -
- -## CLI flags and environment variables - -This section details the CLI flags and corresponding environment variables used to configure the NGINX Agent. - -### CLI flags - -```sh -nginx-agent [flags] -``` - -### Environment variables - -```sh -export ENV_VARIABLE_NAME="value" -nginx-agent -``` - -### Flag and environment arguments - -{{< warning >}} - -Before version 2.35.0, the environment variables were prefixed with `NMS_` instead of `NGINX_AGENT_`. - -If you are upgrading from an older version, update your configuration accordingly. - -{{< /warning >}} - -{{}} -| CLI flag | Environment variable | Description | -|---------------------------------------------|--------------------------------------|-----------------------------------------------------------------------------| -| `--api-cert` | `NGINX_AGENT_API_CERT` | Specifies the certificate used by the Agent API. | -| `--api-host` | `NGINX_AGENT_API_HOST` | Sets the host used by the Agent API. Default: *127.0.0.1* | -| `--api-key` | `NGINX_AGENT_API_KEY` | Specifies the key used by the Agent API. | -| `--api-port` | `NGINX_AGENT_API_PORT` | Sets the port for exposing nginx-agent to HTTP traffic. | -| `--config-dirs` | `NGINX_AGENT_CONFIG_DIRS` | Defines directories NGINX Agent can read/write. Default: *"/etc/nginx:/usr/local/etc/nginx:/usr/share/nginx/modules:/etc/nms"* | -| `--dataplane-report-interval` | `NGINX_AGENT_DATAPLANE_REPORT_INTERVAL` | Sets the interval for dataplane reporting. Default: *24h0m0s* | -| `--dataplane-status-poll-interval` | `NGINX_AGENT_DATAPLANE_STATUS_POLL_INTERVAL` | Sets the interval for polling dataplane status. Default: *30s* | -| `--display-name` | `NGINX_AGENT_DISPLAY_NAME` | Sets the instance's display name. | -| `--dynamic-config-path` | `NGINX_AGENT_DYNAMIC_CONFIG_PATH` | Specifies the path of the Agent dynamic config file. Default: *"/var/lib/nginx-agent/agent-dynamic.conf"* | -| `--features` | `NGINX_AGENT_FEATURES` | Specifies a comma-separated list of features enabled for the agent. Default: *[registration, nginx-config-async, nginx-ssl-config, nginx-counting, metrics, dataplane-status, process-watcher, file-watcher, activity-events, agent-api]* | -| `--ignore-directives` | | Specifies a comma-separated list of directives to ignore for sensitive info.| -| `--instance-group` | `NGINX_AGENT_INSTANCE_GROUP` | Sets the instance's group value. | -| `--log-level` | `NGINX_AGENT_LOG_LEVEL` | Sets the logging level (e.g., panic, fatal, error, info, debug, trace). Default: *info* | -| `--log-path` | `NGINX_AGENT_LOG_PATH` | Specifies the path to output log messages. | -| `--metrics-bulk-size` | `NGINX_AGENT_METRICS_BULK_SIZE` | Specifies the number of metrics reports collected before sending data. Default: *20* | -| `--metrics-collection-interval` | `NGINX_AGENT_METRICS_COLLECTION_INTERVAL` | Sets the interval for metrics collection. Default: *15s* | -| `--metrics-mode` | `NGINX_AGENT_METRICS_MODE` | Sets the metrics collection mode: streaming or aggregation. Default: *aggregated* | -| `--metrics-report-interval` | `NGINX_AGENT_METRICS_REPORT_INTERVAL` | Sets the interval for reporting collected metrics. Default: *1m0s* | -| `--nginx-config-reload-monitoring-period` | | Sets the duration to monitor error logs after an NGINX reload. Default: *10s* | -| `--nginx-exclude-logs` | `NGINX_AGENT_NGINX_EXCLUDE_LOGS` | Specifies paths of NGINX access logs to exclude from metrics collection. | -| `--nginx-socket` | `NGINX_AGENT_NGINX_SOCKET` | Specifies the location of the NGINX Plus counting Unix socket. Default: *unix:/var/run/nginx-agent/nginx.sock* | -| `--nginx-treat-warnings-as-errors` | `NGINX_AGENT_NGINX_TREAT_WARNINGS_AS_ERRORS` | Treats warnings as failures on configuration application. | -| `--queue-size` | `NGINX_AGENT_QUEUE_SIZE` | Specifies the size of the NGINX Agent internal queue. | -| `--server-command` | | Specifies the name of the command server sent in the TLS configuration. | -| `--server-grpcport` | `NGINX_AGENT_SERVER_GRPCPORT` | Sets the desired GRPC port for NGINX Agent traffic. | -| `--server-host` | `NGINX_AGENT_SERVER_HOST` | Specifies the IP address of the server host. | -| `--server-metrics` | | Specifies the name of the metrics server sent in the TLS configuration. | -| `--server-token` | `NGINX_AGENT_SERVER_TOKEN` | Sets the authentication token for accessing the commander and metrics services. Default: *e202f883-54c6-4702-be15-3ba6e507879a* | -| `--tags` | `NGINX_AGENT_TAGS` | Specifies a comma-separated list of tags for the instance or machine. | -| `--tls-ca` | `NGINX_AGENT_TLS_CA` | Specifies the path to the CA certificate file for TLS. | -| `--tls-cert` | `NGINX_AGENT_TLS_CERT` | Specifies the path to the certificate file for TLS. | -| `--tls-enable` | `NGINX_AGENT_TLS_ENABLE` | Enables TLS for secure communications. | -| `--tls-key` | `NGINX_AGENT_TLS_KEY` | Specifies the path to the certificate key file for TLS. | -| `--tls-skip-verify` | `NGINX_AGENT_TLS_SKIP_VERIFY` | Insecurely skips verification for gRPC TLS credentials. | -{{}} - -
- -{{}} -Use the `--config-dirs` command-line option, or the `config_dirs` key in the `nginx-agent.conf` file, to identify the directories NGINX Agent can read from or write to. This setting also defines the location to which you can upload config files when using a control plane. - -NGINX Agent cannot write to directories outside the specified location when updating a config and cannot upload files to directories outside of the configured location. - -NGINX Agent follows NGINX configuration directives to file paths outside the designated directories and reads certificates' metadata. NGINX Agent uses the following directives: - -- [`ssl_certificate`](https://nginx.org/en/docs/http/ngx_http_ssl_module.html#ssl_certificate) - -{{}} - -{{}} Use the `--dynamic-config-path` command-line option to set the location of the dynamic config file. This setting also requires you to move your dynamic config to the new path, or create a new dynamic config file at the specified location. - -Default location in Linux environments: `/var/lib/nginx-agent/agent-dynamic.conf` - -Default location in FreeBSD environments: `/var/db/nginx-agent/agent-dynamic.conf` - -{{}} - -## Logs - -NGINX Agent uses formatted log files to collect metrics. Expanding log formats and instance counts will also increase the size of the NGINX Agent log files. - -We recommend adding a separate partition for `/var/log/nginx-agent`. - -{{< important >}} -Without log rotation or storage on a separate partition, log files could use up all the free drive space and cause your system to become unresponsive to certain services. -{{< /important >}} - -By default, NGINX Agent rotates logs daily using logrotate with the following configuration: - -
- NGINX Agent Logrotate Configuration - -``` yaml -/var/log/nginx-agent/*.log -{ - # log files are rotated every day - daily - # log files are rotated if they grow bigger than 5M - size 5M - # truncate the original log file after creating a copy - copytruncate - # remove rotated logs older than 10 days - maxage 10 - # log files are rotated 10 times before being removed - rotate 10 - # old log files are compressed - compress - # if the log file is missing it will go on to the next one without issuing an error message - missingok - # do not rotate the log if it is empty - notifempty -} -``` -
- -If you need to change the default configuration, update the file at `/etc/logrotate.d/nginx-agent`. - -For more details on logrotate configuration, see [Logrotate Configuration Options](https://linux.die.net/man/8/logrotate). - - -## Extensions - -An extension is noncritical code to the main functionality of NGINX Agent. They generally cover functionality outside of managing NGINX configuration and reporting metrics. - -To enable an extension, it must be added to the extensions list in the `/etc/nginx-agent/nginx-agent.conf`. - -This example enables the advanced metrics extension: - -```yaml -extensions: - - advanced-metrics -``` \ No newline at end of file diff --git a/site/content/how-to/configure-agent-group.md b/site/content/how-to/configure-agent-group.md deleted file mode 100644 index dbfd3c6dd..000000000 --- a/site/content/how-to/configure-agent-group.md +++ /dev/null @@ -1,87 +0,0 @@ ---- -title: "Add users to nginx-agent group" -toc: true -weight: 400 -docs: DOCS-000 ---- - -This page describes how the F5 NGINX Agent process interacts with the NGINX user on a system, and how to add users to the NGINX Agent group. - -## Overview - -During installation, NGINX Agent detects the NGINX user (typically `nginx`) for the master and worker processes and adds this user to a group called `nginx-agent`. - -If you change the NGINX username after installing the NGINX Agent, you'll need to add the new username to the `nginx-agent` group so that the NGINX socket has the proper permissions. - -A failure to update the `nginx-agent` group when the NGINX username changes may result in non-compliance errors for NGINX Plus. - ---- - -## NGINX socket - -NGINX Agent creates a socket in the default location `/var/run/nginx-agent/nginx.sock`. You can customize this location by editing the `nginx-agent.conf` file and setting the path similar to the following example: - -```nginx configuration -nginx: - ... - socket: "unix:/var/run/nginx-agent/nginx.sock" -``` - -The socket server starts when the NGINX socket configuration is enabled; the socket configuration is enabled by default. - ---- - -## Add NGINX Users to nginx-agent group - -To manually add NGINX users to the `nginx-agent` group, take the following steps: - -1. Verify the `nginx-agent` group exists: - - ```shell - sudo getent group | grep nginx-agent - ``` - - The output looks similar to the following example: - - ```shell - nginx-agent:x:1001:root,nginx - ``` - - If the group doesn't exist, create it by running the following command: - - ```shell - sudo groupadd nginx-agent - ``` - -2. Verify the ownership of `/var/run/nginx-agent` directory: - - ```shell - ls -l /var/run/nginx-agent - ``` - - The output looks similar to the following: - - ```shell - total 0 - srwxrwxr-x 1 root nginx-agent 0 Jun 13 10:51 nginx.sockvv - ``` - - If the group ownership is not `nginx-agent`, change the ownership by running the following command: - - ```shell - sudo chown :nginx-agent /var/run/nginx-agent - ``` - -3. To add NGINX user(s) to the `nginx-agent` group, run the following command: - - ```shell - sudo usermod -a -G nginx-agent - ``` - - For example to add the `nginx` user, take the following step: - - ```shell - sudo usermod -a -G nginx-agent nginx - ``` - - Repeat for all NGINX users. diff --git a/site/content/how-to/connect-management-plane.md b/site/content/how-to/connect-management-plane.md deleted file mode 100644 index c0919d65e..000000000 --- a/site/content/how-to/connect-management-plane.md +++ /dev/null @@ -1,114 +0,0 @@ ---- -title: "Connect to management plane" -toc: true -weight: 600 -docs: DOCS-000 ---- - -## Overview - -To monitor and manage all your F5 NGINX Agent instances from a central management plane server, you first need to connect your instances and the server. You can configure the connection by making the required changes to the NGINX Agent configuration file. - -There are three types of connections you can establish between the NGINX Agent and the management plane server: - -- [Mutual Transport Layer Security (mTLS) connection](#mtls-connection) -- [Transport Layer Security (TLS) connection](#tls-connection) -- [Insecure connection](#insecure-connection) - -## mTLS connection - -To establish a mTLS connection between the NGINX Agent and the management plane server, follow these steps: - - 1. Edit the `/etc/nginx-agent/nginx-agent.conf` file to enable mTLS for NGINX Agent. Replace the example values with your own: - - ```yaml - command: - server: - # the server host to connect to in order to send - # and receive commands e.g. config apply instructions - host: example.com - # the server port to connect to in order to send and receive commands - # e.g. config apply instructions - port: 443 - # the type of connection. Currently only "grpc" is supported. - type: grpc - auth: - # the token to be used in the authorization header - # for the Agent initiated requests - token: ... - tls: - # The client key to be used in the TLS/mTLS connection - key: /etc/ssl/certs/key.pem - # The client certificate to be used in the TLS/mTLS connection - cert: /etc/ssl/certs/cert.pem - # The certificate authority certificate to be used in the mTLS connection - ca: /etc/ssl/certs/ca.pem - # controls whether the server certificate chain and host name are verified - skip_verify: false - # A hostname value specified in the Subject Alternative Name extension - server_name: example.com - ``` -2. Restart the NGINX Agent service: - - ```shell - sudo systemctl restart nginx-agent - ``` - -## TLS connection - -To establish a TLS connection between the NGINX Agent and the management plane server, follow these steps: - -1. Edit the `/etc/nginx-agent/nginx-agent.conf` file to enable TLS for NGINX Agent. Replace the example values with your own: - - ```yaml - command: - server: - # the server host to connect to in order to send and receive commands - # e.g. config apply instructions - host: example.com - # the server port to connect to in order to send and receive commands - # e.g. config apply instructions - port: 443 - # the type of connection. Currently only "grpc" is supported. - type: grpc - auth: - # the token to be used in the authorization header for the - # Agent initiated requests - token: ... - tls: - # controls whether the server certificate chain and host name are verified - skip_verify: false - ``` - - {{< note >}}To enable server-side TLS with a self-signed certificate, you must have TLS enabled and set `skip_verify` to `true`, which disables hostname validation. Setting `skip_verify` can be done only by updating the configuration file. **This is not recommended for production environments**.{{< /note >}} - -2. Restart the NGINX Agent service: - - ```shell - sudo systemctl restart nginx-agent - ``` - -## Insecure connection - -{{< warning >}}Insecure connections are not recommended for production environments.{{< /warning >}} - -To establish an insecure connection between the NGINX Agent and the management plane server, follow these steps: - -1. Edit the `/etc/nginx-agent/nginx-agent.conf` file to enable an insecure connection for NGINX Agent. Replace the example values with your own: - - ```yaml - command: - server: - # the server host to connect to in order to send and receive commands e.g. config apply instructions - host: example.com - # the server port to connect to in order to send and receive commands e.g. config apply instructions - port: 443 - # the type of connection. Currently only "grpc" is supported. - type: grpc - ``` - -2. Restart the NGINX Agent service: - - ```shell - sudo systemctl restart nginx-agent - ``` diff --git a/site/content/how-to/enable-interfaces.md b/site/content/how-to/enable-interfaces.md deleted file mode 100644 index c73557165..000000000 --- a/site/content/how-to/enable-interfaces.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -title: "Enable gRPC and REST interfaces" -toc: true -weight: 200 -docs: DOCS-000 ---- - -This document describes how to enable the gRPC and REST interfaces for F5 NGINX Agent. - -## Before you begin - -If it doesn't already exist, create the directory `/etc/nginx-agent/`and copy the `nginx-agent.conf` file into it from the project root directory. - -```shell -sudo mkdir /etc/nginx-agent -sudo cp /nginx-agent.conf /etc/nginx-agent/ -``` - -Create the `agent-dynamic.conf` file, which is required for NGINX Agent to run. - -In Linux environments: -```shell -sudo touch /var/lib/nginx-agent/agent-dynamic.conf -``` - -In FreeBSD environments: -```shell -sudo touch /var/db/nginx-agent/agent-dynamic.conf -``` - ---- - -## Enable the gRPC interface - -Add the the following settings to `/etc/nginx-agent/nginx-agent.conf`: - -```yaml -server: - host: 127.0.0.1 # mock control plane host - grpcPort: 54789 # mock control plane gRPC port - -# gRPC TLS options - DISABLING TLS IS NOT RECOMMENDED FOR PRODUCTION -tls: - enable: false - skip_verify: true -``` - -For more information, see [Agent Protocol Definitions and Documentation](https://github.com/nginx/agent/tree/main/docs/proto/README.md). - ---- - -## Enable the REST interface - -The NGINX Agent REST interface can be exposed by validating the following lines in the `/etc/nginx-agent/nginx-agent.conf` file are present: - -```yaml -api: - # Set API address to allow remote management - host: 127.0.0.1 - # Set this value to a secure port number to prevent information leaks - port: 8038 - # REST TLS parameters - cert: ".crt" - key: ".key" -``` - ---- - -## Start NGINX Agent - -To apply the new configuration, NGINX Agent must be started or restarted. - -You may want to view the [Start mock control plane interface]({{< relref "/contribute/start-mock-interface.md" >}}) topic to test NGINX Agent, or view the [Configuration overview]({{< relref "/how-to/configuration-overview.md" >}}) for more options. \ No newline at end of file diff --git a/site/content/how-to/encrypt-communication.md b/site/content/how-to/encrypt-communication.md deleted file mode 100644 index 00876ffc4..000000000 --- a/site/content/how-to/encrypt-communication.md +++ /dev/null @@ -1,126 +0,0 @@ ---- -title: "Encrypt communication" -toc: true -weight: 500 -docs: DOCS-000 ---- - -## Overview - -Follow the steps in this guide to encrypt communication between F5 NGINX Agent and Instance Manager with TLS. - -## Before You Begin - -To enable mTLS, you must have TLS enabled and supply a key, cert, and a CA cert on both the client and server. TSee the [Secure Traffic with Certificates](https://docs.nginx.com/nginx-management-suite/admin-guides/configuration/secure-traffic/) topic for instructions on how to generate keys and set them in the specific values in the NGINX Agent configuration. - -## Enabling mTLS - -See the examples below for how to set these values using a configuration file, CLI flags, or environment variables. - -### Enabling mTLS via Config Values - -You can edit the `/etc/nginx-agent/nginx-agent.conf` file to enable mTLS for NGINX Agent. Make the following changes: - -```yaml -server: - metrics: "cert-sni-name" - command: "cert-sni-name" -tls: - enable: true - cert: "path-to-cert" - key: "path-to-key" - ca: "path-to-ca-cert" - skip_verify: false -``` - -The `cert-sni-name` value should match the SubjectAltName of the server certificate. For more information see [Configuring HTTPS servers](http://nginx.org/en/docs/http/configuring_https_servers.html). - -### Enabling mTLS with CLI Flags - -To enable mTLS for the NGINX Agent from the command line, run the following command: - -```shell -nginx-agent --tls-cert "path-to-cert" --tls-key "path-to-key" --tls-ca "path-to-ca-cert" --tls-enable -``` - -### Enabling mTLS with Environment Variables - -To enable mTLS for NGINX Agent using environment variables, run the following commands: - -```shell -NMS_TLS_CA="my-env-ca" -NMS_TLS_KEY="my-env-key" -NMS_TLS_CERT="my-env-cert" -NMS_TLS_ENABLE=true -``` - -
- ---- - -## Enabling Server-Side TLS - -To enable server-side TLS you must have TLS enabled. See the following examples for how to set these values using a configuration file, CLI flags, or environment variables. - -### Enabling Server-Side TLS via Config Values - -You can edit the `/etc/nginx-agent/nginx-agent.conf` file to enable server-side TLS. Make the following changes: - -```shell -tls: - enable: true - skip_verify: false -``` - -### Enabling Server Side TLS with CLI Flags - -To enable server-side TLS from the command line, run the following command: - -```shell -nginx-agent --tls-enable -``` - -### Enabling Server-Side TLS with Environment Variables - -To enable server-side TLS using environment variables, run the following commands: - -```shell -NMS_TLS_ENABLE=true -``` - -
- ---- - -## Enable Server-Side TLS With Self-Signed Certificate - -{{< warning >}}These steps are not recommended for production environments.{{< /warning >}} - -To enable server-side TLS with a self-signed certificate, you must have TLS enabled and set `skip_verify` to `true`, which disables hostname validation. Setting `skip_verify` can be done done only by updating the configuration file. See the following example: - -```shell -tls: - enable: true - skip_verify: true -``` - -## Insecure Mode (Not Recommended) - -To enable insecure mode, you simply need to set `tls:enable` to `false`. Setting this value to `false` can be done only by updating the configuration file or with environment variables. See the following examples: - -### Enabling Insecure Mode via Config Values** - -You can edit the `/etc/nginx-agent/nginx-agent.conf` file to enable insecure mode. Make the following changes: - -```shell -tls: - enable: false -``` - -### Enabling Insecure Mode with Environment Variables** - -To enable insecure mode using environment variables, run the following commands: - -```shell -NMS_TLS_ENABLE=false -``` diff --git a/site/content/how-to/export-metrics.md b/site/content/how-to/export-metrics.md deleted file mode 100644 index 96bf7648d..000000000 --- a/site/content/how-to/export-metrics.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -title: "Export metrics data" -weight: 300 -docs: DOCS-000 ---- - -This document describes how to export the metrics data from F5 NGINX Agent. - -[//]: # "These are Markdown comments to guide you through document structure." -[//]: # "Remove them as you go, as well as unnecessary sections for this use case." - -## Overview - -[//]: # "Write a description which outlines precisely what this page of instructions will accomplish." -[//]: # "This description, like all instructions, should be direct and imperative." -[//]: # "Avoid ambiguous promises such as 'enables functionality': state precisely what it does." - ---- - -## Before you begin - -[//]: # "List all of the prerequisites for completing this task." -[//]: # "This might be the first page for a reader, so include a link to installation." - -To begin this task, you will require the following: - -- A [working NGINX Agent instance]({{< ref "/install-upgrade/install.md" >}}). -- -- - ---- - -## Export metrics data - - - ---- - -## See also - -[//]: # "Examples of additional topics users might want to read include:" -[//]: # "Relevant reference information, configuration options and more complex use cases." - -- [OpenTelemetry metrics]({{< ref "/otel/metrics.md" >}}) -- diff --git a/site/content/install-upgrade/_index.md b/site/content/install-upgrade/_index.md deleted file mode 100644 index db1eae8ab..000000000 --- a/site/content/install-upgrade/_index.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "Install and upgrade" -weight: 400 ---- - -Learn how to install, upgrade, and uninstall NGINX Agent. \ No newline at end of file diff --git a/site/content/install-upgrade/install.md b/site/content/install-upgrade/install.md deleted file mode 100644 index 95f651468..000000000 --- a/site/content/install-upgrade/install.md +++ /dev/null @@ -1,784 +0,0 @@ ---- -title: Install NGINX Agent -toc: true -weight: 100 -docs: DOCS-000 ---- - -This document describes the three main ways to install F5 NGINX agent: - -- Using the NGINX Open Source repository -- Using the NGINX Plus repository -- Using the GitHub package files - -## Before you begin - -There are a few prerequisites shared between all installation methods: - -- A [supported operating system and architecture](../technical-specifications/#supported-distributions) -- `root` privilege - -## NGINX Open Source repository - -Before you install NGINX Agent, you must install and run NGINX. - -If you don't have it installed already, read the [Installing NGINX Open Source -](https://docs.nginx.com/nginx/admin-guide/installing-nginx/installing-nginx-open-source/) topic. - - -### Configure NGINX OSS Repository for installing NGINX Agent - -Before you install NGINX Agent for the first time on your system, you need to set up the `nginx-agent` packages repository. Afterward, you can install and update NGINX Agent from the repository. - -- [Install NGINX Agent on RHEL, CentOS, Rocky Linux, AlmaLinux, and Oracle Linux](#install-nginx-agent-on-rhel-centos-rocky-linux-almalinux-and-oracle-linux) -- [Install NGINX Agent on Ubuntu](#install-nginx-agent-on-ubuntu) -- [Install NGINX Agent on Debian](#install-nginx-agent-on-debian) -- [Install NGINX Agent on SLES](#install-nginx-agent-on-sles) -- [Install NGINX Agent on Alpine Linux](#install-nginx-agent-on-alpine-linux) -- [Install NGINX Agent on Amazon Linux](#install-nginx-agent-on-amazon-linux) -- [Install NGINX Agent on FreeBSD](#install-nginx-agent-on-freebsd) - -#### Install NGINX Agent on RHEL, CentOS, Rocky Linux, AlmaLinux, and Oracle Linux - -1. Install the prerequisites: - - ```shell - sudo yum install yum-utils - ``` - -1. To set up the yum repository, create the file named `/etc/yum.repos.d/nginx-agent.repo` with the following contents: - - ``` - [nginx-agent] - name=nginx agent repo - baseurl=http://packages.nginx.org/nginx-agent/centos/$releasever/$basearch/ - gpgcheck=1 - enabled=1 - gpgkey=https://nginx.org/keys/nginx_signing.key - module_hotfixes=true - ``` - -1. To install `nginx-agent`, run the following command: - - ```shell - sudo yum install nginx-agent - ``` - - When prompted to accept the GPG key, verify that the fingerprint matches `573B FD6B 3D8F BC64 1079 A6AB ABF5 BD82 7BD9 BF62`, and if so, accept it. - -#### Install NGINX Agent on Ubuntu - -1. Install the prerequisites: - - ```shell - sudo apt install curl gnupg2 ca-certificates lsb-release ubuntu-keyring - ``` - -1. Import an official nginx signing key so apt could verify the packages authenticity. Fetch the key: - - ```shell - curl https://nginx.org/keys/nginx_signing.key | gpg --dearmor \ - | sudo tee /usr/share/keyrings/nginx-archive-keyring.gpg >/dev/null - ``` - -1. Verify that the downloaded file contains the proper key: - - ```shell - gpg --dry-run --quiet --no-keyring --import --import-options import-show /usr/share/keyrings/nginx-archive-keyring.gpg - ``` - - The output should contain the full fingerprint `573BFD6B3D8FBC641079A6ABABF5BD827BD9BF62` as follows: - - ``` - pub rsa2048 2011-08-19 [SC] [expires: 2024-06-14] - 573BFD6B3D8FBC641079A6ABABF5BD827BD9BF62 - uid nginx signing key - ``` - - {{< important >}}If the fingerprint is different, remove the file.{{< /important >}} - -1. Add the nginx agent repository: - - ```shell - echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] \ - http://packages.nginx.org/nginx-agent/ubuntu/ `lsb_release -cs` agent" \ - | sudo tee /etc/apt/sources.list.d/nginx-agent.list - ``` - -1. To install `nginx-agent`, run the following commands: - - ```shell - sudo apt update - sudo apt install nginx-agent - ``` - -#### Install NGINX Agent on Debian - -1. Install the prerequisites: - - ```shell - sudo apt install curl gnupg2 ca-certificates lsb-release debian-archive-keyring - ``` - -1. Import an official nginx signing key so apt could verify the packages authenticity. Fetch the key: - - ```shell - curl https://nginx.org/keys/nginx_signing.key | gpg --dearmor \ - | sudo tee /usr/share/keyrings/nginx-archive-keyring.gpg >/dev/null - ``` - -1. Verify that the downloaded file contains the proper key: - - ```shell - gpg --dry-run --quiet --no-keyring \ - --import --import-options import-show /usr/share/keyrings/nginx-archive-keyring.gpg - ``` - - The output should contain the full fingerprint `573BFD6B3D8FBC641079A6ABABF5BD827BD9BF62` as follows: - - ``` - pub rsa2048 2011-08-19 [SC] [expires: 2024-06-14] - 573BFD6B3D8FBC641079A6ABABF5BD827BD9BF62 - uid nginx signing key - ``` - - {{< important >}}If the fingerprint is different, remove the file.{{< /important >}} - -1. Add the `nginx-agent` repository: - - ```shell - echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] \ - http://packages.nginx.org/nginx-agent/debian/ `lsb_release -cs` agent" \ | sudo tee /etc/apt/sources.list.d/nginx-agent.list - ``` - -1. To install `nginx-agent`, run the following commands: - - ```shell - sudo apt update - sudo apt install nginx-agent - ``` - -#### Install NGINX Agent on SLES - -1. Install the prerequisites: - - ```shell - sudo zypper install curl ca-certificates gpg2 gawk - ``` - -1. To set up the zypper repository for `nginx-agent` packages, run the following command: - - ```shell - sudo zypper addrepo --gpgcheck --refresh --check \ - 'http://packages.nginx.org/nginx-agent/sles/$releasever_major' nginx-agent - ``` - -1. Next, import an official NGINX signing key so `zypper`/`rpm` can verify the package's authenticity. Fetch the key: - - ```shell - curl -o /tmp/nginx_signing.key https://nginx.org/keys/nginx_signing.key - ``` - -1. Verify that the downloaded file contains the proper key: - - ```shell - gpg --with-fingerprint --dry-run --quiet --no-keyring --import --import-options import-show /tmp/nginx_signing.key - ``` - -1. The output should contain the full fingerprint `573B FD6B 3D8F BC64 1079 A6AB ABF5 BD82 7BD9 BF62` as follows: - - ``` - pub rsa2048 2011-08-19 [SC] [expires: 2024-06-14] - 573B FD6B 3D8F BC64 1079 A6AB ABF5 BD82 7BD9 BF62 - uid nginx signing key - ``` - -1. Finally, import the key to the rpm database: - - ```shell - sudo rpmkeys --import /tmp/nginx_signing.key - ``` - -1. To install `nginx-agent`, run the following command: - - ```shell - sudo zypper install nginx-agent - ``` - -#### Install NGINX Agent on Alpine Linux - -1. Install the prerequisites: - - ```shell - sudo apk add openssl curl ca-certificates - ``` - -1. To set up the apk repository for `nginx-agent` packages, run the following command: - - ```shell - printf "%s%s%s\n" \ - "http://packages.nginx.org/nginx-agent/alpine/v" \ - `grep -o -E '^[0-9]+\.[0-9]+' /etc/alpine-release` \ - "/main" \ - | sudo tee -a /etc/apk/repositories - ``` - -1. Next, import an official NGINX signing key so apk can verify the package's authenticity. Fetch the key: - - ```shell - curl -o /tmp/nginx_signing.rsa.pub https://nginx.org/keys/nginx_signing.rsa.pub - ``` - -1. Verify that downloaded file contains the proper key: - - ```shell - openssl rsa -pubin -in /tmp/nginx_signing.rsa.pub -text -noout - ``` - - The output should contain the following modulus: - - ``` - Public-Key: (2048 bit) - Modulus: - 00:fe:14:f6:0a:1a:b8:86:19:fe:cd:ab:02:9f:58: - 2f:37:70:15:74:d6:06:9b:81:55:90:99:96:cc:70: - 5c:de:5b:e8:4c:b2:0c:47:5b:a8:a2:98:3d:11:b1: - f6:7d:a0:46:df:24:23:c6:d0:24:52:67:ba:69:ab: - 9a:4a:6a:66:2c:db:e1:09:f1:0d:b2:b0:e1:47:1f: - 0a:46:ac:0d:82:f3:3c:8d:02:ce:08:43:19:d9:64: - 86:c4:4e:07:12:c0:5b:43:ba:7d:17:8a:a3:f0:3d: - 98:32:b9:75:66:f4:f0:1b:2d:94:5b:7c:1c:e6:f3: - 04:7f:dd:25:b2:82:a6:41:04:b7:50:93:94:c4:7c: - 34:7e:12:7c:bf:33:54:55:47:8c:42:94:40:8e:34: - 5f:54:04:1d:9e:8c:57:48:d4:b0:f8:e4:03:db:3f: - 68:6c:37:fa:62:14:1c:94:d6:de:f2:2b:68:29:17: - 24:6d:f7:b5:b3:18:79:fd:31:5e:7f:4c:be:c0:99: - 13:cc:e2:97:2b:dc:96:9c:9a:d0:a7:c5:77:82:67: - c9:cb:a9:e7:68:4a:e1:c5:ba:1c:32:0e:79:40:6e: - ef:08:d7:a3:b9:5d:1a:df:ce:1a:c7:44:91:4c:d4: - 99:c8:88:69:b3:66:2e:b3:06:f1:f4:22:d7:f2:5f: - ab:6d - Exponent: 65537 (0x10001) - ``` - -1. Finally, move the key to apk trusted keys storage: - - ```shell - sudo mv /tmp/nginx_signing.rsa.pub /etc/apk/keys/ - ``` - -1. To install `nginx-agent`, run the following command: - - ```shell - sudo apk add nginx-agent - ``` - -#### Install NGINX Agent on Amazon Linux - -1. Install the prerequisites: - - ```shell - sudo yum install yum-utils procps - ``` - -1. To set up the yum repository for Amazon Linux 2, create the file named `/etc/yum.repos.d/nginx-agent.repo` with the following contents: - ``` - [nginx-agent] - name=nginx agent repo - baseurl=http://packages.nginx.org/nginx-agent/amzn2/$releasever/$basearch/ - gpgcheck=1 - enabled=1 - gpgkey=https://nginx.org/keys/nginx_signing.key - module_hotfixes=true - ``` - -1. To install `nginx-agent`, run the following command: - - ```shell - sudo yum install nginx-agent - ``` - -1. When prompted to accept the GPG key, verify that the fingerprint matches `573B FD6B 3D8F BC64 1079 A6AB ABF5 BD82 7BD9 BF62`, and if so, accept it. - -#### Install NGINX Agent on FreeBSD - -1. To setup the pkg repository create the file named `/etc/pkg/nginx-agent.conf` with the following content: - - ``` - nginx-agent: { - URL: pkg+http://packages.nginx.org/nginx-agent/freebsd/${ABI}/latest - ENABLED: true - MIRROR_TYPE: SRV - } - ``` - -1. To install `nginx-agent`, run the following command: - - ```shell - sudo pkg install nginx-agent - ``` - -## NGINX Plus repository - -Before you install NGINX Agent, you must install and run NGINX Plus. - -If you don’t have it installed already, read the [Installing NGINX Plus -](https://docs.nginx.com/nginx/admin-guide/installing-nginx/installing-nginx-plus/) topic. - -You will also need the following: - -- Your credentials to the MyF5 Customer Portal, provided by email from F5, Inc. -- An NGINX Plus subscription (Full or trial) -- Your NGINX Plus certificate and public key (`nginx-repo.crt` and `nginx-repo.key` files), provided by email from F5, Inc. - -### Configure NGINX Plus Repository for installing NGINX Agent - -Before you install NGINX Agent for the first time on your system, you need to set up the `nginx-agent` packages repository. Afterward, you can install and update NGINX Agent from the repository. - -- [Install NGINX Agent on RHEL, CentOS, Rocky Linux, AlmaLinux, and Oracle Linux](#install-nginx-agent-on-rhel-centos-rocky-linux-almalinux-and-oracle-linux) -- [Install NGINX Agent on Ubuntu](#install-nginx-agent-on-ubuntu) -- [Install NGINX Agent on Debian](#install-nginx-agent-on-debian) -- [Install NGINX Agent on SLES](#install-nginx-agent-on-sles) -- [Install NGINX Agent on Alpine Linux](#install-nginx-agent-on-alpine-linux) -- [Install NGINX Agent on Amazon Linux](#install-nginx-agent-on-amazon-linux) -- [Install NGINX Agent on FreeBSD](#install-nginx-agent-on-freebsd) - -#### Install NGINX Agent on RHEL, CentOS, Rocky Linux, AlmaLinux, and Oracle Linux - -1. Create the `/etc/ssl/nginx` directory: - - ```shell - sudo mkdir -p /etc/ssl/nginx - ``` - -1. Log in to [MyF5 Customer Portal](https://account.f5.com/myf5/) and download your `nginx-repo.crt` and `nginx-repo.key` files. - -1. Copy the files to the `/etc/ssl/nginx/` directory: - - ```shell - sudo cp nginx-repo.crt nginx-repo.key /etc/ssl/nginx/ - ``` - -1. Install the prerequisites: - - ```shell - sudo yum install yum-utils procps - ``` - -1. Set up the yum repository by creating the file `nginx-agent.repo` in `/etc/yum.repos.d`, for example using `vi`: - - ```shell - sudo vi /etc/yum.repos.d/nginx-agent.repo - ``` - -1. Add the following lines to `nginx-agent.repo`: - - ``` - [nginx-agent] - name=nginx agent repo - baseurl=https://pkgs.nginx.com/nginx-agent/centos/$releasever/$basearch/ - sslclientcert=/etc/ssl/nginx/nginx-repo.crt - sslclientkey=/etc/ssl/nginx/nginx-repo.key - gpgcheck=0 - enabled=1 - ``` - -1. To install `nginx-agent`, run the following command: - - ```shell - sudo yum install nginx-agent - ``` - - When prompted to accept the GPG key, verify that the fingerprint matches `573B FD6B 3D8F BC64 1079 A6AB ABF5 BD82 7BD9 BF62`, and if so, accept it. - -#### Install NGINX Agent on Ubuntu - -1. Create the `/etc/ssl/nginx` directory: - - ```shell - sudo mkdir -p /etc/ssl/nginx - ``` - -1. Log in to [MyF5 Customer Portal](https://account.f5.com/myf5/) and download your `nginx-repo.crt` and `nginx-repo.key` files. - -1. Copy the files to the `/etc/ssl/nginx/` directory: - - ```shell - sudo cp nginx-repo.crt nginx-repo.key /etc/ssl/nginx/ - ``` - -1. Install the prerequisites: - - ```shell - sudo apt-get install apt-transport-https lsb-release ca-certificates wget gnupg2 ubuntu-keyring - ``` - -1. Download and add [NGINX signing key](https://cs.nginx.com/static/keys/nginx_signing.key): - - ```shell - wget -qO - https://cs.nginx.com/static/keys/nginx_signing.key | gpg --dearmor | sudo tee /usr/share/keyrings/nginx-archive-keyring.gpg >/dev/null - ``` - -1. Create `apt` configuration `/etc/apt/apt.conf.d/90pkgs-nginx`: - - ``` - Acquire::https::pkgs.nginx.com::Verify-Peer "true"; - Acquire::https::pkgs.nginx.com::Verify-Host "true"; - Acquire::https::pkgs.nginx.com::SslCert "/etc/ssl/nginx/nginx-repo.crt"; - Acquire::https::pkgs.nginx.com::SslKey "/etc/ssl/nginx/nginx-repo.key"; - ``` - -1. Add the `nginx-agent` repository: - - ```shell - echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] https://pkgs.nginx.com/nginx-agent/ubuntu/ `lsb_release -cs` agent" \ - | sudo tee /etc/apt/sources.list.d/nginx-agent.list - ``` - -1. To install `nginx-agent`, run the following commands: - - ```shell - sudo apt update - sudo apt install nginx-agent - ``` - -#### Install NGINX Agent on Debian - -1. Create the `/etc/ssl/nginx` directory: - - ```shell - sudo mkdir -p /etc/ssl/nginx - ``` - -1. Log in to [MyF5 Customer Portal](https://account.f5.com/myf5/) and download your `nginx-repo.crt` and `nginx-repo.key` files. - -1. Copy the files to the `/etc/ssl/nginx/` directory: - - ```shell - sudo cp nginx-repo.crt nginx-repo.key /etc/ssl/nginx/ - ``` - -1. Install the prerequisites: - - ```shell - sudo apt install curl gnupg2 ca-certificates lsb-release debian-archive-keyring - ``` - -1. Add the `nginx-agent` repository: - - ```shell - echo "deb https://pkgs.nginx.com/nginx-agent/debian/ `lsb_release -cs` agent" \ - | sudo tee /etc/apt/sources.list.d/nginx-agent.list - ``` - -1. Create apt configuration `/etc/apt/apt.conf.d/90pkgs-nginx`: - - ``` - Acquire::https::pkgs.nginx.com::Verify-Peer "true"; - Acquire::https::pkgs.nginx.com::Verify-Host "true"; - Acquire::https::pkgs.nginx.com::SslCert "/etc/ssl/nginx/nginx-repo.crt"; - Acquire::https::pkgs.nginx.com::SslKey "/etc/ssl/nginx/nginx-repo.key"; - ``` - -1. To install `nginx-agent`, run the following commands: - - ```shell - sudo apt update - sudo apt install nginx-agent - ``` - -#### Install NGINX Agent on SLES - -1. Create the `/etc/ssl/nginx` directory: - - ```shell - sudo mkdir -p /etc/ssl/nginx - ``` - -1. Log in to [MyF5 Customer Portal](https://account.f5.com/myf5/) and download your `nginx-repo.crt` and `nginx-repo.key` files. - -1. Copy the files to the `/etc/ssl/nginx/` directory: - - ```shell - sudo cp nginx-repo.crt nginx-repo.key /etc/ssl/nginx/ - ``` - -1. Create a file bundle of the certificate and key: - - ```shell - cat /etc/ssl/nginx/nginx-repo.crt /etc/ssl/nginx/nginx-repo.key > /etc/ssl/nginx/nginx-repo-bundle.crt - ``` - -1. Install the prerequisites: - - ```shell - sudo zypper install curl ca-certificates gpg2 gawk - ``` - -1. To set up the zypper repository for `nginx-agent` packages, run the following command: - - ```shell - sudo zypper addrepo --refresh --check \ - 'https://pkgs.nginx.com/nginx-agent/sles/$releasever_major?ssl_clientcert=/etc/ssl/nginx/nginx-repo-bundle.crt&ssl_verify=peer' nginx-agent - ``` - -1. Next, import an official NGINX signing key so `zypper`/`rpm` can verify the package's authenticity. Fetch the key: - - ```shell - curl -o /tmp/nginx_signing.key https://nginx.org/keys/nginx_signing.key - ``` - -1. Verify that the downloaded file contains the proper key: - - ```shell - gpg --with-fingerprint --dry-run --quiet --no-keyring --import --import-options import-show /tmp/nginx_signing.key - ``` - -1. The output should contain the full fingerprint `573B FD6B 3D8F BC64 1079 A6AB ABF5 BD82 7BD9 BF62` as follows: - - ``` - pub rsa2048 2011-08-19 [SC] [expires: 2024-06-14] - 573B FD6B 3D8F BC64 1079 A6AB ABF5 BD82 7BD9 BF62 - uid nginx signing key - ``` - -1. Finally, import the key to the rpm database: - - ```shell - sudo rpmkeys --import /tmp/nginx_signing.key - ``` - -1. To install `nginx-agent`, run the following command: - - ```shell - sudo zypper install nginx-agent - ``` - -#### Install NGINX Agent on Alpine Linux - -1. Log in to [MyF5 Customer Portal](https://account.f5.com/myf5/) and download your `nginx-repo.crt` and `nginx-repo.key` files. - -1. Copy the files to the `/etc/apk/` directory: - - ```shell - sudo cp nginx-repo.key /etc/apk/cert.key - sudo cp nginx-repo.crt /etc/apk/cert.pem - ``` - -1. Install the prerequisites: - - ```shell - sudo apk add openssl curl ca-certificates - ``` - -1. To set up the apk repository for `nginx-agent` packages, run the following command: - - ```shell - printf "%s%s%s\n" \ - "https://pkgs.nginx.com/nginx-agent/alpine/v" \ - `grep -o -E '^[0-9]+\.[0-9]+' /etc/alpine-release` \ - "/main" \ - | sudo tee -a /etc/apk/repositories - ``` - -1. Next, import an official NGINX signing key so apk can verify the package's authenticity. Fetch the key: - - ```shell - curl -o /tmp/nginx_signing.rsa.pub https://nginx.org/keys/nginx_signing.rsa.pub - ``` - -1. Verify that downloaded file contains the proper key: - - ```shell - openssl rsa -pubin -in /tmp/nginx_signing.rsa.pub -text -noout - ``` - - The output should contain the following modulus: - - ``` - Public-Key: (2048 bit) - Modulus: - 00:fe:14:f6:0a:1a:b8:86:19:fe:cd:ab:02:9f:58: - 2f:37:70:15:74:d6:06:9b:81:55:90:99:96:cc:70: - 5c:de:5b:e8:4c:b2:0c:47:5b:a8:a2:98:3d:11:b1: - f6:7d:a0:46:df:24:23:c6:d0:24:52:67:ba:69:ab: - 9a:4a:6a:66:2c:db:e1:09:f1:0d:b2:b0:e1:47:1f: - 0a:46:ac:0d:82:f3:3c:8d:02:ce:08:43:19:d9:64: - 86:c4:4e:07:12:c0:5b:43:ba:7d:17:8a:a3:f0:3d: - 98:32:b9:75:66:f4:f0:1b:2d:94:5b:7c:1c:e6:f3: - 04:7f:dd:25:b2:82:a6:41:04:b7:50:93:94:c4:7c: - 34:7e:12:7c:bf:33:54:55:47:8c:42:94:40:8e:34: - 5f:54:04:1d:9e:8c:57:48:d4:b0:f8:e4:03:db:3f: - 68:6c:37:fa:62:14:1c:94:d6:de:f2:2b:68:29:17: - 24:6d:f7:b5:b3:18:79:fd:31:5e:7f:4c:be:c0:99: - 13:cc:e2:97:2b:dc:96:9c:9a:d0:a7:c5:77:82:67: - c9:cb:a9:e7:68:4a:e1:c5:ba:1c:32:0e:79:40:6e: - ef:08:d7:a3:b9:5d:1a:df:ce:1a:c7:44:91:4c:d4: - 99:c8:88:69:b3:66:2e:b3:06:f1:f4:22:d7:f2:5f: - ab:6d - Exponent: 65537 (0x10001) - ``` - -1. Finally, move the key to apk trusted keys storage: - - ```shell - sudo mv /tmp/nginx_signing.rsa.pub /etc/apk/keys/ - ``` - -1. To install `nginx-agent`, run the following command: - - ```shell - sudo apk add nginx-agent - ``` - -#### Install NGINX Agent on Amazon Linux - -1. Create the `/etc/ssl/nginx` directory: - - ```shell - sudo mkdir -p /etc/ssl/nginx - ``` - -1. Log in to [MyF5 Customer Portal](https://account.f5.com/myf5/) and download your `nginx-repo.crt` and `nginx-repo.key` files. - -1. Copy the `nginx-repo.crt` and `nginx-repo.key` files to the `/etc/ssl/nginx/` directory: - - ```shell - sudo cp nginx-repo.crt nginx-repo.key /etc/ssl/nginx/ - ``` - -1. Install the prerequisites: - - ```shell - sudo yum install yum-utils procps ca-certificates - ``` - -1. To set up the yum repository for Amazon Linux 2, create the file named `/etc/yum.repos.d/nginx-agent.repo` with the following contents: - - ``` - [nginx-agent] - name=nginx-agent repo - baseurl=https://pkgs.nginx.com/nginx-agent/amzn2/$releasever/$basearch - sslclientcert=/etc/ssl/nginx/nginx-repo.crt - sslclientkey=/etc/ssl/nginx/nginx-repo.key - gpgcheck=0 - enabled=1 - ``` - -1. To install `nginx-agent`, run the following command: - - ```shell - sudo yum install nginx-agent - ``` - -1. When prompted to accept the GPG key, verify that the fingerprint matches `573B FD6B 3D8F BC64 1079 A6AB ABF5 BD82 7BD9 BF62`, and if so, accept it. - -#### Install NGINX Agent on FreeBSD - -1. Create the `/etc/ssl/nginx` directory: - - ```shell - sudo mkdir -p /etc/ssl/nginx - ``` - -1. Log in to [MyF5 Customer Portal](https://account.f5.com/myf5/) and download your `nginx-repo.crt` and `nginx-repo.key` files. - -1. Copy the files to the `/etc/ssl/nginx/` directory: - - ```shell - sudo cp nginx-repo.crt nginx-repo.key /etc/ssl/nginx/ - ``` - -1. Install the prerequisite `ca_root_nss` package: - - ```shell - sudo pkg install ca_root_nss - ``` - -1. To setup the pkg repository create the file named `/etc/pkg/nginx-agent.conf` with the following content: - - ``` - nginx-agent: { - URL: pkg+https://pkgs.nginx.com/nginx-agent/freebsd/${ABI}/latest - ENABLED: yes - MIRROR_TYPE: SRV - } - ``` - -1. Add the following lines to the `/usr/local/etc/pkg.conf` file: - - ``` - PKG_ENV: { SSL_NO_VERIFY_PEER: "1", - SSL_CLIENT_CERT_FILE: "/etc/ssl/nginx/nginx-repo.crt", - SSL_CLIENT_KEY_FILE: "/etc/ssl/nginx/nginx-repo.key" } - ``` - -1. To install `nginx-agent`, run the following command: - - ```shell - sudo pkg install nginx-agent - ``` - -## GitHub package files - -To install NGINX Agent on your system, go to the [GitHub releases page](https://github.com/nginx/agent/releases) and download the latest package supported by your operating system distribution and CPU architecture. - -Use your system's package manager to install the package. Some examples: - -- Debian, Ubuntu, and other distributions using the `dpkg` package manager. - - ```shell - sudo dpkg -i nginx-agent-.deb - ``` - -- RHEL, CentOS RHEL, Amazon Linux, Oracle Linux, and other distributions using the `yum` package manager - - ```shell - sudo yum localinstall nginx-agent-.rpm - ``` - -- RHEL and other distributions using the `rpm` package manager - - ```shell - sudo rpm -i nginx-agent-.rpm - ``` - -- Alpine Linux - - ```shell - sudo apk add nginx-agent-.apk - ``` - -- FreeBSD - - ```shell - sudo pkg add nginx-agent-.pkg - ``` - -## systemd environments - -To start NGINX Agent on `systemd` systems, run the following command: - -```shell -sudo systemctl start nginx-agent -``` - -To enable NGINX Agent to start on boot, run the following command: - -```shell -sudo systemctl enable nginx-agent -``` - -## Verify that NGINX Agent is running - -Once you have installed NGINX Agent, you can verify that it is running with the following command: - -```shell -sudo nginx-agent -v -``` - -## Enable interfaces - -Once NGINX Agent is successfully running, you can enable the required interfaces, which is described in the [Enable gRPC and REST interfaces]({{< relref "/how-to/enable-interfaces.md" >}}) topic. - -You may also be interested in the [Start mock control plane interface]({{< relref "/contribute/start-mock-interface.md" >}}) topic for development work. \ No newline at end of file diff --git a/site/content/install-upgrade/migrate-v3.md b/site/content/install-upgrade/migrate-v3.md deleted file mode 100644 index 86646208e..000000000 --- a/site/content/install-upgrade/migrate-v3.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -title: Upgrade from v2.x to v3.0 -weight: 500 -docs: DOCS-000 ---- - -This topic describes how to migrate from F5 NGINX Agent v2 to NGINX Agent v3. - -[//]: # "These are Markdown comments to guide you through document structure." -[//]: # "Remove them as you go, as well as unnecessary sections for this use case." - -## Overview - -[//]: # "Write a description which outlines precisely what this page of instructions will accomplish." -[//]: # "This description, like all instructions, should be direct and imperative." -[//]: # "Avoid ambiguous promises such as 'enables functionality': state precisely what it does." - ---- - -## Before you begin - -[//]: # "List all of the prerequisites for completing this task." -[//]: # "This might be the first page for a reader, so include a link to installation." - -To begin this task, you will require the following: - -- A [working NGINX Agent instance]({{< ref "/install-upgrade/install.md" >}}). -- -- - ---- - -## Migrate from NGINX Agent v2 to v3 - - ---- - -## See also - -[//]: # "Examples of additional topics users might want to read include:" -[//]: # "Relevant reference information, configuration options and more complex use cases." - -- -- diff --git a/site/content/install-upgrade/uninstall.md b/site/content/install-upgrade/uninstall.md deleted file mode 100644 index 7fe869d58..000000000 --- a/site/content/install-upgrade/uninstall.md +++ /dev/null @@ -1,145 +0,0 @@ ---- -title: "Uninstall NGINX Agent" -toc: true -weight: 300 -docs: DOCS-000 ---- - -## Overview - -Learn how to uninstall F5 NGINX Agent from your system. - -## Before you begin - -### Prerequisites - -- NGINX Agent installed [NGINX Agent installed](../installation-oss) -- The user following these steps will need `root` privilege - -## Uninstall NGINX Agent -Complete the following steps on each host where you’ve installed NGINX Agent - - -- [Uninstall NGINX Agent on RHEL, CentOS, Rocky Linux, AlmaLinux, and Oracle Linux](#uninstall-nginx-agent-on-rhel-centos-rocky-linux-almalinux-and-oracle-linux) -- [Uninstall NGINX Agent on Ubuntu](#uninstall-nginx-agent-on-ubuntu) -- [Uninstall NGINX Agent on Debian](#uninstall-nginx-agent-on-debian) -- [Uninstall NGINX Agent on SLES](#uninstall-nginx-agent-on-sles) -- [Uninstall NGINX Agent on Alpine Linux](#uninstall-nginx-agent-on-alpine-linux) -- [Uninstall NGINX Agent on Amazon Linux](#uninstall-nginx-agent-on-amazon-linux) -- [Uninstall NGINX Agent on FreeBSD](#uninstall-nginx-agent-on-freebsd) - -### Uninstall NGINX Agent on RHEL, CentOS, Rocky Linux, AlmaLinux, and Oracle Linux - -Complete the following steps on each host where you've installed NGINX Agent: - -1. Stop NGINX Agent: - - ```shell - sudo systemctl stop nginx-agent - ``` - -1. To uninstall NGINX Agent, run the following command: - - ```shell - sudo yum remove nginx-agent - ``` - -### Uninstall NGINX Agent on Ubuntu - -Complete the following steps on each host where you've installed NGINX Agent: - -1. Stop NGINX Agent: - - ```shell - sudo systemctl stop nginx-agent - ``` - -1. To uninstall NGINX Agent, run the following command: - - ```shell - sudo apt-get remove nginx-agent - ``` - - {{< note >}} The `apt-get remove ` command will remove the package from your system, while keeping the associated configuration files for possible future use. If you want to completely remove the package and all of its configuration files, you should use `apt-get purge `. {{< /note >}} - -### Uninstall NGINX Agent on Debian - -Complete the following steps on each host where you've installed NGINX Agent: - -1. Stop NGINX Agent: - - ```shell - sudo systemctl stop nginx-agent - ``` - -1. To uninstall NGINX Agent, run the following command: - - ```shell - sudo apt-get remove nginx-agent - ``` - - {{< note >}} The `apt-get remove ` command will remove the package from your system, while keeping the associated configuration files for possible future use. If you want to completely remove the package and all of its configuration files, you should use `apt-get purge `. {{< /note >}} - -### Uninstall NGINX Agent on SLES - -Complete the following steps on each host where you've installed NGINX Agent: - -1. Stop NGINX agent: - - ```shell - sudo systemctl stop nginx-agent - ``` - -1. To uninstall NGINX agent, run the following command: - - ```shell - sudo zypper remove nginx-agent - ``` - -### Uninstall NGINX Agent on Alpine Linux - -Complete the following steps on each host where you've installed NGINX agent: - -1. Stop NGINX agent: - - ```shell - sudo rc-service nginx-agent stop - ``` - -1. To uninstall NGINX agent, run the following command: - - ```shell - sudo apk del nginx-agent - ``` - -### Uninstall NGINX Agent on Amazon Linux - -Complete the following steps on each host where you've installed NGINX agent: - -1. Stop NGINX agent: - - ```shell - sudo systemctl stop nginx-agent - ``` - -1. To uninstall NGINX agent, run the following command: - - ```shell - sudo yum remove nginx-agent - ``` - -### Uninstall NGINX Agent on FreeBSD - -Complete the following steps on each host where you've installed NGINX agent: - -1. Stop NGINX agent: - - ```shell - sudo service nginx-agent stop - ``` - -1. To uninstall NGINX agent, run the following command: - - ```shell - sudo pkg delete nginx-agent - ``` diff --git a/site/content/install-upgrade/upgrade.md b/site/content/install-upgrade/upgrade.md deleted file mode 100644 index 55898a2bb..000000000 --- a/site/content/install-upgrade/upgrade.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -title: "Upgrade NGINX Agent" -toc: true -weight: 200 -docs: DOCS-000 ---- - -## Overview - -Learn how to upgrade F5 NGINX Agent. - -## Upgrade NGINX Agent from version v2.31.0 or greater - -{{< note >}} Starting from version v2.31.0, NGINX Agent will automatically restart itself during an upgrade. {{< /note >}} - -To upgrade NGINX Agent, follow these steps: - -1. Open an SSH connection to the server where you’ve installed NGINX Agent and log in. - -1. Make a backup copy of the following locations to ensure that you can successfully recover if the upgrade has issues: - - - `/etc/nginx-agent` - - `config_dirs` values for any configuration specified in `/etc/nginx-agent/nginx-agent.conf` - -1. Install the updated version of NGINX Agent: - - - CentOS, RHEL, RPM-Based - - ```shell - sudo yum -y makecache - sudo yum update -y nginx-agent - ``` - - - Debian, Ubuntu, Deb-Based - - ```shell - sudo apt-get update - sudo apt-get install -y --only-upgrade nginx-agent -o Dpkg::Options::="--force-confold" - ``` - -## Upgrade NGINX Agent from a version less than v2.31.0 - -To upgrade NGINX Agent, take the following steps: - -1. Open an SSH connection to the server where you’ve installed NGINX Agent and log in. - -1. Make a backup copy of the following locations to ensure that you can successfully recover if the upgrade has issues: - - - `/etc/nginx-agent` - - `config_dirs` values for any configuration specified in `/etc/nginx-agent/nginx-agent.conf` - -1. Stop NGINX Agent: - - ```shell - sudo systemctl stop nginx-agent - ``` - -1. Install the updated version of NGINX Agent: - - - CentOS, RHEL, RPM-Based - - ```shell - sudo yum -y makecache - sudo yum update -y nginx-agent - ``` - - - Debian, Ubuntu, Deb-Based - - ```shell - sudo apt-get update - sudo apt-get install -y --only-upgrade nginx-agent -o Dpkg::Options::="--force-confold" - ``` - -1. Start NGINX Agent: - - ```shell - sudo systemctl start nginx-agent - ``` diff --git a/site/content/otel/_index.md b/site/content/otel/_index.md deleted file mode 100644 index 9898dcd10..000000000 --- a/site/content/otel/_index.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: OpenTelemetry -weight: 450 ---- \ No newline at end of file diff --git a/site/content/otel/metrics.md b/site/content/otel/metrics.md deleted file mode 100644 index 179f413b9..000000000 --- a/site/content/otel/metrics.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: OpenTelemetry metrics -weight: 300 -docs: DOCS-000 ---- \ No newline at end of file diff --git a/site/content/support.md b/site/content/support.md deleted file mode 100644 index 0c7fff1bf..000000000 --- a/site/content/support.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -title: Support -weight: 800 -docs: DOCS-000 ---- - -## Support policy -F5 NGINX Agent adheres to the support policy detailed in the following knowledge base article: [K000140156](https://my.f5.com/manage/s/article/K000140156). - -## Contact F5 Support -For questions and/or assistance with installing, troubleshooting, or using NGINX Agent, contact Support via the [MyF5 Customer Portal](https://account.f5.com/myf5). - -## Community support -- If you experience issues with NGINX Agent, please [open an issue in GitHub](https://github.com/nginx/agent/issues/new). -- If you have any suggestions or feature requests, please [open an idea in GitHub discussions](https://github.com/nginx/agent/discussions). \ No newline at end of file diff --git a/site/content/technical-specifications.md b/site/content/technical-specifications.md deleted file mode 100644 index fac11fbdc..000000000 --- a/site/content/technical-specifications.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -title: "Technical specifications" -toc: true -weight: 200 -docs: DOCS-000 ---- - -## Overview - -This document provides technical specifications for F5 NGINX Agent. It includes information on supported distributions, deployment environments, NGINX versions, sizing recommendations, and logging. - -## Supported distributions - -NGINX Agent can run in most environments. We support the following distributions: - -{{< bootstrap-table "table table-striped table-bordered" >}} -| | AlmaLinux | Alpine Linux | Amazon Linux | Amazon Linux 2 | CentOS | Debian | -|-|-----------|--------------|--------------|----------------|--------|--------| -|**Version**|8

9 | 3.16

3.17

3.18

3.19| 2023| LTS| 7.4+| 11

12| -|**Architecture**| x86_84

aarch64| x86_64

aarch64 | x86_64

aarch64 | x86_64

aarch64 | x86_64

aarch64 | x86_64

aarch64 | -{{< /bootstrap-table >}} - -{{< bootstrap-table "table table-striped table-bordered" >}} -| |FreeBSD | Oracle Linux | Red Hat
Enterprise Linux
(RHEL) | Rocky Linux | SUSE Linux
Enterprise Server
(SLES) | Ubuntu | -|-|--------|--------------|---------------------------------|-------------|-------------------------------------|--------| -|**Version**|13

14|7.4+

8.1+

9|7.4+

8.1+

9.0+|8

9|12 SP5

15 SP2|20.04 LTS

22.04 LTS| -|**Architecture**|amd64|x86_64|x86_64

aarch64|x86_64

aarch64|x86_64|x86_64

aarch64| -{{< /bootstrap-table >}} - - -## Supported deployment environments - -NGINX Agent can be deployed in the following environments: - -- Bare Metal -- Container -- Public Cloud: AWS, Google Cloud Platform, and Microsoft Azure -- Virtual Machine - -## Supported NGINX versions - -NGINX Agent works with all supported versions of NGINX Open Source and NGINX Plus. - - -## Sizing recommendations - -Minimum system sizing recommendations for NGINX Agent: -{{< bootstrap-table "table table-striped table-bordered" >}} -| CPU | Memory | Network | Storage | -|------------|----------|-----------|---------| -| 1 CPU core | 1 GB RAM | 1 GbE NIC | 20 GB | -{{< /bootstrap-table >}} - -## Logging - -NGINX Agent utilizes log files and formats to collect metrics. Increasing the log formats and instance counts will result in increased log file sizes. - -To prevent system storage issues due to a growing log directory, it is recommended to add a separate partition for `/var/log/nginx-agent` and enable [log rotation](http://nginx.org/en/docs/control.html#logs). - -More information is available in the [Configuration overview]({{< ref "/how-to/configuration-overview.md#logs" >}}) \ No newline at end of file diff --git a/site/content/v2/_index.md b/site/content/v2/_index.md deleted file mode 100644 index f0138d948..000000000 --- a/site/content/v2/_index.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -title: "NGINX Agent v2" -description: "NGINX Agent is a companion daemon for your NGINX Open Source or NGINX Plus instance." -weight: 900 -cascade: - type: agent-v2-migration ---- \ No newline at end of file diff --git a/site/content/v2/changelog.md b/site/content/v2/changelog.md deleted file mode 100644 index 981348f9e..000000000 --- a/site/content/v2/changelog.md +++ /dev/null @@ -1,282 +0,0 @@ ---- -title: "Changelog" -weight: 1200 -toc: true -docs: "DOCS-1093" ---- - -{{< note >}}You can find the full changelog, contributor list and assets for NGINX Agent in the [GitHub repository](https://github.com/nginx/agent/releases).{{< /note >}} - -See the list of supported Operating Systems and architectures in the [Technical Specifications]({{< relref "./technical-specifications.md" >}}). - ---- -## Release [v2.39.0](https://github.com/nginx/agent/releases/tag/v2.39.0) - -### 🌟 Highlights - -- Remove official docker images & move testing images to test folder by [@aphralG](https://github.com/aphralG) in [#838](https://github.com/nginx/agent/pull/838) - -### 🐛 Bug Fixes - -In this release we have resolved the following issues: - -- Race conditions fixes by [@oliveromahony](https://github.com/oliveromahony) in [#810](https://github.com/nginx/agent/pull/810) -- fix r30 pipeline failures by [@oliveromahony](https://github.com/oliveromahony) in [#844](https://github.com/nginx/agent/pull/844) -- Fixed make target pointing at wrong Dockerfile and renamed others to be consistent by [@oliveromahony](https://github.com/oliveromahony) in [#857](https://github.com/nginx/agent/pull/857) -- Fix broken links causing deployment failures by [@ADubhlaoich](https://github.com/ADubhlaoich) in [#863](https://github.com/nginx/agent/pull/863) -- Fix NGINX OSS integration tests by [@dhurley](https://github.com/dhurley) in [#888](https://github.com/nginx/agent/pull/888) -- Fix docs docker failing without git context by [@nginx-jack](https://github.com/nginx-jack) in [#892](https://github.com/nginx/agent/pull/892) - -### 📝 Documentation - -We have made the following updates to the documentation: - -- Add automatic changelog generation in release workflow by [@spencerugbo](https://github.com/spencerugbo) in [#784](https://github.com/nginx/agent/pull/784) -- Add CLA bot workflow by [@lucacome](https://github.com/lucacome) in [#828](https://github.com/nginx/agent/pull/828) -- Refactor docker images by [@nginx-seanmoloney](https://github.com/nginx-seanmoloney) in [#841](https://github.com/nginx/agent/pull/841) -- Docs: Add hugo version check and theme update to Makefile by [@nginx-jack](https://github.com/nginx-jack) in [#869](https://github.com/nginx/agent/pull/869) -- Change casing of docs makefile to Makefile by [@nginx-jack](https://github.com/nginx-jack) in [#884](https://github.com/nginx/agent/pull/884) -- docs: enableGitInfo config and docs-action bump by [@nginx-jack](https://github.com/nginx-jack) in [#886](https://github.com/nginx/agent/pull/886) -- Change go version to latest go 1.23.2 by [@oliveromahony](https://github.com/oliveromahony) in [#889](https://github.com/nginx/agent/pull/889) -- Remove link to github dockerfiles by [@nginx-seanmoloney](https://github.com/nginx-seanmoloney) in [#897](https://github.com/nginx/agent/pull/897) -- Docs: Update link to 3rd party site by [@nginx-aoife](https://github.com/nginx-aoife) in [#898](https://github.com/nginx/agent/pull/898) -- Update the changelog for v2.38 by [@ADubhlaoich](https://github.com/ADubhlaoich) in [#901](https://github.com/nginx/agent/pull/901) - -### 🔨 Maintenance - -We have made the following maintenance-related minor changes: - -- Set log level to debug for inetegration tests by [@aphralG](https://github.com/aphralG) in [#826](https://github.com/nginx/agent/pull/826) -- updated runc dependency highlighted in security scan scan by [@oliveromahony](https://github.com/oliveromahony) in [#842](https://github.com/nginx/agent/pull/842) -- Update CODEOWNERS by [@oCHRISo](https://github.com/oCHRISo) in [#851](https://github.com/nginx/agent/pull/851) -- Check version command output by [@aphralG](https://github.com/aphralG) in [#853](https://github.com/nginx/agent/pull/853) -- Bump NGINX plus go client version from v1 to v2 by [@dhurley](https://github.com/dhurley) in [#879](https://github.com/nginx/agent/pull/879) -- Allowlist Error Messages by [@aphralG](https://github.com/aphralG) in [#907](https://github.com/nginx/agent/pull/907) - ---- -## Release [v2.38.0](https://github.com/nginx/agent/releases/tag/v2.38.0) - -### 🐛 Bug Fixes - -In this release we have resolved the following issues: - -- Fix broken URLS in docs by [@nginx-aoife](https://github.com/nginx-aoife) in [#796](https://github.com/nginx/agent/pull/796) -- fix name of deprecated flag by [@aphralG](https://github.com/aphralG) in [#811](https://github.com/nginx/agent/pull/811) -- Fix make image targets by [@dhurley](https://github.com/dhurley) in [#812](https://github.com/nginx/agent/pull/812) -- Fix debian oss image by [@dhurley](https://github.com/dhurley) in [#819](https://github.com/nginx/agent/pull/819) - -### 📝 Documentation - -We have made the following updates to the documentation: - -- docs: update GPG keys by [@Jcahilltorre](https://github.com/Jcahilltorre) in [#776](https://github.com/nginx/agent/pull/776) -- Add new docker images to v2 pipeline for integration testing by [@oliveromahony](https://github.com/oliveromahony) in [#756](https://github.com/nginx/agent/pull/756) -- Update website changelog for v2.37.0 by [@ADubhlaoich](https://github.com/ADubhlaoich) in [#790](https://github.com/nginx/agent/pull/790) -- Pass on custom error log path at the time of validating config by [@achawla2012](https://github.com/achawla2012) in [#774](https://github.com/nginx/agent/pull/774) -- Remove blocking calls in metrics framework by [@oliveromahony](https://github.com/oliveromahony) in [#788](https://github.com/nginx/agent/pull/788) -- Update broken URL in installation-plus.md by [@nginx-aoife](https://github.com/nginx-aoife) in [#808](https://github.com/nginx/agent/pull/808) - -### 🔨 Maintenance - -We have made the following maintenance-related minor changes: - -- add new plus docker images to v2 pipeline by [@aphralG](https://github.com/aphralG) in [#779](https://github.com/nginx/agent/pull/779) -- Add MaxRecvMsgSize and MaxSendMsgSize to client and server options by [@oliveromahony](https://github.com/oliveromahony) in [#795](https://github.com/nginx/agent/pull/795) -- added leak tests for agent v2 by [@oliveromahony](https://github.com/oliveromahony) in [#807](https://github.com/nginx/agent/pull/807) - ---- -## Release [v2.37.0](https://github.com/nginx/agent/releases/tag/v2.37.0) - -### 🚀 Features - -This release introduces the following new features: - -- feat: Update the changelog by [@ADubhlaoich](https://github.com/ADubhlaoich) in [#753](https://github.com/nginx/agent/pull/753) - -### 🐛 Bug Fixes - -In this release we have resolved the following issues: - -- Prevent writing outside allowed directories list from a config payload with actions by [@oliveromahony](https://github.com/oliveromahony) in [#766](https://github.com/nginx/agent/pull/766) -- The letter v is now always prepended to output of -v by [@olli-holmala](https://github.com/olli-holmala) in [#751](https://github.com/nginx/agent/pull/751) -- Fix backoff to drop Metrics Reports from buffer after max_elapsed_time has been reached by [@oliveromahony](https://github.com/oliveromahony) in [#752](https://github.com/nginx/agent/pull/752) -- Fix Post Install Script Issues by [@spencerugbo](https://github.com/spencerugbo) in [#739](https://github.com/nginx/agent/pull/739) -- docs: fix github links in changelog by [@Jcahilltorre](https://github.com/Jcahilltorre) in [#770](https://github.com/nginx/agent/pull/770) -- Fix post install script for when no nginx instance is installed by [@dhurley](https://github.com/dhurley) in [#773](https://github.com/nginx/agent/pull/773) - -### 📝 Documentation - -We have made the following updates to the documentation: - -- Upgrade prometheus exporter version to latest by [@oliveromahony](https://github.com/oliveromahony) in [#749](https://github.com/nginx/agent/pull/749) -- Add badges for Go version, release, license, contributions, and Slackâ€Ļ by [@oCHRISo](https://github.com/oCHRISo) in [#763](https://github.com/nginx/agent/pull/763) -- Add instructions for Amazon Linux 2023 by [@nginx-seanmoloney](https://github.com/nginx-seanmoloney) in [#759](https://github.com/nginx/agent/pull/759) -- Add docs-build-push github workflow by [@nginx-jack](https://github.com/nginx-jack) in [#765](https://github.com/nginx/agent/pull/765) - -### 🔨 Maintenance - -We have made the following maintenance-related minor changes: - -- Increase timeout period for collecting metrics by [@oliveromahony](https://github.com/oliveromahony) in [#755](https://github.com/nginx/agent/pull/755) - ---- -## Release [v2.36.1](https://github.com/nginx/agent/releases/tag/v2.36.1) - -### 🌟 Highlights - -- Upgrade crossplane version to prevent Agent from rolling back in the case of valid NGINX configurations by [@oliveromahony](https://github.com/oliveromahony) in [#746](https://github.com/nginx/agent/pull/746) - -### 🔨 Maintenance - -We have made the following maintenance-related minor changes: - -- Added version regex to parse the logs to see if matches vsemvar format by [@oliveromahony](https://github.com/oliveromahony) in [#747](https://github.com/nginx/agent/pull/747) - ---- -## Release [v2.36.0](https://github.com/nginx/agent/releases/tag/v2.36.0) - -### 🐛 Bug Fixes - -In this release we have resolved the following issues: - -- Fix incorrect bold tag in heading by [@nginx-seanmoloney](https://github.com/nginx-seanmoloney) in [#715](https://github.com/nginx/agent/pull/715) -- URL fix for building docker image in README.md by [@y82](https://github.com/y82) in [#720](https://github.com/nginx/agent/pull/720) -- Fix for version by [@oliveromahony](https://github.com/oliveromahony) in [#732](https://github.com/nginx/agent/pull/732) - -### 📝 Documentation - -We have made the following updates to the documentation: - -- More flexible container images for the official images by [@oliveromahony](https://github.com/oliveromahony) in [#729](https://github.com/nginx/agent/pull/729) -- Update configuration examples by [@nginx-seanmoloney](https://github.com/nginx-seanmoloney) in [#731](https://github.com/nginx/agent/pull/731) -- updated github.com/rs/cors version by [@oliveromahony](https://github.com/oliveromahony) in [#735](https://github.com/nginx/agent/pull/735) -- docs: update changelog by [@Jcahilltorre](https://github.com/Jcahilltorre) in [#736](https://github.com/nginx/agent/pull/736) -- Upgrade crossplane by [@oliveromahony](https://github.com/oliveromahony) in [#737](https://github.com/nginx/agent/pull/737) - ---- -## Release [v2.35.1](https://github.com/nginx/agent/releases/tag/v2.35.1) - -### 🐛 Bug Fixes - -In this release we have resolved the following issues: - -- fix: add deduplication for the same ssl cert metadata by [@mattdesmarais](https://github.com/mattdesmarais) [@oliveromahony](https://github.com/oliveromahony) in [#716](https://github.com/nginx/agent/pull/716) -- Fix release workflow by [@dhurley](https://github.com/dhurley) in [#724](https://github.com/nginx/agent/pull/724) - -### 📝 Documentation - -We have made the following updates to the documentation: - -- Update environment variables from NMS to NGINX_AGENT by [@spencerugbo](https://github.com/spencerugbo) in [#710](https://github.com/nginx/agent/pull/710) -- Update the flag & environment table callouts by [@ADubhlaoich](https://github.com/ADubhlaoich) in [#712](https://github.com/nginx/agent/pull/712) -- updated golang version to 1.22 by [@oliveromahony](https://github.com/oliveromahony) in [#717](https://github.com/nginx/agent/pull/717) - -### 🔨 Maintenance - -We have made the following maintenance-related minor changes: - -- More detailed test for env variables migration by [@oliveromahony](https://github.com/oliveromahony) in [#709](https://github.com/nginx/agent/pull/709) - ---- -## Release [v2.35.0](https://github.com/nginx/agent/releases/tag/v2.35.0) - -### 🌟 Highlights - -- R32 operating system support parity by [@oliveromahony](https://github.com/oliveromahony) in [#708](https://github.com/nginx/agent/pull/708) - -### 🐛 Bug Fixes - -In this release we have resolved the following issues: - -- Change environment prefix from nms to nginx_agent by [@spencerugbo](https://github.com/spencerugbo) in [#706](https://github.com/nginx/agent/pull/706) - -### 📝 Documentation - -We have made the following updates to the documentation: - -- Consolidated CLI flag and Env Var sections by [@travisamartin](https://github.com/travisamartin) in [#701](https://github.com/nginx/agent/pull/701) -- Add Ubuntu Noble 24.04 LTS support by [@Dean-Coakley](https://github.com/Dean-Coakley) in [#682](https://github.com/nginx/agent/pull/682) - ---- -## Release [v2.34.1](https://github.com/nginx/agent/releases/tag/v2.34.1) - -### 🐛 Bug Fixes - -In this release we have resolved the following issues: - -- Fix metrics reporter retry logic by [@dhurley](https://github.com/dhurley) in [#700](https://github.com/nginx/agent/pull/700) - -### 📝 Documentation - -We have made the following updates to the documentation: - -- Update changelog for release 2.34 by [@ADubhlaoich](https://github.com/ADubhlaoich) in [#693](https://github.com/nginx/agent/pull/693) - ---- -## Release [v2.34.0](https://github.com/nginx/agent/releases/tag/v2.34.0) - -### 🌟 Highlights - -- Bump the version of net package in golang by [@oliveromahony](https://github.com/oliveromahony) in [#645](https://github.com/nginx/agent/pull/645) - -- Add health check endpoint by [@dhurley](https://github.com/dhurley) in [#665](https://github.com/nginx/agent/pull/665) - -- Add pending health status by [@dhurley](https://github.com/dhurley) in [#672](https://github.com/nginx/agent/pull/672) - -### 🐛 Bug Fixes - -In this release we have resolved the following issues: - -- fix: fix titles case by [@Jcahilltorre](https://github.com/Jcahilltorre) in [#674](https://github.com/nginx/agent/pull/674) -- Fix oracle linux integration test by [@dhurley](https://github.com/dhurley) in [#676](https://github.com/nginx/agent/pull/676) - -### 📝 Documentation - -We have made the following updates to the documentation: - -- chore: add 2.33.0 changelog by [@Jcahilltorre](https://github.com/Jcahilltorre) in [#622](https://github.com/nginx/agent/pull/622) -- Change environment variable list to table with CLI references by [@ADubhlaoich](https://github.com/ADubhlaoich) in [#689](https://github.com/nginx/agent/pull/689) -- Add health checks documentation by [@dhurley](https://github.com/dhurley) in [#673](https://github.com/nginx/agent/pull/673) - -### 🔨 Maintenance - -We have made the following maintenance-related minor changes: - -- Keep looking for master process by [@spencerugbo](https://github.com/spencerugbo) in [#617](https://github.com/nginx/agent/pull/617) -- Bump docker dependency to version v24.0.9 by [@dhurley](https://github.com/dhurley) in [#626](https://github.com/nginx/agent/pull/626) -- Bump the version of github.com/opencontainers/runc dependency by [@dhurley](https://github.com/dhurley) in [#657](https://github.com/nginx/agent/pull/657) -- Remove unnecessary freebsd logic for finding process executable by [@dhurley](https://github.com/dhurley) in [#668](https://github.com/nginx/agent/pull/668) -- Add additional checks in chunking functionality by [@dhurley](https://github.com/dhurley) in [#671](https://github.com/nginx/agent/pull/671) - ---- -## Release [v2.33.0](https://github.com/nginx/agent/releases/tag/v2.33.0) - -### 🚀 Features - -This release introduces the following new features: - -- feat: Add Support for NAP 5 by [@edarzins](https://github.com/edarzins) in [#604](https://github.com/nginx/agent/pull/604) - -### 🐛 Bug Fixes - -In this release we have resolved the following issues: - -- Fix nfpm.yaml for apk packages by [@dhurley](https://github.com/dhurley) in [#597](https://github.com/nginx/agent/pull/597) -- fix unit test by [@oliveromahony](https://github.com/oliveromahony) in [#607](https://github.com/nginx/agent/pull/607) -- Fix user workflow performance tests by [@dhurley](https://github.com/dhurley) in [#612](https://github.com/nginx/agent/pull/612) -- fix Advanced Metrics by [@aphralG](https://github.com/aphralG) in [#598](https://github.com/nginx/agent/pull/598) - -### 📝 Documentation - -We have made the following updates to the documentation: - -- chore: Add the 2.32.2 Changelog to the docs website by [@Jcahilltorre](https://github.com/Jcahilltorre) in [#601](https://github.com/nginx/agent/pull/601) - -### 🔨 Maintenance - -We have made the following maintenance-related minor changes: - -- Bump the version of protobuf by [@oliveromahony](https://github.com/oliveromahony) in [#602](https://github.com/nginx/agent/pull/602) -- replace duplicate isContainer call by [@oliveromahony](https://github.com/oliveromahony) in [#596](https://github.com/nginx/agent/pull/596) -- Add logging to NGINX API http requests by [@dhurley](https://github.com/dhurley) in [#605](https://github.com/nginx/agent/pull/605) - diff --git a/site/content/v2/configuration/_index.md b/site/content/v2/configuration/_index.md deleted file mode 100644 index cd9db7e49..000000000 --- a/site/content/v2/configuration/_index.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "Configuration" -weight: "400" ---- - -Learn how to configure NGINX Agent. \ No newline at end of file diff --git a/site/content/v2/configuration/configuration-overview.md b/site/content/v2/configuration/configuration-overview.md deleted file mode 100644 index b5bdd2ef0..000000000 --- a/site/content/v2/configuration/configuration-overview.md +++ /dev/null @@ -1,274 +0,0 @@ ---- -title: "How to configure NGINX Agent" -draft: false -weight: 100 -toc: true -tags: [ "docs" ] -docs: "DOCS-1229" -categories: ["configuration"] -doctypes: ["task"] ---- - -The following sections explain how to configure NGINX Agent using configuration files, CLI flags, and environment variables. - -{{}} - -- NGINX Agent interprets configuration values set by configuration files, CLI flags, and environment variables in the following priorities: - - 1. CLI flags overwrite configuration files and environment variable values. - 2. Environment variables overwrite configuration file values. - 3. Config files are the lowest priority and config settings are superseded if either of the other options is used. - -- You must open any required firewall ports or add SELinux/AppArmor rules for the ports and IPs you want to use. - -{{}} - -## Configure with Config Files - -The default locations of configuration files for NGINX Agent are `/etc/nginx-agent/nginx-agent.conf` and `/var/lib/nginx-agent/agent-dynamic.conf`. The `agent-dynamic.conf` file default location is different for FreeBSD which is located `/var/db/nginx-agent/agent-dynamic.conf`. These files have comments at the top indicating their purpose. - -Examples of the configuration files are provided below: - -
- example nginx-agent.conf - -{{}} -In the following example `nginx-agent.conf` file, you can change the `server.host` and `server.grpcPort` to connect to the control plane. -{{}} - -```nginx {hl_lines=[13]} -# -# /etc/nginx-agent/nginx-agent.conf -# -# Configuration file for NGINX Agent. -# -# This file tracks agent configuration values that are meant to be statically set. There -# are additional NGINX Agent configuration values that are set via the API and agent install script -# which can be found in /etc/nginx-agent/agent-dynamic.conf. - -# specify the server grpc port to connect to -server: - # host of the control plane - host: - grpcPort: 443 - backoff: # note: default values are prepopulated - initial_interval: 100ms # Add the appropriate duration value here, e.g., "100ms" for 100 milliseconds, "5s" for 5 seconds, "1m" for 1 minute, "1h" for 1 hour - randomization_factor: 0.10 # Add the appropriate float value here, e.g., 0.10 - multiplier: 1.5 # Add the appropriate float value here, e.g., 1.5 - max_interval: 1m # Add the appropriate duration value here, e.g., "100ms" for 100 milliseconds, "5s" for 5 seconds, "1m" for 1 minute, "1h" for 1 hour - max_elapsed_time: 0 # Add the appropriate duration value here, e.g., "0" for indefinite "100ms" for 100 milliseconds, "5s" for 5 seconds, "1m" for 1 minute, "1h" for 1 hour -# tls options -tls: - # enable tls in the nginx-agent setup for grpcs - # default to enable to connect with secure connection but without client cert for mtls - enable: true - # controls whether the server certificate chain and host name are verified. - # for production use, see instructions for configuring TLS - skip_verify: false -log: - # set log level (panic, fatal, error, info, debug, trace; default "info") - level: info - # set log path. if empty, don't log to file. - path: /var/log/nginx-agent/ -nginx: - # path of NGINX logs to exclude - exclude_logs: "" - # Set to true when NGINX configuration should contain no warnings when performing a configuration apply (nginx -t is used to carry out this check) - treat_warnings_as_errors: false # Default is false -# data plane status message / 'heartbeat' -dataplane: - status: - # poll interval for dataplane status - the frequency the NGINX Agent will query the dataplane for changes - poll_interval: 30s - # report interval for dataplane status - the maximum duration to wait before syncing dataplane information if no updates have been observed - report_interval: 24h -metrics: - # specify the size of a buffer to build before sending metrics - bulk_size: 20 - # specify metrics poll interval - report_interval: 1m - collection_interval: 15s - mode: aggregated - backoff: # note: default values are prepopulated - initial_interval: 100ms # Add the appropriate duration value here, e.g., "100ms" for 100 milliseconds, "5s" for 5 seconds, "1m" for 1 minute, "1h" for 1 hour - randomization_factor: 0.10 # Add the appropriate float value here, e.g., 0.10 - multiplier: 1.5 # Add the appropriate float value here, e.g., 1.5 - max_interval: 1m # Add the appropriate duration value here, e.g., "100ms" for 100 milliseconds, "5s" for 5 seconds, "1m" for 1 minute, "1h" for 1 hour - max_elapsed_time: 0 # Add the appropriate duration value here, e.g., "0" for indefinite "100ms" for 100 milliseconds, "5s" for 5 seconds, "1m" for 1 minute, "1h" for 1 hour - -# OSS NGINX default config path -# path to aux file dirs can also be added -config_dirs: "/etc/nginx:/usr/local/etc/nginx" - -# Internal queue size -queue_size: 100 - -extensions: - - nginx-app-protect - -# Enable reporting NGINX App Protect details to the control plane. -nginx_app_protect: - # Report interval for NGINX App Protect details - the frequency NGINX Agent checks NGINX App Protect for changes. - report_interval: 15s - # Enable precompiled publication from the NGINX Management Suite (true) or perform compilation on the data plane host (false). - precompiled_publication: true -``` - -
- - -
- example dynamic-agent.conf - -{{}} -Default location in Linux environments: `/var/lib/nginx-agent/agent-dynamic.conf` - -Default location in FreeBSD environments: `/var/db/nginx-agent/agent-dynamic.conf` -{{}} - -```yaml -# Dynamic configuration file for NGINX Agent. -# -# The purpose of this file is to track agent configuration -# values that can be dynamically changed via the API and the agent install script. -# You may edit this file, but API calls that modify the tags on this system will -# overwrite the tag values in this file. -# -# The agent configuration values that API calls can modify are as follows: -# tags: -# - dev -# - qa -# -# The agent configuration value that the agent install script can modify are as follows: -# instance_group: my-instance-group - -instance_group: my-instance-group -tags: - - dev - - qa -``` - -
- -## CLI Flags & Environment Variables - -This section details the CLI flags and corresponding environment variables used to configure the NGINX Agent. - -### Usage - -#### CLI Flags - -```sh -nginx-agent [flags] -``` - -#### Environment Variables - -```sh -export ENV_VARIABLE_NAME="value" -nginx-agent -``` - -### CLI Flags and Environment Variables - -{{< warning >}} - -Before version 2.35.0, the environment variables were prefixed with `NMS_` instead of `NGINX_AGENT_`. - -If you are upgrading from an older version, update your configuration accordingly. - -{{< /warning >}} - -{{}} -| CLI flag | Environment variable | Description | -|---------------------------------------------|--------------------------------------|-----------------------------------------------------------------------------| -| `--api-cert` | `NGINX_AGENT_API_CERT` | Specifies the certificate used by the Agent API. | -| `--api-host` | `NGINX_AGENT_API_HOST` | Sets the host used by the Agent API. Default: *127.0.0.1* | -| `--api-key` | `NGINX_AGENT_API_KEY` | Specifies the key used by the Agent API. | -| `--api-port` | `NGINX_AGENT_API_PORT` | Sets the port for exposing nginx-agent to HTTP traffic. | -| `--config-dirs` | `NGINX_AGENT_CONFIG_DIRS` | Defines directories NGINX Agent can read/write. Default: *"/etc/nginx:/usr/local/etc/nginx:/usr/share/nginx/modules:/etc/nms"* | -| `--dataplane-report-interval` | `NGINX_AGENT_DATAPLANE_REPORT_INTERVAL` | Sets the interval for dataplane reporting. Default: *24h0m0s* | -| `--dataplane-status-poll-interval` | `NGINX_AGENT_DATAPLANE_STATUS_POLL_INTERVAL` | Sets the interval for polling dataplane status. Default: *30s* | -| `--display-name` | `NGINX_AGENT_DISPLAY_NAME` | Sets the instance's display name. | -| `--dynamic-config-path` | `NGINX_AGENT_DYNAMIC_CONFIG_PATH` | Specifies the path of the Agent dynamic config file. Default: *"/var/lib/nginx-agent/agent-dynamic.conf"* | -| `--features` | `NGINX_AGENT_FEATURES` | Specifies a comma-separated list of features enabled for the agent. Default: *[registration, nginx-config-async, nginx-ssl-config, nginx-counting, metrics, dataplane-status, process-watcher, file-watcher, activity-events, agent-api]* | -| `--ignore-directives` | | Specifies a comma-separated list of directives to ignore for sensitive info.| -| `--instance-group` | `NGINX_AGENT_INSTANCE_GROUP` | Sets the instance's group value. | -| `--log-level` | `NGINX_AGENT_LOG_LEVEL` | Sets the logging level (e.g., panic, fatal, error, info, debug, trace). Default: *info* | -| `--log-path` | `NGINX_AGENT_LOG_PATH` | Specifies the path to output log messages. | -| `--metrics-bulk-size` | `NGINX_AGENT_METRICS_BULK_SIZE` | Specifies the number of metrics reports collected before sending data. Default: *20* | -| `--metrics-collection-interval` | `NGINX_AGENT_METRICS_COLLECTION_INTERVAL` | Sets the interval for metrics collection. Default: *15s* | -| `--metrics-mode` | `NGINX_AGENT_METRICS_MODE` | Sets the metrics collection mode: streaming or aggregation. Default: *aggregated* | -| `--metrics-report-interval` | `NGINX_AGENT_METRICS_REPORT_INTERVAL` | Sets the interval for reporting collected metrics. Default: *1m0s* | -| `--nginx-config-reload-monitoring-period` | | Sets the duration to monitor error logs after an NGINX reload. Default: *10s* | -| `--nginx-exclude-logs` | `NGINX_AGENT_NGINX_EXCLUDE_LOGS` | Specifies paths of NGINX access logs to exclude from metrics collection. | -| `--nginx-socket` | `NGINX_AGENT_NGINX_SOCKET` | Specifies the location of the NGINX Plus counting Unix socket. Default: *unix:/var/run/nginx-agent/nginx.sock* | -| `--nginx-treat-warnings-as-errors` | `NGINX_AGENT_NGINX_TREAT_WARNINGS_AS_ERRORS` | Treats warnings as failures on configuration application. | -| `--queue-size` | `NGINX_AGENT_QUEUE_SIZE` | Specifies the size of the NGINX Agent internal queue. | -| `--server-command` | | Specifies the name of the command server sent in the TLS configuration. | -| `--server-grpcport` | `NGINX_AGENT_SERVER_GRPCPORT` | Sets the desired GRPC port for NGINX Agent traffic. | -| `--server-host` | `NGINX_AGENT_SERVER_HOST` | Specifies the IP address of the server host. | -| `--server-metrics` | | Specifies the name of the metrics server sent in the TLS configuration. | -| `--server-token` | `NGINX_AGENT_SERVER_TOKEN` | Sets the authentication token for accessing the commander and metrics services. Default: *e202f883-54c6-4702-be15-3ba6e507879a* | -| `--tags` | `NGINX_AGENT_TAGS` | Specifies a comma-separated list of tags for the instance or machine. | -| `--tls-ca` | `NGINX_AGENT_TLS_CA` | Specifies the path to the CA certificate file for TLS. | -| `--tls-cert` | `NGINX_AGENT_TLS_CERT` | Specifies the path to the certificate file for TLS. | -| `--tls-enable` | `NGINX_AGENT_TLS_ENABLE` | Enables TLS for secure communications. | -| `--tls-key` | `NGINX_AGENT_TLS_KEY` | Specifies the path to the certificate key file for TLS. | -| `--tls-skip-verify` | `NGINX_AGENT_TLS_SKIP_VERIFY` | Insecurely skips verification for gRPC TLS credentials. | -{{}} - -
- -{{}} -Use the `--config-dirs` command-line option, or the `config_dirs` key in the `nginx-agent.conf` file, to identify the directories NGINX Agent can read from or write to. This setting also defines the location to which you can upload config files when using a control plane. - -NGINX Agent cannot write to directories outside the specified location when updating a config and cannot upload files to directories outside of the configured location. - -NGINX Agent follows NGINX configuration directives to file paths outside the designated directories and reads certificates' metadata. NGINX Agent uses the following directives: - -- [`ssl_certificate`](https://nginx.org/en/docs/http/ngx_http_ssl_module.html#ssl_certificate) - -{{}} - -{{}} Use the `--dynamic-config-path` command-line option to set the location of the dynamic config file. This setting also requires you to move your dynamic config to the new path, or create a new dynamic config file at the specified location. - -Default location in Linux environments: `/var/lib/nginx-agent/agent-dynamic.conf` - -Default location in FreeBSD environments: `/var/db/nginx-agent/agent-dynamic.conf` - -{{}} - -## Log Rotation - -By default, NGINX Agent rotates logs daily using logrotate with the following configuration: - -
- NGINX Agent Logrotate Configuration - -``` yaml -/var/log/nginx-agent/*.log -{ - # log files are rotated every day - daily - # log files are rotated if they grow bigger than 5M - size 5M - # truncate the original log file after creating a copy - copytruncate - # remove rotated logs older than 10 days - maxage 10 - # log files are rotated 10 times before being removed - rotate 10 - # old log files are compressed - compress - # if the log file is missing it will go on to the next one without issuing an error message - missingok - # do not rotate the log if it is empty - notifempty -} -``` -
- -If you need to change the default configuration, update the file at `/etc/logrotate.d/nginx-agent`. - -For more details on logrotate configuration, see [Logrotate Configuration Options](https://linux.die.net/man/8/logrotate). \ No newline at end of file diff --git a/site/content/v2/configuration/configure-nginx-agent-group.md b/site/content/v2/configuration/configure-nginx-agent-group.md deleted file mode 100644 index 45c3e9094..000000000 --- a/site/content/v2/configuration/configure-nginx-agent-group.md +++ /dev/null @@ -1,87 +0,0 @@ ---- -title: "Add NGINX users to nginx-agent group" -draft: false -weight: 300 -toc: true -tags: [ "docs" ] -docs: "DOCS-933" -categories: ["configuration"] -doctypes: ["task"] ---- - -## Overview - -During installation, NGINX Agent detects the NGINX user (typically `nginx`) for the master and worker processes and adds this user to a group called `nginx-agent`. - -If you change the NGINX username after installing the NGINX Agent, you'll need to add the new username to the `nginx-agent` group so that the NGINX socket has the proper permissions. - -A failure to update the `nginx-agent` group when the NGINX username changes may result in non-compliance errors for NGINX Plus. - - -## NGINX Socket - -NGINX Agent creates a socket in the default location `/var/run/nginx-agent/nginx.sock`. You can customize this location by editing the `nginx-agent.conf` file and setting the path similar to the following example: - -```nginx configuration -nginx: - ... - socket: "unix:/var/run/nginx-agent/nginx.sock" -``` - -The socket server starts when the NGINX socket configuration is enabled; the socket configuration is enabled by default. - - -## Add NGINX Users to nginx-agent Group - -To manually add NGINX users to the `nginx-agent` group, take the following steps: - -1. Verify the `nginx-agent` group exists: - - ```bash - sudo getent group | grep nginx-agent - ``` - - The output looks similar to the following example: - - ```bash - nginx-agent:x:1001:root,nginx - ``` - - If the group doesn't exist, create it by running the following command: - - ```bash - sudo groupadd nginx-agent - ``` - -2. Verify the ownership of `/var/run/nginx-agent` directory: - - ```bash - ls -l /var/run/nginx-agent - ``` - - The output looks similar to the following: - - ```bash - total 0 - srwxrwxr-x 1 root nginx-agent 0 Jun 13 10:51 nginx.sockvv - ``` - - If the group ownership is not `nginx-agent`, change the ownership by running the following command: - - ```bash - sudo chown :nginx-agent /var/run/nginx-agent - ``` - -3. To add NGINX user(s) to the `nginx-agent` group, run the following command: - - ```bash - sudo usermod -a -G nginx-agent - ``` - - For example to add the `nginx` user, take the following step: - - ```bash - sudo usermod -a -G nginx-agent nginx - ``` - - Repeat for all NGINX users. diff --git a/site/content/v2/configuration/encrypt-communication.md b/site/content/v2/configuration/encrypt-communication.md deleted file mode 100644 index 7d9f1b547..000000000 --- a/site/content/v2/configuration/encrypt-communication.md +++ /dev/null @@ -1,128 +0,0 @@ ---- -title: Encrypt communication -tags: -- docs -toc: true -weight: 200 -docs: "DOCS-802" ---- - -## Overview - -Follow the steps in this guide to encrypt communication between NGINX Agent and Instance Manager with TLS. - -## Before You Begin - -To enable mTLS, you must have TLS enabled and supply a key, cert, and a CA cert on both the client and server. See the [Secure Traffic with Certificates](https://docs.nginx.com/nginx-instance-manager/system-configuration/secure-traffic/) topic for instructions on how to generate keys and set them in the specific values in the NGINX Agent configuration. - -## Enabling mTLS - -See the examples below for how to set these values using a configuration file, CLI flags, or environment variables. - -### Enabling mTLS via Config Values - -You can edit the `/etc/nginx-agent/nginx-agent.conf` file to enable mTLS for NGINX Agent. Make the following changes: - -```yaml -server: - metrics: "cert-sni-name" - command: "cert-sni-name" -tls: - enable: true - cert: "path-to-cert" - key: "path-to-key" - ca: "path-to-ca-cert" - skip_verify: false -``` - -The `cert-sni-name` value should match the SubjectAltName of the server certificate. For more information see [Configuring HTTPS servers](http://nginx.org/en/docs/http/configuring_https_servers.html). - -### Enabling mTLS with CLI Flags - -To enable mTLS for the NGINX Agent from the command line, run the following command: - -```bash -nginx-agent --tls-cert "path-to-cert" --tls-key "path-to-key" --tls-ca "path-to-ca-cert" --tls-enable -``` - -### Enabling mTLS with Environment Variables - -To enable mTLS for NGINX Agent using environment variables, run the following commands: - -```bash -NGINX_AGENT_TLS_CA="my-env-ca" -NGINX_AGENT_TLS_KEY="my-env-key" -NGINX_AGENT_TLS_CERT="my-env-cert" -NGINX_AGENT_TLS_ENABLE=true -``` - -
- ---- - -## Enabling Server-Side TLS - -To enable server-side TLS you must have TLS enabled. See the following examples for how to set these values using a configuration file, CLI flags, or environment variables. - -### Enabling Server-Side TLS via Config Values - -You can edit the `/etc/nginx-agent/nginx-agent.conf` file to enable server-side TLS. Make the following changes: - -```bash -tls: - enable: true - skip_verify: false -``` - -### Enabling Server Side TLS with CLI Flags - -To enable server-side TLS from the command line, run the following command: - -```bash -nginx-agent --tls-enable -``` - -### Enabling Server-Side TLS with Environment Variables - -To enable server-side TLS using environment variables, run the following commands: - -```bash -NGINX_AGENT_TLS_ENABLE=true -``` - -
- ---- - -## Enable Server-Side TLS With Self-Signed Certificate - -{{< warning >}}These steps are not recommended for production environments.{{< /warning >}} - -To enable server-side TLS with a self-signed certificate, you must have TLS enabled and set `skip_verify` to `true`, which disables hostname validation. Setting `skip_verify` can be done done only by updating the configuration file. See the following example: - -```bash -tls: - enable: true - skip_verify: true -``` - -## Insecure Mode (Not Recommended) - -To enable insecure mode, you simply need to set `tls:enable` to `false`. Setting this value to `false` can be done only by updating the configuration file or with environment variables. See the following examples: - -### Enabling Insecure Mode via Config Values - -You can edit the `/etc/nginx-agent/nginx-agent.conf` file to enable insecure mode. Make the following changes: - -```bash -tls: - enable: false -``` - -### Enabling Insecure Mode with Environment Variables - -To enable insecure mode using environment variables, run the following commands: - -```bash -NGINX_AGENT_TLS_ENABLE=false -``` diff --git a/site/content/v2/configuration/health-checks.md b/site/content/v2/configuration/health-checks.md deleted file mode 100644 index f045955fb..000000000 --- a/site/content/v2/configuration/health-checks.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -title: "Health checks" -draft: false -weight: 400 -toc: true -tags: [ "docs" ] -categories: ["configuration"] -doctypes: ["task"] ---- - -## Overview - -The REST API includes a health endpoint to verify the status of NGINX Agent. - -## Configure the REST API - -To enable the REST API, add the following configuration to the NGINX Agent configuration file, `/etc/nginx-agent/nginx-agent.conf`: - -```nginx -api: - host: 127.0.0.1 - port: 8038 -``` - -## Using health checks - -After you enable the REST API, calling the `/health` endpoint returns the following JSON response: - -```json -{ - "status": "OK", - "checks": [ - { - "name": "registration", - "status": "OK" - }, - { - "name": "commandConnection", - "status": "OK" - }, - { - "name": "metricsConnection", - "status": "OK" - } - ] -} -``` - -The top-level `status` field is the overall health status of NGINX Agent. The health status can return three different states: - -1. `PENDING`: NGINX Agent is still determining its health status. -2. `OK`: NGINX Agent is in a healthy state. -3. `ERROR`: NGINX Agent is in an unhealthy state. - -The health checkpoint performs three checks to determine the overall health of the NGINX Agent: - -1. `registration`: Checks if NGINX Agent has successfully registered with the management plane server. -2. `commandConnection`: Checks if NGINX Agent is still able to receive and send commands. -3. `metricsConnection`: Checks if NGINX Agent is still able to send metric reports. - -If any of the checks are in an `ERROR` status, then the overall status of NGINX Agent will change to `ERROR` as well. - diff --git a/site/content/v2/installation-upgrade/_index.md b/site/content/v2/installation-upgrade/_index.md deleted file mode 100644 index 4c3d49899..000000000 --- a/site/content/v2/installation-upgrade/_index.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "Installation and upgrade" -description: "Learn how to install, upgrade, and uninstall NGINX Agent." -menu: docs -weight: 300 ---- \ No newline at end of file diff --git a/site/content/v2/installation-upgrade/container-environments/_index.md b/site/content/v2/installation-upgrade/container-environments/_index.md deleted file mode 100644 index ca964e34b..000000000 --- a/site/content/v2/installation-upgrade/container-environments/_index.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: "Container environments" -description: "Learn how to build and run NGINX Agent docker images." -menu: docs -weight: 800 ---- \ No newline at end of file diff --git a/site/content/v2/installation-upgrade/container-environments/docker-images.md b/site/content/v2/installation-upgrade/container-environments/docker-images.md deleted file mode 100644 index 40b3a3b2f..000000000 --- a/site/content/v2/installation-upgrade/container-environments/docker-images.md +++ /dev/null @@ -1,220 +0,0 @@ ---- -title: "Build container images" -draft: false -weight: 100 -toc: true -tags: [ "docs" ] -categories: ["configuration"] -doctypes: ["task"] -docs: "DOCS-1410" ---- - -## Overview - -NGINX Agent is a companion daemon for NGINX Open Source or NGINX Plus instances and must run in the same container to work. This document explains multiple ways in which NGINX Agent can be run alongside NGINX in a container. - -If you want to use NGINX Agent with NGINX Plus, you need to purchase an NGINX Plus license. Contact your F5 Sales representative for assistance. - -See the requirements and supported operating systems in the [NGINX Agent Technical Specifications]({{< relref "technical-specifications.md" >}}) topic. - -## Deploy Offical NGINX and NGINX Plus Containers - -Docker images are available in the [Deploying NGINX and NGINX Plus on Docker](https://docs.nginx.com/nginx/admin-guide/installing-nginx/installing-nginx-docker/) NGINX documentation. - -This guide provides instructions on how to build images with NGINX Agent and NGINX packaged together. It includes steps for downloading the necessary Docker images, configuring your Docker environment, and deploying NGINX and NGINX Plus containers. - -## Set up your environment - -### Install a container engine - -You can use [Docker](https://docs.docker.com/engine/install/) or [Podman](https://podman.io/docs/installation) to manage NGINX Agent container images. Follow the installation instructions for your preferred container engine and be sure the service is running before proceeding with the instructions in this document. - -{{}}The examples in this document primarily use Docker commands. You can adapt these using the appropriate [Podman commands](https://docs.podman.io/en/latest/Commands.html) if you're not using Docker.{{}} - -### Install the GNU Make package - -You need to use the [GNU Make](https://www.gnu.org/software/make/) package to build the NGINX Agent container images provided in the nginx-agent GitHub repository. - -If you do not already have Make installed, install it using the appropriate package manager for your operating system. - -For example, to install **make** using the Ubuntu Advanced Packaging Tool (APT), run the command **apt install** command shown in the example. In some cases, it may help to update the package source lists in your operating system before proceeding. - -1. Update the package source list: - - ```shell - sudo apt update - ``` - -2. Install the `make` package: - - ```shell - sudo apt install make - ``` - -### Clone the nginx-agent repository - -The NGINX Agent GitHub repo contains the Dockerfiles and supporting scripts that you will use to build your images. - -Run the appropriate command below to clone the GitHub repo by using HTTPS or SSH. - -{{}} - -{{%tab name="HTTPS"%}} - -```shell -git clone https://github.com/nginx/agent.git -``` - -{{% /tab %}} - -{{%tab name="SSH"%}} - -```shell -git clone git@github.com:nginx/agent.git -``` - -{{% /tab %}} - -{{% /tabs %}} - -### Download the NGINX Plus certificate and key {#myf5-download} - -{{< fa "circle-info" "text-muted" >}} **This step is required if you are using NGINX Plus. If you are using NGINX open source, you can skip this section.** - -In order to build a container image with NGINX Plus, you must provide the SSL certificate and private key files provided with your NGINX Plus license. These files grant access to the package repository from which the script will download the NGINX Plus package. - -1. Log in to the [MyF5](https://my.f5.com) customer portal. -1. Go to **My Products and Plans** > **Subscriptions**. -1. Select the product subscription. -1. Download the **SSL Certificate** and **Private Key** files. -1. Move the SSL certificate and private key files to the directory where you cloned the nginx-agent repo. - - - The Makefile expects to find these files in the path *./build/certs*. Assuming you cloned the nginx-agent repo to your **$HOME** directory, you would move and rename the files as follows: - - ```shell - mkdir -p $HOME/nginx-agent/build/certs - mv nginx-repo-S-X00012345.key $HOME/nginx-agent/build/certs/nginx-repo.key - mv nginx-repo-S-X00012345.crt $HOME/nginx-agent/build/certs/nginx-repo.crt - ``` - - - Be sure to replace the example certificate and key filenames shown in the example command with your actual file names. - - The file names in the *build/certs* directory must match those shown in the example. - -## Run the NGINX Agent container - -To run NGINX Agent container using Docker use the following command: - -```shell -docker pull docker-registry.nginx.com/nginx/agent:mainline -``` -```shell -docker tag docker-registry.nginx.com/nginx/agent:mainline nginx-agent -``` -```shell -docker run --name nginx-agent -d nginx-agent -``` - -{{}}To learn more about the configuration options, refer to the NGINX Agent [Configuration Overview]({{< relref "/v2/configuration/configuration-overview" >}}).{{}} - -### Enable the gRPC interface - -To connect your NGINX Agent container to your NGINX One or NGINX Instance Manager instance, you must enable the gRPC interface. To do this, you must edit the NGINX Agent configuration file, *nginx-agent.conf*. For example: - -```yaml -server: - host: 127.0.0.1 # mock control plane host - grpcPort: 54789 # mock control plane gRPC port - -# gRPC TLS options - DISABLING TLS IS NOT RECOMMENDED FOR PRODUCTION -tls: - enable: false - skip_verify: true -``` - -### Enable the REST interface - -If your control plane requires REST API, you can expose NGINX Agent's REST API by editing the NGINX Agent configuration file, *nginx-agent.conf*. For example: - -```yaml -api: - host: 0.0.0.0 - port: 8038 -``` - -Once you have updated the *nginx-agent.conf* file, you can run the container with the updated **nginx-agent.conf** mounted and the port **8038** exposed with the following command: - -```console -docker run --name nginx-agent -d \ - --mount type=bind,source="$(pwd)"/nginx-agent.conf,target=/etc/nginx-agent/nginx-agent.conf,readonly \ - -p 127.0.0.1:8038:8038/tcp \ - nginx-agent -``` - -To ensure that the REST Interface is correctly configured, you can use the `curl` command targeting the following endpoint from your terminal: - -```shell -curl 0.0.0.0:8038/nginx/ -``` - -If the REST Interface is configured correctly, then you should see a JSON object ouputted to the terminal containing metadata such as NGINX version, path to the NGINX conf, and runtime modules. - -**Sample Output:** - -```code -[{"nginx_id":"b636d4376dea15405589692d3c5d3869ff3a9b26b0e7bb4bb1aa7e658ace1437","version":"1.27.1","conf_path":"/etc/nginx/nginx.conf","process_id":"7","process_path":"/usr/sbin/nginx","start_time":1725878806000,"built_from_source":false,"loadable_modules":null,"runtime_modules":["http_addition_module","http_auth_request_module","http_dav_module","http_flv_module","http_gunzip_module","http_gzip_static_module","http_mp4_module","http_random_index_module","http_realip_module","http_secure_link_module","http_slice_module","http_ssl_module","http_stub_status_module","http_sub_module","http_v2_module","http_v3_module","mail_ssl_module","stream_realip_module","stream_ssl_module","stream_ssl_preread_module"],"plus":{"enabled":false,"release":""},"ssl":{"ssl_type":0,"details":["OpenSSL","3.3.0","9 Apr 2024 (running with OpenSSL 3.3.1 4 Jun 2024)"]},"status_url":"","configure_args":["","prefix=/etc/nginx","sbin-path=/usr/sbin/nginx","modules-path=/usr/lib/nginx/modules","conf-path=/etc/nginx/nginx.conf","error-log-path=/var/log/nginx/error.log","http-log-path=/var/log/nginx/access.log","pid-path=/var/run/nginx.pid","lock-path=/var/run/nginx.lock","http-client-body-temp-path=/var/cache/nginx/client_temp","http-proxy-temp-path=/var/cache/nginx/proxy_temp","http-fastcgi-temp-path=/var/cache/nginx/fastcgi_temp","http-uwsgi-temp-path=/var/cache/nginx/uwsgi_temp","http-scgi-temp-path=/var/cache/nginx/scgi_temp","with-perl_modules_path=/usr/lib/perl5/vendor_perl","user=nginx","group=nginx","with-compat","with-file-aio","with-threads","with-http_addition_module","with-http_auth_request_module","with-http_dav_module","with-http_flv_module","with-http_gunzip_module","with-http_gzip_static_module","with-http_mp4_module","with-http_random_index_module","with-http_realip_module","with-http_secure_link_module","with-http_slice_module","with-http_ssl_module","with-http_stub_status_module","with-http_sub_module","with-http_v2_module","with-http_v3_module","with-mail","with-mail_ssl_module","with-stream","with-stream_realip_module","with-stream_ssl_module","with-stream_ssl_preread_module","with-cc-opt='-Os -fstack-clash-protection -Wformat -Werror=format-security -g'","with-ld-opt=-Wl,--as-needed,-O1,--sort-common"],"error_log_paths":null}] -``` - -
- -## Build the NGINX Agent images for specific OS targets - -{{}}The only **officially supported** base operating system is **Alpine**. The instructions below for other operating systems are provided for informational and **testing purposes only**.{{}} - -The NGINX Agent GitHub repo has a set of Make commands that you can use to build a container image for an specific operating system and version: - -- `make oss-image` builds an image containing NGINX Agent and NGINX open source. -- `make image` builds an image containing NGINX Agent and NGINX Plus. - -You can pass the following arguments when running the **make** command to build an NGINX Agent container image. - -{{}} -| Argument | Definition | -| ---------------- | -------------------------| -| OS_RELEASE | The Linux distribution to use as the base image.
Can also be set in the repo Makefile.| -| OS_VERSION | The version of the Linux distribution to use as the base image.
Can also be set in the repo Makefile.| -| AGENT_VERSION | The versions of NGINX agent that you want installed on the image.| - -{{
}} - -### Build NGINX open source images - -Run the following `make` command to build the default image, which uses Alpine as the base image: - -```shell -IMAGE_BUILD_TARGET=install-agent-repo make oss-image -``` - -To build an image with Debian and an older version of NGINX Agent you can run the following command: - -```shell -IMAGE_BUILD_TARGET=install-agent-repo NGINX_AGENT_VERSION=2.37.0~bullseye OS_RELEASE=debian OS_VERSION=bullseye-slim make oss-image -``` - -### Build NGINX Plus images - -{{}}You need a license to use NGINX Agent with NGINX Plus. You must complete the steps in the [Download the certificate and key files from MyF5](#myf5-download) section before proceeding.{{}} - -Run the following `make` command to build the default image, which uses Ubuntu 24.04 (Noble) as the base image. - -```shell -IMAGE_BUILD_TARGET=install-agent-repo make image -``` - -To build an image with Debian and an older version of NGINX Agent you can run the following command: - -```shell -IMAGE_BUILD_TARGET=install-agent-repo NGINX_AGENT_VERSION=2.37.0~bullseye OS_RELEASE=debian OS_VERSION=bullseye-slim make image -``` - - - diff --git a/site/content/v2/installation-upgrade/container-environments/docker-support.md b/site/content/v2/installation-upgrade/container-environments/docker-support.md deleted file mode 100644 index b0bdd98a6..000000000 --- a/site/content/v2/installation-upgrade/container-environments/docker-support.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -title: Container support and troubleshooting -categories: -- installation -draft: false -tags: -- docs -toc: true -weight: 200 -docs: "DOCS-909" ---- - -## Overview - -The NGINX Agent repository includes [Dockerfiles](https://github.com/nginx/agent/tree/main/scripts/docker) that can be used to [build custom container images]({{< relref "/v2/installation-upgrade/container-environments/docker-images.md" >}}). Images are created with an NGINX Open Source or NGINX Plus instance and are available for various operating systems. - -See the [Technical Specifications]({{< relref "/technical-specifications.md#container-support" >}}) for a list of supported operationg systems. - -NGINX Agent running in a container has some limitations that need to be considered, and are listed below. - -## Supported cgroups - -To collect metrics about the Docker container that the NGINX Agent is running in, NGINX Agent uses the available cgroup files to calculate metrics like CPU and memory usage. - -NGINX Agent supports both versions of cgroups. - -- https://www.kernel.org/doc/Documentation/cgroup-v1/ -- https://www.kernel.org/doc/Documentation/cgroup-v2.txt - -## Metrics - -### Unsupported Metrics - -The following system metrics are not supported when running NGINX Agent in a Docker container. NGINX Agent returns no values for these metrics: - -- system.cpu.idle -- system.cpu.iowait -- system.cpu.stolen -- system.mem.buffered -- system.load.1 -- system.load.5 -- system.load.15 -- system.disk.total -- system.disk.used -- system.disk.free -- system.disk.in_use -- system.io.kbs_r -- system.io.kbs_w -- system.io.wait_r -- system.io.wait_w -- system.io.iops_r -- system.io.iops_w - -### Memory Metrics - -If no memory limit is set when starting the Docker container, then the memory limit that's shown in the metrics for the container will be the total memory of the Docker host system. - -### Swap Memory Metrics - -If a warning message similar to the following example is seen in the NGINX Agent logs, the swap memory limit for the Docker container is greater than the swap memory for the Docker host system: - -```bash -Swap memory limit specified for the container, ... is greater than the host system swap memory ... -``` - -The `system.swap.total` metric for the container matches the total swap memory for the Docker host system instead of the swap memory limit specified when starting the Docker container. - -If a warning message similar to the following example is seen in the NGINX Agent logs, the Docker host system does not have cgroup swap limit capabilities enabled. To enable these capabilities, follow the steps below. - -```bash -Unable to collect Swap metrics because the file ... was not found -``` - -#### Enable cgroup swap limit capabilities - -Run the following command to see if the cgroup swap limit capabilities are enabled: - -```bash -$ docker info | grep swap -WARNING: No swap limit support -``` - -To enable cgroup swap limit capabilities, refer to this Docker guide: [Docker - Linux post-installation steps](https://docs.docker.com/engine/install/linux-postinstall/#your-kernel-does-not-support-cgroup-swap-limit-capabilities). diff --git a/site/content/v2/installation-upgrade/getting-started.md b/site/content/v2/installation-upgrade/getting-started.md deleted file mode 100644 index 578037f53..000000000 --- a/site/content/v2/installation-upgrade/getting-started.md +++ /dev/null @@ -1,181 +0,0 @@ ---- -title: "Getting started" -draft: false -weight: 100 -toc: true -tags: [ "docs" ] -docs: "DOCS-1089" -categories: ["configuration"] -doctypes: ["task"] ---- - -## Overview - -Follow these steps to configure and run NGINX Agent and a mock interface ("control plane") to which NGINX Agent will report. - -## Install NGINX - -Follow the steps in the [Installation](https://docs.nginx.com/nginx/admin-guide/installing-nginx/) section to download, install, and run NGINX. - -## Clone the NGINX Agent Repository - -Using your preferred method, clone the NGINX Agent repository into your development directory. See [Cloning a GitHub Repository](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) for additional help. - -## Install Go - -NGINX Agent and the Mock Control Plane are written in Go. Go 1.23 or higher is required to build and run either application from the source code directory. You can [download Go from the official website](https://go.dev/dl/). - -## Start the gRPC Mock Control Plane - -Start the mock control plane by running the following command from the `agent` source code root directory: - -```shell -go run sdk/examples/server.go - -# Command Output -INFO[0000] http listening at 54790 # mock control plane port -INFO[0000] grpc listening at 54789 # grpc control plane port which NGINX Agent will report to -``` - -## NGINX Agent Settings - -If it doesn't already exist, create the `/etc/nginx-agent/` directory and copy the `nginx-agent.conf` file into it from the project root directory. - -```shell -sudo mkdir /etc/nginx-agent -sudo cp /nginx-agent.conf /etc/nginx-agent/ -``` - -Create the `agent-dynamic.conf` file, which is required for NGINX Agent to run. - -In Linux environments: -```shell -sudo touch /var/lib/nginx-agent/agent-dynamic.conf -``` - -In FreeBSD environments: -```shell -sudo touch /var/db/nginx-agent/agent-dynamic.conf -``` - -### Enable the gRPC interface - -Add the the following settings to `/etc/nginx-agent/nginx-agent.conf`: - -```yaml -server: - host: 127.0.0.1 # mock control plane host - grpcPort: 54789 # mock control plane gRPC port - -# gRPC TLS options - DISABLING TLS IS NOT RECOMMENDED FOR PRODUCTION -tls: - enable: false - skip_verify: true -``` - -For more information, see [Agent Protocol Definitions and Documentation](https://github.com/nginx/agent/tree/main/docs/proto/README.md). - -### Enable the REST interface - -The NGINX Agent REST interface can be exposed by validating the following lines in the `/etc/nginx-agent/nginx-agent.conf` file are present: - -```yaml -api: - # Set API address to allow remote management - host: 127.0.0.1 - # Set this value to a secure port number to prevent information leaks - port: 8038 - # REST TLS parameters - cert: ".crt" - key: ".key" -``` - -The mock control plane can use either gRPC or REST protocols to communicate with NGINX Agent. - -## Launch Swagger UI - -Swagger UI requires goswagger be installed. See [instructions for installing goswagger](https://goswagger.io/go-swagger/install/) for additional help. - -To launch the Swagger UI for the REST interface run the following command: - -```shell -make launch-swagger-ui -``` - -## Extensions - -An extension is a piece of code, not critical to the main functionality that NGINX agent is responsible for. This generally falls outside the remit of managing NGINX Configuration and reporting NGINX metrics. - -To enable an extension, it must be added to the extensions list in the `/etc/nginx-agent/nginx-agent.conf`. -Here is an example of enabling the advanced metrics extension: - -```yaml -extensions: - - advanced-metrics -``` - -## Start NGINX Agent - -If already running, restart NGINX Agent to apply the new configuration. Alternatively, if NGINX Agent is not running, you may run it from the source code root directory. - -Open another terminal window and start NGINX Agent. Issue the following command from the `agent` source code root directory. - -```shell -sudo make run - -# Command Output snippet -WARN[0000] Log level is info -INFO[0000] setting displayName to XXX -INFO[0000] NGINX Agent at with pid 12345, clientID=XXXXXX-XXXXXX-XXXXXX-XXXXXX-XXXXXX name=XXX -INFO[0000] NginxBinary initializing -INFO[0000] Commander initializing -INFO[0000] Comms initializing -INFO[0000] OneTimeRegistration initializing -INFO[0000] Registering XXXXXX-XXXXXX-XXXXXX-XXXXXX-XXXXXX -INFO[0000] Metrics initializing -INFO[0000] MetricsThrottle initializing -INFO[0000] DataPlaneStatus initializing -INFO[0000] MetricsThrottle waiting for report ready -INFO[0000] Metrics waiting for handshake to be completed -INFO[0000] ProcessWatcher initializing -INFO[0000] Extensions initializing -INFO[0000] FileWatcher initializing -INFO[0000] FileWatchThrottle initializing -INFO[0001] Events initializing -INFO[0001] OneTimeRegistration completed -``` - -Open a web browser to view the mock control plane at [http://localhost:54790](http://localhost:54790). The following links will be shown in the web interface: - -- **registered** - shows registration information of the data plane -- **nginxes** - lists the nginx instances on the data plane -- **configs** - shows the protobuf payload for NGINX configuration sent to the management plane -- **configs/chunked** - shows the split-up payloads sent to the management plane -- **configs/raw** - shows the actual configuration as it would live on the data plane -- **metrics** - shows a buffer of metrics sent to the management plane (similar to what will be sent back in the REST API) - -For more NGINX Agent use cases, refer to the [NGINX Agent SDK examples](https://github.com/nginx/agent/tree/main/sdk/examples). - -## Start and Enable Start on Boot - -To start NGINX Agent on `systemd` systems, run the following command: - -```shell -sudo systemctl start nginx-agent -``` - -To enable NGINX Agent to start on boot, run the following command: - -```shell -sudo systemctl enable nginx-agent -``` - -## Logs - -NGINX Agent uses formatted log files to collect metrics. Expanding log formats and instance counts will also increase the size of the NGINX Agent log files. We recommend adding a separate partition for `/var/log/nginx-agent`. - -{{< important >}} -Without log rotation or storage on a separate partition, log files could use up all the free drive space and cause your system to become unresponsive to certain services. - -For more information, see [NGINX Agent Log Rotation]({{< relref "/v2/configuration/configuration-overview.md#nginx-agent-log-rotation" >}}). -{{< /important >}} diff --git a/site/content/v2/installation-upgrade/installation-github.md b/site/content/v2/installation-upgrade/installation-github.md deleted file mode 100644 index d53a07006..000000000 --- a/site/content/v2/installation-upgrade/installation-github.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -title: "Installation from GitHub release" -draft: false -weight: 200 -toc: true -tags: [ "docs" ] -docs: "DOCS-1090" -categories: ["configuration"] -doctypes: ["task"] ---- - -## Overview - -Learn how to install NGINX Agent from a GitHub Release. - -## Install NGINX - -NGINX Agent interfaces directly with an NGINX server process installed on the same system. If you don't have it already, follow these steps to install [NGINX Open Source](https://www.nginx.com/resources/wiki/start/topics/tutorials/install/) or [NGINX Plus](https://docs.nginx.com/nginx/admin-guide/installing-nginx/installing-nginx-plus/). Once installed, ensure the NGINX instance is running. - -## Install NGINX Agent from Package Files - -To install NGINX Agent on your system, go to [GitHub Releases](https://github.com/nginx/agent/releases) and download the latest package supported by your OS distribution and CPU architecture. - -Use your system's package manager to install the package. Some examples: - -- Debian, Ubuntu, and other distributions using the `dpkg` package manager. - - ```shell - sudo dpkg -i nginx-agent-.deb - ``` - -- RHEL, CentOS RHEL, Amazon Linux, Oracle Linux, and other distributions using the `yum` package manager - - ```shell - sudo yum localinstall nginx-agent-.rpm - ``` - -- RHEL and other distributions using the `rpm` package manager - - ```shell - sudo rpm -i nginx-agent-.rpm - ``` - -- Alpine Linux - - ```shell - sudo apk add nginx-agent-.apk - ``` - -- FreeBSD - - ```shell - sudo pkg add nginx-agent-.pkg - ``` diff --git a/site/content/v2/installation-upgrade/installation-oss.md b/site/content/v2/installation-upgrade/installation-oss.md deleted file mode 100644 index 5e82dd21b..000000000 --- a/site/content/v2/installation-upgrade/installation-oss.md +++ /dev/null @@ -1,411 +0,0 @@ ---- -title: "Installation from NGINX repository" -draft: false -weight: 300 -toc: true -tags: [ "docs" ] -docs: "DOCS-1216" -categories: ["configuration"] -doctypes: ["task"] ---- - -## Overview - -Learn how to install NGINX Agent from the NGINX Open Source repository. - -## Prerequisites - -- NGINX installed. Once installed, ensure it is running. If you don't have it installed already, follow these steps to install [NGINX](https://docs.nginx.com/nginx/admin-guide/installing-nginx/installing-nginx-open-source/) -- A [supported operating system and architecture]({{< relref "/technical-specifications.md#supported-distributions" >}}) -- `root` privilege - -## Configure NGINX OSS Repository for installing NGINX Agent - -Before you install NGINX Agent for the first time on your system, you need to set up the `nginx-agent` packages repository. Afterward, you can install and update NGINX Agent from the repository. - -- [Installing NGINX Agent on RHEL, CentOS, Rocky Linux, AlmaLinux, and Oracle Linux](#installing-nginx-agent-on-rhel-centos-rocky-linux-almalinux-and-oracle-linux) -- [Installing NGINX Agent on Ubuntu](#installing-nginx-agent-on-ubuntu) -- [Installing NGINX Agent on Debian](#installing-nginx-agent-on-debian) -- [Installing NGINX Agent on SLES](#installing-nginx-agent-on-sles) -- [Installing NGINX Agent on Alpine Linux](#installing-nginx-agent-on-alpine-linux) -- [Installing NGINX Agent on Amazon Linux 2](#installing-nginx-agent-on-amazon-linux-2) -- [Installing NGINX Agent on Amazon Linux 2023](#installing-nginx-agent-on-amazon-linux-2023) -- [Installing NGINX Agent on FreeBSD](#installing-nginx-agent-on-freebsd) - -### Installing NGINX Agent on RHEL, CentOS, Rocky Linux, AlmaLinux, and Oracle Linux - -1. Install the prerequisites: - - ```shell - sudo yum install yum-utils - ``` - -1. To set up the yum repository, create the file named `/etc/yum.repos.d/nginx-agent.repo` with the following contents: - - ``` - [nginx-agent] - name=nginx agent repo - baseurl=http://packages.nginx.org/nginx-agent/centos/$releasever/$basearch/ - gpgcheck=1 - enabled=1 - gpgkey=https://nginx.org/keys/nginx_signing.key - module_hotfixes=true - ``` - -1. To install `nginx-agent`, run the following command: - - ```shell - sudo yum install nginx-agent - ``` - - When prompted to accept the GPG key, verify that the fingerprint matches `8540 A6F1 8833 A80E 9C16 53A4 2FD2 1310 B49F 6B46`, `573B FD6B 3D8F BC64 1079 A6AB ABF5 BD82 7BD9 BF62`, `9E9B E90E ACBC DE69 FE9B 204C BCDC D8A3 8D88 A2B3`, and if so, accept it. - -### Installing NGINX Agent on Ubuntu - -1. Install the prerequisites: - - ```shell - sudo apt install curl gnupg2 ca-certificates lsb-release ubuntu-keyring - ``` - -1. Import an official nginx signing key so apt could verify the packages authenticity. Fetch the key: - - ```shell - curl https://nginx.org/keys/nginx_signing.key | gpg --dearmor \ - | sudo tee /usr/share/keyrings/nginx-archive-keyring.gpg >/dev/null - ``` - -1. Verify that the downloaded file contains the proper key: - - ```shell - gpg --dry-run --quiet --no-keyring --import --import-options import-show /usr/share/keyrings/nginx-archive-keyring.gpg - ``` - - The output should contain the full fingerprints `8540 A6F1 8833 A80E 9C16 53A4 2FD2 1310 B49F 6B46`, `573B FD6B 3D8F BC64 1079 A6AB ABF5 BD82 7BD9 BF62`, `9E9B E90E ACBC DE69 FE9B 204C BCDC D8A3 8D88 A2B3` as follows: - - ``` - pub rsa4096 2024-05-29 [SC] - 8540A6F18833A80E9C1653A42FD21310B49F6B46 - uid nginx signing key - - pub rsa2048 2011-08-19 [SC] [expires: 2027-05-24] - 573BFD6B3D8FBC641079A6ABABF5BD827BD9BF62 - uid nginx signing key - - pub rsa4096 2024-05-29 [SC] - 9E9BE90EACBCDE69FE9B204CBCDCD8A38D88A2B3 - uid nginx signing key - ``` - - {{< important >}}If the fingerprint is different, remove the file.{{< /important >}} - -1. Add the nginx agent repository: - - ```shell - echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] \ - http://packages.nginx.org/nginx-agent/ubuntu/ `lsb_release -cs` agent" \ - | sudo tee /etc/apt/sources.list.d/nginx-agent.list - ``` - -1. To install `nginx-agent`, run the following commands: - - ```shell - sudo apt update - sudo apt install nginx-agent - ``` - -1. Verify the installation: - - ```shell - sudo nginx-agent -v - ``` - - -### Installing NGINX Agent on Debian - -1. Install the prerequisites: - - ```shell - sudo apt install curl gnupg2 ca-certificates lsb-release debian-archive-keyring - ``` - -1. Import an official nginx signing key so apt could verify the packages authenticity. Fetch the key: - - ```shell - curl https://nginx.org/keys/nginx_signing.key | gpg --dearmor \ - | sudo tee /usr/share/keyrings/nginx-archive-keyring.gpg >/dev/null - ``` - -1. Verify that the downloaded file contains the proper key: - - ```shell - gpg --dry-run --quiet --no-keyring \ - --import --import-options import-show /usr/share/keyrings/nginx-archive-keyring.gpg - ``` - - The output should contain the full fingerprints `8540 A6F1 8833 A80E 9C16 53A4 2FD2 1310 B49F 6B46`, `573B FD6B 3D8F BC64 1079 A6AB ABF5 BD82 7BD9 BF62`, `9E9B E90E ACBC DE69 FE9B 204C BCDC D8A3 8D88 A2B3` as follows: - - ``` - pub rsa4096 2024-05-29 [SC] - 8540A6F18833A80E9C1653A42FD21310B49F6B46 - uid nginx signing key - - pub rsa2048 2011-08-19 [SC] [expires: 2027-05-24] - 573BFD6B3D8FBC641079A6ABABF5BD827BD9BF62 - uid nginx signing key - - pub rsa4096 2024-05-29 [SC] - 9E9BE90EACBCDE69FE9B204CBCDCD8A38D88A2B3 - uid nginx signing key - ``` - - {{< important >}}If the fingerprint is different, remove the file.{{< /important >}} - -1. Add the `nginx-agent` repository: - - ```shell - echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] \ - http://packages.nginx.org/nginx-agent/debian/ `lsb_release -cs` agent" \ | sudo tee /etc/apt/sources.list.d/nginx-agent.list - ``` - -1. To install `nginx-agent`, run the following commands: - - ```shell - sudo apt update - sudo apt install nginx-agent - ``` - -1. Verify the installation: - - ```shell - sudo nginx-agent -v - ``` - - -### Installing NGINX Agent on SLES - -1. Install the prerequisites: - - ```shell - sudo zypper install curl ca-certificates gpg2 gawk - ``` - -1. To set up the zypper repository for `nginx-agent` packages, run the following command: - - ```shell - sudo zypper addrepo --gpgcheck --refresh --check \ - 'http://packages.nginx.org/nginx-agent/sles/$releasever_major' nginx-agent - ``` - -1. Next, import an official NGINX signing key so `zypper`/`rpm` can verify the package's authenticity. Fetch the key: - - ```shell - curl -o /tmp/nginx_signing.key https://nginx.org/keys/nginx_signing.key - ``` - -1. Verify that the downloaded file contains the proper key: - - ```shell - gpg --with-fingerprint --dry-run --quiet --no-keyring --import --import-options import-show /tmp/nginx_signing.key - ``` - -1. The output should contain the full fingerprints `8540 A6F1 8833 A80E 9C16 53A4 2FD2 1310 B49F 6B46`, `573B FD6B 3D8F BC64 1079 A6AB ABF5 BD82 7BD9 BF62`, `9E9B E90E ACBC DE69 FE9B 204C BCDC D8A3 8D88 A2B3` as follows: - - ``` - pub rsa4096 2024-05-29 [SC] - 8540A6F18833A80E9C1653A42FD21310B49F6B46 - uid nginx signing key - - pub rsa2048 2011-08-19 [SC] [expires: 2027-05-24] - 573BFD6B3D8FBC641079A6ABABF5BD827BD9BF62 - uid nginx signing key - - pub rsa4096 2024-05-29 [SC] - 9E9BE90EACBCDE69FE9B204CBCDCD8A38D88A2B3 - uid nginx signing key - ``` - -1. Finally, import the key to the rpm database: - - ```shell - sudo rpmkeys --import /tmp/nginx_signing.key - ``` - -1. To install `nginx-agent`, run the following command: - - ```shell - sudo zypper install nginx-agent - ``` - -1. Verify the installation: - - ```shell - sudo nginx-agent -v - ``` - -### Installing NGINX Agent on Alpine Linux - -1. Install the prerequisites: - - ```shell - sudo apk add openssl curl ca-certificates - ``` - -1. To set up the apk repository for `nginx-agent` packages, run the following command: - - ```shell - printf "%s%s%s\n" \ - "http://packages.nginx.org/nginx-agent/alpine/v" \ - `grep -o -E '^[0-9]+\.[0-9]+' /etc/alpine-release` \ - "/main" \ - | sudo tee -a /etc/apk/repositories - ``` - -1. Next, import an official NGINX signing key so apk can verify the package's authenticity. Fetch the key: - - ```shell - curl -o /tmp/nginx_signing.rsa.pub https://nginx.org/keys/nginx_signing.rsa.pub - ``` - -1. Verify that downloaded file contains the proper key: - - ```shell - openssl rsa -pubin -in /tmp/nginx_signing.rsa.pub -text -noout - ``` - - The output should contain the following modulus: - - ``` - Public-Key: (2048 bit) - Modulus: - 00:fe:14:f6:0a:1a:b8:86:19:fe:cd:ab:02:9f:58: - 2f:37:70:15:74:d6:06:9b:81:55:90:99:96:cc:70: - 5c:de:5b:e8:4c:b2:0c:47:5b:a8:a2:98:3d:11:b1: - f6:7d:a0:46:df:24:23:c6:d0:24:52:67:ba:69:ab: - 9a:4a:6a:66:2c:db:e1:09:f1:0d:b2:b0:e1:47:1f: - 0a:46:ac:0d:82:f3:3c:8d:02:ce:08:43:19:d9:64: - 86:c4:4e:07:12:c0:5b:43:ba:7d:17:8a:a3:f0:3d: - 98:32:b9:75:66:f4:f0:1b:2d:94:5b:7c:1c:e6:f3: - 04:7f:dd:25:b2:82:a6:41:04:b7:50:93:94:c4:7c: - 34:7e:12:7c:bf:33:54:55:47:8c:42:94:40:8e:34: - 5f:54:04:1d:9e:8c:57:48:d4:b0:f8:e4:03:db:3f: - 68:6c:37:fa:62:14:1c:94:d6:de:f2:2b:68:29:17: - 24:6d:f7:b5:b3:18:79:fd:31:5e:7f:4c:be:c0:99: - 13:cc:e2:97:2b:dc:96:9c:9a:d0:a7:c5:77:82:67: - c9:cb:a9:e7:68:4a:e1:c5:ba:1c:32:0e:79:40:6e: - ef:08:d7:a3:b9:5d:1a:df:ce:1a:c7:44:91:4c:d4: - 99:c8:88:69:b3:66:2e:b3:06:f1:f4:22:d7:f2:5f: - ab:6d - Exponent: 65537 (0x10001) - ``` - -1. Finally, move the key to apk trusted keys storage: - - ```shell - sudo mv /tmp/nginx_signing.rsa.pub /etc/apk/keys/ - ``` - -1. To install `nginx-agent`, run the following command: - - ```shell - sudo apk add nginx-agent - ``` - -1. Verify the installation: - - ```shell - sudo nginx-agent -v - ``` - -### Installing NGINX Agent on Amazon Linux 2023 - -1. Install the prerequisites: - - ```shell - sudo dnf install yum-utils procps-ng - ``` - -1. To set up the dnf repository for Amazon Linux 2023, create the file named `/etc/yum.repos.d/nginx-agent.repo` with the following contents: - ``` - [nginx-agent] - name=nginx agent repo - baseurl=https://packages.nginx.org/nginx-agent/amzn/2023/$basearch/ - gpgcheck=1 - enabled=1 - gpgkey=https://nginx.org/keys/nginx_signing.key - module_hotfixes=true - ``` - -1. To install `nginx-agent`, run the following command: - - ```shell - sudo dnf install nginx-agent - ``` - -1. When prompted to accept the GPG key, verify that the fingerprint matches - `8540 A6F1 8833 A80E 9C16 53A4 2FD2 1310 B49F 6B46`, - `573B FD6B 3D8F BC64 1079 A6AB ABF5 BD82 7BD9 BF62`, - `9E9B E90E ACBC DE69 FE9B 204C BCDC D8A3 8D88 A2B3` - and if so, accept it. - -1. Verify the installation: - - ```shell - sudo nginx-agent -v - ``` -### Installing NGINX Agent on Amazon Linux 2 - -1. Install the prerequisites: - - ```shell - sudo yum install yum-utils procps - ``` - -1. To set up the yum repository for Amazon Linux 2, create the file named `/etc/yum.repos.d/nginx-agent.repo` with the following contents: - ``` - [nginx-agent] - name=nginx agent repo - baseurl=http://packages.nginx.org/nginx-agent/amzn2/$releasever/$basearch/ - gpgcheck=1 - enabled=1 - gpgkey=https://nginx.org/keys/nginx_signing.key - module_hotfixes=true - ``` - -1. To install `nginx-agent`, run the following command: - - ```shell - sudo yum install nginx-agent - ``` - -1. When prompted to accept the GPG key, verify that the fingerprint matches `8540 A6F1 8833 A80E 9C16 53A4 2FD2 1310 B49F 6B46`, `573B FD6B 3D8F BC64 1079 A6AB ABF5 BD82 7BD9 BF62`, `9E9B E90E ACBC DE69 FE9B 204C BCDC D8A3 8D88 A2B3`, and if so, accept it. - -1. Verify the installation: - - ```shell - sudo nginx-agent -v - ``` - -### Installing NGINX Agent on FreeBSD - -1. To setup the pkg repository create the file named `/etc/pkg/nginx-agent.conf` with the following content: - - ``` - nginx-agent: { - URL: pkg+http://packages.nginx.org/nginx-agent/freebsd/${ABI}/latest - ENABLED: true - MIRROR_TYPE: SRV - } - ``` - -1. To install `nginx-agent`, run the following command: - - ```shell - sudo pkg install nginx-agent - ``` - -1. Verify the installation: - - ```shell - sudo nginx-agent -v - ``` diff --git a/site/content/v2/installation-upgrade/installation-plus.md b/site/content/v2/installation-upgrade/installation-plus.md deleted file mode 100644 index ba4250210..000000000 --- a/site/content/v2/installation-upgrade/installation-plus.md +++ /dev/null @@ -1,511 +0,0 @@ ---- -title: "Installation from NGINX Plus repository" -draft: false -weight: 400 -toc: true -tags: [ "docs" ] -docs: "DOCS-1217" -categories: ["configuration"] -doctypes: ["task"] ---- - -## Overview - -Learn how to install NGINX Agent from NGINX Plus repository - -## Prerequisites - -- An NGINX Plus subscription (purchased or trial) -- NGINX Plus installed. Once installed, ensure it is running. If you don't have it installed already, follow these steps to install [NGINX Plus](https://docs.nginx.com/nginx/admin-guide/installing-nginx/installing-nginx-plus/) -- A [supported operating system and architecture]({{< relref "/technical-specifications.md#supported-distributions" >}}) -- `root` privilege -- Your credentials to the MyF5 Customer Portal, provided by email from F5, Inc. -- Your NGINX Plus certificate and public key (`nginx-repo.crt` and `nginx-repo.key` files), provided by email from F5, Inc. - -## Configure NGINX Plus Repository for installing NGINX Agent - -Before you install NGINX Agent for the first time on your system, you need to set up the `nginx-agent` packages repository. Afterward, you can install and update NGINX Agent from the repository. - -- [Installing NGINX Agent on RHEL, CentOS, Rocky Linux, AlmaLinux, and Oracle Linux](#installing-nginx-agent-on-rhel-centos-rocky-linux-almalinux-and-oracle-linux) -- [Installing NGINX Agent on Ubuntu](#installing-nginx-agent-on-ubuntu) -- [Installing NGINX Agent on Debian](#installing-nginx-agent-on-debian) -- [Installing NGINX Agent on SLES](#installing-nginx-agent-on-sles) -- [Installing NGINX Agent on Alpine Linux](#installing-nginx-agent-on-alpine-linux) -- [Installing NGINX Agent on Amazon Linux 2023](#installing-nginx-agent-on-amazon-linux-2023) -- [Installing NGINX Agent on Amazon Linux](#installing-nginx-agent-on-amazon-linux) -- [Installing NGINX Agent on FreeBSD](#installing-nginx-agent-on-freebsd) - -### Installing NGINX Agent on RHEL, CentOS, Rocky Linux, AlmaLinux, and Oracle Linux - -1. Create the `/etc/ssl/nginx` directory: - - ```shell - sudo mkdir -p /etc/ssl/nginx - ``` - -1. Log in to [MyF5 Customer Portal](https://account.f5.com/myf5/) and download your `nginx-repo.crt` and `nginx-repo.key` files. - -1. Copy the files to the `/etc/ssl/nginx/` directory: - - ```shell - sudo cp nginx-repo.crt nginx-repo.key /etc/ssl/nginx/ - ``` - -1. Install the prerequisites: - - ```shell - sudo yum install yum-utils procps - ``` - -1. Set up the yum repository by creating the file `nginx-agent.repo` in `/etc/yum.repos.d`, for example using `vi`: - - ```shell - sudo vi /etc/yum.repos.d/nginx-agent.repo - ``` - -1. Add the following lines to `nginx-agent.repo`: - - ``` - [nginx-agent] - name=nginx agent repo - baseurl=https://pkgs.nginx.com/nginx-agent/centos/$releasever/$basearch/ - sslclientcert=/etc/ssl/nginx/nginx-repo.crt - sslclientkey=/etc/ssl/nginx/nginx-repo.key - gpgcheck=0 - enabled=1 - ``` - -1. To install `nginx-agent`, run the following command: - - ```shell - sudo yum install nginx-agent - ``` - - When prompted to accept the GPG key, verify that the fingerprint matches `8540 A6F1 8833 A80E 9C16 53A4 2FD2 1310 B49F 6B46`, `573B FD6B 3D8F BC64 1079 A6AB ABF5 BD82 7BD9 BF62`, `9E9B E90E ACBC DE69 FE9B 204C BCDC D8A3 8D88 A2B3`, and if so, accept it. - -1. Verify the installation: - - ```shell - sudo nginx-agent -v - ``` - -### Installing NGINX Agent on Ubuntu - -1. Create the `/etc/ssl/nginx` directory: - - ```shell - sudo mkdir -p /etc/ssl/nginx - ``` - -1. Log in to [MyF5 Customer Portal](https://account.f5.com/myf5/) and download your `nginx-repo.crt` and `nginx-repo.key` files. - -1. Copy the files to the `/etc/ssl/nginx/` directory: - - ```shell - sudo cp nginx-repo.crt nginx-repo.key /etc/ssl/nginx/ - ``` - -1. Install the prerequisites: - - ```shell - sudo apt-get install apt-transport-https lsb-release ca-certificates wget gnupg2 ubuntu-keyring - ``` - -1. Download and add [NGINX signing key](https://cs.nginx.com/static/keys/nginx_signing.key): - - ```shell - wget -qO - https://cs.nginx.com/static/keys/nginx_signing.key | gpg --dearmor | sudo tee /usr/share/keyrings/nginx-archive-keyring.gpg >/dev/null - ``` - -1. Create `apt` configuration `/etc/apt/apt.conf.d/90pkgs-nginx`: - - ``` - Acquire::https::pkgs.nginx.com::Verify-Peer "true"; - Acquire::https::pkgs.nginx.com::Verify-Host "true"; - Acquire::https::pkgs.nginx.com::SslCert "/etc/ssl/nginx/nginx-repo.crt"; - Acquire::https::pkgs.nginx.com::SslKey "/etc/ssl/nginx/nginx-repo.key"; - ``` - -1. Add the `nginx-agent` repository: - - ```shell - echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] https://pkgs.nginx.com/nginx-agent/ubuntu/ `lsb_release -cs` agent" \ - | sudo tee /etc/apt/sources.list.d/nginx-agent.list - ``` - -1. To install `nginx-agent`, run the following commands: - - ```shell - sudo apt update - sudo apt install nginx-agent - ``` - -1. Verify the installation: - - ```shell - sudo nginx-agent -v - ``` - - -### Installing NGINX Agent on Debian - -1. Create the `/etc/ssl/nginx` directory: - - ```shell - sudo mkdir -p /etc/ssl/nginx - ``` - -1. Log in to [MyF5 Customer Portal](https://account.f5.com/myf5/) and download your `nginx-repo.crt` and `nginx-repo.key` files. - -1. Copy the files to the `/etc/ssl/nginx/` directory: - - ```shell - sudo cp nginx-repo.crt nginx-repo.key /etc/ssl/nginx/ - ``` - -1. Install the prerequisites: - - ```shell - sudo apt install curl gnupg2 ca-certificates lsb-release debian-archive-keyring - ``` - -1. Add the `nginx-agent` repository: - - ```shell - echo "deb https://pkgs.nginx.com/nginx-agent/debian/ `lsb_release -cs` agent" \ - | sudo tee /etc/apt/sources.list.d/nginx-agent.list - ``` - -1. Create apt configuration `/etc/apt/apt.conf.d/90pkgs-nginx`: - - ``` - Acquire::https::pkgs.nginx.com::Verify-Peer "true"; - Acquire::https::pkgs.nginx.com::Verify-Host "true"; - Acquire::https::pkgs.nginx.com::SslCert "/etc/ssl/nginx/nginx-repo.crt"; - Acquire::https::pkgs.nginx.com::SslKey "/etc/ssl/nginx/nginx-repo.key"; - ``` - -1. To install `nginx-agent`, run the following commands: - - ```shell - sudo apt update - sudo apt install nginx-agent - ``` - -1. Verify the installation: - - ```shell - sudo nginx-agent -v - ``` - -### Installing NGINX Agent on SLES - -1. Create the `/etc/ssl/nginx` directory: - - ```shell - sudo mkdir -p /etc/ssl/nginx - ``` - -1. Log in to [MyF5 Customer Portal](https://account.f5.com/myf5/) and download your `nginx-repo.crt` and `nginx-repo.key` files. - -1. Copy the files to the `/etc/ssl/nginx/` directory: - - ```shell - sudo cp nginx-repo.crt nginx-repo.key /etc/ssl/nginx/ - ``` - -1. Create a file bundle of the certificate and key: - - ```shell - cat /etc/ssl/nginx/nginx-repo.crt /etc/ssl/nginx/nginx-repo.key > /etc/ssl/nginx/nginx-repo-bundle.crt - ``` - -1. Install the prerequisites: - - ```shell - sudo zypper install curl ca-certificates gpg2 gawk - ``` - -1. To set up the zypper repository for `nginx-agent` packages, run the following command: - - ```shell - sudo zypper addrepo --refresh --check \ - 'https://pkgs.nginx.com/nginx-agent/sles/$releasever_major?ssl_clientcert=/etc/ssl/nginx/nginx-repo-bundle.crt&ssl_verify=peer' nginx-agent - ``` - -1. Next, import an official NGINX signing key so `zypper`/`rpm` can verify the package's authenticity. Fetch the key: - - ```shell - curl -o /tmp/nginx_signing.key https://nginx.org/keys/nginx_signing.key - ``` - -1. Verify that the downloaded file contains the proper key: - - ```shell - gpg --with-fingerprint --dry-run --quiet --no-keyring --import --import-options import-show /tmp/nginx_signing.key - ``` - -1. The output should contain the full fingerprints `8540 A6F1 8833 A80E 9C16 53A4 2FD2 1310 B49F 6B46`, `573B FD6B 3D8F BC64 1079 A6AB ABF5 BD82 7BD9 BF62`, `9E9B E90E ACBC DE69 FE9B 204C BCDC D8A3 8D88 A2B3` as follows: - - ``` - pub rsa4096 2024-05-29 [SC] - 8540A6F18833A80E9C1653A42FD21310B49F6B46 - uid nginx signing key - - pub rsa2048 2011-08-19 [SC] [expires: 2027-05-24] - 573BFD6B3D8FBC641079A6ABABF5BD827BD9BF62 - uid nginx signing key - - pub rsa4096 2024-05-29 [SC] - 9E9BE90EACBCDE69FE9B204CBCDCD8A38D88A2B3 - uid nginx signing key - ``` - -1. Finally, import the key to the rpm database: - - ```shell - sudo rpmkeys --import /tmp/nginx_signing.key - ``` - -1. To install `nginx-agent`, run the following command: - - ```shell - sudo zypper install nginx-agent - ``` - -1. Verify the installation: - - ```shell - sudo nginx-agent -v - ``` - -### Installing NGINX Agent on Alpine Linux - -1. Log in to [MyF5 Customer Portal](https://account.f5.com/myf5/) and download your `nginx-repo.crt` and `nginx-repo.key` files. - -1. Copy the files to the `/etc/apk/` directory: - - ```shell - sudo cp nginx-repo.key /etc/apk/cert.key - sudo cp nginx-repo.crt /etc/apk/cert.pem - ``` - -1. Install the prerequisites: - - ```shell - sudo apk add openssl curl ca-certificates - ``` - -1. To set up the apk repository for `nginx-agent` packages, run the following command: - - ```shell - printf "%s%s%s\n" \ - "https://pkgs.nginx.com/nginx-agent/alpine/v" \ - `grep -o -E '^[0-9]+\.[0-9]+' /etc/alpine-release` \ - "/main" \ - | sudo tee -a /etc/apk/repositories - ``` - -1. Next, import an official NGINX signing key so apk can verify the package's authenticity. Fetch the key: - - ```shell - curl -o /tmp/nginx_signing.rsa.pub https://nginx.org/keys/nginx_signing.rsa.pub - ``` - -1. Verify that downloaded file contains the proper key: - - ```shell - openssl rsa -pubin -in /tmp/nginx_signing.rsa.pub -text -noout - ``` - - The output should contain the following modulus: - - ``` - Public-Key: (2048 bit) - Modulus: - 00:fe:14:f6:0a:1a:b8:86:19:fe:cd:ab:02:9f:58: - 2f:37:70:15:74:d6:06:9b:81:55:90:99:96:cc:70: - 5c:de:5b:e8:4c:b2:0c:47:5b:a8:a2:98:3d:11:b1: - f6:7d:a0:46:df:24:23:c6:d0:24:52:67:ba:69:ab: - 9a:4a:6a:66:2c:db:e1:09:f1:0d:b2:b0:e1:47:1f: - 0a:46:ac:0d:82:f3:3c:8d:02:ce:08:43:19:d9:64: - 86:c4:4e:07:12:c0:5b:43:ba:7d:17:8a:a3:f0:3d: - 98:32:b9:75:66:f4:f0:1b:2d:94:5b:7c:1c:e6:f3: - 04:7f:dd:25:b2:82:a6:41:04:b7:50:93:94:c4:7c: - 34:7e:12:7c:bf:33:54:55:47:8c:42:94:40:8e:34: - 5f:54:04:1d:9e:8c:57:48:d4:b0:f8:e4:03:db:3f: - 68:6c:37:fa:62:14:1c:94:d6:de:f2:2b:68:29:17: - 24:6d:f7:b5:b3:18:79:fd:31:5e:7f:4c:be:c0:99: - 13:cc:e2:97:2b:dc:96:9c:9a:d0:a7:c5:77:82:67: - c9:cb:a9:e7:68:4a:e1:c5:ba:1c:32:0e:79:40:6e: - ef:08:d7:a3:b9:5d:1a:df:ce:1a:c7:44:91:4c:d4: - 99:c8:88:69:b3:66:2e:b3:06:f1:f4:22:d7:f2:5f: - ab:6d - Exponent: 65537 (0x10001) - ``` - -1. Finally, move the key to apk trusted keys storage: - - ```shell - sudo mv /tmp/nginx_signing.rsa.pub /etc/apk/keys/ - ``` - -1. To install `nginx-agent`, run the following command: - - ```shell - sudo apk add nginx-agent - ``` - -1. Verify the installation: - - ```shell - sudo nginx-agent -v - ``` - -### Installing NGINX Agent on Amazon Linux 2023 - -1. Create the `/etc/ssl/nginx` directory: - - ```shell - sudo mkdir -p /etc/ssl/nginx - ``` - -1. Log in to [MyF5 Customer Portal](https://account.f5.com/myf5/) and download your `nginx-repo.crt` and `nginx-repo.key` files. - -1. Copy the `nginx-repo.crt` and `nginx-repo.key` files to the `/etc/ssl/nginx/` directory: - - ```shell - sudo cp nginx-repo.crt nginx-repo.key /etc/ssl/nginx/ - ``` - -1. Install the prerequisites: - - ```shell - sudo dnf install yum-utils procps-ng ca-certificates - ``` - -1. To set up the dnf repository for Amazon Linux 2023, create the file named `/etc/yum.repos.d/nginx-agent.repo` with the following contents: - - ``` - [nginx-agent] - name=nginx-agent repo - baseurl=https://packages.nginx.org/nginx-agent/amzn/2023/$basearch/ - sslclientcert=/etc/ssl/nginx/nginx-repo.crt - sslclientkey=/etc/ssl/nginx/nginx-repo.key - gpgcheck=0 - enabled=1 - ``` - -1. To install `nginx-agent`, run the following command: - - ```shell - sudo dnf install nginx-agent - ``` - -1. When prompted to accept the GPG key, verify that the fingerprint matches `8540 A6F1 8833 A80E 9C16 53A4 2FD2 1310 B49F 6B46`, `573B FD6B 3D8F BC64 1079 A6AB ABF5 BD82 7BD9 BF62`, `9E9B E90E ACBC DE69 FE9B 204C BCDC D8A3 8D88 A2B3`, and if so, accept it. - -1. Verify the installation: - - ```shell - sudo nginx-agent -v - ``` - -### Installing NGINX Agent on Amazon Linux 2 - -1. Create the `/etc/ssl/nginx` directory: - - ```shell - sudo mkdir -p /etc/ssl/nginx - ``` - -1. Log in to [MyF5 Customer Portal](https://account.f5.com/myf5/) and download your `nginx-repo.crt` and `nginx-repo.key` files. - -1. Copy the `nginx-repo.crt` and `nginx-repo.key` files to the `/etc/ssl/nginx/` directory: - - ```shell - sudo cp nginx-repo.crt nginx-repo.key /etc/ssl/nginx/ - ``` - -1. Install the prerequisites: - - ```shell - sudo yum install yum-utils procps ca-certificates - ``` - -1. To set up the yum repository for Amazon Linux 2, create the file named `/etc/yum.repos.d/nginx-agent.repo` with the following contents: - - ``` - [nginx-agent] - name=nginx-agent repo - baseurl=https://pkgs.nginx.com/nginx-agent/amzn/2023/$releasever/$basearch - sslclientcert=/etc/ssl/nginx/nginx-repo.crt - sslclientkey=/etc/ssl/nginx/nginx-repo.key - gpgcheck=0 - enabled=1 - ``` - -1. To install `nginx-agent`, run the following command: - - ```shell - sudo yum install nginx-agent - ``` - -1. When prompted to accept the GPG key, verify that the fingerprint matches `8540 A6F1 8833 A80E 9C16 53A4 2FD2 1310 B49F 6B46`, `573B FD6B 3D8F BC64 1079 A6AB ABF5 BD82 7BD9 BF62`, `9E9B E90E ACBC DE69 FE9B 204C BCDC D8A3 8D88 A2B3`, and if so, accept it. - -1. Verify the installation: - - ```shell - sudo nginx-agent -v - ``` - -### Installing NGINX Agent on FreeBSD - -1. Create the `/etc/ssl/nginx` directory: - - ```shell - sudo mkdir -p /etc/ssl/nginx - ``` - -1. Log in to [MyF5 Customer Portal](https://account.f5.com/myf5/) and download your `nginx-repo.crt` and `nginx-repo.key` files. - -1. Copy the files to the `/etc/ssl/nginx/` directory: - - ```shell - sudo cp nginx-repo.crt nginx-repo.key /etc/ssl/nginx/ - ``` - -1. Install the prerequisite `ca_root_nss` package: - - ```shell - sudo pkg install ca_root_nss - ``` - -1. To setup the pkg repository create the file named `/etc/pkg/nginx-agent.conf` with the following content: - - ``` - nginx-agent: { - URL: pkg+https://pkgs.nginx.com/nginx-agent/freebsd/${ABI}/latest - ENABLED: yes - MIRROR_TYPE: SRV - } - ``` - -1. Add the following lines to the `/usr/local/etc/pkg.conf` file: - - ``` - PKG_ENV: { SSL_NO_VERIFY_PEER: "1", - SSL_CLIENT_CERT_FILE: "/etc/ssl/nginx/nginx-repo.crt", - SSL_CLIENT_KEY_FILE: "/etc/ssl/nginx/nginx-repo.key" } - ``` - -1. To install `nginx-agent`, run the following command: - - ```shell - sudo pkg install nginx-agent - ``` - -1. Verify the installation: - - ```shell - sudo nginx-agent -v - ``` diff --git a/site/content/v2/installation-upgrade/uninstall.md b/site/content/v2/installation-upgrade/uninstall.md deleted file mode 100644 index 49184df82..000000000 --- a/site/content/v2/installation-upgrade/uninstall.md +++ /dev/null @@ -1,162 +0,0 @@ ---- -title: "Uninstall NGINX Agent package" -draft: false -weight: 700 -toc: true -tags: [ "docs" ] -docs: "DOCS-1230" -categories: ["configuration"] -doctypes: ["task"] ---- - -## Overview - -Learn how to uninstall NGINX Agent from your system. - -## Prerequisites - -- NGINX Agent installed [NGINX Agent installed](../installation-oss) -- The user following these steps will need `root` privilege - -## Uninstalling NGINX Agent -Complete the following steps on each host where you’ve installed NGINX Agent - - -- [Uninstalling NGINX Agent on RHEL, CentOS, Rocky Linux, AlmaLinux, and Oracle Linux](#uninstalling-nginx-agent-on-rhel-centos-rocky-linux-almalinux-and-oracle-linux) -- [Uninstalling NGINX Agent on Ubuntu](#uninstalling-nginx-agent-on-ubuntu) -- [Uninstalling NGINX Agent on Debian](#uninstalling-nginx-agent-on-debian) -- [Uninstalling NGINX Agent on SLES](#uninstalling-nginx-agent-on-sles) -- [Uninstalling NGINX Agent on Alpine Linux](#uninstalling-nginx-agent-on-alpine-linux) -- [Uninstalling NGINX Agent on Amazon Linux](#uninstalling-nginx-agent-on-amazon-linux) -- [Uninstalling NGINX Agent on FreeBSD](#uninstalling-nginx-agent-on-freebsd) - -### Uninstalling NGINX Agent on RHEL, CentOS, Rocky Linux, AlmaLinux, and Oracle Linux - -Complete the following steps on each host where you've installed NGINX Agent: - -1. Stop NGINX Agent: - - ```bash - sudo systemctl stop nginx-agent - ``` - -2. To uninstall NGINX Agent, run the following command: - - ```bash - sudo yum remove nginx-agent - ``` - -### Uninstalling NGINX Agent on Ubuntu - -Complete the following steps on each host where you've installed NGINX Agent: - -1. Stop NGINX Agent: - - ```bash - sudo systemctl stop nginx-agent - ``` - -2. To uninstall NGINX Agent, run the following command: - - ```bash - sudo apt-get remove nginx-agent - ``` - - > **Note:** The `apt-get remove ` command will remove the package from your system, while keeping the associated configuration files for possible future use. If you want to completely remove the package and all of its configuration files, you should use `apt-get purge `. - -### Uninstalling NGINX Agent on Debian - -Complete the following steps on each host where you've installed NGINX Agent: - -1. Stop NGINX Agent: - - ```bash - sudo systemctl stop nginx-agent - ``` - -2. To uninstall NGINX Agent, run the following command: - - ```bash - sudo apt-get remove nginx-agent - ``` - - > **Note:** The `apt-get remove ` command will remove the package from your system, while keeping the associated configuration files for possible future use. If you want to completely remove the package and all of its configuration files, you should use `apt-get purge `. - -### Uninstalling NGINX Agent on SLES - -Complete the following steps on each host where you've installed NGINX Agent: - -1. Stop NGINX agent: - - ```bash - sudo systemctl stop nginx-agent - ``` - -2. To uninstall NGINX agent, run the following command: - - ```bash - sudo zypper remove nginx-agent - ``` - -### Uninstalling NGINX Agent on Alpine Linux - -Complete the following steps on each host where you've installed NGINX agent: - -1. Stop NGINX agent: - - ```bash - sudo rc-service nginx-agent stop - ``` - -2. To uninstall NGINX agent, run the following command: - - ```bash - sudo apk del nginx-agent - ``` - -### Uninstalling NGINX Agent on Amazon Linux 2 - -Complete the following steps on each host where you've installed NGINX agent: - -1. Stop NGINX agent: - - ```bash - sudo systemctl stop nginx-agent - ``` - -2. To uninstall NGINX agent, run the following command: - - ```bash - sudo yum remove nginx-agent - ``` - -### Uninstalling NGINX Agent on Amazon Linux 2023 - -Complete the following steps on each host where you've installed NGINX agent: - -1. Stop NGINX agent: - - ```bash - sudo systemctl stop nginx-agent - ``` - -2. To uninstall NGINX agent, run the following command: - - ```bash - sudo dnf remove nginx-agent - ``` -### Uninstalling NGINX Agent on FreeBSD - -Complete the following steps on each host where you've installed NGINX agent: - -1. Stop NGINX agent: - - ```bash - sudo service nginx-agent stop - ``` - -2. To uninstall NGINX agent, run the following command: - - ```bash - sudo pkg delete nginx-agent - ``` diff --git a/site/content/v2/installation-upgrade/upgrade.md b/site/content/v2/installation-upgrade/upgrade.md deleted file mode 100644 index f5e3409af..000000000 --- a/site/content/v2/installation-upgrade/upgrade.md +++ /dev/null @@ -1,84 +0,0 @@ ---- -title: "Upgrade NGINX Agent package" -draft: false -weight: 600 -toc: true -tags: [ "docs" ] -docs: "DOCS-1227" -categories: ["configuration"] -doctypes: ["task"] ---- - -## Overview - -Learn how to upgrade NGINX Agent. - -## Upgrade NGINX Agent from version v2.31.0 or greater - -{{< note >}} Starting from version v2.31.0, NGINX Agent will automatically restart itself during an upgrade. {{< /note >}} - -To upgrade NGINX Agent, follow these steps: - -1. Open an SSH connection to the server where you’ve installed NGINX Agent and log in. - -1. Make a backup copy of the following locations to ensure that you can successfully recover if the upgrade has issues: - - - `/etc/nginx-agent` - - `config_dirs` values for any configuration specified in `/etc/nginx-agent/nginx-agent.conf` - -1. Install the updated version of NGINX Agent: - - - CentOS, RHEL, RPM-Based - - ```shell - sudo yum -y makecache - sudo yum update -y nginx-agent - ``` - - - Debian, Ubuntu, Deb-Based - - ```shell - sudo apt-get update - sudo apt-get install -y --only-upgrade nginx-agent -o Dpkg::Options::="--force-confold" - ``` - - - -## Upgrade NGINX Agent from a version less than v2.31.0 - -To upgrade NGINX Agent, take the following steps: - -1. Open an SSH connection to the server where you’ve installed NGINX Agent and log in. - -1. Make a backup copy of the following locations to ensure that you can successfully recover if the upgrade has issues: - - - `/etc/nginx-agent` - - `config_dirs` values for any configuration specified in `/etc/nginx-agent/nginx-agent.conf` - -1. Stop NGINX Agent: - - ```shell - sudo systemctl stop nginx-agent - ``` - -1. Install the updated version of NGINX Agent: - - - CentOS, RHEL, RPM-Based - - ```shell - sudo yum -y makecache - sudo yum update -y nginx-agent - ``` - - - Debian, Ubuntu, Deb-Based - - ```shell - sudo apt-get update - sudo apt-get install -y --only-upgrade nginx-agent -o Dpkg::Options::="--force-confold" - ``` - -1. Start NGINX Agent: - - ```shell - sudo systemctl start nginx-agent - ``` diff --git a/site/content/v2/metrics.md b/site/content/v2/metrics.md deleted file mode 100644 index e90e55c93..000000000 --- a/site/content/v2/metrics.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: Metrics -catalog: true -catalogType: v2metrics -toc: true -weight: 200 -docs: DOCS-000 ---- - -{{< v2-metrics >}} \ No newline at end of file diff --git a/site/content/v2/technical-specifications.md b/site/content/v2/technical-specifications.md deleted file mode 100644 index efa731527..000000000 --- a/site/content/v2/technical-specifications.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -title: "Technical specifications" -weight: 100 -toc: true -docs: "DOCS-1092" ---- - -This document describes the requirements for NGINX Agent v2. - -## Supported distributions - -NGINX Agent can run in most environments. We support the following distributions: - -{{< bootstrap-table "table table-striped table-bordered" >}} -| | AlmaLinux | Alpine Linux | Amazon Linux | Amazon Linux 2 | CentOS | Debian | -|-|-----------|--------------|--------------|----------------|--------|--------| -|**Version**|8

9 | 3.16

3.17

3.18

3.19| 2023| LTS| 7.4+| 11

12| -|**Architecture**| x86_84

aarch64| x86_64

aarch64 | x86_64

aarch64 | x86_64

aarch64 | x86_64

aarch64 | x86_64

aarch64 | -{{< /bootstrap-table >}} - -{{< bootstrap-table "table table-striped table-bordered" >}} -| |FreeBSD | Oracle Linux | Red Hat
Enterprise Linux
(RHEL) | Rocky Linux | SUSE Linux
Enterprise Server
(SLES) | Ubuntu | -|-|--------|--------------|---------------------------------|-------------|-------------------------------------|--------| -|**Version**|13

14|7.4+

8.1+

9|7.4+

8.1+

9.0+|8

9|12 SP5

15 SP2|20.04 LTS

22.04 LTS| -|**Architecture**|amd64|x86_64|x86_64

aarch64|x86_64

aarch64|x86_64|x86_64

aarch64| -{{< /bootstrap-table >}} - - -## Supported deployment environments - -NGINX Agent can be deployed in the following environments: - -- Bare Metal -- Container -- Public Cloud: AWS, Google Cloud Platform, and Microsoft Azure -- Virtual Machine - -## Supported NGINX versions - -NGINX Agent works with all supported versions of NGINX Open Source and NGINX Plus. - - -## Sizing recommendations - -Minimum system sizing recommendations for NGINX Agent: -{{< bootstrap-table "table table-striped table-bordered" >}} -| CPU | Memory | Network | Storage | -|------------|----------|-----------|---------| -| 1 CPU core | 1 GB RAM | 1 GbE NIC | 20 GB | -{{< /bootstrap-table >}} - -## Logging - -NGINX Agent utilizes log files and formats to collect metrics. Increasing the log formats and instance counts will result in increased log file sizes. To prevent system storage issues due to a growing log directory, it is recommended to add a separate partition for `/var/log/nginx-agent` and enable [log rotation](http://nginx.org/en/docs/control.html#logs). \ No newline at end of file diff --git a/site/data/.gitkeep b/site/data/.gitkeep deleted file mode 100644 index c3bb2f98b..000000000 --- a/site/data/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -Add or mount data sources here \ No newline at end of file diff --git a/site/data/layouts/.gitkeep b/site/data/layouts/.gitkeep deleted file mode 100644 index 02dbb3db1..000000000 --- a/site/data/layouts/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -Add or mount custom layouts here \ No newline at end of file diff --git a/site/go.mod b/site/go.mod deleted file mode 100644 index e6c32c9f6..000000000 --- a/site/go.mod +++ /dev/null @@ -1,5 +0,0 @@ -module github.com/nginx/agent/site - -go 1.18 - -require github.com/nginxinc/nginx-hugo-theme v0.41.23 // indirect diff --git a/site/go.sum b/site/go.sum deleted file mode 100644 index 3e619b076..000000000 --- a/site/go.sum +++ /dev/null @@ -1,14 +0,0 @@ -github.com/nginxinc/nginx-hugo-theme v0.28.0 h1:RHHvBmFk2Uptk+efLPSIuBd2elc3IOZPElkJbkkpAHo= -github.com/nginxinc/nginx-hugo-theme v0.28.0/go.mod h1:DPNgSS5QYxkjH/BfH4uPDiTfODqWJ50NKZdorguom8M= -github.com/nginxinc/nginx-hugo-theme v0.33.0 h1:6mZdgxf5kNhInsrAXXiSlWXRy3fsP51lWKOdrvmkR6U= -github.com/nginxinc/nginx-hugo-theme v0.33.0/go.mod h1:DPNgSS5QYxkjH/BfH4uPDiTfODqWJ50NKZdorguom8M= -github.com/nginxinc/nginx-hugo-theme v0.34.0-alpha h1:8tKWnkhxP5Nk0V64v8rE9T3crusycohXl3wmOWc4uFk= -github.com/nginxinc/nginx-hugo-theme v0.34.0-alpha/go.mod h1:DPNgSS5QYxkjH/BfH4uPDiTfODqWJ50NKZdorguom8M= -github.com/nginxinc/nginx-hugo-theme v0.34.0 h1:G7LPVq7w1ls6IS4+OkTwjhFb67rLCzPdfZvW1/sn2Cw= -github.com/nginxinc/nginx-hugo-theme v0.34.0/go.mod h1:DPNgSS5QYxkjH/BfH4uPDiTfODqWJ50NKZdorguom8M= -github.com/nginxinc/nginx-hugo-theme v0.35.0 h1:7XB2GMy6qeJgKEJy9wOS3SYKYpfvLW3/H+UHRPLM4FU= -github.com/nginxinc/nginx-hugo-theme v0.35.0/go.mod h1:DPNgSS5QYxkjH/BfH4uPDiTfODqWJ50NKZdorguom8M= -github.com/nginxinc/nginx-hugo-theme v0.40.8 h1:VtoSAtf9k67tI2jzbLRo0oFBAMHZBUPRh/xV4MYullI= -github.com/nginxinc/nginx-hugo-theme v0.40.8/go.mod h1:DPNgSS5QYxkjH/BfH4uPDiTfODqWJ50NKZdorguom8M= -github.com/nginxinc/nginx-hugo-theme v0.41.23 h1:ddIfLF7BFd78qyIn3z5aReeC4BO/m9FH81d5S+al/6s= -github.com/nginxinc/nginx-hugo-theme v0.41.23/go.mod h1:DPNgSS5QYxkjH/BfH4uPDiTfODqWJ50NKZdorguom8M= diff --git a/site/layouts/agent-v2-migration/list.html b/site/layouts/agent-v2-migration/list.html deleted file mode 100644 index 54b0b0f66..000000000 --- a/site/layouts/agent-v2-migration/list.html +++ /dev/null @@ -1,10 +0,0 @@ -{{ define "main" }} -
- -
- {{ partial "agent-v2-migration/list-main" . }} -
-
-{{ end }} \ No newline at end of file diff --git a/site/layouts/agent-v2-migration/single.html b/site/layouts/agent-v2-migration/single.html deleted file mode 100644 index e91863080..000000000 --- a/site/layouts/agent-v2-migration/single.html +++ /dev/null @@ -1,49 +0,0 @@ -{{ define "main" }} -
- - - {{if (.Params.catalog) }} -
- {{ else if and (gt .WordCount 200 ) (.Params.toc) }} -
- {{ else }} -
- {{ end }} - -
-
- NGINX Agent v3 is available!

- This documentation is for NGINX Agent v2. We suggest reading the Migrate from NGINX Agent v2 to v3 topic to learn the differences between the two versions, and learn how to upgrade your instances. -
-
- -

{{ .Title }}

- - {{ if eq .Page.Draft true }}{{ partial "draft-badge.html" . }}{{ end }} - {{ if .Description }}

{{ .Description | markdownify }}

{{ end}} - - {{ if in .Params.doctypes "beta" }}{{ partial "beta-badge" . }}{{ end }} - - {{ .Content }} - {{ partial "version-list" . }} -
- {{ partial "previous-next-links-in-section-with-title.html" . }} - -
- {{ if and (gt .WordCount 200 ) (.Params.toc) }} - {{ if (add (len (findRE " - {{ partial "toc.html" . }} -
- {{ end }} - {{ end }} - - -{{if .Params.script}} - {{ $script := (delimit (slice "scripts" .Params.script) "/")}} - {{ partial (string $script) .}} -{{end }} - -{{ end }} \ No newline at end of file diff --git a/site/layouts/catalogs/single.html b/site/layouts/catalogs/single.html deleted file mode 100644 index 57b1d7779..000000000 --- a/site/layouts/catalogs/single.html +++ /dev/null @@ -1,14 +0,0 @@ -{{ define "main" }} -
- - - {{ .Content }} - -{{if .Params.script}} - {{ $script := (delimit (slice "scripts" .Params.script) "/")}} - {{ partial (string $script) .}} -{{end }} - -{{ end }} diff --git a/site/layouts/partials/agent-v2-migration/list-main.html b/site/layouts/partials/agent-v2-migration/list-main.html deleted file mode 100644 index 51c39a214..000000000 --- a/site/layouts/partials/agent-v2-migration/list-main.html +++ /dev/null @@ -1,53 +0,0 @@ -
-
- -
- -
-
-
- {{ range .Pages.GroupBy "Section" }} - - {{ range .Pages.ByWeight }} -
-
-

- - {{ .Title }} -

- {{/*}}

- {{ if .Description }}{{ .Description | markdownify }}{{ end }} -

{{*/}} - -
-
- - {{ end }} -
-
- {{ end }} - - -
- -
\ No newline at end of file diff --git a/site/layouts/shortcodes/v2-metrics.html b/site/layouts/shortcodes/v2-metrics.html deleted file mode 100644 index dc14d9a35..000000000 --- a/site/layouts/shortcodes/v2-metrics.html +++ /dev/null @@ -1,56 +0,0 @@ -
- {{ range .Site.Data.v2metrics }} - -

{{.name}} - -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
description{{.description}}
type{{.type}}
categories{{.categories}}
source{{.source}}
rollup_aggregate{{.rollup_aggregate}}
unit{{.unit}}
aggregations - {{ range .aggregations }} - {{ . }}; - {{end}} -
dimensions - {{ range sort .dimensions }} -
  • - {{ . }} -
  • - {{end}} -
    -
    - {{ end }} -
    \ No newline at end of file diff --git a/site/makefile b/site/makefile deleted file mode 100644 index 849a80b0f..000000000 --- a/site/makefile +++ /dev/null @@ -1,94 +0,0 @@ -HUGO?=hugo -HUGO_IMG?=hugomods/hugo:0.115.3 - -THEME_MODULE = github.com/nginxinc/nginx-hugo-theme -## Pulls the current theme version from the Netlify settings -THEME_VERSION = $(NGINX_THEME_VERSION) - -# if there's no local hugo, fallback to docker -ifeq (, $(shell ${HUGO} version 2> /dev/null)) -ifeq (, $(shell docker version 2> /dev/null)) - $(error Docker and Hugo are not installed. Hugo (<0.91) or Docker are required to build the local preview.) -else - HUGO=docker run --rm -it -v ${CURDIR}:/src -p 1313:1313 ${HUGO_IMG} hugo --bind 0.0.0.0 -p 1313 -endif -endif - -MARKDOWNLINT?=markdownlint -MARKDOWNLINT_IMG?=ghcr.io/igorshubovych/markdownlint-cli:latest - -# if there's no local markdownlint, fallback to docker -ifeq (, $(shell ${MARKDOWNLINT} version 2> /dev/null)) -ifeq (, $(shell docker version 2> /dev/null)) -ifneq (, $(shell $(NETLIFY) "true")) - $(error Docker and markdownlint are not installed. markdownlint or Docker are required to lint.) -endif -else - MARKDOWNLINT=docker run --rm -i -v ${CURDIR}:/src --workdir /src ${MARKDOWNLINT_IMG} -endif -endif - -MARKDOWNLINKCHECK?=markdown-link-check -MARKDOWNLINKCHECK_IMG?=ghcr.io/tcort/markdown-link-check:stable -# if there's no local markdown-link-check, fallback to docker -ifeq (, $(shell ${MARKDOWNLINKCHECK} --version 2> /dev/null)) -ifeq (, $(shell docker version 2> /dev/null)) -ifneq (, $(shell $(NETLIFY) "true")) - $(error Docker and markdown-link-check are not installed. markdown-link-check or Docker are required to check links.) -endif -else - MARKDOWNLINKCHECK=docker run --rm -it -v ${CURDIR}:/docs --workdir /docs ${MARKDOWNLINKCHECK_IMG} -endif -endif - -.PHONY: all clean hugo-mod all-staging all-dev hugo-server-drafts hugo-server netlify deploy-preview - -# Removes the public directory generated by the `hugo` command -clean: - if [[ -d ${PWD}/public ]] ; then rm -rf ${PWD}/public && echo "Removed public directory" ; else echo "Did not find a public directory to remove" ; fi - -hugo-mod: - hugo mod get $(THEME_MODULE)@v$(THEME_VERSION) - -# Builds using the Hugo "production" environment -# For deploys to docs.nginx.com only -all: hugo-mod - hugo --gc -e production - -# Builds using the Hugo "staging" environment -# For deploys to docs-staging.nginx.com only -all-staging: hugo-mod - hugo --gc -e staging - -# Builds using the Hugo "development" environment -# For deploys to docs-dev.nginx.com only -all-dev: hugo-mod - hugo --gc -e development - -# Runs the Hugo server with content marked as draft -# Serves docs at localhost:1313 -docs-drafts: - ${HUGO} server -D --disableFastRender - -docs-local: clean - ${HUGO} - -# Runs the Hugo server -# Serves docs at localhost:1313 -docs: - ${HUGO} server --disableFastRender - -lint-markdown: - ${MARKDOWNLINT} -c .markdownlint.yaml -- content - -link-check: - ${MARKDOWNLINKCHECK} $(shell find content -name '*.md') - -# Can be used to deploy to netlify from your local -# development environment. -# Requires a netlify login. -netlify: clean - netlify deploy --build -d public --alias $(shell git branch --show-current)-branch - -deploy-preview: hugo-mod - hugo --gc -b ${NETLIFY_DEPLOY_URL}/nginx-agent diff --git a/site/md-linkcheck-config.json b/site/md-linkcheck-config.json deleted file mode 100644 index 42cf466df..000000000 --- a/site/md-linkcheck-config.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "replacementPatterns": [ - { - "pattern": "^\/", - "replacement": "/" - } - ], - "ignorePatterns": [ - { - "pattern": "^.+localhost.+$|\/.+yaml" - } - ] - } - \ No newline at end of file diff --git a/site/mdlint.json b/site/mdlint.json deleted file mode 100644 index 0930b5fa3..000000000 --- a/site/mdlint.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "default": false, - "single-trailing-newline": false, - "header-style": { "style": "atx" }, - "ul-style": false, - "no-trailing-spaces": false, - "no-hard-tabs": { "code_blocks": true}, - "no-duplicate-heading": {"allow_different_nesting": true, "siblings_only": true}, - "first-line-heading": false -} diff --git a/site/netlify.toml b/site/netlify.toml deleted file mode 100644 index 8357e7214..000000000 --- a/site/netlify.toml +++ /dev/null @@ -1,39 +0,0 @@ -[build] - command = "make deploy-preview" - -[context.production] - command = "make all" - -[context.docs-development] - command = "make all-dev" - -[context.docs-staging] - command = "make all-staging" - -[context.deploy-preview] - command = "make deploy-preview" - -[context.branch-deploy] - command = "make deploy-preview" - -[[headers]] - for = "/*" - [headers.values] - Access-Control-Allow-Origin = "https://docs.nginx.com" - -[[redirects]] - from = "/" - to = "/nginx-agent/" - status = 301 - force = true - -[[redirects]] - from = "/nginx-agent/*" - to = "/nginx-agent/404.html" - status = 404 - -[[redirects]] - from = "/nginx-agent/robots.txt" - to = "https://docs.nginx.com/robots.txt" - status = 301 - force = true diff --git a/site/static/.gitkeep b/site/static/.gitkeep deleted file mode 100644 index cc34215bb..000000000 --- a/site/static/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -add or mount static resources (that don't need to be built by Hugo but belong in the build context) here \ No newline at end of file diff --git a/site/static/agent-flow.png b/site/static/agent-flow.png deleted file mode 100644 index f65871e5bc80c24a64efd4159d00aceb10cf96e2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 95908 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 V*?Xt%ekk-V1v%9R757bp{ud@vfD8Zt diff --git a/site/static/grafana-dashboard-example.png b/site/static/grafana-dashboard-example.png deleted file mode 100644 index 04a5ebf5f8653e5c2b78bde8cf644d34889402d9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 370502 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�It=8$nw_6->qwBCZRXEzDRoe72a10B{&#Nv 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 Date: Thu, 5 Jun 2025 04:15:37 -0700 Subject: [PATCH 05/28] Changed README to reflect new community forum (#1111) Since we have discontinued the Slack NGINX community, I have updated the README to point to the NGINX Community Forum and specifically to the "Agent" tag section --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4bc155473..bd07002eb 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ TBD ## Community -- Our [Slack channel #nginx-agent](https://nginxcommunity.slack.com/), is the go-to place to start asking questions and sharing your thoughts. +- Our [NGINX Community Forum ](https://community.nginx.org/tag/agent) is the go-to place to ask questions and share your thoughts. - Our [GitHub issues page](https://github.com/nginx/agent/issues) offers space for a more technical discussion at your own pace. From 17db9f343dd11eca1554ef4e5c5a5a3bf4e00f08 Mon Sep 17 00:00:00 2001 From: Nathan Bird Date: Thu, 5 Jun 2025 09:02:02 -0400 Subject: [PATCH 06/28] Update linter version and install instructions in README.md (#1081) * docs: fix mdatagen version in README * chore: fix markdownlint errors in README The only visible change is that the mdatagen issue is now linked. All the rest of these are just markdownlint recommendations. * test: fix assert reference to current test This should fail the local inner test, not the parent. * test: disable test host's OsReleaseInfo `make unit-test` was failing on tests for parsing hostinfo. E.g. - VersionId: (string) (len=5) "22.04", - Version: (string) (len=29) "22.04.5 LTS (Jammy Jellyfish)", + VersionId: (string) (len=5) "24.04", + Version: (string) (len=26) "24.04.2 LTS (Noble Numbat)", My host system (where make is running) is Ubuntu 24.04.2 LTS (Noble Numbat) so it picking that up. After tracing this through I found that - The test is mocking what releaseinfo should return: internal/datasource/host/info_test.go#L518 - mock is consumed at internal/datasource/host/info.go#L298 - but then, internal/datasource/host/info.go#L299-L306 - readOsRelease info (which is different than the "host"?) - if this errors returns the mocked hostReleaseInfo, e.g. on mac - if this returns data (my ubuntu host) it is merged into the returned data It looks like these tests generally haven't been run on a host that has `/etc/os-release` and tests shouldn't be subject to host system data changes like that. I've just put in a bad path so that on ubuntu hosts it won't read anything; this should match the existing behavior of running this on macos hosts. * chore: Upgrade golangci-lint for compatibility with go1.24 When the local go toolchain is go1.24 or newer make lint reports a bunch of spurious errors. This newer version of golangci-lint is compatible with go1.24 eliminating that. - `context` config changed, there are now more options than true or false. "" disables it to match the previous behavior - `exported-is-used` is deprecated, it is always true now. --- .golangci.yml | 4 +-- Makefile.tools | 2 +- README.md | 43 +++++++++++++++++++-------- internal/datasource/host/info_test.go | 3 +- 4 files changed, 34 insertions(+), 18 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index d830af46b..020a7ff63 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -77,8 +77,6 @@ linters-settings: field-writes-are-uses: true # Treat IncDec statement (e.g. `i++` or `i--`) as both read and write operation instead of just wr◊ite. Default: false post-statements-are-reads: true - # Mark all exported identifiers as used. Default: true - exported-is-used: true # Mark all exported fields as used. Default: true exported-fields-are-used: false # Mark all function parameters as used. Default: true @@ -770,7 +768,7 @@ linters-settings: # Enforce using attributes only (overrides no-mixed-args, incompatible with kv-only). Default: false attr-only: false # Enforce using methods that accept a context. Default: false - context-only: false + context: "" # Enforce using static values for log messages. Default: false static-msg: false # Enforce using constants instead of raw keys. Default: false diff --git a/Makefile.tools b/Makefile.tools index 23417c9a6..abb037868 100644 --- a/Makefile.tools +++ b/Makefile.tools @@ -1,6 +1,6 @@ OAPICODEGEN = github.com/deepmap/oapi-codegen/v2/cmd/oapi-codegen@v2.1.0 LEFTHOOK = github.com/evilmartians/lefthook@v1.6.9 -GOLANGCILINT = github.com/golangci/golangci-lint/cmd/golangci-lint@v1.61.0 +GOLANGCILINT = github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.8 PROTOCGENGO = google.golang.org/protobuf/cmd/protoc-gen-go@v1.32.0 GOFUMPT = mvdan.cc/gofumpt@v0.6.0 COUNTERFEITER = github.com/maxbrunsfeld/counterfeiter/v6@v6.8.1 diff --git a/README.md b/README.md index bd07002eb..5f67ec928 100644 --- a/README.md +++ b/README.md @@ -13,15 +13,19 @@ NGINX Agent is a companion daemon for your NGINX Open Source or NGINX Plus insta - Notifications of NGINX events ## Development Environment Setup + ### Installing Prerequisite Packages + The following packages need to be installed: - - make - - golang (https://go.dev/doc/install) - - protoc (https://grpc.io/docs/protoc-installation/) - - mdatagen (There is currently an issue installing mdatagen https://github.com/open-telemetry/opentelemetry-collector/issues/9281. See instructions below for workaround.) + +- make +- golang () +- protoc () +- mdatagen (There is currently an [issue installing mdatagen](https://github.com/open-telemetry/opentelemetry-collector/issues/9281). See instructions below for workaround.) #### Workaround to install mdatagen -``` + +```console git clone https://github.com/open-telemetry/opentelemetry-collector.git cd opentelemetry-collector git checkout v0.124.0 @@ -30,47 +34,60 @@ go install ``` Before starting development on the NGINX Agent, it is important to download and install the necessary tool and dependencies required by the NGINX Agent. You can do this by running the following `make` command: -``` + +```console make install-tools ``` ### Building NGINX Agent from Source Code + Build NGINX Agent deb package: -``` + +```console OSARCH= make local-deb-package ``` + Build NGINX Agent rpm package: -``` + +```console OSARCH= make local-rpm-package ``` + Build NGINX Agent apk package: -``` + +```console OSARCH= make local-apk-package ``` ### Testing NGINX Agent #### Unit tests + To run unit tests and check that there is enough test coverage run the following -``` + +```console make unit-test coverge ``` + To check for race conditions, the unit tests can also be run with a race condition detector -``` + +```console make race-condition-test ``` #### Integration tests + To run integration tests, run the following -``` + +```console make integration-test ``` #### Testing with a mock management plane + For testing command operations, there is a mock management gRPC server that can be used. See here: [mock management gRPC server](test/mock/grpc/README.md) \ For testing metrics, there is a mock management OTel collector that can be used. See here: [mock management OTel collector](test/mock/collector/README.md) - ## NGINX Agent Technical Specifications ### Supported Distributions diff --git a/internal/datasource/host/info_test.go b/internal/datasource/host/info_test.go index 78144bb3f..fb056a518 100644 --- a/internal/datasource/host/info_test.go +++ b/internal/datasource/host/info_test.go @@ -526,11 +526,12 @@ func TestInfo_ContainerInfo(t *testing.T) { info := NewInfo() info.mountInfoLocation = mountInfoFile.Name() info.exec = execMock + info.osReleaseLocation = "/non/existent" containerInfo := info.ContainerInfo(ctx) assert.Equal(tt, test.expectContainerID, containerInfo.ContainerInfo.GetContainerId()) assert.Equal(tt, test.expectHostname, containerInfo.ContainerInfo.GetHostname()) - assert.Equal(t, releaseInfo, containerInfo.ContainerInfo.GetReleaseInfo()) + assert.Equal(tt, releaseInfo, containerInfo.ContainerInfo.GetReleaseInfo()) }) } } From 7579f83675adaa69e34999893bf6535ab2f22011 Mon Sep 17 00:00:00 2001 From: Donal Hurley Date: Thu, 5 Jun 2025 16:25:16 +0100 Subject: [PATCH 07/28] Update github workflows (#1115) --- .github/workflows/ci.yml | 3 ++ .github/workflows/codeql.yml | 56 ++-------------------------- .github/workflows/label-pr.yml | 3 ++ .github/workflows/release-branch.yml | 4 +- 4 files changed, 12 insertions(+), 54 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6b4ce93b..1c9afd990 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,9 @@ on: - reopened - synchronize +permissions: + contents: read + env: NFPM_VERSION: 'v2.35.3' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d8f17cb1b..1ff3bf57b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -55,57 +55,9 @@ jobs: permissions: actions: read # for github/codeql-action/init to get workflow details contents: read # for actions/checkout to fetch code + packages: read security-events: write # for github/codeql-action/autobuild to send a status report name: Analyze - runs-on: ubuntu-24.04 - - strategy: - fail-fast: false - matrix: - language: ["go"] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby', 'swift' ] - # Use only 'java' to analyze code written in Java, Kotlin or both - # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both - # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support - - steps: - - name: Checkout repository - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality - - - name: Setup Golang Environment - uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 - with: - go-version-file: go.mod - if: matrix.language == 'go' - - # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3 - - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3 - with: - category: "/language:${{matrix.language}}" + uses: nginxinc/compliance-rules/.github/workflows/codeql.yml@c903bfe6c668eaba362cde6a7882278bc1564401 # v0.1 + with: + requested_languages: go diff --git a/.github/workflows/label-pr.yml b/.github/workflows/label-pr.yml index 834194dee..2b4a86ff2 100644 --- a/.github/workflows/label-pr.yml +++ b/.github/workflows/label-pr.yml @@ -4,6 +4,9 @@ on: pull_request_target: types: [opened, reopened, synchronize] +permissions: + contents: read + jobs: label-pr: permissions: diff --git a/.github/workflows/release-branch.yml b/.github/workflows/release-branch.yml index 15630f5a1..babdb05a2 100644 --- a/.github/workflows/release-branch.yml +++ b/.github/workflows/release-branch.yml @@ -86,7 +86,7 @@ jobs: runs-on: ubuntu-22.04 needs: [vars] permissions: - contents: write + contents: write # Needed to create draft release outputs: release_id: ${{ steps.vars.outputs.RELEASE_ID }} steps: @@ -210,7 +210,7 @@ jobs: needs: [vars,release-draft,tag-release] permissions: id-token: write - contents: write + contents: write # Needed to update a github release steps: - name: Checkout Repository uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 From c89d9f10d9b9d39641ca7817d44175eb4fb9aa91 Mon Sep 17 00:00:00 2001 From: aphralG <108004222+aphralG@users.noreply.github.com> Date: Fri, 6 Jun 2025 10:19:07 +0100 Subject: [PATCH 08/28] Fix race conditions (#1094) --- internal/collector/otel_collector_plugin.go | 15 ++++++++++++--- internal/file/file_manager_service.go | 3 ++- .../watcher/instance/instance_watcher_service.go | 2 ++ 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/internal/collector/otel_collector_plugin.go b/internal/collector/otel_collector_plugin.go index 6b28b4a20..d980375aa 100644 --- a/internal/collector/otel_collector_plugin.go +++ b/internal/collector/otel_collector_plugin.go @@ -161,10 +161,16 @@ func (oc *Collector) processReceivers(ctx context.Context, receivers []config.Ot } } +// nolint: revive, cyclop func (oc *Collector) bootup(ctx context.Context) error { errChan := make(chan error) go func() { + if oc.service == nil { + errChan <- fmt.Errorf("unable to start OTel collector: service is nil") + return + } + appErr := oc.service.Run(ctx) if appErr != nil { errChan <- appErr @@ -177,8 +183,11 @@ func (oc *Collector) bootup(ctx context.Context) error { case err := <-errChan: return err default: - state := oc.service.GetState() + if oc.service == nil { + return fmt.Errorf("unable to start otel collector: service is nil") + } + state := oc.service.GetState() switch state { case otelcol.StateStarting: // NoOp @@ -212,9 +221,9 @@ func (oc *Collector) Close(ctx context.Context) error { oc.service.Shutdown() oc.cancel() - settings := oc.config.Client.Backoff + settings := *oc.config.Client.Backoff settings.MaxElapsedTime = maxTimeToWaitForShutdown - err := backoff.WaitUntil(ctx, settings, func() error { + err := backoff.WaitUntil(ctx, &settings, func() error { if oc.service.GetState() == otelcol.StateClosed { return nil } diff --git a/internal/file/file_manager_service.go b/internal/file/file_manager_service.go index ceb86e681..e65ba6d2b 100644 --- a/internal/file/file_manager_service.go +++ b/internal/file/file_manager_service.go @@ -178,9 +178,10 @@ func (fms *FileManagerService) UpdateOverview( return response, nil } + backoffSettings := fms.agentConfig.Client.Backoff response, err := backoff.RetryWithData( sendUpdateOverview, - backoffHelpers.Context(backOffCtx, fms.agentConfig.Client.Backoff), + backoffHelpers.Context(backOffCtx, backoffSettings), ) if err != nil { return err diff --git a/internal/watcher/instance/instance_watcher_service.go b/internal/watcher/instance/instance_watcher_service.go index 291902860..b86edfe52 100644 --- a/internal/watcher/instance/instance_watcher_service.go +++ b/internal/watcher/instance/instance_watcher_service.go @@ -223,7 +223,9 @@ func (iw *InstanceWatcherService) checkForUpdates( iw.sendNginxConfigContextUpdate(newCtx, nginxConfigContext) iw.nginxConfigCache[nginxConfigContext.InstanceID] = nginxConfigContext proto.UpdateNginxInstanceRuntime(newInstance, nginxConfigContext) + iw.cacheMutex.Lock() iw.instanceCache[newInstance.GetInstanceMeta().GetInstanceId()] = newInstance + iw.cacheMutex.Unlock() } } } From b9b8428c787ab54cf10fba6c52497dd3aee70e33 Mon Sep 17 00:00:00 2001 From: Valyria McFarland <36799275+valyria257@users.noreply.github.com> Date: Fri, 6 Jun 2025 02:40:28 -0700 Subject: [PATCH 09/28] Allow only CA cert to be set in command server TLS settings (#1116) * test: use smaller selfsigned certs for testing When generating a self-signed cert for _unit-tests_ it doesn't need to have high security. Using a smaller length makes the tests run faster. The difference is ~0.1s vs ~2.0s to run one test that generates a cert. * test: fix grpc mTLS dialoptions test This was incorrectly passing in the cert and key backwards which resulted in not actually getting mTLS credentials, but instead insecure credentials. When insecure credentials are used, skipToken means we don't add a addPerRPCCredentials. When it is a secure TLS credential then addPerRPCCredentials increases the dialoption count by one. * fix: Use specified CA cert for grpc This had been skipping out of the function early if a client key wasn't specified. I don't believe that's correct. If I[User] have specified specified a CA cert because the MPI server I'm trying to talk to is signed by a non-standard CA (e.g. N1 devenv) then it should be respected regardless of whether I've configured mTLS. Silently skipping the CA is really confusing and leads to > Failed to create connection" error="rpc error: code = Unavailable desc = connection error: desc = \"transport: authentication handshake failed: tls: failed to verify certificate: x509: certificate signed by unknown authority\" I've split out the getTLSConfigForCredentials to make it easier to test this translation. Once it is wrapped into a TransportCredential or a DialOption it's opaque and hard to verify. * fix: invalid TLS CA cert should error immediately Previously if a consumer specified the CA cert to verify the command connection but that CA wasn't valid then system would log at Debug (default hidden) and proceed anyways. I don't believe this is good behavior. If the consumer is directly specifying a CA cert then that is the CA that should be used, not silently ignored. This patch returns the error up, which is now caught and swallowed at a higher level, but at least it is more visible: > time=2025-05-21T15:41:33.547Z level=ERROR msg="Unable to add transport credentials to gRPC dial options, adding default transport credentials" error="invalid CA cert while building transport credentials: read CA file (/etc/nginx-agent/bad.crt): open /etc/nginx-agent/bad.crt: no such file or directory" --------- Co-authored-by: Nathan Bird --- internal/grpc/grpc.go | 30 ++++---- internal/grpc/grpc_test.go | 149 ++++++++++++++++++++++++++++++++----- test/helpers/cert_utils.go | 12 +-- 3 files changed, 148 insertions(+), 43 deletions(-) diff --git a/internal/grpc/grpc.go b/internal/grpc/grpc.go index 34f6f3503..a93c6f9c2 100644 --- a/internal/grpc/grpc.go +++ b/internal/grpc/grpc.go @@ -363,30 +363,32 @@ func getTransportCredentials(agentConfig *config.Config) (credentials.TransportC if agentConfig.Command.TLS == nil { return defaultCredentials, nil } + tlsConfig, err := getTLSConfigForCredentials(agentConfig.Command.TLS) + if err != nil { + return nil, err + } + + return credentials.NewTLS(tlsConfig), nil +} - if agentConfig.Command.TLS.SkipVerify { +func getTLSConfigForCredentials(c *config.TLSConfig) (*tls.Config, error) { + if c.SkipVerify { slog.Warn("Verification of the server's certificate chain and host name is disabled") } tlsConfig := &tls.Config{ MinVersion: tls.VersionTLS12, - ServerName: agentConfig.Command.TLS.ServerName, - InsecureSkipVerify: agentConfig.Command.TLS.SkipVerify, + ServerName: c.ServerName, + InsecureSkipVerify: c.SkipVerify, } - if agentConfig.Command.TLS.Key == "" { - return credentials.NewTLS(tlsConfig), nil + if err := appendRootCAs(tlsConfig, c.Ca); err != nil { + return nil, fmt.Errorf("invalid CA cert while building transport credentials: %w", err) } - err := appendCertKeyPair(tlsConfig, agentConfig.Command.TLS.Cert, agentConfig.Command.TLS.Key) - if err != nil { - return nil, fmt.Errorf("append cert and key pair failed: %w", err) + if err := appendCertKeyPair(tlsConfig, c.Cert, c.Key); err != nil { + return nil, fmt.Errorf("invalid client cert while building transport credentials: %w", err) } - err = appendRootCAs(tlsConfig, agentConfig.Command.TLS.Ca) - if err != nil { - slog.Debug("Unable to append root CA", "error", err) - } - - return credentials.NewTLS(tlsConfig), nil + return tlsConfig, nil } diff --git a/internal/grpc/grpc_test.go b/internal/grpc/grpc_test.go index 4aea9ed6b..87828ceb3 100644 --- a/internal/grpc/grpc_test.go +++ b/internal/grpc/grpc_test.go @@ -7,19 +7,20 @@ package grpc import ( "context" + "crypto/tls" + "crypto/x509" "fmt" "testing" - "google.golang.org/grpc/credentials" - "github.com/cenkalti/backoff/v4" - "github.com/nginx/agent/v3/test/helpers" - "github.com/nginx/agent/v3/test/protos" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" + "github.com/nginx/agent/v3/test/helpers" + "github.com/nginx/agent/v3/test/protos" + "github.com/nginx/agent/v3/internal/config" "github.com/nginx/agent/v3/test/types" @@ -103,7 +104,7 @@ func Test_GetDialOptions(t *testing.T) { { name: "Test 2: DialOptions mTLS", agentConfig: types.AgentConfig(), - expected: 7, + expected: 8, createCerts: true, }, { @@ -171,8 +172,8 @@ func Test_GetDialOptions(t *testing.T) { key, cert := helpers.GenerateSelfSignedCert(t) _, ca := helpers.GenerateSelfSignedCert(t) - keyContents := helpers.Cert{Name: keyFileName, Type: certificateType, Contents: key} - certContents := helpers.Cert{Name: certFileName, Type: privateKeyType, Contents: cert} + keyContents := helpers.Cert{Name: keyFileName, Type: privateKeyType, Contents: key} + certContents := helpers.Cert{Name: certFileName, Type: certificateType, Contents: cert} caContents := helpers.Cert{Name: caFileName, Type: certificateType, Contents: ca} helpers.WriteCertFiles(t, tmpDir, keyContents) @@ -356,28 +357,136 @@ func Test_ValidateGrpcError(t *testing.T) { } func Test_getTransportCredentials(t *testing.T) { - tests := []struct { - want credentials.TransportCredentials - conf *config.Config - wantErr assert.ErrorAssertionFunc - name string + tests := map[string]struct { + conf *config.Config + wantSecurityProfile string + wantServerName string + wantErr bool }{ - { - name: "No TLS config returns default credentials", + "Test 1: No TLS config returns default credentials": { conf: &config.Config{ Command: &config.Command{}, }, - want: defaultCredentials, - wantErr: assert.NoError, + wantErr: false, + wantSecurityProfile: "insecure", + }, + "Test 2: With tls config returns secure credentials": { + conf: &config.Config{ + Command: &config.Command{ + TLS: &config.TLSConfig{ + ServerName: "foobar", + SkipVerify: true, + }, + }, + }, + wantErr: false, + wantSecurityProfile: "tls", + }, + "Test 3: With invalid tls config should error": { + conf: types.AgentConfig(), // references non-existent certs + wantErr: true, }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + for name, tt := range tests { + t.Run(name, func(t *testing.T) { got, err := getTransportCredentials(tt.conf) - if !tt.wantErr(t, err, fmt.Sprintf("getTransportCredentials(%v)", tt.conf)) { + if tt.wantErr { + require.Error(t, err, "getTransportCredentials(%v)", tt.conf) + return } - assert.Equalf(t, tt.want, got, "getTransportCredentials(%v)", tt.conf) + require.NoError(t, err, "getTransportCredentials(%v)", tt.conf) + require.Equal(t, tt.wantSecurityProfile, got.Info().SecurityProtocol, "incorrect SecurityProtocol") + }) + } +} + +func Test_getTLSConfig(t *testing.T) { + tmpDir := t.TempDir() + // not mTLS scripts + key, cert := helpers.GenerateSelfSignedCert(t) + _, ca := helpers.GenerateSelfSignedCert(t) + + keyContents := helpers.Cert{Name: keyFileName, Type: privateKeyType, Contents: key} + certContents := helpers.Cert{Name: certFileName, Type: certificateType, Contents: cert} + caContents := helpers.Cert{Name: caFileName, Type: certificateType, Contents: ca} + + keyPath := helpers.WriteCertFiles(t, tmpDir, keyContents) + certPath := helpers.WriteCertFiles(t, tmpDir, certContents) + caPath := helpers.WriteCertFiles(t, tmpDir, caContents) + + tests := map[string]struct { + conf *config.TLSConfig + verify func(require.TestingT, *tls.Config) + wantErr bool + }{ + "Test 1: all config should be translated": { + conf: &config.TLSConfig{ + Cert: certPath, + Key: keyPath, + Ca: caPath, + ServerName: "foobar", + SkipVerify: true, + }, + wantErr: false, + verify: func(t require.TestingT, c *tls.Config) { + require.NotEmpty(t, c.Certificates) + require.Equal(t, "foobar", c.ServerName, "wrong servername") + require.True(t, c.InsecureSkipVerify, "InsecureSkipVerify not set") + }, + }, + "Test 2: CA only config should use CA": { + conf: &config.TLSConfig{ + Ca: caPath, + }, + wantErr: false, + verify: func(t require.TestingT, c *tls.Config) { + require.NotNil(t, c.RootCAs, "RootCAs should be initialized") + require.False(t, x509.NewCertPool().Equal(c.RootCAs), + "CertPool shouldn't be empty, valid CA cert was specified") + require.False(t, c.InsecureSkipVerify, "InsecureSkipVerify should not be set") + }, + }, + "Test 3: incorrect CA should not error": { + conf: &config.TLSConfig{ + Ca: "customca.pem", + }, + wantErr: true, + }, + "Test 4: incorrect key path should error": { + conf: &config.TLSConfig{ + Ca: caPath, + Cert: certPath, + Key: "badkey", + }, + wantErr: true, + }, + "Test 5: incorrect cert path should error": { + conf: &config.TLSConfig{ + Ca: caPath, + Cert: "badcert", + Key: keyPath, + }, + wantErr: true, + }, + "Test 6: incomplete cert info should error": { + conf: &config.TLSConfig{ + Key: keyPath, + }, + wantErr: true, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got, err := getTLSConfigForCredentials(tt.conf) + if tt.wantErr { + require.Error(t, err, "getTLSConfigForCredentials(%v)", tt.conf) + return + } + require.NoError(t, err, "getTLSConfigForCredentials(%v)", tt.conf) + if tt.verify != nil { + tt.verify(t, got) + } }) } } diff --git a/test/helpers/cert_utils.go b/test/helpers/cert_utils.go index 04cacfa77..c292e97a3 100644 --- a/test/helpers/cert_utils.go +++ b/test/helpers/cert_utils.go @@ -11,10 +11,9 @@ import ( "crypto/x509" "crypto/x509/pkix" "encoding/pem" - "fmt" "math/big" "os" - "strings" + "path" "testing" "time" @@ -31,7 +30,7 @@ const ( permission = 0o600 serialNumber = 123123 years, months, days = 5, 0, 0 - bits = 4096 + bits = 1024 ) func GenerateSelfSignedCert(t testing.TB) (keyBytes, certBytes []byte) { @@ -73,12 +72,7 @@ func WriteCertFiles(t *testing.T, location string, cert Cert) string { Bytes: cert.Contents, }) - var certFile string - if strings.HasSuffix(location, string(os.PathSeparator)) { - certFile = fmt.Sprintf("%s%s", location, cert.Name) - } else { - certFile = fmt.Sprintf("%s%s%s", location, string(os.PathSeparator), cert.Name) - } + certFile := path.Join(location, cert.Name) err := os.WriteFile(certFile, pemContents, permission) require.NoError(t, err) From dce573f04d0ccf757e3c27247e7aa3b4a554eac1 Mon Sep 17 00:00:00 2001 From: Nutsa Bidzishvili Date: Fri, 6 Jun 2025 16:28:56 +0100 Subject: [PATCH 10/28] Update mock collector grafana dashboards (#1110) --- .../internal/scraper/cpuscraper/scraper.go | 1 + .../provisioning/dashboards/host-metrics.json | 2840 +++++++-- .../dashboards/nginx-dashboard.json | 281 +- .../dashboards/nginx-plus-dashboard.json | 5361 ++++++++++++++++- 4 files changed, 7666 insertions(+), 817 deletions(-) diff --git a/internal/collector/containermetricsreceiver/internal/scraper/cpuscraper/scraper.go b/internal/collector/containermetricsreceiver/internal/scraper/cpuscraper/scraper.go index 0e92bf463..47b91c337 100644 --- a/internal/collector/containermetricsreceiver/internal/scraper/cpuscraper/scraper.go +++ b/internal/collector/containermetricsreceiver/internal/scraper/cpuscraper/scraper.go @@ -69,6 +69,7 @@ func (s *CPUScraper) Scrape(context.Context) (pmetric.Metrics, error) { s.settings.Logger.Debug("Collected container CPU metrics", zap.Any("cpu", stats)) + s.mb.RecordSystemCPULogicalCountDataPoint(now, int64(stats.NumberOfLogicalCPUs)) s.mb.RecordSystemCPUUtilizationDataPoint(now, stats.User, metadata.AttributeStateUser) s.mb.RecordSystemCPUUtilizationDataPoint(now, stats.System, metadata.AttributeStateSystem) diff --git a/test/mock/collector/grafana/provisioning/dashboards/host-metrics.json b/test/mock/collector/grafana/provisioning/dashboards/host-metrics.json index 99bec4157..caa18288b 100644 --- a/test/mock/collector/grafana/provisioning/dashboards/host-metrics.json +++ b/test/mock/collector/grafana/provisioning/dashboards/host-metrics.json @@ -18,50 +18,34 @@ "editable": true, "fiscalYearStartMonth": 0, "graphTooltip": 0, + "id": 1, "links": [], "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 10, + "panels": [], + "title": "CPU", + "type": "row" + }, { "datasource": { "type": "prometheus", "uid": "otel-prometheus-scraper" }, + "description": "Number of available logical CPUs.", "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, + "fieldMinMax": false, "mappings": [], "thresholds": { "mode": "absolute", @@ -69,10 +53,6 @@ { "color": "green", "value": null - }, - { - "color": "red", - "value": 80 } ] } @@ -80,24 +60,37 @@ "overrides": [] }, "gridPos": { - "h": 8, - "w": 12, + "h": 5, + "w": 24, "x": 0, - "y": 0 + "y": 1 }, - "id": 2, + "id": 7, "options": { + "displayMode": "lcd", "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, - "tooltip": { - "mode": "single", - "sort": "none" - } + "maxVizHeight": 103, + "minVizHeight": 0, + "minVizWidth": 8, + "namePlacement": "hidden", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true, + "sizing": "auto", + "valueMode": "color" }, + "pluginVersion": "11.5.2", "targets": [ { "datasource": { @@ -106,24 +99,25 @@ }, "disableTextWrap": false, "editorMode": "builder", - "expr": "system_cpu_time", + "expr": "system_cpu_logical_count{resource_id=~\"$resourceID\"}", "fullMetaSearch": false, - "includeNullMetadata": false, + "includeNullMetadata": true, "instant": false, - "legendFormat": "{{nginx_conn_outcome}}", + "legendFormat": "Resource ID {{resource_id}}", "range": true, "refId": "A", "useBackend": false } ], - "title": "System CPU Time", - "type": "timeseries" + "title": "System CPU Logical Count", + "type": "bargauge" }, { "datasource": { "type": "prometheus", "uid": "otel-prometheus-scraper" }, + "description": "Difference in system.cpu.time since the last measurement per logical CPU, divided by the elapsed time (value in interval [0,1]).", "fieldConfig": { "defaults": { "color": { @@ -136,9 +130,10 @@ "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, + "barWidthFactor": 0.6, "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", + "fillOpacity": 20, + "gradientMode": "opacity", "hideFrom": { "legend": false, "tooltip": false, @@ -168,10 +163,6 @@ { "color": "green", "value": null - }, - { - "color": "red", - "value": 80 } ] } @@ -179,12 +170,12 @@ "overrides": [] }, "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 0 + "h": 12, + "w": 24, + "x": 0, + "y": 6 }, - "id": 3, + "id": 6, "options": { "legend": { "calcs": [], @@ -193,10 +184,14 @@ "showLegend": true }, "tooltip": { + "hideZeros": false, "mode": "single", "sort": "none" } }, + "pluginVersion": "11.5.2", + "repeat": "resourceID", + "repeatDirection": "h", "targets": [ { "datasource": { @@ -205,520 +200,2419 @@ }, "disableTextWrap": false, "editorMode": "builder", - "expr": "system_filesystem_usage", + "expr": "system_cpu_utilization{resource_id=~\"$resourceID\"}", "fullMetaSearch": false, - "includeNullMetadata": true, + "includeNullMetadata": false, "instant": false, - "legendFormat": "__auto", + "legendFormat": "State: {{state}} | {{resource_id}}", "range": true, "refId": "A", "useBackend": false } ], - "title": "System Filesystem Usage", + "title": "System CPU Utilization", "type": "timeseries" }, { - "datasource": { - "type": "prometheus", - "uid": "otel-prometheus-scraper" + "collapsed": true, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 18 }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" + "id": 11, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" + "description": "The number of packets transferred.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 19 + }, + "id": 22, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right", + "showLegend": true }, - "thresholdsStyle": { - "mode": "off" + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" } }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 8 - }, - "id": 4, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "system_network_packets{device=~\"$network_device\", resource_id=~\"$resourceID\", direction=\"receive\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "Device: {{device}} | {{resource_id}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Network Packets - Receive", + "type": "timeseries" }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ { "datasource": { "type": "prometheus", "uid": "otel-prometheus-scraper" }, - "disableTextWrap": false, - "editorMode": "builder", - "expr": "system_memory_usage", - "fullMetaSearch": false, - "includeNullMetadata": true, - "instant": false, - "legendFormat": "__auto", - "range": true, - "refId": "A", - "useBackend": false - } - ], - "title": "System Memory Usage", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "otel-prometheus-scraper" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" + "description": "The number of packets transferred.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 19 + }, + "id": 23, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right", + "showLegend": true }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "system_network_packets{device=~\"$network_device\", resource_id=~\"$resourceID\", direction=\"transmit\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "Device: {{device}} | {{resource_id}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Network Packets - Transmit", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The number of receive errors encountered.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 114 + }, + "id": 20, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right", + "showLegend": true }, - "thresholdsStyle": { - "mode": "off" + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" } }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "system_network_errors{device=~\"$network_device\", resource_id=~\"$resourceID\", direction=\"receive\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "Device: {{device}} | {{resource_id}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Network Errors - Receive", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The number of transmit errors encountered.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" }, - { - "color": "red", - "value": 80 + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] } - ] - } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 114 + }, + "id": 21, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "system_network_errors{device=~\"$network_device\", resource_id=~\"$resourceID\", direction=\"transmit\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "Device: {{device}} | {{resource_id}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Network Errors - Transmit", + "type": "timeseries" }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 8 - }, - "id": 5, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The number of connections.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + } + }, + "mappings": [] + }, + "overrides": [] + }, + "gridPos": { + "h": 16, + "w": 24, + "x": 0, + "y": 122 + }, + "id": 16, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "repeat": "resourceID", + "repeatDirection": "h", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "system_network_connections{state=~\"$network_state\", resource_id=~\"$resourceID\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{state}} | {{protocol}} | {{resource_id}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Network Connections", + "type": "piechart" }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ { "datasource": { "type": "prometheus", "uid": "otel-prometheus-scraper" }, - "disableTextWrap": false, - "editorMode": "builder", - "expr": "system_network_io", - "fullMetaSearch": false, - "includeNullMetadata": false, - "instant": false, - "legendFormat": "__auto", - "range": true, - "refId": "A", - "useBackend": false - } - ], - "title": "System Network IO", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "otel-prometheus-scraper" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" + "description": "The number of received packets dropped.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false + "gridPos": { + "h": 10, + "w": 12, + "x": 0, + "y": 138 + }, + "id": 19, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right", + "showLegend": true }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "system_network_dropped{device=~\"$network_device\", resource_id=~\"$resourceID\", direction=\"receive\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "Device: {{device}} | {{resource_id}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Network Dropped - Receive", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The number of transmitted packets dropped.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 12, + "x": 12, + "y": 138 + }, + "id": 17, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right", + "showLegend": true }, - "thresholdsStyle": { - "mode": "off" + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" } }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "system_network_dropped{device=~\"$network_device\", resource_id=~\"$resourceID\", direction=\"transmit\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "Device: {{device}} | {{resource_id}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Network Dropped - Transmit", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The number of bytes received.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" }, - { - "color": "red", - "value": 80 + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 8 - }, - "id": 6, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 148 + }, + "id": 5, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "system_network_io{device=~\"$network_device\", resource_id=~\"$resourceID\", direction=\"receive\"}", + "fullMetaSearch": false, + "includeNullMetadata": false, + "instant": false, + "legendFormat": "Device: {{device}} | {{resource_id}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "System Network IO - Receive", + "type": "timeseries" }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ { "datasource": { "type": "prometheus", "uid": "otel-prometheus-scraper" }, - "disableTextWrap": false, - "editorMode": "builder", - "expr": "system_cpu_utilization", - "fullMetaSearch": false, - "includeNullMetadata": false, - "instant": false, - "legendFormat": "__auto", - "range": true, - "refId": "A", - "useBackend": false + "description": "The number of bytes transmitted.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 157 + }, + "id": 9, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "system_network_io{device=~\"$network_device\", resource_id=~\"$resourceID\", direction=\"transmit\"}", + "fullMetaSearch": false, + "includeNullMetadata": false, + "instant": false, + "legendFormat": "Device: {{device}} | {{resource_id}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "System Network IO - Transmit", + "type": "timeseries" } ], - "title": "System CPU Utilization", - "type": "timeseries" + "title": "Network", + "type": "row" }, { - "datasource": { - "type": "prometheus", - "uid": "otel-prometheus-scraper" + "collapsed": true, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 19 }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" + "id": 12, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false + "description": "Free filesystem bytes.", + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "green", + "mode": "shades" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 20 + }, + "id": 3, + "options": { + "displayMode": "basic", + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" + "maxVizHeight": 300, + "minVizHeight": 16, + "minVizWidth": 8, + "namePlacement": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false }, - "thresholdsStyle": { - "mode": "off" + "showUnfilled": true, + "sizing": "auto", + "text": {}, + "valueMode": "color" + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "system_filesystem_usage{mountpoint=~\"$mountpoint\", resource_id=~\"$resourceID\", state=\"free\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "{{type}} | {{mode}} | {{device}} | {{mountpoint}} | {{resource_id}}", + "range": true, + "refId": "Free", + "useBackend": false } + ], + "title": "System Filesystem Usage - Free", + "type": "bargauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null + "description": "Used filesystem bytes.", + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "orange", + "mode": "shades" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ { - "color": "red", - "value": 80 + "__systemRef": "hideSeriesFrom", + "matcher": { + "id": "byNames", + "options": { + "mode": "exclude", + "names": [ + "ext4 | rw | /dev/vda1 | /etc/hosts | 8321c54e-0c9d-362c-8e5e-951e1ef8dfd1" + ], + "prefix": "All except:", + "readOnly": true + } + }, + "properties": [] } ] - } + }, + "gridPos": { + "h": 10, + "w": 12, + "x": 0, + "y": 81 + }, + "id": 27, + "options": { + "displayMode": "basic", + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "maxVizHeight": 300, + "minVizHeight": 16, + "minVizWidth": 8, + "namePlacement": "top", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true, + "sizing": "auto", + "text": {}, + "valueMode": "color" + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "system_filesystem_usage{mountpoint=~\"$mountpoint\", resource_id=~\"$resourceID\", state=\"used\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "{{type}} | {{mode}} | {{device}} | {{mountpoint}} | {{resource_id}}", + "range": true, + "refId": "Used", + "useBackend": false + } + ], + "title": "System Filesystem Usage - Used", + "type": "bargauge" }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 8 - }, - "id": 7, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "Reserved Filesystem bytes.", + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "blue", + "mode": "shades" + }, + "mappings": [], + "min": 2, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 12, + "x": 12, + "y": 81 + }, + "id": 28, + "options": { + "displayMode": "basic", + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "maxVizHeight": 300, + "minVizHeight": 16, + "minVizWidth": 8, + "namePlacement": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true, + "sizing": "auto", + "text": {}, + "valueMode": "color" + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "system_filesystem_usage{mountpoint=~\"$mountpoint\", resource_id=~\"$resourceID\", state=\"reserved\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "{{type}} | {{mode}} | {{device}} | {{mountpoint}} | {{resource_id}}", + "range": true, + "refId": "Reserved", + "useBackend": false + } + ], + "title": "System Filesystem Usage - Reserved", + "type": "bargauge" }, - "tooltip": { - "mode": "single", - "sort": "none" + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "Free fileSystem inodes.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 91 + }, + "id": 29, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "system_filesystem_inodes_usage{mountpoint=~\"$mountpoint\", resource_id=~\"$resourceID\", state=\"free\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{mountpoint}} | {{type}} | {{device}} | {{mode}} | {{resource_id}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "System FileSystem Inodes Usage - Free", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "FileSystem inodes used.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 99 + }, + "id": 14, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "system_filesystem_inodes_usage{mountpoint=~\"$mountpoint\", resource_id=~\"$resourceID\", state=\"used\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{mountpoint}} | {{type}} | {{device}} | {{mode}} | {{resource_id}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "System FileSystem Inodes Usage - Used", + "type": "timeseries" } + ], + "title": "Filesystem", + "type": "row" + }, + { + "collapsed": true, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 20 }, - "targets": [ + "id": 13, + "panels": [ { "datasource": { "type": "prometheus", "uid": "otel-prometheus-scraper" }, - "disableTextWrap": false, - "editorMode": "builder", - "expr": "system_cpu_logical_count", - "fullMetaSearch": false, - "includeNullMetadata": true, - "instant": false, - "legendFormat": "__auto", - "range": true, - "refId": "A", - "useBackend": false + "description": "Bytes of memory in use.", + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "blue", + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 50, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 21 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "system_memory_usage{state=~\"$memory_state\", resource_id=~\"$resourceID\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "State: {{state}} | {{resource_id}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "System Memory Usage", + "type": "timeseries" } ], - "title": "System CPU Logical Count", - "type": "timeseries" + "title": "Memory", + "type": "row" }, { - "datasource": { - "type": "prometheus", - "uid": "otel-prometheus-scraper" + "collapsed": true, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 21 }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" + "id": 15, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false + "description": "Disk bytes transferred.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" + "overrides": [] + }, + "gridPos": { + "h": 12, + "w": 12, + "x": 0, + "y": 22 + }, + "id": 24, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right", + "showLegend": true }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "system_disk_io{resource_id=~\"$resourceID\", device=~\"$disk_device\", direction=\"read\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{device}} | {{direction}} | {{resource_id}}", + "range": true, + "refId": "A", + "useBackend": false }, - "thresholdsStyle": { - "mode": "off" + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "system_disk_io{device=~\"$disk_device\", direction=\"write\", resource_id=~\"$resourceID\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "{{device}} | {{direction}} | {{resource_id}}", + "range": true, + "refId": "B", + "useBackend": false } + ], + "title": "System Disk IO", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null + "description": "Time disk spent activated. On Windows, this is calculated as the inverse of disk idle time.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" }, - { - "color": "red", - "value": 80 + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] } - ] - } + }, + "overrides": [] + }, + "gridPos": { + "h": 12, + "w": 12, + "x": 12, + "y": 22 + }, + "id": 25, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "system_disk_io{device=~\"$disk_device\", resource_id=~\"$resourceID\", direction=\"read\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{device}} | {{direction}} | {{resource_id}}", + "range": true, + "refId": "A", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "system_disk_io{device=~\"$disk_device\", direction=\"write\", resource_id=~\"$resourceID\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "{{device}} | {{direction}} | {{resource_id}}", + "range": true, + "refId": "B", + "useBackend": false + } + ], + "title": "System Disk IO Time", + "type": "timeseries" }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 8 - }, - "id": 8, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "Time disk spent activated multiplied by the queue length.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 34 + }, + "id": 33, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "system_disk_weighted_io_time{resource_id=~\"$resourceID\", device=~\"$disk_device\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{device}} | {{resource_id}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "System Disk Weighted IO Time", + "type": "timeseries" }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ { "datasource": { "type": "prometheus", "uid": "otel-prometheus-scraper" }, - "disableTextWrap": false, - "editorMode": "builder", - "expr": "system_memory_limit", - "fullMetaSearch": false, - "includeNullMetadata": true, - "instant": false, - "legendFormat": "__auto", - "range": true, - "refId": "A", - "useBackend": false + "description": "Disk operations count.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 12, + "x": 0, + "y": 46 + }, + "id": 31, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "system_disk_operations{resource_id=~\"$resourceID\", device=~\"$disk_device\", direction=\"read\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{device}} | {{direction}} | {{resource_id}}", + "range": true, + "refId": "A", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "system_disk_operations{resource_id=~\"$resourceID\", device=~\"$disk_device\", direction=\"write\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "{{device}} | {{direction}} | {{resource_id}}", + "range": true, + "refId": "B", + "useBackend": false + } + ], + "title": "System Disk Operations", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "Time spent in disk operations.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 12, + "x": 12, + "y": 46 + }, + "id": 30, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "system_disk_operation_time{resource_id=~\"$resourceID\", device=~\"$disk_device\", direction=\"read\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{device}} | {{direction}} | {{resource_id}}", + "range": true, + "refId": "A", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "system_disk_operation_time{resource_id=~\"$resourceID\", device=~\"$disk_device\", direction=\"write\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "{{device}} | {{direction}} | {{resource_id}}", + "range": true, + "refId": "B", + "useBackend": false + } + ], + "title": "System Disk Operation Time", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The queue size of pending I/O operations.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 56 + }, + "id": 32, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "system_disk_pending_operations{device=~\"$disk_device\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{device}} | {{resource_id}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "System Disk Pending Operations", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The number of disk reads/writes merged into single physical disk access operations.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 56 + }, + "id": 26, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "system_disk_merged{device=~\"$disk_device\", resource_id=~\"$resourceID\", direction=\"read\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{device}} | {{direction}} | {{resource_id}}", + "range": true, + "refId": "A", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "system_disk_merged{direction=\"write\", device=~\"$disk_device\", resource_id=~\"$resourceID\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "{{device}} | {{direction}} | {{resource_id}}", + "range": true, + "refId": "B", + "useBackend": false + } + ], + "title": "System Disk Merged", + "type": "timeseries" } ], - "title": "System Memory Limit", - "type": "timeseries" + "title": "Disk", + "type": "row" } ], + "preload": false, "refresh": "", - "schemaVersion": 39, + "schemaVersion": 40, "tags": [], "templating": { - "list": [] + "list": [ + { + "allowCustomValue": false, + "current": { + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "definition": "label_values(resource_id)", + "description": "Filter by Resource ID.", + "includeAll": true, + "label": "Resource ID", + "multi": true, + "name": "resourceID", + "options": [], + "query": { + "qryType": 1, + "query": "label_values(resource_id)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "type": "query" + }, + { + "allowCustomValue": false, + "current": { + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "definition": "label_values(system_network_packets,device)", + "description": "Filter by Network Device.", + "includeAll": true, + "label": "Network Device", + "multi": true, + "name": "network_device", + "options": [], + "query": { + "qryType": 1, + "query": "label_values(system_network_packets,device)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "type": "query" + }, + { + "allowCustomValue": false, + "current": { + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "definition": "label_values(system_network_connections,state)", + "description": "Filter by Network State.", + "includeAll": true, + "label": "Network State", + "multi": true, + "name": "network_state", + "options": [], + "query": { + "qryType": 1, + "query": "label_values(system_network_connections,state)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "type": "query" + }, + { + "allowCustomValue": false, + "current": { + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "definition": "label_values(mountpoint)", + "description": "Filter by Mountpoint.", + "includeAll": true, + "label": "Filesystem Mountpoint", + "multi": true, + "name": "mountpoint", + "options": [], + "query": { + "qryType": 1, + "query": "label_values(mountpoint)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "type": "query" + }, + { + "allowCustomValue": false, + "current": { + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "definition": "label_values(system_memory_usage,state)", + "description": "Filter by Memory State.", + "includeAll": true, + "label": "Memory State", + "multi": true, + "name": "memory_state", + "options": [], + "query": { + "qryType": 1, + "query": "label_values(system_memory_usage,state)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "type": "query" + }, + { + "allowCustomValue": false, + "current": { + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "definition": "label_values(system_disk_io,device)", + "description": "Filter by Disk Device.", + "includeAll": true, + "label": "Disk Device", + "multi": true, + "name": "disk_device", + "options": [], + "query": { + "qryType": 1, + "query": "label_values(system_disk_io,device)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "type": "query" + } + ] }, "time": { "from": "now-5m", @@ -728,6 +2622,6 @@ "timezone": "browser", "title": "Host Metrics", "uid": "jbsjbjfjnfjdnf", - "version": 1, + "version": 17, "weekStart": "" } diff --git a/test/mock/collector/grafana/provisioning/dashboards/nginx-dashboard.json b/test/mock/collector/grafana/provisioning/dashboards/nginx-dashboard.json index 7d03615f1..514a7c546 100644 --- a/test/mock/collector/grafana/provisioning/dashboards/nginx-dashboard.json +++ b/test/mock/collector/grafana/provisioning/dashboards/nginx-dashboard.json @@ -23,100 +23,91 @@ "panels": [ { "datasource": { + "default": true, "type": "prometheus", "uid": "otel-prometheus-scraper" }, - "description": "The total number of client requests received, since the last collection interval.", + "description": "The total number of HTTP responses, grouped by status code range, since the last collection interval.", "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" } }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green" - }, - { - "color": "red", - "value": 80 + "mappings": [] + }, + "overrides": [ + { + "__systemRef": "hideSeriesFrom", + "matcher": { + "id": "byNames", + "options": { + "mode": "exclude", + "names": [ + "Status: 4xx" + ], + "prefix": "All except:", + "readOnly": true } - ] + }, + "properties": [] } - }, - "overrides": [] + ] }, "gridPos": { - "h": 8, - "w": 12, + "h": 10, + "w": 7, "x": 0, "y": 0 }, - "id": 6, + "id": 5, "options": { "legend": { - "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, "tooltip": { "hideZeros": false, "mode": "single", "sort": "none" } }, - "pluginVersion": "11.6.0", + "pluginVersion": "11.5.2", "targets": [ { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, "disableTextWrap": false, "editorMode": "builder", - "expr": "nginx_http_request_count{instance_type=\"nginx\"}", + "expr": "nginx_http_response_count{instance_type=\"nginx\"}", "fullMetaSearch": false, "includeNullMetadata": true, - "legendFormat": "__auto", + "instant": false, + "legendFormat": "Status: {{nginx_status_range}}", "range": true, "refId": "A", "useBackend": false } ], - "title": "HTTP Request Count", - "type": "timeseries" + "title": "HTTP Response Count", + "type": "piechart" }, { "datasource": { @@ -124,6 +115,7 @@ "type": "prometheus", "uid": "otel-prometheus-scraper" }, + "description": "The total number of client requests received, since NGINX was last started or reloaded.", "fieldConfig": { "defaults": { "color": { @@ -133,13 +125,14 @@ "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", + "axisGridShow": true, "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", + "fillOpacity": 20, + "gradientMode": "opacity", "hideFrom": { "legend": false, "tooltip": false, @@ -167,7 +160,8 @@ "mode": "absolute", "steps": [ { - "color": "green" + "color": "green", + "value": null }, { "color": "red", @@ -179,15 +173,17 @@ "overrides": [] }, "gridPos": { - "h": 8, - "w": 12, - "x": 12, + "h": 18, + "w": 17, + "x": 7, "y": 0 }, - "id": 4, + "id": 1, "options": { "legend": { - "calcs": [], + "calcs": [ + "last" + ], "displayMode": "list", "placement": "bottom", "showLegend": true @@ -199,7 +195,7 @@ "sort": "none" } }, - "pluginVersion": "11.6.0", + "pluginVersion": "11.5.2", "targets": [ { "datasource": { @@ -208,69 +204,37 @@ }, "disableTextWrap": false, "editorMode": "builder", - "expr": "nginx_http_connections{instance_type=\"nginx\"}", + "expr": "nginx_http_requests{instance_type=\"nginx\"}", "fullMetaSearch": false, "includeNullMetadata": true, "instant": false, - "legendFormat": "{{nginx_conn_outcome}}", + "legendFormat": "{{instance_type}} {{instance_id}}", "range": true, "refId": "A", "useBackend": false } ], - "title": "HTTP Connections", + "title": "Total HTTP Requests", "type": "timeseries" }, { "datasource": { - "default": true, "type": "prometheus", "uid": "otel-prometheus-scraper" }, + "description": "The total number of client requests received, since the last collection interval.", "fieldConfig": { "defaults": { "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } + "mode": "thresholds" }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { - "color": "green" + "color": "green", + "value": null }, { "color": "red", @@ -283,46 +247,42 @@ }, "gridPos": { "h": 8, - "w": 12, + "w": 7, "x": 0, - "y": 8 + "y": 10 }, - "id": 3, + "id": 6, "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false }, - "tooltip": { - "hideZeros": false, - "maxHeight": 600, - "mode": "single", - "sort": "none" - } + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" }, - "pluginVersion": "11.6.0", + "pluginVersion": "11.5.2", "targets": [ { - "datasource": { - "type": "prometheus", - "uid": "otel-prometheus-scraper" - }, "disableTextWrap": false, "editorMode": "builder", - "expr": "nginx_http_connection_count{instance_type=\"nginx\"}", + "expr": "nginx_http_request_count{instance_type=\"nginx\"}", "fullMetaSearch": false, "includeNullMetadata": true, - "instant": false, - "legendFormat": "{{nginx_conn_outcome}}", + "legendFormat": "{{instance_type}} {{instance_id}}", "range": true, "refId": "A", "useBackend": false } ], - "title": "HTTP Connections Count", - "type": "timeseries" + "title": "HTTP Request Count", + "type": "gauge" }, { "datasource": { @@ -330,7 +290,7 @@ "type": "prometheus", "uid": "otel-prometheus-scraper" }, - "description": "The total number of HTTP responses, grouped by status code range, since the last collection interval.", + "description": "The current number of connections.", "fieldConfig": { "defaults": { "color": { @@ -345,8 +305,8 @@ "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", + "fillOpacity": 20, + "gradientMode": "opacity", "hideFrom": { "legend": false, "tooltip": false, @@ -374,7 +334,8 @@ "mode": "absolute", "steps": [ { - "color": "green" + "color": "green", + "value": null }, { "color": "red", @@ -383,40 +344,15 @@ ] } }, - "overrides": [ - { - "__systemRef": "hideSeriesFrom", - "matcher": { - "id": "byNames", - "options": { - "mode": "exclude", - "names": [ - "{__name__=\"nginx_http_response_count\", instance=\"otel-collector:9775\", instance_id=\"cd2d5dc0-c528-3f37-b83c-727fd4010777\", instance_type=\"nginx\", job=\"otel-collector\", nginx_status_range=\"2xx\", resource_id=\"145a800d-5f48-38be-b38b-54475b492cab\"}" - ], - "prefix": "All except:", - "readOnly": true - } - }, - "properties": [ - { - "id": "custom.hideFrom", - "value": { - "legend": false, - "tooltip": false, - "viz": true - } - } - ] - } - ] + "overrides": [] }, "gridPos": { "h": 8, "w": 12, - "x": 12, - "y": 8 + "x": 0, + "y": 18 }, - "id": 5, + "id": 3, "options": { "legend": { "calcs": [], @@ -426,11 +362,12 @@ }, "tooltip": { "hideZeros": false, + "maxHeight": 600, "mode": "single", "sort": "none" } }, - "pluginVersion": "11.6.0", + "pluginVersion": "11.5.2", "targets": [ { "datasource": { @@ -439,17 +376,17 @@ }, "disableTextWrap": false, "editorMode": "builder", - "expr": "nginx_http_response_count{instance_type=\"nginx\"}", + "expr": "nginx_http_connection_count{instance_type=\"nginx\"}", "fullMetaSearch": false, "includeNullMetadata": true, "instant": false, - "legendFormat": "__auto", + "legendFormat": "{{nginx_connections_outcome}}", "range": true, "refId": "A", "useBackend": false } ], - "title": "HTTP Response Count", + "title": "HTTP Connections Count", "type": "timeseries" }, { @@ -458,7 +395,7 @@ "type": "prometheus", "uid": "otel-prometheus-scraper" }, - "description": "", + "description": "The total number of connections.", "fieldConfig": { "defaults": { "color": { @@ -473,8 +410,8 @@ "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", + "fillOpacity": 20, + "gradientMode": "opacity", "hideFrom": { "legend": false, "tooltip": false, @@ -502,7 +439,8 @@ "mode": "absolute", "steps": [ { - "color": "green" + "color": "green", + "value": null }, { "color": "red", @@ -516,10 +454,10 @@ "gridPos": { "h": 8, "w": 12, - "x": 0, - "y": 16 + "x": 12, + "y": 18 }, - "id": 1, + "id": 4, "options": { "legend": { "calcs": [], @@ -534,7 +472,7 @@ "sort": "none" } }, - "pluginVersion": "11.6.0", + "pluginVersion": "11.5.2", "targets": [ { "datasource": { @@ -543,23 +481,23 @@ }, "disableTextWrap": false, "editorMode": "builder", - "expr": "nginx_http_requests{instance_type=\"nginx\"}", + "expr": "nginx_http_connections{instance_type=\"nginx\"}", "fullMetaSearch": false, "includeNullMetadata": true, "instant": false, - "legendFormat": "__auto", + "legendFormat": "{{nginx_connections_outcome}}", "range": true, "refId": "A", "useBackend": false } ], - "title": "Total HTTP Requests", + "title": "HTTP Connections", "type": "timeseries" } ], "preload": false, "refresh": "5s", - "schemaVersion": 41, + "schemaVersion": 40, "tags": [], "templating": { "list": [] @@ -572,5 +510,6 @@ "timezone": "browser", "title": "NGINX OSS", "uid": "bdogpq9khs9hcb", - "version": 1 + "version": 2, + "weekStart": "" } diff --git a/test/mock/collector/grafana/provisioning/dashboards/nginx-plus-dashboard.json b/test/mock/collector/grafana/provisioning/dashboards/nginx-plus-dashboard.json index b4e89f468..193ea7468 100644 --- a/test/mock/collector/grafana/provisioning/dashboards/nginx-plus-dashboard.json +++ b/test/mock/collector/grafana/provisioning/dashboards/nginx-plus-dashboard.json @@ -21,12 +21,4396 @@ "id": 3, "links": [], "panels": [ + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 13, + "panels": [], + "title": "HTTP Connections", + "type": "row" + }, + { + "datasource": { + "default": true, + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The total number of NGINX config reloads.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 30, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 0, + "y": 1 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "code", + "expr": "nginx_config_reloads{instance_type=\"nginxplus\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "Reloads", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "NGINX Config Reloads", + "type": "timeseries" + }, + { + "datasource": { + "default": true, + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The current number of connections.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1 + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "ACTIVE" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "palette-classic" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "IDLE" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "continuous-BlPu" + } + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 18, + "x": 6, + "y": 1 + }, + "id": 2, + "maxDataPoints": 10, + "options": { + "colWidth": 0.9, + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_connection_count{instance_type=\"nginxplus\"}", + "format": "time_series", + "fullMetaSearch": false, + "includeNullMetadata": false, + "instant": false, + "legendFormat": "{{nginx_connections_outcome}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "HTTP Connections Count", + "type": "status-history" + }, + { + "datasource": { + "default": true, + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The total number of connections, since NGINX was last started or reloaded.", + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "green", + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "ACCEPTED" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "shades" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "DROPPED" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "shades" + } + } + ] + } + ] + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 0, + "y": 6 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_connections{instance_type=\"nginxplus\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "{{nginx_connections_outcome}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "HTTP Connections", + "type": "stat" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 11 + }, + "id": 12, + "panels": [], + "title": "HTTP Requests & Responses", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The number of client requests that are currently being processed.", + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "blue", + "mode": "shades" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 7, + "x": 0, + "y": 12 + }, + "id": 16, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_request_processing_count{instance_type=\"nginxplus\", nginx_zone_name=~\"$nginxServerZoneName\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_zone_type}} {{nginx_zone_name}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "HTTP Request Processing Count", + "type": "gauge" + }, + { + "datasource": { + "default": true, + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The total number of client requests received, since the last collection interval.", + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "semi-dark-orange", + "mode": "continuous-BlPu" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 50, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineStyle": { + "fill": "solid" + }, + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 11, + "x": 7, + "y": 12 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_request_count{instance_type=\"nginxplus\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "legendFormat": "Requests", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "HTTP Request Count", + "type": "timeseries" + }, + { + "datasource": { + "default": true, + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The total number of client requests received, since NGINX was last started or reloaded.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "semi-dark-red", + "value": null + }, + { + "color": "dark-green", + "value": 1 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": " " + }, + "properties": [ + { + "id": "displayName", + "value": "Total" + } + ] + }, + { + "matcher": { + "id": "byValue", + "options": { + "op": "gte", + "reducer": "allIsZero", + "value": 0 + } + }, + "properties": [] + } + ] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 18, + "y": 12 + }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "editorMode": "builder", + "expr": "nginx_http_requests{instance_type=\"nginxplus\"}", + "hide": false, + "instant": false, + "legendFormat": "{{nginx_zone_type}} {{nginx_zone_name}}", + "range": true, + "refId": "A" + } + ], + "title": "Total HTTP Requests", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The total number of HTTP byte IO.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 20 + }, + "id": 15, + "options": { + "displayMode": "gradient", + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "maxVizHeight": 300, + "minVizHeight": 16, + "minVizWidth": 8, + "namePlacement": "top", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true, + "sizing": "auto", + "valueMode": "color" + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_request_io{instance_type=\"nginxplus\", nginx_io_direction=\"receive\", nginx_zone_type=\"SERVER\", nginx_zone_name=~\"$nginxServerZoneName\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_zone_type}} {{nginx_zone_name}}", + "range": true, + "refId": "A", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_request_io{instance_type=\"nginxplus\", nginx_io_direction=\"receive\", nginx_zone_type=\"LOCATION\", nginx_zone_name=~\"$nginxLocationZoneName\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_zone_type}} {{nginx_zone_name}}", + "range": true, + "refId": "B", + "useBackend": false + } + ], + "title": "HTTP Request IO - Receive", + "type": "bargauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The total number of HTTP byte IO.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 20 + }, + "id": 33, + "options": { + "displayMode": "gradient", + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "maxVizHeight": 300, + "minVizHeight": 16, + "minVizWidth": 8, + "namePlacement": "top", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true, + "sizing": "auto", + "valueMode": "color" + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_request_io{instance_type=\"nginxplus\", nginx_io_direction=\"transmit\", nginx_zone_type=\"SERVER\", nginx_zone_name=~\"$nginxServerZoneName\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_zone_type}} {{nginx_zone_name}}", + "range": true, + "refId": "A", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_request_io{instance_type=\"nginxplus\", nginx_io_direction=\"transmit\", nginx_zone_type=\"LOCATION\", nginx_zone_name=~\"$nginxLocationZoneName\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_zone_type}} {{nginx_zone_name}}", + "range": true, + "refId": "B", + "useBackend": false + } + ], + "title": "HTTP Request IO - Transmit", + "type": "bargauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The total number of requests completed without sending a response.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "continuous-BlPu" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 20 + }, + "id": 14, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_request_discarded{instance_type=\"nginxplus\", nginx_zone_type=\"SERVER\", nginx_zone_name=~\"$nginxServerZoneName\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_zone_type}} {{nginx_zone_name}}", + "range": true, + "refId": "A", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_request_discarded{instance_type=\"nginxplus\", nginx_zone_type=\"LOCATION\", nginx_zone_name=~\"$nginxLocationZoneName\"}", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_zone_type}} {{nginx_zone_name}}", + "range": true, + "refId": "B", + "useBackend": false + } + ], + "title": "HTTP Requests Discarded", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The total number of HTTP responses sent to clients since the last collection interval, grouped by status code range.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 28 + }, + "id": 6, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "code", + "expr": "nginx_http_response_count{nginx_zone_type=\"SERVER\", nginx_zone_name=~\"$nginxServerZoneName\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "Status Range: {{nginx_status_range}} {{nginx_zone_name}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "HTTP Response Count - ServerZone ", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The total number of responses for ServerZone, grouped by status code range.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 28 + }, + "id": 7, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_response_status{nginx_zone_type=\"SERVER\", nginx_zone_name=~\"$nginxServerZoneName\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "Status Range: {{nginx_status_range}} {{nginx_zone_name}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "HTTP Response Status - ServerZone", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The total number of HTTP responses sent to clients since the last collection interval, grouped by status code range.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 35 + }, + "id": 8, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_response_count{nginx_zone_type=\"LOCATION\", nginx_zone_name=~\"$nginxLocationZoneName\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "Status Range: {{nginx_status_range}} {{nginx_zone_name}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "HTTP Response Count - LocationZone", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The total number of responses for LocationZone, grouped by status code range.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 35 + }, + "id": 9, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_response_status{nginx_zone_type=\"LOCATION\", nginx_zone_name=~\"$nginxLocationZoneName\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "Status Range: {{nginx_status_range}} {{nginx_zone_name}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "HTTP Response Status - LocationZone", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 42 + }, + "id": 11, + "panels": [], + "title": "HTTP Upstream", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The average number of active connections per HTTP upstream peer.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 5, + "x": 0, + "y": 43 + }, + "id": 18, + "options": { + "displayMode": "gradient", + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "maxVizHeight": 300, + "minVizHeight": 16, + "minVizWidth": 8, + "namePlacement": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [], + "fields": "", + "values": false + }, + "showUnfilled": true, + "sizing": "auto", + "valueMode": "color" + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_upstream_peer_connection_count{instance_type=\"nginxplus\", nginx_upstream_name=~\"$nginxUpstreamPeerName\", nginx_peer_address=~\"$nginxUpstreamPeerAddress\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_upstream_name}} {{nginx_peer_address}} ", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "HTTP Upstream Peer Connection Count", + "type": "bargauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The current number of upstream peers removed from the group but still processing active client requests.\n", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 7, + "x": 5, + "y": 43 + }, + "id": 32, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_upstream_zombie_count{instance_type=\"nginxplus\", nginx_upstream_name=~\"$nginxUpstreamPeerName\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_upstream_name}} {{nginx_zone_name}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "HTTP Upstream Zombie Count", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The current count of peers on the HTTP upstream grouped by state.", + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "green", + "mode": "fixed" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byFrameRefID", + "options": "UP" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "CHECKING" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "DRAINING" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "light-red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "DOWN" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "UNHEALTHY" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "semi-dark-red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "UNAVAILABLE" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 18, + "w": 12, + "x": 12, + "y": 43 + }, + "id": 59, + "maxDataPoints": 10, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_upstream_peer_count{instance_type=\"nginxplus\", nginx_peer_state=\"UP\", nginx_upstream_name=~\"$nginxUpstreamPeerName\"}", + "format": "time_series", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_state}} {{nginx_upstream_name}} {{nginx_zone_name}}", + "range": true, + "refId": "UP", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_upstream_peer_count{instance_type=\"nginxplus\", nginx_peer_state=\"CHECKING\", nginx_upstream_name=~\"$nginxUpstreamPeerName\"}", + "format": "time_series", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_state}} {{nginx_upstream_name}} {{nginx_zone_name}}", + "range": true, + "refId": "CHECKING", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_upstream_peer_count{instance_type=\"nginxplus\", nginx_peer_state=\"UNAVAILABLE\", nginx_upstream_name=~\"$nginxUpstreamPeerName\"}", + "format": "time_series", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_state}} {{nginx_upstream_name}} {{nginx_zone_name}}", + "range": true, + "refId": "UNAVAILABLE", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_upstream_peer_count{instance_type=\"nginxplus\", nginx_peer_state=\"DRAINING\", nginx_upstream_name=~\"$nginxUpstreamPeerName\"}", + "format": "time_series", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_state}} {{nginx_upstream_name}} {{nginx_zone_name}}", + "range": true, + "refId": "DRAINING", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_upstream_peer_count{instance_type=\"nginxplus\", nginx_peer_state=\"DOWN\", nginx_upstream_name=~\"$nginxUpstreamPeerName\"}", + "format": "time_series", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_state}} {{nginx_upstream_name}} {{nginx_zone_name}}", + "range": true, + "refId": "DOWN", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_upstream_peer_count{instance_type=\"nginxplus\", nginx_peer_state=\"UNHEALTHY\", nginx_upstream_name=~\"$nginxUpstreamPeerName\"}", + "format": "time_series", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_state}} {{nginx_upstream_name}} {{nginx_zone_name}}", + "range": true, + "refId": "UNHEALTHY", + "useBackend": false + } + ], + "title": "HTTP Upstream Peer Count", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The current number of idle keepalive connections per HTTP upstream.", + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "green", + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 5, + "x": 0, + "y": 49 + }, + "id": 17, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "exemplar": false, + "expr": "nginx_http_upstream_keepalive_count{instance_type=\"nginxplus\", nginx_upstream_name=~\"$nginxUpstreamPeerName\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "instant": false, + "interval": "", + "legendFormat": "{{nginx_upstream_name}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "HTTP Upstream Keepalive Count", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "Number of times the server became unavailable for client requests (“unavail”).", + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "super-light-red", + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "light-red", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 7, + "x": 5, + "y": 49 + }, + "id": 28, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_upstream_peer_unavailables{instance_type=\"nginxplus\", nginx_peer_address=~\"$nginxUpstreamPeerAddress\", nginx_upstream_name=~\"$nginxUpstreamPeerName\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_address}} {{nginx_upstream_name}} {{nginx_zone_name}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "HTTP Upstream Peer Unavailables", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The total number of health check requests made to a HTTP upstream peer.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 55 + }, + "id": 22, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_upstream_peer_health_checks{instance_type=\"nginxplus\", nginx_upstream_name=~\"$nginxUpstreamPeerName\", nginx_peer_address=~\"$nginxUpstreamPeerAddress\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_address}} {{nginx_health_check}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "HTTP Upstream Peer Health Checks", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "Current state of an upstream peer in deployment.", + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "green", + "mode": "fixed" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byFrameRefID", + "options": "UP" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "CHECKING" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "DRAINING" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "light-red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "DOWN" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "UNHEALTHY" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "semi-dark-red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "UNAVAILABLE" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 15, + "w": 12, + "x": 12, + "y": 61 + }, + "id": 27, + "maxDataPoints": 10, + "options": { + "displayMode": "gradient", + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "maxVizHeight": 300, + "minVizHeight": 16, + "minVizWidth": 8, + "namePlacement": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true, + "sizing": "auto", + "valueMode": "color" + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_upstream_peer_state{instance_type=\"nginxplus\", nginx_peer_state=\"UP\", nginx_upstream_name=~\"$nginxUpstreamPeerName\", nginx_peer_address=~\"$nginxUpstreamPeerAddress\"}", + "format": "time_series", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_state}} {{nginx_peer_address}} {{nginx_upstream_name}} {{nginx_zone_name}}", + "range": true, + "refId": "UP", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_upstream_peer_state{instance_type=\"nginxplus\", nginx_peer_state=\"CHECKING\", nginx_upstream_name=~\"$nginxUpstreamPeerName\", nginx_peer_address=~\"$nginxUpstreamPeerAddress\"}", + "format": "time_series", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_state}} {{nginx_peer_address}} {{nginx_upstream_name}} {{nginx_zone_name}}", + "range": true, + "refId": "CHECKING", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_upstream_peer_state{instance_type=\"nginxplus\", nginx_peer_state=\"UNAVAILABLE\", nginx_upstream_name=~\"$nginxUpstreamPeerName\", nginx_peer_address=~\"$nginxUpstreamPeerAddress\"}", + "format": "time_series", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_state}} {{nginx_peer_address}} {{nginx_upstream_name}} {{nginx_zone_name}}", + "range": true, + "refId": "UNAVAILABLE", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_upstream_peer_state{instance_type=\"nginxplus\", nginx_peer_state=\"DRAINING\", nginx_upstream_name=~\"$nginxUpstreamPeerName\", nginx_peer_address=~\"$nginxUpstreamPeerAddress\"}", + "format": "time_series", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_state}} {{nginx_peer_address}} {{nginx_upstream_name}} {{nginx_zone_name}}", + "range": true, + "refId": "DRAINING", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_upstream_peer_state{instance_type=\"nginxplus\", nginx_peer_state=\"DOWN\", nginx_upstream_name=~\"$nginxUpstreamPeerName\", nginx_peer_address=~\"$nginxUpstreamPeerAddress\"}", + "format": "time_series", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_state}} {{nginx_peer_address}} {{nginx_upstream_name}} {{nginx_zone_name}}", + "range": true, + "refId": "DOWN", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_upstream_peer_state{instance_type=\"nginxplus\", nginx_peer_state=\"UNHEALTHY\", nginx_upstream_name=~\"$nginxUpstreamPeerName\", nginx_peer_address=~\"$nginxUpstreamPeerAddress\"}", + "format": "time_series", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_state}} {{nginx_peer_address}} {{nginx_upstream_name}} {{nginx_zone_name}}", + "range": true, + "refId": "UNHEALTHY", + "useBackend": false + } + ], + "title": "HTTP Upstream Peer State", + "type": "bargauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The total number of unsuccessful attempts to communicate with the HTTP upstream peer.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "#EAB839", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 63 + }, + "id": 20, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_upstream_peer_fails{instance_type=\"nginxplus\", nginx_upstream_name=~\"$nginxUpstreamPeerName\", nginx_peer_address=~\"$nginxUpstreamPeerAddress\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_address}} {{nginx_upstream_name}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "HTTP Upstream Peer Fails", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The total number of client requests forwarded to the HTTP upstream peer.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 70 + }, + "id": 24, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_upstream_peer_requests{instance_type=\"nginxplus\", nginx_upstream_name=~\"$nginxUpstreamPeerName\", nginx_peer_address=~\"$nginxUpstreamPeerAddress\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_address}} {{nginx_upstream_name}} {{nginx_zone_name}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "HTTP Upstream Peer Requests", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The average time to get the full response from the HTTP upstream peer.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 12, + "w": 12, + "x": 12, + "y": 76 + }, + "id": 25, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_upstream_peer_response_time{instance_type=\"nginxplus\", nginx_upstream_name=~\"$nginxUpstreamPeerName\", nginx_peer_address=~\"$nginxUpstreamPeerAddress\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_address}} {{nginx_upstream_name}} {{nginx_zone_name}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "HTTP Upstream Peer Response Time", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The total number of responses obtained from the HTTP upstream peer grouped by status range.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 12, + "x": 0, + "y": 79 + }, + "id": 26, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_upstream_peer_responses{instance_type=\"nginxplus\", nginx_upstream_name=~\"$nginxUpstreamPeerName\", nginx_peer_address=~\"$nginxUpstreamPeerAddress\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_address}} {{nginx_status_range}} {{nginx_upstream_name}} {{nginx_zone_name}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "HTTP Upstream Peer Responses", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The total number of requests rejected due to the queue overflow.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 12, + "x": 12, + "y": 88 + }, + "id": 30, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_upstream_queue_overflows{instance_type=\"nginxplus\", nginx_upstream_name=~\"$nginxUpstreamPeerName\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_upstream_name}} {{nginx_zone_name}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "HTTP Upstream Queue Overflows", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The total number of byte IO received per HTTP upstream peer.\n", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 0, + "y": 89 + }, + "id": 23, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_upstream_peer_io{instance_type=\"nginxplus\", nginx_io_direction=\"receive\", nginx_upstream_name=~\"$nginxUpstreamPeerName\", nginx_peer_address=~\"$nginxUpstreamPeerAddress\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_address}} {{nginx_upstream_name}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "HTTP Upstream Peer IO - Receive", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The total number of byte IO transmitted per HTTP upstream peer.\n", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 6, + "y": 89 + }, + "id": 60, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_upstream_peer_io{instance_type=\"nginxplus\", nginx_io_direction=\"transmit\", nginx_upstream_name=~\"$nginxUpstreamPeerName\", nginx_peer_address=~\"$nginxUpstreamPeerAddress\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_address}} {{nginx_upstream_name}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "HTTP Upstream Peer IO - Transmit", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The maximum number of requests that can be in the queue at the same time.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic-by-name" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 5, + "x": 0, + "y": 97 + }, + "id": 29, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_upstream_queue_limit{instance_type=\"nginxplus\", nginx_upstream_name=~\"$nginxUpstreamPeerName\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_upstream_name}} {{nginx_zone_name}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "HTTP Upstream Queue Limit", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The current number of requests in the queue.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 9, + "x": 5, + "y": 97 + }, + "id": 31, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_upstream_queue_usage{instance_type=\"nginxplus\", nginx_upstream_name=~\"$nginxUpstreamPeerName\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_upstream_name}} {{nginx_zone_name}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "HTTP Upstream Queue Usage", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The average time to get the response header from the HTTP upstream peer.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 10, + "x": 14, + "y": 97 + }, + "id": 21, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_http_upstream_peer_header_time{instance_type=\"nginxplus\", nginx_upstream_name=~\"$nginxUpstreamPeerName\", nginx_peer_address=~\"$nginxUpstreamPeerAddress\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_address}} {{nginx_upstream_name}} {{nginx_zone_name}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "HTTP Upstream Peer Header Time", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 106 + }, + "id": 10, + "panels": [], + "title": "Memory", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The current number of free memory slots.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 107 + }, + "id": 37, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_slab_slot_free{nginx_zone_name=~\"$nginx_application_zone\", instance_type=\"nginxplus\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_slab_slot_limit}} {{nginx_zone_name}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Free Memory Slots", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The current number of used memory slots.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 107 + }, + "id": 38, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right", + "showLegend": true, + "sortBy": "Name", + "sortDesc": false + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_slab_slot_usage{nginx_zone_name=~\"$nginx_application_zone\", instance_type=\"nginxplus\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_slab_slot_limit}} {{nginx_zone_name}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Memory Slots in Use", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The current number of free memory pages.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 115 + }, + "id": 34, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_slab_page_free{instance_type=\"nginxplus\", nginx_zone_name=~\"$nginx_application_zone\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_zone_name}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Free Memory Pages", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The current number of used memory pages.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 115 + }, + "id": 35, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_slab_page_usage{instance_type=\"nginxplus\", nginx_zone_name=~\"$nginx_application_zone\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_zone_name}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Memory Pages in Use", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The number of attempts to allocate memory of specified size.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 12, + "x": 0, + "y": 123 + }, + "id": 36, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_slab_slot_allocations{instance_type=\"nginxplus\", nginx_zone_name=~\"$nginx_application_zone\", nginx_slab_slot_allocation_result=\"SUCCESS\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_slab_slot_limit}} {{nginx_zone_name}} | {{nginx_slab_slot_allocation_result}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Successful Memory Allocation of Specified Size Attempts", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The number of attempts to allocate memory of specified size.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 10, + "w": 12, + "x": 12, + "y": 123 + }, + "id": 61, + "options": { + "legend": { + "calcs": [], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_slab_slot_allocations{instance_type=\"nginxplus\", nginx_zone_name=~\"$nginx_application_zone\", nginx_slab_slot_allocation_result=\"FAILURE\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_slab_slot_limit}} {{nginx_zone_name}} | {{nginx_slab_slot_allocation_result}} ", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Failed Memory Allocation of Specified Size Attempts", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 133 + }, + "id": 39, + "panels": [], + "title": "SSL", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The total number of SSL certificate verification failures.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1 + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 134 + }, + "id": 40, + "maxDataPoints": 10, + "options": { + "colWidth": 0.9, + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "editorMode": "code", + "expr": "nginx_ssl_certificate_verify_failures{instance_type=\"nginxplus\"}", + "legendFormat": "{{nginx_ssl_verify_failure_reason}}", + "range": true, + "refId": "A" + } + ], + "title": "SSL Certificate Verify Failures", + "type": "status-history" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The total number of SSL handshakes.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 134 + }, + "id": 41, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "editorMode": "code", + "expr": "nginx_ssl_handshakes{instance_type=\"nginxplus\"}", + "legendFormat": "{{nginx_ssl_status}} {{nginx_ssl_handshake_reason}}", + "range": true, + "refId": "A" + } + ], + "title": "SSL Handshakes", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 142 + }, + "id": 42, + "panels": [], + "title": "Stream", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The total number of connections accepted from clients.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 9, + "x": 0, + "y": 143 + }, + "id": 43, + "options": { + "displayMode": "basic", + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "maxVizHeight": 300, + "minVizHeight": 16, + "minVizWidth": 8, + "namePlacement": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true, + "sizing": "auto", + "valueMode": "color" + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_stream_connection_accepted{instance_type=\"nginxplus\", nginx_zone_name=~\"$nginx_network_zone\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_zone_name}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Stream Connections - Accepted", + "type": "bargauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The number of client connections that are currently being processed.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 12, + "w": 5, + "x": 9, + "y": 143 + }, + "id": 45, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_stream_connection_processing_count{instance_type=\"nginxplus\", nginx_zone_name=~\"$nginx_network_zone\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_zone_name}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Stream Connection Processing Count", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The total number of Stream byte IO.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 10, + "x": 14, + "y": 143 + }, + "id": 62, + "options": { + "displayMode": "basic", + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "maxVizHeight": 300, + "minVizHeight": 16, + "minVizWidth": 8, + "namePlacement": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true, + "sizing": "auto", + "valueMode": "color" + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_stream_io{instance_type=\"nginxplus\", nginx_zone_name=~\"$nginx_network_zone\", nginx_io_direction=\"transmit\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_zone_name}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Stream IO - Transmit", + "type": "bargauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "Total number of connections completed without creating a session.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 9, + "x": 0, + "y": 149 + }, + "id": 44, + "options": { + "displayMode": "basic", + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "maxVizHeight": 300, + "minVizHeight": 16, + "minVizWidth": 8, + "namePlacement": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true, + "sizing": "auto", + "valueMode": "color" + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_stream_connection_discarded{instance_type=\"nginxplus\", nginx_zone_name=~\"$nginx_network_zone\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_zone_name}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Stream Connections - Discarded", + "type": "bargauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The total number of Stream byte IO.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 10, + "x": 14, + "y": 149 + }, + "id": 46, + "options": { + "displayMode": "basic", + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "maxVizHeight": 300, + "minVizHeight": 16, + "minVizWidth": 8, + "namePlacement": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true, + "sizing": "auto", + "valueMode": "color" + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_stream_io{instance_type=\"nginxplus\", nginx_io_direction=\"receive\", nginx_zone_name=~\"$nginx_network_zone\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_zone_name}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Stream IO - Receive", + "type": "bargauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The total number of completed sessions.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 155 + }, + "id": 47, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.5.2", + "repeat": "nginx_network_zone", + "repeatDirection": "h", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_stream_session_status{instance_type=\"nginxplus\", nginx_zone_name=~\"$nginx_network_zone\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_status_range}} Zone: {{nginx_zone_name}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Stream Session Status", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The current number of Stream Upstream Peer connections.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "fillOpacity": 70, + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1 + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#58585a", + "value": null + }, + { + "color": "green", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 10, + "x": 0, + "y": 163 + }, + "id": 48, + "maxDataPoints": 10, + "options": { + "colWidth": 0.9, + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": false + }, + "rowHeight": 0.9, + "showValue": "auto", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_stream_upstream_peer_connection_count{instance_type=\"nginxplus\", nginx_upstream_name=~\"$stream_upstream_peer\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_upstream_name}} {{nginx_zone_name}} {{nginx_peer_address}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Stream Upstream Peer Connections Count", + "type": "status-history" + }, { "datasource": { - "default": true, "type": "prometheus", "uid": "otel-prometheus-scraper" }, + "description": "The total number of client connections forwarded to this stream upstream peer.", "fieldConfig": { "defaults": { "color": { @@ -41,8 +4425,8 @@ "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", + "fillOpacity": 20, + "gradientMode": "opacity", "hideFrom": { "legend": false, "tooltip": false, @@ -70,7 +4454,8 @@ "mode": "absolute", "steps": [ { - "color": "green" + "color": "green", + "value": null }, { "color": "red", @@ -82,12 +4467,12 @@ "overrides": [] }, "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 0 + "h": 9, + "w": 7, + "x": 10, + "y": 163 }, - "id": 2, + "id": 50, "options": { "legend": { "calcs": [], @@ -101,34 +4486,29 @@ "sort": "none" } }, - "pluginVersion": "11.6.0", + "pluginVersion": "11.5.2", "targets": [ { - "datasource": { - "type": "prometheus", - "uid": "otel-prometheus-scraper" - }, "disableTextWrap": false, "editorMode": "builder", - "expr": "nginx_http_connection_count{instance_type=\"nginxplus\"}", + "expr": "nginx_stream_upstream_peer_connections{instance_type=\"nginxplus\", nginx_upstream_name=~\"$stream_upstream_peer\"}", "fullMetaSearch": false, - "includeNullMetadata": false, - "instant": false, - "legendFormat": "{{nginx_conn_outcome}}", + "includeNullMetadata": true, + "legendFormat": "{{nginx_upstream_name}} {{nginx_zone_name}} {{nginx_peer_address}}", "range": true, "refId": "A", "useBackend": false } ], - "title": "HTTP Connections Count", + "title": "Stream Upstream Peer Connections", "type": "timeseries" }, { "datasource": { - "default": true, "type": "prometheus", "uid": "otel-prometheus-scraper" }, + "description": "The average time to connect to the stream upstream peer.", "fieldConfig": { "defaults": { "color": { @@ -143,8 +4523,8 @@ "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", + "fillOpacity": 20, + "gradientMode": "opacity", "hideFrom": { "legend": false, "tooltip": false, @@ -172,7 +4552,8 @@ "mode": "absolute", "steps": [ { - "color": "green" + "color": "green", + "value": null }, { "color": "red", @@ -184,12 +4565,12 @@ "overrides": [] }, "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 0 + "h": 9, + "w": 7, + "x": 17, + "y": 163 }, - "id": 3, + "id": 49, "options": { "legend": { "calcs": [], @@ -203,8 +4584,177 @@ "sort": "none" } }, - "pluginVersion": "11.6.0", + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_stream_upstream_peer_connection_time{instance_type=\"nginxplus\", nginx_upstream_name=~\"$stream_upstream_peer\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_upstream_name}} {{nginx_zone_name}} {{nginx_peer_address}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Stream Upstream Peer Connection Time", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The current number of stream upstream peers grouped by state.", + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "green", + "mode": "fixed" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byFrameRefID", + "options": "UP" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "CHECKING" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "DRAINING" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "light-red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "DOWN" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "UNHEALTHY" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "semi-dark-red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "UNAVAILABLE" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 16, + "w": 12, + "x": 0, + "y": 172 + }, + "id": 64, + "maxDataPoints": 10, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.5.2", "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_stream_upstream_peer_count{instance_type=\"nginxplus\", nginx_peer_state=\"UP\", nginx_upstream_name=~\"$stream_upstream_peer\"}", + "format": "time_series", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_state}} {{nginx_upstream_name}} {{nginx_zone_name}}", + "range": true, + "refId": "UP", + "useBackend": false + }, { "datasource": { "type": "prometheus", @@ -212,25 +4762,94 @@ }, "disableTextWrap": false, "editorMode": "builder", - "expr": "nginx_http_connections{instance_type=\"nginxplus\"}", + "expr": "nginx_stream_upstream_peer_count{instance_type=\"nginxplus\", nginx_peer_state=\"CHECKING\", nginx_upstream_name=~\"$stream_upstream_peer\"}", + "format": "time_series", "fullMetaSearch": false, + "hide": false, "includeNullMetadata": true, - "instant": false, - "legendFormat": "{{nginx_conn_outcome}}", + "legendFormat": "{{nginx_peer_state}} {{nginx_upstream_name}} {{nginx_zone_name}}", "range": true, - "refId": "A", + "refId": "CHECKING", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_stream_upstream_peer_count{instance_type=\"nginxplus\", nginx_peer_state=\"UNAVAILABLE\", nginx_upstream_name=~\"$stream_upstream_peer\"}", + "format": "time_series", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_state}} {{nginx_upstream_name}} {{nginx_zone_name}}", + "range": true, + "refId": "UNAVAILABLE", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_stream_upstream_peer_count{instance_type=\"nginxplus\", nginx_peer_state=\"DRAINING\", nginx_upstream_name=~\"$stream_upstream_peer\"}", + "format": "time_series", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_state}} {{nginx_upstream_name}} {{nginx_zone_name}}", + "range": true, + "refId": "DRAINING", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_stream_upstream_peer_count{instance_type=\"nginxplus\", nginx_peer_state=\"DOWN\", nginx_upstream_name=~\"$stream_upstream_peer\"}", + "format": "time_series", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_state}} {{nginx_upstream_name}} {{nginx_zone_name}}", + "range": true, + "refId": "DOWN", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_stream_upstream_peer_count{instance_type=\"nginxplus\", nginx_peer_state=\"UNHEALTHY\", nginx_upstream_name=~\"$stream_upstream_peer\"}", + "format": "time_series", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_state}} {{nginx_upstream_name}} {{nginx_zone_name}}", + "range": true, + "refId": "UNHEALTHY", "useBackend": false } ], - "title": "HTTP Connections", - "type": "timeseries" + "title": "Stream Upstream Peer Count", + "type": "stat" }, { "datasource": { - "default": true, "type": "prometheus", "uid": "otel-prometheus-scraper" }, + "description": "The total number of health check requests made to the stream upstream peer.", "fieldConfig": { "defaults": { "color": { @@ -245,8 +4864,8 @@ "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", + "fillOpacity": 20, + "gradientMode": "opacity", "hideFrom": { "legend": false, "tooltip": false, @@ -274,7 +4893,8 @@ "mode": "absolute", "steps": [ { - "color": "green" + "color": "green", + "value": null }, { "color": "red", @@ -288,15 +4908,15 @@ "gridPos": { "h": 8, "w": 12, - "x": 0, - "y": 8 + "x": 12, + "y": 172 }, - "id": 4, + "id": 52, "options": { "legend": { "calcs": [], - "displayMode": "list", - "placement": "bottom", + "displayMode": "table", + "placement": "right", "showLegend": true }, "tooltip": { @@ -305,34 +4925,29 @@ "sort": "none" } }, - "pluginVersion": "11.6.0", + "pluginVersion": "11.5.2", "targets": [ { - "datasource": { - "type": "prometheus", - "uid": "otel-prometheus-scraper" - }, "disableTextWrap": false, "editorMode": "builder", - "expr": "nginx_http_request_count{instance_type=\"nginxplus\"}", + "expr": "nginx_stream_upstream_peer_health_checks{instance_type=\"nginxplus\", nginx_upstream_name=~\"$stream_upstream_peer\"}", "fullMetaSearch": false, "includeNullMetadata": true, - "instant": false, - "legendFormat": "Requests", + "legendFormat": "{{nginx_health_check}} {{nginx_peer address}} {{nginx_upstream_name}} {{nginx_zone_name}}", "range": true, "refId": "A", "useBackend": false } ], - "title": "HTTP Request Count", + "title": "Stream Upstream Peer Health Checks", "type": "timeseries" }, { "datasource": { - "default": true, "type": "prometheus", "uid": "otel-prometheus-scraper" }, + "description": "How many times the server became unavailable for client connections (state “unavail”) due to the number of unsuccessful attempts reaching the max_fails threshold.", "fieldConfig": { "defaults": { "color": { @@ -347,8 +4962,8 @@ "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", + "fillOpacity": 20, + "gradientMode": "opacity", "hideFrom": { "legend": false, "tooltip": false, @@ -376,7 +4991,8 @@ "mode": "absolute", "steps": [ { - "color": "green" + "color": "green", + "value": null }, { "color": "red", @@ -391,9 +5007,9 @@ "h": 8, "w": 12, "x": 12, - "y": 8 + "y": 180 }, - "id": 5, + "id": 57, "options": { "legend": { "calcs": [], @@ -407,26 +5023,21 @@ "sort": "none" } }, - "pluginVersion": "11.6.0", + "pluginVersion": "11.5.2", "targets": [ { - "datasource": { - "type": "prometheus", - "uid": "otel-prometheus-scraper" - }, "disableTextWrap": false, "editorMode": "builder", - "expr": "nginx_http_requests{instance_type=\"nginxplus\"}", + "expr": "nginx_stream_upstream_peer_unavailables{instance_type=\"nginxplus\", nginx_upstream_name=~\"$stream_upstream_peer\"}", "fullMetaSearch": false, - "includeNullMetadata": false, - "instant": false, - "legendFormat": "__auto", + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_address}} {{nginx_upstream_name}} {{nginx_zone_name}}", "range": true, "refId": "A", "useBackend": false } ], - "title": "Total HTTP Requests", + "title": "Stream Upstream Peer Unavailable", "type": "timeseries" }, { @@ -434,44 +5045,12 @@ "type": "prometheus", "uid": "otel-prometheus-scraper" }, - "description": "The total number of responses, grouped by status code range.", + "description": "Current state of upstream peers in deployment. If any of the upstream peers in the deployment match the given state then the value will be 1. If no upstream peer is a match then the value will be 0.", "fieldConfig": { "defaults": { "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } + "fixedColor": "green", + "mode": "fixed" }, "mappings": [], "thresholds": { @@ -487,51 +5066,239 @@ ] } }, - "overrides": [] + "overrides": [ + { + "matcher": { + "id": "byFrameRefID", + "options": "UP" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "CHECKING" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "DRAINING" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "light-red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "DOWN" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "UNHEALTHY" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "semi-dark-red", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "UNAVAILABLE" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed" + } + } + ] + } + ] }, "gridPos": { - "h": 8, + "h": 16, "w": 12, "x": 0, - "y": 16 + "y": 188 }, - "id": 9, + "id": 65, + "maxDataPoints": 10, "options": { + "displayMode": "gradient", "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", - "showLegend": true + "showLegend": false }, - "tooltip": { - "hideZeros": false, - "mode": "single", - "sort": "none" - } + "maxVizHeight": 300, + "minVizHeight": 16, + "minVizWidth": 8, + "namePlacement": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true, + "sizing": "auto", + "valueMode": "color" }, - "pluginVersion": "11.6.0", + "pluginVersion": "11.5.2", "targets": [ { "disableTextWrap": false, "editorMode": "builder", - "expr": "nginx_http_response_status{nginx_zone_type=\"LOCATION\"}", + "expr": "nginx_stream_upstream_peer_state{instance_type=\"nginxplus\", nginx_peer_state=\"UP\", nginx_upstream_name=~\"$stream_upstream_peer\"}", + "format": "time_series", "fullMetaSearch": false, "includeNullMetadata": true, - "legendFormat": "__auto", + "legendFormat": "{{nginx_peer_state}} {{nginx_peer_address}} {{nginx_upstream_name}} {{nginx_zone_name}}", "range": true, - "refId": "A", + "refId": "UP", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_stream_upstream_peer_state{instance_type=\"nginxplus\", nginx_peer_state=\"CHECKING\", nginx_upstream_name=~\"$stream_upstream_peer\"}", + "format": "time_series", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_state}} {{nginx_peer_address}} {{nginx_upstream_name}} {{nginx_zone_name}}", + "range": true, + "refId": "CHECKING", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_stream_upstream_peer_state{instance_type=\"nginxplus\", nginx_peer_state=\"UNAVAILABLE\", nginx_upstream_name=~\"$stream_upstream_peer\"}", + "format": "time_series", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_state}} {{nginx_peer_address}} {{nginx_upstream_name}} {{nginx_zone_name}}", + "range": true, + "refId": "UNAVAILABLE", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_stream_upstream_peer_state{instance_type=\"nginxplus\", nginx_peer_state=\"DRAINING\", nginx_upstream_name=~\"$stream_upstream_peer\"}", + "format": "time_series", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_state}} {{nginx_peer_address}} {{nginx_upstream_name}} {{nginx_zone_name}}", + "range": true, + "refId": "DRAINING", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_stream_upstream_peer_state{instance_type=\"nginxplus\", nginx_peer_state=\"DOWN\", nginx_upstream_name=~\"$stream_upstream_peer\"}", + "format": "time_series", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_state}} {{nginx_peer_address}} {{nginx_upstream_name}} {{nginx_zone_name}}", + "range": true, + "refId": "DOWN", + "useBackend": false + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_stream_upstream_peer_state{instance_type=\"nginxplus\", nginx_peer_state=\"UNHEALTHY\", nginx_upstream_name=~\"$stream_upstream_peer\"}", + "format": "time_series", + "fullMetaSearch": false, + "hide": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_state}} {{nginx_peer_address}} {{nginx_upstream_name}} {{nginx_zone_name}}", + "range": true, + "refId": "UNHEALTHY", "useBackend": false } ], - "title": "HTTP Response Status - LocationZone", - "type": "timeseries" + "title": "Stream Upstream Peer State", + "type": "bargauge" }, { "datasource": { "type": "prometheus", "uid": "otel-prometheus-scraper" }, - "description": "The total number of HTTP responses sent to clients, grouped by status code range, since the last collection interval.", + "description": "The current number of peers removed from the group but still processing active client connections.", "fieldConfig": { "defaults": { "color": { @@ -546,8 +5313,8 @@ "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", + "fillOpacity": 20, + "gradientMode": "opacity", "hideFrom": { "legend": false, "tooltip": false, @@ -587,12 +5354,12 @@ "overrides": [] }, "gridPos": { - "h": 8, - "w": 12, + "h": 16, + "w": 7, "x": 12, - "y": 16 + "y": 188 }, - "id": 8, + "id": 58, "options": { "legend": { "calcs": [], @@ -606,21 +5373,21 @@ "sort": "none" } }, - "pluginVersion": "11.6.0", + "pluginVersion": "11.5.2", "targets": [ { "disableTextWrap": false, "editorMode": "builder", - "expr": "nginx_http_response_count{nginx_zone_type=\"LOCATION\"}", + "expr": "nginx_stream_upstream_zombie_count{instance_type=\"nginxplus\", nginx_upstream_name=~\"$stream_upstream_peer\"}", "fullMetaSearch": false, "includeNullMetadata": true, - "legendFormat": "__auto", + "legendFormat": "{{nginx_upstream_name}} {{nginx_zone_name}}", "range": true, "refId": "A", "useBackend": false } ], - "title": "HTTP Response Count - LocationZone", + "title": "Stream Upstream Zombie Count", "type": "timeseries" }, { @@ -628,7 +5395,7 @@ "type": "prometheus", "uid": "otel-prometheus-scraper" }, - "description": "The total number of responses, grouped by status code range. For ServerZone", + "description": "The average time to receive the last byte of data for the stream upstream peer.", "fieldConfig": { "defaults": { "color": { @@ -643,8 +5410,8 @@ "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", + "fillOpacity": 20, + "gradientMode": "opacity", "hideFrom": { "legend": false, "tooltip": false, @@ -685,11 +5452,11 @@ }, "gridPos": { "h": 8, - "w": 12, - "x": 0, - "y": 24 + "w": 5, + "x": 19, + "y": 188 }, - "id": 7, + "id": 54, "options": { "legend": { "calcs": [], @@ -703,21 +5470,21 @@ "sort": "none" } }, - "pluginVersion": "11.6.0", + "pluginVersion": "11.5.2", "targets": [ { "disableTextWrap": false, "editorMode": "builder", - "expr": "nginx_http_response_status{nginx_zone_type=\"SERVER\"}", + "expr": "nginx_stream_upstream_peer_response_time{instance_type=\"nginxplus\", nginx_upstream_name=~\"$stream_upstream_peer\"}", "fullMetaSearch": false, "includeNullMetadata": true, - "legendFormat": "__auto", + "legendFormat": "{{nginx_peer_address}} {{nginx_upstream_name}} {{nginx_zone_name}}", "range": true, "refId": "A", "useBackend": false } ], - "title": "HTTP Response Status - ServerZone", + "title": "Stream Upstream Peer Response Time", "type": "timeseries" }, { @@ -725,6 +5492,75 @@ "type": "prometheus", "uid": "otel-prometheus-scraper" }, + "description": "The average time to receive the first byte of data for the stream upstream peer.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 5, + "x": 19, + "y": 196 + }, + "id": 56, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.5.2", + "targets": [ + { + "disableTextWrap": false, + "editorMode": "builder", + "expr": "nginx_stream_upstream_peer_ttfb_time{instance_type=\"nginxplus\", nginx_upstream_name=~\"$stream_upstream_peer\"}", + "fullMetaSearch": false, + "includeNullMetadata": true, + "legendFormat": "{{nginx_peer_address}} {{nginx_upstream_name}} {{nginx_zone_name}}", + "range": true, + "refId": "A", + "useBackend": false + } + ], + "title": "Stream Upstream Peer TTFB Time", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "otel-prometheus-scraper" + }, + "description": "The total number of Stream Upstream Peer byte IO.", "fieldConfig": { "defaults": { "color": { @@ -739,8 +5575,8 @@ "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", + "fillOpacity": 20, + "gradientMode": "opacity", "hideFrom": { "legend": false, "tooltip": false, @@ -782,15 +5618,15 @@ "gridPos": { "h": 8, "w": 12, - "x": 12, - "y": 24 + "x": 0, + "y": 204 }, - "id": 6, + "id": 53, "options": { "legend": { "calcs": [], - "displayMode": "list", - "placement": "bottom", + "displayMode": "table", + "placement": "right", "showLegend": true }, "tooltip": { @@ -799,29 +5635,29 @@ "sort": "none" } }, - "pluginVersion": "11.6.0", + "pluginVersion": "11.5.2", "targets": [ { "disableTextWrap": false, "editorMode": "builder", - "expr": "nginx_http_response_count{nginx_zone_type=\"SERVER\"}", + "expr": "nginx_stream_upstream_peer_io{instance_type=\"nginxplus\", nginx_io_direction=\"receive\", nginx_upstream_name=~\"$stream_upstream_peer\"}", "fullMetaSearch": false, "includeNullMetadata": true, - "legendFormat": "__auto", + "legendFormat": "{{nginx_io_direction}} {{nginx_peer_address}} {{nginx_upstream_name}} {{nginx_zone_name}}", "range": true, "refId": "A", "useBackend": false } ], - "title": "HTTP Response Count - Server Zone ", + "title": "Stream Upstream Peer IO - Receive", "type": "timeseries" }, { "datasource": { - "default": true, "type": "prometheus", "uid": "otel-prometheus-scraper" }, + "description": "The total number of Stream Upstream Peer byte IO.", "fieldConfig": { "defaults": { "color": { @@ -836,8 +5672,8 @@ "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", - "fillOpacity": 0, - "gradientMode": "none", + "fillOpacity": 20, + "gradientMode": "opacity", "hideFrom": { "legend": false, "tooltip": false, @@ -879,15 +5715,15 @@ "gridPos": { "h": 8, "w": 12, - "x": 6, - "y": 32 + "x": 12, + "y": 204 }, - "id": 1, + "id": 63, "options": { "legend": { "calcs": [], - "displayMode": "list", - "placement": "bottom", + "displayMode": "table", + "placement": "right", "showLegend": true }, "tooltip": { @@ -896,35 +5732,213 @@ "sort": "none" } }, - "pluginVersion": "11.6.0", + "pluginVersion": "11.5.2", "targets": [ { - "datasource": { - "type": "prometheus", - "uid": "otel-prometheus-scraper" - }, "disableTextWrap": false, "editorMode": "builder", - "expr": "nginx_config_reloads{instance_type=\"nginxplus\"}", + "expr": "nginx_stream_upstream_peer_io{instance_type=\"nginxplus\", nginx_io_direction=\"transmit\", nginx_upstream_name=~\"$stream_upstream_peer\"}", "fullMetaSearch": false, "includeNullMetadata": true, - "instant": false, - "legendFormat": "Reloads", + "legendFormat": "{{nginx_io_direction}} {{nginx_peer_address}} {{nginx_upstream_name}} {{nginx_zone_name}}", "range": true, "refId": "A", "useBackend": false } ], - "title": "NGINX Config Reloads", + "title": "Stream Upstream Peer IO - Transmit", "type": "timeseries" } ], "preload": false, "refresh": "5s", - "schemaVersion": 41, + "schemaVersion": 40, "tags": [], "templating": { - "list": [] + "list": [ + { + "allowCustomValue": false, + "current": { + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "definition": "label_values({nginx_zone_type=\"SERVER\"},nginx_zone_name)", + "description": "Filter by Server Zone Name.", + "includeAll": true, + "label": "NGINX Server Zone", + "multi": true, + "name": "nginxServerZoneName", + "options": [], + "query": { + "qryType": 1, + "query": "label_values({nginx_zone_type=\"SERVER\"},nginx_zone_name)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "type": "query" + }, + { + "allowCustomValue": false, + "current": { + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "definition": "label_values({nginx_zone_type=\"LOCATION\"},nginx_zone_name)", + "description": "Filter by Location Zone Name.", + "includeAll": true, + "label": "NGINX Location Zone", + "multi": true, + "name": "nginxLocationZoneName", + "options": [], + "query": { + "qryType": 1, + "query": "label_values({nginx_zone_type=\"LOCATION\"},nginx_zone_name)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "type": "query" + }, + { + "allowCustomValue": false, + "current": { + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "definition": "label_values(nginx_slab_page_usage,nginx_zone_name)", + "description": "Filter by Application Zone Name.", + "includeAll": true, + "label": "NGINX Application Zone", + "multi": true, + "name": "nginx_application_zone", + "options": [], + "query": { + "qryType": 1, + "query": "label_values(nginx_slab_page_usage,nginx_zone_name)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "type": "query" + }, + { + "allowCustomValue": false, + "current": { + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "definition": "label_values(nginx_stream_connection_accepted,nginx_zone_name)", + "description": "Filter by Network Zone Name.", + "includeAll": true, + "label": "NGINX Network Zone", + "multi": true, + "name": "nginx_network_zone", + "options": [], + "query": { + "qryType": 1, + "query": "label_values(nginx_stream_connection_accepted,nginx_zone_name)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "type": "query" + }, + { + "allowCustomValue": false, + "current": { + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "definition": "label_values(nginx_http_upstream_peer_count,nginx_upstream_name)", + "description": "Filter by Upstream Peer Name.", + "includeAll": true, + "label": "NGINX Upstream Peer", + "multi": true, + "name": "nginxUpstreamPeerName", + "options": [], + "query": { + "qryType": 1, + "query": "label_values(nginx_http_upstream_peer_count,nginx_upstream_name)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "type": "query" + }, + { + "allowCustomValue": false, + "current": { + "text": [ + "All" + ], + "value": [ + "$__all" + ] + }, + "definition": "label_values({nginx_upstream_name=~\"$nginxUpstreamPeerName\"},nginx_peer_address)", + "description": "Filter by Upstream Peer Address.", + "includeAll": true, + "label": "NGINX Upstream Peer Address", + "multi": true, + "name": "nginxUpstreamPeerAddress", + "options": [], + "query": { + "qryType": 1, + "query": "label_values({nginx_upstream_name=~\"$nginxUpstreamPeerName\"},nginx_peer_address)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "type": "query" + }, + { + "allowCustomValue": false, + "current": { + "text": [ + "nginx3" + ], + "value": [ + "nginx3" + ] + }, + "definition": "label_values(nginx_stream_upstream_peer_count,nginx_upstream_name)", + "description": "Filter by Stream Upstream Peer Name.", + "includeAll": true, + "label": "NGINX Stream Upstream Peer", + "multi": true, + "name": "stream_upstream_peer", + "options": [], + "query": { + "qryType": 1, + "query": "label_values(nginx_stream_upstream_peer_count,nginx_upstream_name)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "type": "query" + } + ] }, "time": { "from": "now-5m", @@ -934,5 +5948,6 @@ "timezone": "browser", "title": "NGINX Plus", "uid": "fdris4hclbqiob", - "version": 3 + "version": 20, + "weekStart": "" } From 16ab50ed9f964d5dafe3197c91bfa1adbcac8b35 Mon Sep 17 00:00:00 2001 From: John David White <127981157+john-david3@users.noreply.github.com> Date: Mon, 9 Jun 2025 10:47:38 +0100 Subject: [PATCH 11/28] Add tokenpath parameter to collector (#1077) * Add tokenpath parameter to collector * check for empty filepath * update values method --- internal/config/config.go | 26 ++++ internal/config/config_test.go | 125 ++++++++++++++++++ internal/config/testdata/nginx-token.crt | 1 + internal/config/types.go | 1 + nginx-agent.conf | 1 - .../nginx-agent-with-multiple-headers.conf | 17 +++ test/config/agent/nginx-agent-with-token.conf | 11 ++ test/config/nginx_config.go | 14 ++ 8 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 internal/config/testdata/nginx-token.crt create mode 100644 test/config/agent/nginx-agent-with-multiple-headers.conf create mode 100644 test/config/agent/nginx-agent-with-token.conf diff --git a/internal/config/config.go b/internal/config/config.go index 4b2feb20d..8f27ddb74 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -917,12 +917,38 @@ func resolveExtensions() Extensions { } } + if headersSetter != nil { + headersSetter.Headers = updateHeaders(headersSetter.Headers) + } + return Extensions{ Health: health, HeadersSetter: headersSetter, } } +func updateHeaders(headers []Header) []Header { + var err error + newHeaders := []Header{} + + for _, header := range headers { + value := header.Value + if value == "" && header.FilePath != "" { + slog.Debug("Read value from file", "path", header.FilePath) + value, err = file.ReadFromFile(header.FilePath) + if err != nil { + slog.Error("Unable to read value from file path", + "error", err, "file_path", header.FilePath) + } + } + + header.Value = value + newHeaders = append(newHeaders, header) + } + + return newHeaders +} + func isHealthExtensionSet() bool { return viperInstance.IsSet(CollectorExtensionsHealthKey) || (viperInstance.IsSet(CollectorExtensionsHealthServerHostKey) && diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 811a1226d..339a667e1 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -5,6 +5,7 @@ package config import ( + _ "embed" "errors" "os" "path" @@ -12,6 +13,8 @@ import ( "testing" "time" + conf "github.com/nginx/agent/v3/test/config" + "github.com/nginx/agent/v3/pkg/config" "github.com/nginx/agent/v3/test/helpers" @@ -635,6 +638,128 @@ func TestValidateYamlFile(t *testing.T) { } } +func TestResolveExtensions(t *testing.T) { + tests := []struct { + name string + value string + value2 string + path string + path2 string + expected []string + }{ + { + name: "Test 1: User includes a single value header only", + value: "super-secret-token", + path: "", + expected: []string{"super-secret-token"}, + }, + { + name: "Test 2: User includes a single filepath header only", + value: "", + path: "testdata/nginx-token.crt", + expected: []string{"super-secret-token"}, + }, + { + name: "Test 3: User includes both a single token and a single filepath header", + value: "very-secret-token", + path: "testdata/nginx-token.crt", + expected: []string{"very-secret-token"}, + }, + { + name: "Test 4: User includes neither token nor filepath header", + value: "", + path: "", + expected: []string{""}, + }, + { + name: "Test 5: User includes multiple headers", + value: "super-secret-token", + value2: "very-secret-token", + path: "", + path2: "", + expected: []string{"super-secret-token", "very-secret-token"}, + }, + } + + viperInstance = viper.NewWithOptions(viper.KeyDelimiter(KeyDelimiter)) + tempDir := t.TempDir() + var confContent []byte + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx-agent.conf") + defer helpers.RemoveFileWithErrorCheck(t, tempFile.Name()) + + if len(tt.expected) == 1 { + confContent = []byte(conf.GetAgentConfigWithToken(tt.value, tt.path)) + } else { + confContent = []byte(conf.AgentConfigWithMultipleHeaders(tt.value, tt.path, tt.value2, tt.path2)) + } + + _, writeErr := tempFile.Write(confContent) + require.NoError(t, writeErr) + + err := loadPropertiesFromFile(tempFile.Name()) + require.NoError(t, err) + + extension := resolveExtensions() + require.NotNil(t, extension) + + var result []string + for _, header := range extension.HeadersSetter.Headers { + result = append(result, header.Value) + } + + assert.Equal(t, tt.expected, result) + + err = tempFile.Close() + require.NoError(t, err) + }) + } +} + +func TestResolveExtensions_MultipleHeaders(t *testing.T) { + tests := []struct { + name string + token string + token2 string + path string + path2 string + expected string + }{ + { + name: "Test 1: User includes a single value header only", + token: "super-secret-token", + path: "", + expected: "super-secret-token", + }, + } + + viperInstance = viper.NewWithOptions(viper.KeyDelimiter(KeyDelimiter)) + tempDir := t.TempDir() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx-agent.conf") + defer helpers.RemoveFileWithErrorCheck(t, tempFile.Name()) + + confContent := []byte(conf.GetAgentConfigWithToken(tt.token, tt.path)) + _, writeErr := tempFile.Write(confContent) + require.NoError(t, writeErr) + + err := loadPropertiesFromFile(tempFile.Name()) + require.NoError(t, err) + + extension := resolveExtensions() + require.NotNil(t, extension) + assert.Equal(t, tt.expected, extension.HeadersSetter.Headers[0].Value) + + err = tempFile.Close() + require.NoError(t, err) + }) + } +} + func getAgentConfig() *Config { return &Config{ UUID: "", diff --git a/internal/config/testdata/nginx-token.crt b/internal/config/testdata/nginx-token.crt new file mode 100644 index 000000000..4b3786748 --- /dev/null +++ b/internal/config/testdata/nginx-token.crt @@ -0,0 +1 @@ +super-secret-token diff --git a/internal/config/types.go b/internal/config/types.go index fc59eda74..ed4f77c31 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -139,6 +139,7 @@ type ( Value string `yaml:"value" mapstructure:"value"` DefaultValue string `yaml:"default_value" mapstructure:"default_value"` FromContext string `yaml:"from_context" mapstructure:"from_context"` + FilePath string `yaml:"file_path" mapstructure:"file_path"` } DebugExporter struct{} diff --git a/nginx-agent.conf b/nginx-agent.conf index d92004849..b67980261 100644 --- a/nginx-agent.conf +++ b/nginx-agent.conf @@ -27,4 +27,3 @@ allowed_directories: # token: "" # tls: # skip_verify: false - diff --git a/test/config/agent/nginx-agent-with-multiple-headers.conf b/test/config/agent/nginx-agent-with-multiple-headers.conf new file mode 100644 index 000000000..455406e8f --- /dev/null +++ b/test/config/agent/nginx-agent-with-multiple-headers.conf @@ -0,0 +1,17 @@ +log: + level: info + +collector: + extensions: + headers_setter: + headers: + - action: insert + key: "authorization" + value: %s + file_path: %s + + - action: insert + key: "authorization" + value: %s + file_path: %s + diff --git a/test/config/agent/nginx-agent-with-token.conf b/test/config/agent/nginx-agent-with-token.conf new file mode 100644 index 000000000..69edb7004 --- /dev/null +++ b/test/config/agent/nginx-agent-with-token.conf @@ -0,0 +1,11 @@ +log: + level: info + +collector: + extensions: + headers_setter: + headers: + - action: insert + key: "authorization" + value: %s + file_path: %s diff --git a/test/config/nginx_config.go b/test/config/nginx_config.go index e8f6f32ec..a4b19c0db 100644 --- a/test/config/nginx_config.go +++ b/test/config/nginx_config.go @@ -25,6 +25,12 @@ var embedNginxConfWithMultipleSSLCerts string //go:embed nginx/nginx-ssl-certs-with-variables.conf var embedNginxConfWithSSLCertsWithVariables string +//go:embed agent/nginx-agent-with-token.conf +var agentConfigWithToken string + +//go:embed agent/nginx-agent-with-multiple-headers.conf +var agentConfigWithMultipleHeaders string + func GetNginxConfigWithMultipleAccessLogs( errorLogName, accessLogName, @@ -55,3 +61,11 @@ func GetNginxConfigWithSSLCerts(errorLogFile, accessLogFile, certFile string) st func GetNginxConfigWithMultipleSSLCerts(errorLogFile, accessLogFile, certFile1, certFile2 string) string { return fmt.Sprintf(embedNginxConfWithMultipleSSLCerts, errorLogFile, accessLogFile, certFile1, certFile2) } + +func GetAgentConfigWithToken(value, path string) string { + return fmt.Sprintf(agentConfigWithToken, value, path) +} + +func AgentConfigWithMultipleHeaders(value, path, value2, path2 string) string { + return fmt.Sprintf(agentConfigWithMultipleHeaders, value, path, value2, path2) +} From c58156b6ca607d807e6e957fc4283ae5ed7a756b Mon Sep 17 00:00:00 2001 From: Donal Hurley Date: Mon, 9 Jun 2025 11:00:17 +0100 Subject: [PATCH 12/28] Handle file close error (#1117) --- internal/file/file_manager_service.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/internal/file/file_manager_service.go b/internal/file/file_manager_service.go index e65ba6d2b..3140416e8 100644 --- a/internal/file/file_manager_service.go +++ b/internal/file/file_manager_service.go @@ -847,7 +847,7 @@ func (fms *FileManagerService) UpdateManifestFile(currentFiles map[string]*mpi.F return fms.writeManifestFile(updatedFiles) } -func (fms *FileManagerService) writeManifestFile(updatedFiles map[string]*model.ManifestFile) error { +func (fms *FileManagerService) writeManifestFile(updatedFiles map[string]*model.ManifestFile) (writeError error) { manifestJSON, err := json.MarshalIndent(updatedFiles, "", " ") if err != nil { return fmt.Errorf("unable to marshal manifest file json: %w", err) @@ -863,14 +863,18 @@ func (fms *FileManagerService) writeManifestFile(updatedFiles map[string]*model. if err != nil { return fmt.Errorf("failed to read manifest file: %w", err) } - defer newFile.Close() + defer func() { + if closeErr := newFile.Close(); closeErr != nil { + writeError = closeErr + } + }() _, err = newFile.Write(manifestJSON) if err != nil { return fmt.Errorf("failed to write manifest file: %w", err) } - return nil + return writeError } func (fms *FileManagerService) manifestFile() (map[string]*model.ManifestFile, map[string]*mpi.File, error) { From 75905a175dab131039f63a4cf3aa8654b40ac360 Mon Sep 17 00:00:00 2001 From: aphralG <108004222+aphralG@users.noreply.github.com> Date: Tue, 10 Jun 2025 14:17:59 +0100 Subject: [PATCH 13/28] Add source to logs in Debug Mode & Clean up function names (#1104) --- internal/bus/busfakes/fake_message_pipe.go | 12 +- internal/bus/message_pipe.go | 10 +- internal/bus/message_pipe_test.go | 4 +- .../scraper/cpuscraper/internal/cgroup/cpu.go | 8 +- .../memoryscraper/internal/cgroup/memory.go | 4 +- internal/collector/otel_collector_plugin.go | 14 +- .../collector/otel_collector_plugin_test.go | 6 +- internal/command/command_plugin.go | 18 ++- internal/command/command_plugin_test.go | 16 +- internal/command/command_service.go | 6 +- internal/command/command_service_test.go | 24 +-- internal/config/config.go | 4 +- internal/config/config_test.go | 16 +- internal/config/mapper_test.go | 114 +++++++------- internal/config/types_test.go | 2 +- .../config/nginx_config_parser_test.go | 70 ++++----- internal/datasource/host/info.go | 28 ++-- internal/datasource/proto/instance_test.go | 4 +- internal/file/file_manager_service.go | 28 ++-- internal/file/file_manager_service_test.go | 2 +- internal/file/file_plugin.go | 16 +- internal/file/file_plugin_test.go | 20 +-- internal/grpc/grpc.go | 12 +- internal/grpc/grpc_test.go | 30 ++-- internal/logger/logger.go | 36 +++-- internal/logger/logger_test.go | 6 +- .../resource/nginx_instance_operator_test.go | 6 +- internal/resource/nginx_plus_actions.go | 10 +- internal/resource/resource_plugin.go | 8 +- internal/resource/resource_plugin_test.go | 142 +++++++++--------- internal/resource/resource_service_test.go | 78 +++++----- .../credentials/credential_watcher_service.go | 2 +- internal/watcher/file/file_watcher_service.go | 2 +- .../watcher/health/health_watcher_service.go | 2 +- .../health/health_watcher_service_test.go | 64 ++++---- .../nginx_health_watcher_operator_test.go | 4 +- .../instance/instance_watcher_service.go | 4 +- .../instance/instance_watcher_service_test.go | 26 ++-- .../watcher/instance/nginx_process_parser.go | 30 ++-- .../instance/nginx_process_parser_test.go | 30 ++-- internal/watcher/watcher_plugin.go | 9 +- internal/watcher/watcher_plugin_test.go | 18 +-- test/config/nginx_config.go | 12 +- test/helpers/go_utils.go | 6 +- test/helpers/network_utils.go | 4 +- .../install_uninstall_test.go | 6 +- .../grpc_management_plane_api_test.go | 2 +- test/mock/grpc/cmd/main.go | 2 +- .../mock/grpc/mock_management_file_service.go | 8 +- test/mock/grpc/mock_management_server.go | 4 +- test/model/config.go | 8 +- test/protos/config.go | 2 +- test/protos/instances.go | 42 +++--- test/protos/resource.go | 30 ++-- test/types/config.go | 8 +- 55 files changed, 565 insertions(+), 514 deletions(-) diff --git a/internal/bus/busfakes/fake_message_pipe.go b/internal/bus/busfakes/fake_message_pipe.go index d0c697b30..7a16ebf24 100644 --- a/internal/bus/busfakes/fake_message_pipe.go +++ b/internal/bus/busfakes/fake_message_pipe.go @@ -39,14 +39,14 @@ func (p *FakeMessagePipe) DeRegister(ctx context.Context, pluginNames []string) plugins = p.findPlugins(pluginNames, plugins) for _, plugin := range plugins { - index := p.GetIndex(plugin.Info().Name, p.plugins) + index := p.Index(plugin.Info().Name, p.plugins) p.unsubscribePlugin(ctx, index, plugin) } return nil } -func (p *FakeMessagePipe) GetIndex(pluginName string, plugins []bus.Plugin) int { +func (p *FakeMessagePipe) Index(pluginName string, plugins []bus.Plugin) int { for index, plugin := range plugins { if pluginName == plugin.Info().Name { return index @@ -85,14 +85,14 @@ func (p *FakeMessagePipe) Process(_ context.Context, msgs ...*bus.Message) { p.messages = append(p.messages, msgs...) } -func (p *FakeMessagePipe) GetMessages() []*bus.Message { +func (p *FakeMessagePipe) Messages() []*bus.Message { p.messagesLock.Lock() defer p.messagesLock.Unlock() return p.messages } -func (p *FakeMessagePipe) GetProcessedMessages() []*bus.Message { +func (p *FakeMessagePipe) ProcessedMessages() []*bus.Message { return p.processedMessages } @@ -127,14 +127,14 @@ func (p *FakeMessagePipe) RunWithoutInit(ctx context.Context) { } } -func (p *FakeMessagePipe) GetPlugins() []bus.Plugin { +func (p *FakeMessagePipe) Plugins() []bus.Plugin { return p.plugins } func (p *FakeMessagePipe) IsPluginRegistered(pluginName string) bool { pluginAlreadyRegistered := false - for _, plugin := range p.GetPlugins() { + for _, plugin := range p.Plugins() { if plugin.Info().Name == pluginName { pluginAlreadyRegistered = true } diff --git a/internal/bus/message_pipe.go b/internal/bus/message_pipe.go index 483b39456..0cba3a173 100644 --- a/internal/bus/message_pipe.go +++ b/internal/bus/message_pipe.go @@ -35,7 +35,7 @@ type ( DeRegister(ctx context.Context, plugins []string) error Process(ctx context.Context, messages ...*Message) Run(ctx context.Context) - GetPlugins() []Plugin + Plugins() []Plugin IsPluginRegistered(pluginName string) bool } @@ -93,7 +93,7 @@ func (p *MessagePipe) DeRegister(ctx context.Context, pluginNames []string) erro plugins := p.findPlugins(pluginNames) for _, plugin := range plugins { - index := p.GetIndex(plugin.Info().Name, p.plugins) + index := p.Index(plugin.Info().Name, p.plugins) err := p.unsubscribePlugin(ctx, index, plugin) if err != nil { @@ -131,14 +131,14 @@ func (p *MessagePipe) Run(ctx context.Context) { } } -func (p *MessagePipe) GetPlugins() []Plugin { +func (p *MessagePipe) Plugins() []Plugin { return p.plugins } func (p *MessagePipe) IsPluginRegistered(pluginName string) bool { isPluginRegistered := false - for _, plugin := range p.GetPlugins() { + for _, plugin := range p.Plugins() { if plugin.Info().Name == pluginName { isPluginRegistered = true } @@ -181,7 +181,7 @@ func (p *MessagePipe) findPlugins(pluginNames []string) []Plugin { return plugins } -func (p *MessagePipe) GetIndex(pluginName string, plugins []Plugin) int { +func (p *MessagePipe) Index(pluginName string, plugins []Plugin) int { for index, plugin := range plugins { if pluginName == plugin.Info().Name { return index diff --git a/internal/bus/message_pipe_test.go b/internal/bus/message_pipe_test.go index 4bd91f5f3..d45ef6c9a 100644 --- a/internal/bus/message_pipe_test.go +++ b/internal/bus/message_pipe_test.go @@ -88,12 +88,12 @@ func TestMessagePipe_DeRegister(t *testing.T) { err := messagePipe.Register(100, []Plugin{plugin}) require.NoError(t, err) - assert.Len(t, messagePipe.GetPlugins(), 1) + assert.Len(t, messagePipe.Plugins(), 1) err = messagePipe.DeRegister(ctx, []string{plugin.Info().Name}) require.NoError(t, err) - assert.Empty(t, messagePipe.GetPlugins()) + assert.Empty(t, messagePipe.Plugins()) plugin.AssertExpectations(t) } diff --git a/internal/collector/containermetricsreceiver/internal/scraper/cpuscraper/internal/cgroup/cpu.go b/internal/collector/containermetricsreceiver/internal/scraper/cpuscraper/internal/cgroup/cpu.go index 90aa5cb70..2d398868f 100644 --- a/internal/collector/containermetricsreceiver/internal/scraper/cpuscraper/internal/cgroup/cpu.go +++ b/internal/collector/containermetricsreceiver/internal/scraper/cpuscraper/internal/cgroup/cpu.go @@ -74,7 +74,7 @@ func (cs *CPUSource) Collect() (ContainerCPUStats, error) { // nolint: mnd func (cs *CPUSource) collectCPUStats() (ContainerCPUStats, error) { - clockTicks, err := getClockTicks() + clockTicks, err := clockTicks() if err != nil { return ContainerCPUStats{}, err } @@ -107,7 +107,7 @@ func (cs *CPUSource) collectCPUStats() (ContainerCPUStats, error) { cpuTimes.userUsage = convertUsage(cpuTimes.userUsage) cpuTimes.systemUsage = convertUsage(cpuTimes.systemUsage) - hostSystemUsage, err := getSystemCPUUsage(clockTicks) + hostSystemUsage, err := systemCPUUsage(clockTicks) if err != nil { return ContainerCPUStats{}, err } @@ -162,7 +162,7 @@ func (cs *CPUSource) cpuUsageTimes(filePath, userKey, systemKey string) (*Contai } // nolint: revive, gocritic -func getSystemCPUUsage(clockTicks int) (float64, error) { +func systemCPUUsage(clockTicks int) (float64, error) { lines, err := internal.ReadLines(CPUStatsPath) if err != nil { return 0, err @@ -191,7 +191,7 @@ func getSystemCPUUsage(clockTicks int) (float64, error) { return 0, errors.New("unable to process " + CPUStatsPath + ". No cpu found") } -func getClockTicks() (int, error) { +func clockTicks() (int, error) { cmd := exec.Command("getconf", "CLK_TCK") out := new(bytes.Buffer) cmd.Stdout = out diff --git a/internal/collector/containermetricsreceiver/internal/scraper/memoryscraper/internal/cgroup/memory.go b/internal/collector/containermetricsreceiver/internal/scraper/memoryscraper/internal/cgroup/memory.go index f9be934a1..9d86c0957 100644 --- a/internal/collector/containermetricsreceiver/internal/scraper/memoryscraper/internal/cgroup/memory.go +++ b/internal/collector/containermetricsreceiver/internal/scraper/memoryscraper/internal/cgroup/memory.go @@ -94,7 +94,7 @@ func (ms *MemorySource) VirtualMemoryStatWithContext(ctx context.Context) (*mem. return &mem.VirtualMemoryStat{}, err } - memoryStat, err = GetMemoryStat( + memoryStat, err = CalculateMemoryStat( path.Join(ms.basePath, memStatFile), memCachedKey, memSharedKey, @@ -147,7 +147,7 @@ func MemoryLimitInBytes(ctx context.Context, filePath string) (uint64, error) { } // nolint: revive, mnd -func GetMemoryStat(statFile, cachedKey, sharedKey string) (MemoryStat, error) { +func CalculateMemoryStat(statFile, cachedKey, sharedKey string) (MemoryStat, error) { memoryStat := MemoryStat{} lines, err := internal.ReadLines(statFile) if err != nil { diff --git a/internal/collector/otel_collector_plugin.go b/internal/collector/otel_collector_plugin.go index d980375aa..e41a66a4d 100644 --- a/internal/collector/otel_collector_plugin.go +++ b/internal/collector/otel_collector_plugin.go @@ -93,7 +93,7 @@ func New(conf *config.Config) (*Collector, error) { }, nil } -func (oc *Collector) GetState() otelcol.State { +func (oc *Collector) State() otelcol.State { oc.mu.Lock() defer oc.mu.Unlock() @@ -263,6 +263,7 @@ func (oc *Collector) Subscriptions() []string { } func (oc *Collector) handleNginxConfigUpdate(ctx context.Context, msg *bus.Message) { + slog.DebugContext(ctx, "OTel collector plugin received nginx config update message") oc.mu.Lock() defer oc.mu.Unlock() @@ -287,6 +288,7 @@ func (oc *Collector) handleNginxConfigUpdate(ctx context.Context, msg *bus.Messa } func (oc *Collector) handleResourceUpdate(ctx context.Context, msg *bus.Message) { + slog.DebugContext(ctx, "OTel collector plugin received resource update message") oc.mu.Lock() defer oc.mu.Unlock() @@ -407,6 +409,7 @@ func (oc *Collector) checkForNewReceivers(ctx context.Context, nginxConfigContex nginxReceiverFound, reloadCollector := oc.updateExistingNginxPlusReceiver(nginxConfigContext) if !nginxReceiverFound && nginxConfigContext.PlusAPI.URL != "" { + slog.DebugContext(ctx, "Adding new NGINX Plus receiver", "url", nginxConfigContext.PlusAPI.URL) oc.config.Collector.Receivers.NginxPlusReceivers = append( oc.config.Collector.Receivers.NginxPlusReceivers, config.NginxPlusReceiver{ @@ -443,6 +446,7 @@ func (oc *Collector) addNginxOssReceiver(ctx context.Context, nginxConfigContext nginxReceiverFound, reloadCollector := oc.updateExistingNginxOSSReceiver(nginxConfigContext) if !nginxReceiverFound && nginxConfigContext.StubStatus.URL != "" { + slog.DebugContext(ctx, "Adding new NGINX OSS receiver", "url", nginxConfigContext.StubStatus.URL) oc.config.Collector.Receivers.NginxReceivers = append( oc.config.Collector.Receivers.NginxReceivers, config.NginxReceiver{ @@ -479,6 +483,8 @@ func (oc *Collector) updateExistingNginxPlusReceiver( oc.config.Collector.Receivers.NginxPlusReceivers[index+1:]..., ) if nginxConfigContext.PlusAPI.URL != "" { + slog.Debug("Updating existing NGINX Plus receiver", "url", + nginxConfigContext.PlusAPI.URL) nginxPlusReceiver.PlusAPI.URL = nginxConfigContext.PlusAPI.URL oc.config.Collector.Receivers.NginxPlusReceivers = append( oc.config.Collector.Receivers.NginxPlusReceivers, @@ -510,6 +516,8 @@ func (oc *Collector) updateExistingNginxOSSReceiver( oc.config.Collector.Receivers.NginxReceivers[index+1:]..., ) if nginxConfigContext.StubStatus.URL != "" { + slog.Debug("Updating existing NGINX OSS receiver", "url", + nginxConfigContext.StubStatus.URL) nginxReceiver.StubStatus = config.APIDetails{ URL: nginxConfigContext.StubStatus.URL, Listen: nginxConfigContext.StubStatus.Listen, @@ -587,7 +595,7 @@ func (oc *Collector) updateTcplogReceivers(nginxConfigContext *model.NginxConfig } func (oc *Collector) areNapReceiversDeleted(nginxConfigContext *model.NginxConfigContext) bool { - listenAddressesToBeDeleted := oc.getConfigDeletedNapReceivers(nginxConfigContext) + listenAddressesToBeDeleted := oc.configDeletedNapReceivers(nginxConfigContext) if len(listenAddressesToBeDeleted) != 0 { oc.deleteNapReceivers(listenAddressesToBeDeleted) return true @@ -606,7 +614,7 @@ func (oc *Collector) deleteNapReceivers(listenAddressesToBeDeleted map[string]bo oc.config.Collector.Receivers.TcplogReceivers = filteredReceivers } -func (oc *Collector) getConfigDeletedNapReceivers(nginxConfigContext *model.NginxConfigContext) map[string]bool { +func (oc *Collector) configDeletedNapReceivers(nginxConfigContext *model.NginxConfigContext) map[string]bool { elements := make(map[string]bool) for _, tcplogReceiver := range oc.config.Collector.Receivers.TcplogReceivers { diff --git a/internal/collector/otel_collector_plugin_test.go b/internal/collector/otel_collector_plugin_test.go index 85afd57ff..df8bf033c 100644 --- a/internal/collector/otel_collector_plugin_test.go +++ b/internal/collector/otel_collector_plugin_test.go @@ -146,11 +146,11 @@ func TestCollector_InitAndClose(t *testing.T) { collector.service = createFakeCollector() - assert.Equal(t, otelcol.StateRunning, collector.GetState()) + assert.Equal(t, otelcol.StateRunning, collector.State()) require.NoError(t, collector.Close(ctx), "Close should not return an error") - assert.Equal(t, otelcol.StateClosed, collector.GetState()) + assert.Equal(t, otelcol.StateClosed, collector.State()) } // nolint: revive @@ -347,7 +347,7 @@ func TestCollector_ProcessResourceUpdateTopic(t *testing.T) { name: "Test 1: Resource update adds resource id attribute", message: &bus.Message{ Topic: bus.ResourceUpdateTopic, - Data: protos.GetHostResource(), + Data: protos.HostResource(), }, processors: config.Processors{ Resource: &config.Resource{ diff --git a/internal/command/command_plugin.go b/internal/command/command_plugin.go index a1b656fa5..1311cd5e7 100644 --- a/internal/command/command_plugin.go +++ b/internal/command/command_plugin.go @@ -98,11 +98,12 @@ func (cp *CommandPlugin) Process(ctx context.Context, msg *bus.Message) { case bus.DataPlaneResponseTopic: cp.processDataPlaneResponse(ctx, msg) default: - slog.DebugContext(ctx, "Command plugin unknown topic", "topic", msg.Topic) + slog.DebugContext(ctx, "Command plugin received unknown topic", "topic", msg.Topic) } } func (cp *CommandPlugin) processResourceUpdate(ctx context.Context, msg *bus.Message) { + slog.DebugContext(ctx, "Command plugin received resource update message") if resource, ok := msg.Data.(*mpi.Resource); ok { if !cp.commandService.IsConnected() { cp.createConnection(ctx, resource) @@ -138,9 +139,10 @@ func (cp *CommandPlugin) createConnection(ctx context.Context, resource *mpi.Res } func (cp *CommandPlugin) processDataPlaneHealth(ctx context.Context, msg *bus.Message) { + slog.DebugContext(ctx, "Command plugin received data plane health message") if instances, ok := msg.Data.([]*mpi.InstanceHealth); ok { err := cp.commandService.UpdateDataPlaneHealth(ctx, instances) - correlationID := logger.GetCorrelationID(ctx) + correlationID := logger.CorrelationID(ctx) if err != nil { slog.ErrorContext(ctx, "Unable to update data plane health", "error", err) cp.messagePipe.Process(ctx, &bus.Message{ @@ -152,12 +154,13 @@ func (cp *CommandPlugin) processDataPlaneHealth(ctx context.Context, msg *bus.Me cp.messagePipe.Process(ctx, &bus.Message{ Topic: bus.DataPlaneResponseTopic, Data: cp.createDataPlaneResponse(correlationID, mpi.CommandResponse_COMMAND_STATUS_OK, - "Successfully sent the health status update", ""), + "Successfully sent health status update", ""), }) } } func (cp *CommandPlugin) processInstanceHealth(ctx context.Context, msg *bus.Message) { + slog.DebugContext(ctx, "Command plugin received instance health message") if instances, ok := msg.Data.([]*mpi.InstanceHealth); ok { err := cp.commandService.UpdateDataPlaneHealth(ctx, instances) if err != nil { @@ -167,7 +170,10 @@ func (cp *CommandPlugin) processInstanceHealth(ctx context.Context, msg *bus.Mes } func (cp *CommandPlugin) processDataPlaneResponse(ctx context.Context, msg *bus.Message) { + slog.DebugContext(ctx, "Command plugin received data plane response message") if response, ok := msg.Data.(*mpi.DataPlaneResponse); ok { + slog.InfoContext(ctx, "Sending data plane response message", "message", + response.GetCommandResponse().GetMessage(), "status", response.GetCommandResponse().GetStatus()) err := cp.commandService.SendDataPlaneResponse(ctx, response) if err != nil { slog.ErrorContext(ctx, "Unable to send data plane response", "error", err) @@ -176,7 +182,7 @@ func (cp *CommandPlugin) processDataPlaneResponse(ctx context.Context, msg *bus. } func (cp *CommandPlugin) processConnectionReset(ctx context.Context, msg *bus.Message) { - slog.DebugContext(ctx, "Command plugin received connection reset") + slog.DebugContext(ctx, "Command plugin received connection reset message") if newConnection, ok := msg.Data.(grpc.GrpcConnectionInterface); ok { connectionErr := cp.conn.Close(ctx) if connectionErr != nil { @@ -217,12 +223,16 @@ func (cp *CommandPlugin) monitorSubscribeChannel(ctx context.Context) { switch message.GetRequest().(type) { case *mpi.ManagementPlaneRequest_ConfigUploadRequest: + slog.InfoContext(ctx, "Received management plane config upload request") cp.handleConfigUploadRequest(newCtx, message) case *mpi.ManagementPlaneRequest_ConfigApplyRequest: + slog.InfoContext(ctx, "Received management plane config apply request") cp.handleConfigApplyRequest(newCtx, message) case *mpi.ManagementPlaneRequest_HealthRequest: + slog.InfoContext(ctx, "Received management plane health request") cp.handleHealthRequest(newCtx) case *mpi.ManagementPlaneRequest_ActionRequest: + slog.InfoContext(ctx, "Received management plane action request") cp.handleAPIActionRequest(newCtx, message) default: slog.DebugContext(newCtx, "Management plane request not implemented yet") diff --git a/internal/command/command_plugin_test.go b/internal/command/command_plugin_test.go index de3a94edd..d3d0c51da 100644 --- a/internal/command/command_plugin_test.go +++ b/internal/command/command_plugin_test.go @@ -96,12 +96,12 @@ func TestCommandPlugin_createConnection(t *testing.T) { assert.Eventually( t, - func() bool { return len(messagePipe.GetMessages()) == 1 }, + func() bool { return len(messagePipe.Messages()) == 1 }, 2*time.Second, 10*time.Millisecond, ) - messages := messagePipe.GetMessages() + messages := messagePipe.Messages() assert.Len(t, messages, 1) assert.Equal(t, bus.ConnectionCreatedTopic, messages[0].Topic) } @@ -125,13 +125,13 @@ func TestCommandPlugin_Process(t *testing.T) { commandPlugin.commandService = fakeCommandService - commandPlugin.Process(ctx, &bus.Message{Topic: bus.ResourceUpdateTopic, Data: protos.GetHostResource()}) + 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.GetHostResource()}) + 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.GetInstanceHealths()}) + 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()}) @@ -139,7 +139,7 @@ func TestCommandPlugin_Process(t *testing.T) { commandPlugin.Process(ctx, &bus.Message{ Topic: bus.DataPlaneHealthResponseTopic, - Data: protos.GetHealthyInstanceHealth(), + Data: protos.HealthyInstanceHealth(), }) require.Equal(t, 1, fakeCommandService.UpdateDataPlaneHealthCallCount()) require.Equal(t, 1, fakeCommandService.SendDataPlaneResponseCallCount()) @@ -230,12 +230,12 @@ func TestCommandPlugin_monitorSubscribeChannel(t *testing.T) { assert.Eventually( t, - func() bool { return len(messagePipe.GetMessages()) == 1 }, + func() bool { return len(messagePipe.Messages()) == 1 }, 2*time.Second, 10*time.Millisecond, ) - messages := messagePipe.GetMessages() + messages := messagePipe.Messages() assert.Len(tt, messages, 1) assert.Equal(tt, test.expectedTopic.Topic, messages[0].Topic) diff --git a/internal/command/command_service.go b/internal/command/command_service.go index 2aa80284c..5caa431d5 100644 --- a/internal/command/command_service.go +++ b/internal/command/command_service.go @@ -78,7 +78,7 @@ func (cs *CommandService) UpdateDataPlaneStatus( ctx context.Context, resource *mpi.Resource, ) error { - correlationID := logger.GetCorrelationID(ctx) + correlationID := logger.CorrelationID(ctx) if !cs.isConnected.Load() { return errors.New("command service client not connected yet") } @@ -138,7 +138,7 @@ func (cs *CommandService) UpdateDataPlaneHealth(ctx context.Context, instanceHea return errors.New("command service client not connected yet") } - correlationID := logger.GetCorrelationID(ctx) + correlationID := logger.CorrelationID(ctx) request := &mpi.UpdateDataPlaneHealthRequest{ MessageMeta: &mpi.MessageMeta{ @@ -208,7 +208,7 @@ func (cs *CommandService) CreateConnection( ctx context.Context, resource *mpi.Resource, ) (*mpi.CreateConnectionResponse, error) { - correlationID := logger.GetCorrelationID(ctx) + correlationID := logger.CorrelationID(ctx) if len(resource.GetInstances()) <= 1 { slog.InfoContext(ctx, "No Data Plane Instance found") } diff --git a/internal/command/command_service_test.go b/internal/command/command_service_test.go index bcdc7c714..6226585b6 100644 --- a/internal/command/command_service_test.go +++ b/internal/command/command_service_test.go @@ -56,7 +56,7 @@ func (*FakeConfigApplySubscribeClient) Send(*mpi.DataPlaneResponse) error { // nolint: nilnil func (*FakeConfigApplySubscribeClient) Recv() (*mpi.ManagementPlaneRequest, error) { - nginxInstance := protos.GetNginxOssInstance([]string{}) + nginxInstance := protos.NginxOssInstance([]string{}) return &mpi.ManagementPlaneRequest{ MessageMeta: &mpi.MessageMeta{ @@ -95,7 +95,7 @@ func TestCommandService_receiveCallback_configApplyRequest(t *testing.T) { go commandService.Subscribe(subscribeCtx) defer subscribeCancel() - nginxInstance := protos.GetNginxOssInstance([]string{}) + nginxInstance := protos.NginxOssInstance([]string{}) commandService.resourceMutex.Lock() commandService.resource.Instances = append(commandService.resource.Instances, nginxInstance) commandService.resourceMutex.Unlock() @@ -136,11 +136,11 @@ func TestCommandService_UpdateDataPlaneStatus(t *testing.T) { make(chan *mpi.ManagementPlaneRequest), ) // Fail first time since there are no other instances besides the agent - err := commandService.UpdateDataPlaneStatus(ctx, protos.GetHostResource()) + err := commandService.UpdateDataPlaneStatus(ctx, protos.HostResource()) require.Error(t, err) - resource := protos.GetHostResource() - resource.Instances = append(resource.Instances, protos.GetNginxOssInstance([]string{})) + 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) @@ -174,7 +174,7 @@ func TestCommandService_UpdateDataPlaneStatusSubscribeError(t *testing.T) { commandService.isConnected.Store(true) - err := commandService.UpdateDataPlaneStatus(ctx, protos.GetHostResource()) + err := commandService.UpdateDataPlaneStatus(ctx, protos.HostResource()) require.Error(t, err) helpers.ValidateLog(t, "Failed to send update data plane status", logBuf) @@ -193,7 +193,7 @@ func TestCommandService_CreateConnection(t *testing.T) { ) // connection created when no nginx instance found - resource := protos.GetHostResource() + resource := protos.HostResource() _, err := commandService.CreateConnection(ctx, resource) require.NoError(t, err) } @@ -223,19 +223,19 @@ func TestCommandService_UpdateDataPlaneHealth(t *testing.T) { ) // connection not created yet - err := commandService.UpdateDataPlaneHealth(ctx, protos.GetInstanceHealths()) + err := commandService.UpdateDataPlaneHealth(ctx, protos.InstanceHealths()) require.Error(t, err) assert.Equal(t, 0, commandServiceClient.UpdateDataPlaneHealthCallCount()) // connection created - resource := protos.GetHostResource() - resource.Instances = append(resource.Instances, protos.GetNginxOssInstance([]string{})) + 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.GetInstanceHealths()) + err = commandService.UpdateDataPlaneHealth(ctx, protos.InstanceHealths()) require.NoError(t, err) assert.Equal(t, 1, commandServiceClient.UpdateDataPlaneHealthCallCount()) @@ -393,7 +393,7 @@ func TestCommandService_isValidRequest(t *testing.T) { commandService.subscribeClient = subscribeClient commandService.subscribeClientMutex.Unlock() - nginxInstance := protos.GetNginxOssInstance([]string{}) + nginxInstance := protos.NginxOssInstance([]string{}) commandService.resourceMutex.Lock() commandService.resource.Instances = append(commandService.resource.Instances, nginxInstance) diff --git a/internal/config/config.go b/internal/config/config.go index 8f27ddb74..63dd0c87c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -56,7 +56,7 @@ func Init(version, commit string) { } func RegisterConfigFile() error { - configPath, err := seekFileInPaths(ConfigFileName, getConfigFilePaths()...) + configPath, err := seekFileInPaths(ConfigFileName, configFilePaths()...) if err != nil { return err } @@ -543,7 +543,7 @@ func seekFileInPaths(fileName string, directories ...string) (string, error) { return "", fmt.Errorf("a valid configuration has not been found in any of the search paths") } -func getConfigFilePaths() []string { +func configFilePaths() []string { paths := []string{ "/etc/nginx-agent/", } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 339a667e1..6ec1f7775 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -130,7 +130,7 @@ func TestResolveConfigFilePaths(t *testing.T) { currentDirectory, err := os.Getwd() require.NoError(t, err) - result := getConfigFilePaths() + result := configFilePaths() assert.Len(t, result, 2) assert.Equal(t, "/etc/nginx-agent/", result[0]) @@ -177,7 +177,7 @@ func TestResolveClient(t *testing.T) { } func TestResolveCollector(t *testing.T) { - testDefault := getAgentConfig() + testDefault := agentConfig() t.Run("Test 1: Happy path", func(t *testing.T) { expected := testDefault.Collector @@ -220,7 +220,7 @@ func TestResolveCollector(t *testing.T) { func TestCommand(t *testing.T) { viperInstance = viper.NewWithOptions(viper.KeyDelimiter(KeyDelimiter)) - expected := getAgentConfig().Command + expected := agentConfig().Command // Server viperInstance.Set(CommandServerHostKey, expected.Server.Host) @@ -253,7 +253,7 @@ func TestCommand(t *testing.T) { func TestMissingServerTLS(t *testing.T) { viperInstance = viper.NewWithOptions(viper.KeyDelimiter(KeyDelimiter)) - expected := getAgentConfig().Command + expected := agentConfig().Command viperInstance.Set(CommandServerHostKey, expected.Server.Host) viperInstance.Set(CommandServerPortKey, expected.Server.Port) @@ -272,7 +272,7 @@ func TestMissingServerTLS(t *testing.T) { func TestClient(t *testing.T) { viperInstance = viper.NewWithOptions(viper.KeyDelimiter(KeyDelimiter)) - expected := getAgentConfig().Client + expected := agentConfig().Client viperInstance.Set(ClientGRPCMaxMessageSizeKey, expected.Grpc.MaxMessageSize) viperInstance.Set(ClientKeepAlivePermitWithoutStreamKey, expected.Grpc.KeepAlive.PermitWithoutStream) @@ -691,7 +691,7 @@ func TestResolveExtensions(t *testing.T) { defer helpers.RemoveFileWithErrorCheck(t, tempFile.Name()) if len(tt.expected) == 1 { - confContent = []byte(conf.GetAgentConfigWithToken(tt.value, tt.path)) + confContent = []byte(conf.AgentConfigWithToken(tt.value, tt.path)) } else { confContent = []byte(conf.AgentConfigWithMultipleHeaders(tt.value, tt.path, tt.value2, tt.path2)) } @@ -743,7 +743,7 @@ func TestResolveExtensions_MultipleHeaders(t *testing.T) { tempFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx-agent.conf") defer helpers.RemoveFileWithErrorCheck(t, tempFile.Name()) - confContent := []byte(conf.GetAgentConfigWithToken(tt.token, tt.path)) + confContent := []byte(conf.AgentConfigWithToken(tt.token, tt.path)) _, writeErr := tempFile.Write(confContent) require.NoError(t, writeErr) @@ -760,7 +760,7 @@ func TestResolveExtensions_MultipleHeaders(t *testing.T) { } } -func getAgentConfig() *Config { +func agentConfig() *Config { return &Config{ UUID: "", Version: "", diff --git a/internal/config/mapper_test.go b/internal/config/mapper_test.go index 021bcd5d8..6217ed3dd 100644 --- a/internal/config/mapper_test.go +++ b/internal/config/mapper_test.go @@ -21,23 +21,23 @@ func TestFromCommandProto(t *testing.T) { name: "Test 1: Valid input with all fields", protoConfig: &mpi.CommandServer{ Server: &mpi.ServerSettings{ - Host: getAgentConfig().Command.Server.Host, - Port: int32(getAgentConfig().Command.Server.Port), + Host: agentConfig().Command.Server.Host, + Port: int32(agentConfig().Command.Server.Port), Type: mpi.ServerSettings_SERVER_SETTINGS_TYPE_GRPC, }, Auth: &mpi.AuthSettings{}, Tls: &mpi.TLSSettings{ - Cert: getAgentConfig().Command.TLS.Cert, - Key: getAgentConfig().Command.TLS.Key, - Ca: getAgentConfig().Command.TLS.Ca, - ServerName: getAgentConfig().Command.TLS.ServerName, - SkipVerify: getAgentConfig().Command.TLS.SkipVerify, + Cert: agentConfig().Command.TLS.Cert, + Key: agentConfig().Command.TLS.Key, + Ca: agentConfig().Command.TLS.Ca, + ServerName: agentConfig().Command.TLS.ServerName, + SkipVerify: agentConfig().Command.TLS.SkipVerify, }, }, expected: &Command{ - Server: getAgentConfig().Command.Server, + Server: agentConfig().Command.Server, Auth: nil, - TLS: getAgentConfig().Command.TLS, + TLS: agentConfig().Command.TLS, }, }, { @@ -45,53 +45,53 @@ func TestFromCommandProto(t *testing.T) { protoConfig: &mpi.CommandServer{ Auth: &mpi.AuthSettings{}, Tls: &mpi.TLSSettings{ - Cert: getAgentConfig().Command.TLS.Cert, - Key: getAgentConfig().Command.TLS.Key, - Ca: getAgentConfig().Command.TLS.Ca, - ServerName: getAgentConfig().Command.TLS.ServerName, - SkipVerify: getAgentConfig().Command.TLS.SkipVerify, + Cert: agentConfig().Command.TLS.Cert, + Key: agentConfig().Command.TLS.Key, + Ca: agentConfig().Command.TLS.Ca, + ServerName: agentConfig().Command.TLS.ServerName, + SkipVerify: agentConfig().Command.TLS.SkipVerify, }, }, expected: &Command{ Server: nil, Auth: nil, - TLS: getAgentConfig().Command.TLS, + TLS: agentConfig().Command.TLS, }, }, { name: "Test 3: Missing auth", protoConfig: &mpi.CommandServer{ Server: &mpi.ServerSettings{ - Host: getAgentConfig().Command.Server.Host, - Port: int32(getAgentConfig().Command.Server.Port), + Host: agentConfig().Command.Server.Host, + Port: int32(agentConfig().Command.Server.Port), Type: mpi.ServerSettings_SERVER_SETTINGS_TYPE_GRPC, }, Tls: &mpi.TLSSettings{ - Cert: getAgentConfig().Command.TLS.Cert, - Key: getAgentConfig().Command.TLS.Key, - Ca: getAgentConfig().Command.TLS.Ca, - ServerName: getAgentConfig().Command.TLS.ServerName, - SkipVerify: getAgentConfig().Command.TLS.SkipVerify, + Cert: agentConfig().Command.TLS.Cert, + Key: agentConfig().Command.TLS.Key, + Ca: agentConfig().Command.TLS.Ca, + ServerName: agentConfig().Command.TLS.ServerName, + SkipVerify: agentConfig().Command.TLS.SkipVerify, }, }, expected: &Command{ - Server: getAgentConfig().Command.Server, + Server: agentConfig().Command.Server, Auth: nil, - TLS: getAgentConfig().Command.TLS, + TLS: agentConfig().Command.TLS, }, }, { name: "Test 4: Missing TLS", protoConfig: &mpi.CommandServer{ Server: &mpi.ServerSettings{ - Host: getAgentConfig().Command.Server.Host, - Port: int32(getAgentConfig().Command.Server.Port), + Host: agentConfig().Command.Server.Host, + Port: int32(agentConfig().Command.Server.Port), Type: mpi.ServerSettings_SERVER_SETTINGS_TYPE_GRPC, }, Auth: &mpi.AuthSettings{}, }, expected: &Command{ - Server: getAgentConfig().Command.Server, + Server: agentConfig().Command.Server, Auth: nil, TLS: nil, }, @@ -124,23 +124,23 @@ func TestToCommandProto(t *testing.T) { { name: "Test 1: Valid input with all fields", cmd: &Command{ - Server: getAgentConfig().Command.Server, - Auth: getAgentConfig().Command.Auth, - TLS: getAgentConfig().Command.TLS, + Server: agentConfig().Command.Server, + Auth: agentConfig().Command.Auth, + TLS: agentConfig().Command.TLS, }, expected: &mpi.CommandServer{ Server: &mpi.ServerSettings{ - Host: getAgentConfig().Command.Server.Host, - Port: int32(getAgentConfig().Command.Server.Port), + Host: agentConfig().Command.Server.Host, + Port: int32(agentConfig().Command.Server.Port), Type: mpi.ServerSettings_SERVER_SETTINGS_TYPE_GRPC, }, Auth: &mpi.AuthSettings{}, Tls: &mpi.TLSSettings{ - Cert: getAgentConfig().Command.TLS.Cert, - Key: getAgentConfig().Command.TLS.Key, - Ca: getAgentConfig().Command.TLS.Ca, - ServerName: getAgentConfig().Command.TLS.ServerName, - SkipVerify: getAgentConfig().Command.TLS.SkipVerify, + Cert: agentConfig().Command.TLS.Cert, + Key: agentConfig().Command.TLS.Key, + Ca: agentConfig().Command.TLS.Ca, + ServerName: agentConfig().Command.TLS.ServerName, + SkipVerify: agentConfig().Command.TLS.SkipVerify, }, }, }, @@ -148,54 +148,54 @@ func TestToCommandProto(t *testing.T) { name: "Test 2: Missing server", cmd: &Command{ Server: nil, - Auth: getAgentConfig().Command.Auth, - TLS: getAgentConfig().Command.TLS, + Auth: agentConfig().Command.Auth, + TLS: agentConfig().Command.TLS, }, expected: &mpi.CommandServer{ Server: nil, Auth: &mpi.AuthSettings{}, Tls: &mpi.TLSSettings{ - Cert: getAgentConfig().Command.TLS.Cert, - Key: getAgentConfig().Command.TLS.Key, - Ca: getAgentConfig().Command.TLS.Ca, - ServerName: getAgentConfig().Command.TLS.ServerName, - SkipVerify: getAgentConfig().Command.TLS.SkipVerify, + Cert: agentConfig().Command.TLS.Cert, + Key: agentConfig().Command.TLS.Key, + Ca: agentConfig().Command.TLS.Ca, + ServerName: agentConfig().Command.TLS.ServerName, + SkipVerify: agentConfig().Command.TLS.SkipVerify, }, }, }, { name: "Test 3: Missing auth", cmd: &Command{ - Server: getAgentConfig().Command.Server, + Server: agentConfig().Command.Server, Auth: nil, - TLS: getAgentConfig().Command.TLS, + TLS: agentConfig().Command.TLS, }, expected: &mpi.CommandServer{ Server: &mpi.ServerSettings{ - Host: getAgentConfig().Command.Server.Host, - Port: int32(getAgentConfig().Command.Server.Port), + Host: agentConfig().Command.Server.Host, + Port: int32(agentConfig().Command.Server.Port), Type: mpi.ServerSettings_SERVER_SETTINGS_TYPE_GRPC, }, Tls: &mpi.TLSSettings{ - Cert: getAgentConfig().Command.TLS.Cert, - Key: getAgentConfig().Command.TLS.Key, - Ca: getAgentConfig().Command.TLS.Ca, - ServerName: getAgentConfig().Command.TLS.ServerName, - SkipVerify: getAgentConfig().Command.TLS.SkipVerify, + Cert: agentConfig().Command.TLS.Cert, + Key: agentConfig().Command.TLS.Key, + Ca: agentConfig().Command.TLS.Ca, + ServerName: agentConfig().Command.TLS.ServerName, + SkipVerify: agentConfig().Command.TLS.SkipVerify, }, }, }, { name: "Test 4: Missing TLS", cmd: &Command{ - Server: getAgentConfig().Command.Server, - Auth: getAgentConfig().Command.Auth, + Server: agentConfig().Command.Server, + Auth: agentConfig().Command.Auth, TLS: nil, }, expected: &mpi.CommandServer{ Server: &mpi.ServerSettings{ - Host: getAgentConfig().Command.Server.Host, - Port: int32(getAgentConfig().Command.Server.Port), + Host: agentConfig().Command.Server.Host, + Port: int32(agentConfig().Command.Server.Port), Type: mpi.ServerSettings_SERVER_SETTINGS_TYPE_GRPC, }, Auth: &mpi.AuthSettings{}, diff --git a/internal/config/types_test.go b/internal/config/types_test.go index 657d09fea..46e2c7f61 100644 --- a/internal/config/types_test.go +++ b/internal/config/types_test.go @@ -12,7 +12,7 @@ import ( ) func TestTypes_IsDirectoryAllowed(t *testing.T) { - config := getAgentConfig() + config := agentConfig() tests := []struct { name string diff --git a/internal/datasource/config/nginx_config_parser_test.go b/internal/datasource/config/nginx_config_parser_test.go index c388b2ae1..1474fce06 100644 --- a/internal/datasource/config/nginx_config_parser_test.go +++ b/internal/datasource/config/nginx_config_parser_test.go @@ -319,19 +319,19 @@ func TestNginxConfigParser_Parse(t *testing.T) { }{ { name: "Test 1: Valid response", - instance: protos.GetNginxOssInstance([]string{}), - content: testconfig.GetNginxConfigWithMultipleAccessLogs( + instance: protos.NginxOssInstance([]string{}), + content: testconfig.NginxConfigWithMultipleAccessLogs( errorLog.Name(), accessLog.Name(), combinedAccessLog.Name(), ltsvAccessLog.Name(), ), - expectedConfigContext: modelHelpers.GetConfigContextWithNames( + expectedConfigContext: modelHelpers.ConfigContextWithNames( accessLog.Name(), combinedAccessLog.Name(), ltsvAccessLog.Name(), errorLog.Name(), - protos.GetNginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId(), + protos.NginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId(), []string{"127.0.0.1:1515"}, ), expectedLog: "", @@ -339,19 +339,19 @@ func TestNginxConfigParser_Parse(t *testing.T) { }, { name: "Test 2: Error response", - instance: protos.GetNginxPlusInstance([]string{}), - content: testconfig.GetNginxConfigWithMultipleAccessLogs( + instance: protos.NginxPlusInstance([]string{}), + content: testconfig.NginxConfigWithMultipleAccessLogs( errorLog.Name(), accessLog.Name(), combinedAccessLog.Name(), ltsvAccessLog.Name(), ), - expectedConfigContext: modelHelpers.GetConfigContextWithNames( + expectedConfigContext: modelHelpers.ConfigContextWithNames( accessLog.Name(), combinedAccessLog.Name(), ltsvAccessLog.Name(), errorLog.Name(), - protos.GetNginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId(), + protos.NginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId(), []string{"127.0.0.1:1515"}, ), expectedLog: "", @@ -359,14 +359,14 @@ func TestNginxConfigParser_Parse(t *testing.T) { }, { name: "Test 3: File outside allowed directories", - instance: protos.GetNginxPlusInstance([]string{}), - content: testconfig.GetNginxConfigWithNotAllowedDir(errorLog.Name(), allowedFile.Name(), + instance: protos.NginxPlusInstance([]string{}), + content: testconfig.NginxConfigWithNotAllowedDir(errorLog.Name(), allowedFile.Name(), notAllowedFile.Name(), accessLog.Name()), - expectedConfigContext: modelHelpers.GetConfigContextWithFiles( + expectedConfigContext: modelHelpers.ConfigContextWithFiles( accessLog.Name(), errorLog.Name(), []*mpi.File{&allowedFileWithMetas}, - protos.GetNginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId(), + protos.NginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId(), nil, ), expectedLog: "", @@ -374,12 +374,12 @@ func TestNginxConfigParser_Parse(t *testing.T) { }, { name: "Test 4: SSL Certificate file path containing variables", - instance: protos.GetNginxPlusInstance([]string{}), - content: testconfig.GetNginxConfWithSSLCertsWithVariables(), + instance: protos.NginxPlusInstance([]string{}), + content: testconfig.NginxConfWithSSLCertsWithVariables(), expectedConfigContext: &model.NginxConfigContext{ StubStatus: &model.APIDetails{}, PlusAPI: &model.APIDetails{}, - InstanceID: protos.GetNginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId(), + InstanceID: protos.NginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId(), Files: []*mpi.File{}, AccessLogs: []*model.AccessLog{}, ErrorLogs: []*model.ErrorLog{}, @@ -389,18 +389,18 @@ func TestNginxConfigParser_Parse(t *testing.T) { }, { name: "Test 5: Error Log outputting to stderr", - instance: protos.GetNginxPlusInstance([]string{}), - content: testconfig.GetNginxConfigWithMultipleAccessLogs( + instance: protos.NginxPlusInstance([]string{}), + content: testconfig.NginxConfigWithMultipleAccessLogs( "stderr", accessLog.Name(), combinedAccessLog.Name(), ltsvAccessLog.Name(), ), - expectedConfigContext: modelHelpers.GetConfigContextWithoutErrorLog( + expectedConfigContext: modelHelpers.ConfigContextWithoutErrorLog( accessLog.Name(), combinedAccessLog.Name(), ltsvAccessLog.Name(), - protos.GetNginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId(), + protos.NginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId(), []string{"127.0.0.1:1515"}, ), expectedLog: "Currently error log outputs to stderr. Log monitoring is disabled while applying a " + @@ -409,18 +409,18 @@ func TestNginxConfigParser_Parse(t *testing.T) { }, { name: "Test 6: Error Log outputting to stdout", - instance: protos.GetNginxPlusInstance([]string{}), - content: testconfig.GetNginxConfigWithMultipleAccessLogs( + instance: protos.NginxPlusInstance([]string{}), + content: testconfig.NginxConfigWithMultipleAccessLogs( "stdout", accessLog.Name(), combinedAccessLog.Name(), ltsvAccessLog.Name(), ), - expectedConfigContext: modelHelpers.GetConfigContextWithoutErrorLog( + expectedConfigContext: modelHelpers.ConfigContextWithoutErrorLog( accessLog.Name(), combinedAccessLog.Name(), ltsvAccessLog.Name(), - protos.GetNginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId(), + protos.NginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId(), []string{"127.0.0.1:1515"}, ), expectedLog: "Currently error log outputs to stdout. Log monitoring is disabled while applying a " + @@ -429,53 +429,53 @@ func TestNginxConfigParser_Parse(t *testing.T) { }, { name: "Test 7: Check Parser for SSL Certs", - instance: protos.GetNginxPlusInstance([]string{}), - content: testconfig.GetNginxConfigWithSSLCerts( + instance: protos.NginxPlusInstance([]string{}), + content: testconfig.NginxConfigWithSSLCerts( errorLog.Name(), accessLog.Name(), certFile, ), - expectedConfigContext: modelHelpers.GetConfigContextWithFiles( + expectedConfigContext: modelHelpers.ConfigContextWithFiles( accessLog.Name(), errorLog.Name(), []*mpi.File{&certFileWithMetas}, - protos.GetNginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId(), + protos.NginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId(), nil, ), allowedDirectories: []string{dir}, }, { name: "Test 8: Check for multiple different SSL Certs", - instance: protos.GetNginxPlusInstance([]string{}), - content: testconfig.GetNginxConfigWithMultipleSSLCerts( + instance: protos.NginxPlusInstance([]string{}), + content: testconfig.NginxConfigWithMultipleSSLCerts( errorLog.Name(), accessLog.Name(), certFile, diffCertFile, ), - expectedConfigContext: modelHelpers.GetConfigContextWithFiles( + expectedConfigContext: modelHelpers.ConfigContextWithFiles( accessLog.Name(), errorLog.Name(), []*mpi.File{&diffCertFileWithMetas, &certFileWithMetas}, - protos.GetNginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId(), + protos.NginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId(), nil, ), allowedDirectories: []string{dir}, }, { name: "Test 9: Check for multiple same SSL Certs", - instance: protos.GetNginxPlusInstance([]string{}), - content: testconfig.GetNginxConfigWithMultipleSSLCerts( + instance: protos.NginxPlusInstance([]string{}), + content: testconfig.NginxConfigWithMultipleSSLCerts( errorLog.Name(), accessLog.Name(), certFile, certFile, ), - expectedConfigContext: modelHelpers.GetConfigContextWithFiles( + expectedConfigContext: modelHelpers.ConfigContextWithFiles( accessLog.Name(), errorLog.Name(), []*mpi.File{&certFileWithMetas}, - protos.GetNginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId(), + protos.NginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId(), nil, ), allowedDirectories: []string{dir}, diff --git a/internal/datasource/host/info.go b/internal/datasource/host/info.go index efa01b003..60e72d040 100644 --- a/internal/datasource/host/info.go +++ b/internal/datasource/host/info.go @@ -128,10 +128,10 @@ func (i *Info) IsContainer() bool { func (i *Info) ResourceID(ctx context.Context) string { if i.IsContainer() { - return i.getContainerID() + return i.containerID() } - return i.getHostID(ctx) + return i.hostID(ctx) } func (i *Info) ContainerInfo(ctx context.Context) *v1.Resource_ContainerInfo { @@ -142,9 +142,9 @@ func (i *Info) ContainerInfo(ctx context.Context) *v1.Resource_ContainerInfo { return &v1.Resource_ContainerInfo{ ContainerInfo: &v1.ContainerInfo{ - ContainerId: i.getContainerID(), + ContainerId: i.containerID(), Hostname: hostname, - ReleaseInfo: i.getReleaseInfo(ctx, i.osReleaseLocation), + ReleaseInfo: i.releaseInfo(ctx, i.osReleaseLocation), }, } } @@ -157,9 +157,9 @@ func (i *Info) HostInfo(ctx context.Context) *v1.Resource_HostInfo { return &v1.Resource_HostInfo{ HostInfo: &v1.HostInfo{ - HostId: i.getHostID(ctx), + HostId: i.hostID(ctx), Hostname: hostname, - ReleaseInfo: i.getReleaseInfo(ctx, i.osReleaseLocation), + ReleaseInfo: i.releaseInfo(ctx, i.osReleaseLocation), }, } } @@ -182,9 +182,9 @@ func containsContainerReference(cgroupFile string) bool { return false } -func (i *Info) getContainerID() string { +func (i *Info) containerID() string { res, err, _ := singleflightGroup.Do(GetContainerIDKey, func() (interface{}, error) { - containerID, err := getContainerIDFromMountInfo(i.mountInfoLocation) + containerID, err := containerIDFromMountInfo(i.mountInfoLocation) return uuid.NewMD5(uuid.NameSpaceDNS, []byte(containerID)).String(), err }) @@ -200,10 +200,10 @@ func (i *Info) getContainerID() string { return "" } -// getContainerID returns the container ID of the current running environment. +// containerID returns the container ID of the current running environment. // Supports cgroup v1 and v2. Reading "/proc/1/cpuset" would only work for cgroups v1 // mountInfo is the path: "/proc/self/mountinfo" -func getContainerIDFromMountInfo(mountInfo string) (string, error) { +func containerIDFromMountInfo(mountInfo string) (string, error) { mInfoFile, err := os.Open(mountInfo) if err != nil { return "", fmt.Errorf("could not read %s: %w", mountInfo, err) @@ -226,7 +226,7 @@ func getContainerIDFromMountInfo(mountInfo string) (string, error) { for _, line := range lines { splitLine := strings.Split(line, " ") for _, word := range splitLine { - containerID := getContainerIDFromPatterns(word) + containerID := containerIDFromPatterns(word) if containerID != "" { return containerID, nil } @@ -236,7 +236,7 @@ func getContainerIDFromMountInfo(mountInfo string) (string, error) { return "", fmt.Errorf("container ID not found in %s", mountInfo) } -func getContainerIDFromPatterns(word string) string { +func containerIDFromPatterns(word string) string { slices := scopePattern.FindStringSubmatch(word) if containsContainerID(slices) { return slices[1] @@ -269,7 +269,7 @@ func containsContainerID(slices []string) bool { return len(slices) >= 2 && len(slices[1]) == lengthOfContainerID } -func (i *Info) getHostID(ctx context.Context) string { +func (i *Info) hostID(ctx context.Context) string { res, err, _ := singleflightGroup.Do(GetSystemUUIDKey, func() (interface{}, error) { var err error @@ -294,7 +294,7 @@ func (i *Info) getHostID(ctx context.Context) string { return "" } -func (i *Info) getReleaseInfo(ctx context.Context, osReleaseLocation string) (releaseInfo *v1.ReleaseInfo) { +func (i *Info) releaseInfo(ctx context.Context, osReleaseLocation string) (releaseInfo *v1.ReleaseInfo) { hostReleaseInfo := i.exec.ReleaseInfo(ctx) osRelease, err := readOsRelease(osReleaseLocation) if err != nil { diff --git a/internal/datasource/proto/instance_test.go b/internal/datasource/proto/instance_test.go index 155a4b9e4..0ad5c25a8 100644 --- a/internal/datasource/proto/instance_test.go +++ b/internal/datasource/proto/instance_test.go @@ -61,12 +61,12 @@ func TestInstanceWatcherService_updateNginxInstanceRuntime(t *testing.T) { { name: "Test 1: OSS Instance", nginxConfigContext: nginxOSSConfigContext, - instance: protos.GetNginxOssInstance([]string{}), + instance: protos.NginxOssInstance([]string{}), }, { name: "Test 2: Plus Instance", nginxConfigContext: nginxPlusConfigContext, - instance: protos.GetNginxPlusInstance([]string{}), + instance: protos.NginxPlusInstance([]string{}), }, } diff --git a/internal/file/file_manager_service.go b/internal/file/file_manager_service.go index 3140416e8..2fda4f9ed 100644 --- a/internal/file/file_manager_service.go +++ b/internal/file/file_manager_service.go @@ -121,7 +121,7 @@ func (fms *FileManagerService) UpdateOverview( filesToUpdate []*mpi.File, iteration int, ) error { - correlationID := logger.GetCorrelationID(ctx) + correlationID := logger.CorrelationID(ctx) // error case for the UpdateOverview attempts if iteration > maxAttempts { @@ -157,11 +157,8 @@ func (fms *FileManagerService) UpdateOverview( return nil, errors.New("CreateConnection rpc has not being called yet") } - slog.InfoContext(newCtx, "Updating file overview", - "instance_id", request.GetOverview().GetConfigVersion().GetInstanceId(), - "parent_correlation_id", correlationID, - ) slog.DebugContext(newCtx, "Sending update overview request", + "instance_id", request.GetOverview().GetConfigVersion().GetInstanceId(), "request", request, "parent_correlation_id", correlationID, ) @@ -203,13 +200,13 @@ func (fms *FileManagerService) UpdateOverview( } func (fms *FileManagerService) setupIdentifiers(ctx context.Context, iteration int) (context.Context, string) { - correlationID := logger.GetCorrelationID(ctx) + correlationID := logger.CorrelationID(ctx) var requestCorrelationID slog.Attr if iteration == 0 { requestCorrelationID = logger.GenerateCorrelationID() } else { - requestCorrelationID = logger.GetCorrelationIDAttr(ctx) + requestCorrelationID = logger.CorrelationIDAttr(ctx) } newCtx := context.WithValue(ctx, logger.CorrelationIDContextKey, requestCorrelationID) @@ -234,7 +231,7 @@ func (fms *FileManagerService) updateFiles( } iteration++ - slog.Debug("Updating file overview", "attempt_number", iteration) + slog.Info("Updating file overview after file updates", "attempt_number", iteration) return fms.UpdateOverview(ctx, instanceID, fileOverview, iteration) } @@ -244,7 +241,7 @@ func (fms *FileManagerService) UpdateFile( instanceID string, fileToUpdate *mpi.File, ) error { - slog.InfoContext(ctx, "Updating file", "instance_id", instanceID, "file_name", fileToUpdate.GetFileMeta().GetName()) + slog.InfoContext(ctx, "Updating file", "file_name", fileToUpdate.GetFileMeta().GetName(), "instance_id", instanceID) slog.DebugContext(ctx, "Checking file size", "file_size", fileToUpdate.GetFileMeta().GetSize(), @@ -264,7 +261,7 @@ func (fms *FileManagerService) sendUpdateFileRequest( ) error { messageMeta := &mpi.MessageMeta{ MessageId: id.GenerateMessageID(), - CorrelationId: logger.GetCorrelationID(ctx), + CorrelationId: logger.CorrelationID(ctx), Timestamp: timestamppb.Now(), } @@ -351,7 +348,7 @@ func (fms *FileManagerService) sendUpdateFileStreamHeader( ) error { messageMeta := &mpi.MessageMeta{ MessageId: id.GenerateMessageID(), - CorrelationId: logger.GetCorrelationID(ctx), + CorrelationId: logger.CorrelationID(ctx), Timestamp: timestamppb.Now(), } @@ -450,7 +447,7 @@ func (fms *FileManagerService) sendFileUpdateStreamChunk( ) error { messageMeta := &mpi.MessageMeta{ MessageId: id.GenerateMessageID(), - CorrelationId: logger.GetCorrelationID(ctx), + CorrelationId: logger.CorrelationID(ctx), Timestamp: timestamppb.Now(), } @@ -593,6 +590,7 @@ func (fms *FileManagerService) executeFileActions(ctx context.Context) error { for _, fileAction := range fms.fileActions { switch fileAction.Action { case model.Delete: + slog.Debug("File action, deleting file", "file", fileAction.File.GetFileMeta().GetName()) if err := os.Remove(fileAction.File.GetFileMeta().GetName()); err != nil && !os.IsNotExist(err) { return fmt.Errorf("error deleting file: %s error: %w", fileAction.File.GetFileMeta().GetName(), err) @@ -600,6 +598,7 @@ func (fms *FileManagerService) executeFileActions(ctx context.Context) error { continue case model.Add, model.Update: + slog.Debug("File action, add or update file", "file", fileAction.File.GetFileMeta().GetName()) updateErr := fms.fileUpdate(ctx, fileAction.File) if updateErr != nil { return updateErr @@ -613,7 +612,6 @@ func (fms *FileManagerService) executeFileActions(ctx context.Context) error { } func (fms *FileManagerService) fileUpdate(ctx context.Context, file *mpi.File) error { - slog.DebugContext(ctx, "Updating file", "file", file.GetFileMeta().GetName()) if file.GetFileMeta().GetSize() <= int64(fms.agentConfig.Client.Grpc.MaxFileSize) { return fms.file(ctx, file) } @@ -631,7 +629,7 @@ func (fms *FileManagerService) file(ctx context.Context, file *mpi.File) error { return fms.fileServiceClient.GetFile(ctx, &mpi.GetFileRequest{ MessageMeta: &mpi.MessageMeta{ MessageId: id.GenerateMessageID(), - CorrelationId: logger.GetCorrelationID(ctx), + CorrelationId: logger.CorrelationID(ctx), Timestamp: timestamppb.Now(), }, FileMeta: file.GetFileMeta(), @@ -661,7 +659,7 @@ func (fms *FileManagerService) chunkedFile(ctx context.Context, file *mpi.File) stream, err := fms.fileServiceClient.GetFileStream(ctx, &mpi.GetFileRequest{ MessageMeta: &mpi.MessageMeta{ MessageId: id.GenerateMessageID(), - CorrelationId: logger.GetCorrelationID(ctx), + CorrelationId: logger.CorrelationID(ctx), Timestamp: timestamppb.Now(), }, FileMeta: file.GetFileMeta(), diff --git a/internal/file/file_manager_service_test.go b/internal/file/file_manager_service_test.go index a821084b6..b6a07d6f7 100644 --- a/internal/file/file_manager_service_test.go +++ b/internal/file/file_manager_service_test.go @@ -524,7 +524,7 @@ func TestFileManagerService_Rollback(t *testing.T) { updateFile.Name(): oldFileContent, } - instanceID := protos.GetNginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId() + instanceID := protos.NginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId() fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} fileManagerService := NewFileManagerService(fakeFileServiceClient, types.AgentConfig()) fileManagerService.rollbackFileContents = fileContentCache diff --git a/internal/file/file_plugin.go b/internal/file/file_plugin.go index 539ef07ed..e402fae27 100644 --- a/internal/file/file_plugin.go +++ b/internal/file/file_plugin.go @@ -66,6 +66,7 @@ func (fp *FilePlugin) Process(ctx context.Context, msg *bus.Message) { case bus.ConnectionResetTopic: fp.handleConnectionReset(ctx, msg) case bus.ConnectionCreatedTopic: + slog.DebugContext(ctx, "File plugin received connection created message") fp.fileManagerService.SetIsConnected(true) case bus.NginxConfigUpdateTopic: fp.handleNginxConfigUpdate(ctx, msg) @@ -80,7 +81,7 @@ func (fp *FilePlugin) Process(ctx context.Context, msg *bus.Message) { case bus.ConfigApplyFailedTopic: fp.handleConfigApplyFailedRequest(ctx, msg) default: - slog.DebugContext(ctx, "File plugin unknown topic", "topic", msg.Topic) + slog.DebugContext(ctx, "File plugin received unknown topic", "topic", msg.Topic) } } @@ -111,11 +112,12 @@ func (fp *FilePlugin) handleConnectionReset(ctx context.Context, msg *bus.Messag fp.fileManagerService = NewFileManagerService(fp.conn.FileServiceClient(), fp.config) fp.fileManagerService.SetIsConnected(reconnect) - slog.DebugContext(ctx, "File plugin: client reset successfully") + slog.DebugContext(ctx, "File manager service client reset successfully") } } func (fp *FilePlugin) handleConfigApplyComplete(ctx context.Context, msg *bus.Message) { + slog.DebugContext(ctx, "File plugin received config apply complete message") response, ok := msg.Data.(*mpi.DataPlaneResponse) if !ok { @@ -128,6 +130,7 @@ func (fp *FilePlugin) handleConfigApplyComplete(ctx context.Context, msg *bus.Me } func (fp *FilePlugin) handleConfigApplySuccess(ctx context.Context, msg *bus.Message) { + slog.DebugContext(ctx, "File plugin received config success message") successMessage, ok := msg.Data.(*model.ConfigApplySuccess) if !ok { @@ -152,6 +155,8 @@ func (fp *FilePlugin) handleConfigApplySuccess(ctx context.Context, msg *bus.Mes } func (fp *FilePlugin) handleConfigApplyFailedRequest(ctx context.Context, msg *bus.Message) { + slog.DebugContext(ctx, "File plugin received config failed message") + data, ok := msg.Data.(*model.ConfigApplyMessage) if data.InstanceID == "" || !ok { slog.ErrorContext(ctx, "Unable to cast message payload to *model.ConfigApplyMessage", @@ -185,7 +190,7 @@ func (fp *FilePlugin) handleConfigApplyFailedRequest(ctx context.Context, msg *b func (fp *FilePlugin) handleConfigApplyRequest(ctx context.Context, msg *bus.Message) { slog.DebugContext(ctx, "File plugin received config apply request message") var response *mpi.DataPlaneResponse - correlationID := logger.GetCorrelationID(ctx) + correlationID := logger.CorrelationID(ctx) managementPlaneRequest, ok := msg.Data.(*mpi.ManagementPlaneRequest) if !ok { @@ -306,6 +311,7 @@ func (fp *FilePlugin) handleConfigApplyRequest(ctx context.Context, msg *bus.Mes } func (fp *FilePlugin) handleNginxConfigUpdate(ctx context.Context, msg *bus.Message) { + slog.DebugContext(ctx, "File plugin received nginx config update message") nginxConfigContext, ok := msg.Data.(*model.NginxConfigContext) if !ok { slog.ErrorContext(ctx, "Unable to cast message payload to *model.NginxConfigContext", "payload", msg.Data) @@ -322,6 +328,7 @@ func (fp *FilePlugin) handleNginxConfigUpdate(ctx context.Context, msg *bus.Mess slog.ErrorContext(ctx, "Unable to update current files on disk", "error", updateError) } + slog.InfoContext(ctx, "Updating overview after nginx config update") err := fp.fileManagerService.UpdateOverview(ctx, nginxConfigContext.InstanceID, nginxConfigContext.Files, 0) if err != nil { slog.ErrorContext( @@ -334,6 +341,7 @@ func (fp *FilePlugin) handleNginxConfigUpdate(ctx context.Context, msg *bus.Mess } func (fp *FilePlugin) handleConfigUploadRequest(ctx context.Context, msg *bus.Message) { + slog.DebugContext(ctx, "File plugin received config upload request message") managementPlaneRequest, ok := msg.Data.(*mpi.ManagementPlaneRequest) if !ok { slog.ErrorContext( @@ -347,7 +355,7 @@ func (fp *FilePlugin) handleConfigUploadRequest(ctx context.Context, msg *bus.Me configUploadRequest := managementPlaneRequest.GetConfigUploadRequest() - correlationID := logger.GetCorrelationID(ctx) + correlationID := logger.CorrelationID(ctx) var updatingFilesError error diff --git a/internal/file/file_plugin_test.go b/internal/file/file_plugin_test.go index 0dae83eff..fb7e556f5 100644 --- a/internal/file/file_plugin_test.go +++ b/internal/file/file_plugin_test.go @@ -167,7 +167,7 @@ func TestFilePlugin_Process_ConfigApplyRequestTopic(t *testing.T) { filePlugin.Process(ctx, &bus.Message{Topic: bus.ConfigApplyRequestTopic, Data: test.message}) - messages := messagePipe.GetMessages() + messages := messagePipe.Messages() switch { case test.configApplyStatus == model.OK: @@ -272,7 +272,7 @@ func TestFilePlugin_Process_ConfigUploadRequestTopic(t *testing.T) { 10*time.Millisecond, ) - messages := messagePipe.GetMessages() + messages := messagePipe.Messages() assert.Len(t, messages, 1) assert.Equal(t, bus.DataPlaneResponseTopic, messages[0].Topic) @@ -322,14 +322,14 @@ func TestFilePlugin_Process_ConfigUploadRequestTopic_Failure(t *testing.T) { assert.Eventually( t, - func() bool { return len(messagePipe.GetMessages()) == 2 }, + func() bool { return len(messagePipe.Messages()) == 2 }, 2*time.Second, 10*time.Millisecond, ) assert.Equal(t, 0, fakeFileServiceClient.UpdateFileCallCount()) - messages := messagePipe.GetMessages() + messages := messagePipe.Messages() assert.Len(t, messages, 2) assert.Equal(t, bus.DataPlaneResponseTopic, messages[0].Topic) @@ -354,7 +354,7 @@ func TestFilePlugin_Process_ConfigUploadRequestTopic_Failure(t *testing.T) { func TestFilePlugin_Process_ConfigApplyFailedTopic(t *testing.T) { ctx := context.Background() - instanceID := protos.GetNginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId() + instanceID := protos.NginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId() tests := []struct { name string @@ -404,7 +404,7 @@ func TestFilePlugin_Process_ConfigApplyFailedTopic(t *testing.T) { filePlugin.Process(ctx, &bus.Message{Topic: bus.ConfigApplyFailedTopic, Data: data}) - messages := messagePipe.GetMessages() + messages := messagePipe.Messages() switch { case test.rollbackReturns == nil: @@ -431,7 +431,7 @@ func TestFilePlugin_Process_ConfigApplyFailedTopic(t *testing.T) { func TestFilePlugin_Process_ConfigApplyRollbackCompleteTopic(t *testing.T) { ctx := context.Background() - instance := protos.GetNginxOssInstance([]string{}) + instance := protos.NginxOssInstance([]string{}) mockFileManager := &filefakes.FakeFileManagerServiceInterface{} messagePipe := busfakes.NewFakeMessagePipe() @@ -462,7 +462,7 @@ func TestFilePlugin_Process_ConfigApplyRollbackCompleteTopic(t *testing.T) { DataPlaneResponse: expectedResponse, }}) - messages := messagePipe.GetMessages() + messages := messagePipe.Messages() response, ok := messages[0].Data.(*mpi.DataPlaneResponse) assert.True(t, ok) @@ -476,7 +476,7 @@ func TestFilePlugin_Process_ConfigApplyRollbackCompleteTopic(t *testing.T) { func TestFilePlugin_Process_ConfigApplyCompleteTopic(t *testing.T) { ctx := context.Background() - instance := protos.GetNginxOssInstance([]string{}) + instance := protos.NginxOssInstance([]string{}) mockFileManager := &filefakes.FakeFileManagerServiceInterface{} messagePipe := busfakes.NewFakeMessagePipe() @@ -503,7 +503,7 @@ func TestFilePlugin_Process_ConfigApplyCompleteTopic(t *testing.T) { filePlugin.Process(ctx, &bus.Message{Topic: bus.ConfigApplyCompleteTopic, Data: expectedResponse}) - messages := messagePipe.GetMessages() + messages := messagePipe.Messages() response, ok := messages[0].Data.(*mpi.DataPlaneResponse) assert.True(t, ok) diff --git a/internal/grpc/grpc.go b/internal/grpc/grpc.go index a93c6f9c2..8d23e0c13 100644 --- a/internal/grpc/grpc.go +++ b/internal/grpc/grpc.go @@ -90,7 +90,7 @@ func NewGrpcConnection(ctx context.Context, agentConfig *config.Config) (*GrpcCo var err error grpcConnection.mutex.Lock() - grpcConnection.conn, err = grpc.NewClient(serverAddr, GetDialOptions(agentConfig, resourceID)...) + grpcConnection.conn, err = grpc.NewClient(serverAddr, DialOptions(agentConfig, resourceID)...) grpcConnection.mutex.Unlock() if err != nil { return nil, err @@ -160,7 +160,7 @@ func (w *wrappedStream) SendMsg(message any) error { return w.ClientStream.SendMsg(message) } -func GetDialOptions(agentConfig *config.Config, resourceID string) []grpc.DialOption { +func DialOptions(agentConfig *config.Config, resourceID string) []grpc.DialOption { streamClientInterceptors := []grpc.StreamClientInterceptor{grpcRetry.StreamClientInterceptor()} unaryClientInterceptors := []grpc.UnaryClientInterceptor{grpcRetry.UnaryClientInterceptor()} @@ -221,7 +221,7 @@ func GetDialOptions(agentConfig *config.Config, resourceID string) []grpc.DialOp } func addTransportCredentials(agentConfig *config.Config, opts []grpc.DialOption) ([]grpc.DialOption, bool) { - transportCredentials, err := getTransportCredentials(agentConfig) + transportCredentials, err := transportCredentials(agentConfig) if err != nil { slog.Error("Unable to add transport credentials to gRPC dial options, adding "+ "default transport credentials", "error", err) @@ -359,11 +359,11 @@ func validateMessage(validator protovalidate.Validator, message any) error { return nil } -func getTransportCredentials(agentConfig *config.Config) (credentials.TransportCredentials, error) { +func transportCredentials(agentConfig *config.Config) (credentials.TransportCredentials, error) { if agentConfig.Command.TLS == nil { return defaultCredentials, nil } - tlsConfig, err := getTLSConfigForCredentials(agentConfig.Command.TLS) + tlsConfig, err := tlsConfigForCredentials(agentConfig.Command.TLS) if err != nil { return nil, err } @@ -371,7 +371,7 @@ func getTransportCredentials(agentConfig *config.Config) (credentials.TransportC return credentials.NewTLS(tlsConfig), nil } -func getTLSConfigForCredentials(c *config.TLSConfig) (*tls.Config, error) { +func tlsConfigForCredentials(c *config.TLSConfig) (*tls.Config, error) { if c.SkipVerify { slog.Warn("Verification of the server's certificate chain and host name is disabled") } diff --git a/internal/grpc/grpc_test.go b/internal/grpc/grpc_test.go index 87828ceb3..0270421d7 100644 --- a/internal/grpc/grpc_test.go +++ b/internal/grpc/grpc_test.go @@ -185,7 +185,7 @@ func Test_GetDialOptions(t *testing.T) { test.agentConfig.Command.TLS.Ca = fmt.Sprintf("%s%s%s", tmpDir, pathSeparator, caFileName) } - options := GetDialOptions(test.agentConfig, "123") + options := DialOptions(test.agentConfig, "123") assert.NotNil(tt, options) assert.Len(tt, options, test.expected) }) @@ -216,19 +216,19 @@ func Test_ProtoValidatorUnaryClientInterceptor(t *testing.T) { { name: "Test 1: Invalid request type", request: "invalid", - reply: protos.GetNginxOssInstance([]string{}), + reply: protos.NginxOssInstance([]string{}), isErrorExpected: true, }, { name: "Test 2: Invalid reply type", - request: protos.GetNginxOssInstance([]string{}), + request: protos.NginxOssInstance([]string{}), reply: "invalid", isErrorExpected: true, }, { name: "Test 3: Valid request & reply types", - request: protos.GetNginxOssInstance([]string{}), - reply: protos.GetNginxOssInstance([]string{}), + request: protos.NginxOssInstance([]string{}), + reply: protos.NginxOssInstance([]string{}), isErrorExpected: false, }, } @@ -257,7 +257,7 @@ func Test_ProtoValidatorStreamClientInterceptor_RecvMsg(t *testing.T) { isErrorExpected: true, }, { name: "Test 2: Valid received message type", - receivedMessage: protos.GetNginxOssInstance([]string{}), + receivedMessage: protos.NginxOssInstance([]string{}), isErrorExpected: false, }, } @@ -288,7 +288,7 @@ func Test_ProtoValidatorStreamClientInterceptor_SendMsg(t *testing.T) { isErrorExpected: true, }, { name: "Test 2: Valid sent message type", - sentMessage: protos.GetNginxOssInstance([]string{}), + sentMessage: protos.NginxOssInstance([]string{}), isErrorExpected: false, }, } @@ -356,7 +356,7 @@ func Test_ValidateGrpcError(t *testing.T) { assert.IsType(t, &backoff.PermanentError{}, result) } -func Test_getTransportCredentials(t *testing.T) { +func Test_transportCredentials(t *testing.T) { tests := map[string]struct { conf *config.Config wantSecurityProfile string @@ -389,19 +389,19 @@ func Test_getTransportCredentials(t *testing.T) { } for name, tt := range tests { t.Run(name, func(t *testing.T) { - got, err := getTransportCredentials(tt.conf) + got, err := transportCredentials(tt.conf) if tt.wantErr { - require.Error(t, err, "getTransportCredentials(%v)", tt.conf) + require.Error(t, err, "transportCredentials(%v)", tt.conf) return } - require.NoError(t, err, "getTransportCredentials(%v)", tt.conf) + require.NoError(t, err, "transportCredentials(%v)", tt.conf) require.Equal(t, tt.wantSecurityProfile, got.Info().SecurityProtocol, "incorrect SecurityProtocol") }) } } -func Test_getTLSConfig(t *testing.T) { +func Test_tlsConfig(t *testing.T) { tmpDir := t.TempDir() // not mTLS scripts key, cert := helpers.GenerateSelfSignedCert(t) @@ -478,12 +478,12 @@ func Test_getTLSConfig(t *testing.T) { } for name, tt := range tests { t.Run(name, func(t *testing.T) { - got, err := getTLSConfigForCredentials(tt.conf) + got, err := tlsConfigForCredentials(tt.conf) if tt.wantErr { - require.Error(t, err, "getTLSConfigForCredentials(%v)", tt.conf) + require.Error(t, err, "tlsConfigForCredentials(%v)", tt.conf) return } - require.NoError(t, err, "getTLSConfigForCredentials(%v)", tt.conf) + require.NoError(t, err, "tlsConfigForCredentials(%v)", tt.conf) if tt.verify != nil { tt.verify(t, got) } diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 87171a20b..ea5606728 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -11,6 +11,7 @@ import ( "log/slog" "os" "path" + "strconv" "strings" "github.com/nginx/agent/v3/pkg/id" @@ -44,11 +45,28 @@ type ( ) func New(logPath, level string) *slog.Logger { + handlerOptions := &slog.HandlerOptions{ + Level: LogLevel(level), + } + + if level == "debug" { + handlerOptions.AddSource = true + handlerOptions.ReplaceAttr = func(groups []string, a slog.Attr) slog.Attr { + if a.Key == slog.SourceKey { + source, ok := a.Value.Any().(*slog.Source) + if ok { + relativeFilePath := strings.Split(source.File, "/agent/")[1] + a.Value = slog.StringValue(relativeFilePath + ":" + strconv.Itoa(source.Line)) + } + } + + return a + } + } + handler := slog.NewTextHandler( - getLogWriter(logPath), - &slog.HandlerOptions{ - Level: GetLogLevel(level), - }, + logWriter(logPath), + handlerOptions, ) return slog.New( @@ -59,7 +77,7 @@ func New(logPath, level string) *slog.Logger { }) } -func GetLogLevel(level string) slog.Level { +func LogLevel(level string) slog.Level { if level == "" { return slog.LevelInfo } @@ -67,7 +85,7 @@ func GetLogLevel(level string) slog.Level { return logLevels[strings.ToLower(level)] } -func getLogWriter(logFile string) io.Writer { +func logWriter(logFile string) io.Writer { logPath := logFile if logFile != "" { fileInfo, err := os.Stat(logPath) @@ -123,11 +141,11 @@ func GenerateCorrelationID() slog.Attr { return slog.Any(CorrelationIDKey, id.GenerateMessageID()) } -func GetCorrelationID(ctx context.Context) string { - return GetCorrelationIDAttr(ctx).Value.String() +func CorrelationID(ctx context.Context) string { + return CorrelationIDAttr(ctx).Value.String() } -func GetCorrelationIDAttr(ctx context.Context) slog.Attr { +func CorrelationIDAttr(ctx context.Context) slog.Attr { value, ok := ctx.Value(CorrelationIDContextKey).(slog.Attr) if !ok { correlationID := GenerateCorrelationID() diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go index 9ee83ad22..468a752f3 100644 --- a/internal/logger/logger_test.go +++ b/internal/logger/logger_test.go @@ -58,7 +58,7 @@ func TestGetLogLevel(t *testing.T) { for _, test := range tests { t.Run(test.name, func(tt *testing.T) { - result := GetLogLevel(test.input) + result := LogLevel(test.input) assert.IsType(tt, test.expected, result) }) } @@ -100,7 +100,7 @@ func TestGetLogWriter(t *testing.T) { for _, test := range tests { t.Run(test.name, func(tt *testing.T) { - result := getLogWriter(test.input) + result := logWriter(test.input) assert.IsType(tt, test.expected, result) }) } @@ -108,7 +108,7 @@ func TestGetLogWriter(t *testing.T) { func TestGetCorrelationID(t *testing.T) { ctx := context.WithValue(context.Background(), CorrelationIDContextKey, GenerateCorrelationID()) - correlationID := GetCorrelationID(ctx) + correlationID := CorrelationID(ctx) assert.NotEmpty(t, correlationID) } diff --git a/internal/resource/nginx_instance_operator_test.go b/internal/resource/nginx_instance_operator_test.go index 914eda6a8..259c3f9ad 100644 --- a/internal/resource/nginx_instance_operator_test.go +++ b/internal/resource/nginx_instance_operator_test.go @@ -84,7 +84,7 @@ func TestInstanceOperator_Validate(t *testing.T) { mockExec := &execfakes.FakeExecInterface{} mockExec.RunCmdReturns(test.out, test.err) - instance := protos.GetNginxOssInstance([]string{}) + instance := protos.NginxOssInstance([]string{}) operator := NewInstanceOperator(types.AgentConfig()) operator.executer = mockExec @@ -124,7 +124,7 @@ func TestInstanceOperator_Reload(t *testing.T) { mockExec := &execfakes.FakeExecInterface{} mockExec.KillProcessReturns(test.err) - instance := protos.GetNginxOssInstance([]string{}) + instance := protos.NginxOssInstance([]string{}) operator := NewInstanceOperator(types.AgentConfig()) operator.executer = mockExec @@ -172,7 +172,7 @@ func TestInstanceOperator_ReloadAndMonitor(t *testing.T) { mockExec := &execfakes.FakeExecInterface{} mockExec.KillProcessReturns(nil) - instance := protos.GetNginxOssInstance([]string{}) + instance := protos.NginxOssInstance([]string{}) if test.errorLogs != "" { instance.GetInstanceRuntime().GetNginxRuntimeInfo().ErrorLogs = []string{test.errorLogs} } diff --git a/internal/resource/nginx_plus_actions.go b/internal/resource/nginx_plus_actions.go index e0bcdc819..a0b949f04 100644 --- a/internal/resource/nginx_plus_actions.go +++ b/internal/resource/nginx_plus_actions.go @@ -24,7 +24,7 @@ type APIAction struct { func (a *APIAction) HandleUpdateStreamServersRequest(ctx context.Context, action *mpi.NGINXPlusAction, instance *mpi.Instance, ) *mpi.DataPlaneResponse { - correlationID := logger.GetCorrelationID(ctx) + correlationID := logger.CorrelationID(ctx) instanceID := instance.GetInstanceMeta().GetInstanceId() add, update, del, err := a.ResourceService.UpdateStreamServers(ctx, instance, @@ -48,7 +48,7 @@ func (a *APIAction) HandleUpdateStreamServersRequest(ctx context.Context, action func (a *APIAction) HandleGetStreamUpstreamsRequest(ctx context.Context, instance *mpi.Instance, ) *mpi.DataPlaneResponse { - correlationID := logger.GetCorrelationID(ctx) + correlationID := logger.CorrelationID(ctx) instanceID := instance.GetInstanceMeta().GetInstanceId() streamUpstreamsResponse := emptyResponse @@ -72,7 +72,7 @@ func (a *APIAction) HandleGetStreamUpstreamsRequest(ctx context.Context, } func (a *APIAction) HandleGetUpstreamsRequest(ctx context.Context, instance *mpi.Instance) *mpi.DataPlaneResponse { - correlationID := logger.GetCorrelationID(ctx) + correlationID := logger.CorrelationID(ctx) instanceID := instance.GetInstanceMeta().GetInstanceId() upstreamsResponse := emptyResponse @@ -99,7 +99,7 @@ func (a *APIAction) HandleGetUpstreamsRequest(ctx context.Context, instance *mpi func (a *APIAction) HandleUpdateHTTPUpstreamsRequest(ctx context.Context, action *mpi.NGINXPlusAction, instance *mpi.Instance, ) *mpi.DataPlaneResponse { - correlationID := logger.GetCorrelationID(ctx) + correlationID := logger.CorrelationID(ctx) instanceID := instance.GetInstanceMeta().GetInstanceId() add, update, del, err := a.ResourceService.UpdateHTTPUpstreamServers(ctx, instance, @@ -124,7 +124,7 @@ func (a *APIAction) HandleUpdateHTTPUpstreamsRequest(ctx context.Context, action func (a *APIAction) HandleGetHTTPUpstreamsServersRequest(ctx context.Context, action *mpi.NGINXPlusAction, instance *mpi.Instance, ) *mpi.DataPlaneResponse { - correlationID := logger.GetCorrelationID(ctx) + correlationID := logger.CorrelationID(ctx) instanceID := instance.GetInstanceMeta().GetInstanceId() upstreamsResponse := emptyResponse diff --git a/internal/resource/resource_plugin.go b/internal/resource/resource_plugin.go index b71679c95..940a4f247 100644 --- a/internal/resource/resource_plugin.go +++ b/internal/resource/resource_plugin.go @@ -75,6 +75,7 @@ func (*Resource) Info() *bus.Info { func (r *Resource) Process(ctx context.Context, msg *bus.Message) { switch msg.Topic { case bus.AddInstancesTopic: + slog.DebugContext(ctx, "Resource plugin received add instances message") instanceList, ok := msg.Data.([]*mpi.Instance) if !ok { slog.ErrorContext(ctx, "Unable to cast message payload to []*mpi.Instance", "payload", msg.Data) @@ -88,6 +89,7 @@ func (r *Resource) Process(ctx context.Context, msg *bus.Message) { return case bus.UpdatedInstancesTopic: + slog.DebugContext(ctx, "Resource plugin received update instances message") instanceList, ok := msg.Data.([]*mpi.Instance) if !ok { slog.ErrorContext(ctx, "Unable to cast message payload to []*mpi.Instance", "payload", msg.Data) @@ -101,6 +103,7 @@ func (r *Resource) Process(ctx context.Context, msg *bus.Message) { return case bus.DeletedInstancesTopic: + slog.DebugContext(ctx, "Resource plugin received delete instances message") instanceList, ok := msg.Data.([]*mpi.Instance) if !ok { slog.ErrorContext(ctx, "Unable to cast message payload to []*mpi.Instance", "payload", msg.Data) @@ -135,6 +138,7 @@ func (*Resource) Subscriptions() []string { } func (r *Resource) handleAPIActionRequest(ctx context.Context, msg *bus.Message) { + slog.DebugContext(ctx, "Resource plugin received api action request message") managementPlaneRequest, ok := msg.Data.(*mpi.ManagementPlaneRequest) if !ok { @@ -161,7 +165,7 @@ func (r *Resource) handleAPIActionRequest(ctx context.Context, msg *bus.Message) } func (r *Resource) handleNginxPlusActionRequest(ctx context.Context, action *mpi.NGINXPlusAction, instanceID string) { - correlationID := logger.GetCorrelationID(ctx) + correlationID := logger.CorrelationID(ctx) instance := r.resourceService.Instance(instanceID) apiAction := APIAction{ ResourceService: r.resourceService, @@ -214,6 +218,7 @@ func (r *Resource) handleNginxPlusActionRequest(ctx context.Context, action *mpi } func (r *Resource) handleWriteConfigSuccessful(ctx context.Context, msg *bus.Message) { + slog.DebugContext(ctx, "Resource plugin received write config successful message") data, ok := msg.Data.(*model.ConfigApplyMessage) if !ok { slog.ErrorContext(ctx, "Unable to cast message payload to *model.ConfigApplyMessage", "payload", msg.Data) @@ -245,6 +250,7 @@ func (r *Resource) handleWriteConfigSuccessful(ctx context.Context, msg *bus.Mes } func (r *Resource) handleRollbackWrite(ctx context.Context, msg *bus.Message) { + slog.DebugContext(ctx, "Resource plugin received rollback write message") data, ok := msg.Data.(*model.ConfigApplyMessage) if !ok { slog.ErrorContext(ctx, "Unable to cast message payload to *model.ConfigApplyMessage", "payload", msg.Data) diff --git a/internal/resource/resource_plugin_test.go b/internal/resource/resource_plugin_test.go index 1f15a9596..9af07e9ca 100644 --- a/internal/resource/resource_plugin_test.go +++ b/internal/resource/resource_plugin_test.go @@ -38,13 +38,13 @@ func TestResource_Process(t *testing.T) { ctx := context.Background() updatedInstance := &mpi.Instance{ - InstanceConfig: protos.GetNginxOssInstance([]string{}).GetInstanceConfig(), - InstanceMeta: protos.GetNginxOssInstance([]string{}).GetInstanceMeta(), + InstanceConfig: protos.NginxOssInstance([]string{}).GetInstanceConfig(), + InstanceMeta: protos.NginxOssInstance([]string{}).GetInstanceMeta(), InstanceRuntime: &mpi.InstanceRuntime{ ProcessId: 56789, - BinaryPath: protos.GetNginxOssInstance([]string{}).GetInstanceRuntime().GetBinaryPath(), - ConfigPath: protos.GetNginxOssInstance([]string{}).GetInstanceRuntime().GetConfigPath(), - Details: protos.GetNginxOssInstance([]string{}).GetInstanceRuntime().GetDetails(), + BinaryPath: protos.NginxOssInstance([]string{}).GetInstanceRuntime().GetBinaryPath(), + ConfigPath: protos.NginxOssInstance([]string{}).GetInstanceRuntime().GetConfigPath(), + Details: protos.NginxOssInstance([]string{}).GetInstanceRuntime().GetDetails(), }, } @@ -59,10 +59,10 @@ func TestResource_Process(t *testing.T) { message: &bus.Message{ Topic: bus.AddInstancesTopic, Data: []*mpi.Instance{ - protos.GetNginxOssInstance([]string{}), + protos.NginxOssInstance([]string{}), }, }, - resource: protos.GetHostResource(), + resource: protos.HostResource(), topic: bus.ResourceUpdateTopic, }, { @@ -74,11 +74,11 @@ func TestResource_Process(t *testing.T) { }, }, resource: &mpi.Resource{ - ResourceId: protos.GetHostResource().GetResourceId(), + ResourceId: protos.HostResource().GetResourceId(), Instances: []*mpi.Instance{ updatedInstance, }, - Info: protos.GetHostResource().GetInfo(), + Info: protos.HostResource().GetInfo(), }, topic: bus.ResourceUpdateTopic, }, @@ -91,9 +91,9 @@ func TestResource_Process(t *testing.T) { }, }, resource: &mpi.Resource{ - ResourceId: protos.GetHostResource().GetResourceId(), + ResourceId: protos.HostResource().GetResourceId(), Instances: []*mpi.Instance{}, - Info: protos.GetHostResource().GetInfo(), + Info: protos.HostResource().GetInfo(), }, topic: bus.ResourceUpdateTopic, }, @@ -102,7 +102,7 @@ func TestResource_Process(t *testing.T) { for _, test := range tests { t.Run(test.name, func(tt *testing.T) { fakeResourceService := &resourcefakes.FakeResourceServiceInterface{} - fakeResourceService.AddInstancesReturns(protos.GetHostResource()) + fakeResourceService.AddInstancesReturns(protos.HostResource()) fakeResourceService.UpdateInstancesReturns(test.resource) fakeResourceService.DeleteInstancesReturns(test.resource) messagePipe := busfakes.NewFakeMessagePipe() @@ -117,8 +117,8 @@ func TestResource_Process(t *testing.T) { resourcePlugin.Process(ctx, test.message) - assert.Equal(t, test.topic, messagePipe.GetMessages()[0].Topic) - assert.Equal(t, test.resource, messagePipe.GetMessages()[0].Data) + assert.Equal(t, test.topic, messagePipe.Messages()[0].Topic) + assert.Equal(t, test.resource, messagePipe.Messages()[0].Data) }) } } @@ -138,7 +138,7 @@ func TestResource_Process_Apply(t *testing.T) { Topic: bus.WriteConfigSuccessfulTopic, Data: &model.ConfigApplyMessage{ CorrelationID: "dfsbhj6-bc92-30c1-a9c9-85591422068e", - InstanceID: protos.GetNginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId(), + InstanceID: protos.NginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId(), Error: nil, }, }, @@ -151,7 +151,7 @@ func TestResource_Process_Apply(t *testing.T) { Topic: bus.WriteConfigSuccessfulTopic, Data: &model.ConfigApplyMessage{ CorrelationID: "dfsbhj6-bc92-30c1-a9c9-85591422068e", - InstanceID: protos.GetNginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId(), + InstanceID: protos.NginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId(), Error: nil, }, }, @@ -176,14 +176,14 @@ func TestResource_Process_Apply(t *testing.T) { resourcePlugin.Process(ctx, test.message) - assert.Equal(t, test.topic[0], messagePipe.GetMessages()[0].Topic) + assert.Equal(t, test.topic[0], messagePipe.Messages()[0].Topic) if len(test.topic) > 1 { - assert.Equal(t, test.topic[1], messagePipe.GetMessages()[1].Topic) + assert.Equal(t, test.topic[1], messagePipe.Messages()[1].Topic) } if test.applyErr != nil { - response, ok := messagePipe.GetMessages()[0].Data.(*mpi.DataPlaneResponse) + response, ok := messagePipe.Messages()[0].Data.(*mpi.DataPlaneResponse) assert.True(tt, ok) assert.Equal(tt, test.applyErr.Error(), response.GetCommandResponse().GetError()) } @@ -217,7 +217,7 @@ func TestResource_createPlusAPIError(t *testing.T) { func TestResource_Process_APIAction_GetHTTPServers(t *testing.T) { ctx := context.Background() - inValidInstance := protos.GetNginxPlusInstance([]string{}) + inValidInstance := protos.NginxPlusInstance([]string{}) inValidInstance.InstanceMeta.InstanceId = "e1374cb1-462d-3b6c-9f3b-f28332b5f10f" tests := []struct { @@ -233,14 +233,14 @@ func TestResource_Process_APIAction_GetHTTPServers(t *testing.T) { message: &bus.Message{ Topic: bus.APIActionRequestTopic, Data: protos.CreatAPIActionRequestNginxPlusGetHTTPServers("test_upstream", - protos.GetNginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId()), + protos.NginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId()), }, err: nil, upstreams: []client.UpstreamServer{ helpers.CreateNginxPlusUpstreamServer(t), }, topic: []string{bus.DataPlaneResponseTopic}, - instance: protos.GetNginxPlusInstance([]string{}), + instance: protos.NginxPlusInstance([]string{}), }, { @@ -248,35 +248,35 @@ func TestResource_Process_APIAction_GetHTTPServers(t *testing.T) { message: &bus.Message{ Topic: bus.APIActionRequestTopic, Data: protos.CreatAPIActionRequestNginxPlusGetHTTPServers("test_upstream", - protos.GetNginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId()), + protos.NginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId()), }, err: errors.New("failed to get http servers"), upstreams: []client.UpstreamServer{ helpers.CreateNginxPlusUpstreamServer(t), }, topic: []string{bus.DataPlaneResponseTopic}, - instance: protos.GetNginxPlusInstance([]string{}), + instance: protos.NginxPlusInstance([]string{}), }, { name: "Test 3: Fail, OSS Instance", message: &bus.Message{ Topic: bus.APIActionRequestTopic, Data: protos.CreatAPIActionRequestNginxPlusGetHTTPServers("test_upstream", - protos.GetNginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId()), + protos.NginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId()), }, err: errors.New("failed to preform API action, instance is not NGINX Plus"), upstreams: []client.UpstreamServer{ helpers.CreateNginxPlusUpstreamServer(t), }, topic: []string{bus.DataPlaneResponseTopic}, - instance: protos.GetNginxOssInstance([]string{}), + instance: protos.NginxOssInstance([]string{}), }, { name: "Test 4: Fail, No Instance", message: &bus.Message{ Topic: bus.APIActionRequestTopic, Data: protos.CreatAPIActionRequestNginxPlusGetHTTPServers("test_upstream", - protos.GetNginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId()), + protos.NginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId()), }, err: errors.New("failed to preform API action, could not find instance with ID: " + "e1374cb1-462d-3b6c-9f3b-f28332b5f10c"), @@ -308,9 +308,9 @@ func TestResource_Process_APIAction_GetHTTPServers(t *testing.T) { resourcePlugin.Process(ctx, test.message) - assert.Equal(t, test.topic[0], messagePipe.GetMessages()[0].Topic) + assert.Equal(t, test.topic[0], messagePipe.Messages()[0].Topic) - response, ok := messagePipe.GetMessages()[0].Data.(*mpi.DataPlaneResponse) + response, ok := messagePipe.Messages()[0].Data.(*mpi.DataPlaneResponse) assert.True(tt, ok) if test.err != nil { @@ -341,7 +341,7 @@ func TestResource_Process_APIAction_UpdateHTTPUpstreams(t *testing.T) { message: &bus.Message{ Topic: bus.APIActionRequestTopic, Data: protos.CreatAPIActionRequestNginxPlusUpdateHTTPServers("test_upstream", - protos.GetNginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId(), []*structpb.Struct{ + protos.NginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId(), []*structpb.Struct{ { Fields: map[string]*structpb.Value{ "max_cons": structpb.NewNumberValue(8), @@ -357,7 +357,7 @@ func TestResource_Process_APIAction_UpdateHTTPUpstreams(t *testing.T) { helpers.CreateNginxPlusUpstreamServer(t), }, topic: []string{bus.DataPlaneResponseTopic}, - instance: protos.GetNginxPlusInstance([]string{}), + instance: protos.NginxPlusInstance([]string{}), expectedLog: "Successfully updated http upstream", }, { @@ -365,7 +365,7 @@ func TestResource_Process_APIAction_UpdateHTTPUpstreams(t *testing.T) { message: &bus.Message{ Topic: bus.APIActionRequestTopic, Data: protos.CreatAPIActionRequestNginxPlusUpdateHTTPServers("test_upstream", - protos.GetNginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId(), []*structpb.Struct{ + protos.NginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId(), []*structpb.Struct{ { Fields: map[string]*structpb.Value{ "max_cons": structpb.NewNumberValue(8), @@ -381,7 +381,7 @@ func TestResource_Process_APIAction_UpdateHTTPUpstreams(t *testing.T) { helpers.CreateNginxPlusUpstreamServer(t), }, topic: []string{bus.DataPlaneResponseTopic}, - instance: protos.GetNginxPlusInstance([]string{}), + instance: protos.NginxPlusInstance([]string{}), expectedLog: "Unable to update HTTP servers of upstream", }, } @@ -410,9 +410,9 @@ func TestResource_Process_APIAction_UpdateHTTPUpstreams(t *testing.T) { resourcePlugin.Process(ctx, test.message) - assert.Equal(tt, test.topic[0], messagePipe.GetMessages()[0].Topic) + assert.Equal(tt, test.topic[0], messagePipe.Messages()[0].Topic) - response, ok := messagePipe.GetMessages()[0].Data.(*mpi.DataPlaneResponse) + response, ok := messagePipe.Messages()[0].Data.(*mpi.DataPlaneResponse) assert.True(tt, ok) if test.err != nil { @@ -444,7 +444,7 @@ func TestResource_Process_APIAction_UpdateStreamServers(t *testing.T) { message: &bus.Message{ Topic: bus.APIActionRequestTopic, Data: protos.CreateAPIActionRequestNginxPlusUpdateStreamServers("test_upstream", - protos.GetNginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId(), []*structpb.Struct{ + protos.NginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId(), []*structpb.Struct{ { Fields: map[string]*structpb.Value{ "max_cons": structpb.NewNumberValue(8), @@ -460,7 +460,7 @@ func TestResource_Process_APIAction_UpdateStreamServers(t *testing.T) { helpers.CreateNginxPlusStreamServer(t), }, topic: []string{bus.DataPlaneResponseTopic}, - instance: protos.GetNginxPlusInstance([]string{}), + instance: protos.NginxPlusInstance([]string{}), expectedLog: "Successfully updated stream upstream", }, { @@ -468,7 +468,7 @@ func TestResource_Process_APIAction_UpdateStreamServers(t *testing.T) { message: &bus.Message{ Topic: bus.APIActionRequestTopic, Data: protos.CreateAPIActionRequestNginxPlusUpdateStreamServers("test_upstream", - protos.GetNginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId(), []*structpb.Struct{ + protos.NginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId(), []*structpb.Struct{ { Fields: map[string]*structpb.Value{ "max_cons": structpb.NewNumberValue(8), @@ -484,7 +484,7 @@ func TestResource_Process_APIAction_UpdateStreamServers(t *testing.T) { helpers.CreateNginxPlusStreamServer(t), }, topic: []string{bus.DataPlaneResponseTopic}, - instance: protos.GetNginxPlusInstance([]string{}), + instance: protos.NginxPlusInstance([]string{}), expectedLog: "Unable to update stream servers of upstream", }, } @@ -513,9 +513,9 @@ func TestResource_Process_APIAction_UpdateStreamServers(t *testing.T) { resourcePlugin.Process(ctx, test.message) - assert.Equal(tt, test.topic[0], messagePipe.GetMessages()[0].Topic) + assert.Equal(tt, test.topic[0], messagePipe.Messages()[0].Topic) - response, ok := messagePipe.GetMessages()[0].Data.(*mpi.DataPlaneResponse) + response, ok := messagePipe.Messages()[0].Data.(*mpi.DataPlaneResponse) assert.True(tt, ok) if test.err != nil { @@ -534,7 +534,7 @@ func TestResource_Process_APIAction_UpdateStreamServers(t *testing.T) { func TestResource_Process_APIAction_GetStreamUpstreams(t *testing.T) { ctx := context.Background() - inValidInstance := protos.GetNginxPlusInstance([]string{}) + inValidInstance := protos.NginxPlusInstance([]string{}) inValidInstance.InstanceMeta.InstanceId = "e1374cb1-462d-3b6c-9f3b-f28332b5f10f" tests := []struct { @@ -550,7 +550,7 @@ func TestResource_Process_APIAction_GetStreamUpstreams(t *testing.T) { message: &bus.Message{ Topic: bus.APIActionRequestTopic, Data: protos.CreateAPIActionRequestNginxPlusGetStreamUpstreams( - protos.GetNginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId()), + protos.NginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId()), }, err: nil, upstreams: &client.StreamUpstreams{ @@ -565,14 +565,14 @@ func TestResource_Process_APIAction_GetStreamUpstreams(t *testing.T) { }, }, topic: []string{bus.DataPlaneResponseTopic}, - instance: protos.GetNginxPlusInstance([]string{}), + instance: protos.NginxPlusInstance([]string{}), }, { name: "Test 2: Fail, Get Stream Upstreams API Action", message: &bus.Message{ Topic: bus.APIActionRequestTopic, Data: protos.CreateAPIActionRequestNginxPlusGetStreamUpstreams( - protos.GetNginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId()), + protos.NginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId()), }, err: errors.New("failed to get stream upstreams servers"), upstreams: &client.StreamUpstreams{ @@ -587,14 +587,14 @@ func TestResource_Process_APIAction_GetStreamUpstreams(t *testing.T) { }, }, topic: []string{bus.DataPlaneResponseTopic}, - instance: protos.GetNginxPlusInstance([]string{}), + instance: protos.NginxPlusInstance([]string{}), }, { name: "Test 3: Fail, No Instance", message: &bus.Message{ Topic: bus.APIActionRequestTopic, Data: protos.CreatAPIActionRequestNginxPlusGetHTTPServers("test_upstream", - protos.GetNginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId()), + protos.NginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId()), }, err: errors.New("failed to preform API action, could not find instance with ID: " + "e1374cb1-462d-3b6c-9f3b-f28332b5f10c"), @@ -617,7 +617,7 @@ func TestResource_Process_APIAction_GetStreamUpstreams(t *testing.T) { message: &bus.Message{ Topic: bus.APIActionRequestTopic, Data: protos.CreatAPIActionRequestNginxPlusGetHTTPServers("test_upstream", - protos.GetNginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId()), + protos.NginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId()), }, err: errors.New("failed to preform API action, instance is not NGINX Plus"), upstreams: &client.StreamUpstreams{ @@ -632,7 +632,7 @@ func TestResource_Process_APIAction_GetStreamUpstreams(t *testing.T) { }, }, topic: []string{bus.DataPlaneResponseTopic}, - instance: protos.GetNginxOssInstance([]string{}), + instance: protos.NginxOssInstance([]string{}), }, } @@ -656,9 +656,9 @@ func TestResource_Process_APIAction_GetStreamUpstreams(t *testing.T) { resourcePlugin.Process(ctx, test.message) - assert.Equal(t, test.topic[0], messagePipe.GetMessages()[0].Topic) + assert.Equal(t, test.topic[0], messagePipe.Messages()[0].Topic) - response, ok := messagePipe.GetMessages()[0].Data.(*mpi.DataPlaneResponse) + response, ok := messagePipe.Messages()[0].Data.(*mpi.DataPlaneResponse) assert.True(tt, ok) if test.err != nil { @@ -676,7 +676,7 @@ func TestResource_Process_APIAction_GetStreamUpstreams(t *testing.T) { func TestResource_Process_APIAction_GetUpstreams(t *testing.T) { ctx := context.Background() - inValidInstance := protos.GetNginxPlusInstance([]string{}) + inValidInstance := protos.NginxPlusInstance([]string{}) inValidInstance.InstanceMeta.InstanceId = "e1374cb1-462d-3b6c-9f3b-f28332b5f10f" tests := []struct { @@ -692,7 +692,7 @@ func TestResource_Process_APIAction_GetUpstreams(t *testing.T) { message: &bus.Message{ Topic: bus.APIActionRequestTopic, Data: protos.CreateAPIActionRequestNginxPlusGetUpstreams( - protos.GetNginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId()), + protos.NginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId()), }, err: nil, upstreams: &client.Upstreams{ @@ -709,14 +709,14 @@ func TestResource_Process_APIAction_GetUpstreams(t *testing.T) { }, }, topic: []string{bus.DataPlaneResponseTopic}, - instance: protos.GetNginxPlusInstance([]string{}), + instance: protos.NginxPlusInstance([]string{}), }, { name: "Test 2: Fail, Get Upstreams API Action", message: &bus.Message{ Topic: bus.APIActionRequestTopic, Data: protos.CreateAPIActionRequestNginxPlusGetUpstreams( - protos.GetNginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId()), + protos.NginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId()), }, err: errors.New("failed to get upstreams"), upstreams: &client.Upstreams{ @@ -733,14 +733,14 @@ func TestResource_Process_APIAction_GetUpstreams(t *testing.T) { }, }, topic: []string{bus.DataPlaneResponseTopic}, - instance: protos.GetNginxPlusInstance([]string{}), + instance: protos.NginxPlusInstance([]string{}), }, { name: "Test 3: Fail, No Instance", message: &bus.Message{ Topic: bus.APIActionRequestTopic, Data: protos.CreatAPIActionRequestNginxPlusGetHTTPServers("test_upstream", - protos.GetNginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId()), + protos.NginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId()), }, err: errors.New("failed to preform API action, could not find instance with ID: " + "e1374cb1-462d-3b6c-9f3b-f28332b5f10c"), @@ -765,7 +765,7 @@ func TestResource_Process_APIAction_GetUpstreams(t *testing.T) { message: &bus.Message{ Topic: bus.APIActionRequestTopic, Data: protos.CreatAPIActionRequestNginxPlusGetHTTPServers("test_upstream", - protos.GetNginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId()), + protos.NginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId()), }, err: errors.New("failed to preform API action, instance is not NGINX Plus"), upstreams: &client.Upstreams{ @@ -782,7 +782,7 @@ func TestResource_Process_APIAction_GetUpstreams(t *testing.T) { }, }, topic: []string{bus.DataPlaneResponseTopic}, - instance: protos.GetNginxOssInstance([]string{}), + instance: protos.NginxOssInstance([]string{}), }, } @@ -806,9 +806,9 @@ func TestResource_Process_APIAction_GetUpstreams(t *testing.T) { resourcePlugin.Process(ctx, test.message) - assert.Equal(t, test.topic[0], messagePipe.GetMessages()[0].Topic) + assert.Equal(t, test.topic[0], messagePipe.Messages()[0].Topic) - response, ok := messagePipe.GetMessages()[0].Data.(*mpi.DataPlaneResponse) + response, ok := messagePipe.Messages()[0].Data.(*mpi.DataPlaneResponse) assert.True(tt, ok) if test.err != nil { @@ -837,7 +837,7 @@ func TestResource_Process_Rollback(t *testing.T) { Topic: bus.RollbackWriteTopic, Data: &model.ConfigApplyMessage{ CorrelationID: "dfsbhj6-bc92-30c1-a9c9-85591422068e", - InstanceID: protos.GetNginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId(), + InstanceID: protos.NginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId(), Error: fmt.Errorf("something went wrong with config apply"), }, }, @@ -850,7 +850,7 @@ func TestResource_Process_Rollback(t *testing.T) { Topic: bus.RollbackWriteTopic, Data: &model.ConfigApplyMessage{ CorrelationID: "", - InstanceID: protos.GetNginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId(), + InstanceID: protos.NginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId(), Error: fmt.Errorf("something went wrong with config apply"), }, }, @@ -875,22 +875,22 @@ func TestResource_Process_Rollback(t *testing.T) { resourcePlugin.Process(ctx, test.message) - sort.Slice(messagePipe.GetMessages(), func(i, j int) bool { - return messagePipe.GetMessages()[i].Topic < messagePipe.GetMessages()[j].Topic + sort.Slice(messagePipe.Messages(), func(i, j int) bool { + return messagePipe.Messages()[i].Topic < messagePipe.Messages()[j].Topic }) - assert.Equal(tt, len(test.topic), len(messagePipe.GetMessages())) + assert.Equal(tt, len(test.topic), len(messagePipe.Messages())) - assert.Equal(t, test.topic[0], messagePipe.GetMessages()[0].Topic) + assert.Equal(t, test.topic[0], messagePipe.Messages()[0].Topic) if len(test.topic) > 1 { - assert.Equal(t, test.topic[1], messagePipe.GetMessages()[1].Topic) + assert.Equal(t, test.topic[1], messagePipe.Messages()[1].Topic) } if test.rollbackErr != nil { - rollbackResponse, ok := messagePipe.GetMessages()[1].Data.(*mpi.DataPlaneResponse) + rollbackResponse, ok := messagePipe.Messages()[1].Data.(*mpi.DataPlaneResponse) assert.True(tt, ok) - assert.Equal(t, test.topic[1], messagePipe.GetMessages()[1].Topic) + assert.Equal(t, test.topic[1], messagePipe.Messages()[1].Topic) assert.Equal(tt, test.rollbackErr.Error(), rollbackResponse.GetCommandResponse().GetError()) } }) @@ -928,7 +928,7 @@ func TestResource_Init(t *testing.T) { err := resourcePlugin.Init(ctx, messagePipe) require.NoError(t, err) - messages := messagePipe.GetMessages() + messages := messagePipe.Messages() assert.Empty(t, messages) } diff --git a/internal/resource/resource_service_test.go b/internal/resource/resource_service_test.go index 8fc384ef2..b60af6d23 100644 --- a/internal/resource/resource_service_test.go +++ b/internal/resource/resource_service_test.go @@ -39,23 +39,23 @@ func TestResourceService_AddInstance(t *testing.T) { { name: "Test 1: Add One Instance", instanceList: []*v1.Instance{ - protos.GetNginxOssInstance([]string{}), + protos.NginxOssInstance([]string{}), }, - resource: protos.GetHostResource(), + resource: protos.HostResource(), }, { name: "Test 2: Add Multiple Instance", instanceList: []*v1.Instance{ - protos.GetNginxOssInstance([]string{}), - protos.GetNginxPlusInstance([]string{}), + protos.NginxOssInstance([]string{}), + protos.NginxPlusInstance([]string{}), }, resource: &v1.Resource{ - ResourceId: protos.GetHostResource().GetResourceId(), + ResourceId: protos.HostResource().GetResourceId(), Instances: []*v1.Instance{ - protos.GetNginxOssInstance([]string{}), - protos.GetNginxPlusInstance([]string{}), + protos.NginxOssInstance([]string{}), + protos.NginxPlusInstance([]string{}), }, - Info: protos.GetHostResource().GetInfo(), + Info: protos.HostResource().GetInfo(), }, }, } @@ -73,13 +73,13 @@ func TestResourceService_UpdateInstance(t *testing.T) { ctx := context.Background() updatedInstance := &v1.Instance{ - InstanceConfig: protos.GetNginxOssInstance([]string{}).GetInstanceConfig(), - InstanceMeta: protos.GetNginxOssInstance([]string{}).GetInstanceMeta(), + InstanceConfig: protos.NginxOssInstance([]string{}).GetInstanceConfig(), + InstanceMeta: protos.NginxOssInstance([]string{}).GetInstanceMeta(), InstanceRuntime: &v1.InstanceRuntime{ ProcessId: 56789, - BinaryPath: protos.GetNginxOssInstance([]string{}).GetInstanceRuntime().GetBinaryPath(), - ConfigPath: protos.GetNginxOssInstance([]string{}).GetInstanceRuntime().GetConfigPath(), - Details: protos.GetNginxOssInstance([]string{}).GetInstanceRuntime().GetDetails(), + BinaryPath: protos.NginxOssInstance([]string{}).GetInstanceRuntime().GetBinaryPath(), + ConfigPath: protos.NginxOssInstance([]string{}).GetInstanceRuntime().GetConfigPath(), + Details: protos.NginxOssInstance([]string{}).GetInstanceRuntime().GetDetails(), }, } @@ -94,11 +94,11 @@ func TestResourceService_UpdateInstance(t *testing.T) { updatedInstance, }, resource: &v1.Resource{ - ResourceId: protos.GetHostResource().GetResourceId(), + ResourceId: protos.HostResource().GetResourceId(), Instances: []*v1.Instance{ updatedInstance, }, - Info: protos.GetHostResource().GetInfo(), + Info: protos.HostResource().GetInfo(), }, }, } @@ -106,7 +106,7 @@ func TestResourceService_UpdateInstance(t *testing.T) { for _, test := range tests { t.Run(test.name, func(tt *testing.T) { resourceService := NewResourceService(ctx, types.AgentConfig()) - resourceService.resource.Instances = []*v1.Instance{protos.GetNginxOssInstance([]string{})} + resourceService.resource.Instances = []*v1.Instance{protos.NginxOssInstance([]string{})} resource := resourceService.UpdateInstances(ctx, test.instanceList) assert.Equal(tt, test.resource.GetInstances(), resource.GetInstances()) }) @@ -125,14 +125,14 @@ func TestResourceService_DeleteInstance(t *testing.T) { { name: "Test 1: Update Instances", instanceList: []*v1.Instance{ - protos.GetNginxPlusInstance([]string{}), + protos.NginxPlusInstance([]string{}), }, resource: &v1.Resource{ - ResourceId: protos.GetHostResource().GetResourceId(), + ResourceId: protos.HostResource().GetResourceId(), Instances: []*v1.Instance{ - protos.GetNginxOssInstance([]string{}), + protos.NginxOssInstance([]string{}), }, - Info: protos.GetHostResource().GetInfo(), + Info: protos.HostResource().GetInfo(), }, }, } @@ -141,8 +141,8 @@ func TestResourceService_DeleteInstance(t *testing.T) { t.Run(test.name, func(tt *testing.T) { resourceService := NewResourceService(ctx, types.AgentConfig()) resourceService.resource.Instances = []*v1.Instance{ - protos.GetNginxOssInstance([]string{}), - protos.GetNginxPlusInstance([]string{}), + protos.NginxOssInstance([]string{}), + protos.NginxPlusInstance([]string{}), } resource := resourceService.DeleteInstances(ctx, test.instanceList) assert.Equal(tt, test.resource.GetInstances(), resource.GetInstances()) @@ -161,15 +161,15 @@ func TestResourceService_Instance(t *testing.T) { { name: "Test 1: instance found", instances: []*v1.Instance{ - protos.GetNginxOssInstance([]string{}), - protos.GetNginxPlusInstance([]string{}), + protos.NginxOssInstance([]string{}), + protos.NginxPlusInstance([]string{}), }, - result: protos.GetNginxPlusInstance([]string{}), + result: protos.NginxPlusInstance([]string{}), }, { name: "Test 2: instance not found", instances: []*v1.Instance{ - protos.GetNginxOssInstance([]string{}), + protos.NginxOssInstance([]string{}), }, result: nil, }, @@ -179,7 +179,7 @@ func TestResourceService_Instance(t *testing.T) { t.Run(test.name, func(tt *testing.T) { resourceService := NewResourceService(ctx, types.AgentConfig()) resourceService.resource.Instances = test.instances - instance := resourceService.Instance(protos.GetNginxPlusInstance([]string{}). + instance := resourceService.Instance(protos.NginxPlusInstance([]string{}). GetInstanceMeta().GetInstanceId()) assert.Equal(tt, test.result, instance) }) @@ -195,11 +195,11 @@ func TestResourceService_GetResource(t *testing.T) { }{ { isContainer: true, - expectedResource: protos.GetContainerizedResource(), + expectedResource: protos.ContainerizedResource(), }, { isContainer: false, - expectedResource: protos.GetHostResource(), + expectedResource: protos.HostResource(), }, } for _, tc := range testCases { @@ -237,13 +237,13 @@ func TestResourceService_GetResource(t *testing.T) { } func TestResourceService_createPlusClient(t *testing.T) { - instanceWithAPI := protos.GetNginxPlusInstance([]string{}) + instanceWithAPI := protos.NginxPlusInstance([]string{}) instanceWithAPI.InstanceRuntime.GetNginxPlusRuntimeInfo().PlusApi = &v1.APIDetails{ Location: "/api", Listen: "localhost:80", } - instanceWithUnixAPI := protos.GetNginxPlusInstance([]string{}) + instanceWithUnixAPI := protos.NginxPlusInstance([]string{}) instanceWithUnixAPI.InstanceRuntime.GetNginxPlusRuntimeInfo().PlusApi = &v1.APIDetails{ Listen: "unix:/var/run/nginx-status.sock", Location: "/api", @@ -267,7 +267,7 @@ func TestResourceService_createPlusClient(t *testing.T) { }, { name: "Test 3: Fail Creating Client - API not Configured", - instance: protos.GetNginxPlusInstance([]string{}), + instance: protos.NginxPlusInstance([]string{}), err: errors.New("failed to preform API action, NGINX Plus API is not configured"), }, } @@ -276,8 +276,8 @@ func TestResourceService_createPlusClient(t *testing.T) { t.Run(test.name, func(tt *testing.T) { resourceService := NewResourceService(ctx, types.AgentConfig()) resourceService.resource.Instances = []*v1.Instance{ - protos.GetNginxOssInstance([]string{}), - protos.GetNginxPlusInstance([]string{}), + protos.NginxOssInstance([]string{}), + protos.NginxPlusInstance([]string{}), } _, err := resourceService.createPlusClient(test.instance) @@ -298,21 +298,21 @@ func TestResourceService_ApplyConfig(t *testing.T) { }{ { name: "Test 1: Successful reload", - instanceID: protos.GetNginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId(), + instanceID: protos.NginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId(), reloadErr: nil, validateErr: nil, expected: nil, }, { name: "Test 2: Failed reload", - instanceID: protos.GetNginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId(), + instanceID: protos.NginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId(), reloadErr: fmt.Errorf("something went wrong"), validateErr: nil, expected: fmt.Errorf("failed to reload NGINX %w", fmt.Errorf("something went wrong")), }, { name: "Test 3: Failed validate", - instanceID: protos.GetNginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId(), + instanceID: protos.NginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId(), reloadErr: nil, validateErr: fmt.Errorf("something went wrong"), expected: fmt.Errorf("failed validating config %w", fmt.Errorf("something went wrong")), @@ -347,11 +347,11 @@ func TestResourceService_ApplyConfig(t *testing.T) { resourceService := NewResourceService(ctx, types.AgentConfig()) resourceOpMap := make(map[string]instanceOperator) - resourceOpMap[protos.GetNginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId()] = instanceOp + resourceOpMap[protos.NginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId()] = instanceOp resourceService.instanceOperators = resourceOpMap resourceService.nginxConfigParser = &nginxParser - instance := protos.GetNginxOssInstance([]string{}) + instance := protos.NginxOssInstance([]string{}) instances := []*v1.Instance{ instance, } diff --git a/internal/watcher/credentials/credential_watcher_service.go b/internal/watcher/credentials/credential_watcher_service.go index e44d4134c..0a1bcfaa8 100644 --- a/internal/watcher/credentials/credential_watcher_service.go +++ b/internal/watcher/credentials/credential_watcher_service.go @@ -147,7 +147,7 @@ func (cws *CredentialWatcherService) checkForUpdates(ctx context.Context, ch cha ) slog.DebugContext(ctx, "Credential watcher has detected changes") - ch <- CredentialUpdateMessage{CorrelationID: logger.GetCorrelationIDAttr(newCtx)} + ch <- CredentialUpdateMessage{CorrelationID: logger.CorrelationIDAttr(newCtx)} cws.filesChanged.Store(false) } } diff --git a/internal/watcher/file/file_watcher_service.go b/internal/watcher/file/file_watcher_service.go index d864414ff..0ca19f046 100644 --- a/internal/watcher/file/file_watcher_service.go +++ b/internal/watcher/file/file_watcher_service.go @@ -213,7 +213,7 @@ func (fws *FileWatcherService) checkForUpdates(ctx context.Context, ch chan<- Fi ) slog.DebugContext(newCtx, "File watcher detected a file change") - ch <- FileUpdateMessage{CorrelationID: logger.GetCorrelationIDAttr(newCtx)} + ch <- FileUpdateMessage{CorrelationID: logger.CorrelationIDAttr(newCtx)} fws.filesChanged.Store(false) } } diff --git a/internal/watcher/health/health_watcher_service.go b/internal/watcher/health/health_watcher_service.go index 77ae72ea5..0b5b6a2b5 100644 --- a/internal/watcher/health/health_watcher_service.go +++ b/internal/watcher/health/health_watcher_service.go @@ -85,7 +85,7 @@ func (hw *HealthWatcherService) DeleteHealthWatcher(instances []*mpi.Instance) { } } -func (hw *HealthWatcherService) GetInstancesHealth() []*mpi.InstanceHealth { +func (hw *HealthWatcherService) InstancesHealth() []*mpi.InstanceHealth { hw.healthWatcherMutex.Lock() defer hw.healthWatcherMutex.Unlock() diff --git a/internal/watcher/health/health_watcher_service_test.go b/internal/watcher/health/health_watcher_service_test.go index fbe3ed3d7..fb3fa97f0 100644 --- a/internal/watcher/health/health_watcher_service_test.go +++ b/internal/watcher/health/health_watcher_service_test.go @@ -21,7 +21,7 @@ import ( func TestHealthWatcherService_AddHealthWatcher(t *testing.T) { agentConfig := types.AgentConfig() - instance := protos.GetNginxOssInstance([]string{}) + instance := protos.NginxOssInstance([]string{}) tests := []struct { name string @@ -38,7 +38,7 @@ func TestHealthWatcherService_AddHealthWatcher(t *testing.T) { { name: "Test 2: Not Supported Instance", instances: []*mpi.Instance{ - protos.GetUnsupportedInstance(), + protos.UnsupportedInstance(), }, }, } @@ -62,7 +62,7 @@ func TestHealthWatcherService_AddHealthWatcher(t *testing.T) { func TestHealthWatcherService_DeleteHealthWatcher(t *testing.T) { agentConfig := types.AgentConfig() healthWatcher := NewHealthWatcherService(agentConfig) - instance := protos.GetNginxOssInstance([]string{}) + instance := protos.NginxOssInstance([]string{}) instances := []*mpi.Instance{instance} healthWatcher.AddHealthWatcher(instances) @@ -76,8 +76,8 @@ func TestHealthWatcherService_DeleteHealthWatcher(t *testing.T) { func TestHealthWatcherService_UpdateHealthWatcher(t *testing.T) { agentConfig := types.AgentConfig() healthWatcher := NewHealthWatcherService(agentConfig) - instance := protos.GetNginxOssInstance([]string{}) - updatedInstance := protos.GetNginxPlusInstance([]string{}) + instance := protos.NginxOssInstance([]string{}) + updatedInstance := protos.NginxPlusInstance([]string{}) updatedInstance.GetInstanceMeta().InstanceId = instance.GetInstanceMeta().GetInstanceId() instances := []*mpi.Instance{instance} @@ -93,16 +93,16 @@ func TestHealthWatcherService_health(t *testing.T) { ctx := context.Background() agentConfig := types.AgentConfig() healthWatcher := NewHealthWatcherService(agentConfig) - ossInstance := protos.GetNginxOssInstance([]string{}) - plusInstance := protos.GetNginxPlusInstance([]string{}) - unspecifiedInstance := protos.GetUnsupportedInstance() + ossInstance := protos.NginxOssInstance([]string{}) + plusInstance := protos.NginxPlusInstance([]string{}) + unspecifiedInstance := protos.UnsupportedInstance() watchers := make(map[string]healthWatcherOperator) fakeOSSHealthOp := healthfakes.FakeHealthWatcherOperator{} - fakeOSSHealthOp.HealthReturns(protos.GetHealthyInstanceHealth(), nil) + fakeOSSHealthOp.HealthReturns(protos.HealthyInstanceHealth(), nil) fakePlusHealthOp := healthfakes.FakeHealthWatcherOperator{} - fakePlusHealthOp.HealthReturns(protos.GetUnhealthyInstanceHealth(), nil) + fakePlusHealthOp.HealthReturns(protos.UnhealthyInstanceHealth(), nil) fakeUnspecifiedHealthOp := healthfakes.FakeHealthWatcherOperator{} fakeUnspecifiedHealthOp.HealthReturns(nil, fmt.Errorf("unable to determine health")) @@ -113,9 +113,9 @@ func TestHealthWatcherService_health(t *testing.T) { healthWatcher.watchers = watchers expected := []*mpi.InstanceHealth{ - protos.GetHealthyInstanceHealth(), - protos.GetUnhealthyInstanceHealth(), - protos.GetUnspecifiedInstanceHealth(), + protos.HealthyInstanceHealth(), + protos.UnhealthyInstanceHealth(), + protos.UnspecifiedInstanceHealth(), } tests := []struct { @@ -126,21 +126,21 @@ func TestHealthWatcherService_health(t *testing.T) { { name: "Test 1: Status Changed", cache: map[string]*mpi.InstanceHealth{ - ossInstance.GetInstanceMeta().GetInstanceId(): protos.GetHealthyInstanceHealth(), + ossInstance.GetInstanceMeta().GetInstanceId(): protos.HealthyInstanceHealth(), plusInstance.GetInstanceMeta().GetInstanceId(): { InstanceId: plusInstance.GetInstanceMeta().GetInstanceId(), InstanceHealthStatus: mpi.InstanceHealth_INSTANCE_HEALTH_STATUS_HEALTHY, }, - unspecifiedInstance.GetInstanceMeta().GetInstanceId(): protos.GetUnspecifiedInstanceHealth(), + unspecifiedInstance.GetInstanceMeta().GetInstanceId(): protos.UnspecifiedInstanceHealth(), }, isHealthDiff: true, }, { name: "Test 2: Status Not Changed", cache: map[string]*mpi.InstanceHealth{ - ossInstance.GetInstanceMeta().GetInstanceId(): protos.GetHealthyInstanceHealth(), - plusInstance.GetInstanceMeta().GetInstanceId(): protos.GetUnhealthyInstanceHealth(), - unspecifiedInstance.GetInstanceMeta().GetInstanceId(): protos.GetUnspecifiedInstanceHealth(), + ossInstance.GetInstanceMeta().GetInstanceId(): protos.HealthyInstanceHealth(), + plusInstance.GetInstanceMeta().GetInstanceId(): protos.UnhealthyInstanceHealth(), + unspecifiedInstance.GetInstanceMeta().GetInstanceId(): protos.UnspecifiedInstanceHealth(), }, isHealthDiff: false, }, @@ -151,7 +151,7 @@ func TestHealthWatcherService_health(t *testing.T) { InstanceId: ossInstance.GetInstanceMeta().GetInstanceId(), InstanceHealthStatus: mpi.InstanceHealth_INSTANCE_HEALTH_STATUS_UNHEALTHY, }, - unspecifiedInstance.GetInstanceMeta().GetInstanceId(): protos.GetUnspecifiedInstanceHealth(), + unspecifiedInstance.GetInstanceMeta().GetInstanceId(): protos.UnspecifiedInstanceHealth(), }, isHealthDiff: true, }, @@ -169,10 +169,10 @@ func TestHealthWatcherService_health(t *testing.T) { } func TestHealthWatcherService_compareCache(t *testing.T) { - ossInstance := protos.GetNginxOssInstance([]string{}) - plusInstance := protos.GetNginxPlusInstance([]string{}) + ossInstance := protos.NginxOssInstance([]string{}) + plusInstance := protos.NginxPlusInstance([]string{}) healthCache := map[string]*mpi.InstanceHealth{ - ossInstance.GetInstanceMeta().GetInstanceId(): protos.GetHealthyInstanceHealth(), + ossInstance.GetInstanceMeta().GetInstanceId(): protos.HealthyInstanceHealth(), plusInstance.GetInstanceMeta().GetInstanceId(): { InstanceId: plusInstance.GetInstanceMeta().GetInstanceId(), InstanceHealthStatus: mpi.InstanceHealth_INSTANCE_HEALTH_STATUS_HEALTHY, @@ -180,7 +180,7 @@ func TestHealthWatcherService_compareCache(t *testing.T) { } healths := []*mpi.InstanceHealth{ - protos.GetHealthyInstanceHealth(), + protos.HealthyInstanceHealth(), } tests := []struct { @@ -192,7 +192,7 @@ func TestHealthWatcherService_compareCache(t *testing.T) { { name: "Test 1: Instance was deleted", expectedHealth: []*mpi.InstanceHealth{ - protos.GetHealthyInstanceHealth(), + protos.HealthyInstanceHealth(), { InstanceId: plusInstance.GetInstanceMeta().GetInstanceId(), Description: fmt.Sprintf("instance %s not found", plusInstance. @@ -201,7 +201,7 @@ func TestHealthWatcherService_compareCache(t *testing.T) { }, }, expectedCache: map[string]*mpi.InstanceHealth{ - ossInstance.GetInstanceMeta().GetInstanceId(): protos.GetHealthyInstanceHealth(), + ossInstance.GetInstanceMeta().GetInstanceId(): protos.HealthyInstanceHealth(), }, instances: map[string]*mpi.Instance{ ossInstance.GetInstanceMeta().GetInstanceId(): ossInstance, @@ -210,10 +210,10 @@ func TestHealthWatcherService_compareCache(t *testing.T) { { name: "Test 2: No change to instance list", expectedHealth: []*mpi.InstanceHealth{ - protos.GetHealthyInstanceHealth(), + protos.HealthyInstanceHealth(), }, expectedCache: map[string]*mpi.InstanceHealth{ - ossInstance.GetInstanceMeta().GetInstanceId(): protos.GetHealthyInstanceHealth(), + ossInstance.GetInstanceMeta().GetInstanceId(): protos.HealthyInstanceHealth(), }, instances: map[string]*mpi.Instance{ ossInstance.GetInstanceMeta().GetInstanceId(): ossInstance, @@ -237,10 +237,10 @@ func TestHealthWatcherService_compareCache(t *testing.T) { } func TestHealthWatcherService_GetInstancesHealth(t *testing.T) { - ossInstance := protos.GetNginxOssInstance([]string{}) - plusInstance := protos.GetNginxPlusInstance([]string{}) - ossInstanceHealth := protos.GetHealthyInstanceHealth() - plusInstanceHealth := protos.GetUnhealthyInstanceHealth() + ossInstance := protos.NginxOssInstance([]string{}) + plusInstance := protos.NginxPlusInstance([]string{}) + ossInstanceHealth := protos.HealthyInstanceHealth() + plusInstanceHealth := protos.UnhealthyInstanceHealth() healthCache := map[string]*mpi.InstanceHealth{ ossInstance.GetInstanceMeta().GetInstanceId(): ossInstanceHealth, @@ -255,7 +255,7 @@ func TestHealthWatcherService_GetInstancesHealth(t *testing.T) { healthWatcher := NewHealthWatcherService(agentConfig) healthWatcher.cache = healthCache - result := healthWatcher.GetInstancesHealth() + result := healthWatcher.InstancesHealth() assert.ElementsMatch(t, expectedInstancesHealth, result) } diff --git a/internal/watcher/health/nginx_health_watcher_operator_test.go b/internal/watcher/health/nginx_health_watcher_operator_test.go index 18b493602..a607f4d57 100644 --- a/internal/watcher/health/nginx_health_watcher_operator_test.go +++ b/internal/watcher/health/nginx_health_watcher_operator_test.go @@ -25,8 +25,8 @@ func TestNginxHealthWatcherOperator_Health(t *testing.T) { ctx := context.Background() nginxHealthWatcher := NewNginxHealthWatcher() fakeProcessOperator := &processfakes.FakeProcessOperatorInterface{} - instance := protos.GetNginxOssInstance([]string{}) - noChildrenInstance := protos.GetNginxOssInstance([]string{}) + instance := protos.NginxOssInstance([]string{}) + noChildrenInstance := protos.NginxOssInstance([]string{}) noChildrenInstance.GetInstanceRuntime().InstanceChildren = []*mpi.InstanceChild{} tests := []struct { diff --git a/internal/watcher/instance/instance_watcher_service.go b/internal/watcher/instance/instance_watcher_service.go index b86edfe52..12f212365 100644 --- a/internal/watcher/instance/instance_watcher_service.go +++ b/internal/watcher/instance/instance_watcher_service.go @@ -164,7 +164,7 @@ func (iw *InstanceWatcherService) HandleNginxConfigContextUpdate(ctx context.Con updatesRequired := false instance := iw.instanceCache[instanceID] instanceType := instance.GetInstanceMeta().GetInstanceType() - correlationID := logger.GetCorrelationIDAttr(ctx) + correlationID := logger.CorrelationIDAttr(ctx) if instanceType == mpi.InstanceMeta_INSTANCE_TYPE_NGINX || instanceType == mpi.InstanceMeta_INSTANCE_TYPE_NGINX_PLUS { @@ -253,7 +253,7 @@ func (iw *InstanceWatcherService) sendNginxConfigContextUpdate( ) iw.nginxConfigContextChannel <- NginxConfigContextMessage{ - CorrelationID: logger.GetCorrelationIDAttr(ctx), + CorrelationID: logger.CorrelationIDAttr(ctx), NginxConfigContext: nginxConfigContext, } } diff --git a/internal/watcher/instance/instance_watcher_service_test.go b/internal/watcher/instance/instance_watcher_service_test.go index 7cd0f2a28..845e13c85 100644 --- a/internal/watcher/instance/instance_watcher_service_test.go +++ b/internal/watcher/instance/instance_watcher_service_test.go @@ -25,15 +25,15 @@ import ( func TestInstanceWatcherService_checkForUpdates(t *testing.T) { ctx := context.Background() - nginxConfigContext := testModel.GetConfigContext() + nginxConfigContext := testModel.ConfigContext() fakeProcessWatcher := &processfakes.FakeProcessOperatorInterface{} fakeProcessWatcher.ProcessesReturns(nil, nil, nil) fakeProcessParser := &instancefakes.FakeProcessParser{} fakeProcessParser.ParseReturns(map[string]*mpi.Instance{ - protos.GetNginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId(): protos. - GetNginxOssInstance([]string{}), + protos.NginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId(): protos. + NginxOssInstance([]string{}), }) fakeNginxConfigParser := &instancefakes.FakeNginxConfigParser{} @@ -63,9 +63,9 @@ func TestInstanceWatcherService_instanceUpdates(t *testing.T) { ctx := context.Background() processID := int32(123) - agentInstance := protos.GetAgentInstance(processID, types.AgentConfig()) - nginxInstance := protos.GetNginxOssInstance([]string{}) - nginxInstanceWithDifferentPID := protos.GetNginxOssInstance([]string{}) + agentInstance := protos.AgentInstance(processID, types.AgentConfig()) + nginxInstance := protos.NginxOssInstance([]string{}) + nginxInstanceWithDifferentPID := protos.NginxOssInstance([]string{}) nginxInstanceWithDifferentPID.GetInstanceRuntime().ProcessId = 3526 tests := []struct { @@ -117,13 +117,13 @@ func TestInstanceWatcherService_instanceUpdates(t *testing.T) { name: "Test 4: Deleted instance", oldInstances: map[string]*mpi.Instance{ agentInstance.GetInstanceMeta().GetInstanceId(): agentInstance, - protos.GetNginxOssInstance([]string{}).GetInstanceMeta(). - GetInstanceId(): protos.GetNginxOssInstance([]string{}), + protos.NginxOssInstance([]string{}).GetInstanceMeta(). + GetInstanceId(): protos.NginxOssInstance([]string{}), }, parsedInstances: make(map[string]*mpi.Instance), expectedInstanceUpdates: InstanceUpdates{ DeletedInstances: []*mpi.Instance{ - protos.GetNginxOssInstance([]string{}), + protos.NginxOssInstance([]string{}), }, }, }, @@ -247,19 +247,19 @@ func TestInstanceWatcherService_areInstancesEqual(t *testing.T) { func TestInstanceWatcherService_ReparseConfig(t *testing.T) { ctx := context.Background() - nginxConfigContext := testModel.GetConfigContext() - updateNginxConfigContext := testModel.GetConfigContext() + nginxConfigContext := testModel.ConfigContext() + updateNginxConfigContext := testModel.ConfigContext() updateNginxConfigContext.AccessLogs = []*model.AccessLog{ { Name: "access2.log", }, } - instance := protos.GetNginxOssInstance([]string{}) + instance := protos.NginxOssInstance([]string{}) instance.GetInstanceRuntime().GetNginxRuntimeInfo().AccessLogs = []string{"access.logs"} instance.GetInstanceRuntime().GetNginxRuntimeInfo().ErrorLogs = []string{"error.log"} - updatedInstance := protos.GetNginxOssInstance([]string{}) + updatedInstance := protos.NginxOssInstance([]string{}) updatedInstance.GetInstanceRuntime().GetNginxRuntimeInfo().AccessLogs = []string{"access2.log"} updatedInstance.GetInstanceRuntime().GetNginxRuntimeInfo().ErrorLogs = []string{"error.log"} diff --git a/internal/watcher/instance/nginx_process_parser.go b/internal/watcher/instance/nginx_process_parser.go index 5e7289921..3a54cd914 100644 --- a/internal/watcher/instance/nginx_process_parser.go +++ b/internal/watcher/instance/nginx_process_parser.go @@ -77,7 +77,7 @@ func (npp *NginxProcessParser) Parse(ctx context.Context, processes []*nginxproc continue } - nginxInfo, err := npp.getInfo(ctx, proc) + nginxInfo, err := npp.info(ctx, proc) if err != nil { slog.DebugContext(ctx, "Unable to get NGINX info", "pid", proc.PID, "error", err) @@ -105,7 +105,7 @@ func (npp *NginxProcessParser) Parse(ctx context.Context, processes []*nginxproc // check if proc is a master process, process is not a worker but could be cache manager etc if proc.IsMaster() { - nginxInfo, err := npp.getInfo(ctx, proc) + nginxInfo, err := npp.info(ctx, proc) if err != nil { slog.DebugContext(ctx, "Unable to get NGINX info", "pid", proc.PID, "error", err) @@ -126,17 +126,17 @@ func (npp *NginxProcessParser) Parse(ctx context.Context, processes []*nginxproc return instanceMap } -func (npp *NginxProcessParser) getInfo(ctx context.Context, proc *nginxprocess.Process) (*Info, error) { +func (npp *NginxProcessParser) info(ctx context.Context, proc *nginxprocess.Process) (*Info, error) { exePath := proc.Exe if exePath == "" { - exePath = npp.getExe(ctx) + exePath = npp.exe(ctx) if exePath == "" { return nil, fmt.Errorf("unable to find NGINX exe for process %d", proc.PID) } } - confPath := getConfPathFromCommand(proc.Cmd) + confPath := confPathFromCommand(proc.Cmd) var nginxInfo *Info @@ -150,19 +150,19 @@ func (npp *NginxProcessParser) getInfo(ctx context.Context, proc *nginxprocess.P nginxInfo.ExePath = exePath nginxInfo.ProcessID = proc.PID - if nginxInfo.ConfPath = getNginxConfPath(ctx, nginxInfo); confPath != "" { + if nginxInfo.ConfPath = nginxConfPath(ctx, nginxInfo); confPath != "" { nginxInfo.ConfPath = confPath } - loadableModules := getLoadableModules(nginxInfo) + loadableModules := loadableModules(nginxInfo) nginxInfo.LoadableModules = loadableModules - nginxInfo.DynamicModules = getDynamicModules(nginxInfo) + nginxInfo.DynamicModules = dynamicModules(nginxInfo) return nginxInfo, err } -func (npp *NginxProcessParser) getExe(ctx context.Context) string { +func (npp *NginxProcessParser) exe(ctx context.Context) string { exePath := "" out, commandErr := npp.executer.RunCmd(ctx, "sh", "-c", "command -v nginx") @@ -273,7 +273,7 @@ func parseNginxVersionCommandOutput(ctx context.Context, output *bytes.Buffer) * } } - nginxInfo.Prefix = getNginxPrefix(ctx, nginxInfo) + nginxInfo.Prefix = nginxPrefix(ctx, nginxInfo) return nginxInfo } @@ -299,7 +299,7 @@ func parseConfigureArguments(line string) map[string]interface{} { return result } -func getNginxPrefix(ctx context.Context, nginxInfo *Info) string { +func nginxPrefix(ctx context.Context, nginxInfo *Info) string { var prefix string if nginxInfo.ConfigureArgs["prefix"] != nil { @@ -315,7 +315,7 @@ func getNginxPrefix(ctx context.Context, nginxInfo *Info) string { return prefix } -func getNginxConfPath(ctx context.Context, nginxInfo *Info) string { +func nginxConfPath(ctx context.Context, nginxInfo *Info) string { var confPath string if nginxInfo.ConfigureArgs["conf-path"] != nil { @@ -339,7 +339,7 @@ func isKeyValueFlag(vals []string) bool { return len(vals) == keyValueLen } -func getLoadableModules(nginxInfo *Info) (modules []string) { +func loadableModules(nginxInfo *Info) (modules []string) { var err error if mp, ok := nginxInfo.ConfigureArgs["modules-path"]; ok { modulePath, pathOK := mp.(string) @@ -361,7 +361,7 @@ func getLoadableModules(nginxInfo *Info) (modules []string) { return modules } -func getDynamicModules(nginxInfo *Info) (modules []string) { +func dynamicModules(nginxInfo *Info) (modules []string) { configArgs := nginxInfo.ConfigureArgs for arg := range configArgs { if strings.HasPrefix(arg, withWithPrefix) && strings.HasSuffix(arg, withModuleSuffix) { @@ -398,7 +398,7 @@ func convertToMap(processes []*nginxprocess.Process) map[int32]*nginxprocess.Pro return processesByPID } -func getConfPathFromCommand(command string) string { +func confPathFromCommand(command string) string { commands := strings.Split(command, " ") for i, command := range commands { diff --git a/internal/watcher/instance/nginx_process_parser_test.go b/internal/watcher/instance/nginx_process_parser_test.go index 4f3c0271c..fe0c29d81 100644 --- a/internal/watcher/instance/nginx_process_parser_test.go +++ b/internal/watcher/instance/nginx_process_parser_test.go @@ -130,7 +130,7 @@ func TestNginxProcessParser_Parse(t *testing.T) { TLS SNI support enabled configure arguments: %s`, ossArgs), expected: map[string]*mpi.Instance{ - protos.GetNginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId(): protos.GetNginxOssInstance( + protos.NginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId(): protos.NginxOssInstance( []string{expectedModules}), }, }, @@ -143,7 +143,7 @@ func TestNginxProcessParser_Parse(t *testing.T) { TLS SNI support enabled configure arguments: %s`, plusArgs), expected: map[string]*mpi.Instance{ - protos.GetNginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId(): protos.GetNginxPlusInstance( + protos.NginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId(): protos.NginxPlusInstance( []string{expectedModules}), }, }, @@ -155,8 +155,8 @@ func TestNginxProcessParser_Parse(t *testing.T) { TLS SNI support enabled configure arguments: %s`, noModuleArgs), expected: map[string]*mpi.Instance{ - protos.GetNginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId(): protos. - GetNginxOssInstance(nil), + protos.NginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId(): protos. + NginxOssInstance(nil), }, }, } @@ -202,24 +202,24 @@ func TestNginxProcessParser_Parse_Processes(t *testing.T) { TLS SNI support enabled configure arguments: %s`, configArgs) - process1 := protos.GetNginxOssInstance(nil) + process1 := protos.NginxOssInstance(nil) instancesTest1 := map[string]*mpi.Instance{ process1.GetInstanceMeta().GetInstanceId(): process1, } - noChildrenInstance := protos.GetNginxOssInstance(nil) + noChildrenInstance := protos.NginxOssInstance(nil) noChildrenInstance.GetInstanceRuntime().InstanceChildren = nil instancesTest2 := map[string]*mpi.Instance{ noChildrenInstance.GetInstanceMeta().GetInstanceId(): noChildrenInstance, } - noParentInstanceList := protos.GetInstancesNoParentProcess(nil) + noParentInstanceList := protos.InstancesNoParentProcess(nil) instancesTest3 := map[string]*mpi.Instance{ noParentInstanceList[0].GetInstanceMeta().GetInstanceId(): noParentInstanceList[0], noParentInstanceList[1].GetInstanceMeta().GetInstanceId(): noParentInstanceList[1], } - instancesList := protos.GetMultipleInstances(nil) + instancesList := protos.MultipleInstances(nil) instancesTest4 := map[string]*mpi.Instance{ instancesList[0].GetInstanceMeta().GetInstanceId(): instancesList[0], instancesList[1].GetInstanceMeta().GetInstanceId(): instancesList[1], @@ -482,7 +482,7 @@ func TestGetInfo(t *testing.T) { "with-stream_ssl_preread_module": true, }, LoadableModules: []string{expectedModules}, - DynamicModules: protos.GetNginxOssInstance([]string{}).GetInstanceRuntime().GetNginxRuntimeInfo(). + DynamicModules: protos.NginxOssInstance([]string{}).GetInstanceRuntime().GetNginxRuntimeInfo(). GetDynamicModules(), }, }, @@ -558,7 +558,7 @@ func TestGetInfo(t *testing.T) { "with-threads": true, }, LoadableModules: []string{expectedModules}, - DynamicModules: protos.GetNginxPlusInstance([]string{}).GetInstanceRuntime().GetNginxPlusRuntimeInfo(). + DynamicModules: protos.NginxPlusInstance([]string{}).GetInstanceRuntime().GetNginxPlusRuntimeInfo(). GetDynamicModules(), }, }, @@ -571,7 +571,7 @@ func TestGetInfo(t *testing.T) { nginxProcessParser := NewNginxProcessParser() nginxProcessParser.executer = mockExec - result, err := nginxProcessParser.getInfo(ctx, test.process) + result, err := nginxProcessParser.info(ctx, test.process) sort.Strings(result.DynamicModules) assert.Equal(tt, test.expected, result) @@ -610,7 +610,7 @@ func TestNginxProcessParser_GetExe(t *testing.T) { nginxProcessParser := NewNginxProcessParser() nginxProcessParser.executer = mockExec - result := nginxProcessParser.getExe(ctx) + result := nginxProcessParser.exe(ctx) assert.Equal(tt, test.expected, result) }) @@ -618,12 +618,12 @@ func TestNginxProcessParser_GetExe(t *testing.T) { } func TestGetConfigPathFromCommand(t *testing.T) { - result := getConfPathFromCommand("nginx: master process nginx -c /tmp/nginx.conf") + result := confPathFromCommand("nginx: master process nginx -c /tmp/nginx.conf") assert.Equal(t, "/tmp/nginx.conf", result) - result = getConfPathFromCommand("nginx: master process nginx -c") + result = confPathFromCommand("nginx: master process nginx -c") assert.Equal(t, "", result) - result = getConfPathFromCommand("") + result = confPathFromCommand("") assert.Equal(t, "", result) } diff --git a/internal/watcher/watcher_plugin.go b/internal/watcher/watcher_plugin.go index 72164c603..a516c0b69 100644 --- a/internal/watcher/watcher_plugin.go +++ b/internal/watcher/watcher_plugin.go @@ -158,7 +158,7 @@ func (*Watcher) Subscriptions() []string { } func (w *Watcher) handleConfigApplyRequest(ctx context.Context, msg *bus.Message) { - slog.DebugContext(ctx, "Watcher plugin received ConfigApplyRequest event") + slog.DebugContext(ctx, "Watcher plugin received config apply request message") managementPlaneRequest, ok := msg.Data.(*mpi.ManagementPlaneRequest) if !ok { slog.ErrorContext(ctx, "Unable to cast message payload to *mpi.ManagementPlaneRequest", @@ -186,6 +186,7 @@ func (w *Watcher) handleConfigApplyRequest(ctx context.Context, msg *bus.Message } func (w *Watcher) handleConfigApplySuccess(ctx context.Context, msg *bus.Message) { + slog.DebugContext(ctx, "Watcher plugin received config apply success message") successMessage, ok := msg.Data.(*model.ConfigApplySuccess) if !ok { slog.ErrorContext(ctx, "Unable to cast message payload to *model.ConfigApplySuccess", "payload", @@ -216,12 +217,14 @@ func (w *Watcher) handleConfigApplySuccess(ctx context.Context, msg *bus.Message } func (w *Watcher) handleHealthRequest(ctx context.Context) { + slog.DebugContext(ctx, "Watcher plugin received health request message") w.messagePipe.Process(ctx, &bus.Message{ - Topic: bus.DataPlaneHealthResponseTopic, Data: w.healthWatcherService.GetInstancesHealth(), + Topic: bus.DataPlaneHealthResponseTopic, Data: w.healthWatcherService.InstancesHealth(), }) } func (w *Watcher) handleConfigApplyComplete(ctx context.Context, msg *bus.Message) { + slog.DebugContext(ctx, "Watcher plugin received config apply complete message") response, ok := msg.Data.(*mpi.DataPlaneResponse) if !ok { slog.ErrorContext(ctx, "Unable to cast message payload to *mpi.DataPlaneResponse", "payload", @@ -246,7 +249,7 @@ func (w *Watcher) handleConfigApplyComplete(ctx context.Context, msg *bus.Messag } func (w *Watcher) handleCredentialUpdate(ctx context.Context) { - slog.DebugContext(ctx, "Received credential update topic") + slog.DebugContext(ctx, "Watcher plugin received credential update message") w.watcherMutex.Lock() conn, err := grpc.NewGrpcConnection(ctx, w.agentConfig) diff --git a/internal/watcher/watcher_plugin_test.go b/internal/watcher/watcher_plugin_test.go index 752f98568..7e893a343 100644 --- a/internal/watcher/watcher_plugin_test.go +++ b/internal/watcher/watcher_plugin_test.go @@ -46,7 +46,7 @@ func TestWatcher_Init(t *testing.T) { }() require.NoError(t, err) - messages := messagePipe.GetMessages() + messages := messagePipe.Messages() assert.Empty(t, messages) @@ -54,20 +54,20 @@ func TestWatcher_Init(t *testing.T) { CorrelationID: logger.GenerateCorrelationID(), InstanceUpdates: instance.InstanceUpdates{ NewInstances: []*mpi.Instance{ - protos.GetNginxOssInstance([]string{}), + protos.NginxOssInstance([]string{}), }, UpdatedInstances: []*mpi.Instance{ - protos.GetNginxOssInstance([]string{}), + protos.NginxOssInstance([]string{}), }, DeletedInstances: []*mpi.Instance{ - protos.GetNginxPlusInstance([]string{}), + protos.NginxPlusInstance([]string{}), }, }, } nginxConfigContextMessage := instance.NginxConfigContextMessage{ CorrelationID: logger.GenerateCorrelationID(), - NginxConfigContext: model.GetConfigContext(), + NginxConfigContext: model.ConfigContext(), } instanceHealthMessage := health.InstanceHealthMessage{ @@ -84,8 +84,8 @@ func TestWatcher_Init(t *testing.T) { watcherPlugin.instanceHealthChannel <- instanceHealthMessage watcherPlugin.credentialUpdatesChannel <- credentialUpdateMessage - assert.Eventually(t, func() bool { return len(messagePipe.GetMessages()) == 6 }, 2*time.Second, 10*time.Millisecond) - messages = messagePipe.GetMessages() + assert.Eventually(t, func() bool { return len(messagePipe.Messages()) == 6 }, 2*time.Second, 10*time.Millisecond) + messages = messagePipe.Messages() assert.Equal( t, @@ -163,7 +163,7 @@ func TestWatcher_Process_ConfigApplyRequestTopic(t *testing.T) { func TestWatcher_Process_ConfigApplySuccessfulTopic(t *testing.T) { ctx := context.Background() - data := protos.GetNginxOssInstance([]string{}) + data := protos.NginxOssInstance([]string{}) response := &model2.ConfigApplySuccess{ ConfigContext: &model2.NginxConfigContext{ @@ -202,7 +202,7 @@ func TestWatcher_Process_ConfigApplySuccessfulTopic(t *testing.T) { func TestWatcher_Process_RollbackCompleteTopic(t *testing.T) { ctx := context.Background() - ossInstance := protos.GetNginxOssInstance([]string{}) + ossInstance := protos.NginxOssInstance([]string{}) response := &mpi.DataPlaneResponse{ MessageMeta: &mpi.MessageMeta{ diff --git a/test/config/nginx_config.go b/test/config/nginx_config.go index a4b19c0db..e5b3299e4 100644 --- a/test/config/nginx_config.go +++ b/test/config/nginx_config.go @@ -31,7 +31,7 @@ var agentConfigWithToken string //go:embed agent/nginx-agent-with-multiple-headers.conf var agentConfigWithMultipleHeaders string -func GetNginxConfigWithMultipleAccessLogs( +func NginxConfigWithMultipleAccessLogs( errorLogName, accessLogName, combinedAccessLogName, @@ -46,23 +46,23 @@ func GetNginxConfigWithMultipleAccessLogs( ) } -func GetNginxConfigWithNotAllowedDir(errorLogFile, notAllowedFile, allowedFileDir, accessLogFile string) string { +func NginxConfigWithNotAllowedDir(errorLogFile, notAllowedFile, allowedFileDir, accessLogFile string) string { return fmt.Sprintf(embedNginxConfWithNotAllowedDir, errorLogFile, notAllowedFile, allowedFileDir, accessLogFile) } -func GetNginxConfWithSSLCertsWithVariables() string { +func NginxConfWithSSLCertsWithVariables() string { return embedNginxConfWithSSLCertsWithVariables } -func GetNginxConfigWithSSLCerts(errorLogFile, accessLogFile, certFile string) string { +func NginxConfigWithSSLCerts(errorLogFile, accessLogFile, certFile string) string { return fmt.Sprintf(embedNginxConfWithSSLCerts, errorLogFile, accessLogFile, certFile) } -func GetNginxConfigWithMultipleSSLCerts(errorLogFile, accessLogFile, certFile1, certFile2 string) string { +func NginxConfigWithMultipleSSLCerts(errorLogFile, accessLogFile, certFile1, certFile2 string) string { return fmt.Sprintf(embedNginxConfWithMultipleSSLCerts, errorLogFile, accessLogFile, certFile1, certFile2) } -func GetAgentConfigWithToken(value, path string) string { +func AgentConfigWithToken(value, path string) string { return fmt.Sprintf(agentConfigWithToken, value, path) } diff --git a/test/helpers/go_utils.go b/test/helpers/go_utils.go index 5a63d1e28..5e161e482 100644 --- a/test/helpers/go_utils.go +++ b/test/helpers/go_utils.go @@ -21,7 +21,7 @@ func GoVersion(t testing.TB, level int) (string, error) { t.Helper() fileName := goModuleFileName - filePath, modBytes, err := getModfileBytes(fileName, level) + filePath, modBytes, err := modfileBytes(fileName, level) if err != nil || filePath == "" { return "", err } @@ -38,7 +38,7 @@ func RequiredModuleVersion(t testing.TB, moduleName string, level int) (string, t.Helper() fileName := goModuleFileName - filePath, modBytes, err := getModfileBytes(fileName, level) + filePath, modBytes, err := modfileBytes(fileName, level) if err != nil { return "", err } @@ -94,7 +94,7 @@ func generatePattern(n int) (string, error) { return pattern.String(), nil } -func getModfileBytes(fileName string, level int) (string, []byte, error) { +func modfileBytes(fileName string, level int) (string, []byte, error) { prefix, err := generatePattern(level) if err != nil { return "", nil, err diff --git a/test/helpers/network_utils.go b/test/helpers/network_utils.go index 50fb07942..c086ed78c 100644 --- a/test/helpers/network_utils.go +++ b/test/helpers/network_utils.go @@ -12,8 +12,8 @@ import ( "testing" ) -// GetRandomPort generates a random port for testing and checks if a port is available by attempting to bind to it -func GetRandomPort(t *testing.T) (int, error) { +// RandomPort generates a random port for testing and checks if a port is available by attempting to bind to it +func RandomPort(t *testing.T) (int, error) { t.Helper() // Define the range for dynamic ports (49152–65535 as per IANA recommendation) diff --git a/test/integration/installuninstall/install_uninstall_test.go b/test/integration/installuninstall/install_uninstall_test.go index a220e0091..4432999e0 100644 --- a/test/integration/installuninstall/install_uninstall_test.go +++ b/test/integration/installuninstall/install_uninstall_test.go @@ -93,7 +93,7 @@ func verifyAgentPackage(tb testing.TB, testContainer testcontainers.Container) s agentPkgPath, filePathErr := filepath.Abs("../../../build/") require.NoError(tb, filePathErr, "Error finding local agent package build dir") - localAgentPkg, packageErr := os.Stat(getPackagePath(agentPkgPath, osRelease)) + localAgentPkg, packageErr := os.Stat(packagePath(agentPkgPath, osRelease)) require.NoError(tb, packageErr, "Error accessing package at: "+agentPkgPath) // Check the file size is less than or equal 30MB @@ -103,7 +103,7 @@ func verifyAgentPackage(tb testing.TB, testContainer testcontainers.Container) s updateDebRepo(tb, testContainer) } - return getPackagePath(absContainerAgentPackageDir, osRelease) + return packagePath(absContainerAgentPackageDir, osRelease) } func verifyAgentInstall(ctx context.Context, tb testing.TB, testContainer testcontainers.Container, @@ -244,7 +244,7 @@ func createInstallCommand(osReleaseContent, agentPackageFilePath string) []strin return []string{"yum", "localinstall", "-y", agentPackageFilePath} } -func getPackagePath(pkgDir, osReleaseContent string) string { +func packagePath(pkgDir, osReleaseContent string) string { pkgPath := path.Join(pkgDir, packageName) if strings.Contains(osReleaseContent, "ubuntu") || strings.Contains(osReleaseContent, "Debian") { diff --git a/test/integration/managementplane/grpc_management_plane_api_test.go b/test/integration/managementplane/grpc_management_plane_api_test.go index be49a7a47..85bc9b7a7 100644 --- a/test/integration/managementplane/grpc_management_plane_api_test.go +++ b/test/integration/managementplane/grpc_management_plane_api_test.go @@ -94,5 +94,5 @@ func TestGrpc_DataplaneHealthRequest(t *testing.T) { responses = utils.ManagementPlaneResponses(t, 2) assert.Equal(t, mpi.CommandResponse_COMMAND_STATUS_OK, responses[1].GetCommandResponse().GetStatus()) - assert.Equal(t, "Successfully sent the health status update", responses[1].GetCommandResponse().GetMessage()) + assert.Equal(t, "Successfully sent health status update", responses[1].GetCommandResponse().GetMessage()) } diff --git a/test/mock/grpc/cmd/main.go b/test/mock/grpc/cmd/main.go index e836de962..11c833bd4 100644 --- a/test/mock/grpc/cmd/main.go +++ b/test/mock/grpc/cmd/main.go @@ -61,7 +61,7 @@ func main() { agentConfig.Client.Grpc.FileChunkSize = 262144 newLogger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ - Level: logger.GetLogLevel(*logLevel), + Level: logger.LogLevel(*logLevel), })) slog.SetDefault(newLogger) diff --git a/test/mock/grpc/mock_management_file_service.go b/test/mock/grpc/mock_management_file_service.go index c61edcc8b..d57e4e0d4 100644 --- a/test/mock/grpc/mock_management_file_service.go +++ b/test/mock/grpc/mock_management_file_service.go @@ -209,7 +209,7 @@ func (mgs *FileService) sendGetFileStreamHeader(ctx context.Context, ) error { messageMeta := &v1.MessageMeta{ MessageId: id.GenerateMessageID(), - CorrelationId: logger.GetCorrelationID(ctx), + CorrelationId: logger.CorrelationID(ctx), Timestamp: timestamppb.Now(), } @@ -254,7 +254,7 @@ func (mgs *FileService) sendGetFileStreamChunk(ctx context.Context, chunk v1.Fil ) error { messageMeta := &v1.MessageMeta{ MessageId: id.GenerateMessageID(), - CorrelationId: logger.GetCorrelationID(ctx), + CorrelationId: logger.CorrelationID(ctx), Timestamp: timestamppb.Now(), } @@ -334,7 +334,7 @@ func (mgs *FileService) UpdateFile( } } - err := os.WriteFile(fullFilePath, fileContents, getFileMode(filePermissions)) + err := os.WriteFile(fullFilePath, fileContents, fileMode(filePermissions)) if err != nil { slog.Info("Failed to create/update file", "full_file_path", fullFilePath, "error", err) return nil, status.Errorf(codes.Internal, "Failed to create/update file") @@ -430,7 +430,7 @@ func (mgs *FileService) findFile(fileMeta *v1.FileMeta) (fullFilePath string) { return fullFilePath } -func getFileMode(mode string) os.FileMode { +func fileMode(mode string) os.FileMode { result, err := strconv.ParseInt(mode, 8, 32) if err != nil { return os.FileMode(defaultFilePermissions) diff --git a/test/mock/grpc/mock_management_server.go b/test/mock/grpc/mock_management_server.go index 754eb95d3..ad2b371a1 100644 --- a/test/mock/grpc/mock_management_server.go +++ b/test/mock/grpc/mock_management_server.go @@ -86,7 +86,7 @@ func NewMockManagementServer( return nil, err } - grpcServer := grpc.NewServer(getServerOptions(agentConfig)...) + grpcServer := grpc.NewServer(serverOptions(agentConfig)...) healthcheck := health.NewServer() healthgrpc.RegisterHealthServer(grpcServer, healthcheck) @@ -130,7 +130,7 @@ func (ms *MockManagementServer) Stop() { time.Sleep(testTimeout) } -func getServerOptions(agentConfig *config.Config) []grpc.ServerOption { +func serverOptions(agentConfig *config.Config) []grpc.ServerOption { validator, _ := protovalidate.New() opts := []grpc.ServerOption{ diff --git a/test/model/config.go b/test/model/config.go index c52dfb0c2..07c6c53c3 100644 --- a/test/model/config.go +++ b/test/model/config.go @@ -10,7 +10,7 @@ import ( "github.com/nginx/agent/v3/internal/model" ) -func GetConfigContext() *model.NginxConfigContext { +func ConfigContext() *model.NginxConfigContext { return &model.NginxConfigContext{ StubStatus: &model.APIDetails{ URL: "", @@ -23,7 +23,7 @@ func GetConfigContext() *model.NginxConfigContext { } // nolint: revive -func GetConfigContextWithNames( +func ConfigContextWithNames( accessLogName, combinedAccessLogName, ltsvAccessLogName, @@ -76,7 +76,7 @@ func GetConfigContextWithNames( } } -func GetConfigContextWithoutErrorLog( +func ConfigContextWithoutErrorLog( accessLogName, combinedAccessLogName, ltsvAccessLogName, @@ -120,7 +120,7 @@ func GetConfigContextWithoutErrorLog( } } -func GetConfigContextWithFiles( +func ConfigContextWithFiles( accessLogName, errorLogName string, files []*mpi.File, diff --git a/test/protos/config.go b/test/protos/config.go index 4cfc8952a..578c9cf3b 100644 --- a/test/protos/config.go +++ b/test/protos/config.go @@ -12,6 +12,6 @@ const configVersion = "f9a31750-566c-31b3-a763-b9fb5982547b" func CreateConfigVersion() *v1.ConfigVersion { return &v1.ConfigVersion{ Version: configVersion, - InstanceId: GetNginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId(), + InstanceId: NginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId(), } } diff --git a/test/protos/instances.go b/test/protos/instances.go index 3bf88555f..0a5e9bdab 100644 --- a/test/protos/instances.go +++ b/test/protos/instances.go @@ -27,7 +27,7 @@ const ( childID4 = 321 ) -func GetAgentInstance(processID int32, agentConfig *config.Config) *mpi.Instance { +func AgentInstance(processID int32, agentConfig *config.Config) *mpi.Instance { return &mpi.Instance{ InstanceMeta: &mpi.InstanceMeta{ InstanceId: agentConfig.UUID, @@ -42,7 +42,7 @@ func GetAgentInstance(processID int32, agentConfig *config.Config) *mpi.Instance } } -func GetNginxOssInstance(expectedModules []string) *mpi.Instance { +func NginxOssInstance(expectedModules []string) *mpi.Instance { return &mpi.Instance{ InstanceMeta: &mpi.InstanceMeta{ InstanceId: ossInstanceID, @@ -77,7 +77,7 @@ func GetNginxOssInstance(expectedModules []string) *mpi.Instance { } } -func GetNginxPlusInstance(expectedModules []string) *mpi.Instance { +func NginxPlusInstance(expectedModules []string) *mpi.Instance { return &mpi.Instance{ InstanceMeta: &mpi.InstanceMeta{ InstanceId: plusInstanceID, @@ -119,7 +119,7 @@ func GetNginxPlusInstance(expectedModules []string) *mpi.Instance { } } -func GetUnsupportedInstance() *mpi.Instance { +func UnsupportedInstance() *mpi.Instance { return &mpi.Instance{ InstanceMeta: &mpi.InstanceMeta{ InstanceId: unsuportedInstanceID, @@ -131,31 +131,31 @@ func GetUnsupportedInstance() *mpi.Instance { } } -func GetMultipleInstances(expectedModules []string) []*mpi.Instance { - process1 := GetNginxOssInstance(expectedModules) - process2 := getSecondNginxOssInstance(expectedModules) +func MultipleInstances(expectedModules []string) []*mpi.Instance { + process1 := NginxOssInstance(expectedModules) + process2 := secondNginxOssInstance(expectedModules) return []*mpi.Instance{process1, process2} } -func GetMultipleInstancesWithUnsupportedInstance() []*mpi.Instance { - process1 := GetNginxOssInstance([]string{}) - process2 := GetUnsupportedInstance() +func MultipleInstancesWithUnsupportedInstance() []*mpi.Instance { + process1 := NginxOssInstance([]string{}) + process2 := UnsupportedInstance() return []*mpi.Instance{process1, process2} } -func GetInstancesNoParentProcess(expectedModules []string) []*mpi.Instance { - process1 := GetNginxOssInstance(expectedModules) +func InstancesNoParentProcess(expectedModules []string) []*mpi.Instance { + process1 := NginxOssInstance(expectedModules) process1.GetInstanceRuntime().ProcessId = 0 - process2 := getSecondNginxOssInstance(expectedModules) + process2 := secondNginxOssInstance(expectedModules) process2.GetInstanceRuntime().ProcessId = 0 return []*mpi.Instance{process1, process2} } -func GetFileCache(files ...*os.File) (map[string]*mpi.FileMeta, error) { +func FileCache(files ...*os.File) (map[string]*mpi.FileMeta, error) { cache := make(map[string]*mpi.FileMeta) for _, file := range files { lastModified, err := CreateProtoTime("2024-01-09T13:22:21Z") @@ -173,8 +173,8 @@ func GetFileCache(files ...*os.File) (map[string]*mpi.FileMeta, error) { return cache, nil } -func getSecondNginxOssInstance(expectedModules []string) *mpi.Instance { - process2 := GetNginxOssInstance(expectedModules) +func secondNginxOssInstance(expectedModules []string) *mpi.Instance { + process2 := NginxOssInstance(expectedModules) process2.GetInstanceRuntime().ProcessId = processID2 process2.GetInstanceMeta().InstanceId = secondOssInstanceID process2.GetInstanceRuntime().BinaryPath = "/opt/homebrew/etc/nginx/1.25.3/bin/nginx" @@ -183,21 +183,21 @@ func getSecondNginxOssInstance(expectedModules []string) *mpi.Instance { return process2 } -func GetHealthyInstanceHealth() *mpi.InstanceHealth { +func HealthyInstanceHealth() *mpi.InstanceHealth { return &mpi.InstanceHealth{ - InstanceId: GetNginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId(), + InstanceId: NginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId(), InstanceHealthStatus: mpi.InstanceHealth_INSTANCE_HEALTH_STATUS_HEALTHY, } } -func GetUnhealthyInstanceHealth() *mpi.InstanceHealth { +func UnhealthyInstanceHealth() *mpi.InstanceHealth { return &mpi.InstanceHealth{ - InstanceId: GetNginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId(), + InstanceId: NginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId(), InstanceHealthStatus: mpi.InstanceHealth_INSTANCE_HEALTH_STATUS_UNHEALTHY, } } -func GetUnspecifiedInstanceHealth() *mpi.InstanceHealth { +func UnspecifiedInstanceHealth() *mpi.InstanceHealth { return &mpi.InstanceHealth{ InstanceId: unsuportedInstanceID, InstanceHealthStatus: mpi.InstanceHealth_INSTANCE_HEALTH_STATUS_UNSPECIFIED, diff --git a/test/protos/resource.go b/test/protos/resource.go index ac67e872e..ae8df6471 100644 --- a/test/protos/resource.go +++ b/test/protos/resource.go @@ -7,39 +7,39 @@ package protos import "github.com/nginx/agent/v3/api/grpc/mpi/v1" -func GetContainerizedResource() *v1.Resource { +func ContainerizedResource() *v1.Resource { return &v1.Resource{ - ResourceId: GetContainerInfo().GetContainerId(), + ResourceId: ContainerInfo().GetContainerId(), Instances: []*v1.Instance{ - GetNginxOssInstance([]string{}), + NginxOssInstance([]string{}), }, Info: &v1.Resource_ContainerInfo{ - ContainerInfo: GetContainerInfo(), + ContainerInfo: ContainerInfo(), }, } } -func GetHostResource() *v1.Resource { +func HostResource() *v1.Resource { return &v1.Resource{ - ResourceId: GetHostInfo().GetHostId(), + ResourceId: HostInfo().GetHostId(), Instances: []*v1.Instance{ - GetNginxOssInstance([]string{}), + NginxOssInstance([]string{}), }, Info: &v1.Resource_HostInfo{ - HostInfo: GetHostInfo(), + HostInfo: HostInfo(), }, } } -func GetHostInfo() *v1.HostInfo { +func HostInfo() *v1.HostInfo { return &v1.HostInfo{ HostId: "1234", Hostname: "test-host", - ReleaseInfo: GetReleaseInfo(), + ReleaseInfo: ReleaseInfo(), } } -func GetReleaseInfo() *v1.ReleaseInfo { +func ReleaseInfo() *v1.ReleaseInfo { return &v1.ReleaseInfo{ Codename: "Focal Fossa", Id: "ubuntu", @@ -49,21 +49,21 @@ func GetReleaseInfo() *v1.ReleaseInfo { } } -func GetContainerInfo() *v1.ContainerInfo { +func ContainerInfo() *v1.ContainerInfo { return &v1.ContainerInfo{ ContainerId: "f43f5eg54g54g54", } } -func GetInstanceHealths() []*v1.InstanceHealth { +func InstanceHealths() []*v1.InstanceHealth { return []*v1.InstanceHealth{ { - InstanceId: GetNginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId(), + InstanceId: NginxOssInstance([]string{}).GetInstanceMeta().GetInstanceId(), InstanceHealthStatus: v1.InstanceHealth_INSTANCE_HEALTH_STATUS_HEALTHY, Description: "healthy", }, { - InstanceId: GetNginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId(), + InstanceId: NginxPlusInstance([]string{}).GetInstanceMeta().GetInstanceId(), InstanceHealthStatus: v1.InstanceHealth_INSTANCE_HEALTH_STATUS_UNHEALTHY, Description: "unhealthy", }, diff --git a/test/types/config.go b/test/types/config.go index 27a1ee93c..29775f664 100644 --- a/test/types/config.go +++ b/test/types/config.go @@ -177,19 +177,19 @@ func OTelConfig(t *testing.T) *config.Config { ac := AgentConfig() ac.Collector.ConfigPath = filepath.Join(t.TempDir(), "otel-collector-config.yaml") - exporterPort, expErr := helpers.GetRandomPort(t) + exporterPort, expErr := helpers.RandomPort(t) require.NoError(t, expErr) ac.Collector.Exporters.OtlpExporters[0].Server.Port = exporterPort - receiverPort, recErr := helpers.GetRandomPort(t) + receiverPort, recErr := helpers.RandomPort(t) require.NoError(t, recErr) ac.Collector.Receivers.OtlpReceivers[0].Server.Port = receiverPort - healthPort, healthErr := helpers.GetRandomPort(t) + healthPort, healthErr := helpers.RandomPort(t) require.NoError(t, healthErr) ac.Collector.Extensions.Health.Server.Port = healthPort - commandPort, commandErr := helpers.GetRandomPort(t) + commandPort, commandErr := helpers.RandomPort(t) require.NoError(t, commandErr) ac.Command.Server.Port = commandPort From 9f24720a388f2b55b47456a899966846a9e6e757 Mon Sep 17 00:00:00 2001 From: John David White <127981157+john-david3@users.noreply.github.com> Date: Tue, 10 Jun 2025 15:03:50 +0100 Subject: [PATCH 14/28] Add env variables for make dev target (#1069) * Add env variables for make dev target * Update config struct * Fix manifest var names --------- Co-authored-by: Donal Hurley --- Makefile | 4 +- internal/config/config.go | 7 +++- internal/config/defaults.go | 3 ++ internal/config/flags.go | 1 + internal/config/types.go | 1 + internal/file/file_manager_service.go | 17 ++++----- internal/file/file_manager_service_test.go | 44 ++++++++++++++-------- 7 files changed, 50 insertions(+), 27 deletions(-) diff --git a/Makefile b/Makefile index f2c0bb40a..296163ac3 100644 --- a/Makefile +++ b/Makefile @@ -43,6 +43,8 @@ PROTO_DIR := proto BINARY_NAME := nginx-agent PROJECT_DIR = cmd/agent PROJECT_FILE = main.go +COLLECTOR_PATH ?= /etc/nginx-agent/opentelemetry-collector-agent.yaml +MANIFEST_DIR ?= /var/lib/nginx-agent DIRS = $(BUILD_DIR) $(TEST_BUILD_DIR) $(BUILD_DIR)/$(DOCS_DIR) $(BUILD_DIR)/$(DOCS_DIR)/$(PROTO_DIR) $(shell mkdir -p $(DIRS)) @@ -181,7 +183,7 @@ run: build ## Run code dev: ## Run agent executable @echo "🚀 Running App" - $(GORUN) -ldflags=$(DEBUG_LDFLAGS) $(PROJECT_DIR)/$(PROJECT_FILE) + NGINX_AGENT_COLLECTOR_CONFIG_PATH=$(COLLECTOR_PATH) NGINX_AGENT_MANIFEST_DIR=$(MANIFEST_DIR) $(GORUN) -ldflags=$(DEBUG_LDFLAGS) $(PROJECT_DIR)/$(PROJECT_FILE) race-condition-dev: ## Run agent executable with race condition detection @echo "đŸŽī¸ Running app with race condition detection enabled" diff --git a/internal/config/config.go b/internal/config/config.go index 63dd0c87c..85cdc6a71 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -124,6 +124,7 @@ func ResolveConfig() (*Config, error) { Watchers: resolveWatchers(), Features: viperInstance.GetStringSlice(FeaturesKey), Labels: resolveLabels(), + ManifestDir: viperInstance.GetString(ManifestDirPathKey), } checkCollectorConfiguration(collector, config) @@ -231,7 +232,11 @@ func registerFlags() { "The path to output log messages to. "+ "If the default path doesn't exist, log messages are output to stdout/stderr.", ) - + fs.String( + ManifestDirPathKey, + DefManifestDir, + "Specifies the path to the directory containing the manifest files", + ) fs.Duration( NginxReloadMonitoringPeriodKey, DefNginxReloadMonitoringPeriod, diff --git a/internal/config/defaults.go b/internal/config/defaults.go index cc1212edc..ddeee7a3b 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -74,6 +74,9 @@ const ( DefCollectorExtensionsHealthTLSCAPath = "" DefCollectorExtensionsHealthTLSSkipVerify = false DefCollectorExtensionsHealthTLServerNameKey = "" + + // File defaults + DefManifestDir = "/var/lib/nginx-agent" ) func DefaultFeatures() []string { diff --git a/internal/config/flags.go b/internal/config/flags.go index 7c977f1ac..50fb461e5 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -23,6 +23,7 @@ const ( InstanceWatcherMonitoringFrequencyKey = "watchers_instance_watcher_monitoring_frequency" InstanceHealthWatcherMonitoringFrequencyKey = "watchers_instance_health_watcher_monitoring_frequency" FileWatcherKey = "watchers_file_watcher" + ManifestDirPathKey = "manifest_dir" ) var ( diff --git a/internal/config/types.go b/internal/config/types.go index ed4f77c31..8017987f1 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -42,6 +42,7 @@ type ( Version string `yaml:"-"` Path string `yaml:"-"` UUID string `yaml:"-"` + ManifestDir string `yaml:"-"` AllowedDirectories []string `yaml:"allowed_directories" mapstructure:"allowed_directories"` Features []string `yaml:"features" mapstructure:"features"` } diff --git a/internal/file/file_manager_service.go b/internal/file/file_manager_service.go index 4836b6fc8..acbab1f29 100644 --- a/internal/file/file_manager_service.go +++ b/internal/file/file_manager_service.go @@ -48,11 +48,6 @@ const ( filePerm = 0o600 ) -var ( - manifestDirPath = "/var/lib/nginx-agent" - manifestFilePath = manifestDirPath + "/manifest.json" -) - type ( fileOperator interface { Write(ctx context.Context, fileContent []byte, file *mpi.FileMeta) error @@ -98,6 +93,7 @@ type FileManagerService struct { // map of the files currently on disk, used to determine the file action during config apply currentFilesOnDisk map[string]*mpi.File // key is file path previousManifestFiles map[string]*model.ManifestFile + manifestFilePath string filesMutex sync.RWMutex } @@ -113,6 +109,7 @@ func NewFileManagerService(fileServiceClient mpi.FileServiceClient, agentConfig rollbackFileContents: make(map[string][]byte), currentFilesOnDisk: make(map[string]*mpi.File), previousManifestFiles: make(map[string]*model.ManifestFile), + manifestFilePath: agentConfig.ManifestDir + "/manifest.json", isConnected: isConnected, } } @@ -851,12 +848,12 @@ func (fms *FileManagerService) writeManifestFile(updatedFiles map[string]*model. } // 0755 allows read/execute for all, write for owner - if err = os.MkdirAll(manifestDirPath, dirPerm); err != nil { - return fmt.Errorf("unable to create directory %s: %w", manifestDirPath, err) + if err = os.MkdirAll(fms.agentConfig.ManifestDir, dirPerm); err != nil { + return fmt.Errorf("unable to create directory %s: %w", fms.agentConfig.ManifestDir, err) } // 0600 ensures only root can read/write - newFile, err := os.OpenFile(manifestFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, filePerm) + newFile, err := os.OpenFile(fms.manifestFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, filePerm) if err != nil { return fmt.Errorf("failed to read manifest file: %w", err) } @@ -875,11 +872,11 @@ func (fms *FileManagerService) writeManifestFile(updatedFiles map[string]*model. } func (fms *FileManagerService) manifestFile() (map[string]*model.ManifestFile, map[string]*mpi.File, error) { - if _, err := os.Stat(manifestFilePath); err != nil { + if _, err := os.Stat(fms.manifestFilePath); err != nil { return nil, nil, err } - file, err := os.ReadFile(manifestFilePath) + file, err := os.ReadFile(fms.manifestFilePath) if err != nil { return nil, nil, fmt.Errorf("failed to read manifest file: %w", err) } diff --git a/internal/file/file_manager_service_test.go b/internal/file/file_manager_service_test.go index b6a07d6f7..aa139810d 100644 --- a/internal/file/file_manager_service_test.go +++ b/internal/file/file_manager_service_test.go @@ -179,8 +179,8 @@ func TestFileManagerService_ConfigApply_Add(t *testing.T) { overview := protos.FileOverview(filePath, fileHash) - manifestDirPath = tempDir - manifestFilePath = manifestDirPath + "/manifest.json" + manifestDirPath := tempDir + manifestFilePath := manifestDirPath + "/manifest.json" helpers.CreateFileWithErrorCheck(t, manifestDirPath, "manifest.json") fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} @@ -194,7 +194,10 @@ func TestFileManagerService_ConfigApply_Add(t *testing.T) { }, nil) agentConfig := types.AgentConfig() agentConfig.AllowedDirectories = []string{tempDir} + fileManagerService := NewFileManagerService(fakeFileServiceClient, agentConfig) + fileManagerService.agentConfig.ManifestDir = manifestDirPath + fileManagerService.manifestFilePath = manifestFilePath request := protos.CreateConfigApplyRequest(overview) writeStatus, err := fileManagerService.ConfigApply(ctx, request) @@ -218,10 +221,6 @@ func TestFileManagerService_ConfigApply_Add_LargeFile(t *testing.T) { overview := protos.FileOverviewLargeFile(filePath, fileHash) - manifestDirPath = tempDir - manifestFilePath = manifestDirPath + "/manifest.json" - helpers.CreateFileWithErrorCheck(t, manifestDirPath, "manifest.json") - fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} fakeFileServiceClient.GetOverviewReturns(&mpi.GetOverviewResponse{ Overview: overview, @@ -237,10 +236,15 @@ func TestFileManagerService_ConfigApply_Add_LargeFile(t *testing.T) { fakeServerStreamingClient.chunks[uint32(i)] = []byte{fileContent[i]} } + manifestDirPath := tempDir + manifestFilePath := manifestDirPath + "/manifest.json" + fakeFileServiceClient.GetFileStreamReturns(fakeServerStreamingClient, nil) agentConfig := types.AgentConfig() agentConfig.AllowedDirectories = []string{tempDir} fileManagerService := NewFileManagerService(fakeFileServiceClient, agentConfig) + fileManagerService.agentConfig.ManifestDir = manifestDirPath + fileManagerService.manifestFilePath = manifestFilePath request := protos.CreateConfigApplyRequest(overview) writeStatus, err := fileManagerService.ConfigApply(ctx, request) @@ -279,8 +283,8 @@ func TestFileManagerService_ConfigApply_Update(t *testing.T) { }, } - manifestDirPath = tempDir - manifestFilePath = manifestDirPath + "/manifest.json" + manifestDirPath := tempDir + manifestFilePath := manifestDirPath + "/manifest.json" helpers.CreateFileWithErrorCheck(t, manifestDirPath, "manifest.json") overview := protos.FileOverview(tempFile.Name(), fileHash) @@ -296,12 +300,14 @@ func TestFileManagerService_ConfigApply_Update(t *testing.T) { }, nil) agentConfig := types.AgentConfig() agentConfig.AllowedDirectories = []string{tempDir} + fileManagerService := NewFileManagerService(fakeFileServiceClient, agentConfig) + fileManagerService.agentConfig.ManifestDir = 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) @@ -336,14 +342,17 @@ func TestFileManagerService_ConfigApply_Delete(t *testing.T) { }, } - manifestDirPath = tempDir - manifestFilePath = manifestDirPath + "/manifest.json" + 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) + fileManagerService.agentConfig.ManifestDir = manifestDirPath + fileManagerService.manifestFilePath = manifestFilePath err := fileManagerService.UpdateCurrentFilesOnDisk(ctx, filesOnDisk, false) require.NoError(t, err) @@ -462,8 +471,8 @@ func TestFileManagerService_Rollback(t *testing.T) { _, writeErr = updateFile.Write(newFileContent) require.NoError(t, writeErr) - manifestDirPath = tempDir - manifestFilePath = manifestDirPath + "/manifest.json" + manifestDirPath := tempDir + manifestFilePath := manifestDirPath + "/manifest.json" helpers.CreateFileWithErrorCheck(t, manifestDirPath, "manifest.json") filesCache := map[string]*model.FileCache{ @@ -529,6 +538,8 @@ func TestFileManagerService_Rollback(t *testing.T) { fileManagerService := NewFileManagerService(fakeFileServiceClient, types.AgentConfig()) fileManagerService.rollbackFileContents = fileContentCache fileManagerService.fileActions = filesCache + fileManagerService.agentConfig.ManifestDir = manifestDirPath + fileManagerService.manifestFilePath = manifestFilePath err := fileManagerService.Rollback(ctx, instanceID) require.NoError(t, err) @@ -690,11 +701,14 @@ func TestFileManagerService_DetermineFileActions(t *testing.T) { // Delete manifest file if it already exists manifestFile := CreateTestManifestFile(t, tempDir, test.currentFiles) defer manifestFile.Close() - manifestDirPath = tempDir - manifestFilePath = manifestFile.Name() + manifestDirPath := tempDir + manifestFilePath := manifestFile.Name() fakeFileServiceClient := &v1fakes.FakeFileServiceClient{} fileManagerService := NewFileManagerService(fakeFileServiceClient, types.AgentConfig()) + fileManagerService.agentConfig.ManifestDir = manifestDirPath + fileManagerService.manifestFilePath = manifestFilePath + require.NoError(tt, err) diff, contents, fileActionErr := fileManagerService.DetermineFileActions(test.currentFiles, From 039d14e04819b0df2d7edfb553a03f0ac70438da Mon Sep 17 00:00:00 2001 From: Sean Breen <101327931+sean-breen@users.noreply.github.com> Date: Fri, 13 Jun 2025 15:03:33 +0100 Subject: [PATCH 15/28] [Packaging] Package verifier script (#1120) * add verifier script * rework * make CERT and KEY optional * update comments, now prints the download location for each package --- scripts/packages/package-check.sh | 159 ++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100755 scripts/packages/package-check.sh diff --git a/scripts/packages/package-check.sh b/scripts/packages/package-check.sh new file mode 100755 index 000000000..2ea64926b --- /dev/null +++ b/scripts/packages/package-check.sh @@ -0,0 +1,159 @@ +#!/bin/bash +# Usage: +# +# Check package v3.0.0 availability for all platforms, no auth required: +# > ./package_check.sh 3.0.0 +# +# Check pkgs and download if present, with authentication: +# > CERT= KEY= DL=1 ./package_check.sh 3.0.0 +# +# Required parameters: +# +# version: the version of agent you wish to search for i.e 3.0.0 +# +# Optional parameters: +# +# PKG_REPO: The root url for the repository you wish to check, defaults to packages.nginx.org +# CERT: Path to your cert file +# KEY: Path to your key file +# DL: Switch to download the package if it is present, set to 1 if download required, defaults to 0 +# +# Packages are downloaded to the local directory with the path of its corresponding repo url + uri i.e +# +# packages.nginx.org/nginx-agent/debian/pool/agent/n/nginx-agent/nginx-agent_3.0.0~bullseye_arm64.deb +# + + +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' # No Color + +CURL_OPTS="" +if [[ ! -z ${CERT} ]] && [[ ! -z ${KEY} ]]; then + CURL_OPTS="-E ${CERT} --key ${KEY}" +fi + +if [[ -z ${PKG_REPO} ]]; then + echo "defaulting to packages.nginx.com" + PKG_REPO="packages.nginx.org" +fi + +PKG_NAME="nginx-agent" +VERSION="${1}" +if [[ -z $VERSION ]]; then + echo "no version provided" + exit 1 +fi + +PKG_DIR="${PKG_REPO}/${PKG_NAME}" +PKG_REPO_URL="https://${PKG_DIR}" + +APK=( + alpine/v3.21/main/aarch64/nginx-agent-$VERSION.apk + alpine/v3.21/main/x86_64/nginx-agent-$VERSION.apk + alpine/v3.20/main/aarch64/nginx-agent-$VERSION.apk + alpine/v3.20/main/x86_64/nginx-agent-$VERSION.apk + alpine/v3.18/main/aarch64/nginx-agent-$VERSION.apk + alpine/v3.18/main/x86_64/nginx-agent-$VERSION.apk + alpine/v3.19/main/aarch64/nginx-agent-$VERSION.apk + alpine/v3.19/main/x86_64/nginx-agent-$VERSION.apk +) +UBUNTU=( + ubuntu/pool/agent/n/nginx-agent/nginx-agent_$VERSION~focal_arm64.deb + ubuntu/pool/agent/n/nginx-agent/nginx-agent_$VERSION~jammy_amd64.deb + ubuntu/pool/agent/n/nginx-agent/nginx-agent_$VERSION~noble_arm64.deb + ubuntu/pool/agent/n/nginx-agent/nginx-agent_$VERSION~jammy_arm64.deb + ubuntu/pool/agent/n/nginx-agent/nginx-agent_$VERSION~noble_amd64.deb + ubuntu/pool/agent/n/nginx-agent/nginx-agent_$VERSION~focal_amd64.deb +) +DEBIAN=( + debian/pool/agent/n/nginx-agent/nginx-agent_$VERSION~bullseye_arm64.deb + debian/pool/agent/n/nginx-agent/nginx-agent_$VERSION~bookworm_amd64.deb + debian/pool/agent/n/nginx-agent/nginx-agent_$VERSION~bookworm_arm64.deb + debian/pool/agent/n/nginx-agent/nginx-agent_$VERSION~bullseye_amd64.deb +) +AMZN=( + amzn/2023/aarch64/RPMS/nginx-agent-$VERSION.amzn2023.ngx.aarch64.rpm + amzn/2023/x86_64/RPMS/nginx-agent-$VERSION.amzn2023.ngx.x86_64.rpm + + amzn2/2/aarch64/RPMS/nginx-agent-$VERSION.amzn2.ngx.aarch64.rpm + amzn2/2/x86_64/RPMS/nginx-agent-$VERSION.amzn2.ngx.x86_64.rpm +) +SUSE=( + sles/15/x86_64/RPMS/nginx-agent-$VERSION.sles15.ngx.x86_64.rpm + sles/12/x86_64/RPMS/nginx-agent-$VERSION.sles12.ngx.x86_64.rpm +) +CENTOS=( + centos/9/aarch64/RPMS/nginx-agent-$VERSION.el9.ngx.aarch64.rpm + centos/9/x86_64/RPMS/nginx-agent-$VERSION.el9.ngx.x86_64.rpm + centos/8/aarch64/RPMS/nginx-agent-$VERSION.el8.ngx.aarch64.rpm + centos/8/x86_64/RPMS/nginx-agent-$VERSION.el8.ngx.x86_64.rpm +) + +uris=( + ${DEBIAN[@]} + ${UBUNTU[@]} + ${CENTOS[@]} + ${APK[@]} + ${AMZN[@]} + ${SUSE[@]} +) + +## Check and download if nginx-agent packages are present in the repository +check_pkgs () { + for pkg in ${uris[@]}; do + echo -n "CHECK: ${PKG_REPO_URL}/${pkg} -> " + local ret=$(curl -I -s ${CURL_OPTS} "https://${PKG_DIR}/${pkg}" | head -n1 | awk '{ print $2 }') + if [[ ${ret} != 200 ]]; then + echo -e "${RED}${ret}${NC}" + continue + fi + echo -e "${GREEN}${ret}${NC}" + + if [[ ${DL} == 1 ]]; then + dl_pkg "${PKG_REPO_URL}/${pkg}" + fi + + done +} + +## Download a package +dl_pkg () { + local url=${1} + echo -n "GET: ${url}... " + mkdir -p "${PKG_DIR}/$(dirname ${pkg})" + local ret=$(curl -s ${CURL_OPTS} "${url}" --output "${PKG_DIR}/${pkg}") + if [[ $? != 0 ]]; then + echo -e "${RED}Download failed!${NC}" + return + fi + echo -e "${GREEN}Done${NC}" + echo "SAVED: ${PKG_DIR}/${pkg}" +} + +## Check for the presence of an nginx-agent version matching $VERSION +check_repo() { + echo -n "Checking package repository ${PKG_REPO_URL}... " + curl -s -I ${CURL_OPTS} "${PKG_REPO_URL}/index.xml" > /dev/null + if [[ $? != 0 ]]; then + echo -e "${RED}index.xml not found in ${PKG_REPO_URL} repository${NC}" + exit 1 + else + echo -e "${GREEN}Found!${NC}" + fi + + mkdir -p ${PKG_DIR} + curl -s ${CURL_OPTS} "${PKG_REPO_URL}/index.xml" --output "${PKG_DIR}/index.xml" || exit 1 + + echo -n "Checking for nginx-agent version ${VERSION}... " + grep -qnF "ver=\"${VERSION}\"" "${PKG_DIR}/index.xml" + if [[ $? != 0 ]]; then + echo -e "${RED}not found${NC}" + exit 1 + else + echo -e "${GREEN}Found!${NC}" + fi +} + +check_repo +check_pkgs \ No newline at end of file From 55bca48c7ee3e5f1f3b26bc9f40cd592bf3ccc1a Mon Sep 17 00:00:00 2001 From: Donal Hurley Date: Fri, 13 Jun 2025 16:42:56 +0100 Subject: [PATCH 16/28] Fix debug log format (#1126) --- internal/logger/logger.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/logger/logger.go b/internal/logger/logger.go index ea5606728..6f05bfbf7 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -11,6 +11,7 @@ import ( "log/slog" "os" "path" + "path/filepath" "strconv" "strings" @@ -55,8 +56,9 @@ func New(logPath, level string) *slog.Logger { if a.Key == slog.SourceKey { source, ok := a.Value.Any().(*slog.Source) if ok { - relativeFilePath := strings.Split(source.File, "/agent/")[1] - a.Value = slog.StringValue(relativeFilePath + ":" + strconv.Itoa(source.Line)) + directory := filepath.Dir(source.File) + relativePath := path.Join(filepath.Base(directory), filepath.Base(source.File)) + a.Value = slog.StringValue(relativePath + ":" + strconv.Itoa(source.Line)) } } From 699efa273c08a49f216ec6304a53d2cf92fc0497 Mon Sep 17 00:00:00 2001 From: Sean Breen <101327931+sean-breen@users.noreply.github.com> Date: Wed, 18 Jun 2025 10:28:22 +0100 Subject: [PATCH 17/28] [Release 3.0.2] Merge back into main (#1133) * Handle scenario where agent tries to delete a file that does not exist during a config apply (#1123) * Bump golang version from 1.23.8 to 1.23.10 (#1130) --------- Co-authored-by: Donal Hurley --- go.mod | 2 +- internal/file/file_manager_service.go | 31 ++++++++++++++----- internal/file/file_manager_service_test.go | 21 +++++++++++-- .../fake_file_manager_service_interface.go | 26 +++++++++------- 4 files changed, 56 insertions(+), 24 deletions(-) diff --git a/go.mod b/go.mod index 8c16eab1b..434b15a13 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/nginx/agent/v3 go 1.23.7 -toolchain go1.23.8 +toolchain go1.23.10 require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.4-20250130201111-63bb56e20495.1 diff --git a/internal/file/file_manager_service.go b/internal/file/file_manager_service.go index acbab1f29..2591aa107 100644 --- a/internal/file/file_manager_service.go +++ b/internal/file/file_manager_service.go @@ -68,14 +68,19 @@ type ( fileManagerServiceInterface interface { UpdateOverview(ctx context.Context, instanceID string, filesToUpdate []*mpi.File, iteration int) error - ConfigApply(ctx context.Context, configApplyRequest *mpi.ConfigApplyRequest) (writeStatus model.WriteStatus, - err error) + ConfigApply( + ctx context.Context, + configApplyRequest *mpi.ConfigApplyRequest, + ) (writeStatus model.WriteStatus, err error) Rollback(ctx context.Context, instanceID string) error UpdateFile(ctx context.Context, instanceID string, fileToUpdate *mpi.File) error ClearCache() UpdateCurrentFilesOnDisk(ctx context.Context, updateFiles map[string]*mpi.File, referenced bool) error - DetermineFileActions(currentFiles map[string]*mpi.File, modifiedFiles map[string]*model.FileCache) ( - map[string]*model.FileCache, map[string][]byte, error) + DetermineFileActions( + ctx context.Context, + currentFiles map[string]*mpi.File, + modifiedFiles map[string]*model.FileCache, + ) (map[string]*model.FileCache, map[string][]byte, error) IsConnected() bool SetIsConnected(isConnected bool) } @@ -505,8 +510,11 @@ func (fms *FileManagerService) ConfigApply(ctx context.Context, return model.Error, allowedErr } - diffFiles, fileContent, compareErr := fms.DetermineFileActions(fms.currentFilesOnDisk, - ConvertToMapOfFileCache(fileOverview.GetFiles())) + diffFiles, fileContent, compareErr := fms.DetermineFileActions( + ctx, + fms.currentFilesOnDisk, + ConvertToMapOfFileCache(fileOverview.GetFiles()), + ) if compareErr != nil { return model.Error, compareErr @@ -541,7 +549,7 @@ func (fms *FileManagerService) ClearCache() { // nolint:revive,cyclop func (fms *FileManagerService) Rollback(ctx context.Context, instanceID string) error { - slog.InfoContext(ctx, "Rolling back config for instance", "instanceid", instanceID) + slog.InfoContext(ctx, "Rolling back config for instance", "instance_id", instanceID) fms.filesMutex.Lock() defer fms.filesMutex.Unlock() @@ -710,6 +718,7 @@ func (fms *FileManagerService) checkAllowedDirectory(checkFiles []*mpi.File) err // that have changed and a map of the contents for each updated and deleted file. Key to both maps is file path // nolint: revive,cyclop,gocognit func (fms *FileManagerService) DetermineFileActions( + ctx context.Context, currentFiles map[string]*mpi.File, modifiedFiles map[string]*model.FileCache, ) ( @@ -732,6 +741,7 @@ func (fms *FileManagerService) DetermineFileActions( return nil, nil, manifestFileErr } } + // if file is in manifestFiles but not in modified files, file has been deleted // copy contents, set file action for fileName, manifestFile := range filesMap { @@ -741,7 +751,12 @@ func (fms *FileManagerService) DetermineFileActions( // Read file contents before marking it deleted fileContent, readErr := os.ReadFile(fileName) if readErr != nil { - return nil, nil, fmt.Errorf("error reading file %s: %w", fileName, readErr) + if errors.Is(readErr, os.ErrNotExist) { + slog.DebugContext(ctx, "Unable to backup file contents since file does not exist", "file", fileName) + continue + } else { + return nil, nil, fmt.Errorf("error reading file %s: %w", fileName, readErr) + } } fileContents[fileName] = fileContent diff --git a/internal/file/file_manager_service_test.go b/internal/file/file_manager_service_test.go index aa139810d..5f9237942 100644 --- a/internal/file/file_manager_service_test.go +++ b/internal/file/file_manager_service_test.go @@ -559,6 +559,7 @@ func TestFileManagerService_Rollback(t *testing.T) { } func TestFileManagerService_DetermineFileActions(t *testing.T) { + ctx := context.Background() tempDir := os.TempDir() deleteTestFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_delete.conf") @@ -584,7 +585,6 @@ func TestFileManagerService_DetermineFileActions(t *testing.T) { addTestFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_add.conf") defer helpers.RemoveFileWithErrorCheck(t, addTestFile.Name()) - t.Logf("Adding file: %s", addTestFile.Name()) addFileContent := []byte("test add file") addErr := os.WriteFile(addTestFile.Name(), addFileContent, 0o600) require.NoError(t, addErr) @@ -694,6 +694,18 @@ func TestFileManagerService_DetermineFileActions(t *testing.T) { 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 { @@ -711,8 +723,11 @@ func TestFileManagerService_DetermineFileActions(t *testing.T) { require.NoError(tt, err) - diff, contents, fileActionErr := fileManagerService.DetermineFileActions(test.currentFiles, - test.modifiedFiles) + 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) diff --git a/internal/file/filefakes/fake_file_manager_service_interface.go b/internal/file/filefakes/fake_file_manager_service_interface.go index 6a494e5b6..eee6577c7 100644 --- a/internal/file/filefakes/fake_file_manager_service_interface.go +++ b/internal/file/filefakes/fake_file_manager_service_interface.go @@ -28,11 +28,12 @@ type FakeFileManagerServiceInterface struct { result1 model.WriteStatus result2 error } - DetermineFileActionsStub func(map[string]*v1.File, map[string]*model.FileCache) (map[string]*model.FileCache, map[string][]byte, error) + DetermineFileActionsStub func(context.Context, map[string]*v1.File, map[string]*model.FileCache) (map[string]*model.FileCache, map[string][]byte, error) determineFileActionsMutex sync.RWMutex determineFileActionsArgsForCall []struct { - arg1 map[string]*v1.File - arg2 map[string]*model.FileCache + arg1 context.Context + arg2 map[string]*v1.File + arg3 map[string]*model.FileCache } determineFileActionsReturns struct { result1 map[string]*model.FileCache @@ -204,19 +205,20 @@ func (fake *FakeFileManagerServiceInterface) ConfigApplyReturnsOnCall(i int, res }{result1, result2} } -func (fake *FakeFileManagerServiceInterface) DetermineFileActions(arg1 map[string]*v1.File, arg2 map[string]*model.FileCache) (map[string]*model.FileCache, map[string][]byte, error) { +func (fake *FakeFileManagerServiceInterface) DetermineFileActions(arg1 context.Context, arg2 map[string]*v1.File, arg3 map[string]*model.FileCache) (map[string]*model.FileCache, map[string][]byte, error) { fake.determineFileActionsMutex.Lock() ret, specificReturn := fake.determineFileActionsReturnsOnCall[len(fake.determineFileActionsArgsForCall)] fake.determineFileActionsArgsForCall = append(fake.determineFileActionsArgsForCall, struct { - arg1 map[string]*v1.File - arg2 map[string]*model.FileCache - }{arg1, arg2}) + arg1 context.Context + arg2 map[string]*v1.File + arg3 map[string]*model.FileCache + }{arg1, arg2, arg3}) stub := fake.DetermineFileActionsStub fakeReturns := fake.determineFileActionsReturns - fake.recordInvocation("DetermineFileActions", []interface{}{arg1, arg2}) + fake.recordInvocation("DetermineFileActions", []interface{}{arg1, arg2, arg3}) fake.determineFileActionsMutex.Unlock() if stub != nil { - return stub(arg1, arg2) + return stub(arg1, arg2, arg3) } if specificReturn { return ret.result1, ret.result2, ret.result3 @@ -230,17 +232,17 @@ func (fake *FakeFileManagerServiceInterface) DetermineFileActionsCallCount() int return len(fake.determineFileActionsArgsForCall) } -func (fake *FakeFileManagerServiceInterface) DetermineFileActionsCalls(stub func(map[string]*v1.File, map[string]*model.FileCache) (map[string]*model.FileCache, map[string][]byte, error)) { +func (fake *FakeFileManagerServiceInterface) DetermineFileActionsCalls(stub func(context.Context, map[string]*v1.File, map[string]*model.FileCache) (map[string]*model.FileCache, map[string][]byte, error)) { fake.determineFileActionsMutex.Lock() defer fake.determineFileActionsMutex.Unlock() fake.DetermineFileActionsStub = stub } -func (fake *FakeFileManagerServiceInterface) DetermineFileActionsArgsForCall(i int) (map[string]*v1.File, map[string]*model.FileCache) { +func (fake *FakeFileManagerServiceInterface) DetermineFileActionsArgsForCall(i int) (context.Context, map[string]*v1.File, map[string]*model.FileCache) { fake.determineFileActionsMutex.RLock() defer fake.determineFileActionsMutex.RUnlock() argsForCall := fake.determineFileActionsArgsForCall[i] - return argsForCall.arg1, argsForCall.arg2 + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 } func (fake *FakeFileManagerServiceInterface) DetermineFileActionsReturns(result1 map[string]*model.FileCache, result2 map[string][]byte, result3 error) { From 9cfefb8b7a5054415da3c375a98369e7cb90b160 Mon Sep 17 00:00:00 2001 From: Sean Breen <101327931+sean-breen@users.noreply.github.com> Date: Wed, 18 Jun 2025 11:48:07 +0100 Subject: [PATCH 18/28] Revert "[Release 3.0.2] Merge back into main (#1133)" (#1135) This reverts commit 699efa273c08a49f216ec6304a53d2cf92fc0497. --- go.mod | 2 +- internal/file/file_manager_service.go | 31 +++++-------------- internal/file/file_manager_service_test.go | 21 ++----------- .../fake_file_manager_service_interface.go | 26 +++++++--------- 4 files changed, 24 insertions(+), 56 deletions(-) diff --git a/go.mod b/go.mod index 434b15a13..8c16eab1b 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/nginx/agent/v3 go 1.23.7 -toolchain go1.23.10 +toolchain go1.23.8 require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.4-20250130201111-63bb56e20495.1 diff --git a/internal/file/file_manager_service.go b/internal/file/file_manager_service.go index 2591aa107..acbab1f29 100644 --- a/internal/file/file_manager_service.go +++ b/internal/file/file_manager_service.go @@ -68,19 +68,14 @@ type ( fileManagerServiceInterface interface { UpdateOverview(ctx context.Context, instanceID string, filesToUpdate []*mpi.File, iteration int) error - ConfigApply( - ctx context.Context, - configApplyRequest *mpi.ConfigApplyRequest, - ) (writeStatus model.WriteStatus, err error) + ConfigApply(ctx context.Context, configApplyRequest *mpi.ConfigApplyRequest) (writeStatus model.WriteStatus, + err error) Rollback(ctx context.Context, instanceID string) error UpdateFile(ctx context.Context, instanceID string, fileToUpdate *mpi.File) error ClearCache() UpdateCurrentFilesOnDisk(ctx context.Context, updateFiles map[string]*mpi.File, referenced bool) error - DetermineFileActions( - ctx context.Context, - currentFiles map[string]*mpi.File, - modifiedFiles map[string]*model.FileCache, - ) (map[string]*model.FileCache, map[string][]byte, error) + DetermineFileActions(currentFiles map[string]*mpi.File, modifiedFiles map[string]*model.FileCache) ( + map[string]*model.FileCache, map[string][]byte, error) IsConnected() bool SetIsConnected(isConnected bool) } @@ -510,11 +505,8 @@ func (fms *FileManagerService) ConfigApply(ctx context.Context, return model.Error, allowedErr } - diffFiles, fileContent, compareErr := fms.DetermineFileActions( - ctx, - fms.currentFilesOnDisk, - ConvertToMapOfFileCache(fileOverview.GetFiles()), - ) + diffFiles, fileContent, compareErr := fms.DetermineFileActions(fms.currentFilesOnDisk, + ConvertToMapOfFileCache(fileOverview.GetFiles())) if compareErr != nil { return model.Error, compareErr @@ -549,7 +541,7 @@ func (fms *FileManagerService) ClearCache() { // nolint:revive,cyclop func (fms *FileManagerService) Rollback(ctx context.Context, instanceID string) error { - slog.InfoContext(ctx, "Rolling back config for instance", "instance_id", instanceID) + slog.InfoContext(ctx, "Rolling back config for instance", "instanceid", instanceID) fms.filesMutex.Lock() defer fms.filesMutex.Unlock() @@ -718,7 +710,6 @@ func (fms *FileManagerService) checkAllowedDirectory(checkFiles []*mpi.File) err // that have changed and a map of the contents for each updated and deleted file. Key to both maps is file path // nolint: revive,cyclop,gocognit func (fms *FileManagerService) DetermineFileActions( - ctx context.Context, currentFiles map[string]*mpi.File, modifiedFiles map[string]*model.FileCache, ) ( @@ -741,7 +732,6 @@ func (fms *FileManagerService) DetermineFileActions( return nil, nil, manifestFileErr } } - // if file is in manifestFiles but not in modified files, file has been deleted // copy contents, set file action for fileName, manifestFile := range filesMap { @@ -751,12 +741,7 @@ func (fms *FileManagerService) DetermineFileActions( // Read file contents before marking it deleted fileContent, readErr := os.ReadFile(fileName) if readErr != nil { - if errors.Is(readErr, os.ErrNotExist) { - slog.DebugContext(ctx, "Unable to backup file contents since file does not exist", "file", fileName) - continue - } else { - return nil, nil, fmt.Errorf("error reading file %s: %w", fileName, readErr) - } + return nil, nil, fmt.Errorf("error reading file %s: %w", fileName, readErr) } fileContents[fileName] = fileContent diff --git a/internal/file/file_manager_service_test.go b/internal/file/file_manager_service_test.go index 5f9237942..aa139810d 100644 --- a/internal/file/file_manager_service_test.go +++ b/internal/file/file_manager_service_test.go @@ -559,7 +559,6 @@ func TestFileManagerService_Rollback(t *testing.T) { } func TestFileManagerService_DetermineFileActions(t *testing.T) { - ctx := context.Background() tempDir := os.TempDir() deleteTestFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_delete.conf") @@ -585,6 +584,7 @@ func TestFileManagerService_DetermineFileActions(t *testing.T) { addTestFile := helpers.CreateFileWithErrorCheck(t, tempDir, "nginx_add.conf") defer helpers.RemoveFileWithErrorCheck(t, addTestFile.Name()) + t.Logf("Adding file: %s", addTestFile.Name()) addFileContent := []byte("test add file") addErr := os.WriteFile(addTestFile.Name(), addFileContent, 0o600) require.NoError(t, addErr) @@ -694,18 +694,6 @@ func TestFileManagerService_DetermineFileActions(t *testing.T) { 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 { @@ -723,11 +711,8 @@ func TestFileManagerService_DetermineFileActions(t *testing.T) { require.NoError(tt, err) - diff, contents, fileActionErr := fileManagerService.DetermineFileActions( - ctx, - test.currentFiles, - test.modifiedFiles, - ) + diff, contents, fileActionErr := fileManagerService.DetermineFileActions(test.currentFiles, + test.modifiedFiles) require.NoError(tt, fileActionErr) assert.Equal(tt, test.expectedContent, contents) assert.Equal(tt, test.expectedCache, diff) diff --git a/internal/file/filefakes/fake_file_manager_service_interface.go b/internal/file/filefakes/fake_file_manager_service_interface.go index eee6577c7..6a494e5b6 100644 --- a/internal/file/filefakes/fake_file_manager_service_interface.go +++ b/internal/file/filefakes/fake_file_manager_service_interface.go @@ -28,12 +28,11 @@ type FakeFileManagerServiceInterface struct { result1 model.WriteStatus result2 error } - DetermineFileActionsStub func(context.Context, map[string]*v1.File, map[string]*model.FileCache) (map[string]*model.FileCache, map[string][]byte, error) + DetermineFileActionsStub func(map[string]*v1.File, map[string]*model.FileCache) (map[string]*model.FileCache, map[string][]byte, error) determineFileActionsMutex sync.RWMutex determineFileActionsArgsForCall []struct { - arg1 context.Context - arg2 map[string]*v1.File - arg3 map[string]*model.FileCache + arg1 map[string]*v1.File + arg2 map[string]*model.FileCache } determineFileActionsReturns struct { result1 map[string]*model.FileCache @@ -205,20 +204,19 @@ func (fake *FakeFileManagerServiceInterface) ConfigApplyReturnsOnCall(i int, res }{result1, result2} } -func (fake *FakeFileManagerServiceInterface) DetermineFileActions(arg1 context.Context, arg2 map[string]*v1.File, arg3 map[string]*model.FileCache) (map[string]*model.FileCache, map[string][]byte, error) { +func (fake *FakeFileManagerServiceInterface) DetermineFileActions(arg1 map[string]*v1.File, arg2 map[string]*model.FileCache) (map[string]*model.FileCache, map[string][]byte, error) { fake.determineFileActionsMutex.Lock() ret, specificReturn := fake.determineFileActionsReturnsOnCall[len(fake.determineFileActionsArgsForCall)] fake.determineFileActionsArgsForCall = append(fake.determineFileActionsArgsForCall, struct { - arg1 context.Context - arg2 map[string]*v1.File - arg3 map[string]*model.FileCache - }{arg1, arg2, arg3}) + arg1 map[string]*v1.File + arg2 map[string]*model.FileCache + }{arg1, arg2}) stub := fake.DetermineFileActionsStub fakeReturns := fake.determineFileActionsReturns - fake.recordInvocation("DetermineFileActions", []interface{}{arg1, arg2, arg3}) + fake.recordInvocation("DetermineFileActions", []interface{}{arg1, arg2}) fake.determineFileActionsMutex.Unlock() if stub != nil { - return stub(arg1, arg2, arg3) + return stub(arg1, arg2) } if specificReturn { return ret.result1, ret.result2, ret.result3 @@ -232,17 +230,17 @@ func (fake *FakeFileManagerServiceInterface) DetermineFileActionsCallCount() int return len(fake.determineFileActionsArgsForCall) } -func (fake *FakeFileManagerServiceInterface) DetermineFileActionsCalls(stub func(context.Context, map[string]*v1.File, map[string]*model.FileCache) (map[string]*model.FileCache, map[string][]byte, error)) { +func (fake *FakeFileManagerServiceInterface) DetermineFileActionsCalls(stub func(map[string]*v1.File, map[string]*model.FileCache) (map[string]*model.FileCache, map[string][]byte, error)) { fake.determineFileActionsMutex.Lock() defer fake.determineFileActionsMutex.Unlock() fake.DetermineFileActionsStub = stub } -func (fake *FakeFileManagerServiceInterface) DetermineFileActionsArgsForCall(i int) (context.Context, map[string]*v1.File, map[string]*model.FileCache) { +func (fake *FakeFileManagerServiceInterface) DetermineFileActionsArgsForCall(i int) (map[string]*v1.File, map[string]*model.FileCache) { fake.determineFileActionsMutex.RLock() defer fake.determineFileActionsMutex.RUnlock() argsForCall := fake.determineFileActionsArgsForCall[i] - return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 + return argsForCall.arg1, argsForCall.arg2 } func (fake *FakeFileManagerServiceInterface) DetermineFileActionsReturns(result1 map[string]*model.FileCache, result2 map[string][]byte, result3 error) { From 4c52f40eab6c9ab9b001cef2ce82b48096f5ef16 Mon Sep 17 00:00:00 2001 From: Sean Breen <101327931+sean-breen@users.noreply.github.com> Date: Fri, 20 Jun 2025 10:44:05 +0100 Subject: [PATCH 19/28] [Secure Build] New workflow to pull packages (#1137) * add verifier script * rework * make CERT and KEY optional * update comments, now prints the download location for each package * add new workflow to download signed packages and add them to azure + github release * remove old steps * tidy * remove references to v3 from release-branch.yml * fix script path * handle alpine package naming for Azure * add Azure logout * use public runner --- .github/workflows/release-branch.yml | 50 +--------- .github/workflows/upload-release-assets.yml | 102 ++++++++++++++++++++ 2 files changed, 106 insertions(+), 46 deletions(-) create mode 100644 .github/workflows/upload-release-assets.yml diff --git a/.github/workflows/release-branch.yml b/.github/workflows/release-branch.yml index babdb05a2..33e8de40e 100644 --- a/.github/workflows/release-branch.yml +++ b/.github/workflows/release-branch.yml @@ -28,7 +28,7 @@ on: default: false type: boolean createPullRequest: - description: 'Create pull request back into v3' + description: 'Create pull request back into main' default: false type: boolean releaseBranch: @@ -262,23 +262,6 @@ jobs: echo "$GPG_KEY" | base64 --decode > ${NFPM_SIGNING_KEY_FILE} make package - - name: Azure Login - if: ${{ inputs.uploadAzure == true }} - uses: azure/login@8c334a195cbb38e46038007b304988d888bf676a # v2.0.0 - with: - creds: ${{ secrets.AZURE_CREDENTIALS }} - - - name: Azure Upload Release Packages - if: ${{ inputs.uploadAzure == true }} - uses: azure/CLI@965c8d7571d2231a54e321ddd07f7b10317f34d9 # v2.0.0 - with: - inlineScript: | - for i in ./build/azure/packages/nginx-agent*; do - echo "Uploading ${i} to nginx-agent/${GITHUB_REF##*/}/${i##*/}" - az storage blob upload --auth-mode=login -f "$i" -c ${{ secrets.AZURE_CONTAINER_NAME }} \ - --account-name ${{ secrets.AZURE_ACCOUNT_NAME }} --overwrite -n nginx-agent/${GITHUB_REF##*/}/${i##*/} - done - - name: Install GPG tools if: ${{ inputs.publishPackages == true }} run: | @@ -302,34 +285,9 @@ jobs: run: | make release - - name: Upload Release Assets - if: ${{ needs.vars.outputs.github_release == 'true' }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # clobber overwrites existing assets of the same name - run: | - gh release upload --clobber v${{ inputs.packageVersion }} \ - $(find ./build/github/packages -type f \( -name "*.deb" -o -name "*.rpm" -o -name "*.pkg" -o -name "*.apk" \)) - - - name: Publish Github Release - if: ${{ needs.vars.outputs.github_release == 'true' }} - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - with: - script: | - const {RELEASE_ID} = process.env - const release = (await github.rest.repos.updateRelease({ - owner: context.payload.repository.owner.login, - repo: context.payload.repository.name, - release_id: `${RELEASE_ID}`, - draft: false, - })) - console.log(`Release published: ${release.data.html_url}`) - env: - RELEASE_ID: ${{ needs.release-draft.outputs.release_id }} - merge-release: if: ${{ needs.vars.outputs.create_pull_request == 'true' }} - name: Merge release branch back into V3 branch + name: Merge release branch back into main branch runs-on: ubuntu-22.04 needs: [vars,tag-release] permissions: @@ -346,11 +304,11 @@ jobs: script: | const { repo, owner } = context.repo; const result = await github.rest.pulls.create({ - title: 'Merge ${{ github.ref_name }} back into v3', + title: 'Merge ${{ github.ref_name }} back into main', owner, repo, head: '${{ github.ref_name }}', - base: 'v3', + base: 'main', body: [ 'This PR is auto-generated by the release workflow.' ].join('\n') diff --git a/.github/workflows/upload-release-assets.yml b/.github/workflows/upload-release-assets.yml new file mode 100644 index 000000000..093b14d04 --- /dev/null +++ b/.github/workflows/upload-release-assets.yml @@ -0,0 +1,102 @@ +name: Publish Release packages + +on: + workflow_dispatch: + inputs: + pkgRepo: + description: "Source repository to pull packages from" + type: string + default: "" + pkgVersion: + description: 'Agent version' + type: string + default: "" + uploadAzure: + description: 'Publish packages Azure storage' + type: boolean + default: false + uploadGithub: + description: 'Publish packages to GitHub release' + type: boolean + default: false + +defaults: + run: + shell: bash + +permissions: + contents: read + +jobs: + vars: + name: Set workflow variables + runs-on: ubuntu-22.04 + outputs: + github_release: ${{steps.vars.outputs.github_release }} + upload_azure: ${{steps.vars.outputs.upload_azure }} + steps: + - name: Checkout Repository + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 + with: + ref: ${{ inputs.releaseBranch }} + + - name: Set variables + id: vars + run: | + echo "github_release=${{ inputs.uploadGithub }}" >> $GITHUB_OUTPUT + echo "upload_azure=${{ inputs.uploadAzure }}" >> $GITHUB_OUTPUT + cat $GITHUB_OUTPUT + + upload-release-assets: + name: Upload assets + runs-on: ubuntu-22.04 + needs: [vars] + steps: + - name: Checkout Repository + uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2 + with: + ref: ${{ inputs.releaseBranch }} + + - name: Azure Login + if: ${{ inputs.uploadAzure == true }} + uses: azure/login@8c334a195cbb38e46038007b304988d888bf676a # v2.0.0 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + + - name: Download Packages + run: + | + echo "Checking Packages in ${{inputs.pkgRepo}}/nginx-agent" + PKG_REPO=${{inputs.pkgRepo}} CERT=${{secrets.PUBTEST_CERT}} KEY=${{secrets.PUBTEST_KEY}} DL=1 scripts/packages/package-check.sh ${{inputs.pkgVersion}} + find ${{inputs.pkgRepo}}/nginx-agent | grep -e "nginx-agent[_-]${{inputs.pkgVersion}}" + + - name: Azure Upload Release Packages + if: ${{ inputs.uploadAzure == true }} + uses: azure/CLI@965c8d7571d2231a54e321ddd07f7b10317f34d9 # v2.0.0 + with: + inlineScript: | + for i in $(find ${{inputs.pkgRepo}}/nginx-agent | grep -e "nginx-agent[_-]${{inputs.pkgVersion}}"); do + dest="nginx-agent/${GITHUB_REF##*/}/${i##*/}" + if [[ "$i" == *.apk ]]; then + ver=$(echo "$i" | grep -o -e "v[0-9]*\.[0-9]*") + arch=$(echo "$i" | grep -o -F -e "x86_64" -e "aarch64") + dest="nginx-agent/${GITHUB_REF##*/}/nginx-agent-$VER-$ver-$arch.apk" + fi + echo "Uploading ${i} to ${dest}" + az storage blob upload --auth-mode=login -f "$i" -c ${{ secrets.AZURE_CONTAINER_NAME }} \ + --account-name ${{ secrets.AZURE_ACCOUNT_NAME }} --overwrite -n ${dest} + done + + - name: Azure Logout + run: | + az logout + if: always() + + - name: GitHub Upload Release Assets + if: ${{ needs.vars.outputs.github_release == 'true' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # clobber overwrites existing assets of the same name + run: | + gh release upload --clobber v${{ inputs.pkgVersion }} \ + $(find ${{inputs.pkgRepo}}/nginx-agent | grep -e "nginx-agent[_-]${{inputs.pkgVersion}}") From 52e318030cef77de88cfa7170f4efe0e4561f038 Mon Sep 17 00:00:00 2001 From: Jakub Jarosz <99677300+jjngx@users.noreply.github.com> Date: Mon, 23 Jun 2025 16:33:19 +0100 Subject: [PATCH 20/28] Add mend workflow (#1138) * add mend workflow Signed-off-by: Jakub Jarosz * update mend workflow, exclude commits to docs Signed-off-by: Jakub Jarosz --------- Signed-off-by: Jakub Jarosz --- .github/workflows/mend.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/mend.yml diff --git a/.github/workflows/mend.yml b/.github/workflows/mend.yml new file mode 100644 index 000000000..a28495f00 --- /dev/null +++ b/.github/workflows/mend.yml @@ -0,0 +1,33 @@ +name: Mend + +on: + push: + branches: + - main + - release-* + tags: + - "v[0-9]+.[0-9]+.[0-9]+*" + paths-ignore: + - docs/** + pull_request: + branches: + - main + - release-* + paths-ignore: + - docs/** + +concurrency: + group: ${{ github.ref_name }}-mend + cancel-in-progress: true + +permissions: + contents: read + +jobs: + mend: + if: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) || (github.event_name == 'push' && github.event.repository.fork == false) }} + uses: nginxinc/compliance-rules/.github/workflows/mend.yml@a27656f8f9a8748085b434ebe007f5b572709aad # v0.2 + secrets: inherit + with: + product_name: nginx-agent-v3_${{ github.ref_name }} + project_name: nginx-agent-v3 From a76bd70cdb45aaa4b17c99b87d456f2488cd50dc Mon Sep 17 00:00:00 2001 From: Donal Hurley Date: Wed, 25 Jun 2025 13:55:41 +0100 Subject: [PATCH 21/28] Fix NAP log regex (#1141) --- internal/collector/otel_collector_plugin.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/collector/otel_collector_plugin.go b/internal/collector/otel_collector_plugin.go index e41a66a4d..18c101818 100644 --- a/internal/collector/otel_collector_plugin.go +++ b/internal/collector/otel_collector_plugin.go @@ -37,7 +37,7 @@ const ( // 2024-11-16T17:19:24+00:00 ---> Nov 16 17:19:24 timestampConversionExpression = `'EXPR(let timestamp = split(split(body, ">")[1], " ")[0]; ` + `let newTimestamp = ` + - `timestamp matches "(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})([+-]\\d{2}:\\d{2}|Z)" ` + + `timestamp matches "(\\d{4})-(\\d{2})-(0\\d{1})T(\\d{2}):(\\d{2}):(\\d{2})([+-]\\d{2}:\\d{2}|Z)" ` + `? (let utcTime = ` + `date(timestamp).UTC(); utcTime.Format("Jan 2 15:04:05")) : date(timestamp).Format("Jan 02 15:04:05"); ` + `split(body, ">")[0] + ">" + newTimestamp + " " + split(body, " ", 2)[1])'` From 3524bcf3e5f9023e35434eb26402b10652d9de62 Mon Sep 17 00:00:00 2001 From: Sean Breen <101327931+sean-breen@users.noreply.github.com> Date: Thu, 26 Jun 2025 10:23:31 +0100 Subject: [PATCH 22/28] [Secure build] Fix certificate (#1140) * add verifier script * rework * make CERT and KEY optional * update comments, now prints the download location for each package * add new workflow to download signed packages and add them to azure + github release * remove old steps * tidy * remove references to v3 from release-branch.yml * fix script path * handle alpine package naming for Azure * add Azure logout * use public runner * fix cert and key params * fix cat command * use printf * quotes * test * test * dont fail if azure logout fails * last one, move az login --- .github/workflows/upload-release-assets.yml | 42 +++++++++++---------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/.github/workflows/upload-release-assets.yml b/.github/workflows/upload-release-assets.yml index 093b14d04..1dcaa80c3 100644 --- a/.github/workflows/upload-release-assets.yml +++ b/.github/workflows/upload-release-assets.yml @@ -6,7 +6,7 @@ on: pkgRepo: description: "Source repository to pull packages from" type: string - default: "" + default: "packages.nginx.org" pkgVersion: description: 'Agent version' type: string @@ -57,20 +57,31 @@ jobs: with: ref: ${{ inputs.releaseBranch }} - - name: Azure Login - if: ${{ inputs.uploadAzure == true }} - uses: azure/login@8c334a195cbb38e46038007b304988d888bf676a # v2.0.0 - with: - creds: ${{ secrets.AZURE_CREDENTIALS }} - - name: Download Packages run: | echo "Checking Packages in ${{inputs.pkgRepo}}/nginx-agent" - PKG_REPO=${{inputs.pkgRepo}} CERT=${{secrets.PUBTEST_CERT}} KEY=${{secrets.PUBTEST_KEY}} DL=1 scripts/packages/package-check.sh ${{inputs.pkgVersion}} + echo "${{secrets.PUBTEST_CERT}}" > pubtest.crt + echo "${{secrets.PUBTEST_KEY}}" > pubtest.key + PKG_REPO=${{inputs.pkgRepo}} CERT=pubtest.crt KEY=pubtest.key DL=1 scripts/packages/package-check.sh ${{inputs.pkgVersion}} find ${{inputs.pkgRepo}}/nginx-agent | grep -e "nginx-agent[_-]${{inputs.pkgVersion}}" - - name: Azure Upload Release Packages + - name: GitHub Upload + if: ${{ needs.vars.outputs.github_release == 'true' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # clobber overwrites existing assets of the same name + run: | + gh release upload --clobber v${{ inputs.pkgVersion }} \ + $(find ${{inputs.pkgRepo}}/nginx-agent | grep -e "nginx-agent[_-]${{inputs.pkgVersion}}") + + - name: Azure Login + if: ${{ inputs.uploadAzure == true }} + uses: azure/login@8c334a195cbb38e46038007b304988d888bf676a # v2.0.0 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + + - name: Azure Upload if: ${{ inputs.uploadAzure == true }} uses: azure/CLI@965c8d7571d2231a54e321ddd07f7b10317f34d9 # v2.0.0 with: @@ -88,15 +99,6 @@ jobs: done - name: Azure Logout + if: ${{ inputs.uploadAzure == true }} run: | - az logout - if: always() - - - name: GitHub Upload Release Assets - if: ${{ needs.vars.outputs.github_release == 'true' }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # clobber overwrites existing assets of the same name - run: | - gh release upload --clobber v${{ inputs.pkgVersion }} \ - $(find ${{inputs.pkgRepo}}/nginx-agent | grep -e "nginx-agent[_-]${{inputs.pkgVersion}}") + az logout || exit 0 From 3a13a8eeb3ef493436e6b71be31d574ab2eb8bb6 Mon Sep 17 00:00:00 2001 From: Donal Hurley Date: Thu, 26 Jun 2025 11:29:00 +0100 Subject: [PATCH 23/28] Report NGINX App Protect instances (#1122) --- api/grpc/mpi/v1/command.pb.go | 21 +- api/grpc/mpi/v1/command.pb.validate.go | 2 + api/grpc/mpi/v1/command.proto | 4 +- docs/proto/protos.md | 1 + .../instance/instance_watcher_service.go | 48 ++- .../instance/instance_watcher_service_test.go | 6 +- .../instancefakes/fake_instance_finder.go | 110 +++++++ .../nginx-app-protect-instance-watcher.go | 300 ++++++++++++++++++ ...nginx-app-protect-instance-watcher_test.go | 155 +++++++++ .../nginx_app_protect_process_parser.go | 144 --------- .../nginx_app_protect_process_parser_test.go | 116 ------- internal/watcher/process/process_operator.go | 41 +-- .../fake_process_operator_interface.go | 33 +- internal/watcher/watcher_plugin.go | 3 + test/protos/instances.go | 25 ++ 15 files changed, 654 insertions(+), 355 deletions(-) create mode 100644 internal/watcher/instance/instancefakes/fake_instance_finder.go create mode 100644 internal/watcher/instance/nginx-app-protect-instance-watcher.go create mode 100644 internal/watcher/instance/nginx-app-protect-instance-watcher_test.go delete mode 100644 internal/watcher/instance/nginx_app_protect_process_parser.go delete mode 100644 internal/watcher/instance/nginx_app_protect_process_parser_test.go diff --git a/api/grpc/mpi/v1/command.pb.go b/api/grpc/mpi/v1/command.pb.go index 321c347a9..c84baaea1 100644 --- a/api/grpc/mpi/v1/command.pb.go +++ b/api/grpc/mpi/v1/command.pb.go @@ -2320,6 +2320,8 @@ type NGINXAppProtectRuntimeInfo struct { AttackSignatureVersion string `protobuf:"bytes,2,opt,name=attack_signature_version,json=attackSignatureVersion,proto3" json:"attack_signature_version,omitempty"` // Threat campaign version ThreatCampaignVersion string `protobuf:"bytes,3,opt,name=threat_campaign_version,json=threatCampaignVersion,proto3" json:"threat_campaign_version,omitempty"` + // Enforcer engine version + EnforcerEngineVersion string `protobuf:"bytes,4,opt,name=enforcer_engine_version,json=enforcerEngineVersion,proto3" json:"enforcer_engine_version,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -2375,6 +2377,13 @@ func (x *NGINXAppProtectRuntimeInfo) GetThreatCampaignVersion() string { return "" } +func (x *NGINXAppProtectRuntimeInfo) GetEnforcerEngineVersion() string { + if x != nil { + return x.EnforcerEngineVersion + } + return "" +} + // A set of actions that can be performed on an instance type InstanceAction struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -2754,11 +2763,12 @@ const file_mpi_v1_command_proto_rawDesc = "" + "\x0eInstanceConfig\x120\n" + "\aactions\x18\x01 \x03(\v2\x16.mpi.v1.InstanceActionR\aactions\x128\n" + "\fagent_config\x18\x02 \x01(\v2\x13.mpi.v1.AgentConfigH\x00R\vagentConfigB\b\n" + - "\x06config\"\xe7\x03\n" + + "\x06config\"\xee\x03\n" + "\x0fInstanceRuntime\x12\x1d\n" + "\n" + - "process_id\x18\x01 \x01(\x05R\tprocessId\x12)\n" + - "\vbinary_path\x18\x02 \x01(\tB\b\xbaH\x05r\x03:\x01/R\n" + + "process_id\x18\x01 \x01(\x05R\tprocessId\x120\n" + + "\vbinary_path\x18\x02 \x01(\tB\x0f\xbaH\fr\n" + + "2\b^\\/.*|^$R\n" + "binaryPath\x120\n" + "\vconfig_path\x18\x03 \x01(\tB\x0f\xbaH\fr\n" + "2\b^\\/.*|^$R\n" + @@ -2793,11 +2803,12 @@ const file_mpi_v1_command_proto_rawDesc = "" + "\n" + "APIDetails\x12\x1a\n" + "\blocation\x18\x01 \x01(\tR\blocation\x12\x16\n" + - "\x06listen\x18\x02 \x01(\tR\x06listen\"\xa8\x01\n" + + "\x06listen\x18\x02 \x01(\tR\x06listen\"\xe0\x01\n" + "\x1aNGINXAppProtectRuntimeInfo\x12\x18\n" + "\arelease\x18\x01 \x01(\tR\arelease\x128\n" + "\x18attack_signature_version\x18\x02 \x01(\tR\x16attackSignatureVersion\x126\n" + - "\x17threat_campaign_version\x18\x03 \x01(\tR\x15threatCampaignVersion\"\x10\n" + + "\x17threat_campaign_version\x18\x03 \x01(\tR\x15threatCampaignVersion\x126\n" + + "\x17enforcer_engine_version\x18\x04 \x01(\tR\x15enforcerEngineVersion\"\x10\n" + "\x0eInstanceAction\"\x94\x02\n" + "\vAgentConfig\x12/\n" + "\acommand\x18\x01 \x01(\v2\x15.mpi.v1.CommandServerR\acommand\x12/\n" + diff --git a/api/grpc/mpi/v1/command.pb.validate.go b/api/grpc/mpi/v1/command.pb.validate.go index 34825aa11..a6e195ac9 100644 --- a/api/grpc/mpi/v1/command.pb.validate.go +++ b/api/grpc/mpi/v1/command.pb.validate.go @@ -4998,6 +4998,8 @@ func (m *NGINXAppProtectRuntimeInfo) validate(all bool) error { // no validation rules for ThreatCampaignVersion + // no validation rules for EnforcerEngineVersion + if len(errors) > 0 { return NGINXAppProtectRuntimeInfoMultiError(errors) } diff --git a/api/grpc/mpi/v1/command.proto b/api/grpc/mpi/v1/command.proto index 4a0f1ffc2..63a7f785f 100644 --- a/api/grpc/mpi/v1/command.proto +++ b/api/grpc/mpi/v1/command.proto @@ -296,7 +296,7 @@ message InstanceRuntime { // the process identifier int32 process_id = 1; // the binary path location - string binary_path = 2 [(buf.validate.field).string.prefix = "/"]; + string binary_path = 2 [(buf.validate.field).string.pattern = "^\\/.*|^$"]; // the config path location string config_path = 3 [(buf.validate.field).string.pattern = "^\\/.*|^$"]; // more detailed runtime objects @@ -362,6 +362,8 @@ message NGINXAppProtectRuntimeInfo { string attack_signature_version = 2; // Threat campaign version string threat_campaign_version = 3; + // Enforcer engine version + string enforcer_engine_version = 4; } // A set of actions that can be performed on an instance diff --git a/docs/proto/protos.md b/docs/proto/protos.md index 6f05d530a..2905bf767 100644 --- a/docs/proto/protos.md +++ b/docs/proto/protos.md @@ -1053,6 +1053,7 @@ A set of runtime NGINX App Protect settings | release | [string](#string) | | NGINX App Protect Release | | attack_signature_version | [string](#string) | | Attack signature version | | threat_campaign_version | [string](#string) | | Threat campaign version | +| enforcer_engine_version | [string](#string) | | Enforcer engine version | diff --git a/internal/watcher/instance/instance_watcher_service.go b/internal/watcher/instance/instance_watcher_service.go index 12f212365..b88f62cba 100644 --- a/internal/watcher/instance/instance_watcher_service.go +++ b/internal/watcher/instance/instance_watcher_service.go @@ -39,18 +39,17 @@ type ( } InstanceWatcherService struct { - processOperator process.ProcessOperatorInterface - nginxConfigParser parser.ConfigParser - executer exec.ExecInterface - enabled *atomic.Bool - agentConfig *config.Config - instanceCache map[string]*mpi.Instance - nginxConfigCache map[string]*model.NginxConfigContext - instancesChannel chan<- InstanceUpdatesMessage - nginxConfigContextChannel chan<- NginxConfigContextMessage - nginxParser processParser - nginxAppProtectProcessParser processParser - cacheMutex sync.Mutex + processOperator process.ProcessOperatorInterface + nginxConfigParser parser.ConfigParser + executer exec.ExecInterface + enabled *atomic.Bool + agentConfig *config.Config + instanceCache map[string]*mpi.Instance + nginxConfigCache map[string]*model.NginxConfigContext + instancesChannel chan<- InstanceUpdatesMessage + nginxConfigContextChannel chan<- NginxConfigContextMessage + nginxParser processParser + cacheMutex sync.Mutex } InstanceUpdates struct { @@ -75,16 +74,15 @@ func NewInstanceWatcherService(agentConfig *config.Config) *InstanceWatcherServi enabled.Store(true) return &InstanceWatcherService{ - agentConfig: agentConfig, - processOperator: process.NewProcessOperator(), - nginxParser: NewNginxProcessParser(), - nginxAppProtectProcessParser: NewNginxAppProtectProcessParser(), - nginxConfigParser: parser.NewNginxConfigParser(agentConfig), - instanceCache: make(map[string]*mpi.Instance), - cacheMutex: sync.Mutex{}, - nginxConfigCache: make(map[string]*model.NginxConfigContext), - executer: &exec.Exec{}, - enabled: enabled, + agentConfig: agentConfig, + processOperator: process.NewProcessOperator(), + nginxParser: NewNginxProcessParser(), + nginxConfigParser: parser.NewNginxConfigParser(agentConfig), + instanceCache: make(map[string]*mpi.Instance), + cacheMutex: sync.Mutex{}, + nginxConfigCache: make(map[string]*model.NginxConfigContext), + executer: &exec.Exec{}, + enabled: enabled, } } @@ -265,7 +263,7 @@ func (iw *InstanceWatcherService) instanceUpdates(ctx context.Context) ( ) { iw.cacheMutex.Lock() defer iw.cacheMutex.Unlock() - nginxProcesses, nginxAppProtectProcesses, err := iw.processOperator.Processes(ctx) + nginxProcesses, err := iw.processOperator.Processes(ctx) if err != nil { return instanceUpdates, err } @@ -280,10 +278,6 @@ func (iw *InstanceWatcherService) instanceUpdates(ctx context.Context) ( instancesFound[instance.GetInstanceMeta().GetInstanceId()] = instance } - nginxAppProtectInstances := iw.nginxAppProtectProcessParser.Parse(ctx, nginxAppProtectProcesses) - for _, instance := range nginxAppProtectInstances { - instancesFound[instance.GetInstanceMeta().GetInstanceId()] = instance - } newInstances, updatedInstances, deletedInstances := compareInstances(iw.instanceCache, instancesFound) instanceUpdates.NewInstances = newInstances diff --git a/internal/watcher/instance/instance_watcher_service_test.go b/internal/watcher/instance/instance_watcher_service_test.go index 845e13c85..218b64f41 100644 --- a/internal/watcher/instance/instance_watcher_service_test.go +++ b/internal/watcher/instance/instance_watcher_service_test.go @@ -28,7 +28,7 @@ func TestInstanceWatcherService_checkForUpdates(t *testing.T) { nginxConfigContext := testModel.ConfigContext() fakeProcessWatcher := &processfakes.FakeProcessOperatorInterface{} - fakeProcessWatcher.ProcessesReturns(nil, nil, nil) + fakeProcessWatcher.ProcessesReturns(nil, nil) fakeProcessParser := &instancefakes.FakeProcessParser{} fakeProcessParser.ParseReturns(map[string]*mpi.Instance{ @@ -44,7 +44,6 @@ func TestInstanceWatcherService_checkForUpdates(t *testing.T) { instanceWatcherService := NewInstanceWatcherService(types.AgentConfig()) instanceWatcherService.processOperator = fakeProcessWatcher instanceWatcherService.nginxParser = fakeProcessParser - instanceWatcherService.nginxAppProtectProcessParser = fakeProcessParser instanceWatcherService.nginxConfigParser = fakeNginxConfigParser instanceWatcherService.instancesChannel = instanceUpdatesChannel instanceWatcherService.nginxConfigContextChannel = nginxConfigContextChannel @@ -132,7 +131,7 @@ func TestInstanceWatcherService_instanceUpdates(t *testing.T) { for _, test := range tests { t.Run(test.name, func(tt *testing.T) { fakeProcessWatcher := &processfakes.FakeProcessOperatorInterface{} - fakeProcessWatcher.ProcessesReturns(nil, nil, nil) + fakeProcessWatcher.ProcessesReturns(nil, nil) fakeProcessParser := &instancefakes.FakeProcessParser{} fakeProcessParser.ParseReturns(test.parsedInstances) @@ -144,7 +143,6 @@ func TestInstanceWatcherService_instanceUpdates(t *testing.T) { instanceWatcherService := NewInstanceWatcherService(types.AgentConfig()) instanceWatcherService.processOperator = fakeProcessWatcher instanceWatcherService.nginxParser = fakeProcessParser - instanceWatcherService.nginxAppProtectProcessParser = fakeProcessParser instanceWatcherService.instanceCache = test.oldInstances instanceWatcherService.executer = fakeExec diff --git a/internal/watcher/instance/instancefakes/fake_instance_finder.go b/internal/watcher/instance/instancefakes/fake_instance_finder.go new file mode 100644 index 000000000..6f2236a7b --- /dev/null +++ b/internal/watcher/instance/instancefakes/fake_instance_finder.go @@ -0,0 +1,110 @@ +// Code generated by counterfeiter. DO NOT EDIT. +package instancefakes + +import ( + "context" + "sync" + + v1 "github.com/nginx/agent/v3/api/grpc/mpi/v1" +) + +type FakeInstanceFinder struct { + FindStub func(context.Context) *v1.Instance + findMutex sync.RWMutex + findArgsForCall []struct { + arg1 context.Context + } + findReturns struct { + result1 *v1.Instance + } + findReturnsOnCall map[int]struct { + result1 *v1.Instance + } + invocations map[string][][]interface{} + invocationsMutex sync.RWMutex +} + +func (fake *FakeInstanceFinder) Find(arg1 context.Context) *v1.Instance { + fake.findMutex.Lock() + ret, specificReturn := fake.findReturnsOnCall[len(fake.findArgsForCall)] + fake.findArgsForCall = append(fake.findArgsForCall, struct { + arg1 context.Context + }{arg1}) + stub := fake.FindStub + fakeReturns := fake.findReturns + fake.recordInvocation("Find", []interface{}{arg1}) + fake.findMutex.Unlock() + if stub != nil { + return stub(arg1) + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeInstanceFinder) FindCallCount() int { + fake.findMutex.RLock() + defer fake.findMutex.RUnlock() + return len(fake.findArgsForCall) +} + +func (fake *FakeInstanceFinder) FindCalls(stub func(context.Context) *v1.Instance) { + fake.findMutex.Lock() + defer fake.findMutex.Unlock() + fake.FindStub = stub +} + +func (fake *FakeInstanceFinder) FindArgsForCall(i int) context.Context { + fake.findMutex.RLock() + defer fake.findMutex.RUnlock() + argsForCall := fake.findArgsForCall[i] + return argsForCall.arg1 +} + +func (fake *FakeInstanceFinder) FindReturns(result1 *v1.Instance) { + fake.findMutex.Lock() + defer fake.findMutex.Unlock() + fake.FindStub = nil + fake.findReturns = struct { + result1 *v1.Instance + }{result1} +} + +func (fake *FakeInstanceFinder) FindReturnsOnCall(i int, result1 *v1.Instance) { + fake.findMutex.Lock() + defer fake.findMutex.Unlock() + fake.FindStub = nil + if fake.findReturnsOnCall == nil { + fake.findReturnsOnCall = make(map[int]struct { + result1 *v1.Instance + }) + } + fake.findReturnsOnCall[i] = struct { + result1 *v1.Instance + }{result1} +} + +func (fake *FakeInstanceFinder) Invocations() map[string][][]interface{} { + fake.invocationsMutex.RLock() + defer fake.invocationsMutex.RUnlock() + fake.findMutex.RLock() + defer fake.findMutex.RUnlock() + copiedInvocations := map[string][][]interface{}{} + for key, value := range fake.invocations { + copiedInvocations[key] = value + } + return copiedInvocations +} + +func (fake *FakeInstanceFinder) recordInvocation(key string, args []interface{}) { + fake.invocationsMutex.Lock() + defer fake.invocationsMutex.Unlock() + if fake.invocations == nil { + fake.invocations = map[string][][]interface{}{} + } + if fake.invocations[key] == nil { + fake.invocations[key] = [][]interface{}{} + } + fake.invocations[key] = append(fake.invocations[key], args) +} diff --git a/internal/watcher/instance/nginx-app-protect-instance-watcher.go b/internal/watcher/instance/nginx-app-protect-instance-watcher.go new file mode 100644 index 000000000..2f20efdcc --- /dev/null +++ b/internal/watcher/instance/nginx-app-protect-instance-watcher.go @@ -0,0 +1,300 @@ +// 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 instance + +import ( + "context" + "log/slog" + "os" + "strings" + "time" + + "github.com/fsnotify/fsnotify" + mpi "github.com/nginx/agent/v3/api/grpc/mpi/v1" + "github.com/nginx/agent/v3/internal/config" + "github.com/nginx/agent/v3/internal/logger" + "github.com/nginx/agent/v3/pkg/id" +) + +var ( + versionFilePath = "/opt/app_protect/VERSION" + releaseFilePath = "/opt/app_protect/RELEASE" + attackSignatureVersionFilePath = "/opt/app_protect/var/update_files/signatures/version" + threatCampaignVersionFilePath = "/opt/app_protect/var/update_files/threat_campaigns/version" + enforcerEngineVersionFilePath = "/opt/app_protect/bd_config/enforcer.version" + + versionFiles = []string{ + versionFilePath, + releaseFilePath, + attackSignatureVersionFilePath, + threatCampaignVersionFilePath, + enforcerEngineVersionFilePath, + } +) + +type NginxAppProtectInstanceWatcher struct { + agentConfig *config.Config + watcher *fsnotify.Watcher + instancesChannel chan<- InstanceUpdatesMessage + nginxAppProtectInstance *mpi.Instance + filesBeingWatched map[string]bool + version string + release string + attackSignatureVersion string + threatCampaignVersion string + enforcerEngineVersion string +} + +func NewNginxAppProtectInstanceWatcher(agentConfig *config.Config) *NginxAppProtectInstanceWatcher { + return &NginxAppProtectInstanceWatcher{ + agentConfig: agentConfig, + filesBeingWatched: make(map[string]bool), + } +} + +func (w *NginxAppProtectInstanceWatcher) Watch(ctx context.Context, instancesChannel chan<- InstanceUpdatesMessage) { + monitoringFrequency := w.agentConfig.Watchers.InstanceWatcher.MonitoringFrequency + slog.DebugContext( + ctx, + "Starting NGINX App Protect instance watcher monitoring", + "monitoring_frequency", monitoringFrequency, + ) + + watcher, err := fsnotify.NewWatcher() + if err != nil { + slog.ErrorContext(ctx, "Failed to create NGINX App Protect instance watcher", "error", err) + return + } + + w.watcher = watcher + w.instancesChannel = instancesChannel + + w.watchVersionFiles(ctx) + + instanceWatcherTicker := time.NewTicker(monitoringFrequency) + defer instanceWatcherTicker.Stop() + + for { + select { + case <-ctx.Done(): + closeError := w.watcher.Close() + if closeError != nil { + slog.ErrorContext(ctx, "Unable to close NGINX App Protect instance watcher", "error", closeError) + } + + return + case <-instanceWatcherTicker.C: + // Need to keep watching directories in case NAP gets installed a while after NGINX Agent is started + w.watchVersionFiles(ctx) + w.checkForUpdates(ctx) + case event := <-w.watcher.Events: + w.handleEvent(ctx, event) + case watcherError := <-w.watcher.Errors: + slog.ErrorContext(ctx, "Unexpected error in NGINX App Protect instance watcher", "error", watcherError) + } + } +} + +func (w *NginxAppProtectInstanceWatcher) watchVersionFiles(ctx context.Context) { + for _, versionFile := range versionFiles { + if !w.filesBeingWatched[versionFile] { + if _, fileOs := os.Stat(versionFile); fileOs != nil && os.IsNotExist(fileOs) { + w.filesBeingWatched[versionFile] = false + continue + } + + w.addWatcher(ctx, versionFile) + w.filesBeingWatched[versionFile] = true + + // On startup we need to read the files initially if they are discovered for the first time + w.readVersionFile(ctx, versionFile) + } + } +} + +func (w *NginxAppProtectInstanceWatcher) addWatcher(ctx context.Context, versionFile string) { + if err := w.watcher.Add(versionFile); err != nil { + slog.ErrorContext( + ctx, + "Failed to add NGINX App Protect file watcher", + "file", versionFile, "error", err, + ) + removeError := w.watcher.Remove(versionFile) + if removeError != nil { + slog.ErrorContext( + ctx, + "Failed to remove NGINX App Protect file watcher", + "file", versionFile, "error", removeError, + ) + } + } + + slog.DebugContext(ctx, "Added NGINX App Protect file watcher", "file", versionFile) +} + +func (w *NginxAppProtectInstanceWatcher) readVersionFile(ctx context.Context, versionFile string) { + switch { + case versionFile == versionFilePath: + w.version = w.readFile(ctx, versionFilePath) + case versionFile == releaseFilePath: + w.release = w.readFile(ctx, releaseFilePath) + case versionFile == threatCampaignVersionFilePath: + w.threatCampaignVersion = w.readFile(ctx, threatCampaignVersionFilePath) + case versionFile == enforcerEngineVersionFilePath: + w.enforcerEngineVersion = w.readFile(ctx, enforcerEngineVersionFilePath) + case versionFile == attackSignatureVersionFilePath: + w.attackSignatureVersion = w.readFile(ctx, attackSignatureVersionFilePath) + } +} + +func (w *NginxAppProtectInstanceWatcher) handleEvent(ctx context.Context, event fsnotify.Event) { + switch { + case event.Has(fsnotify.Write), event.Has(fsnotify.Create): + w.handleFileUpdateEvent(ctx, event) + case event.Has(fsnotify.Remove), event.Has(fsnotify.Rename): + w.handleFileDeleteEvent(event) + } +} + +func (w *NginxAppProtectInstanceWatcher) handleFileUpdateEvent(ctx context.Context, event fsnotify.Event) { + switch event.Name { + case versionFilePath: + w.version = w.readFile(ctx, event.Name) + case releaseFilePath: + w.release = w.readFile(ctx, event.Name) + case attackSignatureVersionFilePath: + w.attackSignatureVersion = w.readFile(ctx, event.Name) + case enforcerEngineVersionFilePath: + w.enforcerEngineVersion = w.readFile(ctx, event.Name) + case threatCampaignVersionFilePath: + w.threatCampaignVersion = w.readFile(ctx, event.Name) + } +} + +func (w *NginxAppProtectInstanceWatcher) handleFileDeleteEvent(event fsnotify.Event) { + switch event.Name { + case versionFilePath: + w.version = "" + case releaseFilePath: + w.release = "" + case attackSignatureVersionFilePath: + w.attackSignatureVersion = "" + case enforcerEngineVersionFilePath: + w.enforcerEngineVersion = "" + case threatCampaignVersionFilePath: + w.threatCampaignVersion = "" + } +} + +func (w *NginxAppProtectInstanceWatcher) checkForUpdates(ctx context.Context) { + // If a version file is discovered for the first time we treat that as a new instance + if w.isNewInstance() { + w.createInstance(ctx) + } else if w.nginxAppProtectInstance != nil { + // If a version file disappears then we assume that NGINX App Protect is uninstalled + if w.version == "" { + w.deleteInstance(ctx) + // If any version changes then we update the instance metadata + } else if w.haveVersionsChanged() { + w.updateInstance(ctx) + } + } +} + +func (w *NginxAppProtectInstanceWatcher) isNewInstance() bool { + return w.nginxAppProtectInstance == nil && w.version != "" +} + +func (w *NginxAppProtectInstanceWatcher) createInstance(ctx context.Context) { + w.nginxAppProtectInstance = &mpi.Instance{ + InstanceMeta: &mpi.InstanceMeta{ + InstanceId: id.Generate(versionFilePath), + InstanceType: mpi.InstanceMeta_INSTANCE_TYPE_NGINX_APP_PROTECT, + Version: w.version, + }, + InstanceConfig: &mpi.InstanceConfig{}, + InstanceRuntime: &mpi.InstanceRuntime{ + ProcessId: 0, + BinaryPath: "", + ConfigPath: "", + Details: &mpi.InstanceRuntime_NginxAppProtectRuntimeInfo{ + NginxAppProtectRuntimeInfo: &mpi.NGINXAppProtectRuntimeInfo{ + Release: w.release, + AttackSignatureVersion: w.attackSignatureVersion, + ThreatCampaignVersion: w.threatCampaignVersion, + EnforcerEngineVersion: w.enforcerEngineVersion, + }, + }, + InstanceChildren: make([]*mpi.InstanceChild, 0), + }, + } + + slog.InfoContext(ctx, "Discovered a new NGINX App Protect instance") + + w.instancesChannel <- InstanceUpdatesMessage{ + CorrelationID: logger.CorrelationIDAttr(ctx), + InstanceUpdates: InstanceUpdates{ + NewInstances: []*mpi.Instance{ + w.nginxAppProtectInstance, + }, + }, + } +} + +func (w *NginxAppProtectInstanceWatcher) deleteInstance(ctx context.Context) { + slog.InfoContext(ctx, "NGINX App Protect instance not longer exists") + + w.instancesChannel <- InstanceUpdatesMessage{ + CorrelationID: logger.CorrelationIDAttr(ctx), + InstanceUpdates: InstanceUpdates{ + DeletedInstances: []*mpi.Instance{ + w.nginxAppProtectInstance, + }, + }, + } + w.nginxAppProtectInstance = nil +} + +func (w *NginxAppProtectInstanceWatcher) updateInstance(ctx context.Context) { + w.nginxAppProtectInstance.GetInstanceMeta().Version = w.version + runtimeInfo := w.nginxAppProtectInstance.GetInstanceRuntime().GetNginxAppProtectRuntimeInfo() + runtimeInfo.Release = w.release + runtimeInfo.AttackSignatureVersion = w.attackSignatureVersion + runtimeInfo.ThreatCampaignVersion = w.threatCampaignVersion + runtimeInfo.EnforcerEngineVersion = w.enforcerEngineVersion + + slog.DebugContext(ctx, "NGINX App Protect instance updated") + + w.instancesChannel <- InstanceUpdatesMessage{ + CorrelationID: logger.CorrelationIDAttr(ctx), + InstanceUpdates: InstanceUpdates{ + UpdatedInstances: []*mpi.Instance{ + w.nginxAppProtectInstance, + }, + }, + } +} + +func (w *NginxAppProtectInstanceWatcher) haveVersionsChanged() bool { + version := w.nginxAppProtectInstance.GetInstanceMeta().GetVersion() + runtimeInfo := w.nginxAppProtectInstance.GetInstanceRuntime().GetNginxAppProtectRuntimeInfo() + + return version != w.version || + runtimeInfo.GetRelease() != w.release || + runtimeInfo.GetAttackSignatureVersion() != w.attackSignatureVersion || + runtimeInfo.GetThreatCampaignVersion() != w.threatCampaignVersion || + runtimeInfo.GetEnforcerEngineVersion() != w.enforcerEngineVersion +} + +func (w *NginxAppProtectInstanceWatcher) readFile(ctx context.Context, filePath string) string { + contents, err := os.ReadFile(filePath) + if err != nil && !os.IsNotExist(err) { + slog.DebugContext(ctx, "Unable to read NGINX App Protect file", "file_path", filePath, "error", err) + return "" + } + + return strings.TrimSuffix(string(contents), "\n") +} diff --git a/internal/watcher/instance/nginx-app-protect-instance-watcher_test.go b/internal/watcher/instance/nginx-app-protect-instance-watcher_test.go new file mode 100644 index 000000000..48925663d --- /dev/null +++ b/internal/watcher/instance/nginx-app-protect-instance-watcher_test.go @@ -0,0 +1,155 @@ +// 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 instance + +import ( + "context" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/proto" + + "github.com/nginx/agent/v3/internal/config" + + "github.com/nginx/agent/v3/pkg/id" + "github.com/nginx/agent/v3/test/protos" + + "github.com/nginx/agent/v3/test/helpers" + "github.com/stretchr/testify/require" +) + +const timeout = 5 * time.Second + +func TestNginxAppProtectInstanceWatcher_Watch(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + expectedInstance := protos.NginxAppProtectInstance() + + versionFile := helpers.CreateFileWithErrorCheck(t, os.TempDir(), "version") + defer os.Remove(versionFile.Name()) + defer versionFile.Close() + + _, err := versionFile.WriteString("5.144.0") + require.NoError(t, err) + + // Instance ID is generated based on version file path + expectedInstance.GetInstanceMeta().InstanceId = id.Generate(versionFile.Name()) + + releaseFile := helpers.CreateFileWithErrorCheck(t, os.TempDir(), "release") + defer helpers.RemoveFileWithErrorCheck(t, releaseFile.Name()) + defer releaseFile.Close() + + _, err = releaseFile.WriteString("4.11.0") + require.NoError(t, err) + + attackSignatureVersionFile := helpers.CreateFileWithErrorCheck(t, os.TempDir(), "version") + defer helpers.RemoveFileWithErrorCheck(t, attackSignatureVersionFile.Name()) + defer attackSignatureVersionFile.Close() + + _, err = attackSignatureVersionFile.WriteString("2024.11.28") + require.NoError(t, err) + + threatCampaignVersionFile := helpers.CreateFileWithErrorCheck(t, os.TempDir(), "version") + defer helpers.RemoveFileWithErrorCheck(t, threatCampaignVersionFile.Name()) + defer threatCampaignVersionFile.Close() + + _, err = threatCampaignVersionFile.WriteString("2024.12.02") + require.NoError(t, err) + + enforcerEngineVersionFile := helpers.CreateFileWithErrorCheck(t, os.TempDir(), "enforcer_version") + defer helpers.RemoveFileWithErrorCheck(t, enforcerEngineVersionFile.Name()) + defer enforcerEngineVersionFile.Close() + + _, err = enforcerEngineVersionFile.WriteString("5.113.0") + require.NoError(t, err) + + versionFilePath = versionFile.Name() + releaseFilePath = releaseFile.Name() + attackSignatureVersionFilePath = attackSignatureVersionFile.Name() + threatCampaignVersionFilePath = threatCampaignVersionFile.Name() + enforcerEngineVersionFilePath = enforcerEngineVersionFile.Name() + + versionFiles = []string{ + versionFilePath, + releaseFilePath, + attackSignatureVersionFilePath, + threatCampaignVersionFilePath, + enforcerEngineVersionFilePath, + } + + instancesChannel := make(chan InstanceUpdatesMessage) + + nginxAppProtectInstanceWatcher := NewNginxAppProtectInstanceWatcher( + &config.Config{ + Watchers: &config.Watchers{ + InstanceWatcher: config.InstanceWatcher{ + MonitoringFrequency: 200 * time.Millisecond, + }, + }, + }, + ) + + go nginxAppProtectInstanceWatcher.Watch(ctx, instancesChannel) + + t.Run("Test 1: New instance", func(t *testing.T) { + select { + case instanceUpdates := <-instancesChannel: + assert.Len(t, instanceUpdates.InstanceUpdates.NewInstances, 1) + assert.Empty(t, instanceUpdates.InstanceUpdates.UpdatedInstances) + assert.Empty(t, instanceUpdates.InstanceUpdates.DeletedInstances) + assert.Truef( + t, + proto.Equal(instanceUpdates.InstanceUpdates.NewInstances[0], expectedInstance), + "expected %s, actual %s", expectedInstance, instanceUpdates.InstanceUpdates.NewInstances[0], + ) + case <-time.After(timeout): + t.Fatalf("Timed out waiting for instance updates") + } + }) + + t.Run("Test 2: Update instance", func(t *testing.T) { + _, err = enforcerEngineVersionFile.WriteAt([]byte("6.113.0"), 0) + require.NoError(t, err) + + expectedInstance.GetInstanceRuntime().GetNginxAppProtectRuntimeInfo().EnforcerEngineVersion = "6.113.0" + + select { + case instanceUpdates := <-instancesChannel: + assert.Len(t, instanceUpdates.InstanceUpdates.UpdatedInstances, 1) + assert.Empty(t, instanceUpdates.InstanceUpdates.NewInstances) + assert.Empty(t, instanceUpdates.InstanceUpdates.DeletedInstances) + assert.Truef( + t, + proto.Equal(instanceUpdates.InstanceUpdates.UpdatedInstances[0], expectedInstance), + "expected %s, actual %s", expectedInstance, instanceUpdates.InstanceUpdates.UpdatedInstances[0], + ) + case <-time.After(timeout): + t.Fatalf("Timed out waiting for instance updates") + } + }) + t.Run("Test 3: Delete instance", func(t *testing.T) { + helpers.RemoveFileWithErrorCheck(t, versionFile.Name()) + closeErr := versionFile.Close() + require.NoError(t, closeErr) + + select { + case instanceUpdates := <-instancesChannel: + assert.Len(t, instanceUpdates.InstanceUpdates.DeletedInstances, 1) + assert.Empty(t, instanceUpdates.InstanceUpdates.NewInstances) + assert.Empty(t, instanceUpdates.InstanceUpdates.UpdatedInstances) + assert.Truef( + t, + proto.Equal(instanceUpdates.InstanceUpdates.DeletedInstances[0], expectedInstance), + "expected %s, actual %s", expectedInstance, instanceUpdates.InstanceUpdates.DeletedInstances[0], + ) + case <-time.After(timeout): + t.Fatalf("Timed out waiting for instance updates") + } + }) +} diff --git a/internal/watcher/instance/nginx_app_protect_process_parser.go b/internal/watcher/instance/nginx_app_protect_process_parser.go deleted file mode 100644 index 93001e86e..000000000 --- a/internal/watcher/instance/nginx_app_protect_process_parser.go +++ /dev/null @@ -1,144 +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 instance - -import ( - "context" - "log/slog" - "os" - "strings" - - "github.com/nginx/agent/v3/pkg/nginxprocess" - - mpi "github.com/nginx/agent/v3/api/grpc/mpi/v1" - "github.com/nginx/agent/v3/pkg/id" -) - -const ( - versionFilePath = "/opt/app_protect/VERSION" - releaseFilePath = "/opt/app_protect/RELEASE" - processName = "bd-socket-plugin" - attackSignatureVersionFilePath = "/opt/app_protect/var/update_files/signatures/version" - threatCampaignVersionFilePath = "/opt/app_protect/var/update_files/threat_campaigns/version" -) - -type ( - NginxAppProtectProcessParser struct { - versionFilePath string - releaseFilePath string - attackSignatureVersionFilePath string - threatCampaignVersionFilePath string - } -) - -var _ processParser = (*NginxAppProtectProcessParser)(nil) - -func NewNginxAppProtectProcessParser() *NginxAppProtectProcessParser { - return &NginxAppProtectProcessParser{ - versionFilePath: versionFilePath, - releaseFilePath: releaseFilePath, - attackSignatureVersionFilePath: attackSignatureVersionFilePath, - threatCampaignVersionFilePath: threatCampaignVersionFilePath, - } -} - -func (n NginxAppProtectProcessParser) Parse( - ctx context.Context, - processes []*nginxprocess.Process, -) map[string]*mpi.Instance { - instanceMap := make(map[string]*mpi.Instance) // key is instanceID - - for _, process := range processes { - if process.Name == processName { - instanceID := n.instanceID(process) - - binaryPath := process.Exe - if binaryPath == "" { - binaryPath = strings.Split(process.Cmd, " ")[0] - } - - instanceMap[instanceID] = &mpi.Instance{ - InstanceMeta: &mpi.InstanceMeta{ - InstanceId: instanceID, - InstanceType: mpi.InstanceMeta_INSTANCE_TYPE_NGINX_APP_PROTECT, - Version: n.instanceVersion(ctx), - }, - InstanceConfig: &mpi.InstanceConfig{}, - InstanceRuntime: &mpi.InstanceRuntime{ - ProcessId: process.PID, - BinaryPath: binaryPath, - ConfigPath: "", - Details: &mpi.InstanceRuntime_NginxAppProtectRuntimeInfo{ - NginxAppProtectRuntimeInfo: &mpi.NGINXAppProtectRuntimeInfo{ - Release: n.release(ctx), - AttackSignatureVersion: n.attackSignatureVersion(ctx), - ThreatCampaignVersion: n.threatCampaignVersion(ctx), - }, - }, - InstanceChildren: make([]*mpi.InstanceChild, 0), - }, - } - } - } - - return instanceMap -} - -func (n NginxAppProtectProcessParser) instanceID(process *nginxprocess.Process) string { - return id.Generate("%s", process.Exe) -} - -func (n NginxAppProtectProcessParser) instanceVersion(ctx context.Context) string { - version, err := os.ReadFile(n.versionFilePath) - if err != nil { - slog.WarnContext(ctx, "Unable to read NAP version file", "file_path", n.versionFilePath, "error", err) - return "" - } - - return strings.TrimSuffix(string(version), "\n") -} - -func (n NginxAppProtectProcessParser) release(ctx context.Context) string { - release, err := os.ReadFile(n.releaseFilePath) - if err != nil { - slog.WarnContext(ctx, "Unable to read NAP release file", "file_path", n.releaseFilePath, "error", err) - return "" - } - - return strings.TrimSuffix(string(release), "\n") -} - -func (n NginxAppProtectProcessParser) attackSignatureVersion(ctx context.Context) string { - attackSignatureVersion, err := os.ReadFile(n.attackSignatureVersionFilePath) - if err != nil { - slog.WarnContext( - ctx, - "Unable to read NAP attack signature version file", - "file_path", n.attackSignatureVersionFilePath, - "error", err, - ) - - return "" - } - - return string(attackSignatureVersion) -} - -func (n NginxAppProtectProcessParser) threatCampaignVersion(ctx context.Context) string { - threatCampaignVersion, err := os.ReadFile(n.threatCampaignVersionFilePath) - if err != nil { - slog.WarnContext( - ctx, - "Unable to read NAP threat campaign version file", - "file_path", n.threatCampaignVersionFilePath, - "error", err, - ) - - return "" - } - - return string(threatCampaignVersion) -} diff --git a/internal/watcher/instance/nginx_app_protect_process_parser_test.go b/internal/watcher/instance/nginx_app_protect_process_parser_test.go deleted file mode 100644 index 56ba2d1f0..000000000 --- a/internal/watcher/instance/nginx_app_protect_process_parser_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 instance - -import ( - "context" - "os" - "testing" - - "github.com/nginx/agent/v3/pkg/nginxprocess" - - mpi "github.com/nginx/agent/v3/api/grpc/mpi/v1" - "github.com/nginx/agent/v3/test/helpers" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "google.golang.org/protobuf/proto" -) - -func TestNginxAppProtectProcessParser_Parse(t *testing.T) { - ctx := context.Background() - - expectedInstance := &mpi.Instance{ - InstanceMeta: &mpi.InstanceMeta{ - InstanceId: "ca22d03b-06a4-3a2c-aa81-a6c4dd042ff4", - InstanceType: mpi.InstanceMeta_INSTANCE_TYPE_NGINX_APP_PROTECT, - Version: "5.144.0", - }, - InstanceConfig: &mpi.InstanceConfig{}, - InstanceRuntime: &mpi.InstanceRuntime{ - ProcessId: 1111, - BinaryPath: "/usr/share/ts/bin/bd-socket-plugin", - Details: &mpi.InstanceRuntime_NginxAppProtectRuntimeInfo{ - NginxAppProtectRuntimeInfo: &mpi.NGINXAppProtectRuntimeInfo{ - Release: "4.11.0", - AttackSignatureVersion: "2024.11.28", - ThreatCampaignVersion: "2024.12.02", - }, - }, - InstanceChildren: make([]*mpi.InstanceChild, 0), - }, - } - - processes := []*nginxprocess.Process{ - { - PID: 789, - PPID: 1234, - Name: "nginx", - Cmd: "nginx: worker process", - Exe: exePath, - }, - { - PID: 567, - PPID: 1234, - Name: "nginx", - Cmd: "nginx: worker process", - Exe: exePath, - }, - { - PID: 1234, - PPID: 1, - Name: "nginx", - Cmd: "nginx: master process /usr/local/opt/nginx/bin/nginx -g daemon off;", - Exe: exePath, - }, - { - PID: 1111, - PPID: 1, - Name: "bd-socket-plugin", - Cmd: "/usr/share/ts/bin/bd-socket-plugin tmm_count 4 no_static_config", - Exe: "/usr/share/ts/bin/bd-socket-plugin", - }, - } - - versionFile := helpers.CreateFileWithErrorCheck(t, os.TempDir(), "version") - defer helpers.RemoveFileWithErrorCheck(t, versionFile.Name()) - - _, err := versionFile.WriteString("5.144.0") - require.NoError(t, err) - - releaseFile := helpers.CreateFileWithErrorCheck(t, os.TempDir(), "release") - defer helpers.RemoveFileWithErrorCheck(t, releaseFile.Name()) - - _, err = releaseFile.WriteString("4.11.0") - require.NoError(t, err) - - attackSignatureVersionFile := helpers.CreateFileWithErrorCheck(t, os.TempDir(), "version") - defer helpers.RemoveFileWithErrorCheck(t, attackSignatureVersionFile.Name()) - - _, err = attackSignatureVersionFile.WriteString("2024.11.28") - require.NoError(t, err) - - threatCampaignVersionFile := helpers.CreateFileWithErrorCheck(t, os.TempDir(), "version") - defer helpers.RemoveFileWithErrorCheck(t, threatCampaignVersionFile.Name()) - - _, err = threatCampaignVersionFile.WriteString("2024.12.02") - require.NoError(t, err) - - nginxAppProtectProcessParser := NewNginxAppProtectProcessParser() - nginxAppProtectProcessParser.versionFilePath = versionFile.Name() - nginxAppProtectProcessParser.releaseFilePath = releaseFile.Name() - nginxAppProtectProcessParser.attackSignatureVersionFilePath = attackSignatureVersionFile.Name() - nginxAppProtectProcessParser.threatCampaignVersionFilePath = threatCampaignVersionFile.Name() - - instances := nginxAppProtectProcessParser.Parse(ctx, processes) - - assert.Len(t, instances, 1) - - assert.Truef( - t, - proto.Equal(instances["ca22d03b-06a4-3a2c-aa81-a6c4dd042ff4"], expectedInstance), - "expected %s, actual %s", expectedInstance, instances["ca22d03b-06a4-3a2c-aa81-a6c4dd042ff4"], - ) -} diff --git a/internal/watcher/process/process_operator.go b/internal/watcher/process/process_operator.go index 5ff0a8f09..c4dcff8e8 100644 --- a/internal/watcher/process/process_operator.go +++ b/internal/watcher/process/process_operator.go @@ -22,33 +22,12 @@ type ( ProcessOperatorInterface interface { Processes(ctx context.Context) ( nginxProcesses []*nginxprocess.Process, - nginxAppProtectProcesses []*nginxprocess.Process, err error, ) Process(ctx context.Context, pid int32) (*nginxprocess.Process, error) } ) -func nginxFilter(ctx context.Context, p *process.Process) bool { - name, _ := p.NameWithContext(ctx) // slow: shells out to ps - if name != "nginx" { - return false - } - - cmdLine, _ := p.CmdlineWithContext(ctx) // slow: shells out to ps - // ignore nginx processes in the middle of an upgrade - if !strings.HasPrefix(cmdLine, "nginx:") || strings.Contains(cmdLine, "upgrade") { - return false - } - - return true -} - -func napFilter(ctx context.Context, p *process.Process) bool { - name, _ := p.NameWithContext(ctx) // slow: shells out to ps - return name == "bd-socket-plugin" -} - var _ ProcessOperatorInterface = (*ProcessOperator)(nil) func NewProcessOperator() *ProcessOperator { @@ -57,30 +36,14 @@ func NewProcessOperator() *ProcessOperator { func (pw *ProcessOperator) Processes(ctx context.Context) ( nginxProcesses []*nginxprocess.Process, - nginxAppProtectProcesses []*nginxprocess.Process, err error, ) { processes, err := process.ProcessesWithContext(ctx) if err != nil { - return nil, nil, err - } - - var filteredNginxProcesses []*process.Process - - for _, p := range processes { - if nginxFilter(ctx, p) { - filteredNginxProcesses = append(filteredNginxProcesses, p) - } else if napFilter(ctx, p) { - nginxAppProtectProcesses = append(nginxAppProtectProcesses, convertProcess(ctx, p)) - } - } - - nginxProcesses, err = nginxprocess.ListWithProcesses(ctx, filteredNginxProcesses) - if err != nil { - return nil, nil, err + return nil, err } - return nginxProcesses, nginxAppProtectProcesses, nil + return nginxprocess.ListWithProcesses(ctx, processes) } func (pw *ProcessOperator) Process(ctx context.Context, pid int32) (*nginxprocess.Process, error) { diff --git a/internal/watcher/process/processfakes/fake_process_operator_interface.go b/internal/watcher/process/processfakes/fake_process_operator_interface.go index 742c91e86..82fd7c96a 100644 --- a/internal/watcher/process/processfakes/fake_process_operator_interface.go +++ b/internal/watcher/process/processfakes/fake_process_operator_interface.go @@ -24,20 +24,18 @@ type FakeProcessOperatorInterface struct { result1 *nginxprocess.Process result2 error } - ProcessesStub func(context.Context) ([]*nginxprocess.Process, []*nginxprocess.Process, error) + ProcessesStub func(context.Context) ([]*nginxprocess.Process, error) processesMutex sync.RWMutex processesArgsForCall []struct { arg1 context.Context } processesReturns struct { result1 []*nginxprocess.Process - result2 []*nginxprocess.Process - result3 error + result2 error } processesReturnsOnCall map[int]struct { result1 []*nginxprocess.Process - result2 []*nginxprocess.Process - result3 error + result2 error } invocations map[string][][]interface{} invocationsMutex sync.RWMutex @@ -108,7 +106,7 @@ func (fake *FakeProcessOperatorInterface) ProcessReturnsOnCall(i int, result1 *n }{result1, result2} } -func (fake *FakeProcessOperatorInterface) Processes(arg1 context.Context) ([]*nginxprocess.Process, []*nginxprocess.Process, error) { +func (fake *FakeProcessOperatorInterface) Processes(arg1 context.Context) ([]*nginxprocess.Process, error) { fake.processesMutex.Lock() ret, specificReturn := fake.processesReturnsOnCall[len(fake.processesArgsForCall)] fake.processesArgsForCall = append(fake.processesArgsForCall, struct { @@ -122,9 +120,9 @@ func (fake *FakeProcessOperatorInterface) Processes(arg1 context.Context) ([]*ng return stub(arg1) } if specificReturn { - return ret.result1, ret.result2, ret.result3 + return ret.result1, ret.result2 } - return fakeReturns.result1, fakeReturns.result2, fakeReturns.result3 + return fakeReturns.result1, fakeReturns.result2 } func (fake *FakeProcessOperatorInterface) ProcessesCallCount() int { @@ -133,7 +131,7 @@ func (fake *FakeProcessOperatorInterface) ProcessesCallCount() int { return len(fake.processesArgsForCall) } -func (fake *FakeProcessOperatorInterface) ProcessesCalls(stub func(context.Context) ([]*nginxprocess.Process, []*nginxprocess.Process, error)) { +func (fake *FakeProcessOperatorInterface) ProcessesCalls(stub func(context.Context) ([]*nginxprocess.Process, error)) { fake.processesMutex.Lock() defer fake.processesMutex.Unlock() fake.ProcessesStub = stub @@ -146,33 +144,30 @@ func (fake *FakeProcessOperatorInterface) ProcessesArgsForCall(i int) context.Co return argsForCall.arg1 } -func (fake *FakeProcessOperatorInterface) ProcessesReturns(result1 []*nginxprocess.Process, result2 []*nginxprocess.Process, result3 error) { +func (fake *FakeProcessOperatorInterface) ProcessesReturns(result1 []*nginxprocess.Process, result2 error) { fake.processesMutex.Lock() defer fake.processesMutex.Unlock() fake.ProcessesStub = nil fake.processesReturns = struct { result1 []*nginxprocess.Process - result2 []*nginxprocess.Process - result3 error - }{result1, result2, result3} + result2 error + }{result1, result2} } -func (fake *FakeProcessOperatorInterface) ProcessesReturnsOnCall(i int, result1 []*nginxprocess.Process, result2 []*nginxprocess.Process, result3 error) { +func (fake *FakeProcessOperatorInterface) ProcessesReturnsOnCall(i int, result1 []*nginxprocess.Process, result2 error) { fake.processesMutex.Lock() defer fake.processesMutex.Unlock() fake.ProcessesStub = nil if fake.processesReturnsOnCall == nil { fake.processesReturnsOnCall = make(map[int]struct { result1 []*nginxprocess.Process - result2 []*nginxprocess.Process - result3 error + result2 error }) } fake.processesReturnsOnCall[i] = struct { result1 []*nginxprocess.Process - result2 []*nginxprocess.Process - result3 error - }{result1, result2, result3} + result2 error + }{result1, result2} } func (fake *FakeProcessOperatorInterface) Invocations() map[string][][]interface{} { diff --git a/internal/watcher/watcher_plugin.go b/internal/watcher/watcher_plugin.go index a516c0b69..c875e2238 100644 --- a/internal/watcher/watcher_plugin.go +++ b/internal/watcher/watcher_plugin.go @@ -38,6 +38,7 @@ type ( messagePipe bus.MessagePipeInterface agentConfig *config.Config instanceWatcherService instanceWatcherServiceInterface + nginxAppProtectInstanceWatcher *instance.NginxAppProtectInstanceWatcher healthWatcherService *health.HealthWatcherService fileWatcherService *file.FileWatcherService credentialWatcherService credentialWatcherServiceInterface @@ -76,6 +77,7 @@ func NewWatcher(agentConfig *config.Config) *Watcher { return &Watcher{ agentConfig: agentConfig, instanceWatcherService: instance.NewInstanceWatcherService(agentConfig), + nginxAppProtectInstanceWatcher: instance.NewNginxAppProtectInstanceWatcher(agentConfig), healthWatcherService: health.NewHealthWatcherService(agentConfig), fileWatcherService: file.NewFileWatcherService(agentConfig), credentialWatcherService: credentials.NewCredentialWatcherService(agentConfig), @@ -98,6 +100,7 @@ func (w *Watcher) Init(ctx context.Context, messagePipe bus.MessagePipeInterface watcherContext, cancel := context.WithCancel(ctx) w.cancel = cancel + go w.nginxAppProtectInstanceWatcher.Watch(watcherContext, w.instanceUpdatesChannel) go w.instanceWatcherService.Watch(watcherContext, w.instanceUpdatesChannel, w.nginxConfigContextChannel) go w.healthWatcherService.Watch(watcherContext, w.instanceHealthChannel) go w.credentialWatcherService.Watch(watcherContext, w.credentialUpdatesChannel) diff --git a/test/protos/instances.go b/test/protos/instances.go index 0a5e9bdab..7f4fc874c 100644 --- a/test/protos/instances.go +++ b/test/protos/instances.go @@ -15,6 +15,7 @@ import ( const ( ossInstanceID = "e1374cb1-462d-3b6c-9f3b-f28332b5f10c" + napInstanceID = "j4234cb1-462d-3b6c-9f3b-f28332b5f13g" plusInstanceID = "40f9dda0-e45f-34cf-bba7-f173700f50a2" secondOssInstanceID = "557cdf06-08fd-31eb-a8e7-daafd3a93db7" unsuportedInstanceID = "fcd99f8f-00fb-3097-8d75-32ae269b46c3" @@ -119,6 +120,30 @@ func NginxPlusInstance(expectedModules []string) *mpi.Instance { } } +func NginxAppProtectInstance() *mpi.Instance { + return &mpi.Instance{ + InstanceMeta: &mpi.InstanceMeta{ + InstanceId: napInstanceID, + InstanceType: mpi.InstanceMeta_INSTANCE_TYPE_NGINX_APP_PROTECT, + Version: "5.144.0", + }, + InstanceConfig: &mpi.InstanceConfig{}, + InstanceRuntime: &mpi.InstanceRuntime{ + ProcessId: 0, + BinaryPath: "", + Details: &mpi.InstanceRuntime_NginxAppProtectRuntimeInfo{ + NginxAppProtectRuntimeInfo: &mpi.NGINXAppProtectRuntimeInfo{ + Release: "4.11.0", + AttackSignatureVersion: "2024.11.28", + ThreatCampaignVersion: "2024.12.02", + EnforcerEngineVersion: "5.113.0", + }, + }, + InstanceChildren: make([]*mpi.InstanceChild, 0), + }, + } +} + func UnsupportedInstance() *mpi.Instance { return &mpi.Instance{ InstanceMeta: &mpi.InstanceMeta{ From ce964d4074cf85493d15ab220a51c3953cf665e0 Mon Sep 17 00:00:00 2001 From: Chris Adams Date: Fri, 27 Jun 2025 11:15:50 +0100 Subject: [PATCH 24/28] Update cert utils (#1143) --- test/helpers/cert_utils.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/helpers/cert_utils.go b/test/helpers/cert_utils.go index c292e97a3..f4516349c 100644 --- a/test/helpers/cert_utils.go +++ b/test/helpers/cert_utils.go @@ -30,7 +30,7 @@ const ( permission = 0o600 serialNumber = 123123 years, months, days = 5, 0, 0 - bits = 1024 + bits = 2048 ) func GenerateSelfSignedCert(t testing.TB) (keyBytes, certBytes []byte) { From d4138d48b9fb5c665ade28975b1f675714647c88 Mon Sep 17 00:00:00 2001 From: Kamal Chaturvedi Date: Fri, 27 Jun 2025 09:30:52 -0700 Subject: [PATCH 25/28] Default NAP security-violation logs to be gzipped individually via custom processor (#1125) --- internal/collector/factories.go | 2 + internal/collector/factories_test.go | 2 +- .../collector/logsgzipprocessor/README.md | 81 +++++++ .../collector/logsgzipprocessor/processor.go | 182 +++++++++++++++ .../processor_benchmark_test.go | 89 ++++++++ .../logsgzipprocessor/processor_test.go | 209 ++++++++++++++++++ .../collector/otel_collector_plugin_test.go | 4 +- internal/collector/otelcol.tmpl | 6 + internal/config/config.go | 1 + internal/config/config_test.go | 3 + internal/config/flags.go | 1 + internal/config/testdata/nginx-agent.conf | 1 + internal/config/types.go | 3 + 13 files changed, 582 insertions(+), 2 deletions(-) create mode 100644 internal/collector/logsgzipprocessor/README.md create mode 100644 internal/collector/logsgzipprocessor/processor.go create mode 100644 internal/collector/logsgzipprocessor/processor_benchmark_test.go create mode 100644 internal/collector/logsgzipprocessor/processor_test.go diff --git a/internal/collector/factories.go b/internal/collector/factories.go index 7c00fe40f..6443dba7c 100644 --- a/internal/collector/factories.go +++ b/internal/collector/factories.go @@ -7,6 +7,7 @@ package collector import ( "github.com/nginx/agent/v3/internal/collector/containermetricsreceiver" + "github.com/nginx/agent/v3/internal/collector/logsgzipprocessor" nginxreceiver "github.com/nginx/agent/v3/internal/collector/nginxossreceiver" "github.com/nginx/agent/v3/internal/collector/nginxplusreceiver" @@ -104,6 +105,7 @@ func createProcessorFactories() map[component.Type]processor.Factory { redactionprocessor.NewFactory(), resourceprocessor.NewFactory(), transformprocessor.NewFactory(), + logsgzipprocessor.NewFactory(), } processors := make(map[component.Type]processor.Factory) diff --git a/internal/collector/factories_test.go b/internal/collector/factories_test.go index 9d2dec397..34dcdcc1f 100644 --- a/internal/collector/factories_test.go +++ b/internal/collector/factories_test.go @@ -19,7 +19,7 @@ func TestOTelComponentFactoriesDefault(t *testing.T) { assert.NotNil(t, factories, "factories should not be nil") assert.Len(t, factories.Receivers, 6) - assert.Len(t, factories.Processors, 8) + assert.Len(t, factories.Processors, 9) assert.Len(t, factories.Exporters, 4) assert.Len(t, factories.Extensions, 3) assert.Empty(t, factories.Connectors) diff --git a/internal/collector/logsgzipprocessor/README.md b/internal/collector/logsgzipprocessor/README.md new file mode 100644 index 000000000..290eff318 --- /dev/null +++ b/internal/collector/logsgzipprocessor/README.md @@ -0,0 +1,81 @@ +# Logs gzip processor + +The Logs gzip processor gzips the input log record body, updating the log record in-place. + +For metrics and traces, this will just be a pass-through as it does not implement related interfaces. + +## Configuration + +No configuration needed. + +## Benchmarking + +We performed benchmark measuring the performance of serial and concurrent operations (more practical) of this processor, with and without the `sync.Pool`. Here are the results: + +``` +Concurrent Run: Without Sync Pool +goos: darwin +goarch: arm64 +pkg: github.com/nginx/agent/v3/internal/collector/logsgzipprocessor +cpu: Apple M2 Pro +BenchmarkGzipProcessor_Concurrent-12 24 45279866 ns/op 817791582 B/op 24727 allocs/op +PASS +ok github.com/nginx/agent/v3/internal/collector/logsgzipprocessor 1.939s + +Concurrent Run: With Sync Pool + +goos: darwin +goarch: arm64 +pkg: github.com/nginx/agent/v3/internal/collector/logsgzipprocessor +cpu: Apple M2 Pro +BenchmarkGzipProcessor_Concurrent-12 147 9383213 ns/op 10948640 B/op 7820 allocs/op +PASS +ok github.com/nginx/agent/v3/internal/collector/logsgzipprocessor 2.026s + +———— + +Serial Run: Without Sync Pool + +goos: darwin +goarch: arm64 +pkg: github.com/nginx/agent/v3/internal/collector/logsgzipprocessor +cpu: Apple M2 Pro +BenchmarkGzipProcessor/SmallRecords-12 100 12048268 ns/op 81898890 B/op 2537 allocs/op +BenchmarkGzipProcessor/MediumRecords-12 100 13143269 ns/op 82027307 B/op 2541 allocs/op +BenchmarkGzipProcessor/LargeRecords-12 91 15912399 ns/op 83198992 B/op 2580 allocs/op +BenchmarkGzipProcessor/ManySmallRecords-12 2 807707542 ns/op 8143237656 B/op 243348 allocs/op + + +Serial Run: With Sync Pool + +goos: darwin +goarch: arm64 +pkg: github.com/nginx/agent/v3/internal/collector/logsgzipprocessor +cpu: Apple M2 Pro +BenchmarkGzipProcessor/SmallRecords-12 205 7304839 ns/op 1027942 B/op 783 allocs/op +BenchmarkGzipProcessor/MediumRecords-12 182 7336266 ns/op 1078050 B/op 784 allocs/op +BenchmarkGzipProcessor/LargeRecords-12 132 9646940 ns/op 2057059 B/op 815 allocs/op +BenchmarkGzipProcessor/ManySmallRecords-12 5 239726258 ns/op 6883977 B/op 73679 allocs/op +PASS +``` + + +To run this benchmark yourself with syncpool implementation, you can run the tests in `processor_benchmark_test.go` in with the `sync.Pool` mode. + +To compare benchmark without syncpool, you can use this code block in `processor.go` and comment the existing `gzipCompress` function, and run `processor_benchmark_test.go` : + +``` +func (p *logsGzipProcessor) gzipCompress(data []byte) ([]byte, error) { + var buf bytes.Buffer + w := gzip.NewWriter(&buf) + _, err := w.Write(data) + if err != nil { + return nil, err + } + if err = w.Close(); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} +``` diff --git a/internal/collector/logsgzipprocessor/processor.go b/internal/collector/logsgzipprocessor/processor.go new file mode 100644 index 000000000..ce3232ab1 --- /dev/null +++ b/internal/collector/logsgzipprocessor/processor.go @@ -0,0 +1,182 @@ +// 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 logsgzipprocessor + +import ( + "bytes" + "compress/gzip" + "context" + "fmt" + "io" + "sync" + + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/consumer" + "go.opentelemetry.io/collector/pdata/pcommon" + "go.opentelemetry.io/collector/pdata/plog" + "go.opentelemetry.io/collector/processor" + "go.uber.org/multierr" + "go.uber.org/zap" +) + +// nolint: ireturn +func NewFactory() processor.Factory { + return processor.NewFactory( + component.MustNewType("logsgzip"), + func() component.Config { + return &struct{}{} + }, + processor.WithLogs(createLogsGzipProcessor, component.StabilityLevelBeta), + ) +} + +// nolint: ireturn +func createLogsGzipProcessor(_ context.Context, + settings processor.Settings, + cfg component.Config, + logs consumer.Logs, +) (processor.Logs, error) { + logger := settings.Logger + logger.Info("Creating logs gzip processor") + + return newLogsGzipProcessor(logs, settings), nil +} + +// logsGzipProcessor is a custom-processor implementation for compressing individual log records into +// gzip format. This can be used to reduce the size of log records and improve performance when processing +// large log volumes. This processor will be used by default for agent interacting with NGINX One +// console (https://docs.nginx.com/nginx-one/about/). +type logsGzipProcessor struct { + nextConsumer consumer.Logs + // We use sync.Pool to efficiently manage and reuse gzip.Writer instances within this processor. + // Otherwise, creating a new compressor for every log record would result in frequent memory allocations + // and increased garbage collection overhead, especially under high-throughput workload like this one. + // By pooling these objects, we minimize allocation churn, reduce GC pressure, and improve overall performance. + pool *sync.Pool + settings processor.Settings +} + +type GzipWriter interface { + Write(p []byte) (int, error) + Close() error + Reset(w io.Writer) +} + +func newLogsGzipProcessor(logs consumer.Logs, settings processor.Settings) *logsGzipProcessor { + return &logsGzipProcessor{ + nextConsumer: logs, + pool: &sync.Pool{ + New: func() any { + return gzip.NewWriter(nil) + }, + }, + settings: settings, + } +} + +func (p *logsGzipProcessor) ConsumeLogs(ctx context.Context, ld plog.Logs) error { + var errs error + resourceLogs := ld.ResourceLogs() + for i := range resourceLogs.Len() { + scopeLogs := resourceLogs.At(i).ScopeLogs() + for j := range scopeLogs.Len() { + err := p.processLogRecords(scopeLogs.At(j).LogRecords()) + if err != nil { + errs = multierr.Append(errs, err) + } + } + } + if errs != nil { + return fmt.Errorf("failed processing log records: %w", errs) + } + + return p.nextConsumer.ConsumeLogs(ctx, ld) +} + +func (p *logsGzipProcessor) processLogRecords(logRecords plog.LogRecordSlice) error { + var errs error + // Filter out unsupported data types in the log before processing + logRecords.RemoveIf(func(lr plog.LogRecord) bool { + body := lr.Body() + // Keep only STRING or BYTES types + if body.Type() != pcommon.ValueTypeStr && + body.Type() != pcommon.ValueTypeBytes { + p.settings.Logger.Debug("Skipping log record with unsupported body type", zap.Any("type", body.Type())) + return true + } + + return false + }) + // Process remaining valid records + for k := range logRecords.Len() { + record := logRecords.At(k) + body := record.Body() + var data []byte + //nolint:exhaustive // Already filtered out other types with RemoveIf + switch body.Type() { + case pcommon.ValueTypeStr: + data = []byte(body.Str()) + case pcommon.ValueTypeBytes: + data = body.Bytes().AsRaw() + } + gzipped, err := p.gzipCompress(data) + if err != nil { + errs = multierr.Append(errs, fmt.Errorf("failed to compress log record: %w", err)) + + continue + } + err = record.Body().FromRaw(gzipped) + if err != nil { + errs = multierr.Append(errs, fmt.Errorf("failed to set gzipped data to log record body: %w", err)) + + continue + } + } + + return errs +} + +func (p *logsGzipProcessor) gzipCompress(data []byte) ([]byte, error) { + var buf bytes.Buffer + var err error + wIface := p.pool.Get() + w, ok := wIface.(GzipWriter) + if !ok { + return nil, fmt.Errorf("writer of type %T not supported", wIface) + } + w.Reset(&buf) + defer func() { + if err = w.Close(); err != nil { + p.settings.Logger.Error("Failed to close gzip writer", zap.Error(err)) + } + p.pool.Put(w) + }() + + _, err = w.Write(data) + if err != nil { + return nil, err + } + if err = w.Close(); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func (p *logsGzipProcessor) Capabilities() consumer.Capabilities { + return consumer.Capabilities{ + MutatesData: true, + } +} + +func (p *logsGzipProcessor) Start(ctx context.Context, _ component.Host) error { + p.settings.Logger.Info("Starting logs gzip processor") + return nil +} + +func (p *logsGzipProcessor) Shutdown(ctx context.Context) error { + p.settings.Logger.Info("Shutting down logs gzip processor") + return nil +} diff --git a/internal/collector/logsgzipprocessor/processor_benchmark_test.go b/internal/collector/logsgzipprocessor/processor_benchmark_test.go new file mode 100644 index 000000000..4bb9ccab5 --- /dev/null +++ b/internal/collector/logsgzipprocessor/processor_benchmark_test.go @@ -0,0 +1,89 @@ +// 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 logsgzipprocessor + +import ( + "context" + "crypto/rand" + "math/big" + "testing" + + "go.opentelemetry.io/collector/consumer/consumertest" + "go.opentelemetry.io/collector/pdata/plog" + "go.opentelemetry.io/collector/processor" +) + +// Helper to generate logs with variable size and content +func generateLogs(numRecords, recordSize int) plog.Logs { + logs := plog.NewLogs() + rl := logs.ResourceLogs().AppendEmpty() + sl := rl.ScopeLogs().AppendEmpty() + for i := 0; i < numRecords; i++ { + lr := sl.LogRecords().AppendEmpty() + content, _ := randomString(recordSize) + lr.Body().SetStr(content) + } + + return logs +} + +func randomString(n int) (string, error) { + const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, n) + lettersSize := big.NewInt(int64(len(letters))) + for i := range b { + num, err := rand.Int(rand.Reader, lettersSize) + if err != nil { + return "", err + } + b[i] = letters[num.Int64()] + } + + return string(b), nil +} + +func BenchmarkGzipProcessor(b *testing.B) { + benchmarks := []struct { + name string + numRecords int + recordSize int + }{ + {"SmallRecords", 100, 50}, + {"MediumRecords", 100, 500}, + {"LargeRecords", 100, 5000}, + {"ManySmallRecords", 10000, 50}, + } + + for _, bm := range benchmarks { + b.Run(bm.name, func(b *testing.B) { + b.ReportAllocs() + consumer := &consumertest.LogsSink{} + p := newLogsGzipProcessor(consumer, processor.Settings{}) + logs := generateLogs(bm.numRecords, bm.recordSize) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = p.ConsumeLogs(context.Background(), logs) + } + }) + } +} + +// Optional: Benchmark with concurrency to simulate real pipeline load +func BenchmarkGzipProcessor_Concurrent(b *testing.B) { + // nolint:unused // concurrent runs require total parallel workers to be specified + const workers = 8 + logs := generateLogs(1000, 1000) + consumer := &consumertest.LogsSink{} + p := newLogsGzipProcessor(consumer, processor.Settings{}) + + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + _ = p.ConsumeLogs(context.Background(), logs) + } + }) +} diff --git a/internal/collector/logsgzipprocessor/processor_test.go b/internal/collector/logsgzipprocessor/processor_test.go new file mode 100644 index 000000000..a1c7423b3 --- /dev/null +++ b/internal/collector/logsgzipprocessor/processor_test.go @@ -0,0 +1,209 @@ +// 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 logsgzipprocessor + +import ( + "bytes" + "compress/gzip" + "context" + "errors" + "io" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/collector/consumer/consumertest" + "go.opentelemetry.io/collector/pdata/plog" + "go.opentelemetry.io/collector/processor/processortest" + "go.uber.org/zap" +) + +var dummyInputStr = "hello world" + +func TestGzipProcessor(t *testing.T) { + testCases := []struct { + input any + name string + }{ + { + name: "Test 1: string content", + input: dummyInputStr, + }, + { + name: "Test 2: byte content", + input: []byte("binary data"), + }, + { + name: "Test 3: integer content", + input: 12345, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + settings := processortest.NewNopSettings(processortest.NopType) + settings.Logger = zap.NewNop() + // Setup: create a log record with the test case content + logs := plog.NewLogs() + logRecord := logs.ResourceLogs().AppendEmpty(). + ScopeLogs().AppendEmpty(). + LogRecords().AppendEmpty() + var expectNoOutput bool + switch v := tc.input.(type) { + case string: + logRecord.Body().SetStr(v) + case []byte: + logRecord.Body().SetEmptyBytes().FromRaw(v) + case int: + logRecord.Body().SetInt(int64(v)) + expectNoOutput = true + } + + next := &consumertest.LogsSink{} + processor := newLogsGzipProcessor(next, settings) + require.NoError(t, processor.Start(ctx, nil)) + + capability := processor.Capabilities() + assert.True(t, capability.MutatesData, "logs mutation should be a capability") + + // process logs + err := processor.ConsumeLogs(ctx, logs) + require.NoError(t, err, "processor failed") + + // output should be gzipped + if expectNoOutput { + assert.Equal(t, 0, next.LogRecordCount(), "no logs should be produced") + return + } + got := next.AllLogs()[0] + record := got.ResourceLogs().At(0).ScopeLogs().At(0).LogRecords().At(0) + gzipped := record.Body().Bytes().AsRaw() + + // Decompress and check content + verifyGzippedContent(t, gzipped, tc.input) + require.NoError(t, processor.Shutdown(ctx)) + }) + } +} + +type mockGzipWriter struct { + WriteFunc func(p []byte) (int, error) + CloseFunc func() error + ResetFunc func(w io.Writer) +} + +func (m *mockGzipWriter) Write(p []byte) (int, error) { + if m.WriteFunc != nil { + return m.WriteFunc(p) + } + + return len(p), nil +} + +func (m *mockGzipWriter) Close() error { + if m.CloseFunc != nil { + return m.CloseFunc() + } + + return nil +} + +func (m *mockGzipWriter) Reset(w io.Writer) { + if m.ResetFunc != nil { + m.ResetFunc(w) + } +} + +func TestGzipProcessorFailure(t *testing.T) { + testCases := []struct { + name string + isGzipWriteError bool + isGzipCloseError bool + }{ + { + name: "Test 1: gzip write failure", + isGzipWriteError: true, + }, + { + name: "Test 2: gzip writer close failure", + isGzipCloseError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + settings := processortest.NewNopSettings(processortest.NopType) + settings.Logger = zap.NewNop() + // Setup: create a log record with the test case content + logs := plog.NewLogs() + logRecord := logs.ResourceLogs().AppendEmpty(). + ScopeLogs().AppendEmpty(). + LogRecords().AppendEmpty() + logRecord.Body().SetStr(dummyInputStr) + + next := &consumertest.LogsSink{} + + mockWriter := customMockWriter(tc.isGzipWriteError, tc.isGzipCloseError) + // explicitly set writer that fails + processor := &logsGzipProcessor{ + nextConsumer: next, + pool: &sync.Pool{ + New: func() any { + return mockWriter + }, + }, + settings: settings, + } + require.NoError(t, processor.Start(ctx, nil)) + + err := processor.ConsumeLogs(ctx, logs) + require.Error(t, err, "processor should return error when gzip writer fails") + require.Contains(t, err.Error(), "failed processing log records", + "processor should return relevant error") + + require.NoError(t, processor.Shutdown(ctx)) + }) + } +} + +// nolint: revive +func customMockWriter(isGzipWriteErr, isGzipCloseErr bool) *mockGzipWriter { + return &mockGzipWriter{ + WriteFunc: func(p []byte) (int, error) { + if isGzipWriteErr { + return 0, errors.New("mock write error") + } + + return 0, nil + }, + CloseFunc: func() error { + if isGzipCloseErr { + return errors.New("mock close error") + } + + return nil + }, + } +} + +func verifyGzippedContent(t *testing.T, gzipped []byte, input any) { + t.Helper() + gr, err := gzip.NewReader(bytes.NewReader(gzipped)) + require.NoError(t, err, "failed to read gzipped content") + defer gr.Close() + plain, err := io.ReadAll(gr) + require.NoError(t, err, "failed to read decompress content") + + // check if plain text is as expected + switch v := input.(type) { + case string: + assert.Equal(t, v, string(plain)) + case []byte: + assert.Equal(t, v, plain) + } +} diff --git a/internal/collector/otel_collector_plugin_test.go b/internal/collector/otel_collector_plugin_test.go index df8bf033c..a767c62cc 100644 --- a/internal/collector/otel_collector_plugin_test.go +++ b/internal/collector/otel_collector_plugin_test.go @@ -405,6 +405,7 @@ func TestCollector_ProcessResourceUpdateTopicFails(t *testing.T) { conf.Collector.Processors.Batch = nil conf.Collector.Processors.Attribute = nil conf.Collector.Processors.Resource = nil + conf.Collector.Processors.LogsGzip = nil conf.Collector.Exporters.OtlpExporters = nil conf.Collector.Exporters.PrometheusExporter = &config.PrometheusExporter{ Server: &config.ServerConfig{ @@ -457,6 +458,7 @@ func TestCollector_ProcessResourceUpdateTopicFails(t *testing.T) { Batch: nil, Attribute: nil, Resource: nil, + LogsGzip: nil, }, collector.config.Collector.Processors) }) @@ -727,7 +729,7 @@ func TestCollector_updateTcplogReceivers(t *testing.T) { conf.Collector.Processors.Batch = nil conf.Collector.Processors.Attribute = nil conf.Collector.Processors.Resource = nil - + conf.Collector.Processors.LogsGzip = nil collector, err := New(conf) require.NoError(t, err) diff --git a/internal/collector/otelcol.tmpl b/internal/collector/otelcol.tmpl index bad9f68b6..6af00e9d2 100644 --- a/internal/collector/otelcol.tmpl +++ b/internal/collector/otelcol.tmpl @@ -143,6 +143,9 @@ processors: timeout: {{ .Processors.Batch.Timeout }} send_batch_max_size: {{ .Processors.Batch.SendBatchMaxSize }} {{- end }} +{{- if ne .Processors.LogsGzip nil }} + logsgzip: {} +{{- end }} exporters: {{- range $index, $otlpExporter := .Exporters.OtlpExporters }} @@ -294,6 +297,9 @@ service: - resource {{- end }} {{- end }} + {{- if ne .Processors.LogsGzip nil }} + - logsgzip + {{- end }} {{- if ne .Processors.Batch nil }} - batch {{- end }} diff --git a/internal/config/config.go b/internal/config/config.go index 85cdc6a71..f04df5196 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -828,6 +828,7 @@ func resolveProcessors() Processors { SendBatchMaxSize: viperInstance.GetUint32(CollectorBatchProcessorSendBatchMaxSizeKey), Timeout: viperInstance.GetDuration(CollectorBatchProcessorTimeoutKey), }, + LogsGzip: &LogsGzip{}, } if viperInstance.IsSet(CollectorAttributeProcessorKey) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6ec1f7775..985e420cf 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -191,6 +191,7 @@ func TestResolveCollector(t *testing.T) { viperInstance.Set(CollectorBatchProcessorSendBatchSizeKey, expected.Processors.Batch.SendBatchSize) viperInstance.Set(CollectorBatchProcessorSendBatchMaxSizeKey, expected.Processors.Batch.SendBatchMaxSize) viperInstance.Set(CollectorBatchProcessorTimeoutKey, expected.Processors.Batch.Timeout) + viperInstance.Set(CollectorLogsGzipProcessorKey, expected.Processors.LogsGzip) viperInstance.Set(CollectorExportersKey, expected.Exporters) viperInstance.Set(CollectorOtlpExportersKey, expected.Exporters.OtlpExporters) viperInstance.Set(CollectorExtensionsHealthServerHostKey, expected.Extensions.Health.Server.Host) @@ -818,6 +819,7 @@ func agentConfig() *Config { SendBatchSize: DefCollectorBatchProcessorSendBatchSize, Timeout: DefCollectorBatchProcessorTimeout, }, + LogsGzip: &LogsGzip{}, }, Receivers: Receivers{ OtlpReceivers: []OtlpReceiver{ @@ -981,6 +983,7 @@ func createConfig() *Config { }, }, }, + LogsGzip: &LogsGzip{}, }, Receivers: Receivers{ OtlpReceivers: []OtlpReceiver{ diff --git a/internal/config/flags.go b/internal/config/flags.go index 50fb461e5..652621025 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -65,6 +65,7 @@ var ( CollectorBatchProcessorSendBatchSizeKey = pre(CollectorBatchProcessorKey) + "send_batch_size" CollectorBatchProcessorSendBatchMaxSizeKey = pre(CollectorBatchProcessorKey) + "send_batch_max_size" CollectorBatchProcessorTimeoutKey = pre(CollectorBatchProcessorKey) + "timeout" + CollectorLogsGzipProcessorKey = pre(CollectorProcessorsKey) + "logsgzip" CollectorExtensionsKey = pre(CollectorRootKey) + "extensions" CollectorExtensionsHealthKey = pre(CollectorExtensionsKey) + "health" CollectorExtensionsHealthServerHostKey = pre(CollectorExtensionsHealthKey) + "server_host" diff --git a/internal/config/testdata/nginx-agent.conf b/internal/config/testdata/nginx-agent.conf index a228a4e0d..41a000c55 100644 --- a/internal/config/testdata/nginx-agent.conf +++ b/internal/config/testdata/nginx-agent.conf @@ -112,6 +112,7 @@ collector: - key: "test" action: "insert" value: "value" + logsgzip: {} exporters: otlp: - server: diff --git a/internal/config/types.go b/internal/config/types.go index 8017987f1..6d3b1ad8c 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -155,6 +155,7 @@ type ( Attribute *Attribute `yaml:"attribute" mapstructure:"attribute"` Resource *Resource `yaml:"resource" mapstructure:"resource"` Batch *Batch `yaml:"batch" mapstructure:"batch"` + LogsGzip *LogsGzip `yaml:"logsgzip" mapstructure:"logsgzip"` } Attribute struct { @@ -183,6 +184,8 @@ type ( Timeout time.Duration `yaml:"timeout" mapstructure:"timeout"` } + LogsGzip struct{} + // OTel Collector Receiver configuration. Receivers struct { ContainerMetrics *ContainerMetricsReceiver `yaml:"container_metrics" mapstructure:"container_metrics"` From 4e44ae5ef0966f60f3c978ca8497cc972050abf9 Mon Sep 17 00:00:00 2001 From: Donal Hurley Date: Wed, 2 Jul 2025 12:11:01 +0100 Subject: [PATCH 26/28] Fix config format outputted by preinstall script (#1149) --- go.mod | 4 ++-- scripts/packages/preinstall.sh | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 434b15a13..e84730f75 100644 --- a/go.mod +++ b/go.mod @@ -66,6 +66,7 @@ require ( go.opentelemetry.io/collector/processor v1.30.0 go.opentelemetry.io/collector/processor/batchprocessor v0.124.0 go.opentelemetry.io/collector/processor/memorylimiterprocessor v0.124.0 + go.opentelemetry.io/collector/processor/processortest v0.124.0 go.opentelemetry.io/collector/receiver v1.30.0 go.opentelemetry.io/collector/receiver/otlpreceiver v0.124.0 go.opentelemetry.io/collector/receiver/receivertest v0.124.0 @@ -74,6 +75,7 @@ require ( go.opentelemetry.io/collector/scraper/scrapertest v0.124.0 go.opentelemetry.io/otel v1.35.0 go.uber.org/goleak v1.3.0 + go.uber.org/multierr v1.11.0 go.uber.org/zap v1.27.0 golang.org/x/mod v0.23.0 golang.org/x/sync v0.13.0 @@ -260,7 +262,6 @@ require ( go.opentelemetry.io/collector/pipeline/xpipeline v0.124.0 // indirect go.opentelemetry.io/collector/processor/processorhelper v0.124.0 // indirect go.opentelemetry.io/collector/processor/processorhelper/xprocessorhelper v0.124.0 // indirect - go.opentelemetry.io/collector/processor/processortest v0.124.0 // indirect go.opentelemetry.io/collector/processor/xprocessor v0.124.0 // indirect go.opentelemetry.io/collector/receiver/receiverhelper v0.124.0 // indirect go.opentelemetry.io/collector/receiver/xreceiver v0.124.0 // indirect @@ -289,7 +290,6 @@ require ( go.opentelemetry.io/otel/sdk/log v0.11.0 // indirect go.opentelemetry.io/otel/trace v1.35.0 // indirect go.opentelemetry.io/proto/otlp v1.5.0 // indirect - go.uber.org/multierr v1.11.0 // indirect golang.org/x/arch v0.12.0 // indirect golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect golang.org/x/tools v0.30.0 // indirect diff --git a/scripts/packages/preinstall.sh b/scripts/packages/preinstall.sh index 6b4a0fcdd..7bcc02250 100644 --- a/scripts/packages/preinstall.sh +++ b/scripts/packages/preinstall.sh @@ -130,8 +130,13 @@ command: skip_verify: false " - echo "$v3_config_contents" > "$v3_config_file" \ - || err_exit "Failed to write v3 config" + if [ -n "$( echo -e )" ]; then + echo "$v3_config_contents" > "$v3_config_file" \ + || err_exit "Failed to write v3 config" + else + echo -e "$v3_config_contents" > "$v3_config_file" \ + || err_exit "Failed to write v3 config" + fi else echo "Existing NGINX Agent version is not v2, skipping config migration" fi From a9e5d34ef9f9ccb5a50978f4d9df4e9cfa8db89a Mon Sep 17 00:00:00 2001 From: Sean Breen <101327931+sean-breen@users.noreply.github.com> Date: Wed, 2 Jul 2025 13:39:40 +0100 Subject: [PATCH 27/28] [CI/CD] Fix debian package naming (#1153) --- .github/workflows/upload-release-assets.yml | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/.github/workflows/upload-release-assets.yml b/.github/workflows/upload-release-assets.yml index 1dcaa80c3..6d788129e 100644 --- a/.github/workflows/upload-release-assets.yml +++ b/.github/workflows/upload-release-assets.yml @@ -64,9 +64,23 @@ jobs: echo "${{secrets.PUBTEST_CERT}}" > pubtest.crt echo "${{secrets.PUBTEST_KEY}}" > pubtest.key PKG_REPO=${{inputs.pkgRepo}} CERT=pubtest.crt KEY=pubtest.key DL=1 scripts/packages/package-check.sh ${{inputs.pkgVersion}} + for i in $(find ${{inputs.pkgRepo}}/nginx-agent | grep -e "nginx-agent[_-]${{inputs.pkgVersion}}"); do + if [[ "$i" == *.deb ]]; then + echo "Renaming ${i} to ${i/_/-}" + mv "${i}" "${i/_/-}" + fi + if [[ "$i" == *.apk ]]; then + ver=$(echo "$i" | grep -o -e "v[0-9]*\.[0-9]*") + arch=$(echo "$i" | grep -o -F -e "x86_64" -e "aarch64") + dest="$(dirname "$i")/nginx-agent-${{inputs.pkgVersion}}-$ver-$arch.apk" + echo "Renaming ${i} to ${dest}" + mv "${i}" "${dest}" + fi + done find ${{inputs.pkgRepo}}/nginx-agent | grep -e "nginx-agent[_-]${{inputs.pkgVersion}}" - name: GitHub Upload + continue-on-error: true if: ${{ needs.vars.outputs.github_release == 'true' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -88,11 +102,6 @@ jobs: inlineScript: | for i in $(find ${{inputs.pkgRepo}}/nginx-agent | grep -e "nginx-agent[_-]${{inputs.pkgVersion}}"); do dest="nginx-agent/${GITHUB_REF##*/}/${i##*/}" - if [[ "$i" == *.apk ]]; then - ver=$(echo "$i" | grep -o -e "v[0-9]*\.[0-9]*") - arch=$(echo "$i" | grep -o -F -e "x86_64" -e "aarch64") - dest="nginx-agent/${GITHUB_REF##*/}/nginx-agent-$VER-$ver-$arch.apk" - fi echo "Uploading ${i} to ${dest}" az storage blob upload --auth-mode=login -f "$i" -c ${{ secrets.AZURE_CONTAINER_NAME }} \ --account-name ${{ secrets.AZURE_ACCOUNT_NAME }} --overwrite -n ${dest} From 2b6a9f2cba509406902a24ea74440e50570902dd Mon Sep 17 00:00:00 2001 From: aphralG <108004222+aphralG@users.noreply.github.com> Date: Thu, 3 Jul 2025 11:39:18 +0100 Subject: [PATCH 28/28] Add Alpine 3.22 Support (#1150) --- .github/workflows/ci.yml | 4 +++- Makefile | 8 ++++---- Makefile.packaging | 4 ++-- scripts/packages/package-check.sh | 5 +++-- test/docker/nginx-oss/deb/Dockerfile | 5 +++-- 5 files changed, 15 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c9afd990..8340a95cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,10 +93,12 @@ jobs: strategy: matrix: container: + - image: "ubuntu" + version: "24.04" - image: "redhatenterprise" version: "9" - image: "alpine" - version: "3.19" + version: "3.22" steps: - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 diff --git a/Makefile b/Makefile index 296163ac3..d27ae81da 100644 --- a/Makefile +++ b/Makefile @@ -22,12 +22,12 @@ GOBIN ?= $$(go env GOPATH)/bin # | redhatenterprise | 8, 9 | | # | rockylinux | 8, 9 | | # | almalinux | 8, 9 | | -# | alpine | 3.17, 3.18, 3.19, 3.20, 3.21 | | +# | alpine | 3.18, 3.19, 3.20, 3.21 3.22 | | # | oraclelinux | 8, 9 | | -# | suse | sles12sp5, sle15 | | +# | suse | sle15 | | # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # OS_RELEASE ?= ubuntu -OS_VERSION ?= 22.04 +OS_VERSION ?= 24.04 BASE_IMAGE = "docker.io/$(OS_RELEASE):$(OS_VERSION)" IMAGE_TAG = "agent_$(OS_RELEASE)_$(OS_VERSION)" DOCKERFILE_PATH = "./test/docker/nginx-oss/$(CONTAINER_OS_TYPE)/Dockerfile" @@ -62,7 +62,7 @@ APK_PACKAGE := ./build/$(PACKAGE_NAME).apk DEB_PACKAGE := ./build/$(PACKAGE_NAME).deb RPM_PACKAGE := ./build/$(PACKAGE_NAME).rpm -MOCK_MANAGEMENT_PLANE_CONFIG_DIRECTORY ?= +MOCK_MANAGEMENT_PLANE_CONFIG_DIRECTORY ?= MOCK_MANAGEMENT_PLANE_LOG_LEVEL ?= INFO MOCK_MANAGEMENT_PLANE_GRPC_ADDRESS ?= 127.0.0.1:0 MOCK_MANAGEMENT_PLANE_API_ADDRESS ?= 127.0.0.1:0 diff --git a/Makefile.packaging b/Makefile.packaging index e155cbe30..754e4ed91 100644 --- a/Makefile.packaging +++ b/Makefile.packaging @@ -14,14 +14,14 @@ TARBALL_NAME := $(PACKAGE_PREFIX).tar.gz DEB_DISTROS ?= ubuntu-noble-24.04 ubuntu-jammy-22.04 ubuntu-focal-20.04 debian-bookworm-12 debian-bullseye-11 DEB_ARCHS ?= arm64 amd64 -RPM_DISTROS ?= oraclelinux-8-x86_64 oraclelinux-9-x86_64 suse-12-x86_64 suse-15-x86_64 +RPM_DISTROS ?= oraclelinux-8-x86_64 oraclelinux-9-x86_64 suse-15-x86_64 RPM_ARCH := x86_64 REDHAT_VERSIONS ?= redhatenterprise-8 redhatenterprise-9 REDHAT_ARCHS ?= aarch64 x86_64 ROCKY_VERSIONS ?= rocky-8 rocky-9 ROCKY_ARCHS ?= aarch64 x86_64 FREEBSD_DISTROS ?= "FreeBSD:13:amd64" "FreeBSD:14:amd64" -APK_VERSIONS ?= 3.18 3.19 3.20 3.21 +APK_VERSIONS ?= 3.18 3.19 3.20 3.21 3.22 APK_ARCHS ?= aarch64 x86_64 APK_REVISION ?= 1 ALMA_VERSIONS ?= almalinux-8 almalinux-9 diff --git a/scripts/packages/package-check.sh b/scripts/packages/package-check.sh index 2ea64926b..917d8ce91 100755 --- a/scripts/packages/package-check.sh +++ b/scripts/packages/package-check.sh @@ -49,6 +49,8 @@ PKG_DIR="${PKG_REPO}/${PKG_NAME}" PKG_REPO_URL="https://${PKG_DIR}" APK=( + alpine/v3.22/main/aarch64/nginx-agent-$VERSION.apk + alpine/v3.22/main/x86_64/nginx-agent-$VERSION.apk alpine/v3.21/main/aarch64/nginx-agent-$VERSION.apk alpine/v3.21/main/x86_64/nginx-agent-$VERSION.apk alpine/v3.20/main/aarch64/nginx-agent-$VERSION.apk @@ -81,7 +83,6 @@ AMZN=( ) SUSE=( sles/15/x86_64/RPMS/nginx-agent-$VERSION.sles15.ngx.x86_64.rpm - sles/12/x86_64/RPMS/nginx-agent-$VERSION.sles12.ngx.x86_64.rpm ) CENTOS=( centos/9/aarch64/RPMS/nginx-agent-$VERSION.el9.ngx.aarch64.rpm @@ -156,4 +157,4 @@ check_repo() { } check_repo -check_pkgs \ No newline at end of file +check_pkgs diff --git a/test/docker/nginx-oss/deb/Dockerfile b/test/docker/nginx-oss/deb/Dockerfile index 988cbfe13..5cea596c1 100644 --- a/test/docker/nginx-oss/deb/Dockerfile +++ b/test/docker/nginx-oss/deb/Dockerfile @@ -12,8 +12,9 @@ COPY ./build /agent/build COPY $ENTRY_POINT /agent/entrypoint.sh RUN set -x \ - && addgroup --system --gid 101 nginx \ - && adduser --system --disabled-login --ingroup nginx --no-create-home --home /nonexistent --gecos "nginx user" --shell /bin/false --uid 101 nginx \ + && ls /usr/sbin/ \ + && groupadd --system --gid 101 nginx \ + && useradd --system --gid nginx --no-create-home --home /nonexistent --comment "nginx user" --shell /bin/false --uid 101 nginx \ && apt-get update \ && apt-get install --no-install-recommends --no-install-suggests -y ca-certificates \ gnupg2 \